@promptctl/cc-candybar 1.29.0 → 1.31.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 +75 -75
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +67 -0
- package/src/click/wire.ts +20 -0
- package/src/config/action.ts +64 -2
- package/src/config/layout-ops.ts +156 -0
- package/src/config/loader/actions.ts +155 -3
- package/src/config/loader/cross-ref.ts +101 -8
- 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 +367 -26
- package/src/daemon/verbs/config-validators.ts +38 -0
- package/src/daemon/verbs/index.ts +88 -3
- package/src/render/action.ts +67 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.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.31.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.31.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.31.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.31.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": {
|
|
@@ -1356,6 +1399,30 @@
|
|
|
1356
1399
|
"reset"
|
|
1357
1400
|
],
|
|
1358
1401
|
"additionalProperties": false
|
|
1402
|
+
},
|
|
1403
|
+
{
|
|
1404
|
+
"type": "object",
|
|
1405
|
+
"properties": {
|
|
1406
|
+
"undo": {
|
|
1407
|
+
"const": true
|
|
1408
|
+
}
|
|
1409
|
+
},
|
|
1410
|
+
"required": [
|
|
1411
|
+
"undo"
|
|
1412
|
+
],
|
|
1413
|
+
"additionalProperties": false
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
"type": "object",
|
|
1417
|
+
"properties": {
|
|
1418
|
+
"redo": {
|
|
1419
|
+
"const": true
|
|
1420
|
+
}
|
|
1421
|
+
},
|
|
1422
|
+
"required": [
|
|
1423
|
+
"redo"
|
|
1424
|
+
],
|
|
1425
|
+
"additionalProperties": false
|
|
1359
1426
|
}
|
|
1360
1427
|
]
|
|
1361
1428
|
}
|
package/src/click/wire.ts
CHANGED
|
@@ -66,6 +66,26 @@ 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";
|
|
80
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.2's global history step
|
|
81
|
+
// over the config-overrides layer — the fine-grained sibling of
|
|
82
|
+
// VERB_RESET_CONFIG's coarse "clear one key". Args: `[sessionId]` — there is
|
|
83
|
+
// no key: the history is ONE stack over every persist/reset write ever made
|
|
84
|
+
// to the overrides file (config-overrides-store.ts), not a per-key log. An
|
|
85
|
+
// empty stack is a loud BAD_REQUEST surfaced through click.error like any
|
|
86
|
+
// other verb failure, never a silent no-op.
|
|
87
|
+
export const VERB_UNDO = "undo";
|
|
88
|
+
export const VERB_REDO = "redo";
|
|
69
89
|
|
|
70
90
|
// [LAW:types-are-the-program] An effect to EMIT: a verb plus its raw (unencoded)
|
|
71
91
|
// positional args. The wire owns all encoding — callers never percent-encode.
|
package/src/config/action.ts
CHANGED
|
@@ -43,7 +43,24 @@ export type { OptionDomain } from "./option-domain.js";
|
|
|
43
43
|
// overrides layer (never the hand-authored config file). `reset` clears one
|
|
44
44
|
// persisted override — the gated undo `persist` needs, since a machine-owned
|
|
45
45
|
// write with no way back would be a one-way ratchet.
|
|
46
|
-
|
|
46
|
+
//
|
|
47
|
+
// [LAW:one-source-of-truth] `undo`/`redo` (brandon-layout-edit-2gc.2) are
|
|
48
|
+
// `reset`'s FINE-GRAINED siblings: `reset` clears one named key outright
|
|
49
|
+
// (the coarse "forget this override" case); `undo`/`redo` step ONE GLOBAL
|
|
50
|
+
// history of every `persist`/`reset` write ever made to the overrides layer
|
|
51
|
+
// — every key, not just structural layout edits — back and forth. Neither
|
|
52
|
+
// carries a key: the history is a single stack over the whole overrides
|
|
53
|
+
// file (config-overrides-store.ts owns it), so the action is a bare marker,
|
|
54
|
+
// like `int: true` is for a set-int cursor.
|
|
55
|
+
export const ACTION_KEYS = [
|
|
56
|
+
"set",
|
|
57
|
+
"persist",
|
|
58
|
+
"copy",
|
|
59
|
+
"open",
|
|
60
|
+
"reset",
|
|
61
|
+
"undo",
|
|
62
|
+
"redo",
|
|
63
|
+
] as const;
|
|
47
64
|
export type ActionKey = (typeof ACTION_KEYS)[number];
|
|
48
65
|
|
|
49
66
|
// [LAW:types-are-the-program] An ActionDecl is the click effect a named action
|
|
@@ -75,6 +92,22 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
|
|
|
75
92
|
// -> allow-list {members}
|
|
76
93
|
// copy — copy templated text to the clipboard -> no gate
|
|
77
94
|
// open — open a templated target in the editor -> no gate
|
|
95
|
+
// undo — step the config-overrides layer's GLOBAL history one
|
|
96
|
+
// entry back (any persist/reset write, not just a
|
|
97
|
+
// layout op) -> no gate, no key: there is nothing a
|
|
98
|
+
// template could smuggle, since the value restored is
|
|
99
|
+
// whatever the daemon's own history recorded, never
|
|
100
|
+
// wire input
|
|
101
|
+
// redo — the inverse of undo: re-apply the most recently
|
|
102
|
+
// undone entry -> no gate, no key
|
|
103
|
+
// removeSegment — (persist only) remove the named segment from the
|
|
104
|
+
// preset-root the `persist` key addresses
|
|
105
|
+
// (`presets.<name>.rootOps`) -> allow-list {one op
|
|
106
|
+
// token — see src/config/layout-ops.ts}
|
|
107
|
+
// insertSegment +
|
|
108
|
+
// anchor + relation — (persist only) insert a named segment before/after
|
|
109
|
+
// an existing one, same key shape -> allow-list {one
|
|
110
|
+
// op token}
|
|
78
111
|
//
|
|
79
112
|
// [LAW:one-source-of-truth] `set` writes SessionState and `persist` writes
|
|
80
113
|
// the config-overrides layer, so only those two derive a validator (through
|
|
@@ -87,6 +120,15 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
|
|
|
87
120
|
// arms verbatim (to/from/min-max-by/cycle) MINUS `int`: an unbounded page
|
|
88
121
|
// cursor is a UI-only paging concept (a picker's own navigation state) with
|
|
89
122
|
// no meaning as a persisted config default.
|
|
123
|
+
//
|
|
124
|
+
// [LAW:locality-or-seam] `removeSegment`/`insertSegment` are `persist`-ONLY
|
|
125
|
+
// (brandon-layout-edit-2gc.1) — a structural edit is always a durable,
|
|
126
|
+
// machine-owned write by design (the ticket's own instruction: reuse 71o's
|
|
127
|
+
// writer, land in the SAME overrides layer), so there is no SessionState
|
|
128
|
+
// twin. Every operation is fully literal at config-author time — the
|
|
129
|
+
// segment names and relation are DATA the loader proves at load, not a
|
|
130
|
+
// runtime picker — so each declared action has exactly one legal request,
|
|
131
|
+
// gated the same one-member-allow-list way a literal `to` already is.
|
|
90
132
|
export type ActionDecl =
|
|
91
133
|
| { readonly set: string; readonly to: string }
|
|
92
134
|
| { readonly set: string; readonly from: OptionDomain }
|
|
@@ -107,9 +149,18 @@ export type ActionDecl =
|
|
|
107
149
|
readonly by: number;
|
|
108
150
|
}
|
|
109
151
|
| { readonly persist: string; readonly cycle: readonly string[] }
|
|
152
|
+
| { readonly persist: string; readonly removeSegment: string }
|
|
153
|
+
| {
|
|
154
|
+
readonly persist: string;
|
|
155
|
+
readonly insertSegment: string;
|
|
156
|
+
readonly anchor: string;
|
|
157
|
+
readonly relation: "before" | "after";
|
|
158
|
+
}
|
|
110
159
|
| { readonly copy: string }
|
|
111
160
|
| { readonly open: string }
|
|
112
|
-
| { readonly reset: string }
|
|
161
|
+
| { readonly reset: string }
|
|
162
|
+
| { readonly undo: true }
|
|
163
|
+
| { readonly redo: true };
|
|
113
164
|
|
|
114
165
|
// [LAW:dataflow-not-control-flow] Does this action write a SessionState key? A
|
|
115
166
|
// `set` action composes a set-state click URL whose first segment is session.id;
|
|
@@ -131,3 +182,14 @@ export function actionBindsPersist(a: ActionDecl): boolean {
|
|
|
131
182
|
export function actionBindsReset(a: ActionDecl): boolean {
|
|
132
183
|
return "reset" in a;
|
|
133
184
|
}
|
|
185
|
+
|
|
186
|
+
// [LAW:dataflow-not-control-flow] Does this action step the config-overrides
|
|
187
|
+
// history? `undo`/`redo` carry session.id on the wire too — same reason as
|
|
188
|
+
// `reset`: an empty stack is a loud, session-scoped click.error, not a
|
|
189
|
+
// silent no-op (the ticket's own done-gate).
|
|
190
|
+
export function actionBindsUndo(a: ActionDecl): boolean {
|
|
191
|
+
return "undo" in a;
|
|
192
|
+
}
|
|
193
|
+
export function actionBindsRedo(a: ActionDecl): boolean {
|
|
194
|
+
return "redo" in a;
|
|
195
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -145,6 +145,8 @@ const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
|
|
|
145
145
|
copy: templateArm("copy"),
|
|
146
146
|
open: templateArm("open"),
|
|
147
147
|
reset: resetArm,
|
|
148
|
+
undo: markerArm("undo"),
|
|
149
|
+
redo: markerArm("redo"),
|
|
148
150
|
};
|
|
149
151
|
|
|
150
152
|
// [LAW:one-source-of-truth] A copy/open action emits the closed single-key
|
|
@@ -170,6 +172,8 @@ function actionDeclJson(): JsonNode {
|
|
|
170
172
|
templateArmJson("copy"),
|
|
171
173
|
templateArmJson("open"),
|
|
172
174
|
templateArmJson("reset"),
|
|
175
|
+
markerArmJson("undo"),
|
|
176
|
+
markerArmJson("redo"),
|
|
173
177
|
],
|
|
174
178
|
};
|
|
175
179
|
}
|
|
@@ -228,6 +232,47 @@ function resetArm(
|
|
|
228
232
|
return key === null ? null : { reset: key };
|
|
229
233
|
}
|
|
230
234
|
|
|
235
|
+
// [LAW:one-type-per-behavior] `undo`/`redo` are copy/open/reset's shape one
|
|
236
|
+
// step further reduced: a single required key whose only legal VALUE is the
|
|
237
|
+
// literal `true` (mirrors intMarkerSpec — a marker, not data), because there
|
|
238
|
+
// is no key to name: the history they step is one global stack over the
|
|
239
|
+
// whole overrides layer, not a per-target write. `function`, not a const
|
|
240
|
+
// arrow, so ACTION_ARMS above (built before this declaration in source
|
|
241
|
+
// order) can reference it directly via hoisting.
|
|
242
|
+
function markerArm(key: "undo" | "redo"): ArmParse<ActionDecl> {
|
|
243
|
+
return (ctx, path, raw) => {
|
|
244
|
+
for (const k of Object.keys(raw)) {
|
|
245
|
+
if (k !== key)
|
|
246
|
+
issue(
|
|
247
|
+
ctx,
|
|
248
|
+
`${path}.${k}`,
|
|
249
|
+
`Unknown key "${k}" on a ${key} action. Expected only: ${key}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (raw[key] !== true) {
|
|
253
|
+
issue(
|
|
254
|
+
ctx,
|
|
255
|
+
`${path}.${key}`,
|
|
256
|
+
`${key} must be the literal true (it takes no key — it steps the ONE global history over the whole overrides layer), got ${describeValue(raw[key])}`,
|
|
257
|
+
);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return { [key]: true } as unknown as ActionDecl;
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// [LAW:one-source-of-truth] Mirrors templateArmJson's shape one level
|
|
265
|
+
// narrower: the value schema is `const: true`, not `type: string` — a
|
|
266
|
+
// marker action carries no data, on the wire or in the schema.
|
|
267
|
+
function markerArmJson(key: "undo" | "redo"): JsonNode {
|
|
268
|
+
return {
|
|
269
|
+
type: "object",
|
|
270
|
+
properties: { [key]: { const: true } },
|
|
271
|
+
required: [key],
|
|
272
|
+
additionalProperties: false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
231
276
|
// ─── The `set` value-source sub-union ────────────────────────────────────────
|
|
232
277
|
|
|
233
278
|
// [LAW:single-enforcer] A set-state URL path segment must be a non-empty,
|
|
@@ -324,6 +369,25 @@ const CYCLE_FIELDS_SET: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
|
324
369
|
const CYCLE_FIELDS_PERSIST: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
325
370
|
cycle: cycleSpec("persist"),
|
|
326
371
|
};
|
|
372
|
+
// [LAW:one-type-per-behavior] brandon-layout-edit-2gc.1's two structural-edit
|
|
373
|
+
// arms — PERSIST-only (see action.ts's ActionDecl doc comment for why there
|
|
374
|
+
// is no `set` twin). Each field reuses layoutNameSpec: a segment/anchor name
|
|
375
|
+
// must be non-empty and free of both `/` (the click wire's own segment
|
|
376
|
+
// delimiter) and `:` (layout-ops.ts's op-token delimiter) — the SAME
|
|
377
|
+
// wire-safety diligence slashFreeString already applies to `to`/`cycle`
|
|
378
|
+
// members, one forbidden character wider.
|
|
379
|
+
const REMOVE_SEGMENT_FIELDS: FieldSpecMap<{ removeSegment: string }> = {
|
|
380
|
+
removeSegment: layoutNameSpec("removeSegment"),
|
|
381
|
+
};
|
|
382
|
+
const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
|
|
383
|
+
insertSegment: string;
|
|
384
|
+
anchor: string;
|
|
385
|
+
relation: "before" | "after";
|
|
386
|
+
}> = {
|
|
387
|
+
insertSegment: layoutNameSpec("insertSegment"),
|
|
388
|
+
anchor: layoutNameSpec("anchor"),
|
|
389
|
+
relation: relationSpec(),
|
|
390
|
+
};
|
|
327
391
|
|
|
328
392
|
// [LAW:types-are-the-program] A bounded step is fully described by an integer
|
|
329
393
|
// domain (min < max) and a non-zero integer increment (`by`; negative for a
|
|
@@ -418,16 +482,49 @@ const SET_ARMS: readonly ValueSourceArm[] = [
|
|
|
418
482
|
|
|
419
483
|
// [LAW:one-type-per-behavior] `persist` mirrors `set` minus the `int` arm — a
|
|
420
484
|
// page cursor is a UI-only paging concept with no meaning as a persisted
|
|
421
|
-
// config default (see action.ts's ActionDecl comment).
|
|
485
|
+
// config default (see action.ts's ActionDecl comment). `removeSegment`/
|
|
486
|
+
// `insertSegment` are ADDITIONAL persist-only arms with no `set` counterpart.
|
|
422
487
|
const PERSIST_ARMS: readonly ValueSourceArm[] = [
|
|
423
488
|
valueSourceArm("persist", TO_FIELDS_PERSIST),
|
|
424
489
|
valueSourceArm("persist", FROM_FIELDS_PERSIST),
|
|
425
490
|
valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
426
491
|
valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
|
|
492
|
+
valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
|
|
493
|
+
valueSourceArm("persist", INSERT_SEGMENT_FIELDS),
|
|
427
494
|
];
|
|
428
495
|
|
|
429
|
-
|
|
430
|
-
|
|
496
|
+
// [LAW:one-source-of-truth] The clause list, not the joined string, is the
|
|
497
|
+
// data that varies per discriminator — the "or" belongs on the LAST clause
|
|
498
|
+
// only, and which clause is last differs between `set` (ends at cycle) and
|
|
499
|
+
// `persist` (ends at insertSegment), so building a list and joining it is
|
|
500
|
+
// what keeps that placement correct without a second copy of the sentence.
|
|
501
|
+
function valueSourceClauses(discriminator: "set" | "persist"): string[] {
|
|
502
|
+
const clauses = [
|
|
503
|
+
`"to" (a literal value)`,
|
|
504
|
+
`"from" (an option domain — a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values)`,
|
|
505
|
+
`"min"/"max"/"by" (a bounded step)`,
|
|
506
|
+
];
|
|
507
|
+
if (discriminator === "set")
|
|
508
|
+
clauses.push(`"int" (an unbounded integer cursor)`);
|
|
509
|
+
clauses.push(`"cycle" (an enumerated domain stepped in order)`);
|
|
510
|
+
if (discriminator === "persist") {
|
|
511
|
+
clauses.push(
|
|
512
|
+
`"removeSegment" (remove a named segment from the layout)`,
|
|
513
|
+
`"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
return clauses;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function VALUE_SOURCE_MESSAGE(discriminator: "set" | "persist"): string {
|
|
520
|
+
const clauses = valueSourceClauses(discriminator);
|
|
521
|
+
const last = clauses[clauses.length - 1]!;
|
|
522
|
+
const list =
|
|
523
|
+
clauses.length === 1
|
|
524
|
+
? last
|
|
525
|
+
: `${clauses.slice(0, -1).join(", ")}, or ${last}`;
|
|
526
|
+
return `a ${discriminator} action declares exactly one value source: ${list}`;
|
|
527
|
+
}
|
|
431
528
|
|
|
432
529
|
// [LAW:dataflow-not-control-flow] The set/persist sub-union eliminator:
|
|
433
530
|
// validate the shared discriminator key, count which value sources are
|
|
@@ -681,6 +778,61 @@ function intMarkerSpec(): FieldSpec<true> {
|
|
|
681
778
|
};
|
|
682
779
|
}
|
|
683
780
|
|
|
781
|
+
// [LAW:one-source-of-truth] A layout op's segment-name field (removeSegment /
|
|
782
|
+
// insertSegment / anchor) is non-empty and free of BOTH wire-structural
|
|
783
|
+
// characters: `/` (the click wire's own multi-arg segment delimiter, the
|
|
784
|
+
// same restriction slashFreeString already enforces for `to`/`cycle`) and
|
|
785
|
+
// `:` (layout-ops.ts's op-token delimiter — a name containing it would make
|
|
786
|
+
// encodeLayoutOp's output ambiguous to decode). One spec, three callsites,
|
|
787
|
+
// so the two-character restriction can't drift between them.
|
|
788
|
+
function layoutNameSpec(field: string): FieldSpec<string> {
|
|
789
|
+
return {
|
|
790
|
+
required: true,
|
|
791
|
+
json: { type: "string" },
|
|
792
|
+
parse: (ctx, path, f, raw) => {
|
|
793
|
+
const v = requireString(ctx, path, raw, f);
|
|
794
|
+
if (v === null) return undefined;
|
|
795
|
+
const at = `${path}.${f}`;
|
|
796
|
+
if (v === "") {
|
|
797
|
+
issue(ctx, at, `${field} must be non-empty (a segment name)`);
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
if (v.includes("/") || v.includes(":")) {
|
|
801
|
+
issue(
|
|
802
|
+
ctx,
|
|
803
|
+
at,
|
|
804
|
+
`${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)`,
|
|
805
|
+
);
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
808
|
+
return v;
|
|
809
|
+
},
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// [LAW:types-are-the-program] `relation` is a closed two-value enum, not a
|
|
814
|
+
// free string — a typo (`"befor"`) is a load error, never a click-time
|
|
815
|
+
// surprise. Mirrors intMarkerSpec's "one legal literal" shape, widened to
|
|
816
|
+
// two.
|
|
817
|
+
function relationSpec(): FieldSpec<"before" | "after"> {
|
|
818
|
+
return {
|
|
819
|
+
required: true,
|
|
820
|
+
json: { enum: ["before", "after"] },
|
|
821
|
+
parse: (ctx, path, field, raw) => {
|
|
822
|
+
const v = raw[field];
|
|
823
|
+
if (v !== "before" && v !== "after") {
|
|
824
|
+
issue(
|
|
825
|
+
ctx,
|
|
826
|
+
`${path}.${field}`,
|
|
827
|
+
`relation must be "before" or "after", got ${describeValue(v)}`,
|
|
828
|
+
);
|
|
829
|
+
return undefined;
|
|
830
|
+
}
|
|
831
|
+
return v;
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
684
836
|
// [LAW:types-are-the-program] A required integer field — the field key (min / max
|
|
685
837
|
// / by) comes from the map, the message names it. A non-integer or absent value
|
|
686
838
|
// reports and fails the arm.
|