@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.
- package/dist/index.mjs +62 -59
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +27 -1
- package/src/check.ts +398 -0
- package/src/config/action.ts +12 -5
- package/src/config/cli.ts +19 -118
- package/src/config/default-dsl-config.ts +64 -0
- package/src/config/dsl-loader.ts +3 -0
- package/src/config/dsl-types.ts +28 -0
- package/src/config/loader/cross-ref.ts +14 -0
- package/src/config/loader/emit-schema.ts +2 -0
- package/src/config/loader/globals.ts +6 -0
- package/src/config/loader/layout.ts +8 -4
- package/src/config/loader/looks.ts +96 -0
- package/src/config/loader/merge.ts +4 -0
- package/src/config/loader/validate-core.ts +35 -0
- package/src/daemon/render-payload.ts +13 -0
- package/src/daemon/server.ts +16 -1
- package/src/daemon/verbs/state-validators.ts +21 -7
- package/src/demo/dsl.ts +15 -1
- package/src/dsl/node-registry.ts +29 -11
- package/src/dsl/render.ts +54 -12
- package/src/index.ts +12 -5
- package/src/render/action.ts +18 -7
- package/src/themes/index.ts +2 -0
- package/src/themes/palette-resolvers.ts +33 -27
- package/src/themes/policy.ts +51 -1
|
@@ -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 }}`
|
package/src/config/dsl-loader.ts
CHANGED
|
@@ -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
|
]);
|
package/src/config/dsl-types.ts
CHANGED
|
@@ -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 } }),
|
package/src/daemon/server.ts
CHANGED
|
@@ -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
|
|
435
|
-
// style validators consult — the rendered options and the derived gate
|
|
436
|
-
// diverge because there is no second enumeration.
|
|
437
|
-
|
|
438
|
-
|
|
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: {
|
|
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) =>
|
|
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 {
|
|
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);
|
package/src/dsl/node-registry.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
};
|