@promptctl/cc-candybar 1.26.0 → 1.28.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 +188 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +72 -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
@@ -1,97 +1,60 @@
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
1
+ // [LAW:single-enforcer] The SessionState instance of the shared keyed-
2
+ // validator registry (validator-registry.ts): the click protocol's `set-state`
3
+ // verb writes only what's registered here, paired with the per-key validator
3
4
  // that decides whether a raw incoming string is a legal value for that key.
4
5
  // Adding a new state-writable key is one entry in this table — no new verb,
5
6
  // no scattered string-matching, no defensive guard in the dispatcher.
6
7
  //
7
8
  // [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.
9
+ // SessionState mutations the click protocol can perform. Unknown-key
10
+ // rejection lists these names operators see exactly the surface they're
11
+ // allowed to write.
12
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.
13
+ // [LAW:one-type-per-behavior] The spec algebra (DerivedValidatorSpec,
14
+ // mergeKeySpecs, the registry's register/validate/dispose lifecycle) lives in
15
+ // validator-registry.ts, shared verbatim with config-validators.ts (the
16
+ // `persist` action's keyspace) — two keyspaces, one mechanism.
26
17
 
27
18
  import { listResolvablePaletteNames, STRIP_STYLES } from "../../themes/policy";
28
- import type { ActionDecl, OptionSource } from "../../config/action";
19
+ import type { ActionDecl } from "../../config/action";
20
+ import {
21
+ perConfigDomainsFor,
22
+ resolveOptionDomain,
23
+ } from "../../config/option-domain";
29
24
  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
- };
25
+ import {
26
+ clampSeed,
27
+ createValidatorRegistry,
28
+ mergeContributions,
29
+ type DerivedValidatorSpec,
30
+ type KeySpecContribution,
31
+ type KeyValidator,
32
+ type RangeParams,
33
+ type ValidateResult,
34
+ } from "./validator-registry";
35
+
36
+ export type {
37
+ DerivedValidatorSpec,
38
+ KeySpecContribution,
39
+ KeyValidator,
40
+ RangeParams,
41
+ ValidateResult,
42
+ } from "./validator-registry";
43
+ // [LAW:locality-or-seam] Re-exported for existing test/consumer imports the
44
+ // builder is generic (validator-registry.ts owns it), but state-validators.ts
45
+ // stays a stable barrel so nothing outside this module needs to know the
46
+ // factory moved.
47
+ export {
48
+ makeAllowListValidator,
49
+ makeIntValidator,
50
+ makeRangeValidator,
51
+ } from "./validator-registry";
82
52
 
83
53
  // [LAW:one-source-of-truth] listResolvablePaletteNames is THE set whose
84
54
  // members resolve to a concrete Palette. It deliberately excludes the "custom"
85
55
  // sentinel (which needs inline colors and is not a renderable theme name):
86
56
  // accepting "custom" here would persist an unrenderable value into SessionState
87
57
  // 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 STRIP_STYLES
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
58
  const RESOLVABLE_THEMES_LIST: readonly string[] = listResolvablePaletteNames();
96
59
  const RESOLVABLE_THEMES: ReadonlySet<string> = new Set(RESOLVABLE_THEMES_LIST);
97
60
  const RESOLVABLE_STYLES: ReadonlySet<string> = new Set(STRIP_STYLES);
@@ -118,23 +81,10 @@ const validateStyle: KeyValidator = (raw) => {
118
81
  return { ok: true, value: raw };
119
82
  };
120
83
 
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.
84
+ // [LAW:dataflow-not-control-flow] Boolean-ish accepts exactly four canonical
85
+ // inputs and normalizes to two canonical outputs: truthy ("1"/"true") → "1",
86
+ // falsy ("0"/"false") → "" (empty). The empty falsy sentinel matches what
87
+ // `toolbar-toggle` produces via `clear()` for the next render.
138
88
  const BOOLEAN_TRUTHY = new Set(["1", "true"]);
139
89
  const BOOLEAN_FALSY = new Set(["0", "false"]);
