@promptctl/cc-candybar 1.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -95,10 +95,10 @@
95
95
  "mobx": "^6.15.0"
96
96
  },
97
97
  "optionalDependencies": {
98
- "@promptctl/cc-candybar-darwin-arm64": "1.20.0",
99
- "@promptctl/cc-candybar-darwin-x64": "1.20.0",
100
- "@promptctl/cc-candybar-linux-x64": "1.20.0",
101
- "@promptctl/cc-candybar-linux-arm64": "1.20.0"
98
+ "@promptctl/cc-candybar-darwin-arm64": "1.21.0",
99
+ "@promptctl/cc-candybar-darwin-x64": "1.21.0",
100
+ "@promptctl/cc-candybar-linux-x64": "1.21.0",
101
+ "@promptctl/cc-candybar-linux-arm64": "1.21.0"
102
102
  },
103
103
  "pnpm": {
104
104
  "supportedArchitectures": {
@@ -26,6 +26,9 @@
26
26
  "palette": {
27
27
  "type": "string"
28
28
  },
29
+ "look": {
30
+ "type": "string"
31
+ },
29
32
  "style": {
30
33
  "enum": [
31
34
  "powerline",
@@ -1140,7 +1143,8 @@
1140
1143
  "from": {
1141
1144
  "enum": [
1142
1145
  "themes",
1143
- "styles"
1146
+ "styles",
1147
+ "looks"
1144
1148
  ]
1145
1149
  }
1146
1150
  },
@@ -1239,6 +1243,28 @@
1239
1243
  ]
1240
1244
  }
1241
1245
  },
