Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/graphs/src/pages/look.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,8 @@ figure.plate {
/* The document plate viewport: full width and 60svh below the datasheet
* breakpoint, stretched beside the datasheet above it (see the 1024 px
* block). Without scripting it scrolls at scale 1; the plate client
* adds is-live and pans/zooms the drawing itself instead. */
* adds is-live and pans/zooms the drawing itself instead, and the
* one-column viewport then follows the drawing's proportions (below). */
.doc-cols figure.plate {
margin: 0;
width: 100%;
Expand All @@ -430,6 +431,16 @@ figure.plate.is-live {
position: relative;
touch-action: none;
}
/* Live in one column, the viewport takes the drawing's own proportions
* (--plate-ratio, set by the client) between 200 px and 60svh: a wide
* plate fits the full width without the letterbox bands a fixed 60svh
* left above and below it on a phone; a tall one still stops at 60svh. */
.doc-cols figure.plate.is-live {
height: auto;
aspect-ratio: var(--plate-ratio, auto);
min-height: 200px;
max-height: 60svh;
}
.plate svg { display: block; height: auto; margin: 0 auto; }
.plate.is-live svg { margin: 0; transform-origin: 0 0; }
/* The overlaid zoom controls, rendered by the client: three 44 px targets
Expand Down Expand Up @@ -676,6 +687,7 @@ pre.prompt, pre.hash {
@media (min-width: 1024px) {
.doc-cols { flex-direction: row; align-items: stretch; }
.doc-cols figure.plate { flex: 1; min-width: 0; width: auto; height: auto; min-height: 420px; }
.doc-cols figure.plate.is-live { aspect-ratio: auto; min-height: 420px; max-height: none; }
dl.sheet { flex: 0 0 320px; }
}
@media (prefers-reduced-motion: reduce) {
Expand Down
3 changes: 3 additions & 0 deletions apps/graphs/src/plate/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ function mountFigure(figure: HTMLElement): void {
figure.classList.add("is-live");
svg.style.width = `${content.w}px`;
svg.style.height = `${content.h}px`;
// The drawing's proportions, for the one-column layout's viewport height
// (look.ts): full width, as tall as the fitted drawing within its bounds.
figure.style.setProperty("--plate-ratio", `${content.w} / ${content.h}`);
const viewer: Viewer = {
figure,
svg,
Expand Down
102 changes: 87 additions & 15 deletions apps/graphs/test/__snapshots__/pages.test.ts.snap

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions apps/graphs/test/__snapshots__/plate.test.ts.snap

Large diffs are not rendered by default.

62 changes: 54 additions & 8 deletions apps/graphs/test/e2e/plate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,54 @@
/**
* The document plate viewport, in a browser against the fixture Worker:
* one horizontal plate at every size, as tall as the datasheet beside it
* on desktop (1440x900) and full width at 60svh on a phone (390x844),
* panning under a drag, zooming under `+`, and restored whole by Fit —
* with no vertical twin anywhere. A screenshot per test is attached to
* the report for the operator's review, never compared.
* on desktop (1440x900) and full width on a phone (390x844), as tall as
* the fitted drawing between 200 px and 60svh, panning under a drag,
* zooming under `+`, and restored whole by Fit — every edge path, fail
* arcs included, inside the viewport — with no vertical twin anywhere.
* A screenshot per test is attached to the report for the operator's
* review, never compared.
*/
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";

/** The scale out of a `translate(xpx, ypx) scale(z)` paint. */
function scaleOf(transform: string): number {
return Number(/scale\(([^)]+)\)/.exec(transform)?.[1] ?? Number.NaN);
}

/**
* The drawn edge paths that leave the live viewport: each path's own
* painted box, not the svg's, so an arc cut off by the svg's viewBox
* (which the svg box alone never shows) counts as outside.
*/
async function pathsOutside(page: Page): Promise<string[]> {
return page.evaluate(() => {
const figure = document.querySelector("figure.plate") as HTMLElement;
const svg = figure.querySelector("svg") as SVGSVGElement;
const rect = figure.getBoundingClientRect();
const left = rect.left + figure.clientLeft;
const top = rect.top + figure.clientTop;
const right = left + figure.clientWidth;
const bottom = top + figure.clientHeight;
const viewBox = svg.viewBox.baseVal;
const outside: string[] = [];
for (const path of svg.querySelectorAll("g.edges path")) {
const box = path.getBoundingClientRect();
const own = (path as SVGPathElement).getBBox();
const inViewBox =
own.x >= viewBox.x - 0.01 &&
own.y >= viewBox.y - 0.01 &&
own.x + own.width <= viewBox.x + viewBox.width + 0.01 &&
own.y + own.height <= viewBox.y + viewBox.height + 0.01;
const inViewport =
box.left >= left - 0.5 && box.top >= top - 0.5 && box.right <= right + 0.5 && box.bottom <= bottom + 0.5;
if (!inViewBox || !inViewport) {
outside.push(path.getAttribute("d") ?? "");
}
}
return outside;
});
}

test("the viewport stands as tall as the datasheet at 1440x900", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/g/hello-graph", { waitUntil: "load" });
Expand Down Expand Up @@ -47,7 +83,7 @@ test("the viewport stands as tall as the datasheet at 1440x900", async ({ page }
await testInfo.attach("plate-desktop", { body: shot, contentType: "image/png" });
});

test("one horizontal plate in a 60svh viewport at 390x844", async ({ page }, testInfo) => {
test("one horizontal plate in a viewport as tall as the fitted drawing at 390x844", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/g/hello-graph", { waitUntil: "load" });
const figure = page.locator("figure.plate");
Expand All @@ -57,20 +93,28 @@ test("one horizontal plate in a 60svh viewport at 390x844", async ({ page }, tes
await expect(page.locator("figure.plate svg.plate-h")).toBeVisible();
await expect(page.locator("svg.plate-v")).toHaveCount(0);

// Full width of the content column, 60svh tall.
// Full width of the content column, and as tall as the drawing fitted
// to that width, between 200 px and 60svh: no letterbox bands.
const box = await page.evaluate(() => {
const main = document.querySelector("main.look-col") as HTMLElement;
const style = getComputedStyle(main);
const fig = document.querySelector("figure.plate")!.getBoundingClientRect();
const svg = document.querySelector("figure.plate svg") as SVGSVGElement;
return {
width: fig.width,
column: main.getBoundingClientRect().width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
height: fig.height,
drawn: fig.width * (Number(svg.getAttribute("height")) / Number(svg.getAttribute("width"))),
svh: window.innerHeight * 0.6,
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(Math.abs(box.width - box.column)).toBeLessThanOrEqual(1);
expect(Math.abs(box.height - box.svh)).toBeLessThanOrEqual(2);
expect(Math.abs(box.height - Math.min(box.svh, Math.max(200, box.drawn)))).toBeLessThanOrEqual(2);
expect(box.overflow).toBe(0);
// Every edge, the fail and back arcs above the row included, lies
// inside the viewport at Fit.
expect(await pathsOutside(page)).toEqual([]);

// The node page draws its one node the same way.
await page.goto("/n/hello-node", { waitUntil: "load" });
Expand Down Expand Up @@ -105,6 +149,7 @@ test("drag pans, + zooms, Fit restores the whole graph", async ({ page }, testIn
);
});
expect(await fits()).toBe(true);
expect(await pathsOutside(page)).toEqual([]);

// A drag moves the view.
await figure.scrollIntoViewIfNeeded();
Expand All @@ -131,6 +176,7 @@ test("drag pans, + zooms, Fit restores the whole graph", async ({ page }, testIn
// Fit restores the whole graph, centred in the viewport.
await page.getByRole("button", { name: "Fit the graph" }).click();
expect(await fits()).toBe(true);
expect(await pathsOutside(page)).toEqual([]);
expect(await transform()).toBe(before);

const shot = await page.screenshot({
Expand Down
10 changes: 5 additions & 5 deletions apps/graphs/test/e2e/visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,15 @@ for (const viewport of VIEWPORTS) {
expect(box).not.toBeNull();
expect(Math.abs((box?.height ?? 0) - (viewport.width < 640 ? 96 : 160))).toBeLessThanOrEqual(5);
expect((box?.width ?? 0) + (box?.x ?? 0)).toBeLessThanOrEqual(viewport.width);
// The wizard green gradient is a `fill` presentation attribute —
// never a style attribute, which this page's CSP would refuse — so
// the art runs light to dark, not flat ink.
// The teal gradient is a `fill` presentation attribute — never a
// style attribute, which this page's CSP would refuse — so the art
// is teal, not flat ink.
const inks = await page
.locator("header .look-banner-line")
.evaluateAll((groups) => groups.map((group) => getComputedStyle(group).fill));
expect(inks).toHaveLength(11);
expect(inks[0]).toBe("rgb(157, 207, 161)");
expect(inks[10]).toBe("rgb(12, 82, 18)");
expect(inks[0]).toBe("rgb(82, 255, 246)");
expect(inks[10]).toBe("rgb(0, 102, 97)");
expect(refused.filter((text) => text.includes("Content Security Policy"))).toEqual([]);
await expect(page.locator("header nav.site-nav a[href='/editor']")).toHaveText("Editor");
await expect(page.locator("header nav.site-nav a[href='/publish']")).toHaveText("Publish");
Expand Down
4 changes: 4 additions & 0 deletions apps/graphs/test/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ describe("the plate drawing", () => {
expect(html).toMatch(/\.doc-cols figure\.plate \{[^}]*width: 100%;[^}]*height: 60svh;[^}]*min-height: 320px;/);
expect(html).toMatch(/@media \(min-width: 1024px\) \{[\s\S]*\.doc-cols \{ flex-direction: row; align-items: stretch; \}/);
expect(html).toMatch(/\.doc-cols figure\.plate \{ flex: 1; min-width: 0; width: auto; height: auto; min-height: 420px; \}/);
// Live in one column, the viewport follows the drawing's proportions
// between 200 px and 60svh; beside the datasheet it stretches again.
expect(html).toMatch(/\.doc-cols figure\.plate\.is-live \{[^}]*height: auto;[^}]*aspect-ratio: var\(--plate-ratio, auto\);[^}]*min-height: 200px;[^}]*max-height: 60svh;/);
expect(html).toMatch(/\.doc-cols figure\.plate\.is-live \{ aspect-ratio: auto; min-height: 420px; max-height: none; \}/);
// One labelled shape per node: agents are circles, checks rounded
// squares, gates and routers diamonds — the router reads as a gate.
for (const label of ["build", "test", "gate", "triage", "docs"]) {
Expand Down
4 changes: 3 additions & 1 deletion apps/graphs/test/plate-lane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,11 @@ describe.each(["horizontal", "vertical"] as const)("canopy-builder %s", (orienta

describe("no-handler plates", () => {
it("keeps a handler-free svg byte-identical", async () => {
// Re-pinned when the fail arcs' rise moved into the shifted viewBox
// coordinates and the bounds took the arcs' drawn apex.
const svg = renderPlate(qualityBubble as unknown as GraphDocument);
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(svg));
const hex = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
expect(hex).toBe("1c9a793e36ad803b7f30908e96a7b86250a70bc851c5faa60758f90499241409");
expect(hex).toBe("c42b44147641124a9264d8a5dbe29a150185227696f8daead66a36f9ebe3c4b9");
});
});
89 changes: 89 additions & 0 deletions apps/graphs/test/plate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import helloGraph from "./fixtures/graphs/hello-graph/graph.json" with { type: "
import qualityBubble from "./fixtures/graphs/quality-bubble/graph.json" with { type: "json" };
import singleCommitter from "./fixtures/graphs/single-committer/graph.json" with { type: "json" };
import specLoop from "./fixtures/graphs/spec-loop/graph.json" with { type: "json" };
import canopyBuilder from "./fixtures/graphs/canopy-builder/graph.json" with { type: "json" };
import adversarialDeveloper from "./fixtures/graphs/adversarial-developer/graph.json" with { type: "json" };
import reviewPanel from "./fixtures/patterns/review-panel.figure.json" with { type: "json" };
import resilientMembers from "./fixtures/patterns/resilient-members.figure.json" with { type: "json" };
import {
Expand All @@ -22,6 +24,8 @@ import { renderNodePlate, renderPlate } from "../../../packages/plate/render.js"
import { PLATE_SCALE, layoutPlate } from "../../../packages/plate/layout.js";
import { layoutPlateVertical } from "../../../packages/plate/vertical.js";
import { mulberry32 } from "../../../packages/plate/walk.js";
import { cubicExtent } from "../../../packages/plate/vocabulary.js";
import { BUNDLED_PATTERNS } from "../src/patterns-data.generated.js";
import { ROUND_ROBIN_GRAPH, TUI_POLISH_GRAPH, sixtyNodeGraph } from "./plate-fixture.js";

const GRAPHS: Array<[string, GraphDocument]> = [
Expand Down Expand Up @@ -420,6 +424,91 @@ describe("plate centring", () => {
});
});

/**
* The extent a drawn path actually reaches: its absolute M, L and C
* segments, each cubic by its real turning points rather than its controls.
*/
function pathExtent(d: string): { minX: number; maxX: number; minY: number; maxY: number } {
const extent = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
const take = (x0: number, x1: number, y0: number, y1: number): void => {
extent.minX = Math.min(extent.minX, x0);
extent.maxX = Math.max(extent.maxX, x1);
extent.minY = Math.min(extent.minY, y0);
extent.maxY = Math.max(extent.maxY, y1);
};
let pen = { x: 0, y: 0 };
for (const match of d.matchAll(/([MLC])([^MLC]*)/g)) {
const values = (match[2] ?? "").trim().split(/[\s,]+/).filter((v) => v !== "").map(Number);
if (match[1] === "C") {
for (let i = 0; i + 5 < values.length; i += 6) {
const [x1, y1, x2, y2, x, y] = values.slice(i, i + 6) as [number, number, number, number, number, number];
const xs = cubicExtent(pen.x, x1, x2, x);
const ys = cubicExtent(pen.y, y1, y2, y);
take(xs.min, xs.max, ys.min, ys.max);
pen = { x, y };
}
} else {
for (let i = 0; i + 1 < values.length; i += 2) {
pen = { x: values[i] ?? 0, y: values[i + 1] ?? 0 };
take(pen.x, pen.x, pen.y, pen.y);
}
}
}
return extent;
}

describe("plate bounds", () => {
const documents: Array<[string, GraphDocument]> = [
...GRAPHS,
["canopy-builder", canopyBuilder as unknown as GraphDocument],
["adversarial-developer", adversarialDeveloper as unknown as GraphDocument],
["round-robin", ROUND_ROBIN_GRAPH],
...BUNDLED_PATTERNS.map((pattern): [string, GraphDocument] => [
`pattern ${pattern.slug}`,
JSON.parse(pattern.figure) as GraphDocument,
]),
];

it("cubicExtent reaches an arc's apex, a quarter below its controls", () => {
expect(cubicExtent(40, 0, 0, 40)).toEqual({ min: 10, max: 40 });
expect(cubicExtent(0, 1, 2, 3)).toEqual({ min: 0, max: 3 });
});

for (const orientation of ["horizontal", "vertical"] as const) {
it(`keeps every edge and the walk inside the ${orientation} viewBox, fail arcs included`, () => {
for (const [name, document] of documents) {
const layout = orientation === "horizontal"
? layoutPlate(document, mulberry32(7))
: layoutPlateVertical(document, mulberry32(7));
expect(layout, name).not.toBeNull();
if (layout === null) {
continue;
}
const paths = [
...layout.edges.map((edge) => [`${edge.from}→${edge.to}`, edge.d] as const),
...(layout.walk === null ? [] : [["walk", layout.walk] as const]),
];
for (const [label, d] of paths) {
const reach = pathExtent(d);
const where = `${name} ${orientation} ${label}: ${d}`;
expect(reach.minX, where).toBeGreaterThanOrEqual(0);
expect(reach.minY, where).toBeGreaterThanOrEqual(0);
expect(reach.maxX, where).toBeLessThanOrEqual(layout.width);
expect(reach.maxY, where).toBeLessThanOrEqual(layout.height);
}
}
});
}

it("canopy-builder: the highest fail arc tops out one padding below the viewBox top", () => {
const layout = layoutPlate(canopyBuilder);
const top = Math.min(
...(layout?.edges ?? []).filter((edge) => edge.cls === "fail").map((edge) => pathExtent(edge.d).minY),
);
expect(top).toBeCloseTo(10, 0);
});
});

describe("the vertical orientation", () => {
it("runs ranks top to bottom with labels right of nodes", () => {
const document: GraphDocument = {
Expand Down
19 changes: 19 additions & 0 deletions apps/remote/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,22 @@ Watching is read-only: the remote never pauses, continues or resets a
graph. Against the fake host (`/fake.html` in development) two fixture
graphs — one running with an ensemble, one completed — exercise the whole
view, the running one advancing every 2 seconds.

## Guests

A host shares sessions with another GitHub account through an invite:
`canopy remote invite <login>` prints a single-use code and the guest
link `<origin>/g/<owner_uid>/<host_id>#invite=<code>`. The guest opens
the link and signs in with GitHub (the page comes back to the link after
the sign-in); the code rides the fragment, so it never reaches the
relay's logs or a Referer. The page keeps it in this tab's
sessionStorage only, strips it from the address bar, and opens the
relay's guest door with its canonical `CNR-XXXX-XXXX-XXXX-XXXX` form,
the string whose SHA-256 the host publishes. A link without a code asks
for it in a form (case, spaces and dashes do not matter). The relay
refuses a wrong, expired, revoked or used code before the socket opens;
the page says so and asks again. Each code opens the door once, so a
guest whose relay socket closes after joining needs a fresh invite.
Against the fake host, `/fake.html?screen=guest` starts connected,
`&invite=ask` starts on the form and `&invite=refuse` also refuses the
first code.
2 changes: 1 addition & 1 deletion apps/remote/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const port = e2ePort(5199, import.meta.dirname, "E2E_PORT_SMOKE");

export default defineConfig({
testDir: "./test",
testMatch: ["smoke.spec.ts", "terminal-autofit.spec.ts", "terminal-alt-screen.spec.ts", "terminal-dpr1.spec.ts", "terminal-touch-scroll.spec.ts", "graphs.spec.ts", "editor.spec.ts", "signout.spec.ts", "csp.spec.ts", "metrics.spec.ts", "guest.spec.ts", "footer.spec.ts", "fondo.spec.ts", "touch-targets.spec.ts", "account-chip.spec.ts", "upload.spec.ts"],
testMatch: ["smoke.spec.ts", "terminal-autofit.spec.ts", "terminal-alt-screen.spec.ts", "terminal-dpr1.spec.ts", "terminal-touch-scroll.spec.ts", "graphs.spec.ts", "editor.spec.ts", "signout.spec.ts", "csp.spec.ts", "metrics.spec.ts", "guest.spec.ts", "footer.spec.ts", "fondo.spec.ts", "touch-targets.spec.ts", "account-chip.spec.ts", "upload.spec.ts", "control-size.spec.ts"],
timeout: 60_000,
use: {
baseURL: `http://127.0.0.1:${port}`,
Expand Down
Loading
Loading