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:
+111
-301
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user