@intentius/behold 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +85 -0
  2. package/README.md +123 -2
  3. package/demos.json +17 -1
  4. package/dist/cli.js +2522 -294
  5. package/example-argo-estate/README.md +43 -18
  6. package/example-argo-estate/app-a/chant.config.ts +10 -2
  7. package/example-argo-estate/app-a/package.json +2 -2
  8. package/example-argo-estate/app-b/chant.config.ts +11 -2
  9. package/example-argo-estate/app-b/package.json +2 -2
  10. package/example-argo-estate/control-plane/chant.config.ts +20 -5
  11. package/example-argo-estate/control-plane/package.json +2 -2
  12. package/example-argo-estate/package-lock.json +17 -17
  13. package/example-carve/README.md +194 -0
  14. package/example-carve/app/chant.config.ts +6 -0
  15. package/example-carve/app/package-lock.json +1075 -0
  16. package/example-carve/app/package.json +13 -0
  17. package/example-carve/app/src/carved.ts +30 -0
  18. package/example-carve/app/tsconfig.json +1 -0
  19. package/example-carve/carve-report.json +872 -0
  20. package/example-carve/legacy-tf/cdn.tf +23 -0
  21. package/example-carve/legacy-tf/compute.tf +63 -0
  22. package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
  23. package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
  24. package/example-carve/legacy-tf/naming.tf +10 -0
  25. package/example-carve/legacy-tf/network.tf +119 -0
  26. package/example-carve/legacy-tf/observability.tf +18 -0
  27. package/example-carve/legacy-tf/outputs.tf +16 -0
  28. package/example-carve/legacy-tf/storage.tf +33 -0
  29. package/example-carve/legacy-tf/terraform.tfstate +602 -0
  30. package/example-carve/legacy-tf/versions.tf +40 -0
  31. package/example-flux-estate/README.md +9 -4
  32. package/example-flux-estate/app-a/package.json +2 -2
  33. package/example-flux-estate/app-a/src/app.ts +2 -1
  34. package/example-flux-estate/app-b/chant.config.ts +4 -3
  35. package/example-flux-estate/app-b/package.json +2 -2
  36. package/example-flux-estate/app-b/src/app.ts +5 -3
  37. package/example-flux-estate/control-plane/package.json +2 -2
  38. package/example-flux-estate/control-plane/src/flux.ts +4 -2
  39. package/example-flux-estate/package-lock.json +17 -17
  40. package/example-k8s/package-lock.json +18 -18
  41. package/example-k8s/package.json +3 -3
  42. package/example-writes/package-lock.json +14 -14
  43. package/example-writes/package.json +3 -3
  44. package/package.json +8 -6
  45. package/web/app.js +714 -57
  46. package/web/carve-steps.js +610 -0
  47. package/web/carve-steps.test.js +233 -0
  48. package/web/demos.js +71 -0
  49. package/web/demos.test.js +83 -0
  50. package/web/index.html +93 -1
  51. package/web/json-view.js +334 -0
  52. package/web/json-view.test.js +218 -0
  53. package/web/layout-store.js +164 -4
  54. package/web/layout-store.test.js +226 -1
  55. package/web/panel.js +28 -0
  56. package/web/theme.js +57 -1
@@ -9,10 +9,13 @@
9
9
  // dropped without a word (`applicable`).
10
10
  //
11
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. The
13
- // second tier of the issue (a `.behold/layout.json` sidecar behind
14
- // `POST /api/layout`, so exports and snapshots honour the same deltas) is the
15
- // follow-up; this is the shape it will serialize.
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.
16
19
 
17
20
  const PREFIX = "behold.layout";
18
21
  const NUM = ["dx", "dy", "dw", "dh"];
@@ -116,3 +119,160 @@ export function applicable(deltas, liveIds) {
116
119
  for (const [id, d] of Object.entries(normalize(deltas))) if (live.has(id)) out[id] = d;
117
120
  return out;
118
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
+ }
@@ -1,20 +1,34 @@
1
1
  // #228: the hand-layout delta store, checked without a browser. The pointer
2
2
  // work and the SVG live in app.js and are covered by smoke/ui-smoke.mjs; every
