@promptctl/cc-candybar 1.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,708 @@
1
+ // [LAW:single-enforcer] One registry that names every SessionState key the
2
+ // `set-state` verb is allowed to write, paired with the per-key validator
3
+ // that decides whether a raw incoming string is a legal value for that key.
4
+ // Adding a new state-writable key is one entry in this table — no new verb,
5
+ // no scattered string-matching, no defensive guard in the dispatcher.
6
+ //
7
+ // [LAW:one-source-of-truth] The registered keys ARE the schema for what
8
+ // SessionState mutations the click protocol can perform. Tests assert
9
+ // against this table directly so the live schema and the test enumeration
10
+ // cannot drift. Unknown-key rejection lists these names — operators see
11
+ // exactly the surface they're allowed to write.
12
+ //
13
+ // [LAW:no-silent-fallbacks] An unknown key throws BAD_REQUEST at the
14
+ // dispatcher; never accept-and-store an unvalidated key. A typo on the
15
+ // wire surfaces as a structured rejection, not a silent corruption of
16
+ // SessionState.
17
+ //
18
+ // [LAW:types-are-the-program] The validator's return type is the program:
19
+ // the verb body cannot proceed without an `ok: true` branch and cannot
20
+ // fabricate a value — on failure it surfaces the reason verbatim. The
21
+ // SessionState value is currently string-typed, so the validator's
22
+ // `value` is the canonical string to write (post-normalization for boolean-
23
+ // ish keys). When a future widget needs a non-string typed value (e.g. a
24
+ // numeric stepper with int-range bounds), this same shape extends — the
25
+ // validator becomes the parsing boundary, the verb body the dataflow.
26
+
27
+ import { listResolvablePaletteNames, STYLE_ORDER } from "../../themes/policy";
28
+ import type { ActionDecl, OptionSource } from "../../config/action";
29
+ import type { DslConfig } from "../../config/dsl-types";
30
+
31
+ // [LAW:one-source-of-truth] One contribution shape — a (key, spec) pair — every
32
+ // action's `set` declaration projects to. mergeContributions folds a list of
33
+ // these into the final per-key validator specs, so multiple actions writing one
34
+ // key feed ONE coherence merge regardless of which action authored the write.
35
+ interface KeySpecContribution {
36
+ readonly key: string;
37
+ readonly spec: DerivedValidatorSpec;
38
+ }
39
+
40
+ // [LAW:types-are-the-program] Discriminated union — every legal return is
41
+ // either an accepted-and-canonicalized string or a structured rejection
42
+ // reason. There is no third state (no `null`, no thrown exception path
43
+ // inside a validator). The verb body matches exhaustively on `ok`.
44
+ export type ValidateResult =
45
+ | { ok: true; value: string }
46
+ | { ok: false; reason: string };
47
+
48
+ // [LAW:one-type-per-behavior] All key validators have the same shape —
49
+ // they don't carry the key name, the registry does. The validator's only
50
+ // concern is: does this raw string belong in this key's value-set?
51
+ export type KeyValidator = (rawValue: string) => ValidateResult;
52
+
53
+ // [LAW:types-are-the-program] A derived key's SEMANTIC identity — the data a
54
+ // widget config declares about a custom SessionState key, from which the
55
+ // validator is residue. A key is one of three key shapes: an integer (a
56
+ // menu's page index), an allow-list (the union of values some button can write),
57
+ // or a bounded integer range (a stepper's value). The registry compares specs to
58
+ // decide whether two registrations can share a key (same `kind`) and merges them
59
+ // by unioning content (allow-list members; range bounds); the opaque
60
+ // `KeyValidator` it builds from the spec cannot be compared or merged, which is
61
+ // why registration takes the spec and owns validator construction.
62
+ //
63
+ // [LAW:one-source-of-truth] The spec carries only content (kind + allow-list
64
+ // members + range bounds), never the human label — the label is a pure function
65
+ // of the key, computed where the validator is built, so two registrations of one
66
+ // key yield byte-identical validators regardless of which config registered first.
67
+ export type DerivedValidatorSpec =
68
+ | { readonly kind: "int" }
69
+ | { readonly kind: "allow-list"; readonly allowed: readonly string[] }
70
+ | {
71
+ // [LAW:one-source-of-truth] A bounded-integer state key (a stepper's
72
+ // value). `min`/`max` gate the value; `seed` is the value an UNSET key
73
+ // reads as — sourced from the backing state variable's `default` so the
74
+ // first relative click steps from the same number the bar displays (not
75
+ // silently from `min`). The validator ignores `seed` (it only clamps); the
76
+ // step-state handler reads it via rangeParamsFor when the key is unset.
77
+ readonly kind: "range";
78
+ readonly min: number;
79
+ readonly max: number;
80
+ readonly seed: number;
81
+ };
82
+
83
+ // [LAW:one-source-of-truth] listResolvablePaletteNames is THE set whose
84
+ // members resolve to a concrete Palette. The broader listAvailableThemes
85
+ // includes the "custom" sentinel (read inline colors) which is not a
86
+ // renderable theme name; accepting "custom" here would persist an
87
+ // unrenderable value into SessionState and break the next render.
88
+ //
89
+ // [LAW:single-enforcer] Each validator's accepted-set is one constant
90
+ // lookup structure — a Set for O(1) `has` (matching the BOOLEAN_*
91
+ // validators below). The theme registry (rich-js THEMES) and STYLE_ORDER
92
+ // are module-init-static, so caching at module load is correct by
93
+ // construction; the (list, set) pair is built from the same source so
94
+ // the error-message ordering and the lookup membership cannot drift.
95
+ const RESOLVABLE_THEMES_LIST: readonly string[] = listResolvablePaletteNames();
96
+ const RESOLVABLE_THEMES: ReadonlySet<string> = new Set(RESOLVABLE_THEMES_LIST);
97
+ const RESOLVABLE_STYLES: ReadonlySet<string> = new Set(STYLE_ORDER);
98
+
99
+ const validateTheme: KeyValidator = (raw) => {
100
+ if (!raw) return { ok: false, reason: "theme name is required" };
101
+ if (!RESOLVABLE_THEMES.has(raw)) {
102
+ return {
103
+ ok: false,
104
+ reason: `unknown theme "${raw}" (have: ${RESOLVABLE_THEMES_LIST.join(", ")})`,
105
+ };
106
+ }
107
+ return { ok: true, value: raw };
108
+ };
109
+
110
+ const validateStyle: KeyValidator = (raw) => {
111
+ if (!raw) return { ok: false, reason: "style name is required" };
112
+ if (!RESOLVABLE_STYLES.has(raw)) {
113
+ return {
114
+ ok: false,
115
+ reason: `unknown style "${raw}" (have: ${STYLE_ORDER.join(", ")})`,
116
+ };
117
+ }
118
+ return { ok: true, value: raw };
119
+ };
120
+
121
+ // [LAW:dataflow-not-control-flow] Boolean-ish accepts exactly four
122
+ // canonical inputs and normalizes to two canonical outputs: truthy
123
+ // ("1"/"true") → "1", falsy ("0"/"false") → "" (empty). The empty
124
+ // falsy sentinel matches what `toolbar-toggle` produces via `clear()`
125
+ // for the next render — readers treat both as "off" because the DSL
126
+ // state binding's default fires on null/empty. Centralizing this
127
+ // canonical pair here means any future widget that writes a boolean
128
+ // state key gets the same on/off contract by registry row, not by
129
+ // re-deriving it inline.
130
+ //
131
+ // [LAW:no-silent-fallbacks] The empty string as INPUT is rejected, not
132
+ // silently mapped to falsy. An empty value on the wire is structurally
133
+ // ambiguous (did the operator mean "0", or did they forget to provide
134
+ // a value?); accepting it would be a silent semantic guess. Each of
135
+ // the comment, the accepted-input set, and the rejection message names
136
+ // the same four inputs — [LAW:one-source-of-truth] kept by construction
137
+ // instead of by maintenance.
138
+ const BOOLEAN_TRUTHY = new Set(["1", "true"]);
139
+ const BOOLEAN_FALSY = new Set(["0", "false"]);
140
+ const validateBoolean: KeyValidator = (raw) => {
141
+ if (BOOLEAN_TRUTHY.has(raw)) return { ok: true, value: "1" };
142
+ if (BOOLEAN_FALSY.has(raw)) return { ok: true, value: "" };
143
+ return {
144
+ ok: false,
145
+ reason: `expected boolean-ish (1, 0, true, false), got "${raw}"`,
146
+ };
147
+ };
148
+
149
+ // [LAW:types-are-the-program] Two registry-entry shapes, discriminated by
150
+ // `permanent`. A baseline entry is a fixed built-in validator (theme/style/
151
+ // toolbar-expanded) that can never be removed or re-claimed. A derived entry
152
+ // holds the LIVE registrations for a widget-installed key: each registration
153
+ // contributes a spec, the entry's `validator` is rebuilt as their merge, and
154
+ // `specs.length` IS the ref-count. The same derived key legitimately registers
155
+ // more than once — multiple cache entries share one config (one repo, two
156
+ // cwds), and a hot-reload builds the new state's validators BEFORE disposing the
157
+ // old (so the key is briefly held twice). The merged validator stays valid
158
+ // across the whole overlap; the key is removed only when the last spec disposes.
159
+ interface BaselineEntry {
160
+ readonly permanent: true;
161
+ readonly validator: KeyValidator;
162
+ }
163
+ interface DerivedEntry {
164
+ readonly permanent: false;
165
+ // [LAW:types-are-the-program] All live specs for a key share one kind — the
166
+ // registration check rejects a kind change, so `kind` is the entry's stable
167
+ // key shape and the discriminator the rebuild matches on.
168
+ readonly kind: DerivedValidatorSpec["kind"];
169
+ validator: KeyValidator;
170
+ // [LAW:one-source-of-truth] The live registrations. The validator is derived
171
+ // from these (union of allow-list members); they are the single source, the
172
+ // validator the cache. `length` is the ref-count — no separate counter to drift.
173
+ readonly specs: DerivedValidatorSpec[];
174
+ }
175
+ type ValidatorEntry = BaselineEntry | DerivedEntry;
176
+
177
+ // [LAW:one-source-of-truth] THE registry of state keys the click protocol can
178
+ // write. Baseline keys are permanent; widget-derived keys carry their live
179
+ // registrations.
180
+ //
181
+ // [LAW:types-are-the-program] A `Map` lookup is `(key) → entry | undefined` with
182
+ // NO prototype chain — `__proto__`/`constructor` from an untrusted wire URL are
183
+ // ordinary non-members, not truthy hits on Object.prototype. A plain object
184
+ // would admit those as truthy lookups that crash on invocation (RENDER_FAILED
185
+ // instead of the intended BAD_REQUEST). Map makes that unrepresentable.
186
+ const _STATE_VALIDATORS = new Map<string, ValidatorEntry>([
187
+ ["style", { validator: validateStyle, permanent: true }],
188
+ ["theme", { validator: validateTheme, permanent: true }],
189
+ ["toolbar-expanded", { validator: validateBoolean, permanent: true }],
190
+ ]);
191
+
192
+ // [LAW:dataflow-not-control-flow] listStateKeys returns a fresh snapshot on each
193
+ // call — the snapshot semantics IS the contract. A frozen constant would
194
+ // silently misreport the writable surface after a widget config registered a key.
195
+ export function listStateKeys(): readonly string[] {
196
+ return [..._STATE_VALIDATORS.keys()];
197
+ }
198
+
199
+ // [LAW:types-are-the-program] The validator is RESIDUE of the live specs: given
200
+ // the (uniform) kind and every live registration's content, the validator is
201
+ // forced. An int key builds a parse-boundary validator; an allow-list key builds
202
+ // one from the UNION of every live registration's members — so a value any live
203
+ // config can legitimately render is a value the wire accepts, by construction.
204
+ // The label is a pure function of (key, kind) so the built validator is identical
205
+ // across registrations of one key. makeIntValidator/makeAllowListValidator are
206
+ // the single validator constructors (re-validating slash/empty values), so a
207
+ // merged allow-list that somehow held an undeliverable value would throw HERE,
208
+ // at config-load, not at the operator's first click.
209
+ function buildValidatorFromSpecs(
210
+ key: string,
211
+ kind: DerivedValidatorSpec["kind"],
212
+ specs: readonly DerivedValidatorSpec[],
213
+ ): KeyValidator {
214
+ if (kind === "int") return makeIntValidator(`menu page "${key}"`);
215
+ if (kind === "range") {
216
+ // [LAW:types-are-the-program] Two configs declaring one stepper key with
217
+ // different bounds widen to the UNION range — parity with allow-list's
218
+ // member union: a value any live config can legitimately render (step into)
219
+ // is a value the wire accepts. The clamp is to the widest live bounds, so
220
+ // the gate never rejects a write a narrower co-resident stepper could make.
221
+ const mins = specs.flatMap((s) => (s.kind === "range" ? [s.min] : []));
222
+ const maxs = specs.flatMap((s) => (s.kind === "range" ? [s.max] : []));
223
+ return makeRangeValidator(
224
+ Math.min(...mins),
225
+ Math.max(...maxs),
226
+ `stepper "${key}"`,
227
+ );
228
+ }
229
+ const allowed = [
230
+ ...new Set(
231
+ specs.flatMap((s) => (s.kind === "allow-list" ? s.allowed : [])),
232
+ ),
233
+ ];
234
+ return makeAllowListValidator(allowed, `state "${key}"`);
235
+ }
236
+
237
+ // [LAW:locality-or-seam] The widget config (a config-load consumer) owns the
238
+ // lifecycle of the validators it installs; this returns a disposer rather than
239
+ // coupling the registry to a global "config reload" event. The cache's
240
+ // reloadInto installs the new state's validators, then disposes the old — the
241
+ // dispose-before-swap contract that keeps a broken reload from corrupting
242
+ // last-known-good. See src/daemon/cache/render.ts.
243
+ //
244
+ // [LAW:no-silent-fallbacks] A baseline (permanent) key cannot be re-claimed —
245
+ // re-registering one throws, so a menu naming its page key `theme` surfaces a
246
+ // loud config-load error rather than silently shadowing the theme gate.
247
+ //
248
+ // [LAW:types-are-the-program] The semantic-compatibility gate: a key has ONE
249
+ // key shape. Registering an `int` spec for a key already held as `allow-list`
250
+ // (or vice versa) is a genuine conflict — no merged validator could honor both —
251
+ // so it throws at config-load, not silently keeps whichever loaded first. Two
252
+ // registrations of the SAME kind merge: their specs accumulate and the validator
253
+ // is rebuilt as their union, so two cache entries sharing a config (identical
254
+ // specs → idempotent) and two distinct configs sharing a key (different members
255
+ // → unioned, both deliverable) both resolve to one consistent gate.
256
+ //
257
+ // [LAW:single-enforcer] The disposer removes exactly its own spec once
258
+ // (idempotent via the `active` flag), rebuilds the validator from what remains,
259
+ // and deletes the key only when the last spec is gone.
260
+ export function registerStateValidator(
261
+ key: string,
262
+ spec: DerivedValidatorSpec,
263
+ ): () => void {
264
+ if (!key) {
265
+ throw new Error("registerStateValidator: key is required");
266
+ }
267
+ // [LAW:types-are-the-program] The set-state wire splits its tail on `/`, so a
268
+ // slash-bearing key can never be addressed — listing it would be registry-vs-
269
+ // wire drift. Reject at registration so the unreachable-but-listed state is
270
+ // unrepresentable.
271
+ if (key.includes("/")) {
272
+ throw new Error(
273
+ `registerStateValidator: key "${key}" contains "/" — the set-state ` +
274
+ `wire shape splits on "/" so a slash-bearing key cannot be ` +
275
+ `addressed. Use a slash-free key.`,
276
+ );
277
+ }
278
+ const existing = _STATE_VALIDATORS.get(key);
279
+ if (existing) {
280
+ if (existing.permanent) {
281
+ throw new Error(
282
+ `registerStateValidator: key "${key}" is a built-in state key and ` +
283
+ `cannot be re-claimed (built-in keys: ${[...baselineKeys()].join(", ")})`,
284
+ );
285
+ }
286
+ if (existing.kind !== spec.kind) {
287
+ throw new Error(
288
+ `registerStateValidator: key "${key}" is already a ${existing.kind} ` +
289
+ `state key; cannot also register it as ${spec.kind}. A state key has ` +
290
+ `one key shape — a menu page index (int) and a button allow-list ` +
291
+ `cannot share a key.`,
292
+ );
293
+ }
294
+ existing.specs.push(spec);
295
+ existing.validator = buildValidatorFromSpecs(
296
+ key,
297
+ existing.kind,
298
+ existing.specs,
299
+ );
300
+ } else {
301
+ const specs = [spec];
302
+ _STATE_VALIDATORS.set(key, {
303
+ permanent: false,
304
+ kind: spec.kind,
305
+ validator: buildValidatorFromSpecs(key, spec.kind, specs),
306
+ specs,
307
+ });
308
+ }
309
+ let active = true;
310
+ return () => {
311
+ if (!active) return;
312
+ active = false;
313
+ const entry = _STATE_VALIDATORS.get(key);
314
+ if (!entry || entry.permanent) return;
315
+ const i = entry.specs.indexOf(spec);
316
+ if (i >= 0) entry.specs.splice(i, 1);
317
+ if (entry.specs.length === 0) {
318
+ _STATE_VALIDATORS.delete(key);
319
+ } else {
320
+ entry.validator = buildValidatorFromSpecs(key, entry.kind, entry.specs);
321
+ }
322
+ };
323
+ }
324
+
325
+ function baselineKeys(): readonly string[] {
326
+ const out: string[] = [];
327
+ for (const [key, entry] of _STATE_VALIDATORS) {
328
+ if (entry.permanent) out.push(key);
329
+ }
330
+ return out;
331
+ }
332
+
333
+ // [LAW:one-type-per-behavior] The "values come from list Y" pattern IS
334
+ // the canonical widget-config use case (theme picker draws from
335
+ // themes(), style picker draws from styles(), a custom enum picker
336
+ // draws from a user-declared list). One factory builds the validator
337
+ // from the list — every callsite that registers an allow-list key
338
+ // passes through the same shape, so error messages, empty-input
339
+ // rejection, and lookup semantics are identical by construction.
340
+ //
341
+ // [LAW:no-silent-fallbacks] Empty input is rejected with a label-
342
+ // referencing reason rather than silently mapped to a default — the
343
+ // shape matches validateTheme/validateStyle so the operator experience
344
+ // is consistent across baseline and widget-installed keys.
345
+ export function makeAllowListValidator(
346
+ allowed: readonly string[],
347
+ label: string,
348
+ ): KeyValidator {
349
+ // [LAW:types-are-the-program] The factory's contract is "options =
350
+ // allow list" — every value the picker can RENDER must also be a
351
+ // value the wire can DELIVER. Two structural reasons a declared
352
+ // option can't reach the validator as itself:
353
+ // (1) the wire splits the tail on "/", so a slash-bearing value
354
+ // would arrive as two segments — the validator never sees it
355
+ // as one value;
356
+ // (2) the validator's empty-input rejection ("X value is required")
357
+ // fires before the allow-list check, so an "" in the allow
358
+ // list would be listed-but-undeliverable.
359
+ // Both are the same shape as [LAW:registry-vs-wire drift] caught by
360
+ // registerStateValidator's slash-key check. Catching at factory-build
361
+ // time (config-load) per [LAW:verifiable-goals] surfaces a
362
+ // misconfigured option list immediately, not on the operator's first
363
+ // click. Mirrors registry surface = writable surface, by construction.
364
+ const slashOffenders = allowed.filter((v) => v.includes("/"));
365
+ if (slashOffenders.length > 0) {
366
+ throw new Error(
367
+ `makeAllowListValidator(${label}): values contain "/" — the set-state ` +
368
+ `wire shape splits values on "/" so slash-bearing options cannot ` +
369
+ `be addressed. Offending values: ${slashOffenders.join(", ")}`,
370
+ );
371
+ }
372
+ if (allowed.includes("")) {
373
+ throw new Error(
374
+ `makeAllowListValidator(${label}): empty string is not a writable ` +
375
+ `option — the validator rejects empty input before the allow-list ` +
376
+ `check, so an "" in the allowed list could be rendered but never ` +
377
+ `delivered. Remove "" from the allowed list.`,
378
+ );
379
+ }
380
+ const allowedSet: ReadonlySet<string> = new Set(allowed);
381
+ const allowedList = [...allowed];
382
+ return (raw) => {
383
+ if (!raw) return { ok: false, reason: `${label} value is required` };
384
+ if (!allowedSet.has(raw)) {
385
+ return {
386
+ ok: false,
387
+ reason: `unknown ${label} "${raw}" (have: ${allowedList.join(", ")})`,
388
+ };
389
+ }
390
+ return { ok: true, value: raw };
391
+ };
392
+ }
393
+
394
+ // [LAW:types-are-the-program] An integer-valued state key (a menu's page
395
+ // index). The wire delivers a string; the validator IS the parse boundary —
396
+ // it accepts only `^-?\d+$` and canonicalizes to the minimal decimal form, so
397
+ // "007"/"-0" can't persist a non-canonical page that the next render's `int`
398
+ // read would have to re-normalize. Negative is legal: -1 is the menu's CLOSED
399
+ // sentinel.
400
+ const INT_RE = /^-?\d+$/;
401
+ export function makeIntValidator(label: string): KeyValidator {
402
+ return (raw) => {
403
+ if (!raw) return { ok: false, reason: `${label} value is required` };
404
+ if (!INT_RE.test(raw)) {
405
+ return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
406
+ }
407
+ // [LAW:types-are-the-program] Canonicalize as a pure decimal string —
408
+ // strip leading zeros, fold "-0" → "0" — NOT via parseInt/String, which for
409
+ // a >= 1e21 magnitude would emit scientific notation ("1e+21") that a later
410
+ // parseInt(_, 10) reads back as 1. A page index is small in practice, but
411
+ // the canonical form must hold for every accepted input, not just small ones.
412
+ const neg = raw[0] === "-";
413
+ const digits = (neg ? raw.slice(1) : raw).replace(/^0+/, "");
414
+ if (digits === "") return { ok: true, value: "0" };
415
+ return { ok: true, value: neg ? `-${digits}` : digits };
416
+ };
417
+ }
418
+
419
+ // [LAW:types-are-the-program] A bounded-integer state key (a stepper's value).
420
+ // The validator is the parse-AND-clamp boundary: it accepts only `^-?\d+$` then
421
+ // clamps into [min,max]. [LAW:single-enforcer] This is the ONE place bounds are
422
+ // enforced — it owns the [min,max] floor/ceiling for EVERY write to the key,
423
+ // including a hand-typed wire URL. The stepper render owns NAVIGATION (wrap past
424
+ // a bound to the other end) the way the menu render owns page navigation; the
425
+ // stepper only ever emits values already inside bounds, so for stepper clicks
426
+ // this clamp is identity. The clamped result is small (≤ |max| or |min| digits),
427
+ // so String() cannot emit the scientific notation the raw int canonicalizer
428
+ // guards against.
429
+ export function makeRangeValidator(
430
+ min: number,
431
+ max: number,
432
+ label: string,
433
+ ): KeyValidator {
434
+ return (raw) => {
435
+ if (!raw) return { ok: false, reason: `${label} value is required` };
436
+ if (!INT_RE.test(raw)) {
437
+ return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
438
+ }
439
+ const clamped = Math.max(min, Math.min(max, parseInt(raw, 10)));
440
+ return { ok: true, value: String(clamped) };
441
+ };
442
+ }
443
+
444
+ // [LAW:one-source-of-truth] The option members a picker draws from ARE the same
445
+ // canonical lists the `themes()`/`styles()` bindings and the baseline theme/
446
+ // style validators consult — the rendered options and the derived gate cannot
447
+ // diverge because there is no second enumeration.
448
+ function optionValuesFor(src: OptionSource): readonly string[] {
449
+ return src === "themes" ? RESOLVABLE_THEMES_LIST : STYLE_ORDER;
450
+ }
451
+
452
+ // [LAW:types-are-the-program] Collapse one key's spec contributions into the
453
+ // single spec that gates it. A key is an INTEGER spec (a paged cursor `int` or a
454
+ // bounded `range`) or an allow-list — never both. An integer spec ABSORBS
455
+ // integer allow-list members (a trigger writing "0" to a page cursor is a legal
456
+ // int write — the open-trigger pattern), and a NON-integer member aimed at it is
457
+ // the genuine contradiction that throws. Two ranges widen-union; two allow-lists
458
+ // union; an int and a range on one key (a page cursor vs a bounded value) conflict.
459
+ function mergeKeySpecs(
460
+ key: string,
461
+ specs: readonly DerivedValidatorSpec[],
462
+ ): DerivedValidatorSpec {
463
+ type Range = Extract<DerivedValidatorSpec, { kind: "range" }>;
464
+ const ranges = specs.filter((s): s is Range => s.kind === "range");
465
+ const hasInt = specs.some((s) => s.kind === "int");
466
+ const allowed = specs.flatMap((s) =>
467
+ s.kind === "allow-list" ? s.allowed : [],
468
+ );
469
+ if (ranges.length === 0 && !hasInt) {
470
+ return { kind: "allow-list", allowed: [...new Set(allowed)] };
471
+ }
472
+ // [LAW:no-silent-fallbacks] An integer spec accepts only integer writes; a
473
+ // non-integer member is a one-key-shape contradiction surfaced at load.
474
+ const nonInt = allowed.filter((v) => !INT_RE.test(v));
475
+ if (nonInt.length > 0) {
476
+ throw new Error(
477
+ `deriveActionValidators: key "${key}" is an integer spec (a paged ` +
478
+ `cursor or a bounded value) but a click writes non-integer ` +
479
+ `value(s) to it (${nonInt.join(", ")}). A state key has one key ` +
480
+ `shape — point that click at a distinct key, or write an integer.`,
481
+ );
482
+ }
483
+ if (hasInt && ranges.length > 0) {
484
+ throw new Error(
485
+ `deriveActionValidators: key "${key}" is declared as both a paged ` +
486
+ `cursor (int) and a bounded value (range) — a state key has one key ` +
487
+ `shape. Use distinct keys.`,
488
+ );
489
+ }
490
+ if (ranges.length > 0) {
491
+ const min = Math.min(...ranges.map((r) => r.min));
492
+ const max = Math.max(...ranges.map((r) => r.max));
493
+ // [LAW:no-silent-fallbacks] A page cursor (int) is UNBOUNDED, so any integer
494
+ // write is a legal member to absorb. A bounded range is BOUNDED — an integer
495
+ // a click declares OUTSIDE [min,max] would be clamped by the range gate at
496
+ // click time, silently storing a different value than the click rendered.
497
+ // That is a config error, surfaced at load rather than papered over at click.
498
+ const outOfRange = allowed.filter((v) => {
499
+ const n = parseInt(v, 10);
500
+ return n < min || n > max;
501
+ });
502
+ if (outOfRange.length > 0) {
503
+ throw new Error(
504
+ `deriveActionValidators: key "${key}" is a bounded range [${min},${max}] ` +
505
+ `but a click writes out-of-range value(s) to it ` +
506
+ `(${outOfRange.join(", ")}). The range gate would clamp them, storing a ` +
507
+ `different value than the click renders — write an in-range integer, ` +
508
+ `or point that click at a distinct key.`,
509
+ );
510
+ }
511
+ // [LAW:one-source-of-truth] Every range contribution to a key carries the
512
+ // same seed (the one backing state variable's default), so any is canonical;
513
+ // re-clamp it into the widened [min,max] to stay an in-range start value.
514
+ const seed = clampSeed(ranges[0]!.seed, min, max);
515
+ return { kind: "range", min, max, seed };
516
+ }
517
+ return { kind: "int" };
518
+ }
519
+
520
+ // [LAW:single-enforcer] A STRUCTURAL spec (menu int / stepper range) is always
521
+ // kept — even on a baseline key — so a collision throws loudly at registration
522
+ // rather than silently shadowing the permanent gate. Only an ALLOW-LIST
523
+ // contribution to a baseline key is dropped (the click reuses the baseline gate
524
+ // as intended). The spec kind IS that discriminator: structural is int/range, an
525
+ // item/onClick spec is allow-list. Shared by both contribution collectors.
526
+ function dropBaselineAllowLists(
527
+ contributions: readonly KeySpecContribution[],
528
+ ): KeySpecContribution[] {
529
+ const baseline = new Set(baselineKeys());
530
+ return contributions.filter(
531
+ (c) => c.spec.kind !== "allow-list" || !baseline.has(c.key),
532
+ );
533
+ }
534
+
535
+ // [LAW:single-enforcer] THE coherence merge: group every contribution by key and
536
+ // collapse each key's specs into the one spec that gates it (mergeKeySpecs).
537
+ // Multiple actions writing the same key (a picker's int page and a trigger's
538
+ // literal "0" are different KINDS to registerStateValidator) resolve here because
539
+ // mergeKeySpecs absorbs an integer allow-list member into the int spec. One
540
+ // merge, one gate per key.
541
+ function mergeContributions(
542
+ contributions: readonly KeySpecContribution[],
543
+ ): KeySpecContribution[] {
544
+ const byKey = new Map<string, DerivedValidatorSpec[]>();
545
+ for (const { key, spec } of contributions) {
546
+ const specs = byKey.get(key);
547
+ if (specs) specs.push(spec);
548
+ else byKey.set(key, [spec]);
549
+ }
550
+ return [...byKey].map(([key, specs]) => ({
551
+ key,
552
+ spec: mergeKeySpecs(key, specs),
553
+ }));
554
+ }
555
+
556
+ // [LAW:single-enforcer] The ONE place mapping a decoupled ACTION to the validator
557
+ // key SPEC it declares. The discriminator is the action's value SOURCE (which key
558
+ // is present), as DATA:
559
+ // • a literal `set` + `to` declares an allow-list of {to};
560
+ // • an option `set` + `from` declares an allow-list of the resolved domain —
561
+ // the SAME canonical list the picker iterates, so the rendered options and
562
+ // the gate cannot diverge;
563
+ // • a bounded `set` + `min/max/by` declares a range [min,max] (the stepper's
564
+ // navigation owns the wrap; the gate owns the bounds — `by` is render-only,
565
+ // never in the spec) plus a `seed` (the unset initial value, read from the
566
+ // backing state variable's `default` so the first relative click steps from
567
+ // the displayed number);
568
+ // • an `int` `set` declares an unbounded int (a paged picker's page cursor —
569
+ // the renderer owns clamping; the gate requires integer shape);
570
+ // • a `cycle` `set` declares an allow-list of its members (the renderer only
571
+ // ever writes the successor member);
572
+ // • copy/open write nothing, so they declare no spec.
573
+ // A new action arm is one new branch here, returning data the existing merge
574
+ // folds — no consumer re-walks an action's shape.
575
+ function actionKeySpecs(
576
+ a: ActionDecl,
577
+ seeds: ReadonlyMap<string, number>,
578
+ ): KeySpecContribution[] {
579
+ if (!("set" in a)) return [];
580
+ if ("to" in a) {
581
+ return [{ key: a.set, spec: { kind: "allow-list", allowed: [a.to] } }];
582
+ }
583
+ if ("from" in a) {
584
+ return [
585
+ {
586
+ key: a.set,
587
+ spec: { kind: "allow-list", allowed: optionValuesFor(a.from) },
588
+ },
589
+ ];
590
+ }
591
+ // [LAW:single-enforcer] An int cursor (a paged picker's page key) gates as an
592
+ // unbounded int — the SAME `int` spec a menu page used. The renderer owns
593
+ // clamping to valid pages; the gate only requires integer shape.
594
+ if ("int" in a) {
595
+ return [{ key: a.set, spec: { kind: "int" } }];
596
+ }
597
+ // [LAW:one-source-of-truth] A cycle's members ARE its gate: the renderer only
598
+ // ever writes a member (the successor of the current value), and the
599
+ // allow-list admits exactly the members. Sharing groups' cycles on one key
600
+ // union here like any other allow-list contributions — that union IS the
601
+ // accordion's writable path set.
602
+ if ("cycle" in a) {
603
+ return [{ key: a.set, spec: { kind: "allow-list", allowed: a.cycle } }];
604
+ }
605
+ return [
606
+ {
607
+ key: a.set,
608
+ spec: {
609
+ kind: "range",
610
+ min: a.min,
611
+ max: a.max,
612
+ seed: clampSeed(seeds.get(a.set), a.min, a.max),
613
+ },
614
+ },
615
+ ];
616
+ }
617
+
618
+ // [LAW:one-source-of-truth] The unset seed for a stepped key is the backing
619
+ // state variable's `default` — the SAME number the bar displays before the first
620
+ // click — so the first relative step doesn't silently start from `min`. Absent or
621
+ // non-integer default falls back to `min` (the historical render-side behavior).
622
+ function clampSeed(seed: number | undefined, min: number, max: number): number {
623
+ if (seed === undefined) return min;
624
+ return Math.max(min, Math.min(max, seed));
625
+ }
626
+
627
+ // [LAW:one-source-of-truth] Each `state` variable's integer `default` is the
628
+ // initial value of its key — the value the bar renders before any click. The
629
+ // step-state handler must seed an unset key from the SAME number, so the derived
630
+ // range spec carries it. A non-integer or absent default contributes nothing
631
+ // (the key seeds from `min`).
632
+ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
633
+ const seeds = new Map<string, number>();
634
+ for (const decl of Object.values(config.variables)) {
635
+ if (decl.kind !== "state") continue;
636
+ const raw = decl.default;
637
+ if (raw !== undefined && INT_RE.test(raw)) {
638
+ seeds.set(decl.key, parseInt(raw, 10));
639
+ }
640
+ }
641
+ return seeds;
642
+ }
643
+
644
+ // [LAW:one-source-of-truth] The writable-key surface a config's ACTIONS need,
645
+ // DERIVED from the action table — the same declarations the `{{ action }}` fn
646
+ // realizes a click from are the gate the wire enforces.
647
+ function actionContributions(config: DslConfig): KeySpecContribution[] {
648
+ const seeds = stateKeySeeds(config);
649
+ return dropBaselineAllowLists(
650
+ Object.values(config.actions).flatMap((a) => actionKeySpecs(a, seeds)),
651
+ );
652
+ }
653
+
654
+ // [LAW:single-enforcer] The SOLE install-site derivation: a config's writable-key
655
+ // surface is the merge of every ACTION it declares, through ONE coherence pass.
656
+ // The action table is the single interaction authority — the same declarations
657
+ // the `{{ action }}`/`{{ picker }}` funcs realize clicks from are the gate the
658
+ // wire enforces. mergeContributions resolves any intra-table key collision (a
659
+ // picker's int page and a trigger's literal "0" on one key) into one gate.
660
+ export function deriveActionValidators(
661
+ config: DslConfig,
662
+ ): readonly KeySpecContribution[] {
663
+ return mergeContributions(actionContributions(config));
664
+ }
665
+
666
+ // [LAW:dataflow-not-control-flow] Single entry point for validation: the
667
+ // caller hands over (key, value), this returns a uniform ValidateResult
668
+ // regardless of whether the key was known. The verb body never branches
669
+ // on "did I get a validator" — the absence of a validator IS the rejection.
670
+ export function validateStateWrite(
671
+ key: string,
672
+ rawValue: string,
673
+ ): ValidateResult {
674
+ const entry = _STATE_VALIDATORS.get(key);
675
+ if (!entry) {
676
+ return {
677
+ ok: false,
678
+ reason: `unknown state key "${key}" (have: ${listStateKeys().join(", ")})`,
679
+ };
680
+ }
681
+ return entry.validator(rawValue);
682
+ }
683
+
684
+ // [LAW:types-are-the-program] The bounded-step parameters of a key: the (widened)
685
+ // [min,max] the step wraps within plus the `seed` an unset key starts from. The
686
+ // step-state handler reads these to compute `wrap(current ± by)` against LIVE
687
+ // state — the link carries only the signed `by`, so every numeric the wrap needs
688
+ // lives here in the single registry, never snapshotted into the link.
689
+ export interface RangeParams {
690
+ readonly min: number;
691
+ readonly max: number;
692
+ readonly seed: number;
693
+ }
694
+
695
+ // [LAW:one-source-of-truth] The registry IS the source of a key's bounds; the
696
+ // step handler reads them through this one boundary rather than re-deriving from
697
+ // the config. A key with no range registration (unknown, baseline, allow-list,
698
+ // or int) returns null — the handler rejects it as "not a stepper" loudly,
699
+ // never silently treating it as a step target.
700
+ export function rangeParamsFor(key: string): RangeParams | null {
701
+ const entry = _STATE_VALIDATORS.get(key);
702
+ if (!entry || entry.permanent || entry.kind !== "range") return null;
703
+ const ranges = entry.specs.flatMap((s) => (s.kind === "range" ? [s] : []));
704
+ if (ranges.length === 0) return null;
705
+ const min = Math.min(...ranges.map((r) => r.min));
706
+ const max = Math.max(...ranges.map((r) => r.max));
707
+ return { min, max, seed: clampSeed(ranges[0]!.seed, min, max) };
708
+ }