140
90
  const validateBoolean: KeyValidator = (raw) => {
@@ -146,432 +96,46 @@ const validateBoolean: KeyValidator = (raw) => {
146
96
  };
147
97
  };
148
98
 
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
- ]);
99
+ // [LAW:one-source-of-truth] THE SessionState instance of the shared registry.
100
+ // Baseline keys (style/theme/toolbar-expanded) are legacy widget-era targets
101
+ // that predate the action-table-driven world and stay permanent; every other
102
+ // SessionState key is fully derived from a config's action table.
103
+ const registry = createValidatorRegistry({
104
+ style: validateStyle,
105
+ theme: validateTheme,
106
+ "toolbar-expanded": validateBoolean,
107
+ });
191
108
 
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
109
  export function listStateKeys(): readonly string[] {
196
- return [..._STATE_VALIDATORS.keys()];
197
- }
198
-
199
- // [LAW:types-are-the-program] The validator is RESIDUE of a SETTLED spec: given
200
- // one merged spec, its validator is forced. This is a pure projection — kind ⇒
201
- // constructor — with NO union or widen of its own. The label is a pure function
202
- // of (key, kind) so the built validator is identical across registrations of one
203
- // key. makeIntValidator/makeRangeValidator/makeAllowListValidator are the single
204
- // validator constructors (re-validating slash/empty values), so a merged spec
205
- // that somehow held an undeliverable value would throw HERE, at config-load, not
206
- // at the operator's first click.
207
- function validatorForSpec(
208
- key: string,
209
- spec: DerivedValidatorSpec,
210
- ): KeyValidator {
211
- if (spec.kind === "int") return makeIntValidator(`menu page "${key}"`);
212
- if (spec.kind === "range")
213
- return makeRangeValidator(spec.min, spec.max, `stepper "${key}"`);
214
- return makeAllowListValidator(spec.allowed, `state "${key}"`);
110
+ return registry.listKeys();
215
111
  }
216
112
 
