@promptctl/cc-candybar 1.19.0 → 1.21.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.
@@ -197,6 +197,16 @@ export const DEFAULT_DSL_CONFIG = {
197
197
  path: "theme.effective",
198
198
  default: "",
199
199
  },
200
+ // [LAW:one-type-per-behavior] The effective LOOK name, the exact twin of
201
+ // theme.effective one dimension over — effectiveLookName(sessionState.look,
202
+ // globals.look, looks), the SAME name whose ThemeKey adapts the rendered
203
+ // palette. A look-picker trigger reads `{{ .look.effective }}` for its
204
+ // label; the label and the colors trace to one resolution.
205
+ "look.effective": {
206
+ kind: "input",
207
+ path: "look.effective",
208
+ default: "",
209
+ },
200
210
 
201
211
  // [LAW:one-source-of-truth] The usable terminal width for THIS render —
202
212
  // the exact post-reserve cell count FlexStrip wraps to. renderDsl injects
@@ -887,6 +897,60 @@ export const DEFAULT_DSL_CONFIG = {
887
897
  applyStyle: { set: "style", from: "styles" },
888
898
  },
889
899
 
900
+ // ─── Looks ───────────────────────────────────────────────────────────────
901
+ // Named theme ADAPTATIONS — each is a full rich-js ThemeKey applied on top
902
+ // of whatever base theme is active (a transform, not a palette), so every
903
+ // look composes with every theme: pick theme, then pick look. Selected per
904
+ // session via the `look` SessionState key (an action `{ set: "look", from:
905
+ // "looks" }` + a `{{ menu }}`), exactly the theme/style selection seam.
906
+ // [LAW:one-source-of-truth] Merges by name (user wins per name), so this
907
+ // stdlib — including the "none" identity floor effectiveLookName collapses
908
+ // to — is present in every merged config by construction.
909
+ looks: {
910
+ // [LAW:dataflow-not-control-flow] "none" is just the identity look — the
911
+ // resolution floor as a value, not a special case (rich-js's isIdentityKey
912
+ // fast-path makes it free). Spelled literally (not rich-js IDENTITY /
913
+ // INVERT_LIGHTNESS) so the bundled default remains inert JSON-shaped data
914
+ // a user file can mirror axis-for-axis; the loader normalizes user specs
915
+ // onto the same identity axes.
916
+ none: { hueShift: 0, chromaScale: 1, lightnessScale: 1, lightnessShift: 0 },
917
+ // Saturation up/down — chroma is multiplicative, hue and lightness held.
918
+ vivid: {
919
+ hueShift: 0,
920
+ chromaScale: 1.35,
921
+ lightnessScale: 1,
922
+ lightnessShift: 0,
923
+ },
924
+ muted: {
925
+ hueShift: 0,
926
+ chromaScale: 0.55,
927
+ lightnessScale: 1,
928
+ lightnessShift: 0,
929
+ },
930
+ // Lightness down (scale) / up (shift) — dim compresses toward black,
931
+ // bright lifts everything a step; anchors stay hue-locked by rich-js.
932
+ dim: {
933
+ hueShift: 0,
934
+ chromaScale: 1,
935
+ lightnessScale: 0.85,
936
+ lightnessShift: 0,
937
+ },
938
+ bright: {
939
+ hueShift: 0,
940
+ chromaScale: 1,
941
+ lightnessScale: 1,
942
+ lightnessShift: 0.08,
943
+ },
944
+ // The dark↔light "octave" flip (rich-js INVERT_LIGHTNESS: L' = 1 - L) —
945
+ // errors stay red, dark-on-light becomes light-on-dark.
946
+ inverted: {
947
+ hueShift: 0,
948
+ chromaScale: 1,
949
+ lightnessScale: -1,
950
+ lightnessShift: 1,
951
+ },
952
+ },
953
+
890
954
  // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
891
955
  // for the cost/token/budget family lives here as named template helpers, each
892
956
  // DEFINED ONCE and called from every segment via `{{ template "name" .arg }}`
@@ -50,6 +50,7 @@ import { validateSegments } from "./loader/segments.js";
50
50
  import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
51
51
  import { synthesizeMenuDecls } from "./loader/menu-synth.js";
