Files
coop/docs/ui-theme-bezel-migration.md
T
2026-10-09 07:38:21 +07:00

21 KiB

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.

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<WindowAppearance>. 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