@@ -1,574 +0,0 @@
# 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: <https://github.com/crabtalk/bezel> — docs at
<https://bezel.gallery> (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` .
> **Status:** Phases 0– 1 are done on branch `new-theme` (except the manual
> screenshot pass). The token core lives in `crates/theme/src/{color,palette,
> brand,paint,layout,syntax,appearance}.rs`; the legacy fields remain as
> derived aliases so the whole workspace still compiles. Divergences from the
> original plan are noted inline.
## 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 (verified 2026-10-09).** bezel 0.2.10 is built
against `bezel-gpui` 0.3.17, published from `crabtalk/zed` . coop is on
`gpui-pre` 0.3.8, a zed snapshot published by longbridge (huacnlee), and
`gpui-base` 0.7.1 (longbridge's gpui-kit) is built against that line with no
bezel-gpui build available. Adding `bezel` would put two gpui forks in the
graph — two type universes, the failure mode bezel's own docs warn about
first — and would strand `gpui-base` (BaseButton, component_traits,
scrollbars). So the theme * architecture * is ported: token names, palette
values, brand rotation, layout constants, and paint helpers are copied
verbatim from bezel's MIT-licensed source (each ported file carries an
attribution header), and gpui paths stay as they are. Revisit only if
`gpui-pre` and `bezel-gpui` ever unify.
- **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: omitted, not stubbed.** Phase 1 carried no
`vibrancy` /`glass` /`SurfaceSpec` fields at all — coop has no backdrop-blur
surface, and inert fields would only rot. `Brand` carries `tint` , `accent` ,
`radius` and `ink` only. A glass port later adds the fields and the
renderer in one move.
- **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` | 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 |
| `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` | `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 |
| `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` | `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 |
| `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
- [x] Record the token census (command above) as the migration checklist.
- [x] Branch: work happens on `new-theme` .
- [ ] 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. *(manual — run the
app before and after Phase 2/3)*
### Phase 1 — Port the theme core into `crates/theme` (done)
All steps keep the workspace compiling: the legacy token fields remain as
values derived from the tokens (`ThemeColors::sync_legacy` ), so the ~370 call
sites build unchanged.
- [x] **Palette generator. ** `palette.rs` : bezel's two designed palettes,
constants copied verbatim from bezel's `palettes.rs` (oklch-derived
neutrals via `color.rs` ). Light is designed, not inverted.
- [x] **Token set. ** `colors.rs` : the bezel token fields are flat on
`ThemeColors` (bg, surface ladder, hairlines, text ladder, solid,
accent trio, semantic colors, components, code & diff, syntax palette).
Legacy names resolve through `sync_legacy` . Deserialization refines over
the built-in dark palette (`#[serde(default)]` ), so partial JSON still
loads.
- [x] **Appearance plumbing. ** `appearance.rs` : `AppearanceMode::{System,
Light, Dark}` (serde), ` init`, ` set_mode`, ` apply`, ` observe_window`.
` Theme::sync_system_appearance` routes through it. *Divergence: bezel's
macOS ` NSAppearance` override syncing (so pinned themes restyle the
traffic lights) is not ported — needs an ` objc` dependency; follow-up.*
- [x] **Brand.** ` brand.rs`: ` Tint`, ` BASE_COLORS` (Tailwind's five neutral
families), ` TextInk`/` Ink` (AppKit + Accessible), ` Brand { tint, accent,
radius, ink }` with the grey-rotation rule (grey tokens take the tint,
semantic tokens keep their hue, translucent ink is skipped).
` AppExt::set_brand` / ` set_palette` install globals; the palette builder
runs first on every appearance install. A default brand is a strict
no-op so JSON themes are untouched until an app brands itself.
- [x] **Layout constants.** ` layout.rs`: ` SPACE = 8`, ` CONTENT_MARGIN = 20`,
` EDGE = 12`, ` BASE_RADIUS = 8`, ` BUTTON_HEIGHT = 24`,
` CONTROL_HEIGHT_SMALL = 20`, ratio functions (control 0.75x, button 1x,
panel 1.25x, surface 1.5x, bubble 2x), ` inset_radius`. ` Theme::radius`
is now 8 and ` radius_lg` 12 (was 6/10) — intentional visual change.
coop's chrome heights stay (D7).
- [x] **Typography (partial).** ` Theme` gains ` font_body` (defaults to
` font_family`) and ` font_mono` (per-backend system mono). *Divergence:
bezel's ` TextStyle`/` Metrics`/` Typeset` ladder and ` ControlSize`/
` Sizing` are deferred to Phase 2, where the components that would use
them get reskinned.*
- [x] **Paint helpers.** ` paint.rs`: process-wide appearance mirror, ` ink`,
` hairline`, ` wash`, ` scrim`, ` band`, ` INK_FILL_SCALE`/
` INK_HAIRLINE_SCALE`, ` surface_shadows`, plate hover/active helpers.
- [x] **Legacy JSON bridge.** ` ThemeFamily::from_file` and the registry's
loader call ` ThemeColors::rederive_tokens`, which best-effort maps an
old-schema theme's fields back onto the tokens (fields with no legacy
source — ` success`, ` busy`, syntax, diff — keep the built-in palette's
value for that appearance). Phase 4 replaces the schema; this bridge is
what keeps the 8 shipped themes usable in the meantime.
- [x] **` sync_base` rewrite.** Projects the new tokens onto ` gpui_base::Theme`
(bg→background, text→foreground, solid→primary, surface_raised→secondary,
surface_card→muted, element_hover→accent, danger_strong→destructive,
on_solid/on_accent→*_foreground, border→border/input, ring→ring,
selection→selection).
- [x] **Contrast tests.** ` tests/contrast.rs`: text ≥ 13.9:1 on bg and
surface in both appearances (measured 14.4/15.1), muted ≥ 3.9:1,
faint ≥ 1.8:1 (AppKit ink lands below WCAG AA by design), light-vs-dark
parity for primary text within 0.7, default brand is a no-op, brand
rotation moves greys but not semantic hues, legacy aliases track
tokens, and a legacy-JSON round trip. *Note: bezel's docs claim ~0.5
parity for every text role; the shipped AppKit ink only delivers that
for primary text, so the test asserts floors for muted/faint instead.*
- [x] ` cargo test -p theme` (11 tests) and ` cargo check --workspace` green;
clippy clean.
- [ ] Pixel review of the running app against Phase 0 screenshots (needs the
manual screenshot pass first). The palette swap is intentionally visible:
neutral ` #060606 `/` #0d0d0d ` surfaces replace the grey scale, hover/active
washes are neutral ink, focus rings are hairlines, cursor/selection match
macOS defaults.
### 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.
- [x] ` 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`. *(Size ladder heights kept — bezel's 24px control
heights are a consumer-visible layout change, deferred.)*
- [x] ` input/` — ` input_bg`, ` border_faint` resting / ` border_strong` focus,
` ring`, ` caret`, ` selection`, ` text_faint` placeholder. *(coop's Input
paints no frame border/ring of its own — focus mechanics untouched;
` InputEditorStyle::border` now ` border_faint`, placeholder reaches
gpui-base's ` muted_foreground`, now ` text_faint`.)*
- [x] ` dialog.rs` — ` surface_dialog` over ` scrim(SCRIM_ALPHA_DARK)`,
` surface_radius()`, ` surface_shadows()` (entrance animation scales the
helper's shadow alphas).
- [x] ` popover.rs`, ` menu/` — ` surface_overlay`, hairline border,
` popover_surface` stub (D6: plain wash for now). *(The floating plate
is ` StyledExt::popover_style` in styled.rs; popover.rs itself paints no
theme values. Menu item corners are concentric:
` inset_radius(surface_radius(), 4)`.)*
- [x] ` tab.rs` — mapping table row: ` bg` active plane, ` element_hover`
hover, ` text_muted`/` text` labels, ` border_faint` separators. *(No
separator was painted before; none added — structure frozen. Segmented
track is ` surface`.)*
- [x] ` nav.rs`, ` nav_item.rs` — ` element_hover`/` element_active` states,
` accent` selection markers. *(Resting labels are now ` text_muted` —
intentional visible change; radius 12→8.)*
- [x] ` title_bar.rs` — the crate never read ` title_bar`/` title_bar_inactive`
(consumers paint the strip); close-button danger hover reskinned to
` danger_strong`/` on_accent`/` plate_active`. Drag handlers and heights
untouched.
- [x] ` tooltip.rs` — ` surface_overlay` plate, ` text` label,
` control_radius()`.
- [x] ` switch.rs` — on: ` accent_strong` track + ` on_solid` knob; off:
` input_bg` track + ` border`.
- [x] ` indicator.rs`, ` kbd.rs` — indicator turned out to be the loading
spinner and paints no theme values (no status dots exist in ` crates/ui`
— the ` success`/` busy`/` danger` mapping lands at consumer call sites in
Phase 3). kbd: ` surface_raised` + ` border_faint` + ` text_muted`.
- [x] ` avatar.rs` — no ring/border is painted today; dropped the
` ghost_element_background` underlay. Blob behavior kept.
- [x] ` message.rs` — ` text_dim` on the author/timestamp row, ` text_faint`
welcome body, ` element_hover` row wash. *(No bubbles, delivery states,
composer strips or report banners live in this file — those are
consumer-side, Phase 3.)*
- [x] ` markdown.rs` — ` code_text`/` code_wash` (added ` with_inline_code`),
heading hierarchy (` with_heading`: h1– h3 ` text`, h4– h6 ` text_muted`),
links ` accent`. *(Fenced-code highlighting comes from gpui-base's theme
via ` sync_base`; ` SyntaxPalette` stays unconsumed until a coop-side
highlighter exists.)*
- [x] ` notification.rs` — ` surface_overlay` plate + ` surface_radius()` +
` surface_shadows()`; error is a full ` danger_strong` plate with
` on_accent`, success/warning icons ` success`/` warning`.
- [x] ` group_box.rs` — ` surface` plate, ` panel_radius()`. *(No border or
header strip is painted today; adding them is a design change, deferred
to Phase 3/4 review.)*
- [x] ` dock/`, ` resizable/` — ` drop_target`, ` drop_line`, ` border_faint`.
*(Tab-insertion line and drop-region outline both use ` drop_line` now —
slightly more prominent than the old muted outline, intentional. Drag
preview/empty-space drag-over also reskinned.)*
- [x] ` scroll/` — verified: zero theme reads; scrollbar paint comes from
gpui-base via ` sync_base`, ` ScrollbarMode` still lands.
- [x] ` root.rs` — ` bg` plane, ` surface_shadows()` on the window frame (now
gated on ` cx.theme().shadow`); decoration consts untouched.
- [x] ` icon.rs` — default tone ` text_muted` (was ` icon`); ` IconName` set and
APIs unchanged.
- [x] ` styled.rs` — ` divider()` → ` border_faint`; ` popover_style()` →
` surface_overlay` + ` px(surface_radius())` + ` surface_shadows()` gated
on ` cx.theme().shadow` (was always-on ` shadow_md`). Keep
` h_flex`/` v_flex` names (bezel's ` stack::row`/` column` equivalents).
- [x] ` animation.rs` — left as-is this pass; bezel's named-motion catalog is
a follow-up.
Outcome: ` theme::plate_hover`/` plate_active` were made public (wrappers over
the crate-internal ` *_for` helpers) so ` crates/ui` can reskin opaque plates.
Zero legacy-token reads remain in ` crates/ui` (the Phase 3 grep already
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.
### 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.
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).
- [x] ` crates/chat_ui` — all 20 legacy tokens migrated in ` lib.rs` (~46
sites). Radius rulings: message media images → ` bubble_radius()`;
reaction/file chips and composer tiles → ` control_radius()`; trace
callout → ` panel_radius()`; warning/danger report strips and the
floating hover toolbar → ` surface_radius()`. ` elevated_surface`
split: relay chips / reply-row hover → ` surface_raised`, success
report box → ` surface_card`. Warning sites got the bezel strip
treatment (` warning_muted` wash + ` warning` ink); failed-report rows
got the error-strip recipe (` danger` @6% + ` danger_muted` @90%).
- [x] ` crates/workspace` — 58 sites across 18 files. Radius: warning
strips → ` surface_radius()`; input plates and control rows →
` button_radius()`; request-count chip → ` control_radius()`; browse
cards and empty states → ` panel_radius()`. ` text_danger` → ` danger`
everywhere (each site was its view's only error signal). The "new
requests" bead in the sidebar (old ` cursor` token, an accent blue)
→ ` accent`, not ` caret` — it is an attention marker, not a text
cursor. Settings dialog's ` Theme::change` calls left for Phase 4.
- [x] ` crates/nip29_ui`, ` crates/nip4e` — 27 sites. Activity-row hover →
` element_hover` (it interleaves with ` MessageRow` in one list);
message media → ` bubble_radius()`; chips/pills → ` control_radius()`;
nip4e plates → ` surface_card`. Flag for the design pass: the pin
chip now paints ` solid` under a ` text_muted` label — that pairing
predates the migration (old ` element_background` + ` text_muted`);
if it reads loud, its label should become ` on_solid`.
- [x] ` crates/settings`, ` desktop`, ` web` — verified zero theme-token
reads (their appearance wiring is Phase 4 scope).
- [x] Replace ` radius`/` radius_lg` with the ratio function the element is
— done per crate by the agents above (not sed); rulings recorded
per bullet.
- [x] ` cargo check --workspace` clean; the Phase 3 grep gate (extended
with ` surface_background`, ` background`, ` cursor`, ` drop_target_background`)
returns nothing across crates and ` desktop`/` web`.
Follow-ups noted: chat_ui paints delivery/status marks with hardcoded
` gpui::green()`/` red()` — candidates for the ` success`/` busy`/` danger`
status-dot recipe in a later polish pass; warning strips carry no 20%-alpha
ring (no border existed; adding one is a structure change).
### Phase 4 — Settings, appearance, and the removal of the multi-theme system (done)
The original plan (appearance_mode with back-compat, registry re-schema,
brand-izing the 8 shipped JSONs) was superseded by a decision: **coop no
longer ships multiple themes**. The bezel token palettes (dark and light,
optionally rotated by a ` Brand`) are the only theming surface. Done:
- [x] ` crates/settings`: persisted ` theme_mode: ThemeMode` →
` appearance_mode: AppearanceMode` with
` #[serde(default, alias = "theme_mode")]` — an old config's pinned
Light/Dark survives; absence falls back to ` System`. Round-trip tests
cover both. ` set_theme`/` reset_theme` deleted; ` apply_theme` is now
` cx.set_appearance_mode(mode)`.
- [x] ` ThemeRegistry`, ` assets/themes/*.json` (8 files), and the
` themes/**/*` asset embed are deleted. ` theme::init` is
` appearance::init` + scrollbar sync.
- [x] ` ThemeFamily`, ` from_file`/` from_assets`, the theme picker dialog
(` theme_selector` + ` Command::ToggleTheme` + its header/greeter menu
entries), and the settings dialog's "Reset theme" row are deleted.
The appearance picker is now System/Light/Dark via
` AppearanceMode::ALL` and ` AppExt::set_appearance_mode`.
- [x] The old-schema JSON bridge (` rederive_tokens`, ` sync_legacy`,
` legacy_default`) is gone with the schema.
### Phase 5 — Removal and docs (done)
- [x] Deleted ` ThemeColors` legacy fields (~60), ` ColorScale`/` ColorScaleSet`
(` scale.rs`), ` ThemeColors::light/dark` wrappers, ` Theme::apply_theme`,
and the ` Theme` fields ` theme: Rc<ThemeFamily>`, ` radius`, ` radius_lg`
(every site reads the ratio functions now). ` ThemeColors` stays public:
it *is* the token set, in the bezel style.
- [x] ` crates/ui/src/scroll/scrollable.rs` no longer paints scrollbar colors
from coop tokens — gpui-base's ` Scrollbar` paints from its own global
theme (kept in step by ` sync_base`'s scrollbar mode), with base's
defaults for thumb/track.
- [x] ` ui::init` boot order verified: ` theme::init` (appearance::init)
runs before the first window in desktop and web; workspace still
observes window appearance for live System flips. Desktop/web
comments updated.
- [x] Final census: only new token names remain; workspace compiles; tests
green (theme 5, settings 2 incl. serde compat, 52 chat_ui).
Follow-ups noted: chat_ui's hardcoded ` gpui::green()`/` red()` status marks
are candidates for the ` success`/` busy`/` danger` status-dot recipe; the
nip29 pin chip pairs ` solid` with a ` text_muted` label (predates the
migration) — flip its label to ` on_solid` if it reads loud.
### Phase 5 — Removal and docs
*(Executed together with Phase 4 — see above.)*
- [x] Delete ` ThemeColors`, the deprecated aliases, ` ColorScale`/
` ColorScaleSet` public exports (` scale.rs` either goes private as the
palette generator's internal or is deleted), ` ThemeMode`.
- [x] ` crates/ui/src/lib.rs`: ` theme::init` already runs ` appearance::init`;
verify ` ui::init`'s callers boot the theme before the first window, and
that every opened window observes appearance changes
(` theme::observe_window` or workspace's sync-on-activation hook).
- [x] Update ` README`/docs mentions of theme customization; document
` Brand` + override JSON as the two supported ways to theme coop.
- [x] 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: <https://bezel.gallery/docs/theme.md>,
<https://bezel.gallery/docs/color.md>, <https://bezel.gallery/docs/layout.md>,
<https://bezel.gallery/docs/typography.md>
- 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/`