@promptctl/cc-candybar 1.30.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.30.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.30.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.30.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.30.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.30.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": {
@@ -1399,6 +1440,30 @@
1399
1440
  "reset"
1400
1441
  ],
1401
1442
  "additionalProperties": false
1443
+ },
1444
+ {
1445
+ "type": "object",
1446
+ "properties": {
1447
+ "undo": {
1448
+ "const": true
1449
+ }
1450
+ },
1451
+ "required": [
1452
+ "undo"
1453
+ ],
1454
+ "additionalProperties": false
1455
+ },
1456
+ {
1457
+ "type": "object",
1458
+ "properties": {
1459
+ "redo": {
1460
+ "const": true
1461
+ }
1462
+ },
1463
+ "required": [
1464
+ "redo"
1465
+ ],
1466
+ "additionalProperties": false
1402
1467
  }
1403
1468
  ]
1404
1469
  }
package/src/click/wire.ts CHANGED
@@ -77,6 +77,15 @@ export const VERB_RESET_CONFIG = "reset-config";
77
77
  // differs, which is exactly why this is its own verb rather than another
78
78
  // VERB_SET_CONFIG value.
79
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";
80
89
 
81
90
  // [LAW:types-are-the-program] An effect to EMIT: a verb plus its raw (unencoded)
82
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,14 @@ 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
78
103
  // removeSegment — (persist only) remove the named segment from the
79
104
  // preset-root the `persist` key addresses
80
105
  // (`presets.<name>.rootOps`) -> allow-list {one op
@@ -83,6 +108,13 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
83
108
  // anchor + relation — (persist only) insert a named segment before/after
84
109
  // an existing one, same key shape -> allow-list {one
85
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}
86
118
  //
87
119
  // [LAW:one-source-of-truth] `set` writes SessionState and `persist` writes
88
120
  // the config-overrides layer, so only those two derive a validator (through
@@ -131,9 +163,29 @@ export type ActionDecl =
131
163
  readonly anchor: string;
132
164
  readonly relation: "before" | "after";
133
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
+ }
134
184
  | { readonly copy: string }
135
185
  | { readonly open: string }
136
- | { readonly reset: string };
186
+ | { readonly reset: string }
187
+ | { readonly undo: true }
188
+ | { readonly redo: true };
137
189
 
138
190
  // [LAW:dataflow-not-control-flow] Does this action write a SessionState key? A
139
191
  // `set` action composes a set-state click URL whose first segment is session.id;
@@ -155,3 +207,14 @@ export function actionBindsPersist(a: ActionDecl): boolean {
155
207
  export function actionBindsReset(a: ActionDecl): boolean {
156
208
  return "reset" in a;
157
209
  }
210
+
211
+ // [LAW:dataflow-not-control-flow] Does this action step the config-overrides
212
+ // history? `undo`/`redo` carry session.id on the wire too — same reason as
213
+ // `reset`: an empty stack is a loud, session-scoped click.error, not a
214
+ // silent no-op (the ticket's own done-gate).
215
+ export function actionBindsUndo(a: ActionDecl): boolean {
216
+ return "undo" in a;
217
+ }
218
+ export function actionBindsRedo(a: ActionDecl): boolean {
219
+ return "redo" in a;
220
+ }
@@ -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."