23 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.
Status: Phases 0–1 are done on branch
new-theme(except the manual screenshot pass). The token core lives incrates/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.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 (verified 2026-10-09). bezel 0.2.10 is built
against
bezel-gpui0.3.17, published fromcrabtalk/zed. coop is ongpui-pre0.3.8, a zed snapshot published by longbridge (huacnlee), andgpui-base0.7.1 (longbridge's gpui-kit) is built against that line with no bezel-gpui build available. Addingbezelwould put two gpui forks in the graph — two type universes, the failure mode bezel's own docs warn about first — and would strandgpui-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 ifgpui-preandbezel-gpuiever 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 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: omitted, not stubbed. Phase 1 carried no
vibrancy/glass/SurfaceSpecfields at all — coop has no backdrop-blur surface, and inert fields would only rot.Brandcarriestint,accent,radiusandinkonly. 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 = 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
- 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'spalettes.rs(oklch-derived neutrals viacolor.rs). Light is designed, not inverted. - Token set.
colors.rs: the bezel token fields are flat onThemeColors(bg, surface ladder, hairlines, text ladder, solid, accent trio, semantic colors, components, code & diff, syntax palette). Legacy names resolve throughsync_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_appearanceroutes through it. Divergence: bezel's macOSNSAppearanceoverride syncing (so pinned themes restyle the traffic lights) is not ported — needs anobjcdependency; 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_paletteinstall 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::radiusis now 8 andradius_lg12 (was 6/10) — intentional visual change. coop's chrome heights stay (D7). - Typography (partial).
Themegainsfont_body(defaults tofont_family) andfont_mono(per-backend system mono). Divergence: bezel'sTextStyle/Metrics/Typesetladder andControlSize/Sizingare 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_fileand the registry's loader callThemeColors::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_baserewrite. Projects the new tokens ontogpui_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) andcargo check --workspacegreen; 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/#0d0d0dsurfaces 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_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. Note: the settings dialog's Light/Dark buttons callTheme::changedirectly; they should move toAppExt::set_appearance_mode(and gain a System option) in Phase 4.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:theme::initalready runsappearance::init; verifyui::init's callers boot the theme before the first window, and that every opened window observes appearance changes (theme::observe_windowor workspace's sync-on-activation hook).- 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/