@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,26 +1,28 @@
1
1
  // [LAW:types-are-the-program] The action-table schema. An ActionDecl is
2
- // discriminated by exactly-one-of set/copy/open; a `set` adds exactly-one value
3
- // SOURCE (to/from/min-max-by/int). The proof here is what lets every downstream
4
- // consumer (renderAction, deriveActionValidators) match on the present key with
5
- // no fallthrough. Whether a `{{ action "name" }}` reference resolves is a
6
- // cross-ref concern. This file changes when the action vocabulary changes.
2
+ // discriminated by exactly-one-of set/persist/copy/open/reset; a `set` or
3
+ // `persist` adds exactly-one value SOURCE (to/from/min-max-by/int `persist`
4
+ // excludes `int`). The proof here is what lets every downstream consumer
5
+ // (renderAction, deriveActionValidators, deriveConfigActionValidators) match
6
+ // on the present key with no fallthrough. Whether a `{{ action "name" }}`
7
+ // reference resolves is a cross-ref concern. This file changes when the
8
+ // action vocabulary changes.
7
9
  //
8
10
  // [LAW:no-mode-explosion] Unlike cache (single-key value-arms → oneOfPresent) and
9
11
  // variables (tag-by-field-value → taggedUnion), an action's arms are multi-key
10
- // RECORDS: a `set` carries `set` plus a value-source group (`to` | `from` |
11
- // `min`/`max`/`by` | `int`). A single key never selects an arm, so the shared
12
- // present-key engine doesn't fit — bending it to would mean per-arm sibling
13
- // allow-lists and bespoke unknown-key messages bolted on as modes. Instead the
14
- // leaf machinery is shared (`fields` + `refine` + field specs carry every arm's
15
- // shape and cross-field invariant as DATA) and this file owns only the thin total
16
- // present-key dispatch the irreducible union eliminator.
12
+ // RECORDS: a `set`/`persist` carries the discriminator plus a value-source group
13
+ // (`to` | `from` | `min`/`max`/`by` | `int`). A single key never selects an arm, so
14
+ // the shared present-key engine doesn't fit — bending it to would mean per-arm
15
+ // sibling allow-lists and bespoke unknown-key messages bolted on as modes. Instead
16
+ // the leaf machinery is shared (`fields` + `refine` + field specs carry every arm's
17
+ // shape and cross-field invariant as DATA, parameterized by discriminator name so
18
+ // `set` and `persist` share one field-map definition) and this file owns only the
19
+ // thin total present-key dispatch — the irreducible union eliminator.
17
20
 
18
21
  import {
19
22
  ACTION_KEYS,
20
- OPTION_SOURCES,
21
23
  type ActionDecl,
22
24
  type ActionKey,
23
- type OptionSource,
25
+ type OptionDomain,
24
26
  } from "../action.js";
25
27
  import { findKeyLine } from "./diagnostics.js";
26
28
  import {
@@ -116,18 +118,38 @@ function validateActionDecl(
116
118
  }
117
119
 
118
120
  // [LAW:dataflow-not-control-flow] The top-level arm table as DATA: copy/open share
119
- // one template-arm shape (their key names the only difference); set delegates to
120
- // its value-source sub-union. The present key indexes this map — the eliminator
121
- // never branches on the key name.
121
+ // one template-arm shape (their key names the only difference); set/persist
122
+ // delegate to their value-source sub-union (the SAME field shapes, a different
123
+ // discriminator key see valueSourceAction); reset is copy/open's plain-string
124
+ // sibling. The present key indexes this map — the eliminator never branches on
125
+ // the key name.
122
126
  const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
123
- set: validateSetAction,
127
+ set: (ctx, path, raw) =>
128
+ valueSourceAction(
129
+ ctx,
130
+ path,
131
+ raw,
132
+ "set",
133
+ SET_ARMS,
134
+ "the SessionState key to write",
135
+ ),
136
+ persist: (ctx, path, raw) =>
137
+ valueSourceAction(
138
+ ctx,
139
+ path,
140
+ raw,
141
+ "persist",
142
+ PERSIST_ARMS,
143
+ "the config globals field to write",
144
+ ),
124
145
  copy: templateArm("copy"),
125
146
  open: templateArm("open"),
147
+ reset: resetArm,
126
148
  };
