@promptctl/cc-candybar 1.18.1 → 1.20.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.
@@ -30,7 +30,15 @@ import {
30
30
  type VariableDecl,
31
31
  } from "../dsl-types.js";
32
32
  import type { ActionDecl } from "../action.js";
33
+ import {
34
+ DISCLOSURE_CLOSED,
35
+ DISCLOSURE_GLYPH_CLOSED,
36
+ DISCLOSURE_GLYPH_OPEN,
37
+ disclosureCycleAction,
38
+ disclosureStateVar,
39
+ } from "../disclosure.js";
33
40
  import { findKeyLine } from "./diagnostics.js";
41
+ import { reservedNamespaceCollisions } from "./reserved-namespace.js";
34
42
  import {
35
43
  describeType,
36
44
  describeValue,
@@ -385,9 +393,11 @@ export const validateRoot = (
385
393
  // prefix is rejected so synthesis can never silently collide.
386
394
  export const GROUP_NS = "groups.";
387
395
 
388
- // The "no group open" sentinel a group's cycle starts from. Group names are
389
- // forbidden from equaling it, so a cycle's two members are always distinct.
390
- const GROUP_CLOSED = "closed";
396
+ // [LAW:one-source-of-truth] The closed sentinel and ▸/▾ glyphs are the shared
397
+ // disclosure primitive (src/config/disclosure.ts) a group is one of its two
398
+ // body-kinds, so it reuses DISCLOSURE_CLOSED / DISCLOSURE_GLYPH_* rather than
399
+ // keeping a second copy that could drift from the menu's. Group names are
400
+ // forbidden from equaling the sentinel, so a cycle's two members are distinct.
391
401
 
392
402
  // [LAW:types-are-the-program] A group name must be template-addressable — it is
393
403
  // spliced into the synthesized `when` predicate and toggle template as
@@ -396,9 +406,6 @@ const GROUP_CLOSED = "closed";
396
406
  // dots, so a name needs no escaping anywhere it is spliced.
397
407
  const GROUP_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
398
408
 
399
- const GROUP_GLYPH_CLOSED = "▸";
400
- const GROUP_GLYPH_OPEN = "▾";
401
-
402
409
  function groupNameSpec(): FieldSpec<string> {
403
410
  return {
404
411
  required: true,
@@ -408,11 +415,11 @@ function groupNameSpec(): FieldSpec<string> {
408
415
  if (
409
416
  typeof v !== "string" ||
410
417
  !GROUP_NAME_RE.test(v) ||
411
- v === GROUP_CLOSED
418
+ v === DISCLOSURE_CLOSED
412
419
  ) {
413
420
  ctx.issues.push({
414
421
  path: `${path}.${field}`,
415
- message: `a group "name" must be an identifier (letters, digits, _; not starting with a digit) and not the reserved "${GROUP_CLOSED}", got ${describeValue(v)}`,
422
+ message: `a group "name" must be an identifier (letters, digits, _; not starting with a digit) and not the reserved "${DISCLOSURE_CLOSED}", got ${describeValue(v)}`,
416
423
  line: findKeyLine(ctx.source, ["root"]),
417
424
  });
418
425
  return undefined;
@@ -568,21 +575,18 @@ export function synthesizeGroupDecls(
568
575
  ctx: ValidateCtx,
569
576
  out: Mutable<RawDslConfig>,
570
577
  ): void {
578
+ // [LAW:single-enforcer] The disclosure primitive's shared reserved-namespace
579
+ // enforcer (mirroring {{ menu }}'s `menus.`) — a user name under `groups.`
580
+ // would silently shadow a synthesized artifact. Reserved UNCONDITIONALLY,
581
+ // before the no-groups early return, so the reservation is a stable contract
582
+ // ("you never author groups.*"), not a rule that only switches on when a
583
+ // group node happens to be declared this load — same placement as the menus
584
+ // pass (synthesizeMenuDecls).
585
+ reservedNamespaceCollisions(ctx, out, GROUP_NS, "group nodes");
586
+
571
587
  const groups = ctx.groups;
572
588
  if (groups.length === 0) return;
573
589
 
574
- for (const section of ["variables", "actions", "segments"] as const) {
575
- for (const name of Object.keys(out[section] ?? {})) {
576
- if (name.startsWith(GROUP_NS)) {
577
- groupIssue(
578
- ctx,
579
- `${section}.${name}`,
580
- `"${name}" is in the reserved "${GROUP_NS}" namespace (synthesized by group nodes) — rename it`,
581
- );
582
- }
583
- }
584
- }
585
-
586
590
  const seen = new Set<string>();
587
591
  for (const g of groups) {
588
592
  if (seen.has(g.name)) {
@@ -617,10 +621,10 @@ export function synthesizeGroupDecls(
617
621
  const defaultByKey = new Map<string, string>();
618
622
  for (const g of groups) {
619
623
  const key = groupStateKey(g);
620
- if (!defaultByKey.has(key)) defaultByKey.set(key, GROUP_CLOSED);
624
+ if (!defaultByKey.has(key)) defaultByKey.set(key, DISCLOSURE_CLOSED);
621
625
  if (g.open === true) {
622
626
  const prior = defaultByKey.get(key)!;
623
- if (prior !== GROUP_CLOSED) {
627
+ if (prior !== DISCLOSURE_CLOSED) {
624
628
  groupIssue(
625
629
  ctx,
626
630
  g.path,
@@ -645,22 +649,20 @@ export function synthesizeGroupDecls(
645
649
  (other) => other !== g && g.path.startsWith(other.path + "."),
646
650
  ).length;
647
651
  const indent = " ".repeat(depth);
648
- variables[name] = {
649
- kind: "state",
650
- key,
651
- default: defaultByKey.get(key)!,
652
- };
653
- // Members are ordered default-state-first ("closed" first): an unset or
654
- // sibling-held key counts as the first member, so the toggle renders ▸ and
655
- // clicks to its own name — expand, auto-closing the sibling on a shared key.
656
- actions[name] = { set: key, cycle: [GROUP_CLOSED, g.name] };
652
+ // [LAW:one-source-of-truth] The shared disclosure toggle: one state var + one
653
+ // binary cycle action, both from the primitive. Members are ordered default-
654
+ // state-first (closed first): an unset or sibling-held key counts as the first
655
+ // member, so the toggle renders ▸ and clicks to its own name — expand, auto-
656
+ // closing the sibling on a shared key.
657
+ variables[name] = disclosureStateVar(key, defaultByKey.get(key)!);
658
+ actions[name] = disclosureCycleAction(key, g.name);
657
659
  // [LAW:representation] The disclosure glyph trails the label it gates, so an
658
660
  // arrow reads as belonging to the text on its LEFT — adjacent toggles
659
661
  // ("details ▸" "links ▸") stay unambiguous even when abutted. `indent` is a
660
662
  // structural left-margin (nesting depth) and stays leading; the glyph is a
661
663
  // trailing affordance on the label, never a prefix.
662
664
  segments[name] = {
663
- template: `{{ action "${name}" "${indent}${label} ${GROUP_GLYPH_CLOSED}" "${indent}${label} ${GROUP_GLYPH_OPEN}" }}`,
665
+ template: `{{ action "${name}" "${indent}${label} ${DISCLOSURE_GLYPH_CLOSED}" "${indent}${label} ${DISCLOSURE_GLYPH_OPEN}" }}`,
664
666
  ...(g.bg !== undefined && { bg: g.bg }),
665
667
  ...(g.fg !== undefined && { fg: g.fg }),
666
668
  };
@@ -3,80 +3,140 @@
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
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
- // `menus.` namespace. Both land in the raw sections so they merge over the
8
- // default and, crucially, so `deriveActionValidators(config.actions)` derives the
9
- // click gate from them through the ONE existing path a menu toggle is gated
6
+ // "closed"), one `cycle` action per (state key, member), AND — bn5.6 — the
7
+ // picker body's page cursor (a `state` var + int action named by menuPageKey)
8
+ // per state key, all under the reserved `menus.` namespace. Everything lands in
9
+ // the raw sections so it merges over the default and, crucially, so
10
+ // `deriveActionValidators(config.actions)` derives the click gates from them
11
+ // through the ONE existing path — a menu toggle and its page cursor are gated
10
12
  // like every other set, no parallel verb. [LAW:single-enforcer]
11
13
  //
12
14
  // Runs in `parseDslConfig` after group synthesis and after every section parsed,
13
15
  // so the reserved-namespace collision check sees the fully-parsed user sections.
14
16
  //
15
17
  // [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).
18
+ // action + options, is read from the parsed AST (`referencedCalls` → `argExprs`
19
+ // + `staticDictEntries`), not a source-text scan — robust against whitespace,
20
+ // pipelines, and `.menu`/"menu" lookalikes, and it yields each call's literal
21
+ // apply name and literal `(dict …)` options so a menu's identity (member =
22
+ // apply name; key = the optional "key" option) is the SAME fact the render
23
+ // helper reads from those same arguments. A parse failure here is treated as
24
+ // "no menu" because the authoritative parse error is surfaced loudly by
25
+ // `registerDslConfig` when it compiles the same template (so this pass never
26
+ // swallows a real error, it just declines to guess).
24
27
 
25
- import { createEngine } from "@promptctl/go-template-js";
28
+ import {
29
+ createEngine,
30
+ staticDictEntries,
31
+ type ReferencedCall,
32
+ } from "@promptctl/go-template-js";
26
33
  import type { ActionDecl } from "../action.js";
27
34
  import type { Mutable, ValidateCtx } from "./validate-core.js";
28
35
  import {
29
- MENU_CLOSED,
30
36
  MENU_NS,
31
37
  menuActionName,
32
38
  menuMember,
39
+ menuPageKey,
33
40
  menuStateKey,
41
+ parseMenuOptions,
42
+ type MenuOptions,
34
43
  } from "../menu-keys.js";
44
+ import {
45
+ DISCLOSURE_CLOSED,
46
+ disclosureCycleAction,
47
+ disclosureStateVar,
48
+ } from "../disclosure.js";
35
49
  import {
36
50
  walkNodes,
37
51
  type RawDslConfig,
38
52
  type VariableDecl,
39
53
  } from "../dsl-types.js";
40
54
  import { findKeyLine } from "./diagnostics.js";
55
+ import { reservedNamespaceCollisions } from "./reserved-namespace.js";
41
56
 
42
57
  // [LAW:single-enforcer] The helper-name a `{{ menu … }}` call uses — the same
43
58
  // string the render FuncMap registers. A segment "hosts a menu" iff its template
44
59
  // references this function.
45
60
  const MENU_FUNC = "menu";
46
61
 
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;
62
+ // [LAW:types-are-the-program] The `{{ menu }}` surface, mirroring the render
63
+ // helper's signature `menu "apply" [(dict …)]`: the apply name (identity member,
64
+ // a required string literal) and ONE optional trailing options dict
65
+ // closeOnPick / paged / key, all statically readable via `staticDictEntries`.
66
+ // The removed positional tail (page-action string, bare bools, 5th-arg key) is
67
+ // detected and rejected with a migration-pointing error, never silently
68
+ // reinterpreted [LAW:no-silent-failure].
69
+ const MIGRATION = `the positional tail ("pageAction" closeOnPick paged "key") was removed: the page cursor is now synthesized from the menu's identity, and rare knobs are named options in ONE trailing dict — write {{ menu "applyTheme" }} or {{ menu "applyTheme" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key)`;
70
+
71
+ // [LAW:dataflow-not-control-flow] One total analysis of a `{{ menu }}` call site:
72
+ // every reachable argument shape lands in exactly one arm — a usable identity
73
+ // (apply + parsed options) or one load-error message (the tail after
74
+ // `segment "<name>" has a {{ menu }} `). No shape falls through to render time.
75
+ type MenuAnalysis =
76
+ | {
77
+ readonly kind: "ok";
78
+ readonly apply: string;
79
+ readonly options: MenuOptions;
80
+ }
81
+ | { readonly kind: "issue"; readonly message: string };
53
82
 
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;
83
+ function analyzeMenuCall(call: ReferencedCall): MenuAnalysis {
84
+ const issue = (message: string): MenuAnalysis => ({ kind: "issue", message });
85
+ const [applyArg, optsArg] = call.argExprs;
86
+ if (applyArg === undefined) {
87
+ return issue(
88
+ `with no arguments — it takes an apply-action name (e.g. {{ menu "applyTheme" }})`,
89
+ );
90
+ }
91
+ if (call.argExprs.length > 2) {
92
+ return issue(`with more than two arguments — ${MIGRATION}`);
93
+ }
94
+ if (applyArg.kind !== "literal" || typeof applyArg.value !== "string") {
95
+ return issue(
96
+ `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" }})`,
97
+ );
98
+ }
99
+ if (
100
+ optsArg !== undefined &&
101
+ (optsArg.kind !== "call" || optsArg.name !== "dict")
102
+ ) {
103
+ // A literal (the old page-action string / positional bool), a dynamic value,
104
+ // or a non-dict call: none is an options dict — one migration error covers
105
+ // the whole family [LAW:one-type-per-behavior].
106
+ return issue(
107
+ `whose second argument is not an options (dict …) — ${MIGRATION}`,
108
+ );
109
+ }
110
+ const entries = optsArg === undefined ? {} : staticDictEntries(optsArg);
111
+ if (entries === null) {
112
+ return issue(
113
+ `whose options (dict …) is not fully literal — every option value must be a literal so the menu can be gated at load (a dynamic entry like (dict "key" .x) cannot)`,
114
+ );
115
+ }
116
+ try {
117
+ return {
118
+ kind: "ok",
119
+ apply: applyArg.value,
120
+ options: parseMenuOptions(entries),
121
+ };
122
+ } catch (e) {
123
+ return issue(`with invalid options — ${(e as Error).message}`);
124
+ }
62
125
  }
63
126
 
64
127
  // [LAW:no-defensive-null-guards] A bare engine purely for AST introspection: it
65
128
  // never evaluates, so `fromString` is identity and no funcs are registered (parse
66
129
  // does not resolve function existence — that is an eval-time concern).
67
- function parseCalls(template: string): readonly MenuCall[] | "parse-failed" {
130
+ function parseCalls(
131
+ template: string,
132
+ ): readonly MenuAnalysis[] | "parse-failed" {
68
133
  const engine = createEngine<string>({ fromString: (s) => s });
69
134
  try {
70
135
  return engine
71
136
  .parse(template)
72
137
  .referencedCalls()
73
138
  .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
- }));
139
+ .map(analyzeMenuCall);
80
140
  } catch {
81
141
  // A malformed template can host no usable menu; registerDslConfig re-parses
82
142
  // and reports the real error. [LAW:no-silent-failure] — not swallowed, just
@@ -106,18 +166,9 @@ export function synthesizeMenuDecls(
106
166
  // user name under it is rejected whether or not any menu is placed this load, so
107
167
  // the reservation is a stable contract ("you never author menus.*"), not a rule
108
168
  // that only switches on when synthesis happens to collide. Runs before any early
109
- // return so a `menus.*` user name can never load silently.
110
- for (const section of ["variables", "actions", "segments"] as const) {
111
- for (const name of Object.keys(out[section] ?? {})) {
112
- if (name.startsWith(MENU_NS)) {
113
- menuIssue(
114
- ctx,
115
- `${section}.${name}`,
116
- `"${name}" is in the reserved "${MENU_NS}" namespace (synthesized by {{ menu }} helpers) — rename it`,
117
- );
118
- }
119
- }
120
- }
169
+ // return so a `menus.*` user name can never load silently. The check is the
170
+ // disclosure primitive's shared enforcer (mirroring group sugar's `groups.`).
171
+ reservedNamespaceCollisions(ctx, out, MENU_NS, "{{ menu }} helpers");
121
172
 
122
173
  // [LAW:no-silent-failure] A menu derives its identity from the SEGMENT it sits
123
174
  // in (the published segment name) plus its own apply arg; a `{{ define }}`
@@ -196,9 +247,10 @@ export function synthesizeMenuDecls(
196
247
  }
197
248
 
198
249
  // 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.
250
+ // (stateKey, member); one page-cursor var + int action per state key.
251
+ // [LAW:dataflow-not-control-flow] Independent menus each contribute their own
252
+ // key; shared-key menus contribute distinct members to one key, and the
253
+ // same-key validator merge unions them into one accordion gate.
202
254
  const stateKeys = new Set<string>();
203
255
  const actions: Record<string, ActionDecl> = {};
204
256
  // Guard against two menus claiming one identity (same key + same member): for
@@ -206,53 +258,42 @@ export function synthesizeMenuDecls(
206
258
  // segment; for shared-key menus it means two menus with the same apply name
207
259
  // sharing a key — neither can be addressed distinctly, so reject.
208
260
  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>();
261
+ // [LAW:types-are-the-program] A synthesized key is `ident()`-normalized so it
262
+ // carries no separators; that normalization is lossy (`a-b` and `a_b`
263
+ // collapse), so two DISTINCT declarations could map to one key and silently
264
+ // share state (an unintended accordion). Track the raw "owner" each
265
+ // synthesized name (state key AND its derived page key) legitimately belongs
266
+ // to a shared key is owned by its raw key string (every sibling agrees); an
267
+ // independent menu by its raw (segment, apply). A second owner on the same
268
+ // name is a collision, rejected at load so it is unrepresentable
269
+ // [LAW:no-silent-failure] rather than corrupting grouping. Registering the
270
+ // page key too closes the cross-shape aliasing corner (e.g. an apply action
271
+ // named "page" in segment "s" vs the page cursor of a shared key "s").
272
+ const ownerBySynthKey = new Map<string, string>();
218
273
 
219
274
  for (const [segName, seg] of Object.entries(segments)) {
220
275
  if (!segmentReferencesMenu(seg.template)) continue;
221
276
  const calls = parseCalls(seg.template);
222
277
  if (calls === "parse-failed") continue;
223
278
  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) {
279
+ // [LAW:no-silent-failure] Every non-ok argument shape (missing apply,
280
+ // non-literal apply, the removed positional tail, a non-literal or
281
+ // malformed options dict) surfaces here as one load error with the
282
+ // analysis's migration-pointing text.
283
+ if (call.kind === "issue") {
233
284
  menuIssue(
234
285
  ctx,
235
286
  `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).`,
287
+ `segment "${segName}" has a {{ menu }} ${call.message}.`,
248
288
  );
249
289
  continue;
250
290
  }
291
+ const { apply, options } = call;
251
292
  // [LAW:types-are-the-program] An empty apply name → empty member, and the
252
293
  // store returns "" for an absent state key, so `open = read === member`
253
294
  // would be true before any click — the menu would render open spuriously.
254
295
  // Reject it (the member must never alias the absent-state sentinel).
255
- if (call.apply === "") {
296
+ if (apply === "") {
256
297
  menuIssue(
257
298
  ctx,
258
299
  `segments.${segName}`,
@@ -260,56 +301,62 @@ export function synthesizeMenuDecls(
260
301
  );
261
302
  continue;
262
303
  }
263
- const member = menuMember(call.apply);
304
+ const member = menuMember(apply);
264
305
  // [LAW:types-are-the-program] A member equal to the closed sentinel makes
265
306
  // the cycle [closed, "closed"] — two identical members, leaving the menu
266
307
  // unopenable. The only apply name that breaks a menu; reject it at load.
267
- if (member === MENU_CLOSED) {
308
+ if (member === DISCLOSURE_CLOSED) {
268
309
  menuIssue(
269
310
  ctx,
270
311
  `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.`,
312
+ `segment "${segName}" has a {{ menu }} whose apply action is named "${DISCLOSURE_CLOSED}", which collides with the menu's closed-state sentinel and leaves it unopenable. Rename the action.`,
272
313
  );
273
314
  continue;
274
315
  }
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).
316
+ const stateKey = menuStateKey(segName, apply, options.key);
317
+ const pageKey = menuPageKey(stateKey);
318
+ // The raw declaration these keys legitimately belong to. Shared-key
319
+ // siblings all share one owner (their raw key); an independent menu owns
320
+ // its keys alone (its raw segment+apply, NUL-joined so the two parts
321
+ // can't run together).
279
322
  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) {
323
+ options.key !== undefined
324
+ ? `key${options.key}`
325
+ : `ind${segName}${apply}`;
326
+ const clashKey = [stateKey, pageKey].find((k) => {
327
+ const prior = ownerBySynthKey.get(k);
328
+ return prior !== undefined && prior !== owner;
329
+ });
330
+ if (clashKey !== undefined) {
285
331
  menuIssue(
286
332
  ctx,
287
333
  `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.`,
334
+ `two {{ menu }} disclosures normalize to the same state key ("${clashKey}") 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
335
  );
290
336
  continue;
291
337
  }
292
- ownerByStateKey.set(stateKey, owner);
338
+ ownerBySynthKey.set(stateKey, owner);
339
+ ownerBySynthKey.set(pageKey, owner);
293
340
  const identity = menuActionName(stateKey, member);
294
341
  if (claimed.has(identity)) {
295
342
  menuIssue(
296
343
  ctx,
297
344
  `segments.${segName}`,
298
345
  `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}"`
346
+ options.key !== undefined
347
+ ? `menus sharing key "${options.key}" must have distinct apply actions`
348
+ : `a segment cannot contain two menus over the same apply action "${apply}"`
302
349
  }.`,
303
350
  );
304
351
  continue;
305
352
  }
306
353
  claimed.add(identity);
307
354
  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] };
355
+ // [LAW:one-source-of-truth] The shared disclosure toggle: members ordered
356
+ // closed-first (an unset/foreign value counts as the first member the
357
+ // cycle's "unknown ⇒ first" rule — so a never-clicked menu renders ▸ and a
358
+ // click opens it; a shared key holding one member auto-closes its siblings).
359
+ actions[identity] = disclosureCycleAction(stateKey, member);
313
360
  }
314
361
  }
315
362
 
@@ -317,11 +364,19 @@ export function synthesizeMenuDecls(
317
364
 
318
365
  const variables: Record<string, VariableDecl> = {};
319
366
  for (const stateKey of stateKeys) {
320
- variables[stateKey] = {
321
- kind: "state",
322
- key: stateKey,
323
- default: MENU_CLOSED,
324
- };
367
+ variables[stateKey] = disclosureStateVar(stateKey, DISCLOSURE_CLOSED);
368
+ // [LAW:one-source-of-truth] The synthesized page cursor — the half a blind
369
+ // author used to hand-declare and forget, silently freezing the picker on
370
+ // page 0 (renderPicker read an unbound key as "" → clamp 0). Both halves
371
+ // are emitted together, named by menuPageKey, so the pairing is a
372
+ // construction, not a convention: the state VAR (named by the key, the
373
+ // disclosure-var convention) is what the renderer reads the live page
374
+ // through; the int ACTION is what deriveActionValidators derives the ←/→/✕
375
+ // wire gate from — the one existing path, no parallel gate
376
+ // [LAW:single-enforcer].
377
+ const pageKey = menuPageKey(stateKey);
378
+ variables[pageKey] = { kind: "state", key: pageKey, default: "0" };
379
+ actions[pageKey] = { set: pageKey, int: true };
325
380
  }
326
381
 
327
382
  out.variables = { ...(out.variables ?? {}), ...variables };
@@ -55,15 +55,15 @@ export function extractActionRefs(template: string): Set<string> {
55
55
  }
56
56
 
57
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+$/;
58
+ // call references, for the load-time existence check. A `picker` binds an
59
+ // (apply, page) action pair as its first two string-literal args
60
+ // (`{{ picker "applyTheme" "themePage" true true }}`); a `menu` binds ONLY its
61
+ // apply action (`{{ menu "applyTheme" (dict …) }}`) its page cursor is
62
+ // synthesized from identity, and the dict's option-name literals must never be
63
+ // misread as action refs. A menu's body IS a picker, so the existence check is
64
+ // identical; one extractor arms on either keyword with the keyword's own arg
65
+ // count [LAW:single-enforcer]. Same code/string-span walk as extractActionRefs.
66
+ const PICKER_OR_MENU_ARG_RE = /\b(picker|menu)\s+$/;
67
67
  export function extractPickerMenuRefs(template: string): Set<string> {
68
68
  const refs = new Set<string>();
69
69
  TEMPLATE_BLOCK_RE.lastIndex = 0;
@@ -75,7 +75,8 @@ export function extractPickerMenuRefs(template: string): Set<string> {
75
75
  let s: RegExpExecArray | null;
76
76
  STRING_LITERAL_RE.lastIndex = 0;
77
77
  while ((s = STRING_LITERAL_RE.exec(block)) !== null) {
78
- if (PICKER_OR_MENU_ARG_RE.test(block.slice(cursor, s.index))) pending = 2;
78
+ const kw = PICKER_OR_MENU_ARG_RE.exec(block.slice(cursor, s.index));
79
+ if (kw !== null) pending = kw[1] === "picker" ? 2 : 1;
79
80
  if (pending > 0) {
80
81
  refs.add(s[0].slice(1, -1));
81
82
  pending--;
@@ -0,0 +1,38 @@
1
+ // [LAW:one-source-of-truth] The loader-side half of the disclosure primitive
2
+ // (src/config/disclosure.ts): the ONE reserved-namespace collision check both
3
+ // synthesis passes run. Group sugar reserves `groups.` and the `{{ menu }}`
4
+ // helper reserves `menus.`; each synthesizes its `state` var, `cycle` action, and
5
+ // (for a group) a toggle segment under its prefix, so a user-authored name under
6
+ // that prefix must be a loud load error — never a silent overwrite of a
7
+ // synthesized artifact. The check was duplicated verbatim in both passes; it lives
8
+ // here now, parameterized by the prefix and a human description of what
9
+ // synthesizes it, so the two body-kinds share one enforcer [LAW:single-enforcer].
10
+
11
+ import type { Mutable, ValidateCtx } from "./validate-core.js";
12
+ import type { RawDslConfig } from "../dsl-types.js";
13
+ import { findKeyLine } from "./diagnostics.js";
14
+
15
+ // [LAW:no-silent-failure] Reject every user name under the reserved prefix across
16
+ // all three declaration sections (a synthesized disclosure lands in each), before
17
+ // synthesis writes into them — so a `groups.*`/`menus.*` squatter surfaces as a
18
+ // rename-pointing error rather than being silently shadowed. `synthesizedBy`
19
+ // names the feature in the message (e.g. "group nodes", "{{ menu }} helpers") so
20
+ // the author knows which sugar owns the prefix.
21
+ export function reservedNamespaceCollisions(
22
+ ctx: ValidateCtx,
23
+ out: Mutable<RawDslConfig>,
24
+ ns: string,
25
+ synthesizedBy: string,
26
+ ): void {
27
+ for (const section of ["variables", "actions", "segments"] as const) {
28
+ for (const name of Object.keys(out[section] ?? {})) {
29
+ if (name.startsWith(ns)) {
30
+ ctx.issues.push({
31
+ path: `${section}.${name}`,
32
+ message: `"${name}" is in the reserved "${ns}" namespace (synthesized by ${synthesizedBy}) — rename it`,
33
+ line: findKeyLine(ctx.source, ["root"]),
34
+ });
35
+ }
36
+ }
37
+ }
38
+ }