1246
+ "looks": {
1247
+ "type": "object",
1248
+ "additionalProperties": {
1249
+ "type": "object",
1250
+ "properties": {
1251
+ "hueShift": {
1252
+ "type": "number"
1253
+ },
1254
+ "chromaScale": {
1255
+ "type": "number",
1256
+ "minimum": 0
1257
+ },
1258
+ "lightnessScale": {
1259
+ "type": "number"
1260
+ },
1261
+ "lightnessShift": {
1262
+ "type": "number"
1263
+ }
1264
+ },
1265
+ "additionalProperties": false
1266
+ }
1267
+ },
1242
1268
  "helpers": {
1243
1269
  "type": "object",
1244
1270
  "additionalProperties": {
package/src/check.ts CHANGED
@@ -30,7 +30,12 @@ import { SourceRegistry } from "./var-system/sources.js";
30
30
  import { SessionState } from "./daemon/session-state.js";
31
31
  import { registerDslConfig, renderDsl } from "./dsl/render.js";
32
32
  import { deriveActionValidators } from "./daemon/verbs/state-validators.js";
33
- import { effectiveThemeName, effectiveStripStyle } from "./themes/policy.js";
33
+ import {
34
+ effectiveThemeName,
35
+ effectiveLookName,
36
+ lookKeyByName,
37
+ effectiveStripStyle,
38
+ } from "./themes/policy.js";
34
39
  import { resolverForThemeName } from "./themes/palette-resolvers.js";
35
40
  import {
36
41
  DEFAULT_CHARSET,
@@ -61,7 +66,10 @@ const CHECK_WIDTH = 200;
61
66
  // test/example-configs.test.ts asserts rendered content against these literal
62
67
  // values (780s → "◷ 13m", cost $0.39, version 1.15.0, …); changing one here
63
68
  // fails that suite loudly rather than drifting silently.
64
- export function checkPayload(effectiveTheme: string): Record<string, unknown> {
69
+ export function checkPayload(
70
+ effectiveTheme: string,
71
+ effectiveLook: string,
72
+ ): Record<string, unknown> {
65
73
  const home = "/home/tester";
66
74
  const nowSec = Math.floor(Date.now() / 1000);
67
75
  return {
@@ -108,6 +116,7 @@ export function checkPayload(effectiveTheme: string): Record<string, unknown> {
108
116
  cache: { expiresAt: nowSec + 15 * 60 },
109
117
  tmux: { session: "work" },
110
118
  theme: { effective: effectiveTheme },
119
+ look: { effective: effectiveLook },
111
120
  };
112
121
  }
113
122
 
@@ -265,12 +274,28 @@ function loadRegisterRender(
265
274
  // resolution is null — the config default over the floor, exactly what the
266
275
  // daemon renders for a session that has never clicked.
267
276
  const effectiveTheme = effectiveThemeName(null, config.globals.palette);
268
- return renderDsl(
277
+ // Same fresh-session shape one dimension over: no clicked look, so the
278
+ // config default over the "none" floor — exactly what the daemon renders
279
+ // for a session that has never clicked.
280
+ const effectiveLook = effectiveLookName(
281
+ null,
282
+ config.globals.look,
283
+ config.looks,
284
+ );
285
+ // [LAW:no-silent-failure] A segment whose template THROWS while evaluating
286
+ // (an `{{ action }}` display-arity mismatch, a MissingFieldError from a
287
+ // partially-declared variable) renders as a visible ⚠ error cell — partial
288
+ // rendering, the daemon's channel for a human looking at the bar. The blind
289
+ // authoring agent is not looking at the bar; check collects the same errors
290
+ // through the render's observer seam and fails the verdict, so exit 0 never
291
+ // blesses a bar that renders ⚠.
292
+ const segmentErrors: string[] = [];
293
+ const rendered = renderDsl(
269
294
  config,
270
295
  compiled,
271
296
  store,
272
297
  registry,
273
- checkPayload(effectiveTheme),
298
+ checkPayload(effectiveTheme, effectiveLook),
274
299
  resolverForThemeName(effectiveTheme),
275
300
  {
276
301
  style: effectiveStripStyle(null, config.globals.style),
@@ -281,7 +306,21 @@ function loadRegisterRender(
281
306
  padding: config.globals.padding ?? DEFAULT_PADDING,
282
307
  charset: config.globals.charset ?? DEFAULT_CHARSET,
283
308
  },
309
+ {
310
+ onSegmentError: (segName, message) =>
311
+ segmentErrors.push(`segment "${segName}": ${message}`),
312
+ },
313
+ lookKeyByName(config.looks, effectiveLook),
284
314
  );
315
+ if (segmentErrors.length > 0) {
316
+ throw new Error(
317
+ `config renders with ${segmentErrors.length} segment error${
318
+ segmentErrors.length === 1 ? "" : "s"
319
+ } (the daemon would render ⚠ error cells):\n` +
320
+ segmentErrors.map((m) => ` ${m}`).join("\n"),
321
+ );
322
+ }
323
+ return rendered;
285
324
  } finally {
286
325
  // [LAW:single-enforcer] The registry owns every async handle the config
287
326
  // declared (timers, fs watchers, git subscriptions); a one-shot check must
@@ -25,11 +25,18 @@
25
25
  // discriminator the loader and the validator-derivation match on.
26
26
 
27
27
  // [LAW:one-source-of-truth] The domain lists a picker draws options from. Same
28
- // canonical sources the `themes()`/`styles()` bindings and the set-state
29
- // validators consult — the rendered options and the derived gate cannot diverge
30
- // because there is no second enumeration.
31
- export type OptionSource = "themes" | "styles";
32
- export const OPTION_SOURCES: readonly OptionSource[] = ["themes", "styles"];
28
+ // canonical sources the `themes()`/`styles()`/`looks()` bindings and the
29
+ // set-state validators consult — the rendered options and the derived gate
30
+ // cannot diverge because there is no second enumeration. themes/styles are
31
+ // static registry lists; "looks" is the one PER-CONFIG domain (the merged
32
+ // `looks` block's names), so its resolution sites take the config's look names
33
+ // as data rather than consulting a module constant.
34
+ export type OptionSource = "themes" | "styles" | "looks";
35
+ export const OPTION_SOURCES: readonly OptionSource[] = [
36
+ "themes",
37
+ "styles",
38
+ "looks",
39
+ ];
33
40
 
34
41
  // [LAW:types-are-the-program] The top-level discriminator of an ActionDecl — the
35
42
  // click effect is keyed by which of these is present. The loader proves
@@ -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`.
@@ -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