@promptctl/cc-candybar 1.7.3 → 1.8.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.
@@ -0,0 +1,120 @@
1
+ // [LAW:locality-or-seam] The runtime half of the `{{ menu }}` seam — sibling to
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 —
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`).
7
+ //
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.
14
+ //
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).
19
+
20
+ import { RichText } from "@promptctl/rich-js";
21
+ import type { FuncMap } from "@promptctl/go-template-js";
22
+ import {
23
+ MENU_GLYPH_CLOSED,
24
+ MENU_GLYPH_OPEN,
25
+ menuActionName,
26
+ menuStateKey,
27
+ } from "../config/menu-keys.js";
28
+ import { readVar, renderAction, type ActionRuntime } from "./action.js";
29
+ import { renderPicker } from "./picker.js";
30
+
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.
34
+ export interface MenuPlacement {
35
+ readonly rowKey: string;
36
+ readonly segName: string;
37
+ }
38
+
39
+ // [LAW:locality-or-seam] The runtime the `menu` func closes over. It shares the
40
+ // 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]
45
+ export interface MenuRuntime {
46
+ readonly action: ActionRuntime;
47
+ current: MenuPlacement | null;
48
+ }
49
+
50
+ // Realize a `{{ menu }}` against the live placement + state into ONE RichText.
51
+ function renderMenu(
52
+ applyName: string,
53
+ pageName: string,
54
+ closeOnPick: boolean,
55
+ paged: boolean,
56
+ runtime: MenuRuntime,
57
+ ): RichText {
58
+ const placement = runtime.current;
59
+ // [LAW:no-defensive-null-guards] The walk publishes a placement before every
60
+ // segment template evaluates; a `{{ menu }}` only renders inside a segment. A
61
+ // null here is a wiring bug (the func fired with no current segment), surfaced
62
+ // loudly rather than rendering a placeless menu.
63
+ if (placement === null) {
64
+ throw new Error(
65
+ "{{ menu }} rendered with no active segment placement — the render walk must publish one before evaluating a segment template",
66
+ );
67
+ }
68
+ const { rowKey, segName } = placement;
69
+ const action = runtime.action;
70
+
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
+ );
79
+
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;
85
+
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;
90
+ }
91
+
92
+ // [LAW:dataflow-not-control-flow] One func; the two action NAMES select the
93
+ // 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.
97
+ //
98
+ // [LAW:one-way-deps] Injected into the engine by registerDslConfig as data; the
99
+ // generic engine never imports this module.
100
+ export function menuFuncs(runtime: MenuRuntime): FuncMap {
101
+ return {
102
+ menu: {
103
+ fn: (
104
+ applyName: string,
105
+ pageName: string,
106
+ closeOnPick?: boolean,
107
+ paged?: boolean,
108
+ ) =>
109
+ renderMenu(
110
+ applyName,
111
+ pageName,
112
+ closeOnPick === true,
113
+ paged === true,
114
+ runtime,
115
+ ),
116
+ argTypes: ["string", "string", "bool", "bool"],
117
+ returnType: "T",
118
+ },
119
+ };
120
+ }
@@ -124,7 +124,11 @@ function requireKind<K extends CompiledActionDecl["kind"]>(
124
124
  // click APPLIES its option AND (when closeOnPick) resets the page key to -1 in one
125
125
  // atomic set-state — the picker owns the page key, so it derives the close-write
126
126
  // rather than the author re-stating the key.
127
- function renderPicker(
127
+ // [LAW:single-enforcer] Exported so the `{{ menu }}` helper renders its body
128
+ // through the SAME picker renderer — a menu body IS a picker grid; there is no
129
+ // second grid implementation to drift. The menu adds only the disclosure
130
+ // wrapper, never a parallel picker.
131
+ export function renderPicker(
128
132
  applyName: string,
129
133
  pageName: string,
130
134
  closeOnPick: boolean,
@@ -34,34 +34,17 @@ import { renderSparkline, parseSeries } from "./sparkline.js";
34
34
  const THEMES_LIST: readonly string[] = listResolvablePaletteNames();
35
35
  const STYLES_LIST: readonly string[] = [...STRIP_STYLES];
36
36
 
37
- // Normalize an engine-supplied numeric argument. The "number" argType admits
38
- // both number and bigint (per @promptctl/go-template-js); the underlying
39
- // formatters take a JS number, so collapse bigint here. [LAW:single-enforcer]
40
- // every formatter wrapper goes through this — no per-wrapper bigint check.
41
- //
42
- // [LAW:no-silent-fallbacks] A bigint outside JS's safe-integer range cannot
43
- // round-trip through Number without silent precision loss (53-bit mantissa)
44
- // or overflow to ±Infinity. Either would feed a wrong value into a formatter
45
- // (e.g. formatDuration) and produce confidently-wrong output. Throw at the
46
- // conversion boundary so the failure surfaces where the conversion happens,
47
- // not deep inside a formatter doing math on a corrupted number.
48
- function num(v: number | bigint): number {
49
- if (typeof v === "bigint") {
50
- if (
51
- v > BigInt(Number.MAX_SAFE_INTEGER) ||
52
- v < BigInt(Number.MIN_SAFE_INTEGER)
53
- ) {
54
- throw new TypeError(
55
- `Numeric argument ${v}n is outside JS safe-integer range ` +
56
- `(|v| > Number.MAX_SAFE_INTEGER = ${Number.MAX_SAFE_INTEGER}); ` +
57
- `Number(v) would lose precision or overflow. ` +
58
- `Pass a value within ±Number.MAX_SAFE_INTEGER.`,
59
- );
60
- }
61
- return Number(v);
62
- }
63
- return v;
64
- }
37
+ // [LAW:single-enforcer] Numeric validation lives at ONE boundary the engine's
38
+ // `int`/`float` argType gate (@promptctl/go-template-js), which proves membership
39
+ // and normalizes the carrier to a JS `number` before the func body runs. `int`
40
+ // admits only finite integer-valued numbers + safe-integer bigints (rejecting
41
+ // fractionals and precision-losing/overflowing bigints loudly at the gate);
42
+ // `float` admits any finite number. So a formatter wrapper receives a clean
43
+ // `number` and needs no bigint guard of its own — the prior `num()` helper was a
44
+ // second enforcer of what the gate now owns, removed when the formatters adopted
45
+ // int/float. [LAW:no-silent-failure] the gate's rejection is the loud failure on
46
+ // a precision-losing integer input; this is not weaker than the old runtime
47
+ // check, it is the same guarantee moved to the true boundary.
65
48
 
66
49
  // cc-candybar-specific functions not already covered by sprig or Go builtins.
67
50
  // The engine also includes sprigDefaults(), sprigStrings(), and sprigLists()
@@ -185,11 +168,13 @@ export function formatterFuncs(clock: () => Date = () => new Date()): FuncMap {
185
168
  // segments (`formatLongTimeRemaining (minutesUntilReset .resetsAt)`) and the
186
169
  // cacheTimer warmth countdown (numeric `le` thresholds).
187
170
  minutesUntilReset: {
188
- fn: (epochSeconds: number | bigint) =>
171
+ fn: (epochSeconds: number) =>
189
172
  Math.round(
190
- Math.max(0, num(epochSeconds) * 1000 - clock().getTime()) / 60000,
173
+ Math.max(0, epochSeconds * 1000 - clock().getTime()) / 60000,
191
174
  ),
192
- argTypes: ["number"],
175
+ // [LAW:types-are-the-program] An epoch is integer-valued; `int` rejects a
176
+ // fractional or precision-losing carrier at the gate.
177
+ argTypes: ["int"],
193
178
  },
194
179
 
195
180
  // ─── Locale-grouped integer (context's "50,000") ──────────────────
@@ -200,8 +185,10 @@ export function formatterFuncs(clock: () => Date = () => new Date()): FuncMap {
200
185
  // of grouping policy — same parsing/formatting boundary that keeps
201
186
  // formatModelName here.
202
187
  formatInteger: {
203
- fn: (n: number | bigint) => formatInteger(num(n)),
204
- argTypes: ["number"],
188
+ fn: (n: number) => formatInteger(n),
189
+ // [LAW:types-are-the-program] Integer grouping is meaningful only for an
190
+ // integer; `int` rejects a fractional/precision-losing carrier at the gate.
191
+ argTypes: ["int"],
205
192
  },
206
193
 
207
194
  // ─── Numeric helper (block/weekly's Math.round of pct) ────────────
@@ -210,8 +197,10 @@ export function formatterFuncs(clock: () => Date = () => new Date()): FuncMap {
210
197
  // formatters.ts module documents domain-meaningful rules; rounding is
211
198
  // not domain-meaningful, so it stays here.
212
199
  round: {
213
- fn: (n: number | bigint) => Math.round(num(n)),
214
- argTypes: ["number"],
200
+ fn: (n: number) => Math.round(n),
201
+ // [LAW:types-are-the-program] round takes a fractional value (e.g. a
202
+ // percentage) → `float` admits any finite number.
203
+ argTypes: ["float"],
215
204
  },
216
205
 
217
206
  // ─── Model-name normalizers (chunk-7 model dsl-pending → dsl-parity) ─