Style Guide - steilerDev/cornerstone GitHub Wiki

Style Guide

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


Table of Contents

  1. Design Token Architecture
  2. Color Palette (Layer 1)
  3. Semantic Tokens (Layer 2)
  4. Shadow Scale
  5. Typography
  6. Spacing
  7. Border Radius
  8. Transitions
  9. Z-Index Scale
  10. Component Patterns
  11. Shared Components
  12. Dark Mode Implementation Guide
  13. Brand Identity
  14. Accepted UX Deviations

Design Token Architecture

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.css files — 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"

Color Palette (Layer 1)

These are raw color values. Reference these only through Layer 2 semantic tokens in component CSS.

Grayscale

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

Blue (Primary / Action)

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

Red (Danger / Error)

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)

Green (Success)

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

Slate (dark mode surfaces, borders, 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.


Semantic Tokens (Layer 2)

Reference these tokens in all component CSS. They automatically adapt to dark mode via Layer 3 overrides.

Backgrounds

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.

Text

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

Borders

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

Primary Action (Blue)

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

Danger / Error (Red)

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

Success (Green)

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

Sidebar and Navigation

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.

Sidebar structure and states (#2205)

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").

Focus Rings (Color values)

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

Overlays / Backdrops

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

Photo Viewer Tokens (--color-photo-*)

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

Status Badge Tokens

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

Role Badge Tokens

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

User Status Badge Tokens

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

Contrast Guarantees (#2200)

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-muted and --color-primary text on --color-bg-tertiary in dark mode, and --color-primary on --color-primary-bg in light mode (use --color-primary-badge-text where it fails).
  • Hover on a bordered control must change border or fill to a visible state: pagination buttons use --color-bg-hover plus border-color: var(--color-primary).
  • Disabled controls are exempt, but the surrounding labels are not.

Shadow Scale

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

Typography

Font Family

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.

Font Sizes

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

Font Weights

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

Spacing

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

Border Radius

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

Transitions

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

Composite Transition Tokens

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

Z-Index Scale

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)

Component Patterns

How to Use Tokens in CSS Modules

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;
}

When to Use Semantic vs Palette Tokens

  • 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.css at all 3 layers

Status Badge Pattern

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);
}

Form Input Pattern

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);
}

Button Pattern

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);
}

Disabled vs. Busy Buttons

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):

  1. aria-disabled={busy} ('true' while busy; omit or false otherwise) — never also set disabled.
  2. Handler early-return: onClick={() => { if (busy) return; ... }}. aria-disabled does not block clicks or Enter/Space.
  3. 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 */
}
  1. :focus-visible ring 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.
  2. 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/.

Touch Target Sizes

All interactive elements on mobile and tablet must meet the 44px minimum touch target:

@media (max-width: 1024px) {
  .button {
    min-height: 44px;
  }
}

NavLink Active State (CSS Modules + React Router)

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>

Shared Components

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.


Badge

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.


SearchPicker

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 value is externally reset to '', the component self-clears.
  • showItemsOnFocus triggers 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 keeps min-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 calls onChange / onSelectItem but shows no selected chip; the input empties and keeps focus without reopening the list, so focus never drops to <body>. Consumers keep value="". Pickers that choose one value keep the default chip with "Clear selection".
  • Per-row test id (getOptionTestId) puts a data-testid on each result option; inputAriaProps['aria-label'] names an input that has no visible <label> (an input with a label uses htmlFor instead and gets no aria-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).

Modal

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.


Skeleton

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.


EmptyState

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.

Pattern: "No access" page (role guard)

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).


FormError

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.

