Theming everywhere (C8) + the facett-UI migration plan (D3)
Design + guide for the app-wide facett palette across every viz pane, and the plan to re-home the generic viz widgets into the upstream
facettcrate. Closes THE_BIG_PLAN C8 ("viz uses the facett themable/color schema everywhere") and D3 ("ALL generic viz UI migrated into facett; viz consumes facett themes"). Laws honoured: #1 inject-assert tests · #2 this doc · #3 facett components/theme · #4 screenshot proof · #6 "see what the user sees" as data.
TL;DR
- One app-wide facett palette (
UrdrThreadsApp.palette), picked from a 🎨 top-bar combo, applied to eguiVisuals, and broadcast to every pane. Switching it re-skins the whole viz at once — status colours, accents, zebra/hover/selection, gridlines, typography — and the change is observable headlessly instate_json["palette"]and each pane's ownpalettekey. - Every pane routes its colours through the vendored facett palette (
src/viz/facett_theme.rs) + its semantic ramps — no more hard-codedColor32::from_rgb(...)chrome. - The generic, non-nornir widgets (status chips, badges, sparklines, zebra rows, health pill, dep-graph node style) are factored into
src/viz/facett_ui.rs, a clean palette-driven module the viz owns now and re-homes intofacettwhen the egui versions align (the egui-skew blocker below).
Why a vendored palette, not a facett dependency (the egui-version blocker)
nornir's viz is pinned to egui 0.33 — the highest version egui-snarl 0.9 (the Funnel node-graph) supports, so the whole viz unifies on one egui (see Cargo.toml's egui block). The upstream facett crate is on egui 0.34. egui's Color32 / Painter / Ui / Visuals types are version-specific and do not interoperate, so a direct dep:facett-grid / dep:facett-table would link two egui versions into one binary — a hard build/ABI break.
This is the exact skew the L5 agent hit, which is why facett_theme.rs is a verbatim vendored copy of facett_core::theme::Theme (same names, same colours, same ALL order) rather than an import. C8/D3 follow that precedent.
Migration plan (when the move becomes a re-home, not a rewrite)
| When | What happens |
| today | facett_theme (palette) + facett_ui (widgets) live in the viz crate, both fully palette-driven (every widget takes a Theme). |
| egui-snarl ships on egui 0.34 (or the Funnel moves off snarl) | bump the viz to egui 0.34. |
| after the bump | facett_theme collapses to pub use facett::Theme;, facett_ui collapses to pub use facett::widgets::*;, and the panes import the upstream components directly. Because every widget already takes a Theme, the diff is a re-home, not a re-write. |
Tracking dep: egui-snarl 0.34 support (or de-snarl the Funnel). Until then this doc + the module headers are the "facett-bound when egui aligns" marker.
Architecture — one palette, broadcast to all
🎨 top-bar picker ──┐ viz.click { "palette": "…" }
▼ │ (robot / MCP parity)
UrdrThreadsApp.set_palette(Theme) ◄┘
│
┌───────────────────────┼───────────────────────────┐
▼ ▼ ▼
ctx.set_visuals(theme.visuals()) broadcast_palette() state_json["palette"]
(re-skins egui widgets) pane.set_palette(t) ×N (+ each pane's own
(test, release, live, "palette" key — LAW 6)
knowledge, warehouse,
mcp, callgraph, funnel,
security, search, gates,
bench, timetravel, dep_view, leaderboard)
set_palette(pub, onUrdrThreadsApp): idempotent; on change it stores the palette, marks the eguiVisualsstale, callsbroadcast_palette, and emits aui.palettetrace event.broadcast_palette: pushes the palette into every pane'stheme: Themefield via that pane's ownset_palette. Each pane readsself.themeat the top ofdrawand paints with it.- egui
Visuals:Theme::visuals()themes the standard widgets (panels, buttons, selection, hyperlinks) to match the custom-painted surfaces; applied once per change (not every frame) via thepalette_appliedlatch.
The semantic ramp (what panes call instead of raw rgb)
All in src/viz/facett_theme.rs on Theme (+ module consts):
| call | meaning |
theme.bg / node_fill / node_stroke / edge / text / text_dim / accent / point / panel_bg / panel_stroke / glow |
the base palette fields |
theme.status_color(s) |
release/run/op verdict → green/red/amber (succeeded*/ok/pass/done→green, failed*/error/stalled→red, running/dry_run/warn→amber, failed_bench→amber-red, else text_dim) |
theme.status_fill(s) |
test-matrix cell status (green/red/amber/dim) |
theme.health_color(0..100) |
red→amber→green health ramp |
theme.heat(t∈0..1) |
cool→hot ramp (edge→accent) for fan-out / bubbles |
theme.zebra(odd) · hover() · selection() · gridline() · info() |
chrome derived from the palette so it drifts with the theme |
facett_theme::{GREEN, RED, AMBER} |
the canonical verdict colours (stable across palettes so status reads the same everywhere) |
Rule of thumb: verdict colours (pass/fail/warn) are the canonical GREEN/RED/AMBER consts — a green test is green in every palette. Chrome colours (background, gridlines, zebra, hover, selection, accents, dim text) come from the active Theme so a switch re-skins the surface.
facett_ui — the generic widgets (D3)
src/viz/facett_ui.rs — non-nornir-specific, palette-driven, facett-bound:
status_chip(theme, status) -> (label, colour)— generic verdict chip.status_dot(ui, theme, colour, label, n)— summary-strip dot.zebra_row/hover_row— alternating + hover row bands.health_pill(painter, theme, score, …)— 0–100 pill on the health ramp.sparkline(painter, theme, series, rect)— themed trend line.graph_node(painter, theme, rect, label, stroke, selected)— the shared dep-graph / time-travel / release-lit node style.
These are the components that should live in facett (korp / Njord / any egui surface want them). They don't yet, for the egui-skew reason above; the module header documents the re-home. New panes should paint via these, not re-roll.
C8 follow-up — the last hand-rolled colours (2026-06-13)
The first C8 pass themed most panes but left a few hard-coded Color32::from_rgb islands. This follow-up routed them all through the palette:
bench_live.rs(the 📡 live-telemetry panel embedded in the Bench tab) now carries its ownthemefield +set_palette(propagated byops_tabs::BenchState::set_palette). Its status tallies use the canonicalGREEN/RED(+ palette dim for neutral), the cores-busy/N ramp usestheme.health_color(low load = green headroom, pegged = red — inverted load), and the CPU sparkline usestheme.point. Exposes its ownpalettekey understate_json["bench"]["live"]["palette"].leaderboard_tab.rs—draw_matrix_gridnow takes a&Theme: failed cell =RED, winner =AMBER, and the score-heat cell usestheme.heat(score)(cooledge→ hotaccent) instead of a fixed green ramp. The deadchip_forhelper (unused, hard-coded) was removed.test_tab.rs— the two skip-grey literals now calltheme.status_fill("skip").funnel_view.rs— the legacy non-themedNodeStat::color()was removed (it was dead — the snarl viewer only ever callscolor_themed(&theme)).
Computed-ramp exceptions (deliberately left)
A few colours are computed ramps, not flat chrome, and stay as math: callgraph.rs heat(deg) (glacier→cyan by node degree), fog_blend (depth extinction toward a fog tint), and the per-node perspective brightness. These are ramp internals; the visible chrome around them is themed. (Their Color32 literals are the ramp endpoints + #[cfg(test)] inputs, not surface chrome.)
CLI / robot parity (LAW: never GUI-only)
The palette is drivable headlessly, matching every other viz control:
- control channel:
VizCommand { palette: Option<String> }→$NORNIR_VIZ_CMD(consume-once).apply_commandswitches the palette and logs it; an unknown name is a no-op (logged, never a panic). - MCP:
viz.click { "palette": "cyberpunk-neon" }(alongsidetab/workspace), validated against the 7 palette names, returns the freshstate_jsonso the robot loop observes the re-skin. - introspection:
state_json["palette"](active) +["palettes"](the picker list) + each pane's nestedpalette(proves the broadcast landed). - initial theme (launch parity): the
nornir-uibinary picks the opening palette from--theme <name>or$NORNIR_VIZ_THEME(the env wins, same precedence style asNORNIR_WORKSPACE), resolved viaTheme::resolve_initial(env, flag)and applied withset_palettebefore the first frame — so a headless / scripted launch opens already re-skinned, no GUI click. An unknown name at either source falls through todefault(a logged note, never an error). The seven choices match the picker / MCP list.
Tests (LAW #1 inject-assert, LAW #6 data, LAW #4 screenshot)
src/viz/facett_theme.rsunits —status_colorverdict mapping, chrome helpers differ across palettes (heat/gridline/selection move), health ramp.src/viz/facett_ui.rsunit — chip vocabulary is case-insensitive and its unknown-verdict colour shifts with the palette.tests/viz_test_pane.rs::palette_switch_reskins_every_pane— switch the app palette; assertstate_json["palette"]and every pane's ownpalettekey flipped (proves the panes consume the broadcast, not a private default), and that the ramps actually differ. key flipped — the list now includesbenchand asserts the nestedbench.live.paletteflips too (the live-telemetry panel consumes the broadcast), plus that the bench/leaderboard heat +pointramps differ between palettes (the de-hard-coding actually re-skins, not just renames).tests/viz_test_pane.rs::palette_drivable_via_control_command— aVizCommand { palette }re-skins the app (reaches the Test pane); unknown name is ignored.tests/viz_test_pane.rs::palette_selectable_headless_via_env_or_flag— the binary's launch path:Theme::resolve_initial(env, flag)→set_palettelands app-wide AND in the panes (incl.bench.live); no choice →default.src/viz/facett_theme.rs::resolve_initial_honours_env_then_flag_then_default— env-beats-flag precedence, normalisation, unknown-falls-through-to-default.src/viz/control.rsroundtrip — thepalettefield survives write→take.- screenshot proof — the existing wgpu snapshots (
viz_*.png) re-render with the themed panes (default palette) unchanged — the default palette'sGREEN/RED/AMBER+ heat/health ramps match the prior literals, so no PNG churn.
Files
-
src/viz/facett_theme.rs— vendored palette + semantic/chrome ramps. -
src/viz/facett_ui.rs— generic palette-driven widgets (D3). -
src/viz/app.rs—palettefield, picker,set_palette/broadcast_palette, visuals,state_json["palette"], themed timeline + side panel + action trail. -
every
src/viz/*_tab.rs/ pane —themefield +set_palette+ routed coloursstate_json["palette"].
-
src/viz/bench_live.rs—themefield +set_palette+ themed tallies / cores-busy health ramp / CPU sparkline + nestedpaletteinstate_json. -
src/viz/leaderboard_tab.rs— themeddraw_matrix_grid(heat-ramp cells). -
src/viz/funnel_view.rs— deadNodeStat::color()removed (onlycolor_themed). -
src/viz/control.rs+src/bin/nornir-mcp.rs— palette over the robot channel. -
src/bin/urdr-threads.rs—--themeflag +$NORNIR_VIZ_THEMEenv (launch parity), resolved viaTheme::resolve_initialand applied pre-first-frame.