@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.35.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.35.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.35.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.35.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.35.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
@@ -50,6 +50,8 @@ import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
50
50
  import { synthesizeMenuDecls } from "./loader/menu-synth.js";
51
51
  import { synthesizeEditModeToggle } from "./loader/edit-mode.js";
52
52
  import { synthesizeEditChrome } from "./edit-chrome.js";
53
+ import { SETTINGS_NS, synthesizeSettingsMenu } from "./settings-menu.js";
54
+ import { reservedNamespaceCollisions } from "./loader/reserved-namespace.js";
53
55
  import { validateActions } from "./loader/actions.js";
54
56
  import { validateLooks } from "./loader/looks.js";
55
57
  import { validatePresets } from "./loader/presets.js";
@@ -152,7 +154,12 @@ export function validateConfig(
152
154
  // into freshly-synthesized segments, actions into freshly-synthesized
153
155
  // actions) is correct by construction and does not re-enter cross-ref/
154
156
  // cycle checking, exactly as group/menu synthesis's output doesn't either.
155
- const withChrome = synthesizeEditChrome(config);
157
+ // [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.1's global settings
158
+ // menu, spliced BEFORE edit chrome so edit chrome walks the final content tree
159
+ // and treats the menu's reserved `settings.` names as chrome-exempt — the
160
+ // full ordering argument lives in settings-menu.ts's header, beside the pass
161
+ // it governs.
162
+ const withChrome = synthesizeEditChrome(synthesizeSettingsMenu(config));
156
163
  return withChrome as ValidatedConfig;
157
164
  }
158
165
 
@@ -295,6 +302,18 @@ function validateTopLevel(
295
302
  // affordances) runs later, in validateConfig, once the merged/preset-
296
303
  // resolved/rootOps-replayed tree exists to derive it from.
297
304
  synthesizeEditModeToggle(ctx, out);
305
+ // [LAW:one-source-of-truth] The global settings menu reserves its namespace
306
+ // here and synthesizes NOTHING here: the tree it must be present in only
307
+ // exists after merge (a user `root` replaces the default's), so the artifacts
308
+ // are minted in validateConfig. The reservation is unconditional all the same,
309
+ // mirroring every other namespace above — "you never author settings.*" is a
310
+ // stable contract, not a rule that switches on when the pass happens to fire.
311
+ reservedNamespaceCollisions(
312
+ ctx,
313
+ out,
314
+ SETTINGS_NS,
315
+ "the global settings menu",
316
+ );
298
317
  return out;
299
318
  }
300
319
 
@@ -40,6 +40,7 @@ import {
40
40
  EDIT_TOGGLE_ACTION,
41
41
  } from "./loader/edit-mode.js";
42
42
  import { GROUP_NS } from "./loader/layout.js";
