@promptctl/cc-candybar 1.38.0 → 1.40.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.
@@ -1,11 +1,22 @@
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
- // glyph that toggles open/closed, and (when open) its body — a picker grid —
3
+ // TRIGGER that toggles open/closed, and (when open) its body — a picker grid —
4
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
5
+ // renderer (`renderPicker`); the trigger is a coupled set-state the menu composes
6
6
  // directly (like the picker's closeOnPick) — it toggles the open-state AND resets
7
7
  // the page cursor in one atomic batch, gated by the synthesized cycle action.
8
8
  //
9
+ // [LAW:one-source-of-truth] The trigger's TEXT is authored, never emitted here.
10
+ // This module used to append ▸/▾ from the glyph constants, while the codebase's
11
+ // other disclosure — group sugar — spliced those same constants into the
12
+ // template it synthesized, where an author could see and change them. Two
13
+ // policies for one fact; the docs sided with the visible one ("the trigger is
14
+ // any template content you like") while a menu appended a glyph nobody wrote,
15
+ // which is why edit mode's `+` rendered `+▸`. A menu's disclosure IS a
16
+ // two-member cycle, so its trigger now binds displays exactly as a cycle
17
+ // `{{ action }}` does, through the same `pickCycleDisplay` (candybar-settings-
18
+ // ui-aok.4).
19
+ //
9
20
  // [LAW:effects-at-boundaries] The helper is a PURE function of its inputs (the
10
21
  // walk-published placement + the live store): it computes the inline glyph and,
11
22
  // when open, the body, and RETURNS them together — the glyph as the fragment, the
@@ -42,11 +53,7 @@ import {
42
53
  parseMenuOptions,
43
54
  type MenuOptions,
44
55
  } from "../config/menu-keys.js";
45
- import {
46
- DISCLOSURE_CLOSED,
47
- DISCLOSURE_GLYPH_CLOSED,
48
- DISCLOSURE_GLYPH_OPEN,
49
- } from "../config/disclosure.js";
56
+ import { DISCLOSURE_CLOSED, pickCycleDisplay } from "../config/disclosure.js";
50
57
  import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
51
58
  import { linkFragment, readVar, type ActionRuntime } from "./action.js";
52
59
  import { renderPicker } from "./picker.js";
@@ -94,9 +101,10 @@ export function collectMenuDrops(
94
101
  }
95
102
 
96
103
  // Realize a `{{ menu }}` against the live placement + state: return its inline
