Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/moon-ui-components/src/moon/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions crates/moon-ui-components/src/moon/theme/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
80 changes: 80 additions & 0 deletions crates/moon-ui-components/src/moon/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions crates/moon-ui-components/src/moon/tokens/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)] {
Expand Down Expand Up @@ -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]
Expand Down
139 changes: 139 additions & 0 deletions crates/moon-ui-components/themes/moon-graphite.toml
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions docs/PALETTE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions docs/component-api-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -4293,6 +4293,10 @@
"file": "crates/moon-ui-components/src/moon/theme.rs",
"signature": "pub fn load_toml(path: impl AsRef<Path>) -> Result<MoonThemeConfig, MoonThemeConfigError>"
},
{
"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"
Expand Down Expand Up @@ -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"
Expand Down
Loading