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
+42 -23
View File
@@ -70,7 +70,7 @@ export interface RawParentalLeaveFetch {
personal_data_uuid: string
workspace_id: string
child_id: string
source: 'scraped' | 'manual'
source: 'scraped' | 'manual' | 'deduced'
fetched_at: string
total_days: number
days_used: number
@@ -457,7 +457,6 @@ function toPersonalData(raw: RawPersonalData): PersonalData {
interface RawWorkspace {
id: string
name: string
planning_mode: 'solo' | 'couple'
created_at: string
}
@@ -465,7 +464,6 @@ function toWorkspace(raw: RawWorkspace): Workspace {
return {
id: raw.id,
name: raw.name,
planningMode: raw.planning_mode,
createdAt: raw.created_at,
}
}
@@ -585,7 +583,10 @@ async function apiFetch<T>(
}
try {
const body = (await response.json()) as {
detail?: string | Array<{ msg?: string }> | { message_key: string; data?: Record<string, unknown> }
detail?:
| string
| Array<{ msg?: string; loc?: unknown[] }>
| { message_key: string; data?: Record<string, unknown> }
code?: string
message?: string
}
@@ -610,9 +611,20 @@ async function apiFetch<T>(
const detail = Array.isArray(body.detail)
? body.detail.map((e) => e.msg ?? JSON.stringify(e)).join('; ')
: body.detail
// FastAPI's default pydantic 422 shape puts the offending field name
// in each error's `loc` (e.g. ['body', 'sjukpenning_left']), not in
// `msg` — surface the last segment of each `loc` as `details.fields`
// so a caller (e.g. ParentBalanceForm) can map the failure back to
// one input instead of only showing the raw message.
const fields = Array.isArray(body.detail)
? body.detail
.map((e) => (Array.isArray(e.loc) ? e.loc[e.loc.length - 1] : undefined))
.filter((f): f is string => typeof f === 'string')
: []
error = {
code: body.code ?? String(response.status),
message: detail ?? body.message ?? response.statusText,
...(fields.length > 0 ? { details: { fields } } : {}),
}
}
} catch {
@@ -704,7 +716,6 @@ export async function updateWorkspace(
method: 'PUT',
body: JSON.stringify({
name: req.name,
planning_mode: req.planningMode,
}),
})) as RawWorkspace
return toWorkspace(raw)
@@ -747,23 +758,25 @@ export async function getVacationDaysByWorkspace(workspaceId: string): Promise<V
// ---------------------------------------------------------------------------
export async function saveManualFetch(req: ManualFetchRequest): Promise<ParentalLeaveFetch> {
// Optional keys are omitted entirely (rather than sent as `undefined`,
// which `JSON.stringify` would drop anyway but less explicitly) so the
// backend's "null/omitted = derive it" defaulting kicks in — see
// `ManualFetchRequest`'s doc comment.
const body: Record<string, unknown> = {
personal_data_uuid: req.personalDataUuid,
workspace_id: req.workspaceId,
child_id: req.childId,
sjukpenning_left: req.sjukpenningLeft,
lagsta_niva_left: req.lagstaNivaLeft,
dubbeldagar_left: req.dubbeldagarLeft,
}
if (req.reservedDaysLeft !== undefined) body.reserved_days_left = req.reservedDaysLeft
if (req.totalDays !== undefined) body.total_days = req.totalDays
if (req.source !== undefined) body.source = req.source
const raw = (await apiFetch('/parental-leave/manual', {
method: 'POST',
body: JSON.stringify({
personal_data_uuid: req.personalDataUuid,
workspace_id: req.workspaceId,
child_id: req.childId,
total_days: req.totalDays,
days_used: req.daysUsed,
reserved_days_total: req.reservedDaysTotal ?? 90,
reserved_days_left: req.reservedDaysLeft,
transferable_days_left: req.transferableDaysLeft,
dubbeldagar_left: req.dubbeldagarLeft ?? 60,
sjukpenning_total: req.sjukpenningTotal ?? 390,
sjukpenning_used: req.sjukpenningUsed,
lagsta_niva_total: req.lagstaNivaTotal ?? 90,
lagsta_niva_used: req.lagstaNivaUsed,
}),
body: JSON.stringify(body),
})) as RawParentalLeaveFetch
return toParentalLeaveFetch(raw)
}
@@ -1049,9 +1062,15 @@ export async function dismissTip(workspaceId: string, tipId: string): Promise<vo
export async function getParentalLeave(
personalDataUuid: string,
childId: string,
): Promise<ParentalLeaveFetch> {
const raw = (await apiFetch(`/parental-leave/${personalDataUuid}/${childId}`)) as RawParentalLeaveFetch
return toParentalLeaveFetch(raw)
): Promise<ParentalLeaveFetch | null> {
// The backend returns `null` (200 OK) rather than 404 when nothing has
// been saved yet for this parent/child — a brand-new partner, or self
// before any fetch. `toParentalLeaveFetch` assumes a real record, so it
// must not be called on that `null`.
const raw = (await apiFetch(
`/parental-leave/${personalDataUuid}/${childId}`,
)) as RawParentalLeaveFetch | null
return raw ? toParentalLeaveFetch(raw) : null
}
// ---------------------------------------------------------------------------