@promptctl/cc-candybar 1.37.0 → 1.39.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.
@@ -63,6 +63,13 @@ import {
63
63
  menuStateKey,
64
64
  } from "./menu-keys.js";
65
65
  import { presetByName, presetNames, presetRoot } from "./presets.js";
66
+ import type { OptionDomain } from "./option-domain.js";
67
+ import {
68
+ BOOLEAN_FALSE,
69
+ BOOLEAN_MEMBERS,
70
+ BOOLEAN_TRUE,
71
+ PADDING_RANGE,
72
+ } from "../themes/policy.js";
66
73
 
67
74
  // [LAW:one-source-of-truth] The reserved namespace every artifact this pass
68
75
  // mints lives under, mirroring `groups.`/`menus.`/`edit.`. Reserved at parse
@@ -83,13 +90,189 @@ export const SETTINGS_ANCHOR = `${SETTINGS_NS}menu`;
83
90
  // toggle — a binary disclosure holds the CLOSED sentinel or this.
84
91
  const SETTINGS_OPEN = EDIT_MODE_OPEN;
85
92
 
86
- // The body's two content segments and the preset picker's apply action. `.1`
87
- // scopes the body to what its acceptance names — switch presets, enter edit
88
- // mode. The remaining display controls arrive with the config menu (`.3`),
89
- // which is the child that owns them.
90
- const PRESETS_SEG = `${SETTINGS_NS}presets`;
93
+ // The body's content segments. `.1` scoped the body to what its acceptance
94
+ // names — switch presets, enter edit mode; `.3` adds the persist? selector
95
+ // beside them and the config menu below them.
91
96
  const EDIT_SEG = `${SETTINGS_NS}edit`;
