@promptctl/cc-candybar 1.31.0 → 1.33.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/default-dsl-config.ts +27 -2
- 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.33.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.33.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.33.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.33.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.33.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 }
|
|
@@ -872,7 +872,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
|
|
|
872
872
|
},
|
|
873
873
|
// Quick-action tray — the default bar's interactivity: copy the session id,
|
|
874
874
|
// open the project dir / transcript (this session's jsonl) in the editor,
|
|
875
|
-
//
|
|
875
|
+
// open the repo's web page in the browser, and toggle layout edit mode.
|
|
876
876
|
// (copyDir — copy the cwd — stays declared as an action below for users who
|
|
877
877
|
// want a fifth glyph; it is simply not in the default tray.)
|
|
878
878
|
// [LAW:locality-or-seam] The glyph is the REPRESENTATION; the named action
|
|
@@ -887,11 +887,36 @@ export const RAW_DEFAULT_DSL_CONFIG = {
|
|
|
887
887
|
// public web URL through a cc-candybar:// verb would buy nothing. It is
|
|
888
888
|
// gated on the VALUE (`ne … ""`), not on a flag: a local-only repo simply
|
|
889
889
|
// supplies no page and the glyph is absent. [LAW:dataflow-not-control-flow]
|
|
890
|
+
//
|
|
891
|
+
// `✎ edit`/`✎ done` (brandon-layout-edit-2gc.4) is the bundled default's
|
|
892
|
+
// ONLY reference to the reserved `edit.toggle` action — referencing it
|
|
893
|
+
// anywhere is what opts this config into edit mode (see
|
|
894
|
+
// docs/interaction-authoring.md's "Edit mode" section), and this tray
|
|
895
|
+
// segment is where it lives.
|
|
896
|
+
//
|
|
897
|
+
// [LAW:carrying-cost] Placement resolves a self-lockout tension .3 flagged
|
|
898
|
+
// (see the epic's tickets): once edit mode is open, EVERY ordinary segment
|
|
899
|
+
// gets its own removable `-`, including whichever one hosts the trigger —
|
|
900
|
+
// a config can, in principle, remove its own way back into edit mode.
|
|
901
|
+
// Giving the trigger its own standalone segment would make that a one-click
|
|
902
|
+
// accident. Folding it into `toolbar` instead means removing the trigger
|
|
903
|
+
// requires removing the WHOLE quick-action tray — the same deliberate,
|
|
904
|
+
// symmetric risk every other multi-purpose segment already carries, not a
|
|
905
|
+
// bespoke edit-mode hazard — and it costs the default bar one glyph of
|
|
906
|
+
// width instead of a whole new segment's cell+padding+joiner overhead. The
|
|
907
|
+
// risk is bounded either way: `-` only removes the segment from this
|
|
908
|
+
// preset's tree (`edit.mode` itself is untouched SessionState), so the
|
|
909
|
+
// rest of the chrome — every remaining `+`/`-` in the bar — stays visible,
|
|
910
|
+
// and any of them can `+` `toolbar` straight back
|
|
911
|
+
// (test/dsl-layout-edit.test.ts covers the full round trip through a
|
|
912
|
+
// real RenderCache reload; test/dsl-edit-mode.test.ts covers the click
|
|
913
|
+
// itself and that edit.mode survives it).
|
|
890
914
|
toolbar: {
|
|
891
915
|
template:
|
|
892
916
|
'{{ action "copySession" "⎘ id" }}' +
|
|
893
917
|
' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}' +
|
|
894
|
-
'{{ if ne .git.repoUrl "" }} {{ link .git.repoUrl "↗ repo" }}{{ end }}'
|
|
918
|
+
'{{ if ne .git.repoUrl "" }} {{ link .git.repoUrl "↗ repo" }}{{ end }}' +
|
|
919
|
+
' {{ action "edit.toggle" "✎ edit" "✎ done" }}',
|
|
895
920
|
bg: "surface",
|
|
896
921
|
fg: "foreground",
|
|
897
922
|
},
|
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."
|