217
- // [LAW:one-source-of-truth] The validator for a key's live registrations, built
218
- // through the ONE collapse: mergeKeySpecs unions allow-list members, widens range
219
- // bounds, clamps the seed, and absorbs integer members — so the union/widen logic
220
- // lives in exactly one place and this builder is pure plumbing (collapse → project).
221
- // A value any live config can legitimately render is a value the wire accepts, by
222
- // construction, because the rendered options and the derived gate read one merge.
223
- function buildValidatorFromSpecs(
224
- key: string,
225
- specs: readonly DerivedValidatorSpec[],
226
- ): KeyValidator {
227
- return validatorForSpec(key, mergeKeySpecs(key, specs));
228
- }
229
-
230
- // [LAW:locality-or-seam] The widget config (a config-load consumer) owns the
231
- // lifecycle of the validators it installs; this returns a disposer rather than
232
- // coupling the registry to a global "config reload" event. The cache's
233
- // reloadInto installs the new state's validators, then disposes the old — the
234
- // dispose-before-swap contract that keeps a broken reload from corrupting
235
- // last-known-good. See src/daemon/cache/render.ts.
236
- //
237
- // [LAW:no-silent-fallbacks] A baseline (permanent) key cannot be re-claimed —
238
- // re-registering one throws, so a menu naming its page key `theme` surfaces a
239
- // loud config-load error rather than silently shadowing the theme gate.
240
- //
241
- // [LAW:types-are-the-program] The semantic-compatibility gate: a key has ONE
242
- // key shape. Registering an `int` spec for a key already held as `allow-list`
243
- // (or vice versa) is a genuine conflict — no merged validator could honor both —
244
- // so it throws at config-load, not silently keeps whichever loaded first. Two
245
- // registrations of the SAME kind merge: their specs accumulate and the validator
246
- // is rebuilt as their union, so two cache entries sharing a config (identical
247
- // specs → idempotent) and two distinct configs sharing a key (different members
248
- // → unioned, both deliverable) both resolve to one consistent gate.
249
- //
250
- // [LAW:single-enforcer] The disposer removes exactly its own spec once
251
- // (idempotent via the `active` flag), rebuilds the validator from what remains,
252
- // and deletes the key only when the last spec is gone.
253
113
  export function registerStateValidator(
254
114
  key: string,
255
115
  spec: DerivedValidatorSpec,
256
116
  ): () => void {
257
- if (!key) {
258
- throw new Error("registerStateValidator: key is required");
259
- }
260
- // [LAW:types-are-the-program] The set-state wire splits its tail on `/`, so a
261
- // slash-bearing key can never be addressed — listing it would be registry-vs-
262
- // wire drift. Reject at registration so the unreachable-but-listed state is
263
- // unrepresentable.
264
- if (key.includes("/")) {
265
- throw new Error(
266
- `registerStateValidator: key "${key}" contains "/" — the set-state ` +
267
- `wire shape splits on "/" so a slash-bearing key cannot be ` +
268
- `addressed. Use a slash-free key.`,
269
- );
270
- }
271
- const existing = _STATE_VALIDATORS.get(key);
272
- if (existing) {
273
- if (existing.permanent) {
274
- throw new Error(
275
- `registerStateValidator: key "${key}" is a built-in state key and ` +
276
- `cannot be re-claimed (built-in keys: ${[...baselineKeys()].join(", ")})`,
277
- );
278
- }
279
- if (existing.kind !== spec.kind) {
280
- throw new Error(
281
- `registerStateValidator: key "${key}" is already a ${existing.kind} ` +
282
- `state key; cannot also register it as ${spec.kind}. A state key has ` +
283
- `one key shape — a menu page index (int) and a button allow-list ` +
284
- `cannot share a key.`,
285
- );
286
- }
287
- existing.specs.push(spec);
288
- existing.validator = buildValidatorFromSpecs(key, existing.specs);
289
- } else {
290
- const specs = [spec];
291
- _STATE_VALIDATORS.set(key, {
292
- permanent: false,
293
- kind: spec.kind,
294
- validator: buildValidatorFromSpecs(key, specs),
295
- specs,
296
- });
297
- }
298
- let active = true;
299
- return () => {
300
- if (!active) return;
301
- active = false;
302
- const entry = _STATE_VALIDATORS.get(key);
303
- if (!entry || entry.permanent) return;
304
- const i = entry.specs.indexOf(spec);
305
- if (i >= 0) entry.specs.splice(i, 1);
306
- if (entry.specs.length === 0) {
307
- _STATE_VALIDATORS.delete(key);
308
- } else {
309
- entry.validator = buildValidatorFromSpecs(key, entry.specs);
310
- }
311
- };
312
- }
313
-
314
- function baselineKeys(): readonly string[] {
315
- const out: string[] = [];
316
- for (const [key, entry] of _STATE_VALIDATORS) {
317
- if (entry.permanent) out.push(key);
318
- }
319
- return out;
320
- }
321
-
322
- // [LAW:one-type-per-behavior] The "values come from list Y" pattern IS
323
- // the canonical widget-config use case (theme picker draws from
324
- // themes(), style picker draws from styles(), a custom enum picker
325
- // draws from a user-declared list). One factory builds the validator
326
- // from the list — every callsite that registers an allow-list key
327
- // passes through the same shape, so error messages, empty-input
328
- // rejection, and lookup semantics are identical by construction.
329
- //
330
- // [LAW:no-silent-fallbacks] Empty input is rejected with a label-
331
- // referencing reason rather than silently mapped to a default — the
332
- // shape matches validateTheme/validateStyle so the operator experience
333
- // is consistent across baseline and widget-installed keys.
334
- export function makeAllowListValidator(
335
- allowed: readonly string[],
336
- label: string,
337
- ): KeyValidator {
338
- // [LAW:types-are-the-program] The factory's contract is "options =
339
- // allow list" — every value the picker can RENDER must also be a
340
- // value the wire can DELIVER. Two structural reasons a declared
341
- // option can't reach the validator as itself:
342
- // (1) the wire splits the tail on "/", so a slash-bearing value
343
- // would arrive as two segments — the validator never sees it
344
- // as one value;
345
- // (2) the validator's empty-input rejection ("X value is required")
346
- // fires before the allow-list check, so an "" in the allow
347
- // list would be listed-but-undeliverable.
348
- // Both are the same shape as [LAW:registry-vs-wire drift] caught by
349
- // registerStateValidator's slash-key check. Catching at factory-build
350
- // time (config-load) per [LAW:verifiable-goals] surfaces a
351
- // misconfigured option list immediately, not on the operator's first
352
- // click. Mirrors registry surface = writable surface, by construction.
353
- const slashOffenders = allowed.filter((v) => v.includes("/"));
354
- if (slashOffenders.length > 0) {
355
- throw new Error(
356
- `makeAllowListValidator(${label}): values contain "/" — the set-state ` +
357
- `wire shape splits values on "/" so slash-bearing options cannot ` +
358
- `be addressed. Offending values: ${slashOffenders.join(", ")}`,
359
- );
360
- }
361
- if (allowed.includes("")) {
362
- throw new Error(
363
- `makeAllowListValidator(${label}): empty string is not a writable ` +
364
- `option — the validator rejects empty input before the allow-list ` +
365
- `check, so an "" in the allowed list could be rendered but never ` +
366
- `delivered. Remove "" from the allowed list.`,
367
- );
368
- }
369
- const allowedSet: ReadonlySet<string> = new Set(allowed);
370
- const allowedList = [...allowed];
371
- return (raw) => {
372
- if (!raw) return { ok: false, reason: `${label} value is required` };
373
- if (!allowedSet.has(raw)) {
374
- return {
375
- ok: false,
376
- reason: `unknown ${label} "${raw}" (have: ${allowedList.join(", ")})`,
377
- };
378
- }
379
- return { ok: true, value: raw };
380
- };
381
- }
382
-
383
- // [LAW:types-are-the-program] An integer-valued state key (a menu's page
384
- // index). The wire delivers a string; the validator IS the parse boundary —
385
- // it accepts only `^-?\d+$` and canonicalizes to the minimal decimal form, so
386
- // "007"/"-0" can't persist a non-canonical page that the next render's `int`
387
- // read would have to re-normalize. Negative is legal: -1 is the menu's CLOSED
388
- // sentinel.
389
- const INT_RE = /^-?\d+$/;
390
- export function makeIntValidator(label: string): KeyValidator {
391
- return (raw) => {
392
- if (!raw) return { ok: false, reason: `${label} value is required` };
393
- if (!INT_RE.test(raw)) {
394
- return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
395
- }
396
- // [LAW:types-are-the-program] Canonicalize as a pure decimal string —
397
- // strip leading zeros, fold "-0" → "0" — NOT via parseInt/String, which for
398
- // a >= 1e21 magnitude would emit scientific notation ("1e+21") that a later
399
- // parseInt(_, 10) reads back as 1. A page index is small in practice, but
400
- // the canonical form must hold for every accepted input, not just small ones.
401
- const neg = raw[0] === "-";
402
- const digits = (neg ? raw.slice(1) : raw).replace(/^0+/, "");
403
- if (digits === "") return { ok: true, value: "0" };
404
- return { ok: true, value: neg ? `-${digits}` : digits };
405
- };
117
+ return registry.register(key, spec);
406
118
  }
