@promptctl/cc-candybar 1.31.0 → 1.32.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 +26 -26
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +41 -0
- package/src/config/action.ts +25 -0
- package/src/config/dsl-loader.ts +18 -1
- package/src/config/edit-chrome.ts +329 -0
- package/src/config/layout-ops.ts +21 -0
- package/src/config/loader/actions.ts +52 -13
- package/src/config/loader/cross-ref.ts +13 -2
- package/src/config/loader/edit-mode.ts +111 -0
- package/src/daemon/verbs/config-validators.ts +39 -1
- package/src/dsl/render.ts +10 -1
- package/src/render/action.ts +49 -0
- package/src/render/picker.ts +71 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.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.32.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.32.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.32.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.32.0"
|
|
98
98
|
}
|
|
99
99
|
}
|
|
@@ -1364,6 +1364,47 @@
|
|
|
1364
1364
|
],
|
|
1365
1365
|
"additionalProperties": false
|
|
1366
1366
|
},
|
|
1367
|
+
{
|
|
1368
|
+
"type": "object",
|
|
1369
|
+
"properties": {
|
|
1370
|
+
"persist": {
|
|
1371
|
+
"type": "string"
|
|
1372
|
+
},
|
|
1373
|
+
"insertSegmentFrom": {
|
|
1374
|
+
"anyOf": [
|
|
1375
|
+
{
|
|
1376
|
+
"type": "string",
|
|
1377
|
+
"minLength": 1
|
|
1378
|
+
},
|
|
1379
|
+
{
|
|
1380
|
+
"type": "array",
|
|
1381
|
+
"items": {
|
|
1382
|
+
"type": "string",
|
|
1383
|
+
"minLength": 1
|
|
1384
|
+
},
|
|
1385
|
+
"minItems": 1,
|
|
1386
|
+
"uniqueItems": true
|
|
1387
|
+
}
|
|
1388
|
+
]
|
|
1389
|
+
},
|
|
1390
|
+
"anchor": {
|
|
1391
|
+
"type": "string"
|
|
1392
|
+
},
|
|
1393
|
+
"relation": {
|
|
1394
|
+
"enum": [
|
|
1395
|
+
"before",
|
|
1396
|
+
"after"
|
|
1397
|
+
]
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
"required": [
|
|
1401
|
+
"persist",
|
|
1402
|
+
"insertSegmentFrom",
|
|
1403
|
+
"anchor",
|
|
1404
|
+
"relation"
|
|
1405
|
+
],
|
|
1406
|
+
"additionalProperties": false
|
|
1407
|
+
},
|
|
1367
1408
|
{
|
|
1368
1409
|
"type": "object",
|
|
1369
1410
|
"properties": {
|
package/src/config/action.ts
CHANGED
|
@@ -108,6 +108,13 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
|
|
|
108
108
|
// anchor + relation — (persist only) insert a named segment before/after
|
|
109
109
|
// an existing one, same key shape -> allow-list {one
|
|
110
110
|
// op token}
|
|
111
|
+
// insertSegmentFrom +
|
|
112
|
+
// anchor + relation — (persist only) insertSegment's domain-sourced
|
|
113
|
+
// sibling (brandon-layout-edit-2gc.3): the segment
|
|
114
|
+
// name is picked from an option domain at render
|
|
115
|
+
// (a `{{ menu }}`'s bound option) rather than fixed
|
|
116
|
+
// at author time -> allow-list {one op token per
|
|
117
|
+
// domain member}
|
|
111
118
|
//
|
|
112
119
|
// [LAW:one-source-of-truth] `set` writes SessionState and `persist` writes
|
|
113
120
|
// the config-overrides layer, so only those two derive a validator (through
|
|
@@ -156,6 +163,24 @@ export type ActionDecl =
|
|
|
156
163
|
readonly anchor: string;
|
|
157
164
|
readonly relation: "before" | "after";
|
|
158
165
|
}
|
|
166
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's DOMAIN-SOURCED
|
|
167
|
+
// sibling of `insertSegment`: the same tree op, but the segment name comes
|
|
168
|
+
// from the template's bound option (a picker/menu cell) instead of being
|
|
169
|
+
// fixed at config-author time — exactly the `to`-vs-`from` split `set`/
|
|
170
|
+
// `persist` already draw, one arm over. `anchor`/`relation` stay literal
|
|
171
|
+
// (the POSITION is still author-time data; only WHICH segment lands there
|
|
172
|
+
// is picked at render). This is what makes a `{{ menu "insertHere" }}`
|
|
173
|
+
// legal over a structural edit: `requireOptionKind` (render/picker.ts)
|
|
174
|
+
// admits it alongside set-option/persist-option, and the click writes
|
|
175
|
+
// `encodeLayoutOp({ op: "insert", segment: <picked>, anchor, relation })` —
|
|
176
|
+
// the SAME wire shape a literal `insertSegment` action emits, so undo/redo
|
|
177
|
+
// and the daemon's apply-layout-op handler need no changes at all.
|
|
178
|
+
| {
|
|
179
|
+
readonly persist: string;
|
|
180
|
+
readonly insertSegmentFrom: OptionDomain;
|
|
181
|
+
readonly anchor: string;
|
|
182
|
+
readonly relation: "before" | "after";
|
|
183
|
+
}
|
|
159
184
|
| { readonly copy: string }
|
|
160
185
|
| { readonly open: string }
|
|
161
186
|
| { readonly reset: string }
|
package/src/config/dsl-loader.ts
CHANGED
|
@@ -48,6 +48,8 @@ import { validateVariables } from "./loader/variables.js";
|
|
|
48
48
|
import { validateSegments } from "./loader/segments.js";
|
|
49
49
|
import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
|
|
50
50
|
import { synthesizeMenuDecls } from "./loader/menu-synth.js";
|
|
51
|
+
import { synthesizeEditModeToggle } from "./loader/edit-mode.js";
|
|
52
|
+
import { synthesizeEditChrome } from "./edit-chrome.js";
|
|
51
53
|
import { validateActions } from "./loader/actions.js";
|
|
52
54
|
import { validateLooks } from "./loader/looks.js";
|
|
53
55
|
import { validatePresets } from "./loader/presets.js";
|
|
@@ -143,7 +145,15 @@ export function validateConfig(
|
|
|
143
145
|
if (issues.length > 0) {
|
|
144
146
|
throw new ConfigError(filePath, issues);
|
|
145
147
|
}
|
|
146
|
-
|
|
148
|
+
// [LAW:one-source-of-truth] Edit-mode's CHROME half (brandon-layout-edit-
|
|
149
|
+
// 2gc.3), synthesized HERE — not in parseDslConfig alongside the toggle —
|
|
150
|
+
// because it needs the fully merged, preset-resolved, rootOps-replayed
|
|
151
|
+
// tree cross-ref/cycles just proved sound. Its own output (segment refs
|
|
152
|
+
// into freshly-synthesized segments, actions into freshly-synthesized
|
|
153
|
+
// actions) is correct by construction and does not re-enter cross-ref/
|
|
154
|
+
// cycle checking, exactly as group/menu synthesis's output doesn't either.
|
|
155
|
+
const withChrome = synthesizeEditChrome(config);
|
|
156
|
+
return withChrome as ValidatedConfig;
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
/**
|
|
@@ -278,6 +288,13 @@ function validateTopLevel(
|
|
|
278
288
|
// through deriveActionValidators, and collide loudly with any user name under
|
|
279
289
|
// the reserved namespace.
|
|
280
290
|
synthesizeMenuDecls(ctx, out);
|
|
291
|
+
// [LAW:one-source-of-truth] Edit-mode's TOGGLE half (brandon-layout-edit-
|
|
292
|
+
// 2gc.3) — unconditional, like the reservation above, so `edit.mode`/
|
|
293
|
+
// `edit.toggle` exist in EVERY parsed file and a hand-authored trigger
|
|
294
|
+
// segment cross-ref-checks normally. The CHROME half (the per-position +/-
|
|
295
|
+
// affordances) runs later, in validateConfig, once the merged/preset-
|
|
296
|
+
// resolved/rootOps-replayed tree exists to derive it from.
|
|
297
|
+
synthesizeEditModeToggle(ctx, out);
|
|
281
298
|
return out;
|
|
282
299
|
}
|
|
283
300
|
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's CHROME half — the
|
|
2
|
+
// LOWERING that turns "edit mode is a session toggle" into "each row's
|
|
3
|
+
// segments render interleaved with +/- affordances" without a render-walk
|
|
4
|
+
// branch [LAW:dataflow-not-control-flow]. Follows the SAME move `kind:
|
|
5
|
+
// "group"` sugar makes (src/config/loader/layout.ts): one pass produces a NEW
|
|
6
|
+
// tree with synthesized nodes spliced in, each gated by an ordinary `when` —
|
|
7
|
+
// the walk that renders it learns nothing new. The difference from group
|
|
8
|
+
// sugar is WHEN this can run: a group is authored data, lowered per file
|
|
9
|
+
// before merge; edit chrome is DERIVED from which segments are actually in
|
|
10
|
+
// the tree, which is only known after merge, preset-root resolution, and
|
|
11
|
+
// rootOps replay. So this runs from validateConfig, on the fully resolved
|
|
12
|
+
// config each declared preset stages — see synthesizeEditChrome below.
|
|
13
|
+
//
|
|
14
|
+
// [LAW:single-enforcer] The +/- affordances reuse EXISTING primitives
|
|
15
|
+
// wholesale rather than inventing parallel ones: `-` is an ordinary
|
|
16
|
+
// `{ persist, removeSegment }` action behind `{{ action }}` (2gc.1); `+` is an
|
|
17
|
+
// ordinary `{ persist, insertSegmentFrom }` action behind `{{ menu }}`
|
|
18
|
+
// (2gc.3's new arm — see action.ts), synthesized by calling the SAME pure
|
|
19
|
+
// functions `{{ menu }}`'s own load-time synthesis calls
|
|
20
|
+
// (menu-keys.ts/disclosure.ts) so a synthesized menu and a hand-authored one
|
|
21
|
+
// are indistinguishable at render. Nothing here is a new render concept.
|
|
22
|
+
|
|
23
|
+
import type { ActionDecl as ActionDeclType, OptionDomain } from "./action.js";
|
|
24
|
+
import type {
|
|
25
|
+
ContainerNode,
|
|
26
|
+
DslConfig,
|
|
27
|
+
LayoutNode,
|
|
28
|
+
PresetDecl,
|
|
29
|
+
SegmentDecl,
|
|
30
|
+
SegmentNode,
|
|
31
|
+
VariableDecl,
|
|
32
|
+
} from "./dsl-types.js";
|
|
33
|
+
import { collectSegmentNames } from "./layout-ops.js";
|
|
34
|
+
import { presetByName, presetNames, presetRoot } from "./presets.js";
|
|
35
|
+
import {
|
|
36
|
+
EDIT_MODE_GATE,
|
|
37
|
+
EDIT_NS,
|
|
38
|
+
EDIT_TOGGLE_ACTION,
|
|
39
|
+
} from "./loader/edit-mode.js";
|
|
40
|
+
import { GROUP_NS } from "./loader/layout.js";
|
|
41
|
+
import {
|
|
42
|
+
menuActionName,
|
|
43
|
+
menuMember,
|
|
44
|
+
menuPageKey,
|
|
45
|
+
menuStateKey,
|
|
46
|
+
MENU_NS,
|
|
47
|
+
} from "./menu-keys.js";
|
|
48
|
+
import {
|
|
49
|
+
DISCLOSURE_CLOSED,
|
|
50
|
+
disclosureCycleAction,
|
|
51
|
+
disclosureStateVar,
|
|
52
|
+
} from "./disclosure.js";
|
|
53
|
+
|
|
54
|
+
// [LAW:one-source-of-truth] group/menu-synthesized segments (`groups.`/
|
|
55
|
+
// `menus.`) and edit mode's own trigger/chrome (`edit.`) are structural —
|
|
56
|
+
// removing one via `-` would strand its sibling artifacts (a toggle segment
|
|
57
|
+
// with no body, a menu with no host), and offering one back via `+` would
|
|
58
|
+
// insert a bare ref with none of the synthesis that made it work. Ordinary
|
|
59
|
+
// content segments only.
|
|
60
|
+
function isChromeExempt(name: string): boolean {
|
|
61
|
+
return (
|
|
62
|
+
name.startsWith(EDIT_NS) ||
|
|
63
|
+
name.startsWith(MENU_NS) ||
|
|
64
|
+
name.startsWith(GROUP_NS)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// [LAW:types-are-the-program] Collapse an arbitrary name to a template-
|
|
69
|
+
// identifier-safe fragment — the SAME shape menu-keys.ts's `ident` enforces,
|
|
70
|
+
// reimplemented here rather than imported (menu-keys.ts's copy is
|
|
71
|
+
// module-private) since both need only the one rule: alphanumerics survive,
|
|
72
|
+
// everything else collapses to `_`.
|
|
73
|
+
function ident(name: string): string {
|
|
74
|
+
return name.replace(/[^A-Za-z0-9]+/g, "_");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// [LAW:one-source-of-truth] Every synthesized decl this pass produces, keyed
|
|
78
|
+
// by its final name — one accumulator threaded through every preset's splice
|
|
79
|
+
// so cross-preset names (disambiguated by `presetIdent`) can never collide.
|
|
80
|
+
interface ChromeArtifacts {
|
|
81
|
+
readonly variables: Record<string, VariableDecl>;
|
|
82
|
+
readonly actions: Record<string, ActionDeclType>;
|
|
83
|
+
readonly segments: Record<string, SegmentDecl>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// [LAW:one-source-of-truth] The domain name a preset's `+` pickers range —
|
|
87
|
+
// computed once per preset (declared segments minus the ones already present
|
|
88
|
+
// in ITS current tree) and consumed two ways: here (by name, for every
|
|
89
|
+
// insertSegmentFrom action this preset's splice synthesizes) and by
|
|
90
|
+
// registerDslConfig/deriveConfigActionValidators (which call
|
|
91
|
+
// `addableSegmentDomains` directly to populate `perConfigDomains` before
|
|
92
|
+
// resolving `from`). Both read the SAME string shape so a synthesized
|
|
93
|
+
// action's domain name always resolves.
|
|
94
|
+
export function addableDomainName(presetName: string): string {
|
|
95
|
+
return `${EDIT_NS}addable.${presetName}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// [LAW:one-source-of-truth] THE per-preset "what can `+` offer here" set:
|
|
99
|
+
// every declared, non-exempt segment name minus the ones already present
|
|
100
|
+
// anywhere in that preset's CURRENT (merged, rootOps-replayed) tree. Exported
|
|
101
|
+
// so render.ts's registerDslConfig and config-validators.ts's
|
|
102
|
+
// deriveConfigActionValidators — the two sites that resolve `from` domains —
|
|
103
|
+
// merge this into `perConfigDomainsFor`'s map without each re-deriving it
|
|
104
|
+
// [LAW:locality-or-seam]; option-domain.ts itself stays untouched (its
|
|
105
|
+
// `perConfigDomainsFor` deliberately never imports dsl-types.ts — see that
|
|
106
|
+
// file's own header — so a third per-preset domain merges at the two call
|
|
107
|
+
// sites instead of inside it).
|
|
108
|
+
export function addableSegmentDomains(
|
|
109
|
+
config: DslConfig,
|
|
110
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
111
|
+
const declared = Object.keys(config.segments).filter(
|
|
112
|
+
(n) => !isChromeExempt(n),
|
|
113
|
+
);
|
|
114
|
+
const domains = new Map<string, readonly string[]>();
|
|
115
|
+
for (const name of presetNames(config.presets)) {
|
|
116
|
+
const { node } = presetRoot(config, name);
|
|
117
|
+
const present = collectSegmentNames(node);
|
|
118
|
+
domains.set(
|
|
119
|
+
addableDomainName(name),
|
|
120
|
+
declared.filter((n) => !present.has(n)),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return domains;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Synthesize the `-` affordance for one segment instance: a literal
|
|
127
|
+
// `removeSegment` action plus the segment that hosts its `{{ action }}`.
|
|
128
|
+
function removeChrome(
|
|
129
|
+
presetIdent: string,
|
|
130
|
+
rootOpsKey: string,
|
|
131
|
+
segName: string,
|
|
132
|
+
artifacts: ChromeArtifacts,
|
|
133
|
+
): SegmentNode {
|
|
134
|
+
const actionName = `${EDIT_NS}${presetIdent}.remove.${ident(segName)}`;
|
|
135
|
+
const chromeSegName = `${EDIT_NS}${presetIdent}.removeSeg.${ident(segName)}`;
|
|
136
|
+
artifacts.actions[actionName] = {
|
|
137
|
+
persist: rootOpsKey,
|
|
138
|
+
removeSegment: segName,
|
|
139
|
+
};
|
|
140
|
+
artifacts.segments[chromeSegName] = {
|
|
141
|
+
template: `{{ action "${actionName}" "-" }}`,
|
|
142
|
+
when: EDIT_MODE_GATE,
|
|
143
|
+
};
|
|
144
|
+
return { kind: "segment", name: chromeSegName };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Synthesize the `+` affordance for one gap: an `insertSegmentFrom` action
|
|
148
|
+
// over this preset's addable domain, plus a segment hosting `{{ menu }}` over
|
|
149
|
+
// it. The menu's own disclosure (open state, page cursor, toggle action) is
|
|
150
|
+
// synthesized here by calling the SAME pure functions menu-synth.ts's
|
|
151
|
+
// file-parse-time pass calls — this pass runs too late to piggyback on that
|
|
152
|
+
// pass directly (it needs post-merge/post-rootOps data menu-synth.ts's
|
|
153
|
+
// per-file timing does not have), so parity is achieved by sharing the
|
|
154
|
+
// functions, not by re-deriving the shape.
|
|
155
|
+
function insertChrome(
|
|
156
|
+
presetIdent: string,
|
|
157
|
+
rootOpsKey: string,
|
|
158
|
+
posIdent: string,
|
|
159
|
+
domainName: OptionDomain,
|
|
160
|
+
anchor: string,
|
|
161
|
+
relation: "before" | "after",
|
|
162
|
+
artifacts: ChromeArtifacts,
|
|
163
|
+
): SegmentNode {
|
|
164
|
+
const applyName = `${EDIT_NS}${presetIdent}.insert.${posIdent}`;
|
|
165
|
+
const chromeSegName = `${EDIT_NS}${presetIdent}.insertSeg.${posIdent}`;
|
|
166
|
+
artifacts.actions[applyName] = {
|
|
167
|
+
persist: rootOpsKey,
|
|
168
|
+
insertSegmentFrom: domainName,
|
|
169
|
+
anchor,
|
|
170
|
+
relation,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const member = menuMember(applyName);
|
|
174
|
+
const stateKey = menuStateKey(chromeSegName, applyName, undefined);
|
|
175
|
+
const pageKey = menuPageKey(stateKey);
|
|
176
|
+
const identity = menuActionName(stateKey, member);
|
|
177
|
+
artifacts.variables[stateKey] = disclosureStateVar(
|
|
178
|
+
stateKey,
|
|
179
|
+
DISCLOSURE_CLOSED,
|
|
180
|
+
);
|
|
181
|
+
artifacts.variables[pageKey] = { kind: "state", key: pageKey, default: "0" };
|
|
182
|
+
artifacts.actions[identity] = disclosureCycleAction(stateKey, member);
|
|
183
|
+
artifacts.actions[pageKey] = { set: pageKey, int: true };
|
|
184
|
+
|
|
185
|
+
artifacts.segments[chromeSegName] = {
|
|
186
|
+
template: `+{{ menu "${applyName}" }}`,
|
|
187
|
+
when: EDIT_MODE_GATE,
|
|
188
|
+
};
|
|
189
|
+
return { kind: "segment", name: chromeSegName };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// [LAW:dataflow-not-control-flow] One recursive splice: a container's
|
|
193
|
+
// non-exempt segment children get a `+` before and a `-` after (so N
|
|
194
|
+
// consecutive segments read `+ [seg1 -] + [seg2 -] + [seg3 -] +` — N+1 insert
|
|
195
|
+
// points, N remove points); a container child recurses; an exempt segment
|
|
196
|
+
// (a group toggle, a menu host, edit mode's own chrome) passes through
|
|
197
|
+
// untouched. `posCounter` is threaded by reference so position identifiers
|
|
198
|
+
// stay unique across the WHOLE preset tree, not just one container.
|
|
199
|
+
function spliceContainer(
|
|
200
|
+
node: ContainerNode,
|
|
201
|
+
presetIdent: string,
|
|
202
|
+
rootOpsKey: string,
|
|
203
|
+
domainName: OptionDomain,
|
|
204
|
+
artifacts: ChromeArtifacts,
|
|
205
|
+
posCounter: { n: number },
|
|
206
|
+
): ContainerNode {
|
|
207
|
+
const children: LayoutNode[] = [];
|
|
208
|
+
for (const child of node.children) {
|
|
209
|
+
if (child.kind === "container") {
|
|
210
|
+
children.push(
|
|
211
|
+
spliceContainer(
|
|
212
|
+
child,
|
|
213
|
+
presetIdent,
|
|
214
|
+
rootOpsKey,
|
|
215
|
+
domainName,
|
|
216
|
+
artifacts,
|
|
217
|
+
posCounter,
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (isChromeExempt(child.name)) {
|
|
223
|
+
children.push(child);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
children.push(
|
|
227
|
+
insertChrome(
|
|
228
|
+
presetIdent,
|
|
229
|
+
rootOpsKey,
|
|
230
|
+
String(posCounter.n++),
|
|
231
|
+
domainName,
|
|
232
|
+
child.name,
|
|
233
|
+
"before",
|
|
234
|
+
artifacts,
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
children.push(child);
|
|
238
|
+
children.push(removeChrome(presetIdent, rootOpsKey, child.name, artifacts));
|
|
239
|
+
}
|
|
240
|
+
const last = node.children[node.children.length - 1];
|
|
241
|
+
if (
|
|
242
|
+
last !== undefined &&
|
|
243
|
+
last.kind === "segment" &&
|
|
244
|
+
!isChromeExempt(last.name)
|
|
245
|
+
) {
|
|
246
|
+
children.push(
|
|
247
|
+
insertChrome(
|
|
248
|
+
presetIdent,
|
|
249
|
+
rootOpsKey,
|
|
250
|
+
String(posCounter.n++),
|
|
251
|
+
domainName,
|
|
252
|
+
last.name,
|
|
253
|
+
"after",
|
|
254
|
+
artifacts,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return { ...node, children };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// One preset's chrome-spliced root. A bare-segment root (the A-grammar
|
|
262
|
+
// collapses a single top-level segment ref to `{ kind: "segment", name }`
|
|
263
|
+
// with no enclosing container) is wrapped in a synthetic horizontal
|
|
264
|
+
// container for splicing purposes — the wrap becomes the real returned root,
|
|
265
|
+
// which is exactly right: a lone segment needs a `+` on each side too.
|
|
266
|
+
function spliceEditChromeForPreset(
|
|
267
|
+
config: DslConfig,
|
|
268
|
+
presetName: string,
|
|
269
|
+
artifacts: ChromeArtifacts,
|
|
270
|
+
): LayoutNode {
|
|
271
|
+
const { node } = presetRoot(config, presetName);
|
|
272
|
+
const rootOpsKey = `presets.${presetName}.rootOps`;
|
|
273
|
+
const domainName = addableDomainName(presetName);
|
|
274
|
+
const presetIdent = ident(presetName);
|
|
275
|
+
const posCounter = { n: 0 };
|
|
276
|
+
const container: ContainerNode =
|
|
277
|
+
node.kind === "container"
|
|
278
|
+
? node
|
|
279
|
+
: { kind: "container", direction: "horizontal", children: [node] };
|
|
280
|
+
return spliceContainer(
|
|
281
|
+
container,
|
|
282
|
+
presetIdent,
|
|
283
|
+
rootOpsKey,
|
|
284
|
+
domainName,
|
|
285
|
+
artifacts,
|
|
286
|
+
posCounter,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// [LAW:single-enforcer] THE synthesis entry point, called once from
|
|
291
|
+
// validateConfig after cross-ref/cycle checks pass. Every declared preset
|
|
292
|
+
// (the floor "default" included — presetNames/presetByName/presetRoot
|
|
293
|
+
// already treat it uniformly) gets an explicit `presets[name].root` carrying
|
|
294
|
+
// its spliced tree; `config.root` itself is left untouched (presetRoot falls
|
|
295
|
+
// back to it only when a preset declares no root of its own, and every name
|
|
296
|
+
// now does). The synthesized variables/actions/segments merge additively —
|
|
297
|
+
// nothing here can collide with user data, since every name it mints lives
|
|
298
|
+
// under the `edit.`/`menus.` namespaces `synthesizeEditModeToggle` and
|
|
299
|
+
// `synthesizeMenuDecls` already reserve unconditionally at parse time.
|
|
300
|
+
export function synthesizeEditChrome(config: DslConfig): DslConfig {
|
|
301
|
+
// [LAW:carrying-cost] Demand-driven, mirroring synthesizeEditModeToggle's
|
|
302
|
+
// own gate: `edit.toggle` exists in the merged config iff SOME file's
|
|
303
|
+
// Phase A synthesis fired (iff some segment referenced it), which iff some
|
|
304
|
+
// author actually placed an edit-mode trigger. A config that never opted
|
|
305
|
+
// in gets back the identical config, untouched — no extra segments,
|
|
306
|
+
// actions, or variables, and critically no NEW `set`/`state` surface that
|
|
307
|
+
// would force session.id onto an otherwise fully static bar.
|
|
308
|
+
if (!(EDIT_TOGGLE_ACTION in config.actions)) return config;
|
|
309
|
+
const artifacts: ChromeArtifacts = {
|
|
310
|
+
variables: {},
|
|
311
|
+
actions: {},
|
|
312
|
+
segments: {},
|
|
313
|
+
};
|
|
314
|
+
const presets: Record<string, PresetDecl> = { ...config.presets };
|
|
315
|
+
for (const name of presetNames(config.presets)) {
|
|
316
|
+
const splicedRoot = spliceEditChromeForPreset(config, name, artifacts);
|
|
317
|
+
presets[name] = {
|
|
318
|
+
...presetByName(config.presets, name),
|
|
319
|
+
root: splicedRoot,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
...config,
|
|
324
|
+
variables: { ...config.variables, ...artifacts.variables },
|
|
325
|
+
actions: { ...config.actions, ...artifacts.actions },
|
|
326
|
+
segments: { ...config.segments, ...artifacts.segments },
|
|
327
|
+
presets,
|
|
328
|
+
};
|
|
329
|
+
}
|
package/src/config/layout-ops.ts
CHANGED
|
@@ -79,6 +79,27 @@ export function decodeLayoutOp(token: string): LayoutOp | null {
|
|
|
79
79
|
return null;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// [LAW:single-enforcer] THE one collector of "which segment names does this
|
|
83
|
+
// tree contain" — brandon-layout-edit-2gc.3's edit-chrome synthesis
|
|
84
|
+
// (src/config/edit-chrome.ts) uses it to compute both halves of the +/-
|
|
85
|
+
// affordances: which segments are PRESENT (get a `-`) and, by set difference
|
|
86
|
+
// against every declared segment, which are ADDABLE (populate the `+`
|
|
87
|
+
// picker's domain). A name appearing more than once collapses to one entry —
|
|
88
|
+
// callers that care about occurrence COUNT (none currently do) need a
|
|
89
|
+
// different walk.
|
|
90
|
+
export function collectSegmentNames(root: LayoutNode): ReadonlySet<string> {
|
|
91
|
+
const out = new Set<string>();
|
|
92
|
+
const walk = (node: LayoutNode): void => {
|
|
93
|
+
if (node.kind === "segment") {
|
|
94
|
+
out.add(node.name);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const child of node.children) walk(child);
|
|
98
|
+
};
|
|
99
|
+
walk(root);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
// [LAW:dataflow-not-control-flow] Ops are DATA folded over the tree in
|
|
83
104
|
// order — replaying zero ops is the identity fold, replaying N is the same
|
|
84
105
|
// reduce for every N. No branch on "are there ops to apply."
|
|
@@ -388,6 +388,20 @@ const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
|
|
|
388
388
|
anchor: layoutNameSpec("anchor"),
|
|
389
389
|
relation: relationSpec(),
|
|
390
390
|
};
|
|
391
|
+
// [LAW:one-type-per-behavior] `insertSegmentFrom`'s payload mirrors
|
|
392
|
+
// `insertSegment`'s verbatim except the segment name is a `from`-shaped
|
|
393
|
+
// OptionDomain (fromSpec, the SAME field `set`/`persist … from` already
|
|
394
|
+
// validate) instead of a literal layout name — the "to" vs "from" split every
|
|
395
|
+
// other value source already draws, one arm over.
|
|
396
|
+
const INSERT_SEGMENT_FROM_FIELDS: FieldSpecMap<{
|
|
397
|
+
insertSegmentFrom: OptionDomain;
|
|
398
|
+
anchor: string;
|
|
399
|
+
relation: "before" | "after";
|
|
400
|
+
}> = {
|
|
401
|
+
insertSegmentFrom: fromSpec("persist"),
|
|
402
|
+
anchor: layoutNameSpec("anchor"),
|
|
403
|
+
relation: relationSpec(),
|
|
404
|
+
};
|
|
391
405
|
|
|
392
406
|
// [LAW:types-are-the-program] A bounded step is fully described by an integer
|
|
393
407
|
// domain (min < max) and a non-zero integer increment (`by`; negative for a
|
|
@@ -440,12 +454,25 @@ interface ValueSourceArm {
|
|
|
440
454
|
readonly parse: ArmParse<Partial<ActionDecl>>;
|
|
441
455
|
}
|
|
442
456
|
|
|
457
|
+
// [LAW:no-mode-explosion] `detectKeys` narrows WHICH of an arm's fields the
|
|
458
|
+
// present-count dispatch keys off, independent of `allowed`/`label` (still
|
|
459
|
+
// the full field set — what the arm PERMITS and is NAMED by never changes).
|
|
460
|
+
// Every arm before insertSegmentFrom had a field set disjoint from every
|
|
461
|
+
// other arm's, so `Object.keys(fieldMap)` was a safe default for both jobs
|
|
462
|
+
// at once. insertSegmentFrom breaks that: it shares `anchor`/`relation` with
|
|
463
|
+
// insertSegment (same POSITION shape, different segment-name SOURCE), so
|
|
464
|
+
// dispatching on the full set would make an ordinary `insertSegment` action
|
|
465
|
+
// spuriously match both arms via those shared keys. Pass the true
|
|
466
|
+
// discriminator (the field no sibling arm carries) here; omit it when the
|
|
467
|
+
// field set already is disjoint from every sibling, as it is everywhere else.
|
|
443
468
|
function valueSourceArm<P extends object>(
|
|
444
469
|
discriminator: "set" | "persist",
|
|
445
470
|
fieldMap: FieldSpecMap<P>,
|
|
446
|
-
|
|
471
|
+
checks: ReadonlyArray<Refinement<P>> = [],
|
|
472
|
+
detectKeys?: readonly string[],
|
|
447
473
|
): ValueSourceArm {
|
|
448
|
-
const
|
|
474
|
+
const fullKeys = Object.keys(fieldMap);
|
|
475
|
+
const detect = detectKeys ?? fullKeys;
|
|
449
476
|
const inner: ArmParse<P> = (ctx, path, raw) =>
|
|
450
477
|
fields(ctx, fieldMap, path, raw);
|
|
451
478
|
const source = objectJson(fieldMap) as {
|
|
@@ -454,8 +481,8 @@ function valueSourceArm<P extends object>(
|
|
|
454
481
|
};
|
|
455
482
|
return {
|
|
456
483
|
detect,
|
|
457
|
-
allowed: [discriminator, ...
|
|
458
|
-
label:
|
|
484
|
+
allowed: [discriminator, ...fullKeys],
|
|
485
|
+
label: fullKeys.join("/"),
|
|
459
486
|
json: {
|
|
460
487
|
type: "object",
|
|
461
488
|
properties: { [discriminator]: { type: "string" }, ...source.properties },
|
|
@@ -475,7 +502,7 @@ function valueSourceArm<P extends object>(
|
|
|
475
502
|
const SET_ARMS: readonly ValueSourceArm[] = [
|
|
476
503
|
valueSourceArm("set", TO_FIELDS_SET),
|
|
477
504
|
valueSourceArm("set", FROM_FIELDS_SET),
|
|
478
|
-
valueSourceArm("set", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
505
|
+
valueSourceArm("set", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
479
506
|
valueSourceArm("set", INT_FIELDS),
|
|
480
507
|
valueSourceArm("set", CYCLE_FIELDS_SET),
|
|
481
508
|
];
|
|
@@ -487,10 +514,21 @@ const SET_ARMS: readonly ValueSourceArm[] = [
|
|
|
487
514
|
const PERSIST_ARMS: readonly ValueSourceArm[] = [
|
|
488
515
|
valueSourceArm("persist", TO_FIELDS_PERSIST),
|
|
489
516
|
valueSourceArm("persist", FROM_FIELDS_PERSIST),
|
|
490
|
-
valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
517
|
+
valueSourceArm("persist", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
491
518
|
valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
|
|
492
519
|
valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
|
|
493
|
-
|
|
520
|
+
// [LAW:no-mode-explosion] Both insertSegment arms narrow detectKeys to
|
|
521
|
+
// their own discriminating field — see valueSourceArm's own comment. They
|
|
522
|
+
// share "anchor"/"relation" (same position shape, different segment-name
|
|
523
|
+
// source), so dispatching on the full field set would make EITHER arm
|
|
524
|
+
// spuriously match an action declaring the other.
|
|
525
|
+
valueSourceArm("persist", INSERT_SEGMENT_FIELDS, [], ["insertSegment"]),
|
|
526
|
+
valueSourceArm(
|
|
527
|
+
"persist",
|
|
528
|
+
INSERT_SEGMENT_FROM_FIELDS,
|
|
529
|
+
[],
|
|
530
|
+
["insertSegmentFrom"],
|
|
531
|
+
),
|
|
494
532
|
];
|
|
495
533
|
|
|
496
534
|
// [LAW:one-source-of-truth] The clause list, not the joined string, is the
|
|
@@ -511,6 +549,7 @@ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
|
|
|
511
549
|
clauses.push(
|
|
512
550
|
`"removeSegment" (remove a named segment from the layout)`,
|
|
513
551
|
`"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
|
|
552
|
+
`"insertSegmentFrom"/"anchor"/"relation" (insert a segment PICKED from an option domain before/after an existing one)`,
|
|
514
553
|
);
|
|
515
554
|
}
|
|
516
555
|
return clauses;
|
|
@@ -635,7 +674,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
635
674
|
const at = `${path}.${field}`;
|
|
636
675
|
if (typeof from === "string") {
|
|
637
676
|
if (from === "") {
|
|
638
|
-
issue(ctx, at,
|
|
677
|
+
issue(ctx, at, `${field} must be a non-empty domain name`);
|
|
639
678
|
return undefined;
|
|
640
679
|
}
|
|
641
680
|
return from;
|
|
@@ -646,7 +685,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
646
685
|
issue(
|
|
647
686
|
ctx,
|
|
648
687
|
at,
|
|
649
|
-
|
|
688
|
+
`${field} must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
|
|
650
689
|
);
|
|
651
690
|
return undefined;
|
|
652
691
|
}
|
|
@@ -654,7 +693,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
654
693
|
issue(
|
|
655
694
|
ctx,
|
|
656
695
|
at,
|
|
657
|
-
|
|
696
|
+
`${field} array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
|
|
658
697
|
);
|
|
659
698
|
return undefined;
|
|
660
699
|
}
|
|
@@ -663,7 +702,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
663
702
|
issue(
|
|
664
703
|
ctx,
|
|
665
704
|
at,
|
|
666
|
-
|
|
705
|
+
`${field} array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
|
|
667
706
|
);
|
|
668
707
|
return undefined;
|
|
669
708
|
}
|
|
@@ -671,7 +710,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
671
710
|
issue(
|
|
672
711
|
ctx,
|
|
673
712
|
at,
|
|
674
|
-
|
|
713
|
+
`${field} array members must be unique — a duplicated value would render the same picker option twice`,
|
|
675
714
|
);
|
|
676
715
|
return undefined;
|
|
677
716
|
}
|
|
@@ -680,7 +719,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
680
719
|
issue(
|
|
681
720
|
ctx,
|
|
682
721
|
at,
|
|
683
|
-
|
|
722
|
+
`${field} must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
|
|
684
723
|
);
|
|
685
724
|
return undefined;
|
|
686
725
|
},
|