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
@@ -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);
}