@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.
@@ -28,17 +28,11 @@
28
28
  // can never silently collide.
29
29
  export const MENU_NS = "menus.";
30
30
 
31
- // The "no menu open" sentinel a key starts from and returns to on close. A
32
- // menu's member name is its apply-action name; an apply action named exactly
33
- // this would make the cycle [closed, "closed"] (two identical members, never
34
- // openable), which the synthesis pass rejects.
35
- export const MENU_CLOSED = "closed";
36
-
37
- // [LAW:representation] Disclosure glyph vocabulary — identical to group sugar so
38
- // every disclosure across the bar reads the same (trailing the label/content it
39
- // gates, per pdu.8): collapsed ▸, expanded ▾.
40
- export const MENU_GLYPH_CLOSED = "▸";
41
- export const MENU_GLYPH_OPEN = "▾";
31
+ // [LAW:one-source-of-truth] The closed sentinel and the ▸/▾ glyphs are the shared
32
+ // disclosure primitive, not menu-specific they live in src/config/disclosure.ts
33
+ // (DISCLOSURE_CLOSED / DISCLOSURE_GLYPH_*) so group sugar and {{ menu }} cannot
34
+ // drift. This module keeps only the menu's own IDENTITY derivation (member = apply
35
+ // name; key = optional shared key), which group sugar derives differently.
42
36
 
43
37
  // [LAW:types-are-the-program] Collapse an arbitrary name to an identifier-shaped
44
38
  // id so the synthesized var/action/SessionState-key names carry no dots or
