@promptctl/cc-candybar 1.42.1 → 1.44.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.
Files changed (126) hide show
  1. package/dist/index.mjs +98 -84
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,96 +0,0 @@
1
- // [LAW:types-are-the-program] The `looks` schema: each look is a named rich-js
2
- // ThemeKey — an ADAPTATION applied on top of whatever base theme is active (a
3
- // transform, not a palette), so one look composes with every theme. The config
4
- // spelling mirrors ThemeKey's field names VERBATIM (hueShift / chromaScale /
5
- // lightnessScale / lightnessShift) and the parsed output IS a rich-js ThemeKey —
6
- // no translation layer to drift, and a rich-js field rename fails this module's
7
- // compile instead of silently diverging. [LAW:one-source-of-truth]
8
- //
9
- // The adaptation vocabulary is CAPPED at ThemeKey's four axes. Role remap (the
10
- // old surface/button "role emphasis") is deferred; its exit plan is a future
11
- // rich-js resolver-level role→role operation carried as an additive `roles`
12
- // field here — growing the vocabulary means growing rich-js, never adding color
13
- // math to cc-candybar.
14
-
15
- import type { ThemeKey } from "@promptctl/rich-js";
16
- import { IDENTITY } from "@promptctl/rich-js";
17
- import {
18
- describeType,
19
- isPlainObject,
20
- optionalNumberSpec,
21
- record,
22
- recordJson,
23
- type JsonNode,
24
- type RecordSchema,
25
- type ValidateCtx,
26
- } from "./validate-core.js";
27
- import { findKeyLine } from "./diagnostics.js";
28
-
29
- // [LAW:types-are-the-program] The AUTHORING shape: every axis optional, absent =
30
- // identity. Distinct from ThemeKey (all fields required) so the record engine's
31
- // omit-absent output is honestly typed; validateLooks normalizes each parsed
32
- // spec onto IDENTITY, and past this module a partial look is unrepresentable.
33
- interface LookSpec {
34
- readonly hueShift?: number;
35
- readonly chromaScale?: number;
36
- readonly lightnessScale?: number;
37
- readonly lightnessShift?: number;
38
- }
39
-
40
- // [LAW:one-source-of-truth] The four axes, declared once as DATA the record
41
- // engine interprets for both validation and schema emit. chromaScale is a
42
- // multiplier on saturation — negative chroma is not a color, so the one bound.
43
- const LOOK_SCHEMA: RecordSchema<LookSpec> = {
44
- noun: "look key",
45
- fields: {
46
- hueShift: optionalNumberSpec(),
47
- chromaScale: optionalNumberSpec({ min: 0 }),
48
- lightnessScale: optionalNumberSpec(),
49
- lightnessShift: optionalNumberSpec(),
50
- },
51
- };
52
-
53
- // An absent looks block is handled by the caller (absence survives the parse);
54
- // a non-object is a reported error recovering to empty — parseDslConfig throws
55
- // once any issue exists, so the recovery value never renders.
56
- export function validateLooks(
57
- ctx: ValidateCtx,
58
- raw: unknown,
59
- ): Readonly<Record<string, ThemeKey>> {
60
- if (!isPlainObject(raw)) {
61
- ctx.issues.push({
62
- path: "looks",
63
- message: `looks must be an object mapping look names to adaptation objects, got ${describeType(raw)}`,
64
- line: findKeyLine(ctx.source, ["looks"]),
65
- });
66
- return {};
67
- }
68
- const out: Record<string, ThemeKey> = {};
69
- for (const [name, value] of Object.entries(raw)) {
70
- // [LAW:no-silent-fallbacks] A look name is a deliverable set-state value —
71
- // a look picker writes it on the wire, which rejects empty values and
72
- // splits on "/". Rejecting the shape HERE surfaces the error on every
73
- // config load, not only once an action ranges the "looks" domain (the same
74
- // wire shape cycle members and `to` literals enforce in actions.ts).
75
- if (name === "" || name.includes("/")) {
76
- ctx.issues.push({
77
- path: `looks.${name}`,
78
- message: `look name ${JSON.stringify(name)} must be non-empty and slash-free — a look picker writes the name on the set-state wire, which rejects empty values and splits on "/"`,
79
- line: findKeyLine(ctx.source, ["looks", name]),
80
- });
81
- continue;
82
- }
83
- const parsed = record(ctx, LOOK_SCHEMA, `looks.${name}`, value);
84
- // [LAW:one-source-of-truth] Normalization onto IDENTITY is the single
85
- // "absent axis = identity" site — downstream consumers receive a total
86
- // ThemeKey and never re-default a missing axis.
87
- if (parsed !== null) out[name] = { ...IDENTITY, ...parsed };
88
- }
89
- return out;
90
- }
91
-
92
- // [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
93
- // the validator interprets — a map of look names to the closed four-axis object.
94
- export function looksJson(): JsonNode {
95
- return { type: "object", additionalProperties: recordJson(LOOK_SCHEMA) };
96
- }
@@ -1,435 +0,0 @@
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 menu state key (default
6
- // "closed"), one `cycle` action per (state key, member), AND — bn5.6 — the
7
- // picker body's page cursor (a `state` var + int action named by menuPageKey)
8
- // per state key, all under the reserved `menus.` namespace. Everything lands in
9
- // the raw sections so it merges over the default and, crucially, so
10
- // `deriveActionValidators(config.actions)` derives the click gates from them
11
- // through the ONE existing path — a menu toggle and its page cursor are gated
12
- // like every other set, no parallel verb. [LAW:single-enforcer]
13
- //
14
- // Runs in `parseDslConfig` after group synthesis and after every section parsed,
15
- // so the reserved-namespace collision check sees the fully-parsed user sections.
16
- //
17
- // [LAW:types-are-the-program] WHICH segments host a menu, and with WHAT apply
18
- // action + options, is read from the parsed AST (`referencedCalls` → `argExprs`
19
- // + `staticDictEntries`), not a source-text scan — robust against whitespace,
20
- // pipelines, and `.menu`/"menu" lookalikes, and it yields each call's literal
21
- // apply name and literal `(dict …)` options so a menu's identity (member =
22
- // apply name; key = the optional "key" option) is the SAME fact the render
23
- // helper reads from those same arguments. A parse failure here is treated as
24
- // "no menu" because the authoritative parse error is surfaced loudly by
25
- // `registerDslConfig` when it compiles the same template (so this pass never
26
- // swallows a real error, it just declines to guess).
27
-
28
- import {
29
- createEngine,
30
- staticDictEntries,
31
- type ReferencedCall,
32
- } from "@promptctl/go-template-js";
33
- import type { ActionDecl } from "../action.js";
34
- import type { Mutable, ValidateCtx } from "./validate-core.js";
35
- import {
36
- MENU_NS,
37
- menuActionName,
38
- menuMember,
39
- menuPageKey,
40
- menuStateKey,
41
- parseMenuOptions,
42
- type MenuOptions,
43
- } from "../menu-keys.js";
44
- import {
45
- cycleDisplayIssue,
46
- DISCLOSURE_CLOSED,
47
- disclosureCycleAction,
48
- disclosureStateVar,
49
- } from "../disclosure.js";
50
- import {
51
- walkNodes,
52
- type RawDslConfig,
53
- type VariableDecl,
54
- } from "../dsl-types.js";
55
- import { findKeyLine } from "./diagnostics.js";
56
- import { reservedNamespaceCollisions } from "./reserved-namespace.js";
57
-
58
- // [LAW:single-enforcer] The helper-name a `{{ menu … }}` call uses — the same
59
- // string the render FuncMap registers. A segment "hosts a menu" iff its template
60
- // references this function.
61
- const MENU_FUNC = "menu";
62
-
63
- // [LAW:types-are-the-program] The `{{ menu }}` surface, mirroring the render
64
- // helper's signature `menu "apply" display… [(dict …)]`: the apply name
65
- // (identity member, a required string literal), the trigger's authored display
66
- // text (one per state or one static — the arity is statically countable, so it
67
- // is checked here), and ONE optional trailing options dict — closeOnPick /
68
- // paged / key, all statically readable via `staticDictEntries`. Displays
69
- // themselves are NOT required to be literals; identity does not depend on
70
- // them, exactly as a cycle `{{ action }}`'s displays are free. Every removed
71
- // spelling is rejected with a migration-pointing error, never silently
72
- // reinterpreted [LAW:no-silent-failure].
73
- const MIGRATION = `a menu binds its trigger text the way a cycle action binds a display — write {{ menu "applyTheme" "▸" "▾" }} (one per state) or {{ menu "insertHere" "+" }} (one static display for both), with the rare knobs in ONE trailing dict: {{ menu "applyTheme" "▸" "▾" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key). The renderer no longer appends ▸/▾ of its own (candybar-settings-ui-aok.4), and the older positional tail ("pageAction" closeOnPick paged "key") was removed — the page cursor is synthesized from the menu's identity`;
74
-
75
- // [LAW:dataflow-not-control-flow] One total analysis of a `{{ menu }}` call site:
76
- // every reachable argument shape lands in exactly one arm — a usable identity
77
- // (apply + parsed options) or one load-error message (the tail after
78
- // `segment "<name>" has a {{ menu }} `). No shape falls through to render time.
79
- type MenuAnalysis =
80
- | {
81
- readonly kind: "ok";
82
- readonly apply: string;
83
- readonly options: MenuOptions;
84
- }
85
- | { readonly kind: "issue"; readonly message: string };
86
-
87
- type ArgExpr = ReferencedCall["argExprs"][number];
88
-
89
- const isDictCall = (e: ArgExpr): boolean =>
90
- e.kind === "call" && e.name === "dict";
91
-
92
- // [LAW:one-source-of-truth] The two sides split the tail on different evidence —
93
- // exprs here, evaluated values in `parseMenuArgs` — so the loader admits only
94
- // call sites where those two readings PROVABLY coincide. The renderer's split
95
- // asks one question of the last value, "is it an object", so a literal answers
96
- // it here: a parse-time constant evaluates to itself and can never become the
97
- // options dict. A literal `(dict …)` always does. Everything else in that slot
98
- // is classified by whatever it happens to evaluate to.
99
- const isNonObjectLiteral = (e: ArgExpr): boolean =>
100
- e.kind === "literal" && typeof e.value !== "object";
101
-
102
- // The display-arity rule used as a predicate; the message is the caller's
103
- // business, so the subject never surfaces. [LAW:single-enforcer] — legality is
104
- // read off the disclosure primitive, never restated as a count comparison.
105
- const legalDisplayCount = (count: number): boolean =>
106
- cycleDisplayIssue("", count, 2) === undefined;
107
-
108
- function analyzeMenuCall(call: ReferencedCall): MenuAnalysis {
109
- const issue = (message: string): MenuAnalysis => ({ kind: "issue", message });
110
- const [applyArg, ...tail] = call.argExprs;
111
- if (applyArg === undefined) {
112
- return issue(
113
- `with no arguments — it takes an apply-action name and its trigger text (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
114
- );
115
- }
116
- if (applyArg.kind !== "literal" || typeof applyArg.value !== "string") {
117
- return issue(
118
- `whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
119
- );
120
- }
121
- // [LAW:types-are-the-program] The dict is the LAST argument when present;
122
- // everything before it is a display. Splitting on that one position is the
123
- // whole grammar, and it is the same split `parseMenuArgs` performs on the
124
- // evaluated tail at render — one shape, read twice from the two things each
125
- // side has (exprs here, values there).
126
- const last = tail[tail.length - 1];
127
- const optsArg = last !== undefined && isDictCall(last) ? last : undefined;
128
- const displays = optsArg === undefined ? tail : tail.slice(0, -1);
129
- if (displays.some(isDictCall)) {
130
- return issue(
131
- `whose options (dict …) is not its last argument — ${MIGRATION}`,
132
- );
133
- }
134
- // [LAW:no-silent-failure] The last slot is the one both readings can claim.
135
- // When the expr there is not provably one or the other AND dropping it still
136
- // leaves a legal display count, the renderer's value-based split can land on
137
- // a DIFFERENT reading than this one — same call, two shapes, no error either
138
- // side: the options dict skips `staticDictEntries` (so a dynamic `key` derives
139
- // a state key with no synthesized var behind it, and the menu never opens) or
140
- // a display vanishes into the static form. Reject that call site; an explicit
141
- // trailing `(dict …)` disambiguates it and keeps dynamic displays legal.
142
- // Where the alternate reading is an ILLEGAL count the renderer throws instead
143
- // of diverging, so it stays accepted — loudness, not refusal, is the bar.
144
- if (
145
- last !== undefined &&
146
- optsArg === undefined &&
147
- !isNonObjectLiteral(last) &&
148
- legalDisplayCount(displays.length - 1)
149
- ) {
150
- return issue(
151
- `whose last argument is neither a literal nor a literal (dict …) — the renderer tells a display from the options dict by the value it evaluates to, so this call could be read as ${displays.length} displays or as ${displays.length - 1} plus options, and both are legal. Make the options explicit as a trailing (dict …) — {{ menu "${applyArg.value}" (printf "…") (printf "…") (dict) }} binds dynamic displays unambiguously — or bind the trigger text as literals`,
152
- );
153
- }
154
- // [LAW:single-enforcer] The display-arity rule is the disclosure primitive's,
155
- // the same one the renderer picks through — checked HERE too because the
156
- // count is statically known, so an unauthored trigger is a load error naming
157
- // the fix rather than a diagnostic glyph on the next render.
158
- // "whose trigger …" completes the caller's `segment "X" has a {{ menu }} `.
159
- const arity = cycleDisplayIssue("whose trigger", displays.length, 2);
160
- if (arity !== undefined) return issue(`${arity} — ${MIGRATION}`);
161
- const entries = optsArg === undefined ? {} : staticDictEntries(optsArg);
162
- if (entries === null) {
163
- return issue(
164
- `whose options (dict …) is not fully literal — every option value must be a literal so the menu can be gated at load (a dynamic entry like (dict "key" .x) cannot)`,
165
- );
166
- }
167
- try {
168
- return {
169
- kind: "ok",
170
- apply: applyArg.value,
171
- options: parseMenuOptions(entries),
172
- };
173
- } catch (e) {
174
- return issue(`with invalid options — ${(e as Error).message}`);
175
- }
176
- }
177
-
178
- // [LAW:no-defensive-null-guards] A bare engine purely for AST introspection: it
179
- // never evaluates, so `fromString` is identity and no funcs are registered (parse
180
- // does not resolve function existence — that is an eval-time concern).
181
- function parseCalls(
182
- template: string,
183
- ): readonly MenuAnalysis[] | "parse-failed" {
184
- const engine = createEngine<string>({ fromString: (s) => s });
185
- try {
186
- return engine
187
- .parse(template)
188
- .referencedCalls()
189
- .filter((c) => c.name === MENU_FUNC)
190
- .map(analyzeMenuCall);
191
- } catch {
192
- // A malformed template can host no usable menu; registerDslConfig re-parses
193
- // and reports the real error. [LAW:no-silent-failure] — not swallowed, just
194
- // not the place that reports it.
195
- return "parse-failed";
196
- }
197
- }
198
-
199
- function segmentReferencesMenu(template: string): boolean {
200
- const engine = createEngine<string>({ fromString: (s) => s });
201
- try {
202
- return engine.parse(template).referencedFunctions().has(MENU_FUNC);
203
- } catch {
204
- return false;
205
- }
206
- }
207
-
208
- function menuIssue(ctx: ValidateCtx, path: string, message: string): void {
209
- ctx.issues.push({ path, message, line: findKeyLine(ctx.source, ["root"]) });
210
- }
211
-
212
- export function synthesizeMenuDecls(
213
- ctx: ValidateCtx,
214
- out: Mutable<RawDslConfig>,
215
- ): void {
216
- // [LAW:single-enforcer] The `menus.` namespace is reserved UNCONDITIONALLY — a
217
- // user name under it is rejected whether or not any menu is placed this load, so
218
- // the reservation is a stable contract ("you never author menus.*"), not a rule
219
- // that only switches on when synthesis happens to collide. Runs before any early
220
- // return so a `menus.*` user name can never load silently. The check is the
221
- // disclosure primitive's shared enforcer (mirroring group sugar's `groups.`).
222
- reservedNamespaceCollisions(ctx, out, MENU_NS, "{{ menu }} helpers");
223
-
224
- // [LAW:no-silent-failure] A menu derives its identity from the SEGMENT it sits
225
- // in (the published segment name) plus its own apply arg; a `{{ define }}`
226
- // helper is shared across segments, so the synthesis pass — which scans each
227
- // segment's own template — cannot see a menu reached only through `{{ template
228
- // }}` and would never synthesize its backing state var/cycle action, failing at
229
- // render. Reject it loudly at load, pointing the author to inline the menu.
230
- // [LAW:no-mode-explosion] We reject rather than resolve the helper call graph
231
- // speculatively; revisit only if a real shared-menu need appears.
232
- for (const [name, body] of Object.entries(out.helpers ?? {})) {
233
- if (segmentReferencesMenu(body)) {
234
- ctx.issues.push({
235
- path: `helpers.${name}`,
236
- message: `helper "${name}" uses {{ menu }}, but a menu must live directly in a segment template — its identity is derived from the segment it sits in, which a shared helper does not have. Inline the {{ menu }} call into each segment that needs it.`,
237
- line: findKeyLine(ctx.source, ["helpers", name]),
238
- });
239
- }
240
- }
241
-
242
- const segments = out.segments ?? {};
243
-
244
- // [LAW:locality-or-seam] A menu publishes its placement ONLY around the segment
245
- // `template` eval; bg/fg evaluate after that window and a node/segment `when`
246
- // before it, so a {{ menu }} in any of them throws at render. The template is
247
- // the menu's one valid seam — reject it anywhere else at load, rather than
248
- // admit a config that parses but crashes on render.
249
- for (const [segName, seg] of Object.entries(segments)) {
250
- for (const field of ["bg", "fg", "when"] as const) {
251
- const tpl = seg[field];
252
- if (typeof tpl === "string" && segmentReferencesMenu(tpl)) {
253
- menuIssue(
254
- ctx,
255
- `segments.${segName}.${field}`,
256
- `segment "${segName}" uses {{ menu }} in its "${field}" — a menu is only valid in a segment's "template" (its placement is published only there; "${field}" needs a ${field === "when" ? "predicate" : "color"}). Move the {{ menu }} into the template.`,
257
- );
258
- }
259
- }
260
- }
261
-
262
- // One walk over the layout: reject {{ menu }} in node `when` predicates (same
263
- // template-only rule), and count each segment's placements for the reuse check.
264
- const placementCounts = new Map<string, number>();
265
- if (out.root !== undefined) {
266
- for (const node of walkNodes(out.root)) {
267
- if (typeof node.when === "string" && segmentReferencesMenu(node.when)) {
268
- menuIssue(
269
- ctx,
270
- "root",
271
- `a layout node's "when" predicate uses {{ menu }} — a menu is only valid in a segment's "template", not a node predicate. Move it into a segment.`,
272
- );
273
- }
274
- if (node.kind === "segment") {
275
- placementCounts.set(
276
- node.name,
277
- (placementCounts.get(node.name) ?? 0) + 1,
278
- );
279
- }
280
- }
281
- }
282
-
283
- // [LAW:types-are-the-program] A menu-bearing segment placed in more than one
284
- // layout slot is ambiguous: its disclosure open-state is keyed by segment name
285
- // (one state for the segment), so two placements would share it and a click on
286
- // one would toggle both. Reject the repeated placement — the ambiguity is
287
- // unrepresentable, and identity stays name-derived (no placement path threaded
288
- // into the key). Two independent disclosures = two named segments.
289
- for (const [segName, seg] of Object.entries(segments)) {
290
- if (!segmentReferencesMenu(seg.template)) continue;
291
- if ((placementCounts.get(segName) ?? 0) > 1) {
292
- menuIssue(
293
- ctx,
294
- `segments.${segName}`,
295
- `segment "${segName}" hosts a {{ menu }} and is placed in the layout more than once — a menu's open-state is keyed by segment name, so the copies would share one state (clicking one would toggle both). Give each placement its own named segment.`,
296
- );
297
- }
298
- }
299
-
300
- // One state var per state key (default "closed"); one cycle action per
301
- // (stateKey, member); one page-cursor var + int action per state key.
302
- // [LAW:dataflow-not-control-flow] Independent menus each contribute their own
303
- // key; shared-key menus contribute distinct members to one key, and the
304
- // same-key validator merge unions them into one accordion gate.
305
- const stateKeys = new Set<string>();
306
- const actions: Record<string, ActionDecl> = {};
307
- // Guard against two menus claiming one identity (same key + same member): for
308
- // independent menus that means the literal same `{{ menu }}` twice in a
309
- // segment; for shared-key menus it means two menus with the same apply name
310
- // sharing a key — neither can be addressed distinctly, so reject.
311
- const claimed = new Set<string>();
312
- // [LAW:types-are-the-program] A synthesized key is `ident()`-normalized so it
313
- // carries no separators; that normalization is lossy (`a-b` and `a_b`
314
- // collapse), so two DISTINCT declarations could map to one key and silently
315
- // share state (an unintended accordion). Track the raw "owner" each
316
- // synthesized name (state key AND its derived page key) legitimately belongs
317
- // to — a shared key is owned by its raw key string (every sibling agrees); an
318
- // independent menu by its raw (segment, apply). A second owner on the same
319
- // name is a collision, rejected at load so it is unrepresentable
320
- // [LAW:no-silent-failure] rather than corrupting grouping. Registering the
321
- // page key too closes the cross-shape aliasing corner (e.g. an apply action
322
- // named "page" in segment "s" vs the page cursor of a shared key "s").
323
- const ownerBySynthKey = new Map<string, string>();
324
-
325
- for (const [segName, seg] of Object.entries(segments)) {
326
- if (!segmentReferencesMenu(seg.template)) continue;
327
- const calls = parseCalls(seg.template);
328
- if (calls === "parse-failed") continue;
329
- for (const call of calls) {
330
- // [LAW:no-silent-failure] Every non-ok argument shape (missing apply,
331
- // non-literal apply, the removed positional tail, a non-literal or
332
- // malformed options dict) surfaces here as one load error with the
333
- // analysis's migration-pointing text.
334
- if (call.kind === "issue") {
335
- menuIssue(
336
- ctx,
337
- `segments.${segName}`,
338
- `segment "${segName}" has a {{ menu }} ${call.message}.`,
339
- );
340
- continue;
341
- }
342
- const { apply, options } = call;
343
- // [LAW:types-are-the-program] An empty apply name → empty member, and the
344
- // store returns "" for an absent state key, so `open = read === member`
345
- // would be true before any click — the menu would render open spuriously.
346
- // Reject it (the member must never alias the absent-state sentinel).
347
- if (apply === "") {
348
- menuIssue(
349
- ctx,
350
- `segments.${segName}`,
351
- `segment "${segName}" has a {{ menu }} with an empty apply-action name — an empty member aliases the absent-state sentinel ("") so the menu would render open before any click. Name the apply action.`,
352
- );
353
- continue;
354
- }
355
- const member = menuMember(apply);
356
- // [LAW:types-are-the-program] A member equal to the closed sentinel makes
357
- // the cycle [closed, "closed"] — two identical members, leaving the menu
358
- // unopenable. The only apply name that breaks a menu; reject it at load.
359
- if (member === DISCLOSURE_CLOSED) {
360
- menuIssue(
361
- ctx,
362
- `segments.${segName}`,
363
- `segment "${segName}" has a {{ menu }} whose apply action is named "${DISCLOSURE_CLOSED}", which collides with the menu's closed-state sentinel and leaves it unopenable. Rename the action.`,
364
- );
365
- continue;
366
- }
367
- const stateKey = menuStateKey(segName, apply, options.key);
368
- const pageKey = menuPageKey(stateKey);
369
- // The raw declaration these keys legitimately belong to. Shared-key
370
- // siblings all share one owner (their raw key); an independent menu owns
371
- // its keys alone (its raw segment+apply, NUL-joined so the two parts
372
- // can't run together).
373
- const owner =
374
- options.key !== undefined
375
- ? `key${options.key}`
376
- : `ind${segName}${apply}`;
377
- const clashKey = [stateKey, pageKey].find((k) => {
378
- const prior = ownerBySynthKey.get(k);
379
- return prior !== undefined && prior !== owner;
380
- });
381
- if (clashKey !== undefined) {
382
- menuIssue(
383
- ctx,
384
- `segments.${segName}`,
385
- `two {{ menu }} disclosures normalize to the same state key ("${clashKey}") but were declared differently — distinct names that differ only by non-alphanumeric characters (e.g. "a-b" vs "a_b") collapse to one key and would silently share open-state. Rename so they don't collide.`,
386
- );
387
- continue;
388
- }
389
- ownerBySynthKey.set(stateKey, owner);
390
- ownerBySynthKey.set(pageKey, owner);
391
- const identity = menuActionName(stateKey, member);
392
- if (claimed.has(identity)) {
393
- menuIssue(
394
- ctx,
395
- `segments.${segName}`,
396
- `two {{ menu }} disclosures resolve to the same identity ("${identity}") — ${
397
- options.key !== undefined
398
- ? `menus sharing key "${options.key}" must have distinct apply actions`
399
- : `a segment cannot contain two menus over the same apply action "${apply}"`
400
- }.`,
401
- );
402
- continue;
403
- }
404
- claimed.add(identity);
405
- stateKeys.add(stateKey);
406
- // [LAW:one-source-of-truth] The shared disclosure toggle: members ordered
407
- // closed-first (an unset/foreign value counts as the first member — the
408
- // cycle's "unknown ⇒ first" rule — so a never-clicked menu renders ▸ and a
409
- // click opens it; a shared key holding one member auto-closes its siblings).
410
- actions[identity] = disclosureCycleAction(stateKey, member);
411
- }
412
- }
413
-
414
- if (stateKeys.size === 0) return;
415
-
416
- const variables: Record<string, VariableDecl> = {};
417
- for (const stateKey of stateKeys) {
418
- variables[stateKey] = disclosureStateVar(stateKey, DISCLOSURE_CLOSED);
419
- // [LAW:one-source-of-truth] The synthesized page cursor — the half a blind
420
- // author used to hand-declare and forget, silently freezing the picker on
421
- // page 0 (renderPicker read an unbound key as "" → clamp 0). Both halves
422
- // are emitted together, named by menuPageKey, so the pairing is a
423
- // construction, not a convention: the state VAR (named by the key, the
424
- // disclosure-var convention) is what the renderer reads the live page
425
- // through; the int ACTION is what deriveActionValidators derives the ←/→/✕
426
- // wire gate from — the one existing path, no parallel gate
427
- // [LAW:single-enforcer].
428
- const pageKey = menuPageKey(stateKey);
429
- variables[pageKey] = { kind: "state", key: pageKey, default: "0" };
430
- actions[pageKey] = { set: pageKey, int: true };
431
- }
432
-
433
- out.variables = { ...(out.variables ?? {}), ...variables };
434
- out.actions = { ...(out.actions ?? {}), ...actions };
435
- }
@@ -1,115 +0,0 @@
1
- // [LAW:one-source-of-truth] The single point that merges a raw user config
2
- // onto a default DslConfig to fill missing keys. A user file declares only
3
- // what differs; the cascade here (shallow-merge globals, by-name merge
4
- // variables/segments/actions, wholesale root replacement) is the one place
5
- // "absent means inherit" is decided. This file changes when the merge
6
- // semantics change.
7
- //
8
- // [LAW:one-way-deps] `dflt` is a required parameter — this module is generic
9
- // merge machinery and does not know about DEFAULT_DSL_CONFIG, the specific
10
- // bundled instance built ON TOP of it (default-dsl-config.ts imports this
11
- // function to synthesize itself). A default param pointing back at
12
- // DEFAULT_DSL_CONFIG would make this generic module depend on its own
13
- // specific consumer, a cycle. Callers who want "the bundled default" import
14
- // DEFAULT_DSL_CONFIG from default-dsl-config.ts and pass it explicitly.
15
-
16
- import {
17
- type DslConfig,
18
- type RawDslConfig,
19
- type SegmentDecl,
20
- } from "../dsl-types.js";
21
-
22
- /**
23
- * Merge a RawDslConfig on top of a default DslConfig. Pure function.
24
- *
25
- * globals : shallow merge per field (user wins per-field)
26
- * variables : merge by name (user wins per-name)
27
- * segments : merge by name (user wins per-name)
28
- * root : the canonical layout tree. Authored via the A-grammar (`root`)
29
- * replaces wholesale; absent → default's tree.
30
- * [LAW:one-source-of-truth] `layout:` is rejected at parse time
31
- * with a migration error (removed in 2de.19), so only `root`
32
- * ever reaches this function.
33
- */
34
- export function mergeWithDefault(
35
- raw: RawDslConfig,
36
- dflt: DslConfig,
37
- ): DslConfig {
38
- return {
39
- globals: { ...dflt.globals, ...(raw.globals ?? {}) },
40
- variables: { ...dflt.variables, ...(raw.variables ?? {}) },
41
- segments: { ...dflt.segments, ...(raw.segments ?? {}) },
42
- root: raw.root !== undefined ? raw.root : dflt.root,
43
- // [LAW:one-source-of-truth] actions merge by name, same cascade — a user
44
- // declares only the actions that differ from the bundled default (which
45
- // ships none).
46
- actions: { ...dflt.actions, ...(raw.actions ?? {}) },
47
- // [LAW:one-source-of-truth] looks merge by name, same cascade — a user
48
- // overrides one adaptation by re-declaring its name; the bundled stdlib
49
- // (incl. the "none" identity floor) survives every merge by construction.
50
- looks: { ...dflt.looks, ...(raw.looks ?? {}) },
51
- // [LAW:one-source-of-truth] presets merge by name, same cascade — a user
52
- // overrides one arrangement by re-declaring its name; the bundled stdlib
53
- // (incl. the "default" empty-fragment floor effectivePresetName collapses
54
- // to) survives every merge by construction, exactly as looks' "none" does.
55
- presets: { ...dflt.presets, ...(raw.presets ?? {}) },
56
- // [LAW:one-source-of-truth] editGlobals merges FIELD by field — the
57
- // `globals` cascade above, not the by-name cascades around it, because it
58
- // IS a globals fragment: a user retuning edit mode's separator says nothing
59
- // about its `style`, exactly as a user setting `globals.padding` says
60
- // nothing about `globals.charset`.
61
- editGlobals: { ...dflt.editGlobals, ...(raw.editGlobals ?? {}) },
62
- // [LAW:one-source-of-truth] helpers merge by name, same cascade — a user
63
- // overrides one formatter helper by re-declaring its name; the rest inherit
64
- // from the bundled default.
65
- helpers: { ...dflt.helpers, ...(raw.helpers ?? {}) },
66
- };
67
- }
68
-
69
- // [LAW:one-source-of-truth] The segment-scoped half of the config-overrides
70
- // layer's merge (candybar-config-engine-71o.6) — the SAME "changes the
71
- // DEFAULT, never the hand-authored file" precedence mergeWithDefault's
72
- // `globals` cascade already applies, but patches ONE field (`palette`)
73
- // inside an already-merged segment rather than replacing the segment
74
- // wholesale. mergeWithDefault's `segments` cascade is deliberately per-name
75
- // WHOLESALE replacement (a user overriding a segment restates it in full,
76
- // same as any other by-name merge in this file) — routing a one-field
77
- // override through that cascade would silently drop every other field the
78
- // segment declares (template, bg, fg, when, vars...). This runs AFTER
79
- // mergeWithDefault, directly against the already-merged config, so it never
80
- // fights that cascade; it is its own, later, narrower merge step.
81
- //
82
- // [LAW:no-silent-failure] exception: a stale override naming a segment the
83
- // config no longer declares is not a load-time error — the CONFIG, not the
84
- // override, is the source of truth for which segments exist. Skipping it is
85
- // a no-op, not a swallowed failure: a fresh `persist` write can only ever
86
- // name a segment the config declares (cross-ref checks that at load time),
87
- // so a dangling entry here only happens after a later config edit removed
88
- // the segment, and there is nothing left for the override to apply to.
89
- //
90
- // [LAW:no-defensive-null-guards] exception: `Object.assign(Object.create(null),
91
- // ...)` instead of `{ ...config.segments }` — the SAME null-prototype hygiene
92
- // as the config-overrides-store.ts accumulators above (segment names come
93
- // from user config and this loop WRITES via bracket assignment, `segments[name]
94
- // = ...`, not a pure spread). Pure object spread never risks this (it defines
95
- // every key directly, never invoking an inherited setter), but a stale
96
- // override naming a since-removed segment `__proto__` hits exactly the
97
- // "no own property yet, so the read returns the inherited accessor and the
98
- // write invokes its setter" case a plain accumulator does not guard against.
99
- export function applySegmentPaletteOverrides(
100
- config: DslConfig,
101
- overrides: Readonly<Record<string, string>>,
102
- ): DslConfig {
103
- const entries = Object.entries(overrides);
104
- if (entries.length === 0) return config;
105
- const segments: Record<string, SegmentDecl> = Object.assign(
106
- Object.create(null) as Record<string, SegmentDecl>,
107
- config.segments,
108
- );
109
- for (const [name, palette] of entries) {
110
- const seg = segments[name];
111
- if (seg === undefined) continue;
112
- segments[name] = { ...seg, palette };
113
- }
114
- return { ...config, segments };
115
- }