port bezel theme system into the theme crate

This commit is contained in:
2026-10-09 08:42:54 +07:00
parent 22f57def7d
commit 90377c8c76
14 changed files with 2121 additions and 566 deletions
+99 -75
View File
@@ -9,6 +9,12 @@ Upstream: <https://github.com/crabtalk/bezel> — docs at
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).
@@ -30,12 +36,18 @@ whole corpus). The source of truth for exact constants is bezel's repo:
## 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.
- **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
@@ -53,10 +65,11 @@ whole corpus). The source of truth for exact constants is bezel's repo:
(`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.
- **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`) —
@@ -187,73 +200,80 @@ validation listed. Follow the PR hygiene in `AGENTS.md`.
### Phase 0 — Baseline
- [x] Record the token census (command above) as the migration checklist.
- [x] 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.
- [ ] Record the token census (command above) as the migration checklist.
- [ ] Branch. No code changes.
(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`
### Phase 1 — Port the theme core into `crates/theme` (done)
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).
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.** 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).
- [x] **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.
- [x] **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.
- [x] **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.*
- [x] **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.
- [x] **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).
- [x] **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.*
- [x] **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.
- [x] **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.
- [x] **`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).
- [x] **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.*
- [x] `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`
@@ -317,6 +337,9 @@ deprecated accessors as the worklist — the goal is zero deprecated hits.
`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 →
@@ -349,9 +372,10 @@ deprecated accessors as the worklist — the goal is zero deprecated hits.
- [ ] 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.
- [ ] `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.