@promptctl/cc-candybar 1.29.0 → 1.31.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.29.0",
3
+ "version": "1.31.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,9 +91,9 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.29.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.29.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.29.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.29.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.31.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.31.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.31.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.31.0"
98
98
  }
99
99
  }
@@ -1321,6 +1321,49 @@
1321
1321
  ],
1322
1322
  "additionalProperties": false
1323
1323
  },
1324
+ {
1325
+ "type": "object",
1326
+ "properties": {
1327
+ "persist": {
1328
+ "type": "string"
1329
+ },
1330
+ "removeSegment": {
1331
+ "type": "string"
1332
+ }
1333
+ },
1334
+ "required": [
1335
+ "persist",
1336
+ "removeSegment"
1337
+ ],
1338
+ "additionalProperties": false
1339
+ },
1340
+ {
1341
+ "type": "object",
1342
+ "properties": {
1343
+ "persist": {
1344
+ "type": "string"
1345
+ },
1346
+ "insertSegment": {
1347
+ "type": "string"
1348
+ },
1349
+ "anchor": {
1350
+ "type": "string"
1351
+ },
1352
+ "relation": {
1353
+ "enum": [
1354
+ "before",
1355
+ "after"
1356
+ ]
1357
+ }
1358
+ },
1359
+ "required": [
1360
+ "persist",
1361
+ "insertSegment",
1362
+ "anchor",
1363
+ "relation"
1364
+ ],
1365
+ "additionalProperties": false
1366
+ },
1324
1367
  {
1325
1368
  "type": "object",
1326
1369
  "properties": {
@@ -1356,6 +1399,30 @@
1356
1399
  "reset"
1357
1400
  ],
1358
1401
  "additionalProperties": false
1402
+ },
1403
+ {
1404
+ "type": "object",
1405
+ "properties": {
1406
+ "undo": {
1407
+ "const": true
1408
+ }
1409
+ },
1410
+ "required": [
1411
+ "undo"
1412
+ ],
1413
+ "additionalProperties": false
1414
+ },
1415
+ {
1416
+ "type": "object",
1417
+ "properties": {
1418
+ "redo": {
1419
+ "const": true
1420
+ }
1421
+ },
1422
+ "required": [
1423
+ "redo"
1424
+ ],
1425
+ "additionalProperties": false
1359
1426
  }
1360
1427
  ]
1361
1428
  }
package/src/click/wire.ts CHANGED
@@ -66,6 +66,26 @@ export const VERB_STEP_CONFIG = "step-config";
66
66
  // config-overrides key, restoring the user-file/bundled-default value on the
67
67
  // next reload. Args: `[sessionId, key]`.
68
68
  export const VERB_RESET_CONFIG = "reset-config";
69
+ // [LAW:one-type-per-behavior] brandon-layout-edit-2gc.1's structural-edit
70
+ // verb — a THIRD write semantic beside set-config's plain overwrite and
71
+ // step-config's numeric read-modify-write: read the current op-token LIST at
72
+ // `key` (a "presets.<name>.rootOps" config-overrides target), append the
73
+ // validated `op` token, write the whole list back. Args: `[sessionId, key,
74
+ // op]` — `op` is one opaque token from src/config/layout-ops.ts's codec, the
75
+ // SAME shape a `persist … to` literal's value would be, gated the SAME way
76
+ // (validateConfigWrite) — only the write's SHAPE (append vs. overwrite)
77
+ // differs, which is exactly why this is its own verb rather than another
78
+ // VERB_SET_CONFIG value.
79
+ export const VERB_APPLY_LAYOUT_OP = "apply-layout-op";
80
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.2's global history step
81
+ // over the config-overrides layer — the fine-grained sibling of
82
+ // VERB_RESET_CONFIG's coarse "clear one key". Args: `[sessionId]` — there is
83
+ // no key: the history is ONE stack over every persist/reset write ever made
84
+ // to the overrides file (config-overrides-store.ts), not a per-key log. An
85
+ // empty stack is a loud BAD_REQUEST surfaced through click.error like any
86
+ // other verb failure, never a silent no-op.
87
+ export const VERB_UNDO = "undo";
88
+ export const VERB_REDO = "redo";
69
89
 
