@promptctl/cc-candybar 1.26.0 → 1.28.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 +188 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +72 -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
@@ -0,0 +1,457 @@
1
+ // [LAW:one-type-per-behavior] The keyed-validator-registry ALGEBRA, extracted
2
+ // from state-validators.ts (candybar-config-engine-71o.2) so it has exactly
3
+ // ONE implementation shared by two independent keyspaces: SessionState writes
4
+ // (`set` actions, state-validators.ts) and persistent config writes (`persist`
5
+ // actions, config-validators.ts). What differs between the two is only DATA —
6
+ // which keys are baseline/permanent and what namespace the keys live in — so
7
+ // this module is the "one cutter" and each keyspace is an instance of it, not
8
+ // a hand-rolled copy of the merge/dispose/rebuild logic.
9
+ //
10
+ // [LAW:one-source-of-truth] THE spec algebra: a key's live registrations
11
+ // (DerivedValidatorSpec[]) collapse to ONE spec via mergeKeySpecs, and a spec
12
+ // is residue-projected to a KeyValidator via validatorForSpec. Both keyspaces
13
+ // read this from the SAME functions, so "what does a range/allow-list/int
14
+ // spec mean" cannot drift between session and config gates.
15
+
16
+ // [LAW:types-are-the-program] Discriminated union — every legal return is
17
+ // either an accepted-and-canonicalized string or a structured rejection
18
+ // reason. There is no third state (no `null`, no thrown exception path
19
+ // inside a validator). The verb body matches exhaustively on `ok`.
20
+ export type ValidateResult =
21
+ | { ok: true; value: string }
22
+ | { ok: false; reason: string };
23
+
24
+ // [LAW:one-type-per-behavior] All key validators have the same shape — they
25
+ // don't carry the key name, the registry does. The validator's only concern
26
+ // is: does this raw string belong in this key's value-set?
27
+ export type KeyValidator = (rawValue: string) => ValidateResult;
28
+
29
+ // [LAW:types-are-the-program] A derived key's SEMANTIC identity — the data an
30
+ // action's `set`/`persist` declares about a key, from which the validator is
31
+ // residue. A key is one of three key shapes: an integer (a menu's page
32
+ // index), an allow-list (the union of values some button can write), or a
33
+ // bounded integer range (a stepper's value). The registry compares specs to
34
+ // decide whether two registrations can share a key (same `kind`) and merges
35
+ // them by unioning content (allow-list members; range bounds); the opaque
36
+ // `KeyValidator` it builds from the spec cannot be compared or merged, which
37
+ // is why registration takes the spec and owns validator construction.
38
+ //
39
+ // [LAW:one-source-of-truth] The spec carries only content (kind + allow-list
40
+ // members + range bounds), never the human label — the label is a pure
41
+ // function of the key, computed where the validator is built, so two
42
+ // registrations of one key yield byte-identical validators regardless of
43
+ // which config registered first.
44
+ export type DerivedValidatorSpec =
45
+ | { readonly kind: "int" }
46
+ | { readonly kind: "allow-list"; readonly allowed: readonly string[] }
47
+ | {
48
+ // [LAW:one-source-of-truth] A bounded-integer state key (a stepper's
49
+ // value). `min`/`max` gate the value; `seed` is the value an UNSET key
50
+ // reads as — sourced from the backing default so the first relative
51
+ // click steps from the same number the bar displays (not silently from
52
+ // `min`). The validator ignores `seed` (it only clamps); the caller
53
+ // reads it via rangeParamsFor when the key is unset.
54
+ readonly kind: "range";
55
+ readonly min: number;
56
+ readonly max: number;
57
+ readonly seed: number;
58
+ };
59
+
60
+ // [LAW:one-source-of-truth] One contribution shape — a (key, spec) pair —
61
+ // every action's write declaration projects to. mergeContributions folds a
62
+ // list of these into the final per-key validator specs, so multiple actions
63
+ // writing one key feed ONE coherence merge regardless of which action
64
+ // authored the write.
65
+ export interface KeySpecContribution {
66
+ readonly key: string;
67
+ readonly spec: DerivedValidatorSpec;
68
+ }
69
+
70
+ const INT_RE = /^-?\d+$/;
71
+
72
+ // [LAW:one-source-of-truth] The unset seed for a stepped key is the backing
73
+ // default — the SAME number the bar displays before the first click — so the
74
+ // first relative step doesn't silently start from `min`. Absent or
75
+ // non-integer default falls back to `min` (the historical render-side
76
+ // behavior).
77
+ export function clampSeed(
78
+ seed: number | undefined,
79
+ min: number,
80
+ max: number,
81
+ ): number {
82
+ if (seed === undefined) return min;
83
+ return Math.max(min, Math.min(max, seed));
84
+ }
85
+
86
+ // [LAW:one-type-per-behavior] The "values come from list Y" pattern IS the
87
+ // canonical widget-config use case (theme picker draws from themes(), style
88
+ // picker draws from styles(), a custom enum picker draws from a
89
+ // user-declared list). One factory builds the validator from the list —
90
+ // every callsite that registers an allow-list key passes through the same
91
+ // shape, so error messages, empty-input rejection, and lookup semantics are
92
+ // identical by construction.
93
+ //
94
+ // [LAW:no-silent-fallbacks] Empty input is rejected with a label-referencing
95
+ // reason rather than silently mapped to a default.
96
+ //
97
+ // [LAW:one-source-of-truth] `wire` names the ACTUAL wire this allow-list's
98
+ // values travel over — "set-state" for SessionState keys, "set-config" for
99
+ // config-overrides keys — so the slash-rejection message points at the wire
100
+ // the operator is actually debugging. Defaults to "set-state" (this
101
+ // factory's original, sole caller) so existing direct callers (tests) don't
102
+ // need to pass it; validatorForSpec passes the correct wire for its noun.
103
+ export function makeAllowListValidator(
104
+ allowed: readonly string[],
105
+ label: string,
106
+ wire: string = "set-state",
107
+ ): KeyValidator {
108
+ // [LAW:types-are-the-program] The factory's contract is "options = allow
109
+ // list" — every value the picker can RENDER must also be a value the wire
110
+ // can DELIVER. Two structural reasons a declared option can't reach the
111
+ // validator as itself: (1) the wire splits the tail on "/"; (2) the
112
+ // validator's empty-input rejection fires before the allow-list check, so
113
+ // an "" in the allow list would be listed-but-undeliverable. Catching at
114
+ // factory-build time (config-load) surfaces a misconfigured option list
115
+ // immediately, not on the operator's first click.
116
+ const slashOffenders = allowed.filter((v) => v.includes("/"));
117
+ if (slashOffenders.length > 0) {
118
+ throw new Error(
119
+ `makeAllowListValidator(${label}): values contain "/" — the ${wire} ` +
120
+ `wire shape splits values on "/" so slash-bearing options cannot ` +
121
+ `be addressed. Offending values: ${slashOffenders.join(", ")}`,
122
+ );
123
+ }
124
+ if (allowed.includes("")) {
125
+ throw new Error(
126
+ `makeAllowListValidator(${label}): empty string is not a writable ` +
127
+ `option — the validator rejects empty input before the allow-list ` +
128
+ `check, so an "" in the allowed list could be rendered but never ` +
129
+ `delivered. Remove "" from the allowed list.`,
130
+ );
131
+ }
132
+ const allowedSet: ReadonlySet<string> = new Set(allowed);
133
+ const allowedList = [...allowed];
134
+ return (raw) => {
135
+ if (!raw) return { ok: false, reason: `${label} value is required` };
136
+ if (!allowedSet.has(raw)) {
137
+ return {
138
+ ok: false,
139
+ reason: `unknown ${label} "${raw}" (have: ${allowedList.join(", ")})`,
140
+ };
141
+ }
142
+ return { ok: true, value: raw };
143
+ };
144
+ }
145
+
146
+ // [LAW:types-are-the-program] An integer-valued state key (a menu's page
147
+ // index). The wire delivers a string; the validator IS the parse boundary —
148
+ // it accepts only `^-?\d+$` and canonicalizes to the minimal decimal form.
149
+ // Negative is legal: -1 is the menu's CLOSED sentinel.
150
+ export function makeIntValidator(label: string): KeyValidator {
151
+ return (raw) => {
152
+ if (!raw) return { ok: false, reason: `${label} value is required` };
153
+ if (!INT_RE.test(raw)) {
154
+ return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
155
+ }
156
+ const neg = raw[0] === "-";
157
+ const digits = (neg ? raw.slice(1) : raw).replace(/^0+/, "");
158
+ if (digits === "") return { ok: true, value: "0" };
159
+ return { ok: true, value: neg ? `-${digits}` : digits };
160
+ };
161
+ }
162
+
163
+ // [LAW:types-are-the-program] A bounded-integer state key (a stepper's
164
+ // value). The validator is the parse-AND-clamp boundary: it accepts only
165
+ // `^-?\d+$` then clamps into [min,max]. [LAW:single-enforcer] This is the ONE
166
+ // place bounds are enforced.
167
+ export function makeRangeValidator(
168
+ min: number,
169
+ max: number,
170
+ label: string,
171
+ ): KeyValidator {
172
+ return (raw) => {
173
+ if (!raw) return { ok: false, reason: `${label} value is required` };
174
+ if (!INT_RE.test(raw)) {
175
+ return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
176
+ }
177
+ const clamped = Math.max(min, Math.min(max, parseInt(raw, 10)));
178
+ return { ok: true, value: String(clamped) };
179
+ };
180
+ }
181
+
182
+ // [LAW:types-are-the-program] Collapse one key's spec contributions into the
183
+ // single spec that gates it. A key is an INTEGER spec (a paged cursor `int`
184
+ // or a bounded `range`) or an allow-list — never both. An integer spec
185
+ // ABSORBS integer allow-list members (a trigger writing "0" to a page cursor
186
+ // is a legal int write), and a NON-integer member aimed at it is the genuine
187
+ // contradiction that throws. Two ranges widen-union; two allow-lists union;
188
+ // an int and a range on one key conflict.
189
+ // [LAW:one-source-of-truth] `noun` ("state"/"config") names the keyspace in
190
+ // every thrown message — this function is the SAME merge both
191
+ // deriveActionValidators (state-validators.ts) and deriveConfigActionValidators
192
+ // (config-validators.ts) call, so a conflict thrown while merging a `persist`
193
+ // action's contributions must say "config", never the SessionState-era
194
+ // "state" wording (or the operator debugging a persist action gets pointed at
195
+ // the wrong keyspace's mental model).
196
+ export function mergeKeySpecs(
197
+ key: string,
198
+ specs: readonly DerivedValidatorSpec[],
199
+ noun: string = "state",
200
+ ): DerivedValidatorSpec {
201
+ type Range = Extract<DerivedValidatorSpec, { kind: "range" }>;
202
+ const ranges = specs.filter((s): s is Range => s.kind === "range");
203
+ const hasInt = specs.some((s) => s.kind === "int");
204
+ const allowed = specs.flatMap((s) =>
205
+ s.kind === "allow-list" ? s.allowed : [],
206
+ );
207
+ if (ranges.length === 0 && !hasInt) {
208
+ return { kind: "allow-list", allowed: [...new Set(allowed)] };
209
+ }
210
+ const nonInt = allowed.filter((v) => !INT_RE.test(v));
211
+ if (nonInt.length > 0) {
212
+ throw new Error(
213
+ `${noun} action table: key "${key}" is an integer spec (a paged ` +
214
+ `cursor or a bounded value) but a click writes non-integer ` +
215
+ `value(s) to it (${nonInt.join(", ")}). A ${noun} key has one key ` +
216
+ `shape — point that click at a distinct key, or write an integer.`,
217
+ );
218
+ }
219
+ if (hasInt && ranges.length > 0) {
220
+ throw new Error(
221
+ `${noun} action table: key "${key}" is declared as both a paged ` +
222
+ `cursor (int) and a bounded value (range) — a ${noun} key has one key ` +
223
+ `shape. Use distinct keys.`,
224
+ );
225
+ }
226
+ if (ranges.length > 0) {
227
+ const min = Math.min(...ranges.map((r) => r.min));
228
+ const max = Math.max(...ranges.map((r) => r.max));
229
+ const outOfRange = allowed.filter((v) => {
230
+ const n = parseInt(v, 10);
231
+ return n < min || n > max;
232
+ });
233
+ if (outOfRange.length > 0) {
234
+ throw new Error(
235
+ `${noun} action table: key "${key}" is a bounded range [${min},${max}] ` +
236
+ `but a click writes out-of-range value(s) to it ` +
237
+ `(${outOfRange.join(", ")}). The range gate would clamp them, storing a ` +
238
+ `different value than the click renders — write an in-range integer, ` +
239
+ `or point that click at a distinct key.`,
240
+ );
241
+ }
242
+ const seed = clampSeed(ranges[0]!.seed, min, max);
243
+ return { kind: "range", min, max, seed };
244
+ }
245
+ return { kind: "int" };
246
+ }
247
+
248
+ // [LAW:one-source-of-truth] The click-wire verb name a keyspace's writes
249
+ // travel over — "set-state" for the SessionState keyspace, "set-config" for
250
+ // config-overrides. Mirrors loader/actions.ts's wireName (same concept, the
251
+ // loader's discriminator vocabulary is "set"/"persist" instead of
252
+ // "state"/"config").
253
+ function wireForNoun(noun: string): string {
254
+ return noun === "config" ? "set-config" : "set-state";
255
+ }
256
+
257
+ // [LAW:types-are-the-program] The validator is RESIDUE of a SETTLED spec:
258
+ // given one merged spec, its validator is forced. Pure projection — kind ⇒
259
+ // constructor — with NO union or widen of its own. `noun` ("state"/"config")
260
+ // is threaded through so the SAME shared projection labels a rejection
261
+ // message with the keyspace it actually belongs to — this is the one place
262
+ // that builds every validator, so it is the one place that can misname the
263
+ // keyspace if the noun doesn't ride along.
264
+ function validatorForSpec(
265
+ key: string,
266
+ spec: DerivedValidatorSpec,
267
+ noun: string,
268
+ ): KeyValidator {
269
+ if (spec.kind === "int") return makeIntValidator(`menu page "${key}"`);
270
+ if (spec.kind === "range")
271
+ return makeRangeValidator(spec.min, spec.max, `${noun} stepper "${key}"`);
272
+ return makeAllowListValidator(
273
+ spec.allowed,
274
+ `${noun} "${key}"`,
275
+ wireForNoun(noun),
276
+ );
277
+ }
278
+
279
+ function buildValidatorFromSpecs(
280
+ key: string,
281
+ specs: readonly DerivedValidatorSpec[],
282
+ noun: string,
283
+ ): KeyValidator {
284
+ return validatorForSpec(key, mergeKeySpecs(key, specs, noun), noun);
285
+ }
286
+
287
+ // [LAW:single-enforcer] THE coherence merge: group every contribution by key
288
+ // and collapse each key's specs into the one spec that gates it. `noun`
289
+ // names the keyspace (default "state" — the original, sole caller before
290
+ // config-validators.ts's twin) so a conflict thrown mid-merge for a
291
+ // `persist` action's contributions names the config keyspace, not state.
292
+ export function mergeContributions(
293
+ contributions: readonly KeySpecContribution[],
294
+ noun: string = "state",
295
+ ): KeySpecContribution[] {
296
+ const byKey = new Map<string, DerivedValidatorSpec[]>();
297
+ for (const { key, spec } of contributions) {
298
+ const specs = byKey.get(key);
299
+ if (specs) specs.push(spec);
300
+ else byKey.set(key, [spec]);
301
+ }
302
+ return [...byKey].map(([key, specs]) => ({
303
+ key,
304
+ spec: mergeKeySpecs(key, specs, noun),
305
+ }));
306
+ }
307
+
308
+ export interface RangeParams {
309
+ readonly min: number;
310
+ readonly max: number;
311
+ readonly seed: number;
312
+ }
313
+
314
+ interface BaselineEntry {
315
+ readonly permanent: true;
316
+ readonly validator: KeyValidator;
317
+ }
318
+ interface DerivedEntry {
319
+ readonly permanent: false;
320
+ readonly kind: DerivedValidatorSpec["kind"];
321
+ validator: KeyValidator;
322
+ readonly specs: DerivedValidatorSpec[];
323
+ }
324
+ type ValidatorEntry = BaselineEntry | DerivedEntry;
325
+
326
+ export interface ValidatorRegistry {
327
+ register(key: string, spec: DerivedValidatorSpec): () => void;
328
+ validate(key: string, rawValue: string): ValidateResult;
329
+ listKeys(): readonly string[];
330
+ // [LAW:one-source-of-truth] The permanent/baseline subset of listKeys() —
331
+ // exposed so a consumer that needs to distinguish "derived from an action
332
+ // table" from "always writable" (e.g. dropping an allow-list contribution
333
+ // aimed at a baseline key) reads it from the registry that owns the
334
+ // distinction, rather than re-declaring the baseline set as a second list.
335
+ listBaselineKeys(): readonly string[];
336
+ rangeParamsFor(key: string): RangeParams | null;
337
+ }
338
+
339
+ // [LAW:one-type-per-behavior] ONE registry implementation, instantiated once
340
+ // per keyspace. `baseline` seeds PERMANENT entries (raw KeyValidator
341
+ // functions, never re-claimable — SessionState's legacy theme/style/
342
+ // toolbar-expanded); an empty baseline (config-overrides' keyspace) means
343
+ // every key is fully derived from the action table, exactly the epic's
344
+ // "zero engine edits to add a menu-able field" goal. `noun` names the
345
+ // keyspace in every message ("state"/"config") so the two instances stay
346
+ // operator-distinguishable — a "state key" and "config key" error can never
347
+ // be confused for the other keyspace's gate.
348
+ //
349
+ // [LAW:no-silent-fallbacks] An unknown key is a caller-visible rejection
350
+ // (validate) or a loud throw (a baseline re-claim, a kind clash) — never a
351
+ // silent accept-and-store.
352
+ export function createValidatorRegistry(
353
+ baseline: Readonly<Record<string, KeyValidator>>,
354
+ noun: string = "state",
355
+ ): ValidatorRegistry {
356
+ const entries = new Map<string, ValidatorEntry>(
357
+ Object.entries(baseline).map(([key, validator]) => [
358
+ key,
359
+ { validator, permanent: true } as const,
360
+ ]),
361
+ );
362
+
363
+ function baselineKeys(): readonly string[] {
364
+ const out: string[] = [];
365
+ for (const [key, entry] of entries) if (entry.permanent) out.push(key);
366
+ return out;
367
+ }
368
+
369
+ return {
370
+ register(key, spec) {
371
+ if (!key) throw new Error("register: key is required");
372
+ // [LAW:types-are-the-program] The set-state wire splits its tail on
373
+ // `/`, so a slash-bearing key can never be addressed — listing it
374
+ // would be registry-vs-wire drift. Reject at registration so the
375
+ // unreachable-but-listed state is unrepresentable.
376
+ if (key.includes("/")) {
377
+ throw new Error(
378
+ `register: key "${key}" contains "/" — the wire shape splits on ` +
379
+ `"/" so a slash-bearing key cannot be addressed. Use a slash-free key.`,
380
+ );
381
+ }
382
+ const existing = entries.get(key);
383
+ if (existing) {
384
+ if (existing.permanent) {
385
+ throw new Error(
386
+ `register: key "${key}" is a built-in ${noun} key and cannot be ` +
387
+ `re-claimed (built-in keys: ${[...baselineKeys()].join(", ")})`,
388
+ );
389
+ }
390
+ if (existing.kind !== spec.kind) {
391
+ throw new Error(
392
+ `register: key "${key}" is already a ${existing.kind} ${noun} key; ` +
393
+ `cannot also register it as ${spec.kind}. A ${noun} key has one ` +
394
+ `key shape — a menu page index (int) and a button allow-list ` +
395
+ `cannot share a key.`,
396
+ );
397
+ }
398
+ existing.specs.push(spec);
399
+ existing.validator = buildValidatorFromSpecs(key, existing.specs, noun);
400
+ } else {
401
+ const specs = [spec];
402
+ entries.set(key, {
403
+ permanent: false,
404
+ kind: spec.kind,
405
+ validator: buildValidatorFromSpecs(key, specs, noun),
406
+ specs,
407
+ });
408
+ }
409
+ let active = true;
410
+ return () => {
411
+ if (!active) return;
412
+ active = false;
413
+ const entry = entries.get(key);
414
+ if (!entry || entry.permanent) return;
415
+ const i = entry.specs.indexOf(spec);
416
+ if (i >= 0) entry.specs.splice(i, 1);
417
+ if (entry.specs.length === 0) {
418
+ entries.delete(key);
419
+ } else {
420
+ entry.validator = buildValidatorFromSpecs(key, entry.specs, noun);
421
+ }
422
+ };
423
+ },
424
+
425
+ validate(key, rawValue) {
426
+ const entry = entries.get(key);
427
+ if (!entry) {
428
+ return {
429
+ ok: false,
430
+ reason: `unknown ${noun} key "${key}" (have: ${[...entries.keys()].join(", ")})`,
431
+ };
432
+ }
433
+ return entry.validator(rawValue);
434
+ },
435
+
436
+ listKeys() {
437
+ return [...entries.keys()];
438
+ },
439
+
440
+ listBaselineKeys() {
441
+ return baselineKeys();
442
+ },
443
+
444
+ rangeParamsFor(key) {
445
+ const entry = entries.get(key);
446
+ if (!entry || entry.permanent || entry.kind !== "range") return null;
447
+ const spec = mergeKeySpecs(key, entry.specs, noun);
448
+ if (spec.kind !== "range") {
449
+ throw new Error(
450
+ `rangeParamsFor: key "${key}" holds range specs but the merge ` +
451
+ `produced a ${spec.kind} spec — the entry-kind invariant is broken.`,
452
+ );
453
+ }
454
+ return { min: spec.min, max: spec.max, seed: spec.seed };
455
+ },
456
+ };
457
+ }
package/src/demo/dsl.ts CHANGED
@@ -34,8 +34,9 @@ import {
34
34
  effectiveThemeName,
35
35
  effectiveLookName,
36
36
  lookKeyByName,
37
- resolverForThemeName,
37
+ paletteForThemeName,
38
38
  } from "../themes/index.js";
