@promptctl/cc-candybar 1.8.1 → 1.8.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -85,16 +85,16 @@
85
85
  "typescript": "^5.0.0"
86
86
  },
87
87
  "dependencies": {
88
- "@promptctl/go-template-js": "^0.5.0",
88
+ "@promptctl/go-template-js": "^0.6.0",
89
89
  "@promptctl/rich-js": "^0.6.0",
90
90
  "json5": "^2.2.3",
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.8.1",
95
- "@promptctl/cc-candybar-darwin-x64": "1.8.1",
96
- "@promptctl/cc-candybar-linux-x64": "1.8.1",
97
- "@promptctl/cc-candybar-linux-arm64": "1.8.1"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.8.3",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.8.3",
96
+ "@promptctl/cc-candybar-linux-x64": "1.8.3",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.8.3"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -496,10 +496,12 @@ export const DEFAULT_DSL_CONFIG = {
496
496
  // and the render's read are one value. Empty default ⇒ the daemon's
497
497
  // "powerline" floor is in effect and styleControl shows "(default)".
498
498
  activeStyle: { kind: "state", key: "style", default: "" },
499
- // The style menu's page cursor: −1 closed / 0..N open, mirroring how a theme
500
- // picker's page key gates its reveal row. The stylePage action declares the
501
- // int gate; this var reads it back for the reveal `when`.
502
- stylePage: { kind: "state", key: "style-page", default: "-1" },
499
+ // The style picker's page index. Open/closed is the synthesized menus.* key
500
+ // the styleControl's {{ menu }} owns NOT this var, which is purely the page
501
+ // the picker paginates by (default page 0; the disclosure resets it to 0 on
502
+ // every toggle, so a reopened menu never lands on a stale page). The stylePage
503
+ // action below declares the int gate this var reads back.
504
+ stylePage: { kind: "state", key: "style-page", default: "0" },
503
505
  },
504
506
 
505
507
  // ─── Segments ──────────────────────────────────────────────────────────────
@@ -736,26 +738,22 @@ export const DEFAULT_DSL_CONFIG = {
736
738
  " .metrics.sessionDuration .metrics.messageCount" +
737
739
  " .metrics.linesAdded .metrics.linesRemoved }}",
738
740
  },
739
- // Style control — the powerline-shape switcher's trigger. [LAW:locality-or-
740
- // seam] The ✦ glyph + current-style label is the REPRESENTATION; the
741
- // `openStyleMenu` action is the BEHAVIOR; the name is the seam. Shows the
742
- // active shape (or "(default)" when unset) and a that opens the picker
743
- // row. [LAW:dataflow-not-control-flow] No display state from the provider —
744
- // the label is the one "style" value the click writes and the render reads.
741
+ // Style control — the powerline-shape switcher. A self-contained {{ menu }}
742
+ // disclosure: the ✦ glyph + current-style label is the REPRESENTATION, and the
743
+ // ▸/▾ disclosure (whose identity is DERIVED from this segment + the applyStyle
744
+ // action, see menu-keys.ts) toggles the picker body, which DROPS full-width
745
+ // onto the line below this row when open. [LAW:one-type-per-behavior] "a menu
746
+ // that opens and closes" is one behavior the substrate already expresses no
747
+ // bespoke open-action + page-cursor-as-open-state + when-gated reveal row.
748
+ // paged omitted ⇒ false ⇒ one full-width page (the 3 powerline shapes are a
749
+ // small domain; FlexStrip soft-wraps if needed). closeOnPick omitted ⇒ false
750
+ // ⇒ stay-open, so shapes can be tried in a row; the ▾ collapses.
751
+ // [LAW:dataflow-not-control-flow] No display state from the provider — the
752
+ // label is the one "style" value the click writes and the render reads.
745
753
  styleControl: {
746
754
  template:
747
755
  "✦ {{ if .activeStyle }}{{ .activeStyle }}{{ else }}(default){{ end }} " +
748
- '{{ action "openStyleMenu" "" }}',
749
- bg: "surface",
750
- fg: "foreground",
751
- },
752
- // The expanded style picker: one full-width page (paged=false) over the
753
- // `applyStyle` option domain — the 3 powerline shapes. closeOnPick defaults
754
- // false (omitted here), so a pick reshapes the bar live and LEAVES THE ROW
755
- // OPEN — shapes can be tried in a row; the ✕ affordance closes. The active
756
- // shape is marked by the picker helper.
757
- stylePicker: {
758
- template: '{{ picker "applyStyle" "stylePage" }}',
756
+ '{{ menu "applyStyle" "stylePage" }}',
759
757
  bg: "surface",
760
758
  fg: "foreground",
761
759
  },