70
90
  // [LAW:types-are-the-program] An effect to EMIT: a verb plus its raw (unencoded)
71
91
  // positional args. The wire owns all encoding — callers never percent-encode.
@@ -43,7 +43,24 @@ export type { OptionDomain } from "./option-domain.js";
43
43
  // overrides layer (never the hand-authored config file). `reset` clears one
44
44
  // persisted override — the gated undo `persist` needs, since a machine-owned
45
45
  // write with no way back would be a one-way ratchet.
46
- export const ACTION_KEYS = ["set", "persist", "copy", "open", "reset"] as const;
46
+ //
47
+ // [LAW:one-source-of-truth] `undo`/`redo` (brandon-layout-edit-2gc.2) are
48
+ // `reset`'s FINE-GRAINED siblings: `reset` clears one named key outright
49
+ // (the coarse "forget this override" case); `undo`/`redo` step ONE GLOBAL
50
+ // history of every `persist`/`reset` write ever made to the overrides layer
51
+ // — every key, not just structural layout edits — back and forth. Neither
52
+ // carries a key: the history is a single stack over the whole overrides
53
+ // file (config-overrides-store.ts owns it), so the action is a bare marker,
54
+ // like `int: true` is for a set-int cursor.
55
+ export const ACTION_KEYS = [
56
+ "set",
57
+ "persist",
58
+ "copy",
59
+ "open",
60
+ "reset",
61
+ "undo",
62
+ "redo",
63
+ ] as const;
47
64
  export type ActionKey = (typeof ACTION_KEYS)[number];
48
65
 
49
66
  // [LAW:types-are-the-program] An ActionDecl is the click effect a named action
@@ -75,6 +92,22 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
75
92
  // -> allow-list {members}
76
93
  // copy — copy templated text to the clipboard -> no gate
77
94
  // open — open a templated target in the editor -> no gate
95
+ // undo — step the config-overrides layer's GLOBAL history one
96
+ // entry back (any persist/reset write, not just a
97
+ // layout op) -> no gate, no key: there is nothing a
98
+ // template could smuggle, since the value restored is
99
+ // whatever the daemon's own history recorded, never
100
+ // wire input
101
+ // redo — the inverse of undo: re-apply the most recently
102
+ // undone entry -> no gate, no key
103
+ // removeSegment — (persist only) remove the named segment from the
104
+ // preset-root the `persist` key addresses
105
+ // (`presets.<name>.rootOps`) -> allow-list {one op
106
+ // token — see src/config/layout-ops.ts}
107
+ // insertSegment +
108
+ // anchor + relation — (persist only) insert a named segment before/after
109
+ // an existing one, same key shape -> allow-list {one
110
+ // op token}
78
111
  //
79
112
  // [LAW:one-source-of-truth] `set` writes SessionState and `persist` writes
80
113
  // the config-overrides layer, so only those two derive a validator (through
@@ -87,6 +120,15 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
87
120
  // arms verbatim (to/from/min-max-by/cycle) MINUS `int`: an unbounded page
88
121
  // cursor is a UI-only paging concept (a picker's own navigation state) with
89
122
  // no meaning as a persisted config default.
123
+ //
124
+ // [LAW:locality-or-seam] `removeSegment`/`insertSegment` are `persist`-ONLY
125
+ // (brandon-layout-edit-2gc.1) — a structural edit is always a durable,
126
+ // machine-owned write by design (the ticket's own instruction: reuse 71o's
127
+ // writer, land in the SAME overrides layer), so there is no SessionState
128
+ // twin. Every operation is fully literal at config-author time — the
129
+ // segment names and relation are DATA the loader proves at load, not a
130
+ // runtime picker — so each declared action has exactly one legal request,
131
+ // gated the same one-member-allow-list way a literal `to` already is.
90
132
  export type ActionDecl =
