@promptctl/cc-candybar 1.7.3 → 1.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.7.3",
3
+ "version": "1.8.1",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -85,16 +85,16 @@
85
85
  "typescript": "^5.0.0"
86
86
  },
87
87
  "dependencies": {
88
- "@promptctl/go-template-js": "^0.3.0",
89
- "@promptctl/rich-js": "^0.5.2",
88
+ "@promptctl/go-template-js": "^0.5.0",
89
+ "@promptctl/rich-js": "^0.6.0",
90
90
  "json5": "^2.2.3",
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.7.3",
95
- "@promptctl/cc-candybar-darwin-x64": "1.7.3",
96
- "@promptctl/cc-candybar-linux-x64": "1.7.3",
97
- "@promptctl/cc-candybar-linux-arm64": "1.7.3"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.8.1",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.8.1",
96
+ "@promptctl/cc-candybar-linux-x64": "1.8.1",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.8.1"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -48,6 +48,7 @@ import { validateGlobals } from "./loader/globals.js";
48
48
  import { validateVariables } from "./loader/variables.js";
49
49
  import { validateSegments } from "./loader/segments.js";
50
50
  import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
51
+ import { synthesizeMenuDecls } from "./loader/menu-synth.js";
51
52
  import { validateActions } from "./loader/actions.js";
52
53
  import { validateHelpers } from "./loader/helpers.js";
53
54
  import { validateCrossReferences } from "./loader/cross-ref.js";
@@ -249,6 +250,13 @@ function validateTopLevel(
249
250
  // default and cross-ref like any user declaration), and user names under the
250
251
  // reserved namespace are rejected against the fully-parsed sections.
251
252
  synthesizeGroupDecls(ctx, out);
253
+ // [LAW:one-source-of-truth] Menu synthesis runs AFTER group synthesis (a group
254
+ // body may host menu-bearing segments) and after every section parsed: each
255
+ // menu placement detected in the root walk emits its state var + cycle action
256
+ // into the raw sections, so they merge over the default, derive the click gate
257
+ // through deriveActionValidators, and collide loudly with any user name under
258
+ // the reserved namespace.
259
+ synthesizeMenuDecls(ctx, out);
252
260
  return out;
253
261
  }
254
262
 
@@ -0,0 +1,156 @@
1
+ // [LAW:one-source-of-truth] The menu synthesis pass: the load-side mirror of the
2
+ // `{{ menu }}` render helper. A menu is the group-accordion mechanism with its
3
+ // trigger living inside an arbitrary user segment rather than a synthesized
4
+ // toggle segment — so this emits exactly what group sugar does MINUS the segment
5
+ // (the helper IS the trigger): one `state` var per row key (default "closed")
6
+ // and one `cycle` action per (row, menu-bearing segment) under the reserved
7
+ // `menus.` namespace. Both land in the raw sections so they merge over the
8
+ // default and, crucially, so `deriveActionValidators(config.actions)` derives the
9
+ // click gate from them through the ONE existing path — a menu toggle is gated
10
+ // like every other set, no parallel verb. [LAW:single-enforcer]
11
+ //
12
+ // Runs in `parseDslConfig` after group synthesis and after every section parsed,
13
+ // so the reserved-namespace collision check sees the fully-parsed user sections.
14
+ //
15
+ // [LAW:types-are-the-program] WHICH segments host a menu is read from the parsed
16
+ // AST (`referencedFunctions`), not a source-text scan — robust against whitespace,
17
+ // pipelines, and `.menu`/"menu" lookalikes. Detection bare-parses the segment
18
+ // template; a parse failure here is treated as "no menu" because the authoritative
19
+ // parse error is surfaced loudly by `registerDslConfig` when it compiles the same
20
+ // template (so this pass never swallows a real error, it just declines to guess).
21
+
22
+ import { createEngine } from "@promptctl/go-template-js";
23
+ import type { ActionDecl } from "../action.js";
24
+ import type { Mutable, ValidateCtx } from "./validate-core.js";
25
+ import {
26
+ MENU_CLOSED,
27
+ MENU_NS,
28
+ forEachSegmentPlacement,
29
+ menuActionName,
30
+ menuStateKey,
31
+ } from "../menu-keys.js";
32
+ import type { RawDslConfig, VariableDecl } from "../dsl-types.js";
33
+ import { findKeyLine } from "./diagnostics.js";
34
+
35
+ // [LAW:single-enforcer] The helper-name a `{{ menu … }}` call uses — the same
36
+ // string the render FuncMap registers. A segment "hosts a menu" iff its template
37
+ // references this function.
38
+ const MENU_FUNC = "menu";
39
+
40
+ // [LAW:no-defensive-null-guards] A bare engine purely for AST introspection: it
41
+ // never evaluates, so `fromString` is identity and no funcs are registered (parse
42
+ // does not resolve function existence — that is an eval-time concern).
43
+ function segmentReferencesMenu(template: string): boolean {
44
+ const engine = createEngine<string>({ fromString: (s) => s });
45
+ try {
46
+ return engine.parse(template).referencedFunctions().has(MENU_FUNC);
47
+ } catch {
48
+ // A malformed template can host no usable menu; registerDslConfig re-parses
49
+ // and reports the real error. [LAW:no-silent-failure] — not swallowed, just
50
+ // not the place that reports it.
51
+ return false;
52
+ }
53
+ }
54
+
55
+ function menuIssue(ctx: ValidateCtx, path: string, message: string): void {
56
+ ctx.issues.push({ path, message, line: findKeyLine(ctx.source, ["root"]) });
57
+ }
58
+
59
+ export function synthesizeMenuDecls(
60
+ ctx: ValidateCtx,
61
+ out: Mutable<RawDslConfig>,
62
+ ): void {
63
+ // [LAW:single-enforcer] The `menus.` namespace is reserved UNCONDITIONALLY — a
64
+ // user name under it is rejected whether or not any menu is placed this load, so
65
+ // the reservation is a stable contract ("you never author menus.*"), not a rule
66
+ // that only switches on when synthesis happens to collide. Runs before any early
67
+ // return so a `menus.*` user name can never load silently.
68
+ for (const section of ["variables", "actions", "segments"] as const) {
69
+ for (const name of Object.keys(out[section] ?? {})) {
70
+ if (name.startsWith(MENU_NS)) {
71
+ menuIssue(
72
+ ctx,
73
+ `${section}.${name}`,
74
+ `"${name}" is in the reserved "${MENU_NS}" namespace (synthesized by {{ menu }} helpers) — rename it`,
75
+ );
76
+ }
77
+ }
78
+ }
79
+
80
+ // [LAW:no-silent-failure] A menu derives its accordion identity from its host
81
+ // SEGMENT's tree position; a `{{ define }}` helper is shared and placement-
82
+ // agnostic, so a `{{ menu }}` reached through one has no row to key on and would
83
+ // never get its backing state var/cycle action synthesized — failing at render.
84
+ // Reject it loudly at load, pointing the author to inline the menu in a segment.
85
+ // [LAW:no-mode-explosion] We reject rather than build helper-call-graph
86
+ // resolution speculatively; revisit only if a real shared-menu need appears.
87
+ for (const [name, body] of Object.entries(out.helpers ?? {})) {
88
+ if (segmentReferencesMenu(body)) {
89
+ ctx.issues.push({
90
+ path: `helpers.${name}`,
91
+ message: `helper "${name}" uses {{ menu }}, but a menu must live directly in a segment template — its accordion identity is derived from the segment's position in the layout, which a shared helper does not have. Inline the {{ menu }} call into each segment that needs it.`,
92
+ line: findKeyLine(ctx.source, ["helpers", name]),
93
+ });
94
+ }
95
+ }
96
+
97
+ if (out.root === undefined) return;
98
+ const segments = out.segments ?? {};
99
+
100
+ // [LAW:dataflow-not-control-flow] Memoize detection by segment name — a segment
101
+ // placed in N rows is parsed once, then each placement reads the cached verdict.
102
+ const hostsMenu = new Map<string, boolean>();
103
+ const referencesMenu = (segName: string): boolean => {
104
+ const cached = hostsMenu.get(segName);
105
+ if (cached !== undefined) return cached;
106
+ const seg = segments[segName];
107
+ const result =
108
+ seg !== undefined ? segmentReferencesMenu(seg.template) : false;
109
+ hostsMenu.set(segName, result);
110
+ return result;
111
+ };
112
+
113
+ // One state key per row (default "closed"); one cycle action per (row,segment).
114
+ const stateKeys = new Map<string, string>(); // stateKey → rowKey (for vars)
115
+ const actions: Record<string, ActionDecl> = {};
116
+ forEachSegmentPlacement(out.root, (segName, rowKey) => {
117
+ if (!referencesMenu(segName)) return;
118
+ // [LAW:types-are-the-program] A menu's member name IS its host segment name;
119
+ // a segment named exactly the closed-state sentinel would make the cycle
120
+ // [closed, "closed"] — two identical members, so the toggle could never leave
121
+ // the closed state. Reject that one collision at load (the only segment name
122
+ // that breaks a menu), rather than silently synthesizing an unopenable menu.
123
+ if (segName === MENU_CLOSED) {
124
+ menuIssue(
125
+ ctx,
126
+ `segments.${segName}`,
127
+ `segment "${segName}" hosts a {{ menu }}, but a menu cannot live in a segment named "${MENU_CLOSED}" — that name collides with the menu's closed-state sentinel, leaving the menu unopenable. Rename the segment.`,
128
+ );
129
+ return;
130
+ }
131
+ const stateKey = menuStateKey(rowKey);
132
+ stateKeys.set(stateKey, rowKey);
133
+ // [LAW:one-source-of-truth] Members ordered closed-first: an unset/foreign
134
+ // value counts as the first member (the cycle's "unknown ⇒ first" rule), so a
135
+ // never-clicked menu renders ▸ and a click opens it, auto-closing a row-mate
136
+ // because the shared key can hold only one member name.
137
+ actions[menuActionName(rowKey, segName)] = {
138
+ set: stateKey,
139
+ cycle: [MENU_CLOSED, segName],
140
+ };
141
+ });
142
+
143
+ if (stateKeys.size === 0) return;
144
+
145
+ const variables: Record<string, VariableDecl> = {};
146
+ for (const stateKey of stateKeys.keys()) {
147
+ variables[stateKey] = {
148
+ kind: "state",
149
+ key: stateKey,
150
+ default: MENU_CLOSED,
151
+ };
152
+ }
153
+
154
+ out.variables = { ...(out.variables ?? {}), ...variables };
155
+ out.actions = { ...(out.actions ?? {}), ...actions };
156
+ }
@@ -0,0 +1,101 @@
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 string — they 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.
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";
18
+
19
+ // [LAW:one-source-of-truth] The reserved namespace every synthesized menu
20
+ // artifact (state var + cycle action) lives under, mirroring group sugar's
21
+ // `groups.`. A user-authored name under this prefix is a load error so synthesis
22
+ // can never silently collide.
23
+ export const MENU_NS = "menus.";
24
+
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).
28
+ export const MENU_CLOSED = "closed";
29
+
30
+ // [LAW:representation] Disclosure glyph vocabulary — identical to group sugar so
31
+ // every disclosure across the bar reads the same (trailing the label/content it
32
+ // gates, per pdu.8): collapsed ▸, expanded ▾.
33
+ export const MENU_GLYPH_CLOSED = "▸";
34
+ export const MENU_GLYPH_OPEN = "▾";
35
+
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);
49
+ }
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;
57
+ }
58
+
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,
66
+ ): string {
67
+ return nearestHorizontalPath ?? ownPath;
68
+ }
69
+
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);
101
+ }
@@ -23,7 +23,7 @@
23
23
  // strip item), not a function of matching backgrounds.
