@promptctl/cc-candybar 1.26.0 → 1.27.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 (44) hide show
  1. package/dist/index.mjs +86 -85
  2. package/package.json +6 -6
  3. package/schema/cc-candybar.schema.json +193 -4
  4. package/src/check.ts +49 -27
  5. package/src/click/wire.ts +16 -0
  6. package/src/config/action.ts +57 -22
  7. package/src/config/default-dsl-config.ts +424 -55
  8. package/src/config/dsl-loader.ts +14 -2
  9. package/src/config/dsl-types.ts +59 -0
  10. package/src/config/loader/actions.ts +283 -109
  11. package/src/config/loader/cross-ref.ts +148 -28
  12. package/src/config/loader/emit-schema.ts +2 -0
  13. package/src/config/loader/globals.ts +118 -31
  14. package/src/config/loader/merge.ts +58 -1
  15. package/src/config/loader/persist-target.ts +32 -0
  16. package/src/config/loader/presets.ts +107 -0
  17. package/src/config/option-domain.ts +164 -0
  18. package/src/config/presets.ts +156 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +61 -7
  21. package/src/daemon/config-overrides-store.ts +322 -0
  22. package/src/daemon/paths.ts +10 -0
  23. package/src/daemon/render-payload.ts +84 -19
  24. package/src/daemon/server.ts +68 -55
  25. package/src/daemon/verbs/config-validators.ts +127 -0
  26. package/src/daemon/verbs/index.ts +129 -2
  27. package/src/daemon/verbs/state-validators.ts +98 -586
  28. package/src/daemon/verbs/validator-registry.ts +457 -0
  29. package/src/demo/dsl.ts +17 -10
  30. package/src/dsl/node-registry.ts +54 -39
  31. package/src/dsl/render.ts +158 -46
  32. package/src/help-text.ts +3 -3
  33. package/src/install/index.ts +2 -2
  34. package/src/render/action.ts +155 -33
  35. package/src/render/active-segment.ts +78 -0
  36. package/src/render/menu.ts +16 -11
  37. package/src/render/picker.ts +51 -13
  38. package/src/render/segment-color.ts +74 -0
  39. package/src/segments/git.ts +389 -48
  40. package/src/template-engine/colors.ts +67 -45
  41. package/src/template-engine/engine.ts +11 -12
  42. package/src/themes/index.ts +1 -4
  43. package/src/themes/palette-resolvers.ts +22 -30
  44. package/src/themes/policy.ts +37 -16
@@ -10,9 +10,20 @@ import {
10
10
  hasCacheField,
11
11
  walkNodes,
12
12
  type DslConfig,
13
+ type LayoutNode,
13
14
  type VariableDecl,
14
15
  } from "../dsl-types.js";
15
- import { actionBindsSet } from "../action.js";
16
+ import {
17
+ actionBindsPersist,
18
+ actionBindsReset,
19
+ actionBindsSet,
20
+ } from "../action.js";
21
+ import {
22
+ knownOptionDomainNames,
23
+ perConfigDomainsFor,
24
+ } from "../option-domain.js";
25
+ import { listGlobalsFieldNames } from "./globals.js";
26
+ import { parsePersistTarget } from "./persist-target.js";
16
27
  import { findKeyLine } from "./diagnostics.js";
17
28
  import { isPlainObject, type ValidateCtx } from "./validate-core.js";
18
29
  import {
@@ -50,6 +61,89 @@ export function validateCrossReferences(
50
61
  line: findKeyLine(ctx.source, ["globals", "look"]),
51
62
  });
52
63
  }
