@promptctl/cc-candybar 1.39.0 → 1.41.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.39.0",
3
+ "version": "1.41.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.39.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.39.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.39.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.39.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.41.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.41.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.41.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.41.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,
@@ -1475,6 +1475,24 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1475
1475
  },
1476
1476
  },
1477
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
+
1478
1496
  // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
1479
1497
  // for the cost/token/budget family lives here as named template helpers, each
1480
1498
  // DEFINED ONCE and called from every segment via `{{ template "name" .arg }}`
@@ -89,6 +89,67 @@ export function pickCycleDisplay(
89
89
  return displays.length === 1 ? displays[0]! : displays[index]!;
90
90
  }
91
91
 
92
+ // [LAW:one-source-of-truth] Go-template string-literal escaping for any DISPLAY
93
+ // text a synthesis splices INSIDE a quoted `{{ }}` argument of a template it
94
+ // emits — a group's label, a preset name in the reset banner, a `(?)` trigger's
95
+ // closed/open glyphs. NOT for a template's own body text, which is source rather
96
+ // than a splice: a help line is assigned verbatim (help.ts) because escaping one
97
+ // would put a backslash on the bar. It lives here, beside the two splices
98
+ // that need it most, because it was already two verbatim copies (loader/layout.ts
99
+ // and edit-chrome.ts, whose comment deferred the merge until "one small rule"
100
+ // earned its own home). The `(?)` affordance was the third caller, so it did.
101
+ export function escapeTemplateLiteral(s: string): string {
102
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
103
+ }
104
+
105
+ // [LAW:types-are-the-program] One open disclosure, named by the two strings that
106
+ // decide it: the VARIABLE a body reads and the MEMBER value that means "this one
107
+ // is open". They are distinct because a group's variable is per-group
108
+ // (`groups.<name>`) while its state KEY may be shared with accordion siblings —
109
+ // so the pair, never a lone key, is what identifies an open state.
110
+ export interface DisclosureRef {
111
+ readonly variable: string;
112
+ readonly member: string;
113
+ }
114
+
115
+ // [LAW:single-enforcer] THE body predicate a disclosure implies. Variadic
116
+ // because nesting is conjunction and nothing else: a row inside two disclosures
117
+ // is open when both are, which is one list, not a compound spelling. Callers
118
+ // that used to hand-write `{{ eq .x "open" }}` beside `{{ and (eq .x "open")
119
+ // (eq .y "open") }}` now pass one ref or two to one function — the shape stops
120
+ // varying with the depth [LAW:dataflow-not-control-flow].
121
+ //
122
+ // `and` is variadic in Go templates and returns its sole argument when given
123
+ // one, so the single-disclosure case needs no separate spelling.
124
+ //
125
+ // [LAW:types-are-the-program] The first ref is a separate parameter so a gate
126
+ // over ZERO disclosures — which would emit an argument-less `{{ and }}` and gate
127
+ // on nothing — is unrepresentable, with no runtime guard to state it.
128
+ export function disclosureGate(
129
+ first: DisclosureRef,
130
+ ...rest: readonly DisclosureRef[]
131
+ ): string {
132
+ const terms = [first, ...rest]
133
+ .map((o) => `(eq .${o.variable} "${escapeTemplateLiteral(o.member)}")`)
134
+ .join(" ");
135
+ return `{{ and ${terms} }}`;
136
+ }
137
+
138
+ // [LAW:single-enforcer] THE trigger template a disclosure's toggle segment
139
+ // carries: one `{{ action }}` over the cycle action, binding the author's text
140
+ // per state — closed first, matching the cycle's own closed-first member order
141
+ // so the display index and the member index are the same number. Since .4 every
142
+ // trigger authors its own text (the runtime appends no glyph), which makes this
143
+ // the one place the binding is spelled; before it, five sites spelled it and the
144
+ // `+▸` double-glyph bug lived in the gap between two of them.
145
+ export function disclosureTrigger(
146
+ action: string,
147
+ closed: string,
148
+ open: string,
149
+ ): string {
150
+ return `{{ action "${action}" "${escapeTemplateLiteral(closed)}" "${escapeTemplateLiteral(open)}" }}`;
151
+ }
152
+
92
153
  // [LAW:single-enforcer] THE backing `state` variable a disclosure key implies:
93
154
  // it holds the open member's name and defaults to `def` (the CLOSED sentinel for
94
155
  // 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
@@ -36,9 +36,12 @@ import { presetRootOpsKey } from "./loader/persist-target.js";
36
36
  import { ident } from "./ident.js";
37
37
  import {
38
38
  EDIT_MODE_GATE,
39
+ EDIT_MODE_REF,
39
40
  EDIT_NS,
40
41
  EDIT_TOGGLE_ACTION,
41
42
  } from "./loader/edit-mode.js";
43
+ import { declareHelp, type HelpDisclosure } from "./help.js";
44
+ import { EDIT_MODE_HELP } from "../help-text.js";
42
45
  import { GROUP_NS } from "./loader/layout.js";
43
46
  import { SETTINGS_NS } from "./settings-menu.js";
44
47
  import {
@@ -51,6 +54,7 @@ import {
51
54
  import {
52
55
  DISCLOSURE_CLOSED,
53
56
  DISCLOSURE_GLYPH_CLOSE,
57
+ escapeTemplateLiteral,
54
58
  disclosureCycleAction,
55
59
  disclosureStateVar,
56
60
  } from "./disclosure.js";
@@ -88,17 +92,6 @@ function isChromeExempt(name: string): boolean {
88
92
  );
89
93
  }
90
94
 
91
- // [LAW:no-silent-failure] Go-template string-literal escaping for a preset
92
- // NAME spliced into DISPLAY text (prependCustomizedBanner) rather than an
93
- // identifier — the same hazard loader/layout.ts's group `label` synthesis
94
- // already guards against, reimplemented here since that copy is
95
- // module-private and this one small rule doesn't warrant its own shared
96
- // module the way `ident` (checked for agreement across three sites —
97
- // see ./ident.ts) did.
98
- function escapeTemplateLiteral(s: string): string {
99
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
100
- }
101
-
102
95
  // [LAW:one-source-of-truth] Every synthesized decl this pass produces, keyed
103
96
  // by its final name — one accumulator threaded through every preset's splice
104
97
  // so cross-preset names (disambiguated by `presetIdent`) can never collide.
@@ -312,17 +305,23 @@ function spliceContainer(
312
305
  // synthesized the SAME way (one reset action targeting this preset's exact
313
306
  // `persist` key, one segment hosting `{{ action }}`), UNCONDITIONALLY, with
314
307
  // visibility carried entirely by PRESET_CUSTOMIZED_GATE
315
- // [LAW:dataflow-not-control-flow]. Prepended as an extra ROW (not spliced
316
- // into the row-interleaved chrome spliceContainer builds) because it is not
317
- // bound to any one segment gap — it is a fact about the whole tree — so it
318
- // gets its own line above it, visible or not by the SAME `when` every other
319
- // synthesized affordance here already uses.
320
- function prependCustomizedBanner(
308
+ // [LAW:dataflow-not-control-flow]. It gets its own ROW (not a slot in the
309
+ // row-interleaved chrome spliceContainer builds) because it is not bound to any
310
+ // one segment gap — it is a fact about the whole tree — visible or not by the
311
+ // SAME `when` every other synthesized affordance here already uses.
312
+ //
313
+ // candybar-settings-ui-aok.6 hangs edit mode's `(?)` off the same content — its
314
+ // BODY is a per-preset row on the same footing as the banner, so this function
315
+ // now brackets the content rather than only preceding it, which is what its name
316
+ // says and why it is no longer "prepend". Its TRIGGER is not a row; see
317
+ // withTrailingCell.
318
+ function wrapWithPresetRows(
321
319
  splicedRoot: LayoutNode,
322
320
  presetName: string,
323
321
  presetIdent: string,
324
322
  rootOpsKey: string,
325
323
  artifacts: ChromeArtifacts,
324
+ help: HelpDisclosure,
326
325
  ): LayoutNode {
327
326
  const actionName = `${EDIT_NS}${presetIdent}.resetLayout`;
328
327
  const chromeSegName = `${EDIT_NS}${presetIdent}.customized`;
@@ -365,11 +364,85 @@ function prependCustomizedBanner(
365
364
  return {
366
365
  kind: "container",
367
366
  direction: "vertical",
368
- children: [{ kind: "segment", name: chromeSegName }, splicedRoot],
367
+ children: [
368
+ { kind: "segment", name: chromeSegName },
369
+ withTrailingCell(splicedRoot, help.trigger),
370
+ // The body is a ROW of its own, and only while the disclosure is open —
371
+ // dropping BELOW the row that revealed it, like every other disclosure
372
+ // body in this codebase.
373
+ help.body,
374
+ ],
369
375
  ...(splicedRoot.when !== undefined && { when: splicedRoot.when }),
370
376
  };
371
377
  }
372
378
 
379
+ // [LAW:one-source-of-truth] `HelpDisclosure.trigger` is a CELL, and its contract
380
+ // is that the caller joins it to a row it ALREADY HAS — the settings menu pushes
381
+ // it into the row holding `persist?`. Edit mode's rows are the ones
382
+ // spliceContainer just built, so the trigger joins the last of them. Two
383
+ // constraints pin that placement and nothing else satisfies both:
384
+ //
385
+ // - Closed help must cost no LINE. A trigger given its own vertical slot is a
386
+ // permanent row for the whole time edit mode is on, since a trigger's `when`
387
+ // is its host surface's, never its own open state (a trigger you must open in
388
+ // order to see could never be opened).
389
+ // - Closed help must cost no COLOUR. The hue cursor (src/dsl/render.ts:696)
390
+ // advances in pre-order over every segment leaf — VISIBLE OR NOT, so that
391
+ // toggling a disclosure never recolours the bar — which means a leaf inserted
392
+ // AHEAD of the content shifts the hue index of everything after it. An
393
+ // earlier draft put the `(?)` first and recoloured every cell of the bundled
394
+ // default (status row 33;41;59 → 49;36;52) with edit mode still OFF. Trailing
395
+ // the last row is the one position that moves no other leaf.
396
+ //
397
+ // Those two together disqualify the reset-banner row, which sits above the
398
+ // content.
399
+ //
400
+ // A THIRD requirement decides how far the descent may go, and it outranks the
401
+ // other two: the trigger must be visible exactly when EDIT MODE is, since a
402
+ // trigger you must already have opened something else to reach is not a trigger.
403
+ // A container's `when` reaches every descendant, so descending into a
404
+ // `when`-bearing container would silently make its gate the trigger's gate.
405
+ // `kind: "group"` is the shape that makes this concrete rather than theoretical:
406
+ // lowerGroup emits `{vertical, children: [toggle, {…, when: groupGate}]}`, so a
407
+ // preset root ending in a group — ordinary authoring the A-grammar endorses —
408
+ // would otherwise put the `(?)` INSIDE that group's collapsible body, gated on
409
+ // edit mode AND a disclosure most groups default closed. Pairing beside a gated
410
+ // SEGMENT is a different act and stays allowed: `{h: [gatedSeg, cell]}` puts the
411
+ // cell beside the gate rather than under it.
412
+ //
413
+ // For a root whose last row is gated the three requirements are jointly
414
+ // unsatisfiable, so the priority is stated rather than left to whichever branch
415
+ // the recursion happens to reach: visible (always) > no recolour (always, since
416
+ // appending is still after every existing leaf) > no extra line (surrendered
417
+ // here, in exactly the configs where riding a row was never possible).
418
+ //
419
+ // [LAW:dataflow-not-control-flow] Total over the node shapes with no guard: a
420
+ // segment is a row of one that cannot hold a second cell, so it pairs into one;
421
+ // a vertical container's rows are its children, so it descends into the last one
422
+ // it may; anything else appends. An empty container has no last child and
423
+ // appends, which is the same answer.
424
+ function withTrailingCell(node: LayoutNode, cell: LayoutNode): LayoutNode {
425
+ if (node.kind === "segment") {
426
+ return {
427
+ kind: "container",
428
+ direction: "horizontal",
429
+ children: [node, cell],
430
+ };
431
+ }
432
+ const last = node.children.at(-1);
433
+ if (
434
+ node.direction === "vertical" &&
435
+ last !== undefined &&
436
+ (last.kind === "segment" || last.when === undefined)
437
+ ) {
438
+ return {
439
+ ...node,
440
+ children: [...node.children.slice(0, -1), withTrailingCell(last, cell)],
441
+ };
442
+ }
443
+ return { ...node, children: [...node.children, cell] };
444
+ }
445
+
373
446
  // One preset's chrome-spliced root. A bare-segment root (the A-grammar
374
447
  // collapses a single top-level segment ref to `{ kind: "segment", name }`
375
448
  // with no enclosing container) is wrapped in a synthetic horizontal
@@ -379,6 +452,7 @@ function spliceEditChromeForPreset(
379
452
  config: DslConfig,
380
453
  presetName: string,
381
454
  artifacts: ChromeArtifacts,
455
+ help: HelpDisclosure,
382
456
  ): LayoutNode {
383
457
  const { node } = presetRoot(config, presetName);
384
458
  const rootOpsKey = presetRootOpsKey(presetName);
@@ -387,7 +461,7 @@ function spliceEditChromeForPreset(
387
461
  const posCounter = { n: 0 };
388
462
  // [LAW:no-silent-failure] The bare-segment-root case (the A-grammar's
389
463
  // `{ seg, when }` shorthand is a legal PresetDecl.root) carries its OWN
390
- // `when` onto this synthetic wrapper too — prependCustomizedBanner's own
464
+ // `when` onto this synthetic wrapper too — wrapWithPresetRows's own
391
465
  // when-carry-up reads `splicedRoot.when`, which is this wrapper's `when`
392
466
  // once spliceContainer's `{...node, children}` passes it through
393
467
  // unchanged; without copying it here, a bare-segment preset root's own
@@ -410,12 +484,13 @@ function spliceEditChromeForPreset(
410
484
  artifacts,
411
485
  posCounter,
412
486
  );
413
- return prependCustomizedBanner(
487
+ return wrapWithPresetRows(
414
488
  spliced,
415
489
  presetName,
416
490
  presetIdent,
417
491
  rootOpsKey,
418
492
  artifacts,
493
+ help,
419
494
  );
420
495
  }
421
496
 
@@ -444,9 +519,27 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
444
519
  actions: {},
445
520
  segments: {},
446
521
  };
522
+ // [LAW:one-source-of-truth] Edit mode's `(?)` is minted ONCE and merely
523
+ // REFERENCED from every preset root — the same move the settings menu makes
524
+ // with its anchor, and for the same reason: one disclosure means one open
525
+ // state, so switching presets cannot land you beside a second `(?)` that
526
+ // disagrees about whether help is showing. The text is identical for every
527
+ // preset because what `+` and `-` do is a fact about edit mode, not about a
528
+ // layout.
529
+ const help = declareHelp(
530
+ `${EDIT_NS}help`,
531
+ EDIT_MODE_HELP,
532
+ [EDIT_MODE_REF],
533
+ artifacts,
534
+ );
447
535
  const presets: Record<string, PresetDecl> = { ...config.presets };
448
536
  for (const name of presetNames(config.presets)) {
449
- const splicedRoot = spliceEditChromeForPreset(config, name, artifacts);
537
+ const splicedRoot = spliceEditChromeForPreset(
538
+ config,
539
+ name,
540
+ artifacts,
541
+ help,
542
+ );
450
543
  presets[name] = {
451
544
  ...presetByName(config.presets, name),
452
545
  root: splicedRoot,