31 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 |
light value darkened to neutral(0.45) (~5.3:1) — AppKit's secondary label was too faint on the chrome grey |
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 |
warning |
warning_muted is a translucent amber wash; the label is the amber token itself |
5 |
warning_background |
warning_muted |
translucent amber wash (warning at 10% light / 16% dark) |
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 |
element_active |
one rung past element_hover (darker in light, brighter in dark); hairline separates from the plane |
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. (Size ladder heights kept — bezel's 24px control heights are a consumer-visible layout change, deferred.)input/—input_bg,border_faintresting /border_strongfocus,ring,caret,selection,text_faintplaceholder. (coop's Input paints no frame border/ring of its own — focus mechanics untouched;InputEditorStyle::bordernowborder_faint, placeholder reaches gpui-base'smuted_foreground, nowtext_faint.)dialog.rs—surface_dialogoverscrim(SCRIM_ALPHA_DARK),surface_radius(),surface_shadows()(entrance animation scales the helper's shadow alphas).popover.rs,menu/—surface_overlay, hairline border,popover_surfacestub (D6: plain wash for now). (The floating plate isStyledExt::popover_stylein styled.rs; popover.rs itself paints no theme values. Menu item corners are concentric:inset_radius(surface_radius(), 4).)tab.rs— mapping table row:bgactive plane,element_hoverhover,text_muted/textlabels,border_faintseparators. (No separator was painted before; none added — structure frozen. Segmented track issurface.)nav.rs,nav_item.rs—element_hover/element_activestates,accentselection markers. (Resting labels are nowtext_muted— intentional visible change; radius 12→8.)title_bar.rs— the crate never readtitle_bar/title_bar_inactive(consumers paint the strip); close-button danger hover reskinned todanger_strong/on_accent/plate_active. Drag handlers and heights untouched.tooltip.rs—surface_overlayplate,textlabel,control_radius().switch.rs— on:accent_strongtrack +on_solidknob; off:input_bgtrack +border.indicator.rs,kbd.rs— indicator turned out to be the loading spinner and paints no theme values (no status dots exist incrates/ui— thesuccess/busy/dangermapping lands at consumer call sites in Phase 3). kbd:surface_raised+border_faint+text_muted.avatar.rs— no ring/border is painted today; dropped theghost_element_backgroundunderlay. Blob behavior kept.message.rs—text_dimon the author/timestamp row,text_faintwelcome body,element_hoverrow 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(addedwith_inline_code), heading hierarchy (with_heading: h1–h3text, h4–h6text_muted), linksaccent. (Fenced-code highlighting comes from gpui-base's theme viasync_base;SyntaxPalettestays unconsumed until a coop-side highlighter exists.)notification.rs—surface_overlayplate +surface_radius()+surface_shadows(); error is a fulldanger_strongplate withon_accent, success/warning iconssuccess/warning.group_box.rs—surfaceplate,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 usedrop_linenow — 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 viasync_base,ScrollbarModestill lands.root.rs—bgplane,surface_shadows()on the window frame (now gated oncx.theme().shadow); decoration consts untouched.icon.rs— default tonetext_muted(wasicon);IconNameset and APIs unchanged.styled.rs—divider()→border_faint;popover_style()→surface_overlay+px(surface_radius())+surface_shadows()gated oncx.theme().shadow(was always-onshadow_md). Keeph_flex/v_flexnames (bezel'sstack::row/columnequivalents).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).
Light-mode review fixes (after running the app):
- Warning chip is a wash, not a plate. Bezel's
warning_mutedis a strong amber (800 light / 200 dark) meant to carry white/black ink; on a button it read as muddy brown. Both appearances now derivewarning_mutedas a translucent wash of thewarningtoken (10% light, 16% dark) and pair it withwarning-toned labels; button hover/active deepen the plate with the plate helpers, andsync_legacymapswarning_foreground→warning. A contrast test measures the label against the wash flattened onto the content plane. - Inputs lost their grey. Light
input_bgwas pure white; it is nowneutral(0.950), one rung below the chrome grey like the old scale's step-3 input plate. Dark stays bezel's white-at-3%. - Dock planes move to
bg. The dock tab bar, title strip and panel container readbg(white light /#060606dark — bezel's main panel), matching the old near-whitepanel_background. Sidebar keepssurface(the chrome grey). - Active tab is a wash, not white. The dock tab's selected plane is
element_active— one rung past theelement_hoverhover (darker in light, brighter in dark). The segmented tab's active segment stays a white raised plate with a shadow.sync_legacy.tab_active_backgroundfollows. - Sidebar text contrast. Light
text_mutedmoves from AppKit's secondary label (~4:1, ~3.7:1 on the grey sidebar) toneutral(0.45)(~5.3:1 on white), which also narrows the dark/light muted gap. Faint stays AppKit-faint by design (placeholders). Brand::brandednow skipsapplyfor the default brand so the test's no-op assertion holds now that a shipped token (text_muted) diverges from the AppKit inkapplywould re-assert.- Danger plates carry white ink in both appearances (bezel's
destructive pairing — their
buttons.rshardcodesgpui::white()overdanger_strong): Danger button, error notification plate, title-bar close hover, andsync_base.destructive_foreground. Visually a dark-mode change only; lighton_accentwas already near-white.
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.
Semantic states, per bezel's own widgets (crates/ui/src/widgets/{status, content,buttons}.rs) — the recipes consumer banners, pills and state marks
should follow:
-
Strips (error/warning). Bezel ships exactly two — "two tones, not a level enum"; there is no info strip. Recipe: background = the semantic token at 6% opacity, hairline ring = the same token at 20%, label and leading triangle icon = the family's ink step at 90%, all at
surface_radius(). In coop tokens: error → bgdangerat 6%, ringdangerat 20%, inkdanger_muted(we kept bezel's ink step for danger); warning → bgwarning_muted(our wash token), ringwarningat 20%, inkwarning. -
Success pill. Bezel's
badge_active: background =successat 12%, ink =success_mutedat 90%,rounded_full, no border. -
State dots. No enum — the caller passes the tone (
success/busy/warning/danger,text_faintfor "nothing yet") to a 6px bead. -
Destructive controls. Bezel's plate is
danger_strongwith white ink in both appearances (gpui::white()in theirbuttons.rs), hover dims the whole control to 90% opacity, and role is independent of emphasis (a quiet destructive control is a ghost withdanger_strong-toned text). coop now carries the white-ink rule in button, notification error plate, title-bar close hover, andsync_base.destructive_foreground. -
Info. No semantic tone — neutral copy on
text_muted, or the quiet hairline badge. -
Known divergence (kept deliberately): bezel keeps
warning_mutedas the family's ink token and bakes wash alphas at call sites; coop bakes the warning wash into thewarning_mutedtoken (10% light / 16% dark) because the legacywarning_backgroundalias and the button plate need a token, and pairs it withwarning-toned ink. The danger family keeps bezel's exact semantics (dangerwash source,danger_mutedink,danger_strongplate). -
crates/chat_ui— all 20 legacy tokens migrated inlib.rs(~46 sites). Radius rulings: message media images →bubble_radius(); reaction/file chips and composer tiles →control_radius(); trace callout →panel_radius(); warning/danger report strips and the floating hover toolbar →surface_radius().elevated_surfacesplit: relay chips / reply-row hover →surface_raised, success report box →surface_card. Warning sites got the bezel strip treatment (warning_mutedwash +warningink); failed-report rows got the error-strip recipe (danger@6% +danger_muted@90%). -
crates/workspace— 58 sites across 18 files. Radius: warning strips →surface_radius(); input plates and control rows →button_radius(); request-count chip →control_radius(); browse cards and empty states →panel_radius().text_danger→dangereverywhere (each site was its view's only error signal). The "new requests" bead in the sidebar (oldcursortoken, an accent blue) →accent, notcaret— it is an attention marker, not a text cursor. Settings dialog'sTheme::changecalls left for Phase 4. -
crates/nip29_ui,crates/nip4e— 27 sites. Activity-row hover →element_hover(it interleaves withMessageRowin one list); message media →bubble_radius(); chips/pills →control_radius(); nip4e plates →surface_card. Flag for the design pass: the pin chip now paintssolidunder atext_mutedlabel — that pairing predates the migration (oldelement_background+text_muted); if it reads loud, its label should becomeon_solid. -
crates/settings,desktop,web— verified zero theme-token reads (their appearance wiring is Phase 4 scope). -
Replace
radius/radius_lgwith the ratio function the element is — done per crate by the agents above (not sed); rulings recorded per bullet. -
cargo check --workspaceclean; the Phase 3 grep gate (extended withsurface_background,background,cursor,drop_target_background) returns nothing across crates anddesktop/web.
Follow-ups noted: chat_ui paints delivery/status marks with hardcoded
gpui::green()/red() — candidates for the success/busy/danger
status-dot recipe in a later polish pass; warning strips carry no 20%-alpha
ring (no border existed; adding one is a structure change).
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/