InfiniteScrollFooter

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" + onClick early-return, never native disabled.
  • .loadMoreButton[aria-disabled='true']: opacity: 0.5; cursor: not-allowed; plus a :hover override restoring --color-bg-tertiary (shared btnSecondary:hover:not(:disabled) would otherwise still match).
  • On mobile (max-width: 767px) the button is full width with min-height: 44px.
  • When the list ends, the button unmounts and focus is handed to the end-of-list row (tabIndex={-1}, :focus-visible uses --shadow-focus; plain :focus shows 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.


FilterChipGroup

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-strong border, --color-bg-primary fill; 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 carries padding: 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.

SpotThumbnail

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 is ariaLabel (required); the image is decorative (alt="", lazy), the photo-count pill is aria-hidden (the count belongs in ariaLabel).
  • Without to: non-interactive dashed placeholder showing emptyLabel (optional visually hidden emptySrPrefix for context); reserves the date line height so rows do not jump.
  • Image failure/missing: swaps to a --color-bg-tertiary fallback with a picture-off icon and an sr-only imageUnavailableLabel.
  • Hover/focus: 2px --color-primary ring on the thumb (inset on card), plus --shadow-focus on the link on focus-visible. Count pill uses --color-photo-control-bg-hover with --color-white text. prefers-reduced-motion disables the ring transition.

FileList

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 via aria-describedby. Rows are <li>.
  • Row: --color-bg-secondary fill, 1px solid var(--color-border), --radius-md, --spacing-3 padding, --spacing-2 list gap; grid 1fr 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 (use Badge; status always has a text label). Detail: --font-size-xs/--color-text-secondary.
  • Action: composes btnSecondaryCompact; aria-label must be unique per row (e.g. "Download {filename}").
  • Responsive: below 768px (max-width: 767px) rows stack and the action is full width with min-height: 44px. (The #2161 spec said 640px; implementation uses the app-wide 767px mobile breakpoint, accepted for consistency with InfiniteScrollFooter/FilterChipGroup.)
  • Dark mode: semantic tokens only; no local overrides.

Breadcrumbs and Back link (page identity)

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.
Print 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.

Grammar foundations: StatusMenu, UndoToast, ConfirmDialog (#2209)

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.

Top bar and user menu (#2206)

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.

Phone and tablet shell (#2207)

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.

Command search (#2211)

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.

New menu and object form (#2212)

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.

Companies, Trades and the call button (#2213)

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.

Settings > Project setup and ReferenceList (#2215)

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-sm muted description under it. New is aria-disabled while 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: none on the handle only, aria-label="Reorder ‹name›", focus box-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 with title), 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 is aria-disabled on the first row and Move down on the last, and both while a reorder is pending.
  • States: loading is Skeleton lines={3} under the header; a load error is FormError plus Retry (focus moves to the h2 first, so it never falls to <body>); empty is EmptyState with 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.



Dark Mode Implementation Guide

How the 3-Layer System Works

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.

ThemeContext

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>

Theme control

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.

localStorage Persistence

The theme preference is stored under the key 'theme' in localStorage. Valid values: 'light', 'dark', 'system'. Defaults to 'system' if not set or invalid.

System Preference Detection

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.

How to Add New Dark Mode Tokens

When adding a new semantic token that needs a dark mode override:

  1. Layer 2 — Add the token in :root with a light-mode value referencing a palette token:

    :root {
      --color-my-new-token: var(--color-blue-100);
    }
  2. Layer 3 — Add the override in [data-theme="dark"]:

    [data-theme='dark'] {
      --color-my-new-token: rgba(59, 130, 246, 0.15);
    }
  3. Use in components as normal:

    .element {
      background: var(--color-my-new-token);
    }

Test Infrastructure Note

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 ThemeContext module with jest.unstable_mockModule()

Brand Identity

Logo Assets

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.

Logo Component

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 of icon_light.svg)
  • client/public/logo-dark.svg — dark variant (copy of icon_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.

Favicon

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.

Docs Site Logo

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 Docusaurus ThemedImage component
  • Favicon: docs/static/img/favicon.svg (light variant)

README Logo

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.

Color Identity

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-primary semantic token (light mode)


Photo Annotator Pattern

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.

Layout Structure

Three vertical zones within the annotator:

  1. ToolPalette — min-height: 56px (NOT height: 56px — use min-height so the bar expands when items wrap onto a second row); background: var(--color-bg-primary); border-bottom: 1px solid var(--color-border). The palette uses flex-wrap: wrap so items reflow at narrow viewports when the font-size group is visible.
  2. Photo + SVG canvas — flex: 1; dark background (var(--color-overlay)); photo is object-fit: contain; SVG overlay is position: absolute; inset: 0; touch-action: none
  3. Action bar — min-height: 56px; same style as PhotoViewer info bar (rgba(0,0,0,0.7) background); contains Cancel (shared.btnSecondary) and Save (shared.btnPrimary)

AnnotatorIconButton Sub-Pattern

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 Touch Targets

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.

PhotoViewer Info-Bar Additions

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 Color Constants

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.

SVG Shape Visuals

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).

Screen Reader Announcements

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

Keyboard Navigation

  • Escape: Cancels current operation. PhotoViewer handles Escape at the outer level (window listener) and calls handleAnnotationCancel. 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 use role="radiogroup" + role="radio" which already have their own arrow-key navigation per ARIA spec.

Dark Mode

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.

prefers-reduced-motion

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 UX Deviations

Story #2161 (split reports by max file size)

Accepted deviations from the visual spec on issue #2161, requested by the dev-team-lead:

  1. Limit field is <input type="text" inputMode="decimal"> with live validation, not type="number". Rationale: type=number gives 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-describedby to helper and FormError 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.
  2. Sizing failure card has an extra "Continue without them" action alongside Retry and Back (DOM order: Retry btnPrimary, Continue btnSecondary, Back btnSecondary). It lets the user export without attachments that cannot be fetched; those documents are excluded from the sizing. The failure is a FormError 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.
  3. Stale-files state with an "Update files" button. When settings change after parts were generated, the FileList stays visible with its per-row actions disabled, and a notice is passed as children: a plain <p> (no live role, no banner; it is static text, not an announcement) in a .partsStale flex row (--font-size-sm, --color-text-secondary, wrap, --spacing-2 gap) next to a btnSecondaryCompact "Update files" button. It is not a bannerWarning. 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 the FileList heading (headingRef, tabIndex={-1}) when it finishes, whether it succeeded or partly failed.

Deviation Log: --color-text-muted contrast (light and dark)

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.

Deviation Log: roles and contrast (#2200)

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.

Deviation Log: Command search (#2211)

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 DataTable pages 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 window listener (not a document listener) that always calls preventDefault on a match, because the diary filter bar stops keydown propagation. Both ⌘K and Ctrl K work on every platform.

Deviation Log: New menu and object form (#2212)

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 by inert.
  • (e) The Gantt view pre-fills no date for the New menu.

Deviation Log: Companies and Trades (#2213)

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-sort indicator: DataTable has 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.

Deviation Log: Settings > Account (#2214)

Where the implementation spec differs from the first visual spec (comment on issue #2214), all accepted:

  • (A1) The Regenerate ConfirmDialog uses the default danger tone, not tone="primary". ConfirmDialog supports only danger; a primary branch would put a primary-class button into the shared component and raise primaryButtons on 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 hidden role="status" (no aria-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 are readOnly while busy, never disabled. 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 say Account > Profile name the surface and should be updated to Account > Your details in 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 when oidcEnabled || authProvider === 'oidc' || oidcLinked.

Deviation Log: Page gutter on phones (#2214)

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.

Deviation Log: Settings > Project setup (#2215)

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 useDeleteImpact counts on all three lists. The server suppresses the details of CATEGORY_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, because routeUrl('areas') does not compile for a planned route. The pointer row and the interim card swap on isRouteServed('areas').
  • (P4) The Photo orientations anchor is #orientations, not #photo-orientations: the route map already targets ?tab=orientations to #orientations. Test ids keep project-setup-photo-orientations.
  • (P5, decision E2) ManagePage primary 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/categories becomes done, and the six /settings/manage?tab= query maps become interim (the page now serves them).

_Last updated: Settings > Project setup and ReferenceList (#2215) Managed by the ux-designer agent

⚠️ **GitHub.com Fallback** ⚠️