@promptctl/cc-candybar 1.35.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,17 @@
1
- // [LAW:single-enforcer] The slim name/string policy layered on top of rich-js's
2
- // theme registry. cc-candybar selects theme NAMES and style IDENTIFIERS here;
3
- // every color *value* operation (hydrate hex, resolve specs, darken/contrast,
4
- // hue/transpose) lives in rich-js. This module only moves strings no
5
- // Palette, no ColorRgba, no hex. The semantic/anchor knowledge
6
- // (which tokens keep their hue) stays in rich-js (ANCHORED_ROOTS), not here.
1
+ // [LAW:single-enforcer] The vocabulary and the resolution for every `globals`
2
+ // field a config can set or a click can pick: what values each field admits,
3
+ // what its floor is, and the one function that turns a session pick, a config
4
+ // default and that floor into the value a render actually uses.
5
+ //
6
+ // It sits in the themes module because theme/look/style were the first three
7
+ // fields to need it, and it is a LEAF: the config loader (validation, JSON
8
+ // schema) and the render layer both import it, which is what keeps a
9
+ // config↔render cycle from forming [LAW:one-way-deps].
10
+ //
11
+ // No color arithmetic lives here. cc-candybar selects theme NAMES and style
12
+ // IDENTIFIERS; every color *value* operation (hydrate hex, resolve specs,
13
+ // darken/contrast, hue/transpose) lives in rich-js, as does the semantic/anchor
14
+ // knowledge of which tokens keep their hue (ANCHORED_ROOTS).
7
15
 
