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
+67 -29
View File
@@ -22,7 +22,20 @@ export interface CalendarWeekProps {
selectedDates: Set<string>
parentName: string
personalDataUuid: string
isCurrentMonth: boolean
/** Every workspace member's display name keyed by `personalDataUuid`, used
* to label a band owned by someone other than the current user (a
* partner's band must show their actual name, not the generic
* `calendar.partner` string). Falls back to that generic string for a
* `parentUuid` matching no known member (stale data, removed partner). */
parentNames?: Map<string, string> | undefined
/** The calendar's currently displayed year/month — each day in the week
* is compared against these directly (not against `weekDates[0]`) so a
* leading/trailing week that straddles a month boundary still activates
* its in-month cells. See Calendar.tsx's week-building loop: a leading
* week's `weekDates[0]` can belong entirely to the previous month even
* though most of the row is the displayed month. */
displayedYear: number
displayedMonth: number
showWeekNumbers: boolean
onDayClick: (date: string) => void
onPointerDown: (date: string) => void
@@ -238,7 +251,9 @@ export function CalendarWeek({
selectedDates,
parentName,
personalDataUuid,
isCurrentMonth,
parentNames,
displayedYear,
displayedMonth,
showWeekNumbers,
onDayClick,
onPointerDown,
@@ -253,32 +268,42 @@ export function CalendarWeek({
[weekDates, plannedDays, calendarDaysByDate],
)
// Bands overlay the day-cell row itself (grid-row: 1, position: absolute)
// instead of occupying separate rows below it — see the comment on
// `.calendar-week` in calendar.css. `barHeight`/`laneGap`/`bandBottomInset`
// together define both each lane's vertical offset from the cell's bottom
// edge AND, via `bandsReservedHeight`, how much bottom padding
// `.calendar-day` reserves so its number/badges/holiday name never sit
// under a band.
const barHeight = 20
const laneGap = 2
const bandBottomInset = 4
const laneCount = bandSegments.length > 0 ? Math.max(...bandSegments.map((b) => b.lane)) + 1 : 0
const bandsReservedHeight = laneCount > 0 ? bandBottomInset + laneCount * (barHeight + laneGap) : 0
const colOffset = showWeekNumbers ? 1 : 0
const gridTemplateRows = laneCount > 0 ? `auto repeat(${laneCount}, ${barHeight}px)` : 'auto'
const firstDate = weekDates[0]
const weekNumber = getISOWeekNumber(new Date(firstDate + 'T00:00:00Z'))
const weekStyle = {
'--band-reserved-height': `${bandsReservedHeight}px`,
} as React.CSSProperties & Record<'--band-reserved-height', string>
return (
<div
className={`calendar-week${showWeekNumbers ? ' calendar-week--with-week-numbers' : ''}`}
style={{ gridTemplateRows }}
style={weekStyle}
>
{showWeekNumbers && (
<div
className="calendar__week-number"
style={{ gridColumn: '1', gridRow: `1 / ${laneCount + 2}` }}
>
<div className="calendar__week-number" style={{ gridColumn: '1', gridRow: '1' }}>
<span className="calendar__week-number-text">v.{weekNumber}</span>
</div>
)}
{weekDates.map((date, colIdx) => {
const weekDateObj = new Date(weekDates[0] + 'T00:00:00Z')
const dateObj = new Date(date + 'T00:00:00Z')
const dayIsCurrentMonth = dateObj.getMonth() === weekDateObj.getMonth()
const dayIsCurrentMonth =
dateObj.getUTCFullYear() === displayedYear && dateObj.getUTCMonth() === displayedMonth
const calendarInfo = calendarDaysByDate?.get(date)
return (
@@ -289,35 +314,40 @@ export function CalendarWeek({
>
<CalendarDay
date={date}
isCurrentMonth={isCurrentMonth && dayIsCurrentMonth}
isCurrentMonth={dayIsCurrentMonth}
isToday={date === formatDateLocalISO(new Date())}
isSelected={selectedDates.has(date)}
dayType={calendarInfo?.dayType}
holidayName={calendarInfo?.name ?? null}
isOverride={calendarInfo?.isOverride ?? false}
overrideIsWorkingDay={calendarInfo?.overrideIsWorkingDay ?? null}
onClick={() => {
if (isCurrentMonth && dayIsCurrentMonth) {
onDayClick(date)
}
}}
onPointerDown={(d) => {
if (isCurrentMonth && dayIsCurrentMonth) {
onPointerDown(d)
}
}}
onPointerEnter={(d) => {
if (isCurrentMonth && dayIsCurrentMonth) {
onPointerEnter(d)
}
}}
// Every cell is clickable/drag-selectable regardless of which
// month it visually belongs to — `dayIsCurrentMonth` only
// controls the dimmed `calendar-day--disabled` styling above.
// Two failure modes this avoids, both seen in production:
// (1) gating on `weekDates[0]`'s month killed an entire
// leading week; (2) gating per-cell on the day's own month
// (the previous fix here) still killed every adjacent-month
// cell, so a drag could never cross a month boundary — see
// the regression this closes in Calendar.tsx's pointerup
// handler / drag-create-delete.spec.ts. Clicking a visible
// adjacent-month day plans that day, same as clicking any
// other visible day; that's a deliberate call, not an
// oversight — silently doing nothing on a visible cell is
// worse than acting on it.
onClick={() => onDayClick(date)}
onPointerDown={(d) => onPointerDown(d)}
onPointerEnter={(d) => onPointerEnter(d)}
/>
</div>
)
})}
{bandSegments.map((segment) => {
const label = segment.parentUuid === personalDataUuid ? parentName : t('calendar.partner')
const label =
segment.parentUuid === personalDataUuid
? parentName
: (parentNames?.get(segment.parentUuid) ?? t('calendar.partner'))
const firstRealDay = segment.dayInfos.find((d) => !d.isGap)
const representativeType = firstRealDay?.dayType ?? 'vacation'
const typeLabel = getLabelForType(representativeType, t)
@@ -347,7 +377,9 @@ export function CalendarWeek({
className={classNames}
style={{
gridColumn: `${segment.startCol + 1 + colOffset} / ${segment.endCol + 2 + colOffset}`,
gridRow: String(segment.lane + 2),
gridRow: '1',
bottom: `${bandBottomInset + segment.lane * (barHeight + laneGap)}px`,
height: `${barHeight}px`,
backgroundColor: color,
}}
data-testid={`calendar-band-${segment.id}`}
@@ -384,7 +416,13 @@ export function CalendarWeek({
))}
</div>
<div className="calendar-band__label">
<CalendarEvent leaveType={typeLabel} color={color} isDubbeldag={anyDubbeldag} parentLabel={label} />
<CalendarEvent
leaveType={typeLabel}
color={color}
isDubbeldag={anyDubbeldag}
parentLabel={label}
paintBackground={false}
/>
</div>
</div>
)