@promptctl/cc-candybar 1.33.0 → 1.34.1

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.33.0",
3
+ "version": "1.34.1",
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.33.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.33.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.33.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.33.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.34.1",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.34.1",
96
+ "@promptctl/cc-candybar-linux-x64": "1.34.1",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.34.1"
98
98
  }
99
99
  }
package/src/check.ts CHANGED
@@ -121,6 +121,17 @@ export function checkPayload(
121
121
  tmux: { session: "work" },
122
122
  theme: { effective: effective.theme },
123
123
  look: { effective: effective.look },
124
+ // [LAW:one-source-of-truth] Was missing here even though EffectiveGlobals
125
+ // already carried `preset` — a pre-existing gap this ticket's own fixture
126
+ // needs closed: a preset trigger's `.preset.effective` label and
127
+ // brandon-layout-edit-2gc.5's `.preset.customized` gate both silently
128
+ // fell back to their declared defaults ("" / false) rather than the
129
+ // resolved value, exactly the drift the sibling `*.effective` fields
130
+ // already guard against.
131
+ preset: {
132
+ effective: effective.preset,
133
+ customized: effective.presetCustomized,
134
+ },
124
135
  style: { effective: effective.style },
125
136
  charset: { effective: effective.charset },
126
137
  colorCompatibility: { effective: effective.colorCompatibility },
@@ -294,6 +305,14 @@ function loadRegisterRender(
294
305
  const globals = presetGlobals(config, preset);
295
306
  const effective: EffectiveGlobals = {
296
307
  preset,
308
+ // [LAW:no-silent-failure] `check` validates a config file in isolation
309
+ // — it never reads the daemon-owned overrides file, so there is no
310
+ // rootOps log to be customized BY. false is the honest value for THIS
311
+ // (primary, returned) render, not a stand-in default: a fresh session
312
+ // has never customized anything. A second render pass below also
313
+ // exercises `true`, so a `.preset.customized`-gated segment still
314
+ // gets checked — just not through this value.
315
+ presetCustomized: false,
297
316
  theme: effectiveThemeName(null, globals.palette),
298
317
  look: effectiveLookName(null, globals.look, config.looks),
299
318
  style: effectiveStripStyle(null, globals.style),
@@ -310,40 +329,90 @@ function loadRegisterRender(
310
329
  // authoring agent is not looking at the bar; check collects the same errors
311
330
  // through the render's observer seam and fails the verdict, so exit 0 never
312
331
  // blesses a bar that renders ⚠.
313
- const segmentErrors: string[] = [];
314
- const rendered = renderDsl(
315
- config,
316
- compiled,
317
- store,
318
- registry,
319
- checkPayload(effective),
320
- paletteForThemeName(effective.theme),
321
- {
322
- style: effective.style,
323
- width: CHECK_WIDTH,
324
- colorCompatibility: effective.colorCompatibility,
325
- wrap: effective.autoWrap,
326
- padding: effective.padding,
327
- charset: effective.charset,
328
- },
329
- {
330
- onSegmentError: (segName, message) =>
331
- segmentErrors.push(`segment "${segName}": ${message}`),
332
- },
333
- {
334
- look: lookKeyByName(config.looks, effective.look),
335
- preset: effective.preset,
336
- },
337
- );
338
- if (segmentErrors.length > 0) {
332
+ const renderOnce = (
333
+ payloadEffective: EffectiveGlobals,
334
+ ): { rendered: string; segmentErrors: Map<string, string> } => {
335
+ // [LAW:types-are-the-program] Keyed by segment NAME, not appended to a
336
+ // list — a segment errors at most once per pass, so this is the
337
+ // strongest true shape (dedupe-by-construction within one pass) and
338
+ // what makes deduping ACROSS the two passes below a plain key check
339
+ // rather than a message-text comparison.
340
+ const segmentErrors = new Map<string, string>();
341
+ const rendered = renderDsl(
342
+ config,
343
+ compiled,
344
+ store,
345
+ registry,
346
+ checkPayload(payloadEffective),
347
+ paletteForThemeName(payloadEffective.theme),
348
+ {
349
+ style: payloadEffective.style,
350
+ width: CHECK_WIDTH,
351
+ colorCompatibility: payloadEffective.colorCompatibility,
352
+ wrap: payloadEffective.autoWrap,
353
+ padding: payloadEffective.padding,
354
+ charset: payloadEffective.charset,
355
+ },
356
+ {
357
+ onSegmentError: (segName, message) =>
358
+ segmentErrors.set(segName, message),
359
+ },
360
+ {
361
+ look: lookKeyByName(config.looks, payloadEffective.look),
362
+ preset: payloadEffective.preset,
363
+ },
364
+ );
365
+ return { rendered, segmentErrors };
366
+ };
367
+
368
+ const primary = renderOnce(effective);
369
+ // [LAW:verifiable-goals] `.preset.customized` is the ONE gate this
370
+ // config surface adds that a rich, data-driven fixture (checkPayload's
371
+ // own stated design one comment up) can never drive true on its own —
372
+ // every OTHER field a segment might gate on is a VALUE checkPayload can
373
+ // just supply richly; this one is a daemon-resolved FACT about session
374
+ // state, not a hookData field a config author's own file ever carries.
375
+ // Without a second pass, a typo or MissingFieldError inside a user's
376
+ // OWN `when: '{{ .preset.customized }}'`-gated content (docs/
377
+ // interaction-authoring.md's own documented pattern) would pass check
378
+ // clean and only surface later as a live ⚠ error cell. Second pass
379
+ // only — the RETURNED rendering stays the realistic default (a fresh
380
+ // session has never customized anything); this pass exists purely to
381
+ // catch broken content behind the one gate the first pass can't reach.
382
+ const customizedCheck = renderOnce({
383
+ ...effective,
384
+ presetCustomized: true,
385
+ });
386
+
387
+ // [LAW:no-silent-failure] An UNCONDITIONAL segment error (one whose
388
+ // `when`, if any, is true in both passes — the two renders share the
389
+ // same config/store/registry and differ only in `presetCustomized`)
390
+ // fires in BOTH passes identically. Deduped by segment NAME rather than
391
+ // concatenated: a customizedCheck error is only genuinely NEW
392
+ // information when primary didn't already report that same segment —
393
+ // reporting it twice would double-count one bug and the "(under
394
+ // .preset.customized = true)" tag would misdirect the reader into
395
+ // thinking it's specific to that gate when it isn't.
396
+ const errors = [
397
+ ...[...primary.segmentErrors].map(
398
+ ([segName, message]) => `segment "${segName}": ${message}`,
399
+ ),
400
+ ...[...customizedCheck.segmentErrors]
401
+ .filter(([segName]) => !primary.segmentErrors.has(segName))
402
+ .map(
403
+ ([segName, message]) =>
404
+ `segment "${segName}": ${message} (under .preset.customized = true)`,
405
+ ),
406
+ ];
407
+ if (errors.length > 0) {
339
408
  throw new Error(
340
- `config renders with ${segmentErrors.length} segment error${
341
- segmentErrors.length === 1 ? "" : "s"
409
+ `config renders with ${errors.length} segment error${
410
+ errors.length === 1 ? "" : "s"
342
411
  } (the daemon would render ⚠ error cells):\n` +
343
- segmentErrors.map((m) => ` ${m}`).join("\n"),
412
+ errors.map((m) => ` ${m}`).join("\n"),
344
413
  );
345
414
  }
346
- return rendered;
415
+ return primary.rendered;
347
416
  } finally {
348
417
  // [LAW:single-enforcer] The registry owns every async handle the config
349
418
  // declared (timers, fs watchers, git subscriptions); a one-shot check must
@@ -378,6 +378,17 @@ export const RAW_DEFAULT_DSL_CONFIG = {
378
378
  path: "preset.effective",
379
379
  default: "",
380
380
  },
381
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.5 — presetIsCustomized
382
+ // over the SAME reload's presetRootOps, resolved alongside preset.effective
383
+ // (RenderPayload.preset.customized). edit-chrome.ts's synthesized "↺ …
384
+ // customized" segment gates on this directly; a hand-authored config can
385
+ // read it too for its own reset affordance.
386
+ "preset.customized": {
387
+ kind: "input",
388
+ path: "preset.customized",
389
+ type: "boolean",
390
+ default: false,
391
+ },
381
392
  // [LAW:one-type-per-behavior] style/charset/colorCompatibility/autoWrap/
382
393
  // padding are theme/look's twins over the remaining persistable globals
383
394
  // (candybar-config-engine-71o.3) — the SAME values BuildLineOptions
@@ -32,6 +32,8 @@ import type {
32
32
  } from "./dsl-types.js";
33
33
  import { collectSegmentNames } from "./layout-ops.js";
34
34
  import { presetByName, presetNames, presetRoot } from "./presets.js";
35
+ import { presetRootOpsKey } from "./loader/persist-target.js";
36
+ import { ident } from "./ident.js";
35
37
  import {
36
38
  EDIT_MODE_GATE,
37
39
  EDIT_NS,
@@ -51,6 +53,16 @@ import {
51
53
  disclosureStateVar,
52
54
  } from "./disclosure.js";
53
55
 
56
+ // [LAW:dataflow-not-control-flow] brandon-layout-edit-2gc.5's diagnostic gate
57
+ // — read the SAME way EDIT_MODE_GATE is: a bare boolean input var, false
58
+ // only on the literal text "false" (evaluateWhen's documented contract).
59
+ // `.preset.customized` is a per-render payload fact (presetIsCustomized over
60
+ // entry.state.presetRootOps for whichever preset is ACTIVE), not config-time
61
+ // knowledge, so the banner below is spliced UNCONDITIONALLY for every
62
+ // preset — same shape, every reload — and this predicate is what decides
63
+ // whether it's visible, never a branch in this synthesis pass.
64
+ const PRESET_CUSTOMIZED_GATE = "{{ .preset.customized }}";
65
+
54
66
  // [LAW:one-source-of-truth] group/menu-synthesized segments (`groups.`/
55
67
  // `menus.`) and edit mode's own trigger/chrome (`edit.`) are structural —
56
68
  // removing one via `-` would strand its sibling artifacts (a toggle segment
@@ -65,13 +77,15 @@ function isChromeExempt(name: string): boolean {
65
77
  );
66
78
  }
67
79
 
68
- // [LAW:types-are-the-program] Collapse an arbitrary name to a template-
69
- // identifier-safe fragment the SAME shape menu-keys.ts's `ident` enforces,
70
- // reimplemented here rather than imported (menu-keys.ts's copy is
71
- // module-private) since both need only the one rule: alphanumerics survive,
72
- // everything else collapses to `_`.
73
- function ident(name: string): string {
74
- return name.replace(/[^A-Za-z0-9]+/g, "_");
80
+ // [LAW:no-silent-failure] Go-template string-literal escaping for a preset
81
+ // NAME spliced into DISPLAY text (prependCustomizedBanner) rather than an
82
+ // identifier the same hazard loader/layout.ts's group `label` synthesis
83
+ // already guards against, reimplemented here since that copy is
84
+ // module-private and this one small rule doesn't warrant its own shared
85
+ // module the way `ident` (checked for agreement across three sites —
86
+ // see ./ident.ts) did.
87
+ function escapeTemplateLiteral(s: string): string {
88
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
75
89
  }
76
90
 
77
91
  // [LAW:one-source-of-truth] Every synthesized decl this pass produces, keyed
@@ -258,6 +272,55 @@ function spliceContainer(
258
272
  return { ...node, children };
259
273
  }
260
274
 
275
+ // brandon-layout-edit-2gc.5's other per-preset affordance: a `+`/`-` sibling
276
+ // that isn't about ONE gap but about the preset's rootOps log as a whole —
277
+ // synthesized the SAME way (one reset action targeting this preset's exact
278
+ // `persist` key, one segment hosting `{{ action }}`), UNCONDITIONALLY, with
279
+ // visibility carried entirely by PRESET_CUSTOMIZED_GATE
280
+ // [LAW:dataflow-not-control-flow]. Prepended as an extra ROW (not spliced
281
+ // into the row-interleaved chrome spliceContainer builds) because it is not
282
+ // bound to any one segment gap — it is a fact about the whole tree — so it
283
+ // gets its own line above it, visible or not by the SAME `when` every other
284
+ // synthesized affordance here already uses.
285
+ function prependCustomizedBanner(
286
+ splicedRoot: LayoutNode,
287
+ presetName: string,
288
+ presetIdent: string,
289
+ rootOpsKey: string,
290
+ artifacts: ChromeArtifacts,
291
+ ): LayoutNode {
292
+ const actionName = `${EDIT_NS}${presetIdent}.resetLayout`;
293
+ const chromeSegName = `${EDIT_NS}${presetIdent}.customized`;
294
+ // [LAW:no-silent-failure] `reset` clears `rootOpsKey` outright, restoring
295
+ // presetRoot's own fallback (the config's literal, hand-authored root) on
296
+ // the next reload — the exact undo the ticket's guardrail asked for.
297
+ // `rootOpsKey` is gated the SAME way every other `presets.<name>.rootOps`
298
+ // target is (deriveConfigActionValidators), and is ALWAYS a registered
299
+ // key — config-validators.ts's presetRootOpsContributions registers it
300
+ // for every declared preset UNCONDITIONALLY, specifically so a preset
301
+ // edited down to zero non-exempt segments (no removeChrome/insertChrome
302
+ // persist actions left to register it) doesn't orphan this exact click.
303
+ artifacts.actions[actionName] = { reset: rootOpsKey };
304
+ const label = escapeTemplateLiteral(presetName);
305
+ artifacts.segments[chromeSegName] = {
306
+ template: `{{ action "${actionName}" "↺ ${label} customized" }}`,
307
+ when: PRESET_CUSTOMIZED_GATE,
308
+ };
309
+ // [LAW:no-silent-failure] A preset's root may carry its OWN top-level
310
+ // `when` (the A-grammar's container schemas all permit one) — an author
311
+ // gating the whole preset behind a condition. `spliceContainer` preserves
312
+ // that onto `splicedRoot` via its `{...node, children}` spread, but this
313
+ // new OUTER wrapper is a brand-new node with no `when` of its own; without
314
+ // carrying it up, the reset banner would render even when the author's
315
+ // own condition is false, leaking past a gate they wrote.
316
+ return {
317
+ kind: "container",
318
+ direction: "vertical",
319
+ children: [{ kind: "segment", name: chromeSegName }, splicedRoot],
320
+ ...(splicedRoot.when !== undefined && { when: splicedRoot.when }),
321
+ };
322
+ }
323
+
261
324
  // One preset's chrome-spliced root. A bare-segment root (the A-grammar
262
325
  // collapses a single top-level segment ref to `{ kind: "segment", name }`
263
326
  // with no enclosing container) is wrapped in a synthetic horizontal
@@ -269,15 +332,28 @@ function spliceEditChromeForPreset(
269
332
  artifacts: ChromeArtifacts,
270
333
  ): LayoutNode {
271
334
  const { node } = presetRoot(config, presetName);
272
- const rootOpsKey = `presets.${presetName}.rootOps`;
335
+ const rootOpsKey = presetRootOpsKey(presetName);
273
336
  const domainName = addableDomainName(presetName);
274
337
  const presetIdent = ident(presetName);
275
338
  const posCounter = { n: 0 };
339
+ // [LAW:no-silent-failure] The bare-segment-root case (the A-grammar's
340
+ // `{ seg, when }` shorthand is a legal PresetDecl.root) carries its OWN
341
+ // `when` onto this synthetic wrapper too — prependCustomizedBanner's own
342
+ // when-carry-up reads `splicedRoot.when`, which is this wrapper's `when`
343
+ // once spliceContainer's `{...node, children}` passes it through
344
+ // unchanged; without copying it here, a bare-segment preset root's own
345
+ // gate would never reach that carry-up at all, leaking the reset banner
346
+ // past it exactly like the container case did before that fix.
276
347
  const container: ContainerNode =
277
348
  node.kind === "container"
278
349
  ? node
279
- : { kind: "container", direction: "horizontal", children: [node] };
280
- return spliceContainer(
350
+ : {
351
+ kind: "container",
352
+ direction: "horizontal",
353
+ children: [node],
354
+ ...(node.when !== undefined && { when: node.when }),
355
+ };
356
+ const spliced = spliceContainer(
281
357
  container,
282
358
  presetIdent,
283
359
  rootOpsKey,
@@ -285,6 +361,13 @@ function spliceEditChromeForPreset(
285
361
  artifacts,
286
362
  posCounter,
287
363
  );
364
+ return prependCustomizedBanner(
365
+ spliced,
366
+ presetName,
367
+ presetIdent,
368
+ rootOpsKey,
369
+ artifacts,
370
+ );
288
371
  }
289
372
 
290
373
  // [LAW:single-enforcer] THE synthesis entry point, called once from
@@ -0,0 +1,22 @@
1
+ // [LAW:one-source-of-truth] THE identifier-collapse rule — collapse an
2
+ // arbitrary name (a segment, action, preset, or menu-host name) to an
3
+ // identifier-shaped fragment so synthesized var/action/segment names carry
4
+ // no dots, brackets, or other characters that would break a template field
5
+ // path or a synthesis-time accumulator key. Every non-alphanumeric RUN
6
+ // collapses to a single `_`.
7
+ //
8
+ // [LAW:no-silent-failure] Multiple sites depend on this SAME collapse
9
+ // producing the SAME result for the SAME input: menu-keys.ts derives a
10
+ // menu's synthesized SessionState/action identity from it; edit-chrome.ts
11
+ // keys synthesized per-preset reset/±-chrome artifacts by it;
12
+ // loader/cross-ref.ts's presetIdentCollisions checks that no two preset
13
+ // names collide under it BEFORE edit-chrome.ts ever runs. Before this
14
+ // module existed, three call sites reimplemented the same regex
15
+ // independently (module-privacy taken too far) — a genuine drift risk: if
16
+ // one copy changed without the others, the collision GUARD would stop
17
+ // matching the collision RULE it exists to enforce, silently reopening the
18
+ // exact "second preset steals the first's synthesized action" bug the
19
+ // guard was written to prevent. One function now; every site imports it.
20
+ export function ident(name: string): string {
21
+ return name.replace(/[^A-Za-z0-9]+/g, "_");
22
+ }
@@ -11,6 +11,7 @@ import {
11
11
  walkNodes,
12
12
  type DslConfig,
13
13
  type LayoutNode,
14
+ type PresetDecl,
14
15
  type VariableDecl,
15
16
  } from "../dsl-types.js";
16
17
  import {
@@ -28,6 +29,7 @@ import {
28
29
  import { listGlobalsFieldNames } from "./globals.js";
29
30
  import { parsePersistTarget } from "./persist-target.js";
30
31
  import { presetNames } from "../presets.js";
32
+ import { ident } from "../ident.js";
31
33
  import { findKeyLine } from "./diagnostics.js";
32
34
  import { isPlainObject, type ValidateCtx } from "./validate-core.js";
33
35
  import {
@@ -47,6 +49,54 @@ export const RENAMED_SEGMENTS: Readonly<Record<string, string>> = {
47
49
  gitTaculous: "gitaculous",
48
50
  };
49
51
 
52
+ // [LAW:single-enforcer] Runs HERE — on `cfg.presets`, the MERGED map — not
53
+ // in loader/presets.ts's per-file structural pass (where a round-1 version
54
+ // of this check lived): that pass validates one config source at a time
55
+ // (the bundled default's own RAW_DEFAULT_DSL_CONFIG, or a user's file,
56
+ // independently), so it could only ever catch a collision between two
57
+ // preset names declared in ONE source. synthesizeEditChrome — the thing
58
+ // this guard protects, which keys its per-preset reset action/segment (and
59
+ // the pre-existing per-gap +/- actions) by `ident(presetName)` in a plain
60
+ // object accumulator with no re-entrant cross-ref check — runs on the
61
+ // MERGED config (dsl-loader.ts), so the collision it can actually produce
62
+ // is a merged one: a user preset whose ident collides with a name the
63
+ // BUNDLED library or a different file contributed. This is the one place
64
+ // that sees that merged set, so it's the one place that can prove no
65
+ // collision exists in it.
66
+ //
67
+ // [LAW:one-source-of-truth] `ident` is imported from ../ident.ts — the ONE
68
+ // collapse rule menu-keys.ts, edit-chrome.ts, and this guard all now share,
69
+ // so a future tweak to the rule can't silently desync the guard from the
70
+ // thing it checks.
71
+ //
72
+ // [LAW:no-silent-failure] Two preset names that collapse to the SAME
73
+ // synthesis identifier (e.g. "quick-look" and "quick_look" both → "quick_
74
+ // look") would silently steal each other's synthesized artifacts: the
75
+ // SECOND preset processed overwrites the first's entries, leaving the
76
+ // first preset's already-built tree holding a segment ref to a name that
77
+ // now points at the second preset's reset action. A user clicking "reset"
78
+ // on preset A would silently reset preset B instead.
79
+ function presetIdentCollisions(
80
+ ctx: ValidateCtx,
81
+ presets: Readonly<Record<string, PresetDecl>>,
82
+ ): void {
83
+ const byIdent = new Map<string, string[]>();
84
+ for (const name of Object.keys(presets)) {
85
+ const id = ident(name);
86
+ const names = byIdent.get(id);
87
+ if (names) names.push(name);
88
+ else byIdent.set(id, [name]);
89
+ }
90
+ for (const [id, names] of byIdent) {
91
+ if (names.length < 2) continue;
92
+ ctx.issues.push({
93
+ path: "presets",
94
+ message: `preset names ${names.map((n) => JSON.stringify(n)).join(" and ")} both collapse to the same synthesis identifier "${id}" — edit mode's synthesized reset affordance would silently steal one preset's action for the other. Rename one.`,
95
+ line: findKeyLine(ctx.source, ["presets"]),
96
+ });
97
+ }
98
+ }
99
+
50
100
  export function validateCrossReferences(
51
101
  ctx: ValidateCtx,
52
102
  cfg: DslConfig,
@@ -83,6 +133,7 @@ export function validateCrossReferences(
83
133
  line: findKeyLine(ctx.source, ["globals", "preset"]),
84
134
  });
85
135
  }
136
+ presetIdentCollisions(ctx, cfg.presets);
86
137
  // [LAW:one-source-of-truth] A `set … from` NAME must resolve — checked
87
138
  // against this config's per-config domains ("looks", the merged looks:
88
139
  // block) plus the global registry (themes/styles, and any future
@@ -34,7 +34,26 @@ export type PersistTarget =
34
34
  // `presets.<name>.root` (src/config/presets.ts), a different namespace this
35
35
  // key must never be confused with.
36
36
  const SEGMENT_PALETTE_KEY = /^segments\.([^.]+)\.palette$/;
37
- const PRESET_ROOT_OPS_KEY = /^presets\.([^.]+)\.rootOps$/;
37
+ // [LAW:one-source-of-truth] GREEDY capture, not `[^.]+` — a preset name is
38
+ // validated only non-empty/slash/newline-free (loader/presets.ts), so a dot
39
+ // is a legal preset name (e.g. "v1.compact"). presetRootOpsKey always
40
+ // appends the literal ".rootOps" suffix, so a greedy `(.+)` backtracks to
41
+ // the RIGHTMOST occurrence of that anchored suffix and correctly recovers
42
+ // the full name for ANY preset name, dotted or not — round-tripping
43
+ // presetRootOpsKey exactly, rather than restricting preset names further
44
+ // to dodge the ambiguity a non-greedy/exclusive-dot capture would have.
45
+ const PRESET_ROOT_OPS_KEY = /^presets\.(.+)\.rootOps$/;
46
+
47
+ // [LAW:one-source-of-truth] THE builder for a preset's rootOps key — the
48
+ // inverse of PRESET_ROOT_OPS_KEY's parse. Two independent sites used to
49
+ // spell `presets.${name}.rootOps` themselves (edit-chrome.ts's synthesis,
50
+ // config-validators.ts's always-registered contribution), with only the
51
+ // regex above as a read-side authority and no shared write-side one — they
52
+ // happened to agree, but nothing enforced it. One function now; both call
53
+ // sites import it.
54
+ export function presetRootOpsKey(name: string): string {
55
+ return `presets.${name}.rootOps`;
56
+ }
38
57
 
39
58
  export function parsePersistTarget(key: string): PersistTarget | null {
40
59
  if (isGlobalsField(key)) return { scope: "globals", field: key };
@@ -85,10 +85,22 @@ export function validatePresets(
85
85
  // config load, not only once an action ranges the "presets" domain (the
86
86
  // identical guard looks.ts applies to look names, for the identical
87
87
  // reason).
88
- if (name === "" || name.includes("/")) {
88
+ //
89
+ // [LAW:one-source-of-truth] Newlines are ALSO rejected — the same reason
90
+ // loader/layout.ts's groupLabelSpec rejects \n/\r in a group's `label`
91
+ // before it ever reaches escapeTemplateLiteral: a preset name is spliced
92
+ // as DISPLAY TEXT into a synthesized Go-template string literal
93
+ // (edit-chrome.ts's "customized" banner), and that escaper only handles
94
+ // backslash/quote — an embedded newline produces an unterminated string
95
+ // literal go-template-js forbids, breaking synthesis for the WHOLE
96
+ // config, not just this one preset. Unlike a label, a preset name is
97
+ // also an identifier used across other seams (the `presets` domain, the
98
+ // `presets.<name>.rootOps` wire key), so this belongs in its general
99
+ // validity check, not a narrower escape-harder fix at the one splice site.
100
+ if (name === "" || name.includes("/") || /[\n\r]/.test(name)) {
89
101
  ctx.issues.push({
90
102
  path: `presets.${name}`,
91
- message: `preset name ${JSON.stringify(name)} must be non-empty and slash-free — a preset picker writes the name on the set-state wire, which rejects empty values and splits on "/"`,
103
+ message: `preset name ${JSON.stringify(name)} must be non-empty, slash-free, and newline-free — a preset picker writes the name on the set-state wire (which rejects empty values and splits on "/"), and edit mode splices it into a synthesized template string`,
92
104
  line: findKeyLine(ctx.source, ["presets", name]),
93
105
  });
94
106
  continue;
@@ -22,6 +22,8 @@
22
22
  // the key" position magic: identity depends only on names a reader
23
23
  // can see in the template, never on tree position.
24
24
 
25
+ import { ident } from "./ident.js";
26
+
25
27
  // [LAW:one-source-of-truth] The reserved namespace every synthesized menu
26
28
  // artifact (state var + cycle action) lives under, mirroring group sugar's
27
29
  // `groups.`. A user-authored name under this prefix is a load error so synthesis
@@ -34,14 +36,6 @@ export const MENU_NS = "menus.";
34
36
  // drift. This module keeps only the menu's own IDENTITY derivation (member = apply
35
37
  // name; key = optional shared key), which group sugar derives differently.
36
38
 
37
- // [LAW:types-are-the-program] Collapse an arbitrary name to an identifier-shaped
38
- // id so the synthesized var/action/SessionState-key names carry no dots or
39
- // brackets that would break template field paths. Distinct names never collide
40
- // under this map for the alphanumeric segment/action names the config uses.
41
- function ident(name: string): string {
42
- return name.replace(/[^A-Za-z0-9]+/g, "_");
43
- }
44
-
45
39
  // [LAW:single-enforcer] A menu's member name IS its apply-action name. Both the
46
40
  // loader and the helper call this so neither restates the rule.
47
41
  export function menuMember(applyName: string): string {
@@ -188,6 +188,36 @@ export function sanitizePersistedPresetOverride(
188
188
  return rest;
189
189
  }
190
190
 
191
+ // [LAW:no-silent-failure] sanitizePersistedPresetOverride's twin for the
192
+ // SAME machine-global overrides file, one field over: `presets.<name>.
193
+ // rootOps` entries persist by NAME, and that name only has meaning against
194
+ // the config it was clicked against. Loading a DIFFERENT project's config
195
+ // — one that never declared "compact", say — would otherwise reach
196
+ // applyPresetRootOpsOverrides -> presetRoot -> presetByName, which THROWS
197
+ // for an undeclared name (correctly so for a hand-AUTHORED preset root: a
198
+ // typo in a file the author can see). A stale overrides entry is not that
199
+ // — it is the expected shape of a file that outlives any one project's
200
+ // preset names — so it gets the SAME treatment sanitizePersistedPresetOverride
201
+ // already gives `globals.preset`: dropped before replay ever sees it,
202
+ // never a fatal error replacing an unrelated project's entire bar
203
+ // (brandon-layout-edit-2gc.5 PR review).
204
+ export function sanitizePersistedPresetRootOps(
205
+ presetRootOps: Readonly<Record<string, readonly string[]>>,
206
+ declaredPresets: Readonly<Record<string, PresetDecl>>,
207
+ ): Readonly<Record<string, readonly string[]>> {
208
+ const known = new Set(presetNames(declaredPresets));
209
+ const entries = Object.entries(presetRootOps).filter(([name]) =>
210
+ known.has(name),
211
+ );
212
+ // [LAW:carrying-cost] Identity return when nothing was dropped — the
213
+ // common case (this daemon serves one project, or every persisted name
214
+ // happens to be declared) costs one Set build and no new allocation.
215
+ if (entries.length === Object.keys(presetRootOps).length) {
216
+ return presetRootOps;
217
+ }
218
+ return Object.fromEntries(entries);
219
+ }
220
+
191
221
  // [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's replay step — the
192
222
  // SAME "patch an already-merged config" shape applySegmentPaletteOverrides
193
223
  // (src/config/loader/merge.ts) uses one field over, run at the SAME point in
@@ -208,6 +238,48 @@ export function sanitizePersistedPresetOverride(
208
238
  // already removed it. A malformed individual token (decodeLayoutOp -> null;
209
239
  // can only arise from hand-edited or previous-version state, never from this
210
240
  // process's own encodeLayoutOp) is filtered the same way, never applied.
241
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.5's diagnostic seam — the
242
+ // ONE definition of "does this preset's rendered layout currently differ
243
+ // from what's literally declared in the config file", read from the SAME
244
+ // raw op-log record applyPresetRootOpsOverrides replays (never re-derived by
245
+ // diffing the replayed tree against a fresh presetRoot() call, which would
246
+ // be a second, weaker definition — a structural coincidence between two
247
+ // unrelated edits could equal zero net ops and still disagree with the raw
248
+ // log's own length). A name absent from the record (never edited) and a
249
+ // name present with an empty token list (edited down to nothing, e.g. by
250
+ // undo) are the SAME "not customized" — presence in the record only ever
251
+ // means "this reload's overrides file had an entry", not "and it's non-
252
+ // empty" [LAW:no-defensive-null-guards].
253
+ //
254
+ // Decodes each token the SAME way applyPresetRootOpsOverrides does, rather
255
+ // than trusting the raw token COUNT — a token list where every token
256
+ // decodes to null (malformed, or written by a previous protocol version)
257
+ // is exactly the case applyPresetRootOpsOverrides itself treats as "no ops
258
+ // to replay" (`ops.length === 0 → continue`, leaving the root untouched).
259
+ // Counting raw tokens would disagree with that: "customized" would read
260
+ // true over a tree that is, in fact, byte-identical to the literal
261
+ // declared root — the inverted form of the drift this diagnostic exists to
262
+ // catch.
263
+ //
264
+ // [LAW:carrying-cost] Verifies DECODE only, not whether the decoded op's
265
+ // target/anchor still resolves against the CURRENT tree — that check would
266
+ // need this to duplicate applyLayoutOps' own stale-target resolution
267
+ // (a real walk of the tree) just to COUNT, on every render, a fact only a
268
+ // LATER hand-authored config edit can produce (removing a segment a stored
269
+ // op still names — see the stale-op comment on applyPresetRootOpsOverrides
270
+ // above). A rare, later-edit-triggered false "customized" is the honest,
271
+ // documented cost of keeping this an O(tokens) check rather than an O(tree)
272
+ // one; the doc at docs/interaction-authoring.md's "Knowing when a preset's
273
+ // layout has been edited" section states this same limit.
274
+ export function presetIsCustomized(
275
+ presetRootOps: Readonly<Record<string, readonly string[]>>,
276
+ name: string,
277
+ ): boolean {
278
+ const tokens = presetRootOps[name];
279
+ if (tokens === undefined) return false;
280
+ return tokens.some((t) => decodeLayoutOp(t) !== null);
281
+ }
282
+
211
283
  export function applyPresetRootOpsOverrides(
212
284
  config: DslConfig,
213
285
  presetRootOps: Readonly<Record<string, readonly string[]>>,