@promptctl/cc-candybar 1.37.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.
@@ -25,10 +25,15 @@ import type { FuncMap, Template } from "@promptctl/go-template-js";
25
25
  import type { VariableStore } from "../var-system/store.js";
26
26
  import { toString as varToString } from "../var-system/types.js";
27
27
  import { buildScope } from "../template-engine/scope.js";
28
- import type { ActionDecl } from "../config/action.js";
28
+ import {
29
+ actionDestinations,
30
+ actionIsDual,
31
+ PERSIST_WHEN,
32
+ type ActionDecl,
33
+ } from "../config/action.js";
29
34
  import { resolveOptionDomain } from "../config/option-domain.js";
30
35
  import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
31
- import type { StripStyle } from "../themes/policy.js";
36
+ import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
32
37
  import {
33
38
  effectsUrl,
34
39
  VERB_APPLY_LAYOUT_OP,
@@ -166,7 +171,24 @@ export type CompiledActionDecl =
166
171
  // there is nothing to carry, since the history stack (not this action) is
167
172
  // what decides which entry moves.
168
173
  | { readonly kind: "undo" }
169
- | { readonly kind: "redo" };
174
+ | { readonly kind: "redo" }
175
+ // [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.3's ONE control
176
+ // per setting. Both destinations are compiled here as the ordinary
177
+ // single-destination shapes they are, and `selector` names the session key
178
+ // whose boolean value picks between them at click time. The destination is
179
+ // therefore a VALUE flowing through `activeDestination` — every consumer
180
+ // (realize, the picker, selectDisplay) resolves it once at the top and then
181
+ // runs the code it has always run, so nothing downstream branches on
182
+ // "is this dual".
183
+ | {
184
+ readonly kind: "dual";
185
+ readonly selector: string;
186
+ readonly session: CompiledActionDecl;
187
+ readonly durable: CompiledActionDecl;
188
+ // The SessionState key the session half writes, carried so a durable
189
+ // click can clear it in the same dispatch (see realize's dual arm).
190
+ readonly sessionKey: string;
191
+ };
170
192
 
171
193
  export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
172
194
 
@@ -185,6 +207,13 @@ export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
185
207
  // highlight is inert (readVar falls back to "" since no such var exists).
186
208
  const CONFIG_KEY_TO_EFFECTIVE_VAR: ReadonlyMap<string, string> = new Map([
187
209
  ["palette", "theme.effective"],
210
+ // [LAW:one-source-of-truth] `preset` earns its entry here the moment a DUAL
211
+ // control writes it: compileDual makes BOTH halves read back through this
212
+ // map, so a field missing from it loses its current-selection mark on the
213
+ // session side too — and the preset picker sits on the settings menu's
214
+ // always-visible first row, where "which arrangement am I in" is the whole
215
+ // question the control answers.
216
+ ["preset", "preset.effective"],
188
217
  ["look", "look.effective"],
189
218
  ["style", "style.effective"],
190
219
  ["charset", "charset.effective"],
@@ -263,6 +292,20 @@ function compileAction(
263
292
  stateKeyToVar: ReadonlyMap<string, string>,
264
293
  perConfigDomains: ReadonlyMap<string, readonly string[]>,
265
294
  ): CompiledActionDecl {
295
+ // [LAW:one-source-of-truth] A dual compiles as its own two destinations —
296
+ // the SAME explosion the validator derivations fold over
297
+ // (actionDestinations), so the click a dual realizes and the gate it derives
298
+ // come from one statement of what the two halves are. It is matched BEFORE
299
+ // the `set` arm because a dual carries `set` too.
300
+ if (actionIsDual(action)) {
301
+ const [session, durable] = actionDestinations(action);
302
+ return compileDual(
303
+ stateKeyToVar.get(action[PERSIST_WHEN]) ?? action[PERSIST_WHEN],
304
+ action.set,
305
+ compileAction(parse, name, session!, stateKeyToVar, perConfigDomains),
306
+ compileAction(parse, name, durable!, stateKeyToVar, perConfigDomains),
307
+ );
308
+ }
266
309
  if ("set" in action) {
267
310
  const stateVar = stateKeyToVar.get(action.set) ?? action.set;
268
311
  if ("to" in action) {
@@ -379,6 +422,55 @@ function compileAction(
379
422
  return "undo" in action ? { kind: "undo" } : { kind: "redo" };
380
423
  }
381
424
 
425
+ // [LAW:one-source-of-truth] A dual control shows ONE current value and writes
426
+ // relative to the value it showed — so both destinations read back through the
427
+ // DURABLE half's variable, which is the `.effective` projection the daemon
428
+ // resolved for this render (CONFIG_KEY_TO_EFFECTIVE_VAR above): the value the
429
+ // bar is actually rendering with, whatever chain produced it. Reading the
430
+ // session key instead would let a cycle's glyph name the effective state while
431
+ // its click stepped from an unwritten session key — the toggle would render
432
+ // "wrap: off" and write "false", a click that visibly does nothing. Arms that
433
+ // carry no `stateVar` (the bounded steppers) read nothing at render by design:
434
+ // their step is relative and resolved daemon-side.
435
+ function compileDual(
436
+ selectorVar: string,
437
+ sessionKey: string,
438
+ session: CompiledActionDecl,
439
+ durable: CompiledActionDecl,
440
+ ): CompiledActionDecl {
441
+ const readBack =
442
+ "stateVar" in session && "stateVar" in durable
443
+ ? { ...session, stateVar: durable.stateVar }
444
+ : session;
445
+ return {
446
+ kind: "dual",
447
+ selector: selectorVar,
448
+ session: readBack,
449
+ durable,
450
+ sessionKey,
451
+ };
452
+ }
453
+
454
+ // [LAW:dataflow-not-control-flow] THE destination fold: which store a dual
455
+ // action writes is the boolean value of its selector key, read from the same
456
+ // live store the rest of the render reads. Total over every compiled action —
457
+ // a single-destination action IS its own destination — so callers resolve
458
+ // through it unconditionally and never test for the dual kind.
459
+ //
460
+ // [LAW:one-source-of-truth] `parseSessionBoolean` is the one spelling of a
461
+ // boolean in SessionState (themes/policy.ts), the same parse `autoWrap`'s own
462
+ // session half goes through: an unwritten, malformed, or "false" selector all
463
+ // mean the session destination, and only a canonical "true" means durable.
464
+ export function activeDestination(
465
+ c: CompiledActionDecl,
466
+ store: VariableStore,
467
+ ): CompiledActionDecl {
468
+ if (c.kind !== "dual") return c;
469
+ return parseSessionBoolean(readVar(store, c.selector)) === true
470
+ ? c.durable
471
+ : c.session;
472
+ }
473
+
382
474
  function parseActionTemplate(
383
475
  parse: (src: string) => Template<RichText>,
384
476
  src: string,
@@ -463,18 +555,18 @@ function cycleIndex(
463
555
  // cell of an option picker, is pure waste). set-* arms read individual vars
464
556
  // directly. This is data locality, not a control-flow guard: the scope simply
465
557
  // flows into the arms that need it.
466
- function realize(
558
+ export function realize(
467
559
  c: CompiledActionDecl,
468
560
  display: string,
469
561
  boundValue: string | undefined,
470
562
  store: VariableStore,
471
563
  sessionId: string,
472
- ): { effect: Effect; active: boolean } {
564
+ ): { effects: readonly Effect[]; active: boolean } {
473
565
  switch (c.kind) {
474
566
  case "set-literal": {
475
567
  const current = readVar(store, c.stateVar);
476
568
  return {
477
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] },
569
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] }],
478
570
  active: current === c.value,
479
571
  };
480
572
  }
@@ -482,7 +574,7 @@ function realize(
482
574
  const value = boundValue ?? display;
483
575
  const current = readVar(store, c.stateVar);
484
576
  return {
485
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, value] },
577
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
486
578
  active: current === value,
487
579
  };
488
580
  }
@@ -499,7 +591,7 @@ function realize(
499
591
  const value = boundValue ?? display;
500
592
  const current = readVar(store, c.stateVar);
501
593
  return {
502
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, value] },
594
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
503
595
  active: current === value,
504
596
  };
505
597
  }
@@ -510,7 +602,7 @@ function realize(
510
602
  // promised.
511
603
  const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
512
604
  return {
513
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, next] },
605
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, next] }],
514
606
  active: false,