52
52
  import { validateActions } from "./loader/actions.js";
53
+ import { validateLooks } from "./loader/looks.js";
53
54
  import { validateHelpers } from "./loader/helpers.js";
54
55
  import { validateCrossReferences } from "./loader/cross-ref.js";
55
56
  import { validateNoCycles } from "./loader/cycles.js";
@@ -242,6 +243,7 @@ function validateTopLevel(
242
243
  if (raw.root !== undefined) out.root = validateRoot(ctx, "root", raw.root);
243
244
  if (raw.actions !== undefined)
244
245
  out.actions = validateActions(ctx, raw.actions);
246
+ if (raw.looks !== undefined) out.looks = validateLooks(ctx, raw.looks);
245
247
  if (raw.helpers !== undefined)
246
248
  out.helpers = validateHelpers(ctx, raw.helpers);
247
249
  // [LAW:one-source-of-truth] Group sugar synthesis runs AFTER every section
@@ -269,5 +271,6 @@ const TOP_LEVEL_KEYS = new Set([
269
271
  "layout",
270
272
  "root",
271
273
  "actions",
274
+ "looks",
272
275
  "helpers",
273
276
  ]);
@@ -14,6 +14,12 @@
14
14
  // references it here. The dependency is one-way (this file → action.ts), never
15
15
  // the reverse, so that shape can be lifted out without a cycle.
16
16
  import type { ActionDecl } from "./action.js";
17
+ // [LAW:one-source-of-truth] A look IS a rich-js ThemeKey (four numeric axes:
18
+ // hueShift / chromaScale / lightnessScale / lightnessShift) — the config type
19
+ // references the vocabulary owner's type verbatim, so a rich-js axis rename is
20
+ // a compile error here, never silent drift. Type-only: no runtime rich-js
21
+ // dependency enters the config layer.
22
+ import type { ThemeKey } from "@promptctl/rich-js";
17
23
  import type {
18
24
  Charset,
19
25
  ColorCompatibility,
@@ -129,6 +135,11 @@ export interface RawDslConfig {
129
135
  readonly segments?: Readonly<Record<string, SegmentDecl>>;
130
136
  readonly root?: LayoutNode;
131
137
  readonly actions?: Readonly<Record<string, ActionDecl>>;
138
+ // Named theme-adaptation bundles ("looks"): each is a full ThemeKey (the
139
+ // loader normalizes absent axes to identity at parse). Applied ON TOP of the
140
+ // active theme at render — a transform composing with every theme, selected
141
+ // per session exactly like theme/style (session key `look`).
142
+ readonly looks?: Readonly<Record<string, ThemeKey>>;
132
143
  // [LAW:single-enforcer] Config-level shared helper templates: name → Go-template
133
144
  // body. Each compiles to one `{{ define "name" }}body{{ end }}` block, and the
134
145
  // whole set into a single output-neutral preamble prepended to every template
@@ -153,6 +164,13 @@ export interface DslConfig {
153
164
  // template cannot smuggle an un-gated write. Empty when no config declares
154
165
  // actions — an absent `actions` key merges to `{}`.
155
166
  readonly actions: Readonly<Record<string, ActionDecl>>;
167
+ // [LAW:one-source-of-truth] The effective look set: name → full ThemeKey.
168
+ // Merges by name with the bundled default (user wins per name), like
169
+ // segments/actions/variables — so the default's `none` (the identity look and
170
+ // the resolution floor of effectiveLookName) is present in EVERY merged
171
+ // config by construction. An action `{ set: …, from: "looks" }` ranges these
172
+ // names; the derived click gate and the rendered options read this one map.
173
+ readonly looks: Readonly<Record<string, ThemeKey>>;
156
174
  // [LAW:single-enforcer] The effective helper set: a name → template-body map
157
175
  // compiled to a defines-preamble at registerDslConfig. Empty when no config
158
176
  // declares helpers — an absent `helpers` key merges to `{}` (same cascade as
@@ -186,6 +204,16 @@ export interface Globals {
186
204
  // `palette` is an explicit override that ignores the session theme.
187
205
  readonly palette?: string;
188
206
 
207
+ // [LAW:one-type-per-behavior] The config default for the LOOK (a named
208
+ // theme-adaptation from the `looks` block) — the exact twin of `palette` one
209
+ // dimension over: the daemon resolves the live look per render as
210
+ // `sessionState.look ?? globals.look ?? "none"` (effectiveLookName), so a
211
+ // look click recolors the bar live and a config can set a default adaptation
212
+ // without an edit-per-session. Membership in the merged `looks` map is
213
+ // validated post-merge (cross-ref) — a user's globals.look may name a
214
+ // default-provided look.
215
+ readonly look?: string;
216
+
189
217
  // [LAW:one-type-per-behavior] The config default for the powerline cap/
190
218
  // separator SHAPE — the exact twin of `palette` one dimension over: the
191
219
  // daemon resolves the live strip style per render as
@@ -36,6 +36,20 @@ export function validateCrossReferences(
36
36
  ctx: ValidateCtx,
37
37
  cfg: DslConfig,
38
38
  ): void {
39
+ // [LAW:locality-or-seam] globals.look names a member of the MERGED looks
40
+ // block (a user's default may be a bundled look — same reason every cross-ref
41
+ // runs post-merge). Same existence-check shape as layout→segments; an unknown
42
+ // name is a load error, never a silent identity fallback.
43
+ if (
44
+ cfg.globals.look !== undefined &&
45
+ !Object.prototype.hasOwnProperty.call(cfg.looks, cfg.globals.look)
46
+ ) {
47
+ ctx.issues.push({
48
+ path: "globals.look",
49
+ message: `globals.look "${cfg.globals.look}" does not match any declared look (have: ${Object.keys(cfg.looks).join(", ")})`,
50
+ line: findKeyLine(ctx.source, ["globals", "look"]),
51
+ });
52
+ }
39
53
  // [LAW:one-source-of-truth] THE set of resolvable variable names — a
40
54
  // faithful mirror of the runtime store's key set (declareOne in
41
55
  // src/dsl/render.ts registers globals under their bare names and segment
@@ -16,6 +16,7 @@ import { globalsJson } from "./globals.js";
16
16
  import { variablesMapJson } from "./variables.js";
17
17
  import { segmentsJson } from "./segments.js";
18
18
  import { actionsJson } from "./actions.js";
19
+ import { looksJson } from "./looks.js";
19
20
  import {
20
21
  layoutNodeJson,
21
22
  LAYOUT_NODE_DEF_NAME,
@@ -46,6 +47,7 @@ export function emitConfigSchema(): JsonNode {
46
47
  segments: segmentsJson(),
47
48
  root: { $ref: LAYOUT_NODE_REF },
48
49
  actions: actionsJson(),
50
+ looks: looksJson(),
49
51
  helpers: { type: "object", additionalProperties: { type: "string" } },
50
52
  },
51
53
  definitions: {
@@ -62,6 +62,12 @@ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
62
62
  default_separator: optionalStringSpec(),
63
63
  default_truncate_marker: optionalStringSpec(),
64
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(),
65
71
  // [LAW:types-are-the-program] The strip style is a CLOSED enum (the powerline
66
72
  // shapes the joiner can render), unlike the open-ended palette NAME — so it
67
73
  // validates by membership and emits a JSON-Schema `enum`.
@@ -575,14 +575,18 @@ export function synthesizeGroupDecls(
575
575
  ctx: ValidateCtx,
576
576
  out: Mutable<RawDslConfig>,
577
577
  ): void {
578
- const groups = ctx.groups;
579
- if (groups.length === 0) return;
580
-
581
578
  // [LAW:single-enforcer] The disclosure primitive's shared reserved-namespace
582
579
  // enforcer (mirroring {{ menu }}'s `menus.`) — a user name under `groups.`
583
- // would silently shadow a synthesized artifact.
580
+ // would silently shadow a synthesized artifact. Reserved UNCONDITIONALLY,
581
+ // before the no-groups early return, so the reservation is a stable contract
582
+ // ("you never author groups.*"), not a rule that only switches on when a
583
+ // group node happens to be declared this load — same placement as the menus
584
+ // pass (synthesizeMenuDecls).
584
585
  reservedNamespaceCollisions(ctx, out, GROUP_NS, "group nodes");
585
586
 
587
+ const groups = ctx.groups;
588
+ if (groups.length === 0) return;
589
+
586
590
  const seen = new Set<string>();
587
591
  for (const g of groups) {
588
592
  if (seen.has(g.name)) {
@@ -0,0 +1,96 @@
1
+ // [LAW:types-are-the-program] The `looks` schema: each look is a named rich-js
2
+ // ThemeKey — an ADAPTATION applied on top of whatever base theme is active (a
3
+ // transform, not a palette), so one look composes with every theme. The config
4
+ // spelling mirrors ThemeKey's field names VERBATIM (hueShift / chromaScale /
5
+ // lightnessScale / lightnessShift) and the parsed output IS a rich-js ThemeKey —
6
+ // no translation layer to drift, and a rich-js field rename fails this module's
7
+ // compile instead of silently diverging. [LAW:one-source-of-truth]
8
+ //
9
+ // The adaptation vocabulary is CAPPED at ThemeKey's four axes. Role remap (the
10
+ // old surface/button "role emphasis") is deferred; its exit plan is a future
11
+ // rich-js resolver-level role→role operation carried as an additive `roles`
12
+ // field here — growing the vocabulary means growing rich-js, never adding color
13
+ // math to cc-candybar.
14
+
15
+ import type { ThemeKey } from "@promptctl/rich-js";
16
+ import { IDENTITY } from "@promptctl/rich-js";
17
+ import {
18
+ describeType,
19
+ isPlainObject,
20
+ optionalNumberSpec,
21
+ record,
22
+ recordJson,
23
+ type JsonNode,
24
+ type RecordSchema,
25
+ type ValidateCtx,
26
+ } from "./validate-core.js";
27
+ import { findKeyLine } from "./diagnostics.js";
28
+
29
+ // [LAW:types-are-the-program] The AUTHORING shape: every axis optional, absent =
30
+ // identity. Distinct from ThemeKey (all fields required) so the record engine's
31
+ // omit-absent output is honestly typed; validateLooks normalizes each parsed
32
+ // spec onto IDENTITY, and past this module a partial look is unrepresentable.
33
+ interface LookSpec {
34
+ readonly hueShift?: number;
35
+ readonly chromaScale?: number;
36
+ readonly lightnessScale?: number;
37
+ readonly lightnessShift?: number;
38
+ }
39
+
40
+ // [LAW:one-source-of-truth] The four axes, declared once as DATA the record
41
+ // engine interprets for both validation and schema emit. chromaScale is a
42
+ // multiplier on saturation — negative chroma is not a color, so the one bound.
43
+ const LOOK_SCHEMA: RecordSchema<LookSpec> = {
44
+ noun: "look key",
45
+ fields: {
46
+ hueShift: optionalNumberSpec(),
47
+ chromaScale: optionalNumberSpec({ min: 0 }),
48
+ lightnessScale: optionalNumberSpec(),
49
+ lightnessShift: optionalNumberSpec(),
50
+ },
51
+ };
52
+
53
+ // An absent looks block is handled by the caller (absence survives the parse);
54
+ // a non-object is a reported error recovering to empty — parseDslConfig throws
55
+ // once any issue exists, so the recovery value never renders.
56
+ export function validateLooks(
57
+ ctx: ValidateCtx,
58
+ raw: unknown,
59
+ ): Readonly<Record<string, ThemeKey>> {
60
+ if (!isPlainObject(raw)) {
61
+ ctx.issues.push({
62
+ path: "looks",
63
+ message: `looks must be an object mapping look names to adaptation objects, got ${describeType(raw)}`,
64
+ line: findKeyLine(ctx.source, ["looks"]),
65
+ });
66
+ return {};
67
+ }
68
+ const out: Record<string, ThemeKey> = {};
69
+ for (const [name, value] of Object.entries(raw)) {
70
+ // [LAW:no-silent-fallbacks] A look name is a deliverable set-state value —
71
+ // a look picker writes it on the wire, which rejects empty values and
72
+ // splits on "/". Rejecting the shape HERE surfaces the error on every
73
+ // config load, not only once an action ranges the "looks" domain (the same
74
+ // wire shape cycle members and `to` literals enforce in actions.ts).
75
+ if (name === "" || name.includes("/")) {
76
+ ctx.issues.push({
77
+ path: `looks.${name}`,
78
+ message: `look name ${JSON.stringify(name)} must be non-empty and slash-free — a look picker writes the name on the set-state wire, which rejects empty values and splits on "/"`,
79
+ line: findKeyLine(ctx.source, ["looks", name]),
80
+ });
81
+ continue;
82
+ }
83
+ const parsed = record(ctx, LOOK_SCHEMA, `looks.${name}`, value);
84
+ // [LAW:one-source-of-truth] Normalization onto IDENTITY is the single
85
+ // "absent axis = identity" site — downstream consumers receive a total
86
+ // ThemeKey and never re-default a missing axis.
87
+ if (parsed !== null) out[name] = { ...IDENTITY, ...parsed };
88
+ }
89
+ return out;
90
+ }
91
+
92
+ // [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
93
+ // the validator interprets — a map of look names to the closed four-axis object.
94
+ export function looksJson(): JsonNode {
95
+ return { type: "object", additionalProperties: recordJson(LOOK_SCHEMA) };
96
+ }
@@ -32,6 +32,10 @@ export function mergeWithDefault(
32
32
  // declares only the actions that differ from the bundled default (which
33
33
  // ships none).
34
34
  actions: { ...dflt.actions, ...(raw.actions ?? {}) },
35
+ // [LAW:one-source-of-truth] looks merge by name, same cascade — a user
36
+ // overrides one adaptation by re-declaring its name; the bundled stdlib
37
+ // (incl. the "none" identity floor) survives every merge by construction.
38
+ looks: { ...dflt.looks, ...(raw.looks ?? {}) },
35
39
  // [LAW:one-source-of-truth] helpers merge by name, same cascade — a user
36
40
  // overrides one formatter helper by re-declaring its name; the rest inherit
37
41
  // from the bundled default.
@@ -582,6 +582,41 @@ export function optionalIntSpec(bounds: {
582
582
  };
583
583
  }
584
584
 
585
+ // [LAW:dataflow-not-control-flow] An optional finite-number field: the optional
586
+ // lower bound is DATA feeding both interpreters — `parse` checks it and
587
+ // interpolates it into the one message, `json` emits it as `minimum` — so the
588
+ // validator and the editor-facing schema cannot describe different ranges.
589
+ // Finite matters: JSON5 admits `NaN`/`Infinity` literals, and a non-finite axis
590
+ // would corrupt every OKLCH channel it touches downstream.
591
+ export function optionalNumberSpec(
592
+ bounds: { readonly min?: number } = {},
593
+ ): FieldSpec<number> {
594
+ const { min } = bounds;
595
+ return {
596
+ required: false,
597
+ json: { type: "number", ...(min !== undefined && { minimum: min }) },
598
+ parse: (ctx, path, field, raw) => {
599
+ const v = raw[field];
600
+ if (v === undefined) return undefined;
601
+ const bad =
602
+ typeof v !== "number" ||
603
+ !Number.isFinite(v) ||
604
+ (min !== undefined && v < min);
605
+ if (bad) {
606
+ const shape =
607
+ min !== undefined ? `a finite number >= ${min}` : "a finite number";
608
+ ctx.issues.push({
609
+ path: `${path}.${field}`,
610
+ message: `${field} must be ${shape}, got ${describeValue(v)}`,
611
+ line: findKeyLine(ctx.source, [field]),
612
+ });
613
+ return undefined;
614
+ }
615
+ return v;
616
+ },
617
+ };
618
+ }
619
+
585
620
  // [LAW:single-enforcer] The palette field defers to the one palette-name
586
621
  // authority; the field key is conventionally "palette", which validatePaletteName
587
622
  // reads directly.
@@ -63,6 +63,12 @@ export interface RenderPayload extends ClaudeHookData {
63
63
  // domain truth is "always present". A `?` here would let a callsite believe it
64
64
  // could be undefined and guard defensively against an impossibility.
65
65
  readonly theme: { readonly effective: string };
66
+ // [LAW:one-type-per-behavior] The daemon-resolved effective LOOK name —
67
+ // effectiveLookName(sessionState.look, globals.look, looks) — the exact twin
68
+ // of `theme` one dimension over: the SAME name whose ThemeKey adapts the
69
+ // rendered palette, surfaced so a trigger label can display the active look.
70
+ // Required for the same reason as theme: resolved unconditionally per render.
71
+ readonly look: { readonly effective: string };
66
72
 
67
73
  // Usage-family. Each provider returns null when it has no data (no
68
74
  // transcript yet, no rate-limit window active, etc.); we drop the field
@@ -582,6 +588,11 @@ export async function buildRenderPayload(
582
588
  // in (not re-resolved here) because the daemon already computes it for the
583
589
  // palette; this is that same value, threaded to the sole payload assembler.
584
590
  effectiveTheme: string,
591
+ // [LAW:one-source-of-truth] The effective look name, resolved ONCE by the
592
+ // daemon beside the theme (effectiveLookName over SessionState/globals/looks)
593
+ // and used for BOTH the rendered adaptation and this payload field — so a
594
+ // trigger label reading `.look.effective` can never disagree with the colors.
595
+ effectiveLook: string,
585
596
  ): Promise<RenderPayload> {
586
597
  const wants = (prefix: string): boolean =>
587
598
  anyPathStartsWith(neededInputPaths, prefix);
@@ -788,6 +799,8 @@ export async function buildRenderPayload(
788
799
  // No `wants` gate: it costs nothing (a string already in hand) and a
789
800
  // config that reads `.theme.effective` must always find it.
790
801
  theme: { effective: effectiveTheme },
802
+ // Same contract as theme: always present, a string already in hand.
803
+ look: { effective: effectiveLook },
791
804
  ...(sessionPayload !== undefined && { session: sessionPayload }),
792
805
  ...(todayPayload !== undefined && { today: todayPayload }),
793
806
  ...(costPerHour !== undefined && { burn: { costPerHour } }),
@@ -59,6 +59,8 @@ import { renderDsl } from "../dsl/render.js";
59
59
  import {
60
60
  effectiveStripStyle,
61
61
  effectiveThemeName,
62
+ effectiveLookName,
63
+ lookKeyByName,
62
64
  resolverForThemeName,
63
65
  } from "../themes/index.js";
64
66
  import {
@@ -802,12 +804,24 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
802
804
  sessionState.get(req.hookData.session_id, "theme"),
803
805
  entry.state.config.globals.palette,
804
806
  );
807
+ // [LAW:one-type-per-behavior] The LOOK, resolved per render the exact
808
+ // way the theme is: the session's clicked look (SessionState) over the
809
+ // config default over the "none" floor — so a look click recolors the
810
+ // whole bar on the next render, composing with whatever theme is
811
+ // active. The name feeds the payload's `look.effective`; its ThemeKey
812
+ // (lookKeyByName) feeds renderDsl below — one resolution, two readers.
813
+ const effectiveLook = effectiveLookName(
814
+ sessionState.get(req.hookData.session_id, "look"),
815
+ entry.state.config.globals.look,
816
+ entry.state.config.looks,
817
+ );
805
818
  const payload = await buildRenderPayload(
806
819
  req.hookData,
807
820
  payloadDeps,
808
821
  req.cwd,
809
822
  entry.state.neededInputPaths,
810
823
  effectiveTheme,
824
+ effectiveLook,
811
825
  );
812
826
  // [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
813
827
  // is derived from the same effective theme resolved above — so a theme
@@ -869,7 +883,8 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
869
883
  // in place. Cells are cheap (already computed during the render);
870
884
  // the per-segment ANSI serialization happens lazily inside the
871
885
  // debug handler so normal renders pay no extra serializer cost.
872
- entry.state.lastRenderCellsBySegment,
886
+ { perSegmentSink: entry.state.lastRenderCellsBySegment },
887
+ lookKeyByName(entry.state.config.looks, effectiveLook),
873
888
  );
874
889
  }
875
890
  // [LAW:one-source-of-truth] Consume the transient click error written by
@@ -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
  };