@promptctl/cc-candybar 1.36.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.36.0",
3
+ "version": "1.37.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",
@@ -91,9 +91,9 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.36.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.36.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.36.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.36.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.37.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.37.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.37.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.37.0"
98
98
  }
99
99
  }
package/src/check.ts CHANGED
@@ -36,14 +36,14 @@ import {
36
36
  effectiveLookName,
37
37
  lookKeyByName,
38
38
  effectiveStripStyle,
39
+ effectiveAutoWrap,
40
+ effectivePadding,
39
41
  } from "./themes/policy.js";
40
42
  import { paletteForThemeName } from "./themes/palette-resolvers.js";
41
43
  import { effectivePresetName, presetGlobals } from "./config/presets.js";
42
44
  import {
43
45
  DEFAULT_CHARSET,
44
46
  DEFAULT_COLOR_COMPATIBILITY,
45
- DEFAULT_PADDING,
46
- DEFAULT_WRAP,
47
47
  } from "./render/strip.js";
48
48
  import type { EffectiveGlobals } from "./daemon/render-payload.js";
49
49
 
@@ -321,8 +321,8 @@ function loadRegisterRender(
321
321
  theme: effectiveThemeName(null, globals.palette),
322
322
  look: effectiveLookName(null, globals.look, config.looks),
323
323
  style: effectiveStripStyle(null, globals.style),
324
- autoWrap: globals.autoWrap ?? DEFAULT_WRAP,
325
- padding: globals.padding ?? DEFAULT_PADDING,
324
+ autoWrap: effectiveAutoWrap(null, globals.autoWrap),
325
+ padding: effectivePadding(null, globals.padding),
326
326
  charset: globals.charset ?? DEFAULT_CHARSET,
327
327
  colorCompatibility:
328
328
  globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
@@ -28,6 +28,7 @@
28
28
 
29
29
  import type { DslConfig, LayoutNode, SegmentDecl } from "./dsl-types.js";
30
30
  import { parseDslConfig } from "./dsl-loader.js";
31
+ import { BOOLEAN_MEMBERS, PADDING_RANGE } from "../themes/policy.js";
31
32
  import { mergeWithDefault } from "./loader/merge.js";
32
33
 
33
34
  // ─── Shared template fragments ───────────────────────────────────────────────
@@ -250,7 +251,11 @@ function etaHeatFg(etaRef: string, warnRef: string): string {
250
251
  // theme/style/look/preset (session `set` for a per-conversation preview, PLUS
251
252
  // a persist-forever twin — candybar-config-engine-71o.5, brandon-presets-0yk.3
252
253
  // — for pinning the choice as everyone's default), the four .3 globals
253
- // steppers (persist-only, no SessionState half at all), and one .6
254
+ // steppers (persist-only HERE `autoWrap`/`padding` have gained a session
255
+ // half since, but the drawer's own controls stay durable-by-default until
256
+ // candybar-settings-ui-aok.3 gives the panel one `persist?` toggle instead of
257
+ // a spelling per row; `charset`/`colorCompatibility` have no session half at
258
+ // all, by design), and one .6
254
259
  // segment-scoped persist control (directoryPaletteControl, persist-only like
255
260
  // the four steppers — a per-segment palette pin, not a whole-bar default).
256
261
  // Placed as a sibling in row 1's horizontal container, toggled from beside the
@@ -1222,8 +1227,11 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1222
1227
  // ── The four .3 globals steppers, folded into the settingsDrawer group
1223
1228
  // (candybar-config-engine-71o.4) alongside theme/style/look above. Each
1224
1229
  // pairs a `persist` control with a `↺` reset (docs' persist/reset
1225
- // convention) — these four have no SessionState half at all, so persist
1226
- // is their only seam, unlike theme/style/look's session `set`. Labels
1230
+ // convention) — `charset`/`colorCompatibility` have no SessionState half
1231
+ // at all, so persist is genuinely their only seam, while
1232
+ // `autoWrap`/`padding` do have one and are simply spelled durable here
1233
+ // (candybar-settings-ui-aok.3 owns whether the panel offers the choice).
1234
+ // Labels
1227
1235
  // read `.field.effective` (the daemon-resolved value BuildLineOptions
1228
1236
  // actually rendered with), never a restated literal.
1229
1237
  charsetControl: {
@@ -1421,8 +1429,11 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1421
1429
  // (candybar-config-engine-71o.4), decoupled by NAME from
1422
1430
  // charsetControl/colorCompatControl/wrapToggleControl/paddingControl
1423
1431
  // below. Unlike theme/style/look (a per-session experiment via `set`),
1424
- // these four have no SessionState half at all — .3's handoff established
1425
- // `persist` as their ONLY seam so every one of these writes the
1432
+ // these four are spelled durable: `charset`/`colorCompatibility` have no
1433
+ // SessionState half at all, and `autoWrap`/`padding` have one
1434
+ // (candybar-settings-ui-aok.2) but keep persist as the DRAWER's spelling
1435
+ // until .3 collapses the twins behind one `persist?`. So every one of
1436
+ // these writes the
1426
1437
  // config-file DEFAULT through the daemon-owned overrides layer (never the
1427
1438
  // hand-authored file itself), gated by the SAME deriveActionValidators
1428
1439
  // pass as a `set` (persist mirrors set's value-source shapes one for
@@ -1435,10 +1446,14 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1435
1446
  from: "colorCompatibilities",
1436
1447
  },
1437
1448
  resetColorCompat: { reset: "colorCompatibility" },
1438
- toggleWrapForever: { persist: "autoWrap", cycle: ["true", "false"] },
1449
+ // [LAW:one-source-of-truth] The toggle's members and the stepper's bounds
1450
+ // are the SAME literals the resolvers parse a session pick with
1451
+ // (BOOLEAN_MEMBERS / PADDING_RANGE in themes/policy.ts) — a click cannot
1452
+ // write a value the render's own resolution would then refuse.
1453
+ toggleWrapForever: { persist: "autoWrap", cycle: [...BOOLEAN_MEMBERS] },
1439
1454
  resetAutoWrap: { reset: "autoWrap" },
1440
- paddingDownForever: { persist: "padding", min: 0, max: 16, by: -1 },
1441
- paddingUpForever: { persist: "padding", min: 0, max: 16, by: 1 },
1455
+ paddingDownForever: { persist: "padding", ...PADDING_RANGE, by: -1 },
1456
+ paddingUpForever: { persist: "padding", ...PADDING_RANGE, by: 1 },
1442
1457
  resetPadding: { reset: "padding" },
1443
1458
 
1444
1459
  // [LAW:locality-or-seam] The segment-palette control's behavior
@@ -7,6 +7,7 @@ import { type Globals } from "../dsl-types.js";
7
7
  import {
8
8
  CHARSETS,
9
9
  COLOR_COMPATIBILITIES,
10
+ PADDING_RANGE,
10
11
  STRIP_STYLES,
11
12
  type ColorCompatibility,
12
13
  } from "../../themes/policy.js";
@@ -84,7 +85,11 @@ const GLOBALS_FIELDS: FieldSpecMap<Globals> = {
84
85
  // drive an unbounded `" ".repeat` allocation in the daemon
85
86
  // [LAW:no-silent-failure] — an absurd value is a loud load error, not a
86
87
  // silently-huge render.
87
- padding: optionalIntSpec({ min: 0, max: 16 }),
88
+ // [LAW:one-source-of-truth] The bound comes from PADDING_RANGE, the same
89
+ // literal the bundled default's stepper actions bound clicks by and the
90
+ // session-half parse admits values from — so the file, the click, and the
91
+ // session pick cannot end up honouring three different ranges.
92
+ padding: optionalIntSpec(PADDING_RANGE),
88
93
  // [LAW:types-are-the-program] Closed enum like `style`: the joiner glyph
89
94
  // vocabularies pickJoiner can render — validates by membership, emits a
90
95
  // JSON-Schema `enum` from the same CHARSETS literal.
@@ -168,9 +168,14 @@ export function presetGlobals(config: DslConfig, name: string): Globals {
168
168
  // that is a typo in a file the author can see and fix — but a value that
169
169
  // only exists because the overrides layer supplied it gets the SAME
170
170
  // treatment a stale SessionState pick already gets from effectivePresetName:
171
- // collapse to the floor, visibly, never throw. Dropping the field here (so
172
- // it never reaches validateConfig) is what makes that collapse happen for
173
- // this layer too, instead of a fatal error replacing the whole bar.
171
+ // an undeclared name is not a pick at all, so the resolution moves on to the
172
+ // next rung of the precedence chain visibly, never a throw. (For a stale
173
+ // SESSION pick that next rung is the config's own `globals.preset`, and only
174
+ // then the floor; for the field dropped here it is whatever the config file
175
+ // itself declares. Same rule, different starting rung —
176
+ // see effectiveGlobal in themes/policy.ts.) Dropping the field here (so it
177
+ // never reaches validateConfig) is what applies that rule to this layer too,
178
+ // instead of a fatal error replacing the whole bar.
174
179
  export function sanitizePersistedPresetOverride(
175
180
  globalsOverride: Partial<Globals>,
176
181
  declaredPresets: Readonly<Record<string, PresetDecl>>,
@@ -48,11 +48,13 @@ import type {
48
48
  // BuildLineOptions), so the value a trigger label displays and the value
49
49
  // that actually shaped the render can never disagree — the same reasoning
50
50
  // theme/look already followed, generalized to every globals field a menu or
51
- // stepper can persist. `theme`/`look`/`style` compose SessionState over the
52
- // config default (a session pick can diverge from the persisted default for
53
- // its own session); `charset`/`colorCompatibility`/`autoWrap`/`padding` have
54
- // no SessionState half today, so their "effective" value is just the
55
- // resolved config global over its floor constant.
51
+ // stepper can persist. `theme`/`look`/`style`/`autoWrap`/`padding` compose
52
+ // SessionState over the config default (a session pick can diverge from the
53
+ // persisted default for its own session); `charset` and `colorCompatibility`
54
+ // have no SessionState half they describe the terminal (glyph coverage,
55
+ // colour depth) rather than a per-session taste, so the resolved config global
56
+ // over its floor constant is their whole resolution. See CHARSETS in
57
+ // themes/policy.ts for why that is a decision rather than a gap.
56
58
  export interface EffectiveGlobals {
57
59
  readonly theme: string;
58
60
  readonly look: string;
@@ -65,6 +65,8 @@ import { expandHome } from "../config/dsl-loader.js";
65
65
  import { renderDsl } from "../dsl/render.js";
66
66
  import {
67
67
  effectiveStripStyle,
68
+ effectiveAutoWrap,
69
+ effectivePadding,
68
70
  effectiveThemeName,
69
71
  effectiveLookName,
70
72
  lookKeyByName,
@@ -867,11 +869,12 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
867
869
  //
868
870
  // [LAW:one-source-of-truth] Every globals field a menu/stepper can
869
871
  // persist (candybar-config-engine-71o.3), resolved ONCE into one
870
- // struct: theme/look/style compose the session's click (SessionState)
871
- // over the config default over a floor — a click recolors/reshapes the
872
- // whole bar on the next render; charset/colorCompatibility/autoWrap/
873
- // padding have no SessionState half today, so their "effective" value
874
- // is just the config global over its floor constant. This struct feeds
872
+ // struct: theme/look/style/autoWrap/padding compose the session's click
873
+ // (SessionState) over the config default over a floor — a click
874
+ // recolors/reshapes the whole bar on the next render; charset and
875
+ // colorCompatibility describe the TERMINAL rather than a taste and so
876
+ // have no SessionState half at all, making the config global over its
877
+ // floor constant their whole resolution. This struct feeds
875
878
  // BOTH the payload's `*.effective` fields (trigger labels) AND
876
879
  // renderOpts below (the actual render) — one resolution, two readers,
877
880
  // so a label can never disagree with what was rendered.
@@ -918,8 +921,18 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
918
921
  sessionState.get(req.hookData.session_id, "style"),
919
922
  globals.style,
920
923
  ),
921
- autoWrap: globals.autoWrap ?? DEFAULT_WRAP,
922
- padding: globals.padding ?? DEFAULT_PADDING,
924
+ autoWrap: effectiveAutoWrap(
925
+ sessionState.get(req.hookData.session_id, "autoWrap"),
926
+ globals.autoWrap,
927
+ ),
928
+ padding: effectivePadding(
929
+ sessionState.get(req.hookData.session_id, "padding"),
930
+ globals.padding,
931
+ ),
932
+ // charset and colorCompatibility have no session half by design —
933
+ // they describe the terminal, not a taste (see CHARSETS in
934
+ // themes/policy.ts) — so the config global over its floor is their
935
+ // whole resolution.
923
936
  charset: globals.charset ?? DEFAULT_CHARSET,
924
937
  colorCompatibility:
925
938
  globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
package/src/demo/dsl.ts CHANGED
@@ -33,6 +33,8 @@ import { listResolvablePaletteNames } from "../themes/policy.js";
33
33
  import {
34
34
  effectiveThemeName,
35
35
  effectiveLookName,
36
+ effectiveAutoWrap,
37
+ effectivePadding,
36
38
  lookKeyByName,
37
39
  paletteForThemeName,
38
40
  } from "../themes/index.js";
@@ -41,9 +43,7 @@ import { registerDslConfig, renderDsl } from "../dsl/render.js";
41
43
  import {
42
44
  DEFAULT_CHARSET,
43
45
  DEFAULT_COLOR_COMPATIBILITY,
44
- DEFAULT_PADDING,
45
46
  DEFAULT_TERMINAL_WIDTH,
46
- DEFAULT_WRAP,
47
47
  } from "../render/strip.js";
48
48
  import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
49
49
 
@@ -139,10 +139,11 @@ try {
139
139
  width: applyClaudeCodeReserve(
140
140
  process.stdout.columns ?? DEFAULT_TERMINAL_WIDTH,
141
141
  ),
142
- // Same resolution the daemon applies: the config global over the
143
- // default-on floor.
144
- wrap: globals.autoWrap ?? DEFAULT_WRAP,
145
- padding: globals.padding ?? DEFAULT_PADDING,
142
+ // Same resolvers the daemon applies, with a null session pick — the
143
+ // demo has no SessionState, so both land on the config default over
144
+ // their floor.
145
+ wrap: effectiveAutoWrap(null, globals.autoWrap),
146
+ padding: effectivePadding(null, globals.padding),
146
147
  charset: globals.charset ?? DEFAULT_CHARSET,
147
148
  },
148
149
  undefined,
@@ -37,16 +37,12 @@ export type { Charset, ColorCompatibility, StripStyle };
37
37
  // applyClaudeCodeReserve from src/utils/terminal-width).
38
38
  export const DEFAULT_TERMINAL_WIDTH = 120;
39
39
 
40
- // [LAW:one-source-of-truth] The one statement of the globals.autoWrap
41
- // default (on current behavior). Every resolver of the config global
42
- // (`globals.autoWrap ?? DEFAULT_WRAP`) derives from this constant.
43
- export const DEFAULT_WRAP = true;
44
-
45
- // [LAW:one-source-of-truth] The one statement of the globals.padding
46
- // default (one space per side inside each segment cell — current behavior,
47
- // matching the legacy display.padding). Every resolver of the config global
48
- // (`globals.padding ?? DEFAULT_PADDING`) derives from this constant.
49
- export const DEFAULT_PADDING = 1;
40
+ // [LAW:one-source-of-truth] autoWrap's and padding's defaults live in
41
+ // themes/policy.ts beside their resolvers and padding's range, because the
42
+ // config loader needs them too and config must not import render
43
+ // [LAW:one-way-deps]. Re-exported here on the same terms as the types above, so
44
+ // render-layer consumers keep importing them from the strip module.
45
+ export { DEFAULT_WRAP, DEFAULT_PADDING } from "../themes/policy.js";
50
46
 
51
47
  // [LAW:one-source-of-truth] The one statement of the globals.charset default
52
48
  // (powerline unicode glyphs — current behavior, matching the legacy
@@ -9,6 +9,8 @@ export {
9
9
  effectiveLookName,
10
10
  lookKeyByName,
11
11
  effectiveStripStyle,
12
+ effectiveAutoWrap,
13
+ effectivePadding,
12
14
  isStripStyle,
13
15
  listResolvablePaletteNames,
14
16
  STRIP_STYLES,
@@ -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
+ }