@promptctl/cc-candybar 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.mjs +86 -85
  2. package/package.json +6 -6
  3. package/schema/cc-candybar.schema.json +193 -4
  4. package/src/check.ts +49 -27
  5. package/src/click/wire.ts +16 -0
  6. package/src/config/action.ts +57 -22
  7. package/src/config/default-dsl-config.ts +424 -55
  8. package/src/config/dsl-loader.ts +14 -2
  9. package/src/config/dsl-types.ts +59 -0
  10. package/src/config/loader/actions.ts +283 -109
  11. package/src/config/loader/cross-ref.ts +148 -28
  12. package/src/config/loader/emit-schema.ts +2 -0
  13. package/src/config/loader/globals.ts +118 -31
  14. package/src/config/loader/merge.ts +58 -1
  15. package/src/config/loader/persist-target.ts +32 -0
  16. package/src/config/loader/presets.ts +107 -0
  17. package/src/config/option-domain.ts +164 -0
  18. package/src/config/presets.ts +188 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +72 -7
  21. package/src/daemon/config-overrides-store.ts +322 -0
  22. package/src/daemon/paths.ts +10 -0
  23. package/src/daemon/render-payload.ts +84 -19
  24. package/src/daemon/server.ts +68 -55
  25. package/src/daemon/verbs/config-validators.ts +127 -0
  26. package/src/daemon/verbs/index.ts +129 -2
  27. package/src/daemon/verbs/state-validators.ts +98 -586
  28. package/src/daemon/verbs/validator-registry.ts +457 -0
  29. package/src/demo/dsl.ts +17 -10
  30. package/src/dsl/node-registry.ts +54 -39
  31. package/src/dsl/render.ts +158 -46
  32. package/src/help-text.ts +3 -3
  33. package/src/install/index.ts +2 -2
  34. package/src/render/action.ts +155 -33
  35. package/src/render/active-segment.ts +78 -0
  36. package/src/render/menu.ts +16 -11
  37. package/src/render/picker.ts +51 -13
  38. package/src/render/segment-color.ts +74 -0
  39. package/src/segments/git.ts +389 -48
  40. package/src/template-engine/colors.ts +67 -45
  41. package/src/template-engine/engine.ts +11 -12
  42. package/src/themes/index.ts +1 -4
  43. package/src/themes/palette-resolvers.ts +22 -30
  44. package/src/themes/policy.ts +37 -16
@@ -1,6 +1,15 @@
1
1
  // [LAW:single-enforcer] All per-segment bg/fg resolution flows through
2
2
  // resolveSegmentColors. No second path; a second path would silently drift
3
- // from the two-stage pipeline (bg first, fg with auto-contrast context second).
3
+ // from the ordered pipeline this function owns.
4
+ //
5
+ // [LAW:no-ambient-temporal-coupling] That ordering — publish the segment's
6
+ // palette, resolve bg, publish bg, resolve fg, then hand the body its turn —
7
+ // is not incidental execution order that happens to work. It is the phase
8
+ // structure of a segment's color resolution, and this function is its one
9
+ // owner. Each phase's output is *published* into the active-segment record
10
+ // before the next phase runs, so a later template can read an earlier
11
+ // phase's result (`fg: '{{ contrastOn (bgOf) }}'`) and an earlier one
12
+ // cannot read a later one — it gets a message naming the phase instead.
4
13
  //
5
14
  // [LAW:dataflow-not-control-flow] Steps execute unconditionally; the option
6
15
  // values (undefined template = no spec) are what decides the output, not
@@ -8,60 +17,72 @@
8
17
  // → fragmentsToStripCells's baseStyle merge is a no-op, cells flow through
9
18
  // unchanged.
10
19
 
11
- import { Style, ColorSpec } from "@promptctl/rich-js";
12
- import type { ColorRgba, PaletteResolver } from "@promptctl/rich-js";
20
+ import {
21
+ Style,
22
+ ColorSpec,
23
+ resolveColorRef,
24
+ ColorRefError,
25
+ } from "@promptctl/rich-js";
26
+ import type { ColorRgba, Palette } from "@promptctl/rich-js";
13
27
  import type { RichText } from "@promptctl/rich-js";
14
28
  import type { Template } from "@promptctl/go-template-js";
29
+ import type { ActiveSegmentRef } from "../render/active-segment.js";
15
30
 