91
133
  | { readonly set: string; readonly to: string }
92
134
  | { readonly set: string; readonly from: OptionDomain }
@@ -107,9 +149,18 @@ export type ActionDecl =
107
149
  readonly by: number;
108
150
  }
109
151
  | { readonly persist: string; readonly cycle: readonly string[] }
152
+ | { readonly persist: string; readonly removeSegment: string }
153
+ | {
154
+ readonly persist: string;
155
+ readonly insertSegment: string;
156
+ readonly anchor: string;
157
+ readonly relation: "before" | "after";
158
+ }
110
159
  | { readonly copy: string }
111
160
  | { readonly open: string }
112
- | { readonly reset: string };
161
+ | { readonly reset: string }
162
+ | { readonly undo: true }
163
+ | { readonly redo: true };
113
164
 
114
165
  // [LAW:dataflow-not-control-flow] Does this action write a SessionState key? A
115
166
  // `set` action composes a set-state click URL whose first segment is session.id;
@@ -131,3 +182,14 @@ export function actionBindsPersist(a: ActionDecl): boolean {
131
182
  export function actionBindsReset(a: ActionDecl): boolean {
132
183
  return "reset" in a;
133
184
  }
185
+
186
+ // [LAW:dataflow-not-control-flow] Does this action step the config-overrides
187
+ // history? `undo`/`redo` carry session.id on the wire too — same reason as
188
+ // `reset`: an empty stack is a loud, session-scoped click.error, not a
189
+ // silent no-op (the ticket's own done-gate).
190
+ export function actionBindsUndo(a: ActionDecl): boolean {
191
+ return "undo" in a;
192
+ }
193
+ export function actionBindsRedo(a: ActionDecl): boolean {
194
+ return "redo" in a;
195
+ }
@@ -0,0 +1,156 @@
1
+ // [LAW:one-type-per-behavior] The seam brandon-layout-edit-2gc.1 opens: a
2
+ // bounded, statically-enumerable vocabulary for mutating a LayoutNode tree —
3
+ // remove the segment named X, insert a named segment before/after an
4
+ // existing one. No third LayoutNode kind, no free-form tree editing: a
5
+ // segment's own NAME is the stable position (dsl-types.ts already makes it
6
+ // one — SegmentNode.name is a ref into `segments`, unaffected by a sibling
7
+ // being added or removed elsewhere in the tree), so there is no sibling-index
8
+ // to invalidate when a prior op lands.
9
+ //
10
+ // [LAW:no-ambient-temporal-coupling] A position is never a fact frozen at
11
+ // declaration or click time — `removeSegment`/`insertSegment` always walk
12
+ // the CURRENT tree handed to them. The daemon (src/daemon/cache/render.ts)
13
+ // replays the accumulated op log against the live merged config on every
14
+ // reload, so a later, unrelated config edit is what the next replay sees,
15
+ // never a stale snapshot.
16
+ //
17
+ // [LAW:no-silent-failure] exception: a target/anchor absent from the tree is
18
+ // a documented NO-OP, not a thrown error — mirroring
19
+ // src/config/loader/merge.ts's applySegmentPaletteOverrides, the identical
20
+ // failure shape one layer up. A validated action can only ever name a
21
+ // segment the config declares (loader/cross-ref.ts checks this at config
22
+ // load), so a miss here only happens after a LATER edit (to the bundled
23
+ // default, the user's config, or an earlier op in the same accumulated list)
24
+ // removed the segment — there is nothing left to apply the op to, and
25
+ // bricking the whole render over a stale op would be worse than dropping it.
26
+
27
+ import type { LayoutNode, SegmentNode } from "./dsl-types.js";
28
+
29
+ // [LAW:types-are-the-program] The two operations brandon-layout-edit-2gc.1
30
+ // ships. Both address position by NAME, never by index. A future op (e.g.
31
+ // "move") is a new arm here, not a new node kind or a new codec.
32
+ export type LayoutOp =
33
+ | { readonly op: "remove"; readonly target: string }
34
+ | {
35
+ readonly op: "insert";
36
+ readonly segment: string;
37
+ readonly anchor: string;
38
+ readonly relation: "before" | "after";
39
+ };
40
+
41
+ // [LAW:single-enforcer] THE codec for a LayoutOp crossing the click wire and
42
+ // the config-overrides file as one opaque string. `:` is the delimiter
43
+ // (loader/actions.ts's segmentNameSpec rejects `:` and `/` in every name an
44
+ // op can carry, so decode is unambiguous — a plain split, no escaping).
45
+ // Encode and decode live together so the format cannot drift between the
46
+ // validator gate (config-validators.ts, which encodes the ONE token a
47
+ // declared action allows), the render side (which emits that same token),
48
+ // and the daemon (which decodes it back).
49
+ export function encodeLayoutOp(op: LayoutOp): string {
50
+ return op.op === "remove"
51
+ ? `remove:${op.target}`
52
+ : `insert:${op.segment}:${op.anchor}:${op.relation}`;
53
+ }
54
+
55
+ // [LAW:parse-dont-validate] Returns the typed op, or null for anything that
56
+ // doesn't decode — the boundary a caller stamps before trusting the shape.
57
+ // Reached only from data this process did not itself just encode (a
58
+ // hand-edited or previous-version config-overrides.json), never from a
59
+ // value produced by encodeLayoutOp in the same click.
60
+ export function decodeLayoutOp(token: string): LayoutOp | null {
61
+ const parts = token.split(":");
62
+ if (parts[0] === "remove" && parts.length === 2 && parts[1]) {
63
+ return { op: "remove", target: parts[1] };
64
+ }
65
+ if (
66
+ parts[0] === "insert" &&
67
+ parts.length === 4 &&
68
+ parts[1] &&
69
+ parts[2] &&
70
+ (parts[3] === "before" || parts[3] === "after")
71
+ ) {
72
+ return {
73
+ op: "insert",
74
+ segment: parts[1],
75
+ anchor: parts[2],
76
+ relation: parts[3],
77
+ };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ // [LAW:dataflow-not-control-flow] Ops are DATA folded over the tree in
83
+ // order — replaying zero ops is the identity fold, replaying N is the same
84
+ // reduce for every N. No branch on "are there ops to apply."
85
+ export function applyLayoutOps(
86
+ root: LayoutNode,
87
+ ops: readonly LayoutOp[],
88
+ ): LayoutNode {
89
+ return ops.reduce(applyOne, root);
90
+ }
91
+
92
+ function applyOne(root: LayoutNode, op: LayoutOp): LayoutNode {
93
+ return op.op === "remove"
94
+ ? removeSegment(root, op.target)
95
+ : insertSegment(root, op.segment, op.anchor, op.relation);
96
+ }
97
+
98
+ // [LAW:types-are-the-program] Total and non-throwing: every LayoutNode maps
99
+ // to a LayoutNode, whether or not `target` is present. Acts on the FIRST
100
+ // segment found named `target`, in walkNodes' canonical pre-order — a
101
+ // deterministic, documented tie-break for the edge case of a duplicate name,
102
+ // never an ambiguous or thrown outcome. `done` is a closure-local flag (fresh
103
+ // per call, never shared across calls) that stops the search the instant a
104
+ // match is spliced out, so an EARLIER sibling's match always wins over a
105
+ // LATER one — exactly walkNodes' visit order, not merely "this container's
106
+ // own children before its descendants" (a subtly different, wrong order a
107
+ // single-pass children-array scan would produce). An absent target returns
108
+ // the identical root reference.
109
+ function removeSegment(root: LayoutNode, target: string): LayoutNode {
110
+ let done = false;
111
+ function walk(node: LayoutNode): LayoutNode {
112
+ if (done || node.kind === "segment") return node;
113
+ const children: LayoutNode[] = [];
114
+ for (const child of node.children) {
115
+ if (!done && child.kind === "segment" && child.name === target) {
116
+ done = true;
117
+ continue;
118
+ }
119
+ children.push(walk(child));
120
+ }
121
+ return { ...node, children };
122
+ }
123
+ const result = walk(root);
124
+ return done ? result : root;
125
+ }
126
+
127
+ // [LAW:types-are-the-program] Splices a new `{ kind: "segment", name:
128
+ // segment }` node immediately before/after the FIRST node found named
129
+ // `anchor`, in walkNodes' canonical pre-order (see removeSegment's `done`
130
+ // note — the same closure-local stop-at-first-match shape). Total and
131
+ // non-throwing: an absent anchor returns the identical root reference.
132
+ function insertSegment(
133
+ root: LayoutNode,
134
+ segment: string,
135
+ anchor: string,
136
+ relation: "before" | "after",
137
+ ): LayoutNode {
138
+ let done = false;
139
+ function walk(node: LayoutNode): LayoutNode {
140
+ if (done || node.kind === "segment") return node;
141
+ const children: LayoutNode[] = [];
142
+ for (const child of node.children) {
143
+ if (!done && child.kind === "segment" && child.name === anchor) {
144
+ done = true;
145
+ const inserted: SegmentNode = { kind: "segment", name: segment };
146
+ if (relation === "before") children.push(inserted, child);
147
+ else children.push(child, inserted);
148
+ continue;
149
+ }
150
+ children.push(walk(child));
151
+ }
152
+ return { ...node, children };
153
+ }
154
+ const result = walk(root);
155
+ return done ? result : root;
156
+ }
@@ -145,6 +145,8 @@ const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
145
145
  copy: templateArm("copy"),
146
146
  open: templateArm("open"),
147
147
  reset: resetArm,
148
+ undo: markerArm("undo"),
149
+ redo: markerArm("redo"),
148
150
  };
149
151
 
150
152
  // [LAW:one-source-of-truth] A copy/open action emits the closed single-key
@@ -170,6 +172,8 @@ function actionDeclJson(): JsonNode {
170
172
  templateArmJson("copy"),
171
173
  templateArmJson("open"),
172
174
  templateArmJson("reset"),
175
+ markerArmJson("undo"),
176
+ markerArmJson("redo"),
173
177
  ],
174
178
  };
175
179
  }
