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

25 KiB
Raw Blame History

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

  • Record the token census (command above) as the migration checklist.
  • 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.

  • 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.
  • 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.
  • 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.
  • 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.
  • 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).
  • 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.
  • 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.
  • 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.
  • 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).
  • 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.
  • 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. (Size ladder heights kept — bezel's 24px control heights are a consumer-visible layout change, deferred.)
  • 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.)
  • dialog.rs — surface_dialog over scrim(SCRIM_ALPHA_DARK), surface_radius(), surface_shadows() (entrance animation scales the helper's shadow alphas).
  • 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).)
  • 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.)
  • 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.)
  • 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.
  • tooltip.rs — surface_overlay plate, text label, control_radius().
  • switch.rs — on: accent_strong track + on_solid knob; off: input_bg track + border.
  • 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.
  • avatar.rs — no ring/border is painted today; dropped the ghost_element_background underlay. Blob behavior kept.
  • 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.)
  • 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.)
  • notification.rs — surface_overlay plate + surface_radius() + surface_shadows(); error is a full danger_strong plate with on_accent, success/warning icons success/warning.
  • 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.)
  • 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.)
  • scroll/ — verified: zero theme reads; scrollbar paint comes from gpui-base via sync_base, ScrollbarMode still lands.
  • root.rs — bg plane, surface_shadows() on the window frame (now gated on cx.theme().shadow); decoration consts untouched.
  • icon.rs — default tone text_muted (was icon); IconName set and APIs unchanged.
  • 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).
  • 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