@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.
@@ -25,10 +25,16 @@ 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";
35
+ import { pickCycleDisplay } from "../config/disclosure.js";
30
36
  import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
31
- import type { StripStyle } from "../themes/policy.js";
37
+ import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
32
38
  import {
33
39
  effectsUrl,
34
40
  VERB_APPLY_LAYOUT_OP,
@@ -166,7 +172,24 @@ export type CompiledActionDecl =
166
172
  // there is nothing to carry, since the history stack (not this action) is
167
173
  // what decides which entry moves.
168
174
  | { readonly kind: "undo" }
169
- | { readonly kind: "redo" };
175
+ | { readonly kind: "redo" }
176
+ // [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.3's ONE control
177
+ // per setting. Both destinations are compiled here as the ordinary
178
+ // single-destination shapes they are, and `selector` names the session key
179
+ // whose boolean value picks between them at click time. The destination is
180
+ // therefore a VALUE flowing through `activeDestination` — every consumer
181
+ // (realize, the picker, selectDisplay) resolves it once at the top and then
182
+ // runs the code it has always run, so nothing downstream branches on
183
+ // "is this dual".
184
+ | {
185
+ readonly kind: "dual";
186
+ readonly selector: string;
187
+ readonly session: CompiledActionDecl;
188
+ readonly durable: CompiledActionDecl;
189
+ // The SessionState key the session half writes, carried so a durable
190
+ // click can clear it in the same dispatch (see realize's dual arm).
191
+ readonly sessionKey: string;
192
+ };
170
193
 
171
194
  export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
172
195
 
@@ -185,6 +208,13 @@ export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
185
208
  // highlight is inert (readVar falls back to "" since no such var exists).
186
209
  const CONFIG_KEY_TO_EFFECTIVE_VAR: ReadonlyMap<string, string> = new Map([
187
210
  ["palette", "theme.effective"],
211
+ // [LAW:one-source-of-truth] `preset` earns its entry here the moment a DUAL
212
+ // control writes it: compileDual makes BOTH halves read back through this
213
+ // map, so a field missing from it loses its current-selection mark on the
214
+ // session side too — and the preset picker sits on the settings menu's
215
+ // always-visible first row, where "which arrangement am I in" is the whole
216
+ // question the control answers.
217
+ ["preset", "preset.effective"],
188
218
  ["look", "look.effective"],
189
219
  ["style", "style.effective"],
190
220
  ["charset", "charset.effective"],
@@ -263,6 +293,20 @@ function compileAction(
263
293
  stateKeyToVar: ReadonlyMap<string, string>,
264
294
  perConfigDomains: ReadonlyMap<string, readonly string[]>,
265
295
  ): CompiledActionDecl {
296
+ // [LAW:one-source-of-truth] A dual compiles as its own two destinations —
297
+ // the SAME explosion the validator derivations fold over
298
+ // (actionDestinations), so the click a dual realizes and the gate it derives
299
+ // come from one statement of what the two halves are. It is matched BEFORE
300
+ // the `set` arm because a dual carries `set` too.
301
+ if (actionIsDual(action)) {
302
+ const [session, durable] = actionDestinations(action);
303
+ return compileDual(
304
+ stateKeyToVar.get(action[PERSIST_WHEN]) ?? action[PERSIST_WHEN],
305
+ action.set,
306
+ compileAction(parse, name, session!, stateKeyToVar, perConfigDomains),
307
+ compileAction(parse, name, durable!, stateKeyToVar, perConfigDomains),
308
+ );
309
+ }
266
310
  if ("set" in action) {
267
311
  const stateVar = stateKeyToVar.get(action.set) ?? action.set;
268
312
  if ("to" in action) {
@@ -379,6 +423,55 @@ function compileAction(
379
423
  return "undo" in action ? { kind: "undo" } : { kind: "redo" };
380
424
  }
381
425
 
426
+ // [LAW:one-source-of-truth] A dual control shows ONE current value and writes
427
+ // relative to the value it showed — so both destinations read back through the
428
+ // DURABLE half's variable, which is the `.effective` projection the daemon
429
+ // resolved for this render (CONFIG_KEY_TO_EFFECTIVE_VAR above): the value the
430
+ // bar is actually rendering with, whatever chain produced it. Reading the
431
+ // session key instead would let a cycle's glyph name the effective state while
432
+ // its click stepped from an unwritten session key — the toggle would render
433
+ // "wrap: off" and write "false", a click that visibly does nothing. Arms that
434
+ // carry no `stateVar` (the bounded steppers) read nothing at render by design:
435
+ // their step is relative and resolved daemon-side.
436
+ function compileDual(
437
+ selectorVar: string,
438
+ sessionKey: string,
439
+ session: CompiledActionDecl,
440
+ durable: CompiledActionDecl,
441
+ ): CompiledActionDecl {
442
+ const readBack =
443
+ "stateVar" in session && "stateVar" in durable
444
+ ? { ...session, stateVar: durable.stateVar }
445
+ : session;
446
+ return {
447
+ kind: "dual",
448
+ selector: selectorVar,
449
+ session: readBack,
450
+ durable,
451
+ sessionKey,
452
+ };
453
+ }
454
+
455
+ // [LAW:dataflow-not-control-flow] THE destination fold: which store a dual
456
+ // action writes is the boolean value of its selector key, read from the same
457
+ // live store the rest of the render reads. Total over every compiled action —
458
+ // a single-destination action IS its own destination — so callers resolve
459
+ // through it unconditionally and never test for the dual kind.
460
+ //
461
+ // [LAW:one-source-of-truth] `parseSessionBoolean` is the one spelling of a
462
+ // boolean in SessionState (themes/policy.ts), the same parse `autoWrap`'s own
463
+ // session half goes through: an unwritten, malformed, or "false" selector all
464
+ // mean the session destination, and only a canonical "true" means durable.
465
+ export function activeDestination(
466
+ c: CompiledActionDecl,
467
+ store: VariableStore,
468
+ ): CompiledActionDecl {
469
+ if (c.kind !== "dual") return c;
470
+ return parseSessionBoolean(readVar(store, c.selector)) === true
471
+ ? c.durable
472
+ : c.session;
473
+ }
474
+
382
475
  function parseActionTemplate(
383
476
  parse: (src: string) => Template<RichText>,
384
477
  src: string,
@@ -463,18 +556,18 @@ function cycleIndex(
463
556
  // cell of an option picker, is pure waste). set-* arms read individual vars
464
557
  // directly. This is data locality, not a control-flow guard: the scope simply
465
558
  // flows into the arms that need it.
466
- function realize(
559
+ export function realize(
467
560
  c: CompiledActionDecl,
468
561
  display: string,
469
562
  boundValue: string | undefined,
470
563
  store: VariableStore,
471
564
  sessionId: string,
472
- ): { effect: Effect; active: boolean } {
565
+ ): { effects: readonly Effect[]; active: boolean } {
473
566
  switch (c.kind) {
474
567
  case "set-literal": {
475
568
  const current = readVar(store, c.stateVar);
476
569
  return {
477
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] },
570
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] }],
478
571
  active: current === c.value,
479
572
  };
480
573
  }
@@ -482,7 +575,7 @@ function realize(
482
575
  const value = boundValue ?? display;
483
576
  const current = readVar(store, c.stateVar);
484
577
  return {
485
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, value] },
578
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
486
579
  active: current === value,
487
580
  };
488
581
  }
@@ -499,7 +592,7 @@ function realize(
499
592
  const value = boundValue ?? display;
500
593
  const current = readVar(store, c.stateVar);
501
594
  return {
502
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, value] },
595
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
503
596
  active: current === value,
504
597
  };
505
598
  }
@@ -510,7 +603,7 @@ function realize(
510
603
  // promised.
511
604
  const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
512
605
  return {
513
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, next] },
606
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, next] }],
514
607
  active: false,