64
+ // [LAW:one-type-per-behavior] globals.preset is globals.look one dimension
65
+ // over — the same post-merge membership check against the same kind of
66
+ // per-config block, for the same reason (a user's default may name a
67
+ // bundled preset). A typo'd DEFAULT is a load error even though a stale
68
+ // SESSION pick collapses silently to the floor: the config file is authored
69
+ // and re-readable, so naming a preset that does not exist is a mistake we can
70
+ // point at; a session pick is a click made against a config that has since
71
+ // changed, which is not [LAW:no-silent-failure].
72
+ if (
73
+ cfg.globals.preset !== undefined &&
74
+ !Object.prototype.hasOwnProperty.call(cfg.presets, cfg.globals.preset)
75
+ ) {
76
+ ctx.issues.push({
77
+ path: "globals.preset",
78
+ message: `globals.preset "${cfg.globals.preset}" does not match any declared preset (have: ${Object.keys(cfg.presets).join(", ")})`,
79
+ line: findKeyLine(ctx.source, ["globals", "preset"]),
80
+ });
81
+ }
82
+ // [LAW:one-source-of-truth] A `set … from` NAME must resolve — checked
83
+ // against this config's per-config domains ("looks", the merged looks:
84
+ // block) plus the global registry (themes/styles, and any future
85
+ // registration), the SAME set resolveOptionDomain consults at render and
86
+ // gate-derivation time. An inline array `from` is its own domain — nothing
87
+ // to resolve. Runs post-merge for the same reason globals.look does above:
88
+ // "looks" isn't fully known until the user's looks: block has merged onto
89
+ // the bundled stdlib.
90
+ const optionDomains = perConfigDomainsFor(cfg);
91
+ for (const [name, a] of Object.entries(cfg.actions)) {
92
+ if (!("set" in a) || !("from" in a) || typeof a.from !== "string") continue;
93
+ if (!knownOptionDomainNames(optionDomains).includes(a.from)) {
94
+ ctx.issues.push({
95
+ path: `actions.${name}.from`,
96
+ message: `actions.${name} from: references unknown option domain "${a.from}" (have: ${knownOptionDomainNames(optionDomains).join(", ")})`,
97
+ line: findKeyLine(ctx.source, ["actions", name, "from"]),
98
+ });
99
+ }
100
+ }
101
+ // [LAW:no-silent-failure] A `persist`/`reset` target must name a REAL
102
+ // Globals field OR a declared segment's `palette` (candybar-config-engine-
103
+ // 71o.6 — `segments.<name>.palette`, parsed by the one shared authority in
104
+ // persist-target.ts) — the loader's structural pass (loader/actions.ts)
105
+ // only proves the key is non-empty/slash-free, the same shape a `set` key
106
+ // needs for the wire, but a persist/reset key additionally has to land
107
+ // somewhere real. Catching a typo (`persist: "pallete"`) or a dangling
108
+ // segment name here turns a confusing click-time "registration invariant
109
+ // broken" error into a clear load-time one naming the actual allowed
110
+ // targets — the same species of fix as the `from` domain check just above.
111
+ for (const [name, a] of Object.entries(cfg.actions)) {
112
+ const key = "persist" in a ? a.persist : "reset" in a ? a.reset : null;
113
+ if (key === null) continue;
114
+ const discriminator = "persist" in a ? "persist" : "reset";
115
+ const target = parsePersistTarget(key);
116
+ if (target === null) {
117
+ ctx.issues.push({
118
+ path: `actions.${name}.${discriminator}`,
119
+ message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")}) or a "segments.<name>.palette" target`,
120
+ line: findKeyLine(ctx.source, ["actions", name, discriminator]),
121
+ });
122
+ continue;
123
+ }
124
+ if (target.scope !== "segment-palette") continue;
125
+ if (!Object.prototype.hasOwnProperty.call(cfg.segments, target.segment)) {
126
+ ctx.issues.push({
127
+ path: `actions.${name}.${discriminator}`,
128
+ message: `actions.${name}: "${key}" names segment "${target.segment}" which is not declared (have segments: ${Object.keys(cfg.segments).join(", ")})`,
129
+ line: findKeyLine(ctx.source, ["actions", name, discriminator]),
130
+ });
131
+ continue;
132
+ }
133
+ // [LAW:types-are-the-program] A palette is a NAME, not a number — a
134
+ // bounded stepper (`min`/`max`/`by`) has no meaning over it, unlike a
135
+ // Globals field where nothing today enforces value/field-kind agreement
136
+ // either way. Rejecting it here (rather than tolerating a numeric-string
137
+ // palette name that only fails later at `paletteForThemeName`) keeps
138
+ // the failure at load time, next to the typo it actually is.
139
+ if ("min" in a) {
140
+ ctx.issues.push({
141
+ path: `actions.${name}.${discriminator}`,
142
+ message: `actions.${name}: "${key}" is a segment palette target and cannot use a bounded stepper (min/max/by) — use "to", "from", or "cycle" instead`,
143
+ line: findKeyLine(ctx.source, ["actions", name, discriminator]),
144
+ });
145
+ }
146
+ }
53
147
  // [LAW:one-source-of-truth] THE set of resolvable variable names — a
54
148
  // faithful mirror of the runtime store's key set (declareOne in
55
149
  // src/dsl/render.ts registers globals under their bare names and segment