@@ -228,6 +232,47 @@ function resetArm(
228
232
  return key === null ? null : { reset: key };
229
233
  }
230
234
 
235
+ // [LAW:one-type-per-behavior] `undo`/`redo` are copy/open/reset's shape one
236
+ // step further reduced: a single required key whose only legal VALUE is the
237
+ // literal `true` (mirrors intMarkerSpec — a marker, not data), because there
238
+ // is no key to name: the history they step is one global stack over the
239
+ // whole overrides layer, not a per-target write. `function`, not a const
240
+ // arrow, so ACTION_ARMS above (built before this declaration in source
241
+ // order) can reference it directly via hoisting.
242
+ function markerArm(key: "undo" | "redo"): ArmParse<ActionDecl> {
243
+ return (ctx, path, raw) => {
244
+ for (const k of Object.keys(raw)) {
245
+ if (k !== key)
246
+ issue(
247
+ ctx,
248
+ `${path}.${k}`,
249
+ `Unknown key "${k}" on a ${key} action. Expected only: ${key}`,
250
+ );
251
+ }
252
+ if (raw[key] !== true) {
253
+ issue(
254
+ ctx,
255
+ `${path}.${key}`,
256
+ `${key} must be the literal true (it takes no key — it steps the ONE global history over the whole overrides layer), got ${describeValue(raw[key])}`,
257
+ );
258
+ return null;
259
+ }
260
+ return { [key]: true } as unknown as ActionDecl;
261
+ };
262
+ }
263
+
264
+ // [LAW:one-source-of-truth] Mirrors templateArmJson's shape one level
265
+ // narrower: the value schema is `const: true`, not `type: string` — a
266
+ // marker action carries no data, on the wire or in the schema.
267
+ function markerArmJson(key: "undo" | "redo"): JsonNode {
268
+ return {
269
+ type: "object",
270
+ properties: { [key]: { const: true } },
271
+ required: [key],
272
+ additionalProperties: false,
273
+ };
274
+ }
275
+
231
276
  // ─── The `set` value-source sub-union ────────────────────────────────────────
