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

nordisk/facett

.nornir/theming-reasoning.md

Theming — reasoning

Class: reasoning. This file holds the why, the trade-offs, and every design we rejected for the look system. The descriptive shape we actually ship is in theming-design.md, which by rule mentions none of this. The workflow is in theming-guide.md.

f0 — why native-adaptive at all

The goal stated in the look-&-feel work order was never "one look for everybody" — it was feel at home on the OS without forking the render engine. macOS and Windows 11 users have decades of trained expectations (traffic-lights vs caption buttons, vibrancy vs Mica, rubber-band vs hard-edge scroll) and a UI that ignores them reads as a foreign guest app, no matter how polished. The alternative — literally reimplementing macOS's NSScrollView elasticity or Fluent's reveal shader — was never on the table; facett already had a motion primitive (Tween), a bloom primitive (glow_rect), and a palette (crate::Theme). So the native-feel layer is deliberately thin: Platform

Rejected: reading cfg!(target_os) (or the live OS) everywhere a cue is needed. Every component would need its own if macos { … } else if windows { … } branch, duplicated per call site, and untestable without actually compiling for that OS. We instead resolve Platform once, fold it into a NativeFeel struct carried on the Theme, and publish it on the egui::Context (feel::publish_native) the same way the palette and keymap are published. A component reads native_feel(ui) once; the if macos branching happens exactly once, inside the six NativeFeel::{macos,windows, neutral} constructors — never at a call site. A host can also override the detected platform at runtime (Theme::for_platform), which a pure cfg! branch could never support — the demo's Mac ⇄ Windows ⇄ Neutral toggle depends on this.

Rejected: egui's built-in animate_*. egui ships ctx.animate_value_with_time and friends, which would have been the path of least resistance for the spring overshoot / reveal glow. We use facett's own Tween/SmoothScroll/FadeTrack instead, all driven by an injected clock (elapsed: f32 the caller owns), never ctx.input(|i| i.time) read inside the primitive itself. The reason is determinism: egui's animate helpers memoize state keyed to widget Ids inside the context, which makes a "same inputs → same output" unit test impossible to write without a live, ticking Context. Facett's motion primitives are pure functions of (from, to, duration, curve, elapsed) — Tween::value_at is literally f(elapsed) with no hidden state — so the whole native-feel layer inherits headless-testability for free (FC-1/FC-7). The cost is that every component must pass its own dt/elapsed explicitly; we accepted that cost because the payoff (harness::render_themed + theme::probe/feel::probe proving which palette/platform a component actually painted with) only exists because nothing is captured behind egui's back.

f1 — why derive the flat palette instead of migrating every component