515
607
  };
516
608
  }
@@ -522,27 +614,33 @@ function realize(
522
614
  // single range gate. So the link is byte-identical across renders and N
523
615
  // rapid clicks each accumulate (the idempotent absolute-write bug is gone).
524
616
  return {
525
- effect: {
526
- verb: VERB_STEP_STATE,
527
- args: [sessionId, c.key, String(c.by)],
528
- },
617
+ effects: [
618
+ {
619
+ verb: VERB_STEP_STATE,
620
+ args: [sessionId, c.key, String(c.by)],
621
+ },
622
+ ],
529
623
  active: false,
530
624
  };
531
625
  }
532
626
  case "copy":
533
627
  return {
534
- effect: {
535
- verb: VERB_COPY,
536
- args: [evalTemplate(c.text, buildScope(store))],
537
- },
628
+ effects: [
629
+ {
630
+ verb: VERB_COPY,
631
+ args: [evalTemplate(c.text, buildScope(store))],
632
+ },
633
+ ],
538
634
  active: false,
539
635
  };
540
636
  case "open":
541
637
  return {
542
- effect: {
543
- verb: VERB_OPEN_VSCODE,
544
- args: [evalTemplate(c.target, buildScope(store))],
545
- },
638
+ effects: [
639
+ {
640
+ verb: VERB_OPEN_VSCODE,
641
+ args: [evalTemplate(c.target, buildScope(store))],
642
+ },
643
+ ],
546
644
  active: false,
547
645
  };