@@ -76,3 +70,76 @@ export function menuStateKey(
76
70
  export function menuActionName(stateKey: string, member: string): string {
77
71
  return stateKey + "." + member;
78
72
  }
73
+
74
+ // [LAW:single-enforcer] THE page-cursor key a menu's picker body paginates by —
75
+ // synthesized by the loader (state var + int action, both named by this key)
76
+ // and derived again by the renderer, so neither side hand-declares or restates
77
+ // it. One cursor PER DISCLOSURE STATE KEY, not per menu: a shared (accordion)
78
+ // key holds at most one open member, so its one open body is the only body the
79
+ // cursor can belong to — sharing is exact by construction, not an
80
+ // approximation — and the disclosure toggle's page-0 reset re-seeds it on
81
+ // every open. [LAW:one-source-of-truth] The synthesized state VARIABLE is
82
+ // named by this same string (the disclosure-var convention), so the renderer
83
+ // reads the live page via this one name.
84
+ export function menuPageKey(stateKey: string): string {
85
+ return stateKey + ".page";
86
+ }
87
+
88
+ // [LAW:types-are-the-program] The `{{ menu }}` rare-knob options, spelled as ONE
89
+ // trailing `(dict …)` argument. Defaults are the canonical path: paged=true (a
90
+ // drop menu wants bounded height; a short domain paginates to one page and shows
91
+ // no arrows anyway), closeOnPick=false (stay-open so options can be tried in a
92
+ // row), key omitted (an independent menu).
93
+ export interface MenuOptions {
94
+ readonly closeOnPick: boolean;
95
+ readonly paged: boolean;
96
+ readonly key: string | undefined;
97
+ }
98
+
99
+ // [LAW:one-source-of-truth] THE reader of a menu's options dict — the loader
100
+ // folds it over `staticDictEntries` (gating identity at load) and the renderer
101
+ // folds it over the evaluated dict object (realizing the body), so the option
102
+ // vocabulary, value types, and defaults live exactly once. An unknown name or a
103
+ // mistyped value throws with text naming the legal shape — the blind authoring
104
+ // agent's teaching channel [LAW:no-silent-failure]; the loader attaches segment
105
+ // context, the renderer surfaces it via composeWithDiagnostics.
106
+ export function parseMenuOptions(
107
+ entries: Readonly<Record<string, unknown>>,
108
+ ): MenuOptions {
109
+ for (const name of Object.keys(entries)) {
110
+ if (name !== "closeOnPick" && name !== "paged" && name !== "key") {
111
+ throw new Error(
112
+ `unknown {{ menu }} option "${name}" — the options dict takes "closeOnPick" (bool, default false), "paged" (bool, default true), "key" (string, accordion grouping)`,
113
+ );
114
+ }
115
+ }
116
+ const bool = (name: "closeOnPick" | "paged", def: boolean): boolean => {
117
+ const v = entries[name];
118
+ if (v === undefined) return def;
119
+ if (typeof v !== "boolean") {
120
+ throw new Error(
121
+ `{{ menu }} option "${name}" must be a boolean, got ${JSON.stringify(v)} (e.g. (dict "${name}" ${String(!def)}))`,
122
+ );
123
+ }
124
+ return v;
125
+ };
126
+ const key = entries["key"];
127
+ if (key !== undefined && typeof key !== "string") {
128
+ throw new Error(
129
+ `{{ menu }} option "key" must be a string naming the accordion group, got ${JSON.stringify(key)}`,
130
+ );
131
+ }
132
+ // [LAW:types-are-the-program] An empty shared key would collapse the state key
133
+ // to the bare reserved `menus.` namespace (and a `menus..member` action name) —
134
+ // a key, when present, must name a group.
135
+ if (key === "") {
136
+ throw new Error(
137
+ `{{ menu }} has an empty accordion key — a shared key must be a non-empty name (or omit "key" for an independent menu)`,
138
+ );
139
+ }
140
+ return {
141
+ closeOnPick: bool("closeOnPick", false),
142
+ paged: bool("paged", true),
143
+ key,
144
+ };
145
+ }
@@ -196,42 +196,35 @@ export function listStateKeys(): readonly string[] {
196
196
  return [..._STATE_VALIDATORS.keys()];
197
197
  }
198
198
 
199
- // [LAW:types-are-the-program] The validator is RESIDUE of the live specs: given
200
- // the (uniform) kind and every live registration's content, the validator is
201
- // forced. An int key builds a parse-boundary validator; an allow-list key builds
202
- // one from the UNION of every live registration's members so a value any live
203
- // config can legitimately render is a value the wire accepts, by construction.
204
- // The label is a pure function of (key, kind) so the built validator is identical
205
- // across registrations of one key. makeIntValidator/makeAllowListValidator are
206
- // the single validator constructors (re-validating slash/empty values), so a
207
- // merged allow-list that somehow held an undeliverable value would throw HERE,
208
- // at config-load, not at the operator's first click.
199
+ // [LAW:types-are-the-program] The validator is RESIDUE of a SETTLED spec: given
200
+ // one merged spec, its validator is forced. This is a pure projection — kind ⇒
201
+ // constructor with NO union or widen of its own. The label is a pure function
202
+ // of (key, kind) so the built validator is identical across registrations of one
203
+ // key. makeIntValidator/makeRangeValidator/makeAllowListValidator are the single
204
+ // validator constructors (re-validating slash/empty values), so a merged spec
205
+ // that somehow held an undeliverable value would throw HERE, at config-load, not
206
+ // at the operator's first click.
207
+ function validatorForSpec(
208
+ key: string,
209
+ spec: DerivedValidatorSpec,
210
+ ): KeyValidator {
211
+ if (spec.kind === "int") return makeIntValidator(`menu page "${key}"`);
212
+ if (spec.kind === "range")
213
+ return makeRangeValidator(spec.min, spec.max, `stepper "${key}"`);
214
+ return makeAllowListValidator(spec.allowed, `state "${key}"`);
215
+ }
216
+
217
+ // [LAW:one-source-of-truth] The validator for a key's live registrations, built
218
+ // through the ONE collapse: mergeKeySpecs unions allow-list members, widens range
219
+ // bounds, clamps the seed, and absorbs integer members — so the union/widen logic
220
+ // lives in exactly one place and this builder is pure plumbing (collapse → project).
221
+ // A value any live config can legitimately render is a value the wire accepts, by
222
+ // construction, because the rendered options and the derived gate read one merge.
209
223
  function buildValidatorFromSpecs(
210
224
  key: string,
211
- kind: DerivedValidatorSpec["kind"],
212
225
  specs: readonly DerivedValidatorSpec[],
213
226
  ): KeyValidator {
214
- if (kind === "int") return makeIntValidator(`menu page "${key}"`);
215
- if (kind === "range") {
216
- // [LAW:types-are-the-program] Two configs declaring one stepper key with
217
- // different bounds widen to the UNION range — parity with allow-list's
218
- // member union: a value any live config can legitimately render (step into)
219
- // is a value the wire accepts. The clamp is to the widest live bounds, so
220
- // the gate never rejects a write a narrower co-resident stepper could make.
221
- const mins = specs.flatMap((s) => (s.kind === "range" ? [s.min] : []));
222
- const maxs = specs.flatMap((s) => (s.kind === "range" ? [s.max] : []));
223
- return makeRangeValidator(
224
- Math.min(...mins),
225
- Math.max(...maxs),
226
- `stepper "${key}"`,
227
- );
228
- }
229
- const allowed = [
230
- ...new Set(
231
- specs.flatMap((s) => (s.kind === "allow-list" ? s.allowed : [])),
232
- ),
233
- ];
234
- return makeAllowListValidator(allowed, `state "${key}"`);
227
+ return validatorForSpec(key, mergeKeySpecs(key, specs));
235
228
  }
236
229
 
237
230
  // [LAW:locality-or-seam] The widget config (a config-load consumer) owns the
@@ -292,17 +285,13 @@ export function registerStateValidator(
292
285
  );
293
286
  }