407
119
 
408
- // [LAW:types-are-the-program] A bounded-integer state key (a stepper's value).
409
- // The validator is the parse-AND-clamp boundary: it accepts only `^-?\d+$` then
410
- // clamps into [min,max]. [LAW:single-enforcer] This is the ONE place bounds are
411
- // enforced — it owns the [min,max] floor/ceiling for EVERY write to the key,
412
- // including a hand-typed wire URL. The stepper render owns NAVIGATION (wrap past
413
- // a bound to the other end) the way the menu render owns page navigation; the
414
- // stepper only ever emits values already inside bounds, so for stepper clicks
415
- // this clamp is identity. The clamped result is small (≤ |max| or |min| digits),
416
- // so String() cannot emit the scientific notation the raw int canonicalizer
417
- // guards against.
418
- export function makeRangeValidator(
419
- min: number,
420
- max: number,
421
- label: string,
422
- ): KeyValidator {
423
- return (raw) => {
424
- if (!raw) return { ok: false, reason: `${label} value is required` };
425
- if (!INT_RE.test(raw)) {
426
- return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
427
- }
428
- const clamped = Math.max(min, Math.min(max, parseInt(raw, 10)));
429
- return { ok: true, value: String(clamped) };
430
- };
431
- }
432
-
433
- // [LAW:one-source-of-truth] The option members a picker draws from ARE the same
434
- // canonical lists the `themes()`/`styles()`/`looks()` bindings and the baseline
435
- // theme/style validators consult — the rendered options and the derived gate
436
- // cannot diverge because there is no second enumeration. "looks" is the one
437
- // per-config domain: its names come from the config's merged looks block,
438
- // threaded in as data (the render-side optionDomain takes the same list).
439
- function optionValuesFor(
440
- src: OptionSource,
441
- lookNames: readonly string[],
442
- ): readonly string[] {
443
- if (src === "themes") return RESOLVABLE_THEMES_LIST;
444
- if (src === "styles") return STRIP_STYLES;
445
- return lookNames;
446
- }
447
-
448
- // [LAW:types-are-the-program] Collapse one key's spec contributions into the
449
- // single spec that gates it. A key is an INTEGER spec (a paged cursor `int` or a
450
- // bounded `range`) or an allow-list — never both. An integer spec ABSORBS
451
- // integer allow-list members (a trigger writing "0" to a page cursor is a legal
452
- // int write — the open-trigger pattern), and a NON-integer member aimed at it is
453
- // the genuine contradiction that throws. Two ranges widen-union; two allow-lists
454
- // union; an int and a range on one key (a page cursor vs a bounded value) conflict.
455
- function mergeKeySpecs(
120
+ export function validateStateWrite(
456
121
  key: string,
457
- specs: readonly DerivedValidatorSpec[],
458
- ): DerivedValidatorSpec {
459
- type Range = Extract<DerivedValidatorSpec, { kind: "range" }>;
460
- const ranges = specs.filter((s): s is Range => s.kind === "range");
461
- const hasInt = specs.some((s) => s.kind === "int");
462
- const allowed = specs.flatMap((s) =>
463
- s.kind === "allow-list" ? s.allowed : [],
464
- );
465
- if (ranges.length === 0 && !hasInt) {
466
- return { kind: "allow-list", allowed: [...new Set(allowed)] };
467
- }
468
- // [LAW:no-silent-fallbacks] An integer spec accepts only integer writes; a
469
- // non-integer member is a one-key-shape contradiction surfaced at load.
470
- const nonInt = allowed.filter((v) => !INT_RE.test(v));
471
- if (nonInt.length > 0) {
472
- throw new Error(
473
- `deriveActionValidators: key "${key}" is an integer spec (a paged ` +
474
- `cursor or a bounded value) but a click writes non-integer ` +
475
- `value(s) to it (${nonInt.join(", ")}). A state key has one key ` +
476
- `shape — point that click at a distinct key, or write an integer.`,
477
- );
478
- }
479
- if (hasInt && ranges.length > 0) {
480
- throw new Error(
481
- `deriveActionValidators: key "${key}" is declared as both a paged ` +
482
- `cursor (int) and a bounded value (range) — a state key has one key ` +
483
- `shape. Use distinct keys.`,
484
- );
485
- }
486
- if (ranges.length > 0) {
487
- const min = Math.min(...ranges.map((r) => r.min));
488
- const max = Math.max(...ranges.map((r) => r.max));
489
- // [LAW:no-silent-fallbacks] A page cursor (int) is UNBOUNDED, so any integer
490
- // write is a legal member to absorb. A bounded range is BOUNDED — an integer
491
- // a click declares OUTSIDE [min,max] would be clamped by the range gate at
492
- // click time, silently storing a different value than the click rendered.
493
- // That is a config error, surfaced at load rather than papered over at click.
494
- const outOfRange = allowed.filter((v) => {
495
- const n = parseInt(v, 10);
496
- return n < min || n > max;
497
- });
498
- if (outOfRange.length > 0) {
499
- throw new Error(
500
- `deriveActionValidators: key "${key}" is a bounded range [${min},${max}] ` +
501
- `but a click writes out-of-range value(s) to it ` +
502
- `(${outOfRange.join(", ")}). The range gate would clamp them, storing a ` +
503
- `different value than the click renders — write an in-range integer, ` +
504
- `or point that click at a distinct key.`,
505
- );
506
- }
507
- // [LAW:one-source-of-truth] Every range contribution to a key carries the
508
- // same seed (the one backing state variable's default), so any is canonical;
509
- // re-clamp it into the widened [min,max] to stay an in-range start value.
510
- const seed = clampSeed(ranges[0]!.seed, min, max);
511
- return { kind: "range", min, max, seed };
512
- }
513
- return { kind: "int" };
514
- }
515
-
516
- // [LAW:single-enforcer] A STRUCTURAL spec (menu int / stepper range) is always
517
- // kept — even on a baseline key — so a collision throws loudly at registration
518
- // rather than silently shadowing the permanent gate. Only an ALLOW-LIST
519
- // contribution to a baseline key is dropped (the click reuses the baseline gate
520
- // as intended). The spec kind IS that discriminator: structural is int/range, an
521
- // item/onClick spec is allow-list. Shared by both contribution collectors.
522
- function dropBaselineAllowLists(
523
- contributions: readonly KeySpecContribution[],
524
- ): KeySpecContribution[] {
525
- const baseline = new Set(baselineKeys());
526
- return contributions.filter(
527
- (c) => c.spec.kind !== "allow-list" || !baseline.has(c.key),
528
- );
122
+ rawValue: string,
123
+ ): ValidateResult {
124
+ return registry.validate(key, rawValue);
529
125
  }
530
126
 
531
- // [LAW:single-enforcer] THE coherence merge: group every contribution by key and
532
- // collapse each key's specs into the one spec that gates it (mergeKeySpecs).
533
- // Multiple actions writing the same key (a picker's int page and a trigger's
534
- // literal "0" are different KINDS to registerStateValidator) resolve here because
535
- // mergeKeySpecs absorbs an integer allow-list member into the int spec. One
536
- // merge, one gate per key.
537
- function mergeContributions(
538
- contributions: readonly KeySpecContribution[],
539
- ): KeySpecContribution[] {
540
- const byKey = new Map<string, DerivedValidatorSpec[]>();
541
- for (const { key, spec } of contributions) {
542
- const specs = byKey.get(key);
543
- if (specs) specs.push(spec);
544
- else byKey.set(key, [spec]);
545
- }
546
- return [...byKey].map(([key, specs]) => ({
547
- key,
548
- spec: mergeKeySpecs(key, specs),
549
- }));
127
+ export function rangeParamsFor(key: string): RangeParams | null {
128
+ return registry.rangeParamsFor(key);
550
129
  }
551
130
 
552
- // [LAW:single-enforcer] The ONE place mapping a decoupled ACTION to the validator
553
- // key SPEC it declares. The discriminator is the action's value SOURCE (which key
554
- // is present), as DATA:
555
- // a literal `set` + `to` declares an allow-list of {to};
556
- // • an option `set` + `from` declares an allow-list of the resolved domain —
557
- // the SAME canonical list the picker iterates, so the rendered options and
558
- // the gate cannot diverge;
559
- // • a bounded `set` + `min/max/by` declares a range [min,max] (the stepper's
560
- // navigation owns the wrap; the gate owns the bounds — `by` is render-only,
561
- // never in the spec) plus a `seed` (the unset initial value, read from the
562
- // backing state variable's `default` so the first relative click steps from
563
- // the displayed number);
564
- // • an `int` `set` declares an unbounded int (a paged picker's page cursor —
565
- // the renderer owns clamping; the gate requires integer shape);
566
- // • a `cycle` `set` declares an allow-list of its members (the renderer only
567
- // ever writes the successor member);
568
- // • copy/open write nothing, so they declare no spec.
569
- // A new action arm is one new branch here, returning data the existing merge
570
- // folds — no consumer re-walks an action's shape.
131
+ // [LAW:one-source-of-truth] The ONE place mapping a decoupled ACTION to the
132
+ // validator key SPEC it declares, for `set` (SessionState) actions. See
133
+ // config-validators.ts's actionKeySpecs for the `persist` (config-overrides)
134
+ // twin same shape, different action key and target keyspace.
571
135
  function actionKeySpecs(
572
136
  a: ActionDecl,
573
137
  seeds: ReadonlyMap<string, number>,
574
- lookNames: readonly string[],
138
+ perConfigDomains: ReadonlyMap<string, readonly string[]>,
575
139
  ): KeySpecContribution[] {
576
140
  if (!("set" in a)) return [];
577
141
  if ("to" in a) {
@@ -583,22 +147,14 @@ function actionKeySpecs(
583
147
  key: a.set,
584
148
  spec: {
585
149
  kind: "allow-list",
586
- allowed: optionValuesFor(a.from, lookNames),
150
+ allowed: resolveOptionDomain(a.from, perConfigDomains),
587
151
  },
588
152
  },
589
153
  ];
590
154
  }
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
155
  if ("int" in a) {
595
156
  return [{ key: a.set, spec: { kind: "int" } }];
596
157
  }
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
158
  if ("cycle" in a) {
603
159
  return [{ key: a.set, spec: { kind: "allow-list", allowed: a.cycle } }];
604
160
  }
@@ -615,22 +171,33 @@ function actionKeySpecs(
615
171
  ];
616
172
  }
617
173
 
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));
174
+ // [LAW:single-enforcer] A STRUCTURAL spec (menu int / stepper range) is
175
+ // always kepteven on a baseline key so a collision throws loudly at
176
+ // registration rather than silently shadowing the permanent gate. Only an
177
+ // ALLOW-LIST contribution to a baseline key is dropped (the click reuses the
178
+ // baseline gate as intended).
179
+ //
180
+ // [LAW:one-source-of-truth] The baseline set is read from the registry that
181
+ // owns it (registry.listBaselineKeys()), not re-declared here — the baseline
182
+ // keys were passed to createValidatorRegistry above; a second hardcoded list
183
+ // could silently drift from them if a future baseline key were added there
184
+ // and forgotten here.
185
+ function dropBaselineAllowLists(
186
+ contributions: readonly KeySpecContribution[],
187
+ ): KeySpecContribution[] {
188
+ const baseline = new Set(registry.listBaselineKeys());
189
+ return contributions.filter(
190
+ (c) => c.spec.kind !== "allow-list" || !baseline.has(c.key),
191
+ );
625
192
  }