127
149
 
128
150
  // [LAW:one-source-of-truth] A copy/open action emits the closed single-key
129
151
  // object its arm validates — symmetric to `templateArm(key)`'s parse.
130
- function templateArmJson(key: "copy" | "open"): JsonNode {
152
+ function templateArmJson(key: "copy" | "open" | "reset"): JsonNode {
131
153
  return {
132
154
  type: "object",
133
155
  properties: { [key]: { type: "string" } },
@@ -136,16 +158,18 @@ function templateArmJson(key: "copy" | "open"): JsonNode {
136
158
  };
137
159
  }
138
160
 
139
- // [LAW:one-source-of-truth] One ActionDecl's schema: the set sub-union (each
140
- // arm's `json`, derived from SET_ARMS) joined with copy/open — the SAME members
141
- // `validateActionDecl` dispatches over. The `actions` block is a name → ActionDecl
142
- // map, symmetric to `validateActions`.
161
+ // [LAW:one-source-of-truth] One ActionDecl's schema: the set/persist sub-unions
162
+ // (each arm's `json`, derived from SET_ARMS/PERSIST_ARMS) joined with
163
+ // copy/open/reset — the SAME members `validateActionDecl` dispatches over. The
164
+ // `actions` block is a name → ActionDecl map, symmetric to `validateActions`.
143
165
  function actionDeclJson(): JsonNode {
144
166
  return {
145
167
  anyOf: [
146
168
  ...SET_ARMS.map((arm) => arm.json),
169
+ ...PERSIST_ARMS.map((arm) => arm.json),
147
170
  templateArmJson("copy"),
148
171
  templateArmJson("open"),
172
+ templateArmJson("reset"),
149
173
  ],
150
174
  };
151
175
  }
@@ -173,6 +197,37 @@ function templateArm(key: "copy" | "open"): ArmParse<ActionDecl> {
173
197
  };
174
198
  }
175
199
 
200
+ // [LAW:one-type-per-behavior] `reset` is copy/open's shape (a single required
201
+ // string, no other keys) but the string is a KEY (a config globals field name),
202
+ // not a template — no Go-template parsing happens for it, so it reuses the
203
+ // slash-free/non-empty shape `set`/`persist` keys share rather than
204
+ // requireString's bare-presence check. A `function` declaration (not a const
205
+ // arrow) so it is hoisted — ACTION_ARMS above references it directly, not
206
+ // through a deferred closure.
207
+ function resetArm(
208
+ ctx: ValidateCtx,
209
+ path: string,
210
+ raw: Record<string, unknown>,
211
+ ): ActionDecl | null {
212
+ for (const k of Object.keys(raw)) {
213
+ if (k !== "reset")
214
+ issue(
215
+ ctx,
216
+ `${path}.${k}`,
217
+ `Unknown key "${k}" on a reset action. Expected only: reset`,
218
+ );
219
+ }
220
+ const key = slashFreeString(
221
+ ctx,
222
+ path,
223
+ "reset",
224
+ raw,
225
+ `reset key must be non-empty (the config globals field to clear)`,
226
+ (v) => `reset key "${v}" contains "/" — keys must be slash-free`,
227
+ );
228
+ return key === null ? null : { reset: key };
229
+ }
230
+
176
231
  // ─── The `set` value-source sub-union ────────────────────────────────────────
177
232
 
178
233
  // [LAW:single-enforcer] A set-state URL path segment must be a non-empty,
@@ -205,41 +260,69 @@ function slashFreeString(
205
260
  return v;
206
261
  }
207
262
 
208
- // [LAW:dataflow-not-control-flow] The `set` key is validated once for every value
209
- // source (it is shared across all set arms), before the source is detected — so a
210
- // bad key and an ambiguous source both surface in one pass, matching the
211
- // hand-rolled order. It is therefore NOT a field of any arm's `fields` map; the
212
- // arm parses only the value-source payload, and the dispatcher re-attaches `set`.
213
- function validateSetKey(
263
+ // [LAW:dataflow-not-control-flow] The discriminator key ("set" or "persist")
264
+ // is validated once for every value source (it is shared across all arms of
265
+ // that discriminator), before the source is detected so a bad key and an
266
+ // ambiguous source both surface in one pass. It is therefore NOT a field of
267
+ // any arm's `fields` map; the arm parses only the value-source payload, and
268
+ // the dispatcher re-attaches the discriminator.
269
+ function validateValueSourceKey(
214
270
  ctx: ValidateCtx,
215
271
  path: string,
216
272
  raw: Record<string, unknown>,
273
+ discriminator: "set" | "persist",
274
+ keyNoun: string,
217
275
  ): string | null {
218
276
  return slashFreeString(
219
277
  ctx,
220
278
  path,
221
- "set",
279
+ discriminator,
222
280
  raw,
223
- `set key must be non-empty (the SessionState key to write)`,
224
- (v) => `set key "${v}" contains "/" — state keys must be slash-free`,
281
+ `${discriminator} key must be non-empty (${keyNoun})`,
282
+ (v) => `${discriminator} key "${v}" contains "/" — keys must be slash-free`,
225
283
  );
226
284
  }
227
285
 
286
+ // [LAW:one-source-of-truth] The wire verb name a discriminator's writes
287
+ // travel over — `set-state` for `set` (SessionState), `set-config` for
288
+ // `persist` (the config-overrides layer). Threaded into the shared field
289
+ // specs below so their "cannot be delivered on the X wire" messages name
290
+ // the wire the value actually crosses, and the field/value noun ("set
291
+ // value" / "persist value") names the actual action kind, not always `set`.
292
+ function wireName(discriminator: "set" | "persist"): string {
293
+ return discriminator === "set" ? "set-state" : "set-config";
294
+ }
295
+
228
296
  // [LAW:types-are-the-program] Each value source's payload as a field map — the
229
- // non-`set` keys that source carries. `fields` runs every spec (reporting all
230
- // issues) and fails the arm when a required field is absent or invalid; `refine`
231
- // adds the cross-field invariants `fields` cannot express. The reconstructed
232
- // payload IS the member minus `set`, which the dispatcher re-attaches.
233
- const TO_FIELDS: FieldSpecMap<{ to: string }> = { to: setLiteralSpec() };
234
- const FROM_FIELDS: FieldSpecMap<{ from: OptionSource }> = { from: fromSpec() };
297
+ // non-discriminator keys that source carries. `fields` runs every spec
298
+ // (reporting all issues) and fails the arm when a required field is absent or
299
+ // invalid; `refine` adds the cross-field invariants `fields` cannot express.
300
+ // The reconstructed payload IS the member minus the discriminator, which the
301
+ // dispatcher re-attaches. Built once per discriminator (`set`/`persist` share
302
+ // field SHAPE but not error WORDING see setLiteralSpec/fromSpec/cycleSpec).
303
+ const TO_FIELDS_SET: FieldSpecMap<{ to: string }> = {
304
+ to: setLiteralSpec("set"),
305
+ };
306
+ const TO_FIELDS_PERSIST: FieldSpecMap<{ to: string }> = {
307
+ to: setLiteralSpec("persist"),
308
+ };
309
+ const FROM_FIELDS_SET: FieldSpecMap<{ from: OptionDomain }> = {
310
+ from: fromSpec("set"),
311
+ };
312
+ const FROM_FIELDS_PERSIST: FieldSpecMap<{ from: OptionDomain }> = {
313
+ from: fromSpec("persist"),
314
+ };
235
315
  const BOUNDED_FIELDS: FieldSpecMap<{ min: number; max: number; by: number }> = {
236
316
  min: requireIntSpec(),
237
317
  max: requireIntSpec(),
238
318
  by: requireIntSpec(),
239
319
  };
240
320
  const INT_FIELDS: FieldSpecMap<{ int: true }> = { int: intMarkerSpec() };
241
- const CYCLE_FIELDS: FieldSpecMap<{ cycle: readonly string[] }> = {
242
- cycle: cycleSpec(),
321
+ const CYCLE_FIELDS_SET: FieldSpecMap<{ cycle: readonly string[] }> = {
322
+ cycle: cycleSpec("set"),
323
+ };
324
+ const CYCLE_FIELDS_PERSIST: FieldSpecMap<{ cycle: readonly string[] }> = {
325
+ cycle: cycleSpec("persist"),
243
326
  };
244
327
 
245
328
  // [LAW:types-are-the-program] A bounded step is fully described by an integer
@@ -270,29 +353,34 @@ const byNonZero: Refinement<BoundedPayload> = {
270
353
  }),
271
354
  };
272
355
 
273
- // [LAW:types-are-the-program] A set arm is its payload field map plus its
274
- // refinements; `detect` (the non-`set` keys whose presence selects it), `allowed`
275
- // (those keys plus `set`, the unknown-key allow-list), and `label` (the source
276
- // name in the exactly-one message — the detect keys joined by "/") all DERIVE from
277
- // the field map, so the field set is the single source for what the arm parses,
278
- // permits, and is named by.
279
- interface SetArm {
356
+ // [LAW:types-are-the-program] A value-source arm is its payload field map plus
357
+ // its refinements; `detect` (the non-discriminator keys whose presence
358
+ // selects it), `allowed` (those keys plus the discriminator, the unknown-key
359
+ // allow-list), and `label` (the source name in the exactly-one message — the
360
+ // detect keys joined by "/") all DERIVE from the field map, so the field set
361
+ // is the single source for what the arm parses, permits, and is named by.
362
+ // Parameterized by `discriminator` ("set" | "persist") so `set` and `persist`
363
+ // share the identical to/from/min-max-by/cycle shapes without duplicating
364
+ // their field maps — only the discriminator's NAME differs in the emitted
365
+ // object shape and the reconstructed member.
366
+ interface ValueSourceArm {
280
367
  readonly detect: readonly string[];
281
368
  readonly allowed: readonly string[];
282
369
  readonly label: string;
283
- // [LAW:one-source-of-truth] The arm's emit facet: the closed object schema for
284
- // a `set` of this source — the shared `set` key plus the source's own fields,
285
- // derived from the SAME field map `fields` validates. Cross-field refinements
286
- // (min<max, by≠0) are unexpressible in JSON Schema, so only the structural
287
- // shape is emitted the shape/meaning split every refinement keeps.
370
+ // [LAW:one-source-of-truth] The arm's emit facet: the closed object schema
371
+ // for this discriminator's value source — the discriminator key plus the
372
+ // source's own fields, derived from the SAME field map `fields` validates.
373
+ // Cross-field refinements (min<max, by≠0) are unexpressible in JSON Schema,
374
+ // so only the structural shape is emitted.
288
375
  readonly json: JsonNode;
289
376
  readonly parse: ArmParse<Partial<ActionDecl>>;
290
377
  }
291
378
 
292
- function setArm<P extends object>(
379
+ function valueSourceArm<P extends object>(
380
+ discriminator: "set" | "persist",
293
381
  fieldMap: FieldSpecMap<P>,
294
382
  ...checks: ReadonlyArray<Refinement<P>>
295
- ): SetArm {
383
+ ): ValueSourceArm {
296
384
  const detect = Object.keys(fieldMap);
297
385
  const inner: ArmParse<P> = (ctx, path, raw) =>
298
386
  fields(ctx, fieldMap, path, raw);
@@ -302,12 +390,12 @@ function setArm<P extends object>(
302
390
  };
303
391
  return {
304
392
  detect,
305
- allowed: ["set", ...detect],
393
+ allowed: [discriminator, ...detect],
306
394
  label: detect.join("/"),
307
395
  json: {
308
396
  type: "object",
309
- properties: { set: { type: "string" }, ...source.properties },
310
- required: ["set", ...(source.required ?? [])],
397
+ properties: { [discriminator]: { type: "string" }, ...source.properties },
398
+ required: [discriminator, ...(source.required ?? [])],
311
399
  additionalProperties: false,
312
400
  },
313
401
  parse: (checks.length
@@ -316,40 +404,60 @@ function setArm<P extends object>(
316
404
  };
317
405
  }
318
406
 
319
- // [LAW:dataflow-not-control-flow] The value-source arms in the order their labels
320
- // appear in the exactly-one message. A `set` declares exactly one of these; the
321
- // dispatcher counts presence over `detect` and reconstructs `{ set, ...payload }`.
322
- const SET_ARMS: readonly SetArm[] = [
323
- setArm(TO_FIELDS),
324
- setArm(FROM_FIELDS),
325
- setArm(BOUNDED_FIELDS, minLessThanMax, byNonZero),
326
- setArm(INT_FIELDS),
327
- setArm(CYCLE_FIELDS),
407
+ // [LAW:dataflow-not-control-flow] The value-source arms in the order their
408
+ // labels appear in the exactly-one message. A `set` declares exactly one of
409
+ // these; the dispatcher counts presence over `detect` and reconstructs
410
+ // `{ set, ...payload }`.
411
+ const SET_ARMS: readonly ValueSourceArm[] = [
412
+ valueSourceArm("set", TO_FIELDS_SET),
413
+ valueSourceArm("set", FROM_FIELDS_SET),
414
+ valueSourceArm("set", BOUNDED_FIELDS, minLessThanMax, byNonZero),
415
+ valueSourceArm("set", INT_FIELDS),
416
+ valueSourceArm("set", CYCLE_FIELDS_SET),
417
+ ];
418
+
419
+ // [LAW:one-type-per-behavior] `persist` mirrors `set` minus the `int` arm — a
420
+ // page cursor is a UI-only paging concept with no meaning as a persisted
421
+ // config default (see action.ts's ActionDecl comment).
422
+ const PERSIST_ARMS: readonly ValueSourceArm[] = [
423
+ valueSourceArm("persist", TO_FIELDS_PERSIST),
424
+ valueSourceArm("persist", FROM_FIELDS_PERSIST),
425
+ valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
426
+ valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
328
427
  ];
329
428
 
330
- const VALUE_SOURCE_MESSAGE = `a set action declares exactly one value source: "to" (a literal value), "from" (an option domain: ${OPTION_SOURCES.join(
331
- "/",
332
- )}), "min"/"max"/"by" (a bounded step), "int" (an unbounded integer cursor), or "cycle" (an enumerated domain stepped in order)`;
429
+ const VALUE_SOURCE_MESSAGE = (discriminator: "set" | "persist") =>
430
+ `a ${discriminator} action declares exactly one value source: "to" (a literal value), "from" (an option domain — a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values), "min"/"max"/"by" (a bounded step)${discriminator === "set" ? `, "int" (an unbounded integer cursor)` : ""}, or "cycle" (an enumerated domain stepped in order)`;
333
431
 
334
- // [LAW:dataflow-not-control-flow] The set sub-union eliminator: validate the
335
- // shared `set` key, count which value sources are present, require exactly one,
336
- // reject keys outside that arm's allow-list, parse the payload, reconstruct the
337
- // member. The variability (which arms, each arm's fields/refinements/allow-list)
338
- // is the SET_ARMS data; the only branches are the presence-count and the
339
- // null-threading both the key and the payload share.
340
- function validateSetAction(
432
+ // [LAW:dataflow-not-control-flow] The set/persist sub-union eliminator:
433
+ // validate the shared discriminator key, count which value sources are
434
+ // present, require exactly one, reject keys outside that arm's allow-list,
435
+ // parse the payload, reconstruct the member. The variability (which arms,
436
+ // each arm's fields/refinements/allow-list) is the `arms` data; the only
437
+ // branches are the presence-count and the null-threading both the key and
438
+ // the payload share.
439
+ function valueSourceAction(
341
440
  ctx: ValidateCtx,
342
441
  path: string,
343
442
  raw: Record<string, unknown>,
443
+ discriminator: "set" | "persist",
444
+ arms: readonly ValueSourceArm[],
445
+ keyNoun: string,
344
446
  ): ActionDecl | null {
345
- const stateKey = validateSetKey(ctx, path, raw);
447
+ const stateKey = validateValueSourceKey(
448
+ ctx,
449
+ path,
450
+ raw,
451
+ discriminator,
452
+ keyNoun,
453
+ );
346
454
 
347
- const present = SET_ARMS.filter((arm) => arm.detect.some((k) => k in raw));
455
+ const present = arms.filter((arm) => arm.detect.some((k) => k in raw));
348
456
  if (present.length !== 1) {
349
457
  issue(
350
458
  ctx,
351
459
  path,
352
- `${VALUE_SOURCE_MESSAGE}${
460
+ `${VALUE_SOURCE_MESSAGE(discriminator)}${
353
461
  present.length > 1
354
462
  ? ` — found: ${present.map((a) => a.label).join(", ")}`
355
463
  : ""
@@ -364,21 +472,24 @@ function validateSetAction(
364
472
  issue(
365
473
  ctx,
366
474
  `${path}.${k}`,
367
- `Unknown key "${k}" on this set action. Expected one of: ${arm.allowed.join(", ")}`,
475
+ `Unknown key "${k}" on this ${discriminator} action. Expected one of: ${arm.allowed.join(", ")}`,
368
476
  );
369
477
  }
370
478
 
371
479
  const payload = arm.parse(ctx, path, raw);
372
480
  return stateKey === null || payload === null
373
481
  ? null
374
- : ({ set: stateKey, ...payload } as unknown as ActionDecl);
482
+ : ({ [discriminator]: stateKey, ...payload } as unknown as ActionDecl);
375
483
  }
376
484
 
377
- // [LAW:no-silent-fallbacks] A literal `to` and the `set` key share the
378
- // non-empty/slash-free shape — the set-state wire rejects empty values and splits
379
- // on "/", so either is undeliverable. The empty/slash messages are this arm's,
380
- // the shape is the shared enforcer's.
381
- function setLiteralSpec(): FieldSpec<string> {
485
+ // [LAW:no-silent-fallbacks] A literal `to` and the discriminator key share
486
+ // the non-empty/slash-free shape — the wire rejects empty values and splits
487
+ // on "/", so either is undeliverable. The empty/slash messages are this
488
+ // arm's, the shape is the shared enforcer's. Built once per discriminator
489
+ // (see TO_FIELDS_SET/TO_FIELDS_PERSIST) so a `persist` action's message names
490
+ // "persist value" and the set-config wire, never `set`'s wording.
491
+ function setLiteralSpec(discriminator: "set" | "persist"): FieldSpec<string> {
492
+ const wire = wireName(discriminator);
382
493
  return {
383
494
  required: true,
384
495
  json: { type: "string" },
@@ -388,45 +499,108 @@ function setLiteralSpec(): FieldSpec<string> {
388
499
  path,
389
500
  field,
390
501
  raw,
391
- `set value must be non-empty — an empty value cannot be delivered on the set-state wire`,
392
- (v) => `set value "${v}" contains "/" — set values must be slash-free`,
502
+ `${discriminator} value must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
503
+ (v) =>
504
+ `${discriminator} value "${v}" contains "/" — ${discriminator} values must be slash-free`,
393
505
  ) ?? undefined,
394
506
  };
395
507
  }
396
508
 
397
- // [LAW:types-are-the-program] `from` is a required member of the closed
398
- // OPTION_SOURCES domain the option set a picker ranges. A non-member is a hard
399
- // error with the bespoke one-of message, never a silent fallback.
400
- function fromSpec(): FieldSpec<OptionSource> {
509
+ // [LAW:types-are-the-program] `from` is either a NAME (a non-empty string,
510
+ // resolved against the option-domain registry) or an INLINE literal domain (a
511
+ // non-empty array of deliverable wire values the same non-empty/
512
+ // slash-free wire shape `to` and `cycle` members enforce, plus the same
513
+ // uniqueness `cycleSpec` requires: a duplicate has no successor-ambiguity
514
+ // concern here, but it would render the same picker cell twice for no
515
+ // benefit). This arm proves only the SHAPE; whether a named domain actually
516
+ // resolves needs the merged config's per-config domains (e.g. "looks"), so
517
+ // that check is a cross-reference concern (validateCrossReferences) —
518
+ // symmetric to how a layout node's segment ref or a `{{ action }}` ref
519
+ // resolves post-merge. Built once per discriminator, same reason as
520
+ // setLiteralSpec.
521
+ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
522
+ const wire = wireName(discriminator);
401
523
  return {
402
524
  required: true,
403
- json: { enum: [...OPTION_SOURCES] },
525
+ json: {
526
+ anyOf: [
527
+ { type: "string", minLength: 1 },
528
+ {
529
+ type: "array",
530
+ items: { type: "string", minLength: 1 },
531
+ minItems: 1,
532
+ uniqueItems: true,
533
+ },
534
+ ],
535
+ },
404
536
  parse: (ctx, path, field, raw) => {
405
537
  const from = raw[field];
406
- if (
407
- typeof from !== "string" ||
408
- !(OPTION_SOURCES as readonly string[]).includes(from)
409
- ) {
410
- issue(
411
- ctx,
412
- `${path}.${field}`,
413
- `from must be one of: ${OPTION_SOURCES.join(", ")}, got ${describeValue(from)}`,
414
- );
415
- return undefined;
538
+ const at = `${path}.${field}`;
539
+ if (typeof from === "string") {
540
+ if (from === "") {
541
+ issue(ctx, at, `from must be a non-empty domain name`);
542
+ return undefined;
543
+ }
544
+ return from;
545
+ }
546
+ if (Array.isArray(from) && from.every((m) => typeof m === "string")) {
547
+ const members = from as string[];
548
+ if (members.length === 0) {
549
+ issue(
550
+ ctx,
551
+ at,
552
+ `from must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
553
+ );
554
+ return undefined;
555
+ }
556
+ if (members.some((m) => m === "")) {
557
+ issue(
558
+ ctx,
559
+ at,
560
+ `from array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
561
+ );
562
+ return undefined;
563
+ }
564
+ const slashed = members.filter((m) => m.includes("/"));
565
+ if (slashed.length > 0) {
566
+ issue(
567
+ ctx,
568
+ at,
569
+ `from array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
570
+ );
571
+ return undefined;
572
+ }
573
+ if (new Set(members).size !== members.length) {
574
+ issue(
575
+ ctx,
576
+ at,
577
+ `from array members must be unique — a duplicated value would render the same picker option twice`,
578
+ );
579
+ return undefined;
580
+ }
581
+ return members;
416
582
  }
417
- return from as OptionSource;
583
+ issue(
584
+ ctx,
585
+ at,
586
+ `from must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
587
+ );
588
+ return undefined;
418
589
  },
419
590
  };
420
591
  }
421
592
 
422
593
  // [LAW:types-are-the-program] `cycle` is the enumerated domain a click steps
423
594
  // through: at least two members (one member has no successor to step to — that
424
- // is a literal `to`), each a deliverable set-state value (non-empty, slash-free
595
+ // is a literal `to`), each a deliverable wire value (non-empty, slash-free
425
596
  // — the same wire shape `to` enforces), no duplicates (the successor of a
426
597
  // duplicated member is ambiguous). Members double as the derived allow-list
427
598
  // gate, so a member this spec admits is a value the wire delivers, by
428
- // construction.
429
- function cycleSpec(): FieldSpec<readonly string[]> {
599
+ // construction. Built once per discriminator, same reason as setLiteralSpec.
600
+ function cycleSpec(
601
+ discriminator: "set" | "persist",
602
+ ): FieldSpec<readonly string[]> {
603
+ const wire = wireName(discriminator);
430
604
  return {
431
605
  required: true,
432
606
  json: {
@@ -461,7 +635,7 @@ function cycleSpec(): FieldSpec<readonly string[]> {
461
635
  issue(
462
636
  ctx,
463
637
  at,
464
- `cycle members must be non-empty — an empty value cannot be delivered on the set-state wire`,
638
+ `cycle members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
465
639
  );
466
640
  return undefined;
467
641
  }
@@ -469,7 +643,7 @@ function cycleSpec(): FieldSpec<readonly string[]> {
469
643
  issue(
470
644
  ctx,
471
645
  at,
472
- `cycle member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — set values must be slash-free`,
646
+ `cycle member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
473
647
  );
474
648
  return undefined;
475
649
  }