16
31
  export class ColorSpecError extends Error {
17
- constructor(spec: string, role: "bg" | "fg") {
18
- super(`Invalid ${role} color spec: ${JSON.stringify(spec)}`);
32
+ constructor(spec: string, role: "bg" | "fg", detail: string) {
33
+ super(`Invalid ${role} color ${JSON.stringify(spec)}: ${detail}`);
19
34
  this.name = "ColorSpecError";
20
35
  }
21
36
  }
22
37
 
23
38
  /**
24
- * Resolve per-segment bg and fg template strings into a Style for baseStyle
25
- * injection in fragmentsToStripCells().
39
+ * Resolve a segment's `bg:` and `fg:` templates into the Style that becomes
40
+ * its baseStyle, publishing each phase's result into `ref` as it goes.
26
41
  *
27
- * Pipeline:
28
- * 1. Evaluate bgTemplate plain text color-spec string.
29
- * 2. resolver.resolve(bgSpec) ColorRgba.
30
- * 3. Evaluate fgTemplate plain text color-spec string.
31
- * 4. resolver.resolve(fgSpec, { against: bgColor }) → ColorRgba (auto-contrast).
32
- * 5. Wrap as Style({ bgcolor, color }).
42
+ * A `bg:`/`fg:` field is a template evaluated to a **color reference** — a
43
+ * palette variable name (`"surface-active"`) or a `#RRGGBB` literal. Since
44
+ * rich-js's `resolveColorRef` accepts both and is idempotent, the plain
45
+ * authoring form and a computed one take the identical path:
33
46
  *
34
- * Undefined template → that color is not set in the returned Style, so cells
35
- * fall through to their own style (or no color if they have none).
47
+ * ```json5
48
+ * bg: "surface-active" // a name, evaluated as itself
49
+ * bg: '{{ darken (color "surface-active") 1 }}' // a computed literal
50
+ * ```
36
51
  *
37
- * Throws ColorSpecError if a non-empty spec string resolves to null.
52
+ * There is no "is this a name or a color" branch anywhere — one total
53
+ * function over both. [LAW:dataflow-not-control-flow]
38
54
  *
39
- * Hue rotation is not this function's concern: per-segment hue lives upstream as
40
- * WHICH palette `resolver` carries (a transposed palette), so bg and fg resolve
41
- * from the same transposed palette and their theme-designed relationship is
42
- * preserved. [LAW:dataflow-not-control-flow]
55
+ * Undefined template → that color is not set in the returned Style, so cells
56
+ * fall through to their own style (or no color if they have none).
43
57
  *
44
- * [LAW:dataflow-not-control-flow] Steps are ordered data transformations, not
45
- * guarded branches. The "no spec" case is represented as undefined, which flows
46
- * through to produce an absent Style field — not a skipped step.
58
+ * Hue rotation and looks are not this function's concern: they live upstream
59
+ * as WHICH palette it is handed, so bg, fg, and the body all resolve from one
60
+ * palette and their theme-designed relationships are preserved.
47
61
  */
48
62
  export function resolveSegmentColors(
49
- resolver: PaletteResolver,
63
+ ref: ActiveSegmentRef,
64
+ segName: string,
65
+ palette: Palette,
50
66
  bgTemplate: Template<RichText> | undefined,
51
67
  fgTemplate: Template<RichText> | undefined,
52
68
  scope: object,
53
69
  ): Style {
70
+ // Phase 0 — the palette is live from here until the walk clears it, so
71
+ // `{{ color … }}` in the bg template, the fg template, and the body all read
72
+ // this one palette. `bg` starts undefined: it is what phase 1 computes.
73
+ const active = { segName, palette, bg: undefined as ColorRgba | undefined };
74
+ ref.current = active;
75
+
76
+ // Phase 1 — background.
54
77
  const bgSpec = evalToPlainText(bgTemplate, scope);
55
78
  const bgColor =
56
- bgSpec !== undefined
57
- ? resolveSpec(resolver, bgSpec, undefined, "bg")
58
- : undefined;
79
+ bgSpec !== undefined ? resolveRef(palette, bgSpec, "bg") : undefined;
59
80
 
81
+ // Phase 2 — publish it, then foreground, which may now ask about it.
82
+ active.bg = bgColor;
60
83
  const fgSpec = evalToPlainText(fgTemplate, scope);
61
84
  const fgColor =
62
- fgSpec !== undefined
63
- ? resolveSpec(resolver, fgSpec, bgColor, "fg")
64
- : undefined;
85
+ fgSpec !== undefined ? resolveRef(palette, fgSpec, "fg") : undefined;
65
86
 
66
87
  return new Style({
67
88
  bgcolor: bgColor !== undefined ? ColorSpec.fromRgba(bgColor) : undefined,
@@ -82,21 +103,22 @@ function evalToPlainText(
82
103
  .join("");
83
104
  }
84
105
 
85
- // Resolve a color spec string through the palette resolver.
86
- // Throws ColorSpecError (loud failure) if the spec is unknown or the required
87
- // `against` context is missing never silently falls back to a default.
88
- // [LAW:no-defensive-null-guards] null from resolver.resolve signals broken
89
- // config; the fix is the config, not a silent fallback.
90
- function resolveSpec(
91
- resolver: PaletteResolver,
92
- spec: string,
93
- against: ColorRgba | undefined,
106
+ // [LAW:one-source-of-truth] The same rich-js checkpoint the `{{ color }}`
107
+ // template function crosses, so a reference that works in a body works in a
108
+ // `bg:` field and vice versa. Re-thrown with the field's role attached
109
+ // rich-js knows the reference failed, only cc-candybar knows it came from a
110
+ // `fg:`. [LAW:no-silent-failure] never a substituted default: a broken color
111
+ // reference is a config bug, and the fix is the config.
112
+ function resolveRef(
113
+ palette: Palette,
114
+ ref: string,
94
115
  role: "bg" | "fg",
95
116
  ): ColorRgba {
96
- const color = resolver.resolve(
97
- spec.trim(),
98
- against !== undefined ? { against } : undefined,
99
- );
100
- if (color === null) throw new ColorSpecError(spec, role);
101
- return color;
117
+ try {
118
+ return resolveColorRef(palette, ref);
119
+ } catch (e) {
120
+ if (e instanceof ColorRefError)
121
+ throw new ColorSpecError(ref, role, e.message);
122
+ throw e;
123
+ }
102
124
  }
@@ -18,8 +18,13 @@
18
18
  // (int here is shadowed by ccCandybarFuncs' var-system cast below).
19
19
  // • sprigDicts: dict, get, set, keys, values, pick, omit, hasKey, merge —
20
20
  // `dict` lets a helper take multiple named inputs through its one dot arg.
21
- // • richTextFuncs: bold, italic, red, green, (styling from rich-js).
22
- // paletteFuncs (when resolver provided): primary, accent, palette, paletteOver, auto.
21
+ // • richTextFuncs: fg, bg, bold, italic, link, style (painting) PLUS the
22
+ // palette-free color math (darken, lighten, mix, contrastOn, readableOn,
23
+ // shiftHue, scaleChroma, scaleLightness, shiftLightness) — all from rich-js.
24
+ // Naming a THEME color (`{{ color "primary" }}`) and asking for the current
25
+ // segment's background (`{{ bgOf }}`) are segment-scoped, so they arrive
26
+ // through `extraFuncs` from the DSL render layer, which is the only place
27
+ // that knows which segment is rendering.
23
28
  // • ccCandybarFuncs: basename, dirname, int, string, bool, urlEncode,
24
29
  // themes, styles, sparkline.
25
30
  // • formatterFuncs: minutesUntilReset (clock-reading numeric primitive),
@@ -39,29 +44,24 @@ import {
39
44
  sprigConversions,
40
45
  sprigDicts,
41
46
  } from "@promptctl/go-template-js";
42
- import type { PaletteResolver } from "@promptctl/rich-js";
43
47
  import { richTextFuncs, RichText } from "@promptctl/rich-js";
44
- import { paletteFuncs } from "@promptctl/rich-js/template-bindings";
45
48
  import { ccCandybarFuncs, formatterFuncs } from "./funcs.js";
46
49
 
47
50
  // [LAW:single-enforcer] fromString/toString are declared once here.
48
- // richTextFuncs() provides style functions (bold, red, link, …).
49
- // paletteFuncs(resolver) registers semantic palette functions when a theme
50
- // resolver is provided — same engine instance, no second parse path.
51
+ // richTextFuncs() provides the theme-independent vocabulary: painting, color
52
+ // math, attributes, links. It needs no configuration, so it is unconditional.
51
53
  // [LAW:one-way-deps] `extraFuncs` is an INJECTED FuncMap (e.g. the action +
52
54
  // picker feature funcs, built in render/action.ts + render/picker.ts). The
53
55
  // generic engine never imports a specific feature — the caller hands it the
54
56
  // capability as data, so the dependency runs caller → engine, never engine → feature.
55
- // [LAW:one-type-per-behavior] resolver?/extraFuncs? are values, not modes
56
- // one factory, one engine shape; the data (their presence) governs what's
57
- // registered.
57
+ // [LAW:one-type-per-behavior] extraFuncs? is a value, not a mode one factory,
58
+ // one engine shape; the data (its presence) governs what's registered.
58
59
  // [LAW:single-enforcer] `clock` is the one time source. It feeds sprigDatetime
59
60
  // (the funcs that read "now") AND createEngine's clock option, so every
60
61
  // time-dependent evaluation in this engine reads from one seam. Defaulted here
61
62
  // so the default literal `() => new Date()` lives in exactly one place; callers
62
63
  // that omit it (and forwarders passing `undefined`) inherit it unchanged.
63
64
  export function createCcCandybarEngine(
64
- resolver?: PaletteResolver,
65
65
  extraFuncs?: FuncMap,
66
66
  clock: () => Date = () => new Date(),
67
67
  ): Engine<RichText> {
@@ -88,7 +88,6 @@ export function createCcCandybarEngine(
88
88
  // decoupled from any payload's nesting — no per-payload helper variant.
89
89
  ...sprigDicts(),
90
90
  ...richTextFuncs(),
91
- ...(resolver !== undefined ? paletteFuncs(resolver) : {}),
92
91
  // Domain-specific overrides last (wins on collision with sprig aliases).
93
92
  // [LAW:one-source-of-truth] ccCandybarFuncs' `int` is the var-system cast
94
93
  // (toNumber over VarValue); it intentionally shadows sprigConversions' `int`
@@ -15,7 +15,4 @@ export {
15
15
  } from "./policy.js";
16
16
  export type { StripStyle } from "./policy.js";
17
17
 
18
- export {
19
- resolverForThemeName,
20
- transposedResolver,
21
- } from "./palette-resolvers.js";
18
+ export { paletteForThemeName, transposedPalette } from "./palette-resolvers.js";
@@ -1,36 +1,32 @@
1
- // Memoized PaletteResolver construction over rich-js. cc-candybar moves theme
1
+ // Memoized Palette construction over rich-js. cc-candybar moves theme
2
2
  // NAMES and ThemeKey axes (data); rich-js owns every color value operation. Two
3
- // memos live here: a theme name -> base resolver, and a (base, ThemeKey) ->
4
- // transposed resolver. They compose — the per-render base palette feeds the
3
+ // memos live here: a theme name -> base palette, and a (base, ThemeKey) ->
4
+ // transposed palette. They compose — the per-render base palette feeds the
5
5
  // per-segment transposition (the session look's axes + the segment's hue shift,
6
6
  // folded into one key by the caller).
7
7
  //
8
8
  // [LAW:no-shared-mutable-globals] Single owner: this module. Both Maps are pure
9
9
  // memos of pure rich-js functions, keyed by immutable inputs (resolved theme
10
10
  // name; palette name + the four ThemeKey axes). rich-js palettes are immutable
11
- // registry singletons, so a cached resolver never goes stale. Key spaces are
11
+ // registry singletons, so a cached palette never goes stale. Key spaces are
12
12
  // bounded by #themes and #themes × #declared looks × #distinct hueShifts
13
13
  // (hueShift = look shift + segIndex*hueStep, segIndex bounded by layout; look
14
14
  // axes bounded by the loaded configs' looks blocks) — both small. Shared on
15
- // purpose: a theme's base resolver and its gruvbox+42° transposition are each
15
+ // purpose: a theme's base palette and its gruvbox+42° transposition are each
16
16
  // computed once per process, not once per RenderCache entry or per render.
17
17
  // Read/written only through the two functions below.
18
18
 
19
- import {
20
- PaletteResolver,
21
- transposePalette,
22
- getThemePalette,
23
- } from "@promptctl/rich-js";
24
- import type { ThemeKey } from "@promptctl/rich-js";
19
+ import { transposePalette, getThemePalette } from "@promptctl/rich-js";
20
+ import type { ThemeKey, Palette } from "@promptctl/rich-js";
25
21
  import { resolvePaletteName } from "./policy.js";
26
22
 
27
- const baseCache = new Map<string, PaletteResolver>();
28
- const transposeCache = new Map<string, PaletteResolver>();
23
+ const baseCache = new Map<string, Palette>();
24
+ const transposeCache = new Map<string, Palette>();
29
25
 
30
26
  /**
31
- * The PaletteResolver for a theme name (aliases resolved). Memoized.
27
+ * The Palette for a theme name (aliases resolved). Memoized.
32
28
  *
33
- * [LAW:single-enforcer] The one place a theme name becomes a PaletteResolver
29
+ * [LAW:single-enforcer] The one place a theme name becomes a Palette
34
30
  * the per-render base palette and per-segment `palette:` overrides both flow
35
31
  * through here. A name that does not resolve is registry/resolver drift, never
36
32
  * user error: the loader validates `globals.palette` and the set-state verb
@@ -38,7 +34,7 @@ const transposeCache = new Map<string, PaletteResolver>();
38
34
  * name reaches here it must resolve. [LAW:no-defensive-null-guards] the throw is
39
35
  * the loud failure for that broken invariant, not a fallback.
40
36
  */
41
- export function resolverForThemeName(name: string): PaletteResolver {
37
+ export function paletteForThemeName(name: string): Palette {
42
38
  const resolved = resolvePaletteName(name);
43
39
  const hit = baseCache.get(resolved);
44
40
  if (hit !== undefined) return hit;
@@ -50,14 +46,13 @@ export function resolverForThemeName(name: string): PaletteResolver {
50
46
  `theme registry — allowed names and the registry are inconsistent`,
51
47
  );
52
48
  }
53
- const resolver = new PaletteResolver(palette);
54
- baseCache.set(resolved, resolver);
55
- return resolver;
49
+ baseCache.set(resolved, palette);
50
+ return palette;
56
51
  }
57
52
 
58
53
  /**
59
- * The PaletteResolver for `base`'s palette transposed by a full ThemeKey — the
60
- * adapted-resolver constructor: (base resolver, key) → resolver. The caller
54
+ * `base` transposed by a full ThemeKey — the adapted-palette constructor:
55
+ * (base palette, key) → palette. The caller
61
56
  * composes whatever axes it carries (a look's four axes, the per-segment hue
62
57
  * shift) into ONE key and this makes ONE transposePalette call — never chain
63
58
  * two transpositions: chaining double-pays OKLCH quantization AND collides this
@@ -70,23 +65,20 @@ export function resolverForThemeName(name: string): PaletteResolver {
70
65
  * (error/success/warning), so semantic meaning is preserved by construction —
71
66
  * no local exemption list to drift.
72
67
  *
73
- * [LAW:single-enforcer] The sole place a transposed resolver is built — a
68
+ * [LAW:single-enforcer] The sole place a transposed palette is built — a
74
69
  * future look `roles` remap is additive at this one seam. The memo miss
75
70
  * (undefined) is genuine optionality — not-yet-computed — not a defended
76
71
  * invariant. [LAW:one-source-of-truth] The cache key carries every axis of the
77
72
  * ThemeKey: two keys differing on any axis are distinct palettes.
78
73
  */
79
- export function transposedResolver(
80
- base: PaletteResolver,
81
- key: ThemeKey,
82
- ): PaletteResolver {
74
+ export function transposedPalette(base: Palette, key: ThemeKey): Palette {
83
75
  const cacheKey =
84
- `${base.palette.name} ${key.hueShift} ${key.chromaScale} ` +
76
+ `${base.name} ${key.hueShift} ${key.chromaScale} ` +
85
77
  `${key.lightnessScale} ${key.lightnessShift}`;
86
78
  const hit = transposeCache.get(cacheKey);
87
79
  if (hit !== undefined) return hit;
88
80
 
89
- const resolver = new PaletteResolver(transposePalette(base.palette, key));
90
- transposeCache.set(cacheKey, resolver);
91
- return resolver;
81
+ const transposed = transposePalette(base, key);
82
+ transposeCache.set(cacheKey, transposed);
83
+ return transposed;
92
84
  }
@@ -2,7 +2,7 @@
2
2
  // theme registry. cc-candybar selects theme NAMES and style IDENTIFIERS here;
3
3
  // every color *value* operation (hydrate hex, resolve specs, darken/contrast,
4
4
  // hue/transpose) lives in rich-js. This module only moves strings — no
5
- // PaletteResolver, no ColorRgba, no hex. The semantic/anchor knowledge
5
+ // Palette, no ColorRgba, no hex. The semantic/anchor knowledge
6
6
  // (which tokens keep their hue) stays in rich-js (ANCHORED_ROOTS), not here.
7
7
 
8
8
  import {
@@ -51,28 +51,49 @@ export function listResolvablePaletteNames(): readonly string[] {
51
51
  return [...listThemePalettes(), ...listThemeAliases()];
52
52
  }
53
53
 
54
+ // --- Per-config member selection ---
55
+
56
+ // [LAW:one-type-per-behavior] THE resolution shape shared by every selection
57
+ // whose domain is PER-CONFIG (declared in the config, not a registry-static
58
+ // list): session choice over config default over a floor, then collapse to the
59
+ // floor if the chosen name is not a declared member. `looks` and `presets` are
60
+ // both exactly this — what differs between them is only the floor name and
61
+ // which map holds the members, i.e. DATA. Written once here rather than twice,
62
+ // so a change to the collapse rule cannot land on one and miss the other.
63
+ //
64
+ // [LAW:dataflow-not-control-flow] The `??` chain IS the precedence — no "if the
65
+ // session has one" branch anywhere. [LAW:no-silent-failure] the collapse is the
66
+ // visible floor, not a throw: a stale SessionState entry from a prior config's
67
+ // vocabulary (config edits can orphan a name the per-config gate once admitted)
68
+ // must render the floor, and the caller publishes the RESOLVED name onto the
69
+ // payload so a menu label can never claim a member the render did not use.
70
+ //
71
+ // The floor's membership is a load-time guarantee, not a runtime hope: the
72
+ // bundled stdlib ships it and merge-by-name cannot remove it.
73
+ export function effectiveMemberName(
74
+ sessionPick: string | null,
75
+ configDefault: string | undefined,
76
+ floor: string,
77
+ declared: Readonly<Record<string, unknown>>,
78
+ ): string {
79
+ const chosen = sessionPick ?? configDefault ?? floor;
80
+ return Object.prototype.hasOwnProperty.call(declared, chosen)
81
+ ? chosen
82
+ : floor;
83
+ }
84
+
54
85
  // --- Look (theme-adaptation) identifiers ---
55
86
 
56
- // The look name a render should use, as data. [LAW:dataflow-not-control-flow]
57
- // [LAW:one-type-per-behavior] The exact shape of `effectiveThemeName`, one
58
- // dimension over: session choice over config default over the "none" floor, no
59
- // "if the session has a look" branch. Unlike the registry-static theme/style
60
- // domains, the look domain is PER-CONFIG (the merged `looks` block), so the
61
- // declared names arrive as data. A value outside them (a stale SessionState
62
- // entry from a prior config's look vocabulary — config edits can orphan a
63
- // clicked name the per-config gate once admitted) collapses to "none", which
64
- // every merged DslConfig carries by construction (the bundled stdlib ships it;
65
- // merge-by-name cannot remove it) — the same collapse-to-floor that keeps
66
- // effectiveStripStyle's return honest rather than silently widening.
87
+ // [LAW:one-source-of-truth] The look domain's instance of the shared resolver
88
+ // above the floor is `"none"`, the identity adaptation every merged config
89
+ // carries. A named wrapper (not a bare call at each site) so the floor is
90
+ // spelled once and the three call sites cannot disagree about it.
67
91
  export function effectiveLookName(
68
92
  sessionLook: string | null,
69
93
  globalsLook: string | undefined,
70
94
  declaredLooks: Readonly<Record<string, ThemeKey>>,
71
95
  ): string {
72
- const chosen = sessionLook ?? globalsLook ?? "none";
73
- return Object.prototype.hasOwnProperty.call(declaredLooks, chosen)
74
- ? chosen
75
- : "none";
96
+ return effectiveMemberName(sessionLook, globalsLook, "none", declaredLooks);
76
97
  }
77
98
 
78
99
  // [LAW:single-enforcer] The one place an effective look NAME becomes the