232
277
 
233
278
  // [LAW:single-enforcer] A set-state URL path segment must be a non-empty,
@@ -324,6 +369,25 @@ const CYCLE_FIELDS_SET: FieldSpecMap<{ cycle: readonly string[] }> = {
324
369
  const CYCLE_FIELDS_PERSIST: FieldSpecMap<{ cycle: readonly string[] }> = {
325
370
  cycle: cycleSpec("persist"),
326
371
  };
372
+ // [LAW:one-type-per-behavior] brandon-layout-edit-2gc.1's two structural-edit
373
+ // arms — PERSIST-only (see action.ts's ActionDecl doc comment for why there
374
+ // is no `set` twin). Each field reuses layoutNameSpec: a segment/anchor name
375
+ // must be non-empty and free of both `/` (the click wire's own segment
376
+ // delimiter) and `:` (layout-ops.ts's op-token delimiter) — the SAME
377
+ // wire-safety diligence slashFreeString already applies to `to`/`cycle`
378
+ // members, one forbidden character wider.
379
+ const REMOVE_SEGMENT_FIELDS: FieldSpecMap<{ removeSegment: string }> = {
380
+ removeSegment: layoutNameSpec("removeSegment"),
381
+ };
382
+ const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
383
+ insertSegment: string;
384
+ anchor: string;
385
+ relation: "before" | "after";
386
+ }> = {
387
+ insertSegment: layoutNameSpec("insertSegment"),
388
+ anchor: layoutNameSpec("anchor"),
389
+ relation: relationSpec(),
390
+ };
327
391
 
