@promptctl/cc-candybar 1.8.0 → 1.8.2
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/dist/index.mjs +37 -38
- package/package.json +6 -6
- package/src/config/dsl-loader.ts +1 -1
- package/src/config/loader/cross-ref.ts +8 -6
- package/src/config/loader/menu-synth.ts +232 -59
- package/src/config/loader/refs.ts +13 -10
- package/src/config/menu-keys.ts +54 -77
- package/src/dsl/node-registry.ts +46 -43
- package/src/dsl/render.ts +26 -29
- package/src/render/menu.ts +81 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
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.
|
|
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.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.8.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.8.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.8.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.8.2",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.8.2",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.8.2",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.8.2"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
package/src/config/dsl-loader.ts
CHANGED
|
@@ -71,7 +71,7 @@ export { mergeWithDefault } from "./loader/merge.js";
|
|
|
71
71
|
export {
|
|
72
72
|
extractTemplateRefs,
|
|
73
73
|
extractActionRefs,
|
|
74
|
-
|
|
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
|
-
|
|
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" … }}`
|
|
149
|
-
// named actions — both resolve against
|
|
150
|
-
// existence-check shape as a bare action ref.
|
|
151
|
-
|
|
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
|
|
6
|
-
// and one `cycle` action per (
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
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
|
|
67
|
+
function parseCalls(template: string): readonly MenuCall[] | "parse-failed" {
|
|
44
68
|
const engine = createEngine<string>({ fromString: (s) => s });
|
|
45
69
|
try {
|
|
46
|
-
return engine
|
|
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
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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
|
|
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:
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 }}
|
|
193
|
+
`segment "${segName}" hosts a {{ menu }} and is placed in the layout more than once — a 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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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`
|
|
58
|
-
// references —
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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 (
|
|
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--;
|