515
608
  };
516
609
  }
@@ -522,27 +615,33 @@ function realize(
522
615
  // single range gate. So the link is byte-identical across renders and N
523
616
  // rapid clicks each accumulate (the idempotent absolute-write bug is gone).
524
617
  return {
525
- effect: {
526
- verb: VERB_STEP_STATE,
527
- args: [sessionId, c.key, String(c.by)],
528
- },
618
+ effects: [
619
+ {
620
+ verb: VERB_STEP_STATE,
621
+ args: [sessionId, c.key, String(c.by)],
622
+ },
623
+ ],
529
624
  active: false,
530
625
  };
531
626
  }
532
627
  case "copy":
533
628
  return {
534
- effect: {
535
- verb: VERB_COPY,
536
- args: [evalTemplate(c.text, buildScope(store))],
537
- },
629
+ effects: [
630
+ {
631
+ verb: VERB_COPY,
632
+ args: [evalTemplate(c.text, buildScope(store))],
633
+ },
634
+ ],
538
635
  active: false,
539
636
  };
540
637
  case "open":
541
638
  return {
542
- effect: {
543
- verb: VERB_OPEN_VSCODE,
544
- args: [evalTemplate(c.target, buildScope(store))],
545
- },
639
+ effects: [
640
+ {
641
+ verb: VERB_OPEN_VSCODE,
642
+ args: [evalTemplate(c.target, buildScope(store))],
643
+ },
644
+ ],
546
645
  active: false,
547
646
  };