8
16
  import {
9
17
  listThemePalettes,
@@ -22,19 +30,55 @@ export function resolvePaletteName(name: string): string {
22
30
  return THEME_ALIASES[name] ?? name;
23
31
  }
24
32
 
25
- // The theme name a render should use, as data. [LAW:dataflow-not-control-flow]
26
- // The `??` chain is the precedence — session choice over config default over the
27
- // built-in with no "if the session has a theme" branch. The session value is
28
- // the user's live per-session pick (null when unset); globals.palette is the
29
- // config default; "textual-dark" is the always-present floor.
33
+ // --- The one globals resolution ---
34
+
35
+ // [LAW:one-type-per-behavior] THE resolution every globals field a click can
36
+ // pick shares: the session's own value, over the config default, over a floor.
37
+ // Written once because the fields differ only in DATA — which floor, and how a
38
+ // raw SessionState string becomes a value of that field's type. Theme, look,
39
+ // preset, style, autoWrap and padding are all this function with different
40
+ // arguments, so the precedence order cannot land on one field and miss another.
41
+ //
42
+ // [LAW:dataflow-not-control-flow] The `??` chain IS the precedence; there is no
43
+ // "does this session have one" branch. A session that has never clicked passes
44
+ // null and lands on the config default by the same code path a session that
45
+ // clicked lands on its pick.
46
+ //
47
+ // [LAW:parse-dont-validate] `parseSession` is the boundary between an untyped
48
+ // SessionState string and this field's domain: it returns the typed value or
49
+ // null, and null means "no session pick" — indistinguishable, on purpose, from
50
+ // never having clicked. That is what makes a stale entry from a prior config's
51
+ // vocabulary (or a value a since-narrowed gate would now refuse) resolve to the
52
+ // default rather than throw or render something the label disagrees with
53
+ // [LAW:no-silent-failure] — the caller publishes what this returns as
54
+ // `<field>.effective`, so bar and label always trace to one value.
55
+ export function effectiveGlobal<T>(
56
+ sessionPick: string | null,
57
+ configDefault: T | null | undefined,
58
+ floor: T,
59
+ parseSession: (raw: string) => T | null,
60
+ ): T {
61
+ const picked = sessionPick === null ? null : parseSession(sessionPick);
62
+ return picked ?? configDefault ?? floor;
63
+ }
64
+
65
+ // The theme name a render should use, as data.
30
66
  // [LAW:one-source-of-truth] The single definition of "which theme is effective";
31
67
  // every render derives basePalette through this, so the rendered palette can
32
- // never disagree with the chosen theme.
68
+ // never disagree with the chosen theme. The theme domain is OPEN — registry
69
+ // names, aliases, and per-session sentinels all resolve downstream — so its
70
+ // parse is identity: there is no membership to check here, and pretending
71
+ // otherwise would collapse names `paletteForThemeName` handles fine.
33
72
  export function effectiveThemeName(
34
73
  sessionTheme: string | null,
35
74
  globalsPalette: string | undefined,
36
75
  ): string {
37
- return sessionTheme ?? globalsPalette ?? "textual-dark";
76
+ return effectiveGlobal(
77
+ sessionTheme,
78
+ globalsPalette,
79
+ "textual-dark",
80
+ (raw) => raw,
81
+ );
38
82
  }
39
83
 
40
84
  function listThemeAliases(): readonly string[] {
@@ -53,20 +97,16 @@ export function listResolvablePaletteNames(): readonly string[] {
53
97
 
54
98
  // --- Per-config member selection ---
55
99
 
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.
100
+ // The `effectiveGlobal` instance for every selection whose domain is PER-CONFIG
101
+ // (declared in the config, not a registry-static list). `looks` and `presets`
102
+ // are both exactly this what differs between them is only the floor name and
103
+ // which map holds the members, i.e. DATA.
63
104
  //
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.
105
+ // The config default runs through the SAME membership parse the session pick
106
+ // does, one layer down: a `look:`/`preset:` naming a member the config no longer
107
+ // declares is no default at all, and collapses to the floor exactly as a stale
108
+ // session pick does. The loader cannot catch that for a per-config domain, so
109
+ // this resolution is where it is caught.
70
110
  //
71
111
  // The floor's membership is a load-time guarantee, not a runtime hope: the
72
112
  // bundled stdlib ships it and merge-by-name cannot remove it.
@@ -76,10 +116,14 @@ export function effectiveMemberName(
76
116
  floor: string,
77
117
  declared: Readonly<Record<string, unknown>>,
78
118
  ): string {
79
- const chosen = sessionPick ?? configDefault ?? floor;
80
- return Object.prototype.hasOwnProperty.call(declared, chosen)
81
- ? chosen
82
- : floor;
119
+ const member = (raw: string): string | null =>
120
+ Object.prototype.hasOwnProperty.call(declared, raw) ? raw : null;
121
+ return effectiveGlobal(
122
+ sessionPick,
123
+ member(configDefault ?? floor),
124
+ floor,
125
+ member,
126
+ );
83
127
  }
84
128
 
85
129
  // --- Look (theme-adaptation) identifiers ---
@@ -136,19 +180,28 @@ export function isStripStyle(value: string): value is StripStyle {
136
180
  return (STRIP_STYLES as readonly string[]).includes(value);
137
181
  }
138
182
 
139
- // The strip style a render should use, as data. [LAW:dataflow-not-control-flow]
140
- // [LAW:one-type-per-behavior] The exact shape of `effectiveThemeName`, one
141
- // dimension over: session choice over config default over the "powerline" floor,
142
- // no "if the session has a style" branch. A value outside the domain (a stale
143
- // SessionState entry from a prior option vocabulary) collapses to the floor —
144
- // `pickJoiner` would render it as powerline anyway, so the floor keeps the
145
- // returned type honest rather than silently widening.
183
+ // The strip style a render should use, as data.
184
+ // [LAW:one-type-per-behavior] `effectiveGlobal` over a closed registry-static
185
+ // vocabulary: the narrowing guard IS the parse. `pickJoiner` would render an
186
+ // unknown style as powerline anyway; parsing here keeps the returned TYPE
187
+ // honest rather than silently widening it.
188
+ //
189
+ // A stale SessionState entry (a member of a prior option vocabulary) is an
190
+ // ABSENT session pick, not a pick of the floor: it falls through to the config
191
+ // default, and only reaches "powerline" when the config declares no style
192
+ // either. That is a deliberate change from the pre-`effectiveGlobal` spelling,
193
+ // which collapsed straight to the floor and skipped the user's own declared
194
+ // default — a config saying `style: "capsule"` deserves capsule when a session
195
+ // entry goes stale, not powerline. Every field here now shares that one rule
196
+ // [LAW:one-source-of-truth]; test/session-globals.test.ts pins it with a stale
197
+ // pick over a valid non-floor default, the case the old tests never exercised.
146
198
  export function effectiveStripStyle(
147
199
  sessionStyle: string | null,
148
200
  globalsStyle: StripStyle | undefined,
149
201
  ): StripStyle {
150
- const chosen = sessionStyle ?? globalsStyle ?? "powerline";
151
- return isStripStyle(chosen) ? chosen : "powerline";
202
+ return effectiveGlobal(sessionStyle, globalsStyle, "powerline", (raw) =>
203
+ isStripStyle(raw) ? raw : null,
204
+ );
152
205
  }
153
206
 
154
207
  // --- Joiner charset identifiers ---
@@ -164,7 +217,11 @@ export function effectiveStripStyle(
164
217
  // joiner SHAPE, charset picks the glyph VALUES fed to it.
165
218
  // [config-only] Unlike STRIP_STYLES there is no SessionState/click half, so no
166
219
  // narrowing guard or effective* resolver — the config global over the default
167
- // is the whole resolution.
220
+ // is the whole resolution. That is a decision, not a gap: charset describes the
221
+ // TERMINAL (does its font carry the powerline private-use glyphs), not a taste.
222
+ // It does not vary session-to-session on one machine, so a per-session override
223
+ // would be a knob whose only honest setting is the one already in the config.
224
+ // Same for COLOR_COMPATIBILITIES below.
168
225
  export const CHARSETS = ["unicode", "ascii"] as const;
169
226
  export type Charset = (typeof CHARSETS)[number];
170
227
 
@@ -192,3 +249,88 @@ export const COLOR_COMPATIBILITIES = [
192
249
  "none",
193
250
  ] as const satisfies readonly ColorSystemSpec[];
194
251
  export type ColorCompatibility = (typeof COLOR_COMPATIBILITIES)[number];
252
+
253
+ // --- Layout globals (autoWrap, padding) ---
254
+ //
255
+ // These two DO have a session half, unlike charset/colorCompatibility above:
256
+ // wrapping and cell padding are how much bar you want on your screen right now
257
+ // — a taste that legitimately differs between one session in a wide terminal
258
+ // and another in a split pane. Their floors and domains live here, beside the
259
+ // other globals vocabularies, because both the config loader (range validation,
260
+ // JSON-schema emit) and the render layer need them and config must not import
261
+ // render [LAW:one-way-deps]. src/render/strip.ts re-exports them so render-layer
262
+ // callers keep their existing import site.
263
+
264
+ // [LAW:one-source-of-truth] The one statement of the globals.autoWrap default
265
+ // (on — current behavior).
266
+ export const DEFAULT_WRAP = true;
267
+
268
+ // [LAW:one-source-of-truth] The spelling of a boolean as a SessionState string.
269
+ // SessionState holds strings, so "true"/"false" is the wire vocabulary for every
270
+ // boolean globals field — the same two members the bundled default's
271
+ // `cycle: [...]` toggle writes and the parse below reads. Spelled once so a
272
+ // toggle cannot write a member the resolver refuses to parse.
273
+ export const BOOLEAN_MEMBERS = ["true", "false"] as const;
274
+
275
+ // [LAW:one-source-of-truth] The one statement of the globals.padding default
276
+ // (one space per side inside each segment cell — current behavior, matching the
277
+ // legacy display.padding).
278
+ export const DEFAULT_PADDING = 1;
279
+
280
+ // [LAW:one-source-of-truth] THE padding domain: an integer, inclusive both ends.
281
+ // Read by the loader's `padding` int spec (config-file values), by the bundled
282
+ // default's stepper actions (`min`/`max`, which bound what a click may persist),
283
+ // and by the session parse below. When those were three copies of `0`/`16`, a
284
+ // widened range could land on the file and miss the clicks.
285
+ export const PADDING_RANGE = { min: 0, max: 16 } as const;
286
+
287
+ // [LAW:parse-dont-validate] A SessionState string to a boolean, or null for
288
+ // anything else. `??` in effectiveGlobal (never `||`) is what keeps a parsed
289
+ // `false` a real answer rather than falling through to the default.
290
+ function parseBoolean(raw: string): boolean | null {
291
+ return raw === "true" ? true : raw === "false" ? false : null;
292
+ }
293
+
294
+ // [LAW:parse-dont-validate] A SessionState string to a padding value inside the
295
+ // one declared range. The digits test comes first because `Number("")` is 0 and
296
+ // `Number(" 3 ")` is 3 — an empty or padded entry would otherwise parse to a
297
+ // value nobody wrote.
298
+ function parsePadding(raw: string): number | null {
299
+ if (!/^\d+$/.test(raw)) return null;
300
+ const value = Number(raw);
301
+ return value >= PADDING_RANGE.min && value <= PADDING_RANGE.max
302
+ ? value
303
+ : null;
304
+ }
305
+
306
+ // Whether a render wraps over-wide rows, as data. The session's pick over the
307
+ // config default over the on floor — `effectiveGlobal` with a boolean domain.
308
+ export function effectiveAutoWrap(
309
+ sessionAutoWrap: string | null,
310
+ globalsAutoWrap: boolean | undefined,
311
+ ): boolean {
312
+ return effectiveGlobal(
313
+ sessionAutoWrap,
314
+ globalsAutoWrap,
315
+ DEFAULT_WRAP,
316
+ parseBoolean,
317
+ );
318
+ }
319
+
320
+ // The intra-cell padding a render uses, as data. A session value outside
321
+ // PADDING_RANGE falls through to the config default, the same rule every other
322
+ // field here follows: the gate already refuses out-of-range clicks, so a value
323
+ // that gets here is a stale entry from a narrower-since range, and the user's
324
+ // own default is the honest answer rather than a render at a width nobody
325
+ // chose.
326
+ export function effectivePadding(
327
+ sessionPadding: string | null,
328
+ globalsPadding: number | undefined,
329
+ ): number {
330
+ return effectiveGlobal(
331
+ sessionPadding,
332
+ globalsPadding,
333
+ DEFAULT_PADDING,
334
+ parsePadding,
335
+ );
336
+ }