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