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.rsis 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
bezeldependency. See D1.
Decisions
- D1 — Adapt, do not depend. bezel is built against
bezel-gpui(its own gpui fork); coop is ongpui-pre0.3.8. Addingbezelas 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) intocrates/themeand 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 insidecrates/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'sTheme::of(cx). - D4 — JSON themes become overrides.
assets/themes/*.json(aurora, 4x catppuccin, flexoki, forest, ocean) andThemeRegistrysurvive, but the schema changes from "fullThemeColorsdump" to "per-appearance override deltas + optionalBrand", applied over the built-in palette by the palette builder. 8 small files, migrated by hand. - D5 —
gpui-baseprojection 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,glassas settings-shaped fields with bezel's defaults so a later port is additive; skipSurfaceSpec/MaterialSpecmachinery until a surface needs it. - D7 — App chrome heights stay. bezel's
TITLEBAR_HEIGHT = 38,HEADER_HEIGHT = 44are bezel-app values. coop keepsTITLEBAR_HEIGHT = 36,TABBAR_HEIGHT = 36,SIDEBAR_WIDTH = 240(crates/theme/src/lib.rs) — they are app layout, not tokens.BASE_RADIUSand 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'scrates/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
Themestruct. Rewritecolors.rsinto the semantic token set from the mapping above (flat fields onTheme, bezel-style; drop theTheme→ThemeColorsDeref).Appearance(Dark/Light) replacesThemeModeas the internal enum; keepFrom<WindowAppearance>. Ship both palettes asTheme::for_appearance(appearance). - Appearance plumbing. New
appearance.rs:init(mode, cx)withAppearanceMode::{System, Light, Dark}(serde),observe_window(window, cx) -> Subscription,set_mode. Wire it soTheme::change/sync_system_appearancedelegate 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 anAppExttrait; the builder runs first on every appearance switch, then overrides from the registry, then brand rotation. All of it beforeappearance::initresolves. - Layout constants. New
layout.rs:SPACE = 8.0,CONTENT_MARGIN = 20.0,BASE_RADIUS = 8.0, and the ratio functionscontrol_radius()0.75x,button_radius()1x,panel_radius()1.25x,surface_radius()1.5x,bubble_radius()2x, plusinset_radius(outer, inset). Keep coop's chrome heights (D7). - Typography.
font_sans(platform default, as today), addfont_body(defaults tofont_sans) andfont_mono. Keepbase_text_size()at 15px. Keep the existing Tailwind-style text-size helpers incrates/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
ThemeColorsand the oldThemefields as computed accessors over the new tokens (mapping table above) so the workspace still compiles. Mark#[deprecated]to make Phase 3 greppable. sync_baserewrite. Project the new tokens ontogpui_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 againstgpui-base's actual token names when writing the code.- Contrast tests. Port bezel's
tests/theme.rsidea: assert each light token lands within ~0.5 of its dark counterpart's contrast ratio against its background, andtextonbg≥ 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_mutedchip treatment; Ghost → transparent +element_hover/element_active; Custom stays (sources defaults from new tokens). Size → bezelControlSizeladder values via the existingSizeenum. Radius:button_radius(),bubble_radius()whenrounded.input/—input_bg,border_faintresting /border_strongfocus,ring,caret,selection,text_faintplaceholder.dialog.rs—surface_dialogoverscrim(),surface_radius(), frame shadows.popover.rs,menu/—surface_overlay, hairline border,popover_surfacestub (D6: plain wash for now).tab.rs— mapping table row:bgactive plane,element_hoverhover,text_muted/textlabels,border_faintseparators.nav.rs,nav_item.rs—element_hover/element_activestates,accentselection markers.title_bar.rs—surface; drag handlers unchanged; height stays 36.tooltip.rs—surface_overlayplate,textlabel.switch.rs— on:accent_strongtrack +on_solidknob; off:input_bgtrack +border.indicator.rs,kbd.rs— indicator getssuccess/busy/dangerdots; kbd:surface_raised+border_faint+text_muted.avatar.rs—border_faintring; keep blob behavior.message.rs—bubble_radius()on bubbles,text_dimfor file-path / secondary labels,success/busy/dangerdelivery states,bandfor composer strips.markdown.rs—code_text/code_wash,SyntaxPalette,text_mutedheadings hierarchy.notification.rs—surface_overlayplate + semantic accents.group_box.rs—panel_radius(),border_faint,bandheader.dock/,resizable/—drop_target,drop_line,border_faint.scroll/— scrollbar paints from gpui-base tokens; verifyScrollbarModesetting still lands viasync_base.root.rs—bg+borderwindow frame, frame shadows.icon.rs— tones from text tokens; keep theIconNameset as-is.styled.rs—divider()→border_faint/hairline();popover_style()→surface_overlay+surface_radius(). Keeph_flex/v_flexnames (bezel'sstack::row/columnequivalents).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 onelevated_surface_backgroundreview tosurface_raisedvssurface_card; reactions/toggles toelement_*; report banners towarning_muted/danger_muted.crates/workspace— tabs, dock, settings dialog, theme picker.crates/nip29_ui,crates/settings,crates/nip4e,desktop,web.- Replace
radius/radius_lgwith 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 --workspaceclean;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 persistedtheme_mode: ThemeModewithappearance_mode: AppearanceMode(System/Light/Dark). Serde-compat: customDeserializethat maps the oldLight/Darkvalues and falls back toSystem(or version the field) so existing configs keep loading.apply_themebecomes: 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 throughBrand.tint; only hues rotation cannot reach stay as overrides — flexoki is the likely candidate).workspace/src/dialogs/settings.rs: add theSystemappearance 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/ColorScaleSetpublic exports (scale.rseither goes private as the palette generator's internal or is deleted),ThemeMode. crates/ui/src/lib.rs:initbecomesappearance::init+gpui_base::init+menu::init;theme::sync_baseis now called from the appearance change path, not app boot.- Update
README/docs mentions of theme customization; documentBrand+ override JSON as the two supported ways to theme coop. - Final census run: only new token names remain.
Risks
- Fork mismatch (D1). If
gpui-preandbezel-gpuiever unify, a directbezeldependency 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_baseis the only seam — keep it total, and keep it on the appearance-change path. - Init order.
appearance::initmust run before the first window opens or the first frame paints the wrong palette;observe_windowmust be attached per window for live light/dark flips. - Settings serde. Old persisted
theme_modevalues 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/