548
646
  // [LAW:one-source-of-truth] The persist-* arms mirror set-*'s realization
@@ -553,7 +651,7 @@ function realize(
553
651
  case "persist-literal": {
554
652
  const current = readVar(store, c.stateVar);
555
653
  return {
556
- effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] },
654
+ effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] }],
557
655
  active: current === c.value,
558
656
  };
559
657
  }
@@ -561,29 +659,31 @@ function realize(
561
659
  const value = boundValue ?? display;
562
660
  const current = readVar(store, c.stateVar);
563
661
  return {
564
- effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] },
662
+ effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] }],
565
663
  active: current === value,
566
664
  };
567
665
  }
568
666
  case "persist-cycle": {
569
667
  const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
570
668
  return {
571
- effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] },
669
+ effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] }],
572
670
  active: false,
573
671
  };
574
672
  }
575
673
  case "persist-bounded": {
576
674
  return {
577
- effect: {
578
- verb: VERB_STEP_CONFIG,
579
- args: [sessionId, c.key, String(c.by)],
580
- },
675
+ effects: [
676
+ {
677
+ verb: VERB_STEP_CONFIG,
678
+ args: [sessionId, c.key, String(c.by)],
679
+ },
680
+ ],
581
681
  active: false,
582
682
  };
583
683
  }
584
684
  case "reset":
585
685
  return {
586
- effect: { verb: VERB_RESET_CONFIG, args: [sessionId, c.key] },
686
+ effects: [{ verb: VERB_RESET_CONFIG, args: [sessionId, c.key] }],
587
687
  active: false,
588
688
  };
589
689
  // [LAW:one-source-of-truth] No key to carry — the click just says "step