548
647
  // [LAW:one-source-of-truth] The persist-* arms mirror set-*'s realization
@@ -553,7 +652,7 @@ function realize(
553
652
  case "persist-literal": {
554
653
  const current = readVar(store, c.stateVar);
555
654
  return {
556
- effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] },
655
+ effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] }],
557
656
  active: current === c.value,
558
657
  };
559
658
  }
@@ -561,29 +660,31 @@ function realize(
561
660
  const value = boundValue ?? display;
562
661
  const current = readVar(store, c.stateVar);
563
662
  return {
564
- effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] },
663
+ effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] }],
565
664
  active: current === value,
566
665
  };
567
666
  }
568
667
  case "persist-cycle": {
569
668
  const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
570
669
  return {
571
- effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] },
670
+ effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] }],
572
671
  active: false,
573
672
  };
574
673
  }
575
674
  case "persist-bounded": {
576
675
  return {
577
- effect: {
578
- verb: VERB_STEP_CONFIG,
579
- args: [sessionId, c.key, String(c.by)],
580
- },
676
+ effects: [
677
+ {
678
+ verb: VERB_STEP_CONFIG,
679
+ args: [sessionId, c.key, String(c.by)],
680
+ },
681
+ ],
581
682
  active: false,
582
683
  };
583
684
  }
584
685
  case "reset":
585
686
  return {
586
- effect: { verb: VERB_RESET_CONFIG, args: [sessionId, c.key] },
687
+ effects: [{ verb: VERB_RESET_CONFIG, args: [sessionId, c.key] }],
587
688
  active: false,
588
689
  };
589
690
  // [LAW:one-source-of-truth] No key to carry — the click just says "step