43
+ import { SETTINGS_NS } from "./settings-menu.js";
43
44
  import {
44
45
  menuActionName,
45
46
  menuMember,
@@ -61,7 +62,11 @@ import {
61
62
  // knowledge, so the banner below is spliced UNCONDITIONALLY for every
62
63
  // preset — same shape, every reload — and this predicate is what decides
63
64
  // whether it's visible, never a branch in this synthesis pass.
64
- const PRESET_CUSTOMIZED_GATE = "{{ .preset.customized }}";
65
+ // [LAW:one-source-of-truth] Exported: test/helpers/ambient-chrome.ts filters this
66
+ // ensured name out of "what did the AUTHOR declare" assertions and must read the
67
+ // same string, never a second copy that a rename here would leave behind.
68
+ export const PRESET_CUSTOMIZED_VAR = "preset.customized";
69
+ const PRESET_CUSTOMIZED_GATE = `{{ .${PRESET_CUSTOMIZED_VAR} }}`;
65
70
 
66
71
  // [LAW:one-source-of-truth] group/menu-synthesized segments (`groups.`/
67
72
  // `menus.`) and edit mode's own trigger/chrome (`edit.`) are structural —
@@ -73,7 +78,12 @@ function isChromeExempt(name: string): boolean {
73
78
  return (
74
79
  name.startsWith(EDIT_NS) ||
75
80
  name.startsWith(MENU_NS) ||
76
- name.startsWith(GROUP_NS)
81
+ name.startsWith(GROUP_NS) ||
82
+ // The global settings menu is the entry point edit mode is REACHED from
83
+ // (candybar-settings-ui-aok.1) — offering a `-` beside it would let one
84
+ // click delete the door back in, the self-lockout the `toolbar` trigger's
85
+ // placement was chosen to avoid. Structural, like the three above it.
86
+ name.startsWith(SETTINGS_NS)
77
87
  );
78
88
  }
79
89
 
@@ -93,6 +103,11 @@ function escapeTemplateLiteral(s: string): string {
93
103
  // so cross-preset names (disambiguated by `presetIdent`) can never collide.
94
104
  interface ChromeArtifacts {
95
105
  readonly variables: Record<string, VariableDecl>;
106
+ // [LAW:no-silent-failure] Declarations this synthesis DEPENDS on rather than
107
+ // OWNS: merged UNDER the config so a user's own declaration of the same name
108
+ // wins, unlike `variables` above, which lives in a reserved namespace no user
109
+ // may write and therefore merges over.
110
+ readonly ensured: Record<string, VariableDecl>;
96
111
  readonly actions: Record<string, ActionDeclType>;
97
112
  readonly segments: Record<string, SegmentDecl>;
98
113
  }
@@ -219,7 +234,18 @@ function spliceContainer(
219
234
  posCounter: { n: number },
220
235
  ): ContainerNode {
221
236
  const children: LayoutNode[] = [];
222
- for (const child of node.children) {
237
+ // [LAW:one-source-of-truth] The trailing `+`'s position is "after the last
238
+ // CONTENT segment", not "after the last child". Those coincided until a
239
+ // synthesis started appending exempt chrome (the global settings menu,
240
+ // candybar-settings-ui-aok.1) to a row's end, at which point reading the last
241
+ // child silently dropped the row's final insert point — N segments offering
242
+ // only N insert points instead of N+1.
243
+ const lastContent = node.children.reduce(
244
+ (idx, child, i) =>
245
+ child.kind === "segment" && !isChromeExempt(child.name) ? i : idx,
246
+ -1,
247
+ );
248
+ for (const [i, child] of node.children.entries()) {
223
249
  if (child.kind === "container") {
224
250
  children.push(
225
251
  spliceContainer(
@@ -250,24 +276,19 @@ function spliceContainer(
250
276
  );
251
277
  children.push(child);
252
278
  children.push(removeChrome(presetIdent, rootOpsKey, child.name, artifacts));
253
- }
254
- const last = node.children[node.children.length - 1];
255
- if (
256
- last !== undefined &&
257
- last.kind === "segment" &&
258
- !isChromeExempt(last.name)
259
- ) {
260
- children.push(
261
- insertChrome(
262
- presetIdent,
263
- rootOpsKey,
264
- String(posCounter.n++),
265
- domainName,
266
- last.name,
267
- "after",
268
- artifacts,
269
- ),
270
- );
279
+ if (i === lastContent) {
280
+ children.push(
281
+ insertChrome(
282
+ presetIdent,
283
+ rootOpsKey,
284
+ String(posCounter.n++),
285
+ domainName,
286
+ child.name,
287
+ "after",
288
+ artifacts,
289
+ ),
290
+ );
291
+ }
271
292
  }
272
293
  return { ...node, children };
273
294
  }
@@ -301,6 +322,20 @@ function prependCustomizedBanner(
301
322
  // edited down to zero non-exempt segments (no removeChrome/insertChrome
302
323
  // persist actions left to register it) doesn't orphan this exact click.
303
324
  artifacts.actions[actionName] = { reset: rootOpsKey };
325
+ // [LAW:one-source-of-truth] The banner reads `.preset.customized`, so THIS
326
+ // pass is what requires that variable — not whichever config happens to
327
+ // declare it. The bundled default does, which is why the dependency stayed
328
+ // invisible until the global settings menu made edit mode reachable from
329
+ // configs that never declared it, and the missing field surfaced as a ⚠ on
330
+ // the bar. Ensured, never overridden: a user declaration of the same name
331
+ // wins (see the merge in synthesizeEditChrome), so this only supplies the
332
+ // floor the synthesis itself depends on.
333
+ artifacts.ensured[PRESET_CUSTOMIZED_VAR] = {
334
+ kind: "input",
335
+ path: PRESET_CUSTOMIZED_VAR,
336
+ type: "boolean",
337
+ default: false,
338
+ };
304
339
  const label = escapeTemplateLiteral(presetName);
305
340
  artifacts.segments[chromeSegName] = {
306
341
  template: `{{ action "${actionName}" "↺ ${label} customized" }}`,
@@ -391,6 +426,7 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
391
426
  if (!(EDIT_TOGGLE_ACTION in config.actions)) return config;
392
427
  const artifacts: ChromeArtifacts = {
393
428
  variables: {},
429
+ ensured: {},
394
430
  actions: {},
395
431
  segments: {},
396
432
  };
@@ -404,7 +440,11 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
404
440
  }
405
441
  return {
406
442
  ...config,
407
- variables: { ...config.variables, ...artifacts.variables },
443
+ variables: {
444
+ ...artifacts.ensured,
445
+ ...config.variables,
446
+ ...artifacts.variables,
447
+ },
408
448
  actions: { ...config.actions, ...artifacts.actions },
409
449
  segments: { ...config.segments, ...artifacts.segments },
410
450
  presets,
@@ -29,6 +29,13 @@ import {
29
29
  import { listGlobalsFieldNames } from "./globals.js";
30
30
  import { parsePersistTarget } from "./persist-target.js";
31
31
  import { presetNames } from "../presets.js";
32
+ import {
33
+ canHostSessionState,
34
+ countAnchors,
35
+ isSettingsAnchor,
36
+ SESSION_ID_VAR,
37
+ SETTINGS_ANCHOR,
38
+ } from "../settings-menu.js";
32
39
  import { ident } from "../ident.js";
33
40
  import { findKeyLine } from "./diagnostics.js";
34
41
  import { isPlainObject, type ValidateCtx } from "./validate-core.js";
@@ -248,11 +255,30 @@ export function validateCrossReferences(
248
255
  // never a second traversal that could learn a different idea of what a valid
249
256
  // layout is. This is what makes `cc-candybar check` catch a preset staging a
250
257
  // segment nobody declared.
258
+ //
259
+ // [LAW:single-enforcer] The menu precondition is asked once, of the same
260
+ // predicate synthesizeSettingsMenu gates on, and read by every tree walked.
261
+ const menuWillSynthesize = canHostSessionState(cfg);
251
262
  const checkLayoutTree = (
252
263
  root: LayoutNode,
253
264
  layoutKey: string,
254
265
  layoutLine: number | undefined,
255
266
  ): void => {
267
+ // [LAW:one-source-of-truth] The global settings menu's anchor is a POSITION
268
+ // an author may place and this walk must therefore accept, even though no
269
+ // config declares a segment by that name — synthesizeSettingsMenu provides
270
+ // it unconditionally, immediately after these checks pass. Two placements is
271
+ // the real error: one state key holds one open state, so a second anchor
272
+ // would be a second toggle writing one disclosure, with two bodies claiming
273
+ // to be it. Counted over the SAME census the synthesis reads, so "placed"
274
+ // means one thing [LAW:single-enforcer].
275
+ if (countAnchors(root) > 1) {
276
+ ctx.issues.push({
277
+ path: layoutKey,
278
+ message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}" ${countAnchors(root)} times — it may appear at most once per layout (it is one disclosure, and one state key holds one open state). Remove all but the placement you want; removing every placement puts the menu at its default position.`,
279
+ line: layoutLine,
280
+ });
281
+ }
256
282
  for (const node of walkNodes(root)) {
257
283
  // [LAW:locality-or-seam] A node's `when` reads the global scope (bare
258
284
  // globals + namespaced segment vars) — the same existence-check shape as a
@@ -263,6 +289,24 @@ export function validateCrossReferences(
263
289
  });
264
290
  }
265
291
  if (node.kind !== "segment") continue;
292
+ if (isSettingsAnchor(node.name)) {
293
+ // [LAW:one-source-of-truth] Accepting the anchor asserts that
294
+ // synthesizeSettingsMenu WILL declare a segment by this name, so the
295
+ // acceptance reads the very predicate that pass decides by rather than
296
+ // assuming its answer. When it is false the reference is genuinely
297
+ // dangling, and the error names the unmet precondition: the author
298
+ // placed a documented anchor, they did not typo a segment name, and the
299
+ // generic "does not match any declared segment" would teach the wrong
300
+ // lesson [LAW:no-silent-failure].
301
+ if (!menuWillSynthesize) {
302
+ ctx.issues.push({
303
+ path: layoutKey,
304
+ message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}", but this config declares no "${SESSION_ID_VAR}" variable — the menu is a click surface and every click composes a URL from "${SESSION_ID_VAR}", so it is not synthesized for a config without it. Declare a "${SESSION_ID_VAR}" variable (any config merged onto the bundled default inherits one) or remove the anchor placement.`,
305
+ line: layoutLine,
306
+ });
307
+ }
308
+ continue;
309
+ }
266
310
  if (!Object.prototype.hasOwnProperty.call(cfg.segments, node.name)) {
267
311
  const renamed = RENAMED_SEGMENTS[node.name];
268
312
  const hint =
@@ -29,6 +29,20 @@
29
29
  // of this pass caused. The reserved namespace stays reserved unconditionally
30
30
  // (mirroring reservedNamespaceCollisions' own contract); only the SYNTHESIS is
31
31
  // conditional.
32
+ //
33
+ // WHAT candybar-settings-ui-aok.1 CHANGED, and what it did not: the gate above
34
+ // is intact and still the only way edit mode is reached — but it now has a
35
+ // PERMANENT DEMANDER. synthesizeSettingsMenu mints a `settings.edit` segment
36
+ // referencing `edit.toggle` into every config it can host, so in practice the
37
+ // demand is satisfied for essentially every config a user writes, and reading
38
+ // this section as "most bars carry no edit mode" is no longer true. The
39
+ // separation the gate protects still holds exactly where it always mattered:
40
+ // the menu declines to synthesize for a config with no `session.id`
41
+ // (canHostSessionState in settings-menu.ts), which is precisely the static,
42
+ // non-interactive bar this comment was written to keep clean. Production blast
43
+ // radius was nil either way — the bundled default's `toolbar` segment already
44
+ // referenced `edit.toggle`, so every config merging it already demanded edit
45
+ // mode before the menu existed.
32
46
 
33
47
  import { createEngine } from "@promptctl/go-template-js";
34
48
  import type { Mutable, ValidateCtx } from "./validate-core.js";
@@ -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>>,