@promptctl/cc-candybar 1.42.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,559 +0,0 @@
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 { presetRootOpsKey } from "./loader/persist-target.js";
36
- import { ident } from "./ident.js";
37
- import {
38
- EDIT_MODE_GATE,
39
- EDIT_MODE_REF,
40
- EDIT_NS,
41
- EDIT_TOGGLE_ACTION,
42
- } from "./loader/edit-mode.js";
43
- import { declareHelp, type HelpDisclosure } from "./help.js";
44
- import { EDIT_MODE_HELP } from "../help-text.js";
45
- import { GROUP_NS } from "./loader/layout.js";
46
- import { SETTINGS_NS } from "./settings-menu.js";
47
- import {
48
- menuActionName,
49
- menuMember,
50
- menuPageKey,
51
- menuStateKey,
52
- MENU_NS,
53
- } from "./menu-keys.js";
54
- import {
55
- DISCLOSURE_CLOSED,
56
- DISCLOSURE_GLYPH_CLOSE,
57
- escapeTemplateLiteral,
58
- disclosureCycleAction,
59
- disclosureStateVar,
60
- } from "./disclosure.js";
61
-
62
- // [LAW:dataflow-not-control-flow] brandon-layout-edit-2gc.5's diagnostic gate
63
- // — read the SAME way EDIT_MODE_GATE is: a bare boolean input var, false
64
- // only on the literal text "false" (evaluateWhen's documented contract).
65
- // `.preset.customized` is a per-render payload fact (presetIsCustomized over
66
- // entry.state.presetRootOps for whichever preset is ACTIVE), not config-time
67
- // knowledge, so the banner below is spliced UNCONDITIONALLY for every
68
- // preset — same shape, every reload — and this predicate is what decides
69
- // whether it's visible, never a branch in this synthesis pass.
70
- // [LAW:one-source-of-truth] Exported: test/helpers/ambient-chrome.ts filters this
71
- // ensured name out of "what did the AUTHOR declare" assertions and must read the
72
- // same string, never a second copy that a rename here would leave behind.
73
- export const PRESET_CUSTOMIZED_VAR = "preset.customized";
74
- const PRESET_CUSTOMIZED_GATE = `{{ .${PRESET_CUSTOMIZED_VAR} }}`;
75
-
76
- // [LAW:one-source-of-truth] group/menu-synthesized segments (`groups.`/
77
- // `menus.`) and edit mode's own trigger/chrome (`edit.`) are structural —
78
- // removing one via `-` would strand its sibling artifacts (a toggle segment
79
- // with no body, a menu with no host), and offering one back via `+` would
80
- // insert a bare ref with none of the synthesis that made it work. Ordinary
81
- // content segments only.
82
- function isChromeExempt(name: string): boolean {
83
- return (
84
- name.startsWith(EDIT_NS) ||
85
- name.startsWith(MENU_NS) ||
86
- name.startsWith(GROUP_NS) ||
87
- // The global settings menu is the entry point edit mode is REACHED from
88
- // (candybar-settings-ui-aok.1) — offering a `-` beside it would let one
89
- // click delete the door back in, the self-lockout the `toolbar` trigger's
90
- // placement was chosen to avoid. Structural, like the three above it.
91
- name.startsWith(SETTINGS_NS)
92
- );
93
- }
94
-
95
- // [LAW:one-source-of-truth] Every synthesized decl this pass produces, keyed
96
- // by its final name — one accumulator threaded through every preset's splice
97
- // so cross-preset names (disambiguated by `presetIdent`) can never collide.
98
- interface ChromeArtifacts {
99
- readonly variables: Record<string, VariableDecl>;
100
- // [LAW:no-silent-failure] Declarations this synthesis DEPENDS on rather than
101
- // OWNS: merged UNDER the config so a user's own declaration of the same name
102
- // wins, unlike `variables` above, which lives in a reserved namespace no user
103
- // may write and therefore merges over.
104
- readonly ensured: Record<string, VariableDecl>;
105
- readonly actions: Record<string, ActionDeclType>;
106
- readonly segments: Record<string, SegmentDecl>;
107
- }
108
-
109
- // [LAW:one-source-of-truth] The domain name a preset's `+` pickers range —
110
- // computed once per preset (declared segments minus the ones already present
111
- // in ITS current tree) and consumed two ways: here (by name, for every
112
- // insertSegmentFrom action this preset's splice synthesizes) and by
113
- // registerDslConfig/deriveConfigActionValidators (which call
114
- // `addableSegmentDomains` directly to populate `perConfigDomains` before
115
- // resolving `from`). Both read the SAME string shape so a synthesized
116
- // action's domain name always resolves.
117
- export function addableDomainName(presetName: string): string {
118
- return `${EDIT_NS}addable.${presetName}`;
119
- }
120
-
121
- // [LAW:one-source-of-truth] THE per-preset "what can `+` offer here" set:
122
- // every declared, non-exempt segment name minus the ones already present
123
- // anywhere in that preset's CURRENT (merged, rootOps-replayed) tree. Exported
124
- // so render.ts's registerDslConfig and config-validators.ts's
125
- // deriveConfigActionValidators — the two sites that resolve `from` domains —
126
- // merge this into `perConfigDomainsFor`'s map without each re-deriving it
127
- // [LAW:locality-or-seam]; option-domain.ts itself stays untouched (its
128
- // `perConfigDomainsFor` deliberately never imports dsl-types.ts — see that
129
- // file's own header — so a third per-preset domain merges at the two call
130
- // sites instead of inside it).
131
- export function addableSegmentDomains(
132
- config: DslConfig,
133
- ): ReadonlyMap<string, readonly string[]> {
134
- const declared = Object.keys(config.segments).filter(
135
- (n) => !isChromeExempt(n),
136
- );
137
- const domains = new Map<string, readonly string[]>();
138
- for (const name of presetNames(config.presets)) {
139
- const { node } = presetRoot(config, name);
140
- const present = collectSegmentNames(node);
141
- domains.set(
142
- addableDomainName(name),
143
- declared.filter((n) => !present.has(n)),
144
- );
145
- }
146
- return domains;
147
- }
148
-
149
- // Synthesize the `-` affordance for one segment instance: a literal
150
- // `removeSegment` action plus the segment that hosts its `{{ action }}`.
151
- function removeChrome(
152
- presetIdent: string,
153
- rootOpsKey: string,
154
- segName: string,
155
- artifacts: ChromeArtifacts,
156
- ): SegmentNode {
157
- const actionName = `${EDIT_NS}${presetIdent}.remove.${ident(segName)}`;
158
- const chromeSegName = `${EDIT_NS}${presetIdent}.removeSeg.${ident(segName)}`;
159
- artifacts.actions[actionName] = {
160
- persist: rootOpsKey,
161
- removeSegment: segName,
162
- };
163
- artifacts.segments[chromeSegName] = {
164
- template: `{{ action "${actionName}" "-" }}`,
165
- when: EDIT_MODE_GATE,
166
- };
167
- return { kind: "segment", name: chromeSegName };
168
- }
169
-
170
- // Synthesize the `+` affordance for one gap: an `insertSegmentFrom` action
171
- // over this preset's addable domain, plus a segment hosting `{{ menu }}` over
172
- // it. The menu's own disclosure (open state, page cursor, toggle action) is
173
- // synthesized here by calling the SAME pure functions menu-synth.ts's
174
- // file-parse-time pass calls — this pass runs too late to piggyback on that
175
- // pass directly (it needs post-merge/post-rootOps data menu-synth.ts's
176
- // per-file timing does not have), so parity is achieved by sharing the
177
- // functions, not by re-deriving the shape.
178
- function insertChrome(
179
- presetIdent: string,
180
- rootOpsKey: string,
181
- posIdent: string,
182
- domainName: OptionDomain,
183
- anchor: string,
184
- relation: "before" | "after",
185
- artifacts: ChromeArtifacts,
186
- ): SegmentNode {
187
- const applyName = `${EDIT_NS}${presetIdent}.insert.${posIdent}`;
188
- const chromeSegName = `${EDIT_NS}${presetIdent}.insertSeg.${posIdent}`;
189
- artifacts.actions[applyName] = {
190
- persist: rootOpsKey,
191
- insertSegmentFrom: domainName,
192
- anchor,
193
- relation,
194
- };
195
-
196
- const member = menuMember(applyName);
197
- const stateKey = menuStateKey(chromeSegName, applyName, undefined);
198
- const pageKey = menuPageKey(stateKey);
199
- const identity = menuActionName(stateKey, member);
200
- artifacts.variables[stateKey] = disclosureStateVar(
201
- stateKey,
202
- DISCLOSURE_CLOSED,
203
- );
204
- artifacts.variables[pageKey] = { kind: "state", key: pageKey, default: "0" };
205
- artifacts.actions[identity] = disclosureCycleAction(stateKey, member);
206
- artifacts.actions[pageKey] = { set: pageKey, int: true };
207
-
208
- // The `+` IS the trigger — no appended arrow (candybar-settings-ui-aok.4).
209
- // Beside a `-` that means something else entirely, a ▸ read as part of the
210
- // affordance rather than as a disclosure hint, so the trigger names the ACTION
211
- // its click performs instead: `+` inserts here, `✕` closes what `+` opened —
212
- // the same glyph, and the same effect, as the body's own close cell.
213
- //
214
- // [LAW:no-silent-failure] It is deliberately NOT one static display. A preset
215
- // has N insertion points whose rendered rows are byte-identical, and their
216
- // dropped bodies are identical too — so with no per-state display, an open `+`
217
- // is indistinguishable from the two beside it and the bar silently stops
218
- // answering "which one did I open". The tint that marks other open menus
219
- // (node-registry's `drops.length > 0`) cannot answer it either: this segment
220
- // declares no bg, so there is nothing to tint.
221
- artifacts.segments[chromeSegName] = {
222
- template: `{{ menu "${applyName}" "+" "${DISCLOSURE_GLYPH_CLOSE}" }}`,
223
- when: EDIT_MODE_GATE,
224
- };
225
- return { kind: "segment", name: chromeSegName };
226
- }
227
-
228
- // [LAW:dataflow-not-control-flow] One recursive splice: a container's
229
- // non-exempt segment children get a `+` before and a `-` after (so N
230
- // consecutive segments read `+ [seg1 -] + [seg2 -] + [seg3 -] +` — N+1 insert
231
- // points, N remove points); a container child recurses; an exempt segment
232
- // (a group toggle, a menu host, edit mode's own chrome) passes through
233
- // untouched. `posCounter` is threaded by reference so position identifiers
234
- // stay unique across the WHOLE preset tree, not just one container.
235
- function spliceContainer(
236
- node: ContainerNode,
237
- presetIdent: string,
238
- rootOpsKey: string,
239
- domainName: OptionDomain,
240
- artifacts: ChromeArtifacts,
241
- posCounter: { n: number },
242
- ): ContainerNode {
243
- const children: LayoutNode[] = [];
244
- // [LAW:one-source-of-truth] The trailing `+`'s position is "after the last
245
- // CONTENT segment", not "after the last child". Those coincided until a
246
- // synthesis started appending exempt chrome (the global settings menu,
247
- // candybar-settings-ui-aok.1) to a row's end, at which point reading the last
248
- // child silently dropped the row's final insert point — N segments offering
249
- // only N insert points instead of N+1.
250
- const lastContent = node.children.reduce(
251
- (idx, child, i) =>
252
- child.kind === "segment" && !isChromeExempt(child.name) ? i : idx,
253
- -1,
254
- );
255
- for (const [i, child] of node.children.entries()) {
256
- if (child.kind === "container") {
257
- children.push(
258
- spliceContainer(
259
- child,
260
- presetIdent,
261
- rootOpsKey,
262
- domainName,
263
- artifacts,
264
- posCounter,
265
- ),
266
- );
267
- continue;
268
- }
269
- if (isChromeExempt(child.name)) {
270
- children.push(child);
271
- continue;
272
- }
273
- children.push(
274
- insertChrome(
275
- presetIdent,
276
- rootOpsKey,
277
- String(posCounter.n++),
278
- domainName,
279
- child.name,
280
- "before",
281
- artifacts,
282
- ),
283
- );
284
- children.push(child);
285
- children.push(removeChrome(presetIdent, rootOpsKey, child.name, artifacts));
286
- if (i === lastContent) {
287
- children.push(
288
- insertChrome(
289
- presetIdent,
290
- rootOpsKey,
291
- String(posCounter.n++),
292
- domainName,
293
- child.name,
294
- "after",
295
- artifacts,
296
- ),
297
- );
298
- }
299
- }
300
- return { ...node, children };
301
- }
302
-
303
- // brandon-layout-edit-2gc.5's other per-preset affordance: a `+`/`-` sibling
304
- // that isn't about ONE gap but about the preset's rootOps log as a whole —
305
- // synthesized the SAME way (one reset action targeting this preset's exact
306
- // `persist` key, one segment hosting `{{ action }}`), UNCONDITIONALLY, with
307
- // visibility carried entirely by PRESET_CUSTOMIZED_GATE
308
- // [LAW:dataflow-not-control-flow]. It gets its own ROW (not a slot in the
309
- // row-interleaved chrome spliceContainer builds) because it is not bound to any
310
- // one segment gap — it is a fact about the whole tree — visible or not by the
311
- // SAME `when` every other synthesized affordance here already uses.
312
- //
313
- // candybar-settings-ui-aok.6 hangs edit mode's `(?)` off the same content — its
314
- // BODY is a per-preset row on the same footing as the banner, so this function
315
- // now brackets the content rather than only preceding it, which is what its name
316
- // says and why it is no longer "prepend". Its TRIGGER is not a row; see
317
- // withTrailingCell.
318
- function wrapWithPresetRows(
319
- splicedRoot: LayoutNode,
320
- presetName: string,
321
- presetIdent: string,
322
- rootOpsKey: string,
323
- artifacts: ChromeArtifacts,
324
- help: HelpDisclosure,
325
- ): LayoutNode {
326
- const actionName = `${EDIT_NS}${presetIdent}.resetLayout`;
327
- const chromeSegName = `${EDIT_NS}${presetIdent}.customized`;
328
- // [LAW:no-silent-failure] `reset` clears `rootOpsKey` outright, restoring
329
- // presetRoot's own fallback (the config's literal, hand-authored root) on
330
- // the next reload — the exact undo the ticket's guardrail asked for.
331
- // `rootOpsKey` is gated the SAME way every other `presets.<name>.rootOps`
332
- // target is (deriveConfigActionValidators), and is ALWAYS a registered
333
- // key — config-validators.ts's presetRootOpsContributions registers it
334
- // for every declared preset UNCONDITIONALLY, specifically so a preset
335
- // edited down to zero non-exempt segments (no removeChrome/insertChrome
336
- // persist actions left to register it) doesn't orphan this exact click.
337
- artifacts.actions[actionName] = { reset: rootOpsKey };
338
- // [LAW:one-source-of-truth] The banner reads `.preset.customized`, so THIS
339
- // pass is what requires that variable — not whichever config happens to
340
- // declare it. The bundled default does, which is why the dependency stayed
341
- // invisible until the global settings menu made edit mode reachable from
342
- // configs that never declared it, and the missing field surfaced as a ⚠ on
343
- // the bar. Ensured, never overridden: a user declaration of the same name
344
- // wins (see the merge in synthesizeEditChrome), so this only supplies the
345
- // floor the synthesis itself depends on.
346
- artifacts.ensured[PRESET_CUSTOMIZED_VAR] = {
347
- kind: "input",
348
- path: PRESET_CUSTOMIZED_VAR,
349
- type: "boolean",
350
- default: false,
351
- };
352
- const label = escapeTemplateLiteral(presetName);
353
- artifacts.segments[chromeSegName] = {
354
- template: `{{ action "${actionName}" "↺ ${label} customized" }}`,
355
- when: PRESET_CUSTOMIZED_GATE,
356
- };
357
- // [LAW:no-silent-failure] A preset's root may carry its OWN top-level
358
- // `when` (the A-grammar's container schemas all permit one) — an author
359
- // gating the whole preset behind a condition. `spliceContainer` preserves
360
- // that onto `splicedRoot` via its `{...node, children}` spread, but this
361
- // new OUTER wrapper is a brand-new node with no `when` of its own; without
362
- // carrying it up, the reset banner would render even when the author's
363
- // own condition is false, leaking past a gate they wrote.
364
- return {
365
- kind: "container",
366
- direction: "vertical",
367
- children: [
368
- { kind: "segment", name: chromeSegName },
369
- withTrailingCell(splicedRoot, help.trigger),
370
- // The body is a ROW of its own, and only while the disclosure is open —
371
- // dropping BELOW the row that revealed it, like every other disclosure
372
- // body in this codebase.
373
- help.body,
374
- ],
375
- ...(splicedRoot.when !== undefined && { when: splicedRoot.when }),
376
- };
377
- }
378
-
379
- // [LAW:one-source-of-truth] `HelpDisclosure.trigger` is a CELL, and its contract
380
- // is that the caller joins it to a row it ALREADY HAS — the settings menu pushes
381
- // it into the row holding `persist?`. Edit mode's rows are the ones
382
- // spliceContainer just built, so the trigger joins the last of them. Two
383
- // constraints pin that placement and nothing else satisfies both:
384
- //
385
- // - Closed help must cost no LINE. A trigger given its own vertical slot is a
386
- // permanent row for the whole time edit mode is on, since a trigger's `when`
387
- // is its host surface's, never its own open state (a trigger you must open in
388
- // order to see could never be opened).
389
- // - Closed help must cost no COLOUR. The hue cursor (src/dsl/render.ts:696)
390
- // advances in pre-order over every segment leaf — VISIBLE OR NOT, so that
391
- // toggling a disclosure never recolours the bar — which means a leaf inserted
392
- // AHEAD of the content shifts the hue index of everything after it. An
393
- // earlier draft put the `(?)` first and recoloured every cell of the bundled
394
- // default (status row 33;41;59 → 49;36;52) with edit mode still OFF. Trailing
395
- // the last row is the one position that moves no other leaf.
396
- //
397
- // Those two together disqualify the reset-banner row, which sits above the
398
- // content.
399
- //
400
- // A THIRD requirement decides how far the descent may go, and it outranks the
401
- // other two: the trigger must be visible exactly when EDIT MODE is, since a
402
- // trigger you must already have opened something else to reach is not a trigger.
403
- // A container's `when` reaches every descendant, so descending into a
404
- // `when`-bearing container would silently make its gate the trigger's gate.
405
- // `kind: "group"` is the shape that makes this concrete rather than theoretical:
406
- // lowerGroup emits `{vertical, children: [toggle, {…, when: groupGate}]}`, so a
407
- // preset root ending in a group — ordinary authoring the A-grammar endorses —
408
- // would otherwise put the `(?)` INSIDE that group's collapsible body, gated on
409
- // edit mode AND a disclosure most groups default closed. Pairing beside a gated
410
- // SEGMENT is a different act and stays allowed: `{h: [gatedSeg, cell]}` puts the
411
- // cell beside the gate rather than under it.
412
- //
413
- // For a root whose last row is gated the three requirements are jointly
414
- // unsatisfiable, so the priority is stated rather than left to whichever branch
415
- // the recursion happens to reach: visible (always) > no recolour (always, since
416
- // appending is still after every existing leaf) > no extra line (surrendered
417
- // here, in exactly the configs where riding a row was never possible).
418
- //
419
- // [LAW:dataflow-not-control-flow] Total over the node shapes with no guard: a
420
- // segment is a row of one that cannot hold a second cell, so it pairs into one;
421
- // a vertical container's rows are its children, so it descends into the last one
422
- // it may; anything else appends. An empty container has no last child and
423
- // appends, which is the same answer.
424
- function withTrailingCell(node: LayoutNode, cell: LayoutNode): LayoutNode {
425
- if (node.kind === "segment") {
426
- return {
427
- kind: "container",
428
- direction: "horizontal",
429
- children: [node, cell],
430
- };
431
- }
432
- const last = node.children.at(-1);
433
- if (
434
- node.direction === "vertical" &&
435
- last !== undefined &&
436
- (last.kind === "segment" || last.when === undefined)
437
- ) {
438
- return {
439
- ...node,
440
- children: [...node.children.slice(0, -1), withTrailingCell(last, cell)],
441
- };
442
- }
443
- return { ...node, children: [...node.children, cell] };
444
- }
445
-
446
- // One preset's chrome-spliced root. A bare-segment root (the A-grammar
447
- // collapses a single top-level segment ref to `{ kind: "segment", name }`
448
- // with no enclosing container) is wrapped in a synthetic horizontal
449
- // container for splicing purposes — the wrap becomes the real returned root,
450
- // which is exactly right: a lone segment needs a `+` on each side too.
451
- function spliceEditChromeForPreset(
452
- config: DslConfig,
453
- presetName: string,
454
- artifacts: ChromeArtifacts,
455
- help: HelpDisclosure,
456
- ): LayoutNode {
457
- const { node } = presetRoot(config, presetName);
458
- const rootOpsKey = presetRootOpsKey(presetName);
459
- const domainName = addableDomainName(presetName);
460
- const presetIdent = ident(presetName);
461
- const posCounter = { n: 0 };
462
- // [LAW:no-silent-failure] The bare-segment-root case (the A-grammar's
463
- // `{ seg, when }` shorthand is a legal PresetDecl.root) carries its OWN
464
- // `when` onto this synthetic wrapper too — wrapWithPresetRows's own
465
- // when-carry-up reads `splicedRoot.when`, which is this wrapper's `when`
466
- // once spliceContainer's `{...node, children}` passes it through
467
- // unchanged; without copying it here, a bare-segment preset root's own
468
- // gate would never reach that carry-up at all, leaking the reset banner
469
- // past it exactly like the container case did before that fix.
470
- const container: ContainerNode =
471
- node.kind === "container"
472
- ? node
473
- : {
474
- kind: "container",
475
- direction: "horizontal",
476
- children: [node],
477
- ...(node.when !== undefined && { when: node.when }),
478
- };
479
- const spliced = spliceContainer(
480
- container,
481
- presetIdent,
482
- rootOpsKey,
483
- domainName,
484
- artifacts,
485
- posCounter,
486
- );
487
- return wrapWithPresetRows(
488
- spliced,
489
- presetName,
490
- presetIdent,
491
- rootOpsKey,
492
- artifacts,
493
- help,
494
- );
495
- }
496
-
497
- // [LAW:single-enforcer] THE synthesis entry point, called once from
498
- // validateConfig after cross-ref/cycle checks pass. Every declared preset
499
- // (the floor "default" included — presetNames/presetByName/presetRoot
500
- // already treat it uniformly) gets an explicit `presets[name].root` carrying
501
- // its spliced tree; `config.root` itself is left untouched (presetRoot falls
502
- // back to it only when a preset declares no root of its own, and every name
503
- // now does). The synthesized variables/actions/segments merge additively —
504
- // nothing here can collide with user data, since every name it mints lives
505
- // under the `edit.`/`menus.` namespaces `synthesizeEditModeToggle` and
506
- // `synthesizeMenuDecls` already reserve unconditionally at parse time.
507
- export function synthesizeEditChrome(config: DslConfig): DslConfig {
508
- // [LAW:carrying-cost] Demand-driven, mirroring synthesizeEditModeToggle's
509
- // own gate: `edit.toggle` exists in the merged config iff SOME file's
510
- // Phase A synthesis fired (iff some segment referenced it), which iff some
511
- // author actually placed an edit-mode trigger. A config that never opted
512
- // in gets back the identical config, untouched — no extra segments,
513
- // actions, or variables, and critically no NEW `set`/`state` surface that
514
- // would force session.id onto an otherwise fully static bar.
515
- if (!(EDIT_TOGGLE_ACTION in config.actions)) return config;
516
- const artifacts: ChromeArtifacts = {
517
- variables: {},
518
- ensured: {},
519
- actions: {},
520
- segments: {},
521
- };
522
- // [LAW:one-source-of-truth] Edit mode's `(?)` is minted ONCE and merely
523
- // REFERENCED from every preset root — the same move the settings menu makes
524
- // with its anchor, and for the same reason: one disclosure means one open
525
- // state, so switching presets cannot land you beside a second `(?)` that
526
- // disagrees about whether help is showing. The text is identical for every
527
- // preset because what `+` and `-` do is a fact about edit mode, not about a
528
- // layout.
529
- const help = declareHelp(
530
- `${EDIT_NS}help`,
531
- EDIT_MODE_HELP,
532
- [EDIT_MODE_REF],
533
- artifacts,
534
- );
535
- const presets: Record<string, PresetDecl> = { ...config.presets };
536
- for (const name of presetNames(config.presets)) {
537
- const splicedRoot = spliceEditChromeForPreset(
538
- config,
539
- name,
540
- artifacts,
541
- help,
542
- );
543
- presets[name] = {
544
- ...presetByName(config.presets, name),
545
- root: splicedRoot,
546
- };
547
- }
548
- return {
549
- ...config,
550
- variables: {
551
- ...artifacts.ensured,
552
- ...config.variables,
553
- ...artifacts.variables,
554
- },
555
- actions: { ...config.actions, ...artifacts.actions },
556
- segments: { ...config.segments, ...artifacts.segments },
557
- presets,
558
- };
559
- }