@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/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/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 };
|
|
@@ -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
|
-
|
|
430
|
-
|
|
451
|
+
// [LAW:one-source-of-truth] The clause list, not the joined string, is the
|
|
452
|
+
// data that varies per discriminator — the "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(", ")})
|
|
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
|
|
31
|
-
|
|
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
|
}
|
package/src/config/presets.ts
CHANGED
|
@@ -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
|
+
}
|