Lay the Almanacka design foundation: tokens, type, shell

New token system in global.css (near-white surfaces, one ink-blue
accent, no sub-12px type), self-hosted Schibsted Grotesk, rewritten
primitives (buttons, fields, modal shell, segmented, disclosure,
skeleton), a borderless 56px header with an SV | EN language toggle,
an Icon component replacing glyphs, page skeletons that keep the
layout during loading, and leave-type colours moved out of TS into
CSS variables. Feature blocks keep their structure for later phases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 18:25:04 +02:00
parent 95444d3bf4
commit d14f86478a
44 changed files with 1358 additions and 485 deletions
+11 -11
View File
@@ -1,14 +1,14 @@
/**
* Regression for a specificity bug: `.header__nav-link:hover` (a class +
* pseudo-class, specificity (0,2,0)) outranked `.header__nav-link--active`
* (a single class, (0,1,0)), so hovering the *active* tab kept the active
* rule's white (`--c-surface`) text but reverted the background to the
* hover rule's `--c-bg` — near-white text on a near-white background.
* pseudo-class, specificity (0,2,0)) once outranked the single-class active
* rule, so hovering the *active* tab reverted its background to the ghost
* hover colour while keeping the active text colour — unreadable.
* `.header__nav-link[aria-current='page']:hover` now wins on specificity
* (attribute selector + pseudo-class, (0,2,0)) over the plain hover rule,
* and repaints the background with `--c-accent-hover` so the surface-coloured
* text stays readable. The active state is marked by `aria-current="page"`
* (set automatically by react-router-dom when the route matches). See `header.css`.
* (attribute selector + pseudo-class, (0,2,0)) over the plain hover rule and
* keeps the active link on its accent tint (`--c-accent-weak`) with the
* darker accent text (`--c-accent-hover`). The active state is marked by
* `aria-current="page"` (set automatically by react-router-dom when the
* route matches). See `header.css`.
*/
import { test, expect, type Page } from '@playwright/test'
@@ -37,12 +37,12 @@ test.describe('header nav — active tab stays readable on hover', () => {
const activeLink = page.getByTestId('header-nav').getByRole('link', { name: 'Översikt' })
await expect(activeLink).toHaveAttribute('aria-current', 'page')
const accentWeak = await resolveCssVar(page, '--c-accent-weak')
const accentHover = await resolveCssVar(page, '--c-accent-hover')
const surface = await resolveCssVar(page, '--c-surface')
await activeLink.hover()
await expect(activeLink).toHaveCSS('background-color', accentHover)
await expect(activeLink).toHaveCSS('color', surface)
await expect(activeLink).toHaveCSS('background-color', accentWeak)
await expect(activeLink).toHaveCSS('color', accentHover)
})
})
+7
View File
@@ -3,6 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="preload"
href="/fonts/SchibstedGrotesk-latin-v7.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<title>Föräldraledigplannerare</title>
</head>
<body>
+8
View File
@@ -61,6 +61,14 @@ server {
try_files $uri =404;
}
# Self-hosted webfonts are versioned in their filename (…-v7.woff2), so
# a glyph update ships as a new file and the old one can be cached forever.
location /fonts/ {
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# index.html is the only mutable entry point — never cache it, or a deploy
# leaves clients pinned to a stale asset graph.
location = /index.html {
+93
View File
@@ -0,0 +1,93 @@
Copyright 2023 The Schibsted-Grotesk Project Authors (https://github.com/schibsted/schibsted-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+3 -3
View File
@@ -1,7 +1,8 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from '@/context/AuthContext'
import { WorkspaceProvider } from '@/context/WorkspaceContext'
import { LanguageProvider, useTranslation } from '@/i18n'
import { LanguageProvider } from '@/i18n'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { Auth } from '@/pages/Auth'
import { Calendar } from '@/pages/Calendar'
import { Dashboard } from '@/pages/Dashboard'
@@ -10,8 +11,7 @@ import { DevFetch } from '@/pages/DevFetch'
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth()
const { t } = useTranslation()
if (isLoading) return <p>{t('auth.loadingEllipsis')}</p>
if (isLoading) return <LoadingSpinner />
if (!isAuthenticated) return <Navigate to="/auth" replace />
return <>{children}</>
}
@@ -3,8 +3,8 @@
flex-shrink: 0;
padding: var(--sp-4);
background-color: var(--c-surface);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
align-self: flex-start;
}
@@ -58,6 +58,6 @@
}
.preset-rail__button-detail {
font-size: var(--fs-2xs);
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
@@ -1,6 +1,6 @@
import { ReactNode, useMemo } from 'react'
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
import { getColorForType, getInkForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes'
import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates'
import { useTranslation } from '@/i18n'
import { CalendarDay } from './CalendarDay'
@@ -395,10 +395,12 @@ export function CalendarWeek({
const representativeType = firstRealDay?.dayType ?? 'vacation'
const typeLabel = getLabelForType(representativeType, t)
const color = getColorForType(representativeType)
const ink = getInkForType(representativeType)
const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag)
const classNames = [
'calendar-band',
`calendar-band--${getTypeModifier(representativeType)}`,
segment.isBandStart ? 'calendar-band--start' : '',
segment.isBandEnd ? 'calendar-band--end' : '',
segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
@@ -427,6 +429,7 @@ export function CalendarWeek({
bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`,
height: `${barHeight}px`,
backgroundColor: color,
color: ink,
}}
data-testid={`calendar-band-${segment.id}`}
aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`}
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Icon, ICON_NAMES } from './Icon'
describe('Icon', () => {
it.each(ICON_NAMES)('renders "%s" as a hidden currentColor line glyph', (name) => {
const { container } = render(<Icon name={name} />)
const svg = container.querySelector('svg')
expect(svg).not.toBeNull()
expect(svg).toHaveAttribute('aria-hidden', 'true')
expect(svg).toHaveAttribute('data-icon', name)
expect(svg).toHaveAttribute('stroke', 'currentColor')
expect(svg).toHaveAttribute('stroke-width', '1.5')
expect(svg).toHaveAttribute('fill', 'none')
expect(svg?.querySelectorAll('path').length).toBeGreaterThan(0)
})
it('covers every name in the IconName union exactly once', () => {
expect(ICON_NAMES).toHaveLength(12)
expect(new Set(ICON_NAMES).size).toBe(ICON_NAMES.length)
})
it('defaults to 16px and honours an explicit size', () => {
const { container, rerender } = render(<Icon name="check" />)
expect(container.querySelector('svg')).toHaveAttribute('width', '16')
expect(container.querySelector('svg')).toHaveAttribute('height', '16')
rerender(<Icon name="check" size={24} />)
expect(container.querySelector('svg')).toHaveAttribute('width', '24')
})
it('always carries the icon class and appends a custom one', () => {
const { container, rerender } = render(<Icon name="plus" />)
expect(container.querySelector('svg')).toHaveClass('icon')
rerender(<Icon name="plus" className="btn__icon" />)
expect(container.querySelector('svg')).toHaveClass('icon', 'btn__icon')
})
})
+82
View File
@@ -0,0 +1,82 @@
import { ReactNode } from 'react'
/**
* The app's icon set — a handful of 24-unit line glyphs, inlined so there is
* no icon font or sprite request. Every icon is a 1.5 stroke in
* `currentColor`, so it takes the colour of the text next to it, and is
* `aria-hidden`: the control that contains it carries the accessible name.
*/
export type IconName =
| 'chevron-left'
| 'chevron-right'
| 'close'
| 'pin'
| 'check'
| 'briefcase'
| 'ban'
| 'help'
| 'clock'
| 'plus'
| 'external'
| 'calendar'
const CIRCLE = 'M12 3a9 9 0 1 0 0 18 9 9 0 1 0 0-18z'
const PATHS: Record<IconName, readonly string[]> = {
'chevron-left': ['M15 18l-6-6 6-6'],
'chevron-right': ['M9 18l6-6-6-6'],
close: ['M18 6L6 18', 'M6 6l12 12'],
pin: ['M12 17v5', 'M9 3h6l-1 7 3 3H7l3-3-1-7z'],
check: ['M5 12l5 5L20 7'],
briefcase: [
'M5 7h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z',
'M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2',
'M3 12h18',
],
ban: [CIRCLE, 'M5.6 5.6l12.8 12.8'],
help: [CIRCLE, 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 1-1 1.7', 'M12 17h.01'],
clock: [CIRCLE, 'M12 7v5l3 2'],
plus: ['M12 5v14', 'M5 12h14'],
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5'],
calendar: [
'M5 5h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z',
'M3 10h18',
'M8 3v4',
'M16 3v4',
],
}
/** Every icon name, in one place, so a test can render each of them. */
export const ICON_NAMES = Object.keys(PATHS) as IconName[]
export type IconSize = 16 | 20 | 24
export interface IconProps {
name: IconName
/** Rendered size in px — matches --icon-sm / --icon-md / --icon-lg. */
size?: IconSize | undefined
className?: string | undefined
}
export function Icon({ name, size = 16, className }: IconProps): ReactNode {
return (
<svg
className={className ? `icon ${className}` : 'icon'}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
data-icon={name}
>
{PATHS[name].map((d) => (
<path key={d} d={d} />
))}
</svg>
)
}
@@ -6,11 +6,12 @@ describe('LoadingSpinner', () => {
it('renders loading spinner', () => {
render(<LoadingSpinner />)
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
expect(screen.getByTestId('spinner-icon')).toHaveClass('loading-spinner__icon')
})
it('displays default message', () => {
it('displays the localized default message (Swedish under the test environment)', () => {
render(<LoadingSpinner />)
expect(screen.getByTestId('spinner-message')).toHaveTextContent('Loading...')
expect(screen.getByTestId('spinner-message')).toHaveTextContent('Laddar…')
})
it('displays custom message', () => {
@@ -26,9 +27,9 @@ describe('LoadingSpinner', () => {
expect(spinner).toHaveAttribute('aria-busy', 'true')
})
it('uses default aria label', () => {
it('uses the localized default aria label', () => {
render(<LoadingSpinner />)
const spinner = screen.getByTestId('loading-spinner')
expect(spinner).toHaveAttribute('aria-label', 'Loading')
expect(spinner).toHaveAttribute('aria-label', 'Laddar')
})
})
@@ -1,16 +1,31 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
import './loading-spinner.css'
interface LoadingSpinnerProps {
message?: string
ariaLabel?: string
message?: string | undefined
ariaLabel?: string | undefined
}
export function LoadingSpinner({ message = 'Loading...', ariaLabel = 'Loading' }: LoadingSpinnerProps): ReactNode {
/**
* A ring spinner with a message. Defaults come from the i18n bundle so a
* bare `<LoadingSpinner />` reads correctly in either language; callers
* with a more specific message ("Laddar kalender…") pass it in.
*/
export function LoadingSpinner({ message, ariaLabel }: LoadingSpinnerProps): ReactNode {
const { t } = useTranslation()
return (
<div className="loading-spinner" role="status" aria-label={ariaLabel} aria-busy={true} data-testid="loading-spinner">
<div className="loading-spinner__icon" data-testid="spinner-icon" role="presentation">⊙</div>
<p className="loading-spinner__message" data-testid="spinner-message">{message}</p>
<div
className="loading-spinner"
role="status"
aria-label={ariaLabel ?? t('common.loading')}
aria-busy={true}
data-testid="loading-spinner"
>
<div className="loading-spinner__icon" data-testid="spinner-icon" role="presentation" />
<p className="loading-spinner__message" data-testid="spinner-message">
{message ?? t('common.loadingEllipsis')}
</p>
</div>
)
}
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { PageSkeleton } from './PageSkeleton'
describe('PageSkeleton', () => {
it('renders a busy status region with a localized hidden label', () => {
render(<PageSkeleton />)
const skeleton = screen.getByTestId('page-skeleton')
expect(skeleton).toHaveAttribute('role', 'status')
expect(skeleton).toHaveAttribute('aria-busy', 'true')
expect(skeleton).toHaveAttribute('aria-label', 'Laddar')
expect(screen.getByText('Laddar…')).toHaveClass('visually-hidden')
})
it('renders three placeholder rows by default', () => {
render(<PageSkeleton />)
expect(screen.getAllByTestId('page-skeleton-row')).toHaveLength(3)
})
it('renders the requested number of rows', () => {
render(<PageSkeleton rows={5} />)
expect(screen.getAllByTestId('page-skeleton-row')).toHaveLength(5)
})
it('renders no rows when asked for zero', () => {
render(<PageSkeleton rows={0} />)
expect(screen.queryAllByTestId('page-skeleton-row')).toHaveLength(0)
})
})
@@ -0,0 +1,36 @@
import { ReactNode } from 'react'
import { useTranslation } from '@/i18n'
import './page-skeleton.css'
interface PageSkeletonProps {
/** Number of placeholder text rows under the title block. */
rows?: number | undefined
}
/**
* What a page shows inside the shell while its data loads: a title-shaped
* block and a few text-shaped rows, so the header/nav stay put and the
* content area doesn't flash empty. Announced once to assistive tech via
* the hidden "Laddar…" text.
*/
export function PageSkeleton({ rows = 3 }: PageSkeletonProps): ReactNode {
const { t } = useTranslation()
return (
<div
className="page-skeleton"
role="status"
aria-busy={true}
aria-label={t('common.loading')}
data-testid="page-skeleton"
>
<span className="visually-hidden">{t('common.loadingEllipsis')}</span>
<span className="skeleton page-skeleton__title" aria-hidden="true" />
<span className="skeleton page-skeleton__lede" aria-hidden="true" />
<div className="page-skeleton__rows">
{Array.from({ length: rows }, (_, i) => (
<span key={i} className="skeleton page-skeleton__row" aria-hidden="true" data-testid="page-skeleton-row" />
))}
</div>
</div>
)
}
@@ -1,4 +1,4 @@
/* Loading Spinner */
/* Loading Spinner — a thin ring with an accent arc, not a glyph. */
.loading-spinner {
display: flex;
flex-direction: column;
@@ -10,14 +10,17 @@
}
.loading-spinner__icon {
font-size: var(--icon-lg);
color: var(--c-accent);
animation: spin 2s linear infinite;
display: block;
width: var(--icon-lg);
height: var(--icon-lg);
border: 2px solid var(--c-border);
border-top-color: var(--c-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-spinner__message {
font-size: var(--fs-base);
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin: 0;
}
@@ -0,0 +1,36 @@
/* Page skeleton — sized like a page title, a lede and a few text rows. */
.page-skeleton {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-top: var(--sp-2);
}
.page-skeleton__title {
width: 40%;
max-width: 320px;
height: 28px;
margin-bottom: var(--sp-2);
}
.page-skeleton__lede {
width: 70%;
height: 16px;
margin-bottom: var(--sp-4);
}
.page-skeleton__rows {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.page-skeleton__row {
width: 100%;
height: 48px;
border-radius: var(--radius);
}
.page-skeleton__row:nth-child(2n) {
width: 92%;
}
@@ -1,7 +1,7 @@
import { ReactNode } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
import { getColorForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes'
import { formatDateForDisplay } from '@/utils/dates'
export interface YearTimelineProps {
@@ -134,7 +134,7 @@ export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: Y
{TYPE_PRIORITY.map((type) => (
<li key={type} className="year-timeline__legend-item">
<span
className="year-timeline__legend-swatch"
className={`year-timeline__legend-swatch year-timeline__legend-swatch--${getTypeModifier(type)}`}
style={{ backgroundColor: getColorForType(type) }}
aria-hidden="true"
/>
@@ -165,7 +165,7 @@ export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: Y
<button
key={`${year}-${idx}`}
type="button"
className="year-timeline__band"
className={`year-timeline__band year-timeline__band--${getTypeModifier(band.dominantType)}`}
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: getColorForType(band.dominantType) }}
onClick={() => onBandClick(band.startDate)}
data-testid={`year-timeline-band-${year}-${idx}`}
@@ -126,4 +126,31 @@ describe('Header', () => {
expect(screen.getByText('Inställningar')).toHaveAttribute('aria-current', 'page')
expect(screen.getByText('Översikt')).not.toHaveAttribute('aria-current', 'page')
})
it('renders the SV | EN language toggle in the full variant', () => {
renderWithRouter(<Header />)
expect(screen.getByTestId('language-toggle')).toBeInTheDocument()
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-pressed', 'false')
})
it('renders only brand, language toggle and logout in the bare variant', () => {
const childrenList = [{ id: '1', name: 'Emma' }]
renderWithRouter(
<Header variant="bare" parentName="Elin" childrenList={childrenList} selectedChildId="1" onLogout={vi.fn()} />,
)
expect(screen.getByTestId('header')).toHaveClass('header--bare')
expect(screen.getByTestId('header-title')).toHaveTextContent('Föräldraledigplannerare')
expect(screen.getByTestId('language-toggle')).toBeInTheDocument()
expect(screen.getByTestId('logout-button')).toBeInTheDocument()
expect(screen.queryByTestId('header-nav')).not.toBeInTheDocument()
expect(screen.queryByTestId('child-select')).not.toBeInTheDocument()
expect(screen.queryByTestId('parent-name')).not.toBeInTheDocument()
})
it('defaults to the full variant', () => {
renderWithRouter(<Header />)
expect(screen.getByTestId('header')).toHaveClass('header--full')
expect(screen.getByTestId('header-nav')).toBeInTheDocument()
})
})
+66 -53
View File
@@ -1,9 +1,18 @@
import { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
import { useTranslation } from '@/i18n'
import { LanguageToggle } from './LanguageToggle'
import './header.css'
export type HeaderVariant = 'full' | 'bare'
export interface HeaderProps {
/**
* `full` (default): brand, nav, child select + parent name, language,
* logout. `bare`: brand, language, logout only — for screens where there
* is nothing to navigate to yet (onboarding).
*/
variant?: HeaderVariant | undefined
parentName?: string | undefined
childrenList?: Array<{ id: string; name: string }> | undefined
selectedChildId?: string | null | undefined
@@ -12,18 +21,14 @@ export interface HeaderProps {
}
/**
* #5 fix — previously two rows built from a `header__top` (title + identity,
* with the identity cluster wrapping onto 2-3 lines once a name/child was
* long) plus a separate `header__nav` row. Now a single flex-wrapping bar:
* brand, nav (as `btn btn--ghost`, active state read from `NavLink`'s own
* `aria-current="page"` rather than a hand-rolled `--active` class), the
* child select + parent name, and logout — each sized by its content
* (`.field__control`/`.btn`) instead of the identity cluster's old fixed
* layout, so long content wraps the *row*, not individual words. The
* previously-duplicated `selectedChildName` span is gone: the child select
* already shows the selected child's name as its value.
* One flex-wrapping bar: brand, nav (as `btn btn--ghost`, active state read
* from `NavLink`'s own `aria-current="page"`), the child select + parent
* name, then the language toggle and logout — each sized by its content so
* long content wraps the *row*, not individual words. See responsive.css
* for how the groups reorder at <=640px.
*/
export function Header({
variant = 'full',
parentName,
childrenList,
selectedChildId,
@@ -31,59 +36,67 @@ export function Header({
onLogout,
}: HeaderProps): ReactNode {
const { t } = useTranslation()
const isFull = variant === 'full'
return (
<header className="header" role="banner" data-testid="header">
<header className={`header header--${variant}`} role="banner" data-testid="header">
<div className="header__bar">
<div className="header__brand" data-testid="header-title">
{t('app.name')}
</div>
<nav className="header__nav" role="navigation" data-testid="header-nav">
<NavLink to="/" className="btn btn--ghost header__nav-link">
{t('nav.overview')}
</NavLink>
<NavLink to="/calendar" className="btn btn--ghost header__nav-link">
{t('nav.calendar')}
</NavLink>
<NavLink to="/settings" className="btn btn--ghost header__nav-link">
{t('nav.settings')}
</NavLink>
</nav>
{isFull && (
<nav className="header__nav" role="navigation" data-testid="header-nav">
<NavLink to="/" className="btn btn--ghost header__nav-link">
{t('nav.overview')}
</NavLink>
<NavLink to="/calendar" className="btn btn--ghost header__nav-link">
{t('nav.calendar')}
</NavLink>
<NavLink to="/settings" className="btn btn--ghost header__nav-link">
{t('nav.settings')}
</NavLink>
</nav>
)}
<div className="header__identity">
{childrenList && childrenList.length > 0 && (
<select
className="field__control header__child-select"
value={selectedChildId ?? ''}
onChange={(e) => onChildSelect?.(e.target.value)}
data-testid="child-select"
aria-label={t('nav.selectChildAria')}
{isFull && (
<div className="header__identity">
{childrenList && childrenList.length > 0 && (
<select
className="header__child-select"
value={selectedChildId ?? ''}
onChange={(e) => onChildSelect?.(e.target.value)}
data-testid="child-select"
aria-label={t('nav.selectChildAria')}
>
{childrenList.map((child) => (
<option key={child.id} value={child.id}>
{child.name}
</option>
))}
</select>
)}
{parentName && (
<span className="header__parent-name" data-testid="parent-name">
{parentName}
</span>
)}
</div>
)}
<div className="header__tools">
<LanguageToggle />
{onLogout && (
<button
type="button"
className="btn btn--ghost btn--sm header__logout"
onClick={onLogout}
data-testid="logout-button"
aria-label={t('nav.logoutAria')}
>
{childrenList.map((child) => (
<option key={child.id} value={child.id}>
{child.name}
</option>
))}
</select>
)}
{parentName && (
<span className="header__parent-name" data-testid="parent-name">
{parentName}
</span>
{t('nav.logout')}
</button>
)}
</div>
{onLogout && (
<button
type="button"
className="btn btn--ghost btn--sm header__logout"
onClick={onLogout}
data-testid="logout-button"
aria-label={t('nav.logoutAria')}
>
{t('nav.logout')}
</button>
)}
</div>
</header>
)
@@ -0,0 +1,82 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LanguageProvider } from '@/i18n'
import { LanguageToggle } from './LanguageToggle'
// Node's own experimental `localStorage` global (active in newer Node
// runtimes without a valid --localstorage-file) shadows jsdom's working
// implementation and throws on every call. Stub a real in-memory Storage,
// matching the idiom in LanguageContext.test.tsx.
function createStorageMock(): Storage {
let store: Record<string, string> = {}
return {
getItem: (key: string) => (key in store ? store[key]! : null),
setItem: (key: string, value: string) => {
store[key] = value
},
removeItem: (key: string) => {
delete store[key]
},
clear: () => {
store = {}
},
key: (index: number) => Object.keys(store)[index] ?? null,
get length() {
return Object.keys(store).length
},
}
}
function renderToggle() {
return render(
<LanguageProvider>
<LanguageToggle />
</LanguageProvider>,
)
}
describe('LanguageToggle', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', createStorageMock())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders a labelled group with SV and EN options', () => {
renderToggle()
const group = screen.getByRole('group', { name: 'Språk' })
expect(group).toBe(screen.getByTestId('language-toggle'))
expect(screen.getByTestId('language-toggle-sv')).toHaveTextContent('SV')
expect(screen.getByTestId('language-toggle-en')).toHaveTextContent('EN')
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-label', 'Svenska')
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-label', 'English')
})
it('marks the resolved locale as pressed (Swedish under the test environment)', () => {
renderToggle()
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-pressed', 'false')
})
it('switches the locale (and its own label) when the other option is pressed', async () => {
renderToggle()
await userEvent.click(screen.getByTestId('language-toggle-en'))
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('group', { name: 'Language' })).toBeInTheDocument()
expect(localStorage.getItem('language_preference')).toBe('en')
await userEvent.click(screen.getByTestId('language-toggle-sv'))
expect(screen.getByTestId('language-toggle-sv')).toHaveAttribute('aria-pressed', 'true')
expect(localStorage.getItem('language_preference')).toBe('sv')
})
it('reflects a persisted explicit choice on mount', () => {
localStorage.setItem('language_preference', 'en')
renderToggle()
expect(screen.getByTestId('language-toggle-en')).toHaveAttribute('aria-pressed', 'true')
})
})
@@ -0,0 +1,36 @@
import { ReactNode } from 'react'
import { useTranslation, type Locale } from '@/i18n'
import './language-toggle.css'
const OPTIONS: ReadonlyArray<{ locale: Locale; short: string; ariaKey: 'nav.languageSv' | 'nav.languageEn' }> = [
{ locale: 'sv', short: 'SV', ariaKey: 'nav.languageSv' },
{ locale: 'en', short: 'EN', ariaKey: 'nav.languageEn' },
]
/**
* SV | EN — a two-option segmented control in the header. Pressing an option
* sets an explicit locale (persisted by `LanguageProvider`); the pressed one
* is whichever locale is currently resolved, so it also reflects browser
* detection before the person has chosen.
*/
export function LanguageToggle(): ReactNode {
const { t, locale, setLocale } = useTranslation()
return (
<div className="segmented language-toggle" role="group" aria-label={t('nav.languageAria')} data-testid="language-toggle">
{OPTIONS.map((option) => (
<button
key={option.locale}
type="button"
className="segmented__option language-toggle__option"
aria-pressed={locale === option.locale}
aria-label={t(option.ariaKey)}
lang={option.locale}
onClick={() => setLocale(option.locale)}
data-testid={`language-toggle-${option.locale}`}
>
{option.short}
</button>
))}
</div>
)
}
+23 -2
View File
@@ -23,10 +23,10 @@ vi.mock('react-router-dom', async () => {
const mockUseAuth = vi.mocked(useAuth)
const mockUseWorkspace = vi.mocked(useWorkspace)
function renderLayout(children = <div>page content</div>) {
function renderLayout(children = <div>page content</div>, chrome?: 'full' | 'bare') {
return render(
<MemoryRouter>
<Layout>{children}</Layout>
<Layout chrome={chrome}>{children}</Layout>
</MemoryRouter>,
)
}
@@ -71,6 +71,27 @@ describe('Layout', () => {
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('exposes exactly one main landmark — the <main> element, not the wrapper', () => {
renderLayout()
const mains = screen.getAllByRole('main')
expect(mains).toHaveLength(1)
expect(mains[0]).toBe(screen.getByTestId('layout-main'))
expect(screen.getByTestId('layout')).not.toHaveAttribute('role')
expect(screen.getByTestId('layout')).toHaveClass('layout')
expect(screen.getByTestId('layout-main')).toHaveClass('layout__main')
})
it('shows the full header by default and forwards chrome="bare" to the Header', () => {
const { unmount } = renderLayout()
expect(screen.getByTestId('header')).toHaveClass('header--full')
expect(screen.getByTestId('header-nav')).toBeInTheDocument()
unmount()
renderLayout(<div>page content</div>, 'bare')
expect(screen.getByTestId('header')).toHaveClass('header--bare')
expect(screen.queryByTestId('header-nav')).not.toBeInTheDocument()
expect(screen.getByTestId('logout-button')).toBeInTheDocument()
})
it('passes parent name and children list from workspace context to Header', () => {
mockUseWorkspace.mockReturnValue({
workspace: null,
+12 -5
View File
@@ -2,16 +2,18 @@ import { ReactNode, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { useWorkspace } from '@/context/WorkspaceContext'
import { Header, HeaderProps } from './Header'
import { Header, HeaderProps, HeaderVariant } from './Header'
import { Footer } from './Footer'
import './layout.css'
interface LayoutProps {
children: ReactNode
header?: HeaderProps
header?: HeaderProps | undefined
/** Which header to show — see `HeaderProps.variant`. Defaults to `full`. */
chrome?: HeaderVariant | undefined
}
export function Layout({ children, header }: LayoutProps): ReactNode {
export function Layout({ children, header, chrome = 'full' }: LayoutProps): ReactNode {
const navigate = useNavigate()
const { logout } = useAuth()
const { parentName, children: childrenList, selectedChildId, selectChild } = useWorkspace()
@@ -31,6 +33,7 @@ export function Layout({ children, header }: LayoutProps): ReactNode {
const headerProps: HeaderProps = {
...header,
variant: chrome,
parentName: parentName ?? undefined,
childrenList,
selectedChildId,
@@ -38,10 +41,14 @@ export function Layout({ children, header }: LayoutProps): ReactNode {
onLogout: handleLogout,
}
// The `<main>` below is the page's one main landmark — the wrapper is a
// plain div so assistive tech never sees two of them.
return (
<div role="main" data-testid="layout">
<div className="layout" data-testid="layout">
<Header {...headerProps} />
<main data-testid="layout-main">{children}</main>
<main className="layout__main" data-testid="layout-main">
{children}
</main>
<Footer />
</div>
)
+6 -8
View File
@@ -1,14 +1,12 @@
/* Footer — a single faint line of text at the foot of the page; no rule
* above it, the whitespace before it is the boundary. */
footer {
margin-top: var(--sp-8);
padding: var(--sp-5) 0;
border-top: 1px solid var(--c-border);
background-color: var(--c-bg);
color: var(--c-text-muted);
padding: var(--sp-5) 0 var(--sp-6);
color: var(--c-text-faint);
}
/* Matches [data-testid='layout-main']'s own max-width/side gutters so the
* footer's text lines up with the page content above it instead of
* spreading edge-to-edge under a narrower container. */
/* Matches .layout__main's max-width/side gutters so the footer's text lines
* up with the page content above it. */
.footer__inner {
max-width: var(--maxw);
margin: 0 auto;
+70 -43
View File
@@ -1,104 +1,131 @@
/* Header — 56px, no border, no shadow. The page background runs straight
* into it; the brand wordmark and the accent-tinted active link are the
* only things that mark it as chrome. */
.header {
position: sticky;
top: 0;
z-index: 100;
background-color: var(--c-surface);
border-bottom: 1px solid var(--c-border);
padding: var(--sp-4) var(--sp-5);
box-shadow: var(--shadow-sm);
z-index: var(--z-header);
background-color: var(--c-bg);
}
/* #5 — one flex-wrapping bar instead of a fixed title/identity row plus a
* separate nav row. Each group (brand, nav, identity, logout) is sized by
* its own content and wraps onto a new line as a whole group rather than
* letting a long name/child force the row into 2-3 lines of ragged text —
* see the per-breakpoint order overrides in responsive.css for how the
* groups rearrange at <=640px. */
/* One flex-wrapping bar. Each group (brand, nav, identity, tools) is sized
* by its own content and wraps onto a new line as a whole group — see the
* per-breakpoint `order` overrides in responsive.css for how the groups
* rearrange at <=640px. */
.header__bar {
max-width: var(--maxw);
min-height: 56px;
margin: 0 auto;
padding: 0 var(--sp-5);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-4);
gap: var(--sp-2) var(--sp-5);
}
.header__brand {
font-size: var(--fs-lg);
font-weight: 700;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--c-text);
white-space: nowrap;
}
.header__nav {
display: flex;
gap: var(--sp-2);
gap: var(--sp-1);
flex-wrap: wrap;
}
/* `.btn` doesn't set `text-decoration` (most buttons aren't anchors) — these
* are `<NavLink>`s, so without this the global `a:hover` rule underlines
* the label on hover. */
/* Nav links are `.btn.btn--ghost` `<NavLink>`s. The global `a:hover` rule
* would underline the label; the active state is read from `NavLink`'s own
* `aria-current="page"` rather than a hand-rolled `--active` class. */
.header__nav-link,
.header__nav-link:hover {
text-decoration: none;
}
/* Active state comes from `NavLink`'s own `aria-current="page"` attribute
* (set automatically when the route matches) rather than a hand-rolled
* `--active` class — one less thing for a link's className function to get
* out of sync with. */
.header__nav-link.btn {
min-height: var(--control-h-sm);
padding: 0 var(--sp-3);
font-weight: 500;
}
.header__nav-link.btn[aria-current='page'] {
background-color: var(--c-accent);
color: var(--c-surface);
background-color: var(--c-accent-weak);
color: var(--c-accent);
}
.header__nav-link.btn[aria-current='page']:hover {
background-color: var(--c-accent-hover);
color: var(--c-surface);
background-color: var(--c-accent-weak);
color: var(--c-accent-hover);
}
/* Child select + parent name. `margin-left: auto` pushes this cluster (and
* the tools after it) to the right of the row when there's room. */
.header__identity {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
/* Row 1 reads brand + nav on the left; this margin pushes the child
* select + parent name to the right of that same row when there's room,
* or onto their own wrapped row 2 when there isn't. */
margin-left: auto;
}
/* Sized by its content, not a fixed width — the global `select { width:
* 100% }` reset (global.css) would otherwise stretch this across whatever
* room the flex row happens to leave it. Long child names ellipsize instead
* of forcing the header wider. It shrinks before `.header__parent-name`
* below when the row genuinely runs out of room (that name is pinned to
* `flex: 0 0 auto`), so a name never gets clipped to a few characters while
* this select still has its full width. */
/* Bare: no box, no fill — just the child's name and a chevron. Sized by its
* content (the global `select { width: 100% }` reset would otherwise
* stretch it); long names ellipsize instead of forcing the header wider. */
.header__child-select {
flex-shrink: 1;
min-width: 0;
width: auto;
max-width: 22ch;
min-height: var(--control-h-sm);
padding: 0 var(--sp-5) 0 var(--sp-2);
border: 0;
border-radius: var(--radius-sm);
background-color: transparent;
background-position: right var(--sp-1) center;
font-size: var(--fs-sm);
font-weight: 500;
color: var(--c-text);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Pinned to its content size — never flex-shrunk. Without this, the identity
* group's default flex-shrink squeezed this span down to a sliver (~60px at
* 1280px with a child select present) long before the row ran out of room,
* clipping a name like "Recheck Tester" to a couple of characters. The
* child select (above) and the nav/brand groups absorb tight space instead;
* only once there is truly no room left does the row wrap (`.header__bar`'s
* `flex-wrap: wrap`). */
.header__child-select:hover {
background-color: var(--c-accent-weak);
}
.header__child-select:focus {
border: 0;
box-shadow: none;
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
/* Pinned to its content size — never flex-shrunk, so a name never gets
* clipped to a couple of characters while the select still has its full
* width. Only once there is truly no room left does the row wrap. */
.header__parent-name {
flex: 0 0 auto;
font-size: var(--fs-sm);
color: var(--c-text-muted);
font-weight: 500;
max-width: 24ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Language toggle + logout. */
.header__tools {
display: flex;
align-items: center;
gap: var(--sp-2);
}
/* In the bare variant (no nav, no identity) the tools are the only thing on
* the right, so they take the auto margin the identity group normally has. */
.header--bare .header__tools {
margin-left: auto;
}
@@ -0,0 +1,21 @@
/* SV | EN — built on `.segmented` (primitives.css); this only adds the thin
* divider between the two options and pins the letter-spacing. */
.language-toggle__option {
min-width: 34px;
letter-spacing: 0.02em;
}
.language-toggle__option + .language-toggle__option {
position: relative;
}
.language-toggle__option + .language-toggle__option::before {
content: '';
position: absolute;
left: -2px;
top: 50%;
width: 1px;
height: 12px;
transform: translateY(-50%);
background-color: var(--c-border);
}
+8 -12
View File
@@ -1,17 +1,17 @@
/* Layout container */
[data-testid='layout'] {
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--c-bg);
}
[data-testid='layout-main'] {
.layout__main {
flex: 1;
max-width: var(--maxw);
width: 100%;
margin: 0 auto;
padding: var(--sp-6) var(--sp-5);
padding: var(--sp-6) var(--sp-5) var(--sp-7);
}
/* Dashboard & Pages */
@@ -27,12 +27,9 @@
margin-bottom: var(--sp-5);
}
/* Sections separate by whitespace and a hairline rule, not a stack of
* bordered/shadowed cards — every section on a page looking equally "boxed"
* flattens the hierarchy the headline figures above them are meant to have.
* The `<details>` accordions (plan-fold, dashboard__more-details) keep their
* own bordered treatment in components.css since they're interactive
* disclosure controls, not passive content groups. */
/* Sections separate by whitespace and, where two run together, one
* hairline — never a stack of bordered cards. Every section looking equally
* "boxed" flattens the hierarchy the headline figures are meant to have. */
.dashboard__section,
.settings-page__section,
.calendar__section,
@@ -42,7 +39,7 @@
border-top: 1px solid var(--c-border);
border-radius: 0;
padding: var(--sp-6) 0 0;
margin-bottom: var(--sp-6);
margin-bottom: var(--sp-7);
box-shadow: none;
}
@@ -75,7 +72,7 @@
}
.dashboard__break-item {
padding: var(--sp-3) var(--sp-4);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--c-border);
display: flex;
justify-content: space-between;
@@ -105,4 +102,3 @@
padding-top: var(--sp-3);
border-top: 1px solid var(--c-border);
}
+4
View File
@@ -4,6 +4,7 @@ export const common = {
'common.close': 'Close',
'common.delete': 'Delete',
'common.deleting': 'Deleting…',
'common.loading': 'Loading',
'common.loadingEllipsis': 'Loading…',
'common.save': 'Save',
'common.saving': 'Saving…',
@@ -13,6 +14,9 @@ export const common = {
'leaveType.unknown': 'Unknown',
'leaveType.vacation': 'Vacation',
'nav.calendar': 'Calendar',
'nav.languageAria': 'Language',
'nav.languageEn': 'English',
'nav.languageSv': 'Svenska',
'nav.logout': 'Log out',
'nav.logoutAria': 'Log out',
'nav.overview': 'Overview',
+4
View File
@@ -4,6 +4,7 @@ export const common = {
'common.close': 'Stäng',
'common.delete': 'Ta bort',
'common.deleting': 'Tar bort…',
'common.loading': 'Laddar',
'common.loadingEllipsis': 'Laddar…',
'common.save': 'Spara',
'common.saving': 'Sparar…',
@@ -13,6 +14,9 @@ export const common = {
'leaveType.unknown': 'Okänd',
'leaveType.vacation': 'Semester',
'nav.calendar': 'Kalender',
'nav.languageAria': 'Språk',
'nav.languageEn': 'English',
'nav.languageSv': 'Svenska',
'nav.logout': 'Logga ut',
'nav.logoutAria': 'Logga ut',
'nav.overview': 'Översikt',
+1 -5
View File
@@ -71,10 +71,6 @@ vi.mock('@/components/layout/Layout', () => ({
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/common/LoadingSpinner', () => ({
LoadingSpinner: () => <div>Loading...</div>,
}))
vi.mock('@/components/modals/PlannedDayModal', () => ({
// Exposes the props this test suite cares about as data attributes/buttons
// so tests can assert on Calendar.tsx's wiring without rendering the real
@@ -313,7 +309,7 @@ describe('Calendar', () => {
vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspace, isLoading: true })
render(<Calendar />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
})
it('renders 7 weekday headers in Monday-first Swedish order', () => {
+8 -4
View File
@@ -25,12 +25,12 @@ import type {
WindowOutcome,
} from '@/types'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { PageSkeleton } from '@/components/common/PageSkeleton'
import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/components/common/CalendarWeek'
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
import { PeriodPopover, type PeriodPopoverSavePayload, type PeriodDeleteScope } from '@/components/calendar/PeriodPopover'
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
import { getColorForType } from '@/utils/leaveTypes'
import { getColorForType, getTypeModifier } from '@/utils/leaveTypes'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } from '@/utils/dates'
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
@@ -437,7 +437,11 @@ export function Calendar(): ReactNode {
}, [isDragging, dragAnchor, dragCurrent, personalDataUuid])
if (isLoading) {
return <LoadingSpinner message={t('calendar.loading')} />
return (
<Layout>
<PageSkeleton rows={5} />
</Layout>
)
}
const year = currentMonth.getFullYear()
@@ -1215,7 +1219,7 @@ export function Calendar(): ReactNode {
<span>{t('calendar.legendIncomeBased')}</span>
</div>
<div className="calendar__legend-item">
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('lagsta') }} />
<span className={`calendar__legend-swatch calendar__legend-swatch--${getTypeModifier('lagsta')}`} style={{ backgroundColor: getColorForType('lagsta') }} />
<span>{t('calendar.legendMinimumRate')}</span>
</div>
<div className="calendar__legend-item">
+3 -3
View File
@@ -146,7 +146,7 @@ describe('Dashboard', () => {
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
})
it('hands off to Onboarding when there are no children yet', () => {
@@ -528,7 +528,7 @@ describe('Dashboard', () => {
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
})
it('shows planned days loading state', () => {
@@ -539,7 +539,7 @@ describe('Dashboard', () => {
})
)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
})
it('renders with zero planned days', () => {
+6 -1
View File
@@ -21,6 +21,7 @@ import { isSelf, orderSelfFirst } from '@/utils/members'
import { useTranslation } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { PageSkeleton } from '@/components/common/PageSkeleton'
import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics'
import { DaysBreakdown } from '@/components/dashboard/DaysBreakdown'
import { Hero } from '@/components/dashboard/Hero'
@@ -91,7 +92,11 @@ export function Dashboard(): ReactNode {
const isLoading = wsLoading || analyticsLoading || pdLoading
if (isLoading) {
return <LoadingSpinner message={t('dashboard.loading')} />
return (
<Layout>
<PageSkeleton />
</Layout>
)
}
const handleFetchFK = async () => {
+1 -1
View File
@@ -199,7 +199,7 @@ describe('Settings', () => {
})
)
renderSettings()
expect(screen.getByText(/Laddar inställningar/)).toBeInTheDocument()
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
})
it('renders consistently Swedish section headings', () => {
+6 -1
View File
@@ -17,6 +17,7 @@ import { isApiError } from '@/services/errors'
import { useTranslation, type Locale, type TranslationKey } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { PageSkeleton } from '@/components/common/PageSkeleton'
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput'
import { VacationDaysInput } from '@/components/inputs/VacationDaysInput'
@@ -189,7 +190,11 @@ export function Settings(): ReactNode {
)
if (isLoading) {
return <LoadingSpinner message={t('settings.loading')} />
return (
<Layout>
<PageSkeleton rows={4} />
</Layout>
)
}
const handleLanguageChange = (value: string) => {
+42 -32
View File
@@ -42,7 +42,7 @@
border-radius: var(--radius-sm);
transition: background-color 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
appearance: none;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 8"><path d="M1 1l5 5 5-5" stroke="%231c2733" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>');
background-image: var(--icon-chevron-down);
background-repeat: no-repeat;
background-position: right 6px center;
background-size: 12px;
@@ -172,7 +172,6 @@
.calendar-day:hover {
border-color: var(--c-accent);
box-shadow: 0 2px 8px var(--c-accent-shadow);
background-color: var(--c-accent-weak);
}
@@ -194,7 +193,7 @@
}
.calendar-day--has-events {
background-color: var(--c-accent-tint);
background-color: var(--c-row-hover);
}
.calendar-day__number {
@@ -223,7 +222,7 @@
.calendar-day--selected {
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
box-shadow: 0 2px 8px var(--c-focus-ring);
box-shadow: inset 0 0 0 1px var(--c-accent);
}
.calendar-day--selected:hover {
@@ -235,20 +234,20 @@
title/aria-label carrying the holiday name. */
.calendar-day--red-day {
background-color: var(--c-danger-weak);
border-color: var(--c-redday-border);
border-color: var(--c-border);
}
.calendar-day--red-day:hover {
background-color: var(--c-redday-hover);
background-color: var(--c-danger-weak);
}
.calendar-day--klamdag {
background-color: var(--c-klamdag-bg);
border-color: var(--c-klamdag-border);
background-color: var(--c-warning-weak);
border-color: var(--c-border);
}
.calendar-day--klamdag:hover {
background-color: var(--c-klamdag-hover);
background-color: var(--c-warning-weak);
}
/* Plain Saturday — neutral grey, deliberately NOT red. Matches the Swedish
@@ -257,15 +256,15 @@
(the backend collapses both into a single "weekend" day_type). */
.calendar-day--weekend {
background-color: var(--c-weekend-bg);
border-color: var(--c-weekend-border);
border-color: var(--c-border);
}
.calendar-day--weekend:hover {
background-color: var(--c-weekend-hover);
background-color: var(--c-bg);
}
.calendar-day__holiday-name {
font-size: var(--fs-2xs);
font-size: var(--fs-xs);
color: var(--c-danger);
overflow: hidden;
text-overflow: ellipsis;
@@ -305,7 +304,7 @@
.calendar-day__override-badge,
.calendar-day__excluded-badge,
.calendar-event__badge {
font-size: var(--fs-4xs);
font-size: var(--fs-xs);
font-weight: 700;
line-height: 1;
border-radius: 3px;
@@ -316,7 +315,7 @@
top: 4px;
right: 4px;
color: var(--c-surface);
background-color: var(--c-scrim-strong);
background-color: var(--c-text);
padding: 1px 3px;
}
@@ -446,7 +445,6 @@
justify-content: center;
overflow: hidden;
cursor: pointer;
box-shadow: 0 1px 2px var(--c-band-shadow);
min-width: 0;
}
@@ -460,6 +458,12 @@
border-bottom-right-radius: var(--radius-band);
}
/* Lägsta is a white fill told apart by a dark outline, not a hue (see the
* --c-leave-* tokens); without it the band would vanish into the cell. */
.calendar-band--type-lagsta {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
.calendar-band:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
@@ -473,10 +477,10 @@
@keyframes calendar-band-flash {
0%, 100% {
box-shadow: 0 1px 2px var(--c-band-shadow);
box-shadow: none;
}
30%, 70% {
box-shadow: 0 0 0 3px var(--c-flash), 0 1px 2px var(--c-band-shadow);
box-shadow: 0 0 0 3px var(--c-flash);
}
}
@@ -486,9 +490,9 @@
display: flex;
align-items: center;
padding: 0 6px;
font-size: var(--fs-2xs);
color: var(--c-surface);
text-shadow: 0 1px 1px var(--c-label-text-shadow);
font-size: var(--fs-xs);
/* Ink is set inline on the band from the leave type (getInkForType) */
color: inherit;
pointer-events: none;
z-index: 1;
}
@@ -513,7 +517,7 @@
funded planned_leave_day row) — no funding color, just a light neutral
fill so the band still reads as continuous. */
.calendar-band__day--gap {
background-color: var(--c-fill-light);
background-color: var(--c-band-gap);
}
/* §5/§6 — hand-edited ("pinned") day: the planner must not retype it. */
@@ -526,7 +530,7 @@
position: absolute;
bottom: 1px;
right: 1px;
font-size: var(--fs-5xs);
font-size: var(--fs-xs);
line-height: 1;
}
@@ -538,7 +542,7 @@
}
.calendar-event__label {
font-size: var(--fs-2xs);
font-size: var(--fs-xs);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
@@ -558,7 +562,7 @@
align-items: center;
justify-content: center;
color: inherit;
font-size: var(--fs-2xs);
font-size: var(--fs-xs);
line-height: 1;
}
@@ -569,7 +573,8 @@
min-width: 18px;
height: 18px;
padding: 0 3px;
background-color: var(--c-badge-fill);
background-color: var(--c-band-gap);
color: var(--c-leave-lagsta-ink);
flex-shrink: 0;
}
@@ -608,25 +613,30 @@
height: 16px;
border-radius: 3px;
flex-shrink: 0;
font-size: var(--fs-2xs);
font-weight: bold;
font-size: var(--fs-xs);
font-weight: 600;
color: var(--c-surface);
box-shadow: inset 0 0 0 1px var(--c-border);
}
.calendar__legend-swatch--type-lagsta {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
.calendar__legend-swatch--dubbeldag {
background-color: var(--c-scrim-soft);
background-color: var(--c-band-gap);
border: 1px solid var(--c-text-faint);
color: var(--c-text);
}
.calendar__legend-swatch--red-day {
background-color: var(--c-danger-weak);
border: 1px solid var(--c-redday-border);
border: 1px solid var(--c-danger);
}
.calendar__legend-swatch--klamdag {
background-color: var(--c-klamdag-bg);
border: 1px solid var(--c-klamdag-border);
background-color: var(--c-warning-weak);
border: 1px solid var(--c-warning);
}
.calendar__legend-swatch--override {
@@ -733,7 +743,7 @@
justify-content: center;
padding: var(--sp-2);
font-weight: 600;
font-size: var(--fs-2xs);
font-size: var(--fs-xs);
color: var(--c-text-faint);
border-right: 1px solid var(--c-border);
background-color: var(--c-bg);
+15 -12
View File
@@ -34,7 +34,6 @@
padding: var(--sp-5);
background-color: var(--c-surface);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
/* The "all parents" combined total — a footer row spanning every column
@@ -210,9 +209,9 @@
.dashboard-hero__message {
margin: 0;
font-size: var(--fs-2xl);
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.01em;
font-weight: 600;
line-height: var(--lh-tight);
letter-spacing: -0.02em;
color: var(--c-text);
}
@@ -259,11 +258,9 @@
}
.age4-bars__title {
font-size: var(--fs-base);
font-weight: 600;
font-size: var(--fs-sm);
font-weight: 500;
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
margin-bottom: 0;
}
@@ -314,11 +311,9 @@
}
.year-timeline__title {
font-size: var(--fs-base);
font-weight: 600;
font-size: var(--fs-sm);
font-weight: 500;
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
margin-bottom: var(--sp-1);
}
@@ -351,6 +346,14 @@
height: 10px;
border-radius: var(--radius-full);
flex-shrink: 0;
box-shadow: inset 0 0 0 1px var(--c-border);
}
/* Lägsta is a white fill told apart by its outline, not a hue (see the
* --c-leave-* tokens) — the outline is what makes it visible at all. */
.year-timeline__legend-swatch--type-lagsta,
.year-timeline__band--type-lagsta {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
.year-timeline__row {
+164 -130
View File
@@ -3,112 +3,144 @@
@import './components.css';
@import './responsive.css';
/* Typeface — Schibsted Grotesk (OFL), self-hosted from /public/fonts so no
* request ever leaves for a font CDN. Two subsets with Google's own
* unicode-ranges: the browser only fetches latin-ext when a glyph needs it.
* Variable weight axis 400–900; `swap` so text is never invisible while the
* file loads (index.html preloads the latin file to keep that swap short). */
@font-face {
font-family: 'Schibsted Grotesk';
font-style: normal;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/SchibstedGrotesk-latin-ext-v7.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Schibsted Grotesk';
font-style: normal;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/SchibstedGrotesk-latin-v7.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Design Tokens
*
* One neutral ground + one accent; every other colour is semantic (danger/
* warning/success) and exists as a solid + "-weak" tint pair. Everything
* below is used app-wide, including the calendar and modal families that
* used to hardcode their own hex values — see calendar.css/period-popover.css/
* etc. Two values are load-bearing for e2e: --c-danger-weak (red day
* background) and --c-weekend-bg (plain-Saturday background) — read
* directly by frontend/e2e/tests/red-days.spec.ts. Do not repurpose either.
* One neutral ground, one accent. Hierarchy comes from type and whitespace;
* hairlines only where a boundary is unavoidable; one shadow, only on
* floating layers. Leave types are told apart by fill / ink / outline, not
* by hue, and day states by the numeral, not by a cell fill.
*
* Four names are load-bearing for e2e, which resolves them by name and
* compares against painted elements: --c-danger-weak (red-day cell),
* --c-weekend-bg (plain Saturday), --c-accent-hover and --c-surface
* (header-nav-hover). Do not repurpose any of them.
*/
:root {
/* Color - calm, trustworthy palette */
--c-bg: #f7f8fa;
--c-surface: #ffffff;
--c-border: #e3e6eb;
--c-text: #1c2733;
--c-text-muted: #5b6675;
--c-text-faint: #94a0ac;
--c-accent: #2f6f4f; /* calm green — the app's one accent */
--c-accent-weak: #e8f1ec;
--c-accent-hover: #1f5a3b;
--c-accent-active: #0f4a2f;
--c-danger: #b3261e;
--c-danger-weak: #fdeceb; /* also the red-day cell background — see note above */
--c-danger-hover: #8a1a15;
--c-success: #1e7d4f;
--c-success-weak: #e7f4ec;
--c-success-hover: #0d6a37;
--c-warning: #8a6d1f;
--c-warning-weak: #fef8e7;
--font-sans: 'Schibsted Grotesk', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
/* Calendar-specific semantics (klämdag/weekend/red-day borders + hover) —
* kept distinct from the generic danger/warning pair above because a red
* day and a klämdag are both visible on screen at once and need to read
* as different hues, not different intensities of the same one. */
--c-weekend-bg: #f0f0f0; /* plain Saturday — read directly by red-days.spec.ts */
--c-weekend-border: #cfcfcf;
--c-weekend-hover: #e8e8e8;
--c-redday-border: #d9776e;
--c-redday-hover: #fbdedb;
--c-klamdag-bg: #fef8e7;
--c-klamdag-border: #d9b23c;
--c-klamdag-hover: #fcf0c8;
/* The one deliberately loud color in the app — a "this just changed"
* flash ring on a calendar band, never a static/resting state. */
--c-flash: #ffd54f;
/* Neutrals */
--c-bg: #fcfcfd;
--c-surface: #fff;
--c-border: #ececf0;
--c-text: #111318;
--c-text-muted: #5e6472;
--c-text-faint: #9297a3;
--overlay: rgba(16, 24, 40, 0.5);
/* The one accent — CTA, focus, active nav, today, selection */
--c-accent: #2f5bea;
--c-accent-weak: #eef2fe;
--c-accent-hover: #2549c4;
--c-accent-active: #1d3ca3;
/* Translucent tints used only for subtle hover/shadow/scrim effects — kept
* as tokens (rather than inlined rgba() elsewhere) because the style
* guard test (styles.test.ts) forbids rgba() outside this file. */
--c-row-hover: rgba(47, 111, 79, 0.02);
--c-accent-tint: rgba(47, 111, 79, 0.04);
--c-accent-shadow: rgba(47, 111, 79, 0.12);
--c-scrim-strong: rgba(0, 0, 0, 0.4);
--c-band-shadow: rgba(0, 0, 0, 0.1);
--c-label-text-shadow: rgba(0, 0, 0, 0.35);
--c-fill-light: rgba(255, 255, 255, 0.35);
--c-badge-fill: rgba(255, 255, 255, 0.3);
--c-scrim-soft: rgba(0, 0, 0, 0.2);
/* Semantic */
--c-danger: #d03a2f;
--c-danger-weak: #fbeceb;
--c-danger-hover: #b02f26;
--c-warning: #b26a00;
--c-warning-weak: #fdf3e3;
--c-success: var(--c-accent);
--c-success-weak: var(--c-accent-weak);
--c-success-hover: var(--c-accent-hover);
/* Spacing scale (8px base) */
/* Day states — a colour on the numeral, never a cell fill */
--c-day-red: var(--c-danger);
--c-day-klamdag: var(--c-warning);
--c-day-saturday: var(--c-text-faint);
--c-weekend-bg: var(--c-surface); /* legacy name; Saturday has no fill */
/* Leave types: fill / ink / outline */
--c-leave-vacation: #eef2fe;
--c-leave-vacation-ink: #2f5bea;
--c-leave-sjukpenning: #2a2d35;
--c-leave-sjukpenning-ink: #fff;
--c-leave-lagsta: #fff;
--c-leave-lagsta-ink: #2a2d35;
--c-leave-lagsta-outline: #2a2d35;
--c-leave-lagsta-hatch: rgba(42, 45, 53, 0.35);
--c-band-gap: #f1f2f5;
--c-band-cap-self: var(--c-accent);
--c-band-cap-partner: #9297a3;
/* Interaction chrome */
--c-flash: var(--c-accent);
--overlay: rgba(17, 19, 24, 0.4);
--c-focus-ring: rgba(47, 91, 234, 0.35);
--c-row-hover: rgba(47, 91, 234, 0.04);
/* Spacing */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sp-8: 48px;
--sp-7: 48px;
--sp-8: 64px;
/* Type scale */
--fs-5xs: 0.5rem;
--fs-4xs: 0.5625rem;
--fs-2xs: 0.6875rem;
/* Type scale 12/14/16/20/28/40 — nothing below 12px anywhere */
--fs-xs: 0.75rem;
--fs-sm: 0.875rem;
--fs-base: 1rem;
--fs-lg: 1.25rem;
--fs-xl: 1.5rem;
--fs-2xl: 2rem;
--fs-xl: 1.75rem;
--fs-2xl: 2.5rem;
--lh: 1.5;
--lh-tight: 1.15;
/* Shape & depth */
--radius-sm: 6px;
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--radius-full: 999px;
/* Matches frontend/e2e/tests/calendar-band.spec.ts's literal 4px/0px
* assertions on band corners — kept as its own token (not tied to
* --radius-sm) so a future radius change there can't silently break it. */
* assertions on band corners — kept as its own token so a future radius
* change elsewhere can't silently break it. */
--radius-band: 4px;
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06);
--shadow-md: 0 4px 12px rgba(16, 24, 40, 0.08);
--shadow-float: 0 8px 24px rgba(17, 19, 24, 0.1);
/* Content width */
/* Sizes */
--maxw: 960px;
/* Interaction chrome shared by every focusable control */
--c-focus-ring: rgba(47, 111, 79, 0.32);
--fs-3xs: 0.625rem;
--control-h: 40px;
--control-h-sm: 32px;
--icon-sm: 16px;
--icon-md: 20px;
--icon-lg: 24px;
/* Icon glyph sizes (spinner, etc.) — a separate scale from --fs-* since
* these size a single glyph, not running text. */
--icon-lg: 48px;
--icon-md: 36px;
/* Layers */
--z-header: 100;
--z-popover: 900;
--z-modal: 1000;
/* The one inline image in the system: a 1.5px chevron for native selects.
* Lives here (not in a feature file) so the style guard can keep `url(`
* and hex out of everything else. Colour = --c-text-muted. */
--icon-chevron-down: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 8"><path d="M1 1l5 5 5-5" stroke="%235e6472" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>');
color-scheme: light;
}
@@ -124,8 +156,6 @@ html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
@@ -138,9 +168,7 @@ blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
@@ -153,16 +181,12 @@ q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
@@ -183,9 +207,7 @@ th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
@@ -193,49 +215,54 @@ header,
hgroup,
main,
mark,
meter,
nav,
output,
progress,
section,
summary,
time,
video {
time {
margin: 0;
padding: 0;
border: 0;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
background: var(--c-bg);
color: var(--c-text);
line-height: var(--lh);
font-size: var(--fs-base);
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* Headings — tighter leading than body text, the usual editorial cue that a
* line is a heading rather than another sentence of the paragraph below it. */
body {
font-family: var(--font-sans);
font-size: var(--fs-base);
line-height: var(--lh);
background: var(--c-bg);
color: var(--c-text);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Headings — tighter leading and a touch of negative tracking at 28px+ is
* what makes a line read as a heading, not a heavier weight. */
h1 {
font-size: var(--fs-2xl);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.01em;
font-size: var(--fs-xl);
font-weight: 600;
line-height: var(--lh-tight);
letter-spacing: -0.02em;
margin-bottom: var(--sp-4);
}
h2 {
font-size: var(--fs-xl);
font-weight: 700;
font-size: var(--fs-lg);
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.01em;
margin-bottom: var(--sp-3);
}
h3 {
font-size: var(--fs-lg);
font-size: var(--fs-base);
font-weight: 600;
line-height: 1.3;
margin-bottom: var(--sp-3);
margin-bottom: var(--sp-2);
}
/* Text */
@@ -297,10 +324,10 @@ button:disabled {
/* Inputs & Selects
*
* Matches `.field__control` in primitives.css (same border/radius/padding/
* focus formula) so an unclassed input still looks right. Checkboxes and
* Matches `.field__control` in primitives.css (hairline, 40px, 2px accent
* ring on focus) so an unclassed input still looks right. Checkboxes and
* radios are excluded — they keep their native intrinsic size and just pick
* up the accent color, not the text-field chrome. */
* up the accent color. */
input:not([type='checkbox']):not([type='radio']),
select,
textarea {
@@ -308,12 +335,23 @@ textarea {
min-height: var(--control-h);
font-family: inherit;
font-size: var(--fs-base);
line-height: 1.25;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3);
background: var(--c-surface);
color: var(--c-text);
transition: border-color 0.2s, box-shadow 0.2s;
transition: border-color 0.15s, box-shadow 0.15s;
}
select {
appearance: none;
padding-right: var(--sp-6);
background-image: var(--icon-chevron-down);
background-repeat: no-repeat;
background-position: right var(--sp-3) center;
background-size: 12px 8px;
cursor: pointer;
}
input:focus,
@@ -321,26 +359,28 @@ select:focus,
textarea:focus {
outline: none;
border-color: var(--c-accent);
box-shadow: 0 0 0 3px var(--c-focus-ring);
box-shadow: 0 0 0 2px var(--c-accent);
}
/* Same crisp keyboard-focus outline as `.field__control:focus-visible`
* (primitives.css) for an unclassed input/select/textarea — see that rule's
* comment. */
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
outline: none;
}
input:disabled,
select:disabled,
textarea:disabled {
background-color: var(--c-bg);
color: var(--c-text-muted);
cursor: not-allowed;
}
input::placeholder,
textarea::placeholder {
color: var(--c-text-faint);
}
input[type='checkbox'],
input[type='radio'] {
width: auto;
@@ -348,11 +388,8 @@ input[type='radio'] {
cursor: pointer;
}
/* Number inputs — hide the native up/down spinner everywhere. It doubles
* the field's effective click target with no matching `.field__control`
* affordance and its width isn't accounted for by any of this app's `ch`-
* based sizing. Callers still get numeric-only mobile keyboards via
* `inputmode="numeric"` on the element itself. */
/* Number inputs — hide the native up/down spinner everywhere. Callers still
* get numeric-only mobile keyboards via `inputmode="numeric"`. */
input[type='number'] {
appearance: textfield;
}
@@ -363,9 +400,6 @@ input[type='number']::-webkit-inner-spin-button {
margin: 0;
}
/* Date inputs — same height/padding/font as `.field__control` by default
* (the shared `input:not([type=checkbox]):not([type=radio])` rule above
* already covers it), just with a visibly clickable calendar affordance. */
input[type='date']::-webkit-calendar-picker-indicator {
opacity: 0.6;
cursor: pointer;
@@ -379,7 +413,7 @@ label {
color: var(--c-text);
}
/* Tables */
/* Tables — no header fill; one hairline per row. */
table {
width: 100%;
border-collapse: collapse;
@@ -387,15 +421,15 @@ table {
th {
text-align: left;
font-weight: 600;
padding: var(--sp-3) var(--sp-4);
background-color: var(--c-bg);
font-weight: 500;
font-size: var(--fs-sm);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--c-border);
color: var(--c-text-muted);
}
td {
padding: var(--sp-3) var(--sp-4);
padding: var(--sp-3) var(--sp-3);
border-bottom: 1px solid var(--c-border);
}
+234 -64
View File
@@ -1,26 +1,26 @@
/* Design-system primitives
*
* Every button, card, choice-card, form field, alert, badge, eyebrow and
* modal shell in the app is built from the classes below — defined ONCE
* here instead of once per feature/page. See AGENTS.md's "fix the shared
* rule once" guidance. Feature CSS files (calendar.css, header.css,
* preset-rail.css, ...) keep only the rules that are genuinely specific to
* that feature (e.g. the calendar day-cell grid, the QR image box).
* Every button, card, choice-card, form field, alert, badge, eyebrow,
* segmented control, disclosure and modal shell in the app is built from the
* classes below — defined ONCE here instead of once per feature/page.
* Feature CSS files keep only the rules that are genuinely specific to that
* feature (the calendar day-cell grid, the QR image box, ...).
*
* Principles: whitespace and type carry hierarchy; a hairline only where a
* boundary is unavoidable; one shadow (--shadow-float) and only on floating
* layers; no gradients, no uppercase, nothing below 12px.
*/
/* Buttons
* ------------------------------------------------------------------------
* The bare `button` element (see global.css) is a plain reset — no color,
* no chrome. Every visible button in the app opts into one of these
* variants explicitly, so a semantic element that happens to be a
* `<button>` (a card, a chip, an icon) never inherits button colors it
* didn't ask for.
*/
* primary = accent fill · secondary = hairline outline · ghost = text only.
* 40px tall, 32px for --sm, radius 8. */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
padding: 0 var(--sp-4);
min-height: var(--control-h);
border-radius: var(--radius);
font-size: var(--fs-sm);
@@ -29,7 +29,7 @@
border: 1px solid transparent;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
.btn:focus-visible {
@@ -38,7 +38,7 @@
}
.btn:disabled {
opacity: 0.55;
opacity: 0.5;
cursor: not-allowed;
}
@@ -56,14 +56,18 @@
}
.btn--secondary {
background-color: var(--c-surface);
background-color: transparent;
border-color: var(--c-border);
color: var(--c-text);
}
.btn--secondary:hover:not(:disabled) {
border-color: var(--c-text-faint);
background-color: var(--c-surface);
}
.btn--secondary:active:not(:disabled) {
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
}
.btn--ghost {
@@ -92,6 +96,7 @@
border: 0;
color: var(--c-text-muted);
text-decoration: underline;
text-underline-offset: 2px;
}
.btn--link:hover:not(:disabled) {
@@ -99,9 +104,32 @@
}
.btn--sm {
padding: var(--sp-1) var(--sp-3);
min-height: 32px;
font-size: var(--fs-xs);
padding: 0 var(--sp-3);
min-height: var(--control-h-sm);
font-size: var(--fs-sm);
}
/* Chip — a small, pill-shaped toggle/filter. Pressed state = accent tint. */
.btn--chip {
min-height: var(--control-h-sm);
padding: 0 var(--sp-3);
border-radius: var(--radius-full);
border-color: var(--c-border);
background-color: transparent;
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.btn--chip:hover:not(:disabled) {
color: var(--c-text);
border-color: var(--c-text-faint);
}
.btn--chip[aria-pressed='true'],
.btn--chip[aria-checked='true'] {
background-color: var(--c-accent-weak);
border-color: transparent;
color: var(--c-accent);
}
.btn--icon {
@@ -118,10 +146,14 @@
color: var(--c-text);
}
.btn--icon.btn--sm {
width: var(--control-h-sm);
height: var(--control-h-sm);
}
.btn--block {
display: flex;
width: 100%;
justify-content: center;
}
/* Pins a button (typically a destructive one) to the start of a
@@ -131,17 +163,19 @@
}
/* Cards
* ------------------------------------------------------------------------ */
* ------------------------------------------------------------------------
* A surface with a hairline — used for the front door (auth), the
* onboarding choices and nothing that already sits inside another card. */
.card {
background-color: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius);
border-radius: var(--radius-lg);
padding: var(--sp-5);
}
.card--tinted {
background-color: var(--c-accent-weak);
border-color: var(--c-accent-weak);
border-color: transparent;
}
.card--dashed {
@@ -149,22 +183,22 @@
}
/* Choice card — a card that is also a clickable, single-select choice (the
* onboarding solo/couple picker). Built on the same chrome as `.card`. */
* onboarding solo/couple picker). */
.choice-card {
display: block;
width: 100%;
background-color: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius);
border-radius: var(--radius-lg);
padding: var(--sp-5);
text-align: left;
color: var(--c-text);
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
}
.choice-card:hover {
border-color: var(--c-accent);
border-color: var(--c-text-faint);
}
.choice-card:focus-visible {
@@ -191,7 +225,8 @@
}
/* Form fields
* ------------------------------------------------------------------------ */
* ------------------------------------------------------------------------
* Hairline, 40px, focus = 2px accent ring. */
.field {
display: flex;
flex-direction: column;
@@ -209,33 +244,37 @@
min-height: var(--control-h);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
border-radius: var(--radius);
background-color: var(--c-surface);
font-size: var(--fs-base);
line-height: 1.25;
color: var(--c-text);
transition: border-color 0.2s, box-shadow 0.2s;
transition: border-color 0.15s, box-shadow 0.15s;
}
.field__control:hover:not(:disabled):not(:focus) {
border-color: var(--c-text-faint);
}
.field__control:focus {
outline: none;
border-color: var(--c-accent);
box-shadow: 0 0 0 3px var(--c-focus-ring);
box-shadow: 0 0 0 2px var(--c-accent);
}
/* Keyboard focus gets a crisp solid outline on top of the soft mouse-focus
* box-shadow above — matching `.btn:focus-visible`/`.choice-card:focus-visible`
* elsewhere in this file. The box-shadow ring alone reads as faint against
* the light surface/border palette; text inputs need the same visible ring
* every other focusable control gets. */
.field__control:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
outline: none;
}
.field__control[aria-invalid='true'] {
border-color: var(--c-danger);
}
.field__control[aria-invalid='true']:focus {
border-color: var(--c-danger);
box-shadow: 0 0 0 2px var(--c-danger);
}
.field__helper {
font-size: var(--fs-xs);
color: var(--c-text-muted);
@@ -253,35 +292,32 @@
}
/* Alerts
* ------------------------------------------------------------------------ */
* ------------------------------------------------------------------------
* Tint + coloured text; no border — the tint is the boundary. */
.alert {
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius);
border: 1px solid transparent;
font-size: var(--fs-sm);
line-height: 1.45;
}
.alert--error {
background-color: var(--c-danger-weak);
border-color: var(--c-danger);
color: var(--c-danger);
}
.alert--success {
background-color: var(--c-success-weak);
border-color: var(--c-success);
color: var(--c-success);
}
.alert--warning {
background-color: var(--c-warning-weak);
border-color: var(--c-warning);
color: var(--c-warning);
}
.alert--info {
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
color: var(--c-accent);
}
@@ -295,6 +331,7 @@
font-size: var(--fs-xs);
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.badge--accent {
@@ -318,25 +355,121 @@
color: var(--c-danger);
}
/* A small, muted, sentence-case label above a heading or a block — never
* uppercase; the size and colour alone set it apart from body text. */
.eyebrow {
font-size: var(--fs-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: var(--fs-sm);
font-weight: 500;
color: var(--c-text-muted);
letter-spacing: 0;
text-transform: none;
}
/* Segmented control
* ------------------------------------------------------------------------
* A row of mutually exclusive options (SV | EN, week/month, ...). The
* pressed option carries the accent tint; the others are plain text. */
.segmented {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: var(--radius);
}
.segmented__option {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 var(--sp-2);
border-radius: 6px;
font-size: var(--fs-sm);
font-weight: 500;
color: var(--c-text-muted);
transition: background-color 0.15s, color 0.15s;
}
.segmented__option:hover:not(:disabled) {
color: var(--c-text);
}
.segmented__option[aria-pressed='true'],
.segmented__option[aria-checked='true'] {
background-color: var(--c-accent-weak);
color: var(--c-accent);
}
.segmented__option:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
/* Disclosure
* ------------------------------------------------------------------------
* A `<details>` whose summary reads as a row you can open — a hairline
* above it, not a bordered box. */
.disclosure {
border-top: 1px solid var(--c-border);
}
.disclosure:last-child {
border-bottom: 1px solid var(--c-border);
}
.disclosure__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
min-height: var(--control-h);
padding: var(--sp-3) 0;
font-weight: 500;
color: var(--c-text);
cursor: pointer;
list-style: none;
}
.disclosure__summary::-webkit-details-marker {
display: none;
}
.disclosure__summary::after {
content: '';
width: var(--icon-sm);
height: var(--icon-sm);
flex-shrink: 0;
background-image: var(--icon-chevron-down);
background-repeat: no-repeat;
background-position: center;
background-size: 12px 8px;
transition: transform 0.15s;
}
.disclosure[open] > .disclosure__summary::after {
transform: rotate(180deg);
}
.disclosure__summary:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.disclosure__body {
padding: 0 0 var(--sp-5);
}
/* Modal shell
* ------------------------------------------------------------------------
* Shared by PeriodPopover, PlannedDayModal, ParentBalanceForm and
* QRCodeModal so every modal's overlay/header/body/footer share one
* formula. Each modal's own CSS file keeps only its content-specific rules
* (fields, QR image box, status text, ...).
*/
* QRCodeModal. The one floating layer in the app, so the one place
* --shadow-float is used. Header and footer have no rules of their own;
* the footer is transparent — spacing does the separating. */
.modal__overlay {
position: fixed;
inset: 0;
z-index: 1000;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
@@ -351,8 +484,8 @@
max-height: 85vh;
overflow: hidden;
background-color: var(--c-surface);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.modal__content--md {
@@ -371,20 +504,20 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--c-border);
gap: var(--sp-3);
padding: var(--sp-5) var(--sp-5) 0;
}
.modal__header h2 {
margin: 0;
font-size: var(--fs-xl);
font-weight: 700;
font-size: var(--fs-lg);
font-weight: 600;
}
.modal__body {
flex: 1;
overflow-y: auto;
padding: var(--sp-5);
padding: var(--sp-4) var(--sp-5) var(--sp-5);
}
.modal__footer {
@@ -393,9 +526,8 @@
align-items: stretch;
justify-content: flex-end;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-5);
border-top: 1px solid var(--c-border);
background-color: var(--c-bg);
padding: 0 var(--sp-5) var(--sp-5);
background-color: transparent;
}
/* Inline delete-confirmation row: swaps a footer's normal button set for a
@@ -417,6 +549,44 @@
color: var(--c-text);
}
/* Text utilities
* ------------------------------------------------------------------------ */
/* Tabular figures for any count that sits in a column or changes in place. */
.tnum {
font-variant-numeric: tabular-nums;
}
/* Screen-reader-only text. */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skeleton — a quiet placeholder block while a page's data loads. */
.skeleton {
display: block;
background-color: var(--c-border);
border-radius: var(--radius-sm);
animation: skeleton-pulse 1.4s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
/* Layout helpers
* ------------------------------------------------------------------------ */
.stack {
+30 -58
View File
@@ -1,4 +1,4 @@
/* S7 - Responsive & Accessibility
/* Responsive & Accessibility
*
* The ONLY file in the app allowed to contain @media (enforced by
* styles.test.ts) — every viewport/motion/contrast override lives here so
@@ -7,21 +7,15 @@
/* Mobile - Small screens (max 640px) */
@media (max-width: 640px) {
/* Sticky header with less padding */
.header {
padding: var(--sp-3) var(--sp-4);
}
/* Header — the same four groups (brand / nav / identity / tools) reorder
* into three rows: brand + language + logout, the nav as a full-width
* row, then the child select full-width below. 16px gutters. */
.header__bar {
row-gap: var(--sp-3);
padding: 0 var(--sp-4);
row-gap: var(--sp-2);
padding-bottom: var(--sp-2);
}
/* #5 — reorders the same four groups (brand/nav/identity/logout — no
* markup change, just `order`) into: brand + logout on row 1, the nav as
* a full-width row of three equal buttons, then the child select
* full-width below. The parent name is dropped at this width — the child
* select alone already identifies "whose plan this is" without a second,
* name-only line competing for the same row. */
.header__brand {
order: 1;
flex: 1 1 auto;
@@ -32,7 +26,7 @@
font-size: var(--fs-base);
}
.header__logout {
.header__tools {
order: 2;
flex-shrink: 0;
}
@@ -42,7 +36,7 @@
flex: 0 0 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-2);
gap: var(--sp-1);
}
.header__nav-link {
@@ -59,6 +53,7 @@
.header__child-select {
width: 100%;
max-width: none;
padding-left: 0;
}
.header__parent-name {
@@ -66,11 +61,9 @@
}
/* Main content — the container picks up the horizontal gutter; sections
* themselves stay at zero horizontal padding at every width (see
* layout.css's `.dashboard__section` etc. base rule) so this must only
* ever add top spacing/margin here, never re-introduce side padding. */
[data-testid='layout-main'] {
padding: var(--sp-4);
* themselves stay at zero horizontal padding at every width. */
.layout__main {
padding: var(--sp-5) var(--sp-4);
}
.dashboard__section,
@@ -143,8 +136,7 @@
}
/* Delete-confirmation row: the warning text gets its own line, and the
* two buttons split the row evenly (still equal height, still no
* mid-phrase wrapping) rather than stacking into three separate rows. */
* two buttons split the row evenly. */
.modal__delete-confirm-text {
width: 100%;
margin-right: 0;
@@ -168,10 +160,6 @@
min-height: 200px;
}
.loading-spinner__icon {
font-size: var(--icon-md);
}
/* Calendar responsiveness */
.calendar__grid {
font-size: var(--fs-sm);
@@ -186,11 +174,9 @@
font-size: var(--fs-sm);
}
/* #4 — at 390px the month/year selects (each ~12ch/7ch min-width) plus the
* two prev/next icon buttons are tight against the header's title font
* size; dropping the select text down one step keeps the whole header row
* comfortably inside the viewport (it still wraps cleanly via
* `.calendar__title`'s `flex-wrap` if a locale's month name is wider). */
/* At 390px the month/year selects plus the two prev/next icon buttons are
* tight against the header's title size; one step down keeps the whole
* row inside the viewport. */
.calendar__month-select,
.calendar__year-select {
font-size: var(--fs-lg);
@@ -198,15 +184,10 @@
/* The fixed-width preset rail sits beside the month grid on desktop; on
* mobile the layout stacks and the rail goes full-width below the grid.
* `align-items: stretch` overrides the base rule's `flex-start` (there to
* top-align the rail next to the grid in the row layout) — without it, a
* column-direction flex item is sized by its own shrink-to-fit content
* width instead of stretching to the container's width. `.calendar__main`
* (a CSS Grid with a fixed 50px week-number column) then sized itself off
* its grid tracks' max-content width — well past the viewport — instead
* of the actual space available, which is exactly what caused the
* mobile horizontal-scroll bug once a band's content gave those tracks
* something to size around. */
* `align-items: stretch` overrides the base rule's `flex-start` — without
* it a column-direction flex item is sized by its own shrink-to-fit
* content width, which is what once let `.calendar__main` grow past the
* viewport. */
.calendar__layout {
flex-direction: column;
align-items: stretch;
@@ -217,9 +198,7 @@
}
/* A horizontally-scrollable row of presets, not a stack — keeps the rail
* from pushing the month grid far down the page on a short viewport,
* without ever growing the page itself wider than the viewport (the
* scrolling is contained to this row, see `overflow-x` below). */
* from pushing the month grid far down the page on a short viewport. */
.preset-rail__buttons {
flex-direction: row;
overflow-x: auto;
@@ -233,11 +212,7 @@
scroll-snap-align: start;
}
/* Footer — same side gutter as [data-testid='layout-main'] at this width. */
footer {
padding: var(--sp-4) 0;
}
/* Footer — same side gutter as .layout__main at this width. */
.footer__inner {
padding: 0 var(--sp-4);
}
@@ -253,8 +228,8 @@
grid-template-columns: repeat(2, 1fr);
}
[data-testid='layout-main'] {
padding: var(--sp-4);
.layout__main {
padding: var(--sp-6) var(--sp-5);
}
}
@@ -274,12 +249,14 @@
}
}
/* High Contrast Mode */
/* High Contrast Mode — the darkest step of the accent, pure black text and
* a visibly darker hairline. */
@media (prefers-contrast: more) {
:root {
--c-text: #000;
--c-accent: #1f4d2b;
--c-border: #333;
--c-text-muted: #333;
--c-accent: #1d3ca3;
--c-border: #555;
}
button:focus-visible,
@@ -291,11 +268,6 @@
}
}
/* Color Scheme Preference - Dark Mode (future-proofing) */
@media (prefers-color-scheme: dark) {
/* Currently not implemented, but prepared */
}
/* Keyboard Navigation */
:focus-visible {
outline: 2px solid var(--c-accent);
+39
View File
@@ -114,4 +114,43 @@ describe('style guard', () => {
}
expect(offenders).toEqual([])
})
// The type scale bottoms out at 12px (--fs-xs: .75rem). A smaller step
// used to exist for badges and band labels and made the calendar
// unreadable; nothing may reintroduce one.
it('defines no --fs-* token below .75rem (12px)', () => {
const content = fs.readFileSync(GLOBAL_CSS, 'utf-8')
const tooSmall: string[] = []
for (const match of content.matchAll(/(--fs-[\w-]+):\s*([\d.]+)rem/g)) {
const [, name, value] = match
if (name !== undefined && value !== undefined && Number(value) < 0.75) {
tooSmall.push(`${name}: ${value}rem`)
}
}
expect(tooSmall).toEqual([])
// ...and feature CSS may only ever size text via the scale.
const offenders: string[] = []
for (const file of cssFiles) {
const fileContent = fs.readFileSync(file, 'utf-8')
if (/--fs-(2xs|3xs|4xs|5xs)\b/.test(fileContent)) {
offenders.push(path.relative(SRC_ROOT, file))
}
}
expect(offenders).toEqual([])
})
// Fonts and the one inline chevron are the only external/inline
// resources, and both are declared once in global.css (as @font-face and
// the --icon-chevron-down token). Feature CSS references the token.
it('keeps url() out of feature CSS — fonts and inline images live in global.css', () => {
const offenders: string[] = []
for (const file of cssFiles) {
if (file === GLOBAL_CSS) continue
const content = fs.readFileSync(file, 'utf-8')
if (content.includes('url(')) {
offenders.push(path.relative(SRC_ROOT, file))
}
}
expect(offenders).toEqual([])
})
})
+33 -9
View File
@@ -1,22 +1,46 @@
import { describe, it, expect } from 'vitest'
import { getColorForType, getLabelForType } from './leaveTypes'
import { getColorForType, getInkForType, getLabelForType, getTypeModifier } from './leaveTypes'
import { translate } from '@/i18n'
describe('getColorForType', () => {
it('returns the vacation color', () => {
expect(getColorForType('vacation')).toBe('#4CAF50')
it('returns the vacation fill token', () => {
expect(getColorForType('vacation')).toBe('var(--c-leave-vacation)')
})
it('returns the sjukpenning color', () => {
expect(getColorForType('sjukpenning')).toBe('#FF9800')
it('returns the sjukpenning fill token', () => {
expect(getColorForType('sjukpenning')).toBe('var(--c-leave-sjukpenning)')
})
it('returns the lagsta color', () => {
expect(getColorForType('lagsta')).toBe('#2196F3')
it('returns the lagsta fill token', () => {
expect(getColorForType('lagsta')).toBe('var(--c-leave-lagsta)')
})
it('falls back to grey for an unknown type', () => {
expect(getColorForType('something-else')).toBe('#9E9E9E')
it('falls back to the faint text colour for an unknown type', () => {
expect(getColorForType('something-else')).toBe('var(--c-text-faint)')
})
})
describe('getInkForType', () => {
it('returns the matching ink token for each known type', () => {
expect(getInkForType('vacation')).toBe('var(--c-leave-vacation-ink)')
expect(getInkForType('sjukpenning')).toBe('var(--c-leave-sjukpenning-ink)')
expect(getInkForType('lagsta')).toBe('var(--c-leave-lagsta-ink)')
})
it('falls back to surface ink for an unknown type', () => {
expect(getInkForType('something-else')).toBe('var(--c-surface)')
})
})
describe('getTypeModifier', () => {
it('returns a type-* modifier suffix for each known type', () => {
expect(getTypeModifier('vacation')).toBe('type-vacation')
expect(getTypeModifier('sjukpenning')).toBe('type-sjukpenning')
expect(getTypeModifier('lagsta')).toBe('type-lagsta')
})
it('returns type-unknown for an unknown type', () => {
expect(getTypeModifier('something-else')).toBe('type-unknown')
})
})
+31 -6
View File
@@ -1,12 +1,37 @@
import type { TranslationKey } from '@/i18n'
/**
* Leave types are told apart by fill / ink / outline, not by hue — the
* values live as `--c-leave-*` tokens in `styles/global.css` and these
* helpers hand back `var(...)` references so a palette change never needs a
* code change. jsdom preserves `var(...)` strings on inline styles, so tests
* can still assert on them.
*/
const KNOWN_TYPES = ['vacation', 'sjukpenning', 'lagsta'] as const
type KnownType = (typeof KNOWN_TYPES)[number]
function isKnownType(type: string): type is KnownType {
return (KNOWN_TYPES as readonly string[]).includes(type)
}
/** Background fill for a leave type (band, timeline bar, legend swatch). */
export function getColorForType(type: string): string {
const colorMap: Record<string, string> = {
vacation: '#4CAF50',
sjukpenning: '#FF9800',
lagsta: '#2196F3',
}
return colorMap[type] ?? '#9E9E9E'
return isKnownType(type) ? `var(--c-leave-${type})` : 'var(--c-text-faint)'
}
/** Text colour that reads on top of `getColorForType(type)`. */
export function getInkForType(type: string): string {
return isKnownType(type) ? `var(--c-leave-${type}-ink)` : 'var(--c-surface)'
}
/**
* BEM modifier suffix for a leave type, for callers that need a class hook
* (lägsta's outline/hatch can't be expressed as a single inline colour):
* `` `calendar-band--${getTypeModifier(type)}` ``.
*/
export function getTypeModifier(type: string): string {
return isKnownType(type) ? `type-${type}` : 'type-unknown'
}
/**