facett-core/src/look/mod.rs
//! **facett look & feel** (the work-order architecture, §3) — one [`Theme`] struct
//! that fully describes a coherent, fast, fully-themeable look across every
//! facett component, shipped as three presets: **Windows**, **macOS**, **Device**
//! (effects-off, rugged/military). The [`Theme`] is the *single source of truth*:
//! [`Theme::apply`] installs a complete `egui::Style` (visuals + spacing + scroll)
//! plus the text scale in one call, and also publishes the derived legacy
//! [`crate::Theme`] palette so the existing custom-painted components follow with
//! no per-component wiring (COH-1).
//!
//! Everything is `serde` (ARCH-4): a host can author themes + remap keys in
//! TOML/JSON without recompiling.
use egui::Context;
use serde::{Deserialize, Serialize};
pub mod color;
pub mod feel;
pub mod keymap;
pub mod metrics;
pub mod navtokens;
pub mod oklch;
pub mod palette;
pub mod platform;
pub mod policy;
pub mod scroll;
pub mod typography;
pub use color::{chip, heat_color, lerp_color, AMBER, PINK, RED};
pub use feel::{
apply_focus_ring, elevation_shadow, elevation_shadow_params, focus_ring_visual, native_feel, publish_native,
reveal_on_hover, ElevationShadow, FocusRingVisual,
};
pub use keymap::{Action, KeyMap, keymap, publish_keymap};
pub use metrics::Metrics;
pub use navtokens::{NavOverlayTokens, published_palette};
pub use oklch::{Oklch, contrast_ratio, relative_luminance};
pub use palette::Palette;
pub use platform::{FocusRing, NativeFeel, Platform, WindowControls};
pub use policy::{effects_policy, publish_effects, EffectsPolicy, FocusSpec, Motion, PerfConfig, SurfaceSpec, ThemeMode};
pub use scroll::{ScrollSpec, ScrollVisibility};
pub use typography::{Typography, UiFont};
/// The one theme (ARCH-1). Every sub-struct is fully populated by every preset
/// (no leaked defaults, ARCH-3); fields are public with `with_*` builders
/// (ARCH-5); the whole thing is `serde` (ARCH-4).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Theme {
pub name: String,
pub mode: ThemeMode,
pub palette: Palette,
pub typography: Typography,
pub metrics: Metrics,
pub scroll: ScrollSpec,
pub keymap: KeyMap,
pub focus: FocusSpec,
pub surface: SurfaceSpec,
pub motion: Motion,
pub effects: EffectsPolicy,
pub perf: PerfConfig,
/// The platform-adaptive **native-feel** cues (reveal highlight, rubber-band,
/// focus-ring style, window-control side, accent-tint, elevation) layered over
/// the primitives above. See [`platform`].
pub native: NativeFeel,
}
// `Palette` is `Copy` but not `serde` via derive on `Oklch`; we derive serde on
// it explicitly below so the whole `Theme` is serialisable (ARCH-4).
impl Default for Theme {
fn default() -> Self {
Theme::windows_dark()
}
}
impl Theme {
// ── presets (ARCH-3): each fully populates every sub-struct ──────────────
/// Windows, dark.
pub fn windows_dark() -> Self {
Self {
name: "windows-dark".into(),
mode: ThemeMode::Dark,
palette: presets::windows_dark_palette(),
typography: Typography::default().with_font(UiFont::SegoeUi),
metrics: Metrics::windows(),
scroll: ScrollSpec::windows(),
keymap: KeyMap::windows(),
focus: FocusSpec::default(),
// Full-effects showcase: a frosted-glass surface (real backdrop blur on
// wgpu, degrading to its tint off-wgpu / under Reduced, and forced opaque
// under Device). This is the surface the `chrome` glass card + the deck
// bloom read as "this preset wants glass", and the demo asserts as data.
// Mica: a denser, more opaque material — lower blur, higher tint α.
surface: SurfaceSpec::Frosted { blur_radius: 12.0, tint: [20, 22, 30, 160] },
motion: Motion::windows(),
effects: EffectsPolicy::Full,
perf: PerfConfig::default(),
native: NativeFeel::windows(),
}
}
/// Windows, light.
pub fn windows_light() -> Self {
Self {
name: "windows-light".into(),
mode: ThemeMode::Light,
palette: presets::windows_light_palette(),
..Theme::windows_dark()
}
}
/// The default Windows theme (dark) — `windows()` for the work-order name.
pub fn windows() -> Self {
Theme::windows_dark()
}
/// macOS, dark.
pub fn macos_dark() -> Self {
Self {
name: "macos-dark".into(),
mode: ThemeMode::Dark,
palette: presets::macos_dark_palette(),
typography: Typography::default().with_font(UiFont::SanFrancisco),
metrics: Metrics::macos(),
scroll: ScrollSpec::macos(),
keymap: KeyMap::macos(),
focus: FocusSpec::default(),
// Full-effects showcase: frosted glass (see `windows_dark`).
// Vibrancy: a more translucent material — higher blur, lower tint α.
surface: SurfaceSpec::Frosted { blur_radius: 16.0, tint: [22, 22, 26, 130] },
motion: Motion::macos(),
effects: EffectsPolicy::Full,
perf: PerfConfig::default(),
native: NativeFeel::macos(),
}
}
/// macOS, light.
pub fn macos_light() -> Self {
Self {
name: "macos-light".into(),
mode: ThemeMode::Light,
palette: presets::macos_light_palette(),
..Theme::macos_dark()
}
}
/// The default macOS theme (dark).
pub fn macos() -> Self {
Theme::macos_dark()
}
/// Device — barren, effects-off, sunlight-legible, crisp (§23). GPU still
/// allowed (PerfConfig.prefer_wgpu) — just no eye-candy.
pub fn device() -> Self {
Self {
name: "device".into(),
mode: ThemeMode::Dark,
palette: presets::device_palette(),
typography: Typography::default().with_font(UiFont::System),
metrics: Metrics::device(),
scroll: ScrollSpec::device(),
keymap: KeyMap::device(),
focus: FocusSpec { hints_enabled: false, revolver_enabled: false, ..FocusSpec::default() },
surface: SurfaceSpec::Opaque,
// no decorative motion (instant), but keep a valid curve token.
motion: Motion { duration: 0.0, fast: 0.0, curve: crate::effects::Curve::Linear },
effects: EffectsPolicy::None,
perf: PerfConfig { prefer_wgpu: true, ..PerfConfig::default() },
// Effects-off: no reveal/rubber-band/glow ring — a plain crisp outline.
native: NativeFeel::neutral(),
}
}
/// **Neutral** — the cross-platform / Linux default: premium and full-effects,
/// but with **no OS-specific chrome** (no traffic lights, no reveal highlight,
/// no rubber-band). Moderate radii, the System font, solid scrollbars, gentle
/// cubic motion. This is what [`Platform::Neutral`] selects and the third stop
/// of the demo's Mac ⇄ Windows ⇄ Neutral toggle.
pub fn neutral() -> Self {
Self {
name: "neutral-dark".into(),
mode: ThemeMode::Dark,
palette: presets::windows_dark_palette(),
typography: Typography::default().with_font(UiFont::System),
metrics: Metrics::windows(),
scroll: ScrollSpec::windows(),
keymap: KeyMap::windows(),
focus: FocusSpec::default(),
// A balanced material between Mica and vibrancy.
surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [20, 22, 28, 150] },
motion: Motion::neutral(),
effects: EffectsPolicy::Full,
perf: PerfConfig::default(),
native: NativeFeel::neutral(),
}
}
/// **Skaði "Skade Vinter"** — an icy winter showcase theme (glacier blues,
/// frost/ice-white, pale cyan over deep-winter navy), full effects so the
/// ice-drip decor + frost shimmer light up. The look the holger demo swaps to
/// in its static "winter" mode via the COH-1 bridge (replaces the old red
/// uboat/silent-running theme). Authored fresh — there is no SVT/skade UI to
/// port. Serde name + `by_name`: `"skade_vinter"`.
pub fn skade_vinter() -> Self {
Self {
name: "skade_vinter".into(),
mode: ThemeMode::Dark,
palette: presets::skade_vinter_palette(),
typography: Typography::default().with_font(UiFont::System),
metrics: Metrics::windows(),
scroll: ScrollSpec::windows(),
keymap: KeyMap::windows(),
focus: FocusSpec::default(),
// Icy frosted glass — the winter showcase wants its frost to read as glass.
surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [180, 210, 235, 90] },
motion: Motion::default(),
effects: EffectsPolicy::Full,
perf: PerfConfig::default(),
native: NativeFeel::neutral(),
}
}
/// **Nordisk (dark)** — facett's own flagship identity, and the recommended
/// default look: a calm, premium Nordic take on the Windows-11/macOS idiom
/// with a signature aurora-cyan accent. OS-agnostic (no traffic lights, no
/// reveal), full-effects, soft 8px radii, a cool frosted-glass surface. This
/// is what the demo opens on and the "its own identity" stop of the toggle.
pub fn nordisk_dark() -> Self {
Self {
name: "nordisk-dark".into(),
mode: ThemeMode::Dark,
palette: presets::nordisk_dark_palette(),
typography: Typography::default().with_font(UiFont::System),
metrics: Metrics::nordisk(),
scroll: ScrollSpec::windows(),
keymap: KeyMap::windows(),
focus: FocusSpec::default(),
// A cool blue-slate frosted glass — the Nordic key of the Mica/vibrancy
// material, balanced between the two OS presets.
surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [16, 22, 32, 150] },
motion: Motion::neutral(),
effects: EffectsPolicy::Full,
perf: PerfConfig::default(),
native: NativeFeel::neutral(),
}
}
/// **Nordisk (light)** — the airy daytime key of the flagship identity.
pub fn nordisk_light() -> Self {
Self {
name: "nordisk-light".into(),
mode: ThemeMode::Light,
palette: presets::nordisk_light_palette(),
// A pale frost tint for the light key.
surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [235, 240, 248, 120] },
..Theme::nordisk_dark()
}
}
/// The default Nordisk theme (dark) — the flagship identity look.
pub fn nordisk() -> Self {
Theme::nordisk_dark()
}
/// Pick a preset from the running OS (ARCH-3). Falls back to Windows-dark on
/// Linux/unknown (documented default). This is the **egui-OS** bridge (used by
/// the host at startup); the cfg-detected [`Platform`] path is
/// [`for_platform`](Theme::for_platform).
pub fn from_os(os: egui::os::OperatingSystem) -> Self {
use egui::os::OperatingSystem as Os;
match os {
Os::Mac | Os::IOS => Theme::macos_dark(),
Os::Windows => Theme::windows_dark(),
// Linux / Android / Unknown / Web → documented default.
_ => Theme::windows_dark(),
}
}
/// Pick the **platform-adaptive native-feel** preset for an explicit
/// [`Platform`] (the cfg-detected or runtime-overridden one). `Mac` →
/// macOS-dark, `Windows` → Windows-dark, `Neutral` → the neutral preset. The
/// entry point the demo's Mac ⇄ Windows ⇄ Neutral toggle drives.
pub fn for_platform(p: Platform) -> Self {
match p {
Platform::Mac => Theme::macos_dark(),
Platform::Windows => Theme::windows_dark(),
Platform::Neutral => Theme::neutral(),
}
}
/// The preset selected by **auto-detecting** the host platform
/// (`cfg!(target_os)`), with no override. Convenience for
/// `Theme::for_platform(Platform::detect())`.
pub fn for_detected_platform() -> Self {
Theme::for_platform(Platform::detect())
}
/// Every preset, light+dark where applicable, for a switcher/gallery.
pub const PRESETS: &'static [fn() -> Theme] = &[
Theme::windows_light,
Theme::windows_dark,
Theme::macos_light,
Theme::macos_dark,
Theme::device,
Theme::skade_vinter,
Theme::neutral,
Theme::nordisk_light,
Theme::nordisk_dark,
];
pub fn preset_names() -> Vec<String> {
Self::PRESETS.iter().map(|c| c().name).collect()
}
pub fn by_name(name: &str) -> Option<Theme> {
let norm = |s: &str| s.to_ascii_lowercase().replace([' ', '_'], "-");
let want = norm(name);
Self::PRESETS.iter().map(|c| c()).find(|t| norm(&t.name) == want)
}
// ── builders (ARCH-5) ────────────────────────────────────────────────────
pub fn with_effects(mut self, e: EffectsPolicy) -> Self {
self.effects = e;
self
}
pub fn with_keymap(mut self, k: KeyMap) -> Self {
self.keymap = k;
self
}
pub fn with_focus(mut self, f: FocusSpec) -> Self {
self.focus = f;
self
}
pub fn with_surface(mut self, s: SurfaceSpec) -> Self {
self.surface = s;
self
}
pub fn with_name(mut self, n: impl Into<String>) -> Self {
self.name = n.into();
self
}
/// Is this theme dark? (Resolves `FollowSystem` via the palette's own flag.)
pub fn is_dark(&self) -> bool {
match self.mode {
ThemeMode::Dark => true,
ThemeMode::Light => false,
ThemeMode::FollowSystem => self.palette.dark,
}
}
/// Build a complete `egui::Style` from this theme (visuals + spacing + scroll
/// + text scale). Pure — `apply` installs it on a context.
pub fn egui_style(&self) -> egui::Style {
let mut style = egui::Style::default();
// Visuals from the palette + radius from metrics.
let mut visuals = self.palette.to_visuals(self.metrics.corner_radius);
visuals.window_corner_radius = egui::CornerRadius::same(self.metrics.window_corner_radius);
visuals.menu_corner_radius = egui::CornerRadius::same(self.metrics.menu_corner_radius);
style.visuals = visuals;
// Spacing from metrics.
let sp = &mut style.spacing;
sp.item_spacing = self.metrics.item_spacing_vec();
sp.button_padding = self.metrics.button_padding_vec();
sp.window_margin = self.metrics.window_margin_m();
sp.menu_margin = self.metrics.menu_margin_m();
sp.interact_size = self.metrics.interact_size_vec();
sp.indent = self.metrics.indent;
sp.slider_width = self.metrics.slider_width;
sp.icon_width = self.metrics.icon_width;
sp.scroll = self.scroll.to_scroll_style();
// Text scale from typography.
style.text_styles = self.typography.text_styles();
style
}
/// **ARCH-2** — install the full style + publish the derived legacy palette in
/// one call. Re-applying takes effect next frame, no restart. Also sets the OS
/// (KEY-3) so `Modifiers::COMMAND` formats as ⌘/Ctrl correctly and built-in
/// shortcuts match the preset.
pub fn apply(&self, ctx: &Context) {
// Tell egui which OS we're presenting as (drives ⌘ vs Ctrl labels +
// built-in TextEdit shortcuts). macOS preset → Mac; else Windows.
let os = if self.name.starts_with("macos") {
egui::os::OperatingSystem::Mac
} else {
egui::os::OperatingSystem::Windows
};
ctx.set_os(os);
ctx.set_global_style(self.egui_style());
// Install the bundled UI font (Inter) as the default Proportional family.
// Feature-gated + one-shot (see `install_ui_font`).
self.install_ui_font(ctx);
// Publish the derived legacy flat palette so every existing custom-painted
// component (graph/depgraph/map/grid/…) follows with no change (COH-1).
// Palette-only (not `set_theme`) so we don't re-impose the legacy
// `override_text_color` over the Style we just installed (§27).
crate::theme::publish_palette(ctx, self.to_legacy_palette());
// Publish the semantic palette itself too, so an overlay that derives its
// colours from the v1 roles (the navgraph, `navtokens`) reads the active theme.
navtokens::publish_palette(ctx, self.palette);
// Publish the keymap so every component resolves identical chords (COH-2).
keymap::publish_keymap(ctx, self.keymap.clone());
// Publish the effects policy so effect-gated components (graph3d neon decor)
// follow the theme — Full lights up, Reduced softens, None/device stays flat.
policy::publish_effects(ctx, self.effects);
// Publish the platform native-feel cues (reveal highlight, focus ring/rect,
// elevation) so every component's paint follows the active platform preset
// through the shared `look::feel` helpers (COH: mac/windows parity).
feel::publish_native(ctx, self.native);
}
/// **Font install** — install **Inter** as the default `Proportional` family
/// so text renders in the intended UI face instead of egui's built-in
/// Ubuntu-Light (the "soft text" cause). `Inter` is already the named
/// fallback in [`typography`] ([`UiFont::Inter`]).
///
/// Behind the **`bundled-fonts`** feature (OFF by default) because it embeds
/// a real font asset — `facett-core/assets/fonts/Inter-Regular.ttf` (Inter,
/// SIL Open Font License; `assets/fonts/OFL.txt` ships beside it). With the
/// feature OFF this is a no-op and eframe's `default_fonts` stay in place, so
/// no consumer is re-fonted without asking for it.
#[allow(unused_variables)]
fn install_ui_font(&self, ctx: &Context) {
#[cfg(feature = "bundled-fonts")]
{
// One-shot guard: `apply` runs every frame, but `set_fonts` rebuilds
// the glyph atlas, so install exactly once per context.
let flag = egui::Id::new("facett-look-fonts-installed");
let done = ctx.data(|d| d.get_temp::<bool>(flag).unwrap_or(false));
if done {
return;
}
const INTER_REGULAR: &[u8] = include_bytes!("../../assets/fonts/Inter-Regular.ttf");
// **A 131-byte "font" compiles.** MEASURED on oden 2026-08-27: this exact
// asset was a Git-LFS pointer in `~/git/facett` — 131 B where
// 876 576 belong — while `git status --porcelain` reported the tree CLEAN,
// because the LFS clean filter passes a pointer through unchanged and there
// is no diff to show. `include_bytes!` took it without a word, `FontData::
// from_static` took it, and the only symptom would have been Inter quietly
// not being the face on screen. The check is `const`, so it is the BUILD that
// fails, on every host, before anything ships.
const _: () = assert!(
!crate::asset::is_a_git_lfs_pointer(INTER_REGULAR) && INTER_REGULAR.len() > 4096,
"facett-core/assets/fonts/Inter-Regular.ttf is a Git-LFS pointer or a stub, \
not a font. Run `git lfs checkout` in this repo — `git status` will not \
tell you, it reads clean either way."
);
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"Inter".to_owned(),
std::sync::Arc::new(egui::FontData::from_static(INTER_REGULAR)),
);
// Make Inter the FIRST (default) Proportional face; keep egui's
// built-ins after it as glyph fallbacks (emoji, symbols, CJK).
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "Inter".to_owned());
ctx.set_fonts(fonts);
ctx.data_mut(|d| d.insert_temp(flag, true));
}
}
/// Derive the legacy flat [`crate::Theme`] palette from the semantic roles, so
/// existing components that read `crate::theme(ui)` follow this theme. This is
/// the compatibility bridge: the rich [`Theme`] is the source of truth; the
/// flat palette is *computed*, never authored.
pub fn to_legacy_palette(&self) -> crate::Theme {
let p = &self.palette;
let name: &'static str = match self.name.as_str() {
"windows-dark" => "windows-dark",
"windows-light" => "windows-light",
"macos-dark" => "macos-dark",
"macos-light" => "macos-light",
"device" => "device",
"skade_vinter" => "skade_vinter",
"neutral-dark" => "neutral-dark",
"nordisk-dark" => "nordisk-dark",
"nordisk-light" => "nordisk-light",
_ => "look",
};
crate::Theme {
name,
bg: p.surface.to_color32(),
node_fill: p.surface_container.to_color32(),
node_stroke: p.outline.to_color32(),
edge: p.outline.with_chroma_scale(0.6).to_color32(),
text: p.on_surface.to_color32(),
text_dim: p.on_surface_dim.to_color32(),
accent: p.accent.to_color32(),
point: p.primary.to_color32(),
panel_bg: p.surface_container.to_color32(),
panel_stroke: p.outline.to_color32(),
glow: p.glow.to_color32(),
}
}
}
// ── serde for Palette / Oklch (kept here so the colour modules stay paint-only) ─
mod presets;
#[cfg(test)]
mod tests;