97
- // glyph, carrying the (open) body as out-of-band metadata for the boundary.
104
+ // trigger, carrying the (open) body as out-of-band metadata for the boundary.
98
105
  function renderMenu(
99
106
  applyName: string,
107
+ displays: readonly string[],
100
108
  options: MenuOptions,
101
109
  runtime: MenuRuntime,
102
110
  ): RichText {
@@ -139,8 +147,14 @@ function renderMenu(
139
147
  // coupled batch passes the same wire gate every click does [LAW:single-enforcer].
140
148
  const sessionId = readVar(action.store, "session.id");
141
149
  const successor = open ? DISCLOSURE_CLOSED : member;
150
+ // [LAW:one-source-of-truth] The trigger's text is AUTHORED, resolved through
151
+ // the one display rule a cycle `{{ action }}` uses — a menu's disclosure is a
152
+ // two-member cycle, so binding `"▸" "▾"` gives the per-state form and binding
153
+ // `"+"` gives the static one. Nothing is appended here: a disclosure glyph an
154
+ // author never wrote is a glyph they cannot decline, which is exactly how
155
+ // edit mode's `+` came to read `+▸`.
142
156
  const glyph = linkFragment(
143
- open ? DISCLOSURE_GLYPH_OPEN : DISCLOSURE_GLYPH_CLOSED,
157
+ pickCycleDisplay(`{{ menu "${applyName}" }}`, displays, 2, open ? 1 : 0),
144
158
  effectsUrl([
145
159
  {
146
160
  verb: VERB_SET_STATE,
@@ -178,25 +192,65 @@ function renderMenu(
178
192
  return glyph;
179
193
  }
180
194
 
195
+ // [LAW:parse-dont-validate] THE crossing for a `{{ menu }}`'s argument tail.
196
+ // The engine cannot type these slots for us — displays are strings and the
197
+ // optional trailing knobs are a dict, so one declared slot type would refuse
198
+ // one of them — so the tail arrives as opaque values and leaves here as a
199
+ // record whose shape the renderer can no longer doubt: displays are strings,
200
+ // options are parsed. Every rejected shape names the legal one.
201
+ interface MenuArgs {
202
+ readonly displays: readonly string[];
203
+ readonly options: MenuOptions;
204
+ }
205
+ // [LAW:one-source-of-truth] This splits the tail on VALUES; the loader splits
206
+ // the same tail on EXPRS (`menu-synth.ts`). They agree because the loader admits
207
+ // only call sites where they provably must: a last argument that is neither a
208
+ // string literal nor a literal `(dict …)` is a load error whenever both readings
209
+ // would be legal, so what reaches here can only match the loader's reading or
210
+ // throw below.
211
+ const isDict = (v: unknown): v is Record<string, unknown> =>
212
+ typeof v === "object" && v !== null && !Array.isArray(v);
213
+
214
+ function parseMenuArgs(applyName: string, tail: readonly unknown[]): MenuArgs {
215
+ // The dict is the LAST argument when present; everything before it is a
216
+ // display. One position, so a reader never has to count.
217
+ const last = tail[tail.length - 1];
218
+ const optsArg = isDict(last) ? last : undefined;
219
+ const displayArgs = optsArg === undefined ? tail : tail.slice(0, -1);
220
+ const bad = displayArgs.findIndex((d) => typeof d !== "string");
221
+ if (bad !== -1) {
222
+ throw new Error(
223
+ `{{ menu "${applyName}" }} display #${bad + 1} is not text (${JSON.stringify(displayArgs[bad])}) — a menu binds its trigger text, then an optional trailing (dict …) of options`,
224
+ );
225
+ }
226
+ return {
227
+ displays: displayArgs as readonly string[],
228
+ // [LAW:one-source-of-truth] The same option reader the loader folds over
229
+ // the static dict — vocabulary, types, defaults live once.
230
+ options: parseMenuOptions(optsArg ?? {}),
231
+ };
232
+ }
233
+
181
234
  // [LAW:dataflow-not-control-flow] One func; the apply-action NAME is the menu's
182
- // whole identity (the page cursor is derived from it, not passed), and the rare
183
- // knobs travel as ONE optional trailing `(dict …)` — closeOnPick (default
184
- // false: stay-open), paged (default true: a drop menu wants bounded height),
185
- // key (accordion grouping: omitted ⇒ independent, present ⇒ mutually exclusive
186
- // with siblings sharing it). Values, not modes. The loader gates the same dict
187
- // statically (staticDictEntries), so an old positional tail never reaches this
188
- // fn — it is a migration-pointing load error.
235
+ // whole identity (the page cursor is derived from it, not passed), the TRIGGER
236
+ // TEXT is bound like a cycle action's display (one per state, or one static),
237
+ // and the rare knobs travel as ONE optional trailing `(dict …)` — closeOnPick
238
+ // (default false: stay-open), paged (default true: a drop menu wants bounded
239
+ // height), key (accordion grouping: omitted ⇒ independent, present ⇒ mutually
240
+ // exclusive with siblings sharing it). Values, not modes. The loader gates the
241
+ // same dict statically (staticDictEntries), so an old positional tail never
242
+ // reaches this fn — it is a migration-pointing load error.
189
243
  //
190
244
  // [LAW:one-way-deps] Injected into the engine by registerDslConfig as data; the
191
245
  // generic engine never imports this module.
192
246
  export function menuFuncs(runtime: MenuRuntime): FuncMap {
193
247
  return {
194
248
  menu: {
195
- fn: (applyName: string, opts?: Record<string, unknown>) =>
196
- // [LAW:one-source-of-truth] The same option reader the loader folds
197
- // over the static dict — vocabulary, types, defaults live once.
198
- renderMenu(applyName, parseMenuOptions(opts ?? {}), runtime),
199
- argTypes: ["string", "dict"],
249
+ fn: (applyName: string, ...tail: unknown[]) => {
250
+ const { displays, options } = parseMenuArgs(applyName, tail);
251
+ return renderMenu(applyName, displays, options, runtime);
252
+ },
253
+ argTypes: ["string", "value"],
200
254
  returnType: "T",
201
255
  },
202
256
  };
@@ -37,8 +37,8 @@ import {
37
37
  type ActionRuntime,
38
38
  type CompiledActionDecl,
39
39
  } from "./action.js";
40
+ import { DISCLOSURE_GLYPH_CLOSE } from "../config/disclosure.js";
40
41
 
41
- const PICKER_CLOSE = "✕";
42
42
  const PICKER_PREV = "←";
43
43
  const PICKER_NEXT = "→";
44
44
 
@@ -242,7 +242,7 @@ export function renderPicker(
242
242
  2 * runtime.padding,
243
243
  )
244
244
  : Infinity;
245
- const closeReserve = cellWidth(PICKER_CLOSE) + 1;
245
+ const closeReserve = cellWidth(DISCLOSURE_GLYPH_CLOSE) + 1;
246
246
  const arrowReserve = cellWidth(PICKER_PREV) + 1 + cellWidth(PICKER_NEXT) + 1;
247
247
  const firstPass = paginate(widths, available, closeReserve);
248
248
  const pages =
@@ -303,7 +303,9 @@ export function renderPicker(
303
303
  : effectsUrl([...effects, ...closeEffect]);
304
304
  };
305
305
 
306
- const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
306
+ const frags: RichText[] = [
307
+ linkFragment(DISCLOSURE_GLYPH_CLOSE, closeUrl, false),
308
+ ];
307
309
  if (pageIdx > 0) {
308
310
  frags.push(linkFragment(PICKER_PREV, pageUrl(pageIdx - 1), false));
309
311
  }
@@ -52,14 +52,30 @@ export function resolvePaletteName(name: string): string {
52
52
  // default rather than throw or render something the label disagrees with
53
53
  // [LAW:no-silent-failure] — the caller publishes what this returns as
54
54
  // `<field>.effective`, so bar and label always trace to one value.
55
+ //
56
+ // [LAW:one-source-of-truth] `staged` is the RIGHTMOST rung of the precedence
57
+ // chain documented in src/config/presets.ts — a fragment some transient MODE of
58
+ // the bar puts on top while it is on (today: edit mode's `editGlobals`). It
59
+ // outranks even the session pick because it is decided LATER: a user picks a
60
+ // style, then afterwards enters edit mode. That is the same lifetime rule that
61
+ // forced the preset's own position, applied one rung further along, which is
62
+ // why it is a parameter of THIS function rather than a check at any call site —
63
+ // a chain with a rung missing from one field is exactly the drift a single
64
+ // resolver exists to prevent.
65
+ //
66
+ // [LAW:dataflow-not-control-flow] Absent ⇒ `undefined`, which the `??` chain
67
+ // skips by the same code path a session that never clicked skips its own rung.
68
+ // There is no "is edit mode on" branch anywhere below this line; the mode is
69
+ // carried entirely by whether this argument has a value.
55
70
  export function effectiveGlobal<T>(
71
+ staged: T | null | undefined,
56
72
  sessionPick: string | null,
57
73
  configDefault: T | null | undefined,
58
74
  floor: T,
59
75
  parseSession: (raw: string) => T | null,
60
76
  ): T {
61
77
  const picked = sessionPick === null ? null : parseSession(sessionPick);
62
- return picked ?? configDefault ?? floor;
78
+ return staged ?? picked ?? configDefault ?? floor;
63
79
  }
64
80
 
65
81
  // The theme name a render should use, as data.
@@ -70,10 +86,12 @@ export function effectiveGlobal<T>(
70
86
  // parse is identity: there is no membership to check here, and pretending
71
87
  // otherwise would collapse names `paletteForThemeName` handles fine.
72
88
  export function effectiveThemeName(
89
+ stagedPalette: string | undefined,
73
90
  sessionTheme: string | null,
74
91
  globalsPalette: string | undefined,
75
92
  ): string {
76
93
  return effectiveGlobal(
94
+ stagedPalette,
77
95
  sessionTheme,
78
96
  globalsPalette,
79
97
  "textual-dark",
@@ -110,7 +128,12 @@ export function listResolvablePaletteNames(): readonly string[] {
110
128
  //
111
129
  // The floor's membership is a load-time guarantee, not a runtime hope: the
112
130
  // bundled stdlib ships it and merge-by-name cannot remove it.
131
+ //
132
+ // The staged rung runs through the SAME membership parse the other two do: a
133
+ // fragment naming a member the config does not declare is no more a pick than a
134
+ // stale session entry is, and collapses one rung onward rather than throwing.
113
135
  export function effectiveMemberName(
136
+ stagedName: string | undefined,
114
137
  sessionPick: string | null,
115
138
  configDefault: string | undefined,
116
139
  floor: string,
@@ -119,6 +142,7 @@ export function effectiveMemberName(
119
142
  const member = (raw: string): string | null =>
120
143
  Object.prototype.hasOwnProperty.call(declared, raw) ? raw : null;
121
144
  return effectiveGlobal(
145
+ stagedName === undefined ? null : member(stagedName),
122
146
  sessionPick,
123
147
  member(configDefault ?? floor),
124
148
  floor,
@@ -133,11 +157,18 @@ export function effectiveMemberName(
133
157
  // carries. A named wrapper (not a bare call at each site) so the floor is
134
158
  // spelled once and the three call sites cannot disagree about it.
135
159
  export function effectiveLookName(
160
+ stagedLook: string | undefined,
136
161
  sessionLook: string | null,
137
162
  globalsLook: string | undefined,
138
163
  declaredLooks: Readonly<Record<string, ThemeKey>>,
139
164
  ): string {
140
- return effectiveMemberName(sessionLook, globalsLook, "none", declaredLooks);
165
+ return effectiveMemberName(
166
+ stagedLook,
167
+ sessionLook,
168
+ globalsLook,
169
+ "none",
170
+ declaredLooks,
171
+ );
141
172
  }
142
173
 
143
174
  // [LAW:single-enforcer] The one place an effective look NAME becomes the
@@ -196,11 +227,16 @@ export function isStripStyle(value: string): value is StripStyle {
196
227
  // [LAW:one-source-of-truth]; test/session-globals.test.ts pins it with a stale
197
228
  // pick over a valid non-floor default, the case the old tests never exercised.
198
229
  export function effectiveStripStyle(
230
+ stagedStyle: StripStyle | undefined,
199
231
  sessionStyle: string | null,
200
232
  globalsStyle: StripStyle | undefined,
201
233
  ): StripStyle {
202
- return effectiveGlobal(sessionStyle, globalsStyle, "powerline", (raw) =>
203
- isStripStyle(raw) ? raw : null,
234
+ return effectiveGlobal(
235
+ stagedStyle,
236
+ sessionStyle,
237
+ globalsStyle,
238
+ "powerline",
239
+ (raw) => (isStripStyle(raw) ? raw : null),
204
240
  );
205
241
  }
206
242
 
@@ -323,10 +359,12 @@ function parsePadding(raw: string): number | null {
323
359
  // Whether a render wraps over-wide rows, as data. The session's pick over the
324
360
  // config default over the on floor — `effectiveGlobal` with a boolean domain.
325
361
  export function effectiveAutoWrap(
362
+ stagedAutoWrap: boolean | undefined,
326
363
  sessionAutoWrap: string | null,
327
364
  globalsAutoWrap: boolean | undefined,
328
365
  ): boolean {
329
366
  return effectiveGlobal(
367
+ stagedAutoWrap,
330
368
  sessionAutoWrap,
331
369
  globalsAutoWrap,
332
370
  DEFAULT_WRAP,
@@ -341,10 +379,12 @@ export function effectiveAutoWrap(
341
379
  // own default is the honest answer rather than a render at a width nobody
342
380
  // chose.
343
381
  export function effectivePadding(
382
+ stagedPadding: number | undefined,
344
383
  sessionPadding: string | null,
345
384
  globalsPadding: number | undefined,
346
385
  ): number {
347
386
  return effectiveGlobal(
387
+ stagedPadding,
348
388
  sessionPadding,
349
389
  globalsPadding,
350
390
  DEFAULT_PADDING,