@intentius/behold 0.8.0 → 0.9.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/AGENTS.md +85 -0
- package/README.md +123 -2
- package/demos.json +17 -1
- package/dist/cli.js +2522 -294
- package/example-argo-estate/README.md +43 -18
- package/example-argo-estate/app-a/chant.config.ts +10 -2
- package/example-argo-estate/app-a/package.json +2 -2
- package/example-argo-estate/app-b/chant.config.ts +11 -2
- package/example-argo-estate/app-b/package.json +2 -2
- package/example-argo-estate/control-plane/chant.config.ts +20 -5
- package/example-argo-estate/control-plane/package.json +2 -2
- package/example-argo-estate/package-lock.json +17 -17
- package/example-carve/README.md +194 -0
- package/example-carve/app/chant.config.ts +6 -0
- package/example-carve/app/package-lock.json +1075 -0
- package/example-carve/app/package.json +13 -0
- package/example-carve/app/src/carved.ts +30 -0
- package/example-carve/app/tsconfig.json +1 -0
- package/example-carve/carve-report.json +872 -0
- package/example-carve/legacy-tf/cdn.tf +23 -0
- package/example-carve/legacy-tf/compute.tf +63 -0
- package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
- package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
- package/example-carve/legacy-tf/naming.tf +10 -0
- package/example-carve/legacy-tf/network.tf +119 -0
- package/example-carve/legacy-tf/observability.tf +18 -0
- package/example-carve/legacy-tf/outputs.tf +16 -0
- package/example-carve/legacy-tf/storage.tf +33 -0
- package/example-carve/legacy-tf/terraform.tfstate +602 -0
- package/example-carve/legacy-tf/versions.tf +40 -0
- package/example-flux-estate/README.md +9 -4
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-a/src/app.ts +2 -1
- package/example-flux-estate/app-b/chant.config.ts +4 -3
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/app-b/src/app.ts +5 -3
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/control-plane/src/flux.ts +4 -2
- package/example-flux-estate/package-lock.json +17 -17
- package/example-k8s/package-lock.json +18 -18
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +8 -6
- package/web/app.js +714 -57
- package/web/carve-steps.js +610 -0
- package/web/carve-steps.test.js +233 -0
- package/web/demos.js +71 -0
- package/web/demos.test.js +83 -0
- package/web/index.html +93 -1
- package/web/json-view.js +334 -0
- package/web/json-view.test.js +218 -0
- package/web/layout-store.js +164 -4
- package/web/layout-store.test.js +226 -1
- package/web/panel.js +28 -0
- package/web/theme.js +57 -1
package/web/app.js
CHANGED
|
@@ -9,11 +9,40 @@
|
|
|
9
9
|
// position), and the theme picker into the panel's View-tab slot (a stable element
|
|
10
10
|
// renderPanelView never rewrites, so the select mounts once and survives re-renders).
|
|
11
11
|
import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js";
|
|
12
|
-
import { initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
|
|
12
|
+
import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
|
|
13
|
+
// #254: the carve walkthrough's stepper — everything it DECIDES is a pure
|
|
14
|
+
// function in there; this file owns the fetches, the graph selection, and the
|
|
15
|
+
// "carved" marker the last step leaves on a card.
|
|
16
|
+
import { CARVE_STEPS, blockedReason, initialCarveState, renderCarvePanel } from "./carve-steps.js";
|
|
13
17
|
// #228: the hand-layout delta store — everything about WHAT gets remembered and
|
|
14
18
|
// under which key. The pointer work and the SVG surgery stay here (see the
|
|
15
19
|
// "Hand layout" section below).
|
|
16
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
applicable,
|
|
22
|
+
clampDelta,
|
|
23
|
+
clearLayout,
|
|
24
|
+
debounce,
|
|
25
|
+
fetchServerLayout,
|
|
26
|
+
isEmpty,
|
|
27
|
+
layoutKey,
|
|
28
|
+
lensKeyOf,
|
|
29
|
+
mergeLayouts,
|
|
30
|
+
nodeTransform,
|
|
31
|
+
pathAnchors,
|
|
32
|
+
postServerLayout,
|
|
33
|
+
projectKeyOf,
|
|
34
|
+
readLayout,
|
|
35
|
+
setDelta,
|
|
36
|
+
straightEdge,
|
|
37
|
+
writeLayout,
|
|
38
|
+
} from "./layout-store.js";
|
|
39
|
+
// #259: every JSON value this page shows goes through one renderer — pretty
|
|
40
|
+
// printed, collapsible, copyable per subtree. `valueCell`/`pairCell` below are
|
|
41
|
+
// the call-site shorthands: a container becomes the tree, a scalar stays text.
|
|
42
|
+
import { isContainer, jsonCell, renderJson, scalarText } from "./json-view.js";
|
|
43
|
+
// #268: the demo catalog's presentation — what a catalog row's button says
|
|
44
|
+
// about it (disabled + reason, or the fetch it would do).
|
|
45
|
+
import { fetchDemos, demoLabel, demoTitle, demoProgress } from "./demos.js";
|
|
17
46
|
initTheme();
|
|
18
47
|
initPanel();
|
|
19
48
|
mountThemePicker(document.getElementById("panel-theme"));
|
|
@@ -163,12 +192,68 @@ const ARTIFACT_STATUS_LABEL = { good: "installed", warn: "installed, not healthy
|
|
|
163
192
|
// A declared attribute value may be a cross-resource reference ({$ref:"x.y"}) —
|
|
164
193
|
// the "static infra refs" — rather than a concrete value. Render those readably;
|
|
165
194
|
// concrete values (present once a resource is provisioned) show as-is.
|
|
195
|
+
// #259: everything else that isn't a scalar becomes the collapsible tree rather
|
|
196
|
+
// than a flat one-line JSON.stringify — a declared `spec` used to wrap six times
|
|
197
|
+
// and say nothing about its shape.
|
|
166
198
|
function fmtValue(v) {
|
|
167
|
-
if (v && typeof v === "object")
|
|
168
|
-
|
|
169
|
-
|
|
199
|
+
if (v && typeof v === "object" && typeof v.$ref === "string") return "→ " + v.$ref;
|
|
200
|
+
return valueCell(v);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A `<dd>` body for one value: an object or an array becomes the collapsible
|
|
205
|
+
* tree, a scalar keeps the pane's plain voice — `deploy/api`, not
|
|
206
|
+
* `"deploy/api"`. Quoting is JSON's punctuation and belongs inside a tree, not
|
|
207
|
+
* beside a label, so this is NOT json-view's own `jsonCell` (which quotes; the
|
|
208
|
+
* value PAIRS below want that, since they always did).
|
|
209
|
+
*/
|
|
210
|
+
function valueCell(v) {
|
|
211
|
+
return isContainer(v) ? renderJson(v) : String(v);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Put a value into a `<dd>`: a rendered JSON tree is appended, a scalar's text
|
|
215
|
+
* is set. Every `add`/`section` setter in this pane funnels through here (#259). */
|
|
216
|
+
function setCell(dd, v) {
|
|
217
|
+
if (v instanceof Node) dd.appendChild(v);
|
|
218
|
+
else dd.textContent = v;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The pane shows values in PAIRS as often as alone — old → new (drift),
|
|
223
|
+
* declared · live (field ownership), baseline · live (accepted deviation).
|
|
224
|
+
* Two scalars keep the one-line form the pane always had. If either side is an
|
|
225
|
+
* object or an array it becomes a collapsible tree, and an arrow wedged between
|
|
226
|
+
* two trees reads as neither — so the pair stacks into labelled rows instead
|
|
227
|
+
* (#259). `lead` is the sentence that precedes a field-drift pair ("owned by
|
|
228
|
+
* hpa-controller — drifted"); it keeps its own line in the stacked form.
|
|
229
|
+
* Returns a Node or a string, for setCell.
|
|
230
|
+
*/
|
|
231
|
+
function pairCell(aLabel, a, bLabel, b, opts = {}) {
|
|
232
|
+
const { lead = "", arrow = false } = opts;
|
|
233
|
+
if (!isContainer(a) && !isContainer(b)) {
|
|
234
|
+
const one = arrow ? `${scalarText(a)} → ${scalarText(b)}` : `${aLabel}: ${scalarText(a)} · ${bLabel}: ${scalarText(b)}`;
|
|
235
|
+
return lead ? `${lead} — ${one}` : one;
|
|
170
236
|
}
|
|
171
|
-
|
|
237
|
+
const wrap = document.createElement("div");
|
|
238
|
+
if (lead) {
|
|
239
|
+
const p = document.createElement("div");
|
|
240
|
+
p.textContent = lead;
|
|
241
|
+
wrap.appendChild(p);
|
|
242
|
+
}
|
|
243
|
+
for (const [label, v] of [
|
|
244
|
+
[aLabel, a],
|
|
245
|
+
[bLabel, b],
|
|
246
|
+
]) {
|
|
247
|
+
const row = document.createElement("div");
|
|
248
|
+
row.className = "pair-row";
|
|
249
|
+
const tag = document.createElement("span");
|
|
250
|
+
tag.className = "pair-label";
|
|
251
|
+
tag.textContent = label;
|
|
252
|
+
row.appendChild(tag);
|
|
253
|
+
setCell(row, jsonCell(v));
|
|
254
|
+
wrap.appendChild(row);
|
|
255
|
+
}
|
|
256
|
+
return wrap;
|
|
172
257
|
}
|
|
173
258
|
|
|
174
259
|
function inspect(node) {
|
|
@@ -185,7 +270,7 @@ function inspect(node) {
|
|
|
185
270
|
const dt = document.createElement("dt");
|
|
186
271
|
dt.textContent = k;
|
|
187
272
|
const dd = document.createElement("dd");
|
|
188
|
-
dd
|
|
273
|
+
setCell(dd, v);
|
|
189
274
|
dl.append(dt, dd);
|
|
190
275
|
};
|
|
191
276
|
};
|
|
@@ -433,7 +518,7 @@ function renderObserved(panel, o, health, healthDetail) {
|
|
|
433
518
|
const dt = document.createElement("dt");
|
|
434
519
|
dt.textContent = k;
|
|
435
520
|
const dd = document.createElement("dd");
|
|
436
|
-
dd
|
|
521
|
+
setCell(dd, v);
|
|
437
522
|
if (color) dd.style.color = color;
|
|
438
523
|
dl.append(dt, dd);
|
|
439
524
|
};
|
|
@@ -461,11 +546,16 @@ function renderObserved(panel, o, health, healthDetail) {
|
|
|
461
546
|
// none.
|
|
462
547
|
const conditions = o.attributes?.conditions;
|
|
463
548
|
if (Array.isArray(conditions)) {
|
|
464
|
-
|
|
549
|
+
// Usually chant sends these pre-rendered as sentences; a substrate that
|
|
550
|
+
// sends the raw condition object gets the tree instead of `[object Object]`.
|
|
551
|
+
for (const c of conditions) add("condition", valueCell(c), "var(--degraded)");
|
|
465
552
|
}
|
|
553
|
+
// #259: an observed attribute is whatever the substrate reported — a k8s
|
|
554
|
+
// `spec`, a nested `loadBalancer`, an array of ports. All of it collapsible
|
|
555
|
+
// now, instead of one flat JSON.stringify line per key.
|
|
466
556
|
for (const [k, v] of Object.entries(o.attributes || {})) {
|
|
467
557
|
if (k === "conditions") continue; // rendered above, one line each
|
|
468
|
-
add(k,
|
|
558
|
+
add(k, valueCell(v));
|
|
469
559
|
}
|
|
470
560
|
panel.appendChild(dl);
|
|
471
561
|
}
|
|
@@ -549,7 +639,9 @@ function renderDiff(panel, diff) {
|
|
|
549
639
|
const dt = document.createElement("dt");
|
|
550
640
|
dt.textContent = ch.path;
|
|
551
641
|
const dd = document.createElement("dd");
|
|
552
|
-
|
|
642
|
+
// #259: a drifted `spec` used to be two flat JSON blobs either side of an
|
|
643
|
+
// arrow; now each side is its own collapsible tree with its own copy.
|
|
644
|
+
setCell(dd, pairCell("was", ch.oldValue, "now", ch.newValue, { arrow: true }));
|
|
553
645
|
dl.append(dt, dd);
|
|
554
646
|
}
|
|
555
647
|
panel.appendChild(dl);
|
|
@@ -592,7 +684,7 @@ function renderFieldDrift(panel, fieldDrift) {
|
|
|
592
684
|
// holding it is somebody editing around the pipeline. Both are `changed`.
|
|
593
685
|
// Absent on every substrate but k8s, where the line reads as it always did.
|
|
594
686
|
const owned = ch.owner ? `owned by ${ch.owner} — ` : "";
|
|
595
|
-
dd.
|
|
687
|
+
setCell(dd, pairCell("declared", ch.declared, "live", ch.live, { lead: `${owned}${FIELD_KIND_LABEL[ch.kind] || ch.kind}` }));
|
|
596
688
|
dl.append(dt, dd);
|
|
597
689
|
}
|
|
598
690
|
for (const ch of fieldDrift.accepted) {
|
|
@@ -600,7 +692,7 @@ function renderFieldDrift(panel, fieldDrift) {
|
|
|
600
692
|
dt.textContent = ch.path;
|
|
601
693
|
dt.style.color = "var(--muted)";
|
|
602
694
|
const dd = document.createElement("dd");
|
|
603
|
-
dd
|
|
695
|
+
setCell(dd, pairCell("baseline", ch.baseline, "live", ch.live, { lead: "accepted deviation" }));
|
|
604
696
|
dl.append(dt, dd);
|
|
605
697
|
}
|
|
606
698
|
panel.appendChild(dl);
|
|
@@ -618,6 +710,10 @@ function wire(ir) {
|
|
|
618
710
|
host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
|
|
619
711
|
g.classList.add("sel");
|
|
620
712
|
inspect(node);
|
|
713
|
+
// #254: in carve mode a click is also the walkthrough's Pick step — the
|
|
714
|
+
// inspect pane already shows the score arithmetic the lens spelled out,
|
|
715
|
+
// and the stepper picks up the same node.
|
|
716
|
+
carvePick(node);
|
|
621
717
|
});
|
|
622
718
|
}
|
|
623
719
|
}
|
|
@@ -807,26 +903,54 @@ async function revealProject(dir) {
|
|
|
807
903
|
// client-side list and cache is project-scoped, so a clean boot is the honest
|
|
808
904
|
// way to re-seed all of it.
|
|
809
905
|
async function switchProject(dir) {
|
|
810
|
-
|
|
906
|
+
return postSwitch("/api/project/open", { dir }, `switching to ${pathBasename(dir)}…`, "switch");
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// #268: load a bundled demo and serve it. Same treatment as a project switch —
|
|
910
|
+
// it IS one, after a copy/clone + install the server runs on the click. The
|
|
911
|
+
// body carries the catalog NAME; the server never takes a path here.
|
|
912
|
+
async function openDemo(demo) {
|
|
913
|
+
if (!demo.satisfiable) return;
|
|
914
|
+
return postSwitch("/api/demos/open", { name: demo.name }, demoProgress(demo), "demo");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// The switch itself: a JSON POST behind the loading scrim, then a reload on
|
|
918
|
+
// success. Shared by the recents/path switch and the demo catalog above.
|
|
919
|
+
async function postSwitch(url, body, message, what) {
|
|
920
|
+
showLoading(message);
|
|
811
921
|
try {
|
|
812
|
-
const r = await fetch(
|
|
922
|
+
const r = await fetch(url, {
|
|
813
923
|
method: "POST",
|
|
814
924
|
headers: { "content-type": "application/json" },
|
|
815
|
-
body: JSON.stringify(
|
|
925
|
+
body: JSON.stringify(body),
|
|
816
926
|
});
|
|
817
927
|
const j = await r.json();
|
|
818
928
|
if (!r.ok || j.error) {
|
|
819
929
|
hideLoading();
|
|
820
|
-
showToast(
|
|
930
|
+
showToast(`✗ ${what}: ` + (j.error || r.statusText), false);
|
|
821
931
|
return;
|
|
822
932
|
}
|
|
823
933
|
location.reload();
|
|
824
934
|
} catch (e) {
|
|
825
935
|
hideLoading();
|
|
826
|
-
showToast(
|
|
936
|
+
showToast(`✗ ${what}: ` + e.message, false);
|
|
827
937
|
}
|
|
828
938
|
}
|
|
829
939
|
|
|
940
|
+
// #268: the bundled catalog, fetched once per page. `null` until it lands (the
|
|
941
|
+
// first Scope render kicks it off and re-renders when it does), then a list —
|
|
942
|
+
// empty on a server that doesn't serve the route, which renders no group.
|
|
943
|
+
let demoCatalog = null;
|
|
944
|
+
let demoCatalogPending = false;
|
|
945
|
+
function primeDemoCatalog() {
|
|
946
|
+
if (demoCatalogPending) return;
|
|
947
|
+
demoCatalogPending = true;
|
|
948
|
+
fetchDemos().then((demos) => {
|
|
949
|
+
demoCatalog = demos;
|
|
950
|
+
renderPanelScope();
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
830
954
|
function renderPanelScope() {
|
|
831
955
|
const host = document.getElementById("tab-scope");
|
|
832
956
|
if (!host) return;
|
|
@@ -876,6 +1000,19 @@ function renderPanelScope() {
|
|
|
876
1000
|
});
|
|
877
1001
|
row.append(input, go);
|
|
878
1002
|
host.appendChild(row);
|
|
1003
|
+
// #268: the bundled demo catalog, under recents — every demo `behold demo
|
|
1004
|
+
// --list` names, one click from wherever you are. An entry whose
|
|
1005
|
+
// prerequisites are missing renders disabled with the reason on it, and one
|
|
1006
|
+
// that would clone from the network says so before it runs.
|
|
1007
|
+
if (demoCatalog === null) primeDemoCatalog();
|
|
1008
|
+
else if (demoCatalog.length) {
|
|
1009
|
+
host.appendChild(panelHeading("demos"));
|
|
1010
|
+
for (const d of demoCatalog) {
|
|
1011
|
+
const b = panelOpt(demoLabel(d), false, () => openDemo(d), demoTitle(d));
|
|
1012
|
+
if (!d.satisfiable || d.switchable === false) b.disabled = true;
|
|
1013
|
+
host.appendChild(b);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
879
1016
|
}
|
|
880
1017
|
host.appendChild(panelHeading("environment"));
|
|
881
1018
|
host.appendChild(
|
|
@@ -979,6 +1116,7 @@ function selectNode(id) {
|
|
|
979
1116
|
const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
|
|
980
1117
|
if (g) g.classList.add("sel");
|
|
981
1118
|
inspect(node);
|
|
1119
|
+
carvePick(node);
|
|
982
1120
|
}
|
|
983
1121
|
|
|
984
1122
|
function renderPanelModel() {
|
|
@@ -1044,10 +1182,199 @@ function renderPanelModel() {
|
|
|
1044
1182
|
}
|
|
1045
1183
|
}
|
|
1046
1184
|
|
|
1185
|
+
// ---------------------------------------------------------------------------
|
|
1186
|
+
// The carve walkthrough (#254, M1.5 of #230)
|
|
1187
|
+
//
|
|
1188
|
+
// A Carve tab appears only when /api/project says this server is in carve mode,
|
|
1189
|
+
// and its two ACTION steps light up only when it also says a demo copy is
|
|
1190
|
+
// behind it (`carve.demo.runnable`) — a plain `behold carve report.json` gets
|
|
1191
|
+
// the same six steps with the runs honestly greyed out, rather than buttons
|
|
1192
|
+
// that 403.
|
|
1193
|
+
//
|
|
1194
|
+
// The walkthrough's state lives here and nowhere else: no session on the
|
|
1195
|
+
// server, exactly as #254 asks. Reload and you're back at Advise, with whatever
|
|
1196
|
+
// the previous run wrote still sitting in the demo copy.
|
|
1197
|
+
// ---------------------------------------------------------------------------
|
|
1198
|
+
let carveInfo = null; // /api/project's `carve` block (report meta + demo, or null)
|
|
1199
|
+
let carveReport = null; // the raw report off /api/carve — the boundary lists live here
|
|
1200
|
+
let carveState = initialCarveState();
|
|
1201
|
+
let carveHost = null; // the panel section, mounted on first sight of carve mode
|
|
1202
|
+
const carvedIds = new Set(); // addresses the walkthrough has taken all the way through
|
|
1203
|
+
|
|
1204
|
+
function carveMode() {
|
|
1205
|
+
return !!carveInfo;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** The stepper's wiring. Everything that talks to the network or the graph. */
|
|
1209
|
+
const carveActions = {
|
|
1210
|
+
go(index) {
|
|
1211
|
+
const id = CARVE_STEPS[index] && CARVE_STEPS[index].id;
|
|
1212
|
+
if (!id) return;
|
|
1213
|
+
if (blockedReason(carveState, id)) return;
|
|
1214
|
+
carveState.step = index;
|
|
1215
|
+
carveState.error = null;
|
|
1216
|
+
renderPanelCarve();
|
|
1217
|
+
},
|
|
1218
|
+
select(address) {
|
|
1219
|
+
selectNode(address); // the same path a graph click takes — inspect included
|
|
1220
|
+
},
|
|
1221
|
+
reset() {
|
|
1222
|
+
carveState = initialCarveState();
|
|
1223
|
+
renderPanelCarve();
|
|
1224
|
+
},
|
|
1225
|
+
markHandoff() {
|
|
1226
|
+
carveState.handoff = true;
|
|
1227
|
+
if (carveState.pick) {
|
|
1228
|
+
carvedIds.add(carveState.pick.node.id);
|
|
1229
|
+
markCarvedCards();
|
|
1230
|
+
}
|
|
1231
|
+
carveState.step = CARVE_STEPS.findIndex((s) => s.id === "done");
|
|
1232
|
+
renderPanelCarve();
|
|
1233
|
+
},
|
|
1234
|
+
runEmit: () => runCarveStep("emit"),
|
|
1235
|
+
runBridge: () => runCarveStep("bridge"),
|
|
1236
|
+
copy(text, el) {
|
|
1237
|
+
const done = () => {
|
|
1238
|
+
el.dataset.copied = "1";
|
|
1239
|
+
const was = el.textContent;
|
|
1240
|
+
el.textContent = "copied ✓";
|
|
1241
|
+
setTimeout(() => {
|
|
1242
|
+
el.textContent = was;
|
|
1243
|
+
}, 1200);
|
|
1244
|
+
};
|
|
1245
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done);
|
|
1246
|
+
else done();
|
|
1247
|
+
},
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
/** Run one of the two safe steps. Both are POSTs with a `{select}` body; both
|
|
1251
|
+
* answer either their result or #193's `{error, code, remedy}`. */
|
|
1252
|
+
async function runCarveStep(which) {
|
|
1253
|
+
if (carveState.busy || !carveState.pick) return;
|
|
1254
|
+
carveState.busy = which;
|
|
1255
|
+
carveState.error = null;
|
|
1256
|
+
renderPanelCarve();
|
|
1257
|
+
try {
|
|
1258
|
+
const res = await fetch(`/api/carve/${which}`, {
|
|
1259
|
+
method: "POST",
|
|
1260
|
+
headers: { "content-type": "application/json" },
|
|
1261
|
+
body: JSON.stringify({ select: carveState.pick.node.id }),
|
|
1262
|
+
});
|
|
1263
|
+
const body = await res.json().catch(() => ({ error: `${which} returned an unreadable body`, remedy: "" }));
|
|
1264
|
+
if (!res.ok || body.error) {
|
|
1265
|
+
carveState.error = { step: which, ...body };
|
|
1266
|
+
showToast(`✗ carve ${which}: ${body.error || res.status}`, false);
|
|
1267
|
+
} else {
|
|
1268
|
+
carveState[which] = body;
|
|
1269
|
+
// Deliberately does NOT advance. The result IS the step — the emitted
|
|
1270
|
+
// source and the lint verdict, the proposed patch — and skipping past it
|
|
1271
|
+
// to the next button would hide the thing the run was for. The "next"
|
|
1272
|
+
// control unlocks; pressing it stays the viewer's move.
|
|
1273
|
+
showToast(`✓ carve ${which} — wrote into ${(carveInfo.demo && carveInfo.demo.outLabel) || "the demo copy"}`, true);
|
|
1274
|
+
}
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
carveState.error = { step: which, error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
|
|
1277
|
+
} finally {
|
|
1278
|
+
carveState.busy = null;
|
|
1279
|
+
renderPanelCarve();
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** A picked card becomes the walkthrough's subject. Called from the graph's
|
|
1284
|
+
* click handler and from selectNode(), so the panel rows and the cards agree. */
|
|
1285
|
+
function carvePick(node) {
|
|
1286
|
+
if (!carveMode()) return;
|
|
1287
|
+
const resource = (carveReport && carveReport.resources ? carveReport.resources : []).find((r) => r.address === node.id) || null;
|
|
1288
|
+
const pickStep = CARVE_STEPS.findIndex((s) => s.id === "pick");
|
|
1289
|
+
// A NEW pick invalidates the runs that were about the old one — showing one
|
|
1290
|
+
// resource's emitted source under another's name is the one way this panel
|
|
1291
|
+
// could actively lie — and drops the walkthrough back to Pick, wherever it
|
|
1292
|
+
// had got to. Re-clicking the SAME card is just a re-select and moves
|
|
1293
|
+
// nothing, so reading a card mid-walkthrough costs no progress.
|
|
1294
|
+
if (!carveState.pick || carveState.pick.node.id !== node.id) {
|
|
1295
|
+
carveState.emit = null;
|
|
1296
|
+
carveState.bridge = null;
|
|
1297
|
+
carveState.handoff = false;
|
|
1298
|
+
carveState.error = null;
|
|
1299
|
+
carveState.step = pickStep;
|
|
1300
|
+
} else if (carveState.step < pickStep) {
|
|
1301
|
+
carveState.step = pickStep;
|
|
1302
|
+
}
|
|
1303
|
+
carveState.pick = { node, resource };
|
|
1304
|
+
renderPanelCarve();
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** The "carved" marker the last step leaves on the card — a class the CSS
|
|
1308
|
+
* paints plus a small label. Re-applied after every render, because the SVG is
|
|
1309
|
+
* replaced wholesale on each load. The full morph (the box sliding out of the
|
|
1310
|
+
* Terraform boundary and into the chant project beside last month's carves) is
|
|
1311
|
+
* the follow-up; this is the honest still frame of it. */
|
|
1312
|
+
function markCarvedCards() {
|
|
1313
|
+
const svg = document.querySelector("#graph svg");
|
|
1314
|
+
if (!svg || !carvedIds.size) return;
|
|
1315
|
+
for (const g of svg.querySelectorAll("[data-node-id]")) {
|
|
1316
|
+
const id = g.getAttribute("data-node-id");
|
|
1317
|
+
if (!carvedIds.has(id) || g.querySelector('[data-carved="1"]')) continue;
|
|
1318
|
+
g.classList.add("carved");
|
|
1319
|
+
// Measured, not read off attributes: pinhole sizes the card from its
|
|
1320
|
+
// content and doesn't always stamp width/height, and a missing attribute
|
|
1321
|
+
// read as 0 parks the label at the group's origin — which is off the card
|
|
1322
|
+
// entirely (seen in the browser before this was measured instead).
|
|
1323
|
+
const rect = g.querySelector("rect");
|
|
1324
|
+
const box = rect && rect.getBBox ? rect.getBBox() : null;
|
|
1325
|
+
const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
1326
|
+
tag.setAttribute("data-carved", "1");
|
|
1327
|
+
// Inside the card's own box, bottom-right — the one corner the terraform
|
|
1328
|
+
// presentation pack leaves empty.
|
|
1329
|
+
tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
|
|
1330
|
+
tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9));
|
|
1331
|
+
tag.setAttribute("text-anchor", "end");
|
|
1332
|
+
tag.setAttribute("font-size", "11");
|
|
1333
|
+
tag.setAttribute("font-weight", "600");
|
|
1334
|
+
tag.setAttribute("fill", "var(--managed)");
|
|
1335
|
+
tag.textContent = "✓ carved → chant";
|
|
1336
|
+
g.appendChild(tag);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function renderPanelCarve() {
|
|
1341
|
+
if (!carveMode()) return;
|
|
1342
|
+
if (!carveHost) {
|
|
1343
|
+
carveHost = addPanelTab("carve", "Carve", "The peel walkthrough: advise → pick → emit → bridge → handoff → done.");
|
|
1344
|
+
if (!carveHost) return;
|
|
1345
|
+
carveHost.id = "tab-carve";
|
|
1346
|
+
}
|
|
1347
|
+
renderCarvePanel(
|
|
1348
|
+
carveHost,
|
|
1349
|
+
carveState,
|
|
1350
|
+
{ carve: carveInfo, demo: carveInfo && carveInfo.demo, report: carveReport, renderJson },
|
|
1351
|
+
carveActions,
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** Carve mode's one extra fetch: the raw report, for the per-resource boundary
|
|
1356
|
+
* lists the graph IR deliberately doesn't carry. Best-effort — the stepper
|
|
1357
|
+
* degrades to the counts in the IR's own attrs. */
|
|
1358
|
+
async function loadCarveReport() {
|
|
1359
|
+
if (!carveMode()) return;
|
|
1360
|
+
try {
|
|
1361
|
+
carveReport = await apiFetch("/api/carve").then((r) => r.json());
|
|
1362
|
+
} catch {
|
|
1363
|
+
carveReport = null;
|
|
1364
|
+
}
|
|
1365
|
+
renderPanelCarve();
|
|
1366
|
+
// A readiness marker, so a test can wait for the extra fetch instead of
|
|
1367
|
+
// racing it. The panel itself never waits: `cutSummary` falls back to the
|
|
1368
|
+
// counts the IR node already carries, which is what the report would have
|
|
1369
|
+
// told it anyway on a chant that publishes no edge lists.
|
|
1370
|
+
if (carveHost) carveHost.dataset.report = carveReport ? "1" : "0";
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1047
1373
|
function renderPanel() {
|
|
1048
1374
|
renderPanelView();
|
|
1049
1375
|
renderPanelScope();
|
|
1050
1376
|
renderPanelModel();
|
|
1377
|
+
if (carveMode()) renderPanelCarve();
|
|
1051
1378
|
}
|
|
1052
1379
|
|
|
1053
1380
|
function renderStatusbar() {
|
|
@@ -1716,6 +2043,7 @@ function render(ir, svg, m) {
|
|
|
1716
2043
|
ensureBackToInfra(g);
|
|
1717
2044
|
wire(ir);
|
|
1718
2045
|
if (view.radial && !view.components && !view.logical) addRadialLabels(ir);
|
|
2046
|
+
markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker
|
|
1719
2047
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
1720
2048
|
renderDial();
|
|
1721
2049
|
}
|
|
@@ -1916,6 +2244,14 @@ function ensureZoomControls(host) {
|
|
|
1916
2244
|
// the end of every render(), so the graph underneath stays chant's and a
|
|
1917
2245
|
// delta for a node that left the estate is simply not applied.
|
|
1918
2246
|
//
|
|
2247
|
+
// Two tiers now (#228's second half): localStorage, and the project's own
|
|
2248
|
+
// `.behold/layout.json` behind GET/POST /api/layout. On load the server's map
|
|
2249
|
+
// merges UNDER the local one (`mergeLayouts` — the drag you can see always
|
|
2250
|
+
// wins); on a finished gesture the current lens is POSTed, debounced. A server
|
|
2251
|
+
// that refuses — a static export, preview mode, a read-only project, an older
|
|
2252
|
+
// behold with no such route — leaves the localStorage tier working exactly as
|
|
2253
|
+
// it did before, and says nothing.
|
|
2254
|
+
//
|
|
1919
2255
|
// What this does NOT do, said plainly rather than faked:
|
|
1920
2256
|
// * a resized box does not reflow its children — that is dagre's job on the
|
|
1921
2257
|
// next layout, and the reset control's tooltip says so;
|
|
@@ -1923,51 +2259,86 @@ function ensureZoomControls(host) {
|
|
|
1923
2259
|
// its original anchor points, each shifted by its own node's delta. #228
|
|
1924
2260
|
// accepts the straight-line fallback; spline re-routing is pinhole's job.
|
|
1925
2261
|
// An edge with both ends where dagre put them keeps its bezier untouched.
|
|
1926
|
-
//
|
|
1927
|
-
//
|
|
1928
|
-
//
|
|
2262
|
+
// Everything anchored TO that edge rides with it (#267): both paths in the
|
|
2263
|
+
// group — the visible line and pinhole's fat transparent hit-path — and the
|
|
2264
|
+
// `viaAttr` chip, which lands on the new midpoint (see edgeLabelOf).
|
|
2265
|
+
// A box's title needs nothing: pinhole paints it at the rect's top-left
|
|
2266
|
+
// corner, which is the one corner a resize never moves, and a box move
|
|
2267
|
+
// translates the whole wrapper group the title is already inside.
|
|
2268
|
+
// * the server bakes {dx,dy} into an exported SVG but not a box's {dw,dh} —
|
|
2269
|
+
// only pinhole's ARCHITECTURE boxes carry an id to bake against, and a bake
|
|
2270
|
+
// that worked on some lenses and not others would be worse than none
|
|
2271
|
+
// (src/layout.ts says the same at more length).
|
|
1929
2272
|
let layoutIndex = null; // {nodes,boxes,edges} for the SVG currently on screen
|
|
1930
2273
|
let layoutDeltas = {}; // the deltas in force for the current key
|
|
1931
2274
|
let layoutDrag = null; // the gesture in flight
|
|
1932
2275
|
let layoutWired = false;
|
|
2276
|
+
// The sidecar tier, cached per lens: one GET when a lens is first shown, not
|
|
2277
|
+
// one per render (render() runs on every SSE nudge and every settle poll).
|
|
2278
|
+
let layoutServer = { lens: null, deltas: {}, writable: false };
|
|
1933
2279
|
|
|
1934
2280
|
/** The storage key for the project + lens on screen; null before /api/project lands. */
|
|
1935
2281
|
function currentLayoutKey() {
|
|
1936
2282
|
if (!projectInfo) return null;
|
|
1937
|
-
return layoutKey(projectKeyOf(projectInfo),
|
|
2283
|
+
return layoutKey(projectKeyOf(projectInfo), currentLensKey());
|
|
1938
2284
|
}
|
|
1939
2285
|
|
|
1940
|
-
/**
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
return { sx: +n[0], sy: +n[1], ex: +n[n.length - 2], ey: +n[n.length - 1] };
|
|
2286
|
+
/** The lens half of the key — also what /api/layout is keyed by (the project
|
|
2287
|
+
* half is implicit there: the sidecar lives inside the project). */
|
|
2288
|
+
function currentLensKey() {
|
|
2289
|
+
return lensKeyOf({ zoom: zoomValue(), radial: view.radial, stack: view.stack });
|
|
1945
2290
|
}
|
|
1946
2291
|
|
|
1947
2292
|
/**
|
|
1948
2293
|
* Wrap each containment box in a `<g data-layout-box>` and give it a corner
|
|
1949
2294
|
* handle, once per rendered SVG.
|
|
1950
2295
|
*
|
|
1951
|
-
*
|
|
1952
|
-
*
|
|
1953
|
-
*
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1959
|
-
*
|
|
2296
|
+
* TWO WAYS TO FIND A BOX, in that order (#250):
|
|
2297
|
+
*
|
|
2298
|
+
* 1. `rect[data-group-id]` — pinhole stamps the container key on an
|
|
2299
|
+
* architecture group box from 0.3.3 (pinhole#103/#104), the same hook
|
|
2300
|
+
* `data-node-id` gives a card. That key is also what the box's delta is
|
|
2301
|
+
* stored under, so a box keeps its size when its title changes and two
|
|
2302
|
+
* boxes that happen to read alike stay separate placements.
|
|
2303
|
+
* 2. The structural match #245 shipped, kept for one release as a fallback and
|
|
2304
|
+
* then deleted. It is still load-bearing, not dead weight: `layoutArchitecture`
|
|
2305
|
+
* is the only pinhole layout that sets `GroupBox.id`, so the wave/stack/
|
|
2306
|
+
* container boxes `renderGraph` draws (src/render.ts) arrive with a title
|
|
2307
|
+
* and nothing else. FRAGILE BY CONSTRUCTION and knowingly so — the
|
|
2308
|
+
* discriminators are a positive `rx` (the two page-background rects have
|
|
2309
|
+
* none), a `<text>` as the next non-badge sibling, and svg-root parentage
|
|
2310
|
+
* (a card's rect lives inside `[data-node-id]`, an edge-label's inside its
|
|
2311
|
+
* own `<g>`), exactly the way addGitlabWaveBadges still has to.
|
|
2312
|
+
*
|
|
2313
|
+
* Identification runs over a DOM nothing has moved yet and the wrapping happens
|
|
2314
|
+
* after: both paths read `nextElementSibling`, and inserting a wrapper mid-walk
|
|
2315
|
+
* would put one box's `<g>` in the middle of the next box's member run.
|
|
2316
|
+
*
|
|
2317
|
+
* MIGRATION, said plainly: a box that gains an id changes storage key —
|
|
2318
|
+
* `layoutArchitecture` titles a box `<id> · <kind>`, so `box:vpc · VPC`
|
|
2319
|
+
* becomes `box:vpc` — and `applicable` drops the old key on the next render.
|
|
2320
|
+
* That is a deliberate one-time loss of hand-set box sizes on the logical
|
|
2321
|
+
* views, taken rather than carrying a key-rewriting migration for a delta that
|
|
2322
|
+
* is cosmetic and one drag to redo. Node placements are untouched: they were
|
|
2323
|
+
* always keyed by `data-node-id`.
|
|
1960
2324
|
*/
|
|
1961
2325
|
function wrapContainmentBoxes(svgEl) {
|
|
1962
2326
|
const boxes = new Map();
|
|
1963
2327
|
const handleSize = Math.max(12, Math.round((vbInit ? vbInit[2] : 1000) / 90));
|
|
2328
|
+
const found = [];
|
|
2329
|
+
const claimed = new Set();
|
|
1964
2330
|
for (const rect of [...svgEl.children]) {
|
|
1965
2331
|
if (rect.tagName.toLowerCase() !== "rect") continue;
|
|
2332
|
+
const groupId = (rect.getAttribute("data-group-id") || "").trim();
|
|
1966
2333
|
const x = parseFloat(rect.getAttribute("x"));
|
|
1967
2334
|
const y = parseFloat(rect.getAttribute("y"));
|
|
1968
2335
|
const w = parseFloat(rect.getAttribute("width"));
|
|
1969
2336
|
const h = parseFloat(rect.getAttribute("height"));
|
|
1970
|
-
if (!(
|
|
2337
|
+
if (!(w > 0) || !(h > 0) || Number.isNaN(x) || Number.isNaN(y)) continue;
|
|
2338
|
+
// The `rx` sniff is the fallback's discriminator only. An id'd rect has
|
|
2339
|
+
// already said what it is, and hardening a shape pinhole never promised
|
|
2340
|
+
// would be the same mistake this issue exists to undo.
|
|
2341
|
+
if (!groupId && !(parseFloat(rect.getAttribute("rx")) > 0)) continue;
|
|
1971
2342
|
// Collect rect → title, stepping over anything already stamped between
|
|
1972
2343
|
// them (the GitLab wave badge), and bail at the next box or card.
|
|
1973
2344
|
const members = [rect];
|
|
@@ -1981,17 +2352,28 @@ function wrapContainmentBoxes(svgEl) {
|
|
|
1981
2352
|
break;
|
|
1982
2353
|
}
|
|
1983
2354
|
}
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2355
|
+
// An untitled box is only knowable by its attribute — the structural path
|
|
2356
|
+
// has literally nothing else to go on, and a titleless id'd box keeps its
|
|
2357
|
+
// resize handle and simply has no drag-by-title. It also keeps nothing but
|
|
2358
|
+
// its own rect: the walk above only stops at a title, so without one it has
|
|
2359
|
+
// swept up whatever pinhole painted next, which is not this box's to move.
|
|
2360
|
+
if (!title && !groupId) continue;
|
|
2361
|
+
const id = `box:${groupId || (title.textContent || "").trim() || found.length}`;
|
|
2362
|
+
if (claimed.has(id)) continue;
|
|
2363
|
+
claimed.add(id);
|
|
2364
|
+
found.push({ id, rect, members: title ? members : [rect], title, x, y, w, h });
|
|
2365
|
+
}
|
|
2366
|
+
for (const { id, rect, members, title, x, y, w, h } of found) {
|
|
1987
2367
|
const g = document.createElementNS(SVGNS, "g");
|
|
1988
2368
|
g.setAttribute("data-layout-box", id);
|
|
1989
2369
|
rect.parentNode.insertBefore(g, rect);
|
|
1990
2370
|
members.forEach((m) => g.appendChild(m));
|
|
1991
2371
|
// The title doubles as the box's move handle — the interior stays pan
|
|
1992
2372
|
// territory, which on a logical view is most of the canvas.
|
|
1993
|
-
title
|
|
1994
|
-
|
|
2373
|
+
if (title) {
|
|
2374
|
+
title.setAttribute("data-layout-move", id);
|
|
2375
|
+
title.setAttribute("cursor", "move");
|
|
2376
|
+
}
|
|
1995
2377
|
const handle = document.createElementNS(SVGNS, "g");
|
|
1996
2378
|
handle.setAttribute("data-layout-resize", id);
|
|
1997
2379
|
handle.setAttribute("cursor", "nwse-resize");
|
|
@@ -2015,38 +2397,178 @@ function positionBoxHandle(b, w, h) {
|
|
|
2015
2397
|
b.handle.setAttribute("transform", `translate(${b.x + w - b.size - 3}, ${b.y + h - b.size - 3})`);
|
|
2016
2398
|
}
|
|
2017
2399
|
|
|
2400
|
+
/** A box's rect as it stands right now: pinhole's, plus whatever the hand did to
|
|
2401
|
+
* it. One definition for the painter and for the clamp, so growing a box really
|
|
2402
|
+
* does buy its children room (#267) and cannot drift from what you can see. */
|
|
2403
|
+
function boxRect(b, d) {
|
|
2404
|
+
return {
|
|
2405
|
+
x: b.x + ((d && d.dx) || 0),
|
|
2406
|
+
y: b.y + ((d && d.dy) || 0),
|
|
2407
|
+
w: Math.max(b.size * 3, b.w0 + ((d && d.dw) || 0)),
|
|
2408
|
+
h: Math.max(b.size * 3, b.h0 + ((d && d.dh) || 0)),
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/** Screen pixels → this SVG's own user units, as a function. Null when the SVG
|
|
2413
|
+
* isn't laid out (a hidden pane, a detached document) — and a null mapper means
|
|
2414
|
+
* no measured geometry, which the clamp reads as "don't clamp". */
|
|
2415
|
+
function userSpaceMapper(svgEl) {
|
|
2416
|
+
const m = svgEl.getScreenCTM && svgEl.getScreenCTM();
|
|
2417
|
+
if (!m || typeof svgEl.createSVGPoint !== "function") return null;
|
|
2418
|
+
const inv = m.inverse();
|
|
2419
|
+
const pt = svgEl.createSVGPoint();
|
|
2420
|
+
return (x, y) => {
|
|
2421
|
+
pt.x = x;
|
|
2422
|
+
pt.y = y;
|
|
2423
|
+
return pt.matrixTransform(inv);
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/** An element's box in SVG user units. MEASURED, not parsed off attributes:
|
|
2428
|
+
* pinhole's cards carry absolute coordinates and no transform, the smoke stub's
|
|
2429
|
+
* carry a `translate()`, and a measurement holds either without this code
|
|
2430
|
+
* knowing which. Only ever taken on a freshly rendered SVG, before any delta is
|
|
2431
|
+
* painted — it is the dagre-placed rect the clamp reasons about. */
|
|
2432
|
+
function userRect(el, toUser) {
|
|
2433
|
+
if (!toUser || typeof el.getBoundingClientRect !== "function") return null;
|
|
2434
|
+
const r = el.getBoundingClientRect();
|
|
2435
|
+
if (!(r.width > 0) || !(r.height > 0)) return null;
|
|
2436
|
+
const a = toUser(r.left, r.top);
|
|
2437
|
+
const b = toUser(r.right, r.bottom);
|
|
2438
|
+
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* The chip pinhole paints on a labelled edge (`project`, `sourceRef`,
|
|
2443
|
+
* `selector` — the IR edge's `viaAttr`), or null if this edge has none.
|
|
2444
|
+
*
|
|
2445
|
+
* HOW THE TWO ARE ASSOCIATED, and why it is what it is: `renderSvg` emits
|
|
2446
|
+
* `Canvas.edge(...)` and then, for a labelled edge, `Canvas.edgeLabel(...)` —
|
|
2447
|
+
* an ANONYMOUS `<g><rect rx="9"/><text>…</text></g>` carrying no edge identity
|
|
2448
|
+
* whatsoever. There is no id, no data attribute, no shared class. What is left
|
|
2449
|
+
* is document order (the chip is the edge group's next element sibling) and the
|
|
2450
|
+
* one piece of content the two provably share: the chip's text IS the edge
|
|
2451
|
+
* group's `data-edge-via` value. Behold requires BOTH before it will move
|
|
2452
|
+
* anything, so a pinhole that stops emitting chips, reorders them, or slips
|
|
2453
|
+
* something between the pair leaves the label untouched rather than dragging an
|
|
2454
|
+
* unrelated group around the canvas.
|
|
2455
|
+
*
|
|
2456
|
+
* Read at index time and kept as an element reference, never re-derived:
|
|
2457
|
+
* wireEdgeHighlight's `raise()` re-appends a hovered edge group to the end of
|
|
2458
|
+
* the SVG, so the sibling link is gone the instant a pointer crosses an edge.
|
|
2459
|
+
*
|
|
2460
|
+
* The real fix is upstream, and it is the same ask boxes made and won in
|
|
2461
|
+
* pinhole 0.3.3 (`data-group-id`, #250): stamp the chip with the edge it
|
|
2462
|
+
* belongs to. The moment pinhole emits a `data-edge-*` pairing on the label
|
|
2463
|
+
* group, prefer it here the way wrapContainmentBoxes now prefers the attribute.
|
|
2464
|
+
*/
|
|
2465
|
+
function edgeLabelOf(g) {
|
|
2466
|
+
const via = (g.getAttribute("data-edge-via") || "").trim();
|
|
2467
|
+
if (!via) return null;
|
|
2468
|
+
const next = g.nextElementSibling;
|
|
2469
|
+
if (!next || next.tagName.toLowerCase() !== "g") return null;
|
|
2470
|
+
if (next.hasAttribute("data-node-id") || next.hasAttribute("data-edge-from")) return null;
|
|
2471
|
+
const text = next.querySelector("text");
|
|
2472
|
+
if (!text || (text.textContent || "").trim() !== via) return null;
|
|
2473
|
+
return next;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2018
2476
|
/** Index the freshly rendered SVG: node groups, containment boxes, edge paths. */
|
|
2019
2477
|
function indexLayout(svgEl) {
|
|
2478
|
+
const toUser = userSpaceMapper(svgEl);
|
|
2020
2479
|
const nodes = new Map();
|
|
2021
2480
|
for (const el of svgEl.querySelectorAll("[data-node-id]")) {
|
|
2022
2481
|
const id = el.getAttribute("data-node-id");
|
|
2023
|
-
if (!nodes.has(id)) nodes.set(id, { el, base: el.getAttribute("transform") || "" });
|
|
2482
|
+
if (!nodes.has(id)) nodes.set(id, { el, base: el.getAttribute("transform") || "", rect: userRect(el, toUser) });
|
|
2024
2483
|
}
|
|
2025
2484
|
const edges = [];
|
|
2026
2485
|
for (const g of svgEl.querySelectorAll("g[data-edge-from]")) {
|
|
2027
2486
|
const paths = [...g.querySelectorAll("path")];
|
|
2028
2487
|
if (!paths.length) continue;
|
|
2029
2488
|
const d0 = paths[0].getAttribute("d");
|
|
2030
|
-
|
|
2489
|
+
const label = edgeLabelOf(g);
|
|
2490
|
+
edges.push({
|
|
2491
|
+
from: g.getAttribute("data-edge-from"),
|
|
2492
|
+
to: g.getAttribute("data-edge-to"),
|
|
2493
|
+
paths,
|
|
2494
|
+
d0,
|
|
2495
|
+
anchors: pathAnchors(d0),
|
|
2496
|
+
label,
|
|
2497
|
+
labelBase: label ? label.getAttribute("transform") || "" : "",
|
|
2498
|
+
});
|
|
2031
2499
|
}
|
|
2032
2500
|
layoutIndex = { nodes, boxes: wrapContainmentBoxes(svgEl), edges };
|
|
2033
2501
|
}
|
|
2034
2502
|
|
|
2503
|
+
/**
|
|
2504
|
+
* The box a card sits in, geometrically — the containment is visual and nothing
|
|
2505
|
+
* in the DOM says which card belongs to which box. The SMALLEST box whose
|
|
2506
|
+
* CURRENT rect covers the card's original centre wins, so a card in a namespace
|
|
2507
|
+
* nested inside a cluster clamps to the namespace, not the cluster.
|
|
2508
|
+
*
|
|
2509
|
+
* `current` is the load-bearing word: growing a box is the sanctioned way to
|
|
2510
|
+
* make room, so the wall a drag stops at has to be the box as it is now.
|
|
2511
|
+
*/
|
|
2512
|
+
function containerOf(rect) {
|
|
2513
|
+
if (!layoutIndex || !rect) return null;
|
|
2514
|
+
const cx = rect.x + rect.w / 2;
|
|
2515
|
+
const cy = rect.y + rect.h / 2;
|
|
2516
|
+
let best = null;
|
|
2517
|
+
for (const [id, b] of layoutIndex.boxes) {
|
|
2518
|
+
const r = boxRect(b, layoutDeltas[id]);
|
|
2519
|
+
if (cx < r.x || cx > r.x + r.w || cy < r.y || cy > r.y + r.h) continue;
|
|
2520
|
+
if (!best || r.w * r.h < best.w * best.h) best = r;
|
|
2521
|
+
}
|
|
2522
|
+
return best;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
/** A node's delta, clamped to whatever contains it: its box, or failing that
|
|
2526
|
+
* the canvas pinhole drew (`vbInit` — the graph's own extent, NOT the panned
|
|
2527
|
+
* and zoomed `vb`, which is just where you happen to be looking).
|
|
2528
|
+
*
|
|
2529
|
+
* Nodes only. A box's own gesture is the escape hatch, so clamping a box would
|
|
2530
|
+
* put the only way to make room behind the wall it enforces. And an id with no
|
|
2531
|
+
* measured rect is returned untouched — see clampDelta on failing open. */
|
|
2532
|
+
function clampNodeDelta(id, delta) {
|
|
2533
|
+
const n = layoutIndex && layoutIndex.nodes.get(id);
|
|
2534
|
+
if (!n || !n.rect) return delta;
|
|
2535
|
+
const canvas = vbInit ? { x: vbInit[0], y: vbInit[1], w: vbInit[2], h: vbInit[3] } : null;
|
|
2536
|
+
const bounds = containerOf(n.rect) || canvas;
|
|
2537
|
+
return bounds ? clampDelta(delta, n.rect, bounds) : delta;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/** Bring every stored node delta back inside its container. Clamped, never
|
|
2541
|
+
* discarded (#267): a placement made before this rule existed — or against a
|
|
2542
|
+
* box someone has since shrunk — keeps everything about it except the part that
|
|
2543
|
+
* escaped. Box deltas are read as-is, which is why this runs after they land. */
|
|
2544
|
+
function clampStoredDeltas(deltas) {
|
|
2545
|
+
if (!layoutIndex) return deltas;
|
|
2546
|
+
let out = deltas;
|
|
2547
|
+
for (const id of layoutIndex.nodes.keys()) {
|
|
2548
|
+
const d = out[id];
|
|
2549
|
+
if (!d || (!d.dx && !d.dy)) continue;
|
|
2550
|
+
const c = clampNodeDelta(id, d);
|
|
2551
|
+
if (c.dx !== (d.dx || 0) || c.dy !== (d.dy || 0)) out = setDelta(out, id, { ...d, dx: c.dx, dy: c.dy });
|
|
2552
|
+
}
|
|
2553
|
+
return out;
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2035
2556
|
/** Paint `layoutDeltas` onto the indexed SVG. Idempotent: always from the original. */
|
|
2036
2557
|
function renderLayout() {
|
|
2037
2558
|
if (!layoutIndex) return;
|
|
2038
2559
|
for (const [id, n] of layoutIndex.nodes) {
|
|
2039
|
-
const
|
|
2040
|
-
const t = d && (d.dx || d.dy) ? `translate(${d.dx || 0}, ${d.dy || 0}) ${n.base}`.trim() : n.base;
|
|
2560
|
+
const t = nodeTransform(n.base, layoutDeltas[id] || {});
|
|
2041
2561
|
if (t) n.el.setAttribute("transform", t);
|
|
2042
2562
|
else n.el.removeAttribute("transform");
|
|
2043
2563
|
}
|
|
2044
2564
|
for (const [id, b] of layoutIndex.boxes) {
|
|
2045
2565
|
const d = layoutDeltas[id] || {};
|
|
2046
|
-
const w =
|
|
2047
|
-
const h = Math.max(b.size * 3, b.h0 + (d.dh || 0));
|
|
2566
|
+
const { w, h } = boxRect(b, d);
|
|
2048
2567
|
b.rect.setAttribute("width", String(w));
|
|
2049
2568
|
b.rect.setAttribute("height", String(h));
|
|
2569
|
+
// The title rides inside this same group, and pinhole anchors it to the
|
|
2570
|
+
// rect's top-left — the one corner a resize leaves alone — so a box's
|
|
2571
|
+
// caption never detaches from its box (#267 asked; nothing to fix).
|
|
2050
2572
|
if (d.dx || d.dy) b.g.setAttribute("transform", `translate(${d.dx || 0}, ${d.dy || 0})`);
|
|
2051
2573
|
else b.g.removeAttribute("transform");
|
|
2052
2574
|
positionBoxHandle(b, w, h);
|
|
@@ -2056,30 +2578,88 @@ function renderLayout() {
|
|
|
2056
2578
|
const z = layoutDeltas[e.to];
|
|
2057
2579
|
if ((!a && !z) || !e.anchors) {
|
|
2058
2580
|
if (e.paths[0].getAttribute("d") !== e.d0) e.paths.forEach((p) => p.setAttribute("d", e.d0));
|
|
2581
|
+
placeEdgeLabel(e, 0, 0);
|
|
2059
2582
|
continue;
|
|
2060
2583
|
}
|
|
2061
|
-
const
|
|
2062
|
-
|
|
2584
|
+
const d = straightEdge(e.anchors, a, z);
|
|
2585
|
+
// EVERY path in the group: the visible line and pinhole's 14-wide
|
|
2586
|
+
// transparent hit-path both, so the edge you can grab stays under the edge
|
|
2587
|
+
// you can see.
|
|
2063
2588
|
e.paths.forEach((p) => p.setAttribute("d", d));
|
|
2589
|
+
// The chip was painted at the midpoint of the two anchors, so the midpoint
|
|
2590
|
+
// of the re-anchored line is that point plus the MEAN of the two ends'
|
|
2591
|
+
// deltas. No re-reading of the chip's own coordinates, and no assumption
|
|
2592
|
+
// about how pinhole spelled them.
|
|
2593
|
+
placeEdgeLabel(e, (((a && a.dx) || 0) + ((z && z.dx) || 0)) / 2, (((a && a.dy) || 0) + ((z && z.dy) || 0)) / 2);
|
|
2064
2594
|
}
|
|
2065
2595
|
}
|
|
2066
2596
|
|
|
2597
|
+
/** Shift an edge's label chip by `(sx,sy)`, from its painted position. Composed
|
|
2598
|
+
* onto whatever transform it was painted with (none, today) and removed when
|
|
2599
|
+
* the shift is zero — so this is idempotent, like the rest of renderLayout. */
|
|
2600
|
+
function placeEdgeLabel(e, sx, sy) {
|
|
2601
|
+
if (!e.label) return;
|
|
2602
|
+
const t = nodeTransform(e.labelBase, { dx: sx, dy: sy });
|
|
2603
|
+
if (t) e.label.setAttribute("transform", t);
|
|
2604
|
+
else e.label.removeAttribute("transform");
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2067
2607
|
/** Re-index the SVG, load this lens's deltas, paint them. Called from render(). */
|
|
2068
2608
|
function applyLayout() {
|
|
2069
2609
|
const svgEl = currentSvg();
|
|
2070
2610
|
const host = document.getElementById("graph");
|
|
2071
2611
|
if (!svgEl || !host) return;
|
|
2072
2612
|
indexLayout(svgEl);
|
|
2613
|
+
reloadLayoutDeltas();
|
|
2614
|
+
ensureLayoutReset(host);
|
|
2615
|
+
ensureLayoutDrag(host);
|
|
2616
|
+
const lens = currentLensKey();
|
|
2617
|
+
if (currentLayoutKey() && layoutServer.lens !== lens) pullServerLayout(lens);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/** Recompute both tiers onto the SVG already indexed, and repaint. Separate
|
|
2621
|
+
* from applyLayout() because it must NOT re-index: renderLayout paints from
|
|
2622
|
+
* each node's ORIGINAL transform, so re-indexing an already-painted SVG would
|
|
2623
|
+
* take the displaced transform as the new base and apply the delta twice. */
|
|
2624
|
+
function reloadLayoutDeltas() {
|
|
2625
|
+
if (!layoutIndex) return;
|
|
2073
2626
|
const key = currentLayoutKey();
|
|
2627
|
+
const lens = currentLensKey();
|
|
2074
2628
|
// Stale ids are dropped on apply, not on write: a lens the user hasn't
|
|
2075
2629
|
// opened in a while shouldn't have its deltas quietly deleted because this
|
|
2076
2630
|
// render happened to be a different projection of the same estate.
|
|
2077
|
-
|
|
2631
|
+
const merged = key ? mergeLayouts(readLayout(localStorage, key), layoutServer.lens === lens ? layoutServer.deltas : {}) : {};
|
|
2632
|
+
layoutDeltas = applicable(merged, [...layoutIndex.nodes.keys(), ...layoutIndex.boxes.keys()]);
|
|
2633
|
+
layoutDeltas = clampStoredDeltas(layoutDeltas);
|
|
2078
2634
|
renderLayout();
|
|
2079
|
-
ensureLayoutReset(host);
|
|
2080
|
-
ensureLayoutDrag(host);
|
|
2081
2635
|
}
|
|
2082
2636
|
|
|
2637
|
+
/** One GET per lens. Whatever comes back is merged UNDER the local tier and
|
|
2638
|
+
* repainted; a refusal is cached as an empty, unwritable answer, so a serve
|
|
2639
|
+
* with no sidecar (or no server at all) costs exactly one request per lens. */
|
|
2640
|
+
async function pullServerLayout(lens) {
|
|
2641
|
+
layoutServer = { lens, deltas: {}, writable: false }; // claim it first — no request storm
|
|
2642
|
+
const got = await fetchServerLayout(apiFetch, lens);
|
|
2643
|
+
if (layoutServer.lens !== lens) return; // the view moved on while we waited
|
|
2644
|
+
layoutServer = { lens, ...got };
|
|
2645
|
+
if (!Object.keys(got.deltas).length || currentLensKey() !== lens) return;
|
|
2646
|
+
reloadLayoutDeltas();
|
|
2647
|
+
ensureLayoutReset(document.getElementById("graph"));
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
/** Push the current lens to the sidecar, once the hand has stopped moving.
|
|
2651
|
+
* Nothing here is load-bearing: `writable` false (static export, preview mode,
|
|
2652
|
+
* read-only project, older behold) simply never pushes, and a failed push is
|
|
2653
|
+
* not reported — the localStorage tier already has it. */
|
|
2654
|
+
const pushServerLayout = debounce((lens, deltas) => {
|
|
2655
|
+
if (staticMode || !layoutServer.writable || layoutServer.lens !== lens) return;
|
|
2656
|
+
postServerLayout((url, init) => fetch(url, init), lens, deltas);
|
|
2657
|
+
}, 600);
|
|
2658
|
+
// A reload (or a tab closing) within the debounce window would otherwise drop
|
|
2659
|
+
// the last placement on the floor — localStorage has it, the sidecar wouldn't.
|
|
2660
|
+
// The push rides `keepalive`, so it survives the document.
|
|
2661
|
+
window.addEventListener("pagehide", () => pushServerLayout.flush());
|
|
2662
|
+
|
|
2083
2663
|
/** The grabbable thing at or above `el`, if any. */
|
|
2084
2664
|
function layoutTargetIn(el) {
|
|
2085
2665
|
if (!el || typeof el.closest !== "function") return null;
|
|
@@ -2151,7 +2731,11 @@ function ensureLayoutDrag(host) {
|
|
|
2151
2731
|
layoutDrag.kind === "resize"
|
|
2152
2732
|
? { dx: b.dx || 0, dy: b.dy || 0, dw: (b.dw || 0) + dx, dh: (b.dh || 0) + dy }
|
|
2153
2733
|
: { dx: (b.dx || 0) + dx, dy: (b.dy || 0) + dy, dw: b.dw || 0, dh: b.dh || 0 };
|
|
2154
|
-
|
|
2734
|
+
// #267: the card stops at its box's wall while the pointer keeps going. A
|
|
2735
|
+
// no-op for a box id and for a resize, and the box's CURRENT size is what
|
|
2736
|
+
// is read — so widening a box mid-session immediately buys its children the
|
|
2737
|
+
// room, no re-render needed.
|
|
2738
|
+
layoutDeltas = setDelta(layoutDeltas, layoutDrag.id, clampNodeDelta(layoutDrag.id, next));
|
|
2155
2739
|
renderLayout();
|
|
2156
2740
|
});
|
|
2157
2741
|
window.addEventListener("pointerup", () => {
|
|
@@ -2161,6 +2745,10 @@ function ensureLayoutDrag(host) {
|
|
|
2161
2745
|
if (!moved) return;
|
|
2162
2746
|
const key = currentLayoutKey();
|
|
2163
2747
|
if (key) writeLayout(localStorage, key, layoutDeltas);
|
|
2748
|
+
// The sidecar gets the WHOLE lens map, not the one id that moved: it is a
|
|
2749
|
+
// per-lens document, and the local tier is the authority the user is
|
|
2750
|
+
// looking at. Debounced, so a drag is one write and not sixty.
|
|
2751
|
+
if (key) pushServerLayout(currentLensKey(), layoutDeltas);
|
|
2164
2752
|
ensureLayoutReset(document.getElementById("graph"));
|
|
2165
2753
|
});
|
|
2166
2754
|
}
|
|
@@ -2180,6 +2768,11 @@ function ensureLayoutReset(host) {
|
|
|
2180
2768
|
e.stopPropagation();
|
|
2181
2769
|
const key = currentLayoutKey();
|
|
2182
2770
|
if (key) clearLayout(localStorage, key);
|
|
2771
|
+
// Both tiers, or it isn't a reset: clearing only localStorage would let
|
|
2772
|
+
// the next merge pull the sidecar's deltas straight back in.
|
|
2773
|
+
const lens = currentLensKey();
|
|
2774
|
+
layoutServer = { lens, deltas: {}, writable: layoutServer.lens === lens && layoutServer.writable };
|
|
2775
|
+
pushServerLayout(lens, {});
|
|
2183
2776
|
layoutDeltas = {};
|
|
2184
2777
|
renderLayout();
|
|
2185
2778
|
ensureLayoutReset(host);
|
|
@@ -2202,6 +2795,10 @@ const PRECONDITION_TITLE = {
|
|
|
2202
2795
|
// #193: behold was pointed at a directory that isn't a chant project at all
|
|
2203
2796
|
// — the first screen must say so, not draw a blank graph.
|
|
2204
2797
|
"no-project": "This isn't a chant project",
|
|
2798
|
+
// #252: carve mode is served a `chant carve advise --json` report, not a
|
|
2799
|
+
// project — so a bad file is a bad REPORT, and the card must not blame chant
|
|
2800
|
+
// for failing to evaluate a project that was never involved.
|
|
2801
|
+
"carve-report": "This isn't a carve report",
|
|
2205
2802
|
};
|
|
2206
2803
|
|
|
2207
2804
|
// A precondition failure — the lint gate, a not-installed/no-typegen project,
|
|
@@ -2232,6 +2829,18 @@ function renderPreconditionError(body) {
|
|
|
2232
2829
|
remedy.textContent = body.remedy;
|
|
2233
2830
|
card.appendChild(remedy);
|
|
2234
2831
|
}
|
|
2832
|
+
// #259: the rest of the /api error payload. The card above is the human
|
|
2833
|
+
// reading — code, error, remedy — but the server sends more than those three
|
|
2834
|
+
// (chant's argv, exit code, the endpoint it queried), and until now that
|
|
2835
|
+
// detail was simply dropped on the floor. Collapsed by default so the calm
|
|
2836
|
+
// card stays calm, copyable in one gesture for a bug report.
|
|
2837
|
+
const raw = document.createElement("div");
|
|
2838
|
+
raw.className = "precondition-error-raw";
|
|
2839
|
+
const label = document.createElement("div");
|
|
2840
|
+
label.className = "precondition-error-rawlabel";
|
|
2841
|
+
label.textContent = "response payload";
|
|
2842
|
+
raw.append(label, renderJson(body, { openDepth: -1 }));
|
|
2843
|
+
card.appendChild(raw);
|
|
2235
2844
|
host.appendChild(card);
|
|
2236
2845
|
}
|
|
2237
2846
|
|
|
@@ -2314,7 +2923,15 @@ async function load(opts = {}) {
|
|
|
2314
2923
|
} catch (err) {
|
|
2315
2924
|
// A background settle poll must not blow away a good graph on a transient error.
|
|
2316
2925
|
if (!opts.quiet) {
|
|
2317
|
-
|
|
2926
|
+
// Text, never innerHTML — `err.message` embeds chant's own stderr, which
|
|
2927
|
+
// is not ours to interpolate as markup (the sibling precondition card has
|
|
2928
|
+
// said so since #72; this branch had been left behind).
|
|
2929
|
+
const graph = document.getElementById("graph");
|
|
2930
|
+
graph.innerHTML = "";
|
|
2931
|
+
const box = document.createElement("div");
|
|
2932
|
+
box.className = "err";
|
|
2933
|
+
box.textContent = `graph failed: ${err.message}`;
|
|
2934
|
+
graph.appendChild(box);
|
|
2318
2935
|
meta.textContent = "error";
|
|
2319
2936
|
}
|
|
2320
2937
|
} finally {
|
|
@@ -2381,6 +2998,13 @@ async function initPickers() {
|
|
|
2381
2998
|
// with none leaves `view.stack` (and the picker + status tag) null.
|
|
2382
2999
|
stacks = info.stacks || [];
|
|
2383
3000
|
view.stack = stacks[0] || null;
|
|
3001
|
+
// #254: carve mode declares itself here. The Carve tab is mounted at runtime
|
|
3002
|
+
// (panel.js's addPanelTab), so nothing else grows a dead tab.
|
|
3003
|
+
carveInfo = info.carve || null;
|
|
3004
|
+
if (carveMode()) {
|
|
3005
|
+
renderPanelCarve();
|
|
3006
|
+
loadCarveReport();
|
|
3007
|
+
}
|
|
2384
3008
|
axes = { tier: info.tier || null, target: info.target || null };
|
|
2385
3009
|
environments = info.environments || [];
|
|
2386
3010
|
tiers = info.tiers || [];
|
|
@@ -2541,11 +3165,28 @@ function hideLoading() {
|
|
|
2541
3165
|
if (o) o.hidden = true;
|
|
2542
3166
|
}
|
|
2543
3167
|
|
|
3168
|
+
// #259: the op stream is chant's own stdout, and an Op that reports as JSON
|
|
3169
|
+
// (a plan summary, a run report, an unrecognized `--progress-json` line that
|
|
3170
|
+
// src/op-runner.ts didn't filter out) used to land here as one unbroken line.
|
|
3171
|
+
// Parse it; if it IS a JSON object or array, show the collapsible tree instead.
|
|
3172
|
+
// Anything else — every ordinary human log line — is untouched text.
|
|
3173
|
+
function asJsonPayload(line) {
|
|
3174
|
+
const s = String(line).trim();
|
|
3175
|
+
if (!(s.startsWith("{") || s.startsWith("["))) return null;
|
|
3176
|
+
try {
|
|
3177
|
+
const v = JSON.parse(s);
|
|
3178
|
+
return isContainer(v) ? v : null;
|
|
3179
|
+
} catch {
|
|
3180
|
+
return null;
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
2544
3184
|
function nowline(line) {
|
|
2545
3185
|
const p = document.getElementById("nowline");
|
|
2546
3186
|
p.style.display = "block";
|
|
2547
|
-
const
|
|
2548
|
-
d
|
|
3187
|
+
const payload = asJsonPayload(line);
|
|
3188
|
+
const d = payload ? renderJson(payload) : document.createElement("div");
|
|
3189
|
+
if (!payload) d.textContent = line;
|
|
2549
3190
|
p.appendChild(d);
|
|
2550
3191
|
p.scrollTop = p.scrollHeight;
|
|
2551
3192
|
}
|
|
@@ -2910,6 +3551,22 @@ function paletteCommands() {
|
|
|
2910
3551
|
for (const b of document.querySelectorAll("#panel-tabs button[data-tab]")) {
|
|
2911
3552
|
c.push([`Panel: ${b.textContent}`, () => setPanelTab(b.dataset.tab)]);
|
|
2912
3553
|
}
|
|
3554
|
+
// #254: the walkthrough's steps get palette twins like every other control.
|
|
3555
|
+
// Blocked steps are listed with their reason rather than hidden — "why can't
|
|
3556
|
+
// I do that yet" is the question the palette should be able to answer.
|
|
3557
|
+
if (carveMode()) {
|
|
3558
|
+
CARVE_STEPS.forEach((s, i) => {
|
|
3559
|
+
const why = blockedReason(carveState, s.id);
|
|
3560
|
+
c.push([
|
|
3561
|
+
`Carve: ${i + 1}. ${s.label}${why ? ` — ${why}` : ""}`,
|
|
3562
|
+
() => {
|
|
3563
|
+
if (why) return showToast(why, false);
|
|
3564
|
+
setPanelTab("carve");
|
|
3565
|
+
carveActions.go(i);
|
|
3566
|
+
},
|
|
3567
|
+
]);
|
|
3568
|
+
});
|
|
3569
|
+
}
|
|
2913
3570
|
|
|
2914
3571
|
// Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
|
|
2915
3572
|
for (const [label, v] of ZOOM_OPTS) {
|