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
+111 -301
View File
@@ -8,9 +8,21 @@
margin: 0 0 var(--sp-4) 0;
}
/* `auto-fit` only ever governs the desktop case (>1024px) — the tablet/
* mobile breakpoints in responsive.css force an explicit 2-column/1-column
* layout regardless of this basis. At desktop widths the container is
* capped by `--maxw` (960px) minus the fixed chrome around it (`layout-main`
* + `.dashboard__more-details` padding), landing at ~864px regardless of
* how much wider the viewport itself gets — so the basis only needs to let
* FOUR cards (the two-parent "expiring at 4" case) fit that one width: four
* columns at 13rem plus three 16px gaps (880px) doesn't quite fit 864px and
* `auto-fit` drops to 3, stranding the 4th card alone on its own dangling
* row. 12.5rem keeps that same one-row-of-four for the two-parent overview
* metrics/expiring-at-4 lists while still comfortably wrapping to 2 columns
* for anything narrower than that. */
.overview-metrics__list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));
gap: var(--sp-4);
}
@@ -25,6 +37,22 @@
box-shadow: var(--shadow-sm);
}
/* The "all parents" combined total — a footer row spanning every column
* instead of just another grid cell, so it never leaves a dangling gap
* when the per-parent metrics above it don't fill a whole row. Tinted so
* it reads as "the sum of the above", not one more figure among equals. */
.overview-metrics__item--summary {
grid-column: 1 / -1;
flex-direction: row;
justify-content: center;
gap: var(--sp-3);
background-color: var(--c-accent-weak);
}
.overview-metrics__item--summary .overview-metrics__value {
margin-bottom: 0;
}
.overview-metrics__value {
display: block;
font-size: var(--fs-2xl);
@@ -76,7 +104,7 @@
}
.days-breakdown__table tbody tr:hover {
background-color: rgba(47, 111, 79, 0.02);
background-color: var(--c-row-hover);
}
.days-breakdown__table tbody tr:last-child td {
@@ -131,7 +159,7 @@
}
.parental-leave-breakdown__table tbody tr:hover {
background-color: rgba(47, 111, 79, 0.02);
background-color: var(--c-row-hover);
}
.parental-leave-breakdown__table tbody tr:last-child td {
@@ -174,6 +202,11 @@
background-color: var(--c-danger-weak);
}
.dashboard-hero--reassure {
border-left-color: var(--c-success);
background-color: var(--c-success-weak);
}
.dashboard-hero__message {
margin: 0;
font-size: var(--fs-2xl);
@@ -191,15 +224,29 @@
flex-wrap: wrap;
}
.dashboard__estimate-badge {
display: inline-block;
margin: var(--sp-3) 0;
padding: var(--sp-1) var(--sp-3);
border-radius: var(--radius-full);
/* "Ange manuellt" for a two-parent workspace asks which parent first —
* see `Dashboard.tsx`'s `handleOpenManualFetch`. `.card` gives it the same
* chrome as the rest of the dashboard's boxed content instead of a bare,
* floating row of buttons. */
.dashboard__balance-picker {
margin-top: var(--sp-2);
}
.dashboard__balance-picker-label {
margin: 0 0 var(--sp-3) 0;
font-size: var(--fs-sm);
font-weight: 600;
background-color: var(--c-warning-weak);
color: var(--c-warning);
font-weight: 500;
color: var(--c-text-muted);
}
.dashboard__balance-picker-actions {
display: flex;
gap: var(--sp-3);
flex-wrap: wrap;
}
.dashboard__estimate-badge {
margin: var(--sp-3) 0;
cursor: help;
}
@@ -275,6 +322,37 @@
margin-bottom: var(--sp-1);
}
.year-timeline__caption {
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin: 0;
}
.year-timeline__legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
list-style: none;
margin: 0;
padding: 0;
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
.year-timeline__legend-item {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.year-timeline__legend-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.year-timeline__row {
display: flex;
align-items: center;
@@ -345,52 +423,6 @@
margin-top: var(--sp-4);
}
.plan-fold__error {
color: var(--c-danger);
}
/* Button Styles */
button {
background-color: var(--c-accent);
color: var(--c-surface);
font-weight: 500;
}
button:hover:not(:disabled) {
background-color: var(--c-accent-hover);
}
button:active:not(:disabled) {
background-color: var(--c-accent-active);
}
/* Secondary button */
.button--secondary,
button.secondary {
background-color: transparent;
color: var(--c-accent);
border: 1px solid var(--c-accent);
}
.button--secondary:hover:not(:disabled),
button.secondary:hover:not(:disabled) {
background-color: var(--c-accent-weak);
}
/* Ghost button */
.button--ghost,
button.ghost {
background-color: transparent;
color: var(--c-text);
border: 1px solid var(--c-border);
}
.button--ghost:hover:not(:disabled),
button.ghost:hover:not(:disabled) {
background-color: var(--c-bg);
border-color: var(--c-text);
}
/* Form styles */
.form-group {
margin-bottom: var(--sp-5);
@@ -432,24 +464,8 @@ button.ghost:hover:not(:disabled) {
max-width: none;
}
/* Settings Page Messages */
.settings-page__message {
padding: var(--sp-4);
border-radius: var(--radius);
.settings-page > .alert {
margin-bottom: var(--sp-4);
font-weight: 500;
}
.settings-page__message--success {
background-color: var(--c-success-weak);
color: var(--c-success);
border-left: 4px solid var(--c-success);
}
.settings-page__message--error {
background-color: var(--c-danger-weak);
color: var(--c-danger);
border-left: 4px solid var(--c-danger);
}
.settings-page__help {
@@ -483,11 +499,11 @@ button.ghost:hover:not(:disabled) {
gap: var(--sp-3);
}
/* Box/border/focus chrome comes from `.field__control` in primitives.css —
* this only adds the flex sizing `.field__control`'s own rule doesn't know
* about (it sits in an inline row next to a button, not a stacked `.field`). */
.settings-page__partner-input {
flex: 1;
padding: var(--sp-3) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius);
}
.settings-page__member-list {
@@ -507,11 +523,6 @@ button.ghost:hover:not(:disabled) {
padding: var(--sp-2) 0;
}
.settings-page__you-badge {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.settings-page__remove-confirm {
display: flex;
align-items: center;
@@ -520,46 +531,6 @@ button.ghost:hover:not(:disabled) {
font-size: var(--fs-sm);
}
.settings-page__button:not(:disabled):hover {
background-color: var(--c-accent-hover);
}
.settings-page__button:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.settings-page__button--secondary {
background-color: transparent;
color: var(--c-text);
border: 1px solid var(--c-border);
}
.settings-page__button--secondary:not(:disabled):hover {
background-color: var(--c-bg);
}
.settings-page__button--secondary:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.settings-page__button--danger {
background-color: var(--c-danger-weak);
color: var(--c-danger);
border: 1px solid var(--c-danger);
}
.settings-page__button--danger:not(:disabled):hover {
background-color: var(--c-danger);
color: white;
}
.settings-page__button--danger:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
/* Vacation Days Table (multi-year) */
.vacation-days-table {
width: 100%;
@@ -592,14 +563,7 @@ button.ghost:hover:not(:disabled) {
}
.vacation-days-table__assumed-badge {
display: inline-block;
margin-left: var(--sp-2);
padding: var(--sp-1) var(--sp-2);
border-radius: 999px;
font-size: var(--fs-sm);
font-weight: 400;
background-color: var(--c-warning-weak);
color: var(--c-warning);
cursor: help;
}
@@ -607,36 +571,11 @@ button.ghost:hover:not(:disabled) {
width: 90px;
}
.vacation-days-table__save {
white-space: nowrap;
}
.vacation-days-table__save:not(:disabled):hover {
background-color: var(--c-accent-hover);
}
.vacation-days-table__save:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.vacation-days-table__empty {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
/* Planning Mode Selector */
.planning-mode-selector__option {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.planning-mode-selector__option:last-child {
margin-bottom: 0;
}
/* Fieldsets */
fieldset {
border: 1px solid var(--c-border);
@@ -652,18 +591,20 @@ legend {
margin-left: calc(var(--sp-2) * -1);
}
/* Input Components */
/* Input Components
*
* `ChildBirthdayInput` now builds its fields from `.field`/`.field__label`/
* `.field__control` (primitives.css) directly rather than its own
* `.child-birthday-input__*` set — that set is gone from here accordingly. */
.vacation-days-input,
.planning-goal-selector,
.parent-profile-input,
.child-birthday-input {
.parent-profile-input {
margin-bottom: var(--sp-5);
}
.vacation-days-input__label,
.planning-goal-selector__label,
.parent-profile-input__label,
.child-birthday-input__label {
.parent-profile-input__label {
display: block;
margin-bottom: var(--sp-2);
font-weight: 500;
@@ -673,15 +614,13 @@ legend {
.vacation-days-input__field,
.planning-goal-selector__select,
.parent-profile-input__field,
.child-birthday-input__field {
.parent-profile-input__field {
width: 100%;
max-width: 400px;
}
.vacation-days-input__range,
.parent-profile-input__help,
.child-birthday-input__help {
.parent-profile-input__help {
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin-top: var(--sp-2);
@@ -772,33 +711,13 @@ legend {
margin-bottom: var(--sp-3);
}
.day-overrides-input__remove-button {
width: 100%;
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-sm);
background-color: var(--c-danger-weak);
color: var(--c-danger);
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: background-color 0.2s;
}
.day-overrides-input__remove-button:hover:not(:disabled) {
background-color: var(--c-danger);
color: white;
}
.day-overrides-input__remove-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Plain section, not a bordered card — matches the rest of Settings (see
* layout.css's "sections separate by whitespace and a hairline rule, not a
* stack of bordered/shadowed cards" comment). Only a top hairline separates
* it from the list of existing exceptions above. */
.day-overrides-input__form {
padding: var(--sp-4);
background-color: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding-top: var(--sp-4);
border-top: 1px solid var(--c-border);
}
.day-overrides-input__form h3 {
@@ -808,24 +727,8 @@ legend {
}
.day-overrides-input__form-group {
margin-bottom: var(--sp-4);
}
.day-overrides-input__form-group label {
display: block;
margin-bottom: var(--sp-2);
font-weight: 500;
color: var(--c-text);
font-size: var(--fs-base);
}
.day-overrides-input__form-input {
width: 100%;
max-width: 400px;
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius);
font-size: var(--fs-base);
margin-bottom: var(--sp-4);
}
.day-overrides-input__radio-group {
@@ -863,20 +766,6 @@ legend {
color: var(--c-text-muted);
}
.day-overrides-input__submit-button {
width: 100%;
padding: var(--sp-3) var(--sp-4);
font-weight: 500;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: background-color 0.2s;
}
.day-overrides-input__submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Red Days List (§3.8 — statutory red days + workspace overrides, per year) */
.red-days-list__pagination {
@@ -886,20 +775,6 @@ legend {
margin-bottom: var(--sp-4);
}
.red-days-list__nav-button {
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius);
background-color: var(--c-surface);
color: var(--c-text);
cursor: pointer;
}
.red-days-list__nav-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.red-days-list__range {
font-weight: 600;
}
@@ -943,22 +818,6 @@ legend {
color: var(--c-text);
}
.red-days-list__override-badge {
padding: var(--sp-1) var(--sp-2);
border-radius: 999px;
font-size: var(--fs-sm);
}
.red-days-list__override-badge--working {
background-color: var(--c-danger-weak);
color: var(--c-danger);
}
.red-days-list__override-badge--free {
background-color: var(--c-success-weak);
color: var(--c-success);
}
.red-days-list__effective {
margin-left: auto;
font-size: var(--fs-sm);
@@ -1016,32 +875,13 @@ legend {
color: var(--c-text);
}
/* Sized down from the standard --control-h .btn--icon square — this dismiss
* glyph sits inline in a card header, not in a toolbar. */
.tip-card__dismiss {
flex-shrink: 0;
width: 24px;
height: 24px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--c-text-muted);
border: none;
border-radius: var(--radius-sm);
font-size: var(--fs-base);
line-height: 1;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.tip-card__dismiss:hover {
background-color: rgba(0, 0, 0, 0.06);
color: var(--c-text);
}
.tip-card__dismiss:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
.tip-card__body {
@@ -1049,33 +889,3 @@ legend {
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
/* Responsive */
@media (max-width: 640px) {
[data-testid='metrics-list'] {
grid-template-columns: 1fr;
}
.form-group input,
.form-group select,
.form-group textarea,
.vacation-days-input__field,
.planning-goal-selector__select,
.parent-profile-input__field,
.child-birthday-input__field {
max-width: none;
}
.form-grid {
grid-template-columns: 1fr;
}
.days-breakdown__table {
font-size: var(--fs-sm);
}
.days-breakdown__table th,
.days-breakdown__table td {
padding: var(--sp-2) var(--sp-3);
}
}
+91 -67
View File
@@ -1,6 +1,6 @@
/* Component styles - must come first */
@import './primitives.css';
@import './components.css';
@import './modals.css';
@import './responsive.css';
/* Design Tokens
@@ -52,6 +52,19 @@
--overlay: rgba(16, 24, 40, 0.5);
/* Translucent tints used only for subtle hover/shadow/scrim effects — kept
* as tokens (rather than inlined rgba() elsewhere) because the style
* guard test (styles.test.ts) forbids rgba() outside this file. */
--c-row-hover: rgba(47, 111, 79, 0.02);
--c-accent-tint: rgba(47, 111, 79, 0.04);
--c-accent-shadow: rgba(47, 111, 79, 0.12);
--c-scrim-strong: rgba(0, 0, 0, 0.4);
--c-band-shadow: rgba(0, 0, 0, 0.1);
--c-label-text-shadow: rgba(0, 0, 0, 0.35);
--c-fill-light: rgba(255, 255, 255, 0.35);
--c-badge-fill: rgba(255, 255, 255, 0.3);
--c-scrim-soft: rgba(0, 0, 0, 0.2);
/* Spacing scale (8px base) */
--sp-1: 4px;
--sp-2: 8px;
@@ -62,6 +75,8 @@
--sp-8: 48px;
/* Type scale */
--fs-5xs: 0.5rem;
--fs-4xs: 0.5625rem;
--fs-2xs: 0.6875rem;
--fs-xs: 0.75rem;
--fs-sm: 0.875rem;
@@ -85,6 +100,16 @@
/* Content width */
--maxw: 960px;
/* Interaction chrome shared by every focusable control */
--c-focus-ring: rgba(47, 111, 79, 0.32);
--fs-3xs: 0.625rem;
--control-h: 40px;
/* Icon glyph sizes (spinner, etc.) — a separate scale from --fs-* since
* these size a single glyph, not running text. */
--icon-lg: 48px;
--icon-md: 36px;
color-scheme: light;
}
@@ -250,36 +275,42 @@ a:focus-visible {
border-radius: 2px;
}
/* Buttons - base */
/* Buttons - base
*
* A plain reset only — no color, no padding beyond zero. Every visible
* button opts into a look via a `.btn` variant (see primitives.css) so a
* semantic element that happens to be a `<button>` (a card, a chip, a day
* cell) never inherits a color scheme it didn't ask for. */
button {
font-family: inherit;
font-size: var(--fs-base);
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
border: none;
border-radius: var(--radius);
padding: var(--sp-3) var(--sp-4);
transition: background-color 0.2s, color 0.2s, box-shadow 0.2s;
}
button:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
border-radius: 0;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Inputs & Selects */
input,
/* Inputs & Selects
*
* Matches `.field__control` in primitives.css (same border/radius/padding/
* focus formula) so an unclassed input still looks right. Checkboxes and
* radios are excluded — they keep their native intrinsic size and just pick
* up the accent color, not the text-field chrome. */
input:not([type='checkbox']):not([type='radio']),
select,
textarea {
width: 100%;
min-height: var(--control-h);
font-family: inherit;
font-size: var(--fs-base);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-sm);
padding: var(--sp-2) var(--sp-3);
background: var(--c-surface);
color: var(--c-text);
transition: border-color 0.2s, box-shadow 0.2s;
@@ -290,7 +321,17 @@ select:focus,
textarea:focus {
outline: none;
border-color: var(--c-accent);
box-shadow: 0 0 0 3px rgba(47, 111, 79, 0.1);
box-shadow: 0 0 0 3px var(--c-focus-ring);
}
/* Same crisp keyboard-focus outline as `.field__control:focus-visible`
* (primitives.css) for an unclassed input/select/textarea — see that rule's
* comment. */
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
input:disabled,
@@ -300,6 +341,36 @@ textarea:disabled {
cursor: not-allowed;
}
input[type='checkbox'],
input[type='radio'] {
width: auto;
accent-color: var(--c-accent);
cursor: pointer;
}
/* Number inputs — hide the native up/down spinner everywhere. It doubles
* the field's effective click target with no matching `.field__control`
* affordance and its width isn't accounted for by any of this app's `ch`-
* based sizing. Callers still get numeric-only mobile keyboards via
* `inputmode="numeric"` on the element itself. */
input[type='number'] {
appearance: textfield;
}
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
/* Date inputs — same height/padding/font as `.field__control` by default
* (the shared `input:not([type=checkbox]):not([type=radio])` rule above
* already covers it), just with a visibly clickable calendar affordance. */
input[type='date']::-webkit-calendar-picker-indicator {
opacity: 0.6;
cursor: pointer;
}
/* Label */
label {
display: block;
@@ -329,14 +400,7 @@ td {
}
tr:hover td {
background-color: rgba(47, 111, 79, 0.02);
}
/* Section container */
main {
max-width: var(--maxw);
margin: 0 auto;
padding: var(--sp-5);
background-color: var(--c-row-hover);
}
/* Utility classes for form */
@@ -347,43 +411,3 @@ main {
.form-group label {
margin-bottom: var(--sp-2);
}
/* Message boxes */
.error-message {
padding: var(--sp-3) 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-4);
}
.success-message {
padding: var(--sp-3) var(--sp-4);
background-color: var(--c-success-weak);
color: var(--c-success);
border-left: 4px solid var(--c-success);
border-radius: var(--radius);
margin-bottom: var(--sp-4);
}
.warning-message {
padding: var(--sp-3) var(--sp-4);
background-color: var(--c-warning-weak);
color: var(--c-warning);
border-left: 4px solid var(--c-warning);
border-radius: var(--radius);
margin-bottom: var(--sp-4);
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
-207
View File
@@ -1,207 +0,0 @@
/* Modal Overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(16, 24, 40, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
/* Modal Content */
.modal-content {
background-color: var(--c-surface);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
padding: var(--sp-6);
}
.modal-content h2 {
margin-top: 0;
}
.modal-content h3 {
margin-top: var(--sp-5);
}
/* Modal Header */
.modal__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--sp-5);
}
.modal__title {
font-size: var(--fs-xl);
font-weight: 700;
margin: 0;
}
.modal__close {
background: none;
border: none;
color: var(--c-text);
font-size: var(--fs-lg);
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
transition: background-color 0.2s;
}
.modal__close:hover {
background-color: var(--c-bg);
}
.modal__close:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
/* Modal Body */
.modal__body {
margin-bottom: var(--sp-5);
}
/* Modal Footer */
.modal__footer {
display: flex;
gap: var(--sp-3);
justify-content: flex-end;
padding-top: var(--sp-4);
border-top: 1px solid var(--c-border);
}
.modal__footer button {
min-width: 100px;
}
/* QRCode Modal */
.qrcode-modal__content {
text-align: center;
}
.qrcode-modal__qr {
margin: var(--sp-5) 0;
display: flex;
justify-content: center;
}
.qrcode-modal__qr img {
max-width: 300px;
width: 100%;
height: auto;
border: 1px solid var(--c-border);
border-radius: var(--radius);
background-color: var(--c-bg);
padding: var(--sp-2);
}
.qrcode-modal__status {
padding: var(--sp-4);
background-color: var(--c-bg);
border-radius: var(--radius);
margin-bottom: var(--sp-4);
font-size: var(--fs-sm);
}
.qrcode-modal__status--pending {
background-color: var(--c-warning-weak);
color: var(--c-warning);
}
.qrcode-modal__status--success {
background-color: var(--c-success-weak);
color: var(--c-success);
}
.qrcode-modal__status--error {
background-color: var(--c-danger-weak);
color: var(--c-danger);
}
.qrcode-modal__error {
padding: var(--sp-3) 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-4);
font-size: var(--fs-sm);
}
/* Loading Spinner */
[data-testid='loading-spinner'] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--sp-8);
gap: var(--sp-4);
}
[data-testid='spinner-icon'] {
font-size: 48px;
color: var(--c-accent);
animation: spin 2s linear infinite;
}
[data-testid='spinner-message'] {
font-size: var(--fs-base);
color: var(--c-text-muted);
margin: 0;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Reduce Motion */
@media (prefers-reduced-motion: reduce) {
[data-testid='spinner-icon'] {
animation: none;
}
.modal-content {
animation: none;
}
}
/* Responsive */
@media (max-width: 640px) {
.modal-content {
width: 95%;
padding: var(--sp-4);
max-height: 95vh;
}
.qrcode-modal__qr img {
max-width: 250px;
}
.modal__footer {
flex-direction: column-reverse;
}
.modal__footer button {
width: 100%;
}
}
+433
View File
@@ -0,0 +1,433 @@
/* Design-system primitives
*
* Every button, card, choice-card, form field, alert, badge, eyebrow and
* modal shell in the app is built from the classes below — defined ONCE
* here instead of once per feature/page. See AGENTS.md's "fix the shared
* rule once" guidance. Feature CSS files (calendar.css, header.css,
* preset-rail.css, ...) keep only the rules that are genuinely specific to
* that feature (e.g. the calendar day-cell grid, the QR image box).
*/
/* Buttons
* ------------------------------------------------------------------------
* The bare `button` element (see global.css) is a plain reset — no color,
* no chrome. Every visible button in the app opts into one of these
* variants explicitly, so a semantic element that happens to be a
* `<button>` (a card, a chip, an icon) never inherits button colors it
* didn't ask for.
*/
.btn {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
min-height: var(--control-h);
border-radius: var(--radius);
font-size: var(--fs-sm);
font-weight: 500;
line-height: 1.2;
border: 1px solid transparent;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
}
.btn:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--primary {
background-color: var(--c-accent);
color: var(--c-surface);
}
.btn--primary:hover:not(:disabled) {
background-color: var(--c-accent-hover);
}
.btn--primary:active:not(:disabled) {
background-color: var(--c-accent-active);
}
.btn--secondary {
background-color: var(--c-surface);
border-color: var(--c-border);
color: var(--c-text);
}
.btn--secondary:hover:not(:disabled) {
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
}
.btn--ghost {
background-color: transparent;
color: var(--c-text-muted);
}
.btn--ghost:hover:not(:disabled) {
color: var(--c-text);
background-color: var(--c-accent-weak);
}
.btn--danger {
background-color: var(--c-danger);
color: var(--c-surface);
}
.btn--danger:hover:not(:disabled) {
background-color: var(--c-danger-hover);
}
.btn--link {
background-color: transparent;
padding: 0;
min-height: 0;
border: 0;
color: var(--c-text-muted);
text-decoration: underline;
}
.btn--link:hover:not(:disabled) {
color: var(--c-text);
}
.btn--sm {
padding: var(--sp-1) var(--sp-3);
min-height: 32px;
font-size: var(--fs-xs);
}
.btn--icon {
width: var(--control-h);
height: var(--control-h);
padding: 0;
justify-content: center;
background-color: transparent;
color: var(--c-text-muted);
}
.btn--icon:hover:not(:disabled) {
background-color: var(--c-accent-weak);
color: var(--c-text);
}
.btn--block {
display: flex;
width: 100%;
justify-content: center;
}
/* Pins a button (typically a destructive one) to the start of a
* `.modal__footer` row, separating it from the grouped Cancel/Save pair. */
.btn--start {
margin-right: auto;
}
/* Cards
* ------------------------------------------------------------------------ */
.card {
background-color: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: var(--sp-5);
}
.card--tinted {
background-color: var(--c-accent-weak);
border-color: var(--c-accent-weak);
}
.card--dashed {
border-style: dashed;
}
/* Choice card — a card that is also a clickable, single-select choice (the
* onboarding solo/couple picker). Built on the same chrome as `.card`. */
.choice-card {
display: block;
width: 100%;
background-color: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: var(--sp-5);
text-align: left;
color: var(--c-text);
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
}
.choice-card:hover {
border-color: var(--c-accent);
}
.choice-card:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.choice-card[aria-checked='true'] {
border-color: var(--c-accent);
box-shadow: 0 0 0 1px var(--c-accent);
background-color: var(--c-accent-weak);
}
.choice-card__title {
margin: 0 0 var(--sp-1) 0;
font-size: var(--fs-lg);
font-weight: 600;
color: var(--c-text);
}
.choice-card__text {
margin: 0;
color: var(--c-text-muted);
}
/* Form fields
* ------------------------------------------------------------------------ */
.field {
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.field__label {
font-size: var(--fs-sm);
font-weight: 500;
color: var(--c-text);
}
.field__control {
width: 100%;
min-height: var(--control-h);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background-color: var(--c-surface);
font-size: var(--fs-base);
color: var(--c-text);
transition: border-color 0.2s, box-shadow 0.2s;
}
.field__control:focus {
outline: none;
border-color: var(--c-accent);
box-shadow: 0 0 0 3px var(--c-focus-ring);
}
/* Keyboard focus gets a crisp solid outline on top of the soft mouse-focus
* box-shadow above — matching `.btn:focus-visible`/`.choice-card:focus-visible`
* elsewhere in this file. The box-shadow ring alone reads as faint against
* the light surface/border palette; text inputs need the same visible ring
* every other focusable control gets. */
.field__control:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
.field__control[aria-invalid='true'] {
border-color: var(--c-danger);
}
.field__helper {
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.field__error {
font-size: var(--fs-xs);
color: var(--c-danger);
}
.field__row {
display: flex;
gap: var(--sp-3);
flex-wrap: wrap;
}
/* Alerts
* ------------------------------------------------------------------------ */
.alert {
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius);
border: 1px solid transparent;
font-size: var(--fs-sm);
}
.alert--error {
background-color: var(--c-danger-weak);
border-color: var(--c-danger);
color: var(--c-danger);
}
.alert--success {
background-color: var(--c-success-weak);
border-color: var(--c-success);
color: var(--c-success);
}
.alert--warning {
background-color: var(--c-warning-weak);
border-color: var(--c-warning);
color: var(--c-warning);
}
.alert--info {
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
color: var(--c-accent);
}
/* Badges & eyebrows
* ------------------------------------------------------------------------ */
.badge {
display: inline-flex;
align-items: center;
padding: 2px var(--sp-2);
border-radius: var(--radius-full);
font-size: var(--fs-xs);
font-weight: 500;
line-height: 1.4;
}
.badge--accent {
background-color: var(--c-accent-weak);
color: var(--c-accent);
}
.badge--muted {
background-color: var(--c-bg);
color: var(--c-text-muted);
border: 1px solid var(--c-border);
}
.badge--warning {
background-color: var(--c-warning-weak);
color: var(--c-warning);
}
.badge--danger {
background-color: var(--c-danger-weak);
color: var(--c-danger);
}
.eyebrow {
font-size: var(--fs-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--c-text-muted);
}
/* Modal shell
* ------------------------------------------------------------------------
* Shared by PeriodPopover, PlannedDayModal, ParentBalanceForm and
* QRCodeModal so every modal's overlay/header/body/footer share one
* formula. Each modal's own CSS file keeps only its content-specific rules
* (fields, QR image box, status text, ...).
*/
.modal__overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-4);
background-color: var(--overlay);
}
.modal__content {
display: flex;
flex-direction: column;
width: min(100%, var(--modal-w, 520px));
max-height: 85vh;
overflow: hidden;
background-color: var(--c-surface);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
}
.modal__content--md {
--modal-w: 560px;
}
.modal__content--sm {
--modal-w: 480px;
}
.modal__content--xs {
--modal-w: 420px;
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--c-border);
}
.modal__header h2 {
margin: 0;
font-size: var(--fs-xl);
font-weight: 700;
}
.modal__body {
flex: 1;
overflow-y: auto;
padding: var(--sp-5);
}
.modal__footer {
display: flex;
flex-wrap: wrap;
align-items: stretch;
justify-content: flex-end;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-5);
border-top: 1px solid var(--c-border);
background-color: var(--c-bg);
}
/* Inline delete-confirmation row: swaps a footer's normal button set for a
* warning message plus Cancel/confirm, staying the same height as the row
* it replaces. */
.modal__delete-confirm {
display: flex;
align-items: stretch;
flex-wrap: wrap;
gap: var(--sp-3);
width: 100%;
}
.modal__delete-confirm-text {
display: flex;
align-items: center;
margin-right: auto;
font-size: var(--fs-sm);
color: var(--c-text);
}
/* Layout helpers
* ------------------------------------------------------------------------ */
.stack {
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--sp-3);
}
+130 -76
View File
@@ -1,4 +1,9 @@
/* S7 - Responsive & Accessibility */
/* S7 - Responsive & Accessibility
*
* The ONLY file in the app allowed to contain @media (enforced by
* styles.test.ts) — every viewport/motion/contrast override lives here so
* there is one place to check for breakpoints instead of one per feature
* file. */
/* Mobile - Small screens (max 640px) */
@media (max-width: 640px) {
@@ -7,29 +12,63 @@
padding: var(--sp-3) var(--sp-4);
}
.header__top {
margin-bottom: var(--sp-3);
.header__bar {
row-gap: var(--sp-3);
}
/* #5 — reorders the same four groups (brand/nav/identity/logout — no
* markup change, just `order`) into: brand + logout on row 1, the nav as
* a full-width row of three equal buttons, then the child select
* full-width below. The parent name is dropped at this width — the child
* select alone already identifies "whose plan this is" without a second,
* name-only line competing for the same row. */
.header__brand {
order: 1;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--fs-base);
}
.header__logout {
order: 2;
flex-shrink: 0;
}
.header__nav {
order: 3;
flex: 0 0 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-2);
}
.header__nav-link {
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-sm);
}
.header__title {
font-size: var(--fs-base);
width: 100%;
justify-content: center;
}
.header__identity {
gap: var(--sp-2);
font-size: var(--fs-sm);
order: 4;
flex: 0 0 100%;
margin-left: 0;
}
/* Main content */
.header__child-select {
width: 100%;
max-width: none;
}
.header__parent-name {
display: none;
}
/* Main content — the container picks up the horizontal gutter; sections
* themselves stay at zero horizontal padding at every width (see
* layout.css's `.dashboard__section` etc. base rule) so this must only
* ever add top spacing/margin here, never re-introduce side padding. */
[data-testid='layout-main'] {
padding: var(--sp-4);
}
@@ -38,21 +77,24 @@
.settings-page__section,
.calendar__section,
.tips-page__section {
padding: var(--sp-4);
padding-top: var(--sp-4);
margin-bottom: var(--sp-4);
}
/* Metrics - single column */
.overview-metrics__list {
.overview-metrics__list,
[data-testid='metrics-list'] {
grid-template-columns: 1fr;
}
/* Forms - full width */
.form-group input,
.form-group select,
.form-group textarea {
.form-group textarea,
.vacation-days-input__field,
.planning-goal-selector__select,
.parent-profile-input__field {
max-width: none;
width: 100%;
}
.form-grid {
@@ -84,45 +126,33 @@
width: 70px;
}
/* Modals */
.qrcode-modal {
width: 95%;
padding: var(--sp-4);
max-height: 95vh;
/* Modals — the overlay already gutters the content with its own padding
* and `.modal__content`'s `width: min(100%, var(--modal-w))` already caps
* it to the viewport, so only the footer's button stacking needs a
* mobile-specific rule. */
.modal__footer {
flex-direction: column-reverse;
}
.modal__footer .btn {
width: 100%;
}
.qrcode-modal__qr-image {
max-width: 250px;
}
.qrcode-modal__button-group {
flex-direction: column-reverse;
}
.qrcode-modal__button {
/* Delete-confirmation row: the warning text gets its own line, and the
* two buttons split the row evenly (still equal height, still no
* mid-phrase wrapping) rather than stacking into three separate rows. */
.modal__delete-confirm-text {
width: 100%;
margin-right: 0;
}
/* Footer */
footer {
padding: var(--sp-4);
font-size: var(--fs-sm);
}
/* Button group */
.modal__footer {
flex-direction: column-reverse;
}
.modal__footer button {
width: 100%;
}
/* Remove max-width on modal content */
.modal-content {
width: 95%;
padding: var(--sp-4);
max-height: 95vh;
.modal__delete-confirm .btn {
flex: 1;
min-width: 0;
}
/* Lists */
@@ -139,7 +169,7 @@
}
.loading-spinner__icon {
font-size: 36px;
font-size: var(--icon-md);
}
/* Calendar responsiveness */
@@ -156,17 +186,61 @@
font-size: var(--fs-sm);
}
/* Pre-existing bug this pass also fixes: `.calendar__layout` never
* stacked, so the fixed-width preset rail sat on top of Friday/Saturday/
* Sunday's columns instead of beside them once the month grid had no room
* left. The rail moves below the grid and goes full-width. */
/* #4 — at 390px the month/year selects (each ~12ch/7ch min-width) plus the
* two prev/next icon buttons are tight against the header's title font
* size; dropping the select text down one step keeps the whole header row
* comfortably inside the viewport (it still wraps cleanly via
* `.calendar__title`'s `flex-wrap` if a locale's month name is wider). */
.calendar__month-select,
.calendar__year-select {
font-size: var(--fs-lg);
}
/* The fixed-width preset rail sits beside the month grid on desktop; on
* mobile the layout stacks and the rail goes full-width below the grid.
* `align-items: stretch` overrides the base rule's `flex-start` (there to
* top-align the rail next to the grid in the row layout) — without it, a
* column-direction flex item is sized by its own shrink-to-fit content
* width instead of stretching to the container's width. `.calendar__main`
* (a CSS Grid with a fixed 50px week-number column) then sized itself off
* its grid tracks' max-content width — well past the viewport — instead
* of the actual space available, which is exactly what caused the
* mobile horizontal-scroll bug once a band's content gave those tracks
* something to size around. */
.calendar__layout {
flex-direction: column;
align-items: stretch;
}
.preset-rail {
width: 100%;
}
/* A horizontally-scrollable row of presets, not a stack — keeps the rail
* from pushing the month grid far down the page on a short viewport,
* without ever growing the page itself wider than the viewport (the
* scrolling is contained to this row, see `overflow-x` below). */
.preset-rail__buttons {
flex-direction: row;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: var(--sp-1);
}
.preset-rail__button {
flex: 0 0 auto;
min-width: 65vw;
scroll-snap-align: start;
}
/* Footer — same side gutter as [data-testid='layout-main'] at this width. */
footer {
padding: var(--sp-4) 0;
}
.footer__inner {
padding: 0 var(--sp-4);
}
}
/* Tablet - Medium screens (641px to 1024px) */
@@ -184,7 +258,11 @@
}
}
/* Accessibility - Reduced Motion */
/* Accessibility - Reduced Motion
*
* The universal `*` rule collapses every transition/animation duration to
* near-zero already; there is deliberately no second, narrower list of
* selectors repeating that on top of it. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -194,20 +272,6 @@
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.loading-spinner__icon {
animation: none;
}
.header__nav-link,
.header__logout-button,
.header__child-select,
input,
select,
textarea,
button {
transition: none;
}
}
/* High Contrast Mode */
@@ -237,13 +301,3 @@
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
/* High Contrast Focus */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
/**
* Style guard — a consolidation regression test.
*
* The app used to have five button systems, five modal chromes, seven
* error-box styles and colors/sizes inlined ad hoc across feature CSS files
* instead of the shared tokens in `global.css` / primitives in
* `primitives.css`. These assertions keep it from drifting back: new CSS
* must reuse the existing tokens and the single `responsive.css` file for
* breakpoints, rather than hand-rolling another one-off.
*/
const SRC_ROOT = path.resolve(__dirname, '..')
function findCssFiles(dir: string): string[] {
const out: string[] = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
out.push(...findCssFiles(full))
} else if (entry.isFile() && entry.name.endsWith('.css')) {
out.push(full)
}
}
return out
}
const cssFiles = findCssFiles(SRC_ROOT)
const GLOBAL_CSS = path.join(SRC_ROOT, 'styles', 'global.css')
const RESPONSIVE_CSS = path.join(SRC_ROOT, 'styles', 'responsive.css')
// The universal reduced-motion reset in responsive.css is the one place
// `!important` is load-bearing: it must beat every component's own
// transition/animation declaration regardless of that selector's own
// specificity, which is exactly what `prefers-reduced-motion` requires.
// Every other `!important` in the codebase existed only to win button-skin
// specificity fights that no longer exist post-consolidation.
const REDUCED_MOTION_BLOCK = /@media \(prefers-reduced-motion: reduce\)\s*{[^}]*{[^}]*}\s*}/
// `@media (prefers-contrast: more)` is the only place allowed to redefine
// `--c-text`/`--c-accent`/`--c-border` as literal hex — a token can't
// reference "the high-contrast version of itself", and this override can
// only live in responsive.css (the one file allowed an `@media` at all).
const HIGH_CONTRAST_BLOCK = /@media \(prefers-contrast: more\)\s*{[\s\S]*?\n}\n/
function stripDocumentedExceptions(file: string, content: string): string {
if (file !== RESPONSIVE_CSS) return content
return content.replace(REDUCED_MOTION_BLOCK, '').replace(HIGH_CONTRAST_BLOCK, '')
}
describe('style guard', () => {
it('found the expected CSS files', () => {
expect(cssFiles.length).toBeGreaterThan(5)
})
it('keeps hex colors out of feature CSS — only global.css defines the palette', () => {
const offenders: string[] = []
for (const file of cssFiles) {
if (file === GLOBAL_CSS) continue
const content = stripDocumentedExceptions(file, fs.readFileSync(file, 'utf-8'))
if (/#[0-9a-fA-F]{3,8}\b/.test(content)) {
offenders.push(path.relative(SRC_ROOT, file))
}
}
expect(offenders).toEqual([])
})
it('has no !important outside the documented reduced-motion reset', () => {
const offenders: string[] = []
for (const file of cssFiles) {
const content = stripDocumentedExceptions(file, fs.readFileSync(file, 'utf-8'))
if (content.includes('!important')) {
offenders.push(path.relative(SRC_ROOT, file))
}
}
expect(offenders).toEqual([])
})
it('keeps every @media rule in responsive.css', () => {
const offenders: string[] = []
for (const file of cssFiles) {
if (file === RESPONSIVE_CSS) continue
const content = fs.readFileSync(file, 'utf-8')
if (/@media/.test(content)) {
offenders.push(path.relative(SRC_ROOT, file))
}
}
expect(offenders).toEqual([])
})
it('keeps hardcoded pixel font-sizes out of feature CSS — use the --fs-* scale', () => {
const offenders: string[] = []
for (const file of cssFiles) {
if (file === GLOBAL_CSS) continue
const content = fs.readFileSync(file, 'utf-8')
if (/font-size:\s*\d+px/.test(content)) {
offenders.push(path.relative(SRC_ROOT, file))
}
}
expect(offenders).toEqual([])
})
it('keeps rgba() out of feature CSS — use a token from global.css', () => {
const offenders: string[] = []
for (const file of cssFiles) {
if (file === GLOBAL_CSS) continue
const content = fs.readFileSync(file, 'utf-8')
if (content.includes('rgba(')) {
offenders.push(path.relative(SRC_ROOT, file))
}
}
expect(offenders).toEqual([])
})
})