294
287
  existing.specs.push(spec);
295
- existing.validator = buildValidatorFromSpecs(
296
- key,
297
- existing.kind,
298
- existing.specs,
299
- );
288
+ existing.validator = buildValidatorFromSpecs(key, existing.specs);
300
289
  } else {
301
290
  const specs = [spec];
302
291
  _STATE_VALIDATORS.set(key, {
303
292
  permanent: false,
304
293
  kind: spec.kind,
305
- validator: buildValidatorFromSpecs(key, spec.kind, specs),
294
+ validator: buildValidatorFromSpecs(key, specs),
306
295
  specs,
307
296
  });
308
297
  }
@@ -317,7 +306,7 @@ export function registerStateValidator(
317
306
  if (entry.specs.length === 0) {
318
307
  _STATE_VALIDATORS.delete(key);
319
308
  } else {
320
- entry.validator = buildValidatorFromSpecs(key, entry.kind, entry.specs);
309
+ entry.validator = buildValidatorFromSpecs(key, entry.specs);
321
310
  }
322
311
  };
323
312
  }
@@ -700,9 +689,17 @@ export interface RangeParams {
700
689
  export function rangeParamsFor(key: string): RangeParams | null {
701
690
  const entry = _STATE_VALIDATORS.get(key);
702
691
  if (!entry || entry.permanent || entry.kind !== "range") return null;
703
- const ranges = entry.specs.flatMap((s) => (s.kind === "range" ? [s] : []));
704
- if (ranges.length === 0) return null;
705
- const min = Math.min(...ranges.map((r) => r.min));
706
- const max = Math.max(...ranges.map((r) => r.max));
707
- return { min, max, seed: clampSeed(ranges[0]!.seed, min, max) };
692
+ // [LAW:one-source-of-truth] The bounds/seed come from THE same collapse the
693
+ // validator is built from (mergeKeySpecs) no second widen/clamp lives here.
694
+ const spec = mergeKeySpecs(key, entry.specs);
695
+ // [LAW:no-silent-failure] entry.kind === "range" means every live spec is a
696
+ // range (registration rejects a kind change), so the collapse is a range too;
697
+ // a non-range here is a broken invariant, surfaced loudly, not a silent null.
698
+ if (spec.kind !== "range") {
699
+ throw new Error(
700
+ `rangeParamsFor: key "${key}" holds range specs but the merge produced ` +
701
+ `a ${spec.kind} spec — the entry-kind invariant is broken.`,
702
+ );
703
+ }
704
+ return { min: spec.min, max: spec.max, seed: spec.seed };
708
705
  }
package/src/index.ts CHANGED
@@ -11,7 +11,8 @@ import { tryRenderViaDaemon } from "./daemon/client";
11
11
  import { runDaemonStats } from "./daemon/client-stats";
12
12
  import { runDebug } from "./daemon/client-debug";
13
13
  import { isDebugWhat } from "./daemon/debug-types";
14
- import { runLint, runSchema } from "./config/cli";
14
+ import { runSchema } from "./config/cli";
15
+ import { runCheck } from "./check";
15
16
  import { obtainDaemonKick } from "./daemon/acquire";
16
17
  import { planOutcome } from "./render/outcome-plan";
17
18
 
@@ -66,8 +67,11 @@ Subcommands (macOS):
66
67
  request totals. Does not spawn a daemon.
67
68
 
68
69
  Config tooling:
69
- lint <config-file> Validate a config file (parse + cross-refs + cycles)
70
- with no daemon. Exit 0 valid, 1 invalid, 2 unreadable.
70
+ check [config-file] Validate a config on the full render pipeline (parse
71
+ merge validate register render) with no
72
+ daemon. With no path, checks the same file the daemon
73
+ would load from here. Exit 0 clean (warnings on
74
+ stderr), 1 invalid, 2 unreadable. "lint" is an alias.
71
75
  schema Print the JSON Schema for the config file shape
72
76
  (.cc-candybar.json5). Point an editor's $schema at it
73
77
  for autocomplete + structural validation.
@@ -113,8 +117,11 @@ async function main(): Promise<void> {
113
117
  await runDaemonStats(process.argv.slice(3));
114
118
  process.exit(0);
115
119
  }
116
- if (subcommand === "lint") {
117
- runLint(process.argv.slice(3)); // owns its own exit code (0/1/2)
120
+ // [LAW:one-type-per-behavior] `lint` is an alias of `check` — one config
121
+ // verdict, one pipeline, one exit-code contract (0/1/2). check subsumes the
122
+ // old lint (same loader, plus register + render coverage).
123
+ if (subcommand === "check" || subcommand === "lint") {
124
+ runCheck(process.argv.slice(3)); // owns its own exit code (0/1/2)
118
125
  return;
119
126
  }
120
127
  if (subcommand === "schema") {
@@ -333,7 +333,13 @@ function realize(
333
333
  case "set-int": {
334
334
  // The render binds the integer to write (a picker's page nav passes the
335
335
  // target page as boundValue; a bare `{{ action }}` passes its display).
336
- // The unbounded int gate accepts it; active when the key already holds it.
336
+ // [LAW:no-silent-failure] A bare `{{ action }}` on a set-int MUST render a
337
+ // NUMERIC display (the manual "open at page 0" pattern: `{{ action "openMenu"
338
+ // "0" }}`) — the display IS the value written, and the int gate
339
+ // (makeIntValidator) rejects a non-integer at click with a loud "must be an
340
+ // integer" BAD_REQUEST. There is no load-time check because the display is a
341
+ // template evaluated at render (it may be dynamic), so the shape is enforced
342
+ // at the wire, not silently coerced. active when the key already holds it.
337
343
  const value = boundValue ?? display;
338
344
  const current = readVar(store, c.stateVar);
339
345
  return {
@@ -36,12 +36,17 @@
36
36
  import type { RichText } from "@promptctl/rich-js";
37
37
  import type { FuncMap } from "@promptctl/go-template-js";
38
38
  import {
39
- MENU_CLOSED,
40
- MENU_GLYPH_CLOSED,
41
- MENU_GLYPH_OPEN,
42
39
  menuMember,
40
+ menuPageKey,
43
41
  menuStateKey,
42
+ parseMenuOptions,
43
+ type MenuOptions,
44
44
  } from "../config/menu-keys.js";
45
+ import {
46
+ DISCLOSURE_CLOSED,
47
+ DISCLOSURE_GLYPH_CLOSED,
48
+ DISCLOSURE_GLYPH_OPEN,
49
+ } from "../config/disclosure.js";
45
50
  import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
46
51
  import { linkFragment, readVar, type ActionRuntime } from "./action.js";
47
52
  import { renderPicker } from "./picker.js";
@@ -83,30 +88,11 @@ export function collectMenuDrops(
83
88
  return fragments.flatMap((f) => (f as GlyphWithDrop)[MENU_DROP] ?? []);
84
89
  }
85
90
 
86
- // [LAW:single-enforcer] The page cursor's SessionState key, resolved from the page
87
- // ACTION name the menu binds (its second arg). renderPicker proves pageName is a
88
- // set-int when it builds the body, but the disclosure must reset the page even
89
- // while CLOSED (no body is built then), so it resolves the key here too — the SAME
90
- // set-int action, one source. A non-int page arg is an author error surfaced
91
- // loudly (composeWithDiagnostics shows it), never a silent skipped reset.
92
- function pageKeyOf(action: ActionRuntime, pageName: string): string {
93
- const page = action.compiled.get(pageName);
94
- if (!page || page.kind !== "set-int") {
95
- throw new Error(
96
- `{{ menu }} page action "${pageName}" must be an int action ({ set, int: true })`,
97
- );
98
- }
99
- return page.key;
100
- }
101
-
102
91
  // Realize a `{{ menu }}` against the live placement + state: return its inline
103
92
  // glyph, carrying the (open) body as out-of-band metadata for the boundary.
104
93
  function renderMenu(
105
94
  applyName: string,
106
- pageName: string,
107
- closeOnPick: boolean,
108
- paged: boolean,
109
- sharedKey: string | undefined,
95
+ options: MenuOptions,
110
96
  runtime: MenuRuntime,
111
97
  ): RichText {
112
98
  const placement = runtime.current;
@@ -120,7 +106,12 @@ function renderMenu(
120
106
  );
121
107
  }
122
108
  const action = runtime.action;
123
- const stateKey = menuStateKey(placement.segName, applyName, sharedKey);
109
+ // [LAW:one-source-of-truth] Identity and the page-cursor key derived from it
110
+ // — comes from the SAME menu-keys derivation the loader synthesis used, so the
111
+ // key this render reads/writes is the key whose state var + int gate the
112
+ // loader emitted. No page-action argument to mis-wire.
113
+ const stateKey = menuStateKey(placement.segName, applyName, options.key);
114
+ const pageKey = menuPageKey(stateKey);
124
115
  const member = menuMember(applyName);
125
116
 
126
117
  // [LAW:dataflow-not-control-flow] Open ⇔ the state key holds this menu's member.
@@ -142,19 +133,13 @@ function renderMenu(
142
133
  // GATE source (deriveActionValidators); both keys are independently gated, so the
143
134
  // coupled batch passes the same wire gate every click does [LAW:single-enforcer].
144
135
  const sessionId = readVar(action.store, "session.id");
145
- const successor = open ? MENU_CLOSED : member;
136
+ const successor = open ? DISCLOSURE_CLOSED : member;
146
137
  const glyph = linkFragment(
147
- open ? MENU_GLYPH_OPEN : MENU_GLYPH_CLOSED,
138
+ open ? DISCLOSURE_GLYPH_OPEN : DISCLOSURE_GLYPH_CLOSED,
148
139
  effectsUrl([
149
140
  {
150
141
  verb: VERB_SET_STATE,
151
- args: [
152
- sessionId,
153
- stateKey,
154
- successor,
155
- pageKeyOf(action, pageName),
156
- "0",
157
- ],
142
+ args: [sessionId, stateKey, successor, pageKey, "0"],
158
143
  },
159
144
  ]),
160
145
  false,
@@ -165,40 +150,48 @@ function renderMenu(
165
150
  // No shared mutation: the boundary reads this metadata to place the body.
166
151
  // (renderPicker is pure, so it is only built when open — skipping wasted
167
152
  // computation, gating no effect.)
153
+ // [LAW:one-source-of-truth] The body's page cursor is the identity-derived
154
+ // key (its synthesized state var is named by it, the disclosure-var
155
+ // convention), and CLOSING — the ✕ affordance or a closeOnPick pick — writes
156
+ // the disclosure back to the closed sentinel and resets the page, the same
157
+ // coupled pair the toggle glyph above writes. What the ▾ promised, ✕ delivers.
168
158
  glyph[MENU_DROP] = open
169
- ? [renderPicker(applyName, pageName, closeOnPick, paged, action)]
159
+ ? [
160
+ renderPicker(
161
+ applyName,
162
+ { key: pageKey, stateVar: pageKey },
163
+ [
164
+ [stateKey, DISCLOSURE_CLOSED],
165
+ [pageKey, "0"],
166
+ ],
167
+ options.closeOnPick,
168
+ options.paged,
169
+ action,
170
+ ),
171
+ ]
170
172
  : [];
171
173
  return glyph;
172
174
  }
173
175
 
174
- // [LAW:dataflow-not-control-flow] One func; the two action NAMES select the
175
- // body's apply/page effects, the two optional bools are the bounded author
176
- // choices (closeOnPick, paged) identical to `{{ picker }}`, since the body IS a
177
- // picker and the optional trailing key is the accordion grouping: omitted ⇒ the
178
- // menu is independent (its own key), present ⇒ it shares that key with siblings
179
- // (mutually exclusive). One value, not a mode.
176
+ // [LAW:dataflow-not-control-flow] One func; the apply-action NAME is the menu's
177
+ // whole identity (the page cursor is derived from it, not passed), and the rare
178
+ // knobs travel as ONE optional trailing `(dict …)` closeOnPick (default
179
+ // false: stay-open), paged (default true: a drop menu wants bounded height),
180
+ // key (accordion grouping: omitted independent, present ⇒ mutually exclusive
181
+ // with siblings sharing it). Values, not modes. The loader gates the same dict
182
+ // statically (staticDictEntries), so an old positional tail never reaches this
183
+ // fn — it is a migration-pointing load error.
180
184
  //
181
185
  // [LAW:one-way-deps] Injected into the engine by registerDslConfig as data; the
182
186
  // generic engine never imports this module.
183
187
  export function menuFuncs(runtime: MenuRuntime): FuncMap {
184
188
  return {
185
189
  menu: {
186
- fn: (
187
- applyName: string,
188
- pageName: string,
189
- closeOnPick?: boolean,
190
- paged?: boolean,
191
- key?: string,
192
- ) =>
193
- renderMenu(
194
- applyName,
195
- pageName,
196
- closeOnPick === true,
197
- paged === true,
198
- key,
199
- runtime,
200
- ),
201
- argTypes: ["string", "string", "bool", "bool", "string"],
190
+ fn: (applyName: string, opts?: Record<string, unknown>) =>
191
+ // [LAW:one-source-of-truth] The same option reader the loader folds
192
+ // over the static dict — vocabulary, types, defaults live once.
193
+ renderMenu(applyName, parseMenuOptions(opts ?? {}), runtime),
194
+ argTypes: ["string", "dict"],
202
195
  returnType: "T",
203
196
  },
204
197
  };
@@ -99,6 +99,24 @@ function assemble(frags: readonly RichText[], paged: boolean): RichText {
99
99
  return assembled;
100
100
  }
101
101
 
102
+ // [LAW:types-are-the-program] The page cursor a picker paginates by: the
103
+ // SessionState `key` its ←/→/✕ clicks write and the `stateVar` that reads the
104
+ // live page back. The standalone `{{ picker }}` resolves it from its named
105
+ // set-int page action; a `{{ menu }}` derives it from the menu's identity
106
+ // (menuPageKey) — one value shape, two provenances, one renderer.
107
+ export interface PickerPage {
108
+ readonly key: string;
109
+ readonly stateVar: string;
110
+ }
111
+
112
+ // [LAW:dataflow-not-control-flow] What a "close" WRITES, as data — the (key,
113
+ // value) pairs folded into one atomic set-state by both the ✕ affordance and a
114
+ // closeOnPick option click. The standalone picker closes by paging to -1 (the
115
+ // when-gate idiom); a menu closes by writing its disclosure key back to the
116
+ // closed sentinel and resetting its page cursor. The picker itself never
117
+ // branches on which world it is in — the writes flow in.
118
+ export type CloseWrites = ReadonlyArray<readonly [key: string, value: string]>;
119
+
102
120
  // [LAW:no-defensive-null-guards] The loader proves both picker arg names resolve
103
121
  // to declared actions; this asserts the KIND each must be (apply ⇒ set-option,
104
122
  // page ⇒ set-int) — a wrong kind is an author error surfaced loudly at render
@@ -120,18 +138,19 @@ function requireKind<K extends CompiledActionDecl["kind"]>(
120
138
 
121
139
  // [LAW:dataflow-not-control-flow] The page value (and the live width) select
122
140
  // which option cells render and which boundary arrows exist — a boundary arrow is
123
- // an ABSENT fragment, never a skipped branch. Every affordance click is a `set`
124
- // on the page key: ←/→ navigate (render-computed p±1),closes (-1). Each option
125
- // click APPLIES its option AND (when closeOnPick) resets the page key to -1 in one
126
- // atomic set-state — the picker owns the page key, so it derives the close-write
127
- // rather than the author re-stating the key.
141
+ // an ABSENT fragment, never a skipped branch. ←/→ navigate the page key
142
+ // (render-computed p±1);performs the caller-supplied close writes; each
143
+ // option click APPLIES its option AND (when closeOnPick) folds the same close
144
+ // writes into one atomic set-state — the caller owns what closing means, so the
145
+ // author never re-states a key.
128
146
  // [LAW:single-enforcer] Exported so the `{{ menu }}` helper renders its body
129
147
  // through the SAME picker renderer — a menu body IS a picker grid; there is no
130
148
  // second grid implementation to drift. The menu adds only the disclosure
131
149
  // wrapper, never a parallel picker.
132
150
  export function renderPicker(
133
151
  applyName: string,
134
- pageName: string,
152
+ page: PickerPage,
153
+ close: CloseWrites,
135
154
  closeOnPick: boolean,
136
155
  paged: boolean,
137
156
  runtime: ActionRuntime,
@@ -142,12 +161,6 @@ export function renderPicker(
142
161
  "set-option",
143
162
  "a set-option action ({ set, from })",
144
163
  );
145
- const page = requireKind(
146
- runtime,
147
- pageName,
148
- "set-int",
149
- "an int action ({ set, int: true })",
150
- );
151
164
  const store = runtime.store;
152
165
  const sessionId = readVar(store, "session.id");
153
166
  const current = readVar(store, apply.stateVar);
@@ -201,20 +214,23 @@ export function renderPicker(
201
214
  effectsUrl([
202
215
  { verb: VERB_SET_STATE, args: [sessionId, page.key, String(value)] },
203
216
  ]);
217
+ // [LAW:dataflow-not-control-flow] The close writes arrive as data (see
218
+ // CloseWrites); ✕ performs exactly them, and a closeOnPick option click folds
219
+ // the same pairs into its apply write — one atomic set-state either way, so
220
+ // "what closing means" cannot diverge between the two affordances.
221
+ const closeFlat = close.flatMap(([k, v]) => [k, v]);
222
+ const closeUrl = effectsUrl([
223
+ { verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] },
224
+ ]);
204
225
  const optionUrl = (option: string): string =>
205
226
  effectsUrl([
206
227
  {
207
228
  verb: VERB_SET_STATE,
208
- args: [
209
- sessionId,
210
- apply.key,
211
- option,
212
- ...(closeOnPick ? [page.key, "-1"] : []),
213
- ],
229
+ args: [sessionId, apply.key, option, ...(closeOnPick ? closeFlat : [])],
214
230
  },
215
231
  ]);
216
232
 
217
- const frags: RichText[] = [linkFragment(PICKER_CLOSE, pageUrl(-1), false)];
233
+ const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
218
234
  if (pageIdx > 0) {
219
235
  frags.push(linkFragment(PICKER_PREV, pageUrl(pageIdx - 1), false));
220
236
  }
@@ -254,14 +270,26 @@ export function pickerFuncs(runtime: ActionRuntime): FuncMap {
254
270
  pageName: string,
255
271
  closeOnPick?: boolean,
256
272
  paged?: boolean,
257
- ) =>
258
- renderPicker(
259
- applyName,
273
+ ) => {
274
+ // [LAW:one-source-of-truth] The standalone picker's page cursor comes
275
+ // from its NAMED set-int action (the documented desugaring surface);
276
+ // closing means paging to -1, the when-gate idiom its host row reads
277
+ // (`{{ ge (int .page) 0 }}`).
278
+ const page = requireKind(
279
+ runtime,
260
280
  pageName,
281
+ "set-int",
282
+ "an int action ({ set, int: true })",
283
+ );
284
+ return renderPicker(
285
+ applyName,
286
+ { key: page.key, stateVar: page.stateVar },
287
+ [[page.key, "-1"]],
261
288
  closeOnPick === true,
262
289
  paged === true,
263
290
  runtime,
264
- ),
291
+ );
292
+ },
265
293
  argTypes: ["string", "string", "bool", "bool"],
266
294
  returnType: "T",
267
295
  },