92
- const APPLY_PRESET_ACTION = `${SETTINGS_NS}applyPreset`;
97
+
98
+ // ─── The config menu (candybar-settings-ui-aok.3) ───────────────────────────
99
+ //
100
+ // [LAW:one-source-of-truth] ONE control per setting. The drawer used to spell
101
+ // each of theme/style/look/preset TWICE — `{{ menu "applyTheme" }}` for the
102
+ // session beside `📌{{ menu "applyThemeForever" }}` for the durable default —
103
+ // two controls a reader had to reconcile at every glance, and two declarations
104
+ // an author had to keep in agreement. Here each setting is one control bound
105
+ // to one DUAL action, and the `persist?` selector beside them chooses which
106
+ // store every one of those controls writes [LAW:dataflow-not-control-flow].
107
+ //
108
+ // [LAW:no-mode-explosion] persist? is not a mode: it is a value in
109
+ // SessionState that the compiled action reads at click time. Nothing branches
110
+ // on it — not the synthesis (which mints the same tree either way), not the
111
+ // render walk, and not the daemon's writers, which are the same two writers
112
+ // they were before this menu existed.
113
+ //
114
+ // The selector sits in the menu's FIRST row, above and beside every control it
115
+ // governs, so it never stands over a row it cannot affect: every setting under
116
+ // it — preset here, theme/look/style/wrap/padding in the config row — is dual.
117
+ // `charset` and `colorCompatibility` are deliberately absent: they describe the
118
+ // TERMINAL (glyph coverage, colour depth), not a taste that varies between
119
+ // sessions, so they have no session half to choose and stay config-file
120
+ // settings (see CHARSETS in themes/policy.ts).
121
+ const PERSIST_SEG = `${SETTINGS_NS}persist`;
122
+ const CONFIG_SEG = `${SETTINGS_NS}config`;
123
+
124
+ // The selector's own state key, session-scoped and unchecked by default: you
125
+ // arrive in experimentation mode, and committing a value to every future
126
+ // session is a deliberate act. It also means a checkbox left armed yesterday
127
+ // cannot silently write a durable default today — SessionState is per session.
128
+ const PERSIST_KEY = PERSIST_SEG;
129
+
130
+ // [LAW:one-source-of-truth] The config menu's own disclosure, spelled the same
131
+ // way the anchor above is: ONE name that is the segment, the state variable and
132
+ // the cycle action, so its toggle's click and its body's `when` cannot address
133
+ // different keys.
134
+ const CONFIG_OPEN_GATE = `{{ and (eq .${SETTINGS_ANCHOR} "${SETTINGS_OPEN}") (eq .${CONFIG_SEG} "${SETTINGS_OPEN}") }}`;
135
+
136
+ // [LAW:one-source-of-truth] One accordion key for every picker in the menu:
137
+ // one key holds one open member, so opening a theme picker closes the look
138
+ // picker. The settings menu is a narrow panel — two open drop-downs would
139
+ // overflow it — and this is the same shared-key mechanism group sugar uses,
140
+ // selected by a value, not a mode.
141
+ const PICKER_KEY = `${SETTINGS_NS}pickers`;
142
+
143
+ // [LAW:types-are-the-program] One row of the config menu, as data: everything
144
+ // that differs between "theme" and "padding" is a field here, so the six
145
+ // controls below are six VALUES and the synthesis that mints them is written
146
+ // once. A control names the two keys its dual action writes (they differ where
147
+ // history made them differ — SessionState "theme" over globals field
148
+ // "palette"), the variable whose value it displays, and its value source.
149
+ interface SettingControl {
150
+ readonly name: string;
151
+ readonly sessionKey: string;
152
+ readonly configKey: string;
153
+ // The `.effective` projection the daemon resolved for this render — the
154
+ // value the bar is ACTUALLY rendering with, whatever produced it. A control
155
+ // labels itself with this rather than with its own session key, so the label
156
+ // can never name a value the bar is not in.
157
+ readonly effectiveVar: string;
158
+ readonly glyph: string;
159
+ readonly domain: OptionDomain;
160
+ }
161
+
162
+ // [LAW:one-type-per-behavior] Four settings, one control shape: a glyph, the
163
+ // current value, a picker over a domain, and the ↺ that forgets the durable
164
+ // default. They differ only in which keys they write and which domain they
165
+ // range — configuration, so they are four VALUES of one synthesis, not four
166
+ // hand-written segments. `theme`'s two keys differ (SessionState "theme" over
167
+ // globals field "palette") for the historical reason recorded in
168
+ // state-validators.ts's baseline table; carrying BOTH keys as data is what
169
+ // makes that difference expressible without a special case.
170
+ //
171
+ // They are split into two lists by WHERE they render, because that is a fact
172
+ // about each control, not something the layout should recover by comparing
173
+ // names [LAW:dataflow-not-control-flow]. Switching arrangement is what people
174
+ // open this menu for, so the preset picker sits one click from the toggle;
175
+ // the display settings sit one disclosure deeper, which is what keeps the
176
+ // menu narrow when opened.
177
+ const PRIMARY_CONTROLS: readonly SettingControl[] = [
178
+ {
179
+ name: "preset",
180
+ sessionKey: "preset",
181
+ configKey: "preset",
182
+ effectiveVar: "preset.effective",
183
+ glyph: "▦",
184
+ domain: "presets",
185
+ },
186
+ ];
187
+
188
+ const CONFIG_CONTROLS: readonly SettingControl[] = [
189
+ {
190
+ name: "theme",
191
+ sessionKey: "theme",
192
+ configKey: "palette",
193
+ effectiveVar: "theme.effective",
194
+ glyph: "🎨",
195
+ domain: "themes",
196
+ },
197
+ {
198
+ name: "look",
199
+ sessionKey: "look",
200
+ configKey: "look",
201
+ effectiveVar: "look.effective",
202
+ glyph: "◐",
203
+ domain: "looks",
204
+ },
205
+ {
206
+ name: "style",
207
+ sessionKey: "style",
208
+ configKey: "style",
209
+ effectiveVar: "style.effective",
210
+ glyph: "✦",
211
+ domain: "styles",
212
+ },
213
+ ];
214
+
215
+ // The two settings whose affordance is not a picker: wrapping is a toggle (two
216
+ // members, so a menu would be a drop-down over a binary) and padding is a
217
+ // stepper over a range (16 picker cells for a value you nudge). Both are dual
218
+ // exactly like the pickers — only the affordance differs, so they carry the
219
+ // same key record and only their `domain` is absent.
220
+ //
221
+ // [LAW:one-source-of-truth] Declared as records rather than typed inline at
222
+ // each use, so every key in SETTINGS_WRITTEN_KEYS below traces to one
223
+ // declaration. When these two were string literals repeated across the set,
224
+ // the segment and the action, a rename in one place would have silently
225
+ // misclassified the key rather than failing.
226
+ interface KeyedSetting {
227
+ readonly name: string;
228
+ readonly sessionKey: string;
229
+ readonly configKey: string;
230
+ }
231
+
232
+ const WRAP: KeyedSetting = {
233
+ name: "wrap",
234
+ sessionKey: "autoWrap",
235
+ configKey: "autoWrap",
236
+ };
237
+ const PADDING: KeyedSetting = {
238
+ name: "padding",
239
+ sessionKey: "padding",
240
+ configKey: "padding",
241
+ };
242
+
243
+ const WRAP_SEG = `${SETTINGS_NS}${WRAP.name}`;
244
+ const PADDING_SEG = `${SETTINGS_NS}${PADDING.name}`;
245
+
246
+ // Every picker control, wherever it renders — minting one is the same job in
247
+ // both rows, so the synthesis folds over this and the placement lists above
248
+ // decide only where each lands.
249
+ const PICKER_CONTROLS: readonly SettingControl[] = [
250
+ ...PRIMARY_CONTROLS,
251
+ ...CONFIG_CONTROLS,
252
+ ];
253
+
254
+ // [LAW:one-source-of-truth] Every PLAIN key the settings menu writes — both
255
+ // destinations of every control it mints. Unlike the `settings.` names, these
256
+ // are ordinary words a config can own (`theme`, `padding`, …), so a reader
257
+ // cannot tell from the key alone whether the menu or the author wrote it. This
258
+ // set is the menu's own answer to "which keys do I write", derived from the
259
+ // same records the controls are minted from, so a consumer pairing it with an
260
+ // authorship check (test/helpers/ambient-chrome.ts) can never drift from what
261
+ // the synthesis actually declares.
262
+ export const SETTINGS_WRITTEN_KEYS: ReadonlySet<string> = new Set(
263
+ [...PICKER_CONTROLS, WRAP, PADDING].flatMap((c) => [
264
+ c.sessionKey,
265
+ c.configKey,
266
+ ]),
267
+ );
268
+
269
+ // [LAW:one-source-of-truth] A control's three names, derived from its one
270
+ // name — the segment that shows it, the action its picker applies, and the
271
+ // action its ↺ resets. Derived rather than declared so a control record can
272
+ // never name a segment whose picker writes a different setting.
273
+ const controlSeg = (name: string): string => `${SETTINGS_NS}${name}`;
274
+ const controlApply = (name: string): string => `${SETTINGS_NS}apply.${name}`;
275
+ const controlReset = (name: string): string => `${SETTINGS_NS}reset.${name}`;
93
276
 
