-
Notifications
You must be signed in to change notification settings - Fork 2
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
- Design Token Architecture
- Color Palette (Layer 1)
- Semantic Tokens (Layer 2)
- Shadow Scale
- Typography
- Spacing
- Border Radius
- Transitions
- Z-Index Scale
- Component Patterns
- Shared Components
- Dark Mode Implementation Guide
- Brand Identity
Cornerstone uses a 3-layer token system defined in client/src/styles/tokens.css.
Layer 1 — Palette Raw named color values (hex) — source of truth
↓
Layer 2 — Semantic Purpose-driven aliases using var() references to Layer 1
↓
Layer 3 — Dark mode Scoped overrides on [data-theme="dark"] — replaces Layer 2 values only
Rules:
-
Always reference Layer 2 semantic tokens in component CSS Modules (
var(--color-bg-primary)) - Never use raw hex values in
.module.cssfiles — zero exceptions - Only use Layer 1 palette tokens directly when no semantic alias covers the use case
- Layer 3 requires no changes to component code — theming is automatic via CSS cascade
Verification command — must return zero results:
grep -rn '#[0-9a-fA-F]' client/src --include="*.module.css"These are raw color values. Reference these only through Layer 2 semantic tokens in component CSS.
| Token | Hex | Usage |
|---|---|---|
--color-white |
#ffffff |
Pure white |
--color-black |
#000000 |
Pure black |
--color-gray-50 |
#f9fafb |
Near-white backgrounds |
--color-gray-100 |
#f3f4f6 |
Subtle backgrounds, tertiary surfaces |
--color-gray-200 |
#e5e7eb |
Default borders |
--color-gray-300 |
#d1d5db |
Strong borders, dividers |
--color-gray-400 |
#9ca3af |
Placeholder text, disabled text |
--color-gray-500 |
#6b7280 |
Muted text |
--color-gray-600 |
#4b5563 |
Subtle text |
--color-gray-700 |
#374151 |
Secondary text, sidebar hover |
--color-gray-800 |
#1f2937 |
Sidebar background (light mode), inverse bg |
--color-gray-900 |
#111827 |
Primary text, darkest surface |
| Token | Hex | Usage |
|---|---|---|
--color-blue-100 |
#dbeafe |
Primary background tint |
--color-blue-200 |
#bfdbfe |
Primary background tint (hover) |
--color-blue-400 |
#60a5fa |
Sidebar focus ring |
--color-blue-500 |
#3b82f6 |
Primary action, focus border, favicon |
--color-blue-600 |
#2563eb |
Primary hover |
--color-blue-700 |
#1d4ed8 |
Primary active |
--color-blue-800 |
#1e40af |
Badge text on light bg |
| Token | Hex | Usage |
|---|---|---|
--color-red-50 |
#fef2f2 |
Danger background (very light) |
--color-red-100 |
#fee2e2 |
Danger background, blocked badge bg |
--color-red-200 |
#fecaca |
Danger border |
--color-red-400 |
#ef4444 |
Danger input border |
--color-red-500 |
#dc2626 |
Danger action |
--color-red-600 |
#b91c1c |
Danger hover |
--color-red-700 |
#991b1b |
Danger active, badge text |
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 |
Success action |
--color-green-600 |
#059669 |
Success hover |
--color-green-700 |
#166534 |
Success text on light bg, user active badge text |
--color-green-900 |
#065f46 |
Completed badge text |
Reference these tokens in all component CSS. They automatically adapt to dark mode via Layer 3 overrides.
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-bg-primary |
#ffffff (white) |
#1a1a2e |
Main page background |
--color-bg-secondary |
#f9fafb (gray-50) |
#16213e |
Sidebar of cards, secondary panels |
--color-bg-tertiary |
#f3f4f6 (gray-100) |
#1e293b |
Code blocks, inset regions |
--color-bg-inverse |
#1f2937 (gray-800) |
#f3f4f6 (gray-100) |
Tooltips, dark callouts |
--color-bg-hover |
#f9fafb (gray-50) |
#1e293b |
Row/item hover state |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-text-primary |
#111827 (gray-900) |
#f1f5f9 |
Headings, prominent labels |
--color-text-secondary |
#374151 (gray-700) |
#cbd5e1 |
Sub-headings, labels |
--color-text-body |
#374151 (gray-700) |
#94a3b8 |
Body copy, descriptions |
--color-text-muted |
#6b7280 (gray-500) |
#64748b |
Helper text, metadata |
--color-text-subtle |
#4b5563 (gray-600) |
#94a3b8 |
Less-prominent secondary copy |
--color-text-inverse |
#ffffff (white) |
#111827 (gray-900) |
Text on dark/inverse surfaces |
--color-text-placeholder |
#9ca3af (gray-400) |
#475569 |
Form input placeholders |
--color-text-disabled |
#9ca3af (gray-400) |
#475569 |
Disabled form elements |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-border |
#e5e7eb (gray-200) |
#334155 |
Default borders, dividers |
--color-border-strong |
#d1d5db (gray-300) |
#475569 |
Emphasized borders, table lines |
--color-border-focus |
#3b82f6 (blue-500) |
#60a5fa (blue-400) |
Focused input border color |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-primary |
#3b82f6 |
#60a5fa |
Button background, links |
--color-primary-hover |
#2563eb |
#3b82f6 |
Button hover state |
--color-primary-active |
#1d4ed8 |
#2563eb |
Button active/pressed |
--color-primary-text |
#ffffff |
#111827 |
Text on primary button |
--color-primary-bg |
#dbeafe |
rgba(59,130,246,0.15) |
Subtle primary tint (tags, etc.) |
--color-primary-bg-hover |
#bfdbfe |
rgba(59,130,246,0.25) |
Subtle primary tint hover |
--color-primary-badge-text |
#1e40af |
#93c5fd |
Text inside primary-tinted badges |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-danger |
#dc2626 |
#f87171 |
Destructive button, error icon |
--color-danger-hover |
#b91c1c |
#ef4444 |
Destructive button hover |
--color-danger-active |
#991b1b |
#dc2626 |
Destructive button pressed |
--color-danger-text |
#ffffff |
#111827 |
Text on danger button |
--color-danger-bg |
#fef2f2 |
rgba(239,68,68,0.1) |
Error banner background |
--color-danger-bg-strong |
#fee2e2 |
rgba(239,68,68,0.2) |
Error badge background |
--color-danger-border |
#fecaca |
rgba(239,68,68,0.3) |
Error banner border |
--color-danger-text-on-light |
#991b1b |
#fca5a5 |
Error text on light bg |
--color-danger-input-border |
#ef4444 |
#ef4444 |
Invalid input border |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-success |
#10b981 |
#34d399 |
Success icon, checkmark |
--color-success-hover |
#059669 |
#10b981 |
Success button hover |
--color-success-bg |
#f0fdf4 |
rgba(16,185,129,0.1) |
Success banner background |
--color-success-border |
#bbf7d0 |
rgba(16,185,129,0.3) |
Success banner border |
--color-success-text-on-light |
#166534 |
#6ee7b7 |
Success text on light bg |
--color-success-badge-bg |
#d1fae5 |
rgba(16,185,129,0.15) |
Success badge background |
--color-success-badge-bg-alt |
#dcfce7 |
rgba(16,185,129,0.2) |
Alternate success badge background |
--color-success-badge-text |
#065f46 |
#a7f3d0 |
Success badge text |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-sidebar-bg |
#1f2937 (gray-800) |
#0f172a |
Sidebar panel background |
--color-sidebar-text |
#f9fafb (gray-50) |
#e2e8f0 |
Sidebar text and icons |
--color-sidebar-hover |
#374151 (gray-700) |
#1e293b |
Nav item hover background |
--color-sidebar-active |
#3b82f6 (blue-500) |
#3b82f6 |
Active nav item 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 |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-focus-ring |
rgba(59,130,246,0.3) |
rgba(96,165,250,0.4) |
Standard blue focus ring color |
--color-focus-ring-subtle |
rgba(59,130,246,0.1) |
rgba(96,165,250,0.15) |
Subtle focus ring |
--color-focus-ring-danger |
rgba(220,38,38,0.1) |
rgba(239,68,68,0.15) |
Focus ring on danger element |
--color-focus-ring-primary-alt |
rgba(37,99,235,0.1) |
rgba(96,165,250,0.15) |
Focus ring on alternate primary |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-overlay |
rgba(0,0,0,0.5) |
rgba(0,0,0,0.7) |
Modal backdrop |
--color-overlay-light |
rgba(0,0,0,0.1) |
rgba(0,0,0,0.3) |
Hover overlay on images |
--color-overlay-medium |
rgba(0,0,0,0.15) |
rgba(0,0,0,0.4) |
Medium-weight overlay |
--color-overlay-danger |
rgba(153,27,27,0.1) |
rgba(239,68,68,0.15) |
Danger action overlay |
--color-overlay-delete |
rgba(220,38,38,0.1) |
rgba(239,68,68,0.15) |
Delete confirmation overlay |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-status-not-started-bg |
#e5e7eb (gray-200) |
#334155 |
Not Started badge background |
--color-status-not-started-text |
#374151 (gray-700) |
#94a3b8 |
Not Started badge text |
--color-status-in-progress-bg |
#dbeafe (blue-100) |
rgba(59,130,246,0.2) |
In Progress badge background |
--color-status-in-progress-text |
#1e40af (blue-800) |
#93c5fd |
In Progress badge text |
--color-status-completed-bg |
#d1fae5 (green-100) |
rgba(16,185,129,0.15) |
Completed badge background |
--color-status-completed-text |
#065f46 (green-900) |
#a7f3d0 |
Completed badge text |
--color-status-blocked-bg |
#fee2e2 (red-100) |
rgba(239,68,68,0.15) |
Blocked badge background |
--color-status-blocked-text |
#991b1b (red-700) |
#fca5a5 |
Blocked badge text |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-role-admin-bg |
#dbeafe (blue-100) |
rgba(59,130,246,0.2) |
Admin role badge background |
--color-role-admin-text |
#1e40af (blue-800) |
#93c5fd |
Admin role badge text |
--color-role-member-bg |
#f3f4f6 (gray-100) |
#334155 |
Member role badge background |
--color-role-member-text |
#374151 (gray-700) |
#94a3b8 |
Member role badge text |
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--color-user-active-bg |
#dcfce7 (green-150) |
rgba(16,185,129,0.15) |
Active user badge background |
--color-user-active-text |
#166534 (green-700) |
#6ee7b7 |
Active user badge text |
--color-user-inactive-bg |
#fee2e2 (red-100) |
rgba(239,68,68,0.15) |
Inactive user badge background |
--color-user-inactive-text |
#991b1b (red-700) |
#fca5a5 |
Inactive user badge text |
Shadows automatically deepen in dark mode via Layer 3 overrides.
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
--shadow-sm |
0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06) |
0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2) |
Cards, small surfaces |
--shadow-md |
0 4px 6px rgba(0,0,0,0.1) |
0 4px 6px rgba(0,0,0,0.3) |
Raised elements |
--shadow-lg |
0 10px 15px rgba(0,0,0,0.1), 0 4px 6px rgba(0,0,0,0.05) |
0 10px 15px rgba(0,0,0,0.3), 0 4px 6px rgba(0,0,0,0.2) |
Dropdowns, popovers |
--shadow-xl |
0 20px 25px rgba(0,0,0,0.15) |
0 20px 25px rgba(0,0,0,0.4) |
Modals |
--shadow-xl-strong |
0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04) |
0 20px 25px -5px rgba(0,0,0,0.3), 0 10px 10px -5px rgba(0,0,0,0.2) |
Large modals |
--shadow-2xl |
0 20px 25px rgba(0,0,0,0.2) |
0 20px 25px rgba(0,0,0,0.5) |
Full-page overlays |
--shadow-inset-deep |
0 10px 25px rgba(0,0,0,0.2) |
0 10px 25px rgba(0,0,0,0.4) |
Sidebar depth, inset panels |
--shadow-kbd |
0 1px 0 rgba(0,0,0,0.05) |
0 1px 0 rgba(255,255,255,0.1) |
Keyboard shortcut keys |
--shadow-focus |
0 0 0 3px rgba(59,130,246,0.3) |
0 0 0 3px rgba(96,165,250,0.4) |
Standard focus ring |
--shadow-focus-subtle |
0 0 0 3px rgba(59,130,246,0.1) |
0 0 0 3px rgba(96,165,250,0.15) |
Subtle focus ring |
--shadow-focus-primary-alt |
0 0 0 3px rgba(37,99,235,0.1) |
0 0 0 3px rgba(96,165,250,0.15) |
Alternate primary focus ring |
--shadow-focus-danger |
0 0 0 3px rgba(220,38,38,0.1) |
0 0 0 3px rgba(239,68,68,0.15) |
Focus ring on danger element |
The application uses the system-ui font stack — no web font loading, no layout shift:
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Helvetica Neue',
sans-serif;This is set globally in client/src/styles/index.css and should not be overridden per-component.
| Token | Value | Pixels | Usage |
|---|---|---|---|
--font-size-2xs |
0.8125rem |
13px | Between xs and sm — fine print, dense metadata |
--font-size-xs |
0.75rem |
12px | Micro labels, timestamps |
--font-size-sm |
0.875rem |
14px | Form labels, secondary text, badges |
--font-size-base |
1rem |
16px | Body text, inputs |
--font-size-lg |
1.125rem |
18px | Slightly emphasized text |
--font-size-xl |
1.25rem |
20px | Card titles, section headings |
--font-size-2xl |
1.5rem |
24px | Page sub-headings |
--font-size-3xl |
1.875rem |
30px | Major page headings |
--font-size-4xl |
2rem |
32px | Hero headings, large dashboard numbers |
| Token | Value | Usage |
|---|---|---|
--font-weight-normal |
400 |
Body text |
--font-weight-medium |
500 |
Labels, metadata with slight emphasis |
--font-weight-semibold |
600 |
Section headings, button text |
--font-weight-bold |
700 |
Page titles, badges, strong emphasis |
All spacing tokens follow a 4px base grid. Use these tokens for padding, margin, and gap.
| Token | Value | Pixels | Common Usage |
|---|---|---|---|
--spacing-0 |
0 |
0 | Reset |
--spacing-px |
1px |
1px | Hairline borders, fine adjustments |
--spacing-0-5 |
0.125rem |
2px | Micro gaps |
--spacing-1 |
0.25rem |
4px | Icon padding, tight inline spacing |
--spacing-1-5 |
0.375rem |
6px | Dense list items |
--spacing-2 |
0.5rem |
8px | Default inner padding small elements |
--spacing-2-5 |
0.625rem |
10px | Medium-small gap |
--spacing-3 |
0.75rem |
12px | Badge padding, compact list items |
--spacing-4 |
1rem |
16px | Standard inner padding (cards, inputs) |
--spacing-5 |
1.25rem |
20px | Section spacing |
--spacing-6 |
1.5rem |
24px | Card gap, form field spacing |
--spacing-7 |
1.75rem |
28px | Wide gap |
--spacing-8 |
2rem |
32px | Major section padding |
--spacing-10 |
2.5rem |
40px | Large section separation |
--spacing-12 |
3rem |
48px | Page-level vertical rhythm |
--spacing-16 |
4rem |
64px | Hero-level spacing |
| Token | Value | Pixels | Usage |
|---|---|---|---|
--radius-sm |
0.25rem |
4px | Small elements: badges, keyboard keys |
--radius-md |
0.375rem |
6px | Inputs, buttons, dropdowns, popovers |
--radius-lg |
0.5rem |
8px | Cards, modals, dialog sections |
--radius-circle |
50% |
-- | Circular elements (avatar, icon button) |
--radius-full |
9999px |
-- | Pills: tags, status badges |
| Token | Value | Usage |
|---|---|---|
--transition-fast |
0.1s ease |
Opacity fade (sidebar close button) |
--transition-normal |
0.15s ease |
Most interactive elements (buttons, inputs) |
--transition-medium |
0.2s ease |
Hover effects on larger elements |
--transition-slow |
0.3s ease |
Sidebar slide, modals |
Pre-built multi-property transitions for consistency across components:
| Token | Value | Usage |
|---|---|---|
--transition-input |
border-color 0.15s ease, box-shadow 0.15s ease |
Form input focus transitions |
--transition-button |
background-color 0.15s ease, box-shadow 0.15s ease |
Button background + shadow |
--transition-button-border |
background-color 0.15s ease, border-color 0.15s ease |
Outline button hover |
| Token | Value | Usage |
|---|---|---|
--z-dropdown |
10 |
Dropdown menus, date pickers |
--z-overlay |
50 |
Backdrop overlays (below sidebar) |
--z-sidebar |
100 |
Mobile sidebar panel |
--z-modal |
1000 |
Modal dialogs (above everything) |
Always reference Layer 2 semantic tokens with var():
/* CORRECT */
.card {
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--spacing-6);
color: var(--color-text-body);
}
/* WRONG — hardcoded values break dark mode */
.card {
background: #ffffff;
border: 1px solid #e5e7eb;
}- Always prefer semantic tokens — they adapt automatically to dark mode
- Use palette tokens directly only when creating a new component that genuinely needs a specific hue not yet covered by a semantic alias (e.g., a new badge type)
- If you find yourself using palette tokens repeatedly for the same purpose, add a semantic alias to
tokens.cssat all 3 layers
Use the --color-status-* token family for work item status indicators. Apply --radius-full for pill shape:
.badge {
display: inline-flex;
align-items: center;
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.notStarted {
background: var(--color-status-not-started-bg);
color: var(--color-status-not-started-text);
}
.inProgress {
background: var(--color-status-in-progress-bg);
color: var(--color-status-in-progress-text);
}
.completed {
background: var(--color-status-completed-bg);
color: var(--color-status-completed-text);
}
.blocked {
background: var(--color-status-blocked-bg);
color: var(--color-status-blocked-text);
}Standard input with focus ring and error state:
.input {
width: 100%;
padding: var(--spacing-2) var(--spacing-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: var(--font-size-base);
transition: var(--transition-input);
}
.input::placeholder {
color: var(--color-text-placeholder);
}
.input:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: var(--shadow-focus);
}
/* Error state */
.inputError {
border-color: var(--color-danger-input-border);
}
.inputError:focus {
box-shadow: var(--shadow-focus-danger);
}Three button variants — primary, danger, and outline:
/* Primary button */
.buttonPrimary {
background: var(--color-primary);
color: var(--color-primary-text);
border: none;
border-radius: var(--radius-md);
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: var(--transition-button);
}
.buttonPrimary:hover {
background: var(--color-primary-hover);
}
.buttonPrimary:active {
background: var(--color-primary-active);
}
.buttonPrimary:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
/* Danger button */
.buttonDanger {
background: var(--color-danger);
color: var(--color-danger-text);
/* ... same shape/font/cursor tokens ... */
}
.buttonDanger:hover {
background: var(--color-danger-hover);
}
.buttonDanger:active {
background: var(--color-danger-active);
}
.buttonDanger:focus-visible {
box-shadow: var(--shadow-focus-danger);
}
/* Outline / secondary button */
.buttonOutline {
background: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-border-strong);
/* ... same shape/font/cursor tokens ... */
transition: var(--transition-button-border);
}
.buttonOutline:hover {
background: var(--color-bg-hover);
border-color: var(--color-border-focus);
}
.buttonOutline:focus-visible {
box-shadow: var(--shadow-focus-subtle);
}All interactive elements on mobile and tablet must meet the 44px minimum touch target:
@media (max-width: 1024px) {
.button {
min-height: 44px;
}
}React Router does not add a literal active class when using CSS Modules. Use the function form:
<NavLink
to="/work-items"
className={({ isActive }) => `${styles.navLink} ${isActive ? styles.active : ''}`}
>
Work Items
</NavLink>These components are the required building blocks for all new UI work. Before creating a new component, verify that none of these existing shared components already cover the use case. Using them ensures consistent tokens, accessibility behavior, and dark mode support without additional effort.
Policy: Any new UI that resembles one of these components MUST use it instead of creating a parallel implementation. Extending an existing component with new props is always preferred over duplication.
File: client/src/components/Badge/Badge.tsx
Import: import { Badge } from '../components/Badge/Badge.js'
A parameterized pill badge driven by a variant map. Replaces the previous StatusBadge, HouseholdItemStatusBadge, DiaryOutcomeBadge, and DiarySeverityBadge components — all badge needs should go through this one component.
Props:
| Prop | Type | Required | Description |
|---|---|---|---|
variants |
BadgeVariantMap |
Yes | Maps string values to { label, className } display configs |
value |
string |
Yes | The current value — looked up in variants; falls back to raw value if not found |
ariaLabel |
string |
No | Override the accessible label (use when label text alone is insufficient) |
testId |
string |
No |
data-testid for test selectors |
className |
string |
No | Additional CSS class for positioning/sizing overrides |
Types:
interface BadgeVariant {
label: string; // human-readable display label
className: string; // CSS Module class that sets bg + text color
}
type BadgeVariantMap = Record<string, BadgeVariant>;Usage:
Define the variant map once (typically in the domain component file) then pass it to every Badge instance:
import { Badge, type BadgeVariantMap } from '../components/Badge/Badge.js';
import badgeStyles from '../components/Badge/Badge.module.css';
const WORK_ITEM_STATUS_VARIANTS: BadgeVariantMap = {
not_started: { label: 'Not Started', className: badgeStyles.not_started },
in_progress: { label: 'In Progress', className: badgeStyles.in_progress },
completed: { label: 'Completed', className: badgeStyles.completed },
};
// In JSX:
<Badge variants={WORK_ITEM_STATUS_VARIANTS} value={workItem.status} />Built-in variant classes in Badge.module.css (reference by importing the CSS module):
| Class name | Token family used | Domain |
|---|---|---|
not_started |
--color-status-not-started-* |
Work item status |
in_progress |
--color-status-in-progress-* |
Work item status |
completed |
--color-status-completed-* |
Work item status |
planned |
--color-hi-status-planned-* |
Household item status |
purchased |
--color-hi-status-purchased-* |
Household item status |
scheduled |
--color-hi-status-scheduled-* |
Household item status |
arrived |
--color-hi-status-arrived-* |
Household item status |
pass |
--color-diary-outcome-pass-* |
Diary inspection outcome |
fail |
--color-diary-outcome-fail-* |
Diary inspection outcome |
conditional |
--color-diary-outcome-conditional-* |
Diary inspection outcome |
low |
--color-diary-severity-low-* |
Diary issue severity |
medium |
--color-diary-severity-medium-* |
Diary issue severity |
high |
--color-diary-severity-high-* |
Diary issue severity |
critical |
--color-diary-severity-critical-* |
Diary issue severity |
For a new badge domain, add variant classes to Badge.module.css using the appropriate --color-* token family. Do not inline colors or create a separate CSS module.
File: client/src/components/SearchPicker/SearchPicker.tsx
Import: import { SearchPicker } from '../components/SearchPicker/SearchPicker.js'
A generic, search-as-you-type entity picker with a controlled text input, debounced search, a role="listbox" dropdown, and a clear button. Use this for any "select an entity from a searched list" interaction — work items, household items, users, invoices, etc.
Props:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
searchFn |
(query: string, excludeIds: string[]) => Promise<T[]> |
Yes | — | Async search function called on each debounced keystroke |
renderItem |
(item: T) => { id: string; label: string } |
Yes | — | Maps a result item to its display label and stable ID |
value |
string |
Yes | — | Currently selected ID; '' means no selection |
onChange |
(id: string) => void |
Yes | — | Called with the selected item's ID (or '' on clear) |
excludeIds |
string[] |
Yes | — | Item IDs to omit from results (e.g. already-linked items) |
onSelectItem |
(item: { id: string; label: string }) => void |
No | — | Optional callback with both ID and label on selection |
disabled |
boolean |
No | false |
Disables all interaction |
placeholder |
string |
No | 'Search items...' |
Input placeholder text |
getStatusBorderColor |
(item: T) => string | undefined |
No | — | Returns a CSS color string for the selected-item left border accent |
specialOptions |
SpecialOption[] |
No | — | Fixed options shown above the search results (e.g. "None") |
showItemsOnFocus |
boolean |
No | — | When true, loads and shows all results on input focus |
initialTitle |
string |
No | — | Pre-populate the selected display when value is already set but the full item object is not yet loaded |
emptyHint |
string |
No | 'Type to search items' |
Message shown when the dropdown is open but no query entered |
noResultsMessage |
string |
No | 'No matching items found' |
Message shown when a search returns no results |
loadErrorMessage |
string |
No | 'Failed to load items' |
Error shown when initial load fails |
searchErrorMessage |
string |
No | 'Failed to search items' |
Error shown when a search query fails |
Usage:
import { SearchPicker } from '../components/SearchPicker/SearchPicker.js';
<SearchPicker<WorkItem>
value={selectedWorkItemId}
onChange={(id) => setSelectedWorkItemId(id)}
excludeIds={[]}
searchFn={(query, excludeIds) => api.searchWorkItems(query, excludeIds)}
renderItem={(item) => ({ id: item.id, label: item.name })}
placeholder="Search work items..."
showItemsOnFocus
/>With specialOptions (e.g. a "None / unassigned" choice):
<SearchPicker<User>
value={assigneeId}
onChange={setAssigneeId}
excludeIds={[]}
searchFn={searchUsers}
renderItem={(u) => ({ id: u.id, label: u.name })}
specialOptions={[{ id: 'none', label: 'Unassigned' }]}
/>Behavior notes:
- Debounce delay is 300ms.
- Calling
onChange('')from the clear button resets the picker to its empty state. - When
valueis externally reset to'', the component self-clears. -
showItemsOnFocustriggers a full list load on input focus (useful when the dataset is small enough to show all items without filtering first).
File: client/src/components/Modal/Modal.tsx
Import: import { Modal } from '../components/Modal/index.js'
A dialog overlay rendered via createPortal into document.body. Handles all standard modal behaviors: backdrop dismiss, Escape key close, focus management (auto-focuses first focusable child on mount), and correct ARIA attributes.
Props:
| Prop | Type | Required | Description |
|---|---|---|---|
title |
string |
Yes | Modal heading — announced by screen readers via aria-labelledby
|
onClose |
() => void |
Yes | Called on backdrop click, Escape key, or close button press |
children |
React.ReactNode |
Yes | Modal body content |
footer |
React.ReactNode |
No | Action buttons rendered in the footer strip |
className |
string |
No | Additional CSS class applied to the content panel (use for width overrides) |
Usage:
import { Modal } from '../components/Modal/index.js';
{isOpen && (
<Modal
title="Confirm Deletion"
onClose={() => setIsOpen(false)}
footer={
<>
<button className={sharedStyles.btnSecondary} onClick={() => setIsOpen(false)}>
Cancel
</button>
<button className={sharedStyles.btnDanger} onClick={handleDelete}>
Delete
</button>
</>
}
>
<p>Are you sure you want to delete this item? This action cannot be undone.</p>
</Modal>
)}Sizing: The default modal width is set by shared.module.css → .modalContent. Override for wide modals (e.g. pickers) by passing a className with a max-width rule:
/* In the consuming component's CSS Module */
.wideModal {
max-width: min(860px, calc(100vw - 2rem));
}<Modal title="Select Document" onClose={onClose} className={styles.wideModal}>
...
</Modal>Accessibility: The modal root element carries role="dialog" aria-modal="true" aria-labelledby={titleId}. The title element has the matching id. Focus is moved to the first interactive child on mount. Escape key always closes.
File: client/src/components/Skeleton/Skeleton.tsx
Import: import { Skeleton } from '../components/Skeleton/index.js'
A shimmer placeholder shown during data loading. Renders a configurable number of lines with staggered widths. Use instead of spinner-plus-text loading states for content regions.
Props:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
lines |
number |
No | 3 |
Number of skeleton lines to render |
widths |
string[] |
No | — | Width per line (e.g. ['100%', '75%', '50%']); cycles 100/80/60% when omitted |
loadingLabel |
string |
No | 'Loading' |
aria-label for the container; customize to describe the specific content loading |
className |
string |
No | — | Additional CSS class for layout positioning |
Usage:
import { Skeleton } from '../components/Skeleton/index.js';
// Basic 3-line loading placeholder
{isLoading && <Skeleton />}
// Custom count with explicit widths
{isLoading && (
<Skeleton
lines={4}
widths={['100%', '100%', '70%', '40%']}
loadingLabel="Loading work item details"
/>
)}Accessibility: The container has role="status" aria-busy="true" and an aria-label that describes what is loading. Individual line elements are aria-hidden="true".
Animation: The shimmer animation uses --color-bg-tertiary and --color-bg-hover as gradient stops. The animation is automatically disabled for users with prefers-reduced-motion: reduce — the lines render as static placeholders without the sweep effect.
File: client/src/components/EmptyState/EmptyState.tsx
Import: import { EmptyState } from '../components/EmptyState/index.js'
A centered empty-content display with an optional icon, main message, supporting description, and an action button or link. Use whenever a list, table, or content region has no items to show.
Props:
| Prop | Type | Required | Description |
|---|---|---|---|
message |
string |
Yes | Primary empty-state message (e.g. "No work items yet") |
icon |
ReactNode |
No | Icon to display above the message — emoji string or React element; always aria-hidden
|
description |
string |
No | Secondary helper text beneath the message |
action |
EmptyStateAction |
No | Optional CTA; renders an <a> when href is set, a <button> otherwise |
className |
string |
No | Additional CSS class for layout positioning |
EmptyStateAction shape:
interface EmptyStateAction {
label: string;
href?: string; // renders an <a> link
onClick?: () => void; // renders a <button>; href takes precedence
}Usage:
import { EmptyState } from '../components/EmptyState/index.js';
// List has no items
<EmptyState
icon="📋"
message="No work items yet"
description="Add your first work item to start tracking progress."
action={{ label: 'Add Work Item', onClick: () => setShowForm(true) }}
/>
// Filter produced no results (no icon, no CTA)
<EmptyState
message="No items match your filters"
description="Try adjusting or clearing your filters."
action={{ label: 'Clear filters', onClick: clearFilters }}
/>Pattern guidance: Use the full form (icon + message + description + action) for the "resource does not exist yet" state. Use the minimal form (message + optional description + text action) for "filter returned no results" — omit the icon in filtered-empty states so the two cases are visually distinct.
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.
tokens.css :root {
--color-bg-primary: #ffffff; /* Layer 1 + 2: palette alias */
}
tokens.css [data-theme="dark"] {
--color-bg-primary: #1a1a2e; /* Layer 3: dark override */
}
MyComponent.module.css {
.card { background: var(--color-bg-primary); } /* no change needed */
}
When document.documentElement.dataset.theme = "dark", the browser's CSS cascade automatically picks up the [data-theme="dark"] overrides. No JavaScript or class toggling in components.
Location: client/src/contexts/ThemeContext.tsx
import { useTheme } from '../../contexts/ThemeContext.js';
function MyComponent() {
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: 'light' | 'dark' | 'system' (user preference)
// resolvedTheme: 'light' | 'dark' (actual applied theme)
}Types exported:
ThemePreference = 'light' | 'dark' | 'system'ResolvedTheme = 'light' | 'dark'-
ThemeContextValue— shape of the context value
Provider setup (already in App.tsx):
<BrowserRouter>
<ThemeProvider>
<AuthProvider>{/* app content */}</AuthProvider>
</ThemeProvider>
</BrowserRouter>Location: client/src/components/ThemeToggle/ThemeToggle.tsx
A single button that cycles through Light → Dark → System preferences. It is placed inside the sidebar footer and is the only UI affordance for changing the theme.
Cycle order: light → dark → system → light → ...
Button label shows the current theme; aria-label announces the next theme (what clicking will do):
aria-label="Switch to Dark mode" (when current is Light)
aria-label="Switch to System mode" (when current is Dark)
aria-label="Switch to Light mode" (when current is System)
The theme preference is stored under the key 'theme' in localStorage. Valid values: 'light', 'dark', 'system'. Defaults to 'system' if not set or invalid.
When theme === 'system', ThemeContext reads window.matchMedia('(prefers-color-scheme: dark)') and subscribes to change events. The resolved theme updates automatically when the OS switches between light and dark mode.
When adding a new semantic token that needs a dark mode override:
-
Layer 2 — Add the token in
:rootwith a light-mode value referencing a palette token::root { --color-my-new-token: var(--color-blue-100); }
-
Layer 3 — Add the override in
[data-theme="dark"]:[data-theme='dark'] { --color-my-new-token: rgba(59, 130, 246, 0.15); }
-
Use in components as normal:
.element { background: var(--color-my-new-token); }
jsdom (used by Jest) does not implement window.matchMedia. The test setup at client/src/test/setupTests.ts provides a polyfill. When writing components that call useTheme(), tests must either:
- Wrap in
<ThemeProvider>(integration-style), or - Mock the
ThemeContextmodule withjest.unstable_mockModule()
Source files live in logo/ at the project root. Each asset has a light variant (black strokes, for light backgrounds) and a dark variant (white strokes, for dark backgrounds):
| File | Type | Use Case |
|---|---|---|
logo/icon_light.svg |
Icon only | Favicon, sidebar logo, docs navbar — light mode |
logo/icon_dark.svg |
Icon only | Sidebar logo, docs navbar — dark mode |
logo/logo_light.svg |
Full logo + text | README hero, docs intro — light mode |
logo/logo_dark.svg |
Full logo + text | README hero, docs intro — dark mode |
All variants share rgb(59,130,246) (blue-500) as the accent color and rgb(209,213,219) gray fills. The difference between light and dark variants is stroke color: black strokes for light backgrounds, white strokes for dark backgrounds.
Component: client/src/components/Logo/Logo.tsx
The Cornerstone logo is a keystone / arch motif — the central wedge stone of an arch, flanked by two column blocks on a shared base. It represents the product's role as the foundational element of a home-building project.
The component is theme-aware: it uses useTheme() from ThemeContext to select the correct image source (/logo.svg for light mode, /logo-dark.svg for dark mode).
Files served:
-
client/public/logo.svg— light variant (copy oficon_light.svg) -
client/public/logo-dark.svg— dark variant (copy oficon_dark.svg)
Usage:
import { Logo } from '../../components/Logo/Logo.js';
// Automatically selects light/dark variant based on theme
<Logo size={32} className={styles.logo} />;Sizes: The logo renders cleanly from 16px (very small) to 200px+. Default size prop is 32.
File: client/public/favicon.svg
The favicon uses the light variant (icon_light.svg) — black strokes are visible on most browser chrome backgrounds. The CopyWebpackPlugin in client/webpack.config.cjs copies client/public/ into the dist/ output directory so the favicon is served correctly in both development and production.
The Docusaurus docs site uses theme-aware logos via the srcDark config option:
-
Navbar logo:
docs/static/img/logo.svg(light) +docs/static/img/logo-dark.svg(dark) -
Intro hero:
docs/static/img/logo-full.svg(light) +docs/static/img/logo-full-dark.svg(dark), rendered via DocusaurusThemedImagecomponent -
Favicon:
docs/static/img/favicon.svg(light variant)
The README uses a <picture> element with prefers-color-scheme media queries (GitHub renders these natively) to swap between logo/logo_light.svg and logo/logo_dark.svg.
The brand primary color is blue-500 (rgb(59,130,246) / #3b82f6). This appears in:
- The cornerstone block fill in all logo variants
- Primary action buttons
- Active nav item in the sidebar
- Focus borders on form inputs
- The
--color-primarysemantic token (light mode)
Last updated: Shared Components — Badge, SearchPicker, Modal, Skeleton, EmptyState, FormError
Managed by the ux-designer agent