626
193
 
627
194
  // [LAW:one-source-of-truth] Each `state` variable's integer `default` is the
628
195
  // 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`).
196
+ // step-state handler must seed an unset key from the SAME number, so the
197
+ // derived range spec carries it.
632
198
  function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
633
199
  const seeds = new Map<string, number>();
200
+ const INT_RE = /^-?\d+$/;
634
201
  for (const decl of Object.values(config.variables)) {
635
202
  if (decl.kind !== "state") continue;
636
203
  const raw = decl.default;
@@ -641,79 +208,24 @@ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
641
208
  return seeds;
642
209
  }
643
210
 
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.
211
+ // [LAW:one-source-of-truth] The writable-key surface a config's `set` actions
212
+ // need, DERIVED from the action table — the same declarations the
213
+ // `{{ action }}` fn realizes a click from are the gate the wire enforces.
647
214
  function actionContributions(config: DslConfig): KeySpecContribution[] {
648
215
  const seeds = stateKeySeeds(config);
649
- const lookNames = Object.keys(config.looks);
216
+ const perConfigDomains = perConfigDomainsFor(config);
650
217
  return dropBaselineAllowLists(
651
218
  Object.values(config.actions).flatMap((a) =>
652
- actionKeySpecs(a, seeds, lookNames),
219
+ actionKeySpecs(a, seeds, perConfigDomains),
653
220
  ),
654
221
  );
655
222
  }