94
277
  // [LAW:one-source-of-truth] The predicate the body container gates on, derived
95
278
  // from the same anchor string the toggle's cycle writes — spelled once here,
@@ -204,15 +387,44 @@ function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
204
387
  direction: "vertical",
205
388
  children: [
206
389
  node,
390
+ // Row one: what the menu is FOR — the persist? selector that says
391
+ // where every setting below it lands, the preset switcher, the
392
+ // door into the config menu, and the door into edit mode.
207
393
  {
208
394
  kind: "container",
209
395
  direction: "horizontal",
210
396
  children: [
211
- { kind: "segment", name: PRESETS_SEG },
397
+ { kind: "segment", name: PERSIST_SEG },
398
+ ...PRIMARY_CONTROLS.map(
399
+ (c): LayoutNode => ({
400
+ kind: "segment",
401
+ name: controlSeg(c.name),
402
+ }),
403
+ ),
404
+ { kind: "segment", name: CONFIG_SEG },
212
405
  { kind: "segment", name: EDIT_SEG },
213
406
  ],
214
407
  when: SETTINGS_OPEN_GATE,
215
408
  },
409
+ // Row two: the display settings, behind their own disclosure so
410
+ // the menu opens narrow. Gated on BOTH keys — a config row left
411
+ // open yesterday must not render beside a closed menu today; one
412
+ // gate per disclosure, and this row is inside two of them.
413
+ {
414
+ kind: "container",
415
+ direction: "horizontal",
416
+ children: [
417
+ ...CONFIG_CONTROLS.map(
418
+ (c): LayoutNode => ({
419
+ kind: "segment",
420
+ name: controlSeg(c.name),
421
+ }),
422
+ ),
423
+ { kind: "segment", name: WRAP_SEG },
424
+ { kind: "segment", name: PADDING_SEG },
425
+ ],
426
+ when: CONFIG_OPEN_GATE,
427
+ },
216
428
  ],
217
429
  }
218
430
  : node;
