@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/dist/index.mjs +77 -77
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +43 -0
- package/src/click/wire.ts +11 -0
- package/src/config/action.ts +24 -0
- package/src/config/default-dsl-config.ts +163 -26
- package/src/config/layout-ops.ts +156 -0
- package/src/config/loader/actions.ts +110 -3
- package/src/config/loader/cross-ref.ts +85 -1
- package/src/config/loader/persist-target.ts +20 -4
- package/src/config/presets.ts +42 -0
- package/src/daemon/cache/render.ts +17 -2
- package/src/daemon/config-overrides-store.ts +59 -9
- package/src/daemon/verbs/config-validators.ts +38 -0
- package/src/daemon/verbs/index.ts +50 -3
- package/src/render/action.ts +41 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.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.
|
package/src/config/action.ts
CHANGED
|
@@ -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
|
|
251
|
-
// persist-forever twin — candybar-config-engine-71o.5
|
|
252
|
-
// choice as everyone's default), the four .3 globals
|
|
253
|
-
// no SessionState half at all), and one .6
|
|
254
|
-
// (directoryPaletteControl, persist-only like
|
|
255
|
-
// per-segment palette pin, not a whole-bar default).
|
|
256
|
-
// row 1's horizontal container, toggled from beside the
|
|
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
|
|
1269
|
-
// domain sources (`from`), same picker
|
|
1270
|
-
// Globals field the config DEFAULT
|
|
1271
|
-
// isGlobalsField-checked at
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
// for that session — effectiveThemeName/
|
|
1275
|
-
// effectiveLookName all read
|
|
1276
|
-
// `reset` each, per the docs'
|
|
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 (
|
|
1384
|
-
// `{ set: "preset", from: "presets" }
|
|
1385
|
-
//
|
|
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
|
|
1388
|
-
//
|
|
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.
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
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
|
+
}
|