The design system
The v8.1 interface packet gives Reticle one source of visual truth. Every color,
size, radius, spacing, font, and shadow comes from a single theme module; a lint
makes a hard-coded literal a CI failure; contrast is proven by unit tests, not
eyeballed; and one component library is the only widget source the application
draws from. This chapter describes that system and the workflow that keeps it
honest. The binding token specification is docs/design/tokens.md; the four
decisions behind the system are ADRs 0095 to 0098.
Tokens: one source of visual truth
crates/reticle-app/src/theme/ is the only place a color or size value may live.
theme/tokens.rs encodes the semantic token table from tokens.md and maps it
once onto egui::Style and Visuals, starting from Visuals::dark(). Tokens are
named by role, not by value: bg_canvas, bg_panel, bg_raised, bg_input for
surfaces; text, text_weak, text_faint for type; accent, danger,
warning, success for state; widget_bg, widget_hover, widget_active for
interaction; and a small radius, elevation, and spacing set. Chrome is neutral and
desaturated on purpose, so layer colors and geometry dominate the canvas and color
in a panel always means data or state.
The theme is applied at boot through set_style_of and re-applied only when a
dirty flag is set (a density or reduced-motion change), never per frame. Canvas
data colors (the layer palette, the DRC heatmap, the diff overlay, presence
cursors) are a separate namespace in tokens.rs, so a data color can never
masquerade as chrome and the lint still covers it.
This packet ships a single dark theme (ADR 0095). Light is deferred by design:
the Theme enum stays and session files carrying theme=light keep parsing and
resolve to dark, so a future light variant is a second token table, not an
architecture change. Shipping a tokened dark beside an untokened stock light
would reintroduce exactly the inconsistency the packet exists to remove.
Contrast proven, not eyeballed
theme/contrast.rs re-proves every contrast pair from the tokens.md table in CI,
using the WCAG relative-luminance ratio computed in-crate with no external
dependency. The tests assert full opacity first (Color32 is premultiplied), then
the ratio floor for each pair: primary text clears 4.5:1 on every surface (for
example 14.06:1 on bg_panel), secondary text_weak clears the 3.0:1 large-text
floor, the focus ring clears the 3.0:1 non-text UI floor of WCAG 1.4.11, and the
accent-on-panel and label-on-accent pairs clear 4.5:1 so links and primary-button
labels read as text. A token edit that dropped a pair below its floor would fail
the gate.
Typography and icons
Three faces are subset into the bundle (ADR 0097): Inter Regular and Medium (SIL
OFL 1.1) for the UI family, JetBrains Mono Regular (SIL OFL 1.1) for coordinates,
readouts, and code, and the Lucide icon font (ISC) as a fallback family so any
label can inline a glyph constant. theme/fonts.rs installs the FontDefinitions
at boot; theme/icons.rs holds the generated Lucide codepoint constants; the
subset TTFs live under crates/reticle-app/assets/fonts/ with their license
notices. The subsets are regenerated by scripts/subset-fonts.ps1 (fonttools via
pip, a documented dev tool, never a build or CI dependency), so the glyph list is
part of the reviewable diff. Section headers use Inter Medium at body size in
text_weak with no uppercase (deep-tool honesty over label shouting); status-bar
and inspector numerals use JetBrains Mono with tabular figures.
The component library
theme/components.rs is the single widget source from Wave 2 forward. Every panel
composes from it, so a density or palette change lands in one place. The library
provides buttons (primary, secondary, ghost, danger), IconButton (a glyph char
plus a tooltip of name and keyboard hint), a toggle chip, a segmented control, a
text field, a section header, a collapsible section, the toast and status stack, a
progress row, an empty-state block, a keyboard-hint chip, and a modal frame. All
styling is tokens-only and every interactive component carries the four token
states from tokens.md: widget_hover on hover, widget_active on press, the
1.5px focus ring whenever keyboard focus lands on it, and text_faint disabled.
The focus ring is never removed for aesthetics, which is what makes the F6 and Tab
traversal (see the shortcuts overlay) visible everywhere.
Lane 1C owns this file and froze its public signatures at the first integration gate; the states-and-motion work extended it additively (new defaulted fields and builder methods) without changing a single existing signature, so the panels that compiled against the gate signatures were never disturbed.
The contribution rule: styling goes through the theme
All styling goes through theme/, and the just check-style lint enforces it.
scripts/check-style.ps1 bans raw Color32 constructors and FontId or
RichText size literals anywhere under crates/reticle-app/src and
crates/web/src except the theme module (ADR 0098). It began as a ratchet: a
committed scripts/style-baseline.json grandfathered the 89 legacy literals and
its counts could only fall, so CI stayed green while the debt was paid down. Lane
1A drained every literal to zero and the baseline file was deleted, so the ban is
now absolute: a new color or size literal outside theme/ fails the gate with a
file-and-line list. Adding a visual value means adding a named token, which is the
whole point.
The same lint carries the voice rule (no em-dash U+2014 in any tracked text file) and the README banned-word list, so honesty of prose and consistency of style are one gate.
Density and touch
Two density modes ride the 4px spacing rhythm. Comfortable and compact remap the
egui Spacing fields and the text scale (for example interact_size.y 28 versus
22, body 13.0 versus 12.0 points); the Settings dialog toggles between them and
persists the choice. Touch mode (lane 4B) raises interact_size.y to 40 on top of
either density through theme::apply, so a coarse-pointer device gets 40px targets
without a second layout. The Settings dialog exposes touch as a tri-state (Auto, On,
Off): Auto follows the platform coarse-pointer signal and On/Off override a device
whose signal is wrong.
Motion
Motion is functional: transitions communicate a state change and nothing animates
for decoration. Style::animation_time is 0.12s comfortable, 0.10s compact, and
0.0 when reduced motion is on, so reduced motion collapses every transition to
instant through one shared contract. The animated pieces are the camera tween
(camera::CameraTween, ease-out cubic in log-space zoom), the collapsible section
openness, the progress-row fill, and the toast fade; each honors the reduced-motion
zero-time path.
Visual regression and the frame guard
The gallery is the visual-suite surface. App::gallery() renders every component
group at both densities with deterministic, screenshot-stable content; it is
reachable with the --gallery native flag and the ?gallery=1 web boot arm.
crates/reticle-app/tests/ui_snapshots.rs is the visual-regression suite, built on
egui_kittest 0.35 over the wgpu backend. It has two families: gallery snapshots
(each component group crossed with density through the frozen theme::gallery::ui)
and full-application snapshots of the real App at 1280x800, 1600x1000, and 900x600
plus a palette-open state. just ui-check runs the suite; just ui-baselines
recaptures the committed PNGs under tests/snapshots/. Both families need a GPU
adapter (egui_kittest 0.35 with the wgpu feature has no CPU rasterizer), so they
skip honestly on an adapterless host and .config/nextest.toml serializes them so
concurrent worktrees never contend for the single GPU.
crates/reticle-app/tests/frame_guard.rs guards motion cost. It builds the real
App on the kittest wgpu harness, runs untimed warmup frames, then times 120 steps
and asserts the median step wall time stays under one 60 Hz budget (16 ms). Median,
not mean, so first-layout and font-atlas warmup do not dominate; the guard trips on
a per-frame UI-build regression, which is what the states and motion work could
regress. just frame-guard runs it, serialized on the single GPU alongside the
snapshots. The budget is held at an honest 16 ms and is never widened to force a
pass; a flaky GPU check is quarantined instead.
Where to look
docs/design/tokens.md: the binding token, contrast, spacing, type, radius, and elevation tables.docs/design/catalog-dispositions.md: the per-item disposition of all 100 Improvement Catalog entries with committed evidence.- Decision records: 0095 (semantic token module, one dark theme), 0096 (managed panels over docking), 0097 (typography and the Lucide icon font), and 0098 (the style ratchet and the bundle-size gate).