@@ -764,10 +762,11 @@ export const DEFAULT_DSL_CONFIG = {
764
762
  // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
765
763
  // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
766
764
  // the loader's authoring surface for user JSON, not this typed literal).
767
- // [LAW:dataflow-not-control-flow] Two rows: an always-on control row, and a
768
- // picker reveal row that EXISTS only while the style menu cursor is 0 — the
769
- // row's presence is a value test on stylePage, not a branch in render code.
770
- // The picker itself draws the ✕/←/→ affordances from the page + term width.
765
+ // [LAW:dataflow-not-control-flow] One always-on control row. The styleControl's
766
+ // {{ menu }} disclosure drops its picker body onto the line directly below this
767
+ // row when open — openness is the value of the derived menus.* key, NOT a when-
768
+ // gated reveal row. The picker draws the ✕/←/→ affordances from the page + term
769
+ // width; the vertical container stacks the dropped body under row 0.
771
770
  root: {
772
771
  kind: "container",
773
772
  direction: "vertical",
@@ -786,11 +785,6 @@ export const DEFAULT_DSL_CONFIG = {
786
785
  { kind: "segment", name: "styleControl" },
787
786
  ],
788
787
  },
789
- {
790
- kind: "segment",
791
- name: "stylePicker",
792
- when: "{{ ge (int .stylePage) 0 }}",
793
- },
794
788
  ],
795
789
  },
796
790
 
@@ -814,14 +808,16 @@ export const DEFAULT_DSL_CONFIG = {
814
808
  openProject: { open: "{{ .project_dir }}" },
815
809
  openTranscript: { open: "{{ .transcript_path }}" },
816
810
 
817
- // [LAW:locality-or-seam] The style picker's behaviors, decoupled by NAME
818
- // from styleControl/stylePicker above. Three declarations, all gated by
819
- // derivation (deriveActionValidators): openStyleMenu/stylePage write the
820
- // page cursor (a literal page-open subsumed by the int gate); applyStyle
821
- // writes the chosen shape, gated to the STRIP_STYLES allow-list because its
822
- // value source is `from: "styles"`. The rendered click and the wire gate
823
- // share that one source a template cannot smuggle an un-gated style write.
824
- openStyleMenu: { set: "style-page", to: "0" },
811
+ // [LAW:locality-or-seam] The style menu's behaviors, decoupled by NAME from the
812
+ // styleControl {{ menu }} above. The disclosure's open-state toggle + its
813
+ // backing state var are SYNTHESIZED by the menu pass (under the reserved
814
+ // menus.* namespace) no hand-authored open/close action. These two are the
815
+ // picker body's effects, both gated by derivation (deriveActionValidators):
816
+ // applyStyle writes the chosen shape, gated to the STRIP_STYLES allow-list
817
+ // because its value source is `from: "styles"`; stylePage is the page cursor's
818
+ // unbounded int gate (←/→ navigation + the disclosure's page-0 reset). The
819
+ // rendered click and the wire gate share that one source — a template cannot
820
+ // smuggle an un-gated style write.
825
821
  applyStyle: { set: "style", from: "styles" },
826
822
  stylePage: { set: "style-page", int: true },
827
823
  },
@@ -71,7 +71,7 @@ export { mergeWithDefault } from "./loader/merge.js";
71
71
  export {
72
72
  extractTemplateRefs,
73
73
  extractActionRefs,
74
- extractPickerRefs,
74
+ extractPickerMenuRefs,
75
75
  } from "./loader/refs.js";
76
76
 