@@ -592,12 +692,12 @@ function realize(
592
692
  // a history step is a one-shot trigger, not a current-selection toggle.
593
693
  case "undo":
594
694
  return {
595
- effect: { verb: VERB_UNDO, args: [sessionId] },
695
+ effects: [{ verb: VERB_UNDO, args: [sessionId] }],
596
696
  active: false,
597
697
  };
598
698
  case "redo":
599
699
  return {
600
- effect: { verb: VERB_REDO, args: [sessionId] },
700
+ effects: [{ verb: VERB_REDO, args: [sessionId] }],
601
701
  active: false,
602
702
  };
603
703
  // [LAW:one-source-of-truth] The op is fixed at compile time (see
@@ -608,10 +708,12 @@ function realize(
608
708
  // trigger, not a current-selection toggle.
609
709
  case "layout-op":
610
710
  return {
611
- effect: {
612
- verb: VERB_APPLY_LAYOUT_OP,
613
- args: [sessionId, c.key, encodeLayoutOp(c.op)],
614
- },
711
+ effects: [
712
+ {
713
+ verb: VERB_APPLY_LAYOUT_OP,
714
+ args: [sessionId, c.key, encodeLayoutOp(c.op)],
715
+ },
716
+ ],
615
717
  active: false,
616
718
  };
617
719
  // [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
@@ -620,6 +722,46 @@ function realize(
620
722
  // emits, so the daemon's apply-layout-op handler and undo/redo need no
621
723
  // knowledge of where the segment name came from. Never "active": a
622
724
  // structural edit is a one-shot trigger, not a current-selection toggle.
725
+ // [LAW:dataflow-not-control-flow] The destination is resolved to a value
726
+ // and the SAME fold runs on it — a dual's realization is its chosen
727
+ // half's realization, with nothing about persistence duplicated here.
728
+ // Depth is structurally one: a dual's halves are the single-destination
729
+ // decls actionDestinations built, which can never be dual themselves.
730
+ //
731
+ // [LAW:no-silent-failure] A DURABLE click carries the session key to
732
+ // RELEASE as a trailing arg on its own write, so the daemon drops it only
733
+ // after that write succeeded. Without the release the write would be
734
+ // invisible to the session that made it — every settable global resolves
735
+ // session pick OVER durable default, so the workflow this menu invites
736
+ // ("try it here, then tick persist? to commit it") would set a default the
737
+ // user cannot see and leave the control dead for the rest of the session.
738
+ // Riding the write rather than sitting beside it is what makes the pair
739
+ // unsplittable: a click runs every effect it carries, so a rejected write
740
+ // must not be able to drop the pick on its own.
741
+ case "dual": {
742
+ const chosen = activeDestination(c, store);
743
+ const { effects, active } = realize(
744
+ chosen,
745
+ display,
746
+ boundValue,
747
+ store,
748
+ sessionId,
749
+ );
750
+ // The durable write carries the session key to RELEASE as one more
751
+ // segment on itself, so the daemon clears it only after its own write
752
+ // succeeded. A second effect beside it would not do: `dispatch` runs
753
+ // every effect in a click by design, so a rejected write would still
754
+ // wipe the session pick and leave nothing durable in its place.
755
+ return chosen === c.durable
756
+ ? {
757
+ effects: effects.map((e) => ({
758
+ ...e,
759
+ args: [...e.args, c.sessionKey],
760
+ })),
761
+ active,
762
+ }
763
+ : { effects, active };
764
+ }
623
765
  case "layout-op-option": {
624
766
  const segment = boundValue ?? display;
625
767
  const op: LayoutOp = {
@@ -629,10 +771,12 @@ function realize(
629
771
  relation: c.relation,
630
772
  };
631
773
  return {
632
- effect: {
633
- verb: VERB_APPLY_LAYOUT_OP,
634
- args: [sessionId, c.key, encodeLayoutOp(op)],
635
- },
774
+ effects: [
775
+ {
776
+ verb: VERB_APPLY_LAYOUT_OP,
777
+ args: [sessionId, c.key, encodeLayoutOp(op)],
778
+ },
779
+ ],
636
780
  active: false,
637
781
  };
638
782
  }
@@ -682,24 +826,34 @@ export function renderAction(
682
826
  displays: readonly string[],
683
827
  runtime: ActionRuntime,
684
828
  ): RichText {
685
- const action = runtime.compiled.get(name);
829
+ const declared = runtime.compiled.get(name);
686
830
  // [LAW:no-defensive-null-guards] The loader validates every `{{ action "x" }}`
687
831
  // reference resolves to a declared action, and compileActions compiled every
688
832
  // declared action for THIS config's engine. A miss is a caller/wiring bug.
689
- if (!action) {
833
+ if (!declared) {
690
834
  throw new Error(`action "${name}" is not declared in this config`);
691
835
  }
692
836
  const store = runtime.store;
693
- const { display, boundValue } = selectDisplay(name, action, displays, store);
837
+ // [LAW:dataflow-not-control-flow] DISPLAY selection reads the resolved half
838
+ // (a cycle's glyph is the current member's, whichever store it will write),
839
+ // while REALIZATION is handed the declaration itself — a dual realizes as
840
+ // its chosen half PLUS the session clear that keeps a durable write visible,
841
+ // and that pairing belongs to the one fold that owns the union.
842
+ const { display, boundValue } = selectDisplay(
843
+ name,
844
+ activeDestination(declared, store),
845
+ displays,
846
+ store,
847
+ );
694
848
  const sessionId = readVar(store, "session.id");
695
- const { effect, active } = realize(
696
- action,
849
+ const { effects, active } = realize(
850
+ declared,
697
851
  display,
698
852
  boundValue,
699
853
  store,
700
854
  sessionId,
701
855
  );
702
- return linkFragment(display, effectsUrl([effect]), active);
856
+ return linkFragment(display, effectsUrl(effects), active);
703
857
  }
704
858
 
705
859
  // ─── FuncMap entry ─────────────────────────────────────────────────────────────
@@ -28,15 +28,11 @@ import type { FuncMap } from "@promptctl/go-template-js";
28
28
  import { toNumber } from "../var-system/types.js";
29
29
  import { stripChromeCols } from "./strip.js";
30
30
  import { TERM_COLS_VAR } from "../config/dsl-types.js";
31
+ import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
31
32
  import {
32
- effectsUrl,
33
- VERB_APPLY_LAYOUT_OP,
34
- VERB_SET_CONFIG,
35
- VERB_SET_STATE,
36
- } from "../click/wire.js";
37
- import { encodeLayoutOp } from "../config/layout-ops.js";
38
- import {
33
+ activeDestination,
39
34
  linkFragment,
35
+ realize,
40
36
  readVar,
41
37
  type ActionRuntime,
42
38
  type CompiledActionDecl,
@@ -160,7 +156,15 @@ function requireOptionKind(
160
156
  CompiledActionDecl,
161
157
  { kind: "set-option" | "persist-option" | "layout-op-option" }
162
158
  > {
163
- const action = runtime.compiled.get(name);
159
+ // [LAW:dataflow-not-control-flow] A dual-destination action resolves to the
160
+ // half its selector names BEFORE the kind check, so a picker over a dual is
161
+ // a picker over whichever option kind is live — the grid, its current-mark,
162
+ // and its close folding are the code they already were. The check below then
163
+ // still names a real, single-destination kind in its error.
164
+ const declared = runtime.compiled.get(name);
165
+ const action = declared
166
+ ? activeDestination(declared, runtime.store)
167
+ : declared;
164
168
  if (
165
169
  !action ||
166
170
  (action.kind !== "set-option" &&
@@ -194,6 +198,13 @@ export function renderPicker(
194
198
  runtime: ActionRuntime,
195
199
  ): RichText {
196
200
  const apply = requireOptionKind(runtime, applyName);
201
+ // [LAW:one-source-of-truth] The GRID reads the resolved half above (its
202
+ // options, its current-mark); the CLICK is realized from the declaration
203
+ // itself, through the same fold `{{ action }}` uses. That is what carries a
204
+ // dual's session clear into a picked option — the picker never learns what a
205
+ // dual is, and there is no second projection of "what does this option
206
+ // write" to drift from realize's.
207
+ const declared = runtime.compiled.get(applyName)!;
197
208
  const store = runtime.store;
198
209
  const sessionId = readVar(store, "session.id");
199
210
  // [LAW:no-defensive-null-guards] layout-op-option carries no `stateVar` —
@@ -275,30 +286,21 @@ export function renderPicker(
275
286
  ? [{ verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] }]
276
287
  : [];
277
288
  const optionUrl = (option: string): string => {
278
- if (apply.kind === "persist-option") {
279
- return effectsUrl([
280
- { verb: VERB_SET_CONFIG, args: [sessionId, apply.key, option] },
281
- ...closeEffect,
282
- ]);
283
- }
284
- if (apply.kind === "layout-op-option") {
285
- const op = encodeLayoutOp({
286
- op: "insert",
287
- segment: option,
288
- anchor: apply.anchor,
289
- relation: apply.relation,
290
- });
291
- return effectsUrl([
292
- { verb: VERB_APPLY_LAYOUT_OP, args: [sessionId, apply.key, op] },
293
- ...closeEffect,
294
- ]);
295
- }
296
- return effectsUrl([
297
- {
298
- verb: VERB_SET_STATE,
299
- args: [sessionId, apply.key, option, ...(closeOnPick ? closeFlat : [])],
300
- },
301
- ]);
289
+ const { effects } = realize(declared, option, option, store, sessionId);
290
+ // [LAW:one-source-of-truth] A plain session pick folds its close pairs into
291
+ // the SAME set-state (setState is variadic), so closing and applying are
292
+ // one write. Every other shape — a durable write, a structural op, a dual
293
+ // carrying its session clear — takes more than one effect already, so its
294
+ // close rides as its own effect in the same atomic dispatch.
295
+ const solo = effects.length === 1 ? effects[0]! : undefined;
296
+ return solo?.verb === VERB_SET_STATE
297
+ ? effectsUrl([
298
+ {
299
+ verb: VERB_SET_STATE,
300
+ args: [...solo.args, ...(closeOnPick ? closeFlat : [])],
301
+ },
302
+ ])
303
+ : effectsUrl([...effects, ...closeEffect]);
302
304
  };
303
305
 
304
306
  const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
@@ -270,7 +270,16 @@ export const DEFAULT_WRAP = true;
270
270
  // boolean globals field — the same two members the bundled default's
271
271
  // `cycle: [...]` toggle writes and the parse below reads. Spelled once so a
272
272
  // toggle cannot write a member the resolver refuses to parse.
273
- export const BOOLEAN_MEMBERS = ["true", "false"] as const;
273
+ // [LAW:one-source-of-truth] The two members, named, because their ORDER is
274
+ // meaningful and differs per control: a cycle's members are ordered
275
+ // default-state-first (an unwritten key counts as the first member and clicks
276
+ // to the second), so `autoWrap` — on by default — cycles ["true","false"]
277
+ // while `persist?` — off by default — cycles [BOOLEAN_FALSE, BOOLEAN_TRUE].
278
+ // Spelling the members rather than reversing the pair keeps each declaration's
279
+ // default state readable at its own site.
280
+ export const BOOLEAN_TRUE = "true";
281
+ export const BOOLEAN_FALSE = "false";
282
+ export const BOOLEAN_MEMBERS = [BOOLEAN_TRUE, BOOLEAN_FALSE] as const;
274
283
 
275
284
  // [LAW:one-source-of-truth] The one statement of the globals.padding default
276
285
  // (one space per side inside each segment cell — current behavior, matching the
@@ -287,8 +296,16 @@ export const PADDING_RANGE = { min: 0, max: 16 } as const;
287
296
  // [LAW:parse-dont-validate] A SessionState string to a boolean, or null for
288
297
  // anything else. `??` in effectiveGlobal (never `||`) is what keeps a parsed
289
298
  // `false` a real answer rather than falling through to the default.
290
- function parseBoolean(raw: string): boolean | null {
291
- return raw === "true" ? true : raw === "false" ? false : null;
299
+ //
300
+ // [LAW:one-source-of-truth] Exported because SessionState holds strings and
301
+ // BOOLEAN_MEMBERS above is the one spelling of a boolean in that store — so
302
+ // every reader of a boolean session key parses it HERE, not with its own
303
+ // truthiness rule. The second reader is the dual-destination action's
304
+ // `persistWhen` selector (src/render/action.ts): "is persist? checked" is the
305
+ // same question `autoWrap`'s toggle asks of its own key, and a bespoke
306
+ // `raw !== ""` there would accept values this parse rejects.
307
+ export function parseSessionBoolean(raw: string): boolean | null {
308
+ return raw === BOOLEAN_TRUE ? true : raw === BOOLEAN_FALSE ? false : null;
292
309
  }
293
310
 
294
311
  // [LAW:parse-dont-validate] A SessionState string to a padding value inside the
@@ -313,7 +330,7 @@ export function effectiveAutoWrap(
313
330
  sessionAutoWrap,
314
331
  globalsAutoWrap,
315
332
  DEFAULT_WRAP,
316
- parseBoolean,
333
+ parseSessionBoolean,
317
334
  );
318
335
  }
319
336