Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/theming-design.md

Theming — design

Class: design · Function axis: f0 (the look system) · f1 Theme + presets · f2 NativeFeel/Platform · f3 effects/deckfx · f4 chrome/decor. Strict: this file states only the look system we ship. Every why, every alternative, lives in theming-reasoning.md. Workflow is in theming-guide.md.

f0 — the look system (the whole)

facett has two theme layers, one derived from the other:

  1. look::Theme (facett-core/src/look/mod.rs:43) — the rich source of truth: one struct carrying palette, typography, metrics, scroll, keymap, focus, surface, motion, effects, perf, and native (NativeFeel). Every field is serde and fully populated by every preset — no leaked defaults.
  2. crate::Theme (facett-core/src/theme.rs:18) — a flat Color32 palette (bg, node_fill, edge, text, accent, glow, …) that the custom-painted components (graph/depgraph/map) read directly via theme(ui) (theme.rs:239).

look::Theme::apply(ctx) (look/mod.rs:382) is the single entry point: it installs a complete egui::Style (egui_style, look/mod.rs:351), then derives the flat palette with to_legacy_palette (look/mod.rs:417) and publishes it with theme::publish_palette (theme.rs:227) — so every existing custom-painted component follows the rich theme with no per-component change. apply also publishes the KeyMap (keymap::publish_keymap), the EffectsPolicy (policy::publish_effects), and the NativeFeel (feel::publish_native, look/feel.rs:33) onto the same egui::Context — four independent publish calls, one apply.

Everything downstream reads from these four published values through pure functions of (rect, pointer, published-state) — no egui-memory state, no wall-clock captured inside the look system itself (effects.rs's Tween/FadeTrack take an injected clock). This is what makes every themed paint path headless-testable via harness::render_themed (harness.rs:81).

The four functions below are one system because they compose in paint order: f1 picks the palette + metrics; f2 adapts the OS-feel cues on top; f3 supplies the motion/glow primitives f2 and every component call into; f4 is the one shared "card" shape that combines a f1 fill with an f3 glow edge, gated by f2's policy.

f1 — Theme + presets

look::Theme (look/mod.rs:43) presets, each a fn() -> Theme fully populating every sub-struct:

preset fn mode palette hue metrics native
Windows Theme::windows_dark/windows_light (look/mod.rs:74,98) dark/light blue (Fluent) Metrics::windows() (6/8 radii) NativeFeel::windows()
macOS Theme::macos_dark/macos_light (:113,134) dark/light blue-violet Metrics::macos() (8/12 radii) NativeFeel::macos()
Device Theme::device (:150) dark near-mono amber Metrics::device() (2/2 radii) NativeFeel::neutral(), EffectsPolicy::None
Neutral Theme::neutral (:175) dark Windows palette, no OS chrome Metrics::windows() NativeFeel::neutral()
Skaði "Skade Vinter" Theme::skade_vinter (:200) dark glacier blue / ice white Metrics::windows() NativeFeel::neutral()
Nordisk Theme::nordisk_dark/nordisk_light/nordisk (:224,245,257) dark/light deep-water blue + aurora cyan accent/glow Metrics::nordisk() (8/12 radii) NativeFeel::neutral()

Theme::default() (look/mod.rs:65) resolves to Theme::windows_dark(), the Rust-trait default; facett-demo opens instead on "nordisk-dark", resolving the preset by name (facett-demo/src/lib.rs:811) so it survives a PRESETS reorder — the flagship identity is the demo's chosen starting look, not the Default impl. Theme::PRESETS (:295) lists all nine (light+dark where applicable) for a switcher/gallery; Theme::by_name (:311) and Theme::preset_names (:307) back a fuzzy-matched picker.

Colour is authored in OKLCH semantic roles (look::Palette, look/palette.rs:15): surface, on_surface, on_surface_dim, surface_container, surface_extreme, primary, on_primary, accent, outline, selection, success/warn/error + their on_ counterparts, and glow. egui's five widget states are derived from these roles by lightness steps (Palette::step, palette.rs:49) — there are no per-state colour literals. nordisk_dark_palette/nordisk_light_palette (look/presets.rs:135,163) mirror the (WCAG-passing) Windows lightness values and carry the identity purely in hue/chroma: accent/glow sit at hue 195–200° ("aurora cyan") where no OS preset lands.

Theme::from_os(os) (look/mod.rs:265) maps egui's detected OS to a preset (Mac→macos_dark, Windows→windows_dark, else→windows_dark, the documented Linux default). Theme::egui_style() (:351) builds the full egui::Style (visuals + spacing from Metrics + text scale from Typography) that apply installs.

The flat crate::Theme (theme.rs:18) additionally ships its own family of six standalone palettes reachable without the rich look::Theme — default, sci_fi, nordic_aurora, cyberpunk_neon, amber_crt, deep_space, hugin_noir (theme.rs:56–191), enumerated via Theme::ALL/names/by_name (theme.rs:55,67,74). These are independent of the look::Theme presets above — to_legacy_palette derives from a look::Theme, it does not select among these six.

theme::probe (theme.rs:252) is a thread-local witness: every theme(ui) call records the palette name it handed out, so a headless test asserts a component consumed the published palette rather than a private default (facett/tests/coherence.rs:68).

f2 — NativeFeel / Platform

Platform (look/platform.rs:35) is { Mac, Windows, Neutral }. Platform::detect() (:49) resolves from cfg!(target_os) at compile time (macOS→Mac, Windows→Windows, else→Neutral); a host overrides it explicitly via Theme::for_platform(p) (look/mod.rs:279) or Theme::for_detected_platform() (:290). Platform::cycle() (platform.rs:80) walks Mac→Windows→Neutral→Mac for a runtime toggle.

NativeFeel (platform.rs:158) is the per-platform cue bundle carried on every Theme as its native field:

cue field macOS (NativeFeel::macos, :189) Windows (NativeFeel::windows, :203) Neutral (NativeFeel::neutral, :217)
reveal highlight reveal_highlight: bool false true false
rubber-band overscroll rubber_band: bool true false false
window-control side window_controls: WindowControls TrafficLights (left) Caption (right) None
focus indicator focus_ring: FocusRing FocusRing::macos() — 3px, glow, expansion 2.5 FocusRing::windows() — 2px, crisp, expansion 1.0 FocusRing::neutral() — 2px, crisp, expansion 1.5
accent strength accent_tint: f32 0.85 0.65 0.55
elevation elevation: f32 0.30 0.70 0.45

Motion (look/policy.rs:133) carries the platform's easing personality, read by Tween::from_motion/from_motion_fast (effects.rs:151,156): Motion::windows() (policy.rs:151) = EaseInOutCubic, duration 0.15s/fast 0.09s; Motion::macos() (:157) = EaseOutBack, duration 0.30s/fast 0.18s (the spring-overshoot "rubber-band" personality); Motion::neutral() (:162) = EaseInOutCubic, 0.20s/0.12s. Theme::device() (look/mod.rs:162) pins Motion { duration: 0.0, fast: 0.0, curve: Curve::Linear } — instant, honouring reduced-motion.

The routing layer that turns a published NativeFeel into paint is look::feel (look/feel.rs): native_feel(ui) (:45) is the single resolution point (falls back to Platform::detect().native_feel() if nothing was published), and records the platform into feel::probe (:56) the same way theme::probe witnesses palette consumption. Three one-call helpers read it:

WindowControls (platform.rs:102) is a data hint (TrafficLights/Caption/ None), not drawn chrome — on_left() (:113) tells a host which edge to draw its own window controls on.

f3 — effects / deckfx

facett_core::effects (effects.rs) is glow-backend only: every helper paints layered-alpha shapes on an egui::Painter, so nothing here depends on a GPU shader. Three layers:

  1. easing (effects.rs:26) — linear, ease_in_out_cubic, ease_out_back, elastic, bounce; each clamped so f(0)=0, f(1)=1 exactly. Curve (:88) names a curve so a Theme's Motion can carry which one to use.
  2. Motion primitives — Tween (:132, from→to over a duration with a Curve, from_motion/from_motion_fast at :151/:156 pull the duration from a Motion), tween_rect (:189, drawer slide-over), FadeTrack (:215, per-element hover/ selection fade keyed by u64, begin/lit/advance/factor protocol).
  3. Bloom + decor — glow_rect/glow_text/shimmer (:301,429,454), RevealHighlight (:341, the Windows cursor-follow edge glow; proximity(rect, pointer) at :373 is the pure [0,1] core), ParticleBurst (:493, deterministic given a seed), RavenSprite (:581, the signature bezier- arc flight + perch, RAVEN_FLIGHT_SECS = 1.4, pos_at(elapsed) at :663 is the pure testable core), and IceDrip (:804, the Skaði "Skade Vinter" icicle + melt-droplet

    • frost-shimmer decor, off by default via its enabled gate at :861).

Every animated primitive above takes an injected clock or explicit elapsed — no primitive reads the wall clock or holds egui memory internally — which is what makes (seed, clock) → identical output provable in a headless test.

deckfx.rs wires theme + effects into FacetDeck (lib.rs:297) as opt-in config, off by default:

f4 — chrome / decor

chrome.rs is the shared glass/card decoration: one rounded frame combining an overlay::glass_tint fill with a glow_rect edge, gated end-to-end by EffectsPolicy (look/policy.rs:20).

The domain-agnostic sibling is the decor kernel (facett_core::render::decor, shipped 2026-06-22): pure-CPU L0 "cyber-toon" helpers (glow_halo, ao_shadow, node_decor, dash_line, particles_on_edge, shockwave_ring, bloom_composite) that compose the L0 primitive vocabulary (QuadInstance/LineInstance/CircleInstance/ RingInstance) into the same glow/shadow/motion look with zero GPU/vello dependency, so the wasm demo (which never pulls vello) still gets the full look. Every helper takes an injected phase/t — no internal clock — the same determinism law as f3.