77
77
  // ─── Three-stage pipeline ────────────────────────────────────────────────────
@@ -17,7 +17,7 @@ import { findKeyLine } from "./diagnostics.js";
17
17
  import { isPlainObject, type ValidateCtx } from "./validate-core.js";
18
18
  import {
19
19
  extractActionRefs,
20
- extractPickerRefs,
20
+ extractPickerMenuRefs,
21
21
  extractTemplateRefs,
22
22
  refResolves,
23
23
  } from "./refs.js";
@@ -145,14 +145,16 @@ export function validateCrossReferences(
145
145
  });
146
146
  }
147
147
  }
148
- // [LAW:locality-or-seam] A `{{ picker "apply" "page" … }}` references two
149
- // named actions — both resolve against the action table at load, same
150
- // existence-check shape as a bare action ref.
151
- for (const pref of extractPickerRefs(tpl)) {
148
+ // [LAW:locality-or-seam] A `{{ picker "apply" "page" … }}` OR `{{ menu
149
+ // "apply" "page" … }}` references two named actions — both resolve against
150
+ // the action table at load, same existence-check shape as a bare action ref.
151
+ // A menu binds the same pair as a picker, so it routes through the SAME
152
+ // check rather than failing only when the disclosure is opened.
153
+ for (const pref of extractPickerMenuRefs(tpl)) {
152
154
  if (!Object.prototype.hasOwnProperty.call(cfg.actions, pref)) {
153
155
  ctx.issues.push({
154
156
  path: `segments.${segName}.${field}`,
155
- message: `${field} references unknown action "${pref}" (in a picker)`,
157
+ message: `${field} references unknown action "${pref}" (in a picker or menu)`,
156
158
  line: findKeyLine(ctx.source, ["segments", segName, field]),
157
159
  });
158
160
  }
@@ -2,8 +2,8 @@
2
2
  // `{{ menu }}` render helper. A menu is the group-accordion mechanism with its
3
3
  // trigger living inside an arbitrary user segment rather than a synthesized
4
4
  // toggle segment — so this emits exactly what group sugar does MINUS the segment
5
- // (the helper IS the trigger): one `state` var per row key (default "closed")
6
- // and one `cycle` action per (row, menu-bearing segment) under the reserved
5
+ // (the helper IS the trigger): one `state` var per menu state key (default
6
+ // "closed") and one `cycle` action per (state key, member) under the reserved
7
7
  // `menus.` namespace. Both land in the raw sections so they merge over the
8
8
  // default and, crucially, so `deriveActionValidators(config.actions)` derives the
9
9
  // click gate from them through the ONE existing path — a menu toggle is gated
@@ -12,12 +12,15 @@
12
12
  // Runs in `parseDslConfig` after group synthesis and after every section parsed,
13
13
  // so the reserved-namespace collision check sees the fully-parsed user sections.
14
14
  //
15
- // [LAW:types-are-the-program] WHICH segments host a menu is read from the parsed
16
- // AST (`referencedFunctions`), not a source-text scan robust against whitespace,
17
- // pipelines, and `.menu`/"menu" lookalikes. Detection bare-parses the segment
18
- // template; a parse failure here is treated as "no menu" because the authoritative
19
- // parse error is surfaced loudly by `registerDslConfig` when it compiles the same
20
- // template (so this pass never swallows a real error, it just declines to guess).
15
+ // [LAW:types-are-the-program] WHICH segments host a menu, and with WHAT apply
16
+ // action + optional shared key, is read from the parsed AST (`referencedCalls`),
17
+ // not a source-text scan robust against whitespace, pipelines, and
18
+ // `.menu`/"menu" lookalikes, and it yields each call's literal string arguments
19
+ // so a menu's identity (member = apply name; key = optional shared key) is the
20
+ // SAME fact the render helper reads from those same argument positions. A parse
21
+ // failure here is treated as "no menu" because the authoritative parse error is
22
+ // surfaced loudly by `registerDslConfig` when it compiles the same template (so
23
+ // this pass never swallows a real error, it just declines to guess).
21
24
 
22
25
  import { createEngine } from "@promptctl/go-template-js";
23
26
  import type { ActionDecl } from "../action.js";
@@ -25,11 +28,15 @@ import type { Mutable, ValidateCtx } from "./validate-core.js";
25
28
  import {
26
29
  MENU_CLOSED,
27
30
  MENU_NS,
28
- forEachSegmentPlacement,
29
31
  menuActionName,
32
+ menuMember,
30
33
  menuStateKey,
31
34
  } from "../menu-keys.js";
32
- import type { RawDslConfig, VariableDecl } from "../dsl-types.js";
35
+ import {
36
+ walkNodes,
37
+ type RawDslConfig,
38
+ type VariableDecl,
39
+ } from "../dsl-types.js";
33
40
  import { findKeyLine } from "./diagnostics.js";
34
41
 
35
42
  // [LAW:single-enforcer] The helper-name a `{{ menu … }}` call uses — the same
@@ -37,17 +44,52 @@ import { findKeyLine } from "./diagnostics.js";
37
44
  // references this function.
38
45
  const MENU_FUNC = "menu";
39
46
 
47
+ // [LAW:types-are-the-program] The `{{ menu }}` argument positions, mirroring the
48
+ // render helper's signature `menu apply page closeOnPick paged key`. Only the
49
+ // apply name (identity member) and the optional shared key (accordion grouping)
50
+ // affect synthesis; page/closeOnPick/paged are render-only and ignored here.
51
+ const ARG_APPLY = 0;
52
+ const ARG_KEY = 4;
53
+
54
+ // [LAW:types-are-the-program] One menu call's identity-bearing arguments. Each
55
+ // arg has three states the synthesis must tell apart: a literal string (usable),
56
+ // `null` (a slot present but non-literal — its value is eval-time, so it cannot
57
+ // be gated at load → author error), and `undefined` (the slot was omitted). The
58
+ // apply slot is required; the key slot is optional (undefined ⇒ independent menu).
59
+ interface MenuCall {
60
+ readonly apply: string | null;
61
+ readonly key: string | null | undefined;
62
+ }
63
+
40
64
  // [LAW:no-defensive-null-guards] A bare engine purely for AST introspection: it
41
65
  // never evaluates, so `fromString` is identity and no funcs are registered (parse
42
66
  // does not resolve function existence — that is an eval-time concern).
43
- function segmentReferencesMenu(template: string): boolean {
67
+ function parseCalls(template: string): readonly MenuCall[] | "parse-failed" {
44
68
  const engine = createEngine<string>({ fromString: (s) => s });
45
69
  try {
46
- return engine.parse(template).referencedFunctions().has(MENU_FUNC);
70
+ return engine
71
+ .parse(template)
72
+ .referencedCalls()
73
+ .filter((c) => c.name === MENU_FUNC)
74
+ .map((c) => ({
75
+ apply: c.args[ARG_APPLY] ?? null,
76
+ // `referencedCalls` reports an omitted positional slot as absent and a
77
+ // present non-literal as null; preserve that distinction.
78
+ key: c.args.length > ARG_KEY ? c.args[ARG_KEY] : undefined,
79
+ }));
47
80
  } catch {
48
81
  // A malformed template can host no usable menu; registerDslConfig re-parses
49
82
  // and reports the real error. [LAW:no-silent-failure] — not swallowed, just
50
83
  // not the place that reports it.
84
+ return "parse-failed";
85
+ }
86
+ }
87
+
88
+ function segmentReferencesMenu(template: string): boolean {
89
+ const engine = createEngine<string>({ fromString: (s) => s });
90
+ try {
91
+ return engine.parse(template).referencedFunctions().has(MENU_FUNC);
92
+ } catch {
51
93
  return false;
52
94
  }
53
95
  }
@@ -77,73 +119,204 @@ export function synthesizeMenuDecls(
77
119
  }
78
120
  }
79
121
 
80
- // [LAW:no-silent-failure] A menu derives its accordion identity from its host
81
- // SEGMENT's tree position; a `{{ define }}` helper is shared and placement-
82
- // agnostic, so a `{{ menu }}` reached through one has no row to key on and would
83
- // never get its backing state var/cycle action synthesized failing at render.
84
- // Reject it loudly at load, pointing the author to inline the menu in a segment.
85
- // [LAW:no-mode-explosion] We reject rather than build helper-call-graph
86
- // resolution speculatively; revisit only if a real shared-menu need appears.
122
+ // [LAW:no-silent-failure] A menu derives its identity from the SEGMENT it sits
123
+ // in (the published segment name) plus its own apply arg; a `{{ define }}`
124
+ // helper is shared across segments, so the synthesis pass which scans each
125
+ // segment's own template cannot see a menu reached only through `{{ template
126
+ // }}` and would never synthesize its backing state var/cycle action, failing at
127
+ // render. Reject it loudly at load, pointing the author to inline the menu.
128
+ // [LAW:no-mode-explosion] We reject rather than resolve the helper call graph
129
+ // speculatively; revisit only if a real shared-menu need appears.
87
130
  for (const [name, body] of Object.entries(out.helpers ?? {})) {
88
131
  if (segmentReferencesMenu(body)) {
89
132
  ctx.issues.push({
90
133
  path: `helpers.${name}`,
91
- message: `helper "${name}" uses {{ menu }}, but a menu must live directly in a segment template — its accordion identity is derived from the segment's position in the layout, which a shared helper does not have. Inline the {{ menu }} call into each segment that needs it.`,
134
+ message: `helper "${name}" uses {{ menu }}, but a menu must live directly in a segment template — its identity is derived from the segment it sits in, which a shared helper does not have. Inline the {{ menu }} call into each segment that needs it.`,
92
135
  line: findKeyLine(ctx.source, ["helpers", name]),
93
136
  });
94
137
  }
95
138
  }
96
139
 
97
- if (out.root === undefined) return;
98
140
  const segments = out.segments ?? {};
99
141
 
100
- // [LAW:dataflow-not-control-flow] Memoize detection by segment name a segment
101
- // placed in N rows is parsed once, then each placement reads the cached verdict.
102
- const hostsMenu = new Map<string, boolean>();
103
- const referencesMenu = (segName: string): boolean => {
104
- const cached = hostsMenu.get(segName);
105
- if (cached !== undefined) return cached;
106
- const seg = segments[segName];
107
- const result =
108
- seg !== undefined ? segmentReferencesMenu(seg.template) : false;
109
- hostsMenu.set(segName, result);
110
- return result;
111
- };
112
-
113
- // One state key per row (default "closed"); one cycle action per (row,segment).
114
- const stateKeys = new Map<string, string>(); // stateKey → rowKey (for vars)
115
- const actions: Record<string, ActionDecl> = {};
116
- forEachSegmentPlacement(out.root, (segName, rowKey) => {
117
- if (!referencesMenu(segName)) return;
118
- // [LAW:types-are-the-program] A menu's member name IS its host segment name;
119
- // a segment named exactly the closed-state sentinel would make the cycle
120
- // [closed, "closed"] two identical members, so the toggle could never leave
121
- // the closed state. Reject that one collision at load (the only segment name
122
- // that breaks a menu), rather than silently synthesizing an unopenable menu.
123
- if (segName === MENU_CLOSED) {
142
+ // [LAW:locality-or-seam] A menu publishes its placement ONLY around the segment
143
+ // `template` eval; bg/fg evaluate after that window and a node/segment `when`
144
+ // before it, so a {{ menu }} in any of them throws at render. The template is
145
+ // the menu's one valid seam reject it anywhere else at load, rather than
146
+ // admit a config that parses but crashes on render.
147
+ for (const [segName, seg] of Object.entries(segments)) {
148
+ for (const field of ["bg", "fg", "when"] as const) {
149
+ const tpl = seg[field];
150
+ if (typeof tpl === "string" && segmentReferencesMenu(tpl)) {
151
+ menuIssue(
152
+ ctx,
153
+ `segments.${segName}.${field}`,
154
+ `segment "${segName}" uses {{ menu }} in its "${field}" — a menu is only valid in a segment's "template" (its placement is published only there; "${field}" needs a ${field === "when" ? "predicate" : "color"}). Move the {{ menu }} into the template.`,
155
+ );
156
+ }
157
+ }
158
+ }
159
+
160
+ // One walk over the layout: reject {{ menu }} in node `when` predicates (same
161
+ // template-only rule), and count each segment's placements for the reuse check.
162
+ const placementCounts = new Map<string, number>();
163
+ if (out.root !== undefined) {
164
+ for (const node of walkNodes(out.root)) {
165
+ if (typeof node.when === "string" && segmentReferencesMenu(node.when)) {
166
+ menuIssue(
167
+ ctx,
168
+ "root",
169
+ `a layout node's "when" predicate uses {{ menu }} — a menu is only valid in a segment's "template", not a node predicate. Move it into a segment.`,
170
+ );
171
+ }
172
+ if (node.kind === "segment") {
173
+ placementCounts.set(
174
+ node.name,
175
+ (placementCounts.get(node.name) ?? 0) + 1,
176
+ );
177
+ }
178
+ }
179
+ }
180
+
181
+ // [LAW:types-are-the-program] A menu-bearing segment placed in more than one
182
+ // layout slot is ambiguous: its disclosure open-state is keyed by segment name
183
+ // (one state for the segment), so two placements would share it and a click on
184
+ // one would toggle both. Reject the repeated placement — the ambiguity is
185
+ // unrepresentable, and identity stays name-derived (no placement path threaded
186
+ // into the key). Two independent disclosures = two named segments.
187
+ for (const [segName, seg] of Object.entries(segments)) {
188
+ if (!segmentReferencesMenu(seg.template)) continue;
189
+ if ((placementCounts.get(segName) ?? 0) > 1) {
124
190
  menuIssue(
125
191
  ctx,
126
192
  `segments.${segName}`,
127
- `segment "${segName}" hosts a {{ menu }}, but a menu cannot live in a segment named "${MENU_CLOSED}" that name collides with the menu's closed-state sentinel, leaving the menu unopenable. Rename the segment.`,
193
+ `segment "${segName}" hosts a {{ menu }} and is placed in the layout more than oncea menu's open-state is keyed by segment name, so the copies would share one state (clicking one would toggle both). Give each placement its own named segment.`,
128
194
  );
129
- return;
130
195
  }
131
- const stateKey = menuStateKey(rowKey);
132
- stateKeys.set(stateKey, rowKey);
133
- // [LAW:one-source-of-truth] Members ordered closed-first: an unset/foreign
134
- // value counts as the first member (the cycle's "unknown ⇒ first" rule), so a
135
- // never-clicked menu renders and a click opens it, auto-closing a row-mate
136
- // because the shared key can hold only one member name.
137
- actions[menuActionName(rowKey, segName)] = {
138
- set: stateKey,
139
- cycle: [MENU_CLOSED, segName],
140
- };
141
- });
196
+ }
197
+
198
+ // One state var per state key (default "closed"); one cycle action per
199
+ // (stateKey, member). [LAW:dataflow-not-control-flow] Independent menus each
200
+ // contribute their own key; shared-key menus contribute distinct members to one
201
+ // key, and the same-key validator merge unions them into one accordion gate.
202
+ const stateKeys = new Set<string>();
203
+ const actions: Record<string, ActionDecl> = {};
204
+ // Guard against two menus claiming one identity (same key + same member): for
205
+ // independent menus that means the literal same `{{ menu }}` twice in a
206
+ // segment; for shared-key menus it means two menus with the same apply name
207
+ // sharing a key — neither can be addressed distinctly, so reject.
208
+ const claimed = new Set<string>();
209
+ // [LAW:types-are-the-program] The state key is `ident()`-normalized so it carries
210
+ // no separators; that normalization is lossy (`a-b` and `a_b` collapse), so two
211
+ // DISTINCT declarations could map to one key and silently share open-state (an
212
+ // unintended accordion). Track the raw "owner" each key legitimately belongs to
213
+ // — a shared key is owned by its raw key string (every sibling agrees); an
214
+ // independent menu by its raw (segment, apply). A second owner on the same key
215
+ // is a normalization collision, rejected at load so it is unrepresentable
216
+ // [LAW:no-silent-failure] rather than corrupting grouping.
217
+ const ownerByStateKey = new Map<string, string>();
218
+
219
+ for (const [segName, seg] of Object.entries(segments)) {
220
+ if (!segmentReferencesMenu(seg.template)) continue;
221
+ const calls = parseCalls(seg.template);
222
+ if (calls === "parse-failed") continue;
223
+ for (const call of calls) {
224
+ if (call.apply === null) {
225
+ menuIssue(
226
+ ctx,
227
+ `segments.${segName}`,
228
+ `segment "${segName}" has a {{ menu }} whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" "themePage" }}).`,
229
+ );
230
+ continue;
231
+ }
232
+ if (call.key === null) {
233
+ menuIssue(
234
+ ctx,
235
+ `segments.${segName}`,
236
+ `segment "${segName}" has a {{ menu }} whose accordion key is not a string literal — a shared key must be a literal so the mutually-exclusive group can be gated at load (e.g. {{ menu "applyTheme" "themePage" false false "pickers" }}).`,
237
+ );
238
+ continue;
239
+ }
240
+ // [LAW:types-are-the-program] An empty shared key collapses the state key to
241
+ // the bare reserved `menus.` namespace (and a `menus..member` action name).
242
+ // Reject it — a shared key, when present, must name a group.
243
+ if (call.key === "") {
244
+ menuIssue(
245
+ ctx,
246
+ `segments.${segName}`,
247
+ `segment "${segName}" has a {{ menu }} with an empty accordion key — a shared key must be a non-empty name (or omit it for an independent menu).`,
248
+ );
249
+ continue;
250
+ }
251
+ // [LAW:types-are-the-program] An empty apply name → empty member, and the
252
+ // store returns "" for an absent state key, so `open = read === member`
253
+ // would be true before any click — the menu would render open spuriously.
254
+ // Reject it (the member must never alias the absent-state sentinel).
255
+ if (call.apply === "") {
256
+ menuIssue(
257
+ ctx,
258
+ `segments.${segName}`,
259
+ `segment "${segName}" has a {{ menu }} with an empty apply-action name — an empty member aliases the absent-state sentinel ("") so the menu would render open before any click. Name the apply action.`,
260
+ );
261
+ continue;
262
+ }
263
+ const member = menuMember(call.apply);
264
+ // [LAW:types-are-the-program] A member equal to the closed sentinel makes
265
+ // the cycle [closed, "closed"] — two identical members, leaving the menu
266
+ // unopenable. The only apply name that breaks a menu; reject it at load.
267
+ if (member === MENU_CLOSED) {
268
+ menuIssue(
269
+ ctx,
270
+ `segments.${segName}`,
271
+ `segment "${segName}" has a {{ menu }} whose apply action is named "${MENU_CLOSED}", which collides with the menu's closed-state sentinel and leaves it unopenable. Rename the action.`,
272
+ );
273
+ continue;
274
+ }
275
+ const stateKey = menuStateKey(segName, call.apply, call.key);
276
+ // The raw declaration this key legitimately belongs to. Shared-key siblings
277
+ // all share one owner (their raw key); an independent menu owns its key alone
278
+ // (its raw segment+apply, NUL-joined so the two parts can't run together).
279
+ const owner =
280
+ call.key !== undefined
281
+ ? `key${call.key}`
282
+ : `ind${segName}${call.apply}`;
283
+ const priorOwner = ownerByStateKey.get(stateKey);
284
+ if (priorOwner !== undefined && priorOwner !== owner) {
285
+ menuIssue(
286
+ ctx,
287
+ `segments.${segName}`,
288
+ `two {{ menu }} disclosures normalize to the same state key ("${stateKey}") but were declared differently — distinct names that differ only by non-alphanumeric characters (e.g. "a-b" vs "a_b") collapse to one key and would silently share open-state. Rename so they don't collide.`,
289
+ );
290
+ continue;
291
+ }
292
+ ownerByStateKey.set(stateKey, owner);
293
+ const identity = menuActionName(stateKey, member);
294
+ if (claimed.has(identity)) {
295
+ menuIssue(
296
+ ctx,
297
+ `segments.${segName}`,
298
+ `two {{ menu }} disclosures resolve to the same identity ("${identity}") — ${
299
+ call.key !== undefined
300
+ ? `menus sharing key "${call.key}" must have distinct apply actions`
301
+ : `a segment cannot contain two menus over the same apply action "${call.apply}"`
302
+ }.`,
303
+ );
304
+ continue;
305
+ }
306
+ claimed.add(identity);
307
+ stateKeys.add(stateKey);
308
+ // [LAW:one-source-of-truth] Members ordered closed-first: an unset/foreign
309
+ // value counts as the first member (the cycle's "unknown ⇒ first" rule), so
310
+ // a never-clicked menu renders ▸ and a click opens it; a shared key holding
311
+ // one member auto-closes its siblings.
312
+ actions[identity] = { set: stateKey, cycle: [MENU_CLOSED, member] };
313
+ }
314
+ }
142
315
 
143
316
  if (stateKeys.size === 0) return;
144
317
 
145
318
  const variables: Record<string, VariableDecl> = {};
146
- for (const stateKey of stateKeys.keys()) {
319
+ for (const stateKey of stateKeys) {
147
320
  variables[stateKey] = {
148
321
  kind: "state",
149
322
  key: stateKey,
@@ -54,25 +54,28 @@ export function extractActionRefs(template: string): Set<string> {
54
54
  return refs;
55
55
  }
56
56
 
57
- // [LAW:dataflow-not-control-flow] Extract the action names a `picker` call
58
- // references — its FIRST TWO string-literal args are the apply action and the
59
- // page action (`{{ picker "applyTheme" "themePage" true true }}`). Same code/
60
- // string-span walk as extractActionRefs; the picker keyword arms the next literal
61
- // as the apply name and the one after it as the page name (the trailing bool args
62
- // are not string literals, so they are never captured).
63
- const PICKER_ARG_RE = /\bpicker\s+$/;
64
- export function extractPickerRefs(template: string): Set<string> {
57
+ // [LAW:dataflow-not-control-flow] Extract the action names a `picker` OR `menu`
58
+ // call references — both bind the SAME (apply, page) action pair as their FIRST
59
+ // TWO string-literal args (`{{ picker "applyTheme" "themePage" true true }}`,
60
+ // `{{ menu "applyTheme" "themePage" false true "key" }}`). A menu's body IS a
61
+ // picker, so the existence check on those two refs is identical; one extractor
62
+ // arms on either keyword [LAW:single-enforcer]. Same code/string-span walk as
63
+ // extractActionRefs; the keyword arms the next literal as the apply name and the
64
+ // one after as the page name (trailing bools/the menu key are captured only if
65
+ // they fall in the first two literal slots, which they never do).
66
+ const PICKER_OR_MENU_ARG_RE = /\b(?:picker|menu)\s+$/;
67
+ export function extractPickerMenuRefs(template: string): Set<string> {
65
68
  const refs = new Set<string>();
66
69
  TEMPLATE_BLOCK_RE.lastIndex = 0;
67
70
  let m: RegExpExecArray | null;
68
71
  while ((m = TEMPLATE_BLOCK_RE.exec(template)) !== null) {
69
72
  const block = m[1]!;
70
73
  let cursor = 0;
71
- let pending = 0; // remaining name args to capture for the current picker call
74
+ let pending = 0; // remaining name args to capture for the current call
72
75
  let s: RegExpExecArray | null;
73
76
  STRING_LITERAL_RE.lastIndex = 0;
74
77
  while ((s = STRING_LITERAL_RE.exec(block)) !== null) {
75
- if (PICKER_ARG_RE.test(block.slice(cursor, s.index))) pending = 2;
78
+ if (PICKER_OR_MENU_ARG_RE.test(block.slice(cursor, s.index))) pending = 2;
76
79
  if (pending > 0) {
77
80
  refs.add(s[0].slice(1, -1));
78
81
  pending--;