39
+ import { effectivePresetName, presetGlobals } from "../config/presets.js";
39
40
  import { registerDslConfig, renderDsl } from "../dsl/render.js";
40
41
  import {
41
42
  DEFAULT_CHARSET,
@@ -77,16 +78,22 @@ const payload = {
77
78
  },
78
79
  };
79
80
 
80
- // The demo has no SessionState; the effective theme is just the config default.
81
- const basePalette = resolverForThemeName(
82
- effectiveThemeName(null, config.globals.palette),
81
+ // The demo has no SessionState, so every resolution below is the config default
82
+ // over its floor. The PRESET resolves first — its fragment supplies the display
83
+ // globals every other option reads — the same preset-first order server.ts and
84
+ // check.ts resolve in, so the demo prints the arrangement a fresh session opens
85
+ // in.
86
+ const preset = effectivePresetName(null, config.globals.preset, config.presets);
87
+ const globals = presetGlobals(config, preset);
88
+ const basePalette = paletteForThemeName(
89
+ effectiveThemeName(null, globals.palette),
83
90
  );
84
91
  // Same fresh-session resolution one dimension over: the config-default look
85
92
  // over the "none" identity floor — the exact mirror of the daemon's per-render
86
93
  // effectiveLookName → lookKeyByName chain.
87
94
  const lookKey = lookKeyByName(
88
95
  config.looks,
89
- effectiveLookName(null, config.globals.look, config.looks),
96
+ effectiveLookName(null, globals.look, config.looks),
90
97
  );
91
98
 
92
99
  // A fresh store + registry for this run. (A hot-reloading daemon would
@@ -125,7 +132,7 @@ try {
125
132
  // Same resolution the daemon applies: the config global over the
126
133
  // truecolor default floor.
127
134
  colorCompatibility:
128
- config.globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
135
+ globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
129
136
  // [LAW:one-source-of-truth] Demo applies the same Claude-Code-UI
130
137
  // reserve the daemon does so demo output matches the bytes a real
131
138
  // statusline would emit at the same terminal width.
@@ -134,12 +141,12 @@ try {
134
141
  ),
135
142
  // Same resolution the daemon applies: the config global over the
136
143
  // default-on floor.
137
- wrap: config.globals.autoWrap ?? DEFAULT_WRAP,
138
- padding: config.globals.padding ?? DEFAULT_PADDING,
139
- charset: config.globals.charset ?? DEFAULT_CHARSET,
144
+ wrap: globals.autoWrap ?? DEFAULT_WRAP,
145
+ padding: globals.padding ?? DEFAULT_PADDING,
146
+ charset: globals.charset ?? DEFAULT_CHARSET,
140
147
  },
141
148
  undefined,
142
- lookKey,
149
+ { look: lookKey, preset },
143
150
  );
