@promptctl/cc-candybar 1.29.0 → 1.30.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.29.0",
3
+ "version": "1.30.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.29.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.29.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.29.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.29.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.30.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.30.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.30.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.30.0"
98
98
  }
99
99
  }
@@ -1321,6 +1321,49 @@
1321
1321
  ],
1322
1322
  "additionalProperties": false
1323
1323
  },
1324
+ {
1325
+ "type": "object",
1326
+ "properties": {
1327
+ "persist": {
1328
+ "type": "string"
1329
+ },
1330
+ "removeSegment": {
1331
+ "type": "string"
1332
+ }
1333
+ },
1334
+ "required": [
1335
+ "persist",
1336
+ "removeSegment"
1337
+ ],
1338
+ "additionalProperties": false
1339
+ },
1340
+ {
1341
+ "type": "object",
1342
+ "properties": {
1343
+ "persist": {
1344
+ "type": "string"
1345
+ },
1346
+ "insertSegment": {
1347
+ "type": "string"
1348
+ },
1349
+ "anchor": {
1350
+ "type": "string"
1351
+ },
1352
+ "relation": {
1353
+ "enum": [
1354
+ "before",
1355
+ "after"
1356
+ ]
1357
+ }
1358
+ },
1359
+ "required": [
1360
+ "persist",
1361
+ "insertSegment",
1362
+ "anchor",
1363
+ "relation"
1364
+ ],
1365
+ "additionalProperties": false
1366
+ },
1324
1367
  {
1325
1368
  "type": "object",
1326
1369
  "properties": {
package/src/click/wire.ts CHANGED
@@ -66,6 +66,17 @@ export const VERB_STEP_CONFIG = "step-config";
66
66
  // config-overrides key, restoring the user-file/bundled-default value on the
67
67
  // next reload. Args: `[sessionId, key]`.
68
68
  export const VERB_RESET_CONFIG = "reset-config";
69
+ // [LAW:one-type-per-behavior] brandon-layout-edit-2gc.1's structural-edit
70
+ // verb — a THIRD write semantic beside set-config's plain overwrite and
71
+ // step-config's numeric read-modify-write: read the current op-token LIST at
72
+ // `key` (a "presets.<name>.rootOps" config-overrides target), append the
73
+ // validated `op` token, write the whole list back. Args: `[sessionId, key,
74
+ // op]` — `op` is one opaque token from src/config/layout-ops.ts's codec, the
75
+ // SAME shape a `persist … to` literal's value would be, gated the SAME way
76
+ // (validateConfigWrite) — only the write's SHAPE (append vs. overwrite)
77
+ // differs, which is exactly why this is its own verb rather than another
78
+ // VERB_SET_CONFIG value.
79
+ export const VERB_APPLY_LAYOUT_OP = "apply-layout-op";
69
80
 
70
81
  // [LAW:types-are-the-program] An effect to EMIT: a verb plus its raw (unencoded)
71
82
  // positional args. The wire owns all encoding — callers never percent-encode.
@@ -75,6 +75,14 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
75
75
  // -> allow-list {members}
76
76
  // copy — copy templated text to the clipboard -> no gate
77
77
  // open — open a templated target in the editor -> no gate
78
+ // removeSegment — (persist only) remove the named segment from the
79
+ // preset-root the `persist` key addresses
80
+ // (`presets.<name>.rootOps`) -> allow-list {one op
81
+ // token — see src/config/layout-ops.ts}
82
+ // insertSegment +
83
+ // anchor + relation — (persist only) insert a named segment before/after
84
+ // an existing one, same key shape -> allow-list {one
85
+ // op token}
78
86
  //
79
87
  // [LAW:one-source-of-truth] `set` writes SessionState and `persist` writes
80
88
  // the config-overrides layer, so only those two derive a validator (through
@@ -87,6 +95,15 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
87
95
  // arms verbatim (to/from/min-max-by/cycle) MINUS `int`: an unbounded page
88
96
  // cursor is a UI-only paging concept (a picker's own navigation state) with
89
97
  // no meaning as a persisted config default.
98
+ //
99
+ // [LAW:locality-or-seam] `removeSegment`/`insertSegment` are `persist`-ONLY
100
+ // (brandon-layout-edit-2gc.1) — a structural edit is always a durable,
101
+ // machine-owned write by design (the ticket's own instruction: reuse 71o's
102
+ // writer, land in the SAME overrides layer), so there is no SessionState
103
+ // twin. Every operation is fully literal at config-author time — the
104
+ // segment names and relation are DATA the loader proves at load, not a
105
+ // runtime picker — so each declared action has exactly one legal request,
106
+ // gated the same one-member-allow-list way a literal `to` already is.
90
107
  export type ActionDecl =
91
108
  | { readonly set: string; readonly to: string }
92
109
  | { readonly set: string; readonly from: OptionDomain }
@@ -107,6 +124,13 @@ export type ActionDecl =
107
124
  readonly by: number;
108
125
  }
109
126
  | { readonly persist: string; readonly cycle: readonly string[] }
127
+ | { readonly persist: string; readonly removeSegment: string }
128
+ | {
129
+ readonly persist: string;
130
+ readonly insertSegment: string;
131
+ readonly anchor: string;
132
+ readonly relation: "before" | "after";
133
+ }
110
134
  | { readonly copy: string }
111
135
  | { readonly open: string }
112
136
  | { readonly reset: string };
@@ -0,0 +1,156 @@
1
+ // [LAW:one-type-per-behavior] The seam brandon-layout-edit-2gc.1 opens: a
2
+ // bounded, statically-enumerable vocabulary for mutating a LayoutNode tree —
3
+ // remove the segment named X, insert a named segment before/after an
4
+ // existing one. No third LayoutNode kind, no free-form tree editing: a
5
+ // segment's own NAME is the stable position (dsl-types.ts already makes it
6
+ // one — SegmentNode.name is a ref into `segments`, unaffected by a sibling
7
+ // being added or removed elsewhere in the tree), so there is no sibling-index
8
+ // to invalidate when a prior op lands.
9
+ //
10
+ // [LAW:no-ambient-temporal-coupling] A position is never a fact frozen at
11
+ // declaration or click time — `removeSegment`/`insertSegment` always walk
12
+ // the CURRENT tree handed to them. The daemon (src/daemon/cache/render.ts)
13
+ // replays the accumulated op log against the live merged config on every
14
+ // reload, so a later, unrelated config edit is what the next replay sees,
15
+ // never a stale snapshot.
16
+ //
17
+ // [LAW:no-silent-failure] exception: a target/anchor absent from the tree is
18
+ // a documented NO-OP, not a thrown error — mirroring
19
+ // src/config/loader/merge.ts's applySegmentPaletteOverrides, the identical
20
+ // failure shape one layer up. A validated action can only ever name a
21
+ // segment the config declares (loader/cross-ref.ts checks this at config
22
+ // load), so a miss here only happens after a LATER edit (to the bundled
23
+ // default, the user's config, or an earlier op in the same accumulated list)
24
+ // removed the segment — there is nothing left to apply the op to, and
25
+ // bricking the whole render over a stale op would be worse than dropping it.
26
+
27
+ import type { LayoutNode, SegmentNode } from "./dsl-types.js";
28
+
29
+ // [LAW:types-are-the-program] The two operations brandon-layout-edit-2gc.1
30
+ // ships. Both address position by NAME, never by index. A future op (e.g.
31
+ // "move") is a new arm here, not a new node kind or a new codec.
32
+ export type LayoutOp =
33
+ | { readonly op: "remove"; readonly target: string }
34
+ | {
35
+ readonly op: "insert";
36
+ readonly segment: string;
37
+ readonly anchor: string;
38
+ readonly relation: "before" | "after";
39
+ };
40
+
41
+ // [LAW:single-enforcer] THE codec for a LayoutOp crossing the click wire and
42
+ // the config-overrides file as one opaque string. `:` is the delimiter
43
+ // (loader/actions.ts's segmentNameSpec rejects `:` and `/` in every name an
44
+ // op can carry, so decode is unambiguous — a plain split, no escaping).
45
+ // Encode and decode live together so the format cannot drift between the
46
+ // validator gate (config-validators.ts, which encodes the ONE token a
47
+ // declared action allows), the render side (which emits that same token),
48
+ // and the daemon (which decodes it back).
49
+ export function encodeLayoutOp(op: LayoutOp): string {
50
+ return op.op === "remove"
51
+ ? `remove:${op.target}`
52
+ : `insert:${op.segment}:${op.anchor}:${op.relation}`;
53
+ }
54
+
55
+ // [LAW:parse-dont-validate] Returns the typed op, or null for anything that
56
+ // doesn't decode — the boundary a caller stamps before trusting the shape.
57
+ // Reached only from data this process did not itself just encode (a
58
+ // hand-edited or previous-version config-overrides.json), never from a
59
+ // value produced by encodeLayoutOp in the same click.
60
+ export function decodeLayoutOp(token: string): LayoutOp | null {
61
+ const parts = token.split(":");
62
+ if (parts[0] === "remove" && parts.length === 2 && parts[1]) {
63
+ return { op: "remove", target: parts[1] };
64
+ }
65
+ if (
66
+ parts[0] === "insert" &&
67
+ parts.length === 4 &&
68
+ parts[1] &&
69
+ parts[2] &&
70
+ (parts[3] === "before" || parts[3] === "after")
71
+ ) {
72
+ return {
73
+ op: "insert",
74
+ segment: parts[1],
75
+ anchor: parts[2],
76
+ relation: parts[3],
77
+ };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ // [LAW:dataflow-not-control-flow] Ops are DATA folded over the tree in
83
+ // order — replaying zero ops is the identity fold, replaying N is the same
84
+ // reduce for every N. No branch on "are there ops to apply."
85
+ export function applyLayoutOps(
86
+ root: LayoutNode,
87
+ ops: readonly LayoutOp[],
88
+ ): LayoutNode {
89
+ return ops.reduce(applyOne, root);
90
+ }
91
+
92
+ function applyOne(root: LayoutNode, op: LayoutOp): LayoutNode {
93
+ return op.op === "remove"
94
+ ? removeSegment(root, op.target)
95
+ : insertSegment(root, op.segment, op.anchor, op.relation);
96
+ }
97
+
98
+ // [LAW:types-are-the-program] Total and non-throwing: every LayoutNode maps
99
+ // to a LayoutNode, whether or not `target` is present. Acts on the FIRST
100
+ // segment found named `target`, in walkNodes' canonical pre-order — a
101
+ // deterministic, documented tie-break for the edge case of a duplicate name,
102
+ // never an ambiguous or thrown outcome. `done` is a closure-local flag (fresh
103
+ // per call, never shared across calls) that stops the search the instant a
104
+ // match is spliced out, so an EARLIER sibling's match always wins over a
105
+ // LATER one — exactly walkNodes' visit order, not merely "this container's
106
+ // own children before its descendants" (a subtly different, wrong order a
107
+ // single-pass children-array scan would produce). An absent target returns
108
+ // the identical root reference.
109
+ function removeSegment(root: LayoutNode, target: string): LayoutNode {
110
+ let done = false;
111
+ function walk(node: LayoutNode): LayoutNode {
112
+ if (done || node.kind === "segment") return node;
113
+ const children: LayoutNode[] = [];
114
+ for (const child of node.children) {
115
+ if (!done && child.kind === "segment" && child.name === target) {
116
+ done = true;
117
+ continue;
118
+ }
119
+ children.push(walk(child));
120
+ }
121
+ return { ...node, children };
122
+ }
123
+ const result = walk(root);
124
+ return done ? result : root;
125
+ }
126
+
127
+ // [LAW:types-are-the-program] Splices a new `{ kind: "segment", name:
128
+ // segment }` node immediately before/after the FIRST node found named
129
+ // `anchor`, in walkNodes' canonical pre-order (see removeSegment's `done`
130
+ // note — the same closure-local stop-at-first-match shape). Total and
131
+ // non-throwing: an absent anchor returns the identical root reference.
132
+ function insertSegment(
133
+ root: LayoutNode,
134
+ segment: string,
135
+ anchor: string,
136
+ relation: "before" | "after",
137
+ ): LayoutNode {
138
+ let done = false;
139
+ function walk(node: LayoutNode): LayoutNode {
140
+ if (done || node.kind === "segment") return node;
141
+ const children: LayoutNode[] = [];
142
+ for (const child of node.children) {
143
+ if (!done && child.kind === "segment" && child.name === anchor) {
144
+ done = true;
145
+ const inserted: SegmentNode = { kind: "segment", name: segment };
146
+ if (relation === "before") children.push(inserted, child);
147
+ else children.push(child, inserted);
148
+ continue;
149
+ }
150
+ children.push(walk(child));
151
+ }
152
+ return { ...node, children };
153
+ }
154
+ const result = walk(root);
155
+ return done ? result : root;
156
+ }
@@ -324,6 +324,25 @@ const CYCLE_FIELDS_SET: FieldSpecMap<{ cycle: readonly string[] }> = {
324
324
  const CYCLE_FIELDS_PERSIST: FieldSpecMap<{ cycle: readonly string[] }> = {
325
325
  cycle: cycleSpec("persist"),
326
326
  };
327
+ // [LAW:one-type-per-behavior] brandon-layout-edit-2gc.1's two structural-edit
328
+ // arms — PERSIST-only (see action.ts's ActionDecl doc comment for why there
329
+ // is no `set` twin). Each field reuses layoutNameSpec: a segment/anchor name
330
+ // must be non-empty and free of both `/` (the click wire's own segment
331
+ // delimiter) and `:` (layout-ops.ts's op-token delimiter) — the SAME
332
+ // wire-safety diligence slashFreeString already applies to `to`/`cycle`
333
+ // members, one forbidden character wider.
334
+ const REMOVE_SEGMENT_FIELDS: FieldSpecMap<{ removeSegment: string }> = {
335
+ removeSegment: layoutNameSpec("removeSegment"),
336
+ };
337
+ const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
338
+ insertSegment: string;
339
+ anchor: string;
340
+ relation: "before" | "after";
341
+ }> = {
342
+ insertSegment: layoutNameSpec("insertSegment"),
343
+ anchor: layoutNameSpec("anchor"),
344
+ relation: relationSpec(),
345
+ };
327
346
 
328
347
  // [LAW:types-are-the-program] A bounded step is fully described by an integer
329
348
  // domain (min < max) and a non-zero integer increment (`by`; negative for a
@@ -418,16 +437,49 @@ const SET_ARMS: readonly ValueSourceArm[] = [
418
437
 
419
438
  // [LAW:one-type-per-behavior] `persist` mirrors `set` minus the `int` arm — a
420
439
  // page cursor is a UI-only paging concept with no meaning as a persisted
421
- // config default (see action.ts's ActionDecl comment).
440
+ // config default (see action.ts's ActionDecl comment). `removeSegment`/
441
+ // `insertSegment` are ADDITIONAL persist-only arms with no `set` counterpart.
422
442
  const PERSIST_ARMS: readonly ValueSourceArm[] = [
423
443
  valueSourceArm("persist", TO_FIELDS_PERSIST),
424
444
  valueSourceArm("persist", FROM_FIELDS_PERSIST),
425
445
  valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
426
446
  valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
447
+ valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
448
+ valueSourceArm("persist", INSERT_SEGMENT_FIELDS),
427
449
  ];
428
450
 
429
- const VALUE_SOURCE_MESSAGE = (discriminator: "set" | "persist") =>
430
- `a ${discriminator} action declares exactly one value source: "to" (a literal value), "from" (an option domain a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values), "min"/"max"/"by" (a bounded step)${discriminator === "set" ? `, "int" (an unbounded integer cursor)` : ""}, or "cycle" (an enumerated domain stepped in order)`;
451
+ // [LAW:one-source-of-truth] The clause list, not the joined string, is the
452
+ // data that varies per discriminatorthe "or" belongs on the LAST clause
453
+ // only, and which clause is last differs between `set` (ends at cycle) and
454
+ // `persist` (ends at insertSegment), so building a list and joining it is
455
+ // what keeps that placement correct without a second copy of the sentence.
456
+ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
457
+ const clauses = [
458
+ `"to" (a literal value)`,
459
+ `"from" (an option domain — a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values)`,
460
+ `"min"/"max"/"by" (a bounded step)`,
461
+ ];
462
+ if (discriminator === "set")
463
+ clauses.push(`"int" (an unbounded integer cursor)`);
464
+ clauses.push(`"cycle" (an enumerated domain stepped in order)`);
465
+ if (discriminator === "persist") {
466
+ clauses.push(
467
+ `"removeSegment" (remove a named segment from the layout)`,
468
+ `"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
469
+ );
470
+ }
471
+ return clauses;
472
+ }
473
+
474
+ function VALUE_SOURCE_MESSAGE(discriminator: "set" | "persist"): string {
475
+ const clauses = valueSourceClauses(discriminator);
476
+ const last = clauses[clauses.length - 1]!;
477
+ const list =
478
+ clauses.length === 1
479
+ ? last
480
+ : `${clauses.slice(0, -1).join(", ")}, or ${last}`;
481
+ return `a ${discriminator} action declares exactly one value source: ${list}`;
482
+ }
431
483
 
432
484
  // [LAW:dataflow-not-control-flow] The set/persist sub-union eliminator:
433
485
  // validate the shared discriminator key, count which value sources are
@@ -681,6 +733,61 @@ function intMarkerSpec(): FieldSpec<true> {
681
733
  };
682
734
  }
683
735
 
736
+ // [LAW:one-source-of-truth] A layout op's segment-name field (removeSegment /
737
+ // insertSegment / anchor) is non-empty and free of BOTH wire-structural
738
+ // characters: `/` (the click wire's own multi-arg segment delimiter, the
739
+ // same restriction slashFreeString already enforces for `to`/`cycle`) and
740
+ // `:` (layout-ops.ts's op-token delimiter — a name containing it would make
741
+ // encodeLayoutOp's output ambiguous to decode). One spec, three callsites,
742
+ // so the two-character restriction can't drift between them.
743
+ function layoutNameSpec(field: string): FieldSpec<string> {
744
+ return {
745
+ required: true,
746
+ json: { type: "string" },
747
+ parse: (ctx, path, f, raw) => {
748
+ const v = requireString(ctx, path, raw, f);
749
+ if (v === null) return undefined;
750
+ const at = `${path}.${f}`;
751
+ if (v === "") {
752
+ issue(ctx, at, `${field} must be non-empty (a segment name)`);
753
+ return undefined;
754
+ }
755
+ if (v.includes("/") || v.includes(":")) {
756
+ issue(
757
+ ctx,
758
+ at,
759
+ `${field} "${v}" contains "/" or ":" — segment names in a layout op must be free of both (the click wire's own delimiter and layout-ops.ts's op-token delimiter)`,
760
+ );
761
+ return undefined;
762
+ }
763
+ return v;
764
+ },
765
+ };
766
+ }
767
+
768
+ // [LAW:types-are-the-program] `relation` is a closed two-value enum, not a
769
+ // free string — a typo (`"befor"`) is a load error, never a click-time
770
+ // surprise. Mirrors intMarkerSpec's "one legal literal" shape, widened to
771
+ // two.
772
+ function relationSpec(): FieldSpec<"before" | "after"> {
773
+ return {
774
+ required: true,
775
+ json: { enum: ["before", "after"] },
776
+ parse: (ctx, path, field, raw) => {
777
+ const v = raw[field];
778
+ if (v !== "before" && v !== "after") {
779
+ issue(
780
+ ctx,
781
+ `${path}.${field}`,
782
+ `relation must be "before" or "after", got ${describeValue(v)}`,
783
+ );
784
+ return undefined;
785
+ }
786
+ return v;
787
+ },
788
+ };
789
+ }
790
+
684
791
  // [LAW:types-are-the-program] A required integer field — the field key (min / max
685
792
  // / by) comes from the map, the message names it. A non-integer or absent value
686
793
  // reports and fails the arm.
@@ -17,6 +17,7 @@ import {
17
17
  actionBindsPersist,
18
18
  actionBindsReset,
19
19
  actionBindsSet,
20
+ type ActionDecl,
20
21
  } from "../action.js";
21
22
  import {
22
23
  knownOptionDomainNames,
@@ -24,6 +25,7 @@ import {
24
25
  } from "../option-domain.js";
25
26
  import { listGlobalsFieldNames } from "./globals.js";
26
27
  import { parsePersistTarget } from "./persist-target.js";
28
+ import { presetNames } from "../presets.js";
27
29
  import { findKeyLine } from "./diagnostics.js";
28
30
  import { isPlainObject, type ValidateCtx } from "./validate-core.js";
29
31
  import {
@@ -116,11 +118,23 @@ export function validateCrossReferences(
116
118
  if (target === null) {
117
119
  ctx.issues.push({
118
120
  path: `actions.${name}.${discriminator}`,
119
- message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")}) or a "segments.<name>.palette" target`,
121
+ message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")}), a "segments.<name>.palette" target, or a "presets.<name>.rootOps" target`,
120
122
  line: findKeyLine(ctx.source, ["actions", name, discriminator]),
121
123
  });
122
124
  continue;
123
125
  }
126
+ if (target.scope === "preset-root-ops") {
127
+ checkPresetRootOpsTarget(
128
+ ctx,
129
+ cfg,
130
+ name,
131
+ discriminator,
132
+ key,
133
+ target.preset,
134
+ a,
135
+ );
136
+ continue;
137
+ }
124
138
  if (target.scope !== "segment-palette") continue;
125
139
  if (!Object.prototype.hasOwnProperty.call(cfg.segments, target.segment)) {
126
140
  ctx.issues.push({
@@ -345,6 +359,76 @@ export function validateCrossReferences(
345
359
  }
346
360
  }
347
361
 
362
+ // [LAW:no-silent-failure] brandon-layout-edit-2gc.1's structural-edit target
363
+ // check, one arm of the persist/reset key cross-ref above. Three things must
364
+ // hold at load time, same spirit as the segment-palette check just above it:
365
+ // the preset name must be real (mirrors globals.preset's check earlier in
366
+ // this function), the arm pairing must make sense for this scope (only
367
+ // removeSegment/insertSegment address a tree — a `to`/`from`/cycle/bounded
368
+ // literal has no meaning as "the current op log"), and every segment name
369
+ // the op names must be declared.
370
+ function checkPresetRootOpsTarget(
371
+ ctx: ValidateCtx,
372
+ cfg: DslConfig,
373
+ name: string,
374
+ discriminator: "persist" | "reset",
375
+ key: string,
376
+ presetName: string,
377
+ a: ActionDecl,
378
+ ): void {
379
+ const at = `actions.${name}.${discriminator}`;
380
+ const line = findKeyLine(ctx.source, ["actions", name, discriminator]);
381
+ if (!presetNames(cfg.presets).includes(presetName)) {
382
+ ctx.issues.push({
383
+ path: at,
384
+ message: `actions.${name}: "${key}" names preset "${presetName}" which is not declared (have: ${presetNames(cfg.presets).join(", ")})`,
385
+ line,
386
+ });
387
+ return;
388
+ }
389
+ // [LAW:one-source-of-truth] `reset` has no value-source arm to check — its
390
+ // shape is a bare `{ reset: key }` — so the arm-pairing/segment checks
391
+ // below are `persist`-only, exactly as the "reset" action's clean-slate
392
+ // undo is meant to be: it clears the whole op log regardless of what wrote
393
+ // it.
394
+ if (discriminator === "reset") return;
395
+ const hasRemove = "removeSegment" in a;
396
+ const hasInsert = "insertSegment" in a;
397
+ if (!hasRemove && !hasInsert) {
398
+ ctx.issues.push({
399
+ path: at,
400
+ message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment" or "insertSegment" (not "to"/"from"/"cycle"/bounded — those have no meaning as a tree op)`,
401
+ line,
402
+ });
403
+ return;
404
+ }
405
+ const missing = (segName: string): boolean =>
406
+ !Object.prototype.hasOwnProperty.call(cfg.segments, segName);
407
+ if (hasRemove && "removeSegment" in a && missing(a.removeSegment)) {
408
+ ctx.issues.push({
409
+ path: at,
410
+ message: `actions.${name}: removeSegment "${a.removeSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
411
+ line,
412
+ });
413
+ }
414
+ if (hasInsert && "insertSegment" in a) {
415
+ if (missing(a.insertSegment)) {
416
+ ctx.issues.push({
417
+ path: at,
418
+ message: `actions.${name}: insertSegment "${a.insertSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
419
+ line,
420
+ });
421
+ }
422
+ if (missing(a.anchor)) {
423
+ ctx.issues.push({
424
+ path: at,
425
+ message: `actions.${name}: anchor "${a.anchor}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
426
+ line,
427
+ });
428
+ }
429
+ }
430
+ }
431
+
348
432
  function hasStateKind(cfg: DslConfig): boolean {
349
433
  for (const v of Object.values(cfg.variables)) {
350
434
  if (v.kind === "state") return true;
@@ -16,17 +16,33 @@ import { isGlobalsField } from "./globals.js";
16
16
 
17
17
  export type PersistTarget =
18
18
  | { readonly scope: "globals"; readonly field: keyof Globals }
19
- | { readonly scope: "segment-palette"; readonly segment: string };
19
+ | { readonly scope: "segment-palette"; readonly segment: string }
20
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's structural-edit
21
+ // target — the accumulated op LOG for one preset's root (see
22
+ // src/config/layout-ops.ts), never the tree itself: a scalar-shaped value
23
+ // (a JSON-encoded string[] of op tokens) so it rides the SAME flat-dict
24
+ // overrides file with no shape change to that store's core writer.
25
+ | { readonly scope: "preset-root-ops"; readonly preset: string };
20
26
 
21
27
  // [LAW:locality-or-seam] `segments.<name>.palette` reuses the SAME dotted
22
28
  // namespacing SegmentDecl.vars already uses for segment-local variables
23
29
  // (`<segment>.<var>`, declared in src/dsl/render.ts) — one idiom for "a name
24
30
  // scoped under a segment", not a bespoke second syntax invented for persist
25
- // targets alone.
31
+ // targets alone. `presets.<name>.rootOps` mirrors it one level up (a name
32
+ // scoped under a preset) — deliberately spelled `rootOps`, not `root`, so it
33
+ // never reads as the same string as presetRoot()'s load-time diagnostic path
34
+ // `presets.<name>.root` (src/config/presets.ts), a different namespace this
35
+ // key must never be confused with.
26
36
  const SEGMENT_PALETTE_KEY = /^segments\.([^.]+)\.palette$/;
37
+ const PRESET_ROOT_OPS_KEY = /^presets\.([^.]+)\.rootOps$/;
27
38
 
28
39
  export function parsePersistTarget(key: string): PersistTarget | null {
29
40
  if (isGlobalsField(key)) return { scope: "globals", field: key };
30
- const match = SEGMENT_PALETTE_KEY.exec(key);
31
- return match ? { scope: "segment-palette", segment: match[1]! } : null;
41
+ const segmentMatch = SEGMENT_PALETTE_KEY.exec(key);
42
+ if (segmentMatch)
43
+ return { scope: "segment-palette", segment: segmentMatch[1]! };
44
+ const presetMatch = PRESET_ROOT_OPS_KEY.exec(key);
45
+ return presetMatch
46
+ ? { scope: "preset-root-ops", preset: presetMatch[1]! }
47
+ : null;
32
48
  }
@@ -37,6 +37,7 @@ import type {
37
37
  PresetDecl,
38
38
  } from "./dsl-types.js";
39
39
  import { effectiveMemberName } from "../themes/policy.js";
40
+ import { applyLayoutOps, decodeLayoutOp } from "./layout-ops.js";
40
41
 
41
42
  // [LAW:one-source-of-truth] The floor preset's name, spelled once. `looks` has
42
43
  // `"none"` (the identity adaptation); presets have `"default"` (the identity
@@ -186,3 +187,44 @@ export function sanitizePersistedPresetOverride(
186
187
  delete rest.preset;
187
188
  return rest;
188
189
  }
190
+
191
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's replay step — the
192
+ // SAME "patch an already-merged config" shape applySegmentPaletteOverrides
193
+ // (src/config/loader/merge.ts) uses one field over, run at the SAME point in
194
+ // RenderCache.buildState (after the globals/segment-palette overrides, before
195
+ // validateConfig): for every preset with an accumulated op log, resolve its
196
+ // CURRENT root the normal way (presetRoot — bundled/user root, or the
197
+ // preset's own declared fragment) and replay the ops on top, writing the
198
+ // result back as that preset's `root`. Every later reader (presetRoot,
199
+ // registerDslConfig's per-preset compile, validateConfig's cross-ref walk)
200
+ // sees the patched tree as if it had been authored that way — no second
201
+ // resolution path [LAW:locality-or-seam].
202
+ //
203
+ // [LAW:no-silent-failure] exception: an op whose target/anchor names a
204
+ // segment absent from the CURRENT tree is a no-op (layout-ops.ts's own
205
+ // documented policy) — a validated action can only ever name a segment the
206
+ // config declares at the time it was clicked, so a miss here only happens
207
+ // after a LATER edit (a config change, or an earlier op in the same list)
208
+ // already removed it. A malformed individual token (decodeLayoutOp -> null;
209
+ // can only arise from hand-edited or previous-version state, never from this
210
+ // process's own encodeLayoutOp) is filtered the same way, never applied.
211
+ export function applyPresetRootOpsOverrides(
212
+ config: DslConfig,
213
+ presetRootOps: Readonly<Record<string, readonly string[]>>,
214
+ ): DslConfig {
215
+ const entries = Object.entries(presetRootOps).filter(
216
+ ([, tokens]) => tokens.length > 0,
217
+ );
218
+ if (entries.length === 0) return config;
219
+ const presets: Record<string, PresetDecl> = { ...config.presets };
220
+ for (const [name, tokens] of entries) {
221
+ const ops = tokens.map(decodeLayoutOp).filter((op) => op !== null);
222
+ if (ops.length === 0) continue;
223
+ const { node } = presetRoot(config, name);
224
+ presets[name] = {
225
+ ...presetByName(config.presets, name),
226
+ root: applyLayoutOps(node, ops),
227
+ };
228
+ }
229
+ return { ...config, presets };
230
+ }