Skip to main content

lattice_theme/
registry.rs

1//! The theme-element registry + resolution + the builtin element
2//! set.
3//!
4//! Registration assigns each element a process-stable
5//! [`ElementId`]. Resolution turns every element's reference-form
6//! [`StyleSpec`] into a concrete [`Style`] — walking the `inherit`
7//! chain and looking colors up in the active [`Palette`] — **once**,
8//! into a flat [`ResolvedTheme`] table the renderers read at O(1)
9//! per glyph (`resolved.get(id)`). This is the paramount-#1
10//! contract: the registry / palette / inheritance machinery lives at
11//! theme-build time; the read is an array index.
12//!
13//! T.2 lands the registry struct, resolution, and the builtin
14//! elements whose defaults reproduce today's exact colors (pinned by
15//! `resolved_builtins_match_legacy_literals`). T.3 registers it as a
16//! ServiceRegistry service at boot and exposes the handle to
17//! consumers.
18//!
19//! Design: `docs/dev/architecture/theme-system.md` §3.4–§3.5, §7.
20
21use std::collections::HashMap;
22use std::sync::{Arc, RwLock};
23
24use arc_swap::ArcSwap;
25
26use crate::element::{ColorRef, ElementId, ElementName, ElementOwner, StyleSpec, ThemeElement};
27use crate::palette::{Palette, default_palette};
28use crate::themes::{NamedTheme, builtin_themes};
29use crate::{Color, FontScale, Style, Weight};
30
31/// Maximum `inherit` chain depth before resolution bails (cycle
32/// guard). Builtins are flat or one-deep; this is a safety net for
33/// user/plugin specs.
34const MAX_INHERIT_DEPTH: u8 = 16;
35
36/// The flat, resolved read table for the active theme. `styles[id]`
37/// is the fully-resolved [`Style`] for element `id`. Rebuilt on
38/// theme/palette change; published via `ArcSwap` so the renderer's
39/// read is lock-free.
40#[derive(Debug, Clone, PartialEq)]
41pub struct ResolvedTheme {
42    styles: Box<[Style]>,
43    version: u64,
44}
45
46impl ResolvedTheme {
47    /// The resolved style for `id`. Out-of-range ids resolve to the
48    /// empty style (an unregistered element is styleless, never a
49    /// panic).
50    pub fn get(&self, id: ElementId) -> Style {
51        self.styles
52            .get(id.0 as usize)
53            .copied()
54            .unwrap_or_else(Style::empty)
55    }
56
57    /// Monotonic version, bumped each rebuild. Folds into
58    /// `lattice_cells::MatrixVersion::theme` so a palette change
59    /// rebuilds the cell matrix (design §7).
60    pub fn version(&self) -> u64 {
61        self.version
62    }
63
64    pub fn len(&self) -> usize {
65        self.styles.len()
66    }
67
68    pub fn is_empty(&self) -> bool {
69        self.styles.is_empty()
70    }
71}
72
73impl Default for ResolvedTheme {
74    /// An empty table (version 0) — every `get` returns
75    /// `Style::empty()`. The placeholder a default `RenderState`
76    /// carries before the first real publish snapshots the live
77    /// table; never the live render target.
78    fn default() -> Self {
79        ResolvedTheme {
80            styles: Box::new([]),
81            version: 0,
82        }
83    }
84}
85
86/// Introspection snapshot for a single registered element, returned
87/// by [`ThemeRegistry::describe`] and rendered by `:describe-element`
88/// (T.9.d). Bundles the element's identity + owner + authoring
89/// (reference-form) [`StyleSpec`] default + doc string + its concrete
90/// resolved [`Style`] under the active theme — everything the help
91/// view needs in one read, no second registry round-trip.
92#[derive(Debug, Clone, PartialEq)]
93pub struct ElementInfo {
94    pub name: ElementName,
95    pub owner: ElementOwner,
96    /// The owner-supplied authoring spec (palette-key references +
97    /// inherit parent), pre-resolution. `:describe-element` renders
98    /// this as the `Spec:` line so the user sees what the element
99    /// *references*, not only the baked color.
100    pub default: StyleSpec,
101    pub doc: &'static str,
102    /// The concrete style the element resolves to under the active
103    /// palette + override set — what the renderer actually paints.
104    pub resolved: Style,
105}
106
107/// Registration + resolution surface. Lives as a ServiceRegistry
108/// service (T.3); modes reach it through the handle, never through
109/// `&mut Editor`.
110pub trait ThemeRegistry: Send + Sync {
111    /// Register an element with its owner-supplied default. Idempotent
112    /// by name — re-registering an existing name returns the existing
113    /// id and leaves its default unchanged.
114    fn register(
115        &self,
116        name: ElementName,
117        owner: ElementOwner,
118        default: StyleSpec,
119        doc: &'static str,
120    ) -> ElementId;
121
122    /// The interned id for a name, if registered.
123    fn id(&self, name: &ElementName) -> Option<ElementId>;
124
125    /// The current resolved read table (rebuilt lazily if a
126    /// registration / palette change left it dirty).
127    fn resolved(&self) -> Arc<ResolvedTheme>;
128
129    /// T.9: set (or replace) the theme-global override for an element
130    /// (`:set ui.*`, user TOML, a theme's override list). Overlays the
131    /// override's set fields on the element's resolved default. Marks
132    /// the table dirty (re-resolved on next `resolved()`).
133    fn set_override(&self, name: ElementName, spec: StyleSpec);
134
135    /// T.9: swap the active theme — replace the palette AND the full
136    /// override set atomically (`:colorscheme`). Prior overrides are
137    /// cleared. Marks the table dirty.
138    fn set_theme(&self, palette: Palette, overrides: Vec<(ElementName, StyleSpec)>);
139
140    /// T.9.d: element metadata for introspection (`:describe-element`
141    /// / `:describe-face`). Returns the element's identity + owner +
142    /// authoring default + doc + concrete resolved style, or `None` if
143    /// `name` is not a registered element. Reads the resolved table
144    /// (rebuilding it lazily if dirty) so the `resolved` field reflects
145    /// the active theme — a theme-build-time read, never on the hot
146    /// path.
147    fn describe(&self, name: &ElementName) -> Option<ElementInfo>;
148
149    /// T.9.d follow-up: the names of every registered theme element, sorted.
150    /// Drives `:describe-element` / `:describe-face` `<Tab>` completion (the
151    /// `gen:elements` host generator). Sorted so popup ordering is stable
152    /// across runs; a theme-build-time read, never on the hot path.
153    fn element_names(&self) -> Vec<String>;
154
155    /// TC.4: withdraw an element by name — the reverse of [`Self::register`],
156    /// used when a plugin unloads. Returns whether a name was removed
157    /// (idempotent: a second unload removes nothing).
158    ///
159    /// **The slot is tombstoned, not deleted.** [`ElementId`] is an index into
160    /// the element vector, so removing an entry would silently re-point every
161    /// later id at the wrong element — a far worse failure than a retained
162    /// slot. Withdrawing therefore drops the NAME binding only: `id`,
163    /// `describe` and `element_names` all resolve through `by_name`, so the
164    /// element disappears from every observable surface (lookup, `:customize`,
165    /// `:describe-element`) while ids already handed out stay valid and keep
166    /// resolving to their last style.
167    ///
168    /// Any theme override the user set for the name is left in place: a plugin
169    /// reload should not silently discard the user's customisation, and a
170    /// dangling override for an element that never comes back resolves to
171    /// nothing.
172    fn unregister_element(&self, name: &ElementName) -> bool;
173
174    /// T.11.1: register (or replace, by name) a named theme in the
175    /// catalog. Idempotent by name — re-registering a name replaces its
176    /// palette + overrides. This is the seam `init.rs` (and, later, a
177    /// WASM plugin via WIT) uses to contribute a palette; the builtin
178    /// themes are seeded here at boot. Does NOT change the active theme —
179    /// only `apply_theme` does that.
180    fn register_theme(&self, theme: NamedTheme);
181
182    /// T.11.1: the names of every registered theme, in registration
183    /// order (builtins first, then user/plugin additions). Drives
184    /// `:colorscheme` completion + the T.12 picker.
185    fn theme_names(&self) -> Vec<String>;
186
187    /// T.11.1: swap the active theme to the registered theme `name`
188    /// (`:colorscheme <name>`). Returns `false` (active theme untouched)
189    /// if `name` is not registered — the caller echoes an error, never a
190    /// panic. On a hit, equivalent to `set_theme` with the registered
191    /// theme's palette + overrides (marks the table dirty).
192    fn apply_theme(&self, name: &str) -> bool;
193
194    /// T.12a: snapshot the active palette + the active theme-global
195    /// override set as a `(Palette, Vec<(ElementName, StyleSpec)>)`
196    /// pair. The colorscheme picker captures this on the first live
197    /// preview so `<Esc>` can restore the theme active when the picker
198    /// opened via [`Self::set_theme`]. Cheap clone of the inner state
199    /// under a read lock; no resolution.
200    fn active_theme(&self) -> (Palette, Vec<(ElementName, StyleSpec)>);
201}
202
203/// The canonical handle type. Register and look up under THIS type in
204/// the ServiceRegistry ([[feedback_servicesregistry_arc_typeid]]).
205pub type ThemeRegistryHandle = Arc<dyn ThemeRegistry>;
206
207struct RegistryInner {
208    /// Indexed by `ElementId.0`.
209    elements: Vec<ThemeElement>,
210    by_name: HashMap<ElementName, ElementId>,
211    palette: Palette,
212    /// T.9: theme-global element overrides (the active theme's overrides +
213    /// `:set ui.*` + user TOML). Keyed by name so an override may
214    /// target an element registered later (a mode element). Resolution
215    /// overlays the override's set fields on top of the element's
216    /// resolved default (design §5.1, override scope 1).
217    overrides: HashMap<ElementName, StyleSpec>,
218    /// T.11.1: the named-theme catalog — `(Palette, overrides)` pairs by
219    /// name, in registration order. `:colorscheme` / the picker resolve
220    /// against this; `apply_theme` swaps the active palette+overrides to
221    /// a catalog entry. Seeded with `builtin_themes()` at boot;
222    /// `init.rs` / plugins append via `register_theme`.
223    themes: Vec<NamedTheme>,
224    version: u64,
225    dirty: bool,
226}
227
228/// In-memory [`ThemeRegistry`]. Holds the element table + active
229/// palette behind an `RwLock`, and the resolved read table behind an
230/// `ArcSwap` for lock-free reads.
231pub struct InMemoryThemeRegistry {
232    inner: RwLock<RegistryInner>,
233    resolved: ArcSwap<ResolvedTheme>,
234}
235
236impl InMemoryThemeRegistry {
237    /// Empty registry with the given palette. Register elements, then
238    /// `resolved()` builds the table on first read.
239    pub fn new(palette: Palette) -> Self {
240        InMemoryThemeRegistry {
241            inner: RwLock::new(RegistryInner {
242                elements: Vec::new(),
243                by_name: HashMap::new(),
244                palette,
245                overrides: HashMap::new(),
246                themes: Vec::new(),
247                version: 0,
248                dirty: true,
249            }),
250            resolved: ArcSwap::from_pointee(ResolvedTheme {
251                styles: Box::new([]),
252                version: 0,
253            }),
254        }
255    }
256
257    /// Registry seeded with the default palette + all builtin core
258    /// elements, resolved and ready. The T.3 boot path + tests use
259    /// this.
260    pub fn with_defaults() -> Self {
261        let reg = Self::new(default_palette());
262        register_builtins(&reg);
263        // T.11.1: seed the named-theme catalog with the builtins so
264        // `:colorscheme` / the picker can resolve them; `init.rs` /
265        // plugins append more via `register_theme`.
266        for theme in builtin_themes() {
267            reg.register_theme(theme);
268        }
269        reg.rebuild();
270        reg
271    }
272
273    /// Replace the active palette (a `:colorscheme` / `:set ui.*`
274    /// path lands in T.9). Marks the table dirty.
275    pub fn set_palette(&self, palette: Palette) {
276        let mut inner = self.inner.write().expect("theme registry lock poisoned");
277        inner.palette = palette;
278        inner.dirty = true;
279    }
280
281    /// Re-resolve every element into a fresh [`ResolvedTheme`] and
282    /// publish it. Called on dirty reads; O(elements), theme-build
283    /// time only (never on the hot path).
284    pub fn rebuild(&self) {
285        let mut guard = self.inner.write().expect("theme registry lock poisoned");
286        let n = guard.elements.len();
287        let mut styles = Vec::with_capacity(n);
288        {
289            let inner: &RegistryInner = &guard;
290            for i in 0..n {
291                styles.push(resolve_element(inner, ElementId(i as u32), 0));
292            }
293        }
294        guard.version = guard.version.wrapping_add(1);
295        let version = guard.version;
296        guard.dirty = false;
297        drop(guard);
298        self.resolved.store(Arc::new(ResolvedTheme {
299            styles: styles.into_boxed_slice(),
300            version,
301        }));
302    }
303}
304
305impl ThemeRegistry for InMemoryThemeRegistry {
306    fn register(
307        &self,
308        name: ElementName,
309        owner: ElementOwner,
310        default: StyleSpec,
311        doc: &'static str,
312    ) -> ElementId {
313        let mut inner = self.inner.write().expect("theme registry lock poisoned");
314        if let Some(id) = inner.by_name.get(&name) {
315            return *id;
316        }
317        let id = ElementId(inner.elements.len() as u32);
318        inner.by_name.insert(name.clone(), id);
319        inner.elements.push(ThemeElement {
320            id,
321            name,
322            owner,
323            default,
324            doc,
325        });
326        inner.dirty = true;
327        id
328    }
329
330    fn id(&self, name: &ElementName) -> Option<ElementId> {
331        self.inner
332            .read()
333            .expect("theme registry lock poisoned")
334            .by_name
335            .get(name)
336            .copied()
337    }
338
339    fn unregister_element(&self, name: &ElementName) -> bool {
340        let mut inner = self.inner.write().expect("theme registry lock poisoned");
341        let removed = inner.by_name.remove(name).is_some();
342        if removed {
343            // The resolved table is keyed by id and rebuilt from `elements`,
344            // which still holds the tombstoned slot — but marking dirty keeps
345            // the rebuild honest if resolution ever starts consulting names.
346            inner.dirty = true;
347        }
348        removed
349    }
350
351    fn resolved(&self) -> Arc<ResolvedTheme> {
352        let dirty = self
353            .inner
354            .read()
355            .expect("theme registry lock poisoned")
356            .dirty;
357        if dirty {
358            self.rebuild();
359        }
360        self.resolved.load_full()
361    }
362
363    fn set_override(&self, name: ElementName, spec: StyleSpec) {
364        let mut inner = self.inner.write().expect("theme registry lock poisoned");
365        inner.overrides.insert(name, spec);
366        inner.dirty = true;
367    }
368
369    fn set_theme(&self, palette: Palette, overrides: Vec<(ElementName, StyleSpec)>) {
370        let mut inner = self.inner.write().expect("theme registry lock poisoned");
371        inner.palette = palette;
372        inner.overrides = overrides.into_iter().collect();
373        inner.dirty = true;
374    }
375
376    fn register_theme(&self, theme: NamedTheme) {
377        let mut inner = self.inner.write().expect("theme registry lock poisoned");
378        // Idempotent by name: replace an existing entry in place (keeps
379        // registration order stable), else append.
380        if let Some(slot) = inner.themes.iter_mut().find(|t| t.name == theme.name) {
381            *slot = theme;
382        } else {
383            inner.themes.push(theme);
384        }
385    }
386
387    fn theme_names(&self) -> Vec<String> {
388        let inner = self.inner.read().expect("theme registry lock poisoned");
389        inner.themes.iter().map(|t| t.name.to_string()).collect()
390    }
391
392    fn element_names(&self) -> Vec<String> {
393        let inner = self.inner.read().expect("theme registry lock poisoned");
394        let mut names: Vec<String> = inner
395            .by_name
396            .keys()
397            .map(|n| n.as_str().to_string())
398            .collect();
399        names.sort();
400        names
401    }
402
403    fn apply_theme(&self, name: &str) -> bool {
404        // Clone the catalog entry's palette + overrides under a READ
405        // lock, release it, THEN swap via `set_theme` (which takes a
406        // write lock) — never hold both, so no re-entrant deadlock.
407        let found = {
408            let inner = self.inner.read().expect("theme registry lock poisoned");
409            inner
410                .themes
411                .iter()
412                .find(|t| t.name == name)
413                .map(|t| (t.palette.clone(), t.overrides.clone()))
414        };
415        match found {
416            Some((palette, overrides)) => {
417                self.set_theme(palette, overrides);
418                true
419            }
420            None => false,
421        }
422    }
423
424    fn active_theme(&self) -> (Palette, Vec<(ElementName, StyleSpec)>) {
425        let inner = self.inner.read().expect("theme registry lock poisoned");
426        let palette = inner.palette.clone();
427        let overrides = inner
428            .overrides
429            .iter()
430            .map(|(k, v)| (k.clone(), v.clone()))
431            .collect();
432        (palette, overrides)
433    }
434
435    fn describe(&self, name: &ElementName) -> Option<ElementInfo> {
436        // The resolved style must reflect the active theme, so resolve
437        // the table first (rebuilds lazily if dirty), THEN read the
438        // element metadata + id under the lock. Order matters: a
439        // pre-`resolved()` read could miss an override that just
440        // dirtied the table.
441        let resolved = self.resolved();
442        let inner = self.inner.read().expect("theme registry lock poisoned");
443        let id = *inner.by_name.get(name)?;
444        let elem = inner.elements.get(id.0 as usize)?;
445        Some(ElementInfo {
446            name: elem.name.clone(),
447            owner: elem.owner.clone(),
448            default: elem.default.clone(),
449            doc: elem.doc,
450            resolved: resolved.get(id),
451        })
452    }
453}
454
455// ---- resolution ----
456
457fn resolve_element(inner: &RegistryInner, id: ElementId, depth: u8) -> Style {
458    match inner.elements.get(id.0 as usize) {
459        Some(elem) => {
460            let mut style = resolve_spec(inner, &elem.default, depth);
461            // T.9: overlay the theme-global override (if any) on top of
462            // the resolved default. The override's set fields win; an
463            // override never re-inherits (it adjusts, not redefines).
464            if let Some(ovr) = inner.overrides.get(&elem.name) {
465                apply_overlay(inner, &mut style, ovr);
466            }
467            style
468        }
469        None => Style::empty(),
470    }
471}
472
473fn resolve_named(inner: &RegistryInner, name: &ElementName, depth: u8) -> Style {
474    // Exact match first, then dotted-parent fallback
475    // (`markdown.heading.1` → `markdown.heading` → `markdown`).
476    if let Some(id) = inner.by_name.get(name) {
477        return resolve_element(inner, *id, depth);
478    }
479    match name.parent() {
480        Some(parent) => resolve_named(inner, &parent, depth),
481        None => Style::empty(),
482    }
483}
484
485fn resolve_spec(inner: &RegistryInner, spec: &StyleSpec, depth: u8) -> Style {
486    let mut style = match &spec.inherit {
487        Some(parent) if depth < MAX_INHERIT_DEPTH => resolve_named(inner, parent, depth + 1),
488        Some(parent) => {
489            tracing::warn!(
490                "theme: inherit chain too deep at `{}` — breaking cycle",
491                parent
492            );
493            Style::empty()
494        }
495        None => Style::empty(),
496    };
497    apply_overlay(inner, &mut style, spec);
498    style
499}
500
501/// Apply a spec's *set* fields (fg / bg / each tri-state modifier /
502/// rich attrs) on top of an existing [`Style`], leaving unset fields
503/// untouched. Used both for the inherit base (in [`resolve_spec`]) and
504/// for theme-global overrides (in [`resolve_element`]). `inherit` is
505/// NOT re-applied here — the caller handles the base.
506fn apply_overlay(inner: &RegistryInner, style: &mut Style, spec: &StyleSpec) {
507    if let Some(cref) = &spec.fg
508        && let Some(c) = resolve_color(inner, cref)
509    {
510        style.fg = Some(c);
511    }
512    if let Some(cref) = &spec.bg
513        && let Some(c) = resolve_color(inner, cref)
514    {
515        style.bg = Some(c);
516    }
517
518    let m = &spec.modifiers;
519    if let Some(b) = m.bold {
520        style.modifiers.bold = b;
521    }
522    if let Some(b) = m.italic {
523        style.modifiers.italic = b;
524    }
525    if let Some(b) = m.underline {
526        style.modifiers.underline = b;
527    }
528    if let Some(b) = m.dim {
529        style.modifiers.dim = b;
530    }
531    if let Some(b) = m.reverse {
532        style.modifiers.reverse = b;
533    }
534
535    if let Some(ratio) = spec.scale {
536        style.scale = Some(FontScale::from_ratio(ratio));
537    }
538    if let Some(family) = spec.family {
539        style.family = Some(family);
540    }
541    if let Some(weight) = spec.weight {
542        style.weight = Some(weight);
543    }
544}
545
546fn resolve_color(inner: &RegistryInner, cref: &ColorRef) -> Option<Color> {
547    match cref {
548        ColorRef::Palette(key) => match inner.palette.get(key) {
549            Some(c) => Some(c),
550            None => {
551                tracing::warn!("theme: unknown palette key `{}`", key.as_str());
552                None
553            }
554        },
555        ColorRef::Literal(c) => Some(*c),
556        ColorRef::Default => Some(Color::Default),
557    }
558}
559
560// ---- builtin elements ----
561
562/// Register every core element with its palette-referencing default.
563/// Idempotent (safe to call once at boot). The defaults reproduce
564/// today's `Theme::default()` + `syntax_style()` values exactly — the
565/// resolved table is byte-identical to the legacy literals (the
566/// parity pin), while every color goes through the palette.
567pub fn register_builtins(reg: &dyn ThemeRegistry) {
568    let core = ElementOwner::Core;
569    let reg_one = |name: &'static str, spec: StyleSpec, doc: &'static str| {
570        reg.register(ElementName::from_static(name), core.clone(), spec, doc);
571    };
572    let spec = StyleSpec::new;
573
574    // ---- Pane chrome ----
575    reg_one(
576        "pane.status.active",
577        spec().reverse().bold(),
578        "Active pane's status line.",
579    );
580    reg_one(
581        "pane.status.inactive",
582        spec().fg("overlay").dim(),
583        "Inactive pane's status line.",
584    );
585    reg_one(
586        "pane.inactive_overlay",
587        spec().dim(),
588        "Dim overlay on inactive panes' content.",
589    );
590    reg_one(
591        "pane.separator",
592        spec().fg("overlay"),
593        "Split separator between panes.",
594    );
595
596    // ---- Modeline (ML.1b): per-role segments on a palette-driven bar.
597    // Active pane = a raised `surface1` bar with per-role foregrounds
598    // (lualine/helix convention); inactive = a receded `surface0` bar,
599    // uniformly muted (`overlay`). Palette-keyed so all builtin themes
600    // resolve appropriately without per-theme edits. The `modeline.*`
601    // role keys mirror `lattice_host::modeline::ROLE_*`.
602    reg_one(
603        "modeline.active",
604        spec().bg("surface1"),
605        "Active pane's modeline bar (base background; per-role fg overlays it).",
606    );
607    reg_one(
608        "modeline.inactive",
609        spec().bg("surface0").fg("overlay"),
610        "Inactive pane's modeline bar (uniform muted; no per-role colour).",
611    );
612    reg_one(
613        "modeline.mode",
614        spec().fg("blue").bold(),
615        "Lean modal-state tag (`NOR`/`INS`/…) in the active modeline.",
616    );
617    reg_one(
618        "modeline.path",
619        spec().fg("text"),
620        "Buffer path segment in the active modeline.",
621    );
622    reg_one(
623        "modeline.position",
624        spec().fg("subtext"),
625        "Cursor line:column segment in the active modeline.",
626    );
627    reg_one(
628        "modeline.lang",
629        spec().fg("teal"),
630        "Language label segment in the active modeline.",
631    );
632    reg_one(
633        "modeline.mode_item",
634        spec().fg("subtext"),
635        "Mode-contributed items (LSP / diff) in the active modeline.",
636    );
637
638    // ---- Editor canvas (T.11.0b: palette-driven so a colorscheme /
639    // palette swap recolors the whole canvas — the light-theme seam) ----
640    reg_one(
641        "editor.background",
642        spec().bg("base"),
643        "Editor canvas background.",
644    );
645    reg_one(
646        "editor.foreground",
647        spec().fg("text"),
648        "Editor default foreground text.",
649    );
650    reg_one(
651        "editor.cursor",
652        spec().bg("text").fg("base"),
653        "Block-cursor cell (inverted: bg=text fg=base).",
654    );
655    reg_one(
656        "ui.popup.background",
657        spec().bg("mantle"),
658        "Popup / overlay surface background.",
659    );
660    reg_one(
661        "ui.popup.title",
662        spec().fg("blue").bold(),
663        "Popup header title (bold accent — describe/help/hover popups).",
664    );
665    reg_one(
666        "ui.popup.hint",
667        spec().fg("overlay"),
668        "Popup header hint (dim — e.g. 'Esc to dismiss').",
669    );
670
671    // ---- File tree ----
672    reg_one(
673        "file_tree.dir",
674        spec().fg("blue").bold(),
675        "Directory entries in the file tree.",
676    );
677    reg_one(
678        "file_tree.hidden",
679        spec().fg("overlay").dim(),
680        "Hidden entries in the file tree.",
681    );
682    reg_one("file_tree.file", spec(), "Regular file entries.");
683
684    // ---- Terminal ANSI palette (the 16 colours programs draw in) ----
685    // Each ANSI slot maps to a palette accent so the embedded terminal
686    // recolours with the active colorscheme (catppuccin-style mapping:
687    // magenta→pink, cyan→teal, black/white→surface/subtext). Replaces the
688    // GPUI terminal's old hardcoded dim-VGA xterm palette. Bright variants
689    // (8-15) reuse the same accents; only black/white brighten.
690    reg_one("terminal.ansi.0", spec().fg("surface1"), "ANSI 0 — black.");
691    reg_one("terminal.ansi.1", spec().fg("red"), "ANSI 1 — red.");
692    reg_one("terminal.ansi.2", spec().fg("green"), "ANSI 2 — green.");
693    reg_one("terminal.ansi.3", spec().fg("yellow"), "ANSI 3 — yellow.");
694    reg_one("terminal.ansi.4", spec().fg("blue"), "ANSI 4 — blue.");
695    reg_one("terminal.ansi.5", spec().fg("pink"), "ANSI 5 — magenta.");
696    reg_one("terminal.ansi.6", spec().fg("teal"), "ANSI 6 — cyan.");
697    reg_one("terminal.ansi.7", spec().fg("subtext"), "ANSI 7 — white.");
698    reg_one(
699        "terminal.ansi.8",
700        spec().fg("surface2"),
701        "ANSI 8 — bright black.",
702    );
703    reg_one("terminal.ansi.9", spec().fg("red"), "ANSI 9 — bright red.");
704    reg_one(
705        "terminal.ansi.10",
706        spec().fg("green"),
707        "ANSI 10 — bright green.",
708    );
709    reg_one(
710        "terminal.ansi.11",
711        spec().fg("yellow"),
712        "ANSI 11 — bright yellow.",
713    );
714    reg_one(
715        "terminal.ansi.12",
716        spec().fg("blue"),
717        "ANSI 12 — bright blue.",
718    );
719    reg_one(
720        "terminal.ansi.13",
721        spec().fg("pink"),
722        "ANSI 13 — bright magenta.",
723    );
724    reg_one(
725        "terminal.ansi.14",
726        spec().fg("teal"),
727        "ANSI 14 — bright cyan.",
728    );
729    reg_one(
730        "terminal.ansi.15",
731        spec().fg("text"),
732        "ANSI 15 — bright white.",
733    );
734
735    // ---- Diagnostics ----
736    reg_one(
737        "diagnostic.error",
738        spec().fg("red").bold(),
739        "Error-severity diagnostic sign + text.",
740    );
741    reg_one(
742        "diagnostic.warning",
743        spec().fg("yellow").bold(),
744        "Warning-severity diagnostic.",
745    );
746    reg_one(
747        "diagnostic.info",
748        spec().fg("blue"),
749        "Info-severity diagnostic.",
750    );
751    reg_one(
752        "diagnostic.hint",
753        spec().fg("overlay").dim(),
754        "Hint-severity diagnostic.",
755    );
756
757    // ---- Whitespace + current line ----
758    reg_one(
759        "whitespace",
760        spec().fg("overlay").dim(),
761        "Rendered whitespace markers.",
762    );
763    reg_one(
764        "whitespace.trailing",
765        spec().fg("red"),
766        "Trailing whitespace markers.",
767    );
768    reg_one(
769        "editor.cursor_line",
770        spec().bg("cursor_line.bg"),
771        "Current-line background tint.",
772    );
773
774    // ---- Indentation guides ----
775    //
776    // Dim by default: a guide is a peripheral cue, and one bright
777    // enough to read directly is one that competes with the code it
778    // is measuring. The active guide is the same colour undimmed,
779    // so the contrast between them is what carries the signal.
780    reg_one(
781        "indent.guide",
782        spec().fg("overlay").dim(),
783        "Indentation guide rule.",
784    );
785    reg_one(
786        "indent.guide.active",
787        spec().fg("overlay"),
788        "Indentation guide for the block enclosing the cursor.",
789    );
790
791    // ---- *messages* buffer levels ----
792    reg_one(
793        "messages.timestamp",
794        spec().fg("overlay").dim(),
795        "Timestamp column in *messages*.",
796    );
797    reg_one("messages.trace", spec().dim(), "TRACE-level message.");
798    reg_one("messages.debug", spec().fg("cyan"), "DEBUG-level message.");
799    reg_one("messages.info", spec().fg("green"), "INFO-level message.");
800    reg_one(
801        "messages.warn",
802        spec().fg("yellow").bold(),
803        "WARN-level message.",
804    );
805    reg_one(
806        "messages.error",
807        spec().fg("red").bold(),
808        "ERROR-level message.",
809    );
810
811    // ---- Diff ----
812    reg_one(
813        "diff.add.sign",
814        spec().fg("green").bold(),
815        "`+` gutter sign (added line).",
816    );
817    reg_one(
818        "diff.change.sign",
819        spec().fg("yellow").bold(),
820        "`~` gutter sign (changed line).",
821    );
822    reg_one(
823        "diff.remove.sign",
824        spec().fg("red").bold(),
825        "`-` gutter sign (removed line).",
826    );
827    reg_one(
828        "diff.conflict.sign",
829        spec().fg("purple").bold(),
830        "`?` gutter sign (three-way conflict).",
831    );
832    reg_one(
833        "diff.add.line",
834        spec().bg("diff.add.bg"),
835        "Added-line background tint.",
836    );
837    reg_one(
838        "diff.change.line",
839        spec().bg("diff.change.bg"),
840        "Changed-line background tint.",
841    );
842    reg_one(
843        "diff.remove.line",
844        spec().bg("diff.deletion.bg"),
845        "Removed-line background tint (baseline/left pane of a side-by-side diff). \
846         Reuses the deletion-block palette role for a consistent red.",
847    );
848    // DR.2 (2026-08-12): intra-line refinement — the sub-range of a
849    // changed line that actually differs. A STRONGER version of the
850    // row tint it sits inside, so the eye lands on the changed words
851    // without losing the row's add/remove identity. Foreground is
852    // untouched, which is what keeps the syntax highlighting DS.1–DS.5
853    // added visible underneath.
854    reg_one(
855        "diff.add.refine.bg",
856        spec().bg("diff.add.refine.bg"),
857        "Background for the added bytes within a refined diff line (intra-line refinement).",
858    );
859    reg_one(
860        "diff.remove.refine.bg",
861        spec().bg("diff.remove.refine.bg"),
862        "Background for the removed bytes within a refined diff line (intra-line refinement).",
863    );
864    reg_one(
865        "sticky.context.background",
866        spec().bg("surface0"),
867        "Sticky context strip: the backdrop behind the pinned scope headers. \
868         Host chrome — the strip's rows carry the source lines' own syntax \
869         colour, so this styles only what sits behind them.",
870    );
871    reg_one(
872        "sticky.context.line_number",
873        spec().fg("overlay"),
874        "Sticky context strip: the source line numbers in its gutter. Dimmer \
875         than the document's by default — the strip is orientation, not a \
876         place the cursor can be.",
877    );
878    reg_one(
879        "sticky.context.separator",
880        spec().fg("overlay"),
881        "Sticky context strip: the rule beneath it, when `context.separator` \
882         is set. Off by default — a separator is a preference, not an \
883         affordance.",
884    );
885    reg_one(
886        "sticky.context.active",
887        spec().bg("surface1"),
888        "Sticky context strip: the innermost row — the scope the cursor is \
889         actually in. Distinguished from its ancestors because that is the \
890         one line the reader is looking for.",
891    );
892    reg_one(
893        "diff.deletion_block",
894        spec().bg("diff.deletion.bg"),
895        "Deletion-block virtual-row background tint.",
896    );
897    reg_one(
898        "diff.conflict.line",
899        spec().bg("diff.conflict.bg"),
900        "Conflict-region background tint.",
901    );
902    // MG.4 — inline diff text fg colours (magit unified diff ± lines).
903    reg_one(
904        "diff.add.text",
905        spec().fg("green"),
906        "Added-line text colour in a unified diff.",
907    );
908    reg_one(
909        "diff.remove.text",
910        spec().fg("red"),
911        "Removed-line text colour in a unified diff.",
912    );
913
914    // ---- Magit-owned styles ----
915    // Git concepts magit's own buffers render — distinct from the
916    // `syntax.*` categories above, which name source-code concepts a
917    // magit buffer never actually contains (a SHA is not a "link", the
918    // checked-out branch is not a "keyword"). Kept close to the diff
919    // text colours above since both are magit-domain roles.
920    reg_one(
921        "magit.sha",
922        spec().fg("blue"),
923        "Commit SHA colour (magit-log, magit-blame, magit-rebase's todo).",
924    );
925    reg_one(
926        "magit.branch.current",
927        spec().fg("green").bold(),
928        "The checked-out branch's `* ` marker + name in magit-branch's list.",
929    );
930    reg_one(
931        "magit.ref.decoration",
932        spec().fg("pink"),
933        "Ref-decoration list after a log SHA (`(HEAD -> main, ...)`).",
934    );
935    reg_one(
936        "magit.rebase.verb",
937        spec().fg("purple").bold(),
938        "A rebase-todo verb (pick/reword/edit/squash/fixup/drop).",
939    );
940    reg_one(
941        "magit.author",
942        spec().fg("overlay"),
943        "The author column in magit-blame output.",
944    );
945
946    // ---- Help-owned inline styles (HP.2) ----
947    // A help page is dense with inline literals, and until now none of
948    // them were styled at all: the markdown BLOCK grammar has no
949    // `code_span` node, so `` `gr` `` rendered as prose with visible
950    // backticks.
951    //
952    // Four roles rather than one, because a reader scanning a help page
953    // is looking for a specific thing — "which key do I press" — and a
954    // single literal colour cannot separate that from "which command do
955    // I type". Keys get the strongest treatment (bold) since they are
956    // the most-scanned; commands take the same `blue` the `:` line
957    // itself uses, so the colour matches where you will type them;
958    // actions are dimmer because an action id is machinery a reader
959    // meets rarely; plain literals stay quiet so they don't compete
960    // with the three that carry meaning.
961    reg_one(
962        "help.key",
963        spec().fg("yellow").bold(),
964        "A key or chord you press, in a help page (`gr`, `<C-c>g`).",
965    );
966    reg_one(
967        "help.command",
968        spec().fg("blue"),
969        "An ex-command you type, in a help page (`:magit-status`).",
970    );
971    reg_one(
972        "help.action",
973        spec().fg("purple"),
974        "An action id, in a help page (`action:magit-refresh`).",
975    );
976    reg_one(
977        "help.literal",
978        spec().fg("subtext"),
979        "Any other inline literal in a help page — a path, a flag, a filename.",
980    );
981
982    // ---- Transient menu (magit's dispatch and its submenus) ----
983    // A transient row is three columns that mean different things —
984    // the key you press, what it does, and (for a flag or variable)
985    // its current value. Both peers used to hard-code that palette,
986    // and differently: the TUI picked fixed ANSI colours, so
987    // `:colorscheme` never reached the menu at all; GPUI borrowed
988    // `popup.border` and `cursor.background` for five roles, which
989    // left keys and flags the SAME colour and descriptions painted in
990    // the border tone. Naming the roles is what lets one palette drive
991    // both peers and lets a theme retune it.
992    reg_one(
993        "transient.title",
994        spec().fg("text").bold(),
995        "The transient menu's title.",
996    );
997    reg_one(
998        "transient.group",
999        spec().fg("text").bold(),
1000        "A group heading inside a transient menu (`▸ Arguments`).",
1001    );
1002    reg_one(
1003        "transient.key",
1004        spec().fg("yellow"),
1005        "The key that fires a transient row — the most-scanned column.",
1006    );
1007    reg_one(
1008        "transient.key.inactive",
1009        spec().fg("overlay"),
1010        "A transient row ruled out by the multi-key prefix typed so far.",
1011    );
1012    reg_one(
1013        "transient.description",
1014        spec().fg("subtext"),
1015        "A transient row's description column.",
1016    );
1017    reg_one(
1018        "transient.value",
1019        spec().fg("green"),
1020        "A transient flag's `[x]` state or a variable's current value.",
1021    );
1022    reg_one(
1023        "transient.border",
1024        spec().fg("blue"),
1025        "The transient menu's border.",
1026    );
1027
1028    // ---- Fold markers ----
1029    // Gutter glyphs on a foldable head row: `▾` when the fold is open,
1030    // `▸` when collapsed. Muted by cross-editor convention — VS Code
1031    // (`editorGutter.foldingControlForeground` → `icon.foreground`),
1032    // Zed, JetBrains, Sublime, and Neovim's `FoldColumn` all render fold
1033    // controls in a low-emphasis gray rather than an accent. Open uses
1034    // the dim `overlay` tone so always-visible markers don't clutter;
1035    // closed steps up to `subtext` (the line-number tone) for a touch
1036    // more presence since it signals hidden content. Themes retune both.
1037    reg_one(
1038        "gutter.fold.open",
1039        spec().fg("overlay"),
1040        "`▾` fold marker on an open (expanded) foldable head row.",
1041    );
1042    // SG.2b: the FALLBACK tone for a generic sign whose definition names a
1043    // theme element nobody registered — a plugin that shipped a sign without a
1044    // matching element, or one whose theme has not been reloaded yet. A sign
1045    // with nowhere to get its colour still has to be visible: it was placed to
1046    // tell the user something, and painting it invisibly is the one answer that
1047    // loses the information entirely.
1048    //
1049    // `text` rather than a muted tone, unlike the fold markers: a fold marker is
1050    // always-present chrome and a sign is placed deliberately, so it earns
1051    // ordinary foreground presence. A definition naming a real element
1052    // overrides this completely.
1053    reg_one(
1054        "gutter.sign",
1055        spec().fg("text"),
1056        "Fallback colour for a gutter sign whose own theme element is unregistered.",
1057    );
1058    reg_one(
1059        "gutter.fold.closed",
1060        spec().fg("subtext"),
1061        "`▸` fold marker on a closed (collapsed) fold head row.",
1062    );
1063    // The ` ⋯ N lines` summary trailing a collapsed head row. Registered
1064    // rather than hardcoded so both peers read ONE tone — the TUI painted
1065    // it as a literal `DarkGray` while GPUI had no summary at all, which
1066    // is exactly the drift the registry exists to prevent. `overlay` (the
1067    // dim tone, same as the open marker) keeps it a low-emphasis trailer:
1068    // it is decoration on a row the user is reading, not content.
1069    reg_one(
1070        "gutter.fold.summary",
1071        spec().fg("overlay"),
1072        "The ` ⋯ N lines` summary trailing a closed fold's head row.",
1073    );
1074
1075    // ---- Search + selection + LSP overlays (T.6) ----
1076    // These hoist the scattered/drifted hardcoded overlay literals out
1077    // of the renderers so BOTH peers read the same registered styles
1078    // (closing the TUI/GPUI parity drift). Search match + current are
1079    // BACKGROUND tints in both renderers (the TUI's legacy fg-recolor
1080    // is retired); document-highlight keeps its 3 distinct kinds.
1081    reg_one(
1082        "search.match",
1083        spec().bg("overlay"),
1084        "All hlsearch matches (bg tint).",
1085    );
1086    reg_one(
1087        "search.current",
1088        spec().bg(Color::Rgb(0x6c, 0x5a, 0x1e)),
1089        "Current search match (warm bg tint).",
1090    );
1091    reg_one(
1092        "selection",
1093        spec().bg(Color::Rgb(0x45, 0x47, 0x5a)),
1094        "Visual-mode selection bg.",
1095    );
1096    reg_one(
1097        "doc_highlight.read",
1098        spec().bg(Color::Rgb(20, 50, 25)),
1099        "LSP document-highlight: read occurrence.",
1100    );
1101    reg_one(
1102        "doc_highlight.write",
1103        spec().bg(Color::Rgb(60, 20, 20)),
1104        "LSP document-highlight: write/mutation.",
1105    );
1106    reg_one(
1107        "doc_highlight.text",
1108        spec().bg(Color::Rgb(20, 30, 60)),
1109        "LSP document-highlight: text occurrence.",
1110    );
1111    reg_one(
1112        "substitute.preview",
1113        spec().bg(Color::Rgb(0xf3, 0x8b, 0xa8)),
1114        "`:s///` live-preview match (bg; TUI adds strikethrough).",
1115    );
1116    reg_one(
1117        "inlay.hint",
1118        spec().fg(Color::Rgb(0x7f, 0x84, 0x9c)),
1119        "Inlay hint virtual text (overlay1).",
1120    );
1121
1122    // ---- Completion annotations (T.6) ----
1123    // The 5 base (unselected) colours; the selected-row BRIGHTENING
1124    // stays renderer logic applied on top of the resolved base color.
1125    reg_one(
1126        "completion.annotation.kind",
1127        spec().fg("subtext"),
1128        "Completion annotation: kind.",
1129    );
1130    reg_one(
1131        "completion.annotation.doc",
1132        spec().fg(Color::Rgb(0x89, 0xdc, 0xeb)),
1133        "Completion annotation: doc snippet.",
1134    );
1135    reg_one(
1136        "completion.annotation.keybinding",
1137        spec().fg("yellow"),
1138        "Completion annotation: keybinding.",
1139    );
1140    reg_one(
1141        "completion.annotation.source",
1142        spec().fg("purple"),
1143        "Completion annotation: source.",
1144    );
1145    reg_one(
1146        "completion.annotation.custom",
1147        spec().fg("blue"),
1148        "Completion annotation: custom/plugin.",
1149    );
1150    // ---- MARG §8: per-segment file-metadata marginalia ----
1151    // eza / `ls --color` convention for the permission string
1152    // (one slot per bit class) plus size + mtime columns. Consumed
1153    // by `Annotation::Styled` segments emitted by the file/dir picker.
1154    reg_one(
1155        "completion.annotation.perm.type",
1156        spec().fg("blue"),
1157        "Marginalia: permission type char (d/l/b/c/p/s/-).",
1158    );
1159    reg_one(
1160        "completion.annotation.perm.read",
1161        spec().fg("yellow"),
1162        "Marginalia: permission read bit (r).",
1163    );
1164    reg_one(
1165        "completion.annotation.perm.write",
1166        spec().fg("red"),
1167        "Marginalia: permission write bit (w).",
1168    );
1169    reg_one(
1170        "completion.annotation.perm.exec",
1171        spec().fg("green"),
1172        "Marginalia: permission execute bit (x).",
1173    );
1174    reg_one(
1175        "completion.annotation.perm.special",
1176        spec().fg("pink"),
1177        "Marginalia: permission special bit (setuid/setgid/sticky).",
1178    );
1179    reg_one(
1180        "completion.annotation.perm.none",
1181        spec().fg("overlay"),
1182        "Marginalia: permission absent bit (-).",
1183    );
1184    reg_one(
1185        "completion.annotation.size",
1186        spec().fg("orange"),
1187        "Marginalia: file size column.",
1188    );
1189    reg_one(
1190        "completion.annotation.mtime",
1191        spec().fg("green"),
1192        "Marginalia: file modified-time column.",
1193    );
1194    // PP.2c: the rest of the picker prompt. Until these were named the
1195    // line was the last wholly un-themeable surface in the editor — the
1196    // TUI hardcoded `Color::Cyan` / `Color::DarkGray` and GPUI borrowed
1197    // `cursor_background` and `popup_border`, the same "each renderer
1198    // invents its own answer" the `transient.*` set was introduced to
1199    // end. `picker.prompt` defaults to `picker.title`'s tone because
1200    // that is the relationship the hardcoded version had; it is a
1201    // separate element so a theme can break it, not so it must.
1202    reg_one(
1203        "picker.title",
1204        spec().fg("teal"),
1205        "Picker prompt: the source name (`buffers`, `project-files`).",
1206    );
1207    reg_one(
1208        "picker.prompt",
1209        spec().fg("teal"),
1210        "Picker prompt: the `>` marker where typing begins.",
1211    );
1212    reg_one(
1213        "picker.count",
1214        spec().fg("overlay"),
1215        "Picker prompt: the `(3/40)` match count and `searching…` status.",
1216    );
1217    // PP.2b: the root a rooted picker is scoped to, shown between the
1218    // source and the `>`. `blue` rather than the `overlay` the marginalia
1219    // paths use: this is the answer to "which checkout answered", read
1220    // before you start typing, so it has to be legible at a glance —
1221    // PP.2 shipped it at the same dimness as the `(n/m)` count, where it
1222    // read as chrome rather than as the one piece of context the prompt
1223    // carries. Still below the title, which stays bold.
1224    reg_one(
1225        "picker.root",
1226        spec().fg("blue"),
1227        "Picker prompt: the root a rooted picker is scoped to.",
1228    );
1229    // ---- MARG §9: picker marginalia rollout (location / status /
1230    // latency / args / buffer-id / register). Same `Annotation::Styled`
1231    // mechanism, new slot families consumed by the non-file pickers. ----
1232    reg_one(
1233        "completion.annotation.location.path",
1234        spec().fg("overlay"),
1235        "Marginalia: location path head (grep/jumps/marks).",
1236    );
1237    reg_one(
1238        "completion.annotation.location.line",
1239        spec().fg("yellow"),
1240        "Marginalia: location line number.",
1241    );
1242    reg_one(
1243        "completion.annotation.location.col",
1244        spec().fg("overlay"),
1245        "Marginalia: location column number.",
1246    );
1247    reg_one(
1248        "completion.annotation.status.dirty",
1249        spec().fg("red"),
1250        "Marginalia: dirty-buffer marker.",
1251    );
1252    reg_one(
1253        "completion.annotation.status.active",
1254        spec().fg("green"),
1255        "Marginalia: current-buffer marker.",
1256    );
1257    reg_one(
1258        "completion.annotation.latency.reflex",
1259        spec().fg("green"),
1260        "Marginalia: reflex-latency command class.",
1261    );
1262    reg_one(
1263        "completion.annotation.latency.display",
1264        spec().fg("blue"),
1265        "Marginalia: display-latency command class.",
1266    );
1267    reg_one(
1268        "completion.annotation.latency.background",
1269        spec().fg("orange"),
1270        "Marginalia: background-latency command class.",
1271    );
1272    reg_one(
1273        "completion.annotation.args",
1274        spec().fg("subtext"),
1275        "Marginalia: command argument hint.",
1276    );
1277    reg_one(
1278        "completion.annotation.buffer-id",
1279        spec().fg("overlay"),
1280        "Marginalia: buffer id (#N).",
1281    );
1282    reg_one(
1283        "completion.annotation.register",
1284        spec().fg("purple"),
1285        "Marginalia: register / mark name.",
1286    );
1287
1288    // ---- Syntax (mirrors host `Theme::syntax_style`) ----
1289    reg_one(
1290        "syntax.default",
1291        spec().fg("text"),
1292        "Default foreground text.",
1293    );
1294    reg_one(
1295        "syntax.comment",
1296        spec().fg("overlay").italic(),
1297        "Block / doc comments.",
1298    );
1299    // LineComment is byte-identical to Comment — demonstrates inherit.
1300    reg_one(
1301        "syntax.line_comment",
1302        spec().inherit("syntax.comment"),
1303        "Line comments (inherits syntax.comment).",
1304    );
1305    reg_one("syntax.string", spec().fg("green"), "String literals.");
1306    reg_one(
1307        "syntax.keyword",
1308        spec().fg("purple").bold(),
1309        "Language keywords.",
1310    );
1311    reg_one("syntax.type", spec().fg("yellow"), "Type names.");
1312    reg_one("syntax.number", spec().fg("orange"), "Numeric literals.");
1313    reg_one("syntax.function", spec().fg("blue"), "Function names.");
1314    reg_one("syntax.constant", spec().fg("orange"), "Constants.");
1315    reg_one("syntax.variable", spec().fg("text"), "Variables.");
1316    reg_one("syntax.operator", spec().fg("teal"), "Operators.");
1317    reg_one(
1318        "syntax.punctuation",
1319        spec().fg("subtext"),
1320        "Punctuation / delimiters.",
1321    );
1322    reg_one(
1323        "syntax.attribute",
1324        spec().fg("red"),
1325        "Attributes / annotations.",
1326    );
1327    // T.10: heading levels carry a rich-vocabulary `weight` (finer than
1328    // the bold modifier). The GPUI peer honors it in per-run font
1329    // shaping so headings render heavier than body bold; the TUI degrades
1330    // (any SemiBold-or-heavier weight maps to its bold attribute, and
1331    // these already set `.bold()`, so the TUI is visually unchanged).
1332    //
1333    // F.3 (Thread F): heading levels also carry a rich-vocabulary `scale`
1334    // (emacs `:height`) descending by level — h1 largest, h6 barely above
1335    // body. The GPUI peer honors it via variable row height (F.2): the
1336    // whole heading display line is shaped at `font_size * scale`. The TUI
1337    // degrades (a fixed cell grid cannot vary font size; headings stay
1338    // bold+colored+underlined). Because Heading tokens are markdown-
1339    // exclusive, this core syntax-element default IS effectively buffer-
1340    // local — only markdown buffers carry these tokens (Option A, design
1341    // §6.1; T.8 buffer-local remap stays deferred for true per-buffer
1342    // divergence like variable-pitch prose).
1343    // No underline, and the comment above is why it USED to have one: the
1344    // default was tuned on the premise that "Heading tokens are
1345    // markdown-exclusive", where an h1 is typically the one document title and
1346    // an underline reads as a title rule. Org made that premise false — it
1347    // captures `@text.title.1` for every `*` headline, and a `*` in org is an
1348    // ordinary top-level section, of which a file has many. Underlining all of
1349    // them is noise, and no editor does it: emacs org-mode does not, and
1350    // neither does markdown-mode.
1351    //
1352    // Nothing is lost where the underline was doing work. It was the TUI's
1353    // stand-in for `scale`, which a fixed cell grid cannot render — but red +
1354    // bold already separates an h1 there, and GPUI renders the scale ramp
1355    // directly, so the underline was doubled-up emphasis on the peer the ramp
1356    // exists for.
1357    reg_one(
1358        "syntax.heading.1",
1359        spec().fg("red").bold().weight(Weight::ExtraBold).scale(1.6),
1360        "Markup heading level 1.",
1361    );
1362    reg_one(
1363        "syntax.heading.2",
1364        spec().fg("orange").bold().weight(Weight::Bold).scale(1.4),
1365        "Markup heading level 2.",
1366    );
1367    reg_one(
1368        "syntax.heading.3",
1369        spec().fg("yellow").bold().weight(Weight::Bold).scale(1.25),
1370        "Markup heading level 3.",
1371    );
1372    reg_one(
1373        "syntax.heading.4",
1374        spec().fg("green").bold().scale(1.15),
1375        "Markup heading level 4.",
1376    );
1377    reg_one(
1378        "syntax.heading.5",
1379        spec().fg("blue").bold().scale(1.1),
1380        "Markup heading level 5.",
1381    );
1382    reg_one(
1383        "syntax.heading.6",
1384        spec().fg("purple").bold().scale(1.05),
1385        "Markup heading level 6.",
1386    );
1387    reg_one(
1388        "syntax.bold",
1389        spec().fg("maroon").bold(),
1390        "Strong / bold markup.",
1391    );
1392    reg_one(
1393        "syntax.italic",
1394        spec().fg("pink").italic(),
1395        "Emphasis / italic markup.",
1396    );
1397    reg_one(
1398        "syntax.link",
1399        spec().fg("blue").underline(),
1400        "Markup links.",
1401    );
1402    reg_one("syntax.url", spec().fg("cyan").underline(), "Bare URLs.");
1403    reg_one(
1404        "syntax.markup_raw",
1405        // Inline code is CONTENT the reader reads, so it takes a legible
1406        // accent (the string/literal `green`), not the muted `overlay` +
1407        // `.dim()` it once shared with whitespace markers. Foreground-only
1408        // by the span-layering contract, it must stay readable on any
1409        // background the row can acquire — the cursorline tint above all,
1410        // where `overlay`+dim dropped to ~1.5:1 on the light theme.
1411        spec().fg("green"),
1412        "Inline code / raw markup.",
1413    );
1414    reg_one(
1415        "syntax.code_block",
1416        spec().bg("surface0"),
1417        "Full-width background tint for a fenced/indented code block.",
1418    );
1419    reg_one(
1420        "syntax.markup",
1421        spec().fg("subtext").bold(),
1422        "Generic markup punctuation.",
1423    );
1424}
1425
1426// ---- builtin element id capture (T.4) ----
1427
1428/// The interned [`ElementId`]s for the builtin elements the renderers
1429/// read, captured **once at boot** from the [`ThemeRegistry`] and held
1430/// for the process lifetime. `Copy` + small, so it snapshots into
1431/// `RenderState` per publish for free; a read is then
1432/// `resolved.get(ids.<elem>)` — an array index, no per-frame name
1433/// lookup (design §7).
1434///
1435/// Grown one consumer-group at a time as Thread B migrates renderers
1436/// off the flat `Theme` struct (T.4.a diagnostics → T.4.b diff → …).
1437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1438pub struct BuiltinElementIds {
1439    // T.4.a — diagnostics.
1440    pub diagnostic_error: ElementId,
1441    pub diagnostic_warning: ElementId,
1442    pub diagnostic_info: ElementId,
1443    pub diagnostic_hint: ElementId,
1444    // T.4.b — diff gutter signs (fg styles) + line/block tints (bg).
1445    pub diff_add_sign: ElementId,
1446    pub diff_change_sign: ElementId,
1447    pub diff_remove_sign: ElementId,
1448    pub diff_conflict_sign: ElementId,
1449    pub diff_add_line: ElementId,
1450    /// DR.2: intra-line refinement backgrounds — the sub-range of a
1451    /// changed line that actually differs.
1452    pub diff_add_refine_bg: ElementId,
1453    pub diff_remove_refine_bg: ElementId,
1454    pub diff_change_line: ElementId,
1455    /// D-fix.3b: removed-line tint for the baseline/left pane.
1456    pub diff_remove_line: ElementId,
1457    pub diff_deletion_block: ElementId,
1458    /// TC.3b: the sticky context strip's backdrop.
1459    pub sticky_context_background: ElementId,
1460    /// TC.11: the strip's gutter line numbers.
1461    pub sticky_context_line_number: ElementId,
1462    /// TC.11: the strip's innermost row — the scope the cursor is in.
1463    pub sticky_context_active: ElementId,
1464    /// TC.12: the rule beneath the strip, when `context.separator` is set.
1465    pub sticky_context_separator: ElementId,
1466    pub diff_conflict_line: ElementId,
1467    // MG.4 — inline diff text fg colours (magit unified diff ± lines).
1468    pub diff_add_text: ElementId,
1469    pub diff_remove_text: ElementId,
1470    // Magit-owned styles — git concepts magit's log/blame/branch/
1471    // rebase buffers render, distinct from `syntax.*` (which names
1472    // source-code categories a magit buffer never actually contains).
1473    pub magit_sha: ElementId,
1474    pub magit_branch_current: ElementId,
1475    pub magit_ref_decoration: ElementId,
1476    pub magit_rebase_verb: ElementId,
1477    pub magit_author: ElementId,
1478    // Help-owned inline styles (HP.2). Help pages are markdown and the
1479    // block grammar emits no `code_span`, so inline literals had no
1480    // style at all. Split four ways rather than one because a reader
1481    // scanning a help page is looking for "which key do I press" —
1482    // making that visually distinct from "which command do I type" is
1483    // the whole point, and one shared colour cannot express it.
1484    pub help_key: ElementId,
1485    pub help_command: ElementId,
1486    pub help_action: ElementId,
1487    pub help_literal: ElementId,
1488    // Transient-menu roles. Named rather than borrowed from
1489    // `popup.*` so a theme can retune the menu without moving every
1490    // other popup with it, and so both peers read ONE palette.
1491    pub transient_title: ElementId,
1492    pub transient_group: ElementId,
1493    pub transient_key: ElementId,
1494    pub transient_key_inactive: ElementId,
1495    pub transient_description: ElementId,
1496    pub transient_value: ElementId,
1497    pub transient_border: ElementId,
1498    // Fold-marker gutter glyphs (`▾` open head, `▸` collapsed head).
1499    // Muted by cross-editor convention (VS Code / Zed / JetBrains /
1500    // Sublime / Neovim all render fold controls in a low-emphasis gray,
1501    // never an accent). `closed` gets a touch more presence than `open`
1502    // because it signals hidden content; the `⋯ N lines` summary carries
1503    // the rest of that signal. Both renderers read these ids so the TUI
1504    // and GPUI gutters stay in lockstep and themes can retune the tone.
1505    /// SG.2b: the fallback tone for a sign whose named element is missing.
1506    pub gutter_sign: ElementId,
1507    pub gutter_fold_open: ElementId,
1508    pub gutter_fold_closed: ElementId,
1509    /// The ` ⋯ N lines` summary trailing a collapsed head row. Both
1510    /// peers read it, so the trailer cannot drift between them.
1511    pub gutter_fold_summary: ElementId,
1512    // T.4.c — pane chrome + file tree (writer-free elements only;
1513    // `pane.status.*` / `pane.separator` carry live `:set ui.*`
1514    // overrides and migrate with the registry-override path in T.9).
1515    pub pane_status_active: ElementId,
1516    pub pane_status_inactive: ElementId,
1517    pub pane_separator: ElementId,
1518    pub pane_inactive_overlay: ElementId,
1519    // ML.1b — modeline per-role segments + active/inactive bar base.
1520    pub modeline_active: ElementId,
1521    pub modeline_inactive: ElementId,
1522    pub modeline_mode: ElementId,
1523    pub modeline_path: ElementId,
1524    pub modeline_position: ElementId,
1525    pub modeline_lang: ElementId,
1526    pub modeline_mode_item: ElementId,
1527    // T.11.0b — editor canvas (bg / fg / block cursor / popup surface).
1528    // Palette-driven so a `:colorscheme` / palette swap recolors the
1529    // whole canvas; the readiness for light themes.
1530    pub editor_background: ElementId,
1531    pub editor_foreground: ElementId,
1532    pub editor_cursor: ElementId,
1533    pub ui_popup_background: ElementId,
1534    pub ui_popup_title: ElementId,
1535    pub ui_popup_hint: ElementId,
1536    pub file_tree_dir: ElementId,
1537    pub file_tree_hidden: ElementId,
1538    pub file_tree_file: ElementId,
1539    // T.4.d — current-line tint + *messages* level styling. (Whitespace
1540    // is read in the cell builder, so it migrates with the cell-path
1541    // resolved wiring in T.5.)
1542    pub editor_cursor_line: ElementId,
1543    pub messages_timestamp: ElementId,
1544    pub messages_trace: ElementId,
1545    pub messages_debug: ElementId,
1546    pub messages_info: ElementId,
1547    pub messages_warn: ElementId,
1548    pub messages_error: ElementId,
1549    // T.5 — syntax categories (the cell builder + both display-line
1550    // paths map `lattice_syntax::Style` → these) + whitespace markers.
1551    pub syntax_default: ElementId,
1552    pub syntax_comment: ElementId,
1553    pub syntax_line_comment: ElementId,
1554    pub syntax_string: ElementId,
1555    pub syntax_keyword: ElementId,
1556    pub syntax_type: ElementId,
1557    pub syntax_number: ElementId,
1558    pub syntax_function: ElementId,
1559    pub syntax_constant: ElementId,
1560    pub syntax_variable: ElementId,
1561    pub syntax_operator: ElementId,
1562    pub syntax_punctuation: ElementId,
1563    pub syntax_attribute: ElementId,
1564    pub syntax_heading_1: ElementId,
1565    pub syntax_heading_2: ElementId,
1566    pub syntax_heading_3: ElementId,
1567    pub syntax_heading_4: ElementId,
1568    pub syntax_heading_5: ElementId,
1569    pub syntax_heading_6: ElementId,
1570    pub syntax_bold: ElementId,
1571    pub syntax_italic: ElementId,
1572    pub syntax_link: ElementId,
1573    pub syntax_url: ElementId,
1574    pub syntax_markup_raw: ElementId,
1575    /// MC.1: full-width background tint for fenced/indented code blocks.
1576    pub syntax_code_block: ElementId,
1577    pub syntax_markup: ElementId,
1578    pub whitespace: ElementId,
1579    pub whitespace_trailing: ElementId,
1580    /// IG.2: indentation guides. Two ids so the block enclosing the
1581    /// cursor can be drawn against the rest.
1582    pub indent_guide: ElementId,
1583    pub indent_guide_active: ElementId,
1584    // T.6 — search + selection + LSP overlays (BOTH renderers read
1585    // these; closes the TUI/GPUI parity drift). Search match/current
1586    // are bg tints in both peers now.
1587    pub search_match: ElementId,
1588    pub search_current: ElementId,
1589    pub selection: ElementId,
1590    pub doc_highlight_read: ElementId,
1591    pub doc_highlight_write: ElementId,
1592    pub doc_highlight_text: ElementId,
1593    pub substitute_preview: ElementId,
1594    pub inlay_hint: ElementId,
1595    // T.6 — completion-annotation base (unselected) colours; the
1596    // selected-row brightening stays renderer logic on top of these.
1597    pub completion_annotation_kind: ElementId,
1598    pub completion_annotation_doc: ElementId,
1599    pub completion_annotation_keybinding: ElementId,
1600    pub completion_annotation_source: ElementId,
1601    pub completion_annotation_custom: ElementId,
1602    // MARG §8: per-segment file-metadata marginalia slots.
1603    pub completion_annotation_perm_type: ElementId,
1604    pub completion_annotation_perm_read: ElementId,
1605    pub completion_annotation_perm_write: ElementId,
1606    pub completion_annotation_perm_exec: ElementId,
1607    pub completion_annotation_perm_special: ElementId,
1608    pub completion_annotation_perm_none: ElementId,
1609    pub completion_annotation_size: ElementId,
1610    pub completion_annotation_mtime: ElementId,
1611    // PP.2b / PP.2c: the picker prompt line.
1612    pub picker_root: ElementId,
1613    pub picker_title: ElementId,
1614    pub picker_prompt: ElementId,
1615    pub picker_count: ElementId,
1616    // MARG §9: picker marginalia rollout slots.
1617    pub completion_annotation_location_path: ElementId,
1618    pub completion_annotation_location_line: ElementId,
1619    pub completion_annotation_location_col: ElementId,
1620    pub completion_annotation_status_dirty: ElementId,
1621    pub completion_annotation_status_active: ElementId,
1622    pub completion_annotation_latency_reflex: ElementId,
1623    pub completion_annotation_latency_display: ElementId,
1624    pub completion_annotation_latency_background: ElementId,
1625    pub completion_annotation_args: ElementId,
1626    pub completion_annotation_buffer_id: ElementId,
1627    pub completion_annotation_register: ElementId,
1628    // Terminal ANSI 0-15 (the 16-colour palette programs draw in). Each
1629    // maps to a palette accent so the embedded terminal recolours with the
1630    // colorscheme instead of a hardcoded dim-VGA xterm palette. The GPUI
1631    // terminal renderer reads these; index = ANSI colour number.
1632    pub terminal_ansi: [ElementId; 16],
1633}
1634
1635impl Default for BuiltinElementIds {
1636    /// All [`ElementId::INVALID`] — the placeholder a default
1637    /// `RenderState` carries before the boot capture. Reads against an
1638    /// empty/default resolved table return `Style::empty()`.
1639    fn default() -> Self {
1640        BuiltinElementIds {
1641            diagnostic_error: ElementId::INVALID,
1642            diagnostic_warning: ElementId::INVALID,
1643            diagnostic_info: ElementId::INVALID,
1644            diagnostic_hint: ElementId::INVALID,
1645            diff_add_sign: ElementId::INVALID,
1646            diff_change_sign: ElementId::INVALID,
1647            diff_remove_sign: ElementId::INVALID,
1648            diff_conflict_sign: ElementId::INVALID,
1649            diff_add_line: ElementId::INVALID,
1650            diff_add_refine_bg: ElementId::INVALID,
1651            diff_remove_refine_bg: ElementId::INVALID,
1652            diff_change_line: ElementId::INVALID,
1653            diff_remove_line: ElementId::INVALID,
1654            diff_deletion_block: ElementId::INVALID,
1655            sticky_context_background: ElementId::INVALID,
1656            sticky_context_line_number: ElementId::INVALID,
1657            sticky_context_active: ElementId::INVALID,
1658            sticky_context_separator: ElementId::INVALID,
1659            diff_conflict_line: ElementId::INVALID,
1660            diff_add_text: ElementId::INVALID,
1661            diff_remove_text: ElementId::INVALID,
1662            magit_sha: ElementId::INVALID,
1663            magit_branch_current: ElementId::INVALID,
1664            magit_ref_decoration: ElementId::INVALID,
1665            magit_rebase_verb: ElementId::INVALID,
1666            magit_author: ElementId::INVALID,
1667            help_key: ElementId::INVALID,
1668            help_command: ElementId::INVALID,
1669            help_action: ElementId::INVALID,
1670            help_literal: ElementId::INVALID,
1671            transient_title: ElementId::INVALID,
1672            transient_group: ElementId::INVALID,
1673            transient_key: ElementId::INVALID,
1674            transient_key_inactive: ElementId::INVALID,
1675            transient_description: ElementId::INVALID,
1676            transient_value: ElementId::INVALID,
1677            transient_border: ElementId::INVALID,
1678            gutter_sign: ElementId::INVALID,
1679            gutter_fold_open: ElementId::INVALID,
1680            gutter_fold_summary: ElementId::INVALID,
1681            gutter_fold_closed: ElementId::INVALID,
1682            pane_status_active: ElementId::INVALID,
1683            pane_status_inactive: ElementId::INVALID,
1684            pane_separator: ElementId::INVALID,
1685            pane_inactive_overlay: ElementId::INVALID,
1686            terminal_ansi: [ElementId::INVALID; 16],
1687            modeline_active: ElementId::INVALID,
1688            modeline_inactive: ElementId::INVALID,
1689            modeline_mode: ElementId::INVALID,
1690            modeline_path: ElementId::INVALID,
1691            modeline_position: ElementId::INVALID,
1692            modeline_lang: ElementId::INVALID,
1693            modeline_mode_item: ElementId::INVALID,
1694            editor_background: ElementId::INVALID,
1695            editor_foreground: ElementId::INVALID,
1696            editor_cursor: ElementId::INVALID,
1697            ui_popup_background: ElementId::INVALID,
1698            ui_popup_title: ElementId::INVALID,
1699            ui_popup_hint: ElementId::INVALID,
1700            file_tree_dir: ElementId::INVALID,
1701            file_tree_hidden: ElementId::INVALID,
1702            file_tree_file: ElementId::INVALID,
1703            editor_cursor_line: ElementId::INVALID,
1704            messages_timestamp: ElementId::INVALID,
1705            messages_trace: ElementId::INVALID,
1706            messages_debug: ElementId::INVALID,
1707            messages_info: ElementId::INVALID,
1708            messages_warn: ElementId::INVALID,
1709            messages_error: ElementId::INVALID,
1710            syntax_default: ElementId::INVALID,
1711            syntax_comment: ElementId::INVALID,
1712            syntax_line_comment: ElementId::INVALID,
1713            syntax_string: ElementId::INVALID,
1714            syntax_keyword: ElementId::INVALID,
1715            syntax_type: ElementId::INVALID,
1716            syntax_number: ElementId::INVALID,
1717            syntax_function: ElementId::INVALID,
1718            syntax_constant: ElementId::INVALID,
1719            syntax_variable: ElementId::INVALID,
1720            syntax_operator: ElementId::INVALID,
1721            syntax_punctuation: ElementId::INVALID,
1722            syntax_attribute: ElementId::INVALID,
1723            syntax_heading_1: ElementId::INVALID,
1724            syntax_heading_2: ElementId::INVALID,
1725            syntax_heading_3: ElementId::INVALID,
1726            syntax_heading_4: ElementId::INVALID,
1727            syntax_heading_5: ElementId::INVALID,
1728            syntax_heading_6: ElementId::INVALID,
1729            syntax_bold: ElementId::INVALID,
1730            syntax_italic: ElementId::INVALID,
1731            syntax_link: ElementId::INVALID,
1732            syntax_url: ElementId::INVALID,
1733            syntax_markup_raw: ElementId::INVALID,
1734            syntax_code_block: ElementId::INVALID,
1735            syntax_markup: ElementId::INVALID,
1736            whitespace: ElementId::INVALID,
1737            whitespace_trailing: ElementId::INVALID,
1738            indent_guide: ElementId::INVALID,
1739            indent_guide_active: ElementId::INVALID,
1740            search_match: ElementId::INVALID,
1741            search_current: ElementId::INVALID,
1742            selection: ElementId::INVALID,
1743            doc_highlight_read: ElementId::INVALID,
1744            doc_highlight_write: ElementId::INVALID,
1745            doc_highlight_text: ElementId::INVALID,
1746            substitute_preview: ElementId::INVALID,
1747            inlay_hint: ElementId::INVALID,
1748            picker_root: ElementId::INVALID,
1749            picker_title: ElementId::INVALID,
1750            picker_prompt: ElementId::INVALID,
1751            picker_count: ElementId::INVALID,
1752            completion_annotation_kind: ElementId::INVALID,
1753            completion_annotation_doc: ElementId::INVALID,
1754            completion_annotation_keybinding: ElementId::INVALID,
1755            completion_annotation_source: ElementId::INVALID,
1756            completion_annotation_custom: ElementId::INVALID,
1757            completion_annotation_perm_type: ElementId::INVALID,
1758            completion_annotation_perm_read: ElementId::INVALID,
1759            completion_annotation_perm_write: ElementId::INVALID,
1760            completion_annotation_perm_exec: ElementId::INVALID,
1761            completion_annotation_perm_special: ElementId::INVALID,
1762            completion_annotation_perm_none: ElementId::INVALID,
1763            completion_annotation_size: ElementId::INVALID,
1764            completion_annotation_mtime: ElementId::INVALID,
1765            completion_annotation_location_path: ElementId::INVALID,
1766            completion_annotation_location_line: ElementId::INVALID,
1767            completion_annotation_location_col: ElementId::INVALID,
1768            completion_annotation_status_dirty: ElementId::INVALID,
1769            completion_annotation_status_active: ElementId::INVALID,
1770            completion_annotation_latency_reflex: ElementId::INVALID,
1771            completion_annotation_latency_display: ElementId::INVALID,
1772            completion_annotation_latency_background: ElementId::INVALID,
1773            completion_annotation_args: ElementId::INVALID,
1774            completion_annotation_buffer_id: ElementId::INVALID,
1775            completion_annotation_register: ElementId::INVALID,
1776        }
1777    }
1778}
1779
1780impl BuiltinElementIds {
1781    /// MARG §8: map an [`Annotation::Styled`] segment's slot KEY to its
1782    /// interned element id, shared by both renderer peers so a styled
1783    /// marginalia cell resolves identically. Unknown slots fall back to
1784    /// `completion.annotation.custom` (the plugin-annotation default) —
1785    /// a paint-time styleless-ish read, never a panic. Adding a new
1786    /// builtin slot is one arm here plus the field/registration; plugin
1787    /// slots resolve dynamically once the WASM host lands (design §7).
1788    ///
1789    /// [`Annotation::Styled`]: lattice-completion's `Annotation::Styled`
1790    pub fn annotation_slot(&self, slot: &str) -> ElementId {
1791        match slot {
1792            "completion.annotation.kind" => self.completion_annotation_kind,
1793            "completion.annotation.doc" => self.completion_annotation_doc,
1794            "completion.annotation.keybinding" => self.completion_annotation_keybinding,
1795            "completion.annotation.source" => self.completion_annotation_source,
1796            "completion.annotation.perm.type" => self.completion_annotation_perm_type,
1797            "completion.annotation.perm.read" => self.completion_annotation_perm_read,
1798            "completion.annotation.perm.write" => self.completion_annotation_perm_write,
1799            "completion.annotation.perm.exec" => self.completion_annotation_perm_exec,
1800            "completion.annotation.perm.special" => self.completion_annotation_perm_special,
1801            "completion.annotation.perm.none" => self.completion_annotation_perm_none,
1802            "completion.annotation.size" => self.completion_annotation_size,
1803            "completion.annotation.mtime" => self.completion_annotation_mtime,
1804            "completion.annotation.location.path" => self.completion_annotation_location_path,
1805            "completion.annotation.location.line" => self.completion_annotation_location_line,
1806            "completion.annotation.location.col" => self.completion_annotation_location_col,
1807            "completion.annotation.status.dirty" => self.completion_annotation_status_dirty,
1808            "completion.annotation.status.active" => self.completion_annotation_status_active,
1809            "completion.annotation.latency.reflex" => self.completion_annotation_latency_reflex,
1810            "completion.annotation.latency.display" => self.completion_annotation_latency_display,
1811            "completion.annotation.latency.background" => {
1812                self.completion_annotation_latency_background
1813            }
1814            "completion.annotation.args" => self.completion_annotation_args,
1815            "completion.annotation.buffer-id" => self.completion_annotation_buffer_id,
1816            "completion.annotation.register" => self.completion_annotation_register,
1817            _ => self.completion_annotation_custom,
1818        }
1819    }
1820
1821    /// Intern the builtin ids from the registry once at boot. A
1822    /// missing builtin (a registration bug, never expected) logs once
1823    /// and falls back to [`ElementId::INVALID`] — a styleless read,
1824    /// never a panic (graceful degradation, paramount-goal-aligned).
1825    pub fn capture(reg: &dyn ThemeRegistry) -> Self {
1826        let id = |name: &'static str| match reg.id(&ElementName::from_static(name)) {
1827            Some(id) => id,
1828            None => {
1829                tracing::warn!("theme: builtin element `{name}` not registered at id-capture");
1830                ElementId::INVALID
1831            }
1832        };
1833        BuiltinElementIds {
1834            terminal_ansi: [
1835                id("terminal.ansi.0"),
1836                id("terminal.ansi.1"),
1837                id("terminal.ansi.2"),
1838                id("terminal.ansi.3"),
1839                id("terminal.ansi.4"),
1840                id("terminal.ansi.5"),
1841                id("terminal.ansi.6"),
1842                id("terminal.ansi.7"),
1843                id("terminal.ansi.8"),
1844                id("terminal.ansi.9"),
1845                id("terminal.ansi.10"),
1846                id("terminal.ansi.11"),
1847                id("terminal.ansi.12"),
1848                id("terminal.ansi.13"),
1849                id("terminal.ansi.14"),
1850                id("terminal.ansi.15"),
1851            ],
1852            diagnostic_error: id("diagnostic.error"),
1853            diagnostic_warning: id("diagnostic.warning"),
1854            diagnostic_info: id("diagnostic.info"),
1855            diagnostic_hint: id("diagnostic.hint"),
1856            diff_add_sign: id("diff.add.sign"),
1857            diff_change_sign: id("diff.change.sign"),
1858            diff_remove_sign: id("diff.remove.sign"),
1859            diff_conflict_sign: id("diff.conflict.sign"),
1860            diff_add_line: id("diff.add.line"),
1861            diff_add_refine_bg: id("diff.add.refine.bg"),
1862            diff_remove_refine_bg: id("diff.remove.refine.bg"),
1863            diff_change_line: id("diff.change.line"),
1864            diff_remove_line: id("diff.remove.line"),
1865            diff_deletion_block: id("diff.deletion_block"),
1866            sticky_context_background: id("sticky.context.background"),
1867            sticky_context_line_number: id("sticky.context.line_number"),
1868            sticky_context_active: id("sticky.context.active"),
1869            sticky_context_separator: id("sticky.context.separator"),
1870            diff_conflict_line: id("diff.conflict.line"),
1871            diff_add_text: id("diff.add.text"),
1872            diff_remove_text: id("diff.remove.text"),
1873            magit_sha: id("magit.sha"),
1874            magit_branch_current: id("magit.branch.current"),
1875            magit_ref_decoration: id("magit.ref.decoration"),
1876            magit_rebase_verb: id("magit.rebase.verb"),
1877            magit_author: id("magit.author"),
1878            help_key: id("help.key"),
1879            help_command: id("help.command"),
1880            help_action: id("help.action"),
1881            help_literal: id("help.literal"),
1882            transient_title: id("transient.title"),
1883            transient_group: id("transient.group"),
1884            transient_key: id("transient.key"),
1885            transient_key_inactive: id("transient.key.inactive"),
1886            transient_description: id("transient.description"),
1887            transient_value: id("transient.value"),
1888            transient_border: id("transient.border"),
1889            gutter_sign: id("gutter.sign"),
1890            gutter_fold_open: id("gutter.fold.open"),
1891            gutter_fold_closed: id("gutter.fold.closed"),
1892            gutter_fold_summary: id("gutter.fold.summary"),
1893            pane_status_active: id("pane.status.active"),
1894            pane_status_inactive: id("pane.status.inactive"),
1895            pane_separator: id("pane.separator"),
1896            pane_inactive_overlay: id("pane.inactive_overlay"),
1897            modeline_active: id("modeline.active"),
1898            modeline_inactive: id("modeline.inactive"),
1899            modeline_mode: id("modeline.mode"),
1900            modeline_path: id("modeline.path"),
1901            modeline_position: id("modeline.position"),
1902            modeline_lang: id("modeline.lang"),
1903            modeline_mode_item: id("modeline.mode_item"),
1904            editor_background: id("editor.background"),
1905            editor_foreground: id("editor.foreground"),
1906            editor_cursor: id("editor.cursor"),
1907            ui_popup_background: id("ui.popup.background"),
1908            ui_popup_title: id("ui.popup.title"),
1909            ui_popup_hint: id("ui.popup.hint"),
1910            file_tree_dir: id("file_tree.dir"),
1911            file_tree_hidden: id("file_tree.hidden"),
1912            file_tree_file: id("file_tree.file"),
1913            editor_cursor_line: id("editor.cursor_line"),
1914            messages_timestamp: id("messages.timestamp"),
1915            messages_trace: id("messages.trace"),
1916            messages_debug: id("messages.debug"),
1917            messages_info: id("messages.info"),
1918            messages_warn: id("messages.warn"),
1919            messages_error: id("messages.error"),
1920            syntax_default: id("syntax.default"),
1921            syntax_comment: id("syntax.comment"),
1922            syntax_line_comment: id("syntax.line_comment"),
1923            syntax_string: id("syntax.string"),
1924            syntax_keyword: id("syntax.keyword"),
1925            syntax_type: id("syntax.type"),
1926            syntax_number: id("syntax.number"),
1927            syntax_function: id("syntax.function"),
1928            syntax_constant: id("syntax.constant"),
1929            syntax_variable: id("syntax.variable"),
1930            syntax_operator: id("syntax.operator"),
1931            syntax_punctuation: id("syntax.punctuation"),
1932            syntax_attribute: id("syntax.attribute"),
1933            syntax_heading_1: id("syntax.heading.1"),
1934            syntax_heading_2: id("syntax.heading.2"),
1935            syntax_heading_3: id("syntax.heading.3"),
1936            syntax_heading_4: id("syntax.heading.4"),
1937            syntax_heading_5: id("syntax.heading.5"),
1938            syntax_heading_6: id("syntax.heading.6"),
1939            syntax_bold: id("syntax.bold"),
1940            syntax_italic: id("syntax.italic"),
1941            syntax_link: id("syntax.link"),
1942            syntax_url: id("syntax.url"),
1943            syntax_markup_raw: id("syntax.markup_raw"),
1944            syntax_code_block: id("syntax.code_block"),
1945            syntax_markup: id("syntax.markup"),
1946            whitespace: id("whitespace"),
1947            whitespace_trailing: id("whitespace.trailing"),
1948            indent_guide: id("indent.guide"),
1949            indent_guide_active: id("indent.guide.active"),
1950            search_match: id("search.match"),
1951            search_current: id("search.current"),
1952            selection: id("selection"),
1953            doc_highlight_read: id("doc_highlight.read"),
1954            doc_highlight_write: id("doc_highlight.write"),
1955            doc_highlight_text: id("doc_highlight.text"),
1956            substitute_preview: id("substitute.preview"),
1957            inlay_hint: id("inlay.hint"),
1958            picker_root: id("picker.root"),
1959            picker_title: id("picker.title"),
1960            picker_prompt: id("picker.prompt"),
1961            picker_count: id("picker.count"),
1962            completion_annotation_kind: id("completion.annotation.kind"),
1963            completion_annotation_doc: id("completion.annotation.doc"),
1964            completion_annotation_keybinding: id("completion.annotation.keybinding"),
1965            completion_annotation_source: id("completion.annotation.source"),
1966            completion_annotation_custom: id("completion.annotation.custom"),
1967            completion_annotation_perm_type: id("completion.annotation.perm.type"),
1968            completion_annotation_perm_read: id("completion.annotation.perm.read"),
1969            completion_annotation_perm_write: id("completion.annotation.perm.write"),
1970            completion_annotation_perm_exec: id("completion.annotation.perm.exec"),
1971            completion_annotation_perm_special: id("completion.annotation.perm.special"),
1972            completion_annotation_perm_none: id("completion.annotation.perm.none"),
1973            completion_annotation_size: id("completion.annotation.size"),
1974            completion_annotation_mtime: id("completion.annotation.mtime"),
1975            completion_annotation_location_path: id("completion.annotation.location.path"),
1976            completion_annotation_location_line: id("completion.annotation.location.line"),
1977            completion_annotation_location_col: id("completion.annotation.location.col"),
1978            completion_annotation_status_dirty: id("completion.annotation.status.dirty"),
1979            completion_annotation_status_active: id("completion.annotation.status.active"),
1980            completion_annotation_latency_reflex: id("completion.annotation.latency.reflex"),
1981            completion_annotation_latency_display: id("completion.annotation.latency.display"),
1982            completion_annotation_latency_background: id(
1983                "completion.annotation.latency.background",
1984            ),
1985            completion_annotation_args: id("completion.annotation.args"),
1986            completion_annotation_buffer_id: id("completion.annotation.buffer-id"),
1987            completion_annotation_register: id("completion.annotation.register"),
1988        }
1989    }
1990}
1991
1992#[cfg(test)]
1993mod tests {
1994    use super::*;
1995    use crate::Color;
1996
1997    fn reg() -> InMemoryThemeRegistry {
1998        InMemoryThemeRegistry::with_defaults()
1999    }
2000
2001    fn resolved_of(reg: &InMemoryThemeRegistry, name: &'static str) -> Style {
2002        let id = reg
2003            .id(&ElementName::from_static(name))
2004            .unwrap_or_else(|| panic!("element `{name}` not registered"));
2005        reg.resolved().get(id)
2006    }
2007
2008    #[test]
2009    fn resolved_builtins_match_legacy_literals() {
2010        let reg = reg();
2011        // Chrome — palette accent keys (migrated off `ansi.*` so each theme's
2012        // tuned accent applies and GPUI gets readable truecolor instead of the
2013        // dim VGA `Color::Named` approximations; `ansi.*` collapsed to the same
2014        // `0x0000ee`/`0x7f7f7f` regardless of theme). Default palette = mocha.
2015        let rgb = Color::Rgb;
2016        assert_eq!(
2017            resolved_of(&reg, "pane.status.active"),
2018            Style::empty().reverse().bold()
2019        );
2020        assert_eq!(
2021            resolved_of(&reg, "pane.status.inactive"),
2022            Style::empty().fg(rgb(0x6c, 0x70, 0x86)).dim() // overlay
2023        );
2024        assert_eq!(
2025            resolved_of(&reg, "diagnostic.error"),
2026            Style::empty().fg(rgb(0xf3, 0x8b, 0xa8)).bold() // red
2027        );
2028        assert_eq!(
2029            resolved_of(&reg, "diagnostic.info"),
2030            Style::empty().fg(rgb(0x89, 0xb4, 0xfa)) // blue
2031        );
2032        assert_eq!(
2033            resolved_of(&reg, "file_tree.dir"),
2034            Style::empty().fg(rgb(0x89, 0xb4, 0xfa)).bold() // blue
2035        );
2036        assert_eq!(resolved_of(&reg, "file_tree.file"), Style::empty());
2037        assert_eq!(
2038            resolved_of(&reg, "diff.add.sign"),
2039            Style::empty().fg(rgb(0xa6, 0xe3, 0xa1)).bold() // green
2040        );
2041        assert_eq!(
2042            resolved_of(&reg, "diff.conflict.sign"),
2043            Style::empty().fg(rgb(0xcb, 0xa6, 0xf7)).bold() // purple
2044        );
2045        // Tints — exact RGB / indexed.
2046        assert_eq!(
2047            resolved_of(&reg, "editor.cursor_line"),
2048            Style::empty().bg(Color::Indexed(236))
2049        );
2050        assert_eq!(
2051            resolved_of(&reg, "diff.add.line"),
2052            Style::empty().bg(Color::Rgb(0, 50, 0))
2053        );
2054        assert_eq!(
2055            resolved_of(&reg, "diff.deletion_block"),
2056            Style::empty().bg(Color::Rgb(60, 0, 0))
2057        );
2058        // Syntax — exact Catppuccin RGB + modifiers.
2059        assert_eq!(
2060            resolved_of(&reg, "syntax.keyword"),
2061            Style::empty().fg(Color::Rgb(0xcb, 0xa6, 0xf7)).bold()
2062        );
2063        assert_eq!(
2064            resolved_of(&reg, "syntax.comment"),
2065            Style::empty().fg(Color::Rgb(0x6c, 0x70, 0x86)).italic()
2066        );
2067        // T.10: heading.1 carries the rich `ExtraBold` weight; F.3 adds
2068        // the rich `scale` (1.6× = FontScale(160)). No underline — see the
2069        // registration's own comment: it was tuned for markdown, where an h1
2070        // is the document title, and org captures the same token for every
2071        // top-level `*` headline.
2072        assert_eq!(
2073            resolved_of(&reg, "syntax.heading.1"),
2074            Style::empty()
2075                .fg(Color::Rgb(0xf3, 0x8b, 0xa8))
2076                .bold()
2077                .weight(Weight::ExtraBold)
2078                .scale(FontScale::from_ratio(1.6))
2079        );
2080        assert_eq!(
2081            resolved_of(&reg, "syntax.url"),
2082            Style::empty().fg(Color::Rgb(0x74, 0xc7, 0xec)).underline()
2083        );
2084    }
2085
2086    /// ML.1b: the default theme resolves the modeline elements to their
2087    /// palette-driven colours (Catppuccin Mocha: blue/text/subtext/teal
2088    /// + surface1/surface0/overlay).
2089    #[test]
2090    fn resolved_modeline_elements_are_palette_driven() {
2091        let reg = reg();
2092        assert_eq!(
2093            resolved_of(&reg, "modeline.active"),
2094            Style::empty().bg(Color::Rgb(0x45, 0x47, 0x5a)) // surface1
2095        );
2096        assert_eq!(
2097            resolved_of(&reg, "modeline.inactive"),
2098            Style::empty()
2099                .bg(Color::Rgb(0x31, 0x32, 0x44)) // surface0
2100                .fg(Color::Rgb(0x6c, 0x70, 0x86)) // overlay
2101        );
2102        assert_eq!(
2103            resolved_of(&reg, "modeline.mode"),
2104            Style::empty().fg(Color::Rgb(0x89, 0xb4, 0xfa)).bold() // blue
2105        );
2106        assert_eq!(
2107            resolved_of(&reg, "modeline.path"),
2108            Style::empty().fg(Color::Rgb(0xcd, 0xd6, 0xf4)) // text
2109        );
2110        assert_eq!(
2111            resolved_of(&reg, "modeline.lang"),
2112            Style::empty().fg(Color::Rgb(0x94, 0xe2, 0xd5)) // teal
2113        );
2114    }
2115
2116    /// ML.1b: EVERY builtin theme must resolve the modeline elements to a
2117    /// themed style (fg/bg set, no INVALID fallback). This is the
2118    /// "20 themes appropriately" guarantee — the elements are
2119    /// palette-keyed, so each theme's palette supplies its own colours.
2120    #[test]
2121    fn every_builtin_theme_themes_the_modeline() {
2122        for theme in crate::themes::builtin_themes() {
2123            let reg = reg();
2124            assert!(
2125                reg.apply_theme(theme.name),
2126                "theme `{}` should be registered",
2127                theme.name
2128            );
2129            let resolved = reg.resolved();
2130            let style = |name: &'static str| {
2131                let id = reg
2132                    .id(&ElementName::from_static(name))
2133                    .expect("modeline element registered");
2134                resolved.get(id)
2135            };
2136            // Bars carry a background; per-role segments carry a foreground.
2137            assert!(
2138                style("modeline.active").bg.is_some(),
2139                "theme `{}`: modeline.active needs a bar bg",
2140                theme.name
2141            );
2142            assert!(
2143                style("modeline.inactive").bg.is_some() && style("modeline.inactive").fg.is_some(),
2144                "theme `{}`: modeline.inactive needs a muted bar",
2145                theme.name
2146            );
2147            for role in [
2148                "modeline.mode",
2149                "modeline.path",
2150                "modeline.position",
2151                "modeline.lang",
2152                "modeline.mode_item",
2153            ] {
2154                assert!(
2155                    style(role).fg.is_some(),
2156                    "theme `{}`: {role} needs a themed fg",
2157                    theme.name
2158                );
2159            }
2160        }
2161    }
2162
2163    #[test]
2164    fn builtin_ids_capture_resolves_diagnostics_to_legacy() {
2165        // T.4.a parity net (shared by BOTH renderers — each reads
2166        // `resolved.get(ids.diagnostic_*)`): capture finds the ids and
2167        // the resolved styles equal the legacy `Theme::default()`
2168        // diagnostic literals byte-for-byte.
2169        let reg = reg();
2170        let ids = BuiltinElementIds::capture(&reg);
2171        let resolved = reg.resolved();
2172        // MR.2: the per-segment marginalia slots resolve to eza-convention
2173        // defaults, and `annotation_slot` maps slot keys to them.
2174        assert_ne!(ids.completion_annotation_perm_type, ElementId::INVALID);
2175        assert_eq!(
2176            resolved.get(ids.completion_annotation_perm_write).fg,
2177            Some(Color::Rgb(0xf3, 0x8b, 0xa8)) // red
2178        );
2179        assert_eq!(
2180            resolved.get(ids.completion_annotation_perm_exec).fg,
2181            Some(Color::Rgb(0xa6, 0xe3, 0xa1)) // green
2182        );
2183        assert_eq!(
2184            ids.annotation_slot("completion.annotation.perm.exec"),
2185            ids.completion_annotation_perm_exec,
2186            "annotation_slot maps the perm.exec key"
2187        );
2188        assert_eq!(
2189            ids.annotation_slot("completion.annotation.size"),
2190            ids.completion_annotation_size,
2191        );
2192        assert_eq!(
2193            ids.annotation_slot("totally.unknown.slot"),
2194            ids.completion_annotation_custom,
2195            "unknown slots fall back to the custom annotation id"
2196        );
2197        assert_ne!(ids.diagnostic_error, ElementId::INVALID);
2198        assert_ne!(ids.diagnostic_warning, ElementId::INVALID);
2199        assert_ne!(ids.diagnostic_info, ElementId::INVALID);
2200        assert_ne!(ids.diagnostic_hint, ElementId::INVALID);
2201        assert_eq!(
2202            resolved.get(ids.diagnostic_error),
2203            Style::empty().fg(Color::Rgb(0xf3, 0x8b, 0xa8)).bold()
2204        );
2205        assert_eq!(
2206            resolved.get(ids.diagnostic_warning),
2207            Style::empty().fg(Color::Rgb(0xf9, 0xe2, 0xaf)).bold()
2208        );
2209        assert_eq!(
2210            resolved.get(ids.diagnostic_info),
2211            Style::empty().fg(Color::Rgb(0x89, 0xb4, 0xfa))
2212        );
2213        assert_eq!(
2214            resolved.get(ids.diagnostic_hint),
2215            Style::empty().fg(Color::Rgb(0x6c, 0x70, 0x86)).dim()
2216        );
2217    }
2218
2219    #[test]
2220    fn builtin_ids_capture_resolves_picker_rollout_slots() {
2221        // MP.1 (shared by BOTH renderers — each resolves a `Styled`
2222        // segment via `ids.annotation_slot(slot)`): the picker-rollout
2223        // slots intern, `annotation_slot` maps every key, and each family
2224        // resolves to its intended palette token (asserted against a
2225        // sibling slot that shares the token, so no hardcoded hex).
2226        let reg = reg();
2227        let ids = BuiltinElementIds::capture(&reg);
2228        let resolved = reg.resolved();
2229
2230        for (key, id) in [
2231            (
2232                "completion.annotation.location.path",
2233                ids.completion_annotation_location_path,
2234            ),
2235            (
2236                "completion.annotation.location.line",
2237                ids.completion_annotation_location_line,
2238            ),
2239            (
2240                "completion.annotation.location.col",
2241                ids.completion_annotation_location_col,
2242            ),
2243            (
2244                "completion.annotation.status.dirty",
2245                ids.completion_annotation_status_dirty,
2246            ),
2247            (
2248                "completion.annotation.status.active",
2249                ids.completion_annotation_status_active,
2250            ),
2251            (
2252                "completion.annotation.latency.reflex",
2253                ids.completion_annotation_latency_reflex,
2254            ),
2255            (
2256                "completion.annotation.latency.display",
2257                ids.completion_annotation_latency_display,
2258            ),
2259            (
2260                "completion.annotation.latency.background",
2261                ids.completion_annotation_latency_background,
2262            ),
2263            ("completion.annotation.args", ids.completion_annotation_args),
2264            (
2265                "completion.annotation.buffer-id",
2266                ids.completion_annotation_buffer_id,
2267            ),
2268            (
2269                "completion.annotation.register",
2270                ids.completion_annotation_register,
2271            ),
2272        ] {
2273            assert_ne!(id, ElementId::INVALID, "`{key}` should intern");
2274            assert_eq!(ids.annotation_slot(key), id, "annotation_slot maps `{key}`");
2275        }
2276
2277        // Token parity (against sibling slots sharing the same palette token):
2278        let fg = |id| resolved.get(id).fg;
2279        assert_eq!(
2280            fg(ids.completion_annotation_location_line),
2281            fg(ids.completion_annotation_keybinding), // yellow
2282        );
2283        assert_eq!(
2284            fg(ids.completion_annotation_status_dirty),
2285            fg(ids.completion_annotation_perm_write), // red
2286        );
2287        assert_eq!(
2288            fg(ids.completion_annotation_latency_reflex),
2289            fg(ids.completion_annotation_perm_exec), // green
2290        );
2291        assert_eq!(
2292            fg(ids.completion_annotation_latency_display),
2293            fg(ids.completion_annotation_perm_type), // blue
2294        );
2295        assert_eq!(
2296            fg(ids.completion_annotation_register),
2297            fg(ids.completion_annotation_source), // purple
2298        );
2299        // The accent line is distinct from its dim path/col siblings.
2300        assert_ne!(
2301            fg(ids.completion_annotation_location_line),
2302            fg(ids.completion_annotation_location_path),
2303        );
2304    }
2305
2306    #[test]
2307    fn builtin_ids_capture_resolves_diff_to_legacy() {
2308        // T.4.b parity net (shared by both renderers): diff sign
2309        // styles + line/block tints resolve to the legacy literals.
2310        let reg = reg();
2311        let ids = BuiltinElementIds::capture(&reg);
2312        let resolved = reg.resolved();
2313        assert_eq!(
2314            resolved.get(ids.diff_add_sign),
2315            Style::empty().fg(Color::Rgb(0xa6, 0xe3, 0xa1)).bold()
2316        );
2317        assert_eq!(
2318            resolved.get(ids.diff_conflict_sign),
2319            Style::empty().fg(Color::Rgb(0xcb, 0xa6, 0xf7)).bold()
2320        );
2321        // Tints carry the legacy Rgb on the `bg` channel.
2322        assert_eq!(
2323            resolved.get(ids.diff_add_line).bg,
2324            Some(Color::Rgb(0, 50, 0))
2325        );
2326        assert_eq!(
2327            resolved.get(ids.diff_change_line).bg,
2328            Some(Color::Rgb(50, 50, 0))
2329        );
2330        assert_eq!(
2331            resolved.get(ids.diff_deletion_block).bg,
2332            Some(Color::Rgb(60, 0, 0))
2333        );
2334        assert_eq!(
2335            resolved.get(ids.diff_conflict_line).bg,
2336            Some(Color::Rgb(60, 0, 60))
2337        );
2338    }
2339
2340    #[test]
2341    fn terminal_ansi_palette_resolves_to_themed_accents() {
2342        // The embedded terminal's 16-colour palette is sourced from
2343        // `terminal.ansi.*` roles → palette accents, so a `:colorscheme`
2344        // swap recolours the terminal and the colours are readable on dark
2345        // backgrounds (the old hardcoded `0xcd0000`/`0x0000ee` dim-VGA fix).
2346        // Default palette = mocha.
2347        let reg = reg();
2348        let ids = BuiltinElementIds::capture(&reg);
2349        let resolved = reg.resolved();
2350        let fg = |i: usize| {
2351            resolved
2352                .get(ids.terminal_ansi[i])
2353                .fg
2354                .map(|c| c.to_rgb_u32(0))
2355        };
2356        // red / green / yellow / blue map to the mocha accents — NOT the
2357        // dim VGA ANSI values (0xcd0000 / 0x00cd00 / 0x0000ee).
2358        assert_eq!(fg(1), Some(0x00f3_8ba8), "ANSI red = mocha red");
2359        assert_eq!(fg(2), Some(0x00a6_e3a1), "ANSI green = mocha green");
2360        assert_eq!(
2361            fg(4),
2362            Some(0x0089_b4fa),
2363            "ANSI blue = mocha blue (not 0x0000ee)"
2364        );
2365        assert_eq!(fg(5), Some(0x00f5_c2e7), "ANSI magenta = pink");
2366        assert_eq!(fg(6), Some(0x0094_e2d5), "ANSI cyan = teal");
2367        // bright variants reuse the same accents; black/white brighten.
2368        assert_eq!(fg(9), fg(1), "bright red reuses red");
2369        assert_ne!(
2370            fg(0),
2371            fg(8),
2372            "black (surface1) and bright black (surface2) differ"
2373        );
2374    }
2375
2376    #[test]
2377    fn terminal_ansi_palette_follows_colorscheme_swap() {
2378        // Swapping the palette recolours the terminal ANSI roles — proof
2379        // the terminal is theme-driven, not hardcoded. Gruvbox blue differs
2380        // from mocha blue.
2381        let mocha = InMemoryThemeRegistry::with_defaults();
2382        let ids = BuiltinElementIds::capture(&mocha);
2383        let mocha_blue = mocha
2384            .resolved()
2385            .get(ids.terminal_ansi[4])
2386            .fg
2387            .unwrap()
2388            .to_rgb_u32(0);
2389
2390        let gruv = InMemoryThemeRegistry::new(crate::palette::gruvbox_dark_palette());
2391        register_builtins(&gruv);
2392        let gids = BuiltinElementIds::capture(&gruv);
2393        let gruv_blue = gruv
2394            .resolved()
2395            .get(gids.terminal_ansi[4])
2396            .fg
2397            .unwrap()
2398            .to_rgb_u32(0);
2399
2400        assert_ne!(
2401            mocha_blue, gruv_blue,
2402            "terminal blue tracks the colorscheme"
2403        );
2404    }
2405
2406    #[test]
2407    fn styled_marginalia_slots_follow_colorscheme_swap() {
2408        // MARG §8: the per-segment file-metadata slots are palette
2409        // refs, so swapping the colorscheme recolors them — the shared
2410        // resolution both renderer peers read (`resolved.get(
2411        // ids.annotation_slot(slot))`). Gruvbox red/peach differ from
2412        // mocha's, proving the marginalia is theme-driven, not baked.
2413        let mocha = InMemoryThemeRegistry::with_defaults();
2414        let mids = BuiltinElementIds::capture(&mocha);
2415        let m_write = mocha
2416            .resolved()
2417            .get(mids.annotation_slot("completion.annotation.perm.write"))
2418            .fg
2419            .unwrap()
2420            .to_rgb_u32(0);
2421
2422        let gruv = InMemoryThemeRegistry::new(crate::palette::gruvbox_dark_palette());
2423        register_builtins(&gruv);
2424        let gids = BuiltinElementIds::capture(&gruv);
2425        let g_write = gruv
2426            .resolved()
2427            .get(gids.annotation_slot("completion.annotation.perm.write"))
2428            .fg
2429            .unwrap()
2430            .to_rgb_u32(0);
2431
2432        assert_ne!(m_write, g_write, "perm.write tracks the colorscheme");
2433    }
2434
2435    #[test]
2436    fn picker_rollout_slots_follow_colorscheme_swap() {
2437        // MARG §9: the picker-rollout slots (location/status/latency/…)
2438        // are palette refs too, so a colorscheme swap recolors the
2439        // command/buffer/grep/jumps marginalia on BOTH peers (shared
2440        // `resolved.get(ids.annotation_slot(slot))` resolution).
2441        let swap = |slot: &str| {
2442            let mocha = InMemoryThemeRegistry::with_defaults();
2443            let mids = BuiltinElementIds::capture(&mocha);
2444            let m = mocha
2445                .resolved()
2446                .get(mids.annotation_slot(slot))
2447                .fg
2448                .unwrap()
2449                .to_rgb_u32(0);
2450            let gruv = InMemoryThemeRegistry::new(crate::palette::gruvbox_dark_palette());
2451            register_builtins(&gruv);
2452            let gids = BuiltinElementIds::capture(&gruv);
2453            let g = gruv
2454                .resolved()
2455                .get(gids.annotation_slot(slot))
2456                .fg
2457                .unwrap()
2458                .to_rgb_u32(0);
2459            (m, g)
2460        };
2461        for slot in [
2462            "completion.annotation.location.line",
2463            "completion.annotation.status.dirty",
2464            "completion.annotation.latency.display",
2465            "completion.annotation.register",
2466        ] {
2467            let (m, g) = swap(slot);
2468            assert_ne!(m, g, "`{slot}` should track the colorscheme");
2469        }
2470    }
2471
2472    #[test]
2473    fn builtin_ids_capture_resolves_chrome_to_legacy() {
2474        // Parity net: the writer-free pane/file-tree elements resolve to
2475        // their palette accent keys. `file_tree.dir`/`.hidden` migrated off
2476        // `ansi.*` to `blue`/`overlay` so each theme's tuned accent applies
2477        // and GPUI renders readable truecolor (the dull-blue folder fix).
2478        let reg = reg();
2479        let ids = BuiltinElementIds::capture(&reg);
2480        let resolved = reg.resolved();
2481        assert_eq!(
2482            resolved.get(ids.pane_inactive_overlay),
2483            Style::empty().dim()
2484        );
2485        assert_eq!(
2486            resolved.get(ids.file_tree_dir),
2487            Style::empty().fg(Color::Rgb(0x89, 0xb4, 0xfa)).bold()
2488        );
2489        assert_eq!(
2490            resolved.get(ids.file_tree_hidden),
2491            Style::empty().fg(Color::Rgb(0x6c, 0x70, 0x86)).dim()
2492        );
2493        assert_eq!(resolved.get(ids.file_tree_file), Style::empty());
2494    }
2495
2496    #[test]
2497    fn editor_canvas_resolves_to_catppuccin_mocha() {
2498        // T.11.0b parity net: the canvas elements (bg / fg / block
2499        // cursor / popup surface) resolve to the exact legacy
2500        // Catppuccin-Mocha literals `GpuiTheme::default()` carried, now
2501        // sourced from the palette's background family. A `:colorscheme`
2502        // / palette swap recolors them; the default stays byte-identical.
2503        let reg = reg();
2504        assert_eq!(
2505            resolved_of(&reg, "editor.background").bg,
2506            Some(Color::Rgb(0x1e, 0x1e, 0x2e))
2507        );
2508        assert_eq!(
2509            resolved_of(&reg, "editor.foreground").fg,
2510            Some(Color::Rgb(0xcd, 0xd6, 0xf4))
2511        );
2512        // Block cursor is inverted: bg = text, fg = base.
2513        let cursor = resolved_of(&reg, "editor.cursor");
2514        assert_eq!(cursor.bg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
2515        assert_eq!(cursor.fg, Some(Color::Rgb(0x1e, 0x1e, 0x2e)));
2516        assert_eq!(
2517            resolved_of(&reg, "ui.popup.background").bg,
2518            Some(Color::Rgb(0x18, 0x18, 0x25))
2519        );
2520        // Popup header: bold blue title + muted overlay hint (shared by the
2521        // TUI + GPUI peers so the accent is themeable and identical).
2522        let title = resolved_of(&reg, "ui.popup.title");
2523        assert_eq!(title.fg, Some(Color::Rgb(0x89, 0xb4, 0xfa)));
2524        assert!(title.modifiers.bold);
2525        assert_eq!(
2526            resolved_of(&reg, "ui.popup.hint").fg,
2527            Some(Color::Rgb(0x6c, 0x70, 0x86))
2528        );
2529    }
2530
2531    #[test]
2532    fn builtin_ids_capture_resolves_cursorline_and_messages_to_legacy() {
2533        // T.4.d parity net: current-line tint (bg) + *messages* level
2534        // styles resolve to the legacy literals.
2535        let reg = reg();
2536        let ids = BuiltinElementIds::capture(&reg);
2537        let resolved = reg.resolved();
2538        assert_eq!(
2539            resolved.get(ids.editor_cursor_line).bg,
2540            Some(Color::Indexed(236))
2541        );
2542        assert_eq!(resolved.get(ids.messages_trace), Style::empty().dim());
2543        assert_eq!(
2544            resolved.get(ids.messages_debug),
2545            Style::empty().fg(Color::Rgb(0x74, 0xc7, 0xec))
2546        );
2547        // INFO carries a distinct colour (green) since `feat(messages):
2548        // give INFO a distinct colour in *messages*`; this parity net was
2549        // not updated when that landed and still asserted the pre-colour
2550        // `empty()`, leaving the test red on main.
2551        assert_eq!(
2552            resolved.get(ids.messages_info),
2553            Style::empty().fg(Color::Rgb(0xa6, 0xe3, 0xa1))
2554        );
2555        assert_eq!(
2556            resolved.get(ids.messages_warn),
2557            Style::empty().fg(Color::Rgb(0xf9, 0xe2, 0xaf)).bold()
2558        );
2559        assert_eq!(
2560            resolved.get(ids.messages_error),
2561            Style::empty().fg(Color::Rgb(0xf3, 0x8b, 0xa8)).bold()
2562        );
2563        assert_eq!(
2564            resolved.get(ids.messages_timestamp),
2565            Style::empty().fg(Color::Rgb(0x6c, 0x70, 0x86)).dim()
2566        );
2567    }
2568
2569    #[test]
2570    fn builtin_ids_capture_resolves_syntax_and_whitespace_to_legacy() {
2571        // T.5 parity net (shared by the cell builder + both
2572        // display-line paths): syntax categories + whitespace markers
2573        // resolve to the legacy `Theme::syntax_style` literals.
2574        let reg = reg();
2575        let ids = BuiltinElementIds::capture(&reg);
2576        let resolved = reg.resolved();
2577        assert_eq!(
2578            resolved.get(ids.syntax_keyword),
2579            Style::empty().fg(Color::Rgb(0xcb, 0xa6, 0xf7)).bold()
2580        );
2581        assert_eq!(
2582            resolved.get(ids.syntax_string),
2583            Style::empty().fg(Color::Rgb(0xa6, 0xe3, 0xa1))
2584        );
2585        // LineComment inherits Comment → identical resolved style.
2586        assert_eq!(
2587            resolved.get(ids.syntax_line_comment),
2588            resolved.get(ids.syntax_comment)
2589        );
2590        // T.10: heading.1 resolves with the rich `ExtraBold` weight too;
2591        // F.3 adds the rich `scale` (1.6×). Not underlined.
2592        assert_eq!(
2593            resolved.get(ids.syntax_heading_1),
2594            Style::empty()
2595                .fg(Color::Rgb(0xf3, 0x8b, 0xa8))
2596                .bold()
2597                .weight(Weight::ExtraBold)
2598                .scale(FontScale::from_ratio(1.6))
2599        );
2600        assert_eq!(
2601            resolved.get(ids.whitespace_trailing),
2602            Style::empty().fg(Color::Rgb(0xf3, 0x8b, 0xa8))
2603        );
2604        assert_eq!(
2605            resolved.get(ids.whitespace),
2606            Style::empty().fg(Color::Rgb(0x6c, 0x70, 0x86)).dim()
2607        );
2608    }
2609
2610    #[test]
2611    fn inline_code_is_legible_on_the_cursorline() {
2612        // Regression: `syntax.markup_raw` (markdown `` `code` ``, org
2613        // `~code~` / `=code=`) used to be `fg=overlay` + `.dim()` — the
2614        // muted whitespace-marker role, dimmed — which on the cursorline
2615        // background fell to ~1.5:1 and was very hard to read. Inline code
2616        // is content, so it now takes the string/literal accent and is
2617        // never dimmed. The span-layering contract keeps it foreground-only,
2618        // so it must be legible on ANY row background, the cursorline tint
2619        // especially.
2620        let reg = reg();
2621        let ids = BuiltinElementIds::capture(&reg);
2622        let resolved = reg.resolved();
2623
2624        let raw = resolved.get(ids.syntax_markup_raw);
2625        // Never dimmed — the dim modifier was half the readability loss.
2626        assert!(!raw.modifiers.dim, "inline code must not be dimmed");
2627        // A legible content accent — the string/literal colour, not the
2628        // muted `overlay` role it once shared with whitespace markers.
2629        assert_eq!(
2630            raw.fg,
2631            resolved.get(ids.syntax_string).fg,
2632            "inline code takes the string/literal accent"
2633        );
2634        // …and distinct from the cursorline background, so it stays readable
2635        // when the cursor is on its line (the reported bug).
2636        assert_ne!(
2637            raw.fg,
2638            resolved.get(ids.editor_cursor_line).bg,
2639            "inline code foreground must differ from the cursorline background"
2640        );
2641    }
2642
2643    #[test]
2644    fn heading_builtins_carry_rich_weight() {
2645        // T.10: the heading demo consumers set a rich-vocabulary
2646        // `weight` on top of their fg + bold. The GPUI peer reads
2647        // `Style::weight` per run; the TUI degrades it to bold.
2648        let reg = reg();
2649        let ids = BuiltinElementIds::capture(&reg);
2650        let resolved = reg.resolved();
2651        assert_eq!(
2652            resolved.get(ids.syntax_heading_1).weight,
2653            Some(Weight::ExtraBold)
2654        );
2655        assert_eq!(
2656            resolved.get(ids.syntax_heading_2).weight,
2657            Some(Weight::Bold)
2658        );
2659        assert_eq!(
2660            resolved.get(ids.syntax_heading_3).weight,
2661            Some(Weight::Bold)
2662        );
2663        // heading.4-6 keep bold-only, no rich weight (unchanged).
2664        assert_eq!(resolved.get(ids.syntax_heading_4).weight, None);
2665        assert_eq!(resolved.get(ids.syntax_heading_5).weight, None);
2666        assert_eq!(resolved.get(ids.syntax_heading_6).weight, None);
2667        // The bold bool + fg survive alongside the weight.
2668        assert!(resolved.get(ids.syntax_heading_1).modifiers.bold);
2669        // …and h1 is NOT underlined. It was, on the premise that heading
2670        // tokens are markdown-only and an h1 is a document title; org captures
2671        // `@text.title.1` for every `*` headline, so that underlined every
2672        // top-level section in the file.
2673        assert!(!resolved.get(ids.syntax_heading_1).modifiers.underline);
2674    }
2675
2676    #[test]
2677    fn heading_builtins_carry_descending_scale() {
2678        // F.3 (Thread F): heading levels carry a rich-vocabulary `scale`
2679        // descending by level (emacs `:height`). The GPUI peer honors it
2680        // via variable row height (F.2); the TUI degrades (no per-line
2681        // font size on a cell grid). Every level sets a scale > 1.0 and
2682        // h(n) is strictly larger than h(n+1).
2683        let reg = reg();
2684        let ids = BuiltinElementIds::capture(&reg);
2685        let resolved = reg.resolved();
2686        let scales = [
2687            resolved.get(ids.syntax_heading_1).scale,
2688            resolved.get(ids.syntax_heading_2).scale,
2689            resolved.get(ids.syntax_heading_3).scale,
2690            resolved.get(ids.syntax_heading_4).scale,
2691            resolved.get(ids.syntax_heading_5).scale,
2692            resolved.get(ids.syntax_heading_6).scale,
2693        ];
2694        assert_eq!(scales[0], Some(FontScale::from_ratio(1.6)));
2695        assert_eq!(scales[1], Some(FontScale::from_ratio(1.4)));
2696        assert_eq!(scales[2], Some(FontScale::from_ratio(1.25)));
2697        assert_eq!(scales[3], Some(FontScale::from_ratio(1.15)));
2698        assert_eq!(scales[4], Some(FontScale::from_ratio(1.1)));
2699        assert_eq!(scales[5], Some(FontScale::from_ratio(1.05)));
2700        // Strictly descending and all above body (1.0×).
2701        for w in scales.windows(2) {
2702            assert!(w[0].unwrap().0 > w[1].unwrap().0);
2703        }
2704        assert!(scales[5].unwrap().0 > FontScale::ONE.0);
2705    }
2706
2707    #[test]
2708    fn builtin_ids_capture_resolves_overlays_to_registered() {
2709        // T.6 parity net (shared by BOTH renderers): search / selection
2710        // / document-highlight / substitute / inlay / completion-
2711        // annotation elements resolve to their registered defaults, so
2712        // each peer reads the SAME style (closing the prior drift).
2713        let reg = reg();
2714        let ids = BuiltinElementIds::capture(&reg);
2715        let resolved = reg.resolved();
2716        assert_ne!(ids.selection, ElementId::INVALID);
2717        // Search match = overlay0 bg tint (was a fg recolor in the TUI).
2718        assert_eq!(
2719            resolved.get(ids.search_match).bg,
2720            Some(Color::Rgb(0x6c, 0x70, 0x86))
2721        );
2722        assert_eq!(
2723            resolved.get(ids.search_current).bg,
2724            Some(Color::Rgb(0x6c, 0x5a, 0x1e))
2725        );
2726        assert_eq!(
2727            resolved.get(ids.selection).bg,
2728            Some(Color::Rgb(0x45, 0x47, 0x5a))
2729        );
2730        // Document-highlight keeps its 3 distinct kinds (both peers).
2731        assert_eq!(
2732            resolved.get(ids.doc_highlight_read).bg,
2733            Some(Color::Rgb(20, 50, 25))
2734        );
2735        assert_eq!(
2736            resolved.get(ids.doc_highlight_write).bg,
2737            Some(Color::Rgb(60, 20, 20))
2738        );
2739        assert_eq!(
2740            resolved.get(ids.doc_highlight_text).bg,
2741            Some(Color::Rgb(20, 30, 60))
2742        );
2743        assert_eq!(
2744            resolved.get(ids.substitute_preview).bg,
2745            Some(Color::Rgb(0xf3, 0x8b, 0xa8))
2746        );
2747        assert_eq!(
2748            resolved.get(ids.inlay_hint).fg,
2749            Some(Color::Rgb(0x7f, 0x84, 0x9c))
2750        );
2751        // Completion-annotation base (unselected) colours.
2752        assert_eq!(
2753            resolved.get(ids.completion_annotation_keybinding).fg,
2754            Some(Color::Rgb(0xf9, 0xe2, 0xaf)) // yellow
2755        );
2756        assert_eq!(
2757            resolved.get(ids.completion_annotation_source).fg,
2758            Some(Color::Rgb(0xcb, 0xa6, 0xf7)) // mauve
2759        );
2760        assert_eq!(
2761            resolved.get(ids.completion_annotation_doc).fg,
2762            Some(Color::Rgb(0x89, 0xdc, 0xeb))
2763        );
2764    }
2765
2766    #[test]
2767    fn builtin_ids_default_is_invalid_and_styleless() {
2768        // The placeholder a default `RenderState` carries: all ids
2769        // INVALID, every read styleless (never a panic).
2770        let ids = BuiltinElementIds::default();
2771        assert_eq!(ids.diagnostic_error, ElementId::INVALID);
2772        let resolved = ResolvedTheme::default();
2773        assert_eq!(resolved.get(ids.diagnostic_error), Style::empty());
2774    }
2775
2776    #[test]
2777    fn inherit_reproduces_parent_style() {
2778        let reg = reg();
2779        // syntax.line_comment inherits syntax.comment → identical.
2780        assert_eq!(
2781            resolved_of(&reg, "syntax.line_comment"),
2782            resolved_of(&reg, "syntax.comment")
2783        );
2784    }
2785
2786    #[test]
2787    fn register_is_idempotent_by_name() {
2788        let reg = InMemoryThemeRegistry::new(default_palette());
2789        let a = reg.register(
2790            ElementName::from_static("x.y"),
2791            ElementOwner::Core,
2792            StyleSpec::new().fg("purple"),
2793            "",
2794        );
2795        let b = reg.register(
2796            ElementName::from_static("x.y"),
2797            ElementOwner::Core,
2798            StyleSpec::new().fg("red"),
2799            "",
2800        );
2801        assert_eq!(a, b);
2802    }
2803
2804    #[test]
2805    fn dotted_fallback_resolves_through_parent() {
2806        let reg = InMemoryThemeRegistry::new(default_palette());
2807        reg.register(
2808            ElementName::from_static("markdown.heading"),
2809            ElementOwner::Core,
2810            StyleSpec::new().fg("purple").bold(),
2811            "",
2812        );
2813        // An element whose default inherits an UNREGISTERED specific
2814        // name falls back through the dotted parent.
2815        let id = reg.register(
2816            ElementName::from_static("uses_fallback"),
2817            ElementOwner::Core,
2818            StyleSpec::new().inherit("markdown.heading.1"),
2819            "",
2820        );
2821        let s = reg.resolved().get(id);
2822        assert_eq!(s.fg, Some(Color::Rgb(0xcb, 0xa6, 0xf7)));
2823        assert!(s.modifiers.bold);
2824    }
2825
2826    #[test]
2827    fn unknown_palette_key_leaves_channel_unset() {
2828        let reg = InMemoryThemeRegistry::new(Palette::new()); // empty palette
2829        let id = reg.register(
2830            ElementName::from_static("orphan"),
2831            ElementOwner::Core,
2832            StyleSpec::new().fg("no.such.key"),
2833            "",
2834        );
2835        // Missing key logs + leaves fg None (no panic, no garbage).
2836        assert_eq!(reg.resolved().get(id).fg, None);
2837    }
2838
2839    #[test]
2840    fn set_override_overlays_on_resolved_default() {
2841        // T.9: a theme-global override overlays its set fields on the
2842        // element's resolved default; unset fields keep the default.
2843        let reg = reg();
2844        let base = resolved_of(&reg, "syntax.keyword"); // mauve + bold
2845        assert_eq!(base.fg, Some(Color::Rgb(0xcb, 0xa6, 0xf7)));
2846        assert!(base.modifiers.bold);
2847        // Override only the fg (literal); bold from the default survives.
2848        reg.set_override(
2849            ElementName::from_static("syntax.keyword"),
2850            StyleSpec::new().fg(Color::Rgb(1, 2, 3)),
2851        );
2852        let after = resolved_of(&reg, "syntax.keyword");
2853        assert_eq!(after.fg, Some(Color::Rgb(1, 2, 3)));
2854        assert!(
2855            after.modifiers.bold,
2856            "default bold survives a fg-only override"
2857        );
2858    }
2859
2860    #[test]
2861    fn set_theme_swaps_palette_and_overrides() {
2862        // T.9: `:colorscheme` swap replaces palette + overrides
2863        // atomically; prior overrides are cleared.
2864        let reg = reg();
2865        reg.set_override(
2866            ElementName::from_static("syntax.string"),
2867            StyleSpec::new().fg(Color::Rgb(9, 9, 9)),
2868        );
2869        assert_eq!(
2870            resolved_of(&reg, "syntax.string").fg,
2871            Some(Color::Rgb(9, 9, 9))
2872        );
2873        // New theme: palette with a different `green`, no overrides.
2874        let palette = default_palette().with("green", Color::Rgb(4, 5, 6));
2875        reg.set_theme(palette, Vec::new());
2876        // string references `green` → now (4,5,6); the prior override is gone.
2877        assert_eq!(
2878            resolved_of(&reg, "syntax.string").fg,
2879            Some(Color::Rgb(4, 5, 6))
2880        );
2881    }
2882
2883    #[test]
2884    fn set_theme_to_macchiato_recolors_keyword_to_macchiato_mauve() {
2885        // T.9.b: swapping to the Macchiato palette re-resolves
2886        // `syntax.keyword` (which references `mauve`) to Macchiato's
2887        // mauve. This is the registry half of the `:colorscheme` swap.
2888        let reg = reg();
2889        let ids = BuiltinElementIds::capture(&reg);
2890        // Before: mocha mauve.
2891        assert_eq!(
2892            reg.resolved().get(ids.syntax_keyword).fg,
2893            Some(Color::Rgb(0xcb, 0xa6, 0xf7))
2894        );
2895        reg.set_theme(crate::macchiato_palette(), Vec::new());
2896        assert_eq!(
2897            reg.resolved().get(ids.syntax_keyword).fg,
2898            Some(Color::Rgb(0xc6, 0xa0, 0xf6)),
2899            "keyword resolves to Macchiato mauve after the swap"
2900        );
2901    }
2902
2903    #[test]
2904    fn builtin_themes_lookup_by_name_returns_macchiato_palette() {
2905        // T.9.b: the named-theme lookup `:colorscheme` performs resolves
2906        // `catppuccin-macchiato` to the Macchiato palette.
2907        let theme = crate::builtin_themes()
2908            .into_iter()
2909            .find(|t| t.name == "catppuccin-macchiato")
2910            .expect("macchiato registered");
2911        assert_eq!(
2912            theme.palette.get(&crate::PaletteKey::from_static("purple")),
2913            Some(Color::Rgb(0xc6, 0xa0, 0xf6))
2914        );
2915        assert!(theme.overrides.is_empty());
2916    }
2917
2918    #[test]
2919    fn theme_catalog_register_enumerate_and_apply() {
2920        // T.11.1: the named-theme catalog — builtins seeded at boot,
2921        // listed by `theme_names`, swapped by `apply_theme`;
2922        // `register_theme` adds a custom theme (the init.rs / plugin
2923        // seam). `apply_theme` recolours the canvas elements (T.11.0b).
2924        let reg = reg();
2925        let names = reg.theme_names();
2926        assert!(names.iter().any(|n| n == "catppuccin-mocha"));
2927        assert!(names.iter().any(|n| n == "catppuccin-latte"));
2928
2929        // Swap to the registered LIGHT theme → canvas resolves light.
2930        assert!(reg.apply_theme("catppuccin-latte"));
2931        assert_eq!(
2932            resolved_of(&reg, "editor.background").bg,
2933            Some(Color::Rgb(0xef, 0xf1, 0xf5))
2934        );
2935        // Unknown name → false, active theme untouched (still Latte).
2936        assert!(!reg.apply_theme("no-such-theme"));
2937        assert_eq!(
2938            resolved_of(&reg, "editor.background").bg,
2939            Some(Color::Rgb(0xef, 0xf1, 0xf5))
2940        );
2941
2942        // register_theme adds a theme that apply_theme can then swap to.
2943        reg.register_theme(NamedTheme {
2944            name: "test-custom",
2945            palette: crate::palette::default_palette(),
2946            overrides: Vec::new(),
2947        });
2948        assert!(reg.theme_names().iter().any(|n| n == "test-custom"));
2949        assert!(reg.apply_theme("test-custom"));
2950        assert_eq!(
2951            resolved_of(&reg, "editor.background").bg,
2952            Some(Color::Rgb(0x1e, 0x1e, 0x2e))
2953        );
2954    }
2955
2956    #[test]
2957    fn describe_returns_metadata_and_resolved_style() {
2958        // T.9.d: `describe` bundles owner + doc + authoring default +
2959        // concrete resolved style for a registered element.
2960        let reg = reg();
2961        let info = reg
2962            .describe(&ElementName::from_static("syntax.keyword"))
2963            .expect("syntax.keyword registered");
2964        assert_eq!(info.name.as_str(), "syntax.keyword");
2965        assert_eq!(info.owner, ElementOwner::Core);
2966        assert_eq!(info.doc, "Language keywords.");
2967        // Authoring default references the `mauve` palette key + bold.
2968        assert_eq!(
2969            info.default.fg,
2970            Some(ColorRef::Palette(crate::PaletteKey::from_static("purple")))
2971        );
2972        assert_eq!(info.default.modifiers.bold, Some(true));
2973        // Resolved style is the concrete mocha mauve + bold.
2974        assert_eq!(info.resolved.fg, Some(Color::Rgb(0xcb, 0xa6, 0xf7)));
2975        assert!(info.resolved.modifiers.bold);
2976    }
2977
2978    #[test]
2979    fn element_names_are_sorted_and_include_builtins() {
2980        // T.9.d follow-up: `element_names` backs `gen:elements`
2981        // (`:describe-element <Tab>`). Every registered element appears, and
2982        // the list is sorted so popup ordering is stable across runs.
2983        let reg = reg();
2984        let names = reg.element_names();
2985        assert!(
2986            names.contains(&"syntax.keyword".to_string()),
2987            "builtin element must be enumerable for completion"
2988        );
2989        assert!(names.contains(&"syntax.comment".to_string()));
2990        let mut sorted = names.clone();
2991        sorted.sort();
2992        assert_eq!(names, sorted, "element_names must be sorted");
2993        // Every name resolves via `describe` — no phantom entries.
2994        for n in &names {
2995            assert!(
2996                reg.describe(&ElementName::from(n.clone())).is_some(),
2997                "enumerated element `{n}` must be describable"
2998            );
2999        }
3000    }
3001
3002    #[test]
3003    fn describe_unknown_element_is_none() {
3004        let reg = reg();
3005        assert!(
3006            reg.describe(&ElementName::from_static("no.such.element"))
3007                .is_none()
3008        );
3009    }
3010
3011    #[test]
3012    fn describe_reflects_active_override() {
3013        // T.9.d: the `resolved` field tracks the active override set —
3014        // after `set_override` the described resolved style updates,
3015        // while the authoring `default` (the reference form) is
3016        // unchanged.
3017        let reg = reg();
3018        reg.set_override(
3019            ElementName::from_static("syntax.keyword"),
3020            StyleSpec::new().fg(Color::Rgb(1, 2, 3)),
3021        );
3022        let info = reg
3023            .describe(&ElementName::from_static("syntax.keyword"))
3024            .expect("registered");
3025        assert_eq!(info.resolved.fg, Some(Color::Rgb(1, 2, 3)));
3026        // The override only set fg; the default's bold still resolves.
3027        assert!(info.resolved.modifiers.bold);
3028        // The authoring default still references the palette key.
3029        assert_eq!(
3030            info.default.fg,
3031            Some(ColorRef::Palette(crate::PaletteKey::from_static("purple")))
3032        );
3033    }
3034
3035    #[test]
3036    fn describe_reports_inherit_parent() {
3037        // T.9.d: an element whose default inherits another surfaces the
3038        // parent in `default.inherit` so the help view can show it.
3039        let reg = reg();
3040        let info = reg
3041            .describe(&ElementName::from_static("syntax.line_comment"))
3042            .expect("registered");
3043        assert_eq!(
3044            info.default
3045                .inherit
3046                .as_ref()
3047                .map(|n| n.as_str().to_string()),
3048            Some("syntax.comment".to_string())
3049        );
3050    }
3051
3052    #[test]
3053    fn palette_swap_rebuilds_resolved_table() {
3054        let reg = reg();
3055        let before = resolved_of(&reg, "syntax.keyword");
3056        assert_eq!(before.fg, Some(Color::Rgb(0xcb, 0xa6, 0xf7)));
3057        // Swap "purple" to a different color; keyword re-colors.
3058        let new_palette = default_palette().with("purple", Color::Rgb(1, 2, 3));
3059        reg.set_palette(new_palette);
3060        let after = resolved_of(&reg, "syntax.keyword");
3061        assert_eq!(after.fg, Some(Color::Rgb(1, 2, 3)));
3062        assert!(reg.resolved().version() > 0);
3063    }
3064}