Style Guide - steilerDev/cornerstone GitHub Wiki
This page is the canonical reference for the Cornerstone design system. It documents every design token, component pattern, and visual convention in use. All new UI work must follow these specifications.
Managed by: ux-designer agent
Token source: client/src/styles/tokens.css
- Design Token Architecture
- Color Palette (Layer 1)
- Semantic Tokens (Layer 2)
- Shadow Scale
- Typography
- Spacing
- Border Radius
- Transitions
- Z-Index Scale
- Component Patterns
- Shared Components
- Dark Mode Implementation Guide
- Brand Identity
- Accepted UX Deviations
Cornerstone uses a 3-layer token system defined in client/src/styles/tokens.css.
Layer 1 — Palette Raw named color values (hex) — source of truth
↓
Layer 2 — Semantic Purpose-driven aliases using var() references to Layer 1
↓
Layer 3 — Dark mode Scoped overrides on [data-theme="dark"] — replaces Layer 2 values only
Rules:
-
Always reference Layer 2 semantic tokens in component CSS Modules (
var(--color-bg-primary)) - Never use raw hex values in
.module.cssfiles — zero exceptions - Only use Layer 1 palette tokens directly when no semantic alias covers the use case
- Layer 3 requires no changes to component code — theming is automatic via CSS cascade
Verification command — must return zero results:
grep -rn '#[0-9a-fA-F]' client/src --include="*.module.css"These are raw color values. Reference these only through Layer 2 semantic tokens in component CSS.
| Token | Hex | Usage |
|---|---|---|
--color-white |
#ffffff |
Pure white |
--color-black |
#000000 |
Pure black |
--color-gray-50 |
#f9fafb |
Near-white backgrounds |
--color-gray-100 |
#f3f4f6 |
Subtle backgrounds, tertiary surfaces |
--color-gray-200 |
#e5e7eb |
Default borders |
--color-gray-300 |
#d1d5db |
Strong borders, dividers |
--color-gray-400 |
#9ca3af |
Placeholder text, disabled text |
--color-gray-500 |
#6b7280 |
Muted text (legacy), source-0 dot |
--color-gray-550 |
#5f6674 |
Muted / placeholder / quoted text (light, AA on bg-page, primary, secondary, tertiary) |
--color-gray-600 |
#4b5563 |
Subtle text |
--color-gray-700 |
#374151 |
Secondary text, sidebar hover |
--color-gray-800 |
#1f2937 |
Sidebar background (light mode), inverse bg |
--color-gray-900 |
#111827 |
Primary text, darkest surface |
| Token | Hex | Usage |
|---|---|---|
--color-blue-100 |
#dbeafe |
Primary background tint |
--color-blue-200 |
#bfdbfe |
Primary background tint (hover) |
--color-blue-400 |
#60a5fa |
Sidebar focus ring |
--color-blue-500 |
#3b82f6 |
Focus ring base, dark primary active, favicon |
--color-blue-600 |
#2563eb |
Light primary, focus border, active nav item (white text 5.17:1) |
--color-blue-700 |
#1d4ed8 |
Light primary hover |
--color-blue-800 |
#1e40af |
Light primary active, badge text on light bg |
| Token | Hex | Usage |
|---|---|---|
--color-red-50 |
#fef2f2 |
Danger background (very light) |
--color-red-100 |
#fee2e2 |
Danger background, blocked badge bg |
--color-red-200 |
#fecaca |
Danger border |
--color-red-400 |
#ef4444 |
Danger input border |
--color-red-350 |
#f87171 |
Dark danger (new in #2200) |
--color-red-500 |
#dc2626 |
Focus-ring-danger base |
--color-red-600 |
#b91c1c |
Light danger |
--color-red-700 |
#991b1b |
Light danger hover, badge text |
--color-red-800 |
#7f1d1d |
Light danger active (new in #2200) |
Note: these values map to a mix of Tailwind's green and emerald scales.
| Token | Hex | Usage |
|---|---|---|
--color-green-50 |
#f0fdf4 |
Success background (very light) |
--color-green-100 |
#d1fae5 |
Emerald-100; completed badge bg, success badge bg |
--color-green-150 |
#dcfce7 |
Green-100 (non-standard step); user active badge bg |
--color-green-200 |
#bbf7d0 |
Success border |
--color-green-500 |
#10b981 |
Dark success hover |
--color-green-600 |
#059669 |
Legacy success (no longer --color-success) |
--color-green-650 |
#047857 |
Light success (white text 5.48:1; new in #2200) |
--color-green-700 |
#166534 |
Success text on light bg, user active badge text |
--color-green-900 |
#065f46 |
Completed badge text |
| Token | Hex | Usage |
|---|---|---|
--color-slate-950 |
#0c1424 |
Dark page background (--color-bg-page), new in #2200 |
--color-slate-900 |
#0f172a |
Dark sidebar |
--color-slate-600 |
#1e293b |
Dark cards (--color-bg-primary), sidebar hover |
--color-slate-550 |
#273449 |
Dark secondary / hover / inset surface, new in #2200 |
--color-slate-500 |
#334155 |
Dark tertiary surface, sidebar separator |
--color-slate-400 |
#475569 |
Dark default border, disabled text |
--color-slate-300 |
#64748b |
Dark strong border |
--color-slate-200 |
#94a3b8 |
Dark muted / body / placeholder text |
--color-slate-150 |
#cbd5e1 |
Dark secondary text |
--color-slate-100 |
#e2e8f0 |
Dark sidebar text |
--color-slate-50 |
#f1f5f9 |
Dark primary text |
Removed in #2200 (no consumers): --color-slate-800, --color-slate-700, --color-slate-250 and the semantic --color-sidebar-active. Do not reintroduce them; use --color-nav-active-bg / --color-nav-active-text for the active nav item.
Reference these tokens in all component CSS. They automatically adapt to dark mode via Layer 3 overrides.
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-bg-page |
#f9fafb (gray-50) |
#0c1424 (slate-950) |
Page canvas behind cards: body, .pageContent, auth pages, loading screen. Print resets it to #f9fafb in print.css. |
--color-bg-primary |
#ffffff (white) |
#1e293b (slate-600) |
Cards, panels, modals (sits on bg-page; dark cards reach 1.26:1 against the page) |
--color-bg-secondary |
#f9fafb (gray-50) |
#273449 (slate-550) |
Secondary panels, inset areas |
--color-bg-tertiary |
#f3f4f6 (gray-100) |
#334155 (slate-500) |
Code blocks, inset regions, tracks |
--color-bg-inverse |
#1f2937 (gray-800) |
#f3f4f6 (gray-100) |
Tooltips, dark callouts |
--color-bg-hover |
#f9fafb (gray-50) |
#273449 (slate-550) |
Row/item hover state |
Use --color-bg-page for the canvas and --color-bg-primary for cards. Never use --color-bg-primary as a page background.
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-text-primary |
#111827 (gray-900) |
#f1f5f9 (slate-50) |
Headings, prominent labels |
--color-text-secondary |
#374151 (gray-700) |
#cbd5e1 (slate-150) |
Sub-headings, labels |
--color-text-body |
#374151 (gray-700) |
#94a3b8 (slate-200) |
Body copy, descriptions |
--color-text-muted |
#5f6674 (gray-550) |
#94a3b8 (slate-200) |
Helper text, metadata. AA on bg-page, bg-primary, bg-secondary and bg-tertiary in both themes |
--color-text-subtle |
#4b5563 (gray-600) |
#94a3b8 (slate-200) |
Less-prominent secondary copy |
--color-text-inverse |
#ffffff (white) |
#111827 (gray-900) |
Text on dark/inverse surfaces |
--color-text-placeholder |
#5f6674 (gray-550) |
#94a3b8 (slate-200) |
Text only: input placeholders. Meets 4.5:1 (WCAG 1.4.3) |
--color-text-disabled |
#9ca3af (gray-400) |
#475569 (slate-400) |
Non-text only: disabled control text/borders (exempt from 1.4.3). Never use for readable text; use --color-text-placeholder or --color-text-muted
|
--color-text-quoted |
#5f6674 (gray-550) |
#94a3b8 (slate-200) |
Quoted (not-yet-final) amounts in quotation status. AA in both themes. Reserve for those monetary values |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-border |
#e5e7eb (gray-200) |
#475569 (slate-400) |
Default borders, dividers |
--color-border-strong |
#d1d5db (gray-300) |
#64748b (slate-300) |
Emphasized borders, table lines, input outlines |
--color-border-focus |
#2563eb (blue-600) |
#60a5fa (blue-400) |
Focused input border color |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-primary |
#2563eb (blue-600) |
#60a5fa (blue-400) |
Button background, links (white text 5.17:1) |
--color-primary-hover |
#1d4ed8 (blue-700) |
#93c5fd (blue-300) |
Button hover state |
--color-primary-active |
#1e40af (blue-800) |
#3b82f6 (blue-500) |
Button active/pressed |
--color-primary-text |
#ffffff |
#111827 |
Text on primary button |
--color-primary-bg |
#dbeafe |
rgba(59,130,246,0.15) |
Subtle primary tint (tags, etc.) |
--color-primary-bg-hover |
#bfdbfe |
rgba(59,130,246,0.25) |
Subtle primary tint hover |
--color-primary-badge-text |
#1e40af |
#93c5fd |
Text inside primary-tinted badges |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-danger |
#b91c1c (red-600) |
#f87171 (red-350) |
Destructive button, error icon |
--color-danger-hover |
#991b1b (red-700) |
#fca5a5 (red-300) |
Destructive button hover |
--color-danger-active |
#7f1d1d (red-800) |
#ef4444 (red-400) |
Destructive button pressed |
--color-danger-text |
#ffffff |
#111827 |
Text on danger button |
--color-danger-bg |
#fef2f2 |
rgba(239,68,68,0.1) |
Error banner background |
--color-danger-bg-strong |
#fee2e2 |
rgba(239,68,68,0.2) |
Error badge background |
--color-danger-border |
#fecaca |
rgba(239,68,68,0.3) |
Error banner border |
--color-danger-text-on-light |
#991b1b |
#fca5a5 |
Error text on light bg |
--color-danger-input-border |
#ef4444 |
#ef4444 |
Invalid input border |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-success |
#047857 (green-650) |
#34d399 (emerald-400) |
Success button, icon, checkmark (white text 5.48:1) |
--color-success-hover |
#166534 (green-700) |
#10b981 (green-500) |
Success button hover |
--color-success-bg |
#f0fdf4 |
rgba(16,185,129,0.1) |
Success banner background |
--color-success-border |
#bbf7d0 |
rgba(16,185,129,0.3) |
Success banner border |
--color-success-text-on-light |
#166534 |
#6ee7b7 |
Success text on light bg |
--color-success-badge-bg |
#d1fae5 |
rgba(16,185,129,0.15) |
Success badge background |
--color-success-badge-bg-alt |
#dcfce7 |
rgba(16,185,129,0.2) |
Alternate success badge background |
--color-success-badge-text |
#065f46 |
#a7f3d0 |
Success badge text |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-sidebar-bg |
#1f2937 (gray-800) |
#0f172a |
Sidebar panel background |
--color-sidebar-text |
#f9fafb (gray-50) |
#e2e8f0 |
Sidebar text and icons |
--color-sidebar-hover |
#374151 (gray-700) |
#1e293b |
Nav item hover background |
--color-sidebar-focus-ring |
#60a5fa (blue-400) |
#60a5fa |
Keyboard focus ring in sidebar |
--color-sidebar-separator |
#374151 (gray-700) |
#334155 |
Horizontal rule in sidebar |
--color-nav-active-bg |
#2563eb (blue-600) |
same | Active nav item background (defined once in :root, no dark override) |
--color-nav-active-text |
#ffffff |
same | Active nav item text, weight 600 (5.17:1 on the active background) |
--color-sidebar-active was removed in #2200.
The sidebar is rendered only from NavConfig (navSections(ctx)); no entry, label or path is written in the component. No new token and no new shared component.
Structure. Logo (opens Home, aria-label "Go to Home") · <nav aria-label="Main navigation"> with the primary group (Home, Tasks, Purchases, Site diary, Photos, Money, Companies), a .navSeparator, then the quiet group (Areas, History, Documents; each only when served, Documents only with Paperless) · pinned footer with a second <nav aria-label="Settings"> holding the Settings entry (opens Project setup), and, from 1024px, nothing else (#2206 moved theme, Log out, version and the GitHub link to the user menu). Below 1024px the sidebar is not mounted; the same actions live in the More sheet's user block (see Phone and tablet shell (#2207)), so exactly one control per action is reachable at any width. Each group is a <ul>; a section with views nests a <ul> inside its <li>. Entries that are not served or not allowed for the role are not rendered (removed, never disabled).
Nested views. They render only while the current route belongs to the section. No chevron, no toggle, no collapsed-but-focusable rows: the section entry is a plain link to its main view ("Tasks" always opens the list; Schedule is a view under Tasks, not an entry of its own). The main view is the section entry itself and is not repeated as a nested row.
| Element | Recipe |
|---|---|
Top-level link (.navLink) |
padding var(--spacing-3) var(--spacing-6), --font-size-base
|
Quiet link (.navLinkQuiet) |
same padding, --font-size-sm, no opacity (it would cut contrast); the quiet look comes from the separator and the smaller size |
Nested link (.navLinkNested) |
padding var(--spacing-2) var(--spacing-6) var(--spacing-2) var(--spacing-12), --font-size-sm
|
| Hover |
--color-sidebar-hover, transition: background-color var(--transition-medium)
|
| Focus-visible |
2px solid var(--color-sidebar-focus-ring), outline-offset: -2px (not --shadow-focus, which clips on edge-to-edge dark rows) |
Active (.active) |
--color-nav-active-bg / --color-nav-active-text, --font-weight-semibold, plus box-shadow: inset 3px 0 0 var(--color-sidebar-focus-ring) as a second cue (the fill alone is about 2.7:1 against the light sidebar); aria-current="page"
|
In-section parent (.sectionCurrent) |
--font-weight-semibold only: no fill, no aria-current
|
Single highlighted entry. Exactly one element carries .active and aria-current="page" on every page. The entry is resolved from the route map (the matched route and its parent chain, then the section that owns the route), never from NavLink prefix matching, which double-highlights. A detail page highlights its list view (a task highlights Tasks; a milestone highlights Milestones; an invoice highlights Invoices). Legacy and interim aliases resolve to their section (/project/overview highlights Home, /settings/vendors highlights Companies).
404, No access and unknown routes. Nothing is highlighted: no .active, no .sectionCurrent, no aria-current, and no nested group is open.
No in-page tab rows. Tasks, Money and Settings pages render no tab row. The ten copied tab arrays and ScheduleSubNav are gone, PageLayout has no subNav slot, and no empty row is reserved: pages start with their header, at the same top spacing as every other page. Views are reached from the sidebar (and, on phones and tablets, from the title menu under the top bar). SubNav remains as a component only for the future object-tab pattern; it has no consumers.
Interim Money views. Until Financing is served, Money lists Invoices, Funding sources, Grants and Bank report, so no capability loses its navigation entry when the tab row is removed. These three carry interimUntil: 'financing' in NavConfig; when Financing is served they disappear and Financing appears, with no code change. They look like every other nested row: no "temporary" badge, no different weight, no different indent. They are not page-identity views: their pages keep the Money breadcrumb and fixed headings, while the sidebar highlights the interim view. Document any further interim entry the same way.
Responsive. Desktop: static 240px panel, the nav region scrolls, footer pinned; at viewport heights up to 640px the whole panel scrolls so an open Settings group never hides the primary list. Below 1024px (#2207) the sidebar is not mounted at all (the off-canvas drawer, its overlay and the floating button are gone); destinations come from the bottom bar and More sheet. Reduced motion: hover transitions are off; state is never conveyed by motion.
Accessibility. Tab order is DOM order (logo, main links with nested links right after their parent, quiet group, Settings); links activate with Enter; no roving tabindex. The active state is never colour alone (weight, inset bar, aria-current). Labels are the glossary's plain words ("Schedule", never "Gantt").
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-focus-ring |
rgba(59,130,246,0.3) |
rgba(96,165,250,0.4) |
Standard blue focus ring color |
--color-focus-ring-subtle |
rgba(59,130,246,0.1) |
rgba(96,165,250,0.15) |
Subtle focus ring |
--color-focus-ring-danger |
rgba(220,38,38,0.1) |
rgba(239,68,68,0.15) |
Focus ring on danger element |
--color-focus-ring-primary-alt |
rgba(37,99,235,0.1) |
rgba(96,165,250,0.15) |
Focus ring on alternate primary |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-overlay |
rgba(0,0,0,0.5) |
rgba(0,0,0,0.7) |
Modal backdrop |
--color-overlay-light |
rgba(0,0,0,0.1) |
rgba(0,0,0,0.3) |
Hover overlay on images |
--color-overlay-medium |
rgba(0,0,0,0.15) |
rgba(0,0,0,0.4) |
Medium-weight overlay |
--color-overlay-danger |
rgba(153,27,27,0.1) |
rgba(239,68,68,0.15) |
Danger action overlay |
--color-overlay-delete |
rgba(220,38,38,0.1) |
rgba(239,68,68,0.15) |
Delete confirmation overlay |
Theme-invariant: defined once in :root and not overridden in [data-theme="dark"], because the photo viewer and photo overlays always sit on a dark stage regardless of app theme. Use these (never --color-text-*/--color-bg-*, which flip with the theme) for any surface rendered on top of or beside a photo.
| Token | Value | Usage |
|---|---|---|
--color-photo-overlay-caption |
rgba(0,0,0,0.8) |
Caption strip over a photo |
--color-photo-control-bg / --color-photo-control-bg-hover
|
rgba(0,0,0,0.5/0.8) |
Round overlay controls; count badge on thumbs |
--color-photo-bar-bg |
rgba(0,0,0,0.7) |
Viewer info/toolbar bar |
--color-photo-border |
rgba(255,255,255,0.1) |
Hairlines on dark overlays |
--color-photo-focus-ring |
rgba(255,255,255,0.6) |
Focus ring on dark surfaces (blue ring is low-contrast on black) |
--color-photo-active-bg |
rgba(255,255,255,0.25) |
Pressed/active overlay control |
--color-photo-action-primary-bg / --color-photo-action-danger-bg
|
blue / red at 0.9 alpha | Primary and destructive actions on overlays |
--color-photo-stage-bg |
#0b0f17 |
SpotViewer image stage |
--color-photo-panel-bg / --color-photo-panel-border
|
gray-900 / gray-800 | SpotViewer details panel and filmstrip |
--color-photo-text / --color-photo-text-secondary / --color-photo-text-muted
|
gray-50 / 300 / 400 | Text on the dark panel (all meet AA on panel-bg) |
--color-photo-link |
blue-300 | Links on the dark panel |
--color-photo-item-hover-bg / --color-photo-item-current-bg / --color-photo-item-current-ring
|
white 6% / white 12% / blue-400 | Filmstrip item hover, current item, current ring |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-status-not-started-bg |
#e5e7eb (gray-200) |
#334155 |
Not Started badge background |
--color-status-not-started-text |
#374151 (gray-700) |
#94a3b8 |
Not Started badge text |
--color-status-in-progress-bg |
#dbeafe (blue-100) |
rgba(59,130,246,0.2) |
In Progress badge background |
--color-status-in-progress-text |
#1e40af (blue-800) |
#93c5fd |
In Progress badge text |
--color-status-completed-bg |
#d1fae5 (green-100) |
rgba(16,185,129,0.15) |
Completed badge background |
--color-status-completed-text |
#065f46 (green-900) |
#a7f3d0 |
Completed badge text |
--color-status-blocked-bg |
#fee2e2 (red-100) |
rgba(239,68,68,0.15) |
Blocked badge background |
--color-status-blocked-text |
#991b1b (red-700) |
#fca5a5 |
Blocked badge text |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-role-admin-bg |
#dbeafe (blue-100) |
rgba(59,130,246,0.2) |
Admin role badge background |
--color-role-admin-text |
#1e40af (blue-800) |
#93c5fd |
Admin role badge text |
--color-role-member-bg |
#f3f4f6 (gray-100) |
#334155 |
Member role badge background |
--color-role-member-text |
#374151 (gray-700) |
#94a3b8 |
Member role badge text |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-user-active-bg |
#dcfce7 (green-150) |
rgba(16,185,129,0.15) |
Active user badge background |
--color-user-active-text |
#166534 (green-700) |
#6ee7b7 |
Active user badge text |
--color-user-inactive-bg |
#fee2e2 (red-100) |
rgba(239,68,68,0.15) |
Inactive user badge background |
--color-user-inactive-text |
#991b1b (red-700) |
#fca5a5 |
Inactive user badge text |
Every text/background token pair used in the app meets WCAG 2.1 AA (4.5:1 normal text, 3:1 large text and meaningful non-text) in both themes, enforced by a unit test that resolves the tokens from tokens.css (dark falls back to the light value where the dark block has no override). A token change that drops a pair below AA fails CI. Rules that follow:
- A new colour token or a changed value must keep every consuming pair AA; add the pair to the test.
-
--color-text-placeholder,--color-text-muted,--color-text-quoted: readable text, 4.5:1. -
--color-text-disabled: non-text only (disabled controls are exempt from 1.4.3). Never for content users must read. - Check
--color-text-mutedand--color-primarytext on--color-bg-tertiaryin dark mode, and--color-primaryon--color-primary-bgin light mode (use--color-primary-badge-textwhere it fails). - Hover on a bordered control must change border or fill to a visible state: pagination buttons use
--color-bg-hoverplusborder-color: var(--color-primary). - Disabled controls are exempt, but the surrounding labels are not.
Shadows automatically deepen in dark mode via Layer 3 overrides.
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--shadow-sm |
0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06) |
0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2) |
Cards, small surfaces |
--shadow-md |
0 4px 6px rgba(0,0,0,0.1) |
0 4px 6px rgba(0,0,0,0.3) |
Raised elements |
--shadow-lg |
0 10px 15px rgba(0,0,0,0.1), 0 4px 6px rgba(0,0,0,0.05) |
0 10px 15px rgba(0,0,0,0.3), 0 4px 6px rgba(0,0,0,0.2) |
Dropdowns, popovers |
--shadow-xl |
0 20px 25px rgba(0,0,0,0.15) |
0 20px 25px rgba(0,0,0,0.4) |
Modals |
--shadow-xl-strong |
0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04) |
0 20px 25px -5px rgba(0,0,0,0.3), 0 10px 10px -5px rgba(0,0,0,0.2) |
Large modals |
--shadow-2xl |
0 20px 25px rgba(0,0,0,0.2) |
0 20px 25px rgba(0,0,0,0.5) |
Full-page overlays |
--shadow-inset-deep |
0 10px 25px rgba(0,0,0,0.2) |
0 10px 25px rgba(0,0,0,0.4) |
Sidebar depth, inset panels |
--shadow-kbd |
0 1px 0 rgba(0,0,0,0.05) |
0 1px 0 rgba(255,255,255,0.1) |
Keyboard shortcut keys |
--shadow-focus |
0 0 0 3px rgba(59,130,246,0.3) |
0 0 0 3px rgba(96,165,250,0.4) |
Standard focus ring |
--shadow-focus-subtle |
0 0 0 3px rgba(59,130,246,0.1) |
0 0 0 3px rgba(96,165,250,0.15) |
Subtle focus ring |
--shadow-focus-primary-alt |
0 0 0 3px rgba(37,99,235,0.1) |
0 0 0 3px rgba(96,165,250,0.15) |
Alternate primary focus ring |
--shadow-focus-danger |
0 0 0 3px rgba(220,38,38,0.1) |
0 0 0 3px rgba(239,68,68,0.15) |
Focus ring on danger element |
The application uses the system-ui font stack — no web font loading, no layout shift:
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Helvetica Neue',
sans-serif;This is set globally in client/src/styles/index.css and should not be overridden per-component.
| Token | Value | Pixels | Usage |
|---|---|---|---|
--font-size-2xs |
0.8125rem |
13px | Between xs and sm — fine print, dense metadata |
--font-size-xs |
0.75rem |
12px | Micro labels, timestamps |
--font-size-sm |
0.875rem |
14px | Form labels, secondary text, badges |
--font-size-base |
1rem |
16px | Body text, inputs |
--font-size-lg |
1.125rem |
18px | Slightly emphasized text |
--font-size-xl |
1.25rem |
20px | Card titles, section headings |
--font-size-2xl |
1.5rem |
24px | Page sub-headings |
--font-size-3xl |
1.875rem |
30px | Major page headings |
--font-size-4xl |
2rem |
32px | Hero headings, large dashboard numbers |
| Token | Value | Usage |
|---|---|---|
--font-weight-normal |
400 |
Body text |
--font-weight-medium |
500 |
Labels, metadata with slight emphasis |
--font-weight-semibold |
600 |
Section headings, button text |
--font-weight-bold |
700 |
Page titles, badges, strong emphasis |
All spacing tokens follow a 4px base grid. Use these tokens for padding, margin, and gap.
| Token | Value | Pixels | Common Usage |
|---|---|---|---|
--spacing-0 |
0 |
0 | Reset |
--spacing-px |
1px |
1px | Hairline borders, fine adjustments |
--spacing-0-5 |
0.125rem |
2px | Micro gaps |
--spacing-1 |
0.25rem |
4px | Icon padding, tight inline spacing |
--spacing-1-5 |
0.375rem |
6px | Dense list items |
--spacing-2 |
0.5rem |
8px | Default inner padding small elements |
--spacing-2-5 |
0.625rem |
10px | Medium-small gap |
--spacing-3 |
0.75rem |
12px | Badge padding, compact list items |
--spacing-4 |
1rem |
16px | Standard inner padding (cards, inputs) |
--spacing-5 |
1.25rem |
20px | Section spacing |
--spacing-6 |
1.5rem |
24px | Card gap, form field spacing |
--spacing-7 |
1.75rem |
28px | Wide gap |
--spacing-8 |
2rem |
32px | Major section padding |
--spacing-10 |
2.5rem |
40px | Large section separation |
--spacing-12 |
3rem |
48px | Page-level vertical rhythm |
--spacing-16 |
4rem |
64px | Hero-level spacing |
| Token | Value | Pixels | Usage |
|---|---|---|---|
--radius-sm |
0.25rem |
4px | Small elements: badges, keyboard keys |
--radius-md |
0.375rem |
6px | Inputs, buttons, dropdowns, popovers |
--radius-lg |
0.5rem |
8px | Cards, modals, dialog sections |
--radius-circle |
50% |
-- | Circular elements (avatar, icon button) |
--radius-full |
9999px |
-- | Pills: tags, status badges |
| Token | Value | Usage |
|---|---|---|
--transition-fast |
0.1s ease |
Opacity fade (sidebar close button) |
--transition-normal |
0.15s ease |
Most interactive elements (buttons, inputs) |
--transition-medium |
0.2s ease |
Hover effects on larger elements |
--transition-slow |
0.3s ease |
Sidebar slide, modals |
Pre-built multi-property transitions for consistency across components:
| Token | Value | Usage |
|---|---|---|
--transition-input |
border-color 0.15s ease, box-shadow 0.15s ease |
Form input focus transitions |
--transition-button |
background-color 0.15s ease, box-shadow 0.15s ease |
Button background + shadow |
--transition-button-border |
background-color 0.15s ease, border-color 0.15s ease |
Outline button hover |
| Token | Value | Usage |
|---|---|---|
--z-dropdown |
10 |
Dropdown menus, date pickers |
--z-overlay |
50 |
Backdrop overlays (below sidebar) |
--z-sidebar |
100 |
Mobile sidebar panel |
--z-modal |
1000 |
Modal dialogs (above everything) |
Always reference Layer 2 semantic tokens with var():
/* CORRECT */
.card {
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--spacing-6);
color: var(--color-text-body);
}
/* WRONG — hardcoded values break dark mode */
.card {
background: #ffffff;
border: 1px solid #e5e7eb;
}- Always prefer semantic tokens — they adapt automatically to dark mode
- Use palette tokens directly only when creating a new component that genuinely needs a specific hue not yet covered by a semantic alias (e.g., a new badge type)
- If you find yourself using palette tokens repeatedly for the same purpose, add a semantic alias to
tokens.cssat all 3 layers
Use the --color-status-* token family for work item status indicators. Apply --radius-full for pill shape:
.badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.notStarted {
background: var(--color-status-not-started-bg);
color: var(--color-status-not-started-text);
}
.inProgress {
background: var(--color-status-in-progress-bg);
color: var(--color-status-in-progress-text);
}
.completed {
background: var(--color-status-completed-bg);
color: var(--color-status-completed-text);
}
.blocked {
background: var(--color-status-blocked-bg);
color: var(--color-status-blocked-text);
}Standard input with focus ring and error state:
.input {
width: 100%;
padding: var(--spacing-2) var(--spacing-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: var(--font-size-base);
transition: var(--transition-input);
}
.input::placeholder {
color: var(--color-text-placeholder);
}
.input:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: var(--shadow-focus);
}
/* Error state */
.inputError {
border-color: var(--color-danger-input-border);
}
.inputError:focus {
box-shadow: var(--shadow-focus-danger);
}Three button variants — primary, danger, and outline:
/* Primary button */
.buttonPrimary {
background: var(--color-primary);
color: var(--color-primary-text);
border: none;
border-radius: var(--radius-md);
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: var(--transition-button);
}
.buttonPrimary:hover {
background: var(--color-primary-hover);
}
.buttonPrimary:active {
background: var(--color-primary-active);
}
.buttonPrimary:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
/* Danger button */
.buttonDanger {
background: var(--color-danger);
color: var(--color-danger-text);
/* ... same shape/font/cursor tokens ... */
}
.buttonDanger:hover {
background: var(--color-danger-hover);
}
.buttonDanger:active {
background: var(--color-danger-active);
}
.buttonDanger:focus-visible {
box-shadow: var(--shadow-focus-danger);
}
/* Outline / secondary button */
.buttonOutline {
background: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-border-strong);
/* ... same shape/font/cursor tokens ... */
transition: var(--transition-button-border);
}
.buttonOutline:hover {
background: var(--color-bg-hover);
border-color: var(--color-border-focus);
}
.buttonOutline:focus-visible {
box-shadow: var(--shadow-focus-subtle);
}Rule (house pattern, decided under #2065 AC6): the choice depends on what the control is doing, not on a global preference.
| Situation | Mechanism |
|---|---|
| In-place async button — triggers async work, stays mounted in the same spot, and may be activated again afterwards (load-more / retry, save-and-stay, refresh) |
aria-disabled="true" while busy + handler early-return guard. Do not use disabled. |
| Validity-gated or permanently unavailable control (required field empty, no permission, form invalid, submit that navigates away or unmounts the control) | Native disabled (keeps the ~500 existing uses; no migration required) |
| Disabled-with-reason (user should be able to focus it to read why) |
aria-disabled="true" + aria-describedby/tooltip, as in DataTableHeader
|
Why: a natively disabled button that has focus drops it (focus falls to <body>) the moment it becomes disabled. For an in-place control that re-enables a moment later, keyboard and screen-reader users lose their place and must re-navigate. aria-disabled keeps the element focusable and announces "dimmed/unavailable" without moving focus.
Required pieces for an in-place async button (all five):
-
aria-disabled={busy}('true'while busy; omit orfalseotherwise) — never also setdisabled. - Handler early-return:
onClick={() => { if (busy) return; ... }}.aria-disableddoes not block clicks or Enter/Space. - Busy visual state keyed off the attribute, including a hover override, because
:hover:not(:disabled)rules still match:
.asyncButton[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
/* keep the resting background; shared btn hover rules use :not(:disabled) and still match */
.asyncButton[aria-disabled='true']:hover {
background-color: var(--color-bg-tertiary); /* the button's resting background token */
}-
:focus-visiblering stays visible while busy (box-shadow: var(--shadow-focus); token flips in dark mode — verify in both themes). Do not suppress the ring or lower its opacity with the dimmed state's styling. - If the control unmounts when work completes (e.g. end of list), move focus deliberately to a
tabIndex={-1}target (heading, status row) — never let it fall to<body>. Announce state changes through an existing live region; do not rely on the attribute alone.
Note: opacity 0.5 on text is exempt from contrast minimums for disabled controls under WCAG 1.4.3, but the focus ring and any surrounding status text must still meet 3:1 / 4.5:1.
Existing aria-disabled uses (MassMoveModal, AutoItemizeLineList, DiaryEntryCreatePage, InvoicesPage) were added for mixed reasons and are not retrofitted by this rule; audit them opportunistically when touched. Reference implementation: client/src/components/InfiniteScrollFooter/.
All interactive elements on mobile and tablet must meet the 44px minimum touch target:
@media (max-width: 1024px) {
.button {
min-height: 44px;
}
}React Router does not add a literal active class when using CSS Modules. Use the function form:
<NavLink
to="/work-items"
className={({ isActive }) => `${styles.navLink} ${isActive ? styles.active : ''}`}
>
Work Items
</NavLink>These components are the required building blocks for all new UI work. Before creating a new component, verify that none of these existing shared components already cover the use case. Using them ensures consistent tokens, accessibility behavior, and dark mode support without additional effort.
Policy: Any new UI that resembles one of these components MUST use it instead of creating a parallel implementation. Extending an existing component with new props is always preferred over duplication.
File: client/src/components/Badge/Badge.tsx
Import: import { Badge } from '../components/Badge/Badge.js'
A parameterized pill badge driven by a variant map. Replaces the previous StatusBadge, HouseholdItemStatusBadge, DiaryOutcomeBadge, and DiarySeverityBadge components — all badge needs should go through this one component.
Props:
| Prop | Type | Required | Description |
|---|---|---|---|
variants |
BadgeVariantMap |
Yes | Maps string values to { label, className } display configs |
value |
string |
Yes | The current value — looked up in variants; falls back to raw value if not found |
ariaLabel |
string |
No | Override the accessible label (use when label text alone is insufficient) |
testId |
string |
No |
data-testid for test selectors |
className |
string |
No | Additional CSS class for positioning/sizing overrides |
Types:
interface BadgeVariant {
label: string; // human-readable display label
className: string; // CSS Module class that sets bg + text color
}
type BadgeVariantMap = Record<string, BadgeVariant>;Usage:
Define the variant map once (typically in the domain component file) then pass it to every Badge instance:
import { Badge, type BadgeVariantMap } from '../components/Badge/Badge.js';
import badgeStyles from '../components/Badge/Badge.module.css';
const WORK_ITEM_STATUS_VARIANTS: BadgeVariantMap = {
not_started: { label: 'Not Started', className: badgeStyles.not_started },
in_progress: { label: 'In Progress', className: badgeStyles.in_progress },
completed: { label: 'Completed', className: badgeStyles.completed },
};
// In JSX:
<Badge variants={WORK_ITEM_STATUS_VARIANTS} value={workItem.status} />;Built-in variant classes in Badge.module.css (reference by importing the CSS module):
| Class name | Token family used | Domain |
|---|---|---|
not_started |
--color-status-not-started-* |
Work item status |
in_progress |
--color-status-in-progress-* |
Work item status |
completed |
--color-status-completed-* |
Work item status |
planned |
--color-hi-status-planned-* |
Household item status |
purchased |
--color-hi-status-purchased-* |
Household item status |
scheduled |
--color-hi-status-scheduled-* |
Household item status |
arrived |
--color-hi-status-arrived-* |
Household item status |
pass |
--color-diary-outcome-pass-* |
Diary inspection outcome |
fail |
--color-diary-outcome-fail-* |
Diary inspection outcome |
conditional |
--color-diary-outcome-conditional-* |
Diary inspection outcome |
low |
--color-diary-severity-low-* |
Diary issue severity |
medium |
--color-diary-severity-medium-* |
Diary issue severity |
high |
--color-diary-severity-high-* |
Diary issue severity |
critical |
--color-diary-severity-critical-* |
Diary issue severity |
For a new badge domain, add variant classes to Badge.module.css using the appropriate --color-* token family. Do not inline colors or create a separate CSS module.
File: client/src/components/SearchPicker/SearchPicker.tsx
Import: import { SearchPicker } from '../components/SearchPicker/SearchPicker.js'
A generic, search-as-you-type entity picker with a controlled text input, debounced search, a role="listbox" dropdown, and a clear button. Use this for any "select an entity from a searched list" interaction — work items, household items, users, invoices, etc.
Props:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
searchFn |
(query: string, excludeIds: string[]) => Promise<T[]> |
Yes | — | Async search function called on each debounced keystroke |
renderItem |
(item: T) => { id: string; label: string } |
Yes | — | Maps a result item to its display label and stable ID |
value |
string |
Yes | — | Currently selected ID; '' means no selection |
onChange |
(id: string) => void |
Yes | — | Called with the selected item's ID (or '' on clear) |
excludeIds |
string[] |
Yes | — | Item IDs to omit from results (e.g. already-linked items) |
onSelectItem |
(item: { id: string; label: string }) => void |
No | — | Optional callback with both ID and label on selection |
disabled |
boolean |
No | false |
Disables all interaction |
placeholder |
string |
No | 'Search items...' |
Input placeholder text |
getStatusBorderColor |
(item: T) => string | undefined |
No | — | Returns a CSS color string for the selected-item left border accent |
specialOptions |
SpecialOption[] |
No | — | Fixed options shown above the search results (e.g. "None") |
showItemsOnFocus |
boolean |
No | — | When true, loads and shows all results on input focus |
initialTitle |
string |
No | — | Pre-populate the selected display when value is already set but the full item object is not yet loaded |
emptyHint |
string |
No | 'Type to search items' |
Message shown when the dropdown is open but no query entered |
noResultsMessage |
string |
No | 'No matching items found' |
Message shown when a search returns no results |
loadErrorMessage |
string |
No | 'Failed to load items' |
Error shown when initial load fails |
searchErrorMessage |
string |
No | 'Failed to search items' |
Error shown when a search query fails |
Usage:
import { SearchPicker } from '../components/SearchPicker/SearchPicker.js';
<SearchPicker<WorkItem>
value={selectedWorkItemId}
onChange={(id) => setSelectedWorkItemId(id)}
excludeIds={[]}
searchFn={(query, excludeIds) => api.searchWorkItems(query, excludeIds)}
renderItem={(item) => ({ id: item.id, label: item.name })}
placeholder="Search work items..."
showItemsOnFocus
/>;With specialOptions (e.g. a "None / unassigned" choice):
<SearchPicker<User>
value={assigneeId}
onChange={setAssigneeId}
excludeIds={[]}
searchFn={searchUsers}
renderItem={(u) => ({ id: u.id, label: u.name })}
specialOptions={[{ id: 'none', label: 'Unassigned' }]}
/>Behavior notes:
- Debounce delay is 300ms.
- Calling
onChange('')from the clear button resets the picker to its empty state. - When
valueis externally reset to'', the component self-clears. -
showItemsOnFocustriggers a full list load on input focus (useful when the dataset is small enough to show all items without filtering first). -
Trailing slot (
renderTrailing, #2295 follow-up). A same-line, right-aligned text after the title (an amount, a short status). Rendered as the last child of the option button in.resultTrailing:flex-shrink: 0,margin-left: auto,--font-size-sm,--color-text-secondary,font-variant-numeric: tabular-nums,text-align: right,white-space: nowrap. A long status phrase (not an amount) must wrap instead (white-space: normal,max-width: 50%) so it never ellipsizes the title; the title block keepsmin-width: 0. The secondary line stays under the title (company name under an invoice number). -
Add mode (
clearOnSelect). For add-style lists: a pick or a created item callsonChange/onSelectItembut shows no selected chip; the input empties and keeps focus without reopening the list, so focus never drops to<body>. Consumers keepvalue="". Pickers that choose one value keep the default chip with "Clear selection". -
Per-row test id (
getOptionTestId) puts adata-testidon each result option;inputAriaProps['aria-label']names an input that has no visible<label>(an input with a label useshtmlForinstead and gets noaria-label). - Rows of a portaled listbox are reached with ArrowDown/ArrowUp from the input, never with Tab (a preselected value shows as the selected chip, not as prefilled input text).
File: client/src/components/Modal/Modal.tsx
Import: import { Modal } from '../components/Modal/index.js'
A dialog overlay rendered via createPortal into document.body. Handles all standard modal behaviors: backdrop dismiss, Escape key close, focus management (auto-focuses first focusable child on mount), and correct ARIA attributes.
Props:
| Prop | Type | Required | Description |
|---|---|---|---|
title |
string |
Yes | Modal heading — announced by screen readers via aria-labelledby
|
onClose |
() => void |
Yes | Called on backdrop click, Escape key, or close button press |
children |
React.ReactNode |
Yes | Modal body content |
footer |
React.ReactNode |
No | Action buttons rendered in the footer strip |
className |
string |
No | Additional CSS class applied to the content panel (use for width overrides) |
Usage:
import { Modal } from '../components/Modal/index.js';
{
isOpen && (
<Modal
title="Confirm Deletion"
onClose={() => setIsOpen(false)}
footer={
<>
<button className={sharedStyles.btnSecondary} onClick={() => setIsOpen(false)}>
Cancel
</button>
<button className={sharedStyles.btnDanger} onClick={handleDelete}>
Delete
</button>
</>
}
>
<p>Are you sure you want to delete this item? This action cannot be undone.</p>
</Modal>
);
}Sizing: The default modal width is set by shared.module.css → .modalContent. Override for wide modals (e.g. pickers) by passing a className with a max-width rule:
/* In the consuming component's CSS Module */
.wideModal {
max-width: min(860px, calc(100vw - 2rem));
}<Modal title="Select Document" onClose={onClose} className={styles.wideModal}>
...
</Modal>Accessibility: The modal root element carries role="dialog" aria-modal="true" aria-labelledby={titleId}. The title element has the matching id. Focus is moved to the first interactive child on mount. Escape key always closes.
File: client/src/components/Skeleton/Skeleton.tsx
Import: import { Skeleton } from '../components/Skeleton/index.js'
A shimmer placeholder shown during data loading. Renders a configurable number of lines with staggered widths. Use instead of spinner-plus-text loading states for content regions.
Props:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
lines |
number |
No | 3 |
Number of skeleton lines to render |
widths |
string[] |
No | — | Width per line (e.g. ['100%', '75%', '50%']); cycles 100/80/60% when omitted |
loadingLabel |
string |
No | 'Loading' |
aria-label for the container; customize to describe the specific content loading |
className |
string |
No | — | Additional CSS class for layout positioning |
Usage:
import { Skeleton } from '../components/Skeleton/index.js';
// Basic 3-line loading placeholder
{
isLoading && <Skeleton />;
}
// Custom count with explicit widths
{
isLoading && (
<Skeleton
lines={4}
widths={['100%', '100%', '70%', '40%']}
loadingLabel="Loading work item details"
/>
);
}Accessibility: The container has role="status" aria-busy="true" and an aria-label that describes what is loading. Individual line elements are aria-hidden="true".
Animation: The shimmer animation uses --color-bg-tertiary and --color-bg-hover as gradient stops. The animation is automatically disabled for users with prefers-reduced-motion: reduce — the lines render as static placeholders without the sweep effect.
File: client/src/components/EmptyState/EmptyState.tsx
Import: import { EmptyState } from '../components/EmptyState/index.js'
A centered empty-content display with an optional icon, main message, supporting description, and an action button or link. Use whenever a list, table, or content region has no items to show.
Props:
| Prop | Type | Required | Description |
|---|---|---|---|
message |
string |
Yes | Primary empty-state message (e.g. "No work items yet") |
icon |
ReactNode |
No | Icon to display above the message — emoji string or React element; always aria-hidden
|
description |
string |
No | Secondary helper text beneath the message |
action |
EmptyStateAction |
No | Optional CTA; renders an <a> when href is set, a <button> otherwise |
className |
string |
No | Additional CSS class for layout positioning |
EmptyStateAction shape:
interface EmptyStateAction {
label: string;
href?: string; // renders an <a> link
onClick?: () => void; // renders a <button>; href takes precedence
}Usage:
import { EmptyState } from '../components/EmptyState/index.js';
// List has no items
<EmptyState
icon="📋"
message="No work items yet"
description="Add your first work item to start tracking progress."
action={{ label: 'Add Work Item', onClick: () => setShowForm(true) }}
/>
// Filter produced no results (no icon, no CTA)
<EmptyState
message="No items match your filters"
description="Try adjusting or clearing your filters."
action={{ label: 'Clear filters', onClick: clearFilters }}
/>Pattern guidance: Use the full form (icon + message + description + action) for the "resource does not exist yet" state. Use the minimal form (message + optional description + text action) for "filter returned no results" — omit the icon in filtered-empty states so the two cases are visually distinct.
A signed-in member who opens an admin-only URL (/settings/users, /settings/backups) sees the No access page in place: URL kept, no redirect, sidebar stays, with nothing highlighted. Built from EmptyState (no new component): the message is the page's real <h1> via the messageAs="h1" prop (default 'p', so existing consumers are unchanged), styled by .messageHeading (--font-size-base, --font-weight-semibold, --color-text-primary), with a "Back to Home" in-app link as the action. The guard (RoleGuard, a pathless layout route) renders nothing while auth loads, so the admin page never flashes. Admin-only Settings views (Users, Backups) are not rendered in the sidebar for members (removed, not disabled).
File: client/src/components/FormError/FormError.tsx
Import: import { FormError } from '../components/FormError/index.js'
Displays validation or API error messages in forms. Returns null when message is falsy — safe to always render without conditional wrapping. Two visual variants: a full-width banner for form-level errors and a small inline message for field-level errors.
Props:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
message |
string | null |
No | — | Error text to display; component renders nothing when absent |
variant |
'banner' | 'field' |
No | 'banner' |
banner = full-width error panel; field = inline field hint |
className |
string |
No | — | Additional CSS class |
Usage:
import { FormError } from '../components/FormError/index.js';
// Form-level (banner variant — default)
<FormError message={submitError} />
// Field-level inline error
<label htmlFor="email">Email</label>
<input id="email" className={emailError ? styles.inputError : undefined} />
<FormError message={emailError} variant="field" />Accessibility: The banner variant renders with role="alert", which causes screen readers to announce the error immediately when it appears. The field variant omits the role — it is associated with its input through the label/input relationship and does not need live-region behavior.
File: client/src/components/InfiniteScrollFooter/InfiniteScrollFooter.tsx
Hook: useInfiniteScroll (client/src/hooks/) owns the IntersectionObserver and loading state machine.
Scroll-driven batch-loading footer: sentinel, loading indicator, inline error, end-of-list row, and a load-more/retry button. Parameterized by label props and testIdPrefix; no hardcoded i18n namespace. Use it for every paginated/infinite list instead of hand-rolling a footer.
Button behavior (reference implementation of Disabled vs. Busy Buttons):
- Load-more/retry button stays focusable during a batch:
aria-disabled="true"+onClickearly-return, never nativedisabled. -
.loadMoreButton[aria-disabled='true']:opacity: 0.5; cursor: not-allowed;plus a:hoveroverride restoring--color-bg-tertiary(sharedbtnSecondary:hover:not(:disabled)would otherwise still match). - On mobile (
max-width: 767px) the button is full width withmin-height: 44px. - When the list ends, the button unmounts and focus is handed to the end-of-list row (
tabIndex={-1},:focus-visibleuses--shadow-focus; plain:focusshows no ring).
Accessibility: loading, error, and end-of-list changes must be reachable by assistive tech via the component's status/alert semantics; the focus target on list end prevents focus loss to <body>. Pages that must announce while the footer is unmounted (e.g. a 0-entry initial load, or an all-filtered end-of-list) use the useInfiniteScrollAnnouncements hook (client/src/hooks/) to drive their own role="status" region (which already implies aria-live="polite"; do not add both) rather than a slot on the footer.
File: client/src/components/FilterChipGroup/FilterChipGroup.tsx
Pick-one chip group (e.g. Photos page orientation filter). Props: options: {value,label}[], value, onChange, ariaLabel, testIdPrefix. Each chip is a <button type="button"> exposing aria-pressed; the wrapper is role="group" with aria-label. Use it for a small, always-visible single-select filter instead of a <select> or a bespoke button row.
- Chips:
min-height: 2.75rem(44px touch target),--radius-full,--color-border-strongborder,--color-bg-primaryfill; pressed state inverts to--color-bg-inverse/--color-text-inverse. - Focus:
box-shadow: var(--shadow-focus). The group scrolls horizontally (overflow-x: auto, which also clips vertically), so it carriespadding: var(--spacing-1)with a matching negative margin to keep the 3px ring unclipped without shifting layout. Preserve this pairing if you change the focus ring size. - Not a segmented control for form fields; for a form "choose one" field use the radio group pattern.
File: client/src/components/SpotThumbnail/SpotThumbnail.tsx
Thumbnail for a photo spot (area x orientation) used in the Photos browser. Variants: cell (16:9, unbordered, for table cells) and card (4:3, bordered --radius-lg card with title and date lines, for the mobile layout). Rendering is data-driven:
-
With
to: renders a router<Link>whose accessible name isariaLabel(required); the image is decorative (alt="", lazy), the photo-count pill isaria-hidden(the count belongs inariaLabel). -
Without
to: non-interactive dashed placeholder showingemptyLabel(optional visually hiddenemptySrPrefixfor context); reserves the date line height so rows do not jump. -
Image failure/missing: swaps to a
--color-bg-tertiaryfallback with a picture-off icon and an sr-onlyimageUnavailableLabel. - Hover/focus: 2px
--color-primaryring on the thumb (inset oncard), plus--shadow-focuson the link on focus-visible. Count pill uses--color-photo-control-bg-hoverwith--color-whitetext.prefers-reduced-motiondisables the ring transition.
File: client/src/components/FileList/FileList.tsx (story #2161)
List of generated files, each with its own action (e.g. the split-report parts in the Source Reports wizard, step 5). Use it for any multi-file export instead of a bespoke list. All strings come from props (no i18n namespace). Props: heading, summary, items: FileListItem[] (id, name, meta, optional badges, optional detail, actionLabel, actionAriaLabel, onAction, actionDisabled), headingRef, testIdPrefix (default file-list), and children (rendered between summary and list, e.g. a stale-state notice).
-
Structure:
<section aria-labelledby>with an<h3 tabIndex={-1}>(programmatic focus target after an upload round) and a summary<p>that the<ul>references viaaria-describedby. Rows are<li>. -
Row:
--color-bg-secondaryfill,1px solid var(--color-border),--radius-md,--spacing-3padding,--spacing-2list gap; grid1fr auto. Name:--font-size-sm/--font-weight-medium/--color-text-primary,overflow-wrap: anywhere. Meta:--font-size-xs/--color-text-muted. Badges: flex-wrap row (useBadge; status always has a text label). Detail:--font-size-xs/--color-text-secondary. -
Action: composes
btnSecondaryCompact;aria-labelmust be unique per row (e.g. "Download {filename}"). -
Responsive: below 768px (
max-width: 767px) rows stack and the action is full width withmin-height: 44px. (The #2161 spec said 640px; implementation uses the app-wide 767px mobile breakpoint, accepted for consistency withInfiniteScrollFooter/FilterChipGroup.) - Dark mode: semantic tokens only; no local overrides.
File: client/src/components/Breadcrumbs/Breadcrumbs.tsx (story #2202; reused by #2203/#2204). Presentational: parents: {label, href, dynamic?}[], origin?: {label, href}, pending?, testId. Data comes from usePageBreadcrumbs / PageBreadcrumbs (route-map parent chain + NavConfig labels, never the URL). Replaces every per-page back link ("Back to Diary", "Back to Schedule", ...); delete the link, its CSS and its i18n keys. Exception: the photo spot viewer keeps its dark chrome back link.
Page identity rules
| Rule | Value |
|---|---|
| h1 | list = object plural; view = NavConfig view name; detail = object name; create = "New ‹object›"; edit = "Edit ‹object›". Never a container name ("Project", "Manage", "Budget"). Exactly one h1 per state. |
| Typed fallback | Empty/whitespace name: "Untitled task/purchase/milestone". Loading: plain noun ("Task"). Not found: "Task not found" / "Purchase not found" / "Milestone not found" (deviation from "keeps the existing text": the old text used banned words, see Accepted UX Deviations). |
| Tab title |
‹page› · ‹section› · ‹house› (U+00B7, one space each side) via useDocumentTitle. Section dropped when equal to the page text. House = householdName, else "Cornerstone". Must never lag the h1. |
| Breadcrumb | Parents only, never the current page; root first; unserved ancestors skipped; an object ancestor with no loaded name is omitted (row keeps its height). Views (NavConfig main views and views[]) show no trail and no Back. Nothing to show renders nothing (no empty <nav>). |
| Back | "‹ Back to ‹origin›" only when the origin pathname differs from the nearest breadcrumb parent and the current page. Origin lives in router state (full URL incl. query; label = object name or NavConfig label, never stored translated); no known label = no Back. Back is a normal push and returns the exact URL (Schedule/Calendar view kept). |
Markup: <div class=row> containing the Back link (plain <a>, outside the landmark) and <nav aria-label="You are here"><ol> of links; › (U+203A) separators are aria-hidden spans, not CSS content, no trailing separator. Anchors via useHref + useLinkClickHandler (EmptyState pattern), not <Link>.
Tokens
| Element | Tokens |
|---|---|
| Row | flex, wrap, align-items: center, column-gap: var(--spacing-4), margin-bottom: var(--spacing-3) (--spacing-2 below 768px), min-height: var(--spacing-8)
|
| Trail link |
--font-size-sm, --font-weight-normal, --color-text-muted, no underline, --radius-sm, color var(--transition-fast)
|
| Back link |
--font-size-sm, --font-weight-medium, --color-text-secondary; ‹ glyph aria-hidden, margin-inline-end: var(--spacing-1)
|
| Hover |
--color-text-primary + underline |
| Focus |
:focus-visible only: outline: none; box-shadow: var(--shadow-focus)
|
| Separator |
--color-text-muted, margin-inline: var(--spacing-2)
|
| Long text | Object-name item flex: 0 1 auto; min-width: 0 with ellipsis; Back link max-width: 100% ellipsis; accessible name stays complete. h1 gets overflow-wrap: anywhere. |
Responsive
| Width | Behaviour |
|---|---|
| < 768px | One element only: Back if present, otherwise the nearest parent as "‹ ‹parent›" (other items display: none). Row min-height: 44px, link inline-flex so the target is at least 44px. |
| 768-1023px | Full trail, Back to its left, wraps; links min-height: 44px. |
| >= 1024px | Same; pointer target at least 24px. |
| Row hidden. |
From 1024px the shell top bar (#2206) hosts this row and the page does not render it, so only one You are here landmark exists; below 1024px (#2207) it is hosted by the top bar as well, in the compact layout, so the in-page row no longer exists at any width. tokens.css has no breakpoint tokens; media queries use literal px as in PageLayout.module.css.
Dark mode: semantic Layer-2 tokens only, no override block. --color-text-muted on --color-bg-page is 5.52:1 light / 7.18:1 dark; secondary 9.86 / 12.39 (all at least 4.5:1). Links are distinguished by position, separators and underline on hover/focus, so colour is never the only signal.
Accessibility: one breadcrumb <nav> per page, label differs from sidebar and AreaBreadcrumb; no aria-current (current page is not in the trail); tab order Back, trail left to right, content; no focus moved on arrival; only a colour transition, so prefers-reduced-motion sets transition: none. Opening an object, a crumb and Back are pushes; tab, view and filter changes use replace; no page navigates on mount.
Not reused: AreaBreadcrumb is a non-interactive content path (direction: rtl truncation), only its › and muted type recipe are repeated.
Status: implemented by #2209 (full spec: comment on issue #2209; implementation reviewed in PR #2285). Files: components/StatusMenu/ (new), components/ConfirmDialog/ (new, on Modal), components/Modal/AnchoredPanel (new, Floating UI popover), Toast (extended with an undo variant). Reuse: Badge look via composes, OverflowMenu row recipe, Sheet, FilterChipGroup chip look, FormError, Skeleton, Floating UI (ADR-033).
Confirm or undo (decided in this order): bank-facing action = ConfirmDialog with count and sum; destroys a record others reference, or Undo cannot restore it = ConfirmDialog ("Delete ‹name›?" with counts); status change or actual date = UndoToast; other reversible action = UndoToast once its server snapshot exists; discarding input = ConfirmDialog ("Discard changes?" / "Discard this entry?", cancel side "Keep editing" / "Keep entry"). Never window.confirm, double confirmation, inline Confirm/Cancel swap or a Delete key that deletes directly.
StatusMenu. One implementation for every status vocabulary; three appearances: chip (the status chip is the button), button ("Set status"), action (one transition, e.g. "Start"). No allowed transition = plain Badge. Chip colour only from useStatusBadgeVariants; hover/open = inset 0 0 0 1px currentColor; focus = 0 0 0 2px var(--color-bg-primary), 0 0 0 4px var(--color-border-focus) (a borderless chip needs the two-ring form); hit area extended to 44 px with a ::before; busy = aria-disabled (never disabled). Two steps in one surface: list (role="menu", header "Now: ‹status›", forward then backward moves) then, for Start / Mark done / Mark delivered / Mark reached / progress payment Paid or Submitted, a date step (role="dialog": Today · As planned ‹date› · Pick a date; applies at once; invoices ask no date). Set date is a secondary compact button (a popover step must not add a primary-looking button); Pick toggles and carries aria-expanded. The desktop surface is Modal/AnchoredPanel: the "Now: ‹status›" header sits outside the inner role="menu" (a header inside a menu is invalid ARIA); only the date step gets role="dialog". The phone Sheet stays mounted (inert and hidden while closed, dialog role only while open) so it slides in and out like MoreSheet. The planned chip is hidden when absent, equal to today or in the future. Below 1024 px the surface is a Sheet, from 1024 px a Floating UI popover (instant, no entrance animation). List step keyboard follows OverflowMenu (Tab closes and returns to the trigger); date step traps Tab, Escape closes. The trigger's accessible name is its visible text (WCAG 2.5.3); "Change status" is its aria-describedby description. aria-haspopup is menu for chip and button, dialog for an action that opens the date step. Date chips add border-color: var(--color-border-focus) on focus (the 30% ring alone is under 3:1). Status colours for grants, funding sources and defects are Badge variants (useStatusBadgeVariants): grants and funding sources keep their previous token pairs; defects reuse the blocked (Open), in-progress (Being fixed) and completed (Fixed) pairs. Labels: funding source "Used up", defect "Open / Being fixed / Fixed", no emoji.
UndoToast. Success palette, message names the record, text button "Undo" (aria-keyshortcuts="Control+Z Meta+Z"). Visible 6000 ms (UNDO_TOAST_MS, always less than the 30 s server window UNDO_WINDOW_MS); pauses on hover, focus-within and while the tab is hidden; a second change on the same record replaces its toast. Undo posts the snapshot token only (never a reverse transition); outcomes: "Change undone." / "It is too late to undo this." (404, toast dismissed) / "This changed since, so it can't be undone." (409, toast dismissed). Any other failure (network, 5xx) keeps the toast, re-enables Undo and shows an error toast, because the token stays valid for the rest of the 30 s window. Never takes focus; Ctrl/Cmd+Z (outside editable fields) undoes the newest undo toast. The toast list keeps two persistent live regions (polite role="status" for success, info and undo; an aria-live="assertive" region without a role for errors); individual toasts carry no role. The container has no gap: the regions are spaced only when both hold toasts. A toast replaced by dedupeKey hands focus off like a dismissed one.
ConfirmDialog. role="alertdialog", title = the full question, optional lead line, optional "This also affects:" list of counts (zero counts omitted, counts always from the server), footer Cancel (or Keep ...) left of the action. Action label is a verb ("Delete", "Discard", "Revert submission"); tone="danger" uses .btnDanger, tone="primary" .btnPrimary. Initial focus on the cancel button. Busy = aria-disabled and ignored Escape/backdrop; server error stays inside the dialog as FormError; counts load with Skeleton while the action stays aria-disabled. Always a centred dialog (never a sheet); footer stacks below 640 px at 44 px height.
Focus rule (all three): a control removed by its own result hands focus to the initiating control, else the host's fallback (returnFocusRef, next row, previous row, list region), else the page h1; never <body>. Modal implements it as opener, then returnFocusRef, then focusPageHeading(); after a confirmed action the opener is skipped (it is usually what was just deleted, and the row is removed after the dialog closes), so ConfirmDialog goes straight to returnFocusRef (a stable, enabled section heading with tabIndex={-1}, never a create button, which can be disabled), else the page h1. Cancel, Escape, backdrop and the close button still return to the opener; a host whose opener survives the action opts back in with restoreToOpenerOnConfirm (PhotoViewer clear markup). Each step is verified by reading document.activeElement and re-checked once in a microtask after the commit (a host may remove the opener in the same commit). ConfirmDialog buttons are 44 px high at 1023 px and below (stacked full width below 640 px); Modal's close button shows a busy cue (aria-disabled, 0.5 opacity, hover override) when dismissible is false.
Deviation Log (found while specifying #2209, fixed in that story): ToastList returned null when empty (no persistent live region), every toast was role="alert" inside a role="status" container, slideIn used a literal 0.2s ease with no prefers-reduced-motion rule, and the dismiss button was 20 px; Modal does not restore focus on close and has no alertdialog role support.
Files: components/TopBar/ (new), OverflowMenu (extended: renderTrigger, header, separator, link, choice-group rows, aria-haspopup="menu", stronger item focus), Breadcrumbs (layout="bar"), .btnIcon in shared.module.css. Desktop children from 1024px; below it the same <header> hosts the compact children specified in Phone and tablet shell (#2207). The shell breakpoint is max-width: 1023px drawer / min-width: 1024px static sidebar and bar. Full per-element spec: comment on issue #2206.
Structure. <header> (the only banner) sits in AppShell between the sidebar and <main>, sticky, --topbar-height = calc(var(--spacing-12) + var(--spacing-2)) (56px), --color-bg-primary, border-bottom: 1px solid var(--color-border), padding-inline: var(--spacing-8), z-index: calc(var(--z-dropdown) + 1) (above page-level sticky headers at --z-dropdown, below modals). The bar is a sibling of the .mainContent wrapper inside a non-scrolling .shellColumn, because .mainContent never scrolls and a sticky child of it would not stick. Left slot (flex: 1 1 0; min-width: 0) hosts Back and the breadcrumb trail (Breadcrumbs layout="bar": no wrap, no bottom margin); an empty slot renders nothing. Right group: Search, New, bell, avatar (gap: var(--spacing-2)), pushed right with margin-inline-start: auto.
| Element | Recipe |
|---|---|
| Search, New |
.btnSecondary (neutral on purpose: the shell contributes no primary-styled button), min-height: var(--spacing-10); Search carries an aria-hidden kbd hint (⌘K on Apple platforms, Ctrl K elsewhere) |
| Bell |
.btnIcon: 40px square, --color-text-secondary, hover --color-bg-tertiary; accessible name "Needs attention" |
| Avatar | 40px --radius-full, --color-primary-bg fill, --color-primary-badge-text initials (--font-size-sm, semibold), 1px solid var(--color-border-strong); hover --color-primary-bg-hover + --color-primary border; open holds the hover look |
| Focus | every control: box-shadow: var(--shadow-focus) plus border-color: var(--color-border-focus) (the 30 % ring alone is below 3:1); menu rows: inset 0 0 0 2px var(--color-border-focus)
|
| Inert controls | while their stories are open (Search 1.5, New 1.6, bell 3.4): aria-disabled="true", opacity: 0.5, cursor: not-allowed, still focusable, no handler |
Menu panel. --color-bg-primary, 1px solid var(--color-border-strong), --radius-md, --shadow-md, min-width 272px, max-height: calc(100dvh - var(--topbar-height) - var(--spacing-4)) scrolling. Rows follow the OverflowMenu .item recipe at min-height: var(--spacing-10). Order: header (name, role; not a menu item, linked with aria-describedby) · Account · separator · Theme (Light/Dark/System) · Language (English/Deutsch) · separator · Keyboard shortcuts · Help · About (version, GitHub) · separator · Log out (default text colour, not danger).
Choice groups (role="group" with role="menuitemradio" buttons, aria-checked) toggle in place and keep the menu open. Option: min-height: var(--spacing-8), 1px solid var(--color-border-strong), hover --color-bg-hover + --color-primary border; selected: --color-primary-bg, --color-primary-badge-text, --color-primary border, semibold and a leading ✓ (aria-hidden), so selection is never colour alone. "English" and "Deutsch" are literal endonyms with lang. This is the second in-app "choose one of N" control after the AutoItemizePage radio group; it is deliberately a compact option row because the menu must stay short.
Keyboard (menu button pattern): Enter/Space/ArrowDown on the avatar opens at the first row, ArrowUp at the last; ArrowDown/Up move across every row (each radio option is a stop) with wrap, Home/End jump; Escape closes and returns focus to the avatar; Tab closes, moves focus to the avatar and continues from there (no trap); outside press and route change close. Keyboard shortcuts opens KeyboardShortcutsHelp; on close focus returns to the avatar.
Floating menu button (below 1024px, signed off in #2206): neutral, not primary, so the shell contributes no primary-styled button: --color-bg-primary fill, --color-text-primary glyph, 1px solid var(--color-border-strong), --shadow-md; hover --color-bg-hover plus border-color: var(--color-primary); focus --shadow-focus plus border-color: var(--color-border-focus). Removed in #2207 (replaced by the bottom bar and More sheet).
Log out cannot fail visibly (AuthContext.logout() never rejects), so there is no error toast; the row shows "Logging out…" (aria-disabled) until the session guard navigates away.
Dark mode: Layer-2 tokens only, no override block. Motion: panel appears instantly; hover/focus transitions use --transition-button* and are off under prefers-reduced-motion. Print: bar hidden. Pages that size themselves to the viewport subtract the bar with calc(100dvh - var(--topbar-height)) from 1024px.
Contrast (light / dark): avatar and selected-option text 7.15 / 6.72, muted on panel 5.77 / 5.71, focus border on panel 5.17 / 5.75; all pairs belong in the #2200 contrast test.
Status: implemented by #2207 (visual spec: comment on issue #2207, amended by the implementation spec below). One shell below 1024px (tablets 768-1023px included, no rail), one from 1024px. useMediaQuery('(min-width: 1024px)') switches what is mounted (never CSS display:none, which would duplicate test ids and aria-current): below 1024px the top bar, bottom bar and More sheet; from 1024px the sidebar and desktop top bar. One <header>, one <nav aria-label="Main navigation">, one <nav aria-label="You are here">, one aria-current="page" at every width.
Top bar (56px + env(safe-area-inset-top)): same <header> and --topbar-height. Left slot: compact Breadcrumbs (layout="compact", one link ‹ <origin or nearest parent>, name "Back to ‹label›", test ids breadcrumbs-back for an origin and breadcrumbs-parent for the nearest parent, the same ids at every width). Back rule: the link calls navigate(-1) only when history.state.idx > 0 and the previous in-app pathname equals the link's target pathname; otherwise it is a normal link to its href (so "‹ Tasks" never lands on the Schedule) or, on section roots, the house name (falls back to appName). Centre: the page title, aria-hidden, shown only after the h1 has scrolled above the bar (IntersectionObserver). Right: Search icon (inert, aria-disabled, until story 1.5). PageBreadcrumbs portals into the slot at every width; no inline breadcrumb row below 1024px.
Bottom bar (64px content row + env(safe-area-inset-bottom)): fixed, --color-bg-primary, border-top: 1px solid var(--color-border), z-index: calc(var(--z-dropdown) + 1). Five slots from NavConfig PHONE_BAR: Home (badge slot, story 3.4) · Site diary · New (+, neutral circle, opens /diary/new until 2.3) · Photos · More (button, aria-haspopup="dialog", aria-expanded). Labels always visible, --font-size-xs, nowrap, overflow: visible, never shortened ("Bautagebuch" overhangs rather than truncates on a 320px phone). Rest --color-text-secondary (10.31 / 9.85); active --color-primary (5.17 / 5.75) + --font-weight-semibold + inset 0 3px 0 var(--color-primary) (three cues); focus inset 0 0 0 2px var(--color-border-focus); hover only under (hover: hover). Pressed (:active): --color-bg-tertiary, except on the active slot, which uses --color-bg-hover because --color-primary on --color-bg-tertiary is 4.07:1 in dark (AA needs 4.5:1); on --color-bg-hover it is 4.94 dark / 4.95 light. The More slot follows resolveNavActive exactly like the sidebar (one rule at every width): a page whose home is in More highlights it (aria-current="true"; the sheet row carries aria-current="page"). 404 and unknown routes highlight nothing; a member on an admin-only URL (No access) highlights Settings, so More is highlighted, as the desktop sidebar already highlights Settings. Tablet: slots capped at calc(var(--spacing-16) * 2), centred cluster. Published --shell-bottom-height on :root while mounted; content, full-height pages, sticky headers and Toast subtract or add it. The bar hides while an on-screen keyboard shrinks the visual viewport by 25% or more.
More sheet (new shared Sheet, in components/Modal/ with Modal and the shared useFocusTrap, because the pattern baseline counts every dialog role outside that folder): bottom-anchored, always mounted, inert while closed; role="dialog", aria-modal and aria-labelledby are set only while open, so a closed sheet never matches [role="dialog"] queries; slides with --transition-slow (instant under reduced motion), backdrop --color-overlay, panel --color-bg-primary with 1px solid var(--color-border-strong) top edge and --shadow-xl, max-height: calc(100dvh - var(--topbar-height)), 512px max width on tablets. Covers the bar; focus goes to the first row, traps, returns to More on Escape/backdrop/Close; rest of the shell is inert while open. Content: Tasks · Purchases · Money · Companies, Areas · History · Documents (Paperless only), Settings, then the user block (name and role, Account, Theme and Language as native-radio option rows in the #2206 recipe, Help, About with version and GitHub, Keyboard shortcuts only with a hardware keyboard ((any-pointer: fine), or a keydown seen this session whose target is not an editable field), which closes the sheet before opening its dialog (no stacked dialogs; focus returns to the More button), Log out). Bar and sheet links are NavAnchor anchors (useHref + useLinkClickHandler), not react-router Link. Rows 44px (--touch-target-min: 2.75rem, new Layer 2 token). The user logic is shared with UserMenu through one hook.
View switching ("Tasks ▾"): on a section-or-view page with 2 or more visible views, the h1 is the menu button (PageLayout's PageTitle helper, also used by TimelinePage; OverflowMenu renderTrigger, link rows, current cue: ✓, weight, --color-primary-bg, aria-current="page"); the main view comes first; changes use replace. The trigger always carries padding-inline: var(--spacing-2) with margin-inline: calc(-1 * var(--spacing-2)), so hover and open only add the --color-bg-tertiary background and never shift the text. Detail pages keep a plain object-name h1. No tab row at any width.
Safe areas and zoom: the bars and the sheet use env(safe-area-inset-*, 0rem); .pageContent has no safe-area side padding (without viewport-fit=cover the side insets are 0, and a non-zero value would break TimelinePage's margin: -2rem escape); no viewport-fit=cover; no user-scalable/maximum-scale; touch-action: manipulation only. Dark mode: Layer 2 tokens only, no override block. Retired by #2207: ThemeToggle, .footerLegacy, .menuFab, the drawer and its overlay.
Page gutter (the space between the viewport edge and page content), per breakpoint. The gutter is the sum of the shell's .pageContent padding and PageLayout's .container padding. Phone (up to 767px): 16px in total, owned by the shell: .pageContent uses padding: var(--spacing-4) and PageLayout's .container adds 0 side padding, so pages that do not use PageLayout (detail pages, the Diary create, edit and detail pages, AutoItemize, the Paperless review) are not flush against the screen edge. A card is 343px wide in a 375px viewport. TimelinePage's full-bleed escape on phones is calc(-1 * var(--spacing-4)). Tablet and desktop (768px and up): unchanged, .pageContent var(--spacing-8) plus PageLayout var(--spacing-8) = 64px per side. Full-height pages (data-layout='full-height') still remove the shell padding. No page adds its own side padding to compensate, and PageLayout must not add phone side padding (it would double the gutter). A page with its own root container (detail, Diary and form pages) sets that container's padding to 0 at 767px and below; full-height pages that drop the shell padding (AutoItemize, the Paperless review) give their header and body padding-inline: var(--spacing-4) instead. responsive/page-gutter.spec.ts asserts the 16px content edge.
Status: specified for #2211 (full spec: comment on issue #2211). New shared component CommandSearch (in allowedAdditions); Modal gains variant="palette", Sheet gains variant="fullScreen" (+ hideTitle, headerStart), AreaBreadcrumb gains variant="path". Reuse: Badge via useStatusBadgeVariants, Skeleton (inside aria-hidden), Spinner, EmptyState, FormError, NavAnchor. Mechanics (open state, ⌘K and / handling, history, focus, 401): Architecture › Command search.
Surface. From 1024px a centred palette dialog (min(40rem, 100vw - 2rem), --radius-lg, --shadow-xl, upper third of the screen); below 1024px (tablets included) a full-screen Sheet with the input in its 56px header and the Close X at the end. The two are mounted conditionally, never hidden with CSS. The body is mounted only while open and the page behind is inert, so a screen has one search field (the top bar trigger is a button). No recent searches; the query clears on close unless a list field prefills it.
Combobox. <input type="text" role="combobox"> (16px, so iOS does not zoom) with aria-controls, aria-autocomplete="list", aria-activedescendant; role="listbox" > labelled role="group" > role="option" rows (a row never contains the Call button; the button is its sibling in a role="presentation" wrapper). One persistent role="status" live region ("n results", "No results for ‹query›"); never both role="status" and aria-live. Down/Up move across groups with wrap, Home/End stay with the caret, Enter opens, Shift+Enter calls a company, Escape always closes, Tab cycles input, Clear and Close (options are never tab stops). ⌘K / Ctrl K works from inside text fields (a capture-phase listener on window that always calls preventDefault on a match, because useKeyboardShortcuts ignores modifiers and the diary filter bar stops keydown propagation; both ⌘K and Ctrl K are accepted on every platform) and toggles; / focuses the page's data-page-search field or opens the palette. Focus returns to the opener (the list field for "Search everywhere"); after choosing a result focus goes to the destination h1.
Rows. min-height: var(--touch-target-min); line 1 label (--font-size-base, medium) plus a status chip or the Call button; line 2 --font-size-xs muted: sublabel · phone · date · full area path (ancestors --color-text-muted, last level semibold) · parent · "Found in ‹field›". Active = aria-selected: --color-primary-bg fill plus a 3px --color-primary start edge (pointer movement sets it, so there is no separate hover state). Match highlight = <mark> with semibold weight and a 2px --color-primary underline, never a fill; ranges are mapped through the German fold and dropped when they cannot be mapped exactly.
States. Empty: one hint line. One character: destinations only plus "Type at least 2 characters". Loading: skeleton after 200ms, old results stay with a spinner on refetch. No results: EmptyState. Documents unavailable: a warning-tinted note in the Documents group (not red). Failure: FormError plus "Try again". Debounce 250ms trailing; immediate for prefill, retry and Show more; AbortController per request; IME composition ignored. "Show n more" is an option row (limit 20, then a "Showing 20 of 37" note).
List-field row. "Search everywhere for ‹text›" (button, 44px, --color-primary) sits under a list's search input from 2 characters, also at 0 filtered results; DataTable takes searchEverywhere (default true; only Users turns it off; the diary filter bar renders the row too; the documents browser gets none, its search exists only inside pickers). The row's text truncates with CSS ellipsis (full text in the DOM and title), never cut in code.
Dark mode / contrast (light / dark): muted on panel 5.77 / 5.71, muted on the active row 4.73 / 4.73, muted on footer 5.52 / 4.89, focus border 5.17 / 5.75. Layer 2 tokens only. Motion: palette fade with translateY(-8px) over --transition-medium; instant under prefers-reduced-motion.
Status: specified for #2212 (full spec: comment on issue #2212). New shared components CreateMenu and ObjectForm (both in allowedAdditions). Reuse: OverflowMenu (desktop panel, link rows), Sheet (phone New sheet; full-screen variant from #2211 with a new footer slot), Modal, ConfirmDialog, FormError, Skeleton, .btnSecondary / .btnPrimary.
New menu. Order: Diary entry, Task, Purchase, Invoice, Offer, Company, Milestone; then, on Money pages only (absent elsewhere, not disabled), Funding source and Grant. No role or Paperless gating of rows. Rows are anchors with real hrefs. Desktop: the neutral "New ▾" button opens the UserMenu panel recipe (min-width: 14rem, rows --spacing-10, instant). Below 1024px: the bottom bar + slot is a button (aria-haspopup="dialog") opening a bottom Sheet titled "New" (rows 44px). Context pre-fill (areaId, companyId, date) is provided by pages through useCreateContext and carried as query parameters; a pre-filled field adds no chrome and does not make the form dirty. Programmatic opening: useCreateMenu().open(); the shell registers n as the fallback, a page's own n wins.
ObjectForm. Dialog state is the URL (/…/new, ?create=1); the previous page stays visible behind. Modal (min(40rem, 100vw - 2rem)) from 768px, full-screen Sheet below; form state lives above the container so resizing keeps input. Essential fields first, the rest under a collapsed "More details" (auto-expands for pre-fill, error or change). Required = visible "(required)" text plus aria-required. Footer: optional "Add another after saving", Cancel, Save (the only primary). Validation on Save: first invalid field focused, no summary banner, Save never natively disabled; busy = aria-disabled "Saving…". Server errors in a FormError banner. Dirty close (Close, Cancel, Escape, backdrop, Back) asks "Discard changes?" (Discard / Keep editing); pre-fill is not dirty. Save lands on the new object (focus its h1), or with "Add another" stays and shows a role="status" confirmation; created from a picker, the dialog shows the child form as a second step ("‹ Back to ‹parent›") and returns with the item selected, so dialogs never stack. Baseline: no literal dialog roles outside components/Modal; the shell adds no primary button; ObjectForm, rendered from the router root, has the only one: Save.
Status: specified for #2213 (full spec: comment on issue #2213; the implementation spec amends it, see the Deviation Log). New shared components CallButton and TradesManager (both in allowedAdditions); SearchPicker gains a create-row mode. Reuse: DataTable, FilterChipGroup, OverflowMenu, ConfirmDialog, Modal/Sheet, ObjectForm, TradePicker, Skeleton, EmptyState, FormError. The Trade header enum filter is replaced by chips (one filter, one control).
CallButton (components/CallButton/). One implementation of the Call recipe for the Companies list, the command search row and the Company page. An <a href="tel:…"> (the href keeps digits and +) styled as .btnSecondary (composes on the plain .button selector only) with the 14px phone glyph (aria-hidden) and the text "Call", or "Call ‹number›" in phone cards (showNumber). Height --spacing-8 with a fine pointer, --touch-target-min under (pointer: coarse), and always 44px with fullWidth (card variant: full card width, centred). Focus: box-shadow: var(--shadow-focus) plus border-color: var(--color-border-focus). The accessible name always starts with the visible text (WCAG 2.5.3): "Call" gives "Call ‹name› on ‹number›"; "Call ‹number›" gives "Call ‹number›, ‹name›". The number is also real text in the Phone cell so it can be read and copied. A company with no number anywhere shows "—" and no button. Dark mode: Layer 2 tokens only.
Companies list. Columns: Name (link; default A–Z, no sort indicator until the user sorts), Trade (plain text), Phone (CallButton plus number; the company number, else the first contact person's), Contact persons (count, tabular numerals, 0 as "—"), Last on site, ⋯ (OverflowMenu, Delete). Hidden by default in column settings: Address, Notes, E-mail (mailto link), Date added, Last updated. Trade chips: FilterChipGroup with "All trades" first, 44px, scrolling horizontally, tradeId in the URL; a chip for a trade that no longer exists falls back to "All trades". One primary on the page: "New company" (the empty state shows its own and the header one hides). Below 768px: cards with the full-width Call button last; at 768-1023px the table, hiding Contact persons before Phone if it does not fit. A phone number is reached by More, Companies, Call (phone) or sidebar Companies, Call (desktop).
TradesManager (components/TradesManager/, hosted in Modal from 768px and a bottom Sheet below; opened by the "Trades" .btnSecondary and ?trades=1, which Back closes). Content: a "New trade" input (16px, maxLength 200 to match the server) with Add; an A–Z <ul aria-label="Trades"> whose rows (min-height: var(--touch-target-min)) show the name and a --font-size-xs --color-text-muted line "‹n› companies", each with a ⋯ menu: Rename (in place; Enter saves, Escape cancels to that row's ⋯; renaming a built-in trade replaces its translation and says so), Merge duplicates (a ConfirmDialog with a TradePicker "Keep this trade" and a server-counted line; the danger action is aria-disabled until a target is chosen; no Undo, and the dialog says so) and Delete (ConfirmDialog; blocked with "Merge instead" while companies use the trade). No primary button inside; the footer is one "Done" .btnSecondary. Busy = aria-disabled; one persistent role="status" announces changes. Focus after a merge goes to the kept trade's row; after a delete to the next row.
SearchPicker create row. createAction.show is 'always' (default, the old behaviour) or 'noExactMatch': the row "New company ‹text›" is last, only for a non-empty trimmed query, after loading and without an error, when no result equals the text (umlaut-folded on both sides); with zero results it replaces the "No results" line. min-height: var(--touch-target-min), --color-primary text, leading + (aria-hidden), the label truncates with CSS ellipsis and carries title. Choosing it opens the ObjectForm child step with the name prefilled (not dirty); it returns with the item selected and focus on the picker input.
Status: specified for #2215 (full spec: comment on issue #2215; the implementation spec amends it, see the Deviation Log). New shared component ReferenceList (components/ReferenceList/, already in allowedAdditions; the planned entry is consumed in the same PR). Reuse: PageLayout, shared .card, OverflowMenu, Modal/Sheet, ConfirmDialog, Skeleton, EmptyState, FormError, NavAnchor. No new token, no new dialog primitive.
Page layout. PageLayout maxWidth="narrow", h1 "Project setup" (NavConfig main view of Settings: no breadcrumbs, no Back). One column .sections (max-width: 40rem, gap: var(--spacing-6)), left aligned, the same look as Settings > Account. The phone gutter is the shell's 16px (the page adds none), so the column is 343px at 375px and 640px from 768px (912px of room at 1280px). Card padding --spacing-4 on phones, --spacing-6 from 768px. Section order, which is also tab order: House (#house), Cost categories (#cost-categories), Purchase categories (#purchase-categories), Photo orientations (#orientations), the interim Areas card (#areas, only while /areas is unserved), then the "Managed on other pages" card with the Trades (#trades) and Areas (#areas, once served) pointer rows. Each card is a <section aria-labelledby> with one tabIndex={-1} h2; there is no tab row and no index column.
Section anchors. #house, #cost-categories, #purchase-categories, #orientations, #areas, #trades. The anchors are the targets the route map already uses (/settings/manage?tab=orientations goes to #orientations). Arriving on one, by hash or by an old ?tab= link, scrolls the section into view (instant under prefers-reduced-motion: reduce) and focuses its h2 (for #trades and #areas pointer rows, the row's anchor). scroll-margin-top: calc(var(--topbar-height) + var(--spacing-4)). A ?tab= link is replaced by its #section with a history replace, so Back is never trapped (D-10); loading the page writes nothing to the URL; ?tab=areas goes to /areas once it is served.
House. One form: House name, House address (textarea, 3 rows), "Language of your entries" (.select: Deutsch, English; default Deutsch in this instance). Each field has a --font-size-xs --color-text-muted hint; the language hint is a complete sentence and the pointer to the app language is a separate "Set the app language in Account" link below it. One Save (.btnPrimary), aria-disabled while unchanged or saving. Success .bannerSuccess (role="status"), error .bannerError (role="alert").
ReferenceList. One card per list, strings all from props (no feature namespace inside the component), parameterized by testIdPrefix and anchorId.
- Header: h2 left (
--font-size-xl, semibold) and New right (.btnSecondaryCompact, visible text "New", accessible name "New ‹item›";min-height: var(--touch-target-min)below 768px and on coarse pointers). One line of--font-size-smmuted description under it. New isaria-disabledwhile the list loads. - Row (
<li>in<ul aria-label>): grid handle, color dot, text, ⋯.min-height: calc(var(--touch-target-min) + var(--spacing-2))(52px),padding: var(--spacing-1) var(--spacing-2),1px solid var(--color-border),--radius-md,--color-bg-primary, hover--color-bg-secondary. Gap between rows--spacing-2.- Handle: a 44 x 44px
<button>with the grip glyph (aria-hidden),--color-text-muted,cursor: grab,touch-action: noneon the handle only,aria-label="Reorder ‹name›", focusbox-shadow: var(--shadow-focus). - Color dot: 12px circle (
--radius-circle,1px solid var(--color-border-strong)), decorative (aria-hidden), only in lists that have color. A row without color keeps an empty dot so names stay aligned. No "#N", no sort number, no "built-in" badge. - Text: name (
--font-size-sm, medium, ellipsis withtitle), optional description (--font-size-xs, muted, 2 lines at most). - ⋯:
OverflowMenu, 44 x 44px,aria-label="Actions for ‹name›". Items: Edit, Move up, Move down, separator, Delete (destructive). Move up isaria-disabledon the first row and Move down on the last, and both while a reorder is pending.
- Handle: a 44 x 44px
- States: loading is
Skeleton lines={3}under the header; a load error isFormErrorplus Retry (focus moves to the h2 first, so it never falls to<body>); empty isEmptyStatewith the message only ("No ‹items› yet."), because the header New is the action. - Sections: Cost categories (name, color, description), Purchase categories (name, color), Photo orientations (name, description).
New / Edit dialog. Modal from 768px (width: min(28rem, 100vw - 2rem) = 448px), bottom Sheet below, footer built once for both. Title "New ‹item›" / "Edit ‹item›". Name is required (visible "(required)" plus aria-required) and takes initial focus; Description (textarea, 3 rows) and Color are shown per list. Color is a native <input type="color"> (44px tall) plus a checkbox "No color": a new item starts with No color checked and the input disabled, an existing item with a color starts unchecked, and saving with it checked stores no color. Nothing is random or preset. Footer: Cancel (.btnSecondary) and Save (.btnPrimary), equal width in the Sheet and right-aligned in the Modal; Save is the dialog's only primary, aria-disabled "Saving…" while busy, never natively disabled. Validation on Save only: an empty name shows its message under the field and focuses it, with no summary banner. Server errors sit in a FormError at the top of the body (a duplicate name has its own sentence) and focus the name. Editing a translated built-in row adds the hint "This name is translated. If you change it, your text is shown in every language."; an unchanged name is not sent, so the translation survives. Closing with changes (Cancel, Escape, backdrop) opens a "Discard changes?" ConfirmDialog that stacks above in open order; the lower dialog is not dismissible while it is open, so one Escape closes only the top one. After Save, the dialog closes, the page's status region announces "‹name› added" or "‹name› saved", and focus goes to that row's ⋯.
Reorder. Three ways, all ending in one request with the full id list. Drag: from the handle, after 4px of movement; the row lifts (--shadow-md, --color-bg-secondary), a 2px --color-primary line marks the drop slot, and the page scrolls near the viewport edge; a plain click on the handle does nothing. Keyboard: Space or Enter on the handle picks up (aria-pressed), Arrow Up/Down move one slot, Space or Enter drops, Escape cancels, blur cancels; each step is announced as "‹name›, position 2 of 5." and focus stays on the handle. No drag: row ⋯ > Move up / Move down. While a request is pending every handle and Move item is aria-disabled. A failure rolls the list back and shows "Couldn't save the new order. Try again." in a .bannerError (role="alert") in the card. Lift and drop transitions use var(--transition-fast) on box-shadow and background only, and none under prefers-reduced-motion: reduce.
Delete. Row ⋯ > Delete opens a danger ConfirmDialog "Delete ‹name›?" with irreversible. Photo orientations show the server-counted consequences (photos using the orientation). Cost and purchase categories show no counts: the server hides the in-use details of a category conflict (the #315 hardening), so when a category is still in use the same dialog switches to its blocked state with one fixed sentence per list and no Delete button: cost categories "‹name› is still used by cost lines or grants, so it can't be deleted."; purchase categories "‹name› is still used by purchases, so it can't be deleted." No number, no list of records, in either state. After a delete, focus goes to the next row's ⋯, else the previous row's, else the section h2.
Interim Areas card. While isRouteServed('areas') is false (the Areas index is #2218), the old Areas manager appears unchanged as a card "Areas" (#areas, test id project-setup-areas) between Photo orientations and "Managed on other pages", and the Areas pointer row is absent. It keeps its old create form and "#N" labels, a bounded exception to the no-"#N" rule. The URL for the unserved route is built with navHref('areas'), never routeUrl('areas'). #2218 deletes the card and the pointer row takes over.
Pointer rows. One card "Managed on other pages" with a <ul> of full-row NavAnchors (52px minimum, 1px solid var(--color-border), --radius-md, gap --spacing-2): name (--font-size-sm, medium) over a muted --font-size-xs line, and a decorative › on the right. Trades "Managed on Companies" opens the Trades sheet (/companies?trades=1); Areas "Managed on the Areas page" shows only while the route is served. Hover --color-bg-secondary, focus box-shadow: var(--shadow-focus).
Page-level announcements. One always-mounted, visually hidden role="status" announces added, saved, deleted and reorder positions (no aria-live beside the role).
Baseline. ManagePage primary buttons go from 9 to 4 (House Save, the ReferenceList dialog Save, and the interim Areas card's create and inline-edit Save; 2 after #2218). handRolledDialogs stays 5. No other count rises.
E2E sizes. Column 343 / 640 / 640px at 375 / 768 / 1280px; card padding 16 / 24 / 24px; card gap 24px; row at least 52px; handle and ⋯ 44 x 44px; dialog Sheet below 768px and a 448px Modal from 768px; no role="tablist"; no "#N" or "Sort order" in the three lists; no horizontal overflow down to 320px. Test ids: project-setup-house, -cost-categories, -purchase-categories, -photo-orientations, -areas, -pointers; per list ‹prefix›-new, -row-‹id›, -handle-‹id›, -menu-‹id›, -move-up-‹id›, -move-down-‹id›, -name, -no-color, -save.
tokens.css :root {
--color-bg-primary: #ffffff; /* Layer 1 + 2: palette alias */
}
tokens.css [data-theme="dark"] {
--color-bg-primary: #1a1a2e; /* Layer 3: dark override */
}
MyComponent.module.css {
.card { background: var(--color-bg-primary); } /* no change needed */
}
When document.documentElement.dataset.theme = "dark", the browser's CSS cascade automatically picks up the [data-theme="dark"] overrides. No JavaScript or class toggling in components.
Location: client/src/contexts/ThemeContext.tsx
import { useTheme } from '../../contexts/ThemeContext.js';
function MyComponent() {
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: 'light' | 'dark' | 'system' (user preference)
// resolvedTheme: 'light' | 'dark' (actual applied theme)
}Types exported:
ThemePreference = 'light' | 'dark' | 'system'ResolvedTheme = 'light' | 'dark'-
ThemeContextValue— shape of the context value
Provider setup (already in App.tsx):
<BrowserRouter>
<ThemeProvider>
<AuthProvider>{/* app content */}</AuthProvider>
</ThemeProvider>
</BrowserRouter>ThemeToggle was deleted in #2207. Theme is chosen as Light / Dark / System in the user menu (from 1024px) and in the More sheet's user block (below 1024px); both read and write ThemeContext, so the cycle semantics live in the context, not in a component.
The theme preference is stored under the key 'theme' in localStorage. Valid values: 'light', 'dark', 'system'. Defaults to 'system' if not set or invalid.
When theme === 'system', ThemeContext reads window.matchMedia('(prefers-color-scheme: dark)') and subscribes to change events. The resolved theme updates automatically when the OS switches between light and dark mode.
When adding a new semantic token that needs a dark mode override:
-
Layer 2 — Add the token in
:rootwith a light-mode value referencing a palette token::root { --color-my-new-token: var(--color-blue-100); }
-
Layer 3 — Add the override in
[data-theme="dark"]:[data-theme='dark'] { --color-my-new-token: rgba(59, 130, 246, 0.15); }
-
Use in components as normal:
.element { background: var(--color-my-new-token); }
jsdom (used by Jest) does not implement window.matchMedia. The test setup at client/src/test/setupTests.ts provides a polyfill. When writing components that call useTheme(), tests must either:
- Wrap in
<ThemeProvider>(integration-style), or - Mock the
ThemeContextmodule withjest.unstable_mockModule()
Source files live in logo/ at the project root. Each asset has a light variant (black strokes, for light backgrounds) and a dark variant (white strokes, for dark backgrounds):
| File | Type | Use Case |
|---|---|---|
logo/icon_light.svg |
Icon only | Favicon, sidebar logo, docs navbar — light mode |
logo/icon_dark.svg |
Icon only | Sidebar logo, docs navbar — dark mode |
logo/logo_light.svg |
Full logo + text | README hero, docs intro — light mode |
logo/logo_dark.svg |
Full logo + text | README hero, docs intro — dark mode |
All variants share rgb(59,130,246) (blue-500) as the accent color and rgb(209,213,219) gray fills. The difference between light and dark variants is stroke color: black strokes for light backgrounds, white strokes for dark backgrounds.
Component: client/src/components/Logo/Logo.tsx
The Cornerstone logo is a keystone / arch motif — the central wedge stone of an arch, flanked by two column blocks on a shared base. It represents the product's role as the foundational element of a home-building project.
The component is theme-aware: it uses useTheme() from ThemeContext to select the correct image source (/logo.svg for light mode, /logo-dark.svg for dark mode).
Files served:
-
client/public/logo.svg— light variant (copy oficon_light.svg) -
client/public/logo-dark.svg— dark variant (copy oficon_dark.svg)
Usage:
import { Logo } from '../../components/Logo/Logo.js';
// Automatically selects light/dark variant based on theme
<Logo size={32} className={styles.logo} />;Sizes: The logo renders cleanly from 16px (very small) to 200px+. Default size prop is 32.
File: client/public/favicon.svg
The favicon uses the light variant (icon_light.svg) — black strokes are visible on most browser chrome backgrounds. The CopyWebpackPlugin in client/webpack.config.cjs copies client/public/ into the dist/ output directory so the favicon is served correctly in both development and production.
The Docusaurus docs site uses theme-aware logos via the srcDark config option:
-
Navbar logo:
docs/static/img/logo.svg(light) +docs/static/img/logo-dark.svg(dark) -
Intro hero:
docs/static/img/logo-full.svg(light) +docs/static/img/logo-full-dark.svg(dark), rendered via DocusaurusThemedImagecomponent -
Favicon:
docs/static/img/favicon.svg(light variant)
The README uses a <picture> element with prefers-color-scheme media queries (GitHub renders these natively) to swap between logo/logo_light.svg and logo/logo_dark.svg.
The brand primary color is blue-500 (rgb(59,130,246) / #3b82f6). This appears in:
- The cornerstone block fill in all logo variants
- Primary action buttons
- Active nav item in the sidebar
- Focus borders on form inputs
- The
--color-primarysemantic token (light mode)
The PhotoAnnotator component (in client/src/components/photos/PhotoAnnotator/) is an in-modal drawing editor that layers an SVG overlay on top of a photo and exposes a ToolPalette for shape drawing. It opens within the existing PhotoViewer modal shell.
Three vertical zones within the annotator:
-
ToolPalette —
min-height: 56px(NOTheight: 56px— usemin-heightso the bar expands when items wrap onto a second row);background: var(--color-bg-primary);border-bottom: 1px solid var(--color-border). The palette usesflex-wrap: wrapso items reflow at narrow viewports when the font-size group is visible. -
Photo + SVG canvas —
flex: 1; dark background (var(--color-overlay)); photo isobject-fit: contain; SVG overlay isposition: absolute; inset: 0; touch-action: none -
Action bar —
min-height: 56px; same style asPhotoViewerinfo bar (rgba(0,0,0,0.7)background); contains Cancel (shared.btnSecondary) and Save (shared.btnPrimary)
A reusable icon button class for both the ToolPalette and the PhotoViewer info-bar additions. Two surface variants:
-
Dark surface (info bar, over
rgba(0,0,0,0.7)): transparent background, white icon (var(--color-text-inverse)),rgba(255,255,255,0.15)hover background,box-shadow: 0 0 0 2px rgba(255,255,255,0.6)focus ring -
Light surface (ToolPalette): transparent background,
var(--color-text-secondary)icon,var(--color-bg-tertiary)hover,var(--shadow-focus)focus ring (standard blue)
Minimum touch target: 44×44px on all breakpoints (min-width: 44px; min-height: 44px).
Active tool state (light surface): background: var(--color-primary-bg); color: var(--color-primary); border: 2px solid var(--color-primary-active). Use var(--color-primary-active) (#1d4ed8) for the active border — not var(--color-primary) — because --color-primary (#3b82f6) against the tinted --color-primary-bg background only achieves 2.25:1 contrast, below the WCAG 1.4.11 minimum of 3:1. --color-primary-active achieves 6.67:1 against white.
Color swatch buttons are visually 24×24px circles but must have a 44×44px tap area. Achieve this with padding on the button while keeping box-sizing: content-box:
.swatchButton {
width: 24px;
height: 24px;
padding: 10px; /* 24 + 20 = 44px tap area */
box-sizing: content-box;
border-radius: var(--radius-circle);
border: 2px solid var(--color-border);
cursor: pointer;
background: none;
}The swatchButtonActive state adds border-color: var(--color-text-primary) and box-shadow: 0 0 0 2px var(--color-bg-primary) for the selection ring — a non-color indicator (ring shape) in addition to the border color change.
Three icon buttons added to the right side of the info bar, visible conditionally:
| Button | Visibility | Icon |
|---|---|---|
| Annotate | Always (authenticated) | Pencil SVG |
| View Original toggle | Only when photo.annotatedAt != null
|
Eye / Eye-slash SVG |
| Clear Annotations | Only when photo.annotatedAt != null
|
Trash SVG |
Clear Annotations opens a Modal confirmation dialog (not window.confirm()).
Annotation colors are fixed content values — they must not be design tokens and must not change with dark mode. Define them as TypeScript constants:
export const ANNOTATION_COLORS = {
red: '#dc2626',
yellow: '#facc15',
green: '#22c55e',
blue: '#3b82f6',
black: '#000000',
white: '#ffffff',
} as const;Color swatches use background-color set via style prop (inlined fixed hex). This is the sole exception to the no-hardcoded-hex rule in component CSS — these are user-visible content colors, not theming.
| State | Treatment |
|---|---|
| Draft (dragging) | Current color stroke, stroke-dasharray: 6 4, opacity: 0.8, pointer-events: none
|
| Committed (Rectangle) | Solid stroke, fill: none, opacity: 1
|
| Committed (Highlight) | No stroke, fill at fill-opacity="0.4"
|
| Selected | Shape + bounding box (stroke: var(--color-primary), dashed 4 2) + resize handles (8×8px <rect>, fill var(--color-bg-primary), stroke var(--color-primary), strokeWidth="1.5") |
Selection handle counts by shape type: rect/highlight/callout = 8 handles (corners + edge midpoints); arrow/line/measurement = 2 endpoint handles; ellipse = 4 cardinal handles (N/S/E/W); text = 4 corner handles (move only); freehand = dashed bounding box only (no handles).
All shape operations must be announced via the aria-live="polite" aria-atomic="true" live region. Required announcements:
| Operation | i18n key | Notes |
|---|---|---|
| Rectangle added | shapeAddedRectangle |
After COMMIT_DRAFT |
| Highlight added | shapeAddedHighlight |
After COMMIT_DRAFT |
| Arrow added | shapeAddedArrow |
After COMMIT_DRAFT |
| Line added | shapeAddedLine |
After COMMIT_DRAFT |
| Ellipse added | shapeAddedEllipse |
After COMMIT_DRAFT |
| Text added | shapeAddedText |
After inline input commit |
| Callout added | shapeAddedCallout |
After inline input commit |
| Measurement added | shapeAddedMeasurement |
After inline input commit |
| Freehand added | shapeAddedFreehand |
After COMMIT_DRAFT |
| Shape deleted | shapeDeleted |
After DELETE_SELECTED |
| Undo performed | undoPerformed |
After undoStack.undo() |
| Redo performed | redoPerformed |
After undoStack.redo() |
| Callout tail phase | calloutTailPositioning |
When phase transitions box→tail |
| Saving | saving |
On save start |
| Saved | saved |
On save success |
| Save error | saveError |
On save failure |
-
Escape: Cancels current operation. PhotoViewer handles Escape at the outer level (
windowlistener) and callshandleAnnotationCancel. PhotoAnnotator should NOT register a duplicate Escape handler to avoid double-execution. -
Arrow keys: When a shape is selected (
state.selectedShapeId != null), arrow keys nudge the shape 1px (or 10px with Shift). When no shape is selected, arrow keys are not intercepted. - Delete / Backspace: Deletes the selected shape.
- Cmd+Z / Ctrl+Z: Undo; Ctrl+Shift+Z / Ctrl+Y: Redo.
-
Toolbar tab order: The
role="toolbar"container should implement roving tabindex (only the active tool button in the tab order; Left/Right arrows move between tools). The color, stroke, and font-size sub-groups userole="radiogroup"+role="radio"which already have their own arrow-key navigation per ARIA spec.
No local [data-theme="dark"] blocks needed in the annotator CSS. All surfaces use semantic tokens that switch automatically. The info bar and action bar are theme-invariant dark overlays — their rgba(0,0,0,0.7) background is intentional and does not change with theme. Inline text input uses color: var(--color-text-inverse) (not the literal keyword white) on its dark overlay background.
Wrap all transform and transition rules in @media (prefers-reduced-motion: no-preference) blocks (positive guard). This applies to swatch hover scale, all icon button background-color transitions, and any animation added in the future.
Accepted deviations from the visual spec on issue #2161, requested by the dev-team-lead:
-
Limit field is
<input type="text" inputMode="decimal">with live validation, nottype="number". Rationale:type=numbergives locale-inconsistent decimal handling (comma vs. point) and silently swallows invalid input, so the 1-decimal/min-1 rules cannot be reported. Requirements preserved: visible<label>,aria-describedbyto helper andFormError variant="field",aria-invalid, text (not color-only) errors, 44px min height on mobile. Applies as a pattern for any future decimal field that needs locale-aware validation messages. -
Sizing failure card has an extra "Continue without them" action alongside Retry and Back (DOM order: Retry
btnPrimary, ContinuebtnSecondary, BackbtnSecondary). It lets the user export without attachments that cannot be fetched; those documents are excluded from the sizing. The failure is aFormError variant="banner"plus a list of failed documents. Focus: on failure it moves to Retry; "Continue without them" advances to step 5 and the generic step-change effect focuses the step-5 heading (no dedicated target); Back/Cancel returns to step 4 and focuses the step-4 heading. -
Stale-files state with an "Update files" button. When settings change after parts were generated, the
FileListstays visible with its per-row actions disabled, and a notice is passed aschildren: a plain<p>(no live role, no banner; it is static text, not an announcement) in a.partsStaleflex row (--font-size-sm,--color-text-secondary, wrap,--spacing-2gap) next to abtnSecondaryCompact"Update files" button. It is not abannerWarning. There is no dedicated focus move after "Update files": the button stays where it was while the parts regenerate. The 44px mobile touch-target minimum applies to the compact button (and to the preview part select). Focus management exists only for the upload round, which returns focus to theFileListheading (headingRef,tabIndex={-1}) when it finishes, whether it succeeded or partly failed.
| Date | Was | Now | Reason |
|---|---|---|---|
| 2026-10-02 |
var(--color-slate-300) #64748b |
var(--color-slate-250) #8392aa |
Old value was 3.58:1 / 3.34:1 / 3.07:1 on bg-primary / bg-secondary / bg-tertiary, failing WCAG AA 4.5:1 for normal text. New palette step --color-slate-250: #8392aa (between slate-300 and slate-200) gives 5.41:1 / 5.04:1 / 4.64:1 and stays below --color-text-subtle/--color-text-body (slate-200 #94a3b8: 6.65 / 6.20 / 5.71:1). Light-mode muted (gray-500 #6b7280) is 4.83:1 on bg-primary and 4.63:1 on bg-secondary (pass), but only 4.39:1 on bg-tertiary #f3f4f6, which failed AA (fixed in the light-mode row below). |
| 2026-10-02 |
var(--color-gray-500) #6b7280 (light muted and light quoted) |
var(--color-gray-550) #656c7a |
Old value was 4.83 / 4.63 / 4.39:1 on bg-primary #fff / bg-secondary #f9fafb / bg-tertiary #f3f4f6, failing on tertiary. New palette step --color-gray-550: #656c7a (between gray-500 and gray-600) gives 5.28 / 5.05 / 4.80:1. Gray-600 was rejected because it is --color-text-subtle (7.56 / 7.23 / 6.87:1); gray-550 stays visibly lighter than subtle and than secondary/body (gray-700 #374151, 10.31 / 9.86 / 9.37:1). --color-text-quoted light moves with it. |
Note: --color-source-0-dot still uses slate-300; it is a non-text indicator (3:1 threshold) and is unchanged.
| Date | Change | Reason |
|---|---|---|
| 2026-10-10 | Light primary moved from blue-500 to the blue-600 family (hover 700, active 800, focus border 600); light success to green-650; light danger to red-600 family (hover 700, active 800). | White text on the old fills was below 4.5:1 (blue-500 3.68:1). blue-600 gives 5.17:1, green-650 5.48:1. |
| 2026-10-10 | Dark surfaces re-tuned: page slate-950 (new --color-bg-page), cards slate-600, secondary/hover slate-550 (new), tertiary slate-500; borders slate-400 / slate-300; muted, subtle, body, placeholder and quoted text slate-200; danger red-350 (new) with red-300 hover. |
Dark cards were nearly invisible against the page; borders and muted text failed AA. Cards now 1.26:1 against the page. |
| 2026-10-10 | New palette: slate-950, slate-550, green-650, red-350, red-800. --color-gray-550 changed #656c7a to #5f6674. Removed slate-800, slate-700, slate-250, --color-sidebar-active. |
AA on bg-page and tertiary for muted text; unused tokens deleted. |
| 2026-10-10 |
--color-text-placeholder is text-only (light gray-550, dark slate-200); --color-text-disabled is non-text-only (light gray-400, dark slate-400). |
Placeholders are readable content and need 4.5:1; disabled is exempt. |
| 2026-10-10 | Active nav item uses --color-nav-active-bg/-text (blue-600 / white, both themes), weight 600. Pagination hover: --color-bg-hover plus primary border. |
Spec deviation: --color-border-strong is already the resting border, so it showed no hover change. |
| 2026-10-10 | Not-found h1 reads "Task not found" / "Purchase not found" (new glossary keys), not the existing "Work Item Not Found" text. | Visual spec R2 said "keeps the existing text", but that text uses banned words; the h1 is page identity (#2202). |
Superseded: the 2026-10-02 --color-text-muted rows above (slate-250 #8392aa, gray-550 #656c7a) describe values that no longer exist.
Where the shipped behaviour differs from the first spec text:
- (D3) The "Search everywhere" row is off only for Users. Funding sources (Budget sources) and Grants (Subsidy programs) are not
DataTablepages and carry no row; the diary filter bar does. The documents browser gets no row. - (D7) Results without a label reuse the names the object's own page uses: an invoice or offer without a number is labelled "Invoice" / "Offer" and a diary entry without a title uses the untitled diary name with its type word and short date.
- (D8) The row text truncates with CSS ellipsis, not at 40 characters.
- (D10) ⌘K / Ctrl K is a capture-phase
windowlistener (not adocumentlistener) that always callspreventDefaulton a match, because the diary filter bar stops keydown propagation. Both ⌘K and Ctrl K work on every platform.
Where the shipped behaviour differs from the first spec text:
- (a) The shell adds no primary button.
ObjectForm, rendered from the router root, has the only one: Save. - (b) The discard confirm keeps the shared danger action (not
tone="primary"). - (c) A pointer open of the New menu keeps focus on the trigger; only a keyboard open moves focus to the first row.
- (d) The page behind the dialog is hidden from assistive technology by
aria-modal, not byinert. - (e) The Gantt view pre-fills no date for the New menu.
Where the implementation spec differs from the first visual spec:
- (S5) The trade name input allows 200 characters (the server limit), not 100.
- (S6) The Call button's accessible name always starts with its visible text. The first spec claimed "Call ‹number›" is a prefix of "Call ‹name› on ‹number›"; it is not, so that variant is named "Call ‹number›, ‹name›".
- (S7) A hidden-by-default E-mail column keeps the mailto links the old Contact column carried (MNY-136 "Call or e-mail").
- (S8) The default Name sort shows no
aria-sortindicator:DataTablehas no default-sort concept, and every list shows none until the user sorts. - (E1) The company fields of the invoice create modal and the purchase form become search pickers with the create row, so AC5 holds there; other native company selects (task assignment, filters, detail pages) stay selects.
- (E2) Creating a company from the invoice edit modal stacks one dialog over it (the first spec said dialogs never stack). Accepted with a guard: the lower dialog is not dismissible while the company form is open, so one Escape closes only the top dialog and Tab stays inside it; focus returns to the picker input.
Where the implementation spec differs from the first visual spec (comment on issue #2214), all accepted:
- (A1) The Regenerate
ConfirmDialoguses the default danger tone, nottone="primary".ConfirmDialogsupports onlydanger; a primary branch would put a primary-class button into the shared component and raiseprimaryButtonson every screen that imports it. Invalidating the old token is destructive for the connected apps, so danger fits, and it matches Revoke. Wording is unchanged ("Create a new token?" / "Create new token"). - (A2) The Copy and Hide token buttons sit outside the token's
role="status" aria-atomic="true"region, which wraps only the warning and the<code>. A separate, always-mounted, visually hiddenrole="status"(noaria-atomic) announces "Token copied", so the "Copy" to "Copied" label swap never re-reads the secret. - (A3) Busy buttons use
aria-disabled="true"and stay focusable; fields arereadOnlywhile busy, neverdisabled. Focus after Generate or Regenerate goes to Copy, after Hide token to Regenerate, after Revoke to the section heading (tabIndex={-1}), never<body>. - (A4) The first section heading is "Your details", not "Profile": "Profile" is a former name of Account in the glossary (
formerly), so it may not be a label. Capmap placement strings that sayAccount > Profilename the surface and should be updated toAccount > Your detailsin the same PR so the capmap and the page agree. - (A5) The password form shows for
authProvider === 'local', including a local account linked to single sign-on (it still has a password). The SSO note replaces the form only for SSO-only accounts. The Sign-in method row shows whenoidcEnabled || authProvider === 'oidc' || oidcLinked.
The live E2E run found 48px side gutters at 767px and below (.pageContent 32px plus PageLayout 16px), against the intended 16px. The shell CSS is fixed so the phone gutter is 16px for every page, owned by the shell (.pageContent var(--spacing-4) at 767px and below, PageLayout .container adds 0 on phones, because many pages do not use PageLayout); tablet and desktop are unchanged at 64px per side. The first Account spec (section 10) also assumed a single 32px container padding at 768px and above, so its "available width" figures (704px and 976px) were too large (the true values are 640px and 912px at 768px and 1280px); the 640px column cap and the 343px phone width are unaffected.
Where the implementation spec differs from the first visual spec (comment on issue #2215), all accepted:
- (P1) Reorder sends one request with the full id list per finished move, not one PATCH per moved row (architect contract, owner decision 7).
- (P2, decision E1) Category deletes show no counts. The first spec asked for
useDeleteImpactcounts on all three lists. The server suppresses the details ofCATEGORY_IN_USE(#315), so cost and purchase categories use the dialog's blocked state with a fixed sentence per list and no Delete button; photo orientations keep the server-counted consequences. - (P3) The Areas URL is built with
navHref('areas')while the route is unserved, becauserouteUrl('areas')does not compile for aplannedroute. The pointer row and the interim card swap onisRouteServed('areas'). - (P4) The Photo orientations anchor is
#orientations, not#photo-orientations: the route map already targets?tab=orientationsto#orientations. Test ids keepproject-setup-photo-orientations. - (P5, decision E2)
ManagePageprimary buttons go 9 to 4, not 9 to 1. The first spec assumed the shared dialog's Save did not count; the baseline sums every file in the page's import closure, so the ReferenceList dialog Save counts, and the interim Areas card (kept unchanged) adds two. 2 after #2218. - (P6) US spelling: "Color" and "No color" (the English locale is US English throughout); the first spec wrote "Colour".
- (P7) Per-list string templates ("New {{item}}", "No {{items}} yet.") with the glossary noun as the value, so a money-label baseline count does not rise. The purchase-categories description is "Group your purchases by type." ("Household" is a banned word).
- (P8) The language hint is a complete sentence followed by a separate "Set the app language in Account" link, not an inline link inside the sentence; fragments do not translate.
- (P9) Browser Back is not intercepted by the dirty-close discard confirm (the dialog is not URL-backed and the router has no blocker). Cancel, Escape and the backdrop are.
- (P10) Route-map stages:
/budget/categoriesbecomesdone, and the six/settings/manage?tab=query maps becomeinterim(the page now serves them).
_Last updated: Settings > Project setup and ReferenceList (#2215)
Managed by the ux-designer agent