@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.
- package/dist/index.mjs +41 -39
- package/package.json +6 -6
- package/src/check.ts +359 -0
- package/src/config/cli.ts +19 -118
- package/src/config/default-dsl-config.ts +16 -19
- package/src/config/disclosure.ts +55 -0
- package/src/config/loader/layout.ts +34 -32
- package/src/config/loader/menu-synth.ts +163 -108
- package/src/config/loader/refs.ts +11 -10
- package/src/config/loader/reserved-namespace.ts +38 -0
- package/src/config/menu-keys.ts +78 -11
- package/src/daemon/verbs/state-validators.ts +41 -44
- package/src/index.ts +12 -5
- package/src/render/action.ts +7 -1
- package/src/render/menu.ts +49 -56
- package/src/render/picker.ts +51 -23
package/src/config/menu-keys.ts
CHANGED
|
@@ -28,17 +28,11 @@
|
|
|
28
28
|
// can never silently collide.
|
|
29
29
|
export const MENU_NS = "menus.";
|
|
30
30
|
|
|
31
|
-
// The
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// the
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
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 {
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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") {
|
package/src/render/action.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 {
|
package/src/render/menu.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ?
|
|
136
|
+
const successor = open ? DISCLOSURE_CLOSED : member;
|
|
146
137
|
const glyph = linkFragment(
|
|
147
|
-
open ?
|
|
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
|
-
? [
|
|
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
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
};
|
package/src/render/picker.ts
CHANGED
|
@@ -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.
|
|
124
|
-
//
|
|
125
|
-
// click APPLIES its option AND (when closeOnPick)
|
|
126
|
-
// atomic set-state — the
|
|
127
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
259
|
-
|
|
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
|
},
|