@promptctl/cc-candybar 1.38.0 → 1.40.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.38.0",
3
+ "version": "1.40.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.38.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.38.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.38.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.38.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.40.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.40.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.40.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.40.0"
98
98
  }
99
99
  }
@@ -1648,7 +1648,7 @@
1648
1648
  },
1649
1649
  "preset": {
1650
1650
  "not": {},
1651
- "description": "not allowed inside a preset — a preset cannot select a preset"
1651
+ "description": "not allowed here — a preset cannot select a preset"
1652
1652
  },
1653
1653
  "style": {
1654
1654
  "enum": [
@@ -1686,6 +1686,66 @@
1686
1686
  "additionalProperties": false
1687
1687
  }
1688
1688
  },
1689
+ "editGlobals": {
1690
+ "type": "object",
1691
+ "properties": {
1692
+ "default_bg": {
1693
+ "type": "string"
1694
+ },
1695
+ "default_fg": {
1696
+ "type": "string"
1697
+ },
1698
+ "default_empty_value": {
1699
+ "type": "string"
1700
+ },
1701
+ "default_separator": {
1702
+ "type": "string"
1703
+ },
1704
+ "default_truncate_marker": {
1705
+ "type": "string"
1706
+ },
1707
+ "palette": {
1708
+ "type": "string"
1709
+ },
1710
+ "look": {
1711
+ "type": "string"
1712
+ },
1713
+ "preset": {
1714
+ "not": {},
1715
+ "description": "not allowed here — the editGlobals fragment cannot select a preset"
1716
+ },
1717
+ "style": {
1718
+ "enum": [
1719
+ "powerline",
1720
+ "capsule",
1721
+ "plain"
1722
+ ]
1723
+ },
1724
+ "autoWrap": {
1725
+ "type": "boolean"
1726
+ },
1727
+ "padding": {
1728
+ "type": "integer",
1729
+ "minimum": 0,
1730
+ "maximum": 16
1731
+ },
1732
+ "charset": {
1733
+ "enum": [
1734
+ "unicode",
1735
+ "ascii"
1736
+ ]
1737
+ },
1738
+ "colorCompatibility": {
1739
+ "enum": [
1740
+ "truecolor",
1741
+ "256",
1742
+ "ansi",
1743
+ "none"
1744
+ ]
1745
+ }
1746
+ },
1747
+ "additionalProperties": false
1748
+ },
1689
1749
  "helpers": {
1690
1750
  "type": "object",
1691
1751
  "additionalProperties": {
package/src/check.ts CHANGED
@@ -31,21 +31,12 @@ import { SourceRegistry } from "./var-system/sources.js";
31
31
  import { SessionState } from "./daemon/session-state.js";
32
32
  import { registerDslConfig, renderDsl } from "./dsl/render.js";
33
33
  import { deriveActionValidators } from "./daemon/verbs/state-validators.js";
34
- import {
35
- effectiveThemeName,
36
- effectiveLookName,
37
- lookKeyByName,
38
- effectiveStripStyle,
39
- effectiveAutoWrap,
40
- effectivePadding,
41
- } from "./themes/policy.js";
34
+ import { lookKeyByName } from "./themes/policy.js";
42
35
  import { paletteForThemeName } from "./themes/palette-resolvers.js";
43
- import { effectivePresetName, presetGlobals } from "./config/presets.js";
44
36
  import {
45
- DEFAULT_CHARSET,
46
- DEFAULT_COLOR_COMPATIBILITY,
47
- } from "./render/strip.js";
48
- import type { EffectiveGlobals } from "./daemon/render-payload.js";
37
+ resolveEffectiveGlobals,
38
+ type EffectiveGlobals,
39
+ } from "./daemon/render-payload.js";
49
40
 
50
41
  // [LAW:no-ambient-temporal-coupling] A fixed width keeps the verdict a function
51
42
  // of the config alone, not of whichever terminal invoked the check. Templates
@@ -302,14 +293,13 @@ function loadRegisterRender(
302
293
  // globals feed every field below, the SAME order the daemon resolves in
303
294
  // (server.ts) — so `check` renders the arrangement a fresh session actually
304
295
  // opens in, not the config's un-presetted root.
305
- const preset = effectivePresetName(
306
- null,
307
- config.globals.preset,
308
- config.presets,
309
- );
310
- const globals = presetGlobals(config, preset);
311
- const effective: EffectiveGlobals = {
312
- preset,
296
+ const effective: EffectiveGlobals = resolveEffectiveGlobals(
297
+ config,
298
+ // A fresh session: no clicked theme/style/look, and edit mode off. The
299
+ // resolution is THE daemon's (resolveEffectiveGlobals), not a copy that
300
+ // agrees with it today — which is the whole reason check renders what the
301
+ // daemon would render rather than something adjacent.
302
+ () => null,
313
303
  // [LAW:no-silent-failure] `check` validates a config file in isolation
314
304
  // — it never reads the daemon-owned overrides file, so there is no
315
305
  // rootOps log to be customized BY. false is the honest value for THIS
@@ -317,16 +307,8 @@ function loadRegisterRender(
317
307
  // has never customized anything. A second render pass below also
318
308
  // exercises `true`, so a `.preset.customized`-gated segment still
319
309
  // gets checked — just not through this value.
320
- presetCustomized: false,
321
- theme: effectiveThemeName(null, globals.palette),
322
- look: effectiveLookName(null, globals.look, config.looks),
323
- style: effectiveStripStyle(null, globals.style),
324
- autoWrap: effectiveAutoWrap(null, globals.autoWrap),
325
- padding: effectivePadding(null, globals.padding),
326
- charset: globals.charset ?? DEFAULT_CHARSET,
327
- colorCompatibility:
328
- globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
329
- };
310
+ () => false,
311
+ );
330
312
  // [LAW:no-silent-failure] A segment whose template THROWS while evaluating
331
313
  // (an `{{ action }}` display-arity mismatch, a MissingFieldError from a
332
314
  // partially-declared variable) renders as a visible ⚠ error cell — partial
@@ -352,6 +334,7 @@ function loadRegisterRender(
352
334
  paletteForThemeName(payloadEffective.theme),
353
335
  {
354
336
  style: payloadEffective.style,
337
+ separator: payloadEffective.separator,
355
338
  width: CHECK_WIDTH,
356
339
  colorCompatibility: payloadEffective.colorCompatibility,
357
340
  wrap: payloadEffective.autoWrap,
@@ -28,6 +28,14 @@
28
28
 
29
29
  import type { DslConfig, LayoutNode, SegmentDecl } from "./dsl-types.js";
30
30
  import { parseDslConfig } from "./dsl-loader.js";
31
+ // [LAW:one-source-of-truth] The bundled drawer's menus author their own
32
+ // disclosure glyphs now that `{{ menu }}` appends none. Interpolating the
33
+ // shared constants keeps the stdlib bar reading like every other disclosure
34
+ // without restating the vocabulary in three string literals.
35
+ import {
36
+ DISCLOSURE_GLYPH_CLOSED,
37
+ DISCLOSURE_GLYPH_OPEN,
38
+ } from "./disclosure.js";
31
39
  import { mergeWithDefault } from "./loader/merge.js";
32
40
 
33
41
  // ─── Shared template fragments ───────────────────────────────────────────────
@@ -1133,14 +1141,16 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1133
1141
  charsetControl: {
1134
1142
  template:
1135
1143
  "{{ .charset.effective }} " +
1136
- '{{ menu "applyCharsetForever" }} {{ action "resetCharset" "↺" }}',
1144
+ `{{ menu "applyCharsetForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1145
+ '{{ action "resetCharset" "↺" }}',
1137
1146
  bg: "surface",
1138
1147
  fg: "foreground",
1139
1148
  },
1140
1149
  colorCompatControl: {
1141
1150
  template:
1142
1151
  "{{ .colorCompatibility.effective }} " +
1143
- '{{ menu "applyColorCompatForever" }} {{ action "resetColorCompat" "↺" }}',
1152
+ `{{ menu "applyColorCompatForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1153
+ '{{ action "resetColorCompat" "↺" }}',
1144
1154
  bg: "surface",
1145
1155
  fg: "foreground",
1146
1156
  },
@@ -1168,7 +1178,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1168
1178
  directoryPaletteControl: {
1169
1179
  template:
1170
1180
  "🎨 directory " +
1171
- '{{ menu "applyDirectoryPaletteForever" }} ' +
1181
+ `{{ menu "applyDirectoryPaletteForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1172
1182
  '{{ action "resetDirectoryPalette" "↺" }}',
1173
1183
  bg: "surface",
1174
1184
  fg: "foreground",
@@ -1465,6 +1475,24 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1465
1475
  },
1466
1476
  },
1467
1477
 
1478
+ // [LAW:one-source-of-truth] What edit mode LOOKS like, as config a user can
1479
+ // retune — the whole point of candybar-settings-ui-aok.5, whose predecessor
1480
+ // was renderer constants. Powerline chrome exists to make adjacent segments
1481
+ // read as one continuous strip, which is precisely the wrong signal while a
1482
+ // user is trying to see where one segment ends and the next begins; `plain`
1483
+ // trades the caps for a visible separator between every cell.
1484
+ //
1485
+ // The separator is stated rather than left to PlainJoiner's own default: this
1486
+ // fragment layers over the user's globals, so a config that set
1487
+ // `default_separator` for its own powerline bar would otherwise carry that
1488
+ // choice into edit mode, where the separator is the entire affordance. " | "
1489
+ // (not "│") because it must survive `charset: "ascii"` — the fragment does
1490
+ // not, and should not, know the terminal's glyph coverage.
1491
+ editGlobals: {
1492
+ style: "plain",
1493
+ default_separator: " | ",
1494
+ },
1495
+
1468
1496
  // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
1469
1497
  // for the cost/token/budget family lives here as named template helpers, each
1470
1498
  // DEFINED ONCE and called from every segment via `{{ template "name" .arg }}`
@@ -32,9 +32,63 @@ export const DISCLOSURE_CLOSED = "closed";
32
32
  // [LAW:representation] The disclosure glyph vocabulary — one pair for the whole
33
33
  // bar so every disclosure reads the same (trailing the label/content it gates,
34
34
  // per pdu.8): collapsed ▸, expanded ▾.
35
+ //
36
+ // [LAW:one-source-of-truth] These are the AUTHORED default, never an emission.
37
+ // Every disclosure splices them into the template it synthesizes — group sugar
38
+ // (loader/layout.ts), the settings menu (settings-menu.ts), the bundled drawer
39
+ // — and a hand-authored config writes whichever glyph it likes, because the
40
+ // trigger's text is a display bound at the call site like any other. Until
41
+ // candybar-settings-ui-aok.4 `{{ menu }}` was the exception, appending ▸/▾ from
42
+ // its own runtime where no author could see or decline it, which is how edit
43
+ // mode's `+` came to render `+▸`.
35
44
  export const DISCLOSURE_GLYPH_CLOSED = "▸";
36
45
  export const DISCLOSURE_GLYPH_OPEN = "▾";
37
46
 
47
+ // [LAW:one-source-of-truth] The glyph that CLOSES an open disclosure. The
48
+ // picker body's ✕ has always been this; it lives here now because a trigger can
49
+ // wear it too — edit mode's `+` does, since a `+` whose only open-state cue was
50
+ // the ▸ this change removed would otherwise be indistinguishable from its
51
+ // siblings (three insertion points render byte-identically when one is open,
52
+ // and their dropped bodies are identical too, so row 0 is the only place the
53
+ // answer can live). Two affordances, one meaning, one glyph.
54
+ export const DISCLOSURE_GLYPH_CLOSE = "✕";
55
+
56
+ // [LAW:single-enforcer] THE display rule every multi-state trigger obeys: bind
57
+ // one display per member, or ONE static display that shows in every state. It
58
+ // lives here, beside the toggle machinery, because both disclosure kinds need
59
+ // it at different times — the loader can count a call's arguments statically
60
+ // and wants an ISSUE to report, the renderer holds the evaluated displays and
61
+ // wants to THROW — and a rule spelled once in each place is a rule that drifts.
62
+ // A `{{ menu }}` folds through it with two members (its `[closed, member]`
63
+ // cycle) and a cycle `{{ action }}` with as many as it declares; nothing about
64
+ // the rule is disclosure-specific beyond who calls it.
65
+ export function cycleDisplayIssue(
66
+ subject: string,
67
+ count: number,
68
+ members: number,
69
+ ): string | undefined {
70
+ if (count === 0) return `${subject} needs a display (the clickable text)`;
71
+ if (count !== 1 && count !== members) {
72
+ return `${subject} cycles ${members} members; bind one display per member (${members}) or one static display, got ${count}`;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ // [LAW:dataflow-not-control-flow] Which display shows is a pure function of
78
+ // (bound displays, current member index): a single static display shows in
79
+ // every state, per-member displays index by the state. Throws the one rule's
80
+ // text rather than silently dropping or repeating an argument.
81
+ export function pickCycleDisplay(
82
+ subject: string,
83
+ displays: readonly string[],
84
+ members: number,
85
+ index: number,
86
+ ): string {
87
+ const issue = cycleDisplayIssue(subject, displays.length, members);
88
+ if (issue !== undefined) throw new Error(issue);
89
+ return displays.length === 1 ? displays[0]! : displays[index]!;
90
+ }
91
+
38
92
  // [LAW:single-enforcer] THE backing `state` variable a disclosure key implies:
39
93
  // it holds the open member's name and defaults to `def` (the CLOSED sentinel for
40
94
  // an independent disclosure, or an initially-open member for a group's
@@ -43,7 +43,7 @@ import {
43
43
  type ValidateCtx,
44
44
  } from "./loader/validate-core.js";
45
45
  import { mergeWithDefault } from "./loader/merge.js";
46
- import { validateGlobals } from "./loader/globals.js";
46
+ import { validateEditGlobals, validateGlobals } from "./loader/globals.js";
47
47
  import { validateVariables } from "./loader/variables.js";
48
48
  import { validateSegments } from "./loader/segments.js";
49
49
  import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
@@ -280,6 +280,12 @@ function validateTopLevel(
280
280
  // was staged from.
281
281
  if (raw.presets !== undefined)
282
282
  out.presets = validatePresets(ctx, raw.presets);
283
+ // [LAW:one-type-per-behavior] Edit mode's staged display globals — the same
284
+ // fragment shape a preset carries, one rung later in the precedence chain, so
285
+ // it runs through the same field table (validateEditGlobals) rather than a
286
+ // parallel schema listing which globals edit mode may set.
287
+ if (raw.editGlobals !== undefined)
288
+ out.editGlobals = validateEditGlobals(ctx, "editGlobals", raw.editGlobals);
283
289
  if (raw.helpers !== undefined)
284
290
  out.helpers = validateHelpers(ctx, raw.helpers);
285
291
  // [LAW:one-source-of-truth] Group sugar synthesis runs AFTER every section
@@ -328,5 +334,6 @@ const TOP_LEVEL_KEYS = new Set([
328
334
  "actions",
329
335
  "looks",
330
336
  "presets",
337
+ "editGlobals",
331
338
  "helpers",
332
339
  ]);
@@ -172,6 +172,9 @@ export interface RawDslConfig {
172
172
  // arrangement selected per session, the exact twin of `looks` one level up
173
173
  // (a look adapts the THEME; a preset adapts the LAYOUT + display globals).
174
174
  readonly presets?: Readonly<Record<string, PresetDecl>>;
175
+ // The display globals edit mode stages while it is on — see DslConfig's own
176
+ // `editGlobals` for the shape, the merge, and where it sits in the chain.
177
+ readonly editGlobals?: Partial<Globals>;
175
178
  // Named theme-adaptation bundles ("looks"): each is a full ThemeKey (the
176
179
  // loader normalizes absent axes to identity at parse). Applied ON TOP of the
177
180
  // active theme at render — a transform composing with every theme, selected
@@ -216,6 +219,26 @@ export interface DslConfig {
216
219
  // `{ set: …, from: "presets" }` ranges these names; the derived click gate and
217
220
  // the rendered options read this one map.
218
221
  readonly presets: Readonly<Record<string, PresetDecl>>;
222
+ // [LAW:one-source-of-truth] The display globals edit mode stages while it is
223
+ // on — the `globals` half of the fragment whose `root` half edit chrome
224
+ // already stages (src/config/edit-chrome.ts). Merges FIELD BY FIELD with the
225
+ // bundled default's (like `globals` itself, not wholesale like `root`), so a
226
+ // user retuning the separator keeps the bundled `style: "plain"`.
227
+ //
228
+ // [LAW:types-are-the-program] `Partial<Globals>`, deliberately NOT
229
+ // `PresetDecl`: a preset is root + globals, and edit mode needs only the
230
+ // globals half. Taking the wider type to use half of it would make "an edit
231
+ // fragment that restages the layout" representable — a second authority over
232
+ // a tree edit-chrome already owns. The loader additionally rejects `preset`
233
+ // inside it, for the same reason a preset may not select a preset.
234
+ //
235
+ // Its rung in the precedence chain is the RIGHTMOST one (see
236
+ // src/config/presets.ts): it outranks even a session pick, because entering
237
+ // edit mode is decided later than picking a style. Nothing writes it back to
238
+ // SessionState or the overrides layer, which is why leaving edit mode
239
+ // restores the previous look with no save/restore path
240
+ // [LAW:dataflow-not-control-flow].
241
+ readonly editGlobals: Partial<Globals>;
219
242
  // [LAW:single-enforcer] The effective helper set: a name → template-body map
220
243
  // compiled to a defines-preamble at registerDslConfig. Empty when no config
221
244
  // declares helpers — an absent `helpers` key merges to `{}` (same cascade as
@@ -50,6 +50,7 @@ import {
50
50
  } from "./menu-keys.js";
51
51
  import {
52
52
  DISCLOSURE_CLOSED,
53
+ DISCLOSURE_GLYPH_CLOSE,
53
54
  disclosureCycleAction,
54
55
  disclosureStateVar,
55
56
  } from "./disclosure.js";
@@ -211,8 +212,21 @@ function insertChrome(
211
212
  artifacts.actions[identity] = disclosureCycleAction(stateKey, member);
212
213
  artifacts.actions[pageKey] = { set: pageKey, int: true };
213
214
 
215
+ // The `+` IS the trigger — no appended arrow (candybar-settings-ui-aok.4).
216
+ // Beside a `-` that means something else entirely, a ▸ read as part of the
217
+ // affordance rather than as a disclosure hint, so the trigger names the ACTION
218
+ // its click performs instead: `+` inserts here, `✕` closes what `+` opened —
219
+ // the same glyph, and the same effect, as the body's own close cell.
220
+ //
221
+ // [LAW:no-silent-failure] It is deliberately NOT one static display. A preset
222
+ // has N insertion points whose rendered rows are byte-identical, and their
223
+ // dropped bodies are identical too — so with no per-state display, an open `+`
224
+ // is indistinguishable from the two beside it and the bar silently stops
225
+ // answering "which one did I open". The tint that marks other open menus
226
+ // (node-registry's `drops.length > 0`) cannot answer it either: this segment
227
+ // declares no bg, so there is nothing to tint.
214
228
  artifacts.segments[chromeSegName] = {
215
- template: `+{{ menu "${applyName}" }}`,
229
+ template: `{{ menu "${applyName}" "+" "${DISCLOSURE_GLYPH_CLOSE}" }}`,
216
230
  when: EDIT_MODE_GATE,
217
231
  };
218
232
  return { kind: "segment", name: chromeSegName };
@@ -12,7 +12,7 @@
12
12
  // cycles. Those stay SEMANTIC checks the loader carries — schema = shape, lint =
13
13
  // meaning, the same complementary boundary `config-schema.test.ts` pins.
14
14
 
15
- import { globalsJson } from "./globals.js";
15
+ import { editGlobalsJson, globalsJson } from "./globals.js";
16
16
  import { variablesMapJson } from "./variables.js";
17
17
  import { segmentsJson } from "./segments.js";
18
18
  import { actionsJson } from "./actions.js";
@@ -50,6 +50,7 @@ export function emitConfigSchema(): JsonNode {
50
50
  actions: actionsJson(),
51
51
  looks: looksJson(),
52
52
  presets: presetsJson(),
53
+ editGlobals: editGlobalsJson(),
53
54
  helpers: { type: "object", additionalProperties: { type: "string" } },
54
55
  },
55
56
  definitions: {
@@ -104,43 +104,63 @@ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
104
104
  fields: GLOBALS_FIELDS,
105
105
  };
106
106
 
107
- // [LAW:one-source-of-truth] A preset's `globals` may not carry `preset`: which
108
- // preset is active has exactly one authority (session pick over globals.preset
109
- // over the floor), and a preset re-selecting a preset would be a second one —
110
- // a cyclic second one. Same species of bespoke, migration-pointing rejection as
107
+ // [LAW:one-source-of-truth] A globals FRAGMENT — a delta layered over the
108
+ // config's own globals at render time — may not carry `preset`: which preset is
109
+ // active has exactly one authority (session pick over globals.preset over the
110
+ // floor), and a fragment re-selecting a preset would be a second one. For a
111
+ // preset's own fragment that second authority is also cyclic; for edit mode's
112
+ // it would let a look-only fragment restage the whole layout, which edit chrome
113
+ // already owns. Same species of bespoke, migration-pointing rejection as
111
114
  // colorCompatibility's "auto" above, and for the same reason: an author who
112
115
  // writes it deserves to be told WHY, not handed a bare unknown-key message.
113
- const nestedPresetSpec: FieldSpec<string> = {
114
- required: false,
115
- // Always-fail: JSON Schema's `not: {}` matches nothing, so an editor flags
116
- // the key at the same moment the validator does.
117
- json: {
118
- not: {},
119
- description:
120
- "not allowed inside a preset — a preset cannot select a preset",
121
- },
122
- parse: (ctx, path, field, raw) => {
123
- if (raw[field] !== undefined) {
124
- ctx.issues.push({
125
- path: `${path}.${field}`,
126
- message:
127
- `${path}.${field}: a preset cannot select a preset. Which preset is active is ` +
128
- `resolved once, as session pick over globals.preset over "default"; a preset ` +
129
- `naming another would be a second authority over that, and a cyclic one. ` +
130
- `Set the default arrangement in the top-level globals.preset instead.`,
131
- line: findKeyLine(ctx.source, [...path.split("."), field]),
132
- });
133
- }
134
- return undefined;
135
- },
136
- };
116
+ //
117
+ // [LAW:one-type-per-behavior] Both fragments reject the field identically and
118
+ // differ only in the SUBJECT a diagnostic names, so this is one spec taking
119
+ // that noun as data — never two specs that could drift in what they reject.
120
+ function nestedPresetSpec(subject: string): FieldSpec<string> {
121
+ return {
122
+ required: false,
123
+ // Always-fail: JSON Schema's `not: {}` matches nothing, so an editor flags
124
+ // the key at the same moment the validator does.
125
+ json: {
126
+ not: {},
127
+ description: `not allowed here — ${subject} cannot select a preset`,
128
+ },
129
+ parse: (ctx, path, field, raw) => {
130
+ if (raw[field] !== undefined) {
131
+ ctx.issues.push({
132
+ path: `${path}.${field}`,
133
+ message:
134
+ `${path}.${field}: ${subject} cannot select a preset. Which preset is active is ` +
135
+ `resolved once, as session pick over globals.preset over "default"; a fragment ` +
136
+ `naming another would be a second authority over that. ` +
137
+ `Set the default arrangement in the top-level globals.preset instead.`,
138
+ line: findKeyLine(ctx.source, [...path.split("."), field]),
139
+ });
140
+ }
141
+ return undefined;
142
+ },
143
+ };
144
+ }
137
145
 
138
- // [LAW:one-source-of-truth] The preset-scoped globals schema is the SAME field
139
- // table with exactly one field swapped for its rejection — not a hand-listed
140
- // subset that a future globals field could be forgotten from.
146
+ // [LAW:one-source-of-truth] Each fragment-scoped globals schema is the SAME
147
+ // field table with exactly one field swapped for its rejection — not a
148
+ // hand-listed subset that a future globals field could be forgotten from.
141
149
  const PRESET_GLOBALS_SCHEMA: RecordSchema<Globals> = {
142
150
  noun: "preset globals key",
143
- fields: { ...GLOBALS_FIELDS, preset: nestedPresetSpec },
151
+ fields: { ...GLOBALS_FIELDS, preset: nestedPresetSpec("a preset") },
152
+ };
153
+
154
+ // [LAW:one-type-per-behavior] Edit mode's staged globals are the same shape one
155
+ // rung later in the precedence chain, so they reuse the same table rather than
156
+ // declaring which fields edit mode "supports" — a field added to Globals is
157
+ // edit-settable the same day, with no edit here.
158
+ const EDIT_GLOBALS_SCHEMA: RecordSchema<Globals> = {
159
+ noun: "editGlobals key",
160
+ fields: {
161
+ ...GLOBALS_FIELDS,
162
+ preset: nestedPresetSpec("the editGlobals fragment"),
163
+ },
144
164
  };
145
165
 
146
166
  // An absent globals block is the empty default (no issue); a non-object is a
@@ -177,6 +197,20 @@ export function presetGlobalsJson(): JsonNode {
177
197
  return recordJson(PRESET_GLOBALS_SCHEMA);
178
198
  }
179
199
 
200
+ // Edit mode's twin of the two above: same interpreter, same field table, the
201
+ // schema whose `preset` rejection names the editGlobals fragment.
202
+ export function validateEditGlobals(
203
+ ctx: ValidateCtx,
204
+ path: string,
205
+ raw: unknown,
206
+ ): Globals {
207
+ return record(ctx, EDIT_GLOBALS_SCHEMA, path, raw) ?? {};
208
+ }
209
+
210
+ export function editGlobalsJson(): JsonNode {
211
+ return recordJson(EDIT_GLOBALS_SCHEMA);
212
+ }
213
+
180
214
  // [LAW:one-source-of-truth] THE membership check for "is this a real Globals
181
215
  // field" — derived from GLOBALS_SCHEMA.fields, the same declaration
182
216
  // validateGlobals/globalsJson interpret, so a `persist`/`reset` action's