Design Review Process (#457)
The design system and the shipped app drift apart in both directions: PRs
introduce ad-hoc values the system should have provided, and PRs invent good
patterns the system should adopt. This process closes the loop in both
directions. It complements design_system_workflow.md (which covers how a
design gets built); this covers how shipped design stays coherent.
The two-way street
- Drift down - a PR hardcodes a color, radius, breakpoint, or spacing
value instead of using a token, or restyles an existing component slightly
differently. Outcome: a
design-driftissue, fixed either directly (token swap) or through the design loop when the fix needs visual judgment. - Promotion up - the same new pattern appears in two or more PRs (a chip,
a table register, an empty state). Outcome: promote it - name it, move the
CSS to a component file, add tokens if needed, document it in
docs/technical/css_best_practices.md, and refactor the copies onto it.
Cadence
Per-PR (cheap, always) - the reviewer (Sean, Copilot, or Claude on its
own PRs) checks the design checklist below for any PR touching
app/assets/stylesheets/ or view files. Claude PR descriptions should state
any deliberate deviation from the checklist and why.
Periodic sweep (scripted + judgment, roughly per milestone or after any
multi-PR visual batch) - run the drift greps below, screenshot the main
surfaces (the capture harness in design_system_workflow.md), and compare
against docs/design/returns/*/design-final.html canvases. The ux-designer
agent is the natural runner for the judgment half. Output: design-drift
issues (one per theme, not per instance) plus promotion candidates.
Per-PR design checklist
- Colors come from semantic tokens (
--color-*); no rawoklch()/hex outside_global.css. Alpha variants of a token usecolor-mix()with the token, not a re-derived literal. - Radii, spacing, shadows, transitions come from tokens; a value the scale lacks is a conversation about the scale, not a literal.
- Breakpoints follow the mobile-first round-number convention
(
css_best_practices.md, #431). - Single-class selectors, BEM-ish names, no
!importantoutsideutility.css, no id selectors. - Type sizes in
rem; system font stack; focus states via--focus-ring/--focus-offset. - Every string in six locales; index pages use list rows; beta features use the beta indicator pattern (#388).
- New repeated pattern? Flag it as a promotion candidate in the PR description.
Drift greps
# raw colors outside the token files
grep -rnE "oklch\(|#[0-9a-fA-F]{3,6}([^0-9a-fA-F]|$)" app/assets/stylesheets/ | grep -v "_global\|reset" | grep -v "var(--"
# unscaled radii / px type / importants / id selectors
grep -rn "border-radius: [0-9]" app/assets/stylesheets/*.css | grep -v "var(--radius"
grep -rn "font-size: [0-9]*px" app/assets/stylesheets/*.css
grep -rn "important" app/assets/stylesheets/*.css | grep -v utility.css
grep -rn "^ *#[a-z]" app/assets/stylesheets/*.css
# fractional/legacy breakpoints (should only be documented exceptions)
grep -rn "max-width: [0-9]*\.98px" app/assets/stylesheets/*.css
# legacy light-only palette tokens (pre --lch-* system; break dark mode)
grep -rnE "var\(--color-(primary|grey|text|white|black|blue|teal|orange|green|amber|red)" app/assets/stylesheets/*.css | grep -v _global
# focus suppression without a visible replacement (check each hit's context)
grep -rn -C3 "outline: none" app/assets/stylesheets/*.css
# cascade layers (banned) and dark-mode blocks outside the token file
grep -rn "@layer" app/assets/stylesheets/
grep -rl "prefers-color-scheme" app/assets/stylesheets/*.css | grep -v _global
# inline styles and inline scripts in views (dynamic values like widths are fine)
grep -rn 'style="' app/views/ --include="*.erb"
grep -rl "<script" app/views/ --include="*.erb"
First sweep results (2026-07-22)
Recorded here as the baseline; filed as issues rather than fixed inline:
- Token gaps the drift reveals -
oklch(1 0 0)(white-on-accent) is hand-rolled in 4 files where text sits on accent fills: the palette needs an--color-on-accentsemantic token. The radius scale stops at--radius-sm: 0.25rem+--radius-pill, so 0.3125/0.5rem and 8/10px literals proliferate (documents.css alone has 20): the scale needs--radius-md/--radius-lg, then a sweep. Alpha accents likeoklch(var(--lch-accent) / 0.7)should becomecolor-mixon the semantic token. One hardcoded gold indocuments.css:1476. - Pre-redesign stylesheets -
article.css,article-header.css,base.css,modal.css,grammar_notes.cssstill carry px type sizes, andarticle-header.cssstyles the old audio player through id selectors. These predate the design system and want a modernization pass through the design loop when their surfaces are next touched. - Healthy:
!importantis confined toutility.css(by design) plus 4 strays; transitions all use the token but one; breakpoints were swept to the round-number mobile-first convention in #431.
Second sweep results (2026-07-23)
Widened the lens beyond the first sweep’s color/radius/px greps to the remaining conventions (dark-mode single-sourcing, focus states, cascade, spacing, view layer); the new greps above came from this pass. Findings are listed on #457 as checkboxes; the baseline numbers:
- Legacy light-only palette is the largest drift by far -
_global.cssstill defines the pre---lch-*palette (--color-primary, ten--color-grey-*steps,--color-text-*,--color-white, named colors), raw literals with no dark-mode redefinition. 244 usages across 36 files, and new files keep adopting them (profile-header.css, profile.css, profile-evaluate.css, coach.css) because the tokens still exist and look official. - Focus suppression - 20
outline: nonedeclarations across 10 files; several leave no visible focus indicator at all (button.css.btn-optionand the close-button block).--focus-ringadoption: 10 files. - Spacing scale barely adopted - 45
var(--space-*)usages vs 334padding:literals. Whether to sweep literals onto the scale or accept rawremvalues in components is an open scale conversation, not mechanical drift. (This sweep also fixedcss_best_practices.md, which documented a--space-1..8scale that never existed.) - On-accent white regrew after the first-sweep baseline - four new
oklch(1 0 0)instances in the profile train (profile-shell.css x2, profile-tabs.css, profile-evaluate.css) on top of #482’s four; drift compounds until--color-on-accentlands. - Healthy: no
@layeranywhere; the dark-mode block lives only in_global.cssand redefines only--lch-*primitives plus the shadow tokens; index pages use list rows (shared/_list_grid+list-row.css);native.css’s four!importants are deliberate utility toggles, same class asutility.css; first-sweep stray-!importantand id-selector counts unchanged (#483 still accurate).
Promotion candidates from the recent PR batch
The #454-#456 train shipped several patterns now used by 2+ surfaces and
worth naming as components if they recur again: the workspace check-grid
(deck-workspace-check), the pill radio group (deck workspace card styles,
canvas 1e source switcher), and the quiet row-affordance link (Add example,
workspace rows). No action yet; noted so the next sweep can count instead of
rediscover.