Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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