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
@@ -25,6 +25,16 @@ describe('Hero', () => {
expect(screen.getByTestId('dashboard-hero')).toHaveAttribute('role', 'alert')
})
it('uses a calm reassurance framing when a plan exists and nothing is at risk', () => {
render(
<Hero hasPlan={true} daysLeft={200} daysAtRisk={0} childName="Nova" fourthBirthday="2029-06-01" />,
)
const hero = screen.getByTestId('dashboard-hero')
expect(screen.getByTestId('dashboard-hero-message')).not.toHaveTextContent(/riskerar att försvinna/)
expect(hero).not.toHaveClass('dashboard-hero--warning')
expect(hero).not.toHaveAttribute('role', 'alert')
})
it('renders the same daysAtRisk figure in both framings without recomputing it', () => {
const { rerender } = render(
<Hero hasPlan={false} daysLeft={480} daysAtRisk={104} childName="Nova" fourthBirthday="2029-06-01" />,
+16 -12
View File
@@ -16,32 +16,36 @@ export interface HeroProps {
}
/**
* The headline: same arithmetic (`daysAtRisk` = `combinedExpiringAtB4`), two
* framings depending on whether anything has been planned yet — a forward-
* looking nudge with nothing planned (the entire entitlement is technically
* "at risk" on day one, which reads as an accusation before the user has
* done anything), a plain warning once a plan exists.
* The headline: same arithmetic (`daysAtRisk` = `combinedExpiringAtB4`),
* three framings — a forward-looking nudge with nothing planned yet (the
* entire entitlement is technically "at risk" on day one, which reads as an
* accusation before the user has done anything), a calm confirmation once a
* plan exists and nothing is actually at risk, and a plain warning once a
* plan exists and some days are at risk.
*/
export function Hero({ hasPlan, daysLeft, daysAtRisk, childName, fourthBirthday }: HeroProps): ReactNode {
const { t, locale } = useTranslation()
const noneAtRisk = daysAtRisk === 0
const date = formatDateForDisplay(fourthBirthday, locale)
const message = hasPlan
? t('dashboard.hero.hasPlanMessage', {
atRisk: daysAtRisk,
childName,
date: formatDateForDisplay(fourthBirthday, locale),
})
? noneAtRisk
? t('dashboard.hero.noneAtRiskMessage', { childName, date })
: t('dashboard.hero.hasPlanMessage', { atRisk: daysAtRisk, childName, date })
: t('dashboard.hero.noPlanMessage', {
days: daysLeft,
atRisk: daysAtRisk,
childName,
})
const modifier = hasPlan ? (noneAtRisk ? 'dashboard-hero--reassure' : 'dashboard-hero--warning') : 'dashboard-hero--motivate'
return (
<section
data-testid="dashboard-hero"
className={`dashboard-hero ${hasPlan ? 'dashboard-hero--warning' : 'dashboard-hero--motivate'}`}
role={hasPlan ? 'alert' : undefined}
className={`dashboard-hero ${modifier}`}
role={hasPlan && !noneAtRisk ? 'alert' : undefined}
>
<p className="dashboard-hero__message" data-testid="dashboard-hero-message">
{message}
@@ -42,6 +42,18 @@ describe('OverviewMetrics', () => {
expect(screen.getByTestId('custom-test-id')).toBeInTheDocument()
})
it('renders a summary metric as a full-width footer row', () => {
const metrics = [
{ label: 'A', value: '1', testId: 'metric-a' },
{ label: 'B', value: '2', testId: 'metric-b' },
{ label: 'Total', value: '3', testId: 'metric-total', isSummary: true },
]
render(<OverviewMetrics metrics={metrics} />)
expect(screen.getByTestId('metric-total')).toHaveClass('overview-metrics__item--summary')
expect(screen.getByTestId('metric-a')).not.toHaveClass('overview-metrics__item--summary')
})
it('has proper accessibility structure', () => {
const metrics = [{ label: 'Metric', value: '50' }]
render(<OverviewMetrics metrics={metrics} title="Metrics Section" />)
@@ -5,6 +5,10 @@ interface Metric {
label: string
value: string | number
testId?: string
// A combined/"all parents" total that summarizes the other metrics above
// it — rendered as a full-width footer row instead of just another grid
// cell, so an odd item count never leaves a dangling gap in the last row.
isSummary?: boolean
}
interface OverviewMetricsProps {
@@ -25,7 +29,11 @@ export function OverviewMetrics({ metrics, title }: OverviewMetricsProps): React
role="listitem"
data-testid={metric.testId || `metric-${index}`}
data-label={metric.label}
className="overview-metrics__item"
className={
metric.isSummary
? 'overview-metrics__item overview-metrics__item--summary'
: 'overview-metrics__item'
}
>
<span data-testid={`metric-label-${index}`} className="overview-metrics__label">{metric.label}:</span>
<span data-testid={`metric-value-${index}`} className="overview-metrics__value">{metric.value}</span>
@@ -39,7 +39,7 @@ describe('PlanFold', () => {
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-no-wishes')).toHaveTextContent(
'Lägg till önskemål under Planering för att se ett förslag här.',
'Lägg till önskemål under Kalender för att se ett förslag här.',
)
})
@@ -113,7 +113,7 @@ export function PlanFold({ workspaceId, members }: PlanFoldProps): ReactNode {
)}
{proposalError && (
<div className="plan-fold__error" role="alert" data-testid="plan-fold-proposal-error">
<div className="alert alert--error" role="alert" data-testid="plan-fold-proposal-error">
{proposalError}
</div>
)}
@@ -37,7 +37,7 @@ describe('YearTimeline', () => {
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(1)
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
'title',
'Semester, 2026-06-15 – 2026-06-17',
'Semester, 15 juni 2026 – 17 juni 2026 (3 dagar). Klicka för att öppna denna månad i kalendern.',
)
})
@@ -98,4 +98,36 @@ describe('YearTimeline', () => {
await user.click(screen.getByTestId('year-timeline-band-2026-0'))
expect(onBandClick).toHaveBeenCalledWith('2026-06-15')
})
it('renders an explanatory caption below the title', () => {
render(
<YearTimeline plannedDays={[]} startDate="2025-06-01" endDate="2029-06-01" onBandClick={vi.fn()} />,
)
expect(screen.getByText(/Varje rad är ett år/)).toBeInTheDocument()
})
it('renders a colour legend with one entry per day type', () => {
render(
<YearTimeline plannedDays={[]} startDate="2025-06-01" endDate="2029-06-01" onBandClick={vi.fn()} />,
)
const legend = screen.getByTestId('year-timeline-legend')
expect(legend).toHaveTextContent('Sjukpenningnivå')
expect(legend).toHaveTextContent('Lägstanivå')
expect(legend).toHaveTextContent('Semester')
})
it('includes the day count in a band\'s label', () => {
render(
<YearTimeline
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation' })]}
startDate="2025-06-01"
endDate="2029-06-01"
onBandClick={vi.fn()}
/>,
)
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
'title',
'Semester, 15 juni 2026 – 15 juni 2026 (1 dag). Klicka för att öppna denna månad i kalendern.',
)
})
})
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
import { formatDateForDisplay } from '@/utils/dates'
export interface YearTimelineProps {
plannedDays: PlannedLeaveDay[]
@@ -17,6 +18,9 @@ interface Band {
startDate: string
endDate: string
dominantType: PlannedLeaveDay['dayType']
/** Number of planned days actually in this band (not the calendar span —
* a bridged weekend/röd dag inside the band isn't a planned day). */
dayCount: number
}
// A run of planned days separates into a new band once the gap between
@@ -79,6 +83,7 @@ function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate:
startDate: current[0]!.date,
endDate: current[current.length - 1]!.date,
dominantType: dominantType(current),
dayCount: current.length,
})
current = []
}
@@ -110,7 +115,7 @@ function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate:
* per-parent day bars above it.
*/
export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: YearTimelineProps): ReactNode {
const { t } = useTranslation()
const { t, locale } = useTranslation()
const start = toUtcDate(startDate)
const end = toUtcDate(endDate)
const startYear = start.getUTCFullYear()
@@ -124,6 +129,19 @@ export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: Y
return (
<div data-testid="year-timeline" className="year-timeline">
<h3 className="year-timeline__title">{t('yearTimeline.title')}</h3>
<p className="year-timeline__caption">{t('yearTimeline.caption')}</p>
<ul className="year-timeline__legend" data-testid="year-timeline-legend">
{TYPE_PRIORITY.map((type) => (
<li key={type} className="year-timeline__legend-item">
<span
className="year-timeline__legend-swatch"
style={{ backgroundColor: getColorForType(type) }}
aria-hidden="true"
/>
{getLabelForType(type, t)}
</li>
))}
</ul>
{years.map((year) => {
const yearLength = daysInYear(year)
const bandsForYear = bands.filter((b) => toUtcDate(b.startDate).getUTCFullYear() === year)
@@ -134,11 +152,15 @@ export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: Y
{bandsForYear.map((band, idx) => {
const left = ((dayOfYearUtc(toUtcDate(band.startDate)) - 1) / yearLength) * 100
const width = ((dayOfYearUtc(toUtcDate(band.endDate)) - dayOfYearUtc(toUtcDate(band.startDate)) + 1) / yearLength) * 100
const label = t('yearTimeline.bandLabel', {
dayType: getLabelForType(band.dominantType, t),
startDate: band.startDate,
endDate: band.endDate,
})
const label = t(
band.dayCount === 1 ? 'yearTimeline.bandLabelOneDay' : 'yearTimeline.bandLabel',
{
dayType: getLabelForType(band.dominantType, t),
startDate: formatDateForDisplay(band.startDate, locale),
endDate: formatDateForDisplay(band.endDate, locale),
dayCount: band.dayCount,
},
)
return (
<button
key={`${year}-${idx}`}