328
392
  // [LAW:types-are-the-program] A bounded step is fully described by an integer
329
393
  // domain (min < max) and a non-zero integer increment (`by`; negative for a
@@ -418,16 +482,49 @@ const SET_ARMS: readonly ValueSourceArm[] = [
418
482
 
419
483
  // [LAW:one-type-per-behavior] `persist` mirrors `set` minus the `int` arm — a
420
484
  // page cursor is a UI-only paging concept with no meaning as a persisted
421
- // config default (see action.ts's ActionDecl comment).
485
+ // config default (see action.ts's ActionDecl comment). `removeSegment`/
486
+ // `insertSegment` are ADDITIONAL persist-only arms with no `set` counterpart.
422
487
  const PERSIST_ARMS: readonly ValueSourceArm[] = [
423
488
  valueSourceArm("persist", TO_FIELDS_PERSIST),
424
489
  valueSourceArm("persist", FROM_FIELDS_PERSIST),
425
490
  valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
426
491
  valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
492
+ valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
493
+ valueSourceArm("persist", INSERT_SEGMENT_FIELDS),
427
494
  ];
428
495
 
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)`;
496
+ // [LAW:one-source-of-truth] The clause list, not the joined string, is the
497
+ // data that varies per discriminatorthe "or" belongs on the LAST clause
498
+ // only, and which clause is last differs between `set` (ends at cycle) and
499
+ // `persist` (ends at insertSegment), so building a list and joining it is
500
+ // what keeps that placement correct without a second copy of the sentence.
501
+ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
502
+ const clauses = [
503
+ `"to" (a literal value)`,
504
+ `"from" (an option domain — a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values)`,
505
+ `"min"/"max"/"by" (a bounded step)`,
506
+ ];
507
+ if (discriminator === "set")
508
+ clauses.push(`"int" (an unbounded integer cursor)`);
509
+ clauses.push(`"cycle" (an enumerated domain stepped in order)`);
510
+ if (discriminator === "persist") {
511
+ clauses.push(
512
+ `"removeSegment" (remove a named segment from the layout)`,
513
+ `"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
514
+ );
515
+ }
516
+ return clauses;
517
+ }
518
+
519
+ function VALUE_SOURCE_MESSAGE(discriminator: "set" | "persist"): string {
520
+ const clauses = valueSourceClauses(discriminator);
521
+ const last = clauses[clauses.length - 1]!;
522
+ const list =
523
+ clauses.length === 1
524
+ ? last
525
+ : `${clauses.slice(0, -1).join(", ")}, or ${last}`;
526
+ return `a ${discriminator} action declares exactly one value source: ${list}`;
527
+ }
431
528
 
