Redesign the setup flow and consolidate the CSS into one primitives layer

Onboarding asks explicitly for one or two parents and shows one card per
parent for balances; the balance form is per parent with inline
validation; the partner can be filled from the FK deduction or added
from it. Settings and Dashboard reuse the same form.

CSS: bare buttons carry no colour any more; buttons, cards, choice
cards, fields, alerts, badges, eyebrow and modal chrome are defined once
in primitives.css. Media queries live only in responsive.css. A style
guard test blocks hex, !important and stray media queries. Bilingual
fixes: calendar selects size by content, header no longer wraps. Vitest
is capped at four workers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent e4719cb9ae
commit ae6db7e16f
105 changed files with 5683 additions and 3690 deletions
+3 -1
View File
@@ -7,7 +7,9 @@ export function Footer(): ReactNode {
const currentYear = new Date().getFullYear()
return (
<footer role="contentinfo" data-testid="footer">
<p data-testid="footer-text">{t('footer.copyright', { year: currentYear })}</p>
<div className="footer__inner">
<p data-testid="footer-text">{t('footer.copyright', { year: currentYear })}</p>
</div>
</footer>
)
}
@@ -117,13 +117,13 @@ describe('Header', () => {
it('marks the calendar link active on the calendar route', () => {
renderAtPath(<Header />, '/calendar')
expect(screen.getByText('Kalender')).toHaveClass('header__nav-link--active')
expect(screen.getByText('Översikt')).not.toHaveClass('header__nav-link--active')
expect(screen.getByText('Kalender')).toHaveAttribute('aria-current', 'page')
expect(screen.getByText('Översikt')).not.toHaveAttribute('aria-current', 'page')
})
it('marks the settings link active on the settings route, and the overview link inactive', () => {
renderAtPath(<Header />, '/settings')
expect(screen.getByText('Inställningar')).toHaveClass('header__nav-link--active')
expect(screen.getByText('Översikt')).not.toHaveClass('header__nav-link--active')
expect(screen.getByText('Inställningar')).toHaveAttribute('aria-current', 'page')
expect(screen.getByText('Översikt')).not.toHaveAttribute('aria-current', 'page')
})
})
+35 -35
View File
@@ -5,16 +5,26 @@ import './header.css'
export interface HeaderProps {
parentName?: string | undefined
selectedChildName?: string | undefined
childrenList?: Array<{ id: string; name: string }> | undefined
selectedChildId?: string | null | undefined
onChildSelect?: ((childId: string) => void) | undefined
onLogout?: (() => void) | undefined
}
/**
* #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.
*/
export function Header({
parentName,
selectedChildName,
childrenList,
selectedChildId,
onChildSelect,
@@ -23,19 +33,27 @@ export function Header({
const { t } = useTranslation()
return (
<header className="header" role="banner" data-testid="header">
<div className="header__top">
<div className="header__title" data-testid="header-title">
<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>
<div className="header__identity">
{parentName && (
<span className="header__parent-name" data-testid="parent-name">
{parentName}
</span>
)}
{childrenList && childrenList.length > 0 && (
<select
className="header__child-select"
className="field__control header__child-select"
value={selectedChildId ?? ''}
onChange={(e) => onChildSelect?.(e.target.value)}
data-testid="child-select"
@@ -48,35 +66,17 @@ export function Header({
))}
</select>
)}
{selectedChildName && childrenList && childrenList.length === 1 && (
<span className="header__child-name" data-testid="child-name">
{selectedChildName}
{parentName && (
<span className="header__parent-name" data-testid="parent-name">
{parentName}
</span>
)}
</div>
</div>
<nav className="header__nav" role="navigation" data-testid="header-nav">
<NavLink
to="/"
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
>
{t('nav.overview')}
</NavLink>
<NavLink
to="/calendar"
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
>
{t('nav.calendar')}
</NavLink>
<NavLink
to="/settings"
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
>
{t('nav.settings')}
</NavLink>
{onLogout && (
<button
className="header__logout-button"
type="button"
className="btn btn--ghost btn--sm header__logout"
onClick={onLogout}
data-testid="logout-button"
aria-label={t('nav.logoutAria')}
@@ -84,7 +84,7 @@ export function Header({
{t('nav.logout')}
</button>
)}
</nav>
</div>
</header>
)
}
@@ -55,6 +55,8 @@ describe('Layout', () => {
personalDataUuid: null,
parentName: null,
refreshContext: vi.fn(),
planVersion: 0,
bumpPlanVersion: vi.fn(),
})
})
@@ -86,6 +88,8 @@ describe('Layout', () => {
personalDataUuid: 'pd1',
parentName: 'Elin',
refreshContext: vi.fn(),
planVersion: 0,
bumpPlanVersion: vi.fn(),
})
renderLayout()
expect(screen.getByTestId('parent-name')).toHaveTextContent('Elin')
@@ -108,6 +112,8 @@ describe('Layout', () => {
personalDataUuid: 'pd1',
parentName: null,
refreshContext: vi.fn(),
planVersion: 0,
bumpPlanVersion: vi.fn(),
})
renderLayout()
expect(screen.getByDisplayValue('Emma')).toBeInTheDocument()
@@ -16,8 +16,6 @@ export function Layout({ children, header }: LayoutProps): ReactNode {
const { logout } = useAuth()
const { parentName, children: childrenList, selectedChildId, selectChild } = useWorkspace()
const selectedChild = childrenList.find((c) => c.id === selectedChildId)
const handleLogout = useCallback(async () => {
try {
await logout()
@@ -34,7 +32,6 @@ export function Layout({ children, header }: LayoutProps): ReactNode {
const headerProps: HeaderProps = {
...header,
parentName: parentName ?? undefined,
selectedChildName: selectedChild?.name,
childrenList,
selectedChildId,
onChildSelect: selectChild,
+14 -5
View File
@@ -1,13 +1,22 @@
footer {
margin-top: var(--sp-8);
padding: var(--sp-5);
padding: var(--sp-5) 0;
border-top: 1px solid var(--c-border);
background-color: var(--c-bg);
text-align: center;
color: var(--c-text-muted);
}
footer p {
margin: 0;
font-size: var(--fs-sm);
/* 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. */
.footer__inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 0 var(--sp-5);
text-align: center;
}
.footer__inner p {
margin: 0;
font-size: var(--fs-xs);
}
+74 -83
View File
@@ -8,106 +8,97 @@
box-shadow: var(--shadow-sm);
}
.header__top {
/* #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. */
.header__bar {
max-width: var(--maxw);
margin: 0 auto var(--sp-4);
margin: 0 auto;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-4);
}
.header__title {
.header__brand {
font-size: var(--fs-lg);
font-weight: 700;
color: var(--c-text);
}
.header__nav {
display: flex;
gap: var(--sp-2);
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. */
.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[aria-current='page'] {
background-color: var(--c-accent);
color: var(--c-surface);
}
.header__nav-link.btn[aria-current='page']:hover {
background-color: var(--c-accent-hover);
color: var(--c-surface);
}
.header__identity {
display: flex;
align-items: center;
gap: var(--sp-4);
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. */
.header__child-select {
flex-shrink: 1;
min-width: 0;
width: auto;
max-width: 22ch;
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__parent-name {
flex: 0 0 auto;
font-size: var(--fs-sm);
color: var(--c-text-muted);
font-weight: 500;
}
.header__child-select {
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius);
font-size: var(--fs-sm);
background-color: var(--c-surface);
color: var(--c-text);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.header__child-select:focus {
outline: none;
border-color: var(--c-accent);
box-shadow: 0 0 0 3px rgba(47, 111, 79, 0.1);
}
.header__child-name {
font-size: var(--fs-sm);
color: var(--c-accent);
font-weight: 500;
}
.header__nav {
max-width: var(--maxw);
margin: 0 auto;
display: flex;
gap: var(--sp-3);
flex-wrap: wrap;
justify-content: center;
}
.header__nav-link {
padding: var(--sp-2) var(--sp-4);
font-size: var(--fs-sm);
text-decoration: none;
color: var(--c-text);
border-radius: var(--radius);
border-bottom: 2px solid transparent;
transition: background-color 0.2s, border-color 0.2s;
}
.header__nav-link:hover {
background-color: var(--c-bg);
}
.header__nav-link:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.header__nav-link--active {
background-color: var(--c-accent);
color: var(--c-surface);
border-bottom-color: var(--c-accent);
}
.header__logout-button {
padding: var(--sp-2) var(--sp-4);
font-size: var(--fs-sm);
background-color: transparent;
border: 1px solid var(--c-border);
border-radius: var(--radius);
color: var(--c-text);
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
}
.header__logout-button:hover {
background-color: var(--c-bg);
border-color: var(--c-accent);
}
.header__logout-button:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
max-width: 24ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+4 -23
View File
@@ -23,6 +23,10 @@
width: 100%;
}
.dashboard > .alert {
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.
@@ -64,15 +68,6 @@
margin-top: 0;
}
.dashboard__error {
padding: var(--sp-4);
background-color: var(--c-danger-weak);
color: var(--c-danger);
border-left: 4px solid var(--c-danger);
border-radius: var(--radius);
margin-bottom: var(--sp-5);
}
.dashboard__break-list {
list-style: none;
margin: 0;
@@ -111,17 +106,3 @@
border-top: 1px solid var(--c-border);
}
/* Responsive */
@media (max-width: 640px) {
[data-testid='layout-main'] {
padding: var(--sp-4);
}
.dashboard__section,
.settings-page__section,
.calendar__section,
.tips-page__section {
padding: var(--sp-4);
margin-bottom: var(--sp-4);
}
}