@promptctl/cc-candybar 1.32.0 → 1.34.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.32.0",
3
+ "version": "1.34.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.32.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.32.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.32.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.32.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.34.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.34.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.34.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.34.0"
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
@@ -872,7 +883,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
872
883
  },
873
884
  // Quick-action tray — the default bar's interactivity: copy the session id,
874
885
  // open the project dir / transcript (this session's jsonl) in the editor,
875
- // and open the repo's web page in the browser.
886
+ // open the repo's web page in the browser, and toggle layout edit mode.
876
887
  // (copyDir — copy the cwd — stays declared as an action below for users who
877
888
  // want a fifth glyph; it is simply not in the default tray.)
878
889
  // [LAW:locality-or-seam] The glyph is the REPRESENTATION; the named action
@@ -887,11 +898,36 @@ export const RAW_DEFAULT_DSL_CONFIG = {
887
898
  // public web URL through a cc-candybar:// verb would buy nothing. It is
888
899
  // gated on the VALUE (`ne … ""`), not on a flag: a local-only repo simply
889
900
  // supplies no page and the glyph is absent. [LAW:dataflow-not-control-flow]
901
+ //
902
+ // `✎ edit`/`✎ done` (brandon-layout-edit-2gc.4) is the bundled default's
903
+ // ONLY reference to the reserved `edit.toggle` action — referencing it
904
+ // anywhere is what opts this config into edit mode (see
905
+ // docs/interaction-authoring.md's "Edit mode" section), and this tray
906
+ // segment is where it lives.
907
+ //
908
+ // [LAW:carrying-cost] Placement resolves a self-lockout tension .3 flagged
909
+ // (see the epic's tickets): once edit mode is open, EVERY ordinary segment
910
+ // gets its own removable `-`, including whichever one hosts the trigger —
911
+ // a config can, in principle, remove its own way back into edit mode.
912
+ // Giving the trigger its own standalone segment would make that a one-click
913
+ // accident. Folding it into `toolbar` instead means removing the trigger
914
+ // requires removing the WHOLE quick-action tray — the same deliberate,
915
+ // symmetric risk every other multi-purpose segment already carries, not a
916
+ // bespoke edit-mode hazard — and it costs the default bar one glyph of
917
+ // width instead of a whole new segment's cell+padding+joiner overhead. The
918
+ // risk is bounded either way: `-` only removes the segment from this
919
+ // preset's tree (`edit.mode` itself is untouched SessionState), so the
920
+ // rest of the chrome — every remaining `+`/`-` in the bar — stays visible,
921
+ // and any of them can `+` `toolbar` straight back
922
+ // (test/dsl-layout-edit.test.ts covers the full round trip through a
923
+ // real RenderCache reload; test/dsl-edit-mode.test.ts covers the click
924
+ // itself and that edit.mode survives it).
890
925
  toolbar: {
891
926
  template:
892
927
  '{{ action "copySession" "⎘ id" }}' +
893
928
  ' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}' +
894
- '{{ if ne .git.repoUrl "" }} {{ link .git.repoUrl "↗ repo" }}{{ end }}',
929
+ '{{ if ne .git.repoUrl "" }} {{ link .git.repoUrl "↗ repo" }}{{ end }}' +
930
+ ' {{ action "edit.toggle" "✎ edit" "✎ done" }}',
895
931
  bg: "surface",
896
932
  fg: "foreground",
897
933
  },
@@ -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 {