656
223
 
657
- // [LAW:single-enforcer] The SOLE install-site derivation: a config's writable-key
658
- // surface is the merge of every ACTION it declares, through ONE coherence pass.
659
- // The action table is the single interaction authority — the same declarations
660
- // the `{{ action }}`/`{{ picker }}` funcs realize clicks from are the gate the
661
- // wire enforces. mergeContributions resolves any intra-table key collision (a
662
- // picker's int page and a trigger's literal "0" on one key) into one gate.
224
+ // [LAW:single-enforcer] The SOLE install-site derivation: a config's
225
+ // SessionState-writable-key surface is the merge of every `set` ACTION it
226
+ // declares, through ONE coherence pass.
663
227
  export function deriveActionValidators(
664
228
  config: DslConfig,
665
229
  ): readonly KeySpecContribution[] {
666
230
  return mergeContributions(actionContributions(config));
667
231
  }
668
-
669
- // [LAW:dataflow-not-control-flow] Single entry point for validation: the
670
- // caller hands over (key, value), this returns a uniform ValidateResult
671
- // regardless of whether the key was known. The verb body never branches
672
- // on "did I get a validator" — the absence of a validator IS the rejection.
673
- export function validateStateWrite(
674
- key: string,
675
- rawValue: string,
676
- ): ValidateResult {
677
- const entry = _STATE_VALIDATORS.get(key);
678
- if (!entry) {
679
- return {
680
- ok: false,
681
- reason: `unknown state key "${key}" (have: ${listStateKeys().join(", ")})`,
682
- };
683
- }
684
- return entry.validator(rawValue);
685
- }
686
-
687
- // [LAW:types-are-the-program] The bounded-step parameters of a key: the (widened)
688
- // [min,max] the step wraps within plus the `seed` an unset key starts from. The
689
- // step-state handler reads these to compute `wrap(current ± by)` against LIVE
690
- // state — the link carries only the signed `by`, so every numeric the wrap needs
691
- // lives here in the single registry, never snapshotted into the link.
692
- export interface RangeParams {
693
- readonly min: number;
694
- readonly max: number;
695
- readonly seed: number;
696
- }
697
-
698
- // [LAW:one-source-of-truth] The registry IS the source of a key's bounds; the
699
- // step handler reads them through this one boundary rather than re-deriving from
700
- // the config. A key with no range registration (unknown, baseline, allow-list,
701
- // or int) returns null — the handler rejects it as "not a stepper" loudly,
702
- // never silently treating it as a step target.
703
- export function rangeParamsFor(key: string): RangeParams | null {
704
- const entry = _STATE_VALIDATORS.get(key);
705
- if (!entry || entry.permanent || entry.kind !== "range") return null;
706
- // [LAW:one-source-of-truth] The bounds/seed come from THE same collapse the
707
- // validator is built from (mergeKeySpecs) — no second widen/clamp lives here.
708
- const spec = mergeKeySpecs(key, entry.specs);
709
- // [LAW:no-silent-failure] entry.kind === "range" means every live spec is a
710
- // range (registration rejects a kind change), so the collapse is a range too;
711
- // a non-range here is a broken invariant, surfaced loudly, not a silent null.
712
- if (spec.kind !== "range") {
713
- throw new Error(
714
- `rangeParamsFor: key "${key}" holds range specs but the merge produced ` +
715
- `a ${spec.kind} spec — the entry-kind invariant is broken.`,
716
- );
717
- }
718
- return { min: spec.min, max: spec.max, seed: spec.seed };
719
- }