@intentius/behold 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +85 -0
- package/README.md +124 -2
- package/demos.json +17 -1
- package/dist/cli.js +2553 -315
- package/example-argo-estate/README.md +43 -18
- package/example-argo-estate/app-a/chant.config.ts +10 -2
- package/example-argo-estate/app-a/package.json +2 -2
- package/example-argo-estate/app-b/chant.config.ts +11 -2
- package/example-argo-estate/app-b/package.json +2 -2
- package/example-argo-estate/control-plane/chant.config.ts +20 -5
- package/example-argo-estate/control-plane/package.json +2 -2
- package/example-argo-estate/package-lock.json +20 -20
- package/example-carve/README.md +221 -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 +962 -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 +21 -0
- package/example-carve/legacy-tf/storage.tf +33 -0
- package/example-carve/legacy-tf/terraform.tfstate +602 -0
- package/example-carve/legacy-tf/versions.tf +40 -0
- package/example-flux-estate/README.md +9 -4
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-a/src/app.ts +2 -1
- package/example-flux-estate/app-b/chant.config.ts +4 -3
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/app-b/src/app.ts +5 -3
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/control-plane/src/flux.ts +4 -2
- package/example-flux-estate/package-lock.json +20 -20
- package/example-k8s/package-lock.json +21 -21
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +8 -6
- package/web/app.js +714 -57
- package/web/carve-steps.js +621 -0
- package/web/carve-steps.test.js +248 -0
- package/web/demos.js +71 -0
- package/web/demos.test.js +83 -0
- package/web/index.html +93 -1
- package/web/json-view.js +334 -0
- package/web/json-view.test.js +218 -0
- package/web/layout-store.js +164 -4
- package/web/layout-store.test.js +226 -1
- package/web/panel.js +28 -0
- package/web/theme.js +57 -1
package/web/layout-store.js
CHANGED
|
@@ -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.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
+
}
|
package/web/layout-store.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|