@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,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
- // dropped onto the next line. It composes the two existing helpers, adding only
5
- // the disclosure: the glyph is realized through the synthesized cycle action
6
- // (`renderAction`), the body through the one picker renderer (`renderPicker`).
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:one-source-of-truth] A menu is CONTEXT-FREE in the template but its
9
- // accordion identity (which row key, which member name) is a fact about WHERE its
10
- // host segment sits derived once in menu-keys and published into this runtime
11
- // by the render walk before each segment's template evaluates. The helper reads
12
- // that placement; it never invents a key from its own argument string, so the
13
- // rendered toggle and the loader-synthesized state var + gate share one source.
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:dataflow-not-control-flow] Openness is the value of the row key, not a
16
- // when-gated reveal: open ⇔ the row key holds THIS menu's member name. Open is a
17
- // length-1 drop appended after a "\n"; closed is just the glyph. One expression,
18
- // one returned RichText (the "\n" splits into a dropped line downstream).
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
- menuActionName,
42
+ menuMember,
26
43
  menuStateKey,
27
44
  } from "../config/menu-keys.js";
28
- import { readVar, renderAction, type ActionRuntime } from "./action.js";
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 facts a context-
32
- // free `{{ menu }}` cannot see about itself. Published by the walk per segment
33
- // render; the helper reads the live value.
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 reads the walk-published
42
- // current placement. `current` is mutated by the single owner (the render walk,
43
- // set immediately before each segment template eval) — the spatial cousin of the
44
- // hue cursor, one mutator, never ambient. [LAW:no-ambient-temporal-coupling]
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
- // Realize a `{{ menu }}` against the live placement + state into ONE RichText.
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:single-enforcer] The disclosure glyph IS the synthesized cycle action
72
- // displays bound one-per-member (closed / open ▾), the current member's glyph
73
- // renders, the click writes the successor. Same toggle as group sugar.
74
- const glyph = renderAction(
75
- menuActionName(rowKey, segName),
76
- [MENU_GLYPH_CLOSED, MENU_GLYPH_OPEN],
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:dataflow-not-control-flow] Open the row key holds this menu's member.
81
- // Closed just the glyph; open glyph + "\n" + body. The "\n" is the sole
82
- // vertical sentinel (splitCellsIntoLines drops the body below the row).
83
- const open = readVar(action.store, menuStateKey(rowKey)) === segName;
84
- if (!open) return glyph;
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
- const body = renderPicker(applyName, pageName, closeOnPick, paged, action);
87
- const combined = RichText.fromFragments([glyph, new RichText("\n"), body]);
88
- combined.end = "";
89
- return combined;
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 surface to `{{ picker }}`, since the
95
- // body IS a picker. The disclosure takes no argument: its identity is derived
96
- // from placement, never authored.
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
  };