Files
coop/docs/ui-theme-bezel-migration.md
T

455 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- [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).
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.
- [ ] `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/`