416 lines
23 KiB
Markdown
416 lines
23 KiB
Markdown
# 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` | | 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
|
||
|
||
- [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.
|
||
|
||
- [ ] `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.
|
||
Note: the settings dialog's Light/Dark buttons call `Theme::change`
|
||
directly; they should move to `AppExt::set_appearance_mode` (and gain a
|
||
System option) in Phase 4.
|
||
- [ ] `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`: `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).
|
||
- [ ] 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: <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/`
|