From abe6d3e5c44468eeeeeda5d86ca4acd724958ecb Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:56:14 +0200 Subject: [PATCH] feat(theme): a third bundled palette, Graphite, between dark and light Consumers had exactly two bundled palettes: the near-black TERMINAL and LIGHT. GRAPHITE sits between them, with surfaces around 5x TERMINAL's luminance and light text, so it still resolves as a dark palette everywhere MoonTone and the theme-colour projection branch on is_light(). Two tokens could not be borrowed from the dark set and carry their own values: text_muted (3.0:1 on the graphite shell) and red, which is also the outline danger button's ink (3.7:1); both now clear the 4.5:1 floor the palette tests pin. Ships as themes/moon-graphite.toml plus MoonThemeConfig::moon_graphite(). --- crates/moon-ui-components/src/moon/theme.rs | 11 ++ .../src/moon/theme/tests.rs | 11 ++ crates/moon-ui-components/src/moon/tokens.rs | 80 ++++++++++ .../src/moon/tokens/tests.rs | 29 ++++ .../themes/moon-graphite.toml | 139 ++++++++++++++++++ docs/PALETTE_SPEC.md | 30 ++++ docs/component-api-baseline.json | 8 + 7 files changed, 308 insertions(+) create mode 100644 crates/moon-ui-components/themes/moon-graphite.toml diff --git a/crates/moon-ui-components/src/moon/theme.rs b/crates/moon-ui-components/src/moon/theme.rs index 160c7f2..e04aa13 100644 --- a/crates/moon-ui-components/src/moon/theme.rs +++ b/crates/moon-ui-components/src/moon/theme.rs @@ -319,6 +319,17 @@ impl MoonThemeConfig { .expect("bundled moon-light theme must parse") } + /// The middle theme — [`MoonPalette::GRAPHITE`] on the DARK side, `LIGHT` on the light side. + /// + /// `mode` is `Dark`, and that is not a placeholder: Graphite IS a dark theme, just a softer + /// one, so it takes the dark colour set everywhere a consumer branches on + /// [`MoonPalette::is_light`]. A host that needs to know Graphite specifically asks its own + /// settings, not this config's `mode`. + pub fn moon_graphite() -> Self { + toml::from_str(include_str!("../../themes/moon-graphite.toml")) + .expect("bundled moon-graphite theme must parse") + } + /// Set the font delta on both themes, refusing a value that cannot render. /// /// [`MoonScale::font_delta`] is added directly into every text metric, so a non-finite value diff --git a/crates/moon-ui-components/src/moon/theme/tests.rs b/crates/moon-ui-components/src/moon/theme/tests.rs index 604737e..77f24d9 100644 --- a/crates/moon-ui-components/src/moon/theme/tests.rs +++ b/crates/moon-ui-components/src/moon/theme/tests.rs @@ -24,6 +24,7 @@ fn rgb_of(color: gpui::Hsla) -> u32 { fn selection_inks_are_readable_on_the_surface_each_one_lands_on() { for (name, palette) in [ ("dark", MoonPalette::TERMINAL), + ("graphite", MoonPalette::GRAPHITE), ("light", MoonPalette::LIGHT), ] { let tokens = MoonThemeTokens { @@ -115,3 +116,13 @@ fn a_non_finite_font_delta_is_replaced_while_zero_is_kept() { "zero font delta is 'no adjustment', a real setting - it must be kept" ); } + +/// Catches `theme.rs:MoonThemeConfig::moon_graphite` drifting from its bundled TOML palette. +/// A const and its bundled theme file must agree, or Graphite paints a different palette than planned. +#[test] +fn graphite_theme_config_matches_the_bundled_dark_and_light_palettes() { + let config = MoonThemeConfig::moon_graphite(); + assert_eq!(config.dark.palette, MoonPalette::GRAPHITE); + assert_eq!(config.light.palette, MoonPalette::LIGHT); + assert_eq!(config.mode, crate::moon::foundation::ThemeMode::Dark); +} diff --git a/crates/moon-ui-components/src/moon/tokens.rs b/crates/moon-ui-components/src/moon/tokens.rs index 66fdc28..b2a0495 100644 --- a/crates/moon-ui-components/src/moon/tokens.rs +++ b/crates/moon-ui-components/src/moon/tokens.rs @@ -198,6 +198,86 @@ impl MoonPalette { yellow: 0xB8860B, }; + /// The middle theme: mid-tone neutral surfaces with light ink, between [`Self::TERMINAL`] + /// and [`Self::LIGHT`]. + /// + /// It is a DARK palette by measurement, not by declaration — `shell` has a luma of 54.5, so + /// [`Self::is_light`] answers `false` and every consumer that branches on it (`MoonTone`, + /// `theme_colors`, a host's own logo and chart-colour picks) takes the dark arm with no new + /// code. That is the whole reason this ships as a palette rather than a third `ThemeMode`. + /// + /// Surfaces sit at HSL-L 21.8–29.8%, which is 5.4× `TERMINAL`'s relative luminance — visibly + /// softer than the near-black dark theme while staying unmistakably dark. Lighter was tried + /// and does not work: at a shell of HSL-L 31.6% even `text` falls under 4.5:1 on the matching + /// `panel_high`, and any conforming `text_muted` would then have to be LIGHTER than `text`, + /// erasing the muted/normal distinction the palette exists to express. + /// + /// `text_muted` is deliberately NOT the dark palette's `0x7D7669`: that ink measures 3.03:1 + /// here and is unreadable. Lifting it is the fix — a mid-tone surface needs its own muted + /// ink, never a third colour table. + /// + /// Measured with [`contrast_ratio`] against the WCAG floor of 4.5:1 — `text` 10.35 / 10.35 / + /// 8.22 and `text_muted` 6.13 / 6.13 / 4.87 on `shell` / `window` / `panel`. Accent hues are + /// carried over from `TERMINAL` unchanged, and `green` and `red` are lifted in lightness only, + /// at the same hue. + /// + /// `red` is lifted further than it looks like it needs to be, because on the dark side it is + /// not only a fill: `Button::danger_fg` resolves to `red` for any palette that is not light, + /// and the outline danger button paints that ink over a transparent `shell`. A saturated red + /// cannot reach the required luminance there at all — pure `0xFF0000` is only 0.21 — so the + /// choice is a paler red or unreadable danger text. At `0xFA8A8C` the pairing measures + /// 5.18 / 4.12 / 4.58 on `shell` / `panel` / `card`, which matches or beats `TERMINAL`'s own + /// 4.71 / 4.02 / 4.36 on every one. Do not "restore the saturation" without re-measuring that + /// button. + pub const GRAPHITE: Self = Self { + shell: 0x33373C, + shell_high: 0x3B3F45, + window: 0x33373C, + surface: 0x373B40, + panel: 0x41464C, + panel_high: 0x464B52, + chrome: 0x3B3F45, + tabbar: 0x3B3F45, + panel_head: 0x464B52, + gutter: 0x2E3236, + chart_bg: 0x373B40, + card: 0x3B3F45, + row_alt: 0x3B3F45, + head_row: 0x41464C, + border: 0x51565D, + border_soft: 0x4C5158, + border_card: 0x51565D, + border_hover: 0x646A72, + row_line: 0x4C5158, + shadow: 0x000000, + overlay: 0xFFFFFF, + on_accent: 0xFFFFFF, + text: 0xF1EEE8, + text_soft: 0xC9C5BD, + text_dim: 0xF1EEE8, + text_muted: 0xBDB9B1, + text_faint: 0xA8A49C, + table_head: 0x41464C, + table_body: 0x3B3F45, + table_selected: 0xFFB347, + table_hover: 0x4B5057, + green: 0x2FB673, + green_btn: 0x1E8C5B, + green_text: 0x2FB673, + red: 0xFA8A8C, + red_text: 0xFA8A8C, + red_soft_bd: 0xFA8A8C, + orange: 0xFF8E5A, + amber: 0xFFB347, + blue: 0x7FC9FF, + accent: 0xFFB347, + accent_fg: 0xFFCF94, + // A touch stronger than TERMINAL's 0.11: an 11% tint disappears into a mid grey where on + // near-black it reads clearly. + accent_tint_a: 0.14, + yellow: 0xFFD93D, + }; + pub fn with_legacy_defaults(mut self) -> Self { if self.window == 0 { self.window = self.shell; diff --git a/crates/moon-ui-components/src/moon/tokens/tests.rs b/crates/moon-ui-components/src/moon/tokens/tests.rs index 7eb045c..a0be816 100644 --- a/crates/moon-ui-components/src/moon/tokens/tests.rs +++ b/crates/moon-ui-components/src/moon/tokens/tests.rs @@ -15,6 +15,7 @@ const SELECTED_TEXT_CONTRAST_FLOOR: f32 = 4.5; fn selected_row_ink_stays_readable_on_the_panel_it_is_tinted_over() { for (name, p) in [ ("dark", MoonPalette::TERMINAL), + ("graphite", MoonPalette::GRAPHITE), ("light", MoonPalette::LIGHT), ] { let ratio = contrast_ratio(p.selected_fg(), p.panel); @@ -34,6 +35,7 @@ fn selected_row_ink_stays_readable_on_the_panel_it_is_tinted_over() { fn ink_on_a_solid_fill_is_readable_in_both_palettes() { for (name, p) in [ ("dark", MoonPalette::TERMINAL), + ("graphite", MoonPalette::GRAPHITE), ("light", MoonPalette::LIGHT), ] { for (fill_name, fill) in [("accent", p.accent), ("panel", p.panel)] { @@ -64,6 +66,33 @@ fn dark_terminal_palette_keeps_legacy_core_values() { assert_eq!(p.accent, 0xFFB347); } +/// Catches raising `tokens.rs:MoonPalette::GRAPHITE.shell` above the dark luma threshold. +/// A light classification flips every `is_light()` consumer to the light branch at once. +#[test] +fn graphite_palette_remains_a_dark_palette_by_measured_shell_luma() { + assert!(!MoonPalette::GRAPHITE.is_light()); +} + +/// Catches restoring Graphite's `text_muted` to the dark palette's low-contrast value. +/// Muted text on Graphite's shell, window, or panel must remain readable at the WCAG floor. +#[test] +fn graphite_text_and_muted_text_clear_the_surface_contrast_floor() { + let palette = MoonPalette::GRAPHITE; + for (ink_name, ink) in [("text", palette.text), ("text_muted", palette.text_muted)] { + for (surface_name, surface) in [ + ("shell", palette.shell), + ("window", palette.window), + ("panel", palette.panel), + ] { + let ratio = contrast_ratio(ink, surface); + assert!( + ratio >= SELECTED_TEXT_CONTRAST_FLOOR, + "Graphite {ink_name} on {surface_name} is {ratio:.2}:1, below {SELECTED_TEXT_CONTRAST_FLOOR}:1" + ); + } + } +} + /// Catches changing `tokens.rs:MoonPalette::LIGHT` away from the neutral-terminal design /// specification, which would break semantic contrast and status coloring in the light theme. #[test] diff --git a/crates/moon-ui-components/themes/moon-graphite.toml b/crates/moon-ui-components/themes/moon-graphite.toml new file mode 100644 index 0000000..7c52a26 --- /dev/null +++ b/crates/moon-ui-components/themes/moon-graphite.toml @@ -0,0 +1,139 @@ +mode = "Dark" + +[dark.palette] +shell = 0x33373C +shell_high = 0x3B3F45 +window = 0x33373C +surface = 0x373B40 +panel = 0x41464C +panel_high = 0x464B52 +chrome = 0x3B3F45 +tabbar = 0x3B3F45 +panel_head = 0x464B52 +gutter = 0x2E3236 +chart_bg = 0x373B40 +card = 0x3B3F45 +row_alt = 0x3B3F45 +head_row = 0x41464C +border = 0x51565D +border_soft = 0x4C5158 +border_card = 0x51565D +border_hover = 0x646A72 +row_line = 0x4C5158 +shadow = 0x000000 +overlay = 0xFFFFFF +on_accent = 0xFFFFFF +text = 0xF1EEE8 +text_soft = 0xC9C5BD +text_dim = 0xF1EEE8 +text_muted = 0xBDB9B1 +text_faint = 0xA8A49C +table_head = 0x41464C +table_body = 0x3B3F45 +table_selected = 0xFFB347 +table_hover = 0x4B5057 +green = 0x2FB673 +green_btn = 0x1E8C5B +green_text = 0x2FB673 +red = 0xFA8A8C +red_text = 0xFA8A8C +red_soft_bd = 0xFA8A8C +orange = 0xFF8E5A +amber = 0xFFB347 +blue = 0x7FC9FF +accent = 0xFFB347 +accent_fg = 0xFFCF94 +accent_tint_a = 0.14 +yellow = 0xFFD93D + +[dark.metrics] +header_top_h = 32.0 +toolbar_h = 32.0 +status_h = 22.0 +dock_tab_h = 26.0 +table_header_h = 26.0 +table_row_h = 25.0 +button_radius = 4.0 +container_radius = 8.0 +hairline = 1.0 + +[dark.scale] +ui = 1.0 +font = 1.0 +font_delta = 0.0 + +[dark.typography] +font_family = "Inter" +mono_font_family = "Geist Mono" +font_size = 12.0 +mono_font_size = 11.0 +rem_size = 16.0 + +[light.palette] +shell = 0xF3F5F7 +shell_high = 0xFAFBFC +window = 0xF7F8FA +surface = 0xFFFFFF +panel = 0xF8FAFC +panel_high = 0xFFFFFF +chrome = 0xF5F7FA +tabbar = 0xF2F5F8 +panel_head = 0xF5F7FA +gutter = 0xEEF2F6 +chart_bg = 0xFFFFFF +card = 0xFFFFFF +row_alt = 0xFCFDFE +head_row = 0xF3F6F8 +border = 0xD5DBE1 +border_soft = 0xE1E5EA +border_card = 0xDCE2E8 +border_hover = 0xB8C2CC +row_line = 0xECEFF2 +shadow = 0x000000 +overlay = 0x000000 +on_accent = 0xFFFFFF +text = 0x17202A +text_soft = 0x4B5865 +text_dim = 0x2D3945 +text_muted = 0x768391 +text_faint = 0x98A3AE +table_head = 0xF3F6F8 +table_body = 0xFFFFFF +table_selected = 0x009DFF +table_hover = 0xEEF2F6 +green = 0x178A57 +green_btn = 0x178A57 +green_text = 0x0E6E45 +red = 0xD2483F +red_text = 0xB7352F +red_soft_bd = 0xE1B5B0 +orange = 0xD18A2B +amber = 0xB97824 +blue = 0x2B6F9E +accent = 0x009DFF +accent_fg = 0x0A3F68 +accent_tint_a = 0.08 +yellow = 0xB8860B + +[light.metrics] +header_top_h = 32.0 +toolbar_h = 32.0 +status_h = 22.0 +dock_tab_h = 26.0 +table_header_h = 26.0 +table_row_h = 25.0 +button_radius = 4.0 +container_radius = 8.0 +hairline = 1.0 + +[light.scale] +ui = 1.0 +font = 1.0 +font_delta = 0.0 + +[light.typography] +font_family = "Inter" +mono_font_family = "Geist Mono" +font_size = 12.0 +mono_font_size = 11.0 +rem_size = 16.0 diff --git a/docs/PALETTE_SPEC.md b/docs/PALETTE_SPEC.md index a2601e9..426b5be 100644 --- a/docs/PALETTE_SPEC.md +++ b/docs/PALETTE_SPEC.md @@ -92,6 +92,36 @@ Font scaling and global UI scaling must be architecture-level capabilities, not per-screen hacks. Increasing UI font size should not require hunting through application panels. +### Bundled themes + +Three palettes ship in the crate, each a `MoonPalette` constant in +`moon/tokens.rs` paired with a theme file under `crates/moon-ui-components/themes/`: + +| theme | file | palette | colour set | +|---|---|---|---| +| Terminal | `moon-terminal.toml` | `MoonPalette::TERMINAL` | dark | +| Light | `moon-light.toml` | `MoonPalette::LIGHT` | light | +| Graphite | `moon-graphite.toml` | `MoonPalette::GRAPHITE` | dark | + +The constant and the file hold the same values on purpose — the constant is what +code reads, the file is what a host installs — so a change to one is a change to +both, and a test pins the pair. + +Two rules bind a new bundled palette: + +- **Which side it is on is MEASURED, never declared.** `MoonPalette::is_light` + reads the luminance of `shell`, so a palette lands on the light or dark side by + its own colours. That is why Graphite needs no `ThemeMode` of its own: it is a + dark theme with softer surfaces, and every consumer that branches on `is_light` + takes the dark arm with no new code. Adding a `ThemeMode` variant to carry a + palette is the wrong instrument. +- **`text` and `text_muted` must reach 4.5:1 against `shell`, `window` and + `panel`**, measured by `contrast_ratio` (WCAG 2.x). A palette may not borrow an + ink from a neighbouring theme that fails this: Graphite lifts its own + `text_muted` precisely because the dark palette's value measures 3.03:1 on a + mid-tone surface. Fix legibility in the palette's own tokens, never by adding a + third colour table for consumers to branch on. + ## Real Component Requirement A component is not considered implemented in the palette merely because: diff --git a/docs/component-api-baseline.json b/docs/component-api-baseline.json index dd5a0fc..9669ff3 100644 --- a/docs/component-api-baseline.json +++ b/docs/component-api-baseline.json @@ -4293,6 +4293,10 @@ "file": "crates/moon-ui-components/src/moon/theme.rs", "signature": "pub fn load_toml(path: impl AsRef) -> Result" }, + { + "file": "crates/moon-ui-components/src/moon/theme.rs", + "signature": "pub fn moon_graphite() -> Self" + }, { "file": "crates/moon-ui-components/src/moon/theme.rs", "signature": "pub fn moon_light() -> Self" @@ -4473,6 +4477,10 @@ "file": "crates/moon-ui-components/src/moon/toggle.rs", "signature": "pub struct MoonToggle" }, + { + "file": "crates/moon-ui-components/src/moon/tokens.rs", + "signature": "pub const GRAPHITE: Self = Self" + }, { "file": "crates/moon-ui-components/src/moon/tokens.rs", "signature": "pub const LIGHT: Self = Self"