From 70540abd5d0d2d487f0c342f5a7419e48fda0657 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 9 Oct 2026 11:19:56 +0700 Subject: [PATCH] update theme --- crates/theme/src/brand.rs | 6 ++- crates/theme/src/colors.rs | 10 +++-- crates/theme/src/lib.rs | 4 +- crates/theme/src/palette.rs | 17 +++++-- crates/theme/tests/contrast.rs | 17 ++++++- crates/ui/src/button.rs | 14 +++--- crates/ui/src/dock/mod.rs | 6 +-- crates/ui/src/notification.rs | 6 ++- crates/ui/src/tab/mod.rs | 4 +- crates/ui/src/title_bar.rs | 4 +- docs/ui-theme-bezel-migration.md | 76 ++++++++++++++++++++++++++++++-- 11 files changed, 137 insertions(+), 27 deletions(-) diff --git a/crates/theme/src/brand.rs b/crates/theme/src/brand.rs index d0f31965..47c0fbcd 100644 --- a/crates/theme/src/brand.rs +++ b/crates/theme/src/brand.rs @@ -162,7 +162,11 @@ impl Brand { /// The shipped palette for an appearance, rotated onto this brand. pub fn branded(&self, appearance: ThemeMode) -> ThemeColors { let mut colors = ThemeColors::for_appearance(appearance); - self.apply(appearance, &mut colors); + // apply() re-asserts this brand's ink, so the default brand — whose + // ink is the shipped one — must skip it to stay a strict no-op. + if !self.is_default() { + self.apply(appearance, &mut colors); + } colors } diff --git a/crates/theme/src/colors.rs b/crates/theme/src/colors.rs index 5a2dc66b..81208a12 100644 --- a/crates/theme/src/colors.rs +++ b/crates/theme/src/colors.rs @@ -398,10 +398,12 @@ impl ThemeColors { self.danger_disabled = wash_for(appearance, 0.06); // Warning - self.warning_foreground = self.text; + // warning_muted is a translucent amber wash, so its ink is the amber + // token itself, and its state washes deepen the plate. + self.warning_foreground = self.warning; self.warning_background = self.warning_muted; - self.warning_hover = self.element_hover; - self.warning_active = self.element_active; + self.warning_hover = plate_hover_for(appearance, self.warning_muted); + self.warning_active = plate_active_for(appearance, self.warning_muted); self.warning_selected = self.element_active; self.warning_disabled = wash_for(appearance, 0.06); @@ -417,7 +419,7 @@ impl ThemeColors { self.tab_background = self.surface; self.tab_foreground = self.text_muted; self.tab_hover_background = self.element_hover; - self.tab_active_background = self.bg; + self.tab_active_background = self.element_active; self.tab_active_foreground = self.text; // Scrollbar diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 6ab4a5ca..c8637133 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -163,7 +163,9 @@ pub fn sync_base(cx: &mut App) { tokens.accent = colors.element_hover; tokens.accent_foreground = colors.text; tokens.destructive = colors.danger_strong; - tokens.destructive_foreground = colors.on_accent; + // Bezel's destructive pairing: white ink on the red plate in both + // appearances. + tokens.destructive_foreground = gpui::white(); tokens.border = colors.border; tokens.input = colors.border; tokens.ring = colors.ring; diff --git a/crates/theme/src/palette.rs b/crates/theme/src/palette.rs index b5b33b9d..e3c96f59 100644 --- a/crates/theme/src/palette.rs +++ b/crates/theme/src/palette.rs @@ -53,7 +53,6 @@ pub(crate) fn dark() -> ThemeColors { danger_muted: color::oklch(0.808, 0.114, 19.571), // red-300 danger_strong: color::oklch(0.58, 0.16, 25.0), warning: color::oklch(0.828, 0.189, 84.429), // amber-400 - warning_muted: color::oklch(0.924, 0.12, 95.746), // amber-200 success: color::oklch(0.765, 0.177, 163.223), // emerald-400 success_muted: color::oklch(0.845, 0.143, 164.978), // emerald-300 busy: color::oklch(0.718, 0.202, 349.761), // pink-400 @@ -83,6 +82,9 @@ pub(crate) fn dark() -> ThemeColors { ..ThemeColors::legacy_default() }; + // Same warning-chip rule as light: a translucent amber wash under the + // `warning` token, not an opaque plate. + colors.warning_muted = colors.warning.alpha(0.16); colors.sync_legacy(ThemeMode::Dark); colors } @@ -119,7 +121,9 @@ pub(crate) fn light() -> ThemeColors { // ---- text ---- text: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.text), - text_muted: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.muted), + // One rung darker than AppKit's secondary label: sidebar and list + // labels sit on the chrome grey and need ~5:1, not the system ~4:1. + text_muted: color::neutral(0.45), text_faint: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.faint), text_dim: color::neutral(0.50), @@ -137,7 +141,6 @@ pub(crate) fn light() -> ThemeColors { danger_muted: color::oklch(0.505, 0.213, 27.518), // red-700 danger_strong: color::oklch(0.51, 0.20, 25.0), warning: color::oklch(0.555, 0.163, 48.998), // amber-700 — carries 12px text - warning_muted: color::oklch(0.473, 0.137, 46.201), // amber-800 success: color::oklch(0.596, 0.145, 163.225), // emerald-600 success_muted: color::oklch(0.508, 0.118, 165.612), // emerald-700 busy: color::oklch(0.592, 0.249, 0.584), // pink-600 @@ -147,7 +150,9 @@ pub(crate) fn light() -> ThemeColors { // "brighten the plate, don't wash it out" rule, read the other way. surface_raised_hover: color::neutral(0.900), band: band_for(ThemeMode::Light), - input_bg: color::grey(0xff), + // Inputs read as one rung below the chrome grey — the old scale's + // step-3 input plate. Pure white vanished against the content plane. + input_bg: color::neutral(0.950), selection: rgb(0xb3d7ff).into(), cursor: color::neutral(0.205), caret: color::neutral(0.511), @@ -169,6 +174,10 @@ pub(crate) fn light() -> ThemeColors { ..ThemeColors::legacy_default() }; + // The warning chip is a translucent amber wash, not an opaque plate: the + // 700-800 amber reads as muddy brown, and a wash lets the label ride on + // the `warning` token in both appearances. + colors.warning_muted = colors.warning.alpha(0.10); colors.sync_legacy(ThemeMode::Light); colors } diff --git a/crates/theme/tests/contrast.rs b/crates/theme/tests/contrast.rs index 7994d7f2..d8d7d7a1 100644 --- a/crates/theme/tests/contrast.rs +++ b/crates/theme/tests/contrast.rs @@ -109,6 +109,21 @@ fn brand_tint_rotates_greys_and_keeps_semantics() { ); } +#[test] +fn warning_wash_carries_its_label() { + // warning_muted is a translucent amber wash, so the label pairing is + // measured against the plate flattened onto the content plane. + for appearance in [ThemeMode::Dark, ThemeMode::Light] { + let colors = ThemeColors::for_appearance(appearance); + let plate = flatten(colors.warning_muted, colors.bg); + assert!( + ratio(colors.warning, plate) >= 3.0, + "{appearance:?}: warning on warning_muted = {}", + ratio(colors.warning, plate) + ); + } +} + #[test] fn legacy_aliases_track_tokens() { for appearance in [ThemeMode::Dark, ThemeMode::Light] { @@ -118,7 +133,7 @@ fn legacy_aliases_track_tokens() { assert_eq!(colors.text_placeholder, colors.text_faint); assert_eq!(colors.element_background, colors.solid); assert_eq!(colors.secondary_background, colors.surface_raised); - assert_eq!(colors.tab_active_background, colors.bg); + assert_eq!(colors.tab_active_background, colors.element_active); } } diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index e25b97ab..1580caa0 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -451,8 +451,12 @@ impl ButtonVariant { match self { ButtonVariant::Primary => cx.theme().on_solid, ButtonVariant::Secondary => cx.theme().text, - ButtonVariant::Danger => cx.theme().on_accent, - ButtonVariant::Warning => cx.theme().text, + // Bezel's destructive pairing: the red plate carries white ink in + // both appearances, not the appearance-flipping on_accent. + ButtonVariant::Danger => gpui::white(), + // warning_muted is a translucent amber wash, so the label is the + // amber token itself — dark amber in light, bright in dark. + ButtonVariant::Warning => cx.theme().warning, ButtonVariant::Transparent => cx.theme().text_faint, ButtonVariant::Ghost { alt } => { if *alt { @@ -470,7 +474,7 @@ impl ButtonVariant { ButtonVariant::Primary => plate_hover(cx.theme().solid), ButtonVariant::Secondary => cx.theme().surface_raised_hover, ButtonVariant::Danger => plate_hover(cx.theme().danger_strong), - ButtonVariant::Warning => cx.theme().element_hover, + ButtonVariant::Warning => plate_hover(cx.theme().warning_muted), ButtonVariant::Ghost { .. } => cx.theme().element_hover, ButtonVariant::Transparent => gpui::transparent_black(), ButtonVariant::Custom(colors) => colors.hover, @@ -491,7 +495,7 @@ impl ButtonVariant { ButtonVariant::Primary => plate_active(cx.theme().solid), ButtonVariant::Secondary => plate_active(cx.theme().surface_raised), ButtonVariant::Danger => plate_active(cx.theme().danger_strong), - ButtonVariant::Warning => cx.theme().element_active, + ButtonVariant::Warning => plate_active(cx.theme().warning_muted), ButtonVariant::Ghost { .. } => cx.theme().element_active, ButtonVariant::Transparent => gpui::transparent_black(), ButtonVariant::Custom(colors) => colors.active, @@ -511,7 +515,7 @@ impl ButtonVariant { ButtonVariant::Primary => cx.theme().element_active, ButtonVariant::Secondary => cx.theme().element_active, ButtonVariant::Danger => plate_active(cx.theme().danger_strong), - ButtonVariant::Warning => cx.theme().element_active, + ButtonVariant::Warning => plate_active(cx.theme().warning_muted), ButtonVariant::Ghost { .. } => cx.theme().element_active, ButtonVariant::Transparent => gpui::transparent_black(), ButtonVariant::Custom(colors) => colors.active, diff --git a/crates/ui/src/dock/mod.rs b/crates/ui/src/dock/mod.rs index f0998b54..319e9c47 100644 --- a/crates/ui/src/dock/mod.rs +++ b/crates/ui/src/dock/mod.rs @@ -467,7 +467,7 @@ impl TabGroupSkin { let bar = TabBar::new("tab-bar") .track_scroll(&self.scroll_handle) .h(TABBAR_HEIGHT) - .bg(cx.theme().surface) + .bg(cx.theme().bg) .when(needs_traffic_light_padding, |this| { this.pl(px(TRAFFIC_LIGHT_PADDING)) }) @@ -569,7 +569,7 @@ impl TabGroupSkin { h_flex() .h(TABBAR_HEIGHT) .w_full() - .bg(cx.theme().surface) + .bg(cx.theme().bg) .child(bar.flex_1()) .child(window_controls()) .into_any_element() @@ -718,7 +718,7 @@ impl TabGroupRenderer for TabGroupSkin { .child( div() .size_full() - .bg(cx.theme().surface) + .bg(cx.theme().bg) .overflow_hidden() .child(panel.cached(StyleRefinement::default().v_flex().size_full())), ) diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index b32a0d5a..df7a46e8 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -44,7 +44,7 @@ impl NotificationKind { Self::Info => (IconName::Info, cx.theme().text_muted), Self::Success => (IconName::CheckCircle, cx.theme().success), Self::Warning => (IconName::Warning, cx.theme().warning), - Self::Error => (IconName::CloseCircle, cx.theme().on_accent), + Self::Error => (IconName::CloseCircle, gpui::white()), }; Icon::new(name).with_size(Size::Medium).text_color(color) } @@ -284,7 +284,9 @@ impl Render for Notification { }; let text_color = match self.kind { - Some(NotificationKind::Error) => cx.theme().on_accent, + // White on the danger plate in both appearances, per bezel's + // destructive pairing. + Some(NotificationKind::Error) => gpui::white(), _ => cx.theme().text, }; diff --git a/crates/ui/src/tab/mod.rs b/crates/ui/src/tab/mod.rs index 2bb87696..66ae617e 100644 --- a/crates/ui/src/tab/mod.rs +++ b/crates/ui/src/tab/mod.rs @@ -191,7 +191,9 @@ impl RenderOnce for Tab { .text_sm() .rounded(px(theme::button_radius())) .overflow_hidden() - .when(selected && !disabled, |this| this.bg(cx.theme().bg)) + .when(selected && !disabled, |this| { + this.bg(cx.theme().element_active) + }) .when(!selected && !disabled, |this| { this.hover(|this| { this.bg(cx.theme().element_hover) diff --git a/crates/ui/src/title_bar.rs b/crates/ui/src/title_bar.rs index be63273d..6cf2d3b8 100644 --- a/crates/ui/src/title_bar.rs +++ b/crates/ui/src/title_bar.rs @@ -70,7 +70,9 @@ impl ControlIcon { #[inline] fn hover_fg(&self, cx: &App) -> Hsla { if self.is_close() { - cx.theme().on_accent + // White on the danger plate in both appearances, per bezel's + // destructive pairing. + gpui::white() } else { cx.theme().text } diff --git a/docs/ui-theme-bezel-migration.md b/docs/ui-theme-bezel-migration.md index de684b1f..e870e47e 100644 --- a/docs/ui-theme-bezel-migration.md +++ b/docs/ui-theme-bezel-migration.md @@ -117,7 +117,7 @@ is only consumed via `sync_base` or not at all. | Old | New | Notes | Uses | | --- | --- | --- | --- | | `text` | `text` | | 16 | -| `text_muted` | `text_muted` | | 53 | +| `text_muted` | `text_muted` | light value darkened to `neutral(0.45)` (~5.3:1) — AppKit's secondary label was too faint on the chrome grey | 53 | | `text_placeholder` | `text_faint` | | 25 | | `text_accent` | `accent` | | 1 | | `text_danger` | `danger` | soft/inline error copy → `danger_muted` | 10 | @@ -146,8 +146,8 @@ is only consumed via `sync_base` or not at all. | `danger_background` | `danger_strong` | destructive plate; tinted chips → `danger_muted` | 4 | | `danger_hover` / `danger_active` / `danger_selected` | plate-hover helper | one helper that brightens (dark) / darkens (light) a plate | 1/1/1 | | `danger_disabled` | `wash()` at disabled alpha | | 1 | -| `warning_foreground` | `text` | icons/rules may use `warning` | 5 | -| `warning_background` | `warning_muted` | | 5 | +| `warning_foreground` | `warning` | `warning_muted` is a translucent amber wash; the label is the amber token itself | 5 | +| `warning_background` | `warning_muted` | translucent amber wash (`warning` at 10% light / 16% dark) | 5 | | `warning_hover` / `warning_active` / `warning_selected` | `element_hover` / `element_active` | on the chip | 1/1/1 | | `warning_disabled` | `wash()` at disabled alpha | | 1 | | `ghost_element_background` | transparent | drop | 6 | @@ -163,7 +163,7 @@ is only consumed via `sync_base` or not at all. | `tab_background` | `surface` | | 2 | | `tab_foreground` | `text_muted` | | 1 | | `tab_hover_background` | `element_hover` | | 2 | -| `tab_active_background` | `bg` | active tab merges with the content plane, hairline separates | 1 | +| `tab_active_background` | `element_active` | one rung past `element_hover` (darker in light, brighter in dark); hairline separates from the plane | 1 | | `tab_active_foreground` | `text` | | 2 | | `scrollbar_*` (5 tokens) | gpui-base scrollbar tokens | via `sync_base` only | 0 | | `drop_target_background` | `drop_target` | | 1 | @@ -363,6 +363,41 @@ passes inside this crate); `cargo check -p ui`, `cargo check --workspace`, `cargo test -p theme` (11 tests) and `cargo clippy -p ui -p theme` are green. Public APIs, element structure, sizes and heights unchanged (D2, D7). +Light-mode review fixes (after running the app): + +- **Warning chip is a wash, not a plate.** Bezel's `warning_muted` is a + strong amber (800 light / 200 dark) meant to carry white/black ink; on a + button it read as muddy brown. Both appearances now derive + `warning_muted` as a translucent wash of the `warning` token (10% light, + 16% dark) and pair it with `warning`-toned labels; button hover/active + deepen the plate with the plate helpers, and `sync_legacy` maps + `warning_foreground` → `warning`. A contrast test measures the label + against the wash flattened onto the content plane. +- **Inputs lost their grey.** Light `input_bg` was pure white; it is now + `neutral(0.950)`, one rung below the chrome grey like the old scale's + step-3 input plate. Dark stays bezel's white-at-3%. +- **Dock planes move to `bg`.** The dock tab bar, title strip and panel + container read `bg` (white light / `#060606` dark — bezel's main panel), + matching the old near-white `panel_background`. Sidebar keeps `surface` + (the chrome grey). +- **Active tab is a wash, not white.** The dock tab's selected plane is + `element_active` — one rung past the `element_hover` hover (darker in + light, brighter in dark). The segmented tab's active segment stays a + white raised plate with a shadow. `sync_legacy.tab_active_background` + follows. +- **Sidebar text contrast.** Light `text_muted` moves from AppKit's + secondary label (~4:1, ~3.7:1 on the grey sidebar) to `neutral(0.45)` + (~5.3:1 on white), which also narrows the dark/light muted gap. Faint + stays AppKit-faint by design (placeholders). +- `Brand::branded` now skips `apply` for the default brand so the test's + no-op assertion holds now that a shipped token (`text_muted`) diverges + from the AppKit ink `apply` would re-assert. +- **Danger plates carry white ink in both appearances** (bezel's + destructive pairing — their `buttons.rs` hardcodes `gpui::white()` over + `danger_strong`): Danger button, error notification plate, title-bar + close hover, and `sync_base.destructive_foreground`. Visually a dark-mode + change only; light `on_accent` was already near-white. + Validation remaining: run desktop, walk every component in light/dark against the Phase 0 screenshots. @@ -371,6 +406,39 @@ against the Phase 0 screenshots. Mechanical per the mapping table; ~370 sites across 8 crates. Use the deprecated accessors as the worklist — the goal is zero deprecated hits. +Semantic states, per bezel's own widgets (`crates/ui/src/widgets/{status, +content,buttons}.rs`) — the recipes consumer banners, pills and state marks +should follow: + +- **Strips (error/warning).** Bezel ships exactly two — "two tones, not a + level enum"; there is no info strip. Recipe: background = the semantic + token at 6% opacity, hairline ring = the same token at 20%, label and + leading triangle icon = the family's ink step at 90%, all at + `surface_radius()`. In coop tokens: error → bg `danger` at 6%, ring + `danger` at 20%, ink `danger_muted` (we kept bezel's ink step for danger); + warning → bg `warning_muted` (our wash token), ring `warning` at 20%, ink + `warning`. +- **Success pill.** Bezel's `badge_active`: background = `success` at 12%, + ink = `success_muted` at 90%, `rounded_full`, no border. +- **State dots.** No enum — the caller passes the tone + (`success`/`busy`/`warning`/`danger`, `text_faint` for "nothing yet") to a + 6px bead. +- **Destructive controls.** Bezel's plate is `danger_strong` with **white + ink in both appearances** (`gpui::white()` in their `buttons.rs`), hover + dims the whole control to 90% opacity, and role is independent of emphasis + (a quiet destructive control is a ghost with `danger_strong`-toned text). + coop now carries the white-ink rule in button, notification error plate, + title-bar close hover, and `sync_base.destructive_foreground`. +- **Info.** No semantic tone — neutral copy on `text_muted`, or the quiet + hairline badge. +- Known divergence (kept deliberately): bezel keeps `warning_muted` as the + family's ink token and bakes wash alphas at call sites; coop bakes the + warning wash into the `warning_muted` token (10% light / 16% dark) because + the legacy `warning_background` alias and the button plate need a token, + and pairs it with `warning`-toned ink. The danger family keeps bezel's + exact semantics (`danger` wash source, `danger_muted` ink, `danger_strong` + plate). + - [ ] `crates/chat_ui` (largest: ~200 sites) — chips and rows on `elevated_surface_background` review to `surface_raised` vs `surface_card`; reactions/toggles to `element_*`; report banners to