@promptctl/cc-candybar 1.42.0 → 1.43.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/README.md +4 -0
  2. package/dist/index.mjs +81 -80
  3. package/package.json +5 -6
  4. package/src/check.ts +0 -478
  5. package/src/cli-flags.ts +0 -8
  6. package/src/click/wire.ts +0 -158
  7. package/src/config/action.ts +0 -329
  8. package/src/config/cli.ts +0 -71
  9. package/src/config/default-dsl-config.ts +0 -1645
  10. package/src/config/disclosure.ts +0 -170
  11. package/src/config/dsl-loader.ts +0 -339
  12. package/src/config/dsl-types.ts +0 -581
  13. package/src/config/edit-chrome.ts +0 -559
  14. package/src/config/help.ts +0 -151
  15. package/src/config/ident.ts +0 -22
  16. package/src/config/layout-ops.ts +0 -177
  17. package/src/config/loader/actions.ts +0 -972
  18. package/src/config/loader/cache.ts +0 -206
  19. package/src/config/loader/cross-ref.ts +0 -714
  20. package/src/config/loader/cycles.ts +0 -148
  21. package/src/config/loader/diagnostics.ts +0 -99
  22. package/src/config/loader/discovery.ts +0 -182
  23. package/src/config/loader/edit-mode.ts +0 -137
  24. package/src/config/loader/emit-schema.ts +0 -68
  25. package/src/config/loader/globals.ts +0 -269
  26. package/src/config/loader/helpers.ts +0 -48
  27. package/src/config/loader/layout.ts +0 -693
  28. package/src/config/loader/looks.ts +0 -96
  29. package/src/config/loader/menu-synth.ts +0 -435
  30. package/src/config/loader/merge.ts +0 -115
  31. package/src/config/loader/persist-target.ts +0 -67
  32. package/src/config/loader/presets.ts +0 -119
  33. package/src/config/loader/refs.ts +0 -100
  34. package/src/config/loader/reserved-namespace.ts +0 -38
  35. package/src/config/loader/segments.ts +0 -120
  36. package/src/config/loader/validate-core.ts +0 -737
  37. package/src/config/loader/variables.ts +0 -260
  38. package/src/config/menu-keys.ts +0 -139
  39. package/src/config/option-domain.ts +0 -164
  40. package/src/config/presets.ts +0 -326
  41. package/src/config/settings-menu.ts +0 -775
  42. package/src/daemon/acquire.ts +0 -684
  43. package/src/daemon/cache/git.ts +0 -649
  44. package/src/daemon/cache/render.ts +0 -623
  45. package/src/daemon/cache/session-usage-store.ts +0 -720
  46. package/src/daemon/cache/watchers.ts +0 -249
  47. package/src/daemon/client-debug.ts +0 -120
  48. package/src/daemon/client-stats.ts +0 -130
  49. package/src/daemon/client-transport.ts +0 -273
  50. package/src/daemon/client.ts +0 -78
  51. package/src/daemon/config-overrides-store.ts +0 -663
  52. package/src/daemon/debug-types.ts +0 -91
  53. package/src/daemon/debug.ts +0 -264
  54. package/src/daemon/fork-bomb-breaker.ts +0 -351
  55. package/src/daemon/limits.ts +0 -211
  56. package/src/daemon/log.ts +0 -81
  57. package/src/daemon/parent-watchdog.ts +0 -87
  58. package/src/daemon/paths.ts +0 -211
  59. package/src/daemon/process-fingerprint.ts +0 -146
  60. package/src/daemon/protocol.ts +0 -292
  61. package/src/daemon/render-payload.ts +0 -1256
  62. package/src/daemon/server.ts +0 -1330
  63. package/src/daemon/session-state-file.ts +0 -108
  64. package/src/daemon/session-state.ts +0 -237
  65. package/src/daemon/socket-lease.ts +0 -209
  66. package/src/daemon/socket-ownership.ts +0 -209
  67. package/src/daemon/stats.ts +0 -235
  68. package/src/daemon/verbs/config-validators.ts +0 -250
  69. package/src/daemon/verbs/index.ts +0 -706
  70. package/src/daemon/verbs/state-validators.ts +0 -249
  71. package/src/daemon/verbs/validator-registry.ts +0 -457
  72. package/src/demo/dsl.ts +0 -143
  73. package/src/demo/mock-data.ts +0 -67
  74. package/src/demo/statusline.json5 +0 -94
  75. package/src/dsl/node-registry.ts +0 -374
  76. package/src/dsl/render.ts +0 -803
  77. package/src/help-text.ts +0 -90
  78. package/src/index.ts +0 -210
  79. package/src/install/index.ts +0 -541
  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,693 +0,0 @@
