@intentius/behold 0.7.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 +94 -2
- package/README.md +133 -2
- package/demos.json +25 -1
- package/dist/cli.js +3176 -330
- package/example-argo-estate/README.md +71 -0
- package/example-argo-estate/app-a/chant.config.ts +18 -0
- package/example-argo-estate/app-a/manifests/app.yaml +54 -0
- package/example-argo-estate/app-a/package.json +13 -0
- package/example-argo-estate/app-a/src/app.ts +35 -0
- package/example-argo-estate/app-a/tsconfig.json +1 -0
- package/example-argo-estate/app-b/chant.config.ts +18 -0
- package/example-argo-estate/app-b/manifests/app.yaml +54 -0
- package/example-argo-estate/app-b/package.json +13 -0
- package/example-argo-estate/app-b/src/app.ts +34 -0
- package/example-argo-estate/app-b/tsconfig.json +1 -0
- package/example-argo-estate/control-plane/chant.config.ts +29 -0
- package/example-argo-estate/control-plane/package.json +13 -0
- package/example-argo-estate/control-plane/src/argo.ts +69 -0
- package/example-argo-estate/control-plane/tsconfig.json +1 -0
- package/example-argo-estate/package-lock.json +1949 -0
- package/example-argo-estate/package.json +10 -0
- 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 +22 -12
- 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 +10 -2
- package/example-flux-estate/package-lock.json +17 -17
- package/example-k8s/README.md +16 -2
- package/example-k8s/base/deployment.yaml +21 -0
- package/example-k8s/base/kustomization.yaml +3 -0
- package/example-k8s/base/service.yaml +12 -0
- package/example-k8s/chant.config.ts +10 -0
- package/example-k8s/overlays/dev/kustomization.yaml +6 -0
- 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 +9 -6
- package/web/app.js +1027 -42
- 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/icons/cncf/argo.svg +1 -0
- package/web/icons/cncf/flux.svg +1 -0
- package/web/icons/cncf/helm.svg +1 -0
- package/web/icons/k8s/c-role.svg +1 -0
- package/web/icons/k8s/cm.svg +1 -0
- package/web/icons/k8s/crb.svg +1 -0
- package/web/icons/k8s/crd.svg +1 -0
- package/web/icons/k8s/cronjob.svg +1 -0
- package/web/icons/k8s/deploy.svg +1 -0
- package/web/icons/k8s/ds.svg +1 -0
- package/web/icons/k8s/ep.svg +1 -0
- package/web/icons/k8s/group.svg +1 -0
- package/web/icons/k8s/hpa.svg +1 -0
- package/web/icons/k8s/ing.svg +1 -0
- package/web/icons/k8s/job.svg +1 -0
- package/web/icons/k8s/limits.svg +1 -0
- package/web/icons/k8s/netpol.svg +1 -0
- package/web/icons/k8s/ns.svg +1 -0
- package/web/icons/k8s/pod.svg +1 -0
- package/web/icons/k8s/psp.svg +1 -0
- package/web/icons/k8s/pv.svg +1 -0
- package/web/icons/k8s/pvc.svg +1 -0
- package/web/icons/k8s/quota.svg +1 -0
- package/web/icons/k8s/rb.svg +1 -0
- package/web/icons/k8s/role.svg +1 -0
- package/web/icons/k8s/rs.svg +1 -0
- package/web/icons/k8s/sa.svg +1 -0
- package/web/icons/k8s/sc.svg +1 -0
- package/web/icons/k8s/secret.svg +1 -0
- package/web/icons/k8s/sts.svg +1 -0
- package/web/icons/k8s/svc.svg +1 -0
- package/web/icons/k8s/user.svg +1 -0
- package/web/icons/k8s/vol.svg +1 -0
- package/web/index.html +285 -85
- package/web/json-view.js +334 -0
- package/web/json-view.test.js +218 -0
- package/web/layout-store.js +278 -0
- package/web/layout-store.test.js +388 -0
- package/web/panel.js +28 -0
- package/web/theme.js +114 -6
package/web/app.js
CHANGED
|
@@ -9,7 +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";
|
|
17
|
+
// #228: the hand-layout delta store — everything about WHAT gets remembered and
|
|
18
|
+
// under which key. The pointer work and the SVG surgery stay here (see the
|
|
19
|
+
// "Hand layout" section below).
|
|
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";
|
|
13
46
|
initTheme();
|
|
14
47
|
initPanel();
|
|
15
48
|
mountThemePicker(document.getElementById("panel-theme"));
|
|
@@ -63,6 +96,39 @@ function recolorNodesByCategory(ir) {
|
|
|
63
96
|
}
|
|
64
97
|
onThemeChange(() => recolorNodesByCategory());
|
|
65
98
|
|
|
99
|
+
// #229, the one motion signature behold spends on data: a node whose drift
|
|
100
|
+
// `_status` CHANGED between two renders pulses once in the colour it just
|
|
101
|
+
// became. Node ids are stable across renders, so the diff is a single map
|
|
102
|
+
// compare — no extra fetch, no per-node bookkeeping. The first render seeds the
|
|
103
|
+
// map and pulses nothing (everything would "change"), and a lens switch that
|
|
104
|
+
// swaps the id set pulses nothing either, since only ids present in BOTH
|
|
105
|
+
// renders can have changed. The CSS keyframes + the reduced-motion guard live
|
|
106
|
+
// in index.html.
|
|
107
|
+
let lastStatusById = new Map();
|
|
108
|
+
function markStatusChanges(ir, statusVar) {
|
|
109
|
+
const prev = lastStatusById;
|
|
110
|
+
const next = new Map();
|
|
111
|
+
for (const n of ir.nodes) {
|
|
112
|
+
const s = n.attrs && n.attrs._status;
|
|
113
|
+
if (s) next.set(n.id, s);
|
|
114
|
+
}
|
|
115
|
+
lastStatusById = next;
|
|
116
|
+
if (!prev.size) return;
|
|
117
|
+
const svg = document.querySelector("#graph svg");
|
|
118
|
+
if (!svg) return;
|
|
119
|
+
for (const g of svg.querySelectorAll("[data-node-id]")) {
|
|
120
|
+
const id = g.getAttribute("data-node-id");
|
|
121
|
+
const now = next.get(id);
|
|
122
|
+
if (!now || prev.get(id) === undefined || prev.get(id) === now) continue;
|
|
123
|
+
// The pulse wears the NEW status's hue, read through whichever vocabulary
|
|
124
|
+
// this graph is painted in — a node going foreign glows yellow in the drift
|
|
125
|
+
// overlay; a component that rolled back glows red in the component view,
|
|
126
|
+
// where the same `warn` means something else entirely.
|
|
127
|
+
g.style.setProperty("--pulse", statusVar[now] || "var(--pending)");
|
|
128
|
+
g.classList.add("status-changed");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
66
132
|
// Static-export mode (`behold export`): the SPA runs off a pre-captured bundle
|
|
67
133
|
// with no backend. Detect the flag the export injects, load its manifest, and
|
|
68
134
|
// replay every read from `snapshots/` — the graph, zoom dial, radial, inspect,
|
|
@@ -126,12 +192,68 @@ const ARTIFACT_STATUS_LABEL = { good: "installed", warn: "installed, not healthy
|
|
|
126
192
|
// A declared attribute value may be a cross-resource reference ({$ref:"x.y"}) —
|
|
127
193
|
// the "static infra refs" — rather than a concrete value. Render those readably;
|
|
128
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.
|
|
129
198
|
function fmtValue(v) {
|
|
130
|
-
if (v && typeof v === "object")
|
|
131
|
-
|
|
132
|
-
|
|
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;
|
|
133
236
|
}
|
|
134
|
-
|
|
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;
|
|
135
257
|
}
|
|
136
258
|
|
|
137
259
|
function inspect(node) {
|
|
@@ -141,7 +263,6 @@ function inspect(node) {
|
|
|
141
263
|
const section = (title) => {
|
|
142
264
|
const h = document.createElement("h3");
|
|
143
265
|
h.textContent = title;
|
|
144
|
-
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
145
266
|
panel.appendChild(h);
|
|
146
267
|
const dl = document.createElement("dl");
|
|
147
268
|
panel.appendChild(dl);
|
|
@@ -149,7 +270,7 @@ function inspect(node) {
|
|
|
149
270
|
const dt = document.createElement("dt");
|
|
150
271
|
dt.textContent = k;
|
|
151
272
|
const dd = document.createElement("dd");
|
|
152
|
-
dd
|
|
273
|
+
setCell(dd, v);
|
|
153
274
|
dl.append(dt, dd);
|
|
154
275
|
};
|
|
155
276
|
};
|
|
@@ -202,7 +323,6 @@ function inspect(node) {
|
|
|
202
323
|
if (isComposite) {
|
|
203
324
|
const h = document.createElement("h3");
|
|
204
325
|
h.textContent = `members · ${node.attrs.members}`;
|
|
205
|
-
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
206
326
|
panel.appendChild(h);
|
|
207
327
|
const loading = document.createElement("p");
|
|
208
328
|
loading.style.color = "var(--muted)";
|
|
@@ -354,7 +474,7 @@ function inspect(node) {
|
|
|
354
474
|
panel.appendChild(p);
|
|
355
475
|
return;
|
|
356
476
|
}
|
|
357
|
-
renderObserved(panel, j.observed, j.health); // #30 observed state + #26 health
|
|
477
|
+
renderObserved(panel, j.observed, j.health, j.healthDetail); // #30 observed state + #26/#226 health
|
|
358
478
|
renderDiff(panel, j.diff); // #27 — drift since snapshot
|
|
359
479
|
renderFieldDrift(panel, j.fieldDrift); // #87 — field-level (per-manager) drift
|
|
360
480
|
});
|
|
@@ -388,24 +508,30 @@ const HEALTH_COLOR = {
|
|
|
388
508
|
};
|
|
389
509
|
|
|
390
510
|
// Render a node's observed live state (#30) + health verdict (#26).
|
|
391
|
-
function renderObserved(panel, o, health) {
|
|
511
|
+
function renderObserved(panel, o, health, healthDetail) {
|
|
392
512
|
if (!o) return; // pending/foreign nodes have no observed record in the diff
|
|
393
513
|
const h = document.createElement("h3");
|
|
394
514
|
h.textContent = "observed";
|
|
395
|
-
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
396
515
|
panel.appendChild(h);
|
|
397
516
|
const dl = document.createElement("dl");
|
|
398
517
|
const add = (k, v, color) => {
|
|
399
518
|
const dt = document.createElement("dt");
|
|
400
519
|
dt.textContent = k;
|
|
401
520
|
const dd = document.createElement("dd");
|
|
402
|
-
dd
|
|
521
|
+
setCell(dd, v);
|
|
403
522
|
if (color) dd.style.color = color;
|
|
404
523
|
dl.append(dt, dd);
|
|
405
524
|
};
|
|
406
525
|
// Health first — the "is it well?" verdict, distinct from drift. Absent when
|
|
407
526
|
// the substrate reports no status (not fabricated).
|
|
408
|
-
|
|
527
|
+
// #226: for an Argo Application or a Flux object the verdict comes from the
|
|
528
|
+
// controller's own conditions, and `healthDetail` is the sentence it read
|
|
529
|
+
// there — `Ready=False (BuildFailed)`, `health=Degraded, sync=OutOfSync`.
|
|
530
|
+
// Appended to the verdict rather than given its own row, because it is the
|
|
531
|
+
// same claim said precisely.
|
|
532
|
+
if (health && health !== "unknown") {
|
|
533
|
+
add("health", healthDetail ? `${health} — ${healthDetail}` : health, HEALTH_COLOR[health]);
|
|
534
|
+
}
|
|
409
535
|
if (o.type) add("type", o.type);
|
|
410
536
|
if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
|
|
411
537
|
if (o.physicalId) add("physical id", o.physicalId);
|
|
@@ -420,11 +546,16 @@ function renderObserved(panel, o, health) {
|
|
|
420
546
|
// none.
|
|
421
547
|
const conditions = o.attributes?.conditions;
|
|
422
548
|
if (Array.isArray(conditions)) {
|
|
423
|
-
|
|
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)");
|
|
424
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.
|
|
425
556
|
for (const [k, v] of Object.entries(o.attributes || {})) {
|
|
426
557
|
if (k === "conditions") continue; // rendered above, one line each
|
|
427
|
-
add(k,
|
|
558
|
+
add(k, valueCell(v));
|
|
428
559
|
}
|
|
429
560
|
panel.appendChild(dl);
|
|
430
561
|
}
|
|
@@ -448,7 +579,6 @@ const DIFF_LABEL = {
|
|
|
448
579
|
function renderDiff(panel, diff) {
|
|
449
580
|
const h = document.createElement("h3");
|
|
450
581
|
h.textContent = "drift";
|
|
451
|
-
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
452
582
|
panel.appendChild(h);
|
|
453
583
|
if (!diff) {
|
|
454
584
|
const p = document.createElement("p");
|
|
@@ -466,7 +596,7 @@ function renderDiff(panel, diff) {
|
|
|
466
596
|
// this address makes that visible). Monospace so a request path scans.
|
|
467
597
|
if (diff.queried) {
|
|
468
598
|
const q = document.createElement("p");
|
|
469
|
-
q.
|
|
599
|
+
q.className = "queried"; // mono + muted, styled with the rest of the pane's scale
|
|
470
600
|
q.textContent = `queried: ${diff.queried} → ${diff.category === "missing" ? "not found" : "read failed"}`;
|
|
471
601
|
panel.appendChild(q);
|
|
472
602
|
}
|
|
@@ -509,7 +639,9 @@ function renderDiff(panel, diff) {
|
|
|
509
639
|
const dt = document.createElement("dt");
|
|
510
640
|
dt.textContent = ch.path;
|
|
511
641
|
const dd = document.createElement("dd");
|
|
512
|
-
|
|
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 }));
|
|
513
645
|
dl.append(dt, dd);
|
|
514
646
|
}
|
|
515
647
|
panel.appendChild(dl);
|
|
@@ -539,7 +671,6 @@ function renderFieldDrift(panel, fieldDrift) {
|
|
|
539
671
|
if (!fieldDrift.drifted.length && !fieldDrift.accepted.length) return; // deep ran, nothing to report for this node
|
|
540
672
|
const h = document.createElement("h3");
|
|
541
673
|
h.textContent = "field ownership";
|
|
542
|
-
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
543
674
|
panel.appendChild(h);
|
|
544
675
|
const dl = document.createElement("dl");
|
|
545
676
|
for (const ch of fieldDrift.drifted) {
|
|
@@ -553,7 +684,7 @@ function renderFieldDrift(panel, fieldDrift) {
|
|
|
553
684
|
// holding it is somebody editing around the pipeline. Both are `changed`.
|
|
554
685
|
// Absent on every substrate but k8s, where the line reads as it always did.
|
|
555
686
|
const owned = ch.owner ? `owned by ${ch.owner} — ` : "";
|
|
556
|
-
dd.
|
|
687
|
+
setCell(dd, pairCell("declared", ch.declared, "live", ch.live, { lead: `${owned}${FIELD_KIND_LABEL[ch.kind] || ch.kind}` }));
|
|
557
688
|
dl.append(dt, dd);
|
|
558
689
|
}
|
|
559
690
|
for (const ch of fieldDrift.accepted) {
|
|
@@ -561,7 +692,7 @@ function renderFieldDrift(panel, fieldDrift) {
|
|
|
561
692
|
dt.textContent = ch.path;
|
|
562
693
|
dt.style.color = "var(--muted)";
|
|
563
694
|
const dd = document.createElement("dd");
|
|
564
|
-
dd
|
|
695
|
+
setCell(dd, pairCell("baseline", ch.baseline, "live", ch.live, { lead: "accepted deviation" }));
|
|
565
696
|
dl.append(dt, dd);
|
|
566
697
|
}
|
|
567
698
|
panel.appendChild(dl);
|
|
@@ -579,6 +710,10 @@ function wire(ir) {
|
|
|
579
710
|
host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
|
|
580
711
|
g.classList.add("sel");
|
|
581
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);
|
|
582
717
|
});
|
|
583
718
|
}
|
|
584
719
|
}
|
|
@@ -734,7 +869,7 @@ function renderPanelView() {
|
|
|
734
869
|
lanes.href = "/lanes";
|
|
735
870
|
lanes.textContent = "lanes →";
|
|
736
871
|
lanes.title = "The time-lanes view — captured frames of this graph over time";
|
|
737
|
-
lanes.style.cssText = "color:var(--pending);text-decoration:none;font-size:
|
|
872
|
+
lanes.style.cssText = "color:var(--pending);text-decoration:none;font-size:var(--t-body)";
|
|
738
873
|
row.appendChild(lanes);
|
|
739
874
|
tools.appendChild(row);
|
|
740
875
|
}
|
|
@@ -768,26 +903,54 @@ async function revealProject(dir) {
|
|
|
768
903
|
// client-side list and cache is project-scoped, so a clean boot is the honest
|
|
769
904
|
// way to re-seed all of it.
|
|
770
905
|
async function switchProject(dir) {
|
|
771
|
-
|
|
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);
|
|
772
921
|
try {
|
|
773
|
-
const r = await fetch(
|
|
922
|
+
const r = await fetch(url, {
|
|
774
923
|
method: "POST",
|
|
775
924
|
headers: { "content-type": "application/json" },
|
|
776
|
-
body: JSON.stringify(
|
|
925
|
+
body: JSON.stringify(body),
|
|
777
926
|
});
|
|
778
927
|
const j = await r.json();
|
|
779
928
|
if (!r.ok || j.error) {
|
|
780
929
|
hideLoading();
|
|
781
|
-
showToast(
|
|
930
|
+
showToast(`✗ ${what}: ` + (j.error || r.statusText), false);
|
|
782
931
|
return;
|
|
783
932
|
}
|
|
784
933
|
location.reload();
|
|
785
934
|
} catch (e) {
|
|
786
935
|
hideLoading();
|
|
787
|
-
showToast(
|
|
936
|
+
showToast(`✗ ${what}: ` + e.message, false);
|
|
788
937
|
}
|
|
789
938
|
}
|
|
790
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
|
+
|
|
791
954
|
function renderPanelScope() {
|
|
792
955
|
const host = document.getElementById("tab-scope");
|
|
793
956
|
if (!host) return;
|
|
@@ -837,6 +1000,19 @@ function renderPanelScope() {
|
|
|
837
1000
|
});
|
|
838
1001
|
row.append(input, go);
|
|
839
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
|
+
}
|
|
840
1016
|
}
|
|
841
1017
|
host.appendChild(panelHeading("environment"));
|
|
842
1018
|
host.appendChild(
|
|
@@ -940,6 +1116,7 @@ function selectNode(id) {
|
|
|
940
1116
|
const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
|
|
941
1117
|
if (g) g.classList.add("sel");
|
|
942
1118
|
inspect(node);
|
|
1119
|
+
carvePick(node);
|
|
943
1120
|
}
|
|
944
1121
|
|
|
945
1122
|
function renderPanelModel() {
|
|
@@ -1005,10 +1182,199 @@ function renderPanelModel() {
|
|
|
1005
1182
|
}
|
|
1006
1183
|
}
|
|
1007
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
|
+
|
|
1008
1373
|
function renderPanel() {
|
|
1009
1374
|
renderPanelView();
|
|
1010
1375
|
renderPanelScope();
|
|
1011
1376
|
renderPanelModel();
|
|
1377
|
+
if (carveMode()) renderPanelCarve();
|
|
1012
1378
|
}
|
|
1013
1379
|
|
|
1014
1380
|
function renderStatusbar() {
|
|
@@ -1210,7 +1576,7 @@ function renderDial() {
|
|
|
1210
1576
|
}
|
|
1211
1577
|
host.style.display = "flex";
|
|
1212
1578
|
const hint = document.createElement("span");
|
|
1213
|
-
hint.style.cssText = "font-size:
|
|
1579
|
+
hint.style.cssText = "font-size:var(--t-caption);color:var(--muted);align-self:center";
|
|
1214
1580
|
hint.textContent = "observe → reconcile → apply needs an environment — pick one in ⌘K (env: …)";
|
|
1215
1581
|
host.appendChild(hint);
|
|
1216
1582
|
// A pipeline run (#163) is env-less — its progress still belongs here.
|
|
@@ -1283,7 +1649,7 @@ function renderApplyPicker() {
|
|
|
1283
1649
|
wrap.style.gap = "6px";
|
|
1284
1650
|
const sel = document.createElement("select");
|
|
1285
1651
|
sel.style.cssText =
|
|
1286
|
-
"background:var(--
|
|
1652
|
+
"background:var(--well);color:var(--fg);border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 8px;font-size:var(--t-body)";
|
|
1287
1653
|
sel.add(new Option("all components", "all"));
|
|
1288
1654
|
for (const name of componentChoices) sel.add(new Option(applyOptionLabel(name), name));
|
|
1289
1655
|
const go = button("Apply →", "", () => {
|
|
@@ -1405,7 +1771,7 @@ function renderApplyProgress(state) {
|
|
|
1405
1771
|
const wrap = document.createElement("div");
|
|
1406
1772
|
wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;width:100%;margin-top:4px";
|
|
1407
1773
|
const summary = document.createElement("div");
|
|
1408
|
-
summary.style.cssText = `font-size:
|
|
1774
|
+
summary.style.cssText = `font-size:var(--t-caption);color:${APPLY_STATUS_COLOR[state.status] || "var(--muted)"}`;
|
|
1409
1775
|
// A pipeline run (#163) reuses this whole panel — same shape, different
|
|
1410
1776
|
// executor — and says so instead of claiming to be an apply.
|
|
1411
1777
|
summary.textContent = `${state.kind || "apply"}: ${state.status}`;
|
|
@@ -1414,14 +1780,14 @@ function renderApplyProgress(state) {
|
|
|
1414
1780
|
const row = document.createElement("div");
|
|
1415
1781
|
row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap";
|
|
1416
1782
|
const label = document.createElement("span");
|
|
1417
|
-
label.style.cssText = `font-size:
|
|
1783
|
+
label.style.cssText = `font-size:var(--t-caption);color:${APPLY_STATUS_COLOR[w.status] || "var(--muted)"};min-width:52px`;
|
|
1418
1784
|
label.textContent = `wave ${w.wave}`;
|
|
1419
1785
|
row.appendChild(label);
|
|
1420
1786
|
for (const cname of w.components) {
|
|
1421
1787
|
const c = (state.components || []).find((x) => x.component === cname) || { status: "pending" };
|
|
1422
1788
|
const color = APPLY_STATUS_COLOR[c.status] || "var(--muted)";
|
|
1423
1789
|
const chip = document.createElement("span");
|
|
1424
|
-
chip.style.cssText = `border:1px solid ${color};color:${color};border-radius:
|
|
1790
|
+
chip.style.cssText = `border:1px solid ${color};color:${color};border-radius:var(--r-ctl);padding:2px 8px;font-size:var(--t-caption)`;
|
|
1425
1791
|
const detail = [c.phase, c.step].filter(Boolean).join(" · ");
|
|
1426
1792
|
chip.textContent = `${cname}${detail ? " · " + detail : ""} (${c.status})`;
|
|
1427
1793
|
if (c.error) chip.title = c.error;
|
|
@@ -1657,6 +2023,7 @@ function render(ir, svg, m) {
|
|
|
1657
2023
|
// green — the SVG's own :root shadows behold's override within the graph subtree.
|
|
1658
2024
|
g.innerHTML = svg.replace(/:root\s*\{[^{}]*--pin-[^{}]*\}/g, "");
|
|
1659
2025
|
recolorNodesByCategory(ir); // #62: category-hued fills from the theme's full palette
|
|
2026
|
+
markStatusChanges(ir, componentStatus ? COMPONENT_STATUS_VAR : DRIFT_STATUS_VAR); // #229
|
|
1660
2027
|
const svgEl = g.querySelector("svg");
|
|
1661
2028
|
if (svgEl) {
|
|
1662
2029
|
// Drop pinhole's fixed pixel size so the viewBox drives sizing; behold then
|
|
@@ -1676,6 +2043,8 @@ function render(ir, svg, m) {
|
|
|
1676
2043
|
ensureBackToInfra(g);
|
|
1677
2044
|
wire(ir);
|
|
1678
2045
|
if (view.radial && !view.components && !view.logical) addRadialLabels(ir);
|
|
2046
|
+
markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker
|
|
2047
|
+
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
1679
2048
|
renderDial();
|
|
1680
2049
|
}
|
|
1681
2050
|
|
|
@@ -1833,6 +2202,11 @@ function ensureZoomControls(host) {
|
|
|
1833
2202
|
let py = 0;
|
|
1834
2203
|
host.addEventListener("mousedown", (e) => {
|
|
1835
2204
|
if (!vb) return;
|
|
2205
|
+
// #228: pan is the default on empty ground, but a node card (or a box's
|
|
2206
|
+
// title / resize handle) belongs to the layout drag — starting a pan too
|
|
2207
|
+
// would move the graph out from under the thing you grabbed. The layout
|
|
2208
|
+
// drag owns the panMoved latch for the rest of the gesture.
|
|
2209
|
+
if (layoutTargetOf(e)) return;
|
|
1836
2210
|
drag = true;
|
|
1837
2211
|
panMoved = false;
|
|
1838
2212
|
px = e.clientX;
|
|
@@ -1862,6 +2236,553 @@ function ensureZoomControls(host) {
|
|
|
1862
2236
|
});
|
|
1863
2237
|
}
|
|
1864
2238
|
|
|
2239
|
+
// --- Hand layout (#228): drag a node, resize a containment box -------------
|
|
2240
|
+
// dagre is a good first draft and a bad final layout. A pointer-drag on a card
|
|
2241
|
+
// writes a {dx,dy} for that `data-node-id`; a corner handle on a containment
|
|
2242
|
+
// box writes {dw,dh} (and its title drags the box). The deltas live in
|
|
2243
|
+
// localStorage per project + lens (web/layout-store.js) and are re-applied at
|
|
2244
|
+
// the end of every render(), so the graph underneath stays chant's and a
|
|
2245
|
+
// delta for a node that left the estate is simply not applied.
|
|
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
|
+
//
|
|
2255
|
+
// What this does NOT do, said plainly rather than faked:
|
|
2256
|
+
// * a resized box does not reflow its children — that is dagre's job on the
|
|
2257
|
+
// next layout, and the reset control's tooltip says so;
|
|
2258
|
+
// * an edge touching a displaced node is redrawn as a STRAIGHT line between
|
|
2259
|
+
// its original anchor points, each shifted by its own node's delta. #228
|
|
2260
|
+
// accepts the straight-line fallback; spline re-routing is pinhole's job.
|
|
2261
|
+
// An edge with both ends where dagre put them keeps its bezier untouched.
|
|
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).
|
|
2272
|
+
let layoutIndex = null; // {nodes,boxes,edges} for the SVG currently on screen
|
|
2273
|
+
let layoutDeltas = {}; // the deltas in force for the current key
|
|
2274
|
+
let layoutDrag = null; // the gesture in flight
|
|
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 };
|
|
2279
|
+
|
|
2280
|
+
/** The storage key for the project + lens on screen; null before /api/project lands. */
|
|
2281
|
+
function currentLayoutKey() {
|
|
2282
|
+
if (!projectInfo) return null;
|
|
2283
|
+
return layoutKey(projectKeyOf(projectInfo), currentLensKey());
|
|
2284
|
+
}
|
|
2285
|
+
|
|
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 });
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
/**
|
|
2293
|
+
* Wrap each containment box in a `<g data-layout-box>` and give it a corner
|
|
2294
|
+
* handle, once per rendered SVG.
|
|
2295
|
+
*
|
|
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`.
|
|
2324
|
+
*/
|
|
2325
|
+
function wrapContainmentBoxes(svgEl) {
|
|
2326
|
+
const boxes = new Map();
|
|
2327
|
+
const handleSize = Math.max(12, Math.round((vbInit ? vbInit[2] : 1000) / 90));
|
|
2328
|
+
const found = [];
|
|
2329
|
+
const claimed = new Set();
|
|
2330
|
+
for (const rect of [...svgEl.children]) {
|
|
2331
|
+
if (rect.tagName.toLowerCase() !== "rect") continue;
|
|
2332
|
+
const groupId = (rect.getAttribute("data-group-id") || "").trim();
|
|
2333
|
+
const x = parseFloat(rect.getAttribute("x"));
|
|
2334
|
+
const y = parseFloat(rect.getAttribute("y"));
|
|
2335
|
+
const w = parseFloat(rect.getAttribute("width"));
|
|
2336
|
+
const h = parseFloat(rect.getAttribute("height"));
|
|
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;
|
|
2342
|
+
// Collect rect → title, stepping over anything already stamped between
|
|
2343
|
+
// them (the GitLab wave badge), and bail at the next box or card.
|
|
2344
|
+
const members = [rect];
|
|
2345
|
+
let title = null;
|
|
2346
|
+
for (let el = rect.nextElementSibling; el; el = el.nextElementSibling) {
|
|
2347
|
+
const tag = el.tagName.toLowerCase();
|
|
2348
|
+
if (tag === "rect" || (tag === "g" && (el.hasAttribute("data-node-id") || el.hasAttribute("data-edge-from")))) break;
|
|
2349
|
+
members.push(el);
|
|
2350
|
+
if (tag === "text") {
|
|
2351
|
+
title = el;
|
|
2352
|
+
break;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
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) {
|
|
2367
|
+
const g = document.createElementNS(SVGNS, "g");
|
|
2368
|
+
g.setAttribute("data-layout-box", id);
|
|
2369
|
+
rect.parentNode.insertBefore(g, rect);
|
|
2370
|
+
members.forEach((m) => g.appendChild(m));
|
|
2371
|
+
// The title doubles as the box's move handle — the interior stays pan
|
|
2372
|
+
// territory, which on a logical view is most of the canvas.
|
|
2373
|
+
if (title) {
|
|
2374
|
+
title.setAttribute("data-layout-move", id);
|
|
2375
|
+
title.setAttribute("cursor", "move");
|
|
2376
|
+
}
|
|
2377
|
+
const handle = document.createElementNS(SVGNS, "g");
|
|
2378
|
+
handle.setAttribute("data-layout-resize", id);
|
|
2379
|
+
handle.setAttribute("cursor", "nwse-resize");
|
|
2380
|
+
handle.setAttribute("opacity", "0"); // CSS reveals it on hover; a static export never shows it
|
|
2381
|
+
const grip = document.createElementNS(SVGNS, "rect");
|
|
2382
|
+
grip.setAttribute("width", String(handleSize));
|
|
2383
|
+
grip.setAttribute("height", String(handleSize));
|
|
2384
|
+
grip.setAttribute("rx", "3");
|
|
2385
|
+
grip.setAttribute("fill", "var(--focus)");
|
|
2386
|
+
const tip = document.createElementNS(SVGNS, "title");
|
|
2387
|
+
tip.textContent = "Drag to resize this box. Children don't move with it — that's the next layout's job.";
|
|
2388
|
+
handle.append(tip, grip);
|
|
2389
|
+
g.appendChild(handle);
|
|
2390
|
+
boxes.set(id, { g, rect, handle, x, y, w0: w, h0: h, size: handleSize });
|
|
2391
|
+
}
|
|
2392
|
+
return boxes;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
/** Park the corner handle at the box's current bottom-right. */
|
|
2396
|
+
function positionBoxHandle(b, w, h) {
|
|
2397
|
+
b.handle.setAttribute("transform", `translate(${b.x + w - b.size - 3}, ${b.y + h - b.size - 3})`);
|
|
2398
|
+
}
|
|
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
|
+
|
|
2476
|
+
/** Index the freshly rendered SVG: node groups, containment boxes, edge paths. */
|
|
2477
|
+
function indexLayout(svgEl) {
|
|
2478
|
+
const toUser = userSpaceMapper(svgEl);
|
|
2479
|
+
const nodes = new Map();
|
|
2480
|
+
for (const el of svgEl.querySelectorAll("[data-node-id]")) {
|
|
2481
|
+
const id = el.getAttribute("data-node-id");
|
|
2482
|
+
if (!nodes.has(id)) nodes.set(id, { el, base: el.getAttribute("transform") || "", rect: userRect(el, toUser) });
|
|
2483
|
+
}
|
|
2484
|
+
const edges = [];
|
|
2485
|
+
for (const g of svgEl.querySelectorAll("g[data-edge-from]")) {
|
|
2486
|
+
const paths = [...g.querySelectorAll("path")];
|
|
2487
|
+
if (!paths.length) continue;
|
|
2488
|
+
const d0 = paths[0].getAttribute("d");
|
|
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
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
layoutIndex = { nodes, boxes: wrapContainmentBoxes(svgEl), edges };
|
|
2501
|
+
}
|
|
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
|
+
|
|
2556
|
+
/** Paint `layoutDeltas` onto the indexed SVG. Idempotent: always from the original. */
|
|
2557
|
+
function renderLayout() {
|
|
2558
|
+
if (!layoutIndex) return;
|
|
2559
|
+
for (const [id, n] of layoutIndex.nodes) {
|
|
2560
|
+
const t = nodeTransform(n.base, layoutDeltas[id] || {});
|
|
2561
|
+
if (t) n.el.setAttribute("transform", t);
|
|
2562
|
+
else n.el.removeAttribute("transform");
|
|
2563
|
+
}
|
|
2564
|
+
for (const [id, b] of layoutIndex.boxes) {
|
|
2565
|
+
const d = layoutDeltas[id] || {};
|
|
2566
|
+
const { w, h } = boxRect(b, d);
|
|
2567
|
+
b.rect.setAttribute("width", String(w));
|
|
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).
|
|
2572
|
+
if (d.dx || d.dy) b.g.setAttribute("transform", `translate(${d.dx || 0}, ${d.dy || 0})`);
|
|
2573
|
+
else b.g.removeAttribute("transform");
|
|
2574
|
+
positionBoxHandle(b, w, h);
|
|
2575
|
+
}
|
|
2576
|
+
for (const e of layoutIndex.edges) {
|
|
2577
|
+
const a = layoutDeltas[e.from];
|
|
2578
|
+
const z = layoutDeltas[e.to];
|
|
2579
|
+
if ((!a && !z) || !e.anchors) {
|
|
2580
|
+
if (e.paths[0].getAttribute("d") !== e.d0) e.paths.forEach((p) => p.setAttribute("d", e.d0));
|
|
2581
|
+
placeEdgeLabel(e, 0, 0);
|
|
2582
|
+
continue;
|
|
2583
|
+
}
|
|
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.
|
|
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);
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
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
|
+
|
|
2607
|
+
/** Re-index the SVG, load this lens's deltas, paint them. Called from render(). */
|
|
2608
|
+
function applyLayout() {
|
|
2609
|
+
const svgEl = currentSvg();
|
|
2610
|
+
const host = document.getElementById("graph");
|
|
2611
|
+
if (!svgEl || !host) return;
|
|
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;
|
|
2626
|
+
const key = currentLayoutKey();
|
|
2627
|
+
const lens = currentLensKey();
|
|
2628
|
+
// Stale ids are dropped on apply, not on write: a lens the user hasn't
|
|
2629
|
+
// opened in a while shouldn't have its deltas quietly deleted because this
|
|
2630
|
+
// render happened to be a different projection of the same estate.
|
|
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);
|
|
2634
|
+
renderLayout();
|
|
2635
|
+
}
|
|
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
|
+
|
|
2663
|
+
/** The grabbable thing at or above `el`, if any. */
|
|
2664
|
+
function layoutTargetIn(el) {
|
|
2665
|
+
if (!el || typeof el.closest !== "function") return null;
|
|
2666
|
+
const resize = el.closest("[data-layout-resize]");
|
|
2667
|
+
if (resize) return { kind: "resize", id: resize.getAttribute("data-layout-resize") };
|
|
2668
|
+
const move = el.closest("[data-layout-move]");
|
|
2669
|
+
if (move) return { kind: "move", id: move.getAttribute("data-layout-move") };
|
|
2670
|
+
const node = el.closest("[data-node-id]");
|
|
2671
|
+
if (node) return { kind: "move", id: node.getAttribute("data-node-id") };
|
|
2672
|
+
return null;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
/** What (if anything) a pointerdown grabbed. Also the pan handler's bail test. */
|
|
2676
|
+
function layoutTargetOf(e) {
|
|
2677
|
+
const direct = layoutTargetIn(e.target);
|
|
2678
|
+
if (direct) return direct;
|
|
2679
|
+
// An edge can swallow the grab: wireEdgeHighlight raises every edge on a
|
|
2680
|
+
// hovered card to the top of the SVG, and pinhole's 14-wide transparent
|
|
2681
|
+
// hit-path is anchored at the card's own centre — so by the time the pointer
|
|
2682
|
+
// is down, the edge you just lit is lying across the card you meant to move.
|
|
2683
|
+
// Look through the stack for the card underneath instead of giving up.
|
|
2684
|
+
if (!e.target || typeof e.target.closest !== "function" || !e.target.closest("g[data-edge-from]")) return null;
|
|
2685
|
+
for (const el of document.elementsFromPoint(e.clientX, e.clientY)) {
|
|
2686
|
+
const t = layoutTargetIn(el);
|
|
2687
|
+
if (t) return t;
|
|
2688
|
+
}
|
|
2689
|
+
return null;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
/** Screen pixels → viewBox units at the current zoom. */
|
|
2693
|
+
function userPerPixel(svgEl) {
|
|
2694
|
+
const m = svgEl.getScreenCTM && svgEl.getScreenCTM();
|
|
2695
|
+
if (m && m.a && m.d) return { x: 1 / m.a, y: 1 / m.d };
|
|
2696
|
+
const r = svgEl.getBoundingClientRect();
|
|
2697
|
+
return vb && r.width && r.height ? { x: vb[2] / r.width, y: vb[3] / r.height } : { x: 1, y: 1 };
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
function ensureLayoutDrag(host) {
|
|
2701
|
+
if (layoutWired) return;
|
|
2702
|
+
layoutWired = true;
|
|
2703
|
+
host.addEventListener("pointerdown", (e) => {
|
|
2704
|
+
if (e.button !== 0 || !layoutIndex) return;
|
|
2705
|
+
const t = layoutTargetOf(e);
|
|
2706
|
+
if (!t || !currentSvg()) return;
|
|
2707
|
+
layoutDrag = { ...t, x0: e.clientX, y0: e.clientY, base: layoutDeltas[t.id] || {}, moved: false };
|
|
2708
|
+
// The same latch pan uses (wire()'s click-inspect bails on it): claim it on
|
|
2709
|
+
// grab so a drag is never also a click, and so the NEXT plain click on a
|
|
2710
|
+
// node isn't swallowed by a stale `true` from the gesture before it.
|
|
2711
|
+
panMoved = false;
|
|
2712
|
+
// Deliberately no preventDefault(): cancelling pointerdown suppresses the
|
|
2713
|
+
// compatibility mouse events, and click-inspect rides on those. The pan is
|
|
2714
|
+
// held off by its own guard instead, and the text selection a drag would
|
|
2715
|
+
// otherwise smear across the graph by `user-select: none` on the SVG.
|
|
2716
|
+
});
|
|
2717
|
+
window.addEventListener("pointermove", (e) => {
|
|
2718
|
+
if (!layoutDrag) return;
|
|
2719
|
+
const svgEl = currentSvg();
|
|
2720
|
+
if (!svgEl) return;
|
|
2721
|
+
const px = e.clientX - layoutDrag.x0;
|
|
2722
|
+
const py = e.clientY - layoutDrag.y0;
|
|
2723
|
+
if (!layoutDrag.moved && Math.abs(px) + Math.abs(py) <= 3) return;
|
|
2724
|
+
layoutDrag.moved = true;
|
|
2725
|
+
panMoved = true;
|
|
2726
|
+
const s = userPerPixel(svgEl);
|
|
2727
|
+
const dx = px * s.x;
|
|
2728
|
+
const dy = py * s.y;
|
|
2729
|
+
const b = layoutDrag.base;
|
|
2730
|
+
const next =
|
|
2731
|
+
layoutDrag.kind === "resize"
|
|
2732
|
+
? { dx: b.dx || 0, dy: b.dy || 0, dw: (b.dw || 0) + dx, dh: (b.dh || 0) + dy }
|
|
2733
|
+
: { dx: (b.dx || 0) + dx, dy: (b.dy || 0) + dy, dw: b.dw || 0, dh: b.dh || 0 };
|
|
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));
|
|
2739
|
+
renderLayout();
|
|
2740
|
+
});
|
|
2741
|
+
window.addEventListener("pointerup", () => {
|
|
2742
|
+
if (!layoutDrag) return;
|
|
2743
|
+
const moved = layoutDrag.moved;
|
|
2744
|
+
layoutDrag = null;
|
|
2745
|
+
if (!moved) return;
|
|
2746
|
+
const key = currentLayoutKey();
|
|
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);
|
|
2752
|
+
ensureLayoutReset(document.getElementById("graph"));
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/** The "↺ layout" control beside ⤢ fit — shown only while something is hand-placed. */
|
|
2757
|
+
function ensureLayoutReset(host) {
|
|
2758
|
+
if (!host) return;
|
|
2759
|
+
let btn = document.getElementById("layout-reset");
|
|
2760
|
+
if (!btn || btn.parentElement !== host) {
|
|
2761
|
+
btn = document.createElement("button");
|
|
2762
|
+
btn.id = "layout-reset";
|
|
2763
|
+
btn.textContent = "↺ layout";
|
|
2764
|
+
btn.title =
|
|
2765
|
+
"Drop the hand-placed layout for this project + this lens and go back to dagre's. " +
|
|
2766
|
+
"(A resized box never reflows what's inside it — only the next layout does that.)";
|
|
2767
|
+
btn.addEventListener("click", (e) => {
|
|
2768
|
+
e.stopPropagation();
|
|
2769
|
+
const key = currentLayoutKey();
|
|
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, {});
|
|
2776
|
+
layoutDeltas = {};
|
|
2777
|
+
renderLayout();
|
|
2778
|
+
ensureLayoutReset(host);
|
|
2779
|
+
nowline("↺ layout reset — back to dagre's placement");
|
|
2780
|
+
});
|
|
2781
|
+
host.appendChild(btn);
|
|
2782
|
+
}
|
|
2783
|
+
btn.style.display = isEmpty(layoutDeltas) ? "none" : "";
|
|
2784
|
+
}
|
|
2785
|
+
|
|
1865
2786
|
// Precondition-failure codes (#72) → a short, human title for the entry/error
|
|
1866
2787
|
// screen below. Mirrors every `code` a read route's structured error can
|
|
1867
2788
|
// carry (src/server.ts RouteErrorCode: chant.ts's lint/not-installed/eval,
|
|
@@ -1874,6 +2795,10 @@ const PRECONDITION_TITLE = {
|
|
|
1874
2795
|
// #193: behold was pointed at a directory that isn't a chant project at all
|
|
1875
2796
|
// — the first screen must say so, not draw a blank graph.
|
|
1876
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",
|
|
1877
2802
|
};
|
|
1878
2803
|
|
|
1879
2804
|
// A precondition failure — the lint gate, a not-installed/no-typegen project,
|
|
@@ -1904,6 +2829,18 @@ function renderPreconditionError(body) {
|
|
|
1904
2829
|
remedy.textContent = body.remedy;
|
|
1905
2830
|
card.appendChild(remedy);
|
|
1906
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);
|
|
1907
2844
|
host.appendChild(card);
|
|
1908
2845
|
}
|
|
1909
2846
|
|
|
@@ -1986,7 +2923,15 @@ async function load(opts = {}) {
|
|
|
1986
2923
|
} catch (err) {
|
|
1987
2924
|
// A background settle poll must not blow away a good graph on a transient error.
|
|
1988
2925
|
if (!opts.quiet) {
|
|
1989
|
-
|
|
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);
|
|
1990
2935
|
meta.textContent = "error";
|
|
1991
2936
|
}
|
|
1992
2937
|
} finally {
|
|
@@ -2053,6 +2998,13 @@ async function initPickers() {
|
|
|
2053
2998
|
// with none leaves `view.stack` (and the picker + status tag) null.
|
|
2054
2999
|
stacks = info.stacks || [];
|
|
2055
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
|
+
}
|
|
2056
3008
|
axes = { tier: info.tier || null, target: info.target || null };
|
|
2057
3009
|
environments = info.environments || [];
|
|
2058
3010
|
tiers = info.tiers || [];
|
|
@@ -2213,11 +3165,28 @@ function hideLoading() {
|
|
|
2213
3165
|
if (o) o.hidden = true;
|
|
2214
3166
|
}
|
|
2215
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
|
+
|
|
2216
3184
|
function nowline(line) {
|
|
2217
3185
|
const p = document.getElementById("nowline");
|
|
2218
3186
|
p.style.display = "block";
|
|
2219
|
-
const
|
|
2220
|
-
d
|
|
3187
|
+
const payload = asJsonPayload(line);
|
|
3188
|
+
const d = payload ? renderJson(payload) : document.createElement("div");
|
|
3189
|
+
if (!payload) d.textContent = line;
|
|
2221
3190
|
p.appendChild(d);
|
|
2222
3191
|
p.scrollTop = p.scrollHeight;
|
|
2223
3192
|
}
|
|
@@ -2238,7 +3207,7 @@ function showToast(msg, ok) {
|
|
|
2238
3207
|
}
|
|
2239
3208
|
const t = document.createElement("div");
|
|
2240
3209
|
const color = ok ? "var(--managed)" : "var(--degraded)";
|
|
2241
|
-
t.style.cssText = `background:var(--panel);color:var(--fg);border:1px solid ${color};border-left:4px solid ${color};border-radius:
|
|
3210
|
+
t.style.cssText = `background:var(--panel);color:var(--fg);border:1px solid ${color};border-left:4px solid ${color};border-radius:var(--r-ctl);padding:8px 12px;font-size:var(--t-body);box-shadow:0 6px 18px color-mix(in srgb, var(--shadow) 50%, transparent);cursor:pointer;white-space:pre-wrap`;
|
|
2242
3211
|
t.textContent = msg;
|
|
2243
3212
|
t.onclick = () => t.remove();
|
|
2244
3213
|
host.appendChild(t);
|
|
@@ -2319,7 +3288,7 @@ async function openRollback(btn) {
|
|
|
2319
3288
|
wrap.style.cssText = "display:flex;gap:6px;align-self:center";
|
|
2320
3289
|
const sel = document.createElement("select");
|
|
2321
3290
|
sel.style.cssText =
|
|
2322
|
-
"background:var(--
|
|
3291
|
+
"background:var(--well);color:var(--fg);border:1px solid var(--line);border-radius:var(--r-ctl);padding:4px 8px;font-size:var(--t-body);max-width:340px";
|
|
2323
3292
|
for (const c of commits) sel.add(new Option(`${c.sha} · ${c.subject} (${c.date})`, c.sha));
|
|
2324
3293
|
const go = button("Roll back →", "", () => {
|
|
2325
3294
|
const to = sel.value;
|
|
@@ -2363,7 +3332,7 @@ async function initActions() {
|
|
|
2363
3332
|
const pill = document.createElement("span");
|
|
2364
3333
|
pill.textContent = `● static snapshot${manifest && manifest.capturedAt ? " · " + manifest.capturedAt.slice(0, 16).replace("T", " ") : ""}`;
|
|
2365
3334
|
pill.title = "An exported, read-only snapshot — no live observe or deploy.";
|
|
2366
|
-
pill.style.cssText = "align-self:center;font-
|
|
3335
|
+
pill.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-ctl);padding:2px 8px";
|
|
2367
3336
|
bar.appendChild(pill);
|
|
2368
3337
|
previewMode = true;
|
|
2369
3338
|
return; // nothing else in the bar is a read
|
|
@@ -2395,7 +3364,7 @@ async function initActions() {
|
|
|
2395
3364
|
local.emulators.map((e) => `${e.lexicon} ${e.name} @ ${e.endpoint}`).join("; ") +
|
|
2396
3365
|
". Deploys and the overlay observe them — no cloud creds.";
|
|
2397
3366
|
pill.style.cssText =
|
|
2398
|
-
"align-self:center;font-
|
|
3367
|
+
"align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--managed);border:1px solid var(--managed);border-radius:var(--r-ctl);padding:2px 8px";
|
|
2399
3368
|
bar.appendChild(pill);
|
|
2400
3369
|
}
|
|
2401
3370
|
// Auto-sync banner (#29) — make an active self-heal loop visible, not silent.
|
|
@@ -2404,7 +3373,7 @@ async function initActions() {
|
|
|
2404
3373
|
pill.textContent = `⟳ auto-sync: ${autoSync}`;
|
|
2405
3374
|
pill.title = `On polled drift, behold triggers the ${autoSync === "apply" ? "ApplyOp (heal)" : "ReconcileOp (adopt)"}. Gated applies still wait for Approve.`;
|
|
2406
3375
|
pill.style.cssText =
|
|
2407
|
-
"align-self:center;font-
|
|
3376
|
+
"align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--pending);border:1px solid var(--pending);border-radius:var(--r-ctl);padding:2px 8px";
|
|
2408
3377
|
bar.appendChild(pill);
|
|
2409
3378
|
}
|
|
2410
3379
|
opsApply = ops.find((o) => o.kind === "apply") ?? null;
|
|
@@ -2451,7 +3420,7 @@ async function initActions() {
|
|
|
2451
3420
|
// its deploy path is Apply all, not committed Ops).
|
|
2452
3421
|
if (ops.length === 0 && !previewMode && !opsInitialEnv) {
|
|
2453
3422
|
const hint = document.createElement("span");
|
|
2454
|
-
hint.style.cssText = "color:var(--muted);font-size:
|
|
3423
|
+
hint.style.cssText = "color:var(--muted);font-size:var(--t-caption);align-self:center";
|
|
2455
3424
|
hint.textContent = "no Ops — commit an *.op.ts (ApplyOp / ReconcileOp / any deploy Op) to act";
|
|
2456
3425
|
hint.title = "behold triggers committed Ops on your executor. Add one to enable Deploy / Adopt / Run.";
|
|
2457
3426
|
bar.appendChild(hint);
|
|
@@ -2520,7 +3489,7 @@ events.addEventListener("pr", (e) => {
|
|
|
2520
3489
|
slot.id = "pr-link";
|
|
2521
3490
|
slot.target = "_blank";
|
|
2522
3491
|
slot.rel = "noopener";
|
|
2523
|
-
slot.style.cssText = "color:var(--managed);text-decoration:none;font-size:
|
|
3492
|
+
slot.style.cssText = "color:var(--managed);text-decoration:none;font-size:var(--t-body);align-self:center";
|
|
2524
3493
|
document.getElementById("actions").after(slot);
|
|
2525
3494
|
}
|
|
2526
3495
|
slot.href = url;
|
|
@@ -2582,6 +3551,22 @@ function paletteCommands() {
|
|
|
2582
3551
|
for (const b of document.querySelectorAll("#panel-tabs button[data-tab]")) {
|
|
2583
3552
|
c.push([`Panel: ${b.textContent}`, () => setPanelTab(b.dataset.tab)]);
|
|
2584
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
|
+
}
|
|
2585
3570
|
|
|
2586
3571
|
// Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
|
|
2587
3572
|
for (const [label, v] of ZOOM_OPTS) {
|