@promptctl/cc-candybar 1.42.1 → 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/dist/index.mjs +72 -71
  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,170 +0,0 @@
1
- // [LAW:one-source-of-truth] THE disclosure primitive: the one toggle machinery
2
- // that both group sugar (`kind: "group"`, src/config/loader/layout.ts) and the
3
- // `{{ menu }}` helper (src/config/loader/menu-synth.ts + src/render/menu.ts) are
4
- // built on. A disclosure is a binary toggle over a SessionState key: the key
5
- // holds either the CLOSED sentinel or a single MEMBER name; a `cycle` action
6
- // flips between the two; a ▸/▾ glyph shows which state it is in; sibling
7
- // disclosures sharing one key become mutually exclusive (an accordion) because
8
- // one key holds one open member. Group and menu differ ONLY in their BODY (group
9
- // reveals an arbitrary layout container gated by a `when`; menu drops a picker
10
- // grid below the row) and in where the trigger lives (group synthesizes a toggle
11
- // segment; the menu helper IS the trigger). The toggle itself — sentinel, glyphs,
12
- // the `state` var, the `cycle` action — is single-sourced HERE so the two
13
- // body-kinds cannot drift [LAW:one-type-per-behavior][LAW:decomposition].
14
- //
15
- // [LAW:one-way-deps] This module is intentionally PURE — it imports only decl
16
- // TYPES (erased at build) and holds no loader (`ValidateCtx`, diagnostics) nor
17
- // render (rich-js) dependency, so both the loader synthesis passes and the render
18
- // helper can share it without dragging one layer into the other. The loader-side
19
- // reserved-namespace collision check — the other half of the shared machinery,
20
- // which needs the validation context — lives in
21
- // `src/config/loader/reserved-namespace.ts`.
22
-
23
- import type { ActionDecl } from "./action.js";
24
- import type { VariableDecl } from "./dsl-types.js";
25
-
26
- // The "nothing open" sentinel a disclosure's key starts from and returns to on
27
- // close. A disclosure's MEMBER (a group name / a menu apply-action name) may
28
- // never equal this — an equal member would make the cycle `[closed, "closed"]`
29
- // (two identical members, never openable), which both synthesis passes reject.
30
- export const DISCLOSURE_CLOSED = "closed";
31
-
32
- // [LAW:representation] The disclosure glyph vocabulary — one pair for the whole
33
- // bar so every disclosure reads the same (trailing the label/content it gates,
34
- // per pdu.8): collapsed ▸, expanded ▾.
35
- //
36
- // [LAW:one-source-of-truth] These are the AUTHORED default, never an emission.
37
- // Every disclosure splices them into the template it synthesizes — group sugar
38
- // (loader/layout.ts), the settings menu (settings-menu.ts), the bundled drawer
39
- // — and a hand-authored config writes whichever glyph it likes, because the
40
- // trigger's text is a display bound at the call site like any other. Until
41
- // candybar-settings-ui-aok.4 `{{ menu }}` was the exception, appending ▸/▾ from
42
- // its own runtime where no author could see or decline it, which is how edit
43
- // mode's `+` came to render `+▸`.
44
- export const DISCLOSURE_GLYPH_CLOSED = "▸";
45
- export const DISCLOSURE_GLYPH_OPEN = "▾";
46
-
47
- // [LAW:one-source-of-truth] The glyph that CLOSES an open disclosure. The
48
- // picker body's ✕ has always been this; it lives here now because a trigger can
49
- // wear it too — edit mode's `+` does, since a `+` whose only open-state cue was
50
- // the ▸ this change removed would otherwise be indistinguishable from its
51
- // siblings (three insertion points render byte-identically when one is open,
52
- // and their dropped bodies are identical too, so row 0 is the only place the
53
- // answer can live). Two affordances, one meaning, one glyph.
54
- export const DISCLOSURE_GLYPH_CLOSE = "✕";
55
-
56
- // [LAW:single-enforcer] THE display rule every multi-state trigger obeys: bind
57
- // one display per member, or ONE static display that shows in every state. It
58
- // lives here, beside the toggle machinery, because both disclosure kinds need
59
- // it at different times — the loader can count a call's arguments statically
60
- // and wants an ISSUE to report, the renderer holds the evaluated displays and
61
- // wants to THROW — and a rule spelled once in each place is a rule that drifts.
62
- // A `{{ menu }}` folds through it with two members (its `[closed, member]`
63
- // cycle) and a cycle `{{ action }}` with as many as it declares; nothing about
64
- // the rule is disclosure-specific beyond who calls it.
65
- export function cycleDisplayIssue(
66
- subject: string,
67
- count: number,
68
- members: number,
69
- ): string | undefined {
70
- if (count === 0) return `${subject} needs a display (the clickable text)`;
71
- if (count !== 1 && count !== members) {
72
- return `${subject} cycles ${members} members; bind one display per member (${members}) or one static display, got ${count}`;
73
- }
74
- return undefined;
75
- }
76
-
77
- // [LAW:dataflow-not-control-flow] Which display shows is a pure function of
78
- // (bound displays, current member index): a single static display shows in
79
- // every state, per-member displays index by the state. Throws the one rule's
80
- // text rather than silently dropping or repeating an argument.
81
- export function pickCycleDisplay(
82
- subject: string,
83
- displays: readonly string[],
84
- members: number,
85
- index: number,
86
- ): string {
87
- const issue = cycleDisplayIssue(subject, displays.length, members);
88
- if (issue !== undefined) throw new Error(issue);
89
- return displays.length === 1 ? displays[0]! : displays[index]!;
90
- }
91
-
92
- // [LAW:one-source-of-truth] Go-template string-literal escaping for any DISPLAY
93
- // text a synthesis splices INSIDE a quoted `{{ }}` argument of a template it
94
- // emits — a group's label, a preset name in the reset banner, a `(?)` trigger's
95
- // closed/open glyphs. NOT for a template's own body text, which is source rather
96
- // than a splice: a help line is assigned verbatim (help.ts) because escaping one
97
- // would put a backslash on the bar. It lives here, beside the two splices
98
- // that need it most, because it was already two verbatim copies (loader/layout.ts
99
- // and edit-chrome.ts, whose comment deferred the merge until "one small rule"
100
- // earned its own home). The `(?)` affordance was the third caller, so it did.
101
- export function escapeTemplateLiteral(s: string): string {
102
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
103
- }
104
-
105
- // [LAW:types-are-the-program] One open disclosure, named by the two strings that
106
- // decide it: the VARIABLE a body reads and the MEMBER value that means "this one
107
- // is open". They are distinct because a group's variable is per-group
108
- // (`groups.<name>`) while its state KEY may be shared with accordion siblings —
109
- // so the pair, never a lone key, is what identifies an open state.
110
- export interface DisclosureRef {
111
- readonly variable: string;
112
- readonly member: string;
113
- }
114
-
115
- // [LAW:single-enforcer] THE body predicate a disclosure implies. Variadic
116
- // because nesting is conjunction and nothing else: a row inside two disclosures
117
- // is open when both are, which is one list, not a compound spelling. Callers
118
- // that used to hand-write `{{ eq .x "open" }}` beside `{{ and (eq .x "open")
119
- // (eq .y "open") }}` now pass one ref or two to one function — the shape stops
120
- // varying with the depth [LAW:dataflow-not-control-flow].
121
- //
122
- // `and` is variadic in Go templates and returns its sole argument when given
123
- // one, so the single-disclosure case needs no separate spelling.
124
- //
125
- // [LAW:types-are-the-program] The first ref is a separate parameter so a gate
126
- // over ZERO disclosures — which would emit an argument-less `{{ and }}` and gate
127
- // on nothing — is unrepresentable, with no runtime guard to state it.
128
- export function disclosureGate(
129
- first: DisclosureRef,
130
- ...rest: readonly DisclosureRef[]
131
- ): string {
132
- const terms = [first, ...rest]
133
- .map((o) => `(eq .${o.variable} "${escapeTemplateLiteral(o.member)}")`)
134
- .join(" ");
135
- return `{{ and ${terms} }}`;
136
- }
137
-
138
- // [LAW:single-enforcer] THE trigger template a disclosure's toggle segment
139
- // carries: one `{{ action }}` over the cycle action, binding the author's text
140
- // per state — closed first, matching the cycle's own closed-first member order
141
- // so the display index and the member index are the same number. Since .4 every
142
- // trigger authors its own text (the runtime appends no glyph), which makes this
143
- // the one place the binding is spelled; before it, five sites spelled it and the
144
- // `+▸` double-glyph bug lived in the gap between two of them.
145
- export function disclosureTrigger(
146
- action: string,
147
- closed: string,
148
- open: string,
149
- ): string {
150
- return `{{ action "${action}" "${escapeTemplateLiteral(closed)}" "${escapeTemplateLiteral(open)}" }}`;
151
- }
152
-
153
- // [LAW:single-enforcer] THE backing `state` variable a disclosure key implies:
154
- // it holds the open member's name and defaults to `def` (the CLOSED sentinel for
155
- // an independent disclosure, or an initially-open member for a group's
156
- // `open: true`). One shape, so a group var and a menu var declared on one key
157
- // cannot disagree on kind or key.
158
- export function disclosureStateVar(key: string, def: string): VariableDecl {
159
- return { kind: "state", key, default: def };
160
- }
161
-
162
- // [LAW:single-enforcer] THE toggle action a disclosure realizes: a binary `cycle`
163
- // between the CLOSED sentinel and the member (ordered closed-first, so an unset or
164
- // sibling-held key counts as the first member — the toggle renders ▸ and a click
165
- // writes the member, opening it and auto-closing any accordion sibling). The
166
- // derived click gate (`deriveActionValidators`) reads this like every other
167
- // `set`, so a disclosure toggle needs no parallel verb [LAW:single-enforcer].
168
- export function disclosureCycleAction(key: string, member: string): ActionDecl {
169
- return { set: key, cycle: [DISCLOSURE_CLOSED, member] };
170
- }
@@ -1,339 +0,0 @@
1
- // [LAW:one-type-per-behavior] Three primitives, three concerns:
2
- //
3
- // parseDslConfig (text → RawDslConfig)
4
- // JSON5 syntax + per-record structural validation. Rejects the removed
5
- // `layout:` key and `kind:"cells"` node with migration-pointing errors.
6
- // Throws ConfigError on syntax / structural problems.
7
- //
8
- // mergeWithDefault (RawDslConfig + DslConfig → DslConfig)
9
- // Cascade: shallow merge globals fields, by-name merge variables and
10
- // segments, wholesale root replacement when present. Pure function.
11
- //
12
- // validateConfig (DslConfig → ValidatedConfig)
13
- // Cross-references + cycle detection on the merged shape. Sole producer
14
- // of ValidatedConfig. Throws ConfigError on cross-ref / cycle problems.
15
- //
16
- // loadConfig (path|null → DslConfig) wires parse+merge for the daemon's
17
- // production path. validateConfig finishes the chain.
18
- //
19
- // [LAW:dataflow-not-control-flow] Validation passes accumulate issues into
20
- // a list; consumers see every problem at once (compiler-style).
21
- //
22
- // This file is the pipeline orchestrator + the public barrel. Each validation
23
- // concern lives in its own `loader/` module (split by change-reason); the
24
- // re-exports below keep the import surface stable for every consumer.
25
-
26
- import fs from "node:fs";
27
- import JSON5 from "json5";
28
- import {
29
- type DslConfig,
30
- type RawDslConfig,
31
- type ValidatedConfig,
32
- } from "./dsl-types.js";
33
- import { listResolvablePaletteNames } from "../themes/policy.js";
34
- import {
35
- ConfigError,
36
- findKeyLine,
37
- type ConfigIssue,
38
- } from "./loader/diagnostics.js";
39
- import {
40
- describeType,
41
- isPlainObject,
42
- type Mutable,
43
- type ValidateCtx,
44
- } from "./loader/validate-core.js";
45
- import { mergeWithDefault } from "./loader/merge.js";
46
- import { validateEditGlobals, validateGlobals } from "./loader/globals.js";
47
- import { validateVariables } from "./loader/variables.js";
48
- import { validateSegments } from "./loader/segments.js";
49
- import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
50
- import { synthesizeMenuDecls } from "./loader/menu-synth.js";
51
- import { synthesizeEditModeToggle } from "./loader/edit-mode.js";
52
- import { synthesizeEditChrome } from "./edit-chrome.js";
53
- import { SETTINGS_NS, synthesizeSettingsMenu } from "./settings-menu.js";
54
- import { reservedNamespaceCollisions } from "./loader/reserved-namespace.js";
55
- import { validateActions } from "./loader/actions.js";
56
- import { validateLooks } from "./loader/looks.js";
57
- import { validatePresets } from "./loader/presets.js";
58
- import { validateHelpers } from "./loader/helpers.js";
59
- import { validateCrossReferences } from "./loader/cross-ref.js";
60
- import { validateNoCycles } from "./loader/cycles.js";
61
-
62
- // ─── Public barrel ───────────────────────────────────────────────────────────
63
- // [LAW:locality-or-seam] Consumers import from `dsl-loader`; the internal split
64
- // is invisible to them. Moving a symbol between loader/ modules never touches a
65
- // callsite as long as it stays re-exported here.
66
-
67
- export { ConfigError, findKeyLine } from "./loader/diagnostics.js";
68
- export type { ConfigIssue } from "./loader/diagnostics.js";
69
- export {
70
- expandHome,
71
- dslConfigCandidatePaths,
72
- resolveDslConfigPath,
73
- detectConfigCollisions,
74
- } from "./loader/discovery.js";
75
- export {
76
- mergeWithDefault,
77
- applySegmentPaletteOverrides,
78
- } from "./loader/merge.js";
79
- export {
80
- extractTemplateRefs,
81
- extractActionRefs,
82
- extractPickerMenuRefs,
83
- } from "./loader/refs.js";
84
-
85
- // ─── Three-stage pipeline ────────────────────────────────────────────────────
86
-
87
- /**
88
- * Load a JSON5 DSL config file from disk and merge it with the given
89
- * default. Returns the effective DslConfig AND the raw source text.
90
- *
91
- * `path = null` means "no user file exists" — returns `dflt` unchanged
92
- * (uniform merge against an empty raw, which is deep-equal to `dflt`) and
93
- * an empty source. No consumer branches on file presence; that branch lives
94
- * inside loadConfig exactly once.
95
- *
96
- * [LAW:one-way-deps] `dflt` is a required parameter, not a default pointing at
97
- * DEFAULT_DSL_CONFIG: this module is generic merge/parse machinery, and
98
- * DEFAULT_DSL_CONFIG is a specific, higher-level instance built ON TOP of it
99
- * (default-dsl-config.ts imports parseDslConfig/mergeWithDefault to
100
- * synthesize itself — see that file). A default param here pointing back at
101
- * DEFAULT_DSL_CONFIG would make this generic module depend on its own
102
- * specific consumer — a cycle every caller who wants "the bundled default"
103
- * resolves explicitly by importing DEFAULT_DSL_CONFIG themselves.
104
- *
105
- * [LAW:one-source-of-truth] The source is returned alongside the config so the
106
- * caller can hand it to validateConfig — cross-ref diagnostics (line numbers,
107
- * the authored-surface discriminator) are derived from it, and the file is read
108
- * exactly once here rather than re-read downstream.
109
- *
110
- * Throws ConfigError on JSON5 syntax / structural / per-record validation
111
- * failures. Cross-references and cycles are validateConfig()'s job.
112
- *
113
- * [LAW:dataflow-not-control-flow] One function, one branch, same operations
114
- * each call.
115
- */
116
- export function loadConfig(
117
- path: string | null,
118
- dflt: DslConfig,
119
- allowedPalettes?: ReadonlySet<string>,
120
- ): { config: DslConfig; source: string } {
121
- const source = path === null ? "" : fs.readFileSync(path, "utf-8");
122
- const raw: RawDslConfig =
123
- path === null ? {} : parseDslConfig(path, source, allowedPalettes);
124
- return { config: mergeWithDefault(raw, dflt), source };
125
- }
126
-
127
- /**
128
- * Promote a merged DslConfig to a ValidatedConfig by running cross-references
129
- * and cycle detection. Sole producer of ValidatedConfig in the codebase — the
130
- * phantom brand makes "the renderer never receives an unvalidated config" a
131
- * compile-time invariant, not a runtime convention.
132
- *
133
- * Throws ConfigError aggregating every issue.
134
- *
135
- * [LAW:single-enforcer] One cast site, here, exclusive.
136
- */
137
- export function validateConfig(
138
- config: DslConfig,
139
- filePath = "<config>",
140
- source = "",
141
- allowedPalettes: ReadonlySet<string> = new Set(listResolvablePaletteNames()),
142
- ): ValidatedConfig {
143
- const issues: ConfigIssue[] = [];
144
- const ctx: ValidateCtx = { source, issues, allowedPalettes, groups: [] };
145
- validateCrossReferences(ctx, config);
146
- validateNoCycles(ctx, config);
147
- if (issues.length > 0) {
148
- throw new ConfigError(filePath, issues);
149
- }
150
- // [LAW:one-source-of-truth] Edit-mode's CHROME half (brandon-layout-edit-
151
- // 2gc.3), synthesized HERE — not in parseDslConfig alongside the toggle —
152
- // because it needs the fully merged, preset-resolved, rootOps-replayed
153
- // tree cross-ref/cycles just proved sound. Its own output (segment refs
154
- // into freshly-synthesized segments, actions into freshly-synthesized
155
- // actions) is correct by construction and does not re-enter cross-ref/
156
- // cycle checking, exactly as group/menu synthesis's output doesn't either.
157
- // [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.1's global settings
158
- // menu, spliced BEFORE edit chrome so edit chrome walks the final content tree
159
- // and treats the menu's reserved `settings.` names as chrome-exempt — the
160
- // full ordering argument lives in settings-menu.ts's header, beside the pass
161
- // it governs.
162
- const withChrome = synthesizeEditChrome(synthesizeSettingsMenu(config));
163
- return withChrome as ValidatedConfig;
164
- }
165
-
166
- /**
167
- * Parse a JSON5 DSL config source into a RawDslConfig. JSON5 syntax + per-
168
- * record structural validation. Cross-references and cycles are NOT checked
169
- * here — they belong to validateConfig, which runs on the merged shape.
170
- *
171
- * Returned shape preserves absence: top-level keys are optional in RawDslConfig.
172
- *
173
- * `allowedPalettes` is the set of palette names a `palette:` field may name.
174
- * It defaults to every name that resolves to a concrete Palette, so production
175
- * always validates loudly against the real registry. Tests inject a custom set
176
- * to exercise validation without depending on registry contents.
177
- */
178
- export function parseDslConfig(
179
- filePath: string,
180
- source: string,
181
- allowedPalettes: ReadonlySet<string> = new Set(listResolvablePaletteNames()),
182
- ): RawDslConfig {
183
- // ── Stage 1: JSON5 syntax. A parse error here is single, immediate, and
184
- // carries line/col from the json5 package — no point continuing to other
185
- // passes that need a parsed structure to inspect.
186
- const raw = parseJson5OrThrow(filePath, source);
187
-
188
- const issues: ConfigIssue[] = [];
189
- const ctx: ValidateCtx = { source, issues, allowedPalettes, groups: [] };
190
-
191
- // ── Stage 2: top-level shape + per-record shape. Absence survives as
192
- // `undefined` in the returned RawDslConfig.
193
- if (!isPlainObject(raw)) {
194
- throw new ConfigError(filePath, [
195
- {
196
- path: "",
197
- message: `Config root must be an object, got ${describeType(raw)}`,
198
- },
199
- ]);
200
- }
201
-
202
- const topLevel = validateTopLevel(ctx, raw);
203
-
204
- if (issues.length > 0) {
205
- throw new ConfigError(filePath, issues);
206
- }
207
-
208
- return topLevel;
209
- }
210
-
211
- // ─── Internals ───────────────────────────────────────────────────────────────
212
-
213
- interface Json5Error extends Error {
214
- lineNumber?: number;
215
- columnNumber?: number;
216
- }
217
-
218
- function parseJson5OrThrow(filePath: string, source: string): unknown {
219
- try {
220
- return JSON5.parse(source);
221
- } catch (err) {
222
- const e = err as Json5Error;
223
- throw new ConfigError(filePath, [
224
- {
225
- path: "",
226
- message: `JSON5 syntax error: ${e.message}`,
227
- line: e.lineNumber,
228
- col: e.columnNumber,
229
- },
230
- ]);
231
- }
232
- }
233
-
234
- // [LAW:types-are-the-program] Returns RawDslConfig — absence of a top-level
235
- // key survives the parse as `undefined`, distinct from explicit empty. The
236
- // merge step downstream decides what "absent" means policy-wise (currently:
237
- // inherit from default).
238
- function validateTopLevel(
239
- ctx: ValidateCtx,
240
- raw: Record<string, unknown>,
241
- ): RawDslConfig {
242
- for (const key of Object.keys(raw)) {
243
- if (!TOP_LEVEL_KEYS.has(key)) {
244
- ctx.issues.push({
245
- path: key,
246
- message: `Unknown top-level key "${key}". Expected one of: ${[...TOP_LEVEL_KEYS].join(", ")}`,
247
- line: findKeyLine(ctx.source, [key]),
248
- });
249
- }
250
- }
251
-
252
- const out: Mutable<RawDslConfig> = {};
253
- if (raw.globals !== undefined)
254
- out.globals = validateGlobals(ctx, "globals", raw.globals);
255
- if (raw.variables !== undefined)
256
- out.variables = validateVariables(ctx, "variables", raw.variables);
257
- if (raw.segments !== undefined)
258
- out.segments = validateSegments(ctx, raw.segments);
259
- // [LAW:no-silent-failure] `layout:` was removed in 2de.19. Reject loudly with
260
- // a migration hint so the author knows exactly how to rewrite their config.
261
- if (raw.layout !== undefined) {
262
- ctx.issues.push({
263
- path: "layout",
264
- message:
265
- `"layout" is no longer supported — use "root" with the A-grammar instead.\n` +
266
- ` Replace: layout: [["seg1", "seg2"], ["seg3"]]\n` +
267
- ` With: root: { v: [{ h: ["seg1", "seg2"] }, "seg3"] }\n` +
268
- ` Single-row example: root: { h: ["seg1", "seg2"] }`,
269
- line: findKeyLine(ctx.source, ["layout"]),
270
- });
271
- }
272
- if (raw.root !== undefined) out.root = validateRoot(ctx, "root", raw.root);
273
- if (raw.actions !== undefined)
274
- out.actions = validateActions(ctx, raw.actions);
275
- if (raw.looks !== undefined) out.looks = validateLooks(ctx, raw.looks);
276
- // [LAW:one-source-of-truth] Parsed BEFORE the synthesis passes below, because
277
- // a preset's `root` runs through the same validateRoot and therefore collects
278
- // its group sugar into the same `ctx.groups` the top-level root does — the
279
- // synthesized artifacts must see every group the config declares, wherever it
280
- // was staged from.
281
- if (raw.presets !== undefined)
282
- out.presets = validatePresets(ctx, raw.presets);
283
- // [LAW:one-type-per-behavior] Edit mode's staged display globals — the same
284
- // fragment shape a preset carries, one rung later in the precedence chain, so
285
- // it runs through the same field table (validateEditGlobals) rather than a
286
- // parallel schema listing which globals edit mode may set.
287
- if (raw.editGlobals !== undefined)
288
- out.editGlobals = validateEditGlobals(ctx, "editGlobals", raw.editGlobals);
289
- if (raw.helpers !== undefined)
290
- out.helpers = validateHelpers(ctx, raw.helpers);
291
- // [LAW:one-source-of-truth] Group sugar synthesis runs AFTER every section
292
- // parsed: each group collected during the root walk emits its state var +
293
- // cycle action + toggle segment into the raw sections (so they merge over the
294
- // default and cross-ref like any user declaration), and user names under the
295
- // reserved namespace are rejected against the fully-parsed sections.
296
- synthesizeGroupDecls(ctx, out);
297
- // [LAW:one-source-of-truth] Menu synthesis runs AFTER group synthesis (a group
298
- // body may host menu-bearing segments) and after every section parsed: each
299
- // menu placement detected in the root walk emits its state var + cycle action
300
- // into the raw sections, so they merge over the default, derive the click gate
301
- // through deriveActionValidators, and collide loudly with any user name under
302
- // the reserved namespace.
303
- synthesizeMenuDecls(ctx, out);
304
- // [LAW:one-source-of-truth] Edit-mode's TOGGLE half (brandon-layout-edit-
305
- // 2gc.3) — unconditional, like the reservation above, so `edit.mode`/
306
- // `edit.toggle` exist in EVERY parsed file and a hand-authored trigger
307
- // segment cross-ref-checks normally. The CHROME half (the per-position +/-
308
- // affordances) runs later, in validateConfig, once the merged/preset-
309
- // resolved/rootOps-replayed tree exists to derive it from.
310
- synthesizeEditModeToggle(ctx, out);
311
- // [LAW:one-source-of-truth] The global settings menu reserves its namespace
312
- // here and synthesizes NOTHING here: the tree it must be present in only
313
- // exists after merge (a user `root` replaces the default's), so the artifacts
314
- // are minted in validateConfig. The reservation is unconditional all the same,
315
- // mirroring every other namespace above — "you never author settings.*" is a
316
- // stable contract, not a rule that switches on when the pass happens to fire.
317
- reservedNamespaceCollisions(
318
- ctx,
319
- out,
320
- SETTINGS_NS,
321
- "the global settings menu",
322
- );
323
- return out;
324
- }
325
-
326
- // [LAW:no-silent-failure] `layout` is intentionally absent — a config that
327
- // writes it gets an explicit migration error, not an "unknown key" message.
328
- const TOP_LEVEL_KEYS = new Set([
329
- "globals",
330
- "variables",
331
- "segments",
332
- "layout",
333
- "root",
334
- "actions",
335
- "looks",
336
- "presets",
337
- "editGlobals",
338
- "helpers",
339
- ]);