crate::Theme (the flat Color32 palette) predates look::Theme (the rich, OKLCH-authored one) — 16+ custom-painted components (graph, depgraph, map, grid, …) already read theme(ui) directly. The tidy option was to migrate every one of them to read look::Theme::palette (Palette, OKLCH roles) directly and delete the flat type. Rejected: that is a mechanical-but-invasive change across every component crate, all at once, for a benefit (typed roles instead of Color32 fields) that doesn't change what ships. Instead look::Theme::apply derives the flat palette from the rich one (to_legacy_palette) and publishes it under the same key theme(ui) already reads (theme::publish_palette, not the old set_theme) — so every existing component follows a look::Theme preset with zero per-component code change (COH-1). The rich Theme is the single source of truth; the flat one is computed, never authored twice. The one thing we had to get right for this bridge to be honest: set_theme's override_text_color must not be re-imposed over the Style apply just installed (theme.rs's publish_palette doc, §27) — apply calls publish_palette directly rather than routing through set_theme, which would otherwise silently clobber the rich Style's own text colouring the moment a host called both.

Rejected: two independent palette systems living side by side with no bridge. We could have left crate::Theme's six flat palettes (sci_fi, nordic_aurora, …) and look::Theme's nine OKLCH presets as unrelated APIs, each theming a disjoint set of components. We didn't unify them into one enum, because they serve genuinely different jobs — the flat family is "give this graph/map a standalone accent mood," the rich family is "make the whole app OS-coherent" — but we did wire the rich one to compute the flat one, so a host theming the whole app with look::Theme::nordisk() doesn't leave the flat-palette components looking like they missed the memo.

f1 — the Nordisk-default decision

Theme::nordisk_dark/nordisk_light (look/presets.rs:135,:163) is facett's own identity preset: a calm, premium Nordic take on the Windows/macOS idiom, distinguished purely by hue/chroma (an aurora-cyan accent/glow no OS preset uses) so the lightness values keep the exact WCAG margins already proven for the Windows palette. The Rust Default impl for Theme still resolves to Theme::windows_dark() (look/mod.rs:64–68) — we did not change what "default" means at the type level, because Default::default() is load-bearing in tests and any code that constructs a bare Theme expecting the previously-audited Windows preset. What changed is the demo's own starting look: facett-demo/src/lib.rs:811 resolves look_preset by searching LookTheme::preset_names() for "nordisk-dark" at startup, so the first thing anyone sees when they open the demo is the flagship identity, not a Windows clone. Resolving by name rather than a hard-coded index was deliberate: PRESETS (look/mod.rs:295) is a &'static [fn() -> Theme] a future change could reorder or extend, and an index literal would silently point at the wrong preset the moment that happened.

Rejected: making Nordisk the OS-fallback in Theme::from_os. from_os (used by facett-demo/src/main.rs at startup, keyed off the real host OS) still falls back to Theme::windows_dark() for Linux/unknown (look/mod.rs:265–273), not Nordisk. The identity preset is the demo's own chosen opening look, deliberately independent of OS auto-detection — swapping the OS-fallback itself would have conflated "what a bare Linux/unspecified host gets" (a documented, boring, WCAG-audited default) with "what facett wants to show off first" (its own premium identity). Keeping them as two separate decisions means a host embedding facett on Linux still gets the neutral, unbranded Windows-dark look unless it explicitly opts into Nordisk.

f2 — why WindowControls is a data hint, not drawn chrome

WindowControls::{TrafficLights, Caption, None} (look/platform.rs:102) only tells a host which side the OS convention expects — it doesn't paint anything. Rejected: drawing an actual titlebar with real traffic-light/caption-button widgets inside facett-core. Real window chrome requires cooperating with the host's windowing backend (borderless-window mode, hit-testing the drag region, minimize/maximize/close wired to the actual OS window handle) — none of which facett-core has access to; it only ever gets an egui::Ui. Baking a fake titlebar into the theme layer would have meant either (a) it's decorative and lies about being interactive, or (b) facett-core grows a dependency on window-management APIs it has no business owning. We chose to ship the fact (which side, which style) as serde data and leave drawing it to the host, which already owns the real window.

f2 — why typography states intent, never bundles a face

Typography's UiFont::{SanFrancisco, SegoeUi, System} names a font family; it does not embed one. Rejected: shipping SF Pro / Segoe UI Variable as bundled assets so the look is pixel-identical off any OS. Both fonts carry licenses that don't permit redistribution outside their own OS/SDK — bundling them would have made facett undistributable as an open dependency. UiFont states which face the preset wants; the host loads it at runtime if it has legal access to it (macOS/Windows ship their own system font; other hosts fall back to whatever System resolves to). This is the same "describe intent, let the host supply the resource" pattern as WindowControls.

f3 — why the raven/glow effects stay CPU/glow-backend only, for now

effects.rs's bloom (glow_rect) and the signature RavenSprite are built from layered alpha shapes on an egui::Painter — no custom GPU shader, no vello. Rejected (deferred, not abandoned): true HDR bloom (a bright-pass + separable Gaussian on an HDR render target) and a textured/instanced raven sprite, both of which belong to the wgpu kernel once it lands. We didn't block shipping the look of glow/motion on that GPU work landing first, because the CPU path is indistinguishable at the primitive level to every call site — glow_rect(painter, rect, color, intensity, layers) has the exact call shape a wgpu-backed replacement would keep, so consumers don't change when the swap happens. The same trade shows up in the domain-agnostic decor kernel (facett_core::render::decor, shipped 2026-06-22): its bloom_composite is an explicit CPU substitute for a vello Gaussian glow, chosen specifically because the wasm demo never pulls vello — if "glow" only existed on the GPU/vello lane, the always-available lane (native and wasm) would have no bloom at all. "L0 must look great with zero vello" was the constraint; the CPU bloom pass is how that constraint is met without waiting for the GPU kernel.

f3 — why every effect takes an injected clock/seed, never reads Instant::now()

ParticleBurst, RavenSprite, IceDrip, and the decor-kernel helpers all take an explicit elapsed/phase/seed rather than sampling the wall clock or an RNG crate internally. Rejected: rand-crate jitter and Instant::now()-driven animation, the natural first instinct for "particles fired in a ring" or "icicles that drip." Both would make a snapshot test flaky by construction — two runs of the same scene would disagree pixel-for-pixel, so a regression could hide behind "well it's animated, of course it's different." Determinism was non-negotiable because the whole test strategy for this subsystem (harness::render_themed, the coherence probes, the decor kernel's inject-assert oracles) depends on being able to pin a frame and assert on it byte-for-byte. SplitMix64 (deterministic, dependency-free) replaces rand wherever a "random-looking but reproducible" spread is needed (ParticleBurst::new, IceDrip::new).

f4 — why chrome::card splits into fill_shape + edge instead of one paint call

A single card(ui, rect, …) call is enough for an empty card, but a card whose content is painted into it needs the fill to land behind that content and the glow/border on top of it. Rejected: always painting fill-then-edge back-to-back and asking the caller to paint their content in between two separate chrome calls with matching ChromeStyle — that duplicates the style argument and invites the two calls drifting out of sync (different radius on the fill vs the edge, say). Splitting into fill_shape (returns a Shape value the caller inserts into a reserved painter slot) and edge (paints directly, called after) keeps one ChromeStyle value threaded through both, and makes the "fill behind, edge in front" ordering a property of when you call each function rather than something the caller has to remember to get right twice.

How we got here (the road, briefly)

The flat crate::Theme + effects.rs motion/bloom layer landed first (ce91f1f) as the "make facett feel alive" pass — one palette, easing curves, glow_rect, the raven. The look-&-feel work order then built the rich look::Theme on top: Windows + macOS + Device presets (M1), then macOS parity + composition/overlays (M2), landing the platform-adaptive native-feel layer (Platform/NativeFeel) as the way to make those presets feel genuinely native rather than just re-tinted. The Skaði "Skade Vinter" winter showcase and the Nordisk flagship identity preset were authored fresh (no legacy UI to port) once the preset machinery was proven — Skaði as an effects showcase for the IceDrip decor, Nordisk as facett's answer to "what does our own look feel like," landed 4d8fc8f and immediately adopted as facett-demo's opening preset. The decor kernel (facett_core::render::decor, 2026-06-22) is the parallel domain-agnostic answer to the same "look great with zero GPU dependency" pressure, built for the population-graph showcase but kept generic so any graph/map skin can reuse it. Ongoing honest seams — real host-drawn window chrome, embedded fonts, true wgpu bloom — are each deferred for the specific licensing/ownership reasons above, not oversight.