@@ -237,9 +449,14 @@ function declareHostedMenu(
237
449
  segName: string,
238
450
  applyName: string,
239
451
  artifacts: MenuArtifacts,
452
+ // The accordion key the menu shares with its siblings, or undefined for a
453
+ // menu that toggles only itself — the same `key` option `{{ menu }}` takes,
454
+ // threaded here so the synthesized artifacts and the rendered disclosure
455
+ // derive one identity from one value [LAW:one-source-of-truth].
456
+ sharedKey?: string,
240
457
  ): void {
241
458
  const member = menuMember(applyName);
242
- const stateKey = menuStateKey(segName, applyName, undefined);
459
+ const stateKey = menuStateKey(segName, applyName, sharedKey);
243
460
  const pageKey = menuPageKey(stateKey);
244
461
  artifacts.variables[stateKey] = disclosureStateVar(
245
462
  stateKey,
@@ -266,11 +483,15 @@ function settingsArtifacts(): MenuArtifacts {
266
483
  },
267
484
  actions: {
268
485
  [SETTINGS_ANCHOR]: disclosureCycleAction(SETTINGS_ANCHOR, SETTINGS_OPEN),
269
- // [LAW:single-enforcer] The picker's apply effect, gated by derivation
270
- // like every other `from`-sourced set: `presets` is a per-config domain
271
- // both deriveActionValidators and the rendered options resolve through
272
- // one `resolveOptionDomain`, so this adds a control, never a gate.
273
- [APPLY_PRESET_ACTION]: { set: "preset", from: "presets" },
486
+ [CONFIG_SEG]: disclosureCycleAction(CONFIG_SEG, SETTINGS_OPEN),
487
+ // [LAW:one-source-of-truth] The selector is an ordinary session cycle
488
+ // over the one boolean spelling SessionState uses — off first, because
489
+ // an unwritten key counts as the first member and the menu opens in
490
+ // experimentation mode.
491
+ [PERSIST_SEG]: {
492
+ set: PERSIST_KEY,
493
+ cycle: [BOOLEAN_FALSE, BOOLEAN_TRUE],
494
+ },
274
495
  },
275
496
  segments: {
276
497
  // [LAW:representation] The glyph trails the label it gates, per the
@@ -280,8 +501,36 @@ function settingsArtifacts(): MenuArtifacts {
280
501
  bg: "surface",
281
502
  fg: "foreground",
282
503
  },
283
- [PRESETS_SEG]: {
284
- template: `▦ {{ menu "${APPLY_PRESET_ACTION}" (dict "closeOnPick" true) }}`,
504
+ // [LAW:representation] The checkbox states what the NEXT write does,
505
+ // which is why the glyph and the word live together: "☑ persist?" is
506
+ // the whole explanation of where the click below it lands.
507
+ [PERSIST_SEG]: {
508
+ template: `{{ action "${PERSIST_SEG}" "☐ persist?" "☑ persist?" }}`,
509
+ bg: "surface",
510
+ fg: "foreground",
511
+ },
512
+ [CONFIG_SEG]: {
513
+ template: `{{ action "${CONFIG_SEG}" "⚙ config ${DISCLOSURE_GLYPH_CLOSED}" "⚙ config ${DISCLOSURE_GLYPH_OPEN}" }}`,
514
+ bg: "surface",
515
+ fg: "foreground",
516
+ },
517
+ // [LAW:one-type-per-behavior] Both non-picker controls read the same
518
+ // `.effective` projection their picker siblings read, and write the
519
+ // same two stores through the same dual arm — a toggle and a stepper
520
+ // are affordances over one behavior, not two kinds of setting.
521
+ [WRAP_SEG]: {
522
+ template:
523
+ `{{ action "${controlApply("wrap")}" "wrap: on" "wrap: off" }} ` +
524
+ `{{ action "${controlReset("wrap")}" "↺" }}`,
525
+ bg: "surface",
526
+ fg: "foreground",
527
+ },
528
+ [PADDING_SEG]: {
529
+ template:
530
+ `{{ action "${controlApply("padding")}.down" "◀" }} ` +
531
+ "padding {{ .padding.effective }} " +
532
+ `{{ action "${controlApply("padding")}.up" "▶" }} ` +
533
+ `{{ action "${controlReset("padding")}" "↺" }}`,
285
534
  bg: "surface",
286
535
  fg: "foreground",
287
536
  },
@@ -296,10 +545,84 @@ function settingsArtifacts(): MenuArtifacts {
296
545
  },
297
546
  },
298
547
  };
299
- declareHostedMenu(PRESETS_SEG, APPLY_PRESET_ACTION, artifacts);
548
+ artifacts.variables[PERSIST_KEY] = {
549
+ kind: "state",
550
+ key: PERSIST_KEY,
551
+ default: BOOLEAN_FALSE,
552
+ };
553
+ artifacts.variables[CONFIG_SEG] = disclosureStateVar(
554
+ CONFIG_SEG,
555
+ DISCLOSURE_CLOSED,
556
+ );
557
+ declareSettingControls(artifacts);
300
558
  return artifacts;
301
559
  }
302
560
 
561
+ // [LAW:one-source-of-truth] Every setting the menu offers, minted from the one
562
+ // table that describes them. A picker control is a glyph, its live value, a
563
+ // `{{ menu }}` over its domain, and the ↺ that forgets its durable default;
564
+ // wrap and padding differ only in affordance. Every apply action here is DUAL
565
+ // — one declaration naming both destination keys and the selector that chooses
566
+ // between them — so the panel spells each setting exactly once and the click
567
+ // carries the destination as data [LAW:dataflow-not-control-flow].
568
+ //
569
+ // [LAW:single-enforcer] Nothing here declares a gate. `deriveActionValidators`
570
+ // and `deriveConfigActionValidators` each explode these dual declarations
571
+ // (actionDestinations) and derive the same specs they would have derived from
572
+ // the pair of single-destination actions this replaces — so the writable-key
573
+ // surface is byte-for-byte what it was when the drawer spelled both halves.
574
+ function declareSettingControls(artifacts: MenuArtifacts): void {
575
+ for (const c of PICKER_CONTROLS) {
576
+ const seg = controlSeg(c.name);
577
+ const apply = controlApply(c.name);
578
+ artifacts.segments[seg] = {
579
+ template:
580
+ `${c.glyph} {{ .${c.effectiveVar} }} ` +
581
+ `{{ menu "${apply}" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" ` +
582
+ `(dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
583
+ `{{ action "${controlReset(c.name)}" "↺" }}`,
584
+ bg: "surface",
585
+ fg: "foreground",
586
+ };
587
+ artifacts.actions[apply] = {
588
+ set: c.sessionKey,
589
+ persist: c.configKey,
590
+ persistWhen: PERSIST_KEY,
591
+ from: c.domain,
592
+ };
593
+ // [LAW:one-source-of-truth] ↺ clears the DURABLE default only — the one
594
+ // write the user cannot otherwise take back, since a session value dies
595
+ // with the session. Its target is the config key the dual's durable half
596
+ // writes, read from the same record, so the two can never name different
597
+ // settings.
598
+ artifacts.actions[controlReset(c.name)] = { reset: c.configKey };
599
+ declareHostedMenu(seg, apply, artifacts, PICKER_KEY);
600
+ }
601
+ artifacts.actions[controlApply(WRAP.name)] = {
602
+ set: WRAP.sessionKey,
603
+ persist: WRAP.configKey,
604
+ persistWhen: PERSIST_KEY,
605
+ cycle: [...BOOLEAN_MEMBERS],
606
+ };
607
+ artifacts.actions[controlReset(WRAP.name)] = { reset: WRAP.configKey };
608
+ // [LAW:one-source-of-truth] The stepper's bounds are PADDING_RANGE, the same
609
+ // range the loader validates a config-file `padding` against and the same one
610
+ // both write gates enforce — a click can never reach a value the file could
611
+ // not have held.
612
+ for (const by of [-1, 1]) {
613
+ artifacts.actions[
614
+ `${controlApply(PADDING.name)}.${by < 0 ? "down" : "up"}`
615
+ ] = {
616
+ set: PADDING.sessionKey,
617
+ persist: PADDING.configKey,
618
+ persistWhen: PERSIST_KEY,
619
+ ...PADDING_RANGE,
620
+ by,
621
+ };
622
+ }
623
+ artifacts.actions[controlReset(PADDING.name)] = { reset: PADDING.configKey };
624
+ }
625
+
303
626
  // [LAW:one-source-of-truth] Edit mode's toggle, ensured rather than duplicated:
304
627
  // both this pass and synthesizeEditModeToggle produce it by calling the same two
305
628
  // disclosure functions on the same two exported constants, so the two mints are
@@ -10,14 +10,14 @@
10
10
  // goal, realized more strictly here than SessionState's legacy baseline
11
11
  // theme/style/toolbar-expanded keys.
12
12
 
13
- import type { ActionDecl } from "../../config/action";
13
+ import { actionDestinations, type ActionDecl } from "../../config/action";
14
14
  import {
15
15
  perConfigDomainsFor,
16
16
  resolveOptionDomain,
17
17
  } from "../../config/option-domain";
18
18
  import { addableSegmentDomains } from "../../config/edit-chrome";
19
19
  import type { DslConfig } from "../../config/dsl-types";
20
- import { isGlobalsField } from "../config-overrides-store";
20
+ import { numericGlobalsSeeds } from "../../config/loader/globals";
21
21
  import { encodeLayoutOp } from "../../config/layout-ops";
22
22
  import {
23
23
  parsePersistTarget,
@@ -163,13 +163,7 @@ function actionKeySpecs(
163
163
  // applied. Mirrors stateKeySeeds' "the bar's current display, not silently
164
164
  // min" rule for SessionState steppers.
165
165
  function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
166
- const seeds = new Map<string, number>();
167
- for (const [key, value] of Object.entries(config.globals)) {
168
- if (isGlobalsField(key) && typeof value === "number") {
169
- seeds.set(key, value);
170
- }
171
- }
172
- return seeds;
166
+ return numericGlobalsSeeds(config.globals);
173
167
  }
174
168
 
175
169
  // [LAW:one-source-of-truth] Every preset a config's action table ALREADY
@@ -197,7 +191,7 @@ function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
197
191
  // SOME action names it).
198
192
  function presetRootOpsContributions(config: DslConfig): KeySpecContribution[] {
199
193
  const presets = new Set<string>();
200
- for (const a of Object.values(config.actions)) {
194
+ for (const a of writeDestinations(config)) {
201
195
  const key = "persist" in a ? a.persist : "reset" in a ? a.reset : null;
202
196
  if (key === null) continue;
203
197
  const target = parsePersistTarget(key);
@@ -223,12 +217,21 @@ function actionContributions(config: DslConfig): KeySpecContribution[] {
223
217
  ]);
224
218
  return [
225
219
  ...presetRootOpsContributions(config),
226
- ...Object.values(config.actions).flatMap((a) =>
220
+ ...writeDestinations(config).flatMap((a) =>
227
221
  actionKeySpecs(a, seeds, perConfigDomains),
228
222
  ),
229
223
  ];
230
224
  }
231
225
 
226
+ // [LAW:single-enforcer] Every action as the single-destination declarations it
227
+ // writes through — the SAME explosion state-validators.ts folds over, so a
228
+ // dual-destination action (candybar-settings-ui-aok.3) contributes exactly the
229
+ // `persist` spec its durable half would have contributed alone. One statement
230
+ // of "what are this action's destinations", two derivations reading it.
231
+ function writeDestinations(config: DslConfig): readonly ActionDecl[] {
232
+ return Object.values(config.actions).flatMap(actionDestinations);
233
+ }
234
+
232
235
  // [LAW:single-enforcer] The SOLE install-site derivation: a config's
233
236
  // persistent-config-writable-key surface is the merge of every `persist`
234
237
  // ACTION it declares, through the SAME coherence pass deriveActionValidators
@@ -297,6 +297,40 @@ function wrapStep(n: number, min: number, max: number): number {
297
297
  return n > max ? min : n < min ? max : n;
298
298
  }
299
299
 
300
+ // [LAW:no-ambient-temporal-coupling] The RELEASE half of a durable write, run
301
+ // by the durable handlers themselves AFTER their own write succeeded — never
302
+ // as a separate effect beside them.
303
+ //
304
+ // A dual-destination control commits "make this the durable default AND stop
305
+ // overriding it in this session". Those are one intent, and the session half
306
+ // is destructive: dropping the session pick is only correct if the durable
307
+ // value actually landed. Emitted as two effects, `dispatch` would run the
308
+ // clear even when the persist failed (it runs every effect in a click by
309
+ // design, for independent ones like "write value + close menu") — wiping the
310
+ // user's pick with nothing durable in its place, a lost update whose error
311
+ // message would not even mention it. Ordering that matters belongs inside one
312
+ // handler, not in a hope about the dispatcher.
313
+ //
314
+ // Gated by key MEMBERSHIP (listStateKeys), exactly as reset-config is over the
315
+ // config keyspace: there is no value to validate, only a legitimate target to
316
+ // clear. Absent segment = nothing to release, which is every ordinary persist
317
+ // click [LAW:dataflow-not-control-flow].
318
+ function releaseSessionKey(
319
+ release: string,
320
+ sid: string,
321
+ ctx: VerbContext,
322
+ verb: string,
323
+ ): void {
324
+ if (!release) return;
325
+ if (!listStateKeys().includes(release)) {
326
+ throw new BadVerbArgs(
327
+ `${verb}: unknown session key "${release}" to release (have: ${listStateKeys().join(", ")})`,
328
+ );
329
+ }
330
+ ctx.sessionState.clear(sid, release);
331
+ ctx.dlog("info", `${verb}: released session key ${release} (session=${sid})`);
332
+ }
333
+
300
334
  // [LAW:one-source-of-truth] A RELATIVE nudge to a bounded state key. The link
301
335
  // carries ONLY the irreducible intent `[sessionId, key, by]` (no `current`
302
336
  // snapshot), so the SAME link string fires every render and N rapid clicks each
@@ -374,8 +408,8 @@ function assertGlobalsField(key: string): asserts key is keyof Globals {
374
408
  // BAD_REQUEST — the SAME gate `set-state` uses (validateConfigWrite),
375
409
  // derived from the SAME action table (deriveConfigActionValidators).
376
410
  const setConfig: VerbHandler = (rawValue, ctx) => {
377
- const [sessionId = "", key = "", incoming = ""] = decodeWire(() =>
378
- decodeSegments(rawValue),
411
+ const [sessionId = "", key = "", incoming = "", release = ""] = decodeWire(
412
+ () => decodeSegments(rawValue),
379
413
  );
380
414
  const sid = requireSessionId(sessionId);
381
415
  if (!key) {
@@ -388,6 +422,7 @@ const setConfig: VerbHandler = (rawValue, ctx) => {
388
422
  const typed = coercePersistValue(key, result.value);
389
423
  writeConfigOverride(configOverridesPath(), key, typed, ctx.dlog);
390
424
  ctx.dlog("info", `set-config: ${key}=${result.value} (session=${sid})`);
425
+ releaseSessionKey(release, sid, ctx, "set-config");
391
426
  };
392
427
 
393
428
  // [LAW:one-source-of-truth] `persist`'s twin of stepState: a RELATIVE nudge
@@ -395,7 +430,7 @@ const setConfig: VerbHandler = (rawValue, ctx) => {
395
430
  // unset — rangeParamsForConfig's seed), wrapped and re-validated through the
396
431
  // SAME range gate, then written durably.
397
432
  const stepConfig: VerbHandler = (rawValue, ctx) => {
398
- const [sessionId = "", key = "", byRaw = ""] = decodeWire(() =>
433
+ const [sessionId = "", key = "", byRaw = "", release = ""] = decodeWire(() =>
399
434
  decodeSegments(rawValue),
400
435
  );
401
436
  const sid = requireSessionId(sessionId);
@@ -433,6 +468,7 @@ const stepConfig: VerbHandler = (rawValue, ctx) => {
433
468
  "info",
434
469
  `step-config: ${key} ${current}→${result.value} (by ${by}, session=${sid})`,
435
470
  );
471
+ releaseSessionKey(release, sid, ctx, "step-config");
436
472
  };
437
473
 
438
474
  // [LAW:one-source-of-truth] The gated undo for `persist`: clears one
@@ -16,12 +16,13 @@
16
16
  // `persist` action's keyspace) — two keyspaces, one mechanism.
17
17
 
18
18
  import { listResolvablePaletteNames, STRIP_STYLES } from "../../themes/policy";
19
- import type { ActionDecl } from "../../config/action";
19
+ import { actionDestinations, type ActionDecl } from "../../config/action";
20
20
  import {
21
21
  perConfigDomainsFor,
22
22
  resolveOptionDomain,
23
23
  } from "../../config/option-domain";
24
24
  import type { DslConfig } from "../../config/dsl-types";
25
+ import { numericGlobalsSeeds } from "../../config/loader/globals";
25
26
  import {
26
27
  clampSeed,
27
28
  createValidatorRegistry,
@@ -191,10 +192,18 @@ function dropBaselineAllowLists(
191
192
  );
192
193
  }
193
194
 
194
- // [LAW:one-source-of-truth] Each `state` variable's integer `default` is the
195
- // initial value of its key — the value the bar renders before any click. The
196
- // step-state handler must seed an unset key from the SAME number, so the
197
- // derived range spec carries it.
195
+ // [LAW:one-source-of-truth] The value a bounded key renders with before any
196
+ // click, from the two places that can define it: a `state` variable's integer
197
+ // `default` (the only source for a key of the config's own invention, like a
198
+ // hue stepper), and — winning for the fields it covers — what the config
199
+ // resolves for a GLOBALS field, since a session stepper over `padding` starts
200
+ // from the padding the bar is showing, not from a state var nobody declared.
201
+ //
202
+ // The globals half is the SAME function the config-overrides gate seeds from
203
+ // (numericGlobalsSeeds), so a session stepper and its durable twin cannot
204
+ // start from different numbers. Before it existed, the settings menu's session
205
+ // padding stepper seeded from `min`: a bar reading `padding 1` answered its
206
+ // first ◀ by wrapping to 16.
198
207
  function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
199
208
  const seeds = new Map<string, number>();
200
209
  const INT_RE = /^-?\d+$/;
@@ -205,6 +214,9 @@ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
205
214
  seeds.set(decl.key, parseInt(raw, 10));
206
215
  }
207
216
  }
217
+ for (const [key, seed] of numericGlobalsSeeds(config.globals)) {
218
+ seeds.set(key, seed);
219
+ }
208
220
  return seeds;
209
221
  }
210
222
 
@@ -214,10 +226,16 @@ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
214
226
  function actionContributions(config: DslConfig): KeySpecContribution[] {
215
227
  const seeds = stateKeySeeds(config);
216
228
  const perConfigDomains = perConfigDomainsFor(config);
229
+ // [LAW:single-enforcer] Every action is exploded into the
230
+ // single-destination declarations it writes through BEFORE the fold, so a
231
+ // dual-destination action (candybar-settings-ui-aok.3) contributes exactly
232
+ // the `set` spec its session half would have contributed on its own — the
233
+ // gate is derived by the code that has always derived it, from the same
234
+ // declaration the click realizes, and a dual can widen nothing.
217
235
  return dropBaselineAllowLists(
218
- Object.values(config.actions).flatMap((a) =>
219
- actionKeySpecs(a, seeds, perConfigDomains),
220
- ),
236
+ Object.values(config.actions)
237
+ .flatMap(actionDestinations)
238
+ .flatMap((a) => actionKeySpecs(a, seeds, perConfigDomains)),
221
239
  );
222
240
  }
223
241
 
package/src/help-text.ts CHANGED
@@ -23,9 +23,12 @@ Configuration:
23
23
  to point at a specific file. See the default config for all available options:
24
24
  node dist/index.mjs debug --project-dir . --cwd .
25
25
 
26
- The bundled default bar ships a settings drawer — no config needed. Click
27
- ⚙ settings ${DISCLOSURE_GLYPH_CLOSED} on the bar to reveal a clickable theme/look picker and
28
- other display options.
26
+ Every bar carries a settings menu, whatever your config says — no config
27
+ needed, and writing your own \`root\` cannot delete it. Click
28
+ ☰ ${DISCLOSURE_GLYPH_CLOSED} on the bar for preset switching, edit mode, and a config menu
29
+ of clickable theme/look/style/wrap/padding controls. The \`persist?\`
30
+ checkbox there chooses where a change lands: unchecked it applies to this
31
+ session only, checked it becomes the default every session opens with.
29
32
 
30
33
  Subcommands:
31
34
  install One-shot setup: stages the runtime (native render
@@ -451,8 +451,8 @@ function installSuccessMessage(): string {
451
451
  return (
452
452
  `✓ install complete.\n` +
453
453
  ` Restart Claude Code to pick up the new statusline.\n` +
454
- ` Tip: the default bar has a settings drawer — click ⚙ settings\n` +
455
- ` ${DISCLOSURE_GLYPH_CLOSED} to reveal a clickable theme/look picker and other display options.\n`
454
+ ` Tip: every bar carries a settings menu — click ☰ ${DISCLOSURE_GLYPH_CLOSED} for preset\n` +
455
+ ` switching, edit mode, and clickable theme/look/style/wrap/padding controls.\n`
456
456
  );
457
457
  }
458
458