diff --git a/docs/ui-theme-bezel-migration.md b/docs/ui-theme-bezel-migration.md new file mode 100644 index 00000000..ba10d3ea --- /dev/null +++ b/docs/ui-theme-bezel-migration.md @@ -0,0 +1,391 @@ +# UI theme migration to bezel tokens + +Migrate `crates/theme` and `crates/ui` from the current `ThemeColors` / +12-step-scale system to bezel's token set and theme architecture, keeping every +existing UI element (button, dialog, dock, menu, ...) and its public API. + +Upstream: — docs at + (append `.md` to any docs URL; `llms-full.txt` has the +whole corpus). The source of truth for exact constants is bezel's repo: +`crates/theme/src/theme/{mod,palettes,layout,typography,syntax}.rs`. + +## Goal + +- One semantic token set (bezel's) with two designed palettes (dark, light). + Light is designed, not inverted — bezel's doc comment on + `crates/theme/src/lib.rs` is the reference for why. +- Theming by `Brand` (tint hue, accent hue, base radius) plus a palette-builder + hook that survives light/dark switches. +- Components read the theme global at paint time and take no color, font, or + size parameters. Radii derive from one base radius by ratio; spacing from + named constants. + +## Non-goals + +- No new components from bezel's catalog. The current element set stays. +- No glass/material/vibrancy port in the first pass. The tokens exist in bezel's + `Theme`, but coop has no backdrop-blur surfaces today; port the fields as + inert defaults or omit them (see decision D6). +- No `bezel` dependency. See D1. + +## Decisions + +- **D1 — Adapt, do not depend.** bezel is built against `bezel-gpui` (its own + gpui fork); coop is on `gpui-pre` 0.3.8. Adding `bezel` as a crate dependency + puts two gpui forks in the graph — two type universes, the failure mode + bezel's own docs warn about first. So we port the theme *architecture* (token + names, palettes, brand rotation, layout constants, paint helpers, tests) into + `crates/theme` and keep gpui paths as they are. +- **D2 — Keep component APIs.** `Button`, `Dialog`, `dock`, `menu`, `Sizable`, + `Size`, `ButtonVariant`, ... keep their names and signatures. Variants are + *re-derived* from the new tokens inside `crates/ui`. Callers only break where + they read theme tokens directly (there are ~370 such sites; Phase 3). +- **D3 — Semantic tokens replace scales as the public API.** The Radix-style + 12-step `ColorScale`/`ColorScaleSet` (`crates/theme/src/scale.rs`) stop being + public API. The oklch-derived palette generator replaces them internally. + `ActiveTheme` (`cx.theme()`) stays — it is coop's `Theme::of(cx)`. +- **D4 — JSON themes become overrides.** `assets/themes/*.json` (aurora, 4x + catppuccin, flexoki, forest, ocean) and `ThemeRegistry` survive, but the + schema changes from "full `ThemeColors` dump" to "per-appearance override + deltas + optional `Brand`", applied over the built-in palette by the palette + builder. 8 small files, migrated by hand. +- **D5 — `gpui-base` projection is re-derived, not dropped.** `sync_base` + (`crates/theme/src/lib.rs`) keeps feeding the base global (focus ring, + selection wash, scrollbars, overlay backdrops paint from it). The mapping is + rewritten against the new token names. +- **D6 — Glass/material fields: port stubs, not the renderer.** Carry + `vibrancy_alpha`, `vibrancy`, `glass` as settings-shaped fields with bezel's + defaults so a later port is additive; skip `SurfaceSpec`/`MaterialSpec` + machinery until a surface needs it. +- **D7 — App chrome heights stay.** bezel's `TITLEBAR_HEIGHT = 38`, + `HEADER_HEIGHT = 44` are bezel-app values. coop keeps `TITLEBAR_HEIGHT = 36`, + `TABBAR_HEIGHT = 36`, `SIDEBAR_WIDTH = 240` (`crates/theme/src/lib.rs`) — + they are app layout, not tokens. `BASE_RADIUS` and the spacing constants are + adopted. + +## Token mapping + +Old → new. The census command to re-run at any time: + +``` +grep -rhoE "theme\(\)\.[a-z_0-9]+" --include="*.rs" crates desktop web | sort | uniq -c | sort -rn +``` + +Current usage counts (2026-10-09) are in the right column; zero means the token +is only consumed via `sync_base` or not at all. + +### Surfaces + +| Old (`ThemeColors`) | New (`Theme`) | Notes | Uses | +| --- | --- | --- | --- | +| `background` | `bg` | main content plane | 6 | +| `surface_background` | `surface` | shell / sidebar plane | 16 | +| `elevated_surface_background` | `surface_raised` | review each site; inline cards → `surface_card` | 14 | +| `panel_background` | `surface` | | 3 | +| `overlay` | `scrim()` helper | surfaces mount on `surface_overlay` | 1 | +| `title_bar` | `surface` | currently transparent | 0 | +| `title_bar_inactive` | `surface` | | 0 | +| `window_border` | `border` | | 0 | + +### Borders + +| Old | New | Notes | Uses | +| --- | --- | --- | --- | +| `border` | `border` | | 21 | +| `border_variant` | `border_faint` | dividers, nested frames | 11 | +| `border_focused` | `border_strong` | focus *rings* use `ring` | 1 | +| `border_selected` | `border_strong` | | 1 | +| `border_transparent` | drop | `gpui::transparent_black()` inline | 3 | +| `border_disabled` | `border_faint` | | 0 | +| `ring` | `ring` | | 0 | + +### Text and icons + +| Old | New | Notes | Uses | +| --- | --- | --- | --- | +| `text` | `text` | | 16 | +| `text_muted` | `text_muted` | | 53 | +| `text_placeholder` | `text_faint` | | 25 | +| `text_accent` | `accent` | | 1 | +| `text_danger` | `danger` | soft/inline error copy → `danger_muted` | 10 | +| `text_warning` | `warning` | soft copy → `warning_muted` | 2 | +| `icon` | `text_muted` | bezel has no icon tokens; tone is a text token | 2 | +| `icon_muted` | `text_faint` | | 10 | +| `icon_accent` | `accent` | | 6 | + +### Plates and states + +| Old | New | Notes | Uses | +| --- | --- | --- | --- | +| `element_foreground` | `on_solid` | label on the primary plate | 2 | +| `element_background` | `solid` | bezel's primary button plate is the max-contrast solid; use `accent_strong` + `on_accent` only where a brand plate is wanted | 4 | +| `element_hover` | `element_hover` | | 2 | +| `element_active` | `element_active` | | 5 | +| `element_selected` | `element_active` | | 1 | +| `element_disabled` | `wash()` at disabled alpha | helper, not token | 2 | +| `secondary_foreground` | `text` | tinted secondary buttons use `accent` | 8 | +| `secondary_background` | `surface_raised` | | 5 | +| `secondary_hover` | `element_hover` | | 1 | +| `secondary_active` | `element_active` | | 2 | +| `secondary_selected` | `element_active` | | 1 | +| `secondary_disabled` | `wash()` at disabled alpha | | 1 | +| `danger_foreground` | `on_accent` | over the `danger_strong` plate | 5 | +| `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_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 | +| `ghost_element_background_alt` | `surface_raised` | or `band` for strips | 3 | +| `ghost_element_hover` | `element_hover` | | 7 | +| `ghost_element_active` / `ghost_element_selected` | `element_active` | | 7/1 | +| `ghost_element_disabled` | `wash()` at disabled alpha | | 1 | + +### Tabs, input, misc + +| Old | New | Notes | Uses | +| --- | --- | --- | --- | +| `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_foreground` | `text` | | 2 | +| `scrollbar_*` (5 tokens) | gpui-base scrollbar tokens | via `sync_base` only | 0 | +| `drop_target_background` | `drop_target` | | 1 | +| `cursor` | `cursor` | | 1 | +| `selection` | `selection` | | 0 | +| `radius` | `Theme::button_radius()` | 1x; controls inside controls → `control_radius()` (0.75x). Review each of the 43 sites | 43 | +| `radius_lg` | `panel_radius()` (1.25x) or `surface_radius()` (1.5x) | cards → panel, dialogs/popovers → surface | 8 | +| `shadow` | `surface_shadows()` helper | keep the on/off setting | 10 | +| `font_size` | `base_text_size()` | stays 15px | 1 | +| `font_family` | `font_sans` | | 1 | +| `notification` | stays | `NotificationSettings` remains a coop extension field | 2 | + +### Tokens coop gains + +`text_dim` (one notch below muted), `success` / `success_muted` / `busy` +(message delivery and presence states), `accent` / `accent_strong` / +`on_accent`, `input_bg`, `caret`, `band`, `surface_card` / `surface_dialog` / +`surface_overlay` (elevation ladder), `surface_raised_hover`, `drop_line`, +`code_text` / `code_wash` / `syntax: SyntaxPalette` / `diff_add` / `diff_del` / +`diff_hunk_bg` (for `markdown.rs`), and the paint helpers `ink(alpha)` / +`hairline(alpha)` / `wash(alpha)` / `scrim()`. + +### Tokens coop drops + +The `ColorScale`/`ColorScaleSet` public API, `ThemeColors` as a public struct, +`element_*`/`secondary_*`/`ghost_element_*`/`tab_*`/`warning_*`/`danger_*` +families, `icon*` tokens, `title_bar*`, `window_border`, `scrollbar_*` on the +coop theme. + +## Phases + +Each phase is one PR (or a small series), compiles, and ships with the +validation listed. Follow the PR hygiene in `AGENTS.md`. + +### Phase 0 — Baseline + +- [ ] Capture light/dark screenshots of the main surfaces: chat panel, + message hover/actions, relays & trace dialogs, settings dialog, group + (nip29) views, workspace tabs/dock, notifications. +- [ ] Record the token census (command above) as the migration checklist. +- [ ] Branch. No code changes. + +### Phase 1 — Port the theme core into `crates/theme` + +Port in this order; after each step `cargo check --workspace` must pass because +old token names still resolve (they become computed aliases over the new +palette until Phase 3 removes them). + +- [ ] **Palette generator.** New `palette.rs`: port bezel's oklch-derived + neutral scale and the two designed palettes. Copy the constants from + bezel's `crates/theme/src/theme/palettes.rs` — do not re-derive. Keep the + "light is designed, not inverted" structure: surface order, elevation + ladder, and accent contrast are per-appearance, not mirrored. +- [ ] **New `Theme` struct.** Rewrite `colors.rs` into the semantic token set + from the mapping above (flat fields on `Theme`, bezel-style; drop the + `Theme` → `ThemeColors` Deref). `Appearance` (Dark/Light) replaces + `ThemeMode` as the internal enum; keep `From`. + Ship both palettes as `Theme::for_appearance(appearance)`. +- [ ] **Appearance plumbing.** New `appearance.rs`: `init(mode, cx)` with + `AppearanceMode::{System, Light, Dark}` (serde), `observe_window(window, cx) + -> Subscription`, `set_mode`. Wire it so `Theme::change` / + `sync_system_appearance` delegate to it. Boot order rule: init before the + first window opens; observe every opened window (workspace already + syncs on window activation — keep that hook). +- [ ] **Brand.** New `brand.rs`: `Tint { hue, chroma }` (oklch), `Brand { + tint, accent, radius, vibrancy_alpha, vibrancy, glass }`, hue rotation + over the palette (semantic tokens keep their hue; translucent ink is + untouched; chroma 0 reproduces the shipped palette). `set_brand` / + `set_palette(builder)` on an `AppExt` trait; the builder runs first on + every appearance switch, then overrides from the registry, then brand + rotation. All of it before `appearance::init` resolves. +- [ ] **Layout constants.** New `layout.rs`: `SPACE = 8.0`, + `CONTENT_MARGIN = 20.0`, `BASE_RADIUS = 8.0`, and the ratio functions + `control_radius()` 0.75x, `button_radius()` 1x, `panel_radius()` 1.25x, + `surface_radius()` 1.5x, `bubble_radius()` 2x, plus + `inset_radius(outer, inset)`. Keep coop's chrome heights (D7). +- [ ] **Typography.** `font_sans` (platform default, as today), add + `font_body` (defaults to `font_sans`) and `font_mono`. Keep + `base_text_size()` at 15px. Keep the existing Tailwind-style text-size + helpers in `crates/ui/src/styled.rs`; sizes are named per call site, not + themed. +- [ ] **Paint helpers.** `ink(alpha)`, `hairline(alpha)`, `wash(alpha)`, + `scrim()`, plate-hover helper, `INK_FILL_SCALE` / `INK_HAIRLINE_SCALE`. +- [ ] **Old names as aliases.** Temporarily keep `ThemeColors` and the old + `Theme` fields as computed accessors over the new tokens (mapping table + above) so the workspace still compiles. Mark `#[deprecated]` to make + Phase 3 greppable. +- [ ] **`sync_base` rewrite.** Project the new tokens onto + `gpui_base::Theme`: `bg→background`, `text→foreground`, + `surface→surface`, `solid→primary`, `on_solid→primary_foreground`, + `surface_raised→secondary`, `text→secondary_foreground`, + `surface_card→muted`, `text_muted→muted_foreground`, + `element_hover→accent`, `text→accent_foreground`, + `danger_strong→destructive`, `on_accent→destructive_foreground`, + `border→border`, `border→input`, `ring→ring`, `selection→selection`. + Verify against `gpui-base`'s actual token names when writing the code. +- [ ] **Contrast tests.** Port bezel's `tests/theme.rs` idea: assert each + light token lands within ~0.5 of its dark counterpart's contrast ratio + against its background, and `text` on `bg` ≥ 16:1. This is the safety + net for every palette override that follows. +- [ ] Delete nothing yet. Validation: `cargo test -p theme`, workspace check, + screenshots must be pixel-equivalent (aliases map to equivalent values + or the mapping table records an intentional change). + +### Phase 2 — Reskin `crates/ui` + +Keep public APIs (D2). Per file, replace old token reads with new tokens, +radii with ratio functions, ad-hoc spacing with `SPACE` steps where it is a +real deviation, and state washes with the paint helpers. + +- [ ] `button.rs` — variant table: Primary → `solid`/`on_solid` + + `surface_raised_hover`-style plate hover; Secondary → + `surface_raised` + `border` + `text`; Danger → `danger_strong` + + `on_accent`; Warning → `warning_muted` chip treatment; Ghost → + transparent + `element_hover`/`element_active`; Custom stays (sources + defaults from new tokens). Size → bezel `ControlSize` ladder values via + the existing `Size` enum. Radius: `button_radius()`, `bubble_radius()` + when `rounded`. +- [ ] `input/` — `input_bg`, `border_faint` resting / `border_strong` focus, + `ring`, `caret`, `selection`, `text_faint` placeholder. +- [ ] `dialog.rs` — `surface_dialog` over `scrim()`, `surface_radius()`, + frame shadows. +- [ ] `popover.rs`, `menu/` — `surface_overlay`, hairline border, + `popover_surface` stub (D6: plain wash for now). +- [ ] `tab.rs` — mapping table row: `bg` active plane, `element_hover` + hover, `text_muted`/`text` labels, `border_faint` separators. +- [ ] `nav.rs`, `nav_item.rs` — `element_hover`/`element_active` states, + `accent` selection markers. +- [ ] `title_bar.rs` — `surface`; drag handlers unchanged; height stays 36. +- [ ] `tooltip.rs` — `surface_overlay` plate, `text` label. +- [ ] `switch.rs` — on: `accent_strong` track + `on_solid` knob; off: + `input_bg` track + `border`. +- [ ] `indicator.rs`, `kbd.rs` — indicator gets `success`/`busy`/`danger` + dots; kbd: `surface_raised` + `border_faint` + `text_muted`. +- [ ] `avatar.rs` — `border_faint` ring; keep blob behavior. +- [ ] `message.rs` — `bubble_radius()` on bubbles, `text_dim` for file-path / + secondary labels, `success`/`busy`/`danger` delivery states, `band` for + composer strips. +- [ ] `markdown.rs` — `code_text`/`code_wash`, `SyntaxPalette`, + `text_muted` headings hierarchy. +- [ ] `notification.rs` — `surface_overlay` plate + semantic accents. +- [ ] `group_box.rs` — `panel_radius()`, `border_faint`, `band` header. +- [ ] `dock/`, `resizable/` — `drop_target`, `drop_line`, `border_faint`. +- [ ] `scroll/` — scrollbar paints from gpui-base tokens; verify + `ScrollbarMode` setting still lands via `sync_base`. +- [ ] `root.rs` — `bg` + `border` window frame, frame shadows. +- [ ] `icon.rs` — tones from text tokens; keep the `IconName` set as-is. +- [ ] `styled.rs` — `divider()` → `border_faint` / `hairline()`; + `popover_style()` → `surface_overlay` + `surface_radius()`. Keep + `h_flex`/`v_flex` names (bezel's `stack::row`/`column` equivalents). +- [ ] `animation.rs` — leave as-is this pass; note bezel's named-motion + catalog as follow-up. + +Validation: `cargo check -p ui`, run desktop, walk every component in +light/dark against Phase 0 screenshots. + +### Phase 3 — Migrate consumers + +Mechanical per the mapping table; ~370 sites across 8 crates. Use the +deprecated accessors as the worklist — the goal is zero deprecated hits. + +- [ ] `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 + `warning_muted`/`danger_muted`. +- [ ] `crates/workspace` — tabs, dock, settings dialog, theme picker. +- [ ] `crates/nip29_ui`, `crates/settings`, `crates/nip4e`, `desktop`, `web`. +- [ ] Replace `radius`/`radius_lg` with the ratio function the element is + (control inside control → 0.75x, control → 1x, card → 1.25x, floating → + 1.5x, bubble → 2x). This is the judgment-heavy step; do it per crate, + not with sed. +- [ ] `cargo check --workspace` clean; `grep -rn "theme().\(element\|secondary\|ghost_element\|tab_\|icon\|warning_\|danger_\|title_bar\|window_border\|panel_background\|surface_background\|elevated\)"` returns nothing. + +### Phase 4 — Settings, registry, shipped themes + +- [ ] `crates/settings/src/lib.rs`: replace persisted `theme_mode: ThemeMode` + with `appearance_mode: AppearanceMode` (`System`/`Light`/`Dark`). + Serde-compat: custom `Deserialize` that maps the old `Light`/`Dark` + values and falls back to `System` (or version the field) so existing + configs keep loading. `apply_theme` becomes: install registry overrides + → `set_brand` → `appearance::set_mode`. +- [ ] `ThemeRegistry` + `assets/themes/*.json`: new schema — + `{ id, name, author, brand?: { tint, accent, radius }, overrides: { + light?: {...}, dark?: {...} } }` where overrides are a *subset* of + semantic tokens. Re-derive the 8 shipped themes as override deltas over + the new base palette (catppuccin hues go through `Brand.tint`; only hues + rotation cannot reach stay as overrides — flexoki is the likely + candidate). +- [ ] `workspace/src/dialogs/settings.rs`: add the `System` appearance option + next to Light/Dark. +- [ ] Contrast tests from Phase 1 run against every registered theme — a + shipped theme that fails contrast is a bug in that JSON, fix the JSON. + +### Phase 5 — Removal and docs + +- [ ] Delete `ThemeColors`, the deprecated aliases, `ColorScale`/ + `ColorScaleSet` public exports (`scale.rs` either goes private as the + palette generator's internal or is deleted), `ThemeMode`. +- [ ] `crates/ui/src/lib.rs`: `init` becomes `appearance::init` + + `gpui_base::init` + `menu::init`; `theme::sync_base` is now called from + the appearance change path, not app boot. +- [ ] Update `README`/docs mentions of theme customization; document + `Brand` + override JSON as the two supported ways to theme coop. +- [ ] Final census run: only new token names remain. + +## Risks + +- **Fork mismatch (D1).** If `gpui-pre` and `bezel-gpui` ever unify, a direct + `bezel` dependency becomes possible and Phase 1's ported code could be + swapped for the real crate. Do not half-adopt (bezel + own gpui) — that is + the two-gpuis trap. +- **Light regressions.** coop's light palettes today are scale-derived; the + new light palette is designed. Expect intentional differences in surface + order and accent tones; judge against the Phase 0 screenshots, and record + intentional deltas in the PR description. +- **gpui-base drift.** Anything base paints (focus ring, text selection, + scrollbars, overlay backdrops) bypasses coop tokens. `sync_base` is the only + seam — keep it total, and keep it on the appearance-change path. +- **Init order.** `appearance::init` must run before the first window opens or + the first frame paints the wrong palette; `observe_window` must be attached + per window for live light/dark flips. +- **Settings serde.** Old persisted `theme_mode` values must not break load; + covered by Phase 4 compat shim and a round-trip test. +- **No component gallery.** coop has no bezel-gallery equivalent; the Phase 0 + screenshots are the review surface. If Phase 2 review gets painful, a + throwaway debug window that renders one of each component is cheaper than + regressions. + +## References + +- bezel theme docs: , + , , + +- bezel source (copy constants from here): + `crates/theme/src/theme/{mod,palettes,layout,typography,syntax}.rs`, + `crates/theme/src/{lib,paint,brand}.rs` +- Current coop theme: `crates/theme/src/{lib,colors,scale,theme,registry}.rs` +- Current coop components: `crates/ui/src/`