@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,775 +0,0 @@
1
- // [LAW:one-source-of-truth] candybar-settings-ui-aok.1 — THE global settings
2
- // menu: one disclosure that every rendered bar carries, whatever the config
3
- // says. It exists because `root` replaces wholesale: a user who writes `root:`
4
- // (the ordinary reason to write a config at all) silently deletes every
5
- // interactive surface the bundled default placed there — presets, edit mode,
6
- // the value controls. A door the user can delete by accident is not a door.
7
- //
8
- // [LAW:dataflow-not-control-flow] Placement is a POSITION, never a mode. The
9
- // synthesis runs the same two total functions on every preset root, in the same
10
- // order, every load: `withAnchor` yields a tree that CONTAINS the anchor — the
11
- // author's own placement untouched, or the default position appended — and
12
- // `expandAnchor` replaces that one leaf with the lowered disclosure subtree.
13
- // "The author placed it" and "the author did not" differ only in the VALUE
14
- // handed to one splice; there is no second code path to keep in agreement.
15
- //
16
- // [LAW:one-type-per-behavior] Nothing here is a new render or interaction
17
- // concept. The menu is the disclosure primitive's fourth instance, alongside
18
- // group sugar, `{{ menu }}`, and edit mode's toggle: it calls the SAME
19
- // `disclosureStateVar`/`disclosureCycleAction`/`menuStateKey` functions those
20
- // three call, so a synthesized global menu and a hand-authored group are
21
- // indistinguishable to the render walk.
22
- //
23
- // WHY THIS RUNS FROM validateConfig, BEFORE synthesizeEditChrome — the two
24
- // passes both rewrite every preset root, so their order is a real decision:
25
- // • It cannot run at parse time (loader/*.ts) like group/menu synthesis,
26
- // because the tree it must splice into only exists after merge: the user's
27
- // `root` replaces the bundled default's, and it is the MERGED root the menu
28
- // has to be present in.
29
- // • It runs BEFORE edit chrome so edit chrome walks the final content tree.
30
- // Every name minted here lives under the reserved `settings.` namespace,
31
- // which `isChromeExempt` excludes, so the menu never acquires a `+`/`-`
32
- // affordance and can never be edited out of the bar it is the entry point
33
- // to. Running after would splice the menu into an already-chromed tree,
34
- // landing it between a segment and the `-` that removes it.
35
- // • It also GUARANTEES `edit.toggle` (see ensureEditToggle below), which is
36
- // precisely what edit chrome's own demand gate reads — so the ordering is
37
- // load-bearing in that direction too, not merely tidy.
38
-
39
- import type { ActionDecl } from "./action.js";
40
- import type {
41
- DslConfig,
42
- LayoutNode,
43
- PresetDecl,
44
- SegmentDecl,
45
- VariableDecl,
46
- } from "./dsl-types.js";
47
- import {
48
- DISCLOSURE_CLOSED,
49
- DISCLOSURE_GLYPH_CLOSED,
50
- DISCLOSURE_GLYPH_OPEN,
51
- disclosureCycleAction,
52
- disclosureGate,
53
- disclosureStateVar,
54
- disclosureTrigger,
55
- type DisclosureRef,
56
- } from "./disclosure.js";
57
- import { declareHelp, type HelpDisclosure } from "./help.js";
58
- import { PERSIST_HELP } from "../help-text.js";
59
- import {
60
- EDIT_MODE_KEY,
61
- EDIT_MODE_OPEN,
62
- EDIT_TOGGLE_ACTION,
63
- } from "./loader/edit-mode.js";
64
- import {
65
- menuActionName,
66
- menuMember,
67
- menuPageKey,
68
- menuStateKey,
69
- } from "./menu-keys.js";
70
- import { presetByName, presetNames, presetRoot } from "./presets.js";
71
- import type { OptionDomain } from "./option-domain.js";
72
- import {
73
- BOOLEAN_FALSE,
74
- BOOLEAN_MEMBERS,
75
- BOOLEAN_TRUE,
76
- PADDING_RANGE,
77
- } from "../themes/policy.js";
78
-
79
- // [LAW:one-source-of-truth] The reserved namespace every artifact this pass
80
- // mints lives under, mirroring `groups.`/`menus.`/`edit.`. Reserved at parse
81
- // time (reservedNamespaceCollisions, from dsl-loader's validateTopLevel) so a
82
- // user name under it is a loud load error rather than a silent shadowing of
83
- // the one surface they cannot afford to lose.
84
- export const SETTINGS_NS = "settings.";
85
-
86
- // [LAW:one-source-of-truth] THE anchor: one string that is simultaneously the
87
- // segment name an author places in `root` to choose the menu's position, the
88
- // name of the toggle segment the synthesis puts there, the disclosure's state
89
- // variable, and its cycle action. Group sugar already spans those four with one
90
- // `groups.<name>` string for the same reason — one name means the toggle's
91
- // click and the body's `when` cannot address different keys.
92
- export const SETTINGS_ANCHOR = `${SETTINGS_NS}menu`;
93
-
94
- // The disclosure's open member. Same spelling edit mode uses for its own binary
95
- // toggle — a binary disclosure holds the CLOSED sentinel or this.
96
- const SETTINGS_OPEN = EDIT_MODE_OPEN;
97
-
98
- // The body's content segments. `.1` scoped the body to what its acceptance
99
- // names — switch presets, enter edit mode; `.3` adds the persist? selector
100
- // beside them and the config menu below them.
101
- const EDIT_SEG = `${SETTINGS_NS}edit`;
102
-
103
- // ─── The config menu (candybar-settings-ui-aok.3) ───────────────────────────
104
- //
105
- // [LAW:one-source-of-truth] ONE control per setting. The drawer used to spell
106
- // each of theme/style/look/preset TWICE — `{{ menu "applyTheme" }}` for the
107
- // session beside `📌{{ menu "applyThemeForever" }}` for the durable default —
108
- // two controls a reader had to reconcile at every glance, and two declarations
109
- // an author had to keep in agreement. Here each setting is one control bound
110
- // to one DUAL action, and the `persist?` selector beside them chooses which
111
- // store every one of those controls writes [LAW:dataflow-not-control-flow].
112
- //
113
- // [LAW:no-mode-explosion] persist? is not a mode: it is a value in
114
- // SessionState that the compiled action reads at click time. Nothing branches
115
- // on it — not the synthesis (which mints the same tree either way), not the
116
- // render walk, and not the daemon's writers, which are the same two writers
117
- // they were before this menu existed.
118
- //
119
- // The selector sits in the menu's FIRST row, above and beside every control it
120
- // governs, so it never stands over a row it cannot affect: every setting under
121
- // it — preset here, theme/look/style/wrap/padding in the config row — is dual.
122
- // `charset` and `colorCompatibility` are deliberately absent: they describe the
123
- // TERMINAL (glyph coverage, colour depth), not a taste that varies between
124
- // sessions, so they have no session half to choose and stay config-file
125
- // settings (see CHARSETS in themes/policy.ts).
126
- const PERSIST_SEG = `${SETTINGS_NS}persist`;
127
- const CONFIG_SEG = `${SETTINGS_NS}config`;
128
-
129
- // The selector's own state key, session-scoped and unchecked by default: you
130
- // arrive in experimentation mode, and committing a value to every future
131
- // session is a deliberate act. It also means a checkbox left armed yesterday
132
- // cannot silently write a durable default today — SessionState is per session.
133
- const PERSIST_KEY = PERSIST_SEG;
134
-
135
- // [LAW:one-source-of-truth] The two disclosures this menu IS, as refs rather
136
- // than as gate strings: every gate below — and every `(?)` nested inside them —
137
- // derives from these, so the toggle that writes a key and the `when` that reads
138
- // it cannot name different variables.
139
- const SETTINGS_REF: DisclosureRef = {
140
- variable: SETTINGS_ANCHOR,
141
- member: SETTINGS_OPEN,
142
- };
143
- const CONFIG_REF: DisclosureRef = {
144
- variable: CONFIG_SEG,
145
- member: SETTINGS_OPEN,
146
- };
147
-
148
- // Gated on BOTH keys — a config row left open yesterday must not render beside
149
- // a closed menu today. Nesting is conjunction, which is why it is one list.
150
- const CONFIG_OPEN_GATE = disclosureGate(SETTINGS_REF, CONFIG_REF);
151
-
152
- // The `(?)` that explains `persist?` — the one control in this menu whose
153
- // behaviour a user cannot infer from its label, which is exactly why the ticket
154
- // named it as a required use site. Its body says what the NEXT click does, in
155
- // the same two sentences `--help` prints.
156
- const PERSIST_HELP_SEG = `${SETTINGS_NS}help.persist`;
157
-
158
- // [LAW:one-source-of-truth] The panel's surface colours, spelled once. The help
159
- // cells must wear the same ones as the controls they explain — a `(?)` body in
160
- // a different colour reads as a different panel — and that agreement is only
161
- // guaranteed if there is one value to hand both.
162
- const SETTINGS_SURFACE = { bg: "surface", fg: "foreground" } as const;
163
-
164
- // [LAW:one-source-of-truth] One accordion key for every picker in the menu:
165
- // one key holds one open member, so opening a theme picker closes the look
166
- // picker. The settings menu is a narrow panel — two open drop-downs would
167
- // overflow it — and this is the same shared-key mechanism group sugar uses,
168
- // selected by a value, not a mode.
169
- const PICKER_KEY = `${SETTINGS_NS}pickers`;
170
-
171
- // [LAW:types-are-the-program] One row of the config menu, as data: everything
172
- // that differs between "theme" and "padding" is a field here, so the six
173
- // controls below are six VALUES and the synthesis that mints them is written
174
- // once. A control names the two keys its dual action writes (they differ where
175
- // history made them differ — SessionState "theme" over globals field
176
- // "palette"), the variable whose value it displays, and its value source.
177
- interface SettingControl {
178
- readonly name: string;
179
- readonly sessionKey: string;
180
- readonly configKey: string;
181
- // The `.effective` projection the daemon resolved for this render — the
182
- // value the bar is ACTUALLY rendering with, whatever produced it. A control
183
- // labels itself with this rather than with its own session key, so the label
184
- // can never name a value the bar is not in.
185
- readonly effectiveVar: string;
186
- readonly glyph: string;
187
- readonly domain: OptionDomain;
188
- }
189
-
190
- // [LAW:one-type-per-behavior] Four settings, one control shape: a glyph, the
191
- // current value, a picker over a domain, and the ↺ that forgets the durable
192
- // default. They differ only in which keys they write and which domain they
193
- // range — configuration, so they are four VALUES of one synthesis, not four
194
- // hand-written segments. `theme`'s two keys differ (SessionState "theme" over
195
- // globals field "palette") for the historical reason recorded in
196
- // state-validators.ts's baseline table; carrying BOTH keys as data is what
197
- // makes that difference expressible without a special case.
198
- //
199
- // They are split into two lists by WHERE they render, because that is a fact
200
- // about each control, not something the layout should recover by comparing
201
- // names [LAW:dataflow-not-control-flow]. Switching arrangement is what people
202
- // open this menu for, so the preset picker sits one click from the toggle;
203
- // the display settings sit one disclosure deeper, which is what keeps the
204
- // menu narrow when opened.
205
- const PRIMARY_CONTROLS: readonly SettingControl[] = [
206
- {
207
- name: "preset",
208
- sessionKey: "preset",
209
- configKey: "preset",
210
- effectiveVar: "preset.effective",
211
- glyph: "▦",
212
- domain: "presets",
213
- },
214
- ];
215
-
216
- const CONFIG_CONTROLS: readonly SettingControl[] = [
217
- {
218
- name: "theme",
219
- sessionKey: "theme",
220
- configKey: "palette",
221
- effectiveVar: "theme.effective",
222
- glyph: "🎨",
223
- domain: "themes",
224
- },
225
- {
226
- name: "look",
227
- sessionKey: "look",
228
- configKey: "look",
229
- effectiveVar: "look.effective",
230
- glyph: "◐",
231
- domain: "looks",
232
- },
233
- {
234
- name: "style",
235
- sessionKey: "style",
236
- configKey: "style",
237
- effectiveVar: "style.effective",
238
- glyph: "✦",
239
- domain: "styles",
240
- },
241
- ];
242
-
243
- // The two settings whose affordance is not a picker: wrapping is a toggle (two
244
- // members, so a menu would be a drop-down over a binary) and padding is a
245
- // stepper over a range (16 picker cells for a value you nudge). Both are dual
246
- // exactly like the pickers — only the affordance differs, so they carry the
247
- // same key record and only their `domain` is absent.
248
- //
249
- // [LAW:one-source-of-truth] Declared as records rather than typed inline at
250
- // each use, so every key in SETTINGS_WRITTEN_KEYS below traces to one
251
- // declaration. When these two were string literals repeated across the set,
252
- // the segment and the action, a rename in one place would have silently
253
- // misclassified the key rather than failing.
254
- interface KeyedSetting {
255
- readonly name: string;
256
- readonly sessionKey: string;
257
- readonly configKey: string;
258
- }
259
-
260
- const WRAP: KeyedSetting = {
261
- name: "wrap",
262
- sessionKey: "autoWrap",
263
- configKey: "autoWrap",
264
- };
265
- const PADDING: KeyedSetting = {
266
- name: "padding",
267
- sessionKey: "padding",
268
- configKey: "padding",
269
- };
270
-
271
- const WRAP_SEG = `${SETTINGS_NS}${WRAP.name}`;
272
- const PADDING_SEG = `${SETTINGS_NS}${PADDING.name}`;
273
-
274
- // Every picker control, wherever it renders — minting one is the same job in
275
- // both rows, so the synthesis folds over this and the placement lists above
276
- // decide only where each lands.
277
- const PICKER_CONTROLS: readonly SettingControl[] = [
278
- ...PRIMARY_CONTROLS,
279
- ...CONFIG_CONTROLS,
280
- ];
281
-
282
- // [LAW:one-source-of-truth] Every PLAIN key the settings menu writes — both
283
- // destinations of every control it mints. Unlike the `settings.` names, these
284
- // are ordinary words a config can own (`theme`, `padding`, …), so a reader
285
- // cannot tell from the key alone whether the menu or the author wrote it. This
286
- // set is the menu's own answer to "which keys do I write", derived from the
287
- // same records the controls are minted from, so a consumer pairing it with an
288
- // authorship check (test/helpers/ambient-chrome.ts) can never drift from what
289
- // the synthesis actually declares.
290
- export const SETTINGS_WRITTEN_KEYS: ReadonlySet<string> = new Set(
291
- [...PICKER_CONTROLS, WRAP, PADDING].flatMap((c) => [
292
- c.sessionKey,
293
- c.configKey,
294
- ]),
295
- );
296
-
297
- // [LAW:one-source-of-truth] A control's three names, derived from its one
298
- // name — the segment that shows it, the action its picker applies, and the
299
- // action its ↺ resets. Derived rather than declared so a control record can
300
- // never name a segment whose picker writes a different setting.
301
- const controlSeg = (name: string): string => `${SETTINGS_NS}${name}`;
302
- const controlApply = (name: string): string => `${SETTINGS_NS}apply.${name}`;
303
- const controlReset = (name: string): string => `${SETTINGS_NS}reset.${name}`;
304
-
305
- // [LAW:one-source-of-truth] The predicate the body container gates on, derived
306
- // from the same anchor string the toggle's cycle writes — spelled once here,
307
- // exactly as lowerGroup derives a group body's `when` from the group's own
308
- // reference name.
309
- const SETTINGS_OPEN_GATE = disclosureGate(SETTINGS_REF);
310
-
311
- // [LAW:single-enforcer] The one answer to "is this segment reference the global
312
- // menu's anchor". cross-ref.ts asks it to accept an authored placement of a name
313
- // no config declares (this pass provides it, unconditionally, immediately after
314
- // cross-ref passes), and to reject a SECOND placement — one key holds one open
315
- // state, so two anchors would be two toggles writing one disclosure.
316
- export function isSettingsAnchor(segmentName: string): boolean {
317
- return segmentName === SETTINGS_ANCHOR;
318
- }
319
-
320
- // ─── The anchored-root stamp ────────────────────────────────────────────────
321
-
322
- declare const anchored: unique symbol;
323
-
324
- // [LAW:parse-dont-validate] A tree that is KNOWN to contain the anchor. The
325
- // stamp is the proof, so `expandAnchor` has no "anchor missing" arm to guard
326
- // and no answer-shaped void to return: the only way to obtain this type is to
327
- // go through `withAnchor`, which establishes the fact by construction.
328
- //
329
- // The theorem includes the anchor inheriting no gate the DEFAULT placement
330
- // descended into — a weaker stamp ("contains an anchor" alone) is what let a
331
- // `when`-gated first row silently swallow the menu. Two gates are exempt
332
- // because they are explicit authorial statements rather than accidents: the
333
- // author's own placement of the anchor (they chose that position, gate and
334
- // all) and a `when` on the root itself (there is no bar at all under that
335
- // condition, so there is nothing to host a menu on).
336
- type AnchoredRoot = LayoutNode & { readonly [anchored]: true };
337
-
338
- // [LAW:dataflow-not-control-flow] The default position, as structural recursion
339
- // over the LayoutNode union rather than a placement mode: descend to the bar's
340
- // FIRST horizontal row and append there — where the bundled default's own
341
- // settings affordance already sits, and the place a one-row user config puts
342
- // everything. Total over every tree shape, including the degenerate ones: a
343
- // bare-segment root (the A-grammar collapses a lone top-level ref) grows a
344
- // horizontal wrapper, and an empty container simply becomes the row.
345
- function appendAnchor(node: LayoutNode): LayoutNode {
346
- const anchorRef: LayoutNode = { kind: "segment", name: SETTINGS_ANCHOR };
347
- if (node.kind === "segment") {
348
- // [LAW:no-silent-failure] A bare-segment root may carry its OWN `when` — an
349
- // author gating their whole bar behind a condition. This wrapper is a brand
350
- // new node, so without carrying that gate up, everything spliced beside the
351
- // segment (this menu, and the reset banner edit chrome later prepends by
352
- // reading `splicedRoot.when`) would render past a gate the author wrote.
353
- // The identical carry-up spliceEditChromeForPreset performs, one pass over.
354
- return {
355
- kind: "container",
356
- direction: "horizontal",
357
- children: [node, anchorRef],
358
- ...(node.when !== undefined && { when: node.when }),
359
- };
360
- }
361
- const [first, ...rest] = node.children;
362
- // [LAW:no-silent-failure] Descend only into an UNGATED child. A gate on an
363
- // inner row is a statement about that row's content, not about the bar — an
364
- // author writing an ordinary conditional first row (a git row shown only
365
- // inside a repo) has no idea the default placement attaches the menu there,
366
- // and inheriting that gate would silently delete the one surface this pass
367
- // exists to make undeletable, under exactly their condition. When the first
368
- // row is gated the anchor becomes its own ungated row on this container
369
- // instead, which is a position the author can still override by placing the
370
- // anchor themselves.
371
- //
372
- // The ROOT's own `when` is deliberately NOT lifted out of, here or in the
373
- // segment arm above: gating the whole tree is an explicit statement that
374
- // there is no bar under this condition, and there is no bar to host a menu
375
- // on. That is the same "the author's explicit choice is the answer" rule
376
- // that honors an author-placed anchor inside a gated row — and it is what
377
- // keeps edit chrome's reset banner gated with the content it describes.
378
- if (
379
- node.direction === "vertical" &&
380
- first !== undefined &&
381
- first.when === undefined
382
- ) {
383
- return { ...node, children: [appendAnchor(first), ...rest] };
384
- }
385
- return { ...node, children: [...node.children, anchorRef] };
386
- }
387
-
388
- // [LAW:parse-dont-validate] The checkpoint: in, a tree that may or may not name
389
- // the anchor; out, a tree that provably does. The author's placement passes
390
- // through byte-identical — the position they chose IS the answer — and its
391
- // absence is answered with the default position. One value, two sources.
392
- function withAnchor(node: LayoutNode): AnchoredRoot {
393
- const placed = countAnchors(node) > 0 ? node : appendAnchor(node);
394
- return placed as AnchoredRoot;
395
- }
396
-
397
- // [LAW:single-enforcer] THE anchor census, read by both consumers of the count:
398
- // `withAnchor` (is there a placement to honor?) and the loader's duplicate check
399
- // (is there more than one?). One traversal definition, so "placed" cannot mean
400
- // different things to the two.
401
- export function countAnchors(node: LayoutNode): number {
402
- if (node.kind === "segment") return isSettingsAnchor(node.name) ? 1 : 0;
403
- return node.children.reduce((n, child) => n + countAnchors(child), 0);
404
- }
405
-
406
- // [LAW:one-type-per-behavior] The lowering, identical in shape to lowerGroup's:
407
- // a vertical pair of the toggle segment and a `when`-gated body. Replaces the
408
- // anchor leaf wherever it sits, so the author's chosen position is the menu's
409
- // position with nothing else moved.
410
- function expandAnchor(
411
- node: AnchoredRoot | LayoutNode,
412
- help: HelpDisclosure,
413
- ): LayoutNode {
414
- if (node.kind === "segment") {
415
- return isSettingsAnchor(node.name)
416
- ? {
417
- kind: "container",
418
- direction: "vertical",
419
- children: [
420
- node,
421
- // Row one: what the menu is FOR — the persist? selector that says
422
- // where every setting below it lands, the preset switcher, the
423
- // door into the config menu, and the door into edit mode.
424
- {
425
- kind: "container",
426
- direction: "horizontal",
427
- children: [
428
- { kind: "segment", name: PERSIST_SEG },
429
- // The `(?)` rides the row that already exists, immediately
430
- // after the control it explains — so closed help costs no row
431
- // and widens the bar by one cell, and open help reads as an
432
- // answer to the checkbox on its left.
433
- //
434
- // Mid-row, DELIBERATELY, unlike edit mode's `(?)`, which
435
- // edit-chrome.ts goes to lengths to trail. The difference is
436
- // structural, not a discipline applied in one file and skipped
437
- // here. `nextHueShift` (src/dsl/render.ts:697) counts segment
438
- // leaves in pre-order, so a leaf's hue index is the number of
439
- // leaves before it — which makes the consequence arithmetic:
440
- // reordering leaves WITHIN a subtree cannot change the index of
441
- // any leaf AFTER it, since the subtree's leaf count does not
442
- // move. Edit chrome WRAPS the whole tree, so trailing there is
443
- // after every existing leaf and costs zero. This menu splices
444
- // MID-TREE at an anchor `withAnchor` lets the author put
445
- // anywhere, so no position inside it is after the rest of the
446
- // bar: the leaves it adds — this trigger plus one per
447
- // PERSIST_HELP line, a count that lives in help-text.ts and is
448
- // deliberately not copied here — shift everything past the
449
- // anchor wherever inside the menu they sit. Trailing would cost
450
- // the adjacency that IS the affordance. The fix is decoupling
451
- // colour from tree position — candybar-render-y5h, which fixes
452
- // every mid-tree synthesis at once rather than one file at a
453
- // time.
454
- help.trigger,
455
- ...PRIMARY_CONTROLS.map(
456
- (c): LayoutNode => ({
457
- kind: "segment",
458
- name: controlSeg(c.name),
459
- }),
460
- ),
461
- { kind: "segment", name: CONFIG_SEG },
462
- { kind: "segment", name: EDIT_SEG },
463
- ],
464
- when: SETTINGS_OPEN_GATE,
465
- },
466
- // The help body: one row, present only while the `(?)` is open,
467
- // directly under the row that asked the question.
468
- help.body,
469
- // Row two: the display settings, behind their own disclosure so
470
- // the menu opens narrow. Gated on BOTH keys — a config row left
471
- // open yesterday must not render beside a closed menu today; one
472
- // gate per disclosure, and this row is inside two of them.
473
- {
474
- kind: "container",
475
- direction: "horizontal",
476
- children: [
477
- ...CONFIG_CONTROLS.map(
478
- (c): LayoutNode => ({
479
- kind: "segment",
480
- name: controlSeg(c.name),
481
- }),
482
- ),
483
- { kind: "segment", name: WRAP_SEG },
484
- { kind: "segment", name: PADDING_SEG },
485
- ],
486
- when: CONFIG_OPEN_GATE,
487
- },
488
- ],
489
- }
490
- : node;
491
- }
492
- return {
493
- ...node,
494
- children: node.children.map((child) => expandAnchor(child, help)),
495
- };
496
- }
497
-
498
- // ─── The artifacts ──────────────────────────────────────────────────────────
499
-
500
- interface MenuArtifacts {
501
- readonly variables: Record<string, VariableDecl>;
502
- readonly actions: Record<string, ActionDecl>;
503
- readonly segments: Record<string, SegmentDecl>;
504
- }
505
-
506
- // [LAW:single-enforcer] The `{{ menu }}` disclosure a body segment hosts,
507
- // synthesized by calling the SAME pure functions menu-synth.ts's parse-time pass
508
- // calls — the identical move edit-chrome.ts's insertChrome makes, and for the
509
- // identical reason: this pass runs too late to piggyback on that one, so parity
510
- // comes from sharing the derivation, never from restating it.
511
- function declareHostedMenu(
512
- segName: string,
513
- applyName: string,
514
- artifacts: MenuArtifacts,
515
- // The accordion key the menu shares with its siblings, or undefined for a
516
- // menu that toggles only itself — the same `key` option `{{ menu }}` takes,
517
- // threaded here so the synthesized artifacts and the rendered disclosure
518
- // derive one identity from one value [LAW:one-source-of-truth].
519
- sharedKey?: string,
520
- ): void {
521
- const member = menuMember(applyName);
522
- const stateKey = menuStateKey(segName, applyName, sharedKey);
523
- const pageKey = menuPageKey(stateKey);
524
- artifacts.variables[stateKey] = disclosureStateVar(
525
- stateKey,
526
- DISCLOSURE_CLOSED,
527
- );
528
- artifacts.variables[pageKey] = { kind: "state", key: pageKey, default: "0" };
529
- artifacts.actions[menuActionName(stateKey, member)] = disclosureCycleAction(
530
- stateKey,
531
- member,
532
- );
533
- artifacts.actions[pageKey] = { set: pageKey, int: true };
534
- }
535
-
536
- // [LAW:one-source-of-truth] Everything the menu is, minted ONCE per config and
537
- // merely REFERENCED from each preset root. This is what makes the pass
538
- // idempotent across N presets for free: a preset root carries a segment
539
- // reference, and a second reference to one declaration is a reuse, not the
540
- // self-collision a second `kind: "group"` node would be (see the settingsDrawer
541
- // comment in default-dsl-config.ts for that hazard in its original form).
542
- function settingsArtifacts(): {
543
- artifacts: MenuArtifacts;
544
- help: HelpDisclosure;
545
- } {
546
- const artifacts: MenuArtifacts = {
547
- variables: {
548
- [SETTINGS_ANCHOR]: disclosureStateVar(SETTINGS_ANCHOR, DISCLOSURE_CLOSED),
549
- },
550
- actions: {
551
- [SETTINGS_ANCHOR]: disclosureCycleAction(SETTINGS_ANCHOR, SETTINGS_OPEN),
552
- [CONFIG_SEG]: disclosureCycleAction(CONFIG_SEG, SETTINGS_OPEN),
553
- // [LAW:one-source-of-truth] The selector is an ordinary session cycle
554
- // over the one boolean spelling SessionState uses — off first, because
555
- // an unwritten key counts as the first member and the menu opens in
556
- // experimentation mode.
557
- [PERSIST_SEG]: {
558
- set: PERSIST_KEY,
559
- cycle: [BOOLEAN_FALSE, BOOLEAN_TRUE],
560
- },
561
- },
562
- segments: {
563
- // [LAW:representation] The glyph trails the label it gates, per the
564
- // disclosure vocabulary every other toggle in the bar reads by.
565
- [SETTINGS_ANCHOR]: {
566
- template: disclosureTrigger(
567
- SETTINGS_ANCHOR,
568
- `☰ ${DISCLOSURE_GLYPH_CLOSED}`,
569
- `☰ ${DISCLOSURE_GLYPH_OPEN}`,
570
- ),
571
- ...SETTINGS_SURFACE,
572
- },
573
- // [LAW:representation] The checkbox states what the NEXT write does,
574
- // which is why the glyph and the word live together: "☑ persist?" is
575
- // the whole explanation of where the click below it lands.
576
- [PERSIST_SEG]: {
577
- template: `{{ action "${PERSIST_SEG}" "☐ persist?" "☑ persist?" }}`,
578
- ...SETTINGS_SURFACE,
579
- },
580
- [CONFIG_SEG]: {
581
- template: disclosureTrigger(
582
- CONFIG_SEG,
583
- `⚙ config ${DISCLOSURE_GLYPH_CLOSED}`,
584
- `⚙ config ${DISCLOSURE_GLYPH_OPEN}`,
585
- ),
586
- ...SETTINGS_SURFACE,
587
- },
588
- // [LAW:one-type-per-behavior] Both non-picker controls read the same
589
- // `.effective` projection their picker siblings read, and write the
590
- // same two stores through the same dual arm — a toggle and a stepper
591
- // are affordances over one behavior, not two kinds of setting.
592
- [WRAP_SEG]: {
593
- template:
594
- `{{ action "${controlApply("wrap")}" "wrap: on" "wrap: off" }} ` +
595
- `{{ action "${controlReset("wrap")}" "↺" }}`,
596
- ...SETTINGS_SURFACE,
597
- },
598
- [PADDING_SEG]: {
599
- template:
600
- `{{ action "${controlApply("padding")}.down" "◀" }} ` +
601
- "padding {{ .padding.effective }} " +
602
- `{{ action "${controlApply("padding")}.up" "▶" }} ` +
603
- `{{ action "${controlReset("padding")}" "↺" }}`,
604
- ...SETTINGS_SURFACE,
605
- },
606
- // The entry point edit mode never had: `edit.toggle` is a reserved action
607
- // whose only bundled reference lives in the `toolbar` segment, which a
608
- // user config's `root` drops like everything else. Here it is reachable
609
- // from a segment no config can drop.
610
- [EDIT_SEG]: {
611
- template: `{{ action "${EDIT_TOGGLE_ACTION}" "✎ edit" "✎ done" }}`,
612
- ...SETTINGS_SURFACE,
613
- },
614
- },
615
- };
616
- artifacts.variables[PERSIST_KEY] = {
617
- kind: "state",
618
- key: PERSIST_KEY,
619
- default: BOOLEAN_FALSE,
620
- };
621
- artifacts.variables[CONFIG_SEG] = disclosureStateVar(
622
- CONFIG_SEG,
623
- DISCLOSURE_CLOSED,
624
- );
625
- declareSettingControls(artifacts);
626
- // [LAW:one-source-of-truth] The `(?)` is minted here, with the panel it
627
- // belongs to, and its two NODES are returned so `expandAnchor` places them by
628
- // the value it is handed rather than by re-deriving names this pass already
629
- // owns. Nested in SETTINGS_REF, so closing the menu takes the open help with
630
- // it.
631
- const help = declareHelp(
632
- PERSIST_HELP_SEG,
633
- PERSIST_HELP,
634
- [SETTINGS_REF],
635
- artifacts,
636
- SETTINGS_SURFACE,
637
- );
638
- return { artifacts, help };
639
- }
640
-
641
- // [LAW:one-source-of-truth] Every setting the menu offers, minted from the one
642
- // table that describes them. A picker control is a glyph, its live value, a
643
- // `{{ menu }}` over its domain, and the ↺ that forgets its durable default;
644
- // wrap and padding differ only in affordance. Every apply action here is DUAL
645
- // — one declaration naming both destination keys and the selector that chooses
646
- // between them — so the panel spells each setting exactly once and the click
647
- // carries the destination as data [LAW:dataflow-not-control-flow].
648
- //
649
- // [LAW:single-enforcer] Nothing here declares a gate. `deriveActionValidators`
650
- // and `deriveConfigActionValidators` each explode these dual declarations
651
- // (actionDestinations) and derive the same specs they would have derived from
652
- // the pair of single-destination actions this replaces — so the writable-key
653
- // surface is byte-for-byte what it was when the drawer spelled both halves.
654
- function declareSettingControls(artifacts: MenuArtifacts): void {
655
- for (const c of PICKER_CONTROLS) {
656
- const seg = controlSeg(c.name);
657
- const apply = controlApply(c.name);
658
- artifacts.segments[seg] = {
659
- template:
660
- `${c.glyph} {{ .${c.effectiveVar} }} ` +
661
- `{{ menu "${apply}" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" ` +
662
- `(dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
663
- `{{ action "${controlReset(c.name)}" "↺" }}`,
664
- ...SETTINGS_SURFACE,
665
- };
666
- artifacts.actions[apply] = {
667
- set: c.sessionKey,
668
- persist: c.configKey,
669
- persistWhen: PERSIST_KEY,
670
- from: c.domain,
671
- };
672
- // [LAW:one-source-of-truth] ↺ clears the DURABLE default only — the one
673
- // write the user cannot otherwise take back, since a session value dies
674
- // with the session. Its target is the config key the dual's durable half
675
- // writes, read from the same record, so the two can never name different
676
- // settings.
677
- artifacts.actions[controlReset(c.name)] = { reset: c.configKey };
678
- declareHostedMenu(seg, apply, artifacts, PICKER_KEY);
679
- }
680
- artifacts.actions[controlApply(WRAP.name)] = {
681
- set: WRAP.sessionKey,
682
- persist: WRAP.configKey,
683
- persistWhen: PERSIST_KEY,
684
- cycle: [...BOOLEAN_MEMBERS],
685
- };
686
- artifacts.actions[controlReset(WRAP.name)] = { reset: WRAP.configKey };
687
- // [LAW:one-source-of-truth] The stepper's bounds are PADDING_RANGE, the same
688
- // range the loader validates a config-file `padding` against and the same one
689
- // both write gates enforce — a click can never reach a value the file could
690
- // not have held.
691
- for (const by of [-1, 1]) {
692
- artifacts.actions[
693
- `${controlApply(PADDING.name)}.${by < 0 ? "down" : "up"}`
694
- ] = {
695
- set: PADDING.sessionKey,
696
- persist: PADDING.configKey,
697
- persistWhen: PERSIST_KEY,
698
- ...PADDING_RANGE,
699
- by,
700
- };
701
- }
702
- artifacts.actions[controlReset(PADDING.name)] = { reset: PADDING.configKey };
703
- }
704
-
705
- // [LAW:one-source-of-truth] Edit mode's toggle, ensured rather than duplicated:
706
- // both this pass and synthesizeEditModeToggle produce it by calling the same two
707
- // disclosure functions on the same two exported constants, so the two mints are
708
- // the same value by construction and whichever lands first is the only one.
709
- // Ensuring it here is not an optional courtesy — the EDIT_SEG segment above
710
- // references `edit.toggle`, and that pass is demand-driven off a scan of the
711
- // segments a FILE declared, which cannot see a segment this pass mints later.
712
- function ensureEditToggle(artifacts: MenuArtifacts): void {
713
- artifacts.variables[EDIT_MODE_KEY] = disclosureStateVar(
714
- EDIT_MODE_KEY,
715
- DISCLOSURE_CLOSED,
716
- );
717
- artifacts.actions[EDIT_TOGGLE_ACTION] = disclosureCycleAction(
718
- EDIT_MODE_KEY,
719
- EDIT_MODE_OPEN,
720
- );
721
- }
722
-
723
- // [LAW:one-source-of-truth] The variable whose presence IS the precondition,
724
- // named once so the predicate below and the load error cross-ref.ts raises when
725
- // it fails cannot describe different variables.
726
- export const SESSION_ID_VAR = "session.id";
727
-
728
- // [LAW:types-are-the-program] The menu's one structural prerequisite, read as a
729
- // value: a global `session.id`. It is not a demand gate and not a preference —
730
- // the menu is a CLICK surface, every click composes a URL whose first segment is
731
- // `session.id` read from the store, and cross-ref.ts already rejects an AUTHORED
732
- // state read or `set` write in a config that declares no such variable. A config
733
- // without it describes a static, non-interactive bar, and there is no menu to
734
- // place on one. Every config the daemon renders merges the bundled default,
735
- // which declares `session.id`, so in production this is universally true; what
736
- // it excludes is the hand-built static config, not a user.
737
- //
738
- // [LAW:one-source-of-truth] Exported because this is THE fact "will the anchor
739
- // resolve to a segment?" — asked here to decide whether to mint the menu, and
740
- // asked by cross-ref.ts to decide whether an authored placement of the anchor is
741
- // a reference this pass is about to satisfy or a dangling one. Two readers, one
742
- // predicate: when they were two predicates, cross-ref accepted an anchor this
743
- // pass then declined to provide, and the un-lowered reference reached the render
744
- // walk to throw at `lookupSegment`.
745
- export function canHostSessionState(config: DslConfig): boolean {
746
- return Object.prototype.hasOwnProperty.call(config.variables, SESSION_ID_VAR);
747
- }
748
-
749
- // [LAW:single-enforcer] THE synthesis entry point, called once from
750
- // validateConfig after cross-ref/cycle checks pass and before edit chrome.
751
- // Every declared preset — the floor `default` included — gets an explicit
752
- // `presets[name].root` carrying its anchored, expanded tree; `config.root`
753
- // itself is left untouched, exactly as synthesizeEditChrome leaves it, because
754
- // presetRoot falls back to it only for a preset declaring no root of its own
755
- // and every name now declares one.
756
- export function synthesizeSettingsMenu(config: DslConfig): DslConfig {
757
- if (!canHostSessionState(config)) return config;
758
- const { artifacts, help } = settingsArtifacts();
759
- ensureEditToggle(artifacts);
760
- const presets: Record<string, PresetDecl> = { ...config.presets };
761
- for (const name of presetNames(config.presets)) {
762
- const { node } = presetRoot(config, name);
763
- presets[name] = {
764
- ...presetByName(config.presets, name),
765
- root: expandAnchor(withAnchor(node), help),
766
- };
767
- }
768
- return {
769
- ...config,
770
- variables: { ...config.variables, ...artifacts.variables },
771
- actions: { ...config.actions, ...artifacts.actions },
772
- segments: { ...config.segments, ...artifacts.segments },
773
- presets,
774
- };
775
- }