@@ -80,30 +174,51 @@ export function validateCrossReferences(
80
174
  // field) — would fool a raw `findKeyLine` search and misclassify the config.
81
175
  // Validation is cold-path, so reading the source's top-level keys is exact.
82
176
  // The reported path/message then point at the surface the user wrote.
83
- const layoutKey = authoredLayoutKey(ctx.source);
84
- const layoutLine = findKeyLine(ctx.source, [layoutKey]);
85
- for (const node of walkNodes(cfg.root)) {
86
- // [LAW:locality-or-seam] A node's `when` reads the global scope (bare
87
- // globals + namespaced segment vars) the same existence-check shape as a
88
- // segment template, surfaced at load time.
89
- if (node.when !== undefined) {
90
- checkTemplateRefs(ctx, `${layoutKey}.when`, node.when, templateScope, {
91
- line: layoutLine,
92
- });
93
- }
94
- if (node.kind !== "segment") continue;
95
- if (!Object.prototype.hasOwnProperty.call(cfg.segments, node.name)) {
96
- const renamed = RENAMED_SEGMENTS[node.name];
97
- const hint =
98
- renamed !== undefined
99
- ? ` (the built-in segment "${node.name}" was renamed to "${renamed}" — update this reference)`
100
- : "";
101
- ctx.issues.push({
102
- path: layoutKey,
103
- message: `${layoutKey} entry "${node.name}" does not match any declared segment${hint}`,
104
- line: layoutLine,
105
- });
177
+ //
178
+ // [LAW:one-type-per-behavior] A PRESET's `root` is a root: it gets this exact
179
+ // walk, not a reduced copy. The only thing that varies between the config's
180
+ // own tree and a preset's is the diagnostic key + line — data threaded in,
181
+ // never a second traversal that could learn a different idea of what a valid
182
+ // layout is. This is what makes `cc-candybar check` catch a preset staging a
183
+ // segment nobody declared.
184
+ const checkLayoutTree = (
185
+ root: LayoutNode,
186
+ layoutKey: string,
187
+ layoutLine: number | undefined,
188
+ ): void => {
189
+ for (const node of walkNodes(root)) {
190
+ // [LAW:locality-or-seam] A node's `when` reads the global scope (bare
191
+ // globals + namespaced segment vars) — the same existence-check shape as a
192
+ // segment template, surfaced at load time.
193
+ if (node.when !== undefined) {
194
+ checkTemplateRefs(ctx, `${layoutKey}.when`, node.when, templateScope, {
195
+ line: layoutLine,
196
+ });
197
+ }
198
+ if (node.kind !== "segment") continue;
199
+ if (!Object.prototype.hasOwnProperty.call(cfg.segments, node.name)) {
200
+ const renamed = RENAMED_SEGMENTS[node.name];
201
+ const hint =
202
+ renamed !== undefined
203
+ ? ` (the built-in segment "${node.name}" was renamed to "${renamed}" — update this reference)`
204
+ : "";
205
+ ctx.issues.push({
206
+ path: layoutKey,
207
+ message: `${layoutKey} entry "${node.name}" does not match any declared segment${hint}`,
208
+ line: layoutLine,
209
+ });
210
+ }
106
211
  }
212
+ };
213
+ const layoutKey = authoredLayoutKey(ctx.source);
214
+ checkLayoutTree(cfg.root, layoutKey, findKeyLine(ctx.source, [layoutKey]));
215
+ for (const [name, preset] of Object.entries(cfg.presets)) {
216
+ if (preset.root === undefined) continue;
217
+ checkLayoutTree(
218
+ preset.root,
219
+ `presets.${name}.root`,
220
+ findKeyLine(ctx.source, ["presets", name, "root"]),
221
+ );
107
222
  }
108
223
 
109
224
  // For each variable's template/cache.key, every dotted ref must exist
@@ -243,11 +358,16 @@ function hasStateKind(cfg: DslConfig): boolean {
243
358
  return false;
244
359
  }
245
360
 
246
- // [LAW:dataflow-not-control-flow] A config emits a set-state click — and so needs
247
- // session.id — when any declared action is a `set` (literal, option, or bounded).
248
- // copy/open actions write nothing, so they embed no session.id.
361
+ // [LAW:dataflow-not-control-flow] A config emits a set-state, set-config, OR
362
+ // reset-config click — and so needs session.id — when any declared action is
363
+ // a `set` (literal/option/bounded/cycle), a `persist` (its config-overrides
364
+ // twin), or a `reset` (persist's gated undo) — all three carry session.id on
365
+ // the wire for click-error surfacing. copy/open actions write nothing, so
366
+ // they embed no session.id.
249
367
  function hasActionSetAction(cfg: DslConfig): boolean {
250
- return Object.values(cfg.actions).some(actionBindsSet);
368
+ return Object.values(cfg.actions).some(
369
+ (a) => actionBindsSet(a) || actionBindsPersist(a) || actionBindsReset(a),
370
+ );
251
371
  }
252
372
 
253
373
  function checkVarRefs(
@@ -17,6 +17,7 @@ import { variablesMapJson } from "./variables.js";
17
17
  import { segmentsJson } from "./segments.js";
18
18
  import { actionsJson } from "./actions.js";
19
19
  import { looksJson } from "./looks.js";
20
+ import { presetsJson } from "./presets.js";
20
21
  import {
21
22
  layoutNodeJson,
22
23
  LAYOUT_NODE_DEF_NAME,
@@ -48,6 +49,7 @@ export function emitConfigSchema(): JsonNode {
48
49
  root: { $ref: LAYOUT_NODE_REF },
49
50
  actions: actionsJson(),
50
51
  looks: looksJson(),
52
+ presets: presetsJson(),
51
53
  helpers: { type: "object", additionalProperties: { type: "string" } },
52
54
  },
53
55
  definitions: {
@@ -20,6 +20,7 @@ import {
20
20
  record,
21
21
  recordJson,
22
22
  type FieldSpec,
23
+ type FieldSpecMap,
23
24
  type JsonNode,
24
25
  type RecordSchema,
25
26
  type ValidateCtx,
@@ -53,46 +54,110 @@ const colorCompatibilitySpec: FieldSpec<ColorCompatibility> = {
53
54
  },
54
55
  };
55
56
 
57
+ // [LAW:one-source-of-truth] THE globals field table, declared once. Both the
58
+ // top-level `globals:` schema and the preset-scoped one below are built from
59
+ // this map, so a field added here is automatically settable from a preset —
60
+ // there is no second list to remember to grow.
61
+ const GLOBALS_FIELDS: FieldSpecMap<Globals> = {
62
+ default_bg: optionalStringSpec(),
63
+ default_fg: optionalStringSpec(),
64
+ default_empty_value: optionalStringSpec(),
65
+ default_separator: optionalStringSpec(),
66
+ default_truncate_marker: optionalStringSpec(),
67
+ palette: paletteSpec(),
68
+ // [LAW:types-are-the-program] The config-default LOOK name. Unlike the
69
+ // registry-static palette set, the look domain is per-config (the merged
70
+ // `looks` block), so membership is a cross-ref check on the MERGED config —
71
+ // a user's globals.look may name a default-provided look. Shape-only here,
72
+ // exactly the shape/meaning split paletteSpec's schema facet keeps.
73
+ look: optionalStringSpec(),
74
+ // [LAW:types-are-the-program] The config-default PRESET name — same
75
+ // per-config-domain shape as `look` (membership is a post-merge cross-ref
76
+ // check, since a user's globals.preset may name a default-provided preset).
77
+ preset: optionalStringSpec(),
78
+ // [LAW:types-are-the-program] The strip style is a CLOSED enum (the powerline
79
+ // shapes the joiner can render), unlike the open-ended palette NAME — so it
80
+ // validates by membership and emits a JSON-Schema `enum`.
81
+ style: optionalEnumSpec(STRIP_STYLES),
82
+ autoWrap: optionalBooleanSpec(),
83
+ // Intra-cell spaces per side. Bounded above so a config value can never
84
+ // drive an unbounded `" ".repeat` allocation in the daemon
85
+ // [LAW:no-silent-failure] — an absurd value is a loud load error, not a
86
+ // silently-huge render.
87
+ padding: optionalIntSpec({ min: 0, max: 16 }),
88
+ // [LAW:types-are-the-program] Closed enum like `style`: the joiner glyph
89
+ // vocabularies pickJoiner can render — validates by membership, emits a
90
+ // JSON-Schema `enum` from the same CHARSETS literal.
91
+ charset: optionalEnumSpec(CHARSETS),
92
+ // Closed enum with a bespoke "auto" rejection — see colorCompatibilitySpec.
93
+ colorCompatibility: colorCompatibilitySpec,
94
+ };
95
+
56
96
  const GLOBALS_SCHEMA: RecordSchema<Globals> = {
57
97
  noun: "globals key",
58
- fields: {
59
- default_bg: optionalStringSpec(),
60
- default_fg: optionalStringSpec(),
61
- default_empty_value: optionalStringSpec(),
62
- default_separator: optionalStringSpec(),
63
- default_truncate_marker: optionalStringSpec(),
64
- palette: paletteSpec(),
65
- // [LAW:types-are-the-program] The config-default LOOK name. Unlike the
66
- // registry-static palette set, the look domain is per-config (the merged
67
- // `looks` block), so membership is a cross-ref check on the MERGED config —
68
- // a user's globals.look may name a default-provided look. Shape-only here,
69
- // exactly the shape/meaning split paletteSpec's schema facet keeps.
70
- look: optionalStringSpec(),
71
- // [LAW:types-are-the-program] The strip style is a CLOSED enum (the powerline
72
- // shapes the joiner can render), unlike the open-ended palette NAME — so it
73
- // validates by membership and emits a JSON-Schema `enum`.
74
- style: optionalEnumSpec(STRIP_STYLES),
75
- autoWrap: optionalBooleanSpec(),
76
- // Intra-cell spaces per side. Bounded above so a config value can never
77
- // drive an unbounded `" ".repeat` allocation in the daemon
78
- // [LAW:no-silent-failure] — an absurd value is a loud load error, not a
79
- // silently-huge render.
80
- padding: optionalIntSpec({ min: 0, max: 16 }),
81
- // [LAW:types-are-the-program] Closed enum like `style`: the joiner glyph
82
- // vocabularies pickJoiner can render — validates by membership, emits a
83
- // JSON-Schema `enum` from the same CHARSETS literal.
84
- charset: optionalEnumSpec(CHARSETS),
85
- // Closed enum with a bespoke "auto" rejection — see colorCompatibilitySpec.
86
- colorCompatibility: colorCompatibilitySpec,
98
+ fields: GLOBALS_FIELDS,
99
+ };
100
+
101
+ // [LAW:one-source-of-truth] A preset's `globals` may not carry `preset`: which
102
+ // preset is active has exactly one authority (session pick over globals.preset
103
+ // over the floor), and a preset re-selecting a preset would be a second one —
104
+ // a cyclic second one. Same species of bespoke, migration-pointing rejection as
105
+ // colorCompatibility's "auto" above, and for the same reason: an author who
106
+ // writes it deserves to be told WHY, not handed a bare unknown-key message.
107
+ const nestedPresetSpec: FieldSpec<string> = {
108
+ required: false,
109
+ // Always-fail: JSON Schema's `not: {}` matches nothing, so an editor flags
110
+ // the key at the same moment the validator does.
111
+ json: {
112
+ not: {},
113
+ description:
114
+ "not allowed inside a preset — a preset cannot select a preset",
87
115
  },
116
+ parse: (ctx, path, field, raw) => {
117
+ if (raw[field] !== undefined) {
118
+ ctx.issues.push({
119
+ path: `${path}.${field}`,
120
+ message:
121
+ `${path}.${field}: a preset cannot select a preset. Which preset is active is ` +
122
+ `resolved once, as session pick over globals.preset over "default"; a preset ` +
123
+ `naming another would be a second authority over that, and a cyclic one. ` +
124
+ `Set the default arrangement in the top-level globals.preset instead.`,
125
+ line: findKeyLine(ctx.source, [...path.split("."), field]),
126
+ });
127
+ }
128
+ return undefined;
129
+ },
130
+ };
131
+
132
+ // [LAW:one-source-of-truth] The preset-scoped globals schema is the SAME field
133
+ // table with exactly one field swapped for its rejection — not a hand-listed
134
+ // subset that a future globals field could be forgotten from.
135
+ const PRESET_GLOBALS_SCHEMA: RecordSchema<Globals> = {
136
+ noun: "preset globals key",
137
+ fields: { ...GLOBALS_FIELDS, preset: nestedPresetSpec },
88
138
  };
89
139
 
90
140
  // An absent globals block is the empty default (no issue); a non-object is a
91
141
  // reported error that recovers to the empty default, since parseDslConfig throws
92
142
  // once any issue exists so the recovery value never renders [LAW:no-silent-failure].
93
- export function validateGlobals(ctx: ValidateCtx, raw: unknown): Globals {
143
+ // `path` is explicit because the same schema validates the top-level `globals:`
144
+ // block and a preset's nested one, and a diagnostic must name where it actually is.
145
+ export function validateGlobals(
146
+ ctx: ValidateCtx,
147
+ path: string,
148
+ raw: unknown,
149
+ ): Globals {
94
150
  if (raw === undefined) return {};
95
- return record(ctx, GLOBALS_SCHEMA, "globals", raw) ?? {};
151
+ return record(ctx, GLOBALS_SCHEMA, path, raw) ?? {};
152
+ }
153
+
154
+ // The preset-scoped twin: same interpreter, the schema that rejects `preset`.
155
+ export function validatePresetGlobals(
156
+ ctx: ValidateCtx,
157
+ path: string,
158
+ raw: unknown,
159
+ ): Globals {
160
+ return record(ctx, PRESET_GLOBALS_SCHEMA, path, raw) ?? {};
96
161
  }
97
162
 
98
163
  // [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
@@ -101,3 +166,25 @@ export function validateGlobals(ctx: ValidateCtx, raw: unknown): Globals {
101
166
  export function globalsJson(): JsonNode {
102
167
  return recordJson(GLOBALS_SCHEMA);
103
168
  }
169
+
170
+ export function presetGlobalsJson(): JsonNode {
171
+ return recordJson(PRESET_GLOBALS_SCHEMA);
172
+ }
173
+
174
+ // [LAW:one-source-of-truth] THE membership check for "is this a real Globals
175
+ // field" — derived from GLOBALS_SCHEMA.fields, the same declaration
176
+ // validateGlobals/globalsJson interpret, so a `persist`/`reset` action's
177
+ // target key is checked against exactly the field set a hand-authored
178
+ // `globals: {...}` block would be. Used by cross-ref.ts (candybar-config-
179
+ // engine-71o.2) to catch a typo'd persist target at config-load time instead
180
+ // of a confusing click-time "invariant broken" error.
181
+ const GLOBALS_FIELD_NAMES: ReadonlySet<string> = new Set(
182
+ Object.keys(GLOBALS_SCHEMA.fields),
183
+ );
184
+ export function isGlobalsField(key: string): key is keyof Globals {
185
+ return GLOBALS_FIELD_NAMES.has(key);
186
+ }
187
+
188
+ export function listGlobalsFieldNames(): readonly string[] {
189
+ return [...GLOBALS_FIELD_NAMES];
190
+ }
@@ -13,7 +13,11 @@
13
13
  // specific consumer, a cycle. Callers who want "the bundled default" import
14
14
  // DEFAULT_DSL_CONFIG from default-dsl-config.ts and pass it explicitly.
15
15
 
16
- import { type DslConfig, type RawDslConfig } from "../dsl-types.js";
16
+ import {
17
+ type DslConfig,
18
+ type RawDslConfig,
19
+ type SegmentDecl,
20
+ } from "../dsl-types.js";
17
21
 
18
22
  /**
19
23
  * Merge a RawDslConfig on top of a default DslConfig. Pure function.
@@ -44,9 +48,62 @@ export function mergeWithDefault(
44
48
  // overrides one adaptation by re-declaring its name; the bundled stdlib
45
49
  // (incl. the "none" identity floor) survives every merge by construction.
46
50
  looks: { ...dflt.looks, ...(raw.looks ?? {}) },
51
+ // [LAW:one-source-of-truth] presets merge by name, same cascade — a user
52
+ // overrides one arrangement by re-declaring its name; the bundled stdlib
53
+ // (incl. the "default" empty-fragment floor effectivePresetName collapses
54
+ // to) survives every merge by construction, exactly as looks' "none" does.
55
+ presets: { ...dflt.presets, ...(raw.presets ?? {}) },
47
56
  // [LAW:one-source-of-truth] helpers merge by name, same cascade — a user
48
57
  // overrides one formatter helper by re-declaring its name; the rest inherit
49
58
  // from the bundled default.
50
59
  helpers: { ...dflt.helpers, ...(raw.helpers ?? {}) },
51
60
  };
52
61
  }
62
+
63
+ // [LAW:one-source-of-truth] The segment-scoped half of the config-overrides
64
+ // layer's merge (candybar-config-engine-71o.6) — the SAME "changes the
65
+ // DEFAULT, never the hand-authored file" precedence mergeWithDefault's
66
+ // `globals` cascade already applies, but patches ONE field (`palette`)
67
+ // inside an already-merged segment rather than replacing the segment
68
+ // wholesale. mergeWithDefault's `segments` cascade is deliberately per-name
69
+ // WHOLESALE replacement (a user overriding a segment restates it in full,
70
+ // same as any other by-name merge in this file) — routing a one-field
71
+ // override through that cascade would silently drop every other field the
72
+ // segment declares (template, bg, fg, when, vars...). This runs AFTER
73
+ // mergeWithDefault, directly against the already-merged config, so it never
74
+ // fights that cascade; it is its own, later, narrower merge step.
75
+ //
76
+ // [LAW:no-silent-failure] exception: a stale override naming a segment the
77
+ // config no longer declares is not a load-time error — the CONFIG, not the
78
+ // override, is the source of truth for which segments exist. Skipping it is
79
+ // a no-op, not a swallowed failure: a fresh `persist` write can only ever
80
+ // name a segment the config declares (cross-ref checks that at load time),
81
+ // so a dangling entry here only happens after a later config edit removed
82
+ // the segment, and there is nothing left for the override to apply to.
83
+ //
84
+ // [LAW:no-defensive-null-guards] exception: `Object.assign(Object.create(null),
85
+ // ...)` instead of `{ ...config.segments }` — the SAME null-prototype hygiene
86
+ // as the config-overrides-store.ts accumulators above (segment names come
87
+ // from user config and this loop WRITES via bracket assignment, `segments[name]
88
+ // = ...`, not a pure spread). Pure object spread never risks this (it defines
89
+ // every key directly, never invoking an inherited setter), but a stale
90
+ // override naming a since-removed segment `__proto__` hits exactly the
91
+ // "no own property yet, so the read returns the inherited accessor and the
92
+ // write invokes its setter" case a plain accumulator does not guard against.
93
+ export function applySegmentPaletteOverrides(
94
+ config: DslConfig,
95
+ overrides: Readonly<Record<string, string>>,
96
+ ): DslConfig {
97
+ const entries = Object.entries(overrides);
98
+ if (entries.length === 0) return config;
99
+ const segments: Record<string, SegmentDecl> = Object.assign(
100
+ Object.create(null) as Record<string, SegmentDecl>,
101
+ config.segments,
102
+ );
103
+ for (const [name, palette] of entries) {
104
+ const seg = segments[name];
105
+ if (seg === undefined) continue;
106
+ segments[name] = { ...seg, palette };
107
+ }
108
+ return { ...config, segments };
109
+ }
@@ -0,0 +1,32 @@
1
+ // [LAW:one-source-of-truth] THE parser for what a `persist`/`reset` action's
2
+ // key STRING may legally name — the single place a key is classified as a
3
+ // Globals field (candybar-config-engine-71o.3's scope) or a per-segment
4
+ // palette override (candybar-config-engine-71o.6's scope), so cross-ref
5
+ // validation (load time), the config-overrides store (write/read), and the
6
+ // daemon's write-gate all classify a key through ONE authority instead of
7
+ // three independently-drifting `isGlobalsField` checks.
8
+ //
9
+ // [LAW:types-are-the-program] A discriminated union, not a bag of optional
10
+ // fields: every legal key shape is representable, every illegal one
11
+ // collapses to `null` at exactly this one boundary — no downstream consumer
12
+ // re-parses the string itself.
13
+
14
+ import type { Globals } from "../dsl-types.js";
15
+ import { isGlobalsField } from "./globals.js";
16
+
17
+ export type PersistTarget =
18
+ | { readonly scope: "globals"; readonly field: keyof Globals }
19
+ | { readonly scope: "segment-palette"; readonly segment: string };
20
+
21
+ // [LAW:locality-or-seam] `segments.<name>.palette` reuses the SAME dotted
22
+ // namespacing SegmentDecl.vars already uses for segment-local variables
23
+ // (`<segment>.<var>`, declared in src/dsl/render.ts) — one idiom for "a name
24
+ // scoped under a segment", not a bespoke second syntax invented for persist
25
+ // targets alone.
26
+ const SEGMENT_PALETTE_KEY = /^segments\.([^.]+)\.palette$/;
27
+
28
+ export function parsePersistTarget(key: string): PersistTarget | null {
29
+ if (isGlobalsField(key)) return { scope: "globals", field: key };
30
+ const match = SEGMENT_PALETTE_KEY.exec(key);
31
+ return match ? { scope: "segment-palette", segment: match[1]! } : null;
32
+ }
@@ -0,0 +1,107 @@
1
+ // [LAW:types-are-the-program] The `presets` schema: each preset is a named
2
+ // config FRAGMENT — an alternative arrangement of the bar the user switches to
3
+ // — capped at exactly the two sections a render RESOLVES (`root`, `globals`)
4
+ // and closed to the sections the daemon REGISTERS once per process
5
+ // (`variables`, `segments`, `actions`, `helpers`). The reasoning for that cap
6
+ // lives on PresetDecl in dsl-types.ts; enforcing it is this file's job, and the
7
+ // record engine enforces it for free — `rejectUnknownKeys` reports any other
8
+ // section as a "preset key" error naming the two that are legal.
9
+ //
10
+ // This file is the structural pass only. Whether a preset's root names a
11
+ // segment that exists, and whether globals.preset names a declared preset, are
12
+ // CROSS-REFERENCE concerns (loader/cross-ref.ts), because both run on the
13
+ // MERGED config — a preset may stage segments the bundled default provides.
14
+
15
+ import {
16
+ describeType,
17
+ isPlainObject,
18
+ record,
19
+ recordJson,
20
+ type FieldSpec,
21
+ type JsonNode,
22
+ type RecordSchema,
23
+ type ValidateCtx,
24
+ } from "./validate-core.js";
25
+ import { findKeyLine } from "./diagnostics.js";
26
+ import { presetGlobalsJson, validatePresetGlobals } from "./globals.js";
27
+ import { LAYOUT_NODE_REF, validateRoot } from "./layout.js";
28
+ import type { PresetDecl } from "../dsl-types.js";
29
+
30
+ // [LAW:one-source-of-truth] A preset's `root` runs through THE layout validator
31
+ // — the same `validateRoot` the top-level `root:` uses, not a reduced copy — so
32
+ // the A-grammar, the group sugar, and every migration error read identically
33
+ // wherever a layout is authored. Group sugar declared inside a preset therefore
34
+ // also lands in `ctx.groups` and synthesizes its state var / cycle action /
35
+ // toggle segment into the shared sections, exactly as a top-level group does:
36
+ // the synthesized artifacts are process-lifetime (see PresetDecl's cap), the
37
+ // preset only chooses whether to stage them.
38
+ const presetRootSpec: FieldSpec<NonNullable<PresetDecl["root"]>> = {
39
+ required: false,
40
+ json: { $ref: LAYOUT_NODE_REF },
41
+ parse: (ctx, path, field, raw) =>
42
+ raw[field] === undefined
43
+ ? undefined
44
+ : validateRoot(ctx, `${path}.${field}`, raw[field]),
45
+ };
46
+
47
+ // [LAW:one-source-of-truth] A preset's `globals` runs through the globals field
48
+ // table, minus `preset` itself (a preset cannot select a preset — see
49
+ // validatePresetGlobals). A field added to Globals is preset-settable the same
50
+ // day, with no edit here.
51
+ const presetGlobalsSpec: FieldSpec<NonNullable<PresetDecl["globals"]>> = {
52
+ required: false,
53
+ json: presetGlobalsJson(),
54
+ parse: (ctx, path, field, raw) =>
55
+ raw[field] === undefined
56
+ ? undefined
57
+ : validatePresetGlobals(ctx, `${path}.${field}`, raw[field]),
58
+ };
59
+
60
+ const PRESET_SCHEMA: RecordSchema<PresetDecl> = {
61
+ noun: "preset key",
62
+ fields: { root: presetRootSpec, globals: presetGlobalsSpec },
63
+ };
64
+
65
+ // An absent presets block is handled by the caller (absence survives the parse);
66
+ // a non-object is a reported error recovering to empty — parseDslConfig throws
67
+ // once any issue exists, so the recovery value never renders.
68
+ export function validatePresets(
69
+ ctx: ValidateCtx,
70
+ raw: unknown,
71
+ ): Readonly<Record<string, PresetDecl>> {
72
+ if (!isPlainObject(raw)) {
73
+ ctx.issues.push({
74
+ path: "presets",
75
+ message: `presets must be an object mapping preset names to config fragments, got ${describeType(raw)}`,
76
+ line: findKeyLine(ctx.source, ["presets"]),
77
+ });
78
+ return {};
79
+ }
80
+ const out: Record<string, PresetDecl> = {};
81
+ for (const [name, value] of Object.entries(raw)) {
82
+ // [LAW:no-silent-failure] A preset name is a deliverable set-state value —
83
+ // a preset picker writes it on the wire, which rejects empty values and
84
+ // splits on "/". Rejecting the shape HERE surfaces the error on every
85
+ // config load, not only once an action ranges the "presets" domain (the
86
+ // identical guard looks.ts applies to look names, for the identical
87
+ // reason).
88
+ if (name === "" || name.includes("/")) {
89
+ ctx.issues.push({
90
+ path: `presets.${name}`,
91
+ message: `preset name ${JSON.stringify(name)} must be non-empty and slash-free — a preset picker writes the name on the set-state wire, which rejects empty values and splits on "/"`,
92
+ line: findKeyLine(ctx.source, ["presets", name]),
93
+ });
94
+ continue;
95
+ }
96
+ const parsed = record(ctx, PRESET_SCHEMA, `presets.${name}`, value);
97
+ if (parsed !== null) out[name] = parsed;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
103
+ // the validator interprets — a map of preset names to the closed two-section
104
+ // fragment object.
105
+ export function presetsJson(): JsonNode {
106
+ return { type: "object", additionalProperties: recordJson(PRESET_SCHEMA) };
107
+ }