432
529
  // [LAW:dataflow-not-control-flow] The set/persist sub-union eliminator:
433
530
  // validate the shared discriminator key, count which value sources are
@@ -681,6 +778,61 @@ function intMarkerSpec(): FieldSpec<true> {
681
778
  };
682
779
  }
683
780
 
781
+ // [LAW:one-source-of-truth] A layout op's segment-name field (removeSegment /
782
+ // insertSegment / anchor) is non-empty and free of BOTH wire-structural
783
+ // characters: `/` (the click wire's own multi-arg segment delimiter, the
784
+ // same restriction slashFreeString already enforces for `to`/`cycle`) and
785
+ // `:` (layout-ops.ts's op-token delimiter — a name containing it would make
786
+ // encodeLayoutOp's output ambiguous to decode). One spec, three callsites,
787
+ // so the two-character restriction can't drift between them.
788
+ function layoutNameSpec(field: string): FieldSpec<string> {
789
+ return {
790
+ required: true,
791
+ json: { type: "string" },
792
+ parse: (ctx, path, f, raw) => {
793
+ const v = requireString(ctx, path, raw, f);
794
+ if (v === null) return undefined;
795
+ const at = `${path}.${f}`;
796
+ if (v === "") {
797
+ issue(ctx, at, `${field} must be non-empty (a segment name)`);
798
+ return undefined;
799
+ }
800
+ if (v.includes("/") || v.includes(":")) {
801
+ issue(
802
+ ctx,
803
+ at,
804
+ `${field} "${v}" contains "/" or ":" — segment names in a layout op must be free of both (the click wire's own delimiter and layout-ops.ts's op-token delimiter)`,
805
+ );
806
+ return undefined;
807
+ }
808
+ return v;
809
+ },
810
+ };
811
+ }
812
+
813
+ // [LAW:types-are-the-program] `relation` is a closed two-value enum, not a
814
+ // free string — a typo (`"befor"`) is a load error, never a click-time
815
+ // surprise. Mirrors intMarkerSpec's "one legal literal" shape, widened to
816
+ // two.
817
+ function relationSpec(): FieldSpec<"before" | "after"> {
818
+ return {
819
+ required: true,
820
+ json: { enum: ["before", "after"] },
821
+ parse: (ctx, path, field, raw) => {
822
+ const v = raw[field];
823
+ if (v !== "before" && v !== "after") {
824
+ issue(
825
+ ctx,
826
+ `${path}.${field}`,
827
+ `relation must be "before" or "after", got ${describeValue(v)}`,
828
+ );
829
+ return undefined;
830
+ }
831
+ return v;
832
+ },
833
+ };
834
+ }
835
+
684
836
  // [LAW:types-are-the-program] A required integer field — the field key (min / max
685
837
  // / by) comes from the map, the message names it. A non-integer or absent value
686
838
  // reports and fails the arm.