@promptctl/cc-candybar 1.31.0 → 1.33.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.33.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.33.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.33.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.33.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.33.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 }
@@ -872,7 +872,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
872
872
  },
873
873
  // Quick-action tray — the default bar's interactivity: copy the session id,
874
874
  // open the project dir / transcript (this session's jsonl) in the editor,
875
- // and open the repo's web page in the browser.
875
+ // open the repo's web page in the browser, and toggle layout edit mode.
876
876
  // (copyDir — copy the cwd — stays declared as an action below for users who
877
877
  // want a fifth glyph; it is simply not in the default tray.)
878
878
  // [LAW:locality-or-seam] The glyph is the REPRESENTATION; the named action
@@ -887,11 +887,36 @@ export const RAW_DEFAULT_DSL_CONFIG = {
887
887
  // public web URL through a cc-candybar:// verb would buy nothing. It is
888
888
  // gated on the VALUE (`ne … ""`), not on a flag: a local-only repo simply
889
889
  // supplies no page and the glyph is absent. [LAW:dataflow-not-control-flow]
890
+ //
891
+ // `✎ edit`/`✎ done` (brandon-layout-edit-2gc.4) is the bundled default's
892
+ // ONLY reference to the reserved `edit.toggle` action — referencing it
893
+ // anywhere is what opts this config into edit mode (see
894
+ // docs/interaction-authoring.md's "Edit mode" section), and this tray
895
+ // segment is where it lives.
896
+ //
897
+ // [LAW:carrying-cost] Placement resolves a self-lockout tension .3 flagged
898
+ // (see the epic's tickets): once edit mode is open, EVERY ordinary segment
899
+ // gets its own removable `-`, including whichever one hosts the trigger —
900
+ // a config can, in principle, remove its own way back into edit mode.
901
+ // Giving the trigger its own standalone segment would make that a one-click
902
+ // accident. Folding it into `toolbar` instead means removing the trigger
903
+ // requires removing the WHOLE quick-action tray — the same deliberate,
904
+ // symmetric risk every other multi-purpose segment already carries, not a
905
+ // bespoke edit-mode hazard — and it costs the default bar one glyph of
906
+ // width instead of a whole new segment's cell+padding+joiner overhead. The
907
+ // risk is bounded either way: `-` only removes the segment from this
908
+ // preset's tree (`edit.mode` itself is untouched SessionState), so the
909
+ // rest of the chrome — every remaining `+`/`-` in the bar — stays visible,
910
+ // and any of them can `+` `toolbar` straight back
911
+ // (test/dsl-layout-edit.test.ts covers the full round trip through a
912
+ // real RenderCache reload; test/dsl-edit-mode.test.ts covers the click
913
+ // itself and that edit.mode survives it).
890
914
  toolbar: {
891
915
  template:
892
916
  '{{ action "copySession" "⎘ id" }}' +
893
917
  ' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}' +
894
- '{{ if ne .git.repoUrl "" }} {{ link .git.repoUrl "↗ repo" }}{{ end }}',
918
+ '{{ if ne .git.repoUrl "" }} {{ link .git.repoUrl "↗ repo" }}{{ end }}' +
919
+ ' {{ action "edit.toggle" "✎ edit" "✎ done" }}',
895
920
  bg: "surface",
896
921
  fg: "foreground",
897
922
  },
@@ -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."