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
- No
Color32::from_rgb(...), hex literals, or RGBA tuples outsidecrates/lunco-theme/. Every color in a panel, overlay, widget, or gizmo routes through theThemeresource. If the color you want doesn't exist yet, add a typed field at the right tier — don't inline the value. - Palette reads (
theme.colors.*) only insidefrom_palettebuilders. Anywhere else — including inside extension traits that provide defaults viaget_token— is a smell. If the default you want is a palette entry, that's a sign you should be adding a field toSchematicTokensorDesignTokensfirst. - Four tiers, pick the right one. See § Tier guide.
Consumer code reads fields;
get_tokenis reserved for resolving pinned user overrides. - Never call
ctx.set_visuals(...)from a panel.lunco-ui::sync_theme_systemalready pushestheme.to_visuals()to egui wheneverThemechanges. - Dark/light is
theme.toggle_mode(), not a branch onThemeMode. Overrides survive the toggle automatically. - Spacing and rounding come from
theme.spacingandtheme.rounding, not ad-hoc4.0/6.0/Margin::same(8.0)literals.
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.
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.
// 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.
// crates/lunco-modelica/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 historic 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
fn my_system(
mut contexts: EguiContexts,
theme: Res<lunco_theme::Theme>,
) {
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<Theme> and &mut World at the same time:
let theme = world.resource::<lunco_theme::Theme>().clone();
// now render freely with `theme.tokens.*`, `theme.schematic.*`,
// or (for Modelica) `theme.wire_color("Pin")` via the extension trait.
Imports
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_<kind>_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::WorkbenchPluginauto-addsThemePlugin— 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<Theme>will not be present and systems will panic on access. lunco-ui::LuncoUiPlugininstallssync_theme_system; add it wherever you wantThemechanges to propagate to egui.
Dark / light toggle
world.resource_mut::<lunco_theme::Theme>().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.modein panel code to pick colors; pick the token and trustTheme::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 sync_theme_system push 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 outsidelunco-theme. - No
theme.colors.*reads outsidefrom_palettebuilders or tier-4get_tokenfallbacks. - 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_overridein the domain plugin'sbuild. - No new
ctx.set_visualscalls in panel code. - Spacing/rounding pulled from
theme.spacing/theme.roundingwhere a token exists. - No
theme.mode == Darkbranches picking colors.
Quick sanity check on an existing file
# 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 lunco-ui's sync_theme_system):
grep -rn "set_visuals" crates/
Findings from any command are candidates to refactor into theme tokens at the appropriate tier.