@promptctl/cc-candybar 1.38.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.38.0",
3
+ "version": "1.39.0",
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",
@@ -91,9 +91,9 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.38.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.38.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.38.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.38.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.39.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.39.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.39.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.39.0"
98
98
  }
99
99
  }
@@ -28,6 +28,14 @@
28
28
 
29
29
  import type { DslConfig, LayoutNode, SegmentDecl } from "./dsl-types.js";
30
30
  import { parseDslConfig } from "./dsl-loader.js";
31
+ // [LAW:one-source-of-truth] The bundled drawer's menus author their own
32
+ // disclosure glyphs now that `{{ menu }}` appends none. Interpolating the
33
+ // shared constants keeps the stdlib bar reading like every other disclosure
34
+ // without restating the vocabulary in three string literals.
35
+ import {
36
+ DISCLOSURE_GLYPH_CLOSED,
37
+ DISCLOSURE_GLYPH_OPEN,
38
+ } from "./disclosure.js";
31
39
  import { mergeWithDefault } from "./loader/merge.js";
32
40
 
33
41
  // ─── Shared template fragments ───────────────────────────────────────────────
@@ -1133,14 +1141,16 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1133
1141
  charsetControl: {
1134
1142
  template:
1135
1143
  "{{ .charset.effective }} " +
1136
- '{{ menu "applyCharsetForever" }} {{ action "resetCharset" "↺" }}',
1144
+ `{{ menu "applyCharsetForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1145
+ '{{ action "resetCharset" "↺" }}',
1137
1146
  bg: "surface",
1138
1147
  fg: "foreground",
1139
1148
  },
1140
1149
  colorCompatControl: {
1141
1150
  template:
1142
1151
  "{{ .colorCompatibility.effective }} " +
1143
- '{{ menu "applyColorCompatForever" }} {{ action "resetColorCompat" "↺" }}',
1152
+ `{{ menu "applyColorCompatForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1153
+ '{{ action "resetColorCompat" "↺" }}',
1144
1154
  bg: "surface",
1145
1155
  fg: "foreground",
1146
1156
  },
@@ -1168,7 +1178,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1168
1178
  directoryPaletteControl: {
1169
1179
  template:
1170
1180
  "🎨 directory " +
1171
- '{{ menu "applyDirectoryPaletteForever" }} ' +
1181
+ `{{ menu "applyDirectoryPaletteForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1172
1182
  '{{ action "resetDirectoryPalette" "↺" }}',
1173
1183
  bg: "surface",
1174
1184
  fg: "foreground",
@@ -32,9 +32,63 @@ export const DISCLOSURE_CLOSED = "closed";
32
32
  // [LAW:representation] The disclosure glyph vocabulary — one pair for the whole
33
33
  // bar so every disclosure reads the same (trailing the label/content it gates,
34
34
  // per pdu.8): collapsed ▸, expanded ▾.
35
+ //
36
+ // [LAW:one-source-of-truth] These are the AUTHORED default, never an emission.
37
+ // Every disclosure splices them into the template it synthesizes — group sugar
38
+ // (loader/layout.ts), the settings menu (settings-menu.ts), the bundled drawer
39
+ // — and a hand-authored config writes whichever glyph it likes, because the
40
+ // trigger's text is a display bound at the call site like any other. Until
41
+ // candybar-settings-ui-aok.4 `{{ menu }}` was the exception, appending ▸/▾ from
42
+ // its own runtime where no author could see or decline it, which is how edit
43
+ // mode's `+` came to render `+▸`.
35
44
  export const DISCLOSURE_GLYPH_CLOSED = "▸";
36
45
  export const DISCLOSURE_GLYPH_OPEN = "▾";
37
46
 
47
+ // [LAW:one-source-of-truth] The glyph that CLOSES an open disclosure. The
48
+ // picker body's ✕ has always been this; it lives here now because a trigger can
49
+ // wear it too — edit mode's `+` does, since a `+` whose only open-state cue was
50
+ // the ▸ this change removed would otherwise be indistinguishable from its
51
+ // siblings (three insertion points render byte-identically when one is open,
52
+ // and their dropped bodies are identical too, so row 0 is the only place the
53
+ // answer can live). Two affordances, one meaning, one glyph.
54
+ export const DISCLOSURE_GLYPH_CLOSE = "✕";
55
+
56
+ // [LAW:single-enforcer] THE display rule every multi-state trigger obeys: bind
57
+ // one display per member, or ONE static display that shows in every state. It
58
+ // lives here, beside the toggle machinery, because both disclosure kinds need
59
+ // it at different times — the loader can count a call's arguments statically
60
+ // and wants an ISSUE to report, the renderer holds the evaluated displays and
61
+ // wants to THROW — and a rule spelled once in each place is a rule that drifts.
62
+ // A `{{ menu }}` folds through it with two members (its `[closed, member]`
63
+ // cycle) and a cycle `{{ action }}` with as many as it declares; nothing about
64
+ // the rule is disclosure-specific beyond who calls it.
65
+ export function cycleDisplayIssue(
66
+ subject: string,
67
+ count: number,
68
+ members: number,
69
+ ): string | undefined {
70
+ if (count === 0) return `${subject} needs a display (the clickable text)`;
71
+ if (count !== 1 && count !== members) {
72
+ return `${subject} cycles ${members} members; bind one display per member (${members}) or one static display, got ${count}`;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ // [LAW:dataflow-not-control-flow] Which display shows is a pure function of
78
+ // (bound displays, current member index): a single static display shows in
79
+ // every state, per-member displays index by the state. Throws the one rule's
80
+ // text rather than silently dropping or repeating an argument.
81
+ export function pickCycleDisplay(
82
+ subject: string,
83
+ displays: readonly string[],
84
+ members: number,
85
+ index: number,
86
+ ): string {
87
+ const issue = cycleDisplayIssue(subject, displays.length, members);
88
+ if (issue !== undefined) throw new Error(issue);
89
+ return displays.length === 1 ? displays[0]! : displays[index]!;
90
+ }
91
+
38
92
  // [LAW:single-enforcer] THE backing `state` variable a disclosure key implies:
39
93
  // it holds the open member's name and defaults to `def` (the CLOSED sentinel for
40
94
  // an independent disclosure, or an initially-open member for a group's
@@ -50,6 +50,7 @@ import {
50
50
  } from "./menu-keys.js";
51
51
  import {
52
52
  DISCLOSURE_CLOSED,
53
+ DISCLOSURE_GLYPH_CLOSE,
53
54
  disclosureCycleAction,
54
55
  disclosureStateVar,
55
56
  } from "./disclosure.js";
@@ -211,8 +212,21 @@ function insertChrome(
211
212
  artifacts.actions[identity] = disclosureCycleAction(stateKey, member);
212
213
  artifacts.actions[pageKey] = { set: pageKey, int: true };
213
214
 
215
+ // The `+` IS the trigger — no appended arrow (candybar-settings-ui-aok.4).
216
+ // Beside a `-` that means something else entirely, a ▸ read as part of the
217
+ // affordance rather than as a disclosure hint, so the trigger names the ACTION
218
+ // its click performs instead: `+` inserts here, `✕` closes what `+` opened —
219
+ // the same glyph, and the same effect, as the body's own close cell.
220
+ //
221
+ // [LAW:no-silent-failure] It is deliberately NOT one static display. A preset
222
+ // has N insertion points whose rendered rows are byte-identical, and their
223
+ // dropped bodies are identical too — so with no per-state display, an open `+`
224
+ // is indistinguishable from the two beside it and the bar silently stops
225
+ // answering "which one did I open". The tint that marks other open menus
226
+ // (node-registry's `drops.length > 0`) cannot answer it either: this segment
227
+ // declares no bg, so there is nothing to tint.
214
228
  artifacts.segments[chromeSegName] = {
215
- template: `+{{ menu "${applyName}" }}`,
229
+ template: `{{ menu "${applyName}" "+" "${DISCLOSURE_GLYPH_CLOSE}" }}`,
216
230
  when: EDIT_MODE_GATE,
217
231
  };
218
232
  return { kind: "segment", name: chromeSegName };
@@ -42,6 +42,7 @@ import {
42
42
  type MenuOptions,
43
43
  } from "../menu-keys.js";
44
44
  import {
45
+ cycleDisplayIssue,
45
46
  DISCLOSURE_CLOSED,
46
47
  disclosureCycleAction,
47
48
  disclosureStateVar,
@@ -60,13 +61,16 @@ import { reservedNamespaceCollisions } from "./reserved-namespace.js";
60
61
  const MENU_FUNC = "menu";
61
62
 
62
63
  // [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
64
+ // helper's signature `menu "apply" display… [(dict …)]`: the apply name
65
+ // (identity member, a required string literal), the trigger's authored display
66
+ // text (one per state or one static — the arity is statically countable, so it
67
+ // is checked here), and ONE optional trailing options dict — closeOnPick /
68
+ // paged / key, all statically readable via `staticDictEntries`. Displays
69
+ // themselves are NOT required to be literals; identity does not depend on
70
+ // them, exactly as a cycle `{{ action }}`'s displays are free. Every removed
71
+ // spelling is rejected with a migration-pointing error, never silently
68
72
  // 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)`;
73
+ const MIGRATION = `a menu binds its trigger text the way a cycle action binds a display — write {{ menu "applyTheme" "▸" "▾" }} (one per state) or {{ menu "insertHere" "+" }} (one static display for both), with the rare knobs in ONE trailing dict: {{ menu "applyTheme" "▸" "▾" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key). The renderer no longer appends ▸/▾ of its own (candybar-settings-ui-aok.4), and the older positional tail ("pageAction" closeOnPick paged "key") was removed — the page cursor is synthesized from the menu's identity`;
70
74
 
71
75
  // [LAW:dataflow-not-control-flow] One total analysis of a `{{ menu }}` call site:
72
76
  // every reachable argument shape lands in exactly one arm — a usable identity
@@ -80,33 +84,80 @@ type MenuAnalysis =
80
84
  }
81
85
  | { readonly kind: "issue"; readonly message: string };
82
86
 
87
+ type ArgExpr = ReferencedCall["argExprs"][number];
88
+
89
+ const isDictCall = (e: ArgExpr): boolean =>
90
+ e.kind === "call" && e.name === "dict";
91
+
92
+ // [LAW:one-source-of-truth] The two sides split the tail on different evidence —
93
+ // exprs here, evaluated values in `parseMenuArgs` — so the loader admits only
94
+ // call sites where those two readings PROVABLY coincide. The renderer's split
95
+ // asks one question of the last value, "is it an object", so a literal answers
96
+ // it here: a parse-time constant evaluates to itself and can never become the
97
+ // options dict. A literal `(dict …)` always does. Everything else in that slot
98
+ // is classified by whatever it happens to evaluate to.
99
+ const isNonObjectLiteral = (e: ArgExpr): boolean =>
100
+ e.kind === "literal" && typeof e.value !== "object";
101
+
102
+ // The display-arity rule used as a predicate; the message is the caller's
103
+ // business, so the subject never surfaces. [LAW:single-enforcer] — legality is
104
+ // read off the disclosure primitive, never restated as a count comparison.
105
+ const legalDisplayCount = (count: number): boolean =>
106
+ cycleDisplayIssue("", count, 2) === undefined;
107
+
83
108
  function analyzeMenuCall(call: ReferencedCall): MenuAnalysis {
84
109
  const issue = (message: string): MenuAnalysis => ({ kind: "issue", message });
85
- const [applyArg, optsArg] = call.argExprs;
110
+ const [applyArg, ...tail] = call.argExprs;
86
111
  if (applyArg === undefined) {
87
112
  return issue(
88
- `with no arguments — it takes an apply-action name (e.g. {{ menu "applyTheme" }})`,
113
+ `with no arguments — it takes an apply-action name and its trigger text (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
89
114
  );
90
115
  }
91
- if (call.argExprs.length > 2) {
92
- return issue(`with more than two arguments — ${MIGRATION}`);
93
- }
94
116
  if (applyArg.kind !== "literal" || typeof applyArg.value !== "string") {
95
117
  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" }})`,
118
+ `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" "▸" "▾" }})`,
119
+ );
120
+ }
121
+ // [LAW:types-are-the-program] The dict is the LAST argument when present;
122
+ // everything before it is a display. Splitting on that one position is the
123
+ // whole grammar, and it is the same split `parseMenuArgs` performs on the
124
+ // evaluated tail at render — one shape, read twice from the two things each
125
+ // side has (exprs here, values there).
126
+ const last = tail[tail.length - 1];
127
+ const optsArg = last !== undefined && isDictCall(last) ? last : undefined;
128
+ const displays = optsArg === undefined ? tail : tail.slice(0, -1);
129
+ if (displays.some(isDictCall)) {
130
+ return issue(
131
+ `whose options (dict …) is not its last argument — ${MIGRATION}`,
97
132
  );
98
133
  }
134
+ // [LAW:no-silent-failure] The last slot is the one both readings can claim.
135
+ // When the expr there is not provably one or the other AND dropping it still
136
+ // leaves a legal display count, the renderer's value-based split can land on
137
+ // a DIFFERENT reading than this one — same call, two shapes, no error either
138
+ // side: the options dict skips `staticDictEntries` (so a dynamic `key` derives
139
+ // a state key with no synthesized var behind it, and the menu never opens) or
140
+ // a display vanishes into the static form. Reject that call site; an explicit
141
+ // trailing `(dict …)` disambiguates it and keeps dynamic displays legal.
142
+ // Where the alternate reading is an ILLEGAL count the renderer throws instead
143
+ // of diverging, so it stays accepted — loudness, not refusal, is the bar.
99
144
  if (
100
- optsArg !== undefined &&
101
- (optsArg.kind !== "call" || optsArg.name !== "dict")
145
+ last !== undefined &&
146
+ optsArg === undefined &&
147
+ !isNonObjectLiteral(last) &&
148
+ legalDisplayCount(displays.length - 1)
102
149
  ) {
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
150
  return issue(
107
- `whose second argument is not an options (dict …) — ${MIGRATION}`,
151
+ `whose last argument is neither a literal nor a literal (dict …) — the renderer tells a display from the options dict by the value it evaluates to, so this call could be read as ${displays.length} displays or as ${displays.length - 1} plus options, and both are legal. Make the options explicit as a trailing (dict …) — {{ menu "${applyArg.value}" (printf "…") (printf "…") (dict) }} binds dynamic displays unambiguously — or bind the trigger text as literals`,
108
152
  );
109
153
  }
154
+ // [LAW:single-enforcer] The display-arity rule is the disclosure primitive's,
155
+ // the same one the renderer picks through — checked HERE too because the
156
+ // count is statically known, so an unauthored trigger is a load error naming
157
+ // the fix rather than a diagnostic glyph on the next render.
158
+ // "whose trigger …" completes the caller's `segment "X" has a {{ menu }} `.
159
+ const arity = cycleDisplayIssue("whose trigger", displays.length, 2);
160
+ if (arity !== undefined) return issue(`${arity} — ${MIGRATION}`);
110
161
  const entries = optsArg === undefined ? {} : staticDictEntries(optsArg);
111
162
  if (entries === null) {
112
163
  return issue(
@@ -578,7 +578,8 @@ function declareSettingControls(artifacts: MenuArtifacts): void {
578
578
  artifacts.segments[seg] = {
579
579
  template:
580
580
  `${c.glyph} {{ .${c.effectiveVar} }} ` +
581
- `{{ menu "${apply}" (dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
581
+ `{{ menu "${apply}" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" ` +
582
+ `(dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
582
583
  `{{ action "${controlReset(c.name)}" "↺" }}`,
583
584
  bg: "surface",
584
585
  fg: "foreground",
@@ -32,6 +32,7 @@ import {
32
32
  type ActionDecl,
33
33
  } from "../config/action.js";
34
34
  import { resolveOptionDomain } from "../config/option-domain.js";
35
+ import { pickCycleDisplay } from "../config/disclosure.js";
35
36
  import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
36
37
  import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
37
38
  import {
@@ -800,15 +801,16 @@ function selectDisplay(
800
801
  throw new Error(`action "${name}" needs a display (the clickable text)`);
801
802
  }
802
803
  if (action.kind === "set-cycle" || action.kind === "persist-cycle") {
803
- if (displays.length !== 1 && displays.length !== action.members.length) {
804
- throw new Error(
805
- `action "${name}" cycles ${action.members.length} members; bind one display per member (${action.members.length}) or one static display, got ${displays.length}`,
806
- );
807
- }
808
- const display =
809
- displays.length === 1
810
- ? displays[0]!
811
- : 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
+ );
812
814
  return { display, boundValue: undefined };
813
815
  }
814
816
  if (displays.length > 2) {
@@ -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
  };
@@ -37,8 +37,8 @@ import {
37
37
  type ActionRuntime,
38
38
  type CompiledActionDecl,
39
39
  } from "./action.js";
40
+ import { DISCLOSURE_GLYPH_CLOSE } from "../config/disclosure.js";
40
41
 
41
- const PICKER_CLOSE = "✕";
42
42
  const PICKER_PREV = "←";
43
43
  const PICKER_NEXT = "→";
44
44
 
@@ -242,7 +242,7 @@ export function renderPicker(
242
242
  2 * runtime.padding,
243
243
  )
244
244
  : Infinity;
245
- const closeReserve = cellWidth(PICKER_CLOSE) + 1;
245
+ const closeReserve = cellWidth(DISCLOSURE_GLYPH_CLOSE) + 1;
246
246
  const arrowReserve = cellWidth(PICKER_PREV) + 1 + cellWidth(PICKER_NEXT) + 1;
247
247
  const firstPass = paginate(widths, available, closeReserve);
248
248
  const pages =
@@ -303,7 +303,9 @@ export function renderPicker(
303
303
  : effectsUrl([...effects, ...closeEffect]);
304
304
  };
305
305
 
306
- const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
306
+ const frags: RichText[] = [
307
+ linkFragment(DISCLOSURE_GLYPH_CLOSE, closeUrl, false),
308
+ ];
307
309
  if (pageIdx > 0) {
308
310
  frags.push(linkFragment(PICKER_PREV, pageUrl(pageIdx - 1), false));
309
311
  }