@promptctl/cc-candybar 1.31.0 → 1.32.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.31.0",
3
+ "version": "1.32.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.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"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.32.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.32.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.32.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.32.0"
98
98
  }
99
99
  }
@@ -1364,6 +1364,47 @@
1364
1364
  ],
1365
1365
  "additionalProperties": false
1366
1366
  },
1367
+ {
1368
+ "type": "object",
1369
+ "properties": {
1370
+ "persist": {
1371
+ "type": "string"
1372
+ },
1373
+ "insertSegmentFrom": {
1374
+ "anyOf": [
1375
+ {
1376
+ "type": "string",
1377
+ "minLength": 1
1378
+ },
1379
+ {
1380
+ "type": "array",
1381
+ "items": {
1382
+ "type": "string",
1383
+ "minLength": 1
1384
+ },
1385
+ "minItems": 1,
1386
+ "uniqueItems": true
1387
+ }
1388
+ ]
1389
+ },
1390
+ "anchor": {
1391
+ "type": "string"
1392
+ },
1393
+ "relation": {
1394
+ "enum": [
1395
+ "before",
1396
+ "after"
1397
+ ]
1398
+ }
1399
+ },
1400
+ "required": [
1401
+ "persist",
1402
+ "insertSegmentFrom",
1403
+ "anchor",
1404
+ "relation"
1405
+ ],
1406
+ "additionalProperties": false
1407
+ },
1367
1408
  {
1368
1409
  "type": "object",
1369
1410
  "properties": {
@@ -108,6 +108,13 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
108
108
  // anchor + relation — (persist only) insert a named segment before/after
109
109
  // an existing one, same key shape -> allow-list {one
110
110
  // op token}
111
+ // insertSegmentFrom +
112
+ // anchor + relation — (persist only) insertSegment's domain-sourced
113
+ // sibling (brandon-layout-edit-2gc.3): the segment
114
+ // name is picked from an option domain at render
115
+ // (a `{{ menu }}`'s bound option) rather than fixed
116
+ // at author time -> allow-list {one op token per
117
+ // domain member}
111
118
  //
112
119
  // [LAW:one-source-of-truth] `set` writes SessionState and `persist` writes
113
120
  // the config-overrides layer, so only those two derive a validator (through
@@ -156,6 +163,24 @@ export type ActionDecl =
156
163
  readonly anchor: string;
157
164
  readonly relation: "before" | "after";
158
165
  }
166
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's DOMAIN-SOURCED
167
+ // sibling of `insertSegment`: the same tree op, but the segment name comes
168
+ // from the template's bound option (a picker/menu cell) instead of being
169
+ // fixed at config-author time — exactly the `to`-vs-`from` split `set`/
170
+ // `persist` already draw, one arm over. `anchor`/`relation` stay literal
171
+ // (the POSITION is still author-time data; only WHICH segment lands there
172
+ // is picked at render). This is what makes a `{{ menu "insertHere" }}`
173
+ // legal over a structural edit: `requireOptionKind` (render/picker.ts)
174
+ // admits it alongside set-option/persist-option, and the click writes
175
+ // `encodeLayoutOp({ op: "insert", segment: <picked>, anchor, relation })` —
176
+ // the SAME wire shape a literal `insertSegment` action emits, so undo/redo
177
+ // and the daemon's apply-layout-op handler need no changes at all.
178
+ | {
179
+ readonly persist: string;
180
+ readonly insertSegmentFrom: OptionDomain;
181
+ readonly anchor: string;
182
+ readonly relation: "before" | "after";
183
+ }
159
184
  | { readonly copy: string }
160
185
  | { readonly open: string }
161
186
  | { readonly reset: string }
@@ -48,6 +48,8 @@ import { validateVariables } from "./loader/variables.js";
48
48
  import { validateSegments } from "./loader/segments.js";
49
49
  import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
50
50
  import { synthesizeMenuDecls } from "./loader/menu-synth.js";
51
+ import { synthesizeEditModeToggle } from "./loader/edit-mode.js";
52
+ import { synthesizeEditChrome } from "./edit-chrome.js";
51
53
  import { validateActions } from "./loader/actions.js";
52
54
  import { validateLooks } from "./loader/looks.js";
53
55
  import { validatePresets } from "./loader/presets.js";
@@ -143,7 +145,15 @@ export function validateConfig(
143
145
  if (issues.length > 0) {
144
146
  throw new ConfigError(filePath, issues);
145
147
  }
146
- return config as ValidatedConfig;
148
+ // [LAW:one-source-of-truth] Edit-mode's CHROME half (brandon-layout-edit-
149
+ // 2gc.3), synthesized HERE — not in parseDslConfig alongside the toggle —
150
+ // because it needs the fully merged, preset-resolved, rootOps-replayed
151
+ // tree cross-ref/cycles just proved sound. Its own output (segment refs
152
+ // into freshly-synthesized segments, actions into freshly-synthesized
153
+ // actions) is correct by construction and does not re-enter cross-ref/
154
+ // cycle checking, exactly as group/menu synthesis's output doesn't either.
155
+ const withChrome = synthesizeEditChrome(config);
156
+ return withChrome as ValidatedConfig;
147
157
  }
148
158
 
149
159
  /**
@@ -278,6 +288,13 @@ function validateTopLevel(
278
288
  // through deriveActionValidators, and collide loudly with any user name under
279
289
  // the reserved namespace.
280
290
  synthesizeMenuDecls(ctx, out);
291
+ // [LAW:one-source-of-truth] Edit-mode's TOGGLE half (brandon-layout-edit-
292
+ // 2gc.3) — unconditional, like the reservation above, so `edit.mode`/
293
+ // `edit.toggle` exist in EVERY parsed file and a hand-authored trigger
294
+ // segment cross-ref-checks normally. The CHROME half (the per-position +/-
295
+ // affordances) runs later, in validateConfig, once the merged/preset-
296
+ // resolved/rootOps-replayed tree exists to derive it from.
297
+ synthesizeEditModeToggle(ctx, out);
281
298
  return out;
282
299
  }
283
300
 
@@ -0,0 +1,329 @@
1
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's CHROME half — the
2
+ // LOWERING that turns "edit mode is a session toggle" into "each row's
3
+ // segments render interleaved with +/- affordances" without a render-walk
4
+ // branch [LAW:dataflow-not-control-flow]. Follows the SAME move `kind:
5
+ // "group"` sugar makes (src/config/loader/layout.ts): one pass produces a NEW
6
+ // tree with synthesized nodes spliced in, each gated by an ordinary `when` —
7
+ // the walk that renders it learns nothing new. The difference from group
8
+ // sugar is WHEN this can run: a group is authored data, lowered per file
9
+ // before merge; edit chrome is DERIVED from which segments are actually in
10
+ // the tree, which is only known after merge, preset-root resolution, and
11
+ // rootOps replay. So this runs from validateConfig, on the fully resolved
12
+ // config each declared preset stages — see synthesizeEditChrome below.
13
+ //
14
+ // [LAW:single-enforcer] The +/- affordances reuse EXISTING primitives
15
+ // wholesale rather than inventing parallel ones: `-` is an ordinary
16
+ // `{ persist, removeSegment }` action behind `{{ action }}` (2gc.1); `+` is an
17
+ // ordinary `{ persist, insertSegmentFrom }` action behind `{{ menu }}`
18
+ // (2gc.3's new arm — see action.ts), synthesized by calling the SAME pure
19
+ // functions `{{ menu }}`'s own load-time synthesis calls
20
+ // (menu-keys.ts/disclosure.ts) so a synthesized menu and a hand-authored one
21
+ // are indistinguishable at render. Nothing here is a new render concept.
22
+
23
+ import type { ActionDecl as ActionDeclType, OptionDomain } from "./action.js";
24
+ import type {
25
+ ContainerNode,
26
+ DslConfig,
27
+ LayoutNode,
28
+ PresetDecl,
29
+ SegmentDecl,
30
+ SegmentNode,
31
+ VariableDecl,
32
+ } from "./dsl-types.js";
33
+ import { collectSegmentNames } from "./layout-ops.js";
34
+ import { presetByName, presetNames, presetRoot } from "./presets.js";
35
+ import {
36
+ EDIT_MODE_GATE,
37
+ EDIT_NS,
38
+ EDIT_TOGGLE_ACTION,
39
+ } from "./loader/edit-mode.js";
40
+ import { GROUP_NS } from "./loader/layout.js";
41
+ import {
42
+ menuActionName,
43
+ menuMember,
44
+ menuPageKey,
45
+ menuStateKey,
46
+ MENU_NS,
47
+ } from "./menu-keys.js";
48
+ import {
49
+ DISCLOSURE_CLOSED,
50
+ disclosureCycleAction,
51
+ disclosureStateVar,
52
+ } from "./disclosure.js";
53
+
54
+ // [LAW:one-source-of-truth] group/menu-synthesized segments (`groups.`/
55
+ // `menus.`) and edit mode's own trigger/chrome (`edit.`) are structural —
56
+ // removing one via `-` would strand its sibling artifacts (a toggle segment
57
+ // with no body, a menu with no host), and offering one back via `+` would
58
+ // insert a bare ref with none of the synthesis that made it work. Ordinary
59
+ // content segments only.
60
+ function isChromeExempt(name: string): boolean {
61
+ return (
62
+ name.startsWith(EDIT_NS) ||
63
+ name.startsWith(MENU_NS) ||
64
+ name.startsWith(GROUP_NS)
65
+ );
66
+ }
67
+
68
+ // [LAW:types-are-the-program] Collapse an arbitrary name to a template-
69
+ // identifier-safe fragment — the SAME shape menu-keys.ts's `ident` enforces,
70
+ // reimplemented here rather than imported (menu-keys.ts's copy is
71
+ // module-private) since both need only the one rule: alphanumerics survive,
72
+ // everything else collapses to `_`.
73
+ function ident(name: string): string {
74
+ return name.replace(/[^A-Za-z0-9]+/g, "_");
75
+ }
76
+
77
+ // [LAW:one-source-of-truth] Every synthesized decl this pass produces, keyed
78
+ // by its final name — one accumulator threaded through every preset's splice
79
+ // so cross-preset names (disambiguated by `presetIdent`) can never collide.
80
+ interface ChromeArtifacts {
81
+ readonly variables: Record<string, VariableDecl>;
82
+ readonly actions: Record<string, ActionDeclType>;
83
+ readonly segments: Record<string, SegmentDecl>;
84
+ }
85
+
86
+ // [LAW:one-source-of-truth] The domain name a preset's `+` pickers range —
87
+ // computed once per preset (declared segments minus the ones already present
88
+ // in ITS current tree) and consumed two ways: here (by name, for every
89
+ // insertSegmentFrom action this preset's splice synthesizes) and by
90
+ // registerDslConfig/deriveConfigActionValidators (which call
91
+ // `addableSegmentDomains` directly to populate `perConfigDomains` before
92
+ // resolving `from`). Both read the SAME string shape so a synthesized
93
+ // action's domain name always resolves.
94
+ export function addableDomainName(presetName: string): string {
95
+ return `${EDIT_NS}addable.${presetName}`;
96
+ }
97
+
98
+ // [LAW:one-source-of-truth] THE per-preset "what can `+` offer here" set:
99
+ // every declared, non-exempt segment name minus the ones already present
100
+ // anywhere in that preset's CURRENT (merged, rootOps-replayed) tree. Exported
101
+ // so render.ts's registerDslConfig and config-validators.ts's
102
+ // deriveConfigActionValidators — the two sites that resolve `from` domains —
103
+ // merge this into `perConfigDomainsFor`'s map without each re-deriving it
104
+ // [LAW:locality-or-seam]; option-domain.ts itself stays untouched (its
105
+ // `perConfigDomainsFor` deliberately never imports dsl-types.ts — see that
106
+ // file's own header — so a third per-preset domain merges at the two call
107
+ // sites instead of inside it).
108
+ export function addableSegmentDomains(
109
+ config: DslConfig,
110
+ ): ReadonlyMap<string, readonly string[]> {
111
+ const declared = Object.keys(config.segments).filter(
112
+ (n) => !isChromeExempt(n),
113
+ );
114
+ const domains = new Map<string, readonly string[]>();
115
+ for (const name of presetNames(config.presets)) {
116
+ const { node } = presetRoot(config, name);
117
+ const present = collectSegmentNames(node);
118
+ domains.set(
119
+ addableDomainName(name),
120
+ declared.filter((n) => !present.has(n)),
121
+ );
122
+ }
123
+ return domains;
124
+ }
125
+
126
+ // Synthesize the `-` affordance for one segment instance: a literal
127
+ // `removeSegment` action plus the segment that hosts its `{{ action }}`.
128
+ function removeChrome(
129
+ presetIdent: string,
130
+ rootOpsKey: string,
131
+ segName: string,
132
+ artifacts: ChromeArtifacts,
133
+ ): SegmentNode {
134
+ const actionName = `${EDIT_NS}${presetIdent}.remove.${ident(segName)}`;
135
+ const chromeSegName = `${EDIT_NS}${presetIdent}.removeSeg.${ident(segName)}`;
136
+ artifacts.actions[actionName] = {
137
+ persist: rootOpsKey,
138
+ removeSegment: segName,
139
+ };
140
+ artifacts.segments[chromeSegName] = {
141
+ template: `{{ action "${actionName}" "-" }}`,
142
+ when: EDIT_MODE_GATE,
143
+ };
144
+ return { kind: "segment", name: chromeSegName };
145
+ }
146
+
147
+ // Synthesize the `+` affordance for one gap: an `insertSegmentFrom` action
148
+ // over this preset's addable domain, plus a segment hosting `{{ menu }}` over
149
+ // it. The menu's own disclosure (open state, page cursor, toggle action) is
150
+ // synthesized here by calling the SAME pure functions menu-synth.ts's
151
+ // file-parse-time pass calls — this pass runs too late to piggyback on that
152
+ // pass directly (it needs post-merge/post-rootOps data menu-synth.ts's
153
+ // per-file timing does not have), so parity is achieved by sharing the
154
+ // functions, not by re-deriving the shape.
155
+ function insertChrome(
156
+ presetIdent: string,
157
+ rootOpsKey: string,
158
+ posIdent: string,
159
+ domainName: OptionDomain,
160
+ anchor: string,
161
+ relation: "before" | "after",
162
+ artifacts: ChromeArtifacts,
163
+ ): SegmentNode {
164
+ const applyName = `${EDIT_NS}${presetIdent}.insert.${posIdent}`;
165
+ const chromeSegName = `${EDIT_NS}${presetIdent}.insertSeg.${posIdent}`;
166
+ artifacts.actions[applyName] = {
167
+ persist: rootOpsKey,
168
+ insertSegmentFrom: domainName,
169
+ anchor,
170
+ relation,
171
+ };
172
+
173
+ const member = menuMember(applyName);
174
+ const stateKey = menuStateKey(chromeSegName, applyName, undefined);
175
+ const pageKey = menuPageKey(stateKey);
176
+ const identity = menuActionName(stateKey, member);
177
+ artifacts.variables[stateKey] = disclosureStateVar(
178
+ stateKey,
179
+ DISCLOSURE_CLOSED,
180
+ );
181
+ artifacts.variables[pageKey] = { kind: "state", key: pageKey, default: "0" };
182
+ artifacts.actions[identity] = disclosureCycleAction(stateKey, member);
183
+ artifacts.actions[pageKey] = { set: pageKey, int: true };
184
+
185
+ artifacts.segments[chromeSegName] = {
186
+ template: `+{{ menu "${applyName}" }}`,
187
+ when: EDIT_MODE_GATE,
188
+ };
189
+ return { kind: "segment", name: chromeSegName };
190
+ }
191
+
192
+ // [LAW:dataflow-not-control-flow] One recursive splice: a container's
193
+ // non-exempt segment children get a `+` before and a `-` after (so N
194
+ // consecutive segments read `+ [seg1 -] + [seg2 -] + [seg3 -] +` — N+1 insert
195
+ // points, N remove points); a container child recurses; an exempt segment
196
+ // (a group toggle, a menu host, edit mode's own chrome) passes through
197
+ // untouched. `posCounter` is threaded by reference so position identifiers
198
+ // stay unique across the WHOLE preset tree, not just one container.
199
+ function spliceContainer(
200
+ node: ContainerNode,
201
+ presetIdent: string,
202
+ rootOpsKey: string,
203
+ domainName: OptionDomain,
204
+ artifacts: ChromeArtifacts,
205
+ posCounter: { n: number },
206
+ ): ContainerNode {
207
+ const children: LayoutNode[] = [];
208
+ for (const child of node.children) {
209
+ if (child.kind === "container") {
210
+ children.push(
211
+ spliceContainer(
212
+ child,
213
+ presetIdent,
214
+ rootOpsKey,
215
+ domainName,
216
+ artifacts,
217
+ posCounter,
218
+ ),
219
+ );
220
+ continue;
221
+ }
222
+ if (isChromeExempt(child.name)) {
223
+ children.push(child);
224
+ continue;
225
+ }
226
+ children.push(
227
+ insertChrome(
228
+ presetIdent,
229
+ rootOpsKey,
230
+ String(posCounter.n++),
231
+ domainName,
232
+ child.name,
233
+ "before",
234
+ artifacts,
235
+ ),
236
+ );
237
+ children.push(child);
238
+ children.push(removeChrome(presetIdent, rootOpsKey, child.name, artifacts));
239
+ }
240
+ const last = node.children[node.children.length - 1];
241
+ if (
242
+ last !== undefined &&
243
+ last.kind === "segment" &&
244
+ !isChromeExempt(last.name)
245
+ ) {
246
+ children.push(
247
+ insertChrome(
248
+ presetIdent,
249
+ rootOpsKey,
250
+ String(posCounter.n++),
251
+ domainName,
252
+ last.name,
253
+ "after",
254
+ artifacts,
255
+ ),
256
+ );
257
+ }
258
+ return { ...node, children };
259
+ }
260
+
261
+ // One preset's chrome-spliced root. A bare-segment root (the A-grammar
262
+ // collapses a single top-level segment ref to `{ kind: "segment", name }`
263
+ // with no enclosing container) is wrapped in a synthetic horizontal
264
+ // container for splicing purposes — the wrap becomes the real returned root,
265
+ // which is exactly right: a lone segment needs a `+` on each side too.
266
+ function spliceEditChromeForPreset(
267
+ config: DslConfig,
268
+ presetName: string,
269
+ artifacts: ChromeArtifacts,
270
+ ): LayoutNode {
271
+ const { node } = presetRoot(config, presetName);
272
+ const rootOpsKey = `presets.${presetName}.rootOps`;
273
+ const domainName = addableDomainName(presetName);
274
+ const presetIdent = ident(presetName);
275
+ const posCounter = { n: 0 };
276
+ const container: ContainerNode =
277
+ node.kind === "container"
278
+ ? node
279
+ : { kind: "container", direction: "horizontal", children: [node] };
280
+ return spliceContainer(
281
+ container,
282
+ presetIdent,
283
+ rootOpsKey,
284
+ domainName,
285
+ artifacts,
286
+ posCounter,
287
+ );
288
+ }
289
+
290
+ // [LAW:single-enforcer] THE synthesis entry point, called once from
291
+ // validateConfig after cross-ref/cycle checks pass. Every declared preset
292
+ // (the floor "default" included — presetNames/presetByName/presetRoot
293
+ // already treat it uniformly) gets an explicit `presets[name].root` carrying
294
+ // its spliced tree; `config.root` itself is left untouched (presetRoot falls
295
+ // back to it only when a preset declares no root of its own, and every name
296
+ // now does). The synthesized variables/actions/segments merge additively —
297
+ // nothing here can collide with user data, since every name it mints lives
298
+ // under the `edit.`/`menus.` namespaces `synthesizeEditModeToggle` and
299
+ // `synthesizeMenuDecls` already reserve unconditionally at parse time.
300
+ export function synthesizeEditChrome(config: DslConfig): DslConfig {
301
+ // [LAW:carrying-cost] Demand-driven, mirroring synthesizeEditModeToggle's
302
+ // own gate: `edit.toggle` exists in the merged config iff SOME file's
303
+ // Phase A synthesis fired (iff some segment referenced it), which iff some
304
+ // author actually placed an edit-mode trigger. A config that never opted
305
+ // in gets back the identical config, untouched — no extra segments,
306
+ // actions, or variables, and critically no NEW `set`/`state` surface that
307
+ // would force session.id onto an otherwise fully static bar.
308
+ if (!(EDIT_TOGGLE_ACTION in config.actions)) return config;
309
+ const artifacts: ChromeArtifacts = {
310
+ variables: {},
311
+ actions: {},
312
+ segments: {},
313
+ };
314
+ const presets: Record<string, PresetDecl> = { ...config.presets };
315
+ for (const name of presetNames(config.presets)) {
316
+ const splicedRoot = spliceEditChromeForPreset(config, name, artifacts);
317
+ presets[name] = {
318
+ ...presetByName(config.presets, name),
319
+ root: splicedRoot,
320
+ };
321
+ }
322
+ return {
323
+ ...config,
324
+ variables: { ...config.variables, ...artifacts.variables },
325
+ actions: { ...config.actions, ...artifacts.actions },
326
+ segments: { ...config.segments, ...artifacts.segments },
327
+ presets,
328
+ };
329
+ }
@@ -79,6 +79,27 @@ export function decodeLayoutOp(token: string): LayoutOp | null {
79
79
  return null;
80
80
  }
81
81
 
82
+ // [LAW:single-enforcer] THE one collector of "which segment names does this
83
+ // tree contain" — brandon-layout-edit-2gc.3's edit-chrome synthesis
84
+ // (src/config/edit-chrome.ts) uses it to compute both halves of the +/-
85
+ // affordances: which segments are PRESENT (get a `-`) and, by set difference
86
+ // against every declared segment, which are ADDABLE (populate the `+`
87
+ // picker's domain). A name appearing more than once collapses to one entry —
88
+ // callers that care about occurrence COUNT (none currently do) need a
89
+ // different walk.
90
+ export function collectSegmentNames(root: LayoutNode): ReadonlySet<string> {
91
+ const out = new Set<string>();
92
+ const walk = (node: LayoutNode): void => {
93
+ if (node.kind === "segment") {
94
+ out.add(node.name);
95
+ return;
96
+ }
97
+ for (const child of node.children) walk(child);
98
+ };
99
+ walk(root);
100
+ return out;
101
+ }
102
+
82
103
  // [LAW:dataflow-not-control-flow] Ops are DATA folded over the tree in
83
104
  // order — replaying zero ops is the identity fold, replaying N is the same
84
105
  // reduce for every N. No branch on "are there ops to apply."
@@ -388,6 +388,20 @@ const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
388
388
  anchor: layoutNameSpec("anchor"),
389
389
  relation: relationSpec(),
390
390
  };
391
+ // [LAW:one-type-per-behavior] `insertSegmentFrom`'s payload mirrors
392
+ // `insertSegment`'s verbatim except the segment name is a `from`-shaped
393
+ // OptionDomain (fromSpec, the SAME field `set`/`persist … from` already
394
+ // validate) instead of a literal layout name — the "to" vs "from" split every
395
+ // other value source already draws, one arm over.
396
+ const INSERT_SEGMENT_FROM_FIELDS: FieldSpecMap<{
397
+ insertSegmentFrom: OptionDomain;
398
+ anchor: string;
399
+ relation: "before" | "after";
400
+ }> = {
401
+ insertSegmentFrom: fromSpec("persist"),
402
+ anchor: layoutNameSpec("anchor"),
403
+ relation: relationSpec(),
404
+ };
391
405
 
392
406
  // [LAW:types-are-the-program] A bounded step is fully described by an integer
393
407
  // domain (min < max) and a non-zero integer increment (`by`; negative for a
@@ -440,12 +454,25 @@ interface ValueSourceArm {
440
454
  readonly parse: ArmParse<Partial<ActionDecl>>;
441
455
  }
442
456
 
457
+ // [LAW:no-mode-explosion] `detectKeys` narrows WHICH of an arm's fields the
458
+ // present-count dispatch keys off, independent of `allowed`/`label` (still
459
+ // the full field set — what the arm PERMITS and is NAMED by never changes).
460
+ // Every arm before insertSegmentFrom had a field set disjoint from every
461
+ // other arm's, so `Object.keys(fieldMap)` was a safe default for both jobs
462
+ // at once. insertSegmentFrom breaks that: it shares `anchor`/`relation` with
463
+ // insertSegment (same POSITION shape, different segment-name SOURCE), so
464
+ // dispatching on the full set would make an ordinary `insertSegment` action
465
+ // spuriously match both arms via those shared keys. Pass the true
466
+ // discriminator (the field no sibling arm carries) here; omit it when the
467
+ // field set already is disjoint from every sibling, as it is everywhere else.
443
468
  function valueSourceArm<P extends object>(
444
469
  discriminator: "set" | "persist",
445
470
  fieldMap: FieldSpecMap<P>,
446
- ...checks: ReadonlyArray<Refinement<P>>
471
+ checks: ReadonlyArray<Refinement<P>> = [],
472
+ detectKeys?: readonly string[],
447
473
  ): ValueSourceArm {
448
- const detect = Object.keys(fieldMap);
474
+ const fullKeys = Object.keys(fieldMap);
475
+ const detect = detectKeys ?? fullKeys;
449
476
  const inner: ArmParse<P> = (ctx, path, raw) =>
450
477
  fields(ctx, fieldMap, path, raw);
451
478
  const source = objectJson(fieldMap) as {
@@ -454,8 +481,8 @@ function valueSourceArm<P extends object>(
454
481
  };
455
482
  return {
456
483
  detect,
457
- allowed: [discriminator, ...detect],
458
- label: detect.join("/"),
484
+ allowed: [discriminator, ...fullKeys],
485
+ label: fullKeys.join("/"),
459
486
  json: {
460
487
  type: "object",
461
488
  properties: { [discriminator]: { type: "string" }, ...source.properties },
@@ -475,7 +502,7 @@ function valueSourceArm<P extends object>(
475
502
  const SET_ARMS: readonly ValueSourceArm[] = [
476
503
  valueSourceArm("set", TO_FIELDS_SET),
477
504
  valueSourceArm("set", FROM_FIELDS_SET),
478
- valueSourceArm("set", BOUNDED_FIELDS, minLessThanMax, byNonZero),
505
+ valueSourceArm("set", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
479
506
  valueSourceArm("set", INT_FIELDS),
480
507
  valueSourceArm("set", CYCLE_FIELDS_SET),
481
508
  ];
@@ -487,10 +514,21 @@ const SET_ARMS: readonly ValueSourceArm[] = [
487
514
  const PERSIST_ARMS: readonly ValueSourceArm[] = [
488
515
  valueSourceArm("persist", TO_FIELDS_PERSIST),
489
516
  valueSourceArm("persist", FROM_FIELDS_PERSIST),
490
- valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
517
+ valueSourceArm("persist", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
491
518
  valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
492
519
  valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
493
- valueSourceArm("persist", INSERT_SEGMENT_FIELDS),
520
+ // [LAW:no-mode-explosion] Both insertSegment arms narrow detectKeys to
521
+ // their own discriminating field — see valueSourceArm's own comment. They
522
+ // share "anchor"/"relation" (same position shape, different segment-name
523
+ // source), so dispatching on the full field set would make EITHER arm
524
+ // spuriously match an action declaring the other.
525
+ valueSourceArm("persist", INSERT_SEGMENT_FIELDS, [], ["insertSegment"]),
526
+ valueSourceArm(
527
+ "persist",
528
+ INSERT_SEGMENT_FROM_FIELDS,
529
+ [],
530
+ ["insertSegmentFrom"],
531
+ ),
494
532
  ];
495
533
 
496
534
  // [LAW:one-source-of-truth] The clause list, not the joined string, is the
@@ -511,6 +549,7 @@ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
511
549
  clauses.push(
512
550
  `"removeSegment" (remove a named segment from the layout)`,
513
551
  `"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
552
+ `"insertSegmentFrom"/"anchor"/"relation" (insert a segment PICKED from an option domain before/after an existing one)`,
514
553
  );
515
554
  }
516
555
  return clauses;
@@ -635,7 +674,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
635
674
  const at = `${path}.${field}`;
636
675
  if (typeof from === "string") {
637
676
  if (from === "") {
638
- issue(ctx, at, `from must be a non-empty domain name`);
677
+ issue(ctx, at, `${field} must be a non-empty domain name`);
639
678
  return undefined;
640
679
  }
641
680
  return from;
@@ -646,7 +685,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
646
685
  issue(
647
686
  ctx,
648
687
  at,
649
- `from must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
688
+ `${field} must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
650
689
  );
651
690
  return undefined;
652
691
  }
@@ -654,7 +693,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
654
693
  issue(
655
694
  ctx,
656
695
  at,
657
- `from array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
696
+ `${field} array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
658
697
  );
659
698
  return undefined;
660
699
  }
@@ -663,7 +702,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
663
702
  issue(
664
703
  ctx,
665
704
  at,
666
- `from array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
705
+ `${field} array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
667
706
  );
668
707
  return undefined;
669
708
  }
@@ -671,7 +710,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
671
710
  issue(
672
711
  ctx,
673
712
  at,
674
- `from array members must be unique — a duplicated value would render the same picker option twice`,
713
+ `${field} array members must be unique — a duplicated value would render the same picker option twice`,
675
714
  );
676
715
  return undefined;
677
716
  }
@@ -680,7 +719,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
680
719
  issue(
681
720
  ctx,
682
721
  at,
683
- `from must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
722
+ `${field} must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
684
723
  );
685
724
  return undefined;
686
725
  },