@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.
Files changed (104) hide show
  1. package/AGENTS.md +94 -2
  2. package/README.md +133 -2
  3. package/demos.json +25 -1
  4. package/dist/cli.js +3176 -330
  5. package/example-argo-estate/README.md +71 -0
  6. package/example-argo-estate/app-a/chant.config.ts +18 -0
  7. package/example-argo-estate/app-a/manifests/app.yaml +54 -0
  8. package/example-argo-estate/app-a/package.json +13 -0
  9. package/example-argo-estate/app-a/src/app.ts +35 -0
  10. package/example-argo-estate/app-a/tsconfig.json +1 -0
  11. package/example-argo-estate/app-b/chant.config.ts +18 -0
  12. package/example-argo-estate/app-b/manifests/app.yaml +54 -0
  13. package/example-argo-estate/app-b/package.json +13 -0
  14. package/example-argo-estate/app-b/src/app.ts +34 -0
  15. package/example-argo-estate/app-b/tsconfig.json +1 -0
  16. package/example-argo-estate/control-plane/chant.config.ts +29 -0
  17. package/example-argo-estate/control-plane/package.json +13 -0
  18. package/example-argo-estate/control-plane/src/argo.ts +69 -0
  19. package/example-argo-estate/control-plane/tsconfig.json +1 -0
  20. package/example-argo-estate/package-lock.json +1949 -0
  21. package/example-argo-estate/package.json +10 -0
  22. package/example-carve/README.md +194 -0
  23. package/example-carve/app/chant.config.ts +6 -0
  24. package/example-carve/app/package-lock.json +1075 -0
  25. package/example-carve/app/package.json +13 -0
  26. package/example-carve/app/src/carved.ts +30 -0
  27. package/example-carve/app/tsconfig.json +1 -0
  28. package/example-carve/carve-report.json +872 -0
  29. package/example-carve/legacy-tf/cdn.tf +23 -0
  30. package/example-carve/legacy-tf/compute.tf +63 -0
  31. package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
  32. package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
  33. package/example-carve/legacy-tf/naming.tf +10 -0
  34. package/example-carve/legacy-tf/network.tf +119 -0
  35. package/example-carve/legacy-tf/observability.tf +18 -0
  36. package/example-carve/legacy-tf/outputs.tf +16 -0
  37. package/example-carve/legacy-tf/storage.tf +33 -0
  38. package/example-carve/legacy-tf/terraform.tfstate +602 -0
  39. package/example-carve/legacy-tf/versions.tf +40 -0
  40. package/example-flux-estate/README.md +22 -12
  41. package/example-flux-estate/app-a/package.json +2 -2
  42. package/example-flux-estate/app-a/src/app.ts +2 -1
  43. package/example-flux-estate/app-b/chant.config.ts +4 -3
  44. package/example-flux-estate/app-b/package.json +2 -2
  45. package/example-flux-estate/app-b/src/app.ts +5 -3
  46. package/example-flux-estate/control-plane/package.json +2 -2
  47. package/example-flux-estate/control-plane/src/flux.ts +10 -2
  48. package/example-flux-estate/package-lock.json +17 -17
  49. package/example-k8s/README.md +16 -2
  50. package/example-k8s/base/deployment.yaml +21 -0
  51. package/example-k8s/base/kustomization.yaml +3 -0
  52. package/example-k8s/base/service.yaml +12 -0
  53. package/example-k8s/chant.config.ts +10 -0
  54. package/example-k8s/overlays/dev/kustomization.yaml +6 -0
  55. package/example-k8s/package-lock.json +18 -18
  56. package/example-k8s/package.json +3 -3
  57. package/example-writes/package-lock.json +14 -14
  58. package/example-writes/package.json +3 -3
  59. package/package.json +9 -6
  60. package/web/app.js +1027 -42
  61. package/web/carve-steps.js +610 -0
  62. package/web/carve-steps.test.js +233 -0
  63. package/web/demos.js +71 -0
  64. package/web/demos.test.js +83 -0
  65. package/web/icons/cncf/argo.svg +1 -0
  66. package/web/icons/cncf/flux.svg +1 -0
  67. package/web/icons/cncf/helm.svg +1 -0
  68. package/web/icons/k8s/c-role.svg +1 -0
  69. package/web/icons/k8s/cm.svg +1 -0
  70. package/web/icons/k8s/crb.svg +1 -0
  71. package/web/icons/k8s/crd.svg +1 -0
  72. package/web/icons/k8s/cronjob.svg +1 -0
  73. package/web/icons/k8s/deploy.svg +1 -0
  74. package/web/icons/k8s/ds.svg +1 -0
  75. package/web/icons/k8s/ep.svg +1 -0
  76. package/web/icons/k8s/group.svg +1 -0
  77. package/web/icons/k8s/hpa.svg +1 -0
  78. package/web/icons/k8s/ing.svg +1 -0
  79. package/web/icons/k8s/job.svg +1 -0
  80. package/web/icons/k8s/limits.svg +1 -0
  81. package/web/icons/k8s/netpol.svg +1 -0
  82. package/web/icons/k8s/ns.svg +1 -0
  83. package/web/icons/k8s/pod.svg +1 -0
  84. package/web/icons/k8s/psp.svg +1 -0
  85. package/web/icons/k8s/pv.svg +1 -0
  86. package/web/icons/k8s/pvc.svg +1 -0
  87. package/web/icons/k8s/quota.svg +1 -0
  88. package/web/icons/k8s/rb.svg +1 -0
  89. package/web/icons/k8s/role.svg +1 -0
  90. package/web/icons/k8s/rs.svg +1 -0
  91. package/web/icons/k8s/sa.svg +1 -0
  92. package/web/icons/k8s/sc.svg +1 -0
  93. package/web/icons/k8s/secret.svg +1 -0
  94. package/web/icons/k8s/sts.svg +1 -0
  95. package/web/icons/k8s/svc.svg +1 -0
  96. package/web/icons/k8s/user.svg +1 -0
  97. package/web/icons/k8s/vol.svg +1 -0
  98. package/web/index.html +285 -85
  99. package/web/json-view.js +334 -0
  100. package/web/json-view.test.js +218 -0
  101. package/web/layout-store.js +278 -0
  102. package/web/layout-store.test.js +388 -0
  103. package/web/panel.js +28 -0
  104. package/web/theme.js +114 -6
