@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.
@@ -1,20 +1,26 @@
1
- // [LAW:one-source-of-truth] THE derivation of a menu's accordion identity from
2
- // layout structure — the single place both the loader (which SYNTHESIZES the
3
- // state var + cycle action) and the renderer (which READS openness + emits the
4
- // toggle) agree on "which key holds which open menu". A `{{ menu }}` helper is
5
- // context-free (it cannot see its own tree position), so its row key and member
6
- // name are NOT in the helper stringthey are derived here from where its host
7
- // segment sits, and the two consumers MUST derive identical strings or a click
8
- // would write a key the render never reads. Keeping the rule in one module makes
9
- // that agreement structural rather than a coincidence of two copies.
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] "The row IS the key": menus mutually exclude exactly when
12
- // they share an enclosing horizontal container so the row key is that
13
- // container's structural path, and a menu outside any horizontal container gets
14
- // its own path as a singleton key (it can only toggle itself). The member name a
15
- // row key holds is the host segment's name.
16
-
17
- import type { LayoutNode } from "./dsl-types.js";
11
+ // [LAW:decomposition] A menu's identity is `(stateKey, member)`:
12
+ // member = the menu's apply-action nameunique 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 row key starts from and returns to on close.
26
- // A menu's member name is its host segment name; segment names cannot equal this
27
- // (a row holding "closed" means every menu in it is shut).
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] A row key is a structural path (e.g.
37
- // `root.children[1]`); collapse it to an identifier-shaped id so the synthesized
38
- // var/action/SessionState-key names carry no dots or brackets. Distinct tree
39
- // paths never collide under this map (sibling indices stay distinct: `[1]` vs
40
- // `[11]` `_1_` vs `_11_`).
41
- function menuRowId(rowKey: string): string {
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
- // The synthesized cycle action a menu's disclosure toggle realizes: writing this
52
- // row key between MENU_CLOSED and the host segment's name. Named per (row,
53
- // segment) so two menus sharing a row contribute two cycles on one key — the
54
- // existing same-key validator merge unions their members into the gate.
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 rule for a segment placement's row key: the nearest
60
- // enclosing horizontal container's path, or the segment's own path when none.
61
- // Both the loader walk and the compile walk call this with the values they
62
- // already track, so neither restates the rule.
63
- export function rowKeyFor(
64
- nearestHorizontalPath: string | undefined,
65
- ownPath: string,
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 nearestHorizontalPath ?? ownPath;
67
+ return sharedKey !== undefined
68
+ ? MENU_NS + ident(sharedKey)
69
+ : MENU_NS + ident(segName) + "." + ident(applyName);
68
70
  }
69
71
 
70
- // [LAW:single-enforcer] THE one walk that assigns every segment placement its
71
- // path and row key. The path format (`root` + `.children[i]`) MUST match the
72
- // compile walk's (`registerDslConfig`'s `compileNode`), since the row key a click
73
- // targets is keyed by that path; this is the load-side mirror of that walk, so
74
- // the two produce identical paths over the same tree. `visit` receives the
75
- // segment name, its row key, and its own path (in case a consumer needs to map
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
  }
@@ -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 a capability so this module
119
- // never imports the menu feature. Called once per segment render, BEFORE its
120
- // template evaluates: it publishes this placement (segName + rowKey) so a
121
- // `{{ menu }}` in the template can resolve its accordion identity, and returns
122
- // the baseStyle to use lightened when this segment's own menu is open, so the
123
- // whole focused segment (inline trigger + dropped body band) reads as
124
- // highlighted. The driver owns the menu runtime + state read; this module only
125
- // hands it the values it has (name, compiled rowKey, resolved baseStyle).
126
- prepareSegment(segName: string, rowKey: string, baseStyle: Style): Style;
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:single-enforcer] Publish this placement to the menu seam and adopt
271
- // its baseStyle (focus-lightened iff this segment's menu is open) BEFORE
272
- // evaluating the template a `{{ menu }}` inside reads the placement, and
273
- // the (possibly tinted) baseStyle flows into every cell + the line fill.
274
- const baseStyle = ctx.prepareSegment(
275
- node.name,
276
- node.rowKey,
277
- resolvedStyle,
278
- );
279
-
280
- const fragments = segCompiled.template.evaluate(ctx.scope);
281
- const segCells = fragmentsToCells(fragments, baseStyle);
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 laidLines = splitCellsIntoLines(segCells).map((line) =>
290
- applySegmentLayout(line, {
291
- width: seg.width ?? "auto",
292
- justify: seg.justify ?? "left",
293
- truncate: seg.truncate ?? "right",
294
- baseStyle,
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 { menuFuncs, type MenuRuntime } from "../render/menu.js";
47
- import { forEachSegmentPlacement, menuStateKey } from "../config/menu-keys.js";
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 = { action: actionRuntime, current: null };
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: publish the placement the
572
- // about-to-evaluate segment template needs (so a `{{ menu }}` resolves its
573
- // accordion identity) and, when this segment's own menu is open, focus-tint its
574
- // baseStyle so the whole segment (inline trigger + dropped body band) reads as
575
- // highlighted. [LAW:no-defensive-null-guards] "menu never opened" is a real
576
- // state — has() discriminates an unset key (no menu in this row) from a value.
577
- const prepareSegment = (
578
- segName: string,
579
- rowKey: string,
580
- baseStyle: Style,
581
- ): Style => {
582
- compiled.menuRuntime.current = { rowKey, segName };
583
- const stateKey = menuStateKey(rowKey);
584
- const open = store.has(stateKey) && readVar(store, stateKey) === segName;
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
- prepareSegment,
600
+ beginSegment,
601
+ collectDrops,
602
+ focusTint,
606
603
  lookupSegment,
607
604
  renderChild: renderNode,
608
605
  };