Files
coop/docs/THEME_MIGRATION.md
T
2026-10-08 07:58:56 +07:00

325 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Theme migration plan: full migration to the Zeron theme model
Full migration of `crates/theme` (currently derived from [zed-industries/zed `crates/theme`](https://github.com/zed-industries/zed/tree/main/crates/theme)) to the architecture of [zeronsh/zeron `crates/theme`](https://github.com/zeronsh/zeron/tree/main/crates/theme).
**Scope decision (v2):** wholesale migration. We do *not* preserve the current brand look, do *not* keep the old ~70-role UI color vocabulary, and do *not* bridge the old JSON theme assets. Zeron's model, role set, builtins, and defaults become coop's. A branding refresh (custom coop family/seeds) happens later, on top of the new model.
- Coop license: GPLv3 (theme crate currently credits Zed).
- Zeron license: MIT — code may be ported into coop with attribution (add zeron to `crates/theme/README.md` credits, record the ported revision).
---
## 1. Current state (coop)
### Crate layout (`crates/theme`)
| File | Contents |
| --- | --- |
| `src/lib.rs` | `Theme` gpui `Global` (colors + mode + font/radius/shadow/scrollbar/notification), `ActiveTheme` trait, `init()`, `sync_base()` projection into `gpui-base` tokens, layout constants (`TITLEBAR_HEIGHT`, …) |
| `src/theme.rs` | `ThemeMode` (Light/Dark), `ThemeFamily { id, name, author, url, light: ThemeColors, dark: ThemeColors }`, `from_file`/`from_assets` JSON loading, tests |
| `src/colors.rs` | `ThemeColors`: ~70 `Hsla` roles (Zed-style: `element_*`, `secondary_*`, `danger_*`, `warning_*`, `ghost_element_*`, `tab_*`, `scrollbar_*`, …) with `light()`/`dark()` defaults built from color scales |
| `src/scale.rs` | Zed `ColorScale`/`ColorScaleStep`/`ColorScaleSet` (12-step scales) |
| `src/registry.rs` | `ThemeRegistry`: gpui `Entity` global; loads `themes/**` from the `AssetSource` into `HashMap<path, Rc<ThemeFamily>>` |
| `src/geometry.rs`, `src/notification.rs`, `src/scrollbar_mode.rs`, `src/platform_kind.rs` | App chrome: `Edges`/`Side`/`AxisExt`, `NotificationSettings`, `ScrollbarMode`, `PlatformKind` |
### Assets & consumers
- `assets/themes/*.json` — 8 theme family files (Zed family format): `aurora`, `catppuccin-{latte,frappe,macchiato,mocha}`, `flexoki`, `forest`, `ocean`; embedded via `crates/assets` (rust-embed).
- Apps init: `desktop/src/main.rs` and `web/src/lib.rs` call `ui::init(cx)` then `theme::init(cx)`.
- Settings (`crates/settings/src/lib.rs`): persists `theme: Option<String>` (asset path) + `theme_mode: ThemeMode` to `config_dir()/.settings` (JSON via `smol::fs`, wasm-gated); `AppSettings::apply_theme` loads `ThemeFamily::from_assets(path)` → `Theme::apply_theme` + `Theme::change`.
- Theme picker: `crates/workspace/src/lib.rs` `theme_selector()` lists `ThemeRegistry` entries (name, author, url link).
- Preferences: `crates/workspace/src/dialogs/settings.rs` — Light/Dark toggle via `Theme::change`.
- `sync_base()` mirrors coop colors into the `gpui-base` global theme (focus ring, selection, scrollbars, overlays, typography).
### Usage scale (defines the rewrite scope)
- `use theme::ActiveTheme` across 7 crates.
- ~370 `cx.theme().*` references in **51 files**: `ui` (23), `workspace` (22), `chat_ui` (1, one large `lib.rs`), `nip29_ui` (4), `nip4e` (1). ~305 are color roles; the rest are `radius` (41), `shadow` (10), `radius_lg` (8), `notification` (2), `font_*` (2) — these app-chrome fields are unchanged by the migration.
- 55 distinct color roles are referenced; `ThemeColors` is never named outside `crates/theme`.
---
## 2. Target state (zeron `crates/theme`)
A **source-neutral, gpui-free domain model** plus an offline import pipeline:
| File | Contents |
| --- | --- |
| `src/lib.rs` | Domain model: `Appearance`, `Color` (rgba u8, hex serde, `contrast`/`mix`/`blend_over`/`ensure_contrast`/`best_on_color`), `SurfaceTreatment`/`SurfacePreference`, `AccentPreset`/`AccentSelection`/`AccentRoles` (primary/strong/wash/on/selection/caret/activity/glyph[3], derived with WCAG hardening), `ThemeSelection { light, dark }`, `ThemeSource` (provenance + sha256 `asset_hash`), `ThemeColors` (28 roles: background, shell, raised, card, dialog, overlay, hover, active, border, border_strong, text, text_muted, text_faint, solid, on_solid, danger(+_muted), warning(+_muted), success(+_muted), input, cursor, diff_add/delete/hunk), `TerminalPalette` (bg/fg/selection + ansi[16]), `ThemeVariant` (id, family_id, name, appearance, recommended_surface_treatment, colors, accent, syntax map, terminal, source), `ThemeFamily { id, name, variants }`, `ThemeRegistry` (`builtin()`/`active()`/`variant()`/`variants_for()`/`resolve()`/`validate()`), `ValidationIssue`/`Severity`/`Category`, `VisualFixture`, `replace_custom_families()` |
| `src/builtins.rs` | 19 families / 30 variants compiled in Rust from a `Seeds` struct; derived roles (border/hover/active alphas, dialog/overlay mixes, accent roles, 24-key syntax map, terminal palette); sha256 provenance of the resolved definition |
| `src/library.rs` | `CustomThemeLibrary` persisted as `theme-library.json` (16 MB / 256 entries / 1024 variants caps, atomic recoverable saves, backup recovery); sources: `ImportedSnapshot`, `LinkedFile`, `LinkedPackage`, `EditableFile`; install/reload/unlink/duplicate/remove with last-known-good semantics |
| `src/vscode.rs` | Offline VS Code import: JSONC (`json5`) + `include` resolution + external token files + TextMate `.tmTheme` (`plist`); workbench-key → zeron-role mapping tables; contrast hardening; `ImportReport`; extension-package compilation with per-variant failure isolation; security bounds (4 MB sources, include depth 32, ≤128 variants, path containment) |
| `src/bin/zeron-theme-import.rs` | CLI wrapper around `compile_source` |
Dependencies: `anyhow`, `serde`, `serde_json`, `json5`, `plist`, `sha2`, `thiserror` (clap only for the CLI). **No gpui.**
---
## 3. Key architectural differences
| Aspect | Coop today (Zed-style) | Zeron target |
| --- | --- | --- |
| Color type | `gpui::Hsla` everywhere | Own `Color` (rgba u8) with WCAG contrast math; converted to `Hsla` only at the gpui boundary |
| Family shape | `{ light: ThemeColors, dark: ThemeColors }` pair | `variants: Vec<ThemeVariant>`, each variant carries its own appearance + full payload |
| Role count | ~70 UI roles | 28 semantic roles + derived `AccentRoles` |
| Accent | Implicit (`element_background`) | First-class: presets, per-variant `AccentRoles`, runtime `AccentSelection` override |
| Builtins | JSON files in `assets/themes`, loaded via `AssetSource` at runtime | Compiled into Rust (`builtins.rs`), sha256 provenance |
| Custom themes | None | Durable library (snapshot/link/editable) + VS Code import with reports & hardening |
| Validation | None | `validate()`: contrast thresholds, unique ids, provenance completeness; blocking vs. reviewable |
| Registry | gpui `Entity` global over a path map | Plain struct; `builtin()` static + process-wide custom families (`RwLock`) |
| Syntax / terminal | Not modeled | `syntax` map (24 keys) + `TerminalPalette` per variant |
| Surfaces | N/A | `SurfaceTreatment` (Opaque/Frosted) recommendation + user `SurfacePreference` |
---
## 4. Guiding decisions
- **D1 — Two layers, one crate.** `crates/theme` gets a gpui-free **domain** module (port of zeron's model, kept faithful so upstream diffs stay readable) and a gpui **runtime** module (coop's `Theme` global, `ActiveTheme`, `sync_base`, app chrome). No new crate.
- **D2 — Zeron's role vocabulary is the only UI color vocabulary.** The old ~70-role `ThemeColors` is deleted. UI code uses the 28 zeron roles + `AccentRoles` exclusively. All 305 color call sites are rewritten (Phase 3) using the mapping in §7.
- **D3 — Zeron builtins and defaults adopted as-is.** Port all 19 families / 30 variants unchanged. Default selection: `ThemeSelection::default()` = `zeron-light`/`zeron-dark`. `assets/themes/*.json` and the asset-loading registry are deleted. A coop-branded family is a later, separate change (swap seeds, keep the model).
- **D4 — Runtime exposes a resolved `Hsla` snapshot.** The gpui `Theme` global stores `variant: Arc<ThemeVariant>`, resolved `appearance`, `accent: AccentRoles`, `surface: SurfaceTreatment`, and a precomputed snapshot with the *same field names* as the domain `ThemeColors`/`AccentRoles` but typed `Hsla` (`ResolvedColors`, `ResolvedAccentRoles`). Call sites stay ergonomic (`cx.theme().shell`, `cx.theme().accent.primary`) and gpui-native (`.alpha(...)` still works). App-chrome fields (`font_family`, `font_size`, `radius`, `radius_lg`, `shadow`, `scrollbar_mode`, `notification`) remain on `Theme`, unchanged.
- **D5 — Settings.** `theme: Option<String>` + `theme_mode: ThemeMode` → `theme_selection: ThemeSelection` + appearance setting; old keys are dropped on load (`theme_mode` maps trivially to `Appearance`; the old theme path is reset to defaults — the ids changed anyway). New settings: `accent_selection: AccentSelection`, `surface_preference: SurfacePreference`. Startup keeps following the OS appearance via `sync_system_appearance`.
- **D6 — Transitional compile scaffold, not an end state.** To keep every commit building while 51 files are rewritten, Phase 2 temporarily provides the removed role names (`ghost_element_hover`, `element_background`, `tab_*`, …) as fields on the resolved snapshot, computed from zeron roles per §7. They carry a doc comment "transitional — delete in Phase 5" and are removed as soon as call-site migration completes. This is a tactic, not a compat layer: no visual parity is promised.
- **D7 — Scope cuts for v1.** No frosted rendering (enums land; resolution returns `Opaque`; gpui blur later). `TerminalPalette`/`syntax` are carried in the model with no consumer yet (future: code-block highlighting). `VisualFixture` is not ported. The import CLI (`bin/`) is not ported.
- **D8 — Web target.** `library.rs`/`vscode.rs` use `std::fs`; gate them behind `#[cfg(not(target_arch = "wasm32"))]` (or a cargo feature) so `coop_web` keeps building, mirroring how `settings` gates file IO.
---
## 5. Target crate layout
```
crates/theme/
├── Cargo.toml # + thiserror, sha2 (json5/plist with Phase 6); − schemars
└── src/
├── lib.rs # re-exports; layout constants; init(); sync_base(); ActiveTheme (runtime)
├── model.rs # zeron lib.rs port: Color, Appearance, Surface*, Accent*,
│ # ThemeSelection, ThemeSource, ThemeColors, TerminalPalette,
│ # ThemeVariant, ThemeFamily, ThemeRegistry, ValidationIssue (+ tests)
├── builtins.rs # zeron builtins port: Seeds machinery + 19 families (+ tests)
├── runtime.rs # Theme gpui global over the domain: resolve, change, apply_variant,
│ # sync_system_appearance, sync_scrollbar_appearance
├── resolved.rs # ResolvedColors / ResolvedAccentRoles (Hsla mirror of the domain
│ # structs) + Color→Hsla conversion + transitional scaffold fields
├── geometry.rs # unchanged (Edges/Side/AxisExt; used by NotificationSettings)
├── notification.rs # unchanged
├── scrollbar_mode.rs # unchanged
├── platform_kind.rs # unchanged (font selection)
├── library.rs # Phase 6 (optional): CustomThemeLibrary
└── vscode.rs # Phase 6 (optional): VS Code import
```
Deleted: `src/colors.rs`, `src/scale.rs`, `src/theme.rs`, `src/registry.rs`, `assets/themes/*`, the `themes` include in `crates/assets`, and the `schemars` dependency.
---
## 6. Implementation phases
Each phase leaves the workspace building and tested (D6 makes this possible during the rewrite). Commit boundaries follow phases.
### Phase 0 — Prep (S)
1. Record the ported zeron revision (commit hash) for future diffing; add MIT attribution to `crates/theme/README.md`.
2. Baseline `cargo test` / `cargo build` state; confirm D7 scope cuts.
3. Create the migration branch.
**Acceptance:** branch exists; decisions recorded.
### Phase 1 — Port the zeron domain model (M)
1. `crates/theme/Cargo.toml`: `+ thiserror`, `+ sha2` (both workspace or pinned).
2. Create `src/model.rs` — faithful port of zeron's `lib.rs` (§2), keeping its *why* doc comments (provenance hashing, severity semantics, …) and its test module (color round-trip, accent contrast, surface resolution, builtin validation/counts).
3. Create `src/builtins.rs` — faithful port of the `Seeds` machinery and all 19 families (derived roles, syntax map, ANSI palettes, sha256 self-hash), including the registry-count tests.
4. Export from `lib.rs` without wiring anything into the app yet. Project rules: `Result` propagation, no `unwrap()`; `expect` only for constant builtin seed colors (statically covered by the builtin validation test, as upstream).
**Acceptance:** `cargo test -p theme` green with ported tests; `ThemeRegistry::builtin().validate()` reports no blocking issues; app behavior unchanged.
### Phase 2 — Runtime rework (L)
1. Add `src/resolved.rs`: `ResolvedColors` (28 fields, `Hsla`) and `ResolvedAccentRoles` (primary/strong/wash/on/selection/caret/activity/glyph, `Hsla`), built from a `ThemeVariant` + resolved `AccentRoles` at theme-change time (cheap, precomputed). Conversion: zeron `Color { r, g, b, a }` → `gpui::Rgba` → `Hsla`.
2. Add `src/runtime.rs` and rewrite `lib.rs` around it:
- `Theme` global fields: `variant: Arc<ThemeVariant>`, `appearance: Appearance`, `accent: AccentRoles`, `surface: SurfaceTreatment`, `colors: ResolvedColors` (plus `Deref<Target = ResolvedColors>`), and the unchanged app-chrome fields;
- `Theme::change(appearance)`: resolve `ThemeRegistry::active().resolve(&selection, appearance)` → rebuild the snapshot; still falls back to zeron defaults;
- `Theme::apply_variant(variant)` replaces `apply_theme(family)`;
- `sync_system_appearance` / `sync_scrollbar_appearance` keep their roles;
- `ActiveTheme` unchanged; `ThemeMode` deleted, replaced by `Appearance` (`From<WindowAppearance>` ported).
3. Add the **transitional scaffold** fields to `ResolvedColors` (D6): the removed role names computed from zeron roles per §7, e.g. `surface_background = shell`, `ghost_element_hover = hover`, `element_background = accent.primary`. All 55 used roles covered so downstream compiles untouched.
4. Delete `src/colors.rs`, `src/scale.rs`, `src/theme.rs`, `src/registry.rs`; delete `assets/themes/*.json` and the `themes` include in `crates/assets/src/lib.rs`; remove `schemars` from `Cargo.toml`.
5. Settings (`crates/settings/src/lib.rs`): swap persisted fields to `theme_selection` (+ `#[serde(default)]` `accent_selection`, `surface_preference`), map old `theme_mode` → appearance on load, drop the old `theme` key; `apply_theme` resolves through the registry (`Theme::change`). Update `desktop`/`web` init if signatures moved.
6. Rewrite `sync_base()` per §9.
**Acceptance:** app builds and runs on zeron defaults; appearance toggle + OS sync work; settings round-trip and the old `.settings` file loads (theme resets to defaults, mode preserved); no downstream crate has been touched yet.
### Phase 3 — Call-site migration: 51 files, 5 crates (L)
Migrate in dependency order; after each crate the workspace builds and that crate uses only zeron vocabulary:
1. `ui` (23 files) — includes the component redesigns of §8 (`button.rs` variants, `tab/`, `input/`, `menu/`, `dock/`, `root.rs`, `title_bar.rs`, …).
2. `workspace` (22 files) — sidebar/panels/dialogs + `theme_selector` placeholder compiles via scaffold.
3. `chat_ui` (1 large file), `nip29_ui` (4), `nip4e` (1).
4. Mechanics: apply the §7 mapping mechanically first, then review each file for roles that encode *intent* (e.g. `secondary_*` used as chip styling → `accent.wash` vs `raised`) and for contrast-sensitive spots (text on tinted backgrounds → `ensure_contrast` via the resolved accent where needed).
**Acceptance:** `grep -rE 'theme\(\)\.(element_|secondary_|ghost_element_|tab_|scrollbar_thumb|title_bar|panel_background|surface_background|elevated_surface)' crates` returns nothing; each crate compiles + its tests pass before moving on.
### Phase 4 — Theme UI renewal (M)
1. `theme_selector()`: list `ThemeRegistry::active().variants_for(appearance)` grouped by family; show variant name, family, provenance (url/license); selecting writes `theme_selection.set_variant(appearance, id)` and applies.
2. Preferences dialog (`workspace/dialogs/settings.rs`): Light/Dark toggle now `Appearance`; add **Accent** picker (`AccentSelection`: Theme default + `AccentPreset::ALL`) and **Surface** preference; changing accent re-resolves `AccentRoles` and refreshes windows.
3. Register validation feedback (non-blocking `ValidationIssue`s) somewhere visible later — minimal v1: log only.
**Acceptance:** picker lists families/variants filtered by appearance; selection persists per appearance; accent override recomputes accent-derived UI live; surface preference stored (rendering effect deferred per D7).
### Phase 5 — Remove the scaffold & dead code (S)
1. Delete every transitional field from `ResolvedColors` and the §7 scaffold computations.
2. Remove leftover APIs (`from_assets`, old tests/docs references), run `cargo clippy` + `cargo test` workspace-wide.
3. Update `crates/theme/README.md` (Zed + Zeron credits, ported revision).
**Acceptance:** no scaffold remains; `cargo build` (desktop) and `cargo build -p coop_web` both green.
### Phase 6 — Custom theme library + VS Code import (L, optional)
1. Port `src/vscode.rs` (import pipeline, mapping tables, hardening, reports, package compilation, security bounds) and `src/library.rs` (durable library, atomic recoverable saves, last-known-good reload, duplicate-as-editable). Add `json5` + `plist` deps.
2. Gate file IO for the web target (D8); library lives under `common::config_dir()` (`theme-library.json`, `custom-themes/*.zeron-theme.json`).
3. UI: "Import theme…" flow (file picker → `compile_source` → report dialog with mappings/adjustments/validation) and a library manager (reload/unlink/duplicate/remove). Errors surface in the UI per project rules.
4. After library load/mutation: `install_runtime()` → re-resolve the active theme.
**Acceptance:** ported zeron test suites pass; importing a VS Code package yields usable variants with a report; broken sources keep the last known good theme; `coop_web` unaffected by the gated IO.
### Phase 7 — Validation, tests, release (M)
1. Add a runtime smoke test: resolving every builtin variant produces a `ResolvedColors` snapshot with finite/valid values, and `Theme::change` round-trips appearances.
2. Settings migration test (old `.settings` → new fields).
3. Full QA pass on desktop (+ web if Phase 6 landed): §11 checklist; spot-check a handful of variants (zeron light/dark, catppuccin mocha/latte, github dark).
4. Release notes; schedule the deferred branding pass (custom coop family seeds) as follow-up work.
---
## 7. Call-site rewrite mapping (old role → zeron vocabulary)
`accent.*` refers to the resolved `AccentRoles`. Where intent matters more than mechanics, prefer the *Intent* column over the mechanical mapping.
| Old role | New (mechanical) | Intent / notes |
| --- | --- | --- |
| `background` | `background` | |
| `surface_background` | `shell` | sidebars, secondary surfaces |
| `elevated_surface_background` | `raised` | hover cards, popovers, lists |
| `panel_background` | `shell` | |
| `overlay` | `overlay` | scrim/backdrop (domain role is a surface; blend as needed) |
| `title_bar` | `shell` | |
| `window_border` | `border_strong` | |
| `border` | `border` | |
| `border_variant` | `border` (optionally `.opacity(...)`) | |
| `border_focused` / `border_selected` | `accent.primary` | |
| `border_transparent` | `gpui::transparent_black()` | |
| `border_disabled` | `border` low alpha | |
| `ring` | `accent.primary` | focus ring (also via `sync_base`) |
| `text` | `text` | |
| `text_muted` | `text_muted` | |
| `text_placeholder` | `text_faint` | |
| `text_accent` | `accent.primary` | |
| `text_danger` | `danger` | |
| `text_warning` | `warning` | |
| `icon` | `text_muted` | |
| `icon_muted` | `text_faint` | |
| `icon_accent` | `accent.primary` | |
| `element_background` | `accent.primary` (emphasis) / `solid` (neutral) | §8 button redesign |
| `element_foreground` | `accent.on` / `on_solid` | |
| `element_hover` | `mix(accent.primary, background, ~0.12)` | precompute on `ResolvedAccentRoles` if reused |
| `element_active` / `element_selected` | `mix(accent.primary, background, ~0.2/0.28)` | |
| `element_disabled` | `accent.primary.opacity(0.3)` | |
| `secondary_background` | `accent.wash` | tinted chips/selections |
| `secondary_foreground` | contrast-safe accent text (precomputed) | e.g. `accent.primary.ensure_contrast(wash, 4.5)` at resolve time |
| `secondary_hover` / `secondary_active` / `secondary_selected` | `accent.selection` / deeper wash alphas | |
| `secondary_disabled` | `accent.wash.opacity(0.5)` | |
| `danger_background` | `danger_muted` (tint) or `danger.opacity(0.15)` | |
| `danger_foreground` | `danger` (ensure contrast on tint) | |
| `danger_hover/active/selected` | `danger.opacity(...)` steps | |
| `danger_disabled` | `danger.opacity(0.12)` | |
| `warning_*` | same pattern from `warning` / `warning_muted` | |
| `ghost_element_background` | `gpui::transparent_black()` | |
| `ghost_element_background_alt` | `raised` (or `hover`) | |
| `ghost_element_hover` | `hover` | |
| `ghost_element_active` | `active` | |
| `ghost_element_selected` | `active` | |
| `ghost_element_disabled` | `border.opacity(0.4)` | |
| `tab_background` | `shell` | |
| `tab_foreground` | `text_muted` | |
| `tab_hover_background` | `hover` | |
| `tab_active_background` | `background` | active tab merges into the surface |
| `tab_active_foreground` | `text` | |
| `scrollbar_thumb_background` | `border.opacity(0.7)` | scrollbar painting mostly via `gpui-base` |
| `scrollbar_thumb_hover_background` | `border_strong` | |
| `scrollbar_track_background/border` | transparent / `border` | |
| `drop_target_background` | `accent.selection` | |
| `cursor` | `cursor` | |
| `selection` | `accent.selection` | |
| `radius`, `radius_lg`, `shadow`, `font_family`, `font_size`, `notification`, `scrollbar_mode` | unchanged | app chrome on `Theme` |
Roles with no consumer after migration (`success`, `diff_*`, `terminal`, `syntax`) remain domain-only, available for future features.
## 8. Component redesign notes (`ui` crate, Phase 3.1)
- **`Button` variants** (`button.rs`):
- `Primary` → `accent.primary` bg, `accent.on` fg, hover = mix toward background (precomputed on `ResolvedAccentRoles` as `primary_hover`/`primary_active`).
- `Secondary` → `accent.wash` bg, contrast-safe accent fg.
- `Danger` → `danger` bg, `danger.best_on_color()` fg (precompute); tinted variant uses `danger_muted`.
- `Warning` → `warning` bg + best-on fg / `warning_muted` tint.
- `Ghost { alt }` → transparent / `raised` bg; `hover`/`active` states from domain roles.
- `Transparent` → `text_faint` fg.
- **Tabs** (`tab/`): inactive on `shell` with `text_muted`; hover `hover`; active merges to `background` with `text`; accent indicator via `accent.primary`.
- **Inputs** (`input/`): bg `input`, border `border`, focus ring `accent.primary`, placeholder `text_faint`.
- **Precomputed derived fields** that several components need (`primary_hover`, `primary_active`, `accent_text_on_wash`, `onDanger`…) belong on `ResolvedAccentRoles` — computed once at resolve time with `ensure_contrast`, not ad-hoc at call sites.
## 9. `sync_base()` mapping (new)
| `gpui-base` token | Source |
| --- | --- |
| `appearance` | `theme.appearance` |
| `tokens.background` / `foreground` | `background` / `text` |
| `tokens.surface` / `surface_foreground` | `shell` / `text` |
| `tokens.primary` / `primary_foreground` | `accent.primary` / `accent.on` |
| `tokens.secondary` / `secondary_foreground` | `raised` / `text` |
| `tokens.muted` / `muted_foreground` | `hover` / `text_muted` |
| `tokens.accent` / `accent_foreground` | `accent.wash` / `text` |
| `tokens.destructive` / `destructive_foreground` | `danger` / precomputed on-danger |
| `tokens.border` / `input` | `border` / `input` |
| `tokens.ring` | `accent.primary` |
| `tokens.selection` | `accent.selection` |
| `scrollbar.mode` | `scrollbar_mode` (unchanged) |
| `typography.sans` | `font_family` (unchanged) |
## 10. Settings migration
- Old `.settings`: `{ "theme": "themes/<name>.json" | null, "theme_mode": "Light"|"Dark", … }`.
- New: `theme_selection: ThemeSelection` (default `zeron-light`/`zeron-dark`), appearance setting, `accent_selection`, `surface_preference` (all `#[serde(default)]`).
- On load: `theme_mode` maps to `Appearance`; the old `theme` path is ignored (ids changed; optional nicety: match `catppuccin-mocha`-style family names to builtin ids). Old keys are dropped on next save.
## 11. Dependency changes
| Crate | Change |
| --- | --- |
| `crates/theme` | `+ thiserror`, `+ sha2`, `+ json5`, `+ plist` (Phase 6); `− schemars`; keep `gpui`, `gpui-base`, `anyhow`, `log`, `serde`, `serde_json` |
| `crates/assets` | remove the `themes/**/*` include |
| others | none |
## 12. Risks & open questions
- **Call-site drift:** mechanical mapping can flatten intent (e.g. everything "muted" becoming `text_faint`). Mitigated by the per-file review pass in Phase 3.4 and precomputed contrast-safe fields (§8).
- **Big-bang compile risk:** contained by the D6 scaffold — every intermediate commit builds.
- **Visual QA:** defaults change to zeron's look; accepted by scope. Spot-check a handful of variants; full polish belongs to the later branding pass.
- **Frosted surfaces:** enums land now, rendering later; the preferences UI should not advertise a visible no-op (disable/annotate the control until rendering exists).
- **`Solid` vs accent for primary buttons:** §8 defaults to accent; if the neutral look wins, switch `Primary` to `solid`/`on_solid` — one place.
- **Zeron upstream drift:** record the ported revision; keep the port faithful to ease future diffing.
- **Web target:** Phase 6 file IO must stay wasm-gated; verify `coop_web` builds in CI from Phase 2 onward.
## 13. Validation checklist (final)
- [ ] `cargo build` (desktop), `cargo build -p coop_web`, `cargo test` workspace-wide, `cargo clippy`.
- [ ] Fresh install: zeron light/dark defaults; OS appearance sync; appearance toggle.
- [ ] Theme picker lists builtin families grouped correctly; selection persists per appearance across restarts.
- [ ] Accent preset override recomputes accent-derived UI (buttons, focus ring, selection) live.
- [ ] Old `.settings` loads without error (theme resets, mode preserved); round-trip saves the new format.
- [ ] `gpui-base` projection correct: focus ring, text selection wash, scrollbar styling, overlay backdrop follow the active variant/accent.
- [ ] No references to removed roles remain (`grep` from Phase 3 acceptance).
- [ ] (If Phase 6) import/report/last-known-good flows work; web build unaffected.