@@ -592,12 +693,12 @@ function realize(
592
693
  // a history step is a one-shot trigger, not a current-selection toggle.
593
694
  case "undo":
594
695
  return {
595
- effect: { verb: VERB_UNDO, args: [sessionId] },
696
+ effects: [{ verb: VERB_UNDO, args: [sessionId] }],
596
697
  active: false,
597
698
  };
598
699
  case "redo":
599
700
  return {
600
- effect: { verb: VERB_REDO, args: [sessionId] },
701
+ effects: [{ verb: VERB_REDO, args: [sessionId] }],
601
702
  active: false,
602
703
  };
603
704
  // [LAW:one-source-of-truth] The op is fixed at compile time (see
@@ -608,10 +709,12 @@ function realize(
608
709
  // trigger, not a current-selection toggle.
609
710
  case "layout-op":
610
711
  return {
611
- effect: {
612
- verb: VERB_APPLY_LAYOUT_OP,
613
- args: [sessionId, c.key, encodeLayoutOp(c.op)],
614
- },
712
+ effects: [
713
+ {
714
+ verb: VERB_APPLY_LAYOUT_OP,
715
+ args: [sessionId, c.key, encodeLayoutOp(c.op)],
716
+ },
717
+ ],
615
718
  active: false,
616
719
  };
617
720
  // [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
@@ -620,6 +723,46 @@ function realize(
620
723
  // emits, so the daemon's apply-layout-op handler and undo/redo need no
621
724
  // knowledge of where the segment name came from. Never "active": a
622
725
  // structural edit is a one-shot trigger, not a current-selection toggle.
726
+ // [LAW:dataflow-not-control-flow] The destination is resolved to a value
727
+ // and the SAME fold runs on it — a dual's realization is its chosen
728
+ // half's realization, with nothing about persistence duplicated here.
729
+ // Depth is structurally one: a dual's halves are the single-destination
730
+ // decls actionDestinations built, which can never be dual themselves.
731
+ //
732
+ // [LAW:no-silent-failure] A DURABLE click carries the session key to
733
+ // RELEASE as a trailing arg on its own write, so the daemon drops it only
734
+ // after that write succeeded. Without the release the write would be
735
+ // invisible to the session that made it — every settable global resolves
736
+ // session pick OVER durable default, so the workflow this menu invites
737
+ // ("try it here, then tick persist? to commit it") would set a default the
738
+ // user cannot see and leave the control dead for the rest of the session.
739
+ // Riding the write rather than sitting beside it is what makes the pair
740
+ // unsplittable: a click runs every effect it carries, so a rejected write
741
+ // must not be able to drop the pick on its own.
742
+ case "dual": {
743
+ const chosen = activeDestination(c, store);
744
+ const { effects, active } = realize(
745
+ chosen,
746
+ display,
747
+ boundValue,
748
+ store,
749
+ sessionId,
750
+ );
751
+ // The durable write carries the session key to RELEASE as one more
752
+ // segment on itself, so the daemon clears it only after its own write
753
+ // succeeded. A second effect beside it would not do: `dispatch` runs
754
+ // every effect in a click by design, so a rejected write would still
755
+ // wipe the session pick and leave nothing durable in its place.
756
+ return chosen === c.durable
757
+ ? {
758
+ effects: effects.map((e) => ({
759
+ ...e,
760
+ args: [...e.args, c.sessionKey],
761
+ })),
762
+ active,
763
+ }
764
+ : { effects, active };
765
+ }
623
766
  case "layout-op-option": {
624
767
  const segment = boundValue ?? display;
625
768
  const op: LayoutOp = {
@@ -629,10 +772,12 @@ function realize(
629
772
  relation: c.relation,
630
773
  };
631
774
  return {
632
- effect: {
633
- verb: VERB_APPLY_LAYOUT_OP,
634
- args: [sessionId, c.key, encodeLayoutOp(op)],
635
- },
775
+ effects: [
776
+ {
777
+ verb: VERB_APPLY_LAYOUT_OP,
778
+ args: [sessionId, c.key, encodeLayoutOp(op)],
779
+ },
780
+ ],
636
781
  active: false,
637
782
  };
638
783
  }
@@ -656,15 +801,16 @@ function selectDisplay(
656
801
  throw new Error(`action "${name}" needs a display (the clickable text)`);
657
802
  }
658
803
  if (action.kind === "set-cycle" || action.kind === "persist-cycle") {
659
- if (displays.length !== 1 && displays.length !== action.members.length) {
660
- throw new Error(
661
- `action "${name}" cycles ${action.members.length} members; bind one display per member (${action.members.length}) or one static display, got ${displays.length}`,
662
- );
663
- }
664
- const display =
665
- displays.length === 1
666
- ? displays[0]!
667
- : displays[cycleIndex(action, store)]!;
804
+ // [LAW:single-enforcer] The arity rule and the pick are the disclosure
805
+ // primitive's, not this file's — `{{ menu }}` resolves its own trigger
806
+ // through the same function over its `[closed, member]` cycle, so the two
807
+ // disclosure kinds cannot disagree about what a display binding means.
808
+ const display = pickCycleDisplay(
809
+ `action "${name}"`,
810
+ displays,
811
+ action.members.length,
812
+ cycleIndex(action, store),
813
+ );
668
814
  return { display, boundValue: undefined };
669
815
  }
670
816
  if (displays.length > 2) {
@@ -682,24 +828,34 @@ export function renderAction(
682
828
  displays: readonly string[],
683
829
  runtime: ActionRuntime,
684
830
  ): RichText {
685
- const action = runtime.compiled.get(name);
831
+ const declared = runtime.compiled.get(name);
686
832
  // [LAW:no-defensive-null-guards] The loader validates every `{{ action "x" }}`
687
833
  // reference resolves to a declared action, and compileActions compiled every
688
834
  // declared action for THIS config's engine. A miss is a caller/wiring bug.
689
- if (!action) {
835
+ if (!declared) {
690
836
  throw new Error(`action "${name}" is not declared in this config`);
691
837
  }
692
838
  const store = runtime.store;
693
- const { display, boundValue } = selectDisplay(name, action, displays, store);
839
+ // [LAW:dataflow-not-control-flow] DISPLAY selection reads the resolved half
840
+ // (a cycle's glyph is the current member's, whichever store it will write),
841
+ // while REALIZATION is handed the declaration itself — a dual realizes as
842
+ // its chosen half PLUS the session clear that keeps a durable write visible,
843
+ // and that pairing belongs to the one fold that owns the union.
844
+ const { display, boundValue } = selectDisplay(
845
+ name,
846
+ activeDestination(declared, store),
847
+ displays,
848
+ store,
849
+ );
694
850
  const sessionId = readVar(store, "session.id");
695
- const { effect, active } = realize(
696
- action,
851
+ const { effects, active } = realize(
852
+ declared,
697
853
  display,
698
854
  boundValue,
699
855
  store,
700
856
  sessionId,
701
857
  );
702
- return linkFragment(display, effectsUrl([effect]), active);
858
+ return linkFragment(display, effectsUrl(effects), active);
703
859
  }
704
860
 
705
861
  // ─── FuncMap entry ─────────────────────────────────────────────────────────────
@@ -1,11 +1,22 @@
1
1
  // [LAW:locality-or-seam] The runtime half of the `{{ menu }}` seam — sibling to
2
2
  // `{{ action }}`/`{{ picker }}`. A menu is a self-contained disclosure: an inline
3
- // glyph that toggles open/closed, and (when open) its body — a picker grid —
3
+ // TRIGGER that toggles open/closed, and (when open) its body — a picker grid —
4
4
  // that DROPS onto the line(s) below the enclosing row. The body is the one picker
5
- // renderer (`renderPicker`); the glyph is a coupled set-state the menu composes
5
+ // renderer (`renderPicker`); the trigger is a coupled set-state the menu composes
6
6
  // directly (like the picker's closeOnPick) — it toggles the open-state AND resets
7
7
  // the page cursor in one atomic batch, gated by the synthesized cycle action.
8
8
  //
9
+ // [LAW:one-source-of-truth] The trigger's TEXT is authored, never emitted here.
10
+ // This module used to append ▸/▾ from the glyph constants, while the codebase's
11
+ // other disclosure — group sugar — spliced those same constants into the
12
+ // template it synthesized, where an author could see and change them. Two
13
+ // policies for one fact; the docs sided with the visible one ("the trigger is
14
+ // any template content you like") while a menu appended a glyph nobody wrote,
15
+ // which is why edit mode's `+` rendered `+▸`. A menu's disclosure IS a
16
+ // two-member cycle, so its trigger now binds displays exactly as a cycle
17
+ // `{{ action }}` does, through the same `pickCycleDisplay` (candybar-settings-
18
+ // ui-aok.4).
19
+ //
9
20
  // [LAW:effects-at-boundaries] The helper is a PURE function of its inputs (the
10
21
  // walk-published placement + the live store): it computes the inline glyph and,
11
22
  // when open, the body, and RETURNS them together — the glyph as the fragment, the
@@ -42,11 +53,7 @@ import {
42
53
  parseMenuOptions,
43
54
  type MenuOptions,
44
55
  } from "../config/menu-keys.js";
45
- import {
46
- DISCLOSURE_CLOSED,
47
- DISCLOSURE_GLYPH_CLOSED,
48
- DISCLOSURE_GLYPH_OPEN,
49
- } from "../config/disclosure.js";
56
+ import { DISCLOSURE_CLOSED, pickCycleDisplay } from "../config/disclosure.js";
50
57
  import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
51
58
  import { linkFragment, readVar, type ActionRuntime } from "./action.js";
52
59
  import { renderPicker } from "./picker.js";
@@ -94,9 +101,10 @@ export function collectMenuDrops(
94
101
  }
95
102
 
96
103
  // Realize a `{{ menu }}` against the live placement + state: return its inline
97
- // glyph, carrying the (open) body as out-of-band metadata for the boundary.
104
+ // trigger, carrying the (open) body as out-of-band metadata for the boundary.
98
105
  function renderMenu(
99
106
  applyName: string,
107
+ displays: readonly string[],
100
108
  options: MenuOptions,
101
109
  runtime: MenuRuntime,
102
110
  ): RichText {
@@ -139,8 +147,14 @@ function renderMenu(
139
147
  // coupled batch passes the same wire gate every click does [LAW:single-enforcer].
140
148
  const sessionId = readVar(action.store, "session.id");
141
149
  const successor = open ? DISCLOSURE_CLOSED : member;
150
+ // [LAW:one-source-of-truth] The trigger's text is AUTHORED, resolved through
151
+ // the one display rule a cycle `{{ action }}` uses — a menu's disclosure is a
152
+ // two-member cycle, so binding `"▸" "▾"` gives the per-state form and binding
153
+ // `"+"` gives the static one. Nothing is appended here: a disclosure glyph an
154
+ // author never wrote is a glyph they cannot decline, which is exactly how
155
+ // edit mode's `+` came to read `+▸`.
142
156
  const glyph = linkFragment(
143
- open ? DISCLOSURE_GLYPH_OPEN : DISCLOSURE_GLYPH_CLOSED,
157
+ pickCycleDisplay(`{{ menu "${applyName}" }}`, displays, 2, open ? 1 : 0),
144
158
  effectsUrl([
145
159
  {
146
160
  verb: VERB_SET_STATE,
@@ -178,25 +192,65 @@ function renderMenu(
178
192
  return glyph;
179
193
  }
180
194
 
195
+ // [LAW:parse-dont-validate] THE crossing for a `{{ menu }}`'s argument tail.
196
+ // The engine cannot type these slots for us — displays are strings and the
197
+ // optional trailing knobs are a dict, so one declared slot type would refuse
198
+ // one of them — so the tail arrives as opaque values and leaves here as a
199
+ // record whose shape the renderer can no longer doubt: displays are strings,
200
+ // options are parsed. Every rejected shape names the legal one.
201
+ interface MenuArgs {
202
+ readonly displays: readonly string[];
203
+ readonly options: MenuOptions;
204
+ }
205
+ // [LAW:one-source-of-truth] This splits the tail on VALUES; the loader splits
206
+ // the same tail on EXPRS (`menu-synth.ts`). They agree because the loader admits
207
+ // only call sites where they provably must: a last argument that is neither a
208
+ // string literal nor a literal `(dict …)` is a load error whenever both readings
209
+ // would be legal, so what reaches here can only match the loader's reading or
210
+ // throw below.
211
+ const isDict = (v: unknown): v is Record<string, unknown> =>
212
+ typeof v === "object" && v !== null && !Array.isArray(v);
213
+
214
+ function parseMenuArgs(applyName: string, tail: readonly unknown[]): MenuArgs {
215
+ // The dict is the LAST argument when present; everything before it is a
216
+ // display. One position, so a reader never has to count.
217
+ const last = tail[tail.length - 1];
218
+ const optsArg = isDict(last) ? last : undefined;
219
+ const displayArgs = optsArg === undefined ? tail : tail.slice(0, -1);
220
+ const bad = displayArgs.findIndex((d) => typeof d !== "string");
221
+ if (bad !== -1) {
222
+ throw new Error(
223
+ `{{ menu "${applyName}" }} display #${bad + 1} is not text (${JSON.stringify(displayArgs[bad])}) — a menu binds its trigger text, then an optional trailing (dict …) of options`,
224
+ );
225
+ }
226
+ return {
227
+ displays: displayArgs as readonly string[],
228
+ // [LAW:one-source-of-truth] The same option reader the loader folds over
229
+ // the static dict — vocabulary, types, defaults live once.
230
+ options: parseMenuOptions(optsArg ?? {}),
231
+ };
232
+ }
233
+
181
234
  // [LAW:dataflow-not-control-flow] One func; the apply-action NAME is the menu's
182
- // whole identity (the page cursor is derived from it, not passed), and the rare
183
- // knobs travel as ONE optional trailing `(dict …)` — closeOnPick (default
184
- // false: stay-open), paged (default true: a drop menu wants bounded height),
185
- // key (accordion grouping: omitted ⇒ independent, present ⇒ mutually exclusive
186
- // with siblings sharing it). Values, not modes. The loader gates the same dict
187
- // statically (staticDictEntries), so an old positional tail never reaches this
188
- // fn — it is a migration-pointing load error.
235
+ // whole identity (the page cursor is derived from it, not passed), the TRIGGER
236
+ // TEXT is bound like a cycle action's display (one per state, or one static),
237
+ // and the rare knobs travel as ONE optional trailing `(dict …)` — closeOnPick
238
+ // (default false: stay-open), paged (default true: a drop menu wants bounded
239
+ // height), key (accordion grouping: omitted ⇒ independent, present ⇒ mutually
240
+ // exclusive with siblings sharing it). Values, not modes. The loader gates the
241
+ // same dict statically (staticDictEntries), so an old positional tail never
242
+ // reaches this fn — it is a migration-pointing load error.
189
243
  //
190
244
  // [LAW:one-way-deps] Injected into the engine by registerDslConfig as data; the
191
245
  // generic engine never imports this module.
192
246
  export function menuFuncs(runtime: MenuRuntime): FuncMap {
193
247
  return {
194
248
  menu: {
195
- fn: (applyName: string, opts?: Record<string, unknown>) =>
196
- // [LAW:one-source-of-truth] The same option reader the loader folds
197
- // over the static dict — vocabulary, types, defaults live once.
198
- renderMenu(applyName, parseMenuOptions(opts ?? {}), runtime),
199
- argTypes: ["string", "dict"],
249
+ fn: (applyName: string, ...tail: unknown[]) => {
250
+ const { displays, options } = parseMenuArgs(applyName, tail);
251
+ return renderMenu(applyName, displays, options, runtime);
252
+ },
253
+ argTypes: ["string", "value"],
200
254
  returnType: "T",
201
255
  },
202
256
  };