24
24
 
25
25
  import { RichText } from "@promptctl/rich-js";
26
- import type { PaletteResolver } from "@promptctl/rich-js";
26
+ import type { PaletteResolver, Style } from "@promptctl/rich-js";
27
27
  import type { Template } from "@promptctl/go-template-js";
28
28
  import type {
29
29
  LayoutNode,
@@ -49,6 +49,12 @@ 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;
52
58
  }
53
59
  export interface CompiledContainerNode {
54
60
  readonly kind: "container";
@@ -88,6 +94,11 @@ export interface NodeCompileCtx {
88
94
  readonly path: string;
89
95
  // The node's own `when`, already parsed by the driver (one parse-when site).
90
96
  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>;
91
102
  // Compile a child node (the recursion, injected so this module needn't import
92
103
  // the driver).
93
104
  compileChild(node: LayoutNode, path: string): CompiledNode;
@@ -104,6 +115,15 @@ export interface NodeRenderCtx {
104
115
  // Advance the walk-owned hue cursor by one unit and return that unit's shift.
105
116
  nextHueShift(): number;
106
117
  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
127
  // Resolve a segment name to its decl + compiled form (the driver closes over
108
128
  // config.segments + the compiled segments).
109
129
  lookupSegment(
@@ -120,9 +140,17 @@ export interface NodeRenderCtx {
120
140
  // [LAW:dataflow-not-control-flow] A container's `direction` is the projection it
121
141
  // applies to its already-rendered child blocks — DATA selecting a fold, not a
122
142
  // branch that skips work. `vertical` STACKS (concatenate the children's line-
123
- // lists); `horizontal` ZIPS (row i is every child's row-i cells concatenated, so
124
- // the joiner caps ACROSS the seam — there is no abut). The switch is exhaustive
125
- // over `Direction`; adding `outline` to DIRECTIONS forces a new arm here.
143
+ // lists). The switch is exhaustive over `Direction`; adding `outline` to
144
+ // DIRECTIONS forces a new arm here.
145
+ //
146
+ // [LAW:decomposition] `horizontal` composes ONLY row 0 across the seam — row 0 is
147
+ // every child's FIRST line zipped (the inline powerline run, so the joiner caps
148
+ // across the seam, no abut). Every line BELOW row 0 is a child's DROP (a menu body
149
+ // dropping below its trigger, a genuinely multi-line segment): drops STACK full-
150
+ // width in child order, never zipped. Multi-line side-by-side column alignment is
151
+ // explicitly UNSUPPORTED — aligning two children's row-i cells would require
152
+ // background-as-structure, and bg is never structural. For an all-single-line row
153
+ // (no child has a drop) this is byte-identical to a plain per-row zip.
126
154
  function composeBlocks(
127
155
  direction: Direction,
128
156
  blocks: readonly RenderedLines[],
@@ -131,12 +159,15 @@ function composeBlocks(
131
159
  case "vertical":
132
160
  return blocks.flatMap((b) => b);
133
161
  case "horizontal": {
162
+ // [LAW:dataflow-not-control-flow] height 0 (every child hidden/empty) ⇒ the
163
+ // container contributes NO line — not one empty row. This is the value-driven
164
+ // identity of the fold, preserved from the per-row zip it replaces; a stray
165
+ // [[]] here would render as a spurious blank line.
134
166
  const height = blocks.reduce((m, b) => Math.max(m, b.length), 0);
135
- const rows: RichText[][] = [];
136
- for (let i = 0; i < height; i++) {
137
- rows.push(blocks.flatMap((b) => b[i] ?? []));
138
- }
139
- return rows;
167
+ if (height === 0) return [];
168
+ const row0 = blocks.flatMap((b) => b[0] ?? []);
169
+ const drops = blocks.flatMap((b) => b.slice(1));
170
+ return [row0, ...drops];
140
171
  }
141
172
  }
142
173
  }
@@ -185,7 +216,15 @@ const containerType: NodeType<"container"> = {
185
216
 
186
217
  const segmentType: NodeType<"segment"> = {
187
218
  compile(node, cctx) {
188
- return { kind: "segment", when: cctx.when, name: node.name };
219
+ return {
220
+ kind: "segment",
221
+ when: cctx.when,
222
+ 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
+ };
189
228
  },
190
229
  render(node, ctx) {
191
230
  const found = ctx.lookupSegment(node.name);
@@ -222,12 +261,21 @@ const segmentType: NodeType<"segment"> = {
222
261
  segCompiled.paletteResolver ?? ctx.basePalette,
223
262
  hueShift,
224
263
  );
225
- const baseStyle = resolveSegmentColors(
264
+ const resolvedStyle = resolveSegmentColors(
226
265
  resolver,
227
266
  segCompiled.bg,
228
267
  segCompiled.fg,
229
268
  ctx.scope,
230
269
  );
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
+ );
231
279
 
232
280
  const fragments = segCompiled.template.evaluate(ctx.scope);
233
281
  const segCells = fragmentsToCells(fragments, baseStyle);
package/src/dsl/render.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // govern output, not whether operations run.
13
13
 
14
14
  import type { RichText, PaletteResolver } from "@promptctl/rich-js";
15
+ import { ColorSpec, Style, lighten } from "@promptctl/rich-js";
15
16
  import type { Engine, Template } from "@promptctl/go-template-js";
16
17
  import type {
17
18
  ValidatedConfig,
@@ -38,9 +39,12 @@ import {
38
39
  import {
39
40
  compileActions,
40
41
  actionFuncs,
42
+ readVar,
41
43
  type ActionRuntime,
42
44
  } from "../render/action.js";
43
45
  import { pickerFuncs } from "../render/picker.js";
46
+ import { menuFuncs, type MenuRuntime } from "../render/menu.js";
47
+ import { forEachSegmentPlacement, menuStateKey } from "../config/menu-keys.js";
44
48
  // [LAW:one-way-deps] The node-type registry sits below this driver: it owns the
45
49
  // compiled node shapes + each kind's compile/render, dispatched via nodeType().
46
50
  // render.ts threads the recursion (compileChild/renderChild) + the hue counter in
@@ -66,6 +70,13 @@ import {
66
70
  export interface CompiledConfig {
67
71
  readonly segments: CompiledSegments;
68
72
  readonly root: CompiledNode;
73
+ // [LAW:locality-or-seam] The menu runtime the engine's `menu` func closes over,
74
+ // surfaced here so renderDsl can publish each segment's placement into it before
75
+ // that segment's template evaluates. One instance per compiled config; its
76
+ // `current` is mutated synchronously within a single renderDsl walk (renders are
77
+ // sequential + synchronous, so no cross-render leak) — the spatial cousin of the
78
+ // hue cursor, one owner. [LAW:no-ambient-temporal-coupling]
79
+ readonly menuRuntime: MenuRuntime;
69
80
  // [LAW:types-are-the-program] Variable declaration failures that did NOT
70
81
  // prevent the config from loading (type mismatches, bad defaults). The
71
82
  // affected variables are absent from the store; segments that reference them
@@ -276,11 +287,17 @@ export function registerDslConfig(
276
287
  // [LAW:single-enforcer] Forward the caller's clock (the daemon's `() => new
277
288
  // Date()`, a test's frozen clock) to the one engine. Omitted ⇒ undefined ⇒
278
289
  // createCcCandybarEngine applies its single default; no second default literal.
290
+ // [LAW:locality-or-seam] The menu runtime shares the action runtime (a menu's
291
+ // glyph + body resolve from the same compiled table + store) and carries the
292
+ // walk-published current placement. Built before the engine so the `menu` func
293
+ // can close over it; `current` stays null until a render walk publishes one.
294
+ const menuRuntime: MenuRuntime = { action: actionRuntime, current: null };
279
295
  const engine = createCcCandybarEngine(
280
296
  undefined,
281
297
  {
282
298
  ...actionFuncs(actionRuntime),
283
299
  ...pickerFuncs(actionRuntime),
300
+ ...menuFuncs(menuRuntime),
284
301
  },
285
302
  opts?.clock,
286
303
  );
@@ -406,6 +423,14 @@ export function registerDslConfig(
406
423
  );
407
424
  }
408
425
  };
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
+ });
409
434
  const compileNode = (node: LayoutNode, path: string): CompiledNode => {
410
435
  const cctx: NodeCompileCtx = {
411
436
  path,
@@ -413,6 +438,7 @@ export function registerDslConfig(
413
438
  node.when === undefined
414
439
  ? undefined
415
440
  : parseNodeField(node.when, path, "when"),
441
+ rowKeyByPath,
416
442
  compileChild: compileNode,
417
443
  };
418
444
  return nodeType(node.kind).compile(node, cctx);
@@ -421,10 +447,27 @@ export function registerDslConfig(
421
447
  return {
422
448
  segments: compiled,
423
449
  root: compileNode(config.root, "root"),
450
+ menuRuntime,
424
451
  loadWarnings,
425
452
  };
426
453
  }
427
454
 
455
+ // [LAW:dataflow-not-control-flow] The focus tint: when a segment's own menu is
456
+ // open it is "focused", so its base background is lightened (rich-js owns the
457
+ // math — see [[rich-js-owns-color-math]]). The transform is RELATIVE to the
458
+ // resolved background, so any host theme tints to a consistent step above its own
459
+ // surface; a segment with no background (transparent) has nothing to lighten and
460
+ // passes through unchanged. One level ≈ 10% lightness — a subtle "this is active".
461
+ const MENU_FOCUS_LIGHTEN_LEVELS = 1;
462
+ function focusTint(style: Style): Style {
463
+ const bg = style.bgcolor;
464
+ if (bg === undefined) return style;
465
+ const lit = ColorSpec.fromRgba(
466
+ lighten(bg.getTruecolor(), MENU_FOCUS_LIGHTEN_LEVELS),
467
+ );
468
+ return new Style({ bgcolor: lit, color: style.color });
469
+ }
470
+
428
471
  // ─── renderDsl ───────────────────────────────────────────────────────────────
429
472
 
430
473
  /**
@@ -525,6 +568,23 @@ export function renderDsl(
525
568
  : undefined;
526
569
  };
527
570
 
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;
586
+ };
587
+
528
588
  // [LAW:dataflow-not-control-flow] ONE walk renders any node to LINES OF CELLS
529
589
  // (serialization deferred to the root). The driver owns the cross-cutting
530
590
  // `when`: `visible` ANDs the node's own predicate with its ancestors'. It then
@@ -542,6 +602,7 @@ export function renderDsl(
542
602
  visible,
543
603
  nextHueShift,
544
604
  perSegmentSink,
605
+ prepareSegment,
545
606
  lookupSegment,
546
607
  renderChild: renderNode,
547
608
  };