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
+19
View File
@@ -36,12 +36,31 @@ export function calendarDayCell(page: Page, isoDate: string) {
* cells entirely, which reads as a click on the anchor date. Moving in
* several steps per cell, with a short pause between cells, is what
* reliably fires a `pointerenter` on each one.
*
* The whole dragged range is scrolled into view ONCE, before the drag
* starts — never mid-drag. A default-viewport (1280×720) run of a 6-row
* month puts the trailing week's cells below the fold (`getBoundingClientRect`
* still reports their real, off-screen `y`); moving the mouse to those
* coordinates without scrolling first sends it past the bottom of the
* actually-rendered viewport, where there is no element to receive
* `pointerenter` at all — the browser just stops delivering the event, and
* the drag's `dragCurrent` freezes on the last on-screen cell it entered
* while still climbing toward the target. Scrolling once up front (via the
* last, typically lowest, target date) keeps every waypoint's precomputed
* center valid for the whole gesture; scrolling again after `mouse.down()`
* would invalidate the coordinates of any cell already computed and risk
* disrupting the in-progress native pointer sequence.
*/
export async function dragSelectDates(page: Page, isoDates: string[]): Promise<void> {
if (isoDates.length < 2) {
throw new Error('dragSelectDates needs at least two dates to actually drag across')
}
const lastDate = isoDates[isoDates.length - 1]
if (lastDate) {
await calendarDayCell(page, lastDate).scrollIntoViewIfNeeded()
}
const centerOf = async (isoDate: string): Promise<{ x: number; y: number }> => {
const box = await calendarDayCell(page, isoDate).boundingBox()
if (!box) throw new Error(`calendar day cell not visible for ${isoDate} — is it in the current month view?`)
+30 -4
View File
@@ -43,12 +43,38 @@ export async function fillChildStep(page: Page, childName: string): Promise<void
await expect(page.getByTestId('onboarding-step-family')).toBeVisible()
}
/** From step 2 (family), keeps the default "solo" planning mode, continues
* to step 3 (balances) and skips it — landing on the Dashboard with the
* statutory-assumption balance (no manual fetch, no drained pool). */
/** From step 2 (family) — "Bara jag" is selected by default, so this just
* continues to step 3 (balances) and presses "Klar" without filling
* anything in — landing on the Dashboard with the statutory-assumption
* balance (no manual fetch, no drained pool). */
export async function continueSoloAndSkipBalances(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Fortsätt' }).click()
await expect(page.getByTestId('onboarding-step-balances')).toBeVisible()
await page.getByRole('button', { name: 'Hoppa över för nu' }).click()
await page.getByTestId('onboarding-finish').click()
await expect(page.getByTestId('dashboard')).toBeVisible({ timeout: 15_000 })
}
/** From step 2 (family), selects "Vi är två föräldrar", fills in the other
* parent's name and continues — landing on step 3 (balances) with both
* parents' cards visible. */
export async function chooseTwoParents(page: Page, partnerName: string): Promise<void> {
await page.getByTestId('onboarding-family-couple').click()
await page.getByLabel('Den andra förälderns namn').fill(partnerName)
await page.getByRole('button', { name: 'Fortsätt' }).click()
await expect(page.getByTestId('onboarding-step-balances')).toBeVisible()
}
/** In step 3 (balances), opens "Ange själv" on the parent card matching
* `name` and fills in + saves the three balance fields shown there. */
export async function enterBalancesFor(
page: Page,
name: string,
balances: { sjukpenning: number; lagsta: number; dubbeldagar: number },
): Promise<void> {
const card = page.getByTestId('onboarding-parent-card').filter({ hasText: name })
await card.getByRole('button', { name: 'Ange själv' }).click()
await page.getByLabel(/Sjukpenningnivå/).fill(String(balances.sjukpenning))
await page.getByLabel(/Lägstanivå/).fill(String(balances.lagsta))
await page.getByLabel(/Dubbeldagar/).fill(String(balances.dubbeldagar))
await page.getByRole('button', { name: 'Spara' }).click()
}
+90 -38
View File
@@ -1,74 +1,126 @@
/**
* The two authoring gestures the calendar rework is actually about:
* dragging a range to create a period, and deleting one again through its
* popover. Neither was covered end-to-end before — the existing specs only
* check rendering (bands, red days, age-4 figures) of state the API seeded
* directly.
* dragging a range to create planned days, and deleting them again through
* the period popover. Neither was covered end-to-end before — the existing
* specs only check rendering (bands, red days, age-4 figures) of state the
* API seeded directly.
*
* A drag no longer creates a `LeaveWish`/synced period on its own — it opens
* the day popover pre-selected on every dragged date, and saving writes
* exactly those days (ad-hoc `planned_leave_day` rows, no wish). See
* `Calendar.tsx`'s pointerup handler and `handleSavePlannedDay`.
*
* The drag deliberately crosses a month boundary (the last two days of one
* month into the first day of the next) — a regression guard for the
* leading/trailing-week gating bug, where `CalendarWeek.tsx` used to wire
* pointer handlers only for cells matching `weekDates[0]`'s month, so a
* trailing day belonging to the next month was inert and a drag could never
* reach it.
*
* Uses a brand-new, empty account (see `support/onboarding.ts`) rather than
* the shared demo workspace: `Calendar.tsx`'s drag-to-create always pins the
* new period to the CURRENTLY LOGGED-IN parent
* (`parentMode: 'specific', parentPersonalDataUuid: personalDataUuid`), and
* the demo user (Alex) is deliberately seeded with a fully drained
* sjukpenning/lägstanivå pool (see `backend/scripts/seed_demo.py`'s "near-
* drained FP pool") so that its one low-priority wish always shows a real
* shortfall — a 3-day drag by Alex would fail to fund for the same reason,
* which isn't what this test is about. A fresh account with no manual fetch
* gets the full statutory-assumption pool instead (see
* `AllocationInputLoader.load`'s "No FK snapshot yet" fallback), which is
* always enough for a 3-day period.
* the shared demo workspace, so the calendar starts with nothing on it.
*
* The target month is derived from "today" (never a hardcoded year).
*/
import { test, expect } from '@playwright/test'
import { goToCalendarMonth, calendarDayCell, dragSelectDates } from '../support/calendarNav'
import { toIsoDate } from '../support/dates'
import { toIsoDate, addDays } from '../support/dates'
import { uniqueEmail, registerAndReachOnboarding, fillChildStep, continueSoloAndSkipBalances } from '../support/onboarding'
test.use({ storageState: { cookies: [], origins: [] } })
/**
* A month, at least two months out from today, whose last day is NOT a
* Sunday — i.e. the grid actually renders a trailing-week row bleeding into
* the following month (a month that ends on Sunday has no trailing row at
* all after the "no phantom week" fix; see `Calendar.tsx`'s week-building
* loop). Returns that month plus the last two of its days and the first day
* of the next month — the exact range the drag below crosses.
*/
function targetRange(): { month: Date; dates: [string, string, string] } {
const now = new Date()
// Two months out and mid-month (the 10th–12th), so the range can never
// land on a leading/trailing day of an adjacent month — `CalendarWeek.tsx`
// only wires pointer handlers for cells belonging to the currently
// displayed month.
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 2, 1))
const dates = [10, 11, 12].map((day) =>
toIsoDate(new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth(), day))),
) as [string, string, string]
let month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 2, 1))
let lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0))
while (lastDayOfMonth.getUTCDay() === 0 /* Sunday */) {
month = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 1))
lastDayOfMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0))
}
const dates = [addDays(lastDayOfMonth, -1), lastDayOfMonth, addDays(lastDayOfMonth, 1)].map(toIsoDate) as [
string,
string,
string,
]
return { month, dates }
}
test('dragging a range creates a period, and deleting it removes the days for good', async ({ page }) => {
test('dragging a range across a month boundary plans every day, and deleting it removes the days for good', async ({
page,
}) => {
await registerAndReachOnboarding(page, uniqueEmail('drag-delete'), 'Robin Testsson')
await fillChildStep(page, 'Nova')
await continueSoloAndSkipBalances(page)
const { month, dates } = targetRange()
const [startDate, , endDate] = dates
const [firstDate, , lastDate] = dates
await goToCalendarMonth(page, month)
const wishBand = page.locator('[data-testid^="calendar-band-wish:"]')
await expect(wishBand).toHaveCount(0) // a brand-new account — nothing here yet
const adhocBand = page.locator('[data-testid^="calendar-band-adhoc:"]')
await expect(adhocBand).toHaveCount(0) // a brand-new account — nothing here yet
// The drag spans the month's last two days plus the first day of the
// following month, rendered as a trailing cell in this same grid.
await dragSelectDates(page, dates)
// A toast reports the change (Calendar.tsx's `buildSyncToastMessage`),
// and the dragged range now renders as one band.
await expect(page.getByTestId('calendar-toast')).toBeVisible()
await expect(wishBand).toHaveCount(1)
// A multi-day drag opens the day popover pre-selected on every dragged
// date — it does not create a period/wish by itself.
await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar')
await page.getByTestId('save-button').click()
await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible()
// Open the new period through its band and delete it — via the in-app
// confirmation from PeriodPopover.tsx, not a native `window.confirm`.
await wishBand.click()
// All three dragged days are one continuous ad-hoc PERIOD, but the grid
// renders a multi-week band as one DOM element per week ROW it crosses
// (see CalendarWeek.tsx's `computeBandSegments` and
// calendar-band.spec.ts) — here Nov 29 (a Sunday, the last column of its
// week) and Nov 30 (a Monday, the first column of the next) fall in
// different rows even though they're one calendar day apart. Both
// elements share the same `data-testid` (derived from the band's key +
// overall start date), which IS the "one period" assertion: a real
// second period would get its own id.
await expect(adhocBand.first()).toBeVisible()
await expect
.poll(async () => {
const testIds = await adhocBand.evaluateAll((els) => els.map((el) => el.getAttribute('data-testid')))
return new Set(testIds).size
})
.toBe(1)
// Open it through its band — an ad-hoc band (no wishId) shows the plain
// date range and can only be deleted, not edited. `.first()` because the
// band may render as more than one DOM segment (see above); either one
// opens the same underlying period.
await adhocBand.first().click()
await expect(page.getByTestId('period-popover__content')).toBeVisible()
await expect(page.getByTestId('period-start-input')).toHaveValue(startDate)
await expect(page.getByTestId('period-end-input')).toHaveValue(endDate)
await expect(page.getByTestId('period-adhoc-notice')).toBeVisible()
await expect(page.getByTestId('period-popover__content')).toContainText(`${firstDate} – ${lastDate}`)
await page.getByTestId('period-delete-button').click()
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
// Regression guard for the "Yes, delete" footer bug: the confirm button's
// label is long enough to wrap without `white-space: nowrap` on `.btn`
// (src/styles/primitives.css), which used to make it visibly taller than
// "Cancel" sitting next to it. Assert both the CSS guard and the rendered
// heights actually matching.
const cancelButton = page.getByTestId('period-delete-cancel-button')
const confirmButton = page.getByTestId('period-delete-confirm-button')
await expect(cancelButton).toHaveCSS('white-space', 'nowrap')
await expect(confirmButton).toHaveCSS('white-space', 'nowrap')
const [cancelBox, confirmBox] = await Promise.all([cancelButton.boundingBox(), confirmButton.boundingBox()])
expect(cancelBox).not.toBeNull()
expect(confirmBox).not.toBeNull()
expect(confirmBox?.height).toBe(cancelBox?.height)
await page.getByTestId('period-delete-confirm-button').click()
await expect(page.getByTestId('period-popover__overlay')).not.toBeVisible()
@@ -78,7 +130,7 @@ test('dragging a range creates a period, and deleting it removes the days for go
// from the API, so this is the assertion a surviving-days bug would fail.
await page.reload()
await goToCalendarMonth(page, month)
await expect(page.locator('[data-testid^="calendar-band-wish:"]')).toHaveCount(0)
await expect(page.locator('[data-testid^="calendar-band-adhoc:"]')).toHaveCount(0)
// And the day itself is empty again — clicking it opens the "plan a new
// day" modal, not an edit of a day that supposedly no longer exists.
@@ -0,0 +1,48 @@
/**
* 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.
* `.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`.
*/
import { test, expect, type Page } from '@playwright/test'
/**
* Resolves a CSS custom property (declared on :root in styles/global.css) to
* the computed `rgb(...)` string the browser would report for an element
* actually painted with it — see the identical helper (and its rationale)
* in `red-days.spec.ts`.
*/
async function resolveCssVar(page: Page, varName: string): Promise<string> {
return page.evaluate((name) => {
const probe = document.createElement('div')
probe.style.backgroundColor = `var(${name})`
probe.style.color = `var(${name})`
document.body.appendChild(probe)
const style = getComputedStyle(probe)
const resolved = style.backgroundColor
probe.remove()
return resolved
}, varName)
}
test.describe('header nav — active tab stays readable on hover', () => {
test('hovering the active nav link keeps the text readable', async ({ page }) => {
await page.goto('/')
const activeLink = page.getByTestId('header-nav').getByRole('link', { name: 'Översikt' })
await expect(activeLink).toHaveAttribute('aria-current', 'page')
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)
})
})
+22 -13
View File
@@ -1,9 +1,10 @@
/**
* The other authoring gesture nothing end-to-end covers yet: registering a
* brand-new account and walking the 3-step onboarding wizard (child ->
* solo/couple -> balances). Runs with an empty `storageState` — deliberately
* NOT the shared demo session `auth.setup.ts` produces for the rest of the
* suite — since registering a fresh account is exactly what's under test.
* optional partner -> balances). Runs with an empty `storageState` —
* deliberately NOT the shared demo session `auth.setup.ts` produces for the
* rest of the suite — since registering a fresh account is exactly what's
* under test.
*
* Every date used here is derived from "today", never a hardcoded year (see
* `support/dates.ts`'s module doc for why).
@@ -14,6 +15,8 @@ import {
registerAndReachOnboarding,
fillChildStep,
continueSoloAndSkipBalances,
chooseTwoParents,
enterBalancesFor,
} from '../support/onboarding'
test.use({ storageState: { cookies: [], origins: [] } })
@@ -23,7 +26,7 @@ test.describe('onboarding wizard', () => {
await registerAndReachOnboarding(page, uniqueEmail('onboarding-skip'), 'Alex Testsson')
await fillChildStep(page, 'Nova')
// Solo is the wizard's default planning mode — nothing to change here.
// Adding a partner is optional — nothing to change here.
await continueSoloAndSkipBalances(page)
await expect(page.getByTestId('dashboard-estimate-badge')).toHaveText('Uppskattat')
@@ -33,15 +36,9 @@ test.describe('onboarding wizard', () => {
await registerAndReachOnboarding(page, uniqueEmail('onboarding-couple'), 'Sam Testsson')
await fillChildStep(page, 'Nova')
await page.getByTestId('planning-mode-couple').check()
await expect(page.getByTestId('onboarding-partner-section')).toBeVisible()
await page.getByLabel('Partnerns namn').fill('Kim')
await page.getByRole('button', { name: 'Lägg till partner' }).click()
await expect(page.getByTestId('onboarding-partner-added')).toHaveText('Partner tillagd: Kim')
await page.getByRole('button', { name: 'Fortsätt' }).click()
await expect(page.getByTestId('onboarding-step-balances')).toBeVisible()
await page.getByRole('button', { name: 'Hoppa över för nu' }).click()
await chooseTwoParents(page, 'Kim')
await expect(page.getByTestId('onboarding-parent-card')).toHaveCount(2)
await page.getByTestId('onboarding-finish').click()
// The regression this guards: finishing the wizard flips
// WorkspaceContext's `children` from empty to non-empty, which
@@ -59,4 +56,16 @@ test.describe('onboarding wizard', () => {
await expect(age4Bars).toContainText('Kim')
await expect(age4Bars).not.toContainText('Förälder')
})
test('entering balances for both parents during the couple path carries through to Översikt', async ({ page }) => {
await registerAndReachOnboarding(page, uniqueEmail('onboarding-couple-balances'), 'Robin Testsson')
await fillChildStep(page, 'Nova')
await chooseTwoParents(page, 'Kim')
await enterBalancesFor(page, 'Robin Testsson', { sjukpenning: 150, lagsta: 30, dubbeldagar: 20 })
await enterBalancesFor(page, 'Kim', { sjukpenning: 100, lagsta: 20, dubbeldagar: 10 })
await page.getByTestId('onboarding-finish').click()
await expect(page.getByTestId('dashboard')).toBeVisible({ timeout: 15_000 })
})
})