1
- // [LAW:one-source-of-truth] The ONE layout authoring surface is the A-grammar
2
- // (a bare string = segment ref; { seg, when? } = segment ref with predicate;
3
- // { h: [...], when? } = horizontal container; { v: [...], when? } = vertical
4
- // container; { kind: "group", … } = collapsible group). ALL other shapes are
5
- // migration errors [LAW:no-silent-failure]:
6
- //
7
- // `layout:` top-level key (removed in 2de.19) → error with A-grammar rewrite
8
- // `kind: "cells"` node (removed in 2de.19) → error with { h: […] } rewrite
9
- //
10
- // [LAW:types-are-the-program] The node grammar is DATA schemas interpreted by the
11
- // generic `record` engine: each arm's shape is a FieldSpecMap, each bespoke
12
- // message lives on its field spec as data. The two things the generic engine does
13
- // NOT own stay local: the kind-dispatch (a node folds object-guard / missing-kind
14
- // / unknown-kind into one bespoke message and pins its line to `root`, unlike the
15
- // generic taggedUnion's per-failure messages), and the degenerate-node recovery
16
- // (a node never drops to null; it recovers so traversal keeps collecting issues —
17
- // parseDslConfig throws once any issue exists, so the fallback never renders). The
18
- // recursion (a container's children are nodes) crosses through `lazy`, the engine's
19
- // recursion seam, so the child-list field is data that points back at the node
20
- // parser without a temporal-dead-zone crash at module load.
21
-
22
- import {
23
- DIRECTIONS,
24
- type ContainerNode,
25
- type Direction,
26
- type LayoutNode,
27
- type RawDslConfig,
28
- type SegmentDecl,
29
- type SegmentNode,
30
- type VariableDecl,
31
- } from "../dsl-types.js";
32
- import type { ActionDecl } from "../action.js";
33
- import {
34
- DISCLOSURE_CLOSED,
35
- DISCLOSURE_GLYPH_CLOSED,
36
- DISCLOSURE_GLYPH_OPEN,
37
- disclosureCycleAction,
38
- disclosureGate,
39
- disclosureStateVar,
40
- disclosureTrigger,
41
- } from "../disclosure.js";
42
- import { findKeyLine } from "./diagnostics.js";
43
- import { reservedNamespaceCollisions } from "./reserved-namespace.js";
44
- import {
45
- describeType,
46
- describeValue,
47
- isPlainObject,
48
- lazy,
49
- optionalBooleanSpec,
50
- optionalEnumSpec,
51
- optionalStringSpec,
52
- record,
53
- recordJson,
54
- requireString,
55
- type FieldSpec,
56
- type JsonNode,
57
- type Mutable,
58
- type RecordSchema,
59
- type ValidateCtx,
60
- } from "./validate-core.js";
61
-
62
- // [LAW:types-are-the-program] The recursion seam for EMIT: a container's children
63
- // are LayoutNodes, so the node schema must reference itself. JSON Schema breaks
64
- // the cycle with a named definition + `$ref` — the structural analogue of the
65
- // `lazy` thunk that breaks the parse-time cycle. The emitter publishes the node
66
- // schema at this path; `childrenSpec` and the top-level `root` both point here.
67
- export const LAYOUT_NODE_REF = "#/definitions/LayoutNode";
68
- export const LAYOUT_NODE_DEF_NAME = "LayoutNode";
69
-
70
- // ─── Root node grammar (`root`) ──────────────────────────────────────────────
71
-
72
- // [LAW:dataflow-not-control-flow] On a fundamental shape error (non-object node or
73
- // unknown kind) the dispatch returns a degenerate node so traversal continues
74
- // collecting issues — parseDslConfig throws once any issue exists, so the fallback
75
- // never renders. This is the recovery shape the generic `record`/union engines do
76
- // NOT own (they drop to null); it stays local as a separate pass over the engine.
77
- const EMPTY_VERTICAL_NODE: LayoutNode = {
78
- kind: "container",
79
- direction: "vertical",
80
- children: [],
81
- };
82
-
83
- // [LAW:types-are-the-program] A node's `kind` is a literal the dispatch has already
84
- // validated; as a record field it is included (so the unknown-key rejection allows
85
- // it) and yields the literal back. It can never be absent or wrong here — the
86
- // dispatch routes to this arm only on an exact kind match.
87
- // `required: true` though parse never fails — it's mandatory in the emitted
88
- // schema (the const discriminator), a no-op for `fields`. See `cellsSegmentsSpec`.
89
- function literalSpec<V extends string>(value: V): FieldSpec<V> {
90
- return { required: true, json: { const: value }, parse: () => value };
91
- }
92
-
93
- // [LAW:dataflow-not-control-flow] A segment node's `name`: present-non-empty-string
94
- // → the name; anything else → the bespoke issue plus a `""` fallback (NOT a drop),
95
- // so the node recovers and traversal continues. The fallback IS the value (never
96
- // undefined), so the record always keeps the field.
97
- function segmentNameSpec(): FieldSpec<string> {
98
- return {
99
- // Mandatory in the schema (a missing/empty name pushes an issue → throw); the
100
- // parse recovers to "" so it's a no-op for `fields`. See `cellsSegmentsSpec`.
101
- required: true,
102
- json: { type: "string" },
103
- parse: (ctx, path, field, raw) => {
104
- const v = raw[field];
105
- if (typeof v === "string" && v.length > 0) return v;
106
- ctx.issues.push({
107
- path: `${path}.${field}`,
108
- message: `a segment node must have a non-empty "name" (a segment name), got ${describeValue(v)}`,
109
- line: findKeyLine(ctx.source, ["root"]),
110
- });
111
- return "";
112
- },
113
- };
114
- }
115
-
116
- // [LAW:one-source-of-truth] Valid directions come from the DIRECTIONS list — the
117
- // same set the renderer projects. An invalid/absent direction recovers to
118
- // `vertical` (the node is never dropped) plus the bespoke issue. Distinct from the
119
- // generic `optionalEnumSpec`, which OMITS on invalid; a container's `direction` is
120
- // required, so it must recover to a value, not vanish.
121
- function directionSpec(): FieldSpec<Direction> {
122
- return {
123
- // Mandatory in the schema (a missing/invalid direction pushes an issue →
124
- // throw); the parse recovers to "vertical", a no-op for `fields`. See
125
- // `cellsSegmentsSpec`.
126
- required: true,
127
- json: { enum: [...DIRECTIONS] },
128
- parse: (ctx, path, field, raw) => {
129
- const v = raw[field];
130
- if (
131
- typeof v === "string" &&
132
- (DIRECTIONS as readonly string[]).includes(v)
133
- ) {
134
- return v as Direction;
135
- }
136
- ctx.issues.push({
137
- path: `${path}.${field}`,
138
- message: `a container "direction" must be one of: ${DIRECTIONS.join(", ")} (got ${JSON.stringify(v)})`,
139
- line: findKeyLine(ctx.source, ["root"]),
140
- });
141
- return "vertical";
142
- },
143
- };
144
- }
145
-
146
- // [LAW:decomposition] A container's `children` are themselves nodes — the one
147
- // recursive field. It recovers to `[]` on a non-array value (the node is kept),
148
- // and otherwise maps each child through the node parser. The parser is referenced
149
- // through `lazy` so this spec can live inside CONTAINER_SCHEMA as data that points
150
- // back at `validateRoot` without a temporal-dead-zone read at module load.
151
- function childrenSpec(
152
- node: (ctx: ValidateCtx, path: string, raw: unknown) => LayoutNode,
153
- ): FieldSpec<readonly LayoutNode[]> {
154
- return {
155
- // Mandatory in the schema (a missing/non-array `children` pushes an issue →
156
- // throw); the parse recovers to [], a no-op for `fields`. See `cellsSegmentsSpec`.
157
- required: true,
158
- // [LAW:one-source-of-truth] The recursive field points at the node definition
159
- // via `$ref` — emit's analogue of the `lazy` thunk that defers the parse-time
160
- // self-reference. The runtime recursion and the schema recursion break the
161
- // same cycle, declared in one place.
162
- json: { type: "array", items: { $ref: LAYOUT_NODE_REF } },
163
- parse: (ctx, path, field, raw) => {
164
- const v = raw[field];
165
- if (!Array.isArray(v)) {
166
- ctx.issues.push({
167
- path: `${path}.${field}`,
168
- message: `a container must have a "children" array of layout nodes, got ${describeType(v)}`,
169
- line: findKeyLine(ctx.source, ["root"]),
170
- });
171
- return [];
172
- }
173
- return v.map((child, i) => node(ctx, `${path}.${field}[${i}]`, child));
174
- },
175
- };
176
- }
177
-
178
- const SEGMENT_NODE_SCHEMA: RecordSchema<SegmentNode> = {
179
- noun: "layout-node key",
180
- fields: {
181
- kind: literalSpec("segment"),
182
- name: segmentNameSpec(),
183
- when: optionalStringSpec(),
184
- },
185
- };
186
-
187
- const CONTAINER_SCHEMA: RecordSchema<ContainerNode> = {
188
- noun: "layout-node key",
189
- fields: {
190
- kind: literalSpec("container"),
191
- direction: directionSpec(),
192
- children: childrenSpec(lazy(() => validateRoot)),
193
- when: optionalStringSpec(),
194
- },
195
- };
196
-
197
- // ─── Option A shape grammar (seg / h / v) ────────────────────────────────────
198
-
199
- // [LAW:types-are-the-program] The terse bijective spellings of the canonical
200
- // tree: a bare string names a segment; an object with exactly one of "seg",
201
- // "h", or "v" spells a segment-ref-with-predicate, a horizontal container, or
202
- // a vertical container respectively. Every legal canonical node is expressible;
203
- // no illegal one is — bijectivity is the acceptance test. The key-count check
204
- // (exactly one of seg/h/v) is the dispatch-level invariant that makes the wrong
205
- // arm unrepresentable as a valid parse. [LAW:single-enforcer] — the loader is
206
- // the sole enforcer; the JSON Schema emitter mirrors it, but the loader's exit
207
- // code is the truth.
208
-
209
- interface SegArmNode {
210
- readonly seg: string;
211
- readonly when?: string;
212
- }
213
-
214
- function segArmSpec(): FieldSpec<string> {
215
- return {
216
- required: true,
217
- json: { type: "string" },
218
- parse: (ctx, path, field, raw) => {
219
- const v = raw[field];
220
- if (typeof v === "string" && v.length > 0) return v;
221
- ctx.issues.push({
222
- path: `${path}.${field}`,
223
- message: `a "seg" node must have a non-empty segment name, got ${describeValue(v)}`,
224
- line: findKeyLine(ctx.source, ["root"]),
225
- });
226
- return "";
227
- },
228
- };
229
- }
230
-
231
- const SEG_ARM_SCHEMA: RecordSchema<SegArmNode> = {
232
- noun: "layout-node key",
233
- fields: { seg: segArmSpec(), when: optionalStringSpec() },
234
- };
235
-
236
- interface HArmNode {
237
- readonly h: readonly LayoutNode[];
238
- readonly when?: string;
239
- }
240
-
241
- const H_ARM_SCHEMA: RecordSchema<HArmNode> = {
242
- noun: "layout-node key",
243
- fields: {
244
- h: childrenSpec(lazy(() => validateRoot)),
245
- when: optionalStringSpec(),
246
- },
247
- };
248
-
249
- interface VArmNode {
250
- readonly v: readonly LayoutNode[];
251
- readonly when?: string;
252
- }
253
-
254
- const V_ARM_SCHEMA: RecordSchema<VArmNode> = {
255
- noun: "layout-node key",
256
- fields: {
257
- v: childrenSpec(lazy(() => validateRoot)),
258
- when: optionalStringSpec(),
259
- },
260
- };
261
-
262
- // ─── validateRoot ────────────────────────────────────────────────────────────
263
-
264
- // [LAW:locality-or-seam] The boundary that turns the raw `root` grammar into a
265
- // validated LayoutNode tree. STRUCTURAL only — whether a segment name resolves and
266
- // whether a `when` ref exists are cross-ref concerns (validateCrossReferences runs
267
- // on the MERGED config, so a node can name default-provided segments).
268
- //
269
- // [LAW:dataflow-not-control-flow] The `kind` discriminator selects the arm; an
270
- // unknown kind is rejected, never coerced. Object-guard and unknown-kind fold into
271
- // one bespoke message each (pinned to the `root` line) — the local dispatch the
272
- // generic taggedUnion does not express. A `const` (not a hoisted function) so the
273
- // `lazy` thunk inside CONTAINER_SCHEMA defers reading it; reading it eagerly there
274
- // would be a temporal-dead-zone crash.
275
- export const validateRoot = (
276
- ctx: ValidateCtx,
277
- path: string,
278
- raw: unknown,
279
- ): LayoutNode => {
280
- // [LAW:types-are-the-program] A bare string is the terse segment-ref spelling.
281
- // Checked before the object guard so the "not an object" error does not fire
282
- // on a valid input.
283
- if (typeof raw === "string") {
284
- if (raw.length === 0) {
285
- ctx.issues.push({
286
- path,
287
- message: `a bare-string layout node must be a non-empty segment name`,
288
- line: findKeyLine(ctx.source, ["root"]),
289
- });
290
- return EMPTY_VERTICAL_NODE;
291
- }
292
- return { kind: "segment", name: raw };
293
- }
294
- if (!isPlainObject(raw)) {
295
- ctx.issues.push({
296
- path,
297
- message: `a layout node must be a string (segment name) or an object with "kind" / "seg" / "h" / "v", got ${describeType(raw)}`,
298
- line: findKeyLine(ctx.source, ["root"]),
299
- });
300
- return EMPTY_VERTICAL_NODE;
301
- }
302
- if (raw.kind === "container") {
303
- return record(ctx, CONTAINER_SCHEMA, path, raw) ?? EMPTY_VERTICAL_NODE;
304
- }
305
- if (raw.kind === "segment") {
306
- return record(ctx, SEGMENT_NODE_SCHEMA, path, raw) ?? EMPTY_VERTICAL_NODE;
307
- }
308
- if (raw.kind === "cells") {
309
- // [LAW:no-silent-failure] `kind: "cells"` removed in 2de.19. Reject loudly
310
- // with the A-grammar equivalent so the author knows exactly how to migrate.
311
- ctx.issues.push({
312
- path,
313
- message: `kind: "cells" is no longer supported — use the h-arm spelling instead:\n Old: { kind: "cells", segments: ["seg1", "seg2"] }\n New: { h: ["seg1", "seg2"] }`,
314
- line: findKeyLine(ctx.source, ["root"]),
315
- });
316
- return EMPTY_VERTICAL_NODE;
317
- }
318
- if (raw.kind === "group") {
319
- const group = record(ctx, GROUP_SCHEMA, path, raw);
320
- if (group === null) return EMPTY_VERTICAL_NODE;
321
- // Collected for the post-walk synthesis pass (state var + cycle action +
322
- // toggle segment); the node itself lowers to the canonical grammar here.
323
- ctx.groups.push({
324
- name: group.name,
325
- label: group.label,
326
- ...(group.open !== undefined && { open: group.open }),
327
- ...(group.direction !== undefined && { direction: group.direction }),
328
- ...(group.key !== undefined && { key: group.key }),
329
- ...(group.bg !== undefined && { bg: group.bg }),
330
- ...(group.fg !== undefined && { fg: group.fg }),
331
- ...(group.when !== undefined && { when: group.when }),
332
- path,
333
- });
334
- return lowerGroup(group);
335
- }
336
- // [LAW:types-are-the-program] Option A terse arms: exactly one of "seg" / "h"
337
- // / "v". Two or more present is an illegal state; zero means the object has
338
- // neither a valid "kind" nor a valid terse arm — both are loud rejections.
339
- const hasH = "h" in raw;
340
- const hasV = "v" in raw;
341
- const hasSeg = "seg" in raw;
342
- const armCount = (hasH ? 1 : 0) + (hasV ? 1 : 0) + (hasSeg ? 1 : 0);
343
- if (armCount > 1) {
344
- const present = (["seg", "h", "v"] as const).filter((k) => k in raw);
345
- ctx.issues.push({
346
- path,
347
- message: `a layout node may have exactly one of "seg", "h", or "v" — got ${present.map((k) => `"${k}"`).join(" and ")} together`,
348
- line: findKeyLine(ctx.source, ["root"]),
349
- });
350
- return EMPTY_VERTICAL_NODE;
351
- }
352
- if (hasSeg) {
353
- const arm = record(ctx, SEG_ARM_SCHEMA, path, raw);
354
- if (arm === null) return EMPTY_VERTICAL_NODE;
355
- return {
356
- kind: "segment",
357
- name: arm.seg,
358
- ...(arm.when !== undefined && { when: arm.when }),
359
- };
360
- }
361
- if (hasH) {
362
- const arm = record(ctx, H_ARM_SCHEMA, path, raw);
363
- if (arm === null) return EMPTY_VERTICAL_NODE;
364
- return {
365
- kind: "container",
366
- direction: "horizontal",
367
- children: arm.h,
368
- ...(arm.when !== undefined && { when: arm.when }),
369
- };
370
- }
371
- if (hasV) {
372
- const arm = record(ctx, V_ARM_SCHEMA, path, raw);
373
- if (arm === null) return EMPTY_VERTICAL_NODE;
374
- return {
375
- kind: "container",
376
- direction: "vertical",
377
- children: arm.v,
378
- ...(arm.when !== undefined && { when: arm.when }),
379
- };
380
- }
381
- ctx.issues.push({
382
- path: `${path}.kind`,
383
- message: `a layout node "kind" must be "container", "segment", or "group", or use the terse A-grammar: a bare string, or an object with "seg", "h", or "v" (got ${JSON.stringify(raw.kind)})`,
384
- line: findKeyLine(ctx.source, ["root"]),
385
- });
386
- return EMPTY_VERTICAL_NODE;
387
- };
388
-
389
- // ─── Group sugar (`kind: "group"`) ───────────────────────────────────────────
390
-
391
- // [LAW:one-source-of-truth] The reserved namespace every synthesized artifact
392
- // lives under, in all three sections (variables / actions / segments). One
393
- // group declaration is the single source; the var, the action, and the toggle
394
- // segment all derive their name from it. A user-authored name under this
395
- // prefix is rejected so synthesis can never silently collide.
396
- export const GROUP_NS = "groups.";
397
-
398
- // [LAW:one-source-of-truth] The closed sentinel and ▸/▾ glyphs are the shared
399
- // disclosure primitive (src/config/disclosure.ts) — a group is one of its two
400
- // body-kinds, so it reuses DISCLOSURE_CLOSED / DISCLOSURE_GLYPH_* rather than
401
- // keeping a second copy that could drift from the menu's. Group names are
402
- // forbidden from equaling the sentinel, so a cycle's two members are distinct.
403
-
404
- // [LAW:types-are-the-program] A group name must be template-addressable — it is
405
- // spliced into the synthesized `when` predicate and toggle template as
406
- // `.groups.<name>`, and Go-template field syntax admits identifier characters
407
- // only. The pattern IS that constraint; it also excludes quotes, slashes, and
408
- // dots, so a name needs no escaping anywhere it is spliced.
409
- const GROUP_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
410
-
411
- function groupNameSpec(): FieldSpec<string> {
412
- return {
413
- required: true,
414
- json: { type: "string", pattern: GROUP_NAME_RE.source },
415
- parse: (ctx, path, field, raw) => {
416
- const v = raw[field];
417
- if (
418
- typeof v !== "string" ||
419
- !GROUP_NAME_RE.test(v) ||
420
- v === DISCLOSURE_CLOSED
421
- ) {
422
- ctx.issues.push({
423
- path: `${path}.${field}`,
424
- message: `a group "name" must be an identifier (letters, digits, _; not starting with a digit) and not the reserved "${DISCLOSURE_CLOSED}", got ${describeValue(v)}`,
425
- line: findKeyLine(ctx.source, ["root"]),
426
- });
427
- return undefined;
428
- }
429
- return v;
430
- },
431
- };
432
- }
433
-
434
- // [LAW:single-enforcer] A group's optional shared `key` is a SessionState key —
435
- // the same non-empty/slash-free wire shape the action loader's `set` key
436
- // enforces, restated here because the group synthesizes that `set`.
437
- function groupKeySpec(): FieldSpec<string> {
438
- return {
439
- required: false,
440
- json: { type: "string", minLength: 1 },
441
- parse: (ctx, path, field, raw) => {
442
- const v = raw[field];
443
- if (v === undefined) return undefined;
444
- if (typeof v !== "string" || v === "" || v.includes("/")) {
445
- ctx.issues.push({
446
- path: `${path}.${field}`,
447
- message: `a group "key" must be a non-empty, slash-free SessionState key, got ${describeValue(v)}`,
448
- line: findKeyLine(ctx.source, ["root"]),
449
- });
450
- return undefined;
451
- }
452
- return v;
453
- },
454
- };
455
- }
456
-
457
- // [LAW:types-are-the-program] The `group` input record: one declaration carrying
458
- // everything its synthesized artifacts derive from. `direction` arranges the
459
- // BODY (the children container) — the toggle row always stacks above it;
460
- // `key` opts sibling groups into one accordion (shared key ⇒ one open at a
461
- // time); `open` picks the key's initial state; `bg`/`fg` paint the toggle
462
- // segment; `when` gates the whole group (toggle included).
463
- interface GroupNodeInput {
464
- readonly kind: "group";
465
- readonly name: string;
466
- readonly label: string;
467
- readonly open?: boolean;
468
- readonly direction?: Direction;
469
- readonly key?: string;
470
- readonly bg?: string;
471
- readonly fg?: string;
472
- readonly when?: string;
473
- readonly children: readonly LayoutNode[];
474
- }
475
-
476
- // [LAW:no-silent-failure] Reject newlines at the validator boundary — a label
477
- // with \n or \r would reach escapeTemplateLiteral and produce a Go template
478
- // string literal with an embedded newline, which go-template-js forbids. Fail
479
- // loudly here so the loader surfaces the problem before synthesis runs.
480
- function groupLabelSpec(): FieldSpec<string> {
481
- return {
482
- required: true,
483
- json: { type: "string", pattern: "^[^\\n\\r]*$" },
484
- parse: (ctx, path, field, raw) => {
485
- const s = requireString(ctx, path, raw, field);
486
- if (s === null) return undefined;
487
- if (/[\n\r]/.test(s)) {
488
- ctx.issues.push({
489
- path: `${path}.${field}`,
490
- message: `${path}.${field}: group label must not contain newlines`,
491
- line: findKeyLine(ctx.source, [...path.split("."), field]),
492
- });
493
- return undefined;
494
- }
495
- return s;
496
- },
497
- };
498
- }
499
-
500
- const GROUP_SCHEMA: RecordSchema<GroupNodeInput> = {
501
- noun: "layout-node key",
502
- fields: {
503
- kind: literalSpec("group"),
504
- name: groupNameSpec(),
505
- label: groupLabelSpec(),
506
- open: optionalBooleanSpec(),
507
- direction: optionalEnumSpec(DIRECTIONS),
508
- key: groupKeySpec(),
509
- bg: optionalStringSpec(),
510
- fg: optionalStringSpec(),
511
- when: optionalStringSpec(),
512
- children: childrenSpec(lazy(() => validateRoot)),
513
- },
514
- };
515
-
516
- // The state key a group toggles: the explicit shared `key` (accordion) or the
517
- // group's own derived key (independent toggle). One value selects the behavior
518
- // — no accordion mode [LAW:dataflow-not-control-flow].
519
- function groupStateKey(g: { name: string; key?: string }): string {
520
- return g.key ?? GROUP_NS + g.name;
521
- }
522
-
523
- // [LAW:one-source-of-truth] Lower a group to the canonical grammar. The toggle
524
- // segment ref and the body predicate both derive from the group's name — the
525
- // same name the synthesis names the state var with, so the predicate reads
526
- // exactly the var the toggle's cycle writes. The body is open exactly when the
527
- // key holds THIS group's name (a sibling's name or "closed" hides it — the
528
- // accordion falls out of one key holding one name).
529
- function lowerGroup(g: GroupNodeInput): LayoutNode {
530
- const ref = GROUP_NS + g.name;
531
- return {
532
- kind: "container",
533
- direction: "vertical",
534
- children: [
535
- { kind: "segment", name: ref },
536
- {
537
- kind: "container",
538
- direction: g.direction ?? "vertical",
539
- children: g.children,
540
- when: disclosureGate({ variable: ref, member: g.name }),
541
- },
542
- ],
543
- ...(g.when !== undefined && { when: g.when }),
544
- };
545
- }
546
-
547
- function groupIssue(ctx: ValidateCtx, path: string, message: string): void {
548
- ctx.issues.push({
549
- path,
550
- message,
551
- line: findKeyLine(ctx.source, ["root"]),
552
- });
553
- }
554
-
555
- // [LAW:one-source-of-truth] The synthesis pass: every artifact a group implies,
556
- // derived from its one declaration and merged into the raw sections, AFTER the
557
- // user's own sections parsed — so a user name under the reserved namespace is a
558
- // loud rejection, never a silent overwrite. Runs once per parse, after the root
559
- // walk collected every group with its tree position.
560
- //
561
- // Invariants enforced here (each a load error, never a silent fixup):
562
- // • group names are unique (they name the synthesized artifacts);
563
- // • no user-authored variable/action/segment under the reserved namespace;
564
- // • an ancestor and a descendant group never share a key (one key holds ONE
565
- // open name, so a same-key chain could not represent "both open" — sibling
566
- // accordions share keys, nested disclosure nests distinct keys);
567
- // • at most one group per shared key declares `open: true` (the key's single
568
- // initial value [LAW:one-source-of-truth]).
569
- export function synthesizeGroupDecls(
570
- ctx: ValidateCtx,
571
- out: Mutable<RawDslConfig>,
572
- ): void {
573
- // [LAW:single-enforcer] The disclosure primitive's shared reserved-namespace
574
- // enforcer (mirroring {{ menu }}'s `menus.`) — a user name under `groups.`
575
- // would silently shadow a synthesized artifact. Reserved UNCONDITIONALLY,
576
- // before the no-groups early return, so the reservation is a stable contract
577
- // ("you never author groups.*"), not a rule that only switches on when a
578
- // group node happens to be declared this load — same placement as the menus
579
- // pass (synthesizeMenuDecls).
580
- reservedNamespaceCollisions(ctx, out, GROUP_NS, "group nodes");
581
-
582
- const groups = ctx.groups;
583
- if (groups.length === 0) return;
584
-
585
- const seen = new Set<string>();
586
- for (const g of groups) {
587
- if (seen.has(g.name)) {
588
- groupIssue(
589
- ctx,
590
- g.path,
591
- `duplicate group name "${g.name}" — group names must be unique (they name the synthesized state var, action, and toggle segment)`,
592
- );
593
- }
594
- seen.add(g.name);
595
- }
596
-
597
- for (const inner of groups) {
598
- for (const outer of groups) {
599
- if (
600
- inner !== outer &&
601
- inner.path.startsWith(`${outer.path}.`) &&
602
- groupStateKey(inner) === groupStateKey(outer)
603
- ) {
604
- groupIssue(
605
- ctx,
606
- inner.path,
607
- `group "${inner.name}" shares key "${groupStateKey(inner)}" with its ancestor group "${outer.name}" — a shared key holds ONE open group, so an ancestor and a descendant cannot share one. Sibling accordions share a key; nested groups use distinct keys.`,
608
- );
609
- }
610
- }
611
- }
612
-
613
- // [LAW:one-source-of-truth] One initial value per key: the single open
614
- // group's name, else closed. Every var synthesized on a key carries the SAME
615
- // default, so two vars reading one key cannot disagree.
616
- const defaultByKey = new Map<string, string>();
617
- for (const g of groups) {
618
- const key = groupStateKey(g);
619
- if (!defaultByKey.has(key)) defaultByKey.set(key, DISCLOSURE_CLOSED);
620
- if (g.open === true) {
621
- const prior = defaultByKey.get(key)!;
622
- if (prior !== DISCLOSURE_CLOSED) {
623
- groupIssue(
624
- ctx,
625
- g.path,
626
- `groups "${prior}" and "${g.name}" share key "${key}" and both declare open: true — a shared key holds one open group; pick one`,
627
- );
628
- }
629
- defaultByKey.set(key, g.name);
630
- }
631
- }
632
-
633
- const variables: Record<string, VariableDecl> = {};
634
- const actions: Record<string, ActionDecl> = {};
635
- const segments: Record<string, SegmentDecl> = {};
636
- for (const g of groups) {
637
- const name = GROUP_NS + g.name;
638
- const key = groupStateKey(g);
639
- // [LAW:dataflow-not-control-flow] Depth is a value derivable from the paths
640
- // already in ctx.groups — no extra threading. Strict-prefix count gives
641
- // nesting depth; the indent embeds as a string constant in the template.
642
- const depth = groups.filter(
643
- (other) => other !== g && g.path.startsWith(other.path + "."),
644
- ).length;
645
- const indent = " ".repeat(depth);
646
- // [LAW:one-source-of-truth] The shared disclosure toggle: one state var + one
647
- // binary cycle action, both from the primitive. Members are ordered default-
648
- // state-first (closed first): an unset or sibling-held key counts as the first
649
- // member, so the toggle renders ▸ and clicks to its own name — expand, auto-
650
- // closing the sibling on a shared key.
651
- variables[name] = disclosureStateVar(key, defaultByKey.get(key)!);
652
- actions[name] = disclosureCycleAction(key, g.name);
653
- // [LAW:representation] The disclosure glyph trails the label it gates, so an
654
- // arrow reads as belonging to the text on its LEFT — adjacent toggles
655
- // ("details ▸" "links ▸") stay unambiguous even when abutted. `indent` is a
656
- // structural left-margin (nesting depth) and stays leading; the glyph is a
657
- // trailing affordance on the label, never a prefix.
658
- segments[name] = {
659
- template: disclosureTrigger(
660
- name,
661
- `${indent}${g.label} ${DISCLOSURE_GLYPH_CLOSED}`,
662
- `${indent}${g.label} ${DISCLOSURE_GLYPH_OPEN}`,
663
- ),
664
- ...(g.bg !== undefined && { bg: g.bg }),
665
- ...(g.fg !== undefined && { fg: g.fg }),
666
- };
667
- }
668
- out.variables = { ...(out.variables ?? {}), ...variables };
669
- out.actions = { ...(out.actions ?? {}), ...actions };
670
- out.segments = { ...(out.segments ?? {}), ...segments };
671
- }
672
-
673
- // ─── Schema emit ─────────────────────────────────────────────────────────────
674
-
675
- // [LAW:one-source-of-truth] The LayoutNode definition: the anyOf of ALL arms
676
- // `validateRoot` dispatches over — kind-based (container / segment / group) and
677
- // terse A-grammar (bare string, seg-arm, h-arm, v-arm) — each derived from the
678
- // SAME schema the validator interprets. The `kind` const and the unique required
679
- // key keep arms disjoint; the container/h/v children `$ref` back here, closing
680
- // the recursion. `{ type: "string" }` covers the bare-string segment-ref form.
681
- export function layoutNodeJson(): JsonNode {
682
- return {
683
- anyOf: [
684
- { type: "string" },
685
- recordJson(CONTAINER_SCHEMA),
686
- recordJson(SEGMENT_NODE_SCHEMA),
687
- recordJson(GROUP_SCHEMA),
688
- recordJson(SEG_ARM_SCHEMA),
689
- recordJson(H_ARM_SCHEMA),
690
- recordJson(V_ARM_SCHEMA),
691
- ],
692
- };
693
- }