@promptctl/cc-candybar 1.8.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +37 -38
- package/package.json +6 -6
- package/src/config/dsl-loader.ts +1 -1
- package/src/config/loader/cross-ref.ts +8 -6
- package/src/config/loader/menu-synth.ts +232 -59
- package/src/config/loader/refs.ts +13 -10
- package/src/config/menu-keys.ts +54 -77
- package/src/dsl/node-registry.ts +46 -43
- package/src/dsl/render.ts +26 -29
- package/src/render/menu.ts +81 -39
package/src/config/menu-keys.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
|
-
// [LAW:one-source-of-truth] THE derivation of a menu's
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
1
|
+
// [LAW:one-source-of-truth] THE derivation of a menu's disclosure identity — the
|
|
2
|
+
// single place both the loader (which SYNTHESIZES the state var + cycle action)
|
|
3
|
+
// and the renderer (which READS openness + emits the toggle) agree on "which key
|
|
4
|
+
// holds which open menu". A `{{ menu }}` helper is context-free about its NAME
|
|
5
|
+
// (it cannot see the segment it sits in), so the loader and the render walk both
|
|
6
|
+
// derive identity from the same two facts — the host segment name and the menu's
|
|
7
|
+
// own apply-action name — and MUST produce identical strings or a click would
|
|
8
|
+
// write a key the render never reads. Keeping the rule in one module makes that
|
|
9
|
+
// agreement structural rather than a coincidence of two copies.
|
|
10
10
|
//
|
|
11
|
-
// [LAW:decomposition]
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
// [LAW:decomposition] A menu's identity is `(stateKey, member)`:
|
|
12
|
+
// • member = the menu's apply-action name — unique per menu within a segment,
|
|
13
|
+
// so two menus in ONE segment are distinct.
|
|
14
|
+
// • stateKey = the SessionState key whose value names the open member. By
|
|
15
|
+
// DEFAULT each menu owns a UNIQUE key (`menus.<seg>.<apply>`), so
|
|
16
|
+
// it toggles only itself — menus are INDEPENDENT. Passing an
|
|
17
|
+
// explicit shared key makes sibling menus share one key
|
|
18
|
+
// (`menus.<key>`); one key holds one open member, so they become
|
|
19
|
+
// mutually exclusive (an accordion) — exactly group sugar's shared-
|
|
20
|
+
// key mechanism, selected by a VALUE not a mode
|
|
21
|
+
// [LAW:dataflow-not-control-flow]. There is no implicit "the row is
|
|
22
|
+
// the key" position magic: identity depends only on names a reader
|
|
23
|
+
// can see in the template, never on tree position.
|
|
18
24
|
|
|
19
25
|
// [LAW:one-source-of-truth] The reserved namespace every synthesized menu
|
|
20
26
|
// artifact (state var + cycle action) lives under, mirroring group sugar's
|
|
@@ -22,9 +28,10 @@ import type { LayoutNode } from "./dsl-types.js";
|
|
|
22
28
|
// can never silently collide.
|
|
23
29
|
export const MENU_NS = "menus.";
|
|
24
30
|
|
|
25
|
-
// The "no menu open" sentinel a
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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.
|
|
28
35
|
export const MENU_CLOSED = "closed";
|
|
29
36
|
|
|
30
37
|
// [LAW:representation] Disclosure glyph vocabulary — identical to group sugar so
|
|
@@ -33,69 +40,39 @@ export const MENU_CLOSED = "closed";
|
|
|
33
40
|
export const MENU_GLYPH_CLOSED = "▸";
|
|
34
41
|
export const MENU_GLYPH_OPEN = "▾";
|
|
35
42
|
|
|
36
|
-
// [LAW:types-are-the-program]
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return rowKey.replace(/[^A-Za-z0-9]+/g, "_");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// The SessionState key (and the state-var name reading it) for one row's open
|
|
46
|
-
// menu. One key per row holds at most one open member name → accordion.
|
|
47
|
-
export function menuStateKey(rowKey: string): string {
|
|
48
|
-
return MENU_NS + menuRowId(rowKey);
|
|
43
|
+
// [LAW:types-are-the-program] Collapse an arbitrary name to an identifier-shaped
|
|
44
|
+
// id so the synthesized var/action/SessionState-key names carry no dots or
|
|
45
|
+
// brackets that would break template field paths. Distinct names never collide
|
|
46
|
+
// under this map for the alphanumeric segment/action names the config uses.
|
|
47
|
+
function ident(name: string): string {
|
|
48
|
+
return name.replace(/[^A-Za-z0-9]+/g, "_");
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
export function menuActionName(rowKey: string, segName: string): string {
|
|
56
|
-
return MENU_NS + menuRowId(rowKey) + "." + segName;
|
|
51
|
+
// [LAW:single-enforcer] A menu's member name IS its apply-action name. Both the
|
|
52
|
+
// loader and the helper call this so neither restates the rule.
|
|
53
|
+
export function menuMember(applyName: string): string {
|
|
54
|
+
return applyName;
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
// [LAW:single-enforcer] THE
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
// [LAW:single-enforcer] THE state key for a menu. Independent (no shared key):
|
|
58
|
+
// unique per (segment, apply) so the menu toggles only itself. Shared key: the
|
|
59
|
+
// key all siblings passing the same string agree on, so one open member wins
|
|
60
|
+
// (accordion). The shared form ignores the segment name on purpose — that is how
|
|
61
|
+
// menus in DIFFERENT segments become mutually exclusive.
|
|
62
|
+
export function menuStateKey(
|
|
63
|
+
segName: string,
|
|
64
|
+
applyName: string,
|
|
65
|
+
sharedKey: string | undefined,
|
|
66
66
|
): string {
|
|
67
|
-
return
|
|
67
|
+
return sharedKey !== undefined
|
|
68
|
+
? MENU_NS + ident(sharedKey)
|
|
69
|
+
: MENU_NS + ident(segName) + "." + ident(applyName);
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// by exact placement).
|
|
77
|
-
export function forEachSegmentPlacement(
|
|
78
|
-
root: LayoutNode,
|
|
79
|
-
visit: (segName: string, rowKey: string, ownPath: string) => void,
|
|
80
|
-
): void {
|
|
81
|
-
const recur = (
|
|
82
|
-
node: LayoutNode,
|
|
83
|
-
path: string,
|
|
84
|
-
nearestHorizontalPath: string | undefined,
|
|
85
|
-
): void => {
|
|
86
|
-
if (node.kind === "segment") {
|
|
87
|
-
visit(node.name, rowKeyFor(nearestHorizontalPath, path), path);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
// [LAW:dataflow-not-control-flow] A horizontal container REPLACES the nearest-
|
|
91
|
-
// horizontal path for its subtree; any other container PASSES it through. The
|
|
92
|
-
// direction is a value selecting which path the children inherit, not a branch
|
|
93
|
-
// that skips the recursion.
|
|
94
|
-
const childNearestHorizontal =
|
|
95
|
-
node.direction === "horizontal" ? path : nearestHorizontalPath;
|
|
96
|
-
node.children.forEach((child, i) =>
|
|
97
|
-
recur(child, `${path}.children[${i}]`, childNearestHorizontal),
|
|
98
|
-
);
|
|
99
|
-
};
|
|
100
|
-
recur(root, "root", undefined);
|
|
72
|
+
// The synthesized cycle action a menu's disclosure toggle realizes: writing its
|
|
73
|
+
// state key between MENU_CLOSED and its member. Named per (stateKey, member) so
|
|
74
|
+
// menus sharing a key contribute distinct cycles on it — the existing same-key
|
|
75
|
+
// validator merge unions their members into one gate.
|
|
76
|
+
export function menuActionName(stateKey: string, member: string): string {
|
|
77
|
+
return stateKey + "." + member;
|
|
101
78
|
}
|
package/src/dsl/node-registry.ts
CHANGED
|
@@ -49,12 +49,6 @@ export interface CompiledSegmentNode {
|
|
|
49
49
|
readonly kind: "segment";
|
|
50
50
|
readonly when?: Template<RichText>;
|
|
51
51
|
readonly name: string;
|
|
52
|
-
// [LAW:one-source-of-truth] The menu accordion row key for THIS placement —
|
|
53
|
-
// the nearest enclosing horizontal container's path, or this segment's own path
|
|
54
|
-
// (singleton). Computed once at compile from the SAME walk the loader synthesis
|
|
55
|
-
// uses (menu-keys/forEachSegmentPlacement), so the key a `{{ menu }}` toggle
|
|
56
|
-
// writes is provably the key the loader declared a state var + gate for.
|
|
57
|
-
readonly rowKey: string;
|
|
58
52
|
}
|
|
59
53
|
export interface CompiledContainerNode {
|
|
60
54
|
readonly kind: "container";
|
|
@@ -94,11 +88,6 @@ export interface NodeCompileCtx {
|
|
|
94
88
|
readonly path: string;
|
|
95
89
|
// The node's own `when`, already parsed by the driver (one parse-when site).
|
|
96
90
|
readonly when?: Template<RichText>;
|
|
97
|
-
// [LAW:one-source-of-truth] Path → menu row key, precomputed by the driver from
|
|
98
|
-
// the SAME structural walk the loader synthesis uses. A segment node reads its
|
|
99
|
-
// own rowKey here rather than re-deriving the nearest-horizontal rule, so the
|
|
100
|
-
// two walks cannot drift.
|
|
101
|
-
readonly rowKeyByPath: ReadonlyMap<string, string>;
|
|
102
91
|
// Compile a child node (the recursion, injected so this module needn't import
|
|
103
92
|
// the driver).
|
|
104
93
|
compileChild(node: LayoutNode, path: string): CompiledNode;
|
|
@@ -115,15 +104,20 @@ export interface NodeRenderCtx {
|
|
|
115
104
|
// Advance the walk-owned hue cursor by one unit and return that unit's shift.
|
|
116
105
|
nextHueShift(): number;
|
|
117
106
|
readonly perSegmentSink?: Map<string, readonly RichText[]>;
|
|
118
|
-
// [LAW:locality-or-seam] The menu seam, injected as
|
|
119
|
-
// never imports the menu feature.
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
107
|
+
// [LAW:locality-or-seam] The menu seam, injected as capabilities so this module
|
|
108
|
+
// never imports the menu feature. `beginSegment` runs BEFORE a segment template
|
|
109
|
+
// evaluates: it publishes the segment name so a `{{ menu }}` can derive its
|
|
110
|
+
// identity. `collectDrops` runs AFTER eval: it reads the open menu bodies the
|
|
111
|
+
// menus carried as metadata on the evaluated fragments (template order) for the
|
|
112
|
+
// boundary to stack below the row, and clears the published placement. The
|
|
113
|
+
// driver owns the menu runtime; this module only hands it the fragments.
|
|
114
|
+
beginSegment(segName: string): void;
|
|
115
|
+
collectDrops(fragments: readonly RichText[]): readonly RichText[];
|
|
116
|
+
// [LAW:locality-or-seam] The focus transform, injected as a capability (rich-js
|
|
117
|
+
// owns the color math — see render.ts). Applied to the segment's baseStyle when
|
|
118
|
+
// it has an open menu (it contributed drops), so the whole focused segment —
|
|
119
|
+
// inline trigger + dropped body band — reads as highlighted.
|
|
120
|
+
focusTint(style: Style): Style;
|
|
127
121
|
// Resolve a segment name to its decl + compiled form (the driver closes over
|
|
128
122
|
// config.segments + the compiled segments).
|
|
129
123
|
lookupSegment(
|
|
@@ -220,10 +214,6 @@ const segmentType: NodeType<"segment"> = {
|
|
|
220
214
|
kind: "segment",
|
|
221
215
|
when: cctx.when,
|
|
222
216
|
name: node.name,
|
|
223
|
-
// [LAW:no-defensive-null-guards] Every segment path is visited by the
|
|
224
|
-
// driver's placement walk, so the map has it; the `?? path` is the singleton
|
|
225
|
-
// degenerate (a segment outside any horizontal container is its own row).
|
|
226
|
-
rowKey: cctx.rowKeyByPath.get(cctx.path) ?? cctx.path,
|
|
227
217
|
};
|
|
228
218
|
},
|
|
229
219
|
render(node, ctx) {
|
|
@@ -254,6 +244,17 @@ const segmentType: NodeType<"segment"> = {
|
|
|
254
244
|
try {
|
|
255
245
|
if (!evaluateWhen(segCompiled.when, ctx.scope)) return [];
|
|
256
246
|
|
|
247
|
+
// [LAW:single-enforcer] Publish the segment name + clear the drop sink
|
|
248
|
+
// BEFORE evaluating the template — a `{{ menu }}` inside reads the name to
|
|
249
|
+
// derive its identity and contributes its open body to the sink.
|
|
250
|
+
ctx.beginSegment(node.name);
|
|
251
|
+
const fragments = segCompiled.template.evaluate(ctx.scope);
|
|
252
|
+
// [LAW:decomposition] The open menu bodies, carried as out-of-band metadata
|
|
253
|
+
// on the evaluated fragments — invisible to the inline render, so a menu can
|
|
254
|
+
// sit anywhere in the template and content after it stays inline. Each
|
|
255
|
+
// becomes one full-width line stacked below the segment's row.
|
|
256
|
+
const drops = ctx.collectDrops(fragments);
|
|
257
|
+
|
|
257
258
|
// [LAW:dataflow-not-control-flow] The per-segment variability is WHICH
|
|
258
259
|
// palette — the base resolver (per-segment override or basePalette)
|
|
259
260
|
// transposed by hueShift. bg and fg then resolve from this one palette.
|
|
@@ -267,18 +268,18 @@ const segmentType: NodeType<"segment"> = {
|
|
|
267
268
|
segCompiled.fg,
|
|
268
269
|
ctx.scope,
|
|
269
270
|
);
|
|
270
|
-
// [LAW:
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// the
|
|
274
|
-
const baseStyle =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
271
|
+
// [LAW:dataflow-not-control-flow] Focus is the PRESENCE of a drop: a segment
|
|
272
|
+
// with an open menu (it contributed a body) lightens, so the whole focused
|
|
273
|
+
// segment — inline trigger + dropped band — reads as highlighted. No state
|
|
274
|
+
// re-read; the drop list IS the open-menu signal.
|
|
275
|
+
const baseStyle =
|
|
276
|
+
drops.length > 0 ? ctx.focusTint(resolvedStyle) : resolvedStyle;
|
|
277
|
+
const layout = {
|
|
278
|
+
width: seg.width ?? "auto",
|
|
279
|
+
justify: seg.justify ?? "left",
|
|
280
|
+
truncate: seg.truncate ?? "right",
|
|
281
|
+
baseStyle,
|
|
282
|
+
} as const;
|
|
282
283
|
|
|
283
284
|
// [LAW:single-enforcer] Partition the segment's authored "\n" into visual
|
|
284
285
|
// lines BEFORE per-segment layout — width/justify/truncate then measure each
|
|
@@ -286,14 +287,16 @@ const segmentType: NodeType<"segment"> = {
|
|
|
286
287
|
// laid line is ONE strip item: applySegmentLayout collapses a line's cells to
|
|
287
288
|
// 0-or-1 item (OSC-8 links survive as interior spans), so the joiner caps only
|
|
288
289
|
// at the segment's edges, never inside it.
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
290
|
+
const inlineLines = splitCellsIntoLines(
|
|
291
|
+
fragmentsToCells(fragments, baseStyle),
|
|
292
|
+
).map((line) => applySegmentLayout(line, layout));
|
|
293
|
+
// Each open menu body is one full-width dropped line, in the segment's
|
|
294
|
+
// (focus-tinted) bg, stacked after the inline row(s). composeBlocks then
|
|
295
|
+
// drops every line below row 0 below the enclosing horizontal row.
|
|
296
|
+
const dropLines = drops.map((body) =>
|
|
297
|
+
applySegmentLayout(fragmentsToCells([body], baseStyle), layout),
|
|
296
298
|
);
|
|
299
|
+
const laidLines = [...inlineLines, ...dropLines];
|
|
297
300
|
|
|
298
301
|
if (ctx.perSegmentSink !== undefined) {
|
|
299
302
|
ctx.perSegmentSink.set(node.name, laidLines.flat());
|
package/src/dsl/render.ts
CHANGED
|
@@ -39,12 +39,14 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
compileActions,
|
|
41
41
|
actionFuncs,
|
|
42
|
-
readVar,
|
|
43
42
|
type ActionRuntime,
|
|
44
43
|
} from "../render/action.js";
|
|
45
44
|
import { pickerFuncs } from "../render/picker.js";
|
|
46
|
-
import {
|
|
47
|
-
|
|
45
|
+
import {
|
|
46
|
+
menuFuncs,
|
|
47
|
+
collectMenuDrops,
|
|
48
|
+
type MenuRuntime,
|
|
49
|
+
} from "../render/menu.js";
|
|
48
50
|
// [LAW:one-way-deps] The node-type registry sits below this driver: it owns the
|
|
49
51
|
// compiled node shapes + each kind's compile/render, dispatched via nodeType().
|
|
50
52
|
// render.ts threads the recursion (compileChild/renderChild) + the hue counter in
|
|
@@ -291,7 +293,10 @@ export function registerDslConfig(
|
|
|
291
293
|
// glyph + body resolve from the same compiled table + store) and carries the
|
|
292
294
|
// walk-published current placement. Built before the engine so the `menu` func
|
|
293
295
|
// can close over it; `current` stays null until a render walk publishes one.
|
|
294
|
-
const menuRuntime: MenuRuntime = {
|
|
296
|
+
const menuRuntime: MenuRuntime = {
|
|
297
|
+
action: actionRuntime,
|
|
298
|
+
current: null,
|
|
299
|
+
};
|
|
295
300
|
const engine = createCcCandybarEngine(
|
|
296
301
|
undefined,
|
|
297
302
|
{
|
|
@@ -423,14 +428,6 @@ export function registerDslConfig(
|
|
|
423
428
|
);
|
|
424
429
|
}
|
|
425
430
|
};
|
|
426
|
-
// [LAW:one-source-of-truth] Precompute every segment placement's menu row key
|
|
427
|
-
// from the SAME walk the loader synthesis used, so the compiled segment node's
|
|
428
|
-
// rowKey is provably the key the loader declared a state var + gate for. Each
|
|
429
|
-
// segment node reads its own entry by path in compile.
|
|
430
|
-
const rowKeyByPath = new Map<string, string>();
|
|
431
|
-
forEachSegmentPlacement(config.root, (_segName, rowKey, ownPath) => {
|
|
432
|
-
rowKeyByPath.set(ownPath, rowKey);
|
|
433
|
-
});
|
|
434
431
|
const compileNode = (node: LayoutNode, path: string): CompiledNode => {
|
|
435
432
|
const cctx: NodeCompileCtx = {
|
|
436
433
|
path,
|
|
@@ -438,7 +435,6 @@ export function registerDslConfig(
|
|
|
438
435
|
node.when === undefined
|
|
439
436
|
? undefined
|
|
440
437
|
: parseNodeField(node.when, path, "when"),
|
|
441
|
-
rowKeyByPath,
|
|
442
438
|
compileChild: compileNode,
|
|
443
439
|
};
|
|
444
440
|
return nodeType(node.kind).compile(node, cctx);
|
|
@@ -568,21 +564,20 @@ export function renderDsl(
|
|
|
568
564
|
: undefined;
|
|
569
565
|
};
|
|
570
566
|
|
|
571
|
-
// [LAW:single-enforcer] The menu seam, owned here
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
const
|
|
578
|
-
segName
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
return open ? focusTint(baseStyle) : baseStyle;
|
|
567
|
+
// [LAW:single-enforcer] The menu seam, owned here. `beginSegment` publishes the
|
|
568
|
+
// segment name a `{{ menu }}` needs to derive its identity; `collectDrops` reads
|
|
569
|
+
// the open bodies the menus carried as metadata on their evaluated fragments and
|
|
570
|
+
// clears the published placement. The runtime's `current` is set/cleared around
|
|
571
|
+
// each segment eval by the walk only — never ambient.
|
|
572
|
+
// [LAW:no-ambient-temporal-coupling]
|
|
573
|
+
const beginSegment = (segName: string): void => {
|
|
574
|
+
compiled.menuRuntime.current = { segName };
|
|
575
|
+
};
|
|
576
|
+
const collectDrops = (
|
|
577
|
+
fragments: readonly RichText[],
|
|
578
|
+
): readonly RichText[] => {
|
|
579
|
+
compiled.menuRuntime.current = null;
|
|
580
|
+
return collectMenuDrops(fragments);
|
|
586
581
|
};
|
|
587
582
|
|
|
588
583
|
// [LAW:dataflow-not-control-flow] ONE walk renders any node to LINES OF CELLS
|
|
@@ -602,7 +597,9 @@ export function renderDsl(
|
|
|
602
597
|
visible,
|
|
603
598
|
nextHueShift,
|
|
604
599
|
perSegmentSink,
|
|
605
|
-
|
|
600
|
+
beginSegment,
|
|
601
|
+
collectDrops,
|
|
602
|
+
focusTint,
|
|
606
603
|
lookupSegment,
|
|
607
604
|
renderChild: renderNode,
|
|
608
605
|
};
|
package/src/render/menu.ts
CHANGED
|
@@ -1,58 +1,95 @@
|
|
|
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
|
-
// the disclosure: the glyph is realized through the
|
|
6
|
-
// (`renderAction`), the body through the one picker
|
|
4
|
+
// that DROPS onto the line(s) below the enclosing row. It composes the two
|
|
5
|
+
// existing helpers, adding only the disclosure: the glyph is realized through the
|
|
6
|
+
// synthesized cycle action (`renderAction`), the body through the one picker
|
|
7
|
+
// renderer (`renderPicker`).
|
|
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 {
|
|
23
39
|
MENU_GLYPH_CLOSED,
|
|
24
40
|
MENU_GLYPH_OPEN,
|
|
25
41
|
menuActionName,
|
|
42
|
+
menuMember,
|
|
26
43
|
menuStateKey,
|
|
27
44
|
} from "../config/menu-keys.js";
|
|
28
45
|
import { readVar, renderAction, type ActionRuntime } from "./action.js";
|
|
29
46
|
import { renderPicker } from "./picker.js";
|
|
30
47
|
|
|
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
|
|
48
|
+
// [LAW:types-are-the-program] One menu placement: the structural fact a context-
|
|
49
|
+
// free `{{ menu }}` cannot see about itself — the name of the segment it renders
|
|
50
|
+
// inside. Published by the walk per segment render; the helper reads the live
|
|
51
|
+
// value to derive identity.
|
|
34
52
|
export interface MenuPlacement {
|
|
35
|
-
readonly rowKey: string;
|
|
36
53
|
readonly segName: string;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
// [LAW:locality-or-seam] The runtime the `menu` func closes over. It shares the
|
|
40
57
|
// 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.
|
|
58
|
+
// same compiled table + store as every other helper) and READS the walk-published
|
|
59
|
+
// current placement — both inputs, never written by the helper. `current` is
|
|
60
|
+
// mutated only by the single owner (the render walk, before each segment eval) —
|
|
61
|
+
// the spatial cousin of the hue cursor, one mutator, never ambient.
|
|
62
|
+
// [LAW:no-ambient-temporal-coupling]
|
|
45
63
|
export interface MenuRuntime {
|
|
46
64
|
readonly action: ActionRuntime;
|
|
47
65
|
current: MenuPlacement | null;
|
|
48
66
|
}
|
|
49
67
|
|
|
50
|
-
//
|
|
68
|
+
// [LAW:effects-at-boundaries] The body a `{{ menu }}` drops below its row rides as
|
|
69
|
+
// out-of-band metadata on the returned glyph (a symbol the boundary reads), so the
|
|
70
|
+
// helper returns a description rather than mutating a shared sink. A list whose
|
|
71
|
+
// length carries open/closed — `[body]` open, `[]` closed.
|
|
72
|
+
const MENU_DROP = Symbol("cc-candybar.menuDrop");
|
|
73
|
+
type GlyphWithDrop = RichText & { [MENU_DROP]?: readonly RichText[] };
|
|
74
|
+
|
|
75
|
+
// [LAW:single-enforcer] THE reader of the drop metadata, used by the segment
|
|
76
|
+
// boundary (injected by the driver — node-registry never imports this module).
|
|
77
|
+
// Scans a segment's evaluated fragments in template order and returns every menu
|
|
78
|
+
// body carried on them; a fragment with no metadata contributes nothing.
|
|
79
|
+
export function collectMenuDrops(
|
|
80
|
+
fragments: readonly RichText[],
|
|
81
|
+
): readonly RichText[] {
|
|
82
|
+
return fragments.flatMap((f) => (f as GlyphWithDrop)[MENU_DROP] ?? []);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Realize a `{{ menu }}` against the live placement + state: return its inline
|
|
86
|
+
// glyph, carrying the (open) body as out-of-band metadata for the boundary.
|
|
51
87
|
function renderMenu(
|
|
52
88
|
applyName: string,
|
|
53
89
|
pageName: string,
|
|
54
90
|
closeOnPick: boolean,
|
|
55
91
|
paged: boolean,
|
|
92
|
+
sharedKey: string | undefined,
|
|
56
93
|
runtime: MenuRuntime,
|
|
57
94
|
): RichText {
|
|
58
95
|
const placement = runtime.current;
|
|
@@ -65,35 +102,38 @@ function renderMenu(
|
|
|
65
102
|
"{{ menu }} rendered with no active segment placement — the render walk must publish one before evaluating a segment template",
|
|
66
103
|
);
|
|
67
104
|
}
|
|
68
|
-
const { rowKey, segName } = placement;
|
|
69
105
|
const action = runtime.action;
|
|
106
|
+
const stateKey = menuStateKey(placement.segName, applyName, sharedKey);
|
|
107
|
+
const member = menuMember(applyName);
|
|
70
108
|
|
|
71
109
|
// [LAW:single-enforcer] The disclosure glyph IS the synthesized cycle action —
|
|
72
110
|
// displays bound one-per-member (closed ▸ / open ▾), the current member's glyph
|
|
73
111
|
// renders, the click writes the successor. Same toggle as group sugar.
|
|
74
112
|
const glyph = renderAction(
|
|
75
|
-
menuActionName(
|
|
113
|
+
menuActionName(stateKey, member),
|
|
76
114
|
[MENU_GLYPH_CLOSED, MENU_GLYPH_OPEN],
|
|
77
115
|
action,
|
|
78
116
|
);
|
|
79
117
|
|
|
80
|
-
// [LAW:dataflow-not-control-flow] Open ⇔ the
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
118
|
+
// [LAW:dataflow-not-control-flow] Open ⇔ the state key holds this menu's member.
|
|
119
|
+
// The body is a VALUE whose length carries open/closed — `[body]` open, `[]`
|
|
120
|
+
// closed — attached to the glyph the helper returns. No shared mutation: the
|
|
121
|
+
// boundary reads this metadata to place the body. (renderPicker is pure, so it
|
|
122
|
+
// is only built when open — skipping wasted computation, gating no effect.)
|
|
123
|
+
const open = readVar(action.store, stateKey) === member;
|
|
124
|
+
const bodyLines = open
|
|
125
|
+
? [renderPicker(applyName, pageName, closeOnPick, paged, action)]
|
|
126
|
+
: [];
|
|
127
|
+
(glyph as GlyphWithDrop)[MENU_DROP] = bodyLines;
|
|
128
|
+
return glyph;
|
|
90
129
|
}
|
|
91
130
|
|
|
92
131
|
// [LAW:dataflow-not-control-flow] One func; the two action NAMES select the
|
|
93
132
|
// body's apply/page effects, the two optional bools are the bounded author
|
|
94
|
-
// choices (closeOnPick, paged) — identical
|
|
95
|
-
//
|
|
96
|
-
//
|
|
133
|
+
// choices (closeOnPick, paged) — identical to `{{ picker }}`, since the body IS a
|
|
134
|
+
// picker — and the optional trailing key is the accordion grouping: omitted ⇒ the
|
|
135
|
+
// menu is independent (its own key), present ⇒ it shares that key with siblings
|
|
136
|
+
// (mutually exclusive). One value, not a mode.
|
|
97
137
|
//
|
|
98
138
|
// [LAW:one-way-deps] Injected into the engine by registerDslConfig as data; the
|
|
99
139
|
// generic engine never imports this module.
|
|
@@ -105,15 +145,17 @@ export function menuFuncs(runtime: MenuRuntime): FuncMap {
|
|
|
105
145
|
pageName: string,
|
|
106
146
|
closeOnPick?: boolean,
|
|
107
147
|
paged?: boolean,
|
|
148
|
+
key?: string,
|
|
108
149
|
) =>
|
|
109
150
|
renderMenu(
|
|
110
151
|
applyName,
|
|
111
152
|
pageName,
|
|
112
153
|
closeOnPick === true,
|
|
113
154
|
paged === true,
|
|
155
|
+
key,
|
|
114
156
|
runtime,
|
|
115
157
|
),
|
|
116
|
-
argTypes: ["string", "string", "bool", "bool"],
|
|
158
|
+
argTypes: ["string", "string", "bool", "bool", "string"],
|
|
117
159
|
returnType: "T",
|
|
118
160
|
},
|
|
119
161
|
};
|