@promptctl/cc-candybar 1.8.1 → 1.8.3
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 +73 -74
- package/package.json +6 -6
- package/src/config/default-dsl-config.ts +34 -38
- 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 +132 -47
package/src/render/menu.ts
CHANGED
|
@@ -1,58 +1,112 @@
|
|
|
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
3
|
// glyph that toggles open/closed, and (when open) its body — a picker grid —
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// (
|
|
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
|
|
6
|
+
// directly (like the picker's closeOnPick) — it toggles the open-state AND resets
|
|
7
|
+
// the page cursor in one atomic batch, gated by the synthesized cycle action.
|
|
7
8
|
//
|
|
8
|
-
// [LAW:
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
9
|
+
// [LAW:effects-at-boundaries] The helper is a PURE function of its inputs (the
|
|
10
|
+
// walk-published placement + the live store): it computes the inline glyph and,
|
|
11
|
+
// when open, the body, and RETURNS them together — the glyph as the fragment, the
|
|
12
|
+
// body carried as out-of-band metadata on that returned RichText (a symbol the
|
|
13
|
+
// segment boundary reads). It mutates no shared sink; the EFFECT of placing the
|
|
14
|
+
// body below the row is performed at the boundary (collectMenuDrops + the segment
|
|
15
|
+
// walk). Pure core returns a description; the edge performs it.
|
|
14
16
|
//
|
|
15
|
-
// [LAW:
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
17
|
+
// [LAW:decomposition] The glyph and the body travel on SEPARATE channels: the
|
|
18
|
+
// glyph is the visible fragment, the body rides as metadata invisible to the
|
|
19
|
+
// inline render. This is the fix for the old `\n`-in-the-stream representation —
|
|
20
|
+
// the body never enters the visible inline text, so a menu may sit ANYWHERE in a
|
|
21
|
+
// template (content after it stays inline on row 0), and a segment may contain
|
|
22
|
+
// ANY NUMBER of menus (each returned glyph carries its own body).
|
|
23
|
+
//
|
|
24
|
+
// [LAW:one-source-of-truth] A menu is CONTEXT-FREE about its NAME in the template
|
|
25
|
+
// (it cannot see the segment it sits in), so the host segment name is published
|
|
26
|
+
// into this runtime by the render walk before each segment's template evaluates.
|
|
27
|
+
// The helper combines that segment name with its own apply-action arg (and an
|
|
28
|
+
// optional shared key) to derive identity via menu-keys — the SAME derivation the
|
|
29
|
+
// loader synthesis uses — so the rendered toggle and the loader-synthesized state
|
|
30
|
+
// var + gate share one source.
|
|
31
|
+
//
|
|
32
|
+
// [LAW:dataflow-not-control-flow] Openness is the value of the menu's state key,
|
|
33
|
+
// not a when-gated reveal: open ⇔ the state key holds THIS menu's member name.
|
|
34
|
+
// The body metadata is a list whose length carries open/closed (1 open, 0 closed).
|
|
19
35
|
|
|
20
|
-
import { RichText } from "@promptctl/rich-js";
|
|
36
|
+
import type { RichText } from "@promptctl/rich-js";
|
|
21
37
|
import type { FuncMap } from "@promptctl/go-template-js";
|
|
22
38
|
import {
|
|
39
|
+
MENU_CLOSED,
|
|
23
40
|
MENU_GLYPH_CLOSED,
|
|
24
41
|
MENU_GLYPH_OPEN,
|
|
25
|
-
|
|
42
|
+
menuMember,
|
|
26
43
|
menuStateKey,
|
|
27
44
|
} from "../config/menu-keys.js";
|
|
28
|
-
import {
|
|
45
|
+
import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
|
|
46
|
+
import { linkFragment, readVar, type ActionRuntime } from "./action.js";
|
|
29
47
|
import { renderPicker } from "./picker.js";
|
|
30
48
|
|
|
31
|
-
// [LAW:types-are-the-program] One menu placement: the structural
|
|
32
|
-
// free `{{ menu }}` cannot see about itself
|
|
33
|
-
// render; the helper reads the live
|
|
49
|
+
// [LAW:types-are-the-program] One menu placement: the structural fact a context-
|
|
50
|
+
// free `{{ menu }}` cannot see about itself — the name of the segment it renders
|
|
51
|
+
// inside. Published by the walk per segment render; the helper reads the live
|
|
52
|
+
// value to derive identity.
|
|
34
53
|
export interface MenuPlacement {
|
|
35
|
-
readonly rowKey: string;
|
|
36
54
|
readonly segName: string;
|
|
37
55
|
}
|
|
38
56
|
|
|
39
57
|
// [LAW:locality-or-seam] The runtime the `menu` func closes over. It shares the
|
|
40
58
|
// ACTION runtime (the menu's glyph and body resolve their actions/state from the
|
|
41
|
-
// same compiled table + store as every other helper) and
|
|
42
|
-
// current placement
|
|
43
|
-
//
|
|
44
|
-
// hue cursor, one mutator, never ambient.
|
|
59
|
+
// same compiled table + store as every other helper) and READS the walk-published
|
|
60
|
+
// current placement — both inputs, never written by the helper. `current` is
|
|
61
|
+
// mutated only by the single owner (the render walk, before each segment eval) —
|
|
62
|
+
// the spatial cousin of the hue cursor, one mutator, never ambient.
|
|
63
|
+
// [LAW:no-ambient-temporal-coupling]
|
|
45
64
|
export interface MenuRuntime {
|
|
46
65
|
readonly action: ActionRuntime;
|
|
47
66
|
current: MenuPlacement | null;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
|
-
//
|
|
69
|
+
// [LAW:effects-at-boundaries] The body a `{{ menu }}` drops below its row rides as
|
|
70
|
+
// out-of-band metadata on the returned glyph (a symbol the boundary reads), so the
|
|
71
|
+
// helper returns a description rather than mutating a shared sink. A list whose
|
|
72
|
+
// length carries open/closed — `[body]` open, `[]` closed.
|
|
73
|
+
const MENU_DROP = Symbol("cc-candybar.menuDrop");
|
|
74
|
+
type GlyphWithDrop = RichText & { [MENU_DROP]?: readonly RichText[] };
|
|
75
|
+
|
|
76
|
+
// [LAW:single-enforcer] THE reader of the drop metadata, used by the segment
|
|
77
|
+
// boundary (injected by the driver — node-registry never imports this module).
|
|
78
|
+
// Scans a segment's evaluated fragments in template order and returns every menu
|
|
79
|
+
// body carried on them; a fragment with no metadata contributes nothing.
|
|
80
|
+
export function collectMenuDrops(
|
|
81
|
+
fragments: readonly RichText[],
|
|
82
|
+
): readonly RichText[] {
|
|
83
|
+
return fragments.flatMap((f) => (f as GlyphWithDrop)[MENU_DROP] ?? []);
|
|
84
|
+
}
|
|
85
|
+
|
|
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
|
+
// Realize a `{{ menu }}` against the live placement + state: return its inline
|
|
103
|
+
// glyph, carrying the (open) body as out-of-band metadata for the boundary.
|
|
51
104
|
function renderMenu(
|
|
52
105
|
applyName: string,
|
|
53
106
|
pageName: string,
|
|
54
107
|
closeOnPick: boolean,
|
|
55
108
|
paged: boolean,
|
|
109
|
+
sharedKey: string | undefined,
|
|
56
110
|
runtime: MenuRuntime,
|
|
57
111
|
): RichText {
|
|
58
112
|
const placement = runtime.current;
|
|
@@ -65,35 +119,64 @@ function renderMenu(
|
|
|
65
119
|
"{{ menu }} rendered with no active segment placement — the render walk must publish one before evaluating a segment template",
|
|
66
120
|
);
|
|
67
121
|
}
|
|
68
|
-
const { rowKey, segName } = placement;
|
|
69
122
|
const action = runtime.action;
|
|
123
|
+
const stateKey = menuStateKey(placement.segName, applyName, sharedKey);
|
|
124
|
+
const member = menuMember(applyName);
|
|
70
125
|
|
|
71
|
-
// [LAW:
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
action,
|
|
78
|
-
);
|
|
126
|
+
// [LAW:dataflow-not-control-flow] Open ⇔ the state key holds this menu's member.
|
|
127
|
+
// A foreign value (an accordion sibling's member under a shared key) reads as
|
|
128
|
+
// closed here — exactly the binary [closed, member] cycle the synthesized action
|
|
129
|
+
// gates. This ONE read drives both the glyph and the body, so what the glyph
|
|
130
|
+
// promises and what drops below cannot disagree.
|
|
131
|
+
const open = readVar(action.store, stateKey) === member;
|
|
79
132
|
|
|
80
|
-
// [LAW:
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
133
|
+
// [LAW:one-source-of-truth] / [LAW:locality-or-seam] The disclosure click is ONE
|
|
134
|
+
// atomic set-state that keeps the two split keys coherent: it toggles the open-
|
|
135
|
+
// state (the binary cycle — successor is closed when open, the member when
|
|
136
|
+
// closed) AND resets the page cursor to page 0, in one batch. So a reopened menu
|
|
137
|
+
// is never stranded on a stale page left by ←/→ before the last close. This
|
|
138
|
+
// mirrors the picker's closeOnPick page-reset fold: the picker builds its set-
|
|
139
|
+
// state URLs directly (not via renderAction) so it can couple two writes; the
|
|
140
|
+
// menu — the one part that knows BOTH the open-state key and the page key
|
|
141
|
+
// [LAW:decomposition] — does the same. The synthesized cycle action stays the
|
|
142
|
+
// GATE source (deriveActionValidators); both keys are independently gated, so the
|
|
143
|
+
// coupled batch passes the same wire gate every click does [LAW:single-enforcer].
|
|
144
|
+
const sessionId = readVar(action.store, "session.id");
|
|
145
|
+
const successor = open ? MENU_CLOSED : member;
|
|
146
|
+
const glyph = linkFragment(
|
|
147
|
+
open ? MENU_GLYPH_OPEN : MENU_GLYPH_CLOSED,
|
|
148
|
+
effectsUrl([
|
|
149
|
+
{
|
|
150
|
+
verb: VERB_SET_STATE,
|
|
151
|
+
args: [
|
|
152
|
+
sessionId,
|
|
153
|
+
stateKey,
|
|
154
|
+
successor,
|
|
155
|
+
pageKeyOf(action, pageName),
|
|
156
|
+
"0",
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
]),
|
|
160
|
+
false,
|
|
161
|
+
) as GlyphWithDrop;
|
|
85
162
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
163
|
+
// [LAW:effects-at-boundaries] The body is a VALUE whose length carries open/
|
|
164
|
+
// closed — `[body]` open, `[]` closed — attached to the glyph the helper returns.
|
|
165
|
+
// No shared mutation: the boundary reads this metadata to place the body.
|
|
166
|
+
// (renderPicker is pure, so it is only built when open — skipping wasted
|
|
167
|
+
// computation, gating no effect.)
|
|
168
|
+
glyph[MENU_DROP] = open
|
|
169
|
+
? [renderPicker(applyName, pageName, closeOnPick, paged, action)]
|
|
170
|
+
: [];
|
|
171
|
+
return glyph;
|
|
90
172
|
}
|
|
91
173
|
|
|
92
174
|
// [LAW:dataflow-not-control-flow] One func; the two action NAMES select the
|
|
93
175
|
// body's apply/page effects, the two optional bools are the bounded author
|
|
94
|
-
// choices (closeOnPick, paged) — identical
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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.
|
|
97
180
|
//
|
|
98
181
|
// [LAW:one-way-deps] Injected into the engine by registerDslConfig as data; the
|
|
99
182
|
// generic engine never imports this module.
|
|
@@ -105,15 +188,17 @@ export function menuFuncs(runtime: MenuRuntime): FuncMap {
|
|
|
105
188
|
pageName: string,
|
|
106
189
|
closeOnPick?: boolean,
|
|
107
190
|
paged?: boolean,
|
|
191
|
+
key?: string,
|
|
108
192
|
) =>
|
|
109
193
|
renderMenu(
|
|
110
194
|
applyName,
|
|
111
195
|
pageName,
|
|
112
196
|
closeOnPick === true,
|
|
113
197
|
paged === true,
|
|
198
|
+
key,
|
|
114
199
|
runtime,
|
|
115
200
|
),
|
|
116
|
-
argTypes: ["string", "string", "bool", "bool"],
|
|
201
|
+
argTypes: ["string", "string", "bool", "bool", "string"],
|
|
117
202
|
returnType: "T",
|
|
118
203
|
},
|
|
119
204
|
};
|