--- name: lunco-theme description: > LunCoSim's centralised theming system. Use this skill whenever you are about to write, touch, or review UI code that involves a color, spacing value, rounding, or egui visual style — in any panel, overlay, widget, gizmo label, or diagram. Trigger on any `Color32::from_rgb`, hex color, `ui.style_mut()`, `visuals.*`, `ctx.set_visuals`, "dark mode", "light mode", "accent color", "highlight", palette tweak, mention of Catppuccin, or work on a typed block-diagram editor (wire colours, class-kind badges). Also trigger when adding a new panel that needs colors, or when the user asks to "restyle", "retheme", or "make it match". The rules here are project-specific — defaults from egui or Bevy alone will lead you to hard-code colors, which violates the Tunability Mandate. --- # LunCoSim Theming (`lunco-theme`) Full API reference: `crates/lunco-theme/README.md`. This skill is the decision guide for *where* colors/spacing come from in this repo. ## Hard rules 1. **No `Color32::from_rgb(...)`, hex literals, or RGBA tuples outside `crates/lunco-theme/`.** Every color in a panel, overlay, widget, or gizmo routes through the `Theme` resource. If the color you want doesn't exist yet, **add a typed field** at the right tier — don't inline the value. 2. **Palette reads (`theme.colors.*`) only inside `from_palette` builders.** Anywhere else — including inside extension traits that provide defaults via `get_token` — is a smell. If the default you want is a palette entry, that's a sign you should be adding a field to `SchematicTokens` or `DesignTokens` first. 3. **Four tiers, pick the right one.** See [§ Tier guide](#tier-guide). Consumer code reads **fields**; `get_token` is reserved for resolving pinned user overrides. 4. **Never call `ctx.set_visuals(...)` from a panel.** `lunco-workbench` applies `theme.to_visuals()` when its theme changes; `ThemePlugin` applies shared typography styles in `ThemeApplySet`. Systems that render directly before the workbench should run after that set; the workbench orders its panels and overlays around it. The shell owns chrome styling. 5. **Dark/light is `theme.toggle_mode()`, not a branch on `ThemeMode`.** Overrides survive the toggle automatically. 6. **Spacing and rounding come from `theme.spacing` and `theme.rounding`**, not ad-hoc `4.0` / `6.0` / `Margin::same(8.0)` literals. The merged workbench title bar uses `theme.spacing.titlebar_height` and `theme.spacing.titlebar_control_size` for one shared alignment contract. 7. **Translucent workbench surfaces use `theme.tokens.overlay_backdrop`.** Let `lunco-workbench` own the body fill and keep standard `PanelCtx` content frames transparent over it; do not introduce panel-local alpha values or duplicate backdrop tokens. 8. **Typography is role-based and theme-owned.** `ThemePlugin` maps egui's built-in text styles and LunCoSim named roles from `theme.typography` across the primary egui context. Use egui's normal widget styles for ordinary body, button, heading, caption, and monospace text. Use `TypographyRole::text_style()` for semantic `RichText`, and `TypographyRole::font_id(ui.style())` for custom painter text. Help, status messages, and ordinary guidance use Body. Caption is for secondary metadata and compact status-bar summaries; reserve DenseData and DenseCode for genuinely compact information such as chart labels, timestamps, and log rows. Keep authored model text and spatial label sizing under their content/spatial owner. ### Runtime-authored HTML/CSS surfaces Runtime surfaces under `assets/ui/` are the explicit exception to the egui consumer rules above: their authored presentation belongs in Flair CSS. Put surface colors, spacing, and rounding in CSS custom-property defaults and use `--ui-` bindings for engine-driven state. Do not add a Rust system that copies `Theme` fields into the HTML tree every frame. `Theme` remains the semantic source for egui/workbench consumers; the runtime HTML contract keeps its own reloadable CSS presentation boundary. ## Tier guide Four tiers. Always work at the highest (most specific) tier that fits. If you're tempted to hardcode a palette entry at a lower tier, you're at the wrong tier — go up a level and add a field. ### Tier 1 — `DesignTokens` (generic semantic, universal to any UI) Fields on `DesignTokens`, populated by `DesignTokens::from_palette`. Colours *every* UI uses regardless of domain. ```rust theme.tokens.accent theme.tokens.success theme.tokens.warning theme.tokens.error theme.tokens.text theme.tokens.text_subdued theme.tokens.success_subdued ``` Add a field here when the token is cross-cutting (e.g. a new "destructive-action red" colour). ### Tier 2 — `SchematicTokens` (typed block-diagram editors) Fields on `SchematicTokens`, populated by `SchematicTokens::from_palette`. Colours that any schematic editor uses — Modelica, SysML, electrical CAD, flow charts. Shared vocabulary, one palette→intent mapping. ```rust // Wire colours by connector domain theme.schematic.wire_electrical // Pin, Plug theme.schematic.wire_mechanical // Flange theme.schematic.wire_thermal // HeatPort theme.schematic.wire_fluid theme.schematic.wire_signal // RealInput/Output theme.schematic.wire_boolean theme.schematic.wire_integer theme.schematic.wire_multibody // Frame theme.schematic.wire_unknown // Class/component-kind badges theme.schematic.class_model_badge theme.schematic.class_block_badge theme.schematic.class_class_badge theme.schematic.class_connector_badge theme.schematic.class_record_badge theme.schematic.class_type_badge theme.schematic.class_package_badge theme.schematic.class_function_badge theme.schematic.class_operator_badge theme.schematic.class_badge_fg // Schematic-panel typography theme.schematic.text_muted theme.schematic.text_heading ``` Add a field here when you need a new schematic concept (e.g. a "selected wire" colour, a new connector domain). ### Tier 3 — Domain translation (extension trait) A trait on `Theme` inside the domain crate. Maps domain-specific names (Modelica `Pin`, parsed `ClassType::Model`, SysML stereotype) to **tier 2 fields**. **Zero palette reads** in the trait body — if the intent isn't in tier 2 yet, go add it there first. ```rust // crates/lunco-modelica-ui/src/ui/theme.rs pub trait ModelicaThemeExt { fn wire_color(&self, connector_type: &str) -> Color32; fn class_badge_bg(&self, kind: &ClassType) -> Color32; } impl ModelicaThemeExt for Theme { fn wire_color(&self, connector_type: &str) -> Color32 { let leaf = connector_type.rsplit('.').next().unwrap_or(connector_type); let s = &self.schematic; match leaf { "Pin" | "Plug" => s.wire_electrical, "Flange_a" | "Flange_b" => s.wire_mechanical, "RealInput" | "RealOutput" => s.wire_signal, _ => s.wire_unknown, } } fn class_badge_bg(&self, kind: &ClassType) -> Color32 { match kind { ClassType::Model => self.schematic.class_model_badge, ClassType::Package => self.schematic.class_package_badge, // ... } } } ``` ### Tier 4 — User override (pin a value) `theme.register_override(domain, token, colour)` + `theme.get_token(domain, token, fallback)`. Use **only** when: - A theme author or end-user needs to pin a specific colour that should *not* track the palette on dark/light toggle, or - You're resolving a token whose default is itself a tier 2 field (e.g. `theme.get_token("modelica", "port_input", theme.schematic.wire_signal)`). `get_token` is **not** the pattern for introducing new defaults. If you're writing `self.colors.blue` as the fallback, stop — the right fix is a new field in tier 2. ## How to read `Theme` ### From a Bevy system ```rust fn my_system( mut contexts: EguiContexts, theme: Res, ) { let ctx = contexts.ctx_mut().unwrap(); egui::Area::new("x".into()).show(ctx, |ui| { ui.colored_label(theme.tokens.success, "ok"); ui.colored_label(theme.schematic.wire_electrical, "bus"); }); } ``` ### From a `&mut World` widget / `WorkbenchPanel::ui_world` Clone the whole `Theme` out of `World` before touching `ui` — you can't hold `Res` and `&mut World` at the same time: ```rust let theme = world.resource::().clone(); // now render freely with `theme.tokens.*`, `theme.schematic.*`, // or (for Modelica) `theme.wire_color("Pin")` via the extension trait. ``` ### Imports ```rust use lunco_ui::prelude::{Theme, ThemeMode, ThemePlugin}; // re-exported // or directly: use lunco_theme::{Theme, ThemeMode, ThemePlugin, DesignTokens, SchematicTokens, ColorPalette}; // Domain extension (if any): use crate::ui::theme::ModelicaThemeExt; ``` ## Picking the right token | Need | Use | |--------------------------------|---------------------------------------------------| | Primary/brand action | `theme.tokens.accent` | | Success / ok / online | `theme.tokens.success` | | Warning / caution | `theme.tokens.warning` | | Error / offline / destructive | `theme.tokens.error` | | Body text | `theme.tokens.text` | | Secondary / muted text | `theme.tokens.text_subdued` | | Panel background | `theme.colors.mantle` | | Widget surface | `theme.colors.surface0..surface2` | | Electrical wire | `theme.schematic.wire_electrical` | | Mechanical flange | `theme.schematic.wire_mechanical` | | Signal (Real) | `theme.schematic.wire_signal` | | Class-kind badge | `theme.schematic.class__badge` | | Schematic diagram muted text | `theme.schematic.text_muted` | | Domain type → schematic colour | extension trait (`theme.wire_color(…)`) | | User-pinned override | `theme.get_token(...)` with prior `register_override` | If the answer is "none of these fit" — **add a field in the right tier**. Tier 1 if the colour is cross-UI; tier 2 if schematic-specific. Don't default tier 3 with palette picks. ## Plugin wiring - `lunco-workbench::WorkbenchPlugin` auto-adds `ThemePlugin` — full app shells get it for free. - Headless UI tests or standalone panel harnesses: add it yourself, `app.add_plugins(lunco_theme::ThemePlugin)`. Without it, `Res` will not be present and systems will panic on access. - `WorkbenchPlugin`'s layout/render loop is what pushes `Theme` into the egui context (`ctx.set_visuals(theme.to_visuals())`). A standalone panel harness without the workbench must apply `theme.to_visuals()` itself (e.g. `ui.style_mut().visuals = theme.to_visuals();`). ## Dark / light toggle ```rust world.resource_mut::().toggle_mode(); ``` - Preserves all registered overrides. - The workbench status bar 🌙/☀ button already wires this — don't duplicate it in other panels. - Don't branch on `theme.mode` in panel code to pick colors; pick the token and trust `Theme::dark()` / `Theme::light()` to have remapped it correctly. ## What NOT to do | ❌ Don't | ✅ Do | |-------------------------------------------------------|---------------------------------------------------------| | `Color32::from_rgb(46, 194, 126)` | `theme.tokens.success` | | `theme.colors.blue` in a panel | Add a field to `SchematicTokens` or `DesignTokens` | | `self.colors.blue` as a default in an extension trait | `self.schematic.wire_electrical` field (add if missing) | | `ui.visuals_mut().override_text_color = Some(...)` | Let `lunco-workbench` apply `theme.to_visuals()` | | `if mode == Dark { red } else { dark_red }` | One token; palette handles the swap | | `wire_color_for(connector)` local function per crate | Domain extension trait returning `theme.schematic.wire_*` | | `Margin::same(8.0)` | `theme.spacing.window_padding` | | Add a new `catppuccin-egui` dep in a domain crate | Consume colors via `Theme`; bridging lives in `lunco-theme` only | ## Review checklist Before merging any UI change, scan the diff for: - [ ] No new `Color32::from_rgb`, hex, or RGBA tuples outside `lunco-theme`. - [ ] No `theme.colors.*` reads outside `from_palette` builders or tier-4 `get_token` fallbacks. - [ ] Every new colour goes through `theme.tokens.*`, `theme.schematic.*`, or a domain extension trait mapping domain types → those fields. - [ ] New domain-specific colours modelled as extension-trait methods returning `theme.schematic.*` fields, not inlined at call sites. - [ ] User-specific overrides registered via `register_override` in the domain plugin's `build`. - [ ] No new `ctx.set_visuals` calls in panel code. - [ ] Spacing/rounding pulled from `theme.spacing` / `theme.rounding` where a token exists. - [ ] UI text resolves through `Theme.typography` roles; no ad-hoc font sizes remain in ordinary widget or painter text. - [ ] Guidance and status use Body; DenseData/DenseCode are limited to compact data such as charts, timestamps, and log rows. - [ ] No `theme.mode == Dark` branches picking colors. ## Quick sanity check on an existing file ```bash # Colors that should be routed through theme (ignore lunco-theme itself): grep -rn "Color32::from_rgb\|Color32::from_rgba" crates/ \ | grep -v "crates/lunco-theme/" # Palette reads outside lunco-theme and from_palette builders: grep -rn "theme\.colors\." crates/ \ | grep -v "crates/lunco-theme/" \ | grep -v "from_palette" # ctx.set_visuals calls (should only be in the workbench theme refresh): grep -rn "set_visuals" crates/ ``` Findings from any command are candidates to refactor into theme tokens at the appropriate tier.