3
3
  // rule that decides WHAT gets stored and under which key lives here.
4
- import { describe, expect, it } from "vitest";
4
+ import { describe, expect, it, vi } from "vitest";
5
5
  import {
6
6
  applicable,
7
+ clampDelta,
7
8
  clearLayout,
9
+ CLAMP_PAD,
10
+ debounce,
11
+ fetchServerLayout,
8
12
  isEmpty,
9
13
  layoutKey,
10
14
  lensKeyOf,
15
+ mergeLayouts,
16
+ nodeTransform,
11
17
  normalize,
18
+ pathAnchors,
19
+ postServerLayout,
12
20
  projectKeyOf,
13
21
  readLayout,
14
22
  setDelta,
15
23
  slug,
24
+ straightEdge,
16
25
  writeLayout,
17
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";
18
32
 
19
33
  /** A localStorage stand-in; `fail` makes every operation throw (private mode). */
20
34
  function fakeStorage(seed = {}, fail = false) {
@@ -161,3 +175,214 @@ describe("storage", () => {
161
175
  expect(readLayout(s, layoutKey("/e", "logical"))).toEqual({ a: { dy: 2 } });
162
176
  });
163
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
+ });
package/web/panel.js CHANGED
@@ -122,6 +122,34 @@ export function setPanelTab(tab, { expand = true } = {}) {
122
122
  }
123
123
  }
124
124
 
