@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/dist/index.mjs +26 -26
- package/package.json +5 -5
- package/src/check.ts +99 -30
- package/src/config/default-dsl-config.ts +38 -2
- package/src/config/edit-chrome.ts +93 -10
- package/src/config/ident.ts +22 -0
- package/src/config/loader/cross-ref.ts +51 -0
- package/src/config/loader/persist-target.ts +20 -1
- package/src/config/loader/presets.ts +14 -2
- package/src/config/menu-keys.ts +2 -8
- package/src/config/presets.ts +72 -0
- package/src/daemon/cache/render.ts +31 -1
- package/src/daemon/render-payload.ts +20 -2
- package/src/daemon/server.ts +14 -1
- package/src/daemon/verbs/config-validators.ts +47 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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 ${
|
|
341
|
-
|
|
409
|
+
`config renders with ${errors.length} segment error${
|
|
410
|
+
errors.length === 1 ? "" : "s"
|
|
342
411
|
} (the daemon would render ⚠ error cells):\n` +
|
|
343
|
-
|
|
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
|
-
//
|
|
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:
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
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
|
-
: {
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/config/menu-keys.ts
CHANGED
|
@@ -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 {
|