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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user