port bezel theme system into the theme crate
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user