@promptctl/cc-candybar 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.mjs +86 -85
  2. package/package.json +6 -6
  3. package/schema/cc-candybar.schema.json +193 -4
  4. package/src/check.ts +49 -27
  5. package/src/click/wire.ts +16 -0
  6. package/src/config/action.ts +57 -22
  7. package/src/config/default-dsl-config.ts +424 -55
  8. package/src/config/dsl-loader.ts +14 -2
  9. package/src/config/dsl-types.ts +59 -0
  10. package/src/config/loader/actions.ts +283 -109
  11. package/src/config/loader/cross-ref.ts +148 -28
  12. package/src/config/loader/emit-schema.ts +2 -0
  13. package/src/config/loader/globals.ts +118 -31
  14. package/src/config/loader/merge.ts +58 -1
  15. package/src/config/loader/persist-target.ts +32 -0
  16. package/src/config/loader/presets.ts +107 -0
  17. package/src/config/option-domain.ts +164 -0
  18. package/src/config/presets.ts +156 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +61 -7
  21. package/src/daemon/config-overrides-store.ts +322 -0
  22. package/src/daemon/paths.ts +10 -0
  23. package/src/daemon/render-payload.ts +84 -19
  24. package/src/daemon/server.ts +68 -55
  25. package/src/daemon/verbs/config-validators.ts +127 -0
  26. package/src/daemon/verbs/index.ts +129 -2
  27. package/src/daemon/verbs/state-validators.ts +98 -586
  28. package/src/daemon/verbs/validator-registry.ts +457 -0
  29. package/src/demo/dsl.ts +17 -10
  30. package/src/dsl/node-registry.ts +54 -39
  31. package/src/dsl/render.ts +158 -46
  32. package/src/help-text.ts +3 -3
  33. package/src/install/index.ts +2 -2
  34. package/src/render/action.ts +155 -33
  35. package/src/render/active-segment.ts +78 -0
  36. package/src/render/menu.ts +16 -11
  37. package/src/render/picker.ts +51 -13
  38. package/src/render/segment-color.ts +74 -0
  39. package/src/segments/git.ts +389 -48
  40. package/src/template-engine/colors.ts +67 -45
  41. package/src/template-engine/engine.ts +11 -12
  42. package/src/themes/index.ts +1 -4
  43. package/src/themes/palette-resolvers.ts +22 -30
  44. package/src/themes/policy.ts +37 -16
package/src/dsl/render.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // the input values (kind discriminators, layout length, palette presence)
12
12
  // govern output, not whether operations run.
13
13
 
14
- import type { RichText, PaletteResolver, ThemeKey } from "@promptctl/rich-js";
14
+ import type { RichText, Palette, ThemeKey } from "@promptctl/rich-js";
15
15
  import { ColorSpec, Style, lighten, IDENTITY } from "@promptctl/rich-js";
16
16
  import type { Engine, Template } from "@promptctl/go-template-js";