125
+ /**
126
+ * #254: add a tab the markup doesn't ship — the Carve walkthrough, which only
127
+ * exists when the server is serving one. Mounted at runtime rather than hidden
128
+ * in index.html so no other project grows a dead tab (and so a persisted
129
+ * `tab: "carve"` can never strand someone on an empty section: the tab isn't
130
+ * there to be restored). Returns the section element to render into; calling it
131
+ * twice is a no-op that returns the same one.
132
+ */
133
+ export function addPanelTab(id, label, title) {
134
+ if (!panel) return null;
135
+ const existing = panel.querySelector(`#panel-body section[data-tab="${id}"]`);
136
+ if (existing) return existing;
137
+ const b = document.createElement("button");
138
+ b.dataset.tab = id;
139
+ b.textContent = label;
140
+ if (title) b.title = title;
141
+ b.addEventListener("click", () => setPanelTab(id));
142
+ // Before the ⌘K pill + collapse chevron, which are not tabs.
143
+ document.getElementById("panel-tabs").insertBefore(b, document.getElementById("hintk"));
144
+ const section = document.createElement("section");
145
+ section.dataset.tab = id;
146
+ document.getElementById("panel-body").appendChild(section);
147
+ // The new button starts inactive; the section starts hidden. Re-applying the
148
+ // current tab keeps the two in step whatever it happens to be.
149
+ setPanelTab(state.tab, { expand: false });
150
+ return section;
151
+ }
152
+
125
153
  function setCollapsed(on) {
126
154
  state.collapsed = !!on;
127
155
  panel.classList.toggle("collapsed", state.collapsed);
package/web/theme.js CHANGED
@@ -32,6 +32,18 @@ export function readableOn(bg) { return isDark(bg) ? "#ffffff" : "#000000"; }
32
32
  // --- OKLCH (perceptual hue rotation for category counts > palette slots) --
33
33
  const s2l = (c) => ((c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
34
34
  const l2s = (c) => { const v = c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055; return clamp(v, 0, 1) * 255; };
35
+ // A real WCAG contrast ratio (#240) — sRGB→linear via s2l (the same EOTF step
36
+ // hexToOklch already does below), unlike the quick luminance() above, which
37
+ // skips gamma correction and is only good enough for a light/dark coin-flip
38
+ // (isDark/readableOn). The floor --muted has to clear is a real accessibility
39
+ // number, and luminance()'s shortcut proved too far off it on the exact
40
+ // palette that opened this issue (Darkermatrix: quick metric said 3.03,
41
+ // actual WCAG was 2.13) to be worth the savings.
42
+ function relLuminance(hex) { const [r, g, b] = hexToRgb(hex).map(s2l); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
43
+ export function contrast(a, b) {
44
+ const A = relLuminance(a), B = relLuminance(b);
45
+ return (Math.max(A, B) + 0.05) / (Math.min(A, B) + 0.05);
46
+ }
35
47
  export function hexToOklch(hex) {
36
48
  const [R, G, B] = hexToRgb(hex).map(s2l);
37
49
  const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
@@ -61,6 +73,46 @@ export function desaturate(hex, k) {
61
73
  return oklchToHex({ L, C: C * k, H });
62
74
  }
63
75
 
76
+ // #240: the same guaranteed-visible trick #229 used for --active (push OKLCH
77
+ // L away from a reference until it clears a bar), generalised from a fixed L
78
+ // delta to an actual contrast() floor — because unlike --active's "just don't
79
+ // land on the same fill", secondary text has a real legibility target.
80
+ //
81
+ // Tries `dark`'s intended direction first (lighter text on a dark theme,
82
+ // darker on a light one — the direction a person would actually pick, and
83
+ // the one #229's --active push uses). luminance() isn't gamma-corrected, so
84
+ // its contrast() ratio is asymmetric in a way real perceived contrast isn't
85
+ // — it overstates how much headroom the near-black end has. Chasing that
86
+ // headroom blindly is how a dark theme's muted text ends up pushed to
87
+ // near-black-on-dark-panel, technically clearing the ratio while reading as
88
+ // invisible. So the "chase more headroom" fallback only fires if the
89
+ // intended direction's own gamut-limited walk can't reach the floor at all
90
+ // (a saturated, nominally-dark bg like Hot Dog Stand's red, whose panel ends
91
+ // up mid-bright once mixed toward fg) — never as a first choice. Chroma
92
+ // eases toward 0 as L walks away so a saturated start doesn't clip out of
93
+ // gamut and stall short of the target.
94
+ function pushForContrast(hex, against, floor, dark) {
95
+ if (contrast(hex, against) >= floor) return hex;
96
+ const tok = hexToOklch(hex);
97
+ const walk = (dir) => {
98
+ let best = hex, bestRatio = contrast(hex, against);
99
+ for (let step = 1; step <= 100; step++) {
100
+ const t = step / 100;
101
+ const L = clamp(tok.L + dir * step * 0.01, 0, 1);
102
+ const out = oklchToHex({ L, C: tok.C * (1 - t * 0.6), H: tok.H });
103
+ const ratio = contrast(out, against);
104
+ if (ratio > bestRatio) { bestRatio = ratio; best = out; }
105
+ if (ratio >= floor) return { hex: out, ratio, reached: true };
106
+ if (L === 0 || L === 1) break;
107
+ }
108
+ return { hex: best, ratio: bestRatio, reached: false };
109
+ };
110
+ const primary = walk(dark ? 1 : -1);
111
+ if (primary.reached) return primary.hex;
112
+ const fallback = walk(dark ? -1 : 1);
113
+ return fallback.ratio > primary.ratio ? fallback.hex : primary.hex;
114
+ }
115
+
64
116
  // --- behold's semantic tokens derived from a theme -----------------------
65
117
  // Drift states are ANCHORED to palette slots so "coloured by drift" stays meaningful across
66
118
  // every theme (green=managed … red=degraded); only the shades change. Chrome is derived from
@@ -93,7 +145,11 @@ export function tokensFor(th) {
93
145
  panel,
94
146
  line: desaturate(fgMix(0.16), 0.75),
95
147
  fg: th.fg,
96
- muted: desaturate(fgMix(0.5), 0.75),
148
+ // #240: secondary text needs an actual floor, not just "quieter than
149
+ // fg" — a handful of low-range palettes (Darkermatrix and friends) mix
150
+ // fg/panel close enough that the quieted 0.5 mix reads at ~1.25:1.
151
+ // Push its OKLCH L away from --panel's until it clears 3:1.
152
+ muted: pushForContrast(desaturate(fgMix(0.5), 0.75), panel, 3, th.dark),
97
153
  edge: fgMix(0.32),
98
154
  // The structural border — the panel shell, the pane edge, the footer rule.
99
155
  // Tinted by the palette's OWN bright-black slot instead of being one more