From c59dcdbc0b3b2d61b50825deb24b9abac0e3ef97 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 8 Oct 2026 07:58:56 +0700 Subject: [PATCH] add plan --- docs/THEME_MIGRATION.md | 324 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 docs/THEME_MIGRATION.md diff --git a/docs/THEME_MIGRATION.md b/docs/THEME_MIGRATION.md new file mode 100644 index 00000000..d77e9288 --- /dev/null +++ b/docs/THEME_MIGRATION.md @@ -0,0 +1,324 @@ +# 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>` | +| `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` (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`, 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`, 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` + `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`, `appearance: Appearance`, `accent: AccentRoles`, `surface: SurfaceTreatment`, `colors: ResolvedColors` (plus `Deref`), 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` 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/.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.