@promptctl/cc-candybar 1.20.0 → 1.22.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.
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import process from "node:process";
3
3
 
4
+ import type { ProcessIdentity } from "./process-fingerprint";
5
+
4
6
  // ─── Socket ownership lease ──────────────────────────────────────────────────
5
7
  //
6
8
  // [LAW:one-source-of-truth] The authority for "who owns this socket path" is a
@@ -30,7 +32,7 @@ import process from "node:process";
30
32
  export type LeaseRead =
31
33
  | { kind: "absent" }
32
34
  | { kind: "unreadable"; detail: string }
33
- | { kind: "owned"; pid: number; startTime: string | null };
35
+ | ({ kind: "owned" } & ProcessIdentity);
34
36
 
35
37
  // The EADDRINUSE arbitration outcome. The path already exists (something bound
36
38
  // it or a stale file remains); this says whether a LIVE owner holds it.
@@ -44,11 +46,9 @@ export type SocketArbitration =
44
46
  // root. `startTime` is the kernel start-time token (also human-readable, so it
45
47
  // doubles as the "daemon started at" diagnostic the old `startedAt` gave), or
46
48
  // null when this host could not fingerprint.
47
- export interface LeaseRecord {
48
- pid: number;
49
+ export interface LeaseRecord extends ProcessIdentity {
49
50
  version: number;
50
51
  binPath: string | undefined;
51
- startTime: string | null;
52
52
  }
53
53
 
54
54
  // [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] The whole
@@ -431,11 +431,18 @@ export function makeRangeValidator(
431
431
  }
432
432
 
433
433
  // [LAW:one-source-of-truth] The option members a picker draws from ARE the same
434
- // canonical lists the `themes()`/`styles()` bindings and the baseline theme/
435
- // style validators consult — the rendered options and the derived gate cannot
436
- // diverge because there is no second enumeration.
437
- function optionValuesFor(src: OptionSource): readonly string[] {
438
- return src === "themes" ? RESOLVABLE_THEMES_LIST : STRIP_STYLES;
434
+ // canonical lists the `themes()`/`styles()`/`looks()` bindings and the baseline
435
+ // theme/style validators consult — the rendered options and the derived gate
436
+ // cannot diverge because there is no second enumeration. "looks" is the one
437
+ // per-config domain: its names come from the config's merged looks block,
438
+ // threaded in as data (the render-side optionDomain takes the same list).
439
+ function optionValuesFor(
440
+ src: OptionSource,
441
+ lookNames: readonly string[],
442
+ ): readonly string[] {
443
+ if (src === "themes") return RESOLVABLE_THEMES_LIST;
444
+ if (src === "styles") return STRIP_STYLES;
445
+ return lookNames;
439
446
  }
440
447
 
441
448
  // [LAW:types-are-the-program] Collapse one key's spec contributions into the
@@ -564,6 +571,7 @@ function mergeContributions(
564
571
  function actionKeySpecs(
565
572
  a: ActionDecl,
566
573
  seeds: ReadonlyMap<string, number>,
574
+ lookNames: readonly string[],
567
575
  ): KeySpecContribution[] {
568
576
  if (!("set" in a)) return [];
569
577
  if ("to" in a) {
@@ -573,7 +581,10 @@ function actionKeySpecs(
573
581
  return [
574
582
  {
575
583
  key: a.set,
576
- spec: { kind: "allow-list", allowed: optionValuesFor(a.from) },
584
+ spec: {
585
+ kind: "allow-list",
586
+ allowed: optionValuesFor(a.from, lookNames),
587
+ },
577
588
  },
578
589
  ];
579
590
  }
@@ -635,8 +646,11 @@ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
635
646
  // realizes a click from are the gate the wire enforces.
636
647
  function actionContributions(config: DslConfig): KeySpecContribution[] {
637
648
  const seeds = stateKeySeeds(config);
649
+ const lookNames = Object.keys(config.looks);
638
650
  return dropBaselineAllowLists(
639
- Object.values(config.actions).flatMap((a) => actionKeySpecs(a, seeds)),
651
+ Object.values(config.actions).flatMap((a) =>
652
+ actionKeySpecs(a, seeds, lookNames),
653
+ ),
640
654
  );
641
655
  }
642
656
 
package/src/demo/dsl.ts CHANGED
@@ -29,7 +29,12 @@ import { VariableStore } from "../var-system/store.js";
29
29
  import { SourceRegistry } from "../var-system/sources.js";
30
30
  import { SessionState } from "../daemon/session-state.js";
31
31
  import { listResolvablePaletteNames } from "../themes/policy.js";
32
- import { effectiveThemeName, resolverForThemeName } from "../themes/index.js";
32
+ import {
33
+ effectiveThemeName,
34
+ effectiveLookName,
35
+ lookKeyByName,
36
+ resolverForThemeName,
37
+ } from "../themes/index.js";
33
38
  import { registerDslConfig, renderDsl } from "../dsl/render.js";
34
39
  import {
35
40
  DEFAULT_CHARSET,
@@ -75,6 +80,13 @@ const payload = {
75
80
  const basePalette = resolverForThemeName(
76
81
  effectiveThemeName(null, config.globals.palette),
77
82
  );
83
+ // Same fresh-session resolution one dimension over: the config-default look
84
+ // over the "none" identity floor — the exact mirror of the daemon's per-render
85
+ // effectiveLookName → lookKeyByName chain.
86
+ const lookKey = lookKeyByName(
87
+ config.looks,
88
+ effectiveLookName(null, config.globals.look, config.looks),
89
+ );
78
90
 
79
91
  // A fresh store + registry for this run. (A hot-reloading daemon would
80
92
  // dispose() the old pair and build new ones — see registerDslConfig's docs.)
@@ -125,6 +137,8 @@ try {
125
137
  padding: config.globals.padding ?? DEFAULT_PADDING,
126
138
  charset: config.globals.charset ?? DEFAULT_CHARSET,
127
139
  },
140
+ undefined,
141
+ lookKey,
128
142
  );
129
143
  process.stdout.write(` ${line}\n`);
130
144
  if (frame < FRAMES - 1) await sleep(FRAME_INTERVAL_MS);
@@ -22,8 +22,8 @@
22
22
  // carries NO structural meaning — unit cohesion is structural (one segment = one
23
23
  // strip item), not a function of matching backgrounds.
24
24
 
25
- import { RichText } from "@promptctl/rich-js";
26
- import type { PaletteResolver, Style } from "@promptctl/rich-js";
25
+ import { RichText, IDENTITY } from "@promptctl/rich-js";
26
+ import type { PaletteResolver, Style, ThemeKey } from "@promptctl/rich-js";
27
27
  import type { Template } from "@promptctl/go-template-js";
28
28
  import type {
29
29
  LayoutNode,
@@ -100,6 +100,12 @@ export interface NodeCompileCtx {
100
100
  export interface NodeRenderCtx {
101
101
  readonly scope: object;
102
102
  readonly basePalette: PaletteResolver;
103
+ // [LAW:one-source-of-truth] The render-wide look (the session's chosen
104
+ // theme-adaptation, resolved by the caller via effectiveLookName →
105
+ // lookKeyByName), threaded by the driver — one ThemeKey per render, IDENTITY
106
+ // when no look is chosen. Composed with the per-segment hue shift into ONE
107
+ // transposition key at the segment leaf.
108
+ readonly look: ThemeKey;
103
109
  readonly visible: boolean;
104
110
  // [LAW:one-source-of-truth] The render-wide intra-cell padding (resolved
105
111
  // globals.padding), threaded by the driver from BuildLineOptions into every
@@ -108,6 +114,13 @@ export interface NodeRenderCtx {
108
114
  // Advance the walk-owned hue cursor by one unit and return that unit's shift.
109
115
  nextHueShift(): number;
110
116
  readonly perSegmentSink?: Map<string, readonly RichText[]>;
117
+ // [LAW:no-silent-failure] Optional observer for the per-segment render catch
118
+ // below: a caught evaluation error renders as a visible ⚠ error cell (partial
119
+ // rendering — the daemon's channel), AND is reported here so a headless caller
120
+ // (`cc-candybar check`, a blind authoring agent's eyes) can turn it into a
121
+ // text verdict instead of blessing a bar it cannot see. Trusted non-throwing
122
+ // (the registry-dispose contract) — see RenderObservers.onSegmentError.
123
+ readonly onSegmentError?: (segName: string, message: string) => void;
111
124
  // [LAW:locality-or-seam] The menu seam, injected as capabilities so this module
112
125
  // never imports the menu feature. `beginSegment` runs BEFORE a segment template
113
126
  // evaluates: it publishes the segment name so a `{{ menu }}` can derive its
@@ -261,10 +274,19 @@ const segmentType: NodeType<"segment"> = {
261
274
 
262
275
  // [LAW:dataflow-not-control-flow] The per-segment variability is WHICH
263
276
  // palette — the base resolver (per-segment override or basePalette)
264
- // transposed by hueShift. bg and fg then resolve from this one palette.
277
+ // transposed by the render's look + this segment's hueShift, folded into
278
+ // ONE ThemeKey for a SINGLE transposePalette call (chaining two
279
+ // 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;
265
287
  const resolver = transposedResolver(
266
288
  segCompiled.paletteResolver ?? ctx.basePalette,
267
- hueShift,
289
+ { ...lookKey, hueShift: lookKey.hueShift + hueShift },
268
290
  );
269
291
  const resolvedStyle = resolveSegmentColors(
270
292
  resolver,
@@ -312,13 +334,9 @@ const segmentType: NodeType<"segment"> = {
312
334
  }
313
335
  return laidLines;
314
336
  } catch (err) {
315
- return [
316
- [
317
- new RichText(
318
- `⚠ ${node.name}: ${(err as Error).message ?? String(err)}`,
319
- ),
320
- ],
321
- ];
337
+ const message = (err as Error).message ?? String(err);
338
+ ctx.onSegmentError?.(node.name, message);
339
+ return [[new RichText(`⚠ ${node.name}: ${message}`)]];
322
340
  }
323
341
  },
324
342
  };
package/src/dsl/render.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  // the input values (kind discriminators, layout length, palette presence)
12
12
  // govern output, not whether operations run.
13
13
 
14
- import type { RichText, PaletteResolver } from "@promptctl/rich-js";
15
- import { ColorSpec, Style, lighten } from "@promptctl/rich-js";
14
+ import type { RichText, PaletteResolver, ThemeKey } from "@promptctl/rich-js";
15
+ import { ColorSpec, Style, lighten, IDENTITY } from "@promptctl/rich-js";
16
16
  import type { Engine, Template } from "@promptctl/go-template-js";
17
17
  import type {
18
18
  ValidatedConfig,
@@ -304,12 +304,23 @@ export function registerDslConfig(
304
304
  action: actionRuntime,
305
305
  current: null,
306
306
  };
307
+ // [LAW:one-source-of-truth] The config's look names — the one PER-CONFIG
308
+ // option domain. Computed once here and fed to BOTH consumers (the compiled
309
+ // set-option domains below and the `looks()` binding), so the rendered
310
+ // options, a hand-authored `range looks`, and the derived click gate (which
311
+ // reads the same config in deriveActionValidators) trace to one map.
312
+ const lookNames = Object.keys(config.looks);
307
313
  const engine = createCcCandybarEngine(
308
314
  undefined,
309
315
  {
310
316
  ...actionFuncs(actionRuntime),
311
317
  ...pickerFuncs(actionRuntime),
312
318
  ...menuFuncs(menuRuntime),
319
+ // [LAW:one-type-per-behavior] The per-config sibling of the static
320
+ // themes()/styles() bindings (template-engine/funcs.ts): zero-arg
321
+ // projection of the "looks" option domain. Injected here — not in the
322
+ // static FuncMap — because the domain is this config's looks block.
323
+ looks: { fn: () => lookNames, argTypes: [] },
313
324
  },
314
325
  opts?.clock,
315
326
  );
@@ -348,7 +359,12 @@ export function registerDslConfig(
348
359
  // [LAW:one-source-of-truth] Actions resolve their set key → the reading
349
360
  // variable through the stateKeyToVar map, so an apply action and the picker
350
361
  // that references it read one value.
351
- actionRuntime.compiled = compileActions(parse, config.actions, stateKeyToVar);
362
+ actionRuntime.compiled = compileActions(
363
+ parse,
364
+ config.actions,
365
+ stateKeyToVar,
366
+ lookNames,
367
+ );
352
368
 
353
369
  // [LAW:dataflow-not-control-flow] One variable failing to declare does not
354
370
  // abort the rest. Errors are data (accumulated in loadWarnings); the store
@@ -501,14 +517,11 @@ function focusTint(style: Style): Style {
501
517
  * into nested containers keeps every segment's color; toggling a node's
502
518
  * visibility does not recolor the nodes after it.
503
519
  */
504
- export function renderDsl(
505
- config: ValidatedConfig,
506
- compiled: CompiledConfig,
507
- store: VariableStore,
508
- registry: SourceRegistry,
509
- payload: unknown,
510
- basePalette: PaletteResolver,
511
- opts: BuildLineOptions,
520
+ // [LAW:locality-or-seam] The optional render observers, bundled as ONE named bag
521
+ // so a caller states what it passes by name — no positional tail to count, no
522
+ // `undefined` holes to reach a later observer, and a new observer is one field
523
+ // here rather than a signature change every caller re-counts.
524
+ export interface RenderObservers {
512
525
  // [LAW:dataflow-not-control-flow] Optional per-segment cell sink. When
513
526
  // present, each rendered segment's RichText array (post-layout, pre-
514
527
  // serialization) is written to this map under its segment name. Storing
@@ -521,8 +534,35 @@ export function renderDsl(
521
534
  // joined line (powerline joiners sit *between* segments and have no
522
535
  // place in a one-segment render), but for debug visibility this is the
523
536
  // natural per-segment shape.
524
- perSegmentSink?: Map<string, readonly RichText[]>,
537
+ readonly perSegmentSink?: Map<string, readonly RichText[]>;
538
+ // [LAW:no-silent-failure] Optional observer for per-segment evaluation errors.
539
+ // A failing segment renders as a visible ⚠ error cell (partial rendering, the
540
+ // daemon's author-facing channel) — a headless caller with no one looking at
541
+ // the bar (`cc-candybar check`) passes this to receive the same errors as
542
+ // data and fold them into its text verdict. Trusted non-throwing (the
543
+ // registry-dispose contract): an observer that throws is a caller bug
544
+ // surfaced loudly, never caught and absorbed by the render walk.
545
+ readonly onSegmentError?: (segName: string, message: string) => void;
546
+ }
547
+
548
+ export function renderDsl(
549
+ config: ValidatedConfig,
550
+ compiled: CompiledConfig,
551
+ store: VariableStore,
552
+ registry: SourceRegistry,
553
+ payload: unknown,
554
+ basePalette: PaletteResolver,
555
+ opts: BuildLineOptions,
556
+ observers?: RenderObservers,
557
+ // [LAW:dataflow-not-control-flow] The render's look (the session's chosen
558
+ // theme-adaptation), resolved per render by the caller — effectiveLookName
559
+ // over SessionState/globals, then lookKeyByName — exactly how basePalette
560
+ // resolves. IDENTITY is the domain's own no-adaptation element (the "none"
561
+ // look), so an omitting caller renders unadapted — a true default, not a
562
+ // fallback. Composed with each segment's hue shift into ONE transposition.
563
+ look: ThemeKey = IDENTITY,
525
564
  ): string {
565
+ const { perSegmentSink, onSegmentError } = observers ?? {};
526
566
  // [LAW:one-source-of-truth] Inject the usable width as `term.cols` from the
527
567
  // SAME opts.width the strip wraps to (below), so a width-paginated widget reads
528
568
  // the exact wrap width — never a cached or independently-measured copy. This is
@@ -616,10 +656,12 @@ export function renderDsl(
616
656
  const ctx: NodeRenderCtx = {
617
657
  scope,
618
658
  basePalette,
659
+ look,
619
660
  visible,
620
661
  padding: opts.padding,
621
662
  nextHueShift,
622
663
  perSegmentSink,
664
+ onSegmentError,
623
665
  beginSegment,
624
666
  collectDrops,
625
667
  focusTint,
@@ -107,11 +107,18 @@ export type CompiledActionDecl =
107
107
  export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
108
108
 
109
109
  // [LAW:one-source-of-truth] An option source resolves to the SAME canonical list
110
- // the `themes()`/`styles()` bindings and the derived gate consult — rendered
111
- // options and the gate cannot diverge. The render-side resolver (the daemon's
112
- // validator-derivation has its own that must agree, both reading themes/policy).
113
- export function optionDomain(src: OptionSource): readonly string[] {
114
- return src === "themes" ? listResolvablePaletteNames() : STRIP_STYLES;
110
+ // the `themes()`/`styles()`/`looks()` bindings and the derived gate consult —
111
+ // rendered options and the gate cannot diverge. The render-side resolver (the
112
+ // daemon's validator-derivation has its own that must agree themes/styles from
113
+ // themes/policy, looks from the config's merged look names, threaded in as data
114
+ // because that one domain is per-config, not registry-static).
115
+ export function optionDomain(
116
+ src: OptionSource,
117
+ lookNames: readonly string[],
118
+ ): readonly string[] {
119
+ if (src === "themes") return listResolvablePaletteNames();
120
+ if (src === "styles") return STRIP_STYLES;
121
+ return lookNames;
115
122
  }
116
123
 
117
124
  // [LAW:locality-or-seam] The runtime holder the `action` template function closes
@@ -158,10 +165,13 @@ export function compileActions(
158
165
  parse: (src: string) => Template<RichText>,
159
166
  actions: Readonly<Record<string, ActionDecl>>,
160
167
  stateKeyToVar: ReadonlyMap<string, string>,
168
+ // The config's look names — the one per-config option domain optionDomain
169
+ // resolves from (themes/styles stay registry-static).
170
+ lookNames: readonly string[],
161
171
  ): CompiledActions {
162
172
  const out = new Map<string, CompiledActionDecl>();
163
173
  for (const [name, action] of Object.entries(actions)) {
164
- out.set(name, compileAction(parse, name, action, stateKeyToVar));
174
+ out.set(name, compileAction(parse, name, action, stateKeyToVar, lookNames));
165
175
  }
166
176
  return out;
167
177
  }
@@ -175,6 +185,7 @@ function compileAction(
175
185
  name: string,
176
186
  action: ActionDecl,
177
187
  stateKeyToVar: ReadonlyMap<string, string>,
188
+ lookNames: readonly string[],
178
189
  ): CompiledActionDecl {
179
190
  if ("set" in action) {
180
191
  const stateVar = stateKeyToVar.get(action.set) ?? action.set;
@@ -191,7 +202,7 @@ function compileAction(
191
202
  kind: "set-option",
192
203
  key: action.set,
193
204
  stateVar,
194
- options: [...optionDomain(action.from)],
205
+ options: [...optionDomain(action.from, lookNames)],
195
206
  };
196
207
  }
197
208
  if ("int" in action) {
@@ -6,6 +6,8 @@
6
6
  export {
7
7
  resolvePaletteName,
8
8
  effectiveThemeName,
9
+ effectiveLookName,
10
+ lookKeyByName,
9
11
  effectiveStripStyle,
10
12
  isStripStyle,
11
13
  listResolvablePaletteNames,
@@ -1,18 +1,20 @@
1
1
  // Memoized PaletteResolver construction over rich-js. cc-candybar moves theme
2
- // NAMES and hue shifts (data); rich-js owns every color value operation. Two
3
- // memos live here: a theme name -> base resolver, and a (base, hueShift) ->
2
+ // NAMES and ThemeKey axes (data); rich-js owns every color value operation. Two
3
+ // memos live here: a theme name -> base resolver, and a (base, ThemeKey) ->
4
4
  // transposed resolver. They compose — the per-render base palette feeds the
5
- // per-segment transposition.
5
+ // per-segment transposition (the session look's axes + the segment's hue shift,
6
+ // folded into one key by the caller).
6
7
  //
7
8
  // [LAW:no-shared-mutable-globals] Single owner: this module. Both Maps are pure
8
9
  // memos of pure rich-js functions, keyed by immutable inputs (resolved theme
9
- // name; palette name + hueShift). rich-js palettes are immutable registry
10
- // singletons, so a cached resolver never goes stale. Key spaces are bounded by
11
- // #themes and #themes × #distinct hueShifts (hueShift = segIndex*hueStep,
12
- // segIndex bounded by layout) both small. Shared on purpose: a theme's base
13
- // resolver and its gruvbox+42° transposition are each computed once per process,
14
- // not once per RenderCache entry or per render. Read/written only through the
15
- // two functions below.
10
+ // name; palette name + the four ThemeKey axes). rich-js palettes are immutable
11
+ // registry singletons, so a cached resolver never goes stale. Key spaces are
12
+ // bounded by #themes and #themes × #declared looks × #distinct hueShifts
13
+ // (hueShift = look shift + segIndex*hueStep, segIndex bounded by layout; look
14
+ // axes bounded by the loaded configs' looks blocks) both small. Shared on
15
+ // purpose: a theme's base resolver and its gruvbox+42° transposition are each
16
+ // computed once per process, not once per RenderCache entry or per render.
17
+ // Read/written only through the two functions below.
16
18
 
17
19
  import {
18
20
  PaletteResolver,
@@ -54,32 +56,36 @@ export function resolverForThemeName(name: string): PaletteResolver {
54
56
  }
55
57
 
56
58
  /**
57
- * The PaletteResolver for `base`'s palette transposed by `hueShift` degrees.
59
+ * The PaletteResolver for `base`'s palette transposed by a full ThemeKey — the
60
+ * adapted-resolver constructor: (base resolver, key) → resolver. The caller
61
+ * composes whatever axes it carries (a look's four axes, the per-segment hue
62
+ * shift) into ONE key and this makes ONE transposePalette call — never chain
63
+ * two transpositions: chaining double-pays OKLCH quantization AND collides this
64
+ * memo (a transposed palette keeps the base palette's name, so a re-transposed
65
+ * gruvbox-with-look and plain gruvbox would share cache keys).
58
66
  *
59
- * [LAW:dataflow-not-control-flow] hueShift is data; 0 flows through
60
- * transposePalette's identity fast-path (byte-exact, no round-trip) — no branch
61
- * here. Chroma and lightness are held identity: only hue rotates. rich-js
62
- * hue-locks ANCHORED_ROOTS (error/success/warning), so semantic meaning is
63
- * preserved by construction — no local exemption list to drift.
67
+ * [LAW:dataflow-not-control-flow] The key is data; the identity key flows
68
+ * through transposePalette's isIdentityKey fast-path (byte-exact, no
69
+ * round-trip) no branch here. rich-js hue-locks ANCHORED_ROOTS
70
+ * (error/success/warning), so semantic meaning is preserved by construction —
71
+ * no local exemption list to drift.
64
72
  *
65
- * [LAW:single-enforcer] The sole place a transposed resolver is built. The memo
66
- * miss (undefined) is genuine optionality not-yet-computed not a defended
67
- * invariant.
73
+ * [LAW:single-enforcer] The sole place a transposed resolver is built a
74
+ * future look `roles` remap is additive at this one seam. The memo miss
75
+ * (undefined) is genuine optionality — not-yet-computed — not a defended
76
+ * invariant. [LAW:one-source-of-truth] The cache key carries every axis of the
77
+ * ThemeKey: two keys differing on any axis are distinct palettes.
68
78
  */
69
79
  export function transposedResolver(
70
80
  base: PaletteResolver,
71
- hueShift: number,
81
+ key: ThemeKey,
72
82
  ): PaletteResolver {
73
- const cacheKey = `${base.palette.name} ${hueShift}`;
83
+ const cacheKey =
84
+ `${base.palette.name} ${key.hueShift} ${key.chromaScale} ` +
85
+ `${key.lightnessScale} ${key.lightnessShift}`;
74
86
  const hit = transposeCache.get(cacheKey);
75
87
  if (hit !== undefined) return hit;
76
88
 
77
- const key: ThemeKey = {
78
- hueShift,
79
- chromaScale: 1,
80
- lightnessScale: 1,
81
- lightnessShift: 0,
82
- };
83
89
  const resolver = new PaletteResolver(transposePalette(base.palette, key));
84
90
  transposeCache.set(cacheKey, resolver);
85
91
  return resolver;
@@ -5,7 +5,11 @@
5
5
  // PaletteResolver, no ColorRgba, no hex. The semantic/anchor knowledge
6
6
  // (which tokens keep their hue) stays in rich-js (ANCHORED_ROOTS), not here.
7
7
 
8
- import { listThemePalettes, type ColorSystemSpec } from "@promptctl/rich-js";
8
+ import {
9
+ listThemePalettes,
10
+ type ColorSystemSpec,
11
+ type ThemeKey,
12
+ } from "@promptctl/rich-js";
9
13
 
10
14
  // --- Theme name aliasing ---
11
15
 
@@ -47,6 +51,52 @@ export function listResolvablePaletteNames(): readonly string[] {
47
51
  return [...listThemePalettes(), ...listThemeAliases()];
48
52
  }
49
53
 
54
+ // --- Look (theme-adaptation) identifiers ---
55
+
56
+ // The look name a render should use, as data. [LAW:dataflow-not-control-flow]
57
+ // [LAW:one-type-per-behavior] The exact shape of `effectiveThemeName`, one
58
+ // dimension over: session choice over config default over the "none" floor, no
59
+ // "if the session has a look" branch. Unlike the registry-static theme/style
60
+ // domains, the look domain is PER-CONFIG (the merged `looks` block), so the
61
+ // declared names arrive as data. A value outside them (a stale SessionState
62
+ // entry from a prior config's look vocabulary — config edits can orphan a
63
+ // clicked name the per-config gate once admitted) collapses to "none", which
64
+ // every merged DslConfig carries by construction (the bundled stdlib ships it;
65
+ // merge-by-name cannot remove it) — the same collapse-to-floor that keeps
66
+ // effectiveStripStyle's return honest rather than silently widening.
67
+ export function effectiveLookName(
68
+ sessionLook: string | null,
69
+ globalsLook: string | undefined,
70
+ declaredLooks: Readonly<Record<string, ThemeKey>>,
71
+ ): string {
72
+ const chosen = sessionLook ?? globalsLook ?? "none";
73
+ return Object.prototype.hasOwnProperty.call(declaredLooks, chosen)
74
+ ? chosen
75
+ : "none";
76
+ }
77
+
78
+ // [LAW:single-enforcer] The one place an effective look NAME becomes the
79
+ // ThemeKey a render transposes with. By the time a name reaches here it must be
80
+ // a member: effectiveLookName collapses unknown names to the "none" floor, and
81
+ // mergeWithDefault guarantees the bundled "none" exists in every DslConfig.
82
+ // [LAW:no-defensive-null-guards] the throw is the loud failure for that broken
83
+ // invariant (a hand-built config missing the stdlib), never a silent identity
84
+ // fallback that would hide the drift.
85
+ export function lookKeyByName(
86
+ looks: Readonly<Record<string, ThemeKey>>,
87
+ name: string,
88
+ ): ThemeKey {
89
+ const key = looks[name];
90
+ if (key === undefined) {
91
+ throw new Error(
92
+ `Look "${name}" is not declared in this config — effectiveLookName ` +
93
+ `collapses unknown names to "none", and every merged config carries ` +
94
+ `"none"; a miss here is merge/policy drift`,
95
+ );
96
+ }
97
+ return key;
98
+ }
99
+
50
100
  // --- Powerline strip-style identifiers ---
51
101
 
52
102
  // [LAW:one-source-of-truth][LAW:types-are-the-program] The single canonical set