@@ -0,0 +1,278 @@
1
+ // The hand-layout delta store (#228) — what a dragged node or a resized
2
+ // containment box remembers, and where.
3
+ //
4
+ // Deltas, never absolute positions. The graph is chant's and the layout is
5
+ // yours, and that ordering must not invert: a stored `{dx,dy}` is an offset
6
+ // from whatever dagre decided on THIS render, so a node that legitimately
7
+ // moved in the source still lands under your offset instead of pinning itself
8
+ // to a stale coordinate. A delta whose node id is gone from the graph is
9
+ // dropped without a word (`applicable`).
10
+ //
11
+ // No DOM in here on purpose — this is the testable half of #228 (see
12
+ // web/layout-store.test.js). app.js owns the pointer work and the SVG.
13
+ //
14
+ // Two tiers, and the second one lands here too: `localStorage` (free, per
15
+ // browser) and the `.behold/layout.json` sidecar behind `GET/POST /api/layout`
16
+ // (shareable, versionable, and what a server-side export bakes in — see
17
+ // src/layout.ts). `mergeLayouts` decides who wins when both have an opinion,
18
+ // and every server call degrades to localStorage-only without a word.
19
+
20
+ const PREFIX = "behold.layout";
21
+ const NUM = ["dx", "dy", "dw", "dh"];
22
+
23
+ /** A path or a lens name → one flat, readable key segment. */
24
+ export function slug(s) {
25
+ return (
26
+ String(s ?? "")
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, "-")
29
+ .replace(/^-+|-+$/g, "") || "unknown"
30
+ );
31
+ }
32
+
33
+ /** The storage key for one project + lens: `behold.layout.<project>.<lens>`. */
34
+ export function layoutKey(projectKey, lens) {
35
+ return `${PREFIX}.${slug(projectKey)}.${slug(lens)}`;
36
+ }
37
+
38
+ /** The project half of the key, from /api/project's payload (see initPickers). */
39
+ export function projectKeyOf(info) {
40
+ return slug(info && info.projectDir);
41
+ }
42
+
43
+ /**
44
+ * The lens half of the key. `?logical=1` and the plain graph must keep
45
+ * independent layouts (#228's acceptance), and so must radial vs. straight and
46
+ * one stack vs. another — every one of those re-lays the graph out from
47
+ * scratch, so a delta from one means nothing in another. The env is
48
+ * deliberately NOT in the key: picking an env recolours the same nodes, it
49
+ * doesn't re-place them, and losing your layout on an overlay flip would be
50
+ * the wrong trade.
51
+ */
52
+ export function lensKeyOf({ zoom, radial, stack } = {}) {
53
+ return [zoom || "graph", radial ? "radial" : null, stack ? `stack-${stack}` : null].filter(Boolean).join("+");
54
+ }
55
+
56
+ /** Coerce arbitrary parsed JSON into `{id: {dx,dy,dw,dh}}` of finite non-zero numbers. */
57
+ export function normalize(raw) {
58
+ const out = {};
59
+ if (!raw || typeof raw !== "object") return out;
60
+ for (const [id, d] of Object.entries(raw)) {
61
+ if (!id || !d || typeof d !== "object") continue;
62
+ const clean = {};
63
+ for (const k of NUM) {
64
+ const v = Number(d[k]);
65
+ if (Number.isFinite(v) && v !== 0) clean[k] = v;
66
+ }
67
+ if (Object.keys(clean).length) out[id] = clean;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** True when nothing is hand-placed — drives whether the reset control shows. */
73
+ export function isEmpty(deltas) {
74
+ return Object.keys(normalize(deltas)).length === 0;
75
+ }
76
+
77
+ /** Read one key. Unparseable, missing, or a storage that throws → `{}`. */
78
+ export function readLayout(storage, key) {
79
+ try {
80
+ return normalize(JSON.parse(storage.getItem(key) || "{}"));
81
+ } catch {
82
+ return {};
83
+ }
84
+ }
85
+
86
+ /** Write one key (an empty layout removes it rather than storing `{}`). */
87
+ export function writeLayout(storage, key, deltas) {
88
+ const d = normalize(deltas);
89
+ try {
90
+ if (Object.keys(d).length) storage.setItem(key, JSON.stringify(d));
91
+ else storage.removeItem(key);
92
+ } catch {
93
+ /* private mode, quota, a static export off file:// — layout is a nicety */
94
+ }
95
+ return d;
96
+ }
97
+
98
+ export function clearLayout(storage, key) {
99
+ try {
100
+ storage.removeItem(key);
101
+ } catch {
102
+ /* see writeLayout */
103
+ }
104
+ }
105
+
106
+ /** Replace one id's delta, returning a NEW map. An all-zero delta is pruned. */
107
+ export function setDelta(deltas, id, delta) {
108
+ const next = normalize(deltas);
109
+ const clean = normalize({ [id]: delta })[id];
110
+ if (clean) next[id] = clean;
111
+ else delete next[id];
112
+ return next;
113
+ }
114
+
115
+ /** Drop deltas whose id is no longer in the graph — silently, per #228. */
116
+ export function applicable(deltas, liveIds) {
117
+ const live = liveIds instanceof Set ? liveIds : new Set(liveIds || []);
118
+ const out = {};
119
+ for (const [id, d] of Object.entries(normalize(deltas))) if (live.has(id)) out[id] = d;
120
+ return out;
121
+ }
122
+
123
+ // --- The delta → SVG math ---------------------------------------------------
124
+ // MUST stay identical to the copies in src/layout.ts, which is what bakes a
125
+ // layout into a server-rendered export. web/layout-store.test.js imports both
126
+ // modules and asserts they agree across a table of cases, so a drift fails a
127
+ // test instead of quietly making an export disagree with the screen it came
128
+ // from. (Same discipline as `canonicalKey`, mirrored in app.js and export.ts.)
129
+
130
+ /** A node group's transform with its delta ridden on top of dagre's own. */
131
+ export function nodeTransform(base, d) {
132
+ const dx = (d && d.dx) || 0;
133
+ const dy = (d && d.dy) || 0;
134
+ if (!dx && !dy) return base;
135
+ return `translate(${dx}, ${dy}) ${base}`.trim();
136
+ }
137
+
138
+ /** First and last coordinate pair of a path `d` — pinhole's own edge anchors. */
139
+ export function pathAnchors(d) {
140
+ const n = String(d || "").match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
141
+ if (!n || n.length < 4) return null;
142
+ return { sx: +n[0], sy: +n[1], ex: +n[n.length - 2], ey: +n[n.length - 1] };
143
+ }
144
+
145
+ /** An edge whose ends moved: a straight line between the original anchors,
146
+ * each shifted by ITS OWN end's delta. #228 accepts the straight-line fallback
147
+ * explicitly; spline re-routing stays pinhole's job. */
148
+ export function straightEdge(anchors, from, to) {
149
+ const { sx, sy, ex, ey } = anchors;
150
+ return `M ${sx + ((from && from.dx) || 0)} ${sy + ((from && from.dy) || 0)} L ${ex + ((to && to.dx) || 0)} ${ey + ((to && to.dy) || 0)}`;
151
+ }
152
+
153
+ // --- The containment clamp (#267) -------------------------------------------
154
+ // A card may not leave the box that draws around it. The box is a claim the
155
+ // estate makes — this namespace, this overlay — and a card floating outside one
156
+ // reads as a claim chant never made. So a drag stops at the wall instead, and
157
+ // "I need more room" means grow the box first (#245 already resizes them).
158
+ //
159
+ // No twin in src/layout.ts, unlike the three functions above: the server-side
160
+ // bake does not reconstruct containment boxes (only pinhole's architecture
161
+ // layout stamps them with a `data-group-id`, and not every lens uses it — see
162
+ // wrapContainmentBoxes in web/app.js), so it has nothing to clamp against and
163
+ // deliberately does not try.
164
+
165
+ /** How far a card keeps off its container's edge, in viewBox units. */
166
+ export const CLAMP_PAD = 8;
167
+
168
+ /**
169
+ * `delta` clamped so `rect` — the node's ORIGINAL, un-displaced box — lands
170
+ * inside `bounds` minus `pad`. `{dw,dh}` ride through untouched; a clamp is
171
+ * about position.
172
+ *
173
+ * Missing or degenerate geometry returns the delta unchanged. That is the
174
+ * fail-open the DOM side needs: a graph pane that hasn't laid out yet measures
175
+ * nothing, and "no measurement" must mean "no clamp", not "everything snaps to
176
+ * the origin".
177
+ *
178
+ * A container with no room for the card (a box dragged smaller than its child)
179
+ * has an empty range; the card centres on that axis rather than being pinned to
180
+ * an arbitrary end of it.
181
+ */
182
+ export function clampDelta(delta, rect, bounds, pad = CLAMP_PAD) {
183
+ const d = { ...(delta || {}) };
184
+ if (!rect || !bounds || !(rect.w >= 0) || !(rect.h >= 0) || !(bounds.w > 0) || !(bounds.h > 0)) return d;
185
+ d.dx = clampAxis(d.dx || 0, rect.x, rect.w, bounds.x, bounds.w, pad);
186
+ d.dy = clampAxis(d.dy || 0, rect.y, rect.h, bounds.y, bounds.h, pad);
187
+ return d;
188
+ }
189
+
190
+ function clampAxis(v, start, len, boundStart, boundLen, pad) {
191
+ const lo = boundStart + pad - start;
192
+ const hi = boundStart + boundLen - pad - (start + len);
193
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || !Number.isFinite(v)) return v;
194
+ if (hi < lo) return (lo + hi) / 2; // no room at all — centre, don't pick a wall
195
+ return Math.min(hi, Math.max(lo, v));
196
+ }
197
+
198
+ // --- The server tier --------------------------------------------------------
199
+
200
+ /**
201
+ * The two tiers, merged for display. **Local wins where both have an id.**
202
+ *
203
+ * You are looking at this browser's picture: the drag you just did is in
204
+ * localStorage and must not be argued with by a sidecar someone else committed
205
+ * (or that you yourself pushed from another machine). An id only the server has
206
+ * still comes through — that is what makes a shared layout worth having — so
207
+ * the merge adds without ever overwriting. The reverse ordering would mean a
208
+ * `git pull` silently undoing a placement you can see on your screen.
209
+ *
210
+ * The stale-id rule is unchanged and applies after: `applicable` drops whatever
211
+ * is no longer in the graph, whichever tier it came from.
212
+ */
213
+ export function mergeLayouts(local, server) {
214
+ return { ...normalize(server), ...normalize(local) };
215
+ }
216
+
217
+ /** The lens's deltas as the server has them (`{}` on any refusal), plus whether
218
+ * it would accept a write. No server, a static export, preview mode, a
219
+ * read-only project, a 404 from an older behold — all the same answer: this is
220
+ * a localStorage-only session, and nothing says so out loud. */
221
+ export async function fetchServerLayout(fetchFn, lens) {
222
+ try {
223
+ const res = await fetchFn(`/api/layout?lens=${encodeURIComponent(lens)}`);
224
+ if (!res || !res.ok) return { deltas: {}, writable: false };
225
+ const body = await res.json();
226
+ return { deltas: normalize(body && body.deltas), writable: !!(body && body.writable) };
227
+ } catch {
228
+ return { deltas: {}, writable: false };
229
+ }
230
+ }
231
+
232
+ /** Push one lens's map to the sidecar. Resolves true iff it was stored.
233
+ * `keepalive` so a push flushed on the way out of the page (see `debounce`'s
234
+ * `flush`) still completes after the document is gone. */
235
+ export async function postServerLayout(fetchFn, lens, deltas) {
236
+ try {
237
+ const res = await fetchFn("/api/layout", {
238
+ method: "POST",
239
+ headers: { "content-type": "application/json" },
240
+ body: JSON.stringify({ lens, deltas: normalize(deltas) }),
241
+ keepalive: true,
242
+ });
243
+ return !!(res && res.ok);
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /** A trailing-edge debouncer for the POST above: a drag emits a pointer-move
250
+ * storm and ends on the one write that matters. `flush()` runs a pending call
251
+ * now — what a page leaving mid-debounce needs, or the sidecar would miss the
252
+ * last placement the localStorage tier already has. Exported so the test can
253
+ * drive it with a fake clock rather than sleeping. */
254
+ // The default timers are wrappers, not bare references: a detached
255
+ // `setTimeout` is called with no `this` and Chrome answers that with an
256
+ // "Illegal invocation" TypeError.
257
+ export function debounce(fn, ms, setTimer = (cb, t) => setTimeout(cb, t), clearTimer = (id) => clearTimeout(id)) {
258
+ let t = null;
259
+ let last = null;
260
+ const run = (...args) => {
261
+ if (t !== null) clearTimer(t);
262
+ last = args;
263
+ t = setTimer(() => {
264
+ t = null;
265
+ last = null;
266
+ fn(...args);
267
+ }, ms);
268
+ };
269
+ run.flush = () => {
270
+ if (t === null) return;
271
+ clearTimer(t);
272
+ t = null;
273
+ const args = last || [];
274
+ last = null;
275
+ fn(...args);
276
+ };
277
+ return run;
278
+ }
@@ -0,0 +1,388 @@
1
+ // #228: the hand-layout delta store, checked without a browser. The pointer
2
+ // work and the SVG live in app.js and are covered by smoke/ui-smoke.mjs; every
3
+ // rule that decides WHAT gets stored and under which key lives here.
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import {
6
+ applicable,
7
+ clampDelta,
8
+ clearLayout,
9
+ CLAMP_PAD,
10
+ debounce,
11
+ fetchServerLayout,
12
+ isEmpty,
13
+ layoutKey,
14
+ lensKeyOf,
15
+ mergeLayouts,
16
+ nodeTransform,
17
+ normalize,
18
+ pathAnchors,
19
+ postServerLayout,
20
+ projectKeyOf,
21
+ readLayout,
22
+ setDelta,
23
+ slug,
24
+ straightEdge,
25
+ writeLayout,
26
+ } from "./layout-store.js";
27
+ // The server half of #228 — imported here on purpose: this is the one file
28
+ // that can hold both copies of the delta→SVG math at once (tsconfig excludes
29
+ // web/, so a src/*.test.ts couldn't import the browser module), and the
30
+ // parity block at the bottom is what keeps them from drifting.
31
+ import * as server from "../src/layout.ts";
32
+
33
+ /** A localStorage stand-in; `fail` makes every operation throw (private mode). */
34
+ function fakeStorage(seed = {}, fail = false) {
35
+ const map = new Map(Object.entries(seed));
36
+ return {
37
+ map,
38
+ getItem(k) {
39
+ if (fail) throw new Error("nope");
40
+ return map.has(k) ? map.get(k) : null;
41
+ },
42
+ setItem(k, v) {
43
+ if (fail) throw new Error("nope");
44
+ map.set(k, v);
45
+ },
46
+ removeItem(k) {
47
+ if (fail) throw new Error("nope");
48
+ map.delete(k);
49
+ },
50
+ };
51
+ }
52
+
53
+ describe("keys", () => {
54
+ it("slugs a project dir and a lens into one readable key", () => {
55
+ expect(layoutKey("/estates/stub-estate", "components")).toBe("behold.layout.estates-stub-estate.components");
56
+ });
57
+
58
+ it("takes the project half from /api/project's projectDir", () => {
59
+ expect(projectKeyOf({ projectDir: "/Users/me/work/My Estate" })).toBe("users-me-work-my-estate");
60
+ expect(projectKeyOf(null)).toBe("unknown");
61
+ expect(projectKeyOf({})).toBe("unknown");
62
+ });
63
+
64
+ it("gives the logical lens and the plain graph independent keys (#228 acceptance)", () => {
65
+ const p = "/estates/x";
66
+ expect(layoutKey(p, lensKeyOf({ zoom: "logical" }))).not.toBe(layoutKey(p, lensKeyOf({ zoom: "resources" })));
67
+ });
68
+
69
+ it("separates radial from straight, and one stack from another", () => {
70
+ expect(lensKeyOf({ zoom: "resources", radial: true })).toBe("resources+radial");
71
+ expect(lensKeyOf({ zoom: "resources", stack: "edge" })).toBe("resources+stack-edge");
72
+ expect(lensKeyOf({ zoom: "resources" })).toBe("resources");
73
+ expect(layoutKey("/e", lensKeyOf({ zoom: "resources", radial: true }))).not.toBe(layoutKey("/e", lensKeyOf({ zoom: "resources" })));
74
+ });
75
+
76
+ it("keeps the env OUT of the key — an overlay recolours, it does not re-place", () => {
77
+ expect(lensKeyOf({ zoom: "components", env: "prod" })).toBe(lensKeyOf({ zoom: "components" }));
78
+ });
79
+
80
+ it("never produces an empty segment", () => {
81
+ expect(slug("///")).toBe("unknown");
82
+ expect(layoutKey("", "")).toBe("behold.layout.unknown.unknown");
83
+ });
84
+ });
85
+
86
+ describe("normalize", () => {
87
+ it("keeps finite non-zero deltas only", () => {
88
+ expect(normalize({ a: { dx: 10, dy: 0, dw: 4 } })).toEqual({ a: { dx: 10, dw: 4 } });
89
+ });
90
+
91
+ it("drops junk without throwing", () => {
92
+ expect(normalize(null)).toEqual({});
93
+ expect(normalize("nope")).toEqual({});
94
+ expect(normalize({ a: 5, b: null, c: { dx: NaN }, d: { dx: "x" }, e: { nope: 1 } })).toEqual({});
95
+ });
96
+
97
+ it("parses numeric strings (JSON round-trips are not always clean)", () => {
98
+ expect(normalize({ a: { dx: "12.5" } })).toEqual({ a: { dx: 12.5 } });
99
+ });
100
+
101
+ it("isEmpty ignores entries that normalize away", () => {
102
+ expect(isEmpty({ a: { dx: 0, dy: 0 } })).toBe(true);
103
+ expect(isEmpty({ a: { dx: 1 } })).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe("setDelta", () => {
108
+ it("replaces one id and leaves the rest alone", () => {
109
+ const next = setDelta({ a: { dx: 1 }, b: { dy: 2 } }, "a", { dx: 9, dy: 9 });
110
+ expect(next).toEqual({ a: { dx: 9, dy: 9 }, b: { dy: 2 } });
111
+ });
112
+
113
+ it("prunes an id dragged back to where dagre put it", () => {
114
+ expect(setDelta({ a: { dx: 1 } }, "a", { dx: 0, dy: 0 })).toEqual({});
115
+ });
116
+
117
+ it("does not mutate its input", () => {
118
+ const before = { a: { dx: 1 } };
119
+ setDelta(before, "a", { dx: 4 });
120
+ expect(before).toEqual({ a: { dx: 1 } });
121
+ });
122
+
123
+ it("carries a box's size delta alongside its position delta", () => {
124
+ expect(setDelta({}, "box:vpc", { dx: 3, dy: 0, dw: 40, dh: -20 })).toEqual({ "box:vpc": { dx: 3, dw: 40, dh: -20 } });
125
+ });
126
+ });
127
+
128
+ describe("applicable", () => {
129
+ it("silently drops a delta for a node that left the estate", () => {
130
+ expect(applicable({ gone: { dx: 5 }, here: { dy: 3 } }, ["here"])).toEqual({ here: { dy: 3 } });
131
+ });
132
+
133
+ it("takes a Set as well as an array", () => {
134
+ expect(applicable({ a: { dx: 1 } }, new Set(["a"]))).toEqual({ a: { dx: 1 } });
135
+ });
136
+
137
+ it("is a no-op filter when nothing is live", () => {
138
+ expect(applicable({ a: { dx: 1 } }, [])).toEqual({});
139
+ });
140
+ });
141
+
142
+ describe("storage", () => {
143
+ it("round-trips through a storage", () => {
144
+ const s = fakeStorage();
145
+ const key = layoutKey("/estates/x", "components");
146
+ writeLayout(s, key, { api: { dx: 12, dy: -4 } });
147
+ expect(readLayout(s, key)).toEqual({ api: { dx: 12, dy: -4 } });
148
+ });
149
+
150
+ it("removes the key rather than storing an empty layout", () => {
151
+ const s = fakeStorage();
152
+ writeLayout(s, "k", { api: { dx: 1 } });
153
+ writeLayout(s, "k", {});
154
+ expect(s.map.has("k")).toBe(false);
155
+ expect(readLayout(s, "k")).toEqual({});
156
+ });
157
+
158
+ it("survives a corrupt value", () => {
159
+ expect(readLayout(fakeStorage({ k: "{not json" }), "k")).toEqual({});
160
+ });
161
+
162
+ it("survives a storage that throws (private mode, quota, file://)", () => {
163
+ const s = fakeStorage({}, true);
164
+ expect(() => writeLayout(s, "k", { a: { dx: 1 } })).not.toThrow();
165
+ expect(readLayout(s, "k")).toEqual({});
166
+ expect(() => clearLayout(s, "k")).not.toThrow();
167
+ });
168
+
169
+ it("clearLayout drops just this lens's key", () => {
170
+ const s = fakeStorage();
171
+ writeLayout(s, layoutKey("/e", "components"), { a: { dx: 1 } });
172
+ writeLayout(s, layoutKey("/e", "logical"), { a: { dy: 2 } });
173
+ clearLayout(s, layoutKey("/e", "components"));
174
+ expect(readLayout(s, layoutKey("/e", "components"))).toEqual({});
175
+ expect(readLayout(s, layoutKey("/e", "logical"))).toEqual({ a: { dy: 2 } });
176
+ });
177
+ });
178
+
179
+ // --- The server tier (#228, second half) ------------------------------------
180
+
181
+ describe("mergeLayouts", () => {
182
+ it("local wins where both tiers have the same id", () => {
183
+ expect(mergeLayouts({ api: { dx: 10 } }, { api: { dx: -99 } })).toEqual({ api: { dx: 10 } });
184
+ });
185
+
186
+ it("an id only the sidecar has still comes through — that's the point of sharing", () => {
187
+ expect(mergeLayouts({ api: { dx: 10 } }, { worker: { dy: 4 } })).toEqual({ api: { dx: 10 }, worker: { dy: 4 } });
188
+ });
189
+
190
+ it("is the whole server layout when nothing is local (a fresh browser)", () => {
191
+ expect(mergeLayouts({}, { api: { dx: 1 } })).toEqual({ api: { dx: 1 } });
192
+ });
193
+
194
+ it("normalizes both sides, so junk from either can't reach the SVG", () => {
195
+ expect(mergeLayouts({ a: { dx: 0 } }, { b: "nope", c: { dy: 3 } })).toEqual({ c: { dy: 3 } });
196
+ });
197
+ });
198
+
199
+ describe("fetchServerLayout", () => {
200
+ const res = (body, ok = true) => ({ ok, json: async () => body });
201
+
202
+ it("asks for one lens and normalizes what comes back", async () => {
203
+ const fetchFn = vi.fn(async () => res({ lens: "components", deltas: { api: { dx: "5" }, junk: { dx: 0 } }, writable: true }));
204
+ expect(await fetchServerLayout(fetchFn, "components")).toEqual({ deltas: { api: { dx: 5 } }, writable: true });
205
+ expect(fetchFn).toHaveBeenCalledWith("/api/layout?lens=components");
206
+ });
207
+
208
+ it("encodes the lens key (a stack name can be anything)", async () => {
209
+ const fetchFn = vi.fn(async () => res({ deltas: {}, writable: false }));
210
+ await fetchServerLayout(fetchFn, "resources+stack-edge");
211
+ expect(fetchFn).toHaveBeenCalledWith("/api/layout?lens=resources%2Bstack-edge");
212
+ });
213
+
214
+ it("a refusal is an empty, unwritable answer — never a throw", async () => {
215
+ expect(await fetchServerLayout(async () => res({ error: "read-only" }, false), "components")).toEqual({ deltas: {}, writable: false });
216
+ expect(await fetchServerLayout(async () => {
217
+ throw new Error("offline");
218
+ }, "components")).toEqual({ deltas: {}, writable: false });
219
+ });
220
+ });
221
+
222
+ describe("postServerLayout", () => {
223
+ it("posts the normalized lens map as JSON", async () => {
224
+ const fetchFn = vi.fn(async () => ({ ok: true }));
225
+ expect(await postServerLayout(fetchFn, "components", { api: { dx: 3, dy: 0 } })).toBe(true);
226
+ const [url, init] = fetchFn.mock.calls[0];
227
+ expect(url).toBe("/api/layout");
228
+ expect(init.method).toBe("POST");
229
+ expect(init.headers["content-type"]).toBe("application/json");
230
+ expect(init.keepalive).toBe(true); // survives a flush on the way out of the page
231
+ expect(JSON.parse(init.body)).toEqual({ lens: "components", deltas: { api: { dx: 3 } } });
232
+ });
233
+
234
+ it("a rejection is false, not an exception (offline is fine — localStorage has it)", async () => {
235
+ expect(await postServerLayout(async () => ({ ok: false }), "components", {})).toBe(false);
236
+ expect(
237
+ await postServerLayout(async () => {
238
+ throw new Error("offline");
239
+ }, "components", {}),
240
+ ).toBe(false);
241
+ });
242
+ });
243
+
244
+ describe("clampDelta — a card stays inside the box drawn around it (#267)", () => {
245
+ // The smoke stub's own geometry: a 150×64 card at (40,80) in a 580×220 box
246
+ // at (20,40). Same numbers as smoke/stub.mjs, so a failure here and a failure
247
+ // there are the same failure.
248
+ const card = { x: 40, y: 80, w: 150, h: 64 };
249
+ const box = { x: 20, y: 40, w: 580, h: 220 };
250
+
251
+ it("leaves a delta that keeps the card inside alone", () => {
252
+ expect(clampDelta({ dx: 50, dy: 30 }, card, box)).toEqual({ dx: 50, dy: 30 });
253
+ });
254
+
255
+ it("stops at the wall minus the padding, on every side", () => {
256
+ expect(clampDelta({ dx: -900, dy: -900 }, card, box)).toEqual({ dx: 20 + CLAMP_PAD - 40, dy: 40 + CLAMP_PAD - 80 });
257
+ expect(clampDelta({ dx: 900, dy: 900 }, card, box)).toEqual({ dx: 600 - CLAMP_PAD - 190, dy: 260 - CLAMP_PAD - 144 });
258
+ });
259
+
260
+ it("clamps one axis without touching the other", () => {
261
+ expect(clampDelta({ dx: 900, dy: 10 }, card, box)).toEqual({ dx: 402, dy: 10 });
262
+ });
263
+
264
+ it("carries {dw,dh} through — a clamp is about position", () => {
265
+ expect(clampDelta({ dx: 900, dy: 0, dw: 5, dh: 6 }, card, box)).toEqual({ dx: 402, dy: 0, dw: 5, dh: 6 });
266
+ });
267
+
268
+ it("centres the card when the container has no room for it at all", () => {
269
+ const tiny = { x: 100, y: 100, w: 50, h: 20 };
270
+ const got = clampDelta({ dx: 900, dy: -900 }, card, tiny);
271
+ expect(got.dx + card.x + card.w / 2).toBeCloseTo(tiny.x + tiny.w / 2);
272
+ expect(got.dy + card.y + card.h / 2).toBeCloseTo(tiny.y + tiny.h / 2);
273
+ });
274
+
275
+ it("fails OPEN on missing geometry — no measurement means no clamp", () => {
276
+ expect(clampDelta({ dx: 900, dy: 900 }, null, box)).toEqual({ dx: 900, dy: 900 });
277
+ expect(clampDelta({ dx: 900, dy: 900 }, card, null)).toEqual({ dx: 900, dy: 900 });
278
+ expect(clampDelta({ dx: 900, dy: 900 }, card, { x: 0, y: 0, w: 0, h: 0 })).toEqual({ dx: 900, dy: 900 });
279
+ });
280
+
281
+ it("a growing box loosens the clamp; a shrinking one tightens it", () => {
282
+ const grown = { ...box, w: box.w + 100 };
283
+ expect(clampDelta({ dx: 900, dy: 0 }, card, grown).dx).toBe(clampDelta({ dx: 900, dy: 0 }, card, box).dx + 100);
284
+ });
285
+
286
+ it("normalizes away to nothing when the clamp lands on zero", () => {
287
+ // setDelta is what the drag actually calls; a clamped-to-origin delta must
288
+ // prune like any other no-op, not persist as `{dx: 0, dy: 0}`.
289
+ expect(setDelta({}, "api", clampDelta({ dx: 0, dy: 0 }, card, box))).toEqual({});
290
+ });
291
+ });
292
+
293
+ describe("debounce", () => {
294
+ const fakeTimers = () => {
295
+ const timers = [];
296
+ return { timers, set: (cb) => timers.push(cb) - 1, clear: (i) => (timers[i] = null), run: () => timers.forEach((cb) => cb && cb()) };
297
+ };
298
+
299
+ it("fires once, with the last arguments — a drag is one write, not sixty", () => {
300
+ const t = fakeTimers();
301
+ const fn = vi.fn();
302
+ const d = debounce(fn, 100, t.set, t.clear);
303
+ d("a");
304
+ d("b");
305
+ d("c");
306
+ t.run();
307
+ expect(fn.mock.calls).toEqual([["c"]]);
308
+ });
309
+
310
+ it("flush runs a pending call now — what a page leaving mid-debounce needs", () => {
311
+ const t = fakeTimers();
312
+ const fn = vi.fn();
313
+ const d = debounce(fn, 100, t.set, t.clear);
314
+ d("a");
315
+ d.flush();
316
+ expect(fn.mock.calls).toEqual([["a"]]);
317
+ t.run(); // the cancelled timer must not fire it a second time
318
+ expect(fn).toHaveBeenCalledTimes(1);
319
+ });
320
+
321
+ it("flush with nothing pending does nothing", () => {
322
+ const t = fakeTimers();
323
+ const fn = vi.fn();
324
+ debounce(fn, 100, t.set, t.clear).flush();
325
+ expect(fn).not.toHaveBeenCalled();
326
+ });
327
+ });
328
+
329
+ // --- Parity with src/layout.ts ----------------------------------------------
330
+ // The client paints the deltas in the browser; the server bakes the same ones
331
+ // into an exported SVG. If these two ever disagree, an export stops matching
332
+ // the screen it was taken from — silently. So they are checked against each
333
+ // other here, on the same table.
334
+ describe("the delta→SVG math matches the server's copy", () => {
335
+ const CASES = [
336
+ ["translate(40, 80)", { dx: 12, dy: -4 }],
337
+ ["translate(40, 80) scale(0.9)", { dx: 0.5, dy: 0 }],
338
+ ["", { dx: 3, dy: 4 }],
339
+ ["translate(1, 2)", { dx: 0, dy: 0 }],
340
+ ["translate(1, 2)", { dw: 20, dh: 10 }],
341
+ ["translate(1, 2)", {}],
342
+ ];
343
+ it("nodeTransform", () => {
344
+ for (const [base, d] of CASES) expect(nodeTransform(base, d)).toBe(server.nodeTransform(base, d));
345
+ expect(nodeTransform("translate(40, 80)", { dx: 12, dy: -4 })).toBe("translate(12, -4) translate(40, 80)");
346
+ expect(nodeTransform("translate(1, 2)", { dw: 5 })).toBe("translate(1, 2)");
347
+ });
348
+
349
+ it("pathAnchors", () => {
350
+ const DS = ["M 115 112 C 115 112, 305 112, 305 112", "M1 2L3 4", "M 1e2 -3.5 L 7 8", "M 1 2", "", null];
351
+ for (const d of DS) expect(pathAnchors(d)).toEqual(server.pathAnchors(d));
352
+ expect(pathAnchors("M 115 112 C 115 112, 305 112, 305 112")).toEqual({ sx: 115, sy: 112, ex: 305, ey: 112 });
353
+ });
354
+
355
+ it("straightEdge", () => {
356
+ const a = { sx: 115, sy: 112, ex: 305, ey: 112 };
357
+ for (const [from, to] of [
358
+ [{ dx: 10, dy: 5 }, undefined],
359
+ [undefined, { dx: -2.5, dy: 0 }],
360
+ [{ dx: 1 }, { dy: 2 }],
361
+ [undefined, undefined],
362
+ ]) {
363
+ expect(straightEdge(a, from, to)).toBe(server.straightEdge(a, from, to));
364
+ }
365
+ expect(straightEdge(a, { dx: 10, dy: 5 }, undefined)).toBe("M 125 117 L 305 112");
366
+ });
367
+
368
+ it("slug and normalize agree, so a key written by one is read by the other", () => {
369
+ for (const s of ["/estates/stub-estate", "Resources+Radial", "///", "", "stack-edge"]) expect(slug(s)).toBe(server.slug(s));
370
+ for (const raw of [{ a: { dx: 1, dy: 0 } }, { a: { dx: "12.5" } }, { a: 5 }, null, "nope", { a: { dx: NaN } }]) {
371
+ expect(normalize(raw)).toEqual(server.normalizeDeltas(raw));
372
+ }
373
+ });
374
+
375
+ it("the lens key the client stores under is the one the server derives from a request", () => {
376
+ const q = (s) => new URLSearchParams(s);
377
+ expect(server.lensFromQuery(q("components=1"))).toBe(lensKeyOf({ zoom: "components" }));
378
+ expect(server.lensFromQuery(q("logical=1"))).toBe(lensKeyOf({ zoom: "logical" }));
379
+ expect(server.lensFromQuery(q("env=prod&runtime=1&detail=3"))).toBe(lensKeyOf({ zoom: "runtime" }));
380
+ expect(server.lensFromQuery(q("detail=1"))).toBe(lensKeyOf({ zoom: "composites" }));
381
+ expect(server.lensFromQuery(q("detail=3"))).toBe(lensKeyOf({ zoom: "attributes" }));
382
+ expect(server.lensFromQuery(q(""))).toBe(lensKeyOf({ zoom: "resources" }));
383
+ expect(server.lensFromQuery(q("detail=2&radial=1"))).toBe(lensKeyOf({ zoom: "resources", radial: true }));
384
+ expect(server.lensFromQuery(q("detail=2&stack=edge"))).toBe(lensKeyOf({ zoom: "resources", stack: "edge" }));
385
+ // The env is in neither — an overlay recolours the same nodes (#228).
386
+ expect(server.lensFromQuery(q("components=1&env=prod&tier=dev"))).toBe(server.lensFromQuery(q("components=1")));
387
+ });
388
+ });