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

nordisk/facett

.nornir/theming-guide.md

Theming — guide

Class: guide · audience: a techie theming a facett component or host. The what is in theming-design.md; the why in theming-reasoning.md. This file is the workflow.

f0 — apply a theme, prove a component follows it (the whole workflow)

Three moves take a host from "no theme" to a themed, coherence-tested app:

  1. Apply a preset once per frame (cheap) on the egui::Context:
use facett::look::Theme as LookTheme;
LookTheme::nordisk().apply(ctx);   // the flagship default; or ::windows_dark(), ::macos_dark(), ::device(), ...

apply installs the full egui::Style, and publishes the derived flat palette, the KeyMap, the EffectsPolicy, and the NativeFeel — every existing component picks all four up with zero per-component wiring.

  1. Write components against the published state, never a literal. Custom-painted components pull colour from facett_core::theme(ui); native-feel cues come from facett_core::look::feel::{reveal_on_hover, apply_focus_ring, elevation_shadow}.
  2. Prove it headless. harness::render_themed(facet, theme) snapshots a component under a specific flat Theme; theme::probe/look::feel::probe witness which palette/platform a component actually consumed during that paint — the same seam facett/tests/coherence.rs sweeps across every component.

f1 — picking and switching a Theme

Pick a rich preset directly:

use facett::look::Theme;

let theme = Theme::nordisk();          // flagship identity, default look
// or: Theme::windows_dark(), Theme::macos_light(), Theme::device(), Theme::skade_vinter(), Theme::neutral()
theme.apply(&ctx);

Detect or override the platform instead of naming a preset directly:

use facett::look::{Theme, Platform};

Theme::for_detected_platform().apply(&ctx);      // cfg!(target_os) → Mac/Windows/Neutral
Theme::for_platform(Platform::Windows).apply(&ctx); // force a platform regardless of host OS

Enumerate + fuzzy-match by name (drives a picker or a --theme flag):

Theme::preset_names()                 // Vec<String>: "windows-dark", "nordisk-dark", ...
Theme::by_name("Nordic Dark")         // None (that's not a name) — try "nordisk-dark"
Theme::by_name("NORDISK_DARK")        // Some(Theme) — case/`-`/`_`/space-insensitive

The flat six-palette family (sci_fi, nordic_aurora, cyberpunk_neon, amber_crt, deep_space, hugin_noir) is separate and reachable directly on facett::Theme:

use facett::{set_theme, Theme};
set_theme(ctx, Theme::hugin_noir());  // stores the flat palette + a matching egui::Visuals

Use look::Theme presets for a coherent whole-app look (OS-adaptive, effects-aware); use the flat family when a component only needs a standalone accent palette.

f2 — reading NativeFeel in a component's paint

A component gets platform-adaptive chrome with one call each, no branching on Platform itself:

fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
    let rect = ui.max_rect();
    facett::look::feel::elevation_shadow(ui, rect, 8.0);   // before the fill
    // ... paint the surface fill/content ...
    facett::look::feel::reveal_on_hover(ui, rect, 8.0);    // no-op off Windows
    facett::look::feel::apply_focus_ring(ui, rect, self.focused, 8.0);
    vec![]
}

reveal_on_hover and apply_focus_ring read the live pointer / accent role internally — pass only the rect and (for focus) whether this control is focused. Read the raw spec instead of a helper when you need the numbers directly:

let feel = facett::look::feel::native_feel(ui);       // NativeFeel for the active theme
let ring = feel.focus_ring;                           // FocusRing { width, expansion, glow, accent_tinted }
let shadow = facett::look::feel::elevation_shadow_params(feel.elevation);

Drive motion with the theme's platform curve instead of a hard-coded easing:

let motion = /* from the active look::Theme */ facett::look::Motion::neutral();
let tw = facett::effects::Tween::from_motion(&motion, 0.0, 1.0, motion.curve);
let y = tw.value_at(elapsed_secs);   // macOS: overshoot-and-settle; Windows: snappy cubic

f3 — glow, ravens, and the deck

Turn on deck-level effects (all off by default, no cost when unused):

let mut deck = facett::FacetDeck::new(facets).with_fx(
    facett::deckfx::DeckFx { glow: true, chrome: true, ..Default::default() }
);
deck.set_palette(2);          // pin Theme::ALL[2] as the deck's flat-palette override
deck.cycle_palette();         // or step through them
deck.send_raven(row_rect);    // launch the signature raven onto any rect; deck drives+paints it

palette_picker(ui) draws a ready-made one-line switcher over the flat palette family:

if let Some(i) = deck.palette_picker(ui) {
    // user picked palette i this frame
}

Reach for the raw primitives directly when a component wants its own motion, not the deck's:

use facett::effects::{RavenSprite, glow_rect, easing};

let mut raven = RavenSprite::new().from(launch_pos).fly_to(target_rect);
raven.update(ctx);          // per frame: advances + requests repaint while flying
raven.paint(ui.painter());

let pulse = easing::ease_in_out_cubic(((time * 2.4).sin() as f32) * 0.5 + 0.5);
glow_rect(ui.painter(), rect, theme.glow, pulse, 6);

f4 — a themed glass card

chrome::card is the one-call "frosted card" that degrades gracefully under the active EffectsPolicy:

use facett::chrome::{card, ChromeStyle};
use facett::look::effects_policy;

let policy = effects_policy(ui);
card(ui, rect, &facett_theme, policy, ChromeStyle::default().for_policy(policy));

For content painted into the card (so the fill stays behind it), split fill/edge:

use facett::chrome::{fill_shape, edge, ChromeStyle};

let style = ChromeStyle::default().for_policy(policy);
ui.painter().add(fill_shape(rect, &theme, policy, style));  // reserve the slot first
// ... draw content into `rect` here ...
edge(ui.painter(), rect, &theme, policy, style);             // border + glow on top

f5 — the harness seam (render_themed)

Snapshot a component under a specific theme with no window, no GPU:

use facett_core::harness;

let mut v = MyView::new();
let report = harness::render_themed(&mut v, facett::Theme::sci_fi());
assert!(report.drew());                       // it actually painted something
let sized = harness::render_themed_sized(&mut v, facett::Theme::nordisk().to_legacy_palette(), (400.0, 300.0));

Prove a component reads the active theme rather than a private default, using the probes that back facett/tests/coherence.rs:

use facett::core::theme::probe;
use facett::look::Theme as LookTheme;

let ctx = egui::Context::default();
LookTheme::macos_dark().apply(&ctx);
probe::reset();
let _ = ctx.run(Default::default(), |ctx| {
    egui::CentralPanel::default().show(ctx, |ui| my_facet.ui(ui));
});
assert_eq!(probe::painted(), Some("macos-dark"));

The look::feel::probe module is the native-feel counterpart — reset()/consumed() prove a component read the published Platform through native_feel(ui) rather than assuming one.