@promptctl/cc-candybar 1.36.0 → 1.38.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,83 @@ 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}" (dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
582
+ `{{ action "${controlReset(c.name)}" "↺" }}`,
583
+ bg: "surface",
584
+ fg: "foreground",
585
+ };
586
+ artifacts.actions[apply] = {
587
+ set: c.sessionKey,
588
+ persist: c.configKey,
589
+ persistWhen: PERSIST_KEY,
590
+ from: c.domain,
591
+ };
592
+ // [LAW:one-source-of-truth] ↺ clears the DURABLE default only — the one
593
+ // write the user cannot otherwise take back, since a session value dies
594
+ // with the session. Its target is the config key the dual's durable half
595
+ // writes, read from the same record, so the two can never name different
596
+ // settings.
597
+ artifacts.actions[controlReset(c.name)] = { reset: c.configKey };
598
+ declareHostedMenu(seg, apply, artifacts, PICKER_KEY);
599
+ }
600
+ artifacts.actions[controlApply(WRAP.name)] = {
601
+ set: WRAP.sessionKey,
602
+ persist: WRAP.configKey,
603
+ persistWhen: PERSIST_KEY,
604
+ cycle: [...BOOLEAN_MEMBERS],
605
+ };
606
+ artifacts.actions[controlReset(WRAP.name)] = { reset: WRAP.configKey };
607
+ // [LAW:one-source-of-truth] The stepper's bounds are PADDING_RANGE, the same
608
+ // range the loader validates a config-file `padding` against and the same one
609
+ // both write gates enforce — a click can never reach a value the file could
610
+ // not have held.
611
+ for (const by of [-1, 1]) {
612
+ artifacts.actions[
613
+ `${controlApply(PADDING.name)}.${by < 0 ? "down" : "up"}`
614
+ ] = {
615
+ set: PADDING.sessionKey,
616
+ persist: PADDING.configKey,
617
+ persistWhen: PERSIST_KEY,
618
+ ...PADDING_RANGE,
619
+ by,
620
+ };
621
+ }
622
+ artifacts.actions[controlReset(PADDING.name)] = { reset: PADDING.configKey };
623
+ }
624
+
303
625
  // [LAW:one-source-of-truth] Edit mode's toggle, ensured rather than duplicated:
304
626
  // both this pass and synthesizeEditModeToggle produce it by calling the same two
305
627
  // disclosure functions on the same two exported constants, so the two mints are
@@ -48,11 +48,13 @@ import type {
48
48
  // BuildLineOptions), so the value a trigger label displays and the value
49
49
  // that actually shaped the render can never disagree — the same reasoning
50
50
  // theme/look already followed, generalized to every globals field a menu or
51
- // stepper can persist. `theme`/`look`/`style` compose SessionState over the
52
- // config default (a session pick can diverge from the persisted default for
53
- // its own session); `charset`/`colorCompatibility`/`autoWrap`/`padding` have
54
- // no SessionState half today, so their "effective" value is just the
55
- // resolved config global over its floor constant.
51
+ // stepper can persist. `theme`/`look`/`style`/`autoWrap`/`padding` compose
52
+ // SessionState over the config default (a session pick can diverge from the
53
+ // persisted default for its own session); `charset` and `colorCompatibility`
54
+ // have no SessionState half — they describe the terminal (glyph coverage,
55
+ // colour depth) rather than a per-session taste, so the resolved config global
56
+ // over its floor constant is their whole resolution. See CHARSETS in
57
+ // themes/policy.ts for why that is a decision rather than a gap.
56
58
  export interface EffectiveGlobals {
57
59
  readonly theme: string;
58
60
  readonly look: string;
@@ -65,6 +65,8 @@ import { expandHome } from "../config/dsl-loader.js";
65
65
  import { renderDsl } from "../dsl/render.js";
66
66
  import {
67
67
  effectiveStripStyle,
68
+ effectiveAutoWrap,
69
+ effectivePadding,
68
70
  effectiveThemeName,
69
71
  effectiveLookName,
70
72
  lookKeyByName,
@@ -867,11 +869,12 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
867
869
  //
868
870
  // [LAW:one-source-of-truth] Every globals field a menu/stepper can
869
871
  // persist (candybar-config-engine-71o.3), resolved ONCE into one
870
- // struct: theme/look/style compose the session's click (SessionState)
871
- // over the config default over a floor — a click recolors/reshapes the
872
- // whole bar on the next render; charset/colorCompatibility/autoWrap/
873
- // padding have no SessionState half today, so their "effective" value
874
- // is just the config global over its floor constant. This struct feeds
872
+ // struct: theme/look/style/autoWrap/padding compose the session's click
873
+ // (SessionState) over the config default over a floor — a click
874
+ // recolors/reshapes the whole bar on the next render; charset and
875
+ // colorCompatibility describe the TERMINAL rather than a taste and so
876
+ // have no SessionState half at all, making the config global over its
877
+ // floor constant their whole resolution. This struct feeds
875
878
  // BOTH the payload's `*.effective` fields (trigger labels) AND
876
879
  // renderOpts below (the actual render) — one resolution, two readers,
877
880
  // so a label can never disagree with what was rendered.
@@ -918,8 +921,18 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
918
921
  sessionState.get(req.hookData.session_id, "style"),
919
922
  globals.style,
920
923
  ),
921
- autoWrap: globals.autoWrap ?? DEFAULT_WRAP,
922
- padding: globals.padding ?? DEFAULT_PADDING,
924
+ autoWrap: effectiveAutoWrap(
925
+ sessionState.get(req.hookData.session_id, "autoWrap"),
926
+ globals.autoWrap,
927
+ ),
928
+ padding: effectivePadding(
929
+ sessionState.get(req.hookData.session_id, "padding"),
930
+ globals.padding,
931
+ ),
932
+ // charset and colorCompatibility have no session half by design —
933
+ // they describe the terminal, not a taste (see CHARSETS in
934
+ // themes/policy.ts) — so the config global over its floor is their
935
+ // whole resolution.
923
936
  charset: globals.charset ?? DEFAULT_CHARSET,
924
937
  colorCompatibility:
925
938
  globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
@@ -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