@promptctl/cc-candybar 1.28.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.28.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.28.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.28.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.28.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.28.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 };
@@ -247,14 +247,25 @@ function etaHeatFg(etaRef: string, warnRef: string): string {
247
247
  // time — the type safety net just moves from tsc to that parse, never lost.
248
248
  //
249
249
  // One collapsed-by-default drawer holding every bar-mutable display default:
250
- // theme/style/look (session `set` for a per-conversation preview, PLUS a
251
- // persist-forever twin — candybar-config-engine-71o.5 — for pinning the
252
- // choice as everyone's default), the four .3 globals steppers (persist-only,
253
- // no SessionState half at all), and one .6 segment-scoped persist control
254
- // (directoryPaletteControl, persist-only like the four steppers — a
255
- // per-segment palette pin, not a whole-bar default). Placed as a sibling in
256
- // row 1's horizontal container, toggled from beside the quick-action tray —
257
- // see `root` below.
250
+ // theme/style/look/preset (session `set` for a per-conversation preview, PLUS
251
+ // a persist-forever twin — candybar-config-engine-71o.5, brandon-presets-0yk.3
252
+ // — for pinning the choice as everyone's default), the four .3 globals
253
+ // steppers (persist-only, no SessionState half at all), and one .6
254
+ // segment-scoped persist control (directoryPaletteControl, persist-only like
255
+ // the four steppers — a per-segment palette pin, not a whole-bar default).
256
+ // Placed as a sibling in row 1's horizontal container, toggled from beside the
257
+ // quick-action tray — see `root` below.
258
+ //
259
+ // [LAW:one-source-of-truth] exception: this `kind: "group"` sugar node may
260
+ // appear EXACTLY ONCE in the whole config — group names are a synthesis-wide
261
+ // namespace (synthesizeGroupDecls collects every group across every preset's
262
+ // root too), so a second `settingsDrawer` reference embedded in a preset's
263
+ // own root would be a SECOND declaration of "settings" and collide with
264
+ // itself, not a reuse of the first. It stays only in the default `root`
265
+ // below; the library presets under `presets:` reach back to it — and to the
266
+ // rest of the settingsDrawer's controls — by switching to the "default"
267
+ // preset via the standalone `presetControl` segment they carry instead (see
268
+ // each preset's own comment), never by re-embedding the group.
258
269
  const settingsDrawer = {
259
270
  kind: "group",
260
271
  name: "settings",
@@ -263,6 +274,7 @@ const settingsDrawer = {
263
274
  children: [
264
275
  "themeControl",
265
276
  "lookControl",
277
+ "presetControl",
266
278
  "styleControl",
267
279
  "charsetControl",
268
280
  "colorCompatControl",
@@ -1099,6 +1111,25 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1099
1111
  bg: "surface",
1100
1112
  fg: "foreground",
1101
1113
  },
1114
+ // Preset control — the whole-arrangement switcher (brandon-presets-0yk.3),
1115
+ // the exact twin of themeControl/lookControl one level up: `.preset.
1116
+ // effective` is the daemon-resolved name (effectivePresetName) that chose
1117
+ // BOTH the compiled root this render walked and the globals it rendered
1118
+ // with, so the label can never name an arrangement the bar is not in.
1119
+ // closeOnPick on both menus, like lookControl: a preset swap is a
1120
+ // decisive whole-bar change tried one at a time, not a stackable tweak.
1121
+ // Lives inside settingsDrawer (never inside a preset's own staged root —
1122
+ // see settingsDrawer's comment), so every bundled preset — including the
1123
+ // narrow `compact` one — keeps the one control that switches back.
1124
+ presetControl: {
1125
+ template:
1126
+ "▦ {{ .preset.effective }} " +
1127
+ '{{ menu "applyPreset" (dict "key" "pickers" "closeOnPick" true) }} ' +
1128
+ '📌{{ menu "applyPresetForever" (dict "key" "pickersForever" "closeOnPick" true) }} ' +
1129
+ '{{ action "resetPreset" "↺" }}',
1130
+ bg: "surface",
1131
+ fg: "foreground",
1132
+ },
1102
1133
  // ── The four .3 globals steppers, folded into the settingsDrawer group
1103
1134
  // (candybar-config-engine-71o.4) alongside theme/style/look above. Each
1104
1135
  // pairs a `persist` control with a `↺` reset (docs' persist/reset
@@ -1263,23 +1294,33 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1263
1294
  // block's names — the same derivation test/dsl-looks.test.ts exercises.
1264
1295
  applyTheme: { set: "theme", from: "themes" },
1265
1296
  applyLook: { set: "look", from: "looks" },
1297
+ // [LAW:one-type-per-behavior] The preset picker's session-preview
1298
+ // behavior (brandon-presets-0yk.3), the exact twin of applyLook one level
1299
+ // up: "preset" has no baseline SessionState entry, so this derives a
1300
+ // fresh allow-list validator ranging the merged `presets` block's names
1301
+ // (the floor included — presetNames always seeds it) via the same
1302
+ // `from`-sourced derivation every picker above uses.
1303
+ applyPreset: { set: "preset", from: "presets" },
1266
1304
 
1267
1305
  // [LAW:one-source-of-truth] The persist-forever twins of applyTheme/
1268
- // applyStyle/applyLook above (candybar-config-engine-71o.5) — same
1269
- // domain sources (`from`), same picker mechanism, but the target is the
1270
- // Globals field the config DEFAULT reads (`palette`/`style`/`look`,
1271
- // isGlobalsField-checked at load), not the SessionState key the session
1272
- // preview writes. Precedence is unchanged: a session's own `set` pick
1273
- // (applyTheme/applyStyle/applyLook) still wins over a persisted default
1274
- // for that session — effectiveThemeName/effectiveStripStyle/
1275
- // effectiveLookName all read SessionState before globals. Paired with a
1276
- // `reset` each, per the docs' persist/reset convention.
1306
+ // applyStyle/applyLook/applyPreset above (candybar-config-engine-71o.5,
1307
+ // brandon-presets-0yk.3) — same domain sources (`from`), same picker
1308
+ // mechanism, but the target is the Globals field the config DEFAULT
1309
+ // reads (`palette`/`style`/`look`/`preset`, isGlobalsField-checked at
1310
+ // load), not the SessionState key the session preview writes.
1311
+ // Precedence is unchanged: a session's own `set` pick still wins over a
1312
+ // persisted default for that session — effectiveThemeName/
1313
+ // effectiveStripStyle/effectiveLookName/effectivePresetName all read
1314
+ // SessionState before globals. Paired with a `reset` each, per the docs'
1315
+ // persist/reset convention.
1277
1316
  applyThemeForever: { persist: "palette", from: "themes" },
1278
1317
  resetTheme: { reset: "palette" },
1279
1318
  applyStyleForever: { persist: "style", from: "styles" },
1280
1319
  resetStyle: { reset: "style" },
1281
1320
  applyLookForever: { persist: "look", from: "looks" },
1282
1321
  resetLook: { reset: "look" },
1322
+ applyPresetForever: { persist: "preset", from: "presets" },
1323
+ resetPreset: { reset: "preset" },
1283
1324
 
1284
1325
  // [LAW:locality-or-seam] The settings-drawer steppers' behaviors
1285
1326
  // (candybar-config-engine-71o.4), decoupled by NAME from
@@ -1380,21 +1421,117 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1380
1421
  // Named config FRAGMENTS — each an alternative `root` + display `globals`,
1381
1422
  // i.e. a whole arrangement of the bar rather than one knob. A preset is to
1382
1423
  // configuration what a look is to a theme, and rides the identical seam:
1383
- // selected per session via the `preset` SessionState key (an action
1384
- // `{ set: "preset", from: "presets" }` + a `{{ menu }}`), resolved as session
1385
- // pick over globals.preset over this floor.
1424
+ // selected per session via the `preset` SessionState key (the applyPreset
1425
+ // action, `{ set: "preset", from: "presets" }`, and the presetControl
1426
+ // segment's `{{ menu }}` above), resolved as session pick over
1427
+ // globals.preset over this floor.
1386
1428
  // [LAW:one-source-of-truth] Merges by name (user wins per name), so this
1387
- // stdlib — currently just the floor — is present in every merged config by
1388
- // construction, exactly as looks' "none" is.
1429
+ // stdlib is present in every merged config by construction, exactly as
1430
+ // looks' "none"/"vivid"/"muted"/… is — a user redefining "compact" or
1431
+ // "verbose" wins per name; the floor cannot be shadowed by anything but an
1432
+ // empty fragment, because that IS what it already is.
1433
+ //
1434
+ // [LAW:carrying-cost] Each library preset stages only real deltas from the
1435
+ // bundled default's own root (declared above) — no preset here restates a
1436
+ // segment's template, bg, or fg, only which of the ALREADY-declared
1437
+ // segments appear and in what arrangement, per the field cap
1438
+ // (brandon-presets-0yk.1's premise note): a bundled preset can only STAGE
1439
+ // segments DEFAULT_DSL_CONFIG.segments already declares, never introduce
1440
+ // one.
1441
+ //
1442
+ // [LAW:verifiable-goals] Every entry here is asserted error-cell-free at
1443
+ // 80/120/200 columns against a RICH payload in
1444
+ // test/default-dsl-config.test.ts (the same checkPayload fixture check.ts
1445
+ // uses) — curated by actually rendering each arrangement, not by reading
1446
+ // the segment names off the page.
1389
1447
  presets: {
1390
1448
  // [LAW:dataflow-not-control-flow] "default" is just the identity fragment
1391
1449
  // — the resolution floor as a value, not a special case. An empty fragment
1392
1450
  // declares no `root` and no `globals`, so it stages the config's own, which
1393
- // is precisely what "no preset chosen" means. A usable preset LIBRARY on
1394
- // top of this floor is brandon-presets-0yk.3; the floor itself belongs here
1395
- // because effectivePresetName's collapse target must exist in every merged
1396
- // config, the same load-bearing reason "none" ships beside the real looks.
1451
+ // is precisely what "no preset chosen" means. This is the bundled default's
1452
+ // OWN two-row (identity / status) arrangement declared as `root` above
1453
+ // it needs no entry of its own here beyond the floor, because staging it
1454
+ // AS a named fragment would be a byte-for-byte copy of `root` that could
1455
+ // silently drift from it the moment either changed.
1397
1456
  default: {},
1457
+
1458
+ // Single-row arrangement for narrow terminals and split panes — the
1459
+ // situation where a user most wants a different arrangement and least
1460
+ // wants to hand-write one. Keeps only the three facts a split pane most
1461
+ // needs at a glance (where am I / what's the git state / how much context
1462
+ // is left), the quiet one-line `git` segment rather than the multi-fact
1463
+ // `gitaculous`, and `padding: 0` to buy back the chrome a narrow column
1464
+ // can't spare.
1465
+ //
1466
+ // [LAW:no-silent-failure] Carries the standalone `presetControl` segment
1467
+ // (NOT the full settingsDrawer group — see that constant's own comment on
1468
+ // why a group can't be re-embedded) so a session that switches TO compact
1469
+ // is never stranded: one click back to "default" restores the drawer and
1470
+ // everything else compact traded away for width.
1471
+ compact: {
1472
+ root: {
1473
+ kind: "container",
1474
+ direction: "horizontal",
1475
+ children: [
1476
+ { kind: "segment", name: "directory" },
1477
+ { kind: "segment", name: "git" },
1478
+ { kind: "segment", name: "context" },
1479
+ { kind: "segment", name: "presetControl" },
1480
+ ],
1481
+ },
1482
+ globals: { padding: 0 },
1483
+ },
1484
+
1485
+ // Verbose arrangement surfacing every segment that is declared but NOT in
1486
+ // the default root (gitPr, burnrate, speed, tokenSparkline — see each
1487
+ // segment's own "declared-but-opt-in" comment above) alongside the
1488
+ // default's own two rows, for a user who wants the full usage-monitor
1489
+ // picture rather than the quiet default. A third row carries the two
1490
+ // per-turn throughput segments, which read "—" between turns
1491
+ // ([LAW:no-silent-failure] on speed/tokenSparkline) rather than an empty
1492
+ // or stale row. Carries the standalone `presetControl` in place of the
1493
+ // full settingsDrawer group, exactly like `compact` and for the same
1494
+ // reason (that constant's comment) — switching back to "default" reaches
1495
+ // the drawer and every other bar-mutable default from there.
1496
+ verbose: {
1497
+ root: {
1498
+ kind: "container",
1499
+ direction: "vertical",
1500
+ children: [
1501
+ {
1502
+ kind: "container",
1503
+ direction: "horizontal",
1504
+ children: [
1505
+ { kind: "segment", name: "directory" },
1506
+ { kind: "segment", name: "gitaculous" },
1507
+ { kind: "segment", name: "gitPr" },
1508
+ { kind: "segment", name: "toolbar" },
1509
+ { kind: "segment", name: "presetControl" },
1510
+ ],
1511
+ },
1512
+ {
1513
+ kind: "container",
1514
+ direction: "horizontal",
1515
+ children: [
1516
+ { kind: "segment", name: "model" },
1517
+ { kind: "segment", name: "context" },
1518
+ { kind: "segment", name: "cacheTimer" },
1519
+ { kind: "segment", name: "block" },
1520
+ { kind: "segment", name: "weekly" },
1521
+ { kind: "segment", name: "burnrate" },
1522
+ ],
1523
+ },
1524
+ {
1525
+ kind: "container",
1526
+ direction: "horizontal",
1527
+ children: [
1528
+ { kind: "segment", name: "speed" },
1529
+ { kind: "segment", name: "tokenSparkline" },
1530
+ ],
1531
+ },
1532
+ ],
1533
+ },
1534
+ },
1398
1535
  },
1399
1536
 
1400
1537
  // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
@@ -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
+ }