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

27 KiB
Raw Blame History

Theme migration plan: full migration to the Zeron theme model

Full migration of crates/theme (currently derived from zed-industries/zed crates/theme) to the architecture of zeronsh/zeron 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 ValidationIssues) 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.