17
17
  import type {
@@ -21,6 +21,8 @@ import type {
21
21
  LayoutNode,
22
22
  } from "../config/dsl-types.js";
23
23
  import { HUE_STEP_VAR } from "../config/dsl-types.js";
24
+ import { perConfigDomainsFor } from "../config/option-domain.js";
25
+ import { PRESET_FLOOR, presetNames, presetRoot } from "../config/presets.js";
24
26
  import type { VariableStore } from "../var-system/store.js";
25
27
  import type { SourceRegistry } from "../var-system/sources.js";
26
28
  import {
@@ -30,11 +32,12 @@ import {
30
32
  } from "../var-system/sources.js";
31
33
  import type { BuildLineOptions } from "../render/strip.js";
32
34
  import { DEFAULT_PADDING, renderStripCells } from "../render/strip.js";
33
- import { resolverForThemeName } from "../themes/index.js";
35
+ import { paletteForThemeName } from "../themes/index.js";
34
36
  import { buildScope } from "../template-engine/scope.js";
35
37
  import {
36
38
  createCcCandybarEngine,
37
39
  evaluateWhen,
40
+ resolveSegmentColors,
38
41
  } from "../template-engine/index.js";
39
42
  import {
40
43
  compileActions,
@@ -47,6 +50,11 @@ import {
47
50
  collectMenuDrops,
48
51
  type MenuRuntime,
49
52
  } from "../render/menu.js";
53
+ import {
54
+ createActiveSegmentRef,
55
+ type ActiveSegmentRef,
56
+ } from "../render/active-segment.js";
57
+ import { segmentColorFuncs } from "../render/segment-color.js";
50
58
  // [LAW:one-way-deps] The node-type registry sits below this driver: it owns the
51
59
  // compiled node shapes + each kind's compile/render, dispatched via nodeType().
52
60
  // render.ts threads the recursion (compileChild/renderChild) + the hue counter in
@@ -71,14 +79,27 @@ import {
71
79
  // owns node behavior); this driver only assembles + walks them.
72
80
  export interface CompiledConfig {
73
81
  readonly segments: CompiledSegments;
74
- readonly root: CompiledNode;
75
- // [LAW:locality-or-seam] The menu runtime the engine's `menu` func closes over,
76
- // surfaced here so renderDsl can publish each segment's placement into it before
77
- // that segment's template evaluates. One instance per compiled config; its
78
- // `current` is mutated synchronously within a single renderDsl walk (renders are
79
- // sequential + synchronous, so no cross-render leak) the spatial cousin of the
80
- // hue cursor, one owner. [LAW:no-ambient-temporal-coupling]
82
+ // [LAW:dataflow-not-control-flow] EVERY preset's layout, compiled up front and
83
+ // keyed by preset name the render selects one by name rather than compiling
84
+ // per session. This is the same move `looks` makes one level down (every
85
+ // look's ThemeKey is resolved at load; the render picks one), and it is what
86
+ // lets a per-SESSION preset pick ride a per-ENTRY compilation: one RenderCache
87
+ // entry serves many sessions, so nothing session-shaped may be compiled here.
88
+ // Total over `presetNames` — every selectable name, floor included — so the
89
+ // lookup needs no absent case. A preset declaring no `root` of its own maps
90
+ // to the config's own compiled root: the identity element, not a special
91
+ // case.
92
+ readonly roots: ReadonlyMap<string, CompiledNode>;
93
+ // [LAW:locality-or-seam] The menu runtime the engine's `menu` func closes over.
81
94
  readonly menuRuntime: MenuRuntime;
95
+ // [LAW:one-source-of-truth] The single "which segment is rendering" record
96
+ // every segment-scoped template function reads — the menu's identity, the
97
+ // `color` func's palette, the `bgOf` func's background. Surfaced here so the
98
+ // walk can publish into it. One instance per compiled config; mutated
99
+ // synchronously within a single renderDsl walk (renders are sequential +
100
+ // synchronous, so no cross-render leak) — the spatial cousin of the hue
101
+ // cursor, one owner. [LAW:no-ambient-temporal-coupling]
102
+ readonly activeSegment: ActiveSegmentRef;
82
103
  // [LAW:types-are-the-program] Variable declaration failures that did NOT
83
104
  // prevent the config from loading (type mismatches, bad defaults). The
84
105
  // affected variables are absent from the store; segments that reference them
@@ -296,31 +317,56 @@ export function registerDslConfig(
296
317
  // [LAW:single-enforcer] Forward the caller's clock (the daemon's `() => new
297
318
  // Date()`, a test's frozen clock) to the one engine. Omitted ⇒ undefined ⇒
298
319
  // createCcCandybarEngine applies its single default; no second default literal.
320
+ // [LAW:one-source-of-truth] ONE record for "which segment is rendering", read
321
+ // by every segment-scoped template function: `{{ menu }}` takes its identity
322
+ // from the name, `{{ color }}` its palette, `{{ bgOf }}` its background. A
323
+ // per-feature pointer would let two features disagree about which segment is
324
+ // current. Built before the engine so the funcs can close over it; `current`
325
+ // stays null until a render walk publishes one.
326
+ const activeSegment = createActiveSegmentRef();
299
327
  // [LAW:locality-or-seam] The menu runtime shares the action runtime (a menu's
300
- // glyph + body resolve from the same compiled table + store) and carries the
301
- // walk-published current placement. Built before the engine so the `menu` func
302
- // can close over it; `current` stays null until a render walk publishes one.
328
+ // glyph + body resolve from the same compiled table + store) and reads the
329
+ // active segment through the shared record above.
303
330
  const menuRuntime: MenuRuntime = {
304
331
  action: actionRuntime,
305
- current: null,
332
+ activeSegment,
306
333
  };
307
334
  // [LAW:one-source-of-truth] The config's look names — the one PER-CONFIG
308
- // option domain. Computed once here and fed to BOTH consumers (the compiled
309
- // set-option domains below and the `looks()` binding), so the rendered
310
- // options, a hand-authored `range looks`, and the derived click gate (which
311
- // reads the same config in deriveActionValidators) trace to one map.
335
+ // option domain. Fed to every consumer (the `looks()` binding below, and
336
+ // via perConfigDomainsFor, the SAME construction cross-ref.ts and
337
+ // state-validators.ts use the compiled set-option domains), so the
338
+ // rendered options, a hand-authored `range looks`, and the derived click
339
+ // gate (which reads the same config in deriveActionValidators) trace to
340
+ // one source.
312
341
  const lookNames = Object.keys(config.looks);
342
+ const presetOptions = presetNames(config.presets);
343
+ const perConfigDomains = perConfigDomainsFor(config);
313
344
  const engine = createCcCandybarEngine(
314
- undefined,
315
345
  {
316
346
  ...actionFuncs(actionRuntime),
317
347
  ...pickerFuncs(actionRuntime),
318
348
  ...menuFuncs(menuRuntime),
349
+ // [LAW:one-source-of-truth] `{{ color }}` reads the palette of the
350
+ // segment currently rendering — the same palette its `bg:`/`fg:` resolve
351
+ // from, published by the walk. Binding it to a palette captured HERE
352
+ // (registration runs once per config load, renders happen per tick) was
353
+ // the two-clocks bug this seam exists to close: a session theme click, a
354
+ // look, or a per-segment hue rotation moved a segment's background while
355
+ // every in-body color stayed where it was, so one segment painted from
356
+ // two palettes at once. Reading live costs nothing structurally — FuncMap
357
+ // bodies run at evaluate time, so parse-once/evaluate-many is untouched.
358
+ // `{{ bgOf }}` rides the same record. [LAW:rich-js-owns-color-math]
359
+ ...segmentColorFuncs(activeSegment),
319
360
  // [LAW:one-type-per-behavior] The per-config sibling of the static
320
361
  // themes()/styles() bindings (template-engine/funcs.ts): zero-arg
321
362
  // projection of the "looks" option domain. Injected here — not in the
322
363
  // static FuncMap — because the domain is this config's looks block.
323
364
  looks: { fn: () => lookNames, argTypes: [] },
365
+ // The presets domain's twin of the binding above — same per-config
366
+ // reason, same shape. A hand-authored `range presets` and a
367
+ // `{{ menu "applyPreset" }}` therefore enumerate the same names the
368
+ // derived click gate admits.
369
+ presets: { fn: () => presetOptions, argTypes: [] },
324
370
  },
325
371
  opts?.clock,
326
372
  );
@@ -363,7 +409,7 @@ export function registerDslConfig(
363
409
  parse,
364
410
  config.actions,
365
411
  stateKeyToVar,
366
- lookNames,
412
+ perConfigDomains,
367
413
  );
368
414
 
369
415
  // [LAW:dataflow-not-control-flow] One variable failing to declare does not
@@ -427,9 +473,9 @@ export function registerDslConfig(
427
473
  // per-render basePalette; folding globals.palette in here too would freeze
428
474
  // it per segment and the stale copy would shadow basePalette, so a session
429
475
  // theme change could never recolor the bar.
430
- paletteResolver:
476
+ palette:
431
477
  seg.palette !== undefined
432
- ? resolverForThemeName(seg.palette)
478
+ ? paletteForThemeName(seg.palette)
433
479
  : undefined,
434
480
  };
435
481
  }
@@ -463,9 +509,25 @@ export function registerDslConfig(
463
509
  return nodeType(node.kind).compile(node, cctx);
464
510
  };
465
511
 
512
+ // [LAW:one-source-of-truth] One compiled tree per declared preset, built
513
+ // through the SAME compileNode the config's own root goes through —
514
+ // `presetRoot` resolves the fragment's `root` or falls back to the config's,
515
+ // so the floor preset (the empty fragment) needs no arm and no absent case
516
+ // downstream.
517
+ // Keyed by the SAME domain the menu renders and the click gate admits, so
518
+ // every selectable name has a compiled tree [LAW:one-source-of-truth].
519
+ const roots = new Map<string, CompiledNode>();
520
+ for (const name of presetOptions) {
521
+ // The path travels WITH the tree, so a preset that stages the config's own
522
+ // root diagnoses under `root` — the place its author actually wrote it.
523
+ const { node, path } = presetRoot(config, name);
524
+ roots.set(name, compileNode(node, path));
525
+ }
526
+
466
527
  return {
467
528
  segments: compiled,
468
- root: compileNode(config.root, "root"),
529
+ roots,
530
+ activeSegment,
469
531
  menuRuntime,
470
532
  loadWarnings,
471
533
  };
@@ -545,24 +607,44 @@ export interface RenderObservers {
545
607
  readonly onSegmentError?: (segName: string, message: string) => void;
546
608
  }
547
609
 
610
+ // [LAW:locality-or-seam] The per-render RESOLUTION the caller performs and hands
611
+ // down — the values that are neither config (compiled once) nor payload (input
612
+ // data), but the session's live choices resolved against the config: which
613
+ // theme-adaptation, which preset. Bundled as ONE named bag for exactly the
614
+ // reason RenderObservers is: `look` arrived as a positional tail, `preset` would
615
+ // have been a second one, and the next resolution a third — each a signature
616
+ // every caller re-counts. A new per-render choice is now one field here.
617
+ //
618
+ // Both fields default to their domain's own identity element, so an omitting
619
+ // caller (a compile-only test, the demo) renders the unadapted config — a true
620
+ // default, not a fallback [LAW:no-silent-failure].
621
+ export interface RenderSelection {
622
+ // The resolved look, as a ThemeKey: effectiveLookName over SessionState/
623
+ // globals, then lookKeyByName — resolved by the caller exactly how basePalette
624
+ // is. IDENTITY is the "none" look. Composed with each segment's hue shift into
625
+ // ONE transposition.
626
+ readonly look?: ThemeKey;
627
+ // The resolved preset NAME: effectivePresetName over SessionState/globals,
628
+ // collapsed to the floor if stale. Selects which of `compiled.roots` this
629
+ // render walks. The name (not the fragment) crosses this seam because the
630
+ // fragment's two halves land in two different places — the root here, the
631
+ // globals in `opts`/the payload — and one name keeps them from disagreeing.
632
+ readonly preset?: string;
633
+ }
634
+
548
635
  export function renderDsl(
549
636
  config: ValidatedConfig,
550
637
  compiled: CompiledConfig,
551
638
  store: VariableStore,
552
639
  registry: SourceRegistry,
553
640
  payload: unknown,
554
- basePalette: PaletteResolver,
641
+ basePalette: Palette,
555
642
  opts: BuildLineOptions,
556
643
  observers?: RenderObservers,
557
- // [LAW:dataflow-not-control-flow] The render's look (the session's chosen
558
- // theme-adaptation), resolved per render by the caller — effectiveLookName
559
- // over SessionState/globals, then lookKeyByName — exactly how basePalette
560
- // resolves. IDENTITY is the domain's own no-adaptation element (the "none"
561
- // look), so an omitting caller renders unadapted — a true default, not a
562
- // fallback. Composed with each segment's hue shift into ONE transposition.
563
- look: ThemeKey = IDENTITY,
644
+ selection?: RenderSelection,
564
645
  ): string {
565
646
  const { perSegmentSink, onSegmentError } = observers ?? {};
647
+ const { look = IDENTITY, preset = PRESET_FLOOR } = selection ?? {};
566
648
  // [LAW:one-source-of-truth] Inject the usable width as `term.cols` from the
567
649
  // SAME opts.width the strip wraps to (below), so a width-paginated widget reads
568
650
  // the exact wrap width — never a cached or independently-measured copy. This is
@@ -626,19 +708,34 @@ export function renderDsl(
626
708
  : undefined;
627
709
  };
628
710
 
629
- // [LAW:single-enforcer] The menu seam, owned here. `beginSegment` publishes the
630
- // segment name a `{{ menu }}` needs to derive its identity; `collectDrops` reads
631
- // the open bodies the menus carried as metadata on their evaluated fragments and
632
- // clears the published placement. The runtime's `current` is set/cleared around
633
- // each segment eval by the walk only never ambient.
634
- // [LAW:no-ambient-temporal-coupling]
635
- const beginSegment = (segName: string): void => {
636
- compiled.menuRuntime.current = { segName };
637
- };
638
- const collectDrops = (
639
- fragments: readonly RichText[],
640
- ): readonly RichText[] => {
641
- compiled.menuRuntime.current = null;
711
+ // [LAW:single-enforcer] The segment seam, owned here as a symmetric pair.
712
+ // `enterSegment` establishes everything a segment's templates may ask about
713
+ // themselves the name `{{ menu }}` derives its identity from, the palette
714
+ // `{{ color }}` resolves against, the background `{{ bgOf }}` returns and
715
+ // returns the resolved base Style. `exitSegment` collects the menu bodies the
716
+ // fragments carried as metadata and tears the record back down.
717
+ //
718
+ // [LAW:no-ambient-temporal-coupling] The record is set and cleared around each
719
+ // segment's evaluation by the walk ONLY, so "which segment am I in" is owned
720
+ // state with one writer, never ambient context a reader has to hope is
721
+ // current. Enter/exit are a pair by construction: every path that publishes
722
+ // goes through the first, every path that finishes goes through the second.
723
+ const enterSegment = (
724
+ segName: string,
725
+ palette: Palette,
726
+ bgTemplate: Template<RichText> | undefined,
727
+ fgTemplate: Template<RichText> | undefined,
728
+ ): Style =>
729
+ resolveSegmentColors(
730
+ compiled.activeSegment,
731
+ segName,
732
+ palette,
733
+ bgTemplate,
734
+ fgTemplate,
735
+ scope,
736
+ );
737
+ const exitSegment = (fragments: readonly RichText[]): readonly RichText[] => {
738
+ compiled.activeSegment.current = null;
642
739
  return collectMenuDrops(fragments);
643
740
  };
644
741
 
@@ -662,8 +759,8 @@ export function renderDsl(
662
759
  nextHueShift,
663
760
  perSegmentSink,
664
761
  onSegmentError,
665
- beginSegment,
666
- collectDrops,
762
+ enterSegment,
763
+ exitSegment,
667
764
  focusTint,
668
765
  lookupSegment,
669
766
  renderChild: renderNode,
@@ -676,7 +773,22 @@ export function renderDsl(
676
773
  // emit a "\n"-bearing string (FlexStrip width-overflow wrap); joining the per-
677
774
  // line results with "\n" splices those in place — byte-identical to serializing
678
775
  // each leaf row independently, since the cells and their order are unchanged.
679
- return renderNode(compiled.root, true)
776
+ // [LAW:no-defensive-null-guards] The active preset's compiled tree. By the
777
+ // time a name reaches here it must be a member: effectivePresetName collapses
778
+ // unknown names to the floor, and every merged config declares the floor — so
779
+ // the throw is the loud failure for a broken invariant (a hand-built config
780
+ // missing the bundled presets block), never a silent fall back to some other
781
+ // arrangement than the one the bar's own label claims is active.
782
+ const root = compiled.roots.get(preset);
783
+ if (root === undefined) {
784
+ throw new Error(
785
+ `Preset "${preset}" has no compiled layout — registerDslConfig compiles ` +
786
+ `one per declared preset and effectivePresetName collapses unknown ` +
787
+ `names to "${PRESET_FLOOR}"; a miss here is merge/policy drift ` +
788
+ `(have: ${[...compiled.roots.keys()].join(", ")})`,
789
+ );
790
+ }
791
+ return renderNode(root, true)
680
792
  .map((line) => renderStripCells(line, opts))
681
793
  .join("\n");
682
794
  }
package/src/help-text.ts CHANGED
@@ -23,9 +23,9 @@ Configuration:
23
23
  to point at a specific file. See the default config for all available options:
24
24
  node dist/index.mjs debug --project-dir . --cwd .
25
25
 
26
- The bundled default bar ships a clickable theme/look picker — no config
27
- needed. Click 🎨 <theme> ${DISCLOSURE_GLYPH_CLOSED} or <look> ${DISCLOSURE_GLYPH_CLOSED} on the bar to
28
- switch palettes.
26
+ The bundled default bar ships a settings drawer — no config needed. Click
27
+ settings ${DISCLOSURE_GLYPH_CLOSED} on the bar to reveal a clickable theme/look picker and
28
+ other display options.
29
29
 
30
30
  Subcommands:
31
31
  install One-shot setup: stages the runtime (native render
@@ -451,8 +451,8 @@ function installSuccessMessage(): string {
451
451
  return (
452
452
  `✓ install complete.\n` +
453
453
  ` Restart Claude Code to pick up the new statusline.\n` +
454
- ` Tip: the default bar has a clickable theme/look picker — click\n` +
455
- ` 🎨 <theme> ${DISCLOSURE_GLYPH_CLOSED} or <look> ${DISCLOSURE_GLYPH_CLOSED} on the bar to switch palettes.\n`
454
+ ` Tip: the default bar has a settings drawer — click ⚙ settings\n` +
455
+ ` ${DISCLOSURE_GLYPH_CLOSED} to reveal a clickable theme/look picker and other display options.\n`
456
456
  );
457
457
  }
458
458
 
@@ -25,17 +25,17 @@ import type { FuncMap, Template } from "@promptctl/go-template-js";
25
25
  import type { VariableStore } from "../var-system/store.js";
26
26
  import { toString as varToString } from "../var-system/types.js";
27
27
  import { buildScope } from "../template-engine/scope.js";
28
- import type { ActionDecl, OptionSource } from "../config/action.js";
29
- import {
30
- listResolvablePaletteNames,
31
- STRIP_STYLES,
32
- type StripStyle,
33
- } from "../themes/policy.js";
28
+ import type { ActionDecl } from "../config/action.js";
29
+ import { resolveOptionDomain } from "../config/option-domain.js";
30
+ import type { StripStyle } from "../themes/policy.js";
34
31
  import {
35
32
  effectsUrl,
36
33
  VERB_COPY,
37
34
  VERB_OPEN_VSCODE,
35
+ VERB_RESET_CONFIG,
36
+ VERB_SET_CONFIG,
38
37
  VERB_SET_STATE,
38
+ VERB_STEP_CONFIG,
39
39
  VERB_STEP_STATE,
40
40
  type Effect,
41
41
  } from "../click/wire.js";
@@ -102,24 +102,65 @@ export type CompiledActionDecl =
102
102
  readonly members: readonly string[];
103
103
  }
104
104
  | { readonly kind: "copy"; readonly text: Template<RichText> }
105
- | { readonly kind: "open"; readonly target: Template<RichText> };
105
+ | { readonly kind: "open"; readonly target: Template<RichText> }
106
+ // [LAW:one-source-of-truth] `persist`'s twin of the set-* kinds above,
107
+ // MINUS set-int (a page cursor is never persisted — see action.ts). Carries
108
+ // the SAME shapes for the SAME reason: a persistent write is gated and
109
+ // realized exactly like a session write, only the wire verb (VERB_SET_CONFIG/
110
+ // VERB_STEP_CONFIG) and the write's durability differ.
111
+ | {
112
+ readonly kind: "persist-literal";
113
+ readonly key: string;
114
+ readonly value: string;
115
+ readonly stateVar: string;
116
+ }
117
+ | {
118
+ readonly kind: "persist-option";
119
+ readonly key: string;
120
+ readonly stateVar: string;
121
+ readonly options: readonly string[];
122
+ }
123
+ | {
124
+ readonly kind: "persist-bounded";
125
+ readonly key: string;
126
+ readonly by: number;
127
+ }
128
+ | {
129
+ readonly kind: "persist-cycle";
130
+ readonly key: string;
131
+ readonly stateVar: string;
132
+ readonly members: readonly string[];
133
+ }
134
+ // [LAW:one-source-of-truth] The gated undo for a persistent write: clears
135
+ // one config-overrides key. Carries only the key — there is no value to
136
+ // realize, so it shares copy/open's "no gate" shape at compile time (the
137
+ // GATE is the key-membership check the reset-config verb handler applies).
138
+ | { readonly kind: "reset"; readonly key: string };
106
139
 
107
140
  export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
108
141
 
109
- // [LAW:one-source-of-truth] An option source resolves to the SAME canonical list
110
- // the `themes()`/`styles()`/`looks()` bindings and the derived gate consult —
111
- // rendered options and the gate cannot diverge. The render-side resolver (the
112
- // daemon's validator-derivation has its own that must agree — themes/styles from
113
- // themes/policy, looks from the config's merged look names, threaded in as data
114
- // because that one domain is per-config, not registry-static).
115
- export function optionDomain(
116
- src: OptionSource,
117
- lookNames: readonly string[],
118
- ): readonly string[] {
119
- if (src === "themes") return listResolvablePaletteNames();
120
- if (src === "styles") return STRIP_STYLES;
121
- return lookNames;
122
- }
142
+ // [LAW:one-source-of-truth] Globals fields whose CURRENT resolved value is
143
+ // exposed to templates under a different var name than the field itself (the
144
+ // daemon publishes this resolution once per render e.g. `theme.effective`
145
+ // for `palette`, src/daemon/render-payload.ts). A `persist` action with no
146
+ // entry here reads back through its own key name as an input var (mirrors
147
+ // compileActions' stateKeyToVar fallback), so every persistable globals field
148
+ // needs an entry unless its `.effective` projection happens to be named
149
+ // exactly the bare field (none are — every projection carries the
150
+ // `.effective` suffix). Every field with a projection is listed
151
+ // (candybar-config-engine-71o.3 added style/charset/colorCompatibility/
152
+ // autoWrap/padding to palette/look's original two); a field with no entry
153
+ // here still writes correctly on `persist` — only its "current selection"
154
+ // highlight is inert (readVar falls back to "" since no such var exists).
155
+ const CONFIG_KEY_TO_EFFECTIVE_VAR: ReadonlyMap<string, string> = new Map([
156
+ ["palette", "theme.effective"],
157
+ ["look", "look.effective"],
158
+ ["style", "style.effective"],
159
+ ["charset", "charset.effective"],
160
+ ["colorCompatibility", "colorCompatibility.effective"],
161
+ ["autoWrap", "autoWrap.effective"],
162
+ ["padding", "padding.effective"],
163
+ ]);
123
164
 
124
165
  // [LAW:locality-or-seam] The runtime holder the `action` template function closes
125
166
  // over. Populated after the engine is constructed (the func references the
@@ -165,13 +206,17 @@ export function compileActions(
165
206
  parse: (src: string) => Template<RichText>,
166
207
  actions: Readonly<Record<string, ActionDecl>>,
167
208
  stateKeyToVar: ReadonlyMap<string, string>,
168
- // The config's look names the one per-config option domain optionDomain
169
- // resolves from (themes/styles stay registry-static).
170
- lookNames: readonly string[],
209
+ // This config's per-config option domains (currently just "looks" the
210
+ // config's merged look names) — resolveOptionDomain checks these before
211
+ // falling back to the global registry (themes/styles).
212
+ perConfigDomains: ReadonlyMap<string, readonly string[]>,
171
213
  ): CompiledActions {
172
214
  const out = new Map<string, CompiledActionDecl>();
173
215
  for (const [name, action] of Object.entries(actions)) {
174
- out.set(name, compileAction(parse, name, action, stateKeyToVar, lookNames));
216
+ out.set(
217
+ name,
218
+ compileAction(parse, name, action, stateKeyToVar, perConfigDomains),
219
+ );
175
220
  }
176
221
  return out;
177
222
  }
@@ -185,7 +230,7 @@ function compileAction(
185
230
  name: string,
186
231
  action: ActionDecl,
187
232
  stateKeyToVar: ReadonlyMap<string, string>,
188
- lookNames: readonly string[],
233
+ perConfigDomains: ReadonlyMap<string, readonly string[]>,
189
234
  ): CompiledActionDecl {
190
235
  if ("set" in action) {
191
236
  const stateVar = stateKeyToVar.get(action.set) ?? action.set;
@@ -202,7 +247,7 @@ function compileAction(
202
247
  kind: "set-option",
203
248
  key: action.set,
204
249
  stateVar,
205
- options: [...optionDomain(action.from, lookNames)],
250
+ options: [...resolveOptionDomain(action.from, perConfigDomains)],
206
251
  };
207
252
  }
208
253
  if ("int" in action) {
@@ -222,16 +267,52 @@ function compileAction(
222
267
  by: action.by,
223
268
  };
224
269
  }
270
+ if ("persist" in action) {
271
+ const stateVar =
272
+ CONFIG_KEY_TO_EFFECTIVE_VAR.get(action.persist) ?? action.persist;
273
+ if ("to" in action) {
274
+ return {
275
+ kind: "persist-literal",
276
+ key: action.persist,
277
+ value: action.to,
278
+ stateVar,
279
+ };
280
+ }
281
+ if ("from" in action) {
282
+ return {
283
+ kind: "persist-option",
284
+ key: action.persist,
285
+ stateVar,
286
+ options: [...resolveOptionDomain(action.from, perConfigDomains)],
287
+ };
288
+ }
289
+ if ("cycle" in action) {
290
+ return {
291
+ kind: "persist-cycle",
292
+ key: action.persist,
293
+ stateVar,
294
+ members: action.cycle,
295
+ };
296
+ }
297
+ return {
298
+ kind: "persist-bounded",
299
+ key: action.persist,
300
+ by: action.by,
301
+ };
302
+ }
225
303
  if ("copy" in action) {
226
304
  return {
227
305
  kind: "copy",
228
306
  text: parseActionTemplate(parse, action.copy, name),
229
307
  };
230
308
  }
231
- return {
232
- kind: "open",
233
- target: parseActionTemplate(parse, action.open, name),
234
- };
309
+ if ("open" in action) {
310
+ return {
311
+ kind: "open",
312
+ target: parseActionTemplate(parse, action.open, name),
313
+ };
314
+ }
315
+ return { kind: "reset", key: action.reset };
235
316
  }
236
317
 
237
318
  function parseActionTemplate(
@@ -292,7 +373,7 @@ export function linkFragment(
292
373
  // renders the first display and clicks to the second member (an accordion
293
374
  // sibling's path "counts as closed", a never-written toggle "counts as off").
294
375
  function cycleIndex(
295
- c: Extract<CompiledActionDecl, { kind: "set-cycle" }>,
376
+ c: Extract<CompiledActionDecl, { kind: "set-cycle" | "persist-cycle" }>,
296
377
  store: VariableStore,
297
378
  ): number {
298
379
  return Math.max(c.members.indexOf(readVar(store, c.stateVar)), 0);
@@ -400,6 +481,47 @@ function realize(
400
481
  },
401
482
  active: false,
402
483
  };
484
+ // [LAW:one-source-of-truth] The persist-* arms mirror set-*'s realization
485
+ // verbatim (same literal/option/cycle/bounded semantics), only the wire
486
+ // verb differs (VERB_SET_CONFIG/VERB_STEP_CONFIG instead of
487
+ // VERB_SET_STATE/VERB_STEP_STATE) — the daemon-side handler is what makes
488
+ // the write durable, not the click itself.
489
+ case "persist-literal": {
490
+ const current = readVar(store, c.stateVar);
491
+ return {
492
+ effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] },
493
+ active: current === c.value,
494
+ };
495
+ }
496
+ case "persist-option": {
497
+ const value = boundValue ?? display;
498
+ const current = readVar(store, c.stateVar);
499
+ return {
500
+ effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] },
501
+ active: current === value,
502
+ };
503
+ }
504
+ case "persist-cycle": {
505
+ const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
506
+ return {
507
+ effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] },
508
+ active: false,
509
+ };
510
+ }
511
+ case "persist-bounded": {
512
+ return {
513
+ effect: {
514
+ verb: VERB_STEP_CONFIG,
515
+ args: [sessionId, c.key, String(c.by)],
516
+ },
517
+ active: false,
518
+ };
519
+ }
520
+ case "reset":
521
+ return {
522
+ effect: { verb: VERB_RESET_CONFIG, args: [sessionId, c.key] },
523
+ active: false,
524
+ };
403
525
  }
404
526
  }
405
527
 
@@ -419,7 +541,7 @@ function selectDisplay(
419
541
  if (displays.length === 0) {
420
542
  throw new Error(`action "${name}" needs a display (the clickable text)`);
421
543
  }
422
- if (action.kind === "set-cycle") {
544
+ if (action.kind === "set-cycle" || action.kind === "persist-cycle") {
423
545
  if (displays.length !== 1 && displays.length !== action.members.length) {
424
546
  throw new Error(
425
547
  `action "${name}" cycles ${action.members.length} members; bind one display per member (${action.members.length}) or one static display, got ${displays.length}`,