144
151
  process.stdout.write(` ${line}\n`);
145
152
  if (frame < FRAMES - 1) await sleep(FRAME_INTERVAL_MS);
@@ -23,7 +23,7 @@
23
23
  // strip item), not a function of matching backgrounds.
24
24
 
25
25
  import { RichText, IDENTITY } from "@promptctl/rich-js";
26
- import type { PaletteResolver, Style, ThemeKey } from "@promptctl/rich-js";
26
+ import type { Palette, Style, ThemeKey } from "@promptctl/rich-js";
27
27
  import type { Template } from "@promptctl/go-template-js";
28
28
  import type {
29
29
  LayoutNode,
@@ -31,12 +31,11 @@ import type {
31
31
  SegmentDecl,
32
32
  } from "../config/dsl-types.js";
33
33
  import { splitCellsIntoLines } from "../render/split-lines.js";
34
- import { transposedResolver } from "../themes/index.js";
34
+ import { transposedPalette } from "../themes/index.js";
35
35
  import {
36
36
  fragmentsToCells,
37
37
  evaluateWhen,
38
38
  applySegmentLayout,
39
- resolveSegmentColors,
40
39
  } from "../template-engine/index.js";
41
40
 
42
41
  // ─── Compiled node shapes ──────────────────────────────────────────────────────
@@ -66,7 +65,7 @@ export interface CompiledSegment {
66
65
  readonly template: Template<RichText>;
67
66
  readonly bg?: Template<RichText>;
68
67
  readonly fg?: Template<RichText>;
69
- readonly paletteResolver?: PaletteResolver;
68
+ readonly palette?: Palette;
70
69
  }
71
70
  export type CompiledSegments = Readonly<Record<string, CompiledSegment>>;
72
71
 
@@ -99,7 +98,7 @@ export interface NodeCompileCtx {
99
98
  // (the driver ANDs node.when with the parent's). renderChild continues the walk.
100
99
  export interface NodeRenderCtx {
101
100
  readonly scope: object;
102
- readonly basePalette: PaletteResolver;
101
+ readonly basePalette: Palette;
103
102
  // [LAW:one-source-of-truth] The render-wide look (the session's chosen
104
103
  // theme-adaptation, resolved by the caller via effectiveLookName →
105
104
  // lookKeyByName), threaded by the driver — one ThemeKey per render, IDENTITY
@@ -121,15 +120,28 @@ export interface NodeRenderCtx {
121
120
  // text verdict instead of blessing a bar it cannot see. Trusted non-throwing
122
121
  // (the registry-dispose contract) — see RenderObservers.onSegmentError.
123
122
  readonly onSegmentError?: (segName: string, message: string) => void;
124
- // [LAW:locality-or-seam] The menu seam, injected as capabilities so this module
125
- // never imports the menu feature. `beginSegment` runs BEFORE a segment template
126
- // evaluates: it publishes the segment name so a `{{ menu }}` can derive its
127
- // identity. `collectDrops` runs AFTER eval: it reads the open menu bodies the
128
- // menus carried as metadata on the evaluated fragments (template order) for the
129
- // boundary to stack below the row, and clears the published placement. The
130
- // driver owns the menu runtime; this module only hands it the fragments.
131
- beginSegment(segName: string): void;
132
- collectDrops(fragments: readonly RichText[]): readonly RichText[];
123
+ // [LAW:locality-or-seam] The segment seam, injected as a capability pair so
124
+ // this module never imports the menu or color features it only says when a
125
+ // segment starts and stops.
126
+ //
127
+ // `enterSegment` runs BEFORE any of the segment's templates evaluate. It
128
+ // publishes what the segment's own templates may ask about themselves the
129
+ // name a `{{ menu }}` derives its identity from, the palette `{{ color }}`
130
+ // resolves against, the background `{{ bgOf }}` returns — and resolves the
131
+ // segment's `bg:`/`fg:` into its base Style along the way. The bg/fg
132
+ // resolution HAS to happen here rather than after the body: a body asking for
133
+ // its own background can only be answered once the background exists.
134
+ //
135
+ // `exitSegment` runs AFTER eval: it reads the open menu bodies the menus
136
+ // carried as metadata on the evaluated fragments (template order) for the
137
+ // boundary to stack below the row, and tears the published record down.
138
+ enterSegment(
139
+ segName: string,
140
+ palette: Palette,
141
+ bgTemplate: Template<RichText> | undefined,
142
+ fgTemplate: Template<RichText> | undefined,
143
+ ): Style;
144
+ exitSegment(fragments: readonly RichText[]): readonly RichText[];
133
145
  // [LAW:locality-or-seam] The focus transform, injected as a capability (rich-js
134
146
  // owns the color math — see render.ts). Applied to the segment's baseStyle when
135
147
  // it has an open menu (it contributed drops), so the whole focused segment —
@@ -261,39 +273,42 @@ const segmentType: NodeType<"segment"> = {
261
273
  try {
262
274
  if (!evaluateWhen(segCompiled.when, ctx.scope)) return [];
263
275
 
264
- // [LAW:single-enforcer] Publish the segment name + clear the drop sink
265
- // BEFORE evaluating the template — a `{{ menu }}` inside reads the name to
266
- // derive its identity and contributes its open body to the sink.
267
- ctx.beginSegment(node.name);
268
- const fragments = segCompiled.template.evaluate(ctx.scope);
269
- // [LAW:decomposition] The open menu bodies, carried as out-of-band metadata
270
- // on the evaluated fragments — invisible to the inline render, so a menu can
271
- // sit anywhere in the template and content after it stays inline. Each
272
- // becomes one full-width line stacked below the segment's row.
273
- const drops = ctx.collectDrops(fragments);
274
-
275
276
  // [LAW:dataflow-not-control-flow] The per-segment variability is WHICH
276
- // palette — the base resolver (per-segment override or basePalette)
277
+ // palette — the base palette (per-segment override or basePalette)
277
278
  // transposed by the render's look + this segment's hueShift, folded into
278
279
  // ONE ThemeKey for a SINGLE transposePalette call (chaining two
279
280
  // transpositions would double-pay OKLCH quantization and collide the
280
- // transpose memo — see transposedResolver). bg and fg then resolve from
281
- // this one palette. An explicit per-segment `palette:` pin IGNORES the
282
- // look, exactly as it ignores the session theme: the pin's presence is
283
- // the discriminator, and its arm carries the identity look — a value
284
- // choice, not a skipped operation.
285
- const lookKey =
286
- segCompiled.paletteResolver !== undefined ? IDENTITY : ctx.look;
287
- const resolver = transposedResolver(
288
- segCompiled.paletteResolver ?? ctx.basePalette,
289
- { ...lookKey, hueShift: lookKey.hueShift + hueShift },
281
+ // transpose memo — see transposedPalette). An explicit per-segment
282
+ // `palette:` pin IGNORES the look, exactly as it ignores the session
283
+ // theme: the pin's presence is the discriminator, and its arm carries the
284
+ // identity look a value choice, not a skipped operation.
285
+ const lookKey = segCompiled.palette !== undefined ? IDENTITY : ctx.look;
286
+ const palette = transposedPalette(
287
+ segCompiled.palette ?? ctx.basePalette,
288
+ {
289
+ ...lookKey,
290
+ hueShift: lookKey.hueShift + hueShift,
291
+ },
290
292
  );
291
- const resolvedStyle = resolveSegmentColors(
292
- resolver,
293
+
294
+ // [LAW:one-source-of-truth] ONE palette for this segment: its `bg:`, its
295
+ // `fg:`, and every `{{ color }}` in its body resolve from this same
296
+ // object. That is the whole reason the segment is entered before its body
297
+ // evaluates rather than after — a body coloured from a palette resolved
298
+ // independently of the cell it sits in is two palettes in one segment,
299
+ // and they diverge the moment a theme, look, or hue shift moves.
300
+ const resolvedStyle = ctx.enterSegment(
301
+ node.name,
302
+ palette,
293
303
  segCompiled.bg,
294
304
  segCompiled.fg,
295
- ctx.scope,
296
305
  );
306
+ const fragments = segCompiled.template.evaluate(ctx.scope);
307
+ // [LAW:decomposition] The open menu bodies, carried as out-of-band metadata
308
+ // on the evaluated fragments — invisible to the inline render, so a menu can
309
+ // sit anywhere in the template and content after it stays inline. Each
310
+ // becomes one full-width line stacked below the segment's row.
311
+ const drops = ctx.exitSegment(fragments);
297
312
  // [LAW:dataflow-not-control-flow] Focus is the PRESENCE of a drop: a segment
298
313
  // with an open menu (it contributed a body) lightens, so the whole focused
299
314
  // segment — inline trigger + dropped band — reads as highlighted. No state