Rebrand Zeron theme family as Coop

This commit is contained in:
2026-10-08 09:58:23 +07:00
parent 4dfc9fd668
commit f54f5f35a8
6 changed files with 29 additions and 369 deletions
-1
View File
@@ -1 +0,0 @@
../../LICENSE
-13
View File
@@ -1,13 +0,0 @@
# theme
Theme model and runtime for coop.
The domain model, built-in theme families, and registry design are ported from
[zeron/theme](https://github.com/zeronsh/zeron/tree/main/crates/theme) (MIT licensed),
ported at revision `916cb1ccb1342c2061963a5f6ecf5c977ba46f2c`. The original
gpui-integrated implementation this crate replaced was based on
[zed/theme](https://github.com/zed-industries/zed/tree/main/crates/theme)
(AGPL/GPL, see LICENSE-GPL in the zed repository).
CREDITS: [zeron/theme](https://github.com/zeronsh/zeron/tree/main/crates/theme),
[zed/theme](https://github.com/zed-industries/zed/tree/main/crates/theme)
+16 -19
View File
@@ -12,7 +12,7 @@ pub fn builtin_registry() -> &'static ThemeRegistry {
static REGISTRY: OnceLock<ThemeRegistry> = OnceLock::new(); static REGISTRY: OnceLock<ThemeRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| ThemeRegistry { REGISTRY.get_or_init(|| ThemeRegistry {
families: vec![ families: vec![
family("zeron", "Zeron", vec![zeron_light(), zeron_dark()]), family("coop", "Coop", vec![coop_light(), coop_dark()]),
family("dracula", "Dracula", vec![dracula()]), family("dracula", "Dracula", vec![dracula()]),
family("github", "GitHub", vec![github_light(), github_dark()]), family("github", "GitHub", vec![github_light(), github_dark()]),
family("ayu", "Ayu", vec![ayu_light(), ayu_dark(), ayu_mirage()]), family("ayu", "Ayu", vec![ayu_light(), ayu_dark(), ayu_mirage()]),
@@ -119,7 +119,7 @@ fn variant(seed: Seeds<'_>) -> ThemeVariant {
raised, raised,
card, card,
dialog: card.mix(raised, if dark { 0.18 } else { 0.04 }), dialog: card.mix(raised, if dark { 0.18 } else { 0.04 }),
overlay: card.mix(raised, if dark { 0.34 } else { 0.02 }), overlay: Color::BLACK.with_alpha(if dark { 0.5 } else { 0.3 }),
hover: border_tone.with_alpha(if dark { 0.11 } else { 0.06 }), hover: border_tone.with_alpha(if dark { 0.11 } else { 0.06 }),
active: accent.primary.with_alpha(if dark { 0.18 } else { 0.10 }), active: accent.primary.with_alpha(if dark { 0.18 } else { 0.10 }),
border: border_tone.with_alpha(if dark { 0.10 } else { 0.12 }), border: border_tone.with_alpha(if dark { 0.10 } else { 0.12 }),
@@ -159,9 +159,6 @@ fn variant(seed: Seeds<'_>) -> ThemeVariant {
}, },
source: seed.source, source: seed.source,
}; };
// Hash the checked-in resolved definition itself (with the hash field
// blanked), not merely its source URL. This makes provenance sensitive to
// curation edits as well as upstream revision changes.
variant.source.asset_hash.clear(); variant.source.asset_hash.clear();
let encoded = serde_json::to_vec(&variant).expect("built-in theme serializes"); let encoded = serde_json::to_vec(&variant).expect("built-in theme serializes");
variant.source.asset_hash = format!("sha256:{:x}", Sha256::digest(encoded)); variant.source.asset_hash = format!("sha256:{:x}", Sha256::digest(encoded));
@@ -236,11 +233,11 @@ const ANSI_LIGHT: [&str; 16] = [
"#71717a", "#b91c1c", "#15803d", "#92400e", "#1d4ed8", "#7e22ce", "#155e75", "#18181b", "#71717a", "#b91c1c", "#15803d", "#92400e", "#1d4ed8", "#7e22ce", "#155e75", "#18181b",
]; ];
fn zeron_dark() -> ThemeVariant { fn coop_dark() -> ThemeVariant {
variant(Seeds { variant(Seeds {
id: "zeron-dark", id: "coop-dark",
family_id: "zeron", family_id: "coop",
name: "Zeron Dark", name: "Coop Dark",
appearance: Appearance::Dark, appearance: Appearance::Dark,
treatment: SurfaceTreatment::Frosted, treatment: SurfaceTreatment::Frosted,
background: "#060606", background: "#060606",
@@ -250,7 +247,7 @@ fn zeron_dark() -> ThemeVariant {
text: "#e8e8ea", text: "#e8e8ea",
muted: "#a9a9ae", muted: "#a9a9ae",
faint: "#85858a", faint: "#85858a",
accent: "#8b7cf6", accent: "#FFE629",
danger: "#f87171", danger: "#f87171",
warning: "#facc15", warning: "#facc15",
success: "#34d399", success: "#34d399",
@@ -261,20 +258,20 @@ fn zeron_dark() -> ThemeVariant {
"#a1a1aa", "#f472b6", "#22d3ee", "#f87171", "#a1a1aa", "#f472b6", "#22d3ee", "#f87171",
], ],
source: source( source: source(
"zeron-dark", "coop-dark",
"native", "native",
"https://github.com/zeronsh/comet", "https://git.reya.info/reya/coop",
"d138049", "d138049",
"MIT", "MIT",
), ),
}) })
} }
fn zeron_light() -> ThemeVariant { fn coop_light() -> ThemeVariant {
variant(Seeds { variant(Seeds {
id: "zeron-light", id: "coop-light",
family_id: "zeron", family_id: "coop",
name: "Zeron Light", name: "Coop Light",
appearance: Appearance::Light, appearance: Appearance::Light,
treatment: SurfaceTreatment::Frosted, treatment: SurfaceTreatment::Frosted,
background: "#ffffff", background: "#ffffff",
@@ -284,7 +281,7 @@ fn zeron_light() -> ThemeVariant {
text: "#303035", text: "#303035",
muted: "#62626a", muted: "#62626a",
faint: "#797981", faint: "#797981",
accent: "#5b43e8", accent: "#a19200",
danger: "#dc2626", danger: "#dc2626",
warning: "#a16207", warning: "#a16207",
success: "#15803d", success: "#15803d",
@@ -295,9 +292,9 @@ fn zeron_light() -> ThemeVariant {
"#52525b", "#be185d", "#0e7490", "#b91c1c", "#52525b", "#be185d", "#0e7490", "#b91c1c",
], ],
source: source( source: source(
"zeron-light", "coop-light",
"native", "native",
"https://github.com/zeronsh/comet", "https://git.reya.info/reya/coop",
"d138049", "d138049",
"MIT", "MIT",
), ),
+12 -11
View File
@@ -240,7 +240,8 @@ pub struct ColorParseError;
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum AccentPreset { pub enum AccentPreset {
#[default] #[default]
Zeron, #[serde(alias = "coop")]
Coop,
Orange, Orange,
Amber, Amber,
Green, Green,
@@ -251,7 +252,7 @@ pub enum AccentPreset {
impl AccentPreset { impl AccentPreset {
pub const ALL: [Self; 7] = [ pub const ALL: [Self; 7] = [
Self::Zeron, Self::Coop,
Self::Orange, Self::Orange,
Self::Amber, Self::Amber,
Self::Green, Self::Green,
@@ -262,7 +263,7 @@ impl AccentPreset {
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
Self::Zeron => "Zeron", Self::Coop => "Coop",
Self::Orange => "Orange", Self::Orange => "Orange",
Self::Amber => "Amber", Self::Amber => "Amber",
Self::Green => "Green", Self::Green => "Green",
@@ -274,7 +275,7 @@ impl AccentPreset {
pub fn color(self, appearance: Appearance) -> Color { pub fn color(self, appearance: Appearance) -> Color {
let (dark, light) = match self { let (dark, light) = match self {
Self::Zeron => ("#8b7cf6", "#5b43e8"), Self::Coop => ("#FFE629", "#a19200"),
Self::Orange => ("#fb923c", "#c2410c"), Self::Orange => ("#fb923c", "#c2410c"),
Self::Amber => ("#fbbf24", "#a16207"), Self::Amber => ("#fbbf24", "#a16207"),
Self::Green => ("#4ade80", "#15803d"), Self::Green => ("#4ade80", "#15803d"),
@@ -357,8 +358,8 @@ pub struct ThemeSelection {
impl Default for ThemeSelection { impl Default for ThemeSelection {
fn default() -> Self { fn default() -> Self {
Self { Self {
light: "zeron-light".into(), light: "coop-light".into(),
dark: "zeron-dark".into(), dark: "coop-dark".into(),
} }
} }
} }
@@ -489,12 +490,12 @@ impl ThemeRegistry {
self.variant(selection.variant_id(appearance)) self.variant(selection.variant_id(appearance))
.or_else(|| { .or_else(|| {
self.variant(if appearance.is_dark() { self.variant(if appearance.is_dark() {
"zeron-dark" "coop-dark"
} else { } else {
"zeron-light" "coop-light"
}) })
}) })
.expect("the built-in registry always contains both Zeron variants") .expect("the built-in registry always contains both Coop variants")
} }
pub fn validate(&self) -> Vec<ValidationIssue> { pub fn validate(&self) -> Vec<ValidationIssue> {
@@ -711,8 +712,8 @@ mod tests {
.sum::<usize>(), .sum::<usize>(),
30 30
); );
assert!(registry.variant("zeron-light").is_some()); assert!(registry.variant("coop-light").is_some());
assert!(registry.variant("zeron-dark").is_some()); assert!(registry.variant("coop-dark").is_some());
let errors: Vec<_> = registry let errors: Vec<_> = registry
.validate() .validate()
.into_iter() .into_iter()
+1 -1
View File
@@ -341,7 +341,7 @@ impl RenderOnce for Dialog {
.id(layer_ix) .id(layer_ix)
.bg(cx.theme().background) .bg(cx.theme().background)
.border_1() .border_1()
.border_color(cx.theme().border.alpha(0.4)) .border_color(cx.theme().border)
.rounded(radius) .rounded(radius)
.when(cx.theme().shadow, |this| this.shadow_xl()) .when(cx.theme().shadow, |this| this.shadow_xl())
.min_h_24() .min_h_24()
-324
View File
@@ -1,324 +0,0 @@
# 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.