Theming — design
Class:
design· Function axis:f0(the look system) ·f1Theme + presets ·f2NativeFeel/Platform ·f3effects/deckfx ·f4chrome/decor. Strict: this file states only the look system we ship. Every why, every alternative, lives intheming-reasoning.md. Workflow is intheming-guide.md.
f0 — the look system (the whole)
facett has two theme layers, one derived from the other:
look::Theme(facett-core/src/look/mod.rs:43) — the rich source of truth: one struct carryingpalette,typography,metrics,scroll,keymap,focus,surface,motion,effects,perf, andnative(NativeFeel). Every field isserdeand fully populated by every preset — no leaked defaults.crate::Theme(facett-core/src/theme.rs:18) — a flatColor32palette (bg,node_fill,edge,text,accent,glow, …) that the custom-painted components (graph/depgraph/map) read directly viatheme(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:
reveal_on_hover(ui, rect, corner_radius)(:88) — no-ops unlessNativeFeel::reveal_highlight, else buildseffects::RevealHighlightfrom the publishedglowrole.apply_focus_ring(ui, rect, focused, corner_radius)(:129) — resolvesFocusRingVisual(focus_ring_visual,:115) from theFocusRingspec; the crisp stroke always paints when focused, the soft glow halo only underEffectsPolicy::allows_decorative_motion.elevation_shadow(ui, rect, corner_radius)(:174) — resolvesElevationShadow(elevation_shadow_params,:161) fromNativeFeel::elevation; gated onEffectsPolicy::allows_transparency, andalpha == 0short-circuits (Device: no shadow at all).
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:
easing(effects.rs:26) —linear,ease_in_out_cubic,ease_out_back,elastic,bounce; each clamped sof(0)=0,f(1)=1exactly.Curve(:88) names a curve so aTheme'sMotioncan carry which one to use.- Motion primitives —
Tween(:132, from→to over a duration with aCurve,from_motion/from_motion_fastat:151/:156pull the duration from aMotion),tween_rect(:189, drawer slide-over),FadeTrack(:215, per-element hover/ selection fade keyed byu64,begin/lit/advance/factorprotocol). -
Bloom + decor —
glow_rect/glow_text/shimmer(:301,429,454),RevealHighlight(:341, the Windows cursor-follow edge glow;proximity(rect, pointer)at:373is the pure[0,1]core),ParticleBurst(:493, deterministic given aseed),RavenSprite(:581, the signature bezier- arc flight + perch,RAVEN_FLIGHT_SECS = 1.4,pos_at(elapsed)at:663is the pure testable core), andIceDrip(:804, the Skaði "Skade Vinter" icicle + melt-droplet- frost-shimmer decor, off by default via its
enabledgate at:861).
- frost-shimmer decor, off by default via its
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:
DeckFx(deckfx.rs:31):palette: Option<usize>(an override index intoTheme::ALL),glow: bool+glow_speed/glow_layers,chrome: bool.DeckFx::OFF(:55) /Default(:47) are identical — all off.FacetDeck::with_fx/fx_mut/set_palette/cycle_palette(lib.rs:385,394,399,404) are the deck-level entry points;palette_picker(ui)(lib.rs:452) draws a one-line switcher overTheme::ALLand only pins the override once the user actually clicks a different entry (drawing it must not silently clobber a host's ownlook::Theme).DeckFx::glow_intensity_at(time)(deckfx.rs:96) is a pure[0,1]pulse viaeasing::ease_in_out_cubic;paint_active_glow(:175) blooms the active facet's rect with it.DeckRaven(:127) wraps aRavenSpriteaimed and tinted for a target rect;FacetDeck::send_raven(target)(lib.rs:413) launches one, tinted viaraven_body(deckfx.rs:166, lifted off the theme'sbgso the silhouette stays visible), and the deck drives/paints it on a foreground layer automatically insideui().
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).
ChromeStyle(chrome.rs:27):radius,tint_alpha,glow_intensity,glow_layers,border_width.Default(:41) is an 8px-radius card, light tint, soft 5-layer glow, 1px border.ChromeStyle::for_policy(policy)(:52) softens perEffectsPolicy:Full— full look;Reduced— dropsglow_intensityto 0, keeps tint + border;None— drops both glow and tint (opaque card only).fill_shape(rect, theme, policy, style)(:75) returns the fill (behind content): aglass_tintovertheme.panel_bgwhenpolicy.allows_transparency()andtint_alpha > 0, else the raw opaquepanel_bg.edge(painter, rect, theme, policy, style)(:90) paints the edge (over content):glow_rectwhen transparency + glow are allowed, then a crisp rounded border unconditionally (border width> 0) — the border is what still reads as "a card" underEffectsPolicy::None.card(ui, rect, theme, policy, style)(:110) paints fill-then-edge in one call for an empty card or content drawn after; a host drawing content into the card reserves a slot forfill_shapefirst and callsedgeafter, so the fill stays behind (this is whatFacetDeck::uidoes forDeckFx::chrome).
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.