diff --git a/docs/design.md b/docs/design.md
index b26f276..98c72bb 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -250,7 +250,7 @@ package of the redesign that builds it. Anything not in this table does not anim
| M1 | Every page | Cross-document view transition, root crossfade 220ms | continuity | none (as today) | WP2 |
| M2 | Home → `/mcp/`, `/standards/` → spec, `/explorer/` → scenario | Shared `view-transition-name` on the data view (matrix plate from `MatrixThumb.astro` as `matrix`, map; on the explorer, the scenario title into the page h1) so it morphs instead of crossfading | "this is the same object, larger" | none | WP3, WP5, WP6 |
| M3 | Home facts, server page facts, spec page facts (`Facts.astro`) | Count-up from 0 to the value, 600ms, once, when 50% in view, tabular figures | "these are live numbers" | value rendered at once | WP3 |
-| M4 | Standards map, decorative plate | Highlighted path draws in (`stroke-dashoffset`), 1200ms, halo fades after, once on load | "follow this path" | path shown complete | WP5, WP8 |
+| M4 | Decorative plate | Highlighted path draws in (`stroke-dashoffset`), 1200ms, halo fades after, once on load. The standards map dropped it in R-12: its spine is two or three short edges, so the draw read as a glitch, not a path; the spine is drawn complete at full strength | "follow this path" | path shown complete | WP8 |
| M5 | Explorer chain | Per step: the sender and receiver light up in the actor head, the current arrow draws in (300ms, ease-out), the body fades up 8px (220ms); earlier steps keep a quieter arrow and later ones a hairline, in colour, not opacity, so text keeps its contrast | "you are here" | arrow and body shown, highlight and dimming applied without transition | WP6, R-08 |
| M6 | Explorer chain | One scene clock (a Web Animations API timeline) holds the playhead; Play, Pause, Previous, Next, the scrub bar, a click on a step or a log line and the arrow keys all move it. Steps advance every 2.5 s. Auto-advance (amended by review follow-up 8): armed once per page, it starts when the chain has sat in view for 1 s with no input, and stops for good on hover, touch, focus, scroll or any key; it never restarts by itself and never starts with a step in the URL. Play, once pressed, pauses on any click, key or scroll | guided reading | Play button hidden, no auto-advance; the scrub bar and the step buttons still seek | WP6, R-08 |
| M7 | Explorer token view | On step change, claims that differ from the previous hop get a 2px `--signal` rail that fades to `--rule` over 480ms, starting when the message has drawn (300ms) and 40ms apart | "this is what changed in the token" | rail shown without fade | WP6, R-08 |
@@ -265,6 +265,7 @@ package of the redesign that builds it. Anything not in this table does not anim
| M16 | Theme toggle | Icon swap only; no rotation | n/a | n/a | WP2 |
| M17 | Explorer toggles | A toggle link swaps the chain in place (crumbs, toggles, notice, steps) through a same-document view transition, 220ms; steps the two chains share morph, the rest fade, and M8 runs on the new rails. The toggle stays its own static page for a reload, a new tab or no JS | "the same chain, with one thing changed" | ordinary navigation | R-08 |
| M18 | Explorer chain | A quiet decision log under the chain gains one line per step reached (number, sender → receiver, message), the newest fading in over 220ms; a line seeks back to its step | "what has happened so far" | lines appear | R-08 |
+| M19 | Standards map | Hovering or focusing a node fades the documents and lines outside its neighbourhood to 22% over 220ms, and its own lines to full colour. The highlight holds for 280ms while the pointer crosses the gap to the next node, so moving across the map changes the highlight once per node instead of flashing back to full strength in every gap | "these are its relations" | applied and lifted at once | R-12 |
Removed from the site by the redesign: the pulsing dot in the work-in-progress banner (it loops),
the hover lifts on cards, buttons and the hero thumbnail, the rotating theme toggle, the ambient
diff --git a/specs/016-visual-redesign/tasks.md b/specs/016-visual-redesign/tasks.md
index cdf87ac..9399b34 100644
--- a/specs/016-visual-redesign/tasks.md
+++ b/specs/016-visual-redesign/tasks.md
@@ -205,3 +205,4 @@ Items from the redesign review, one pull request each, in order.
- [x] R-09 Content pass (WP9 above): section-root headlines under 24 characters, the title suffix and footer statement, an accessibility statement that lists every motion with its reduced-motion form, `check:prose` as a gate, and the about, contribute, made-in-Europe, legal and 404 pages in plain English
- [x] R-10 Decorative plate (WP8 above): a deterministic miniature delegation chain in the head of the explorer and breach indexes and every spec page, beside the title on wide screens, drawn in once (M4) and complete under reduced motion, with social cards for the two section roots from the same generator
- [x] R-11 Spec page composition: under the head, relations and features take the wide column and a panel beside them holds the figures (revisions, relations, features, counting up as M3) and the revision timeline (its rail drawing in as M13, the entries still so their contrast holds), sticky as on a breach page; below 60rem the panel follows the relations. Relation groups and feature topics pack into columns (`columns: 19rem`) instead of one long list; a group heading is the verb phrase alone ("Builds on", "Is built on by") and the source link follows the note. Features group by topic under a bar of how many carry each level, named in words beside it. A spec with no recorded features shows a sentence in the panel, not a zero and not an empty section. The withdrawn or expired notice moves into the main column. Baselines through the `refresh-screenshots` label
+- [x] R-12 Standards map polish (asked by Jan): `layoutGraph` lays out each group of specs joined by dependencies on its own and packs the small groups and the standalone specs (one stack per body) into the columns where they sit highest, under the OAuth tree's short columns, instead of hanging them below its long second column; the drawing is about 40% shorter and the empty lower right is gone. A same-column curve in the last column bows left, inside the frame. The hover highlight no longer flashes: the island delegates pointer events on the drawing, holds the neighbourhood for 280ms while the pointer crosses a gap, redraws only when the node changes, and nodes fade with their lines over `--dur` (M19, new). The spine draw-in and halo on the map are gone (M4 keeps the plate); the spine stays drawn at full strength. Baselines through the `refresh-screenshots` label
diff --git a/src/components/StandardsMap.astro b/src/components/StandardsMap.astro
index edfacf3..0fcc526 100644
--- a/src/components/StandardsMap.astro
+++ b/src/components/StandardsMap.astro
@@ -2,6 +2,7 @@
import { type Layout, PAD, wrapLabel } from "@/lib/graph";
import {
BODY_LABEL,
+ BODY_SHORT,
MATURITY_LABEL,
MATURITY_ORDER,
MATURITY_TAG,
@@ -32,12 +33,15 @@ const usedMaturities = MATURITY_ORDER.filter((m) => specs.some((s) => s.maturity
// references are the most and the least telling lines, so the map opens without them
const shownByDefault = (t: RelationType) => t !== "references";
-// M4: the longest builds-on chain draws in from the oldest document to the newest
+// the longest builds-on chain, drawn at full strength as the map's one emphasised path
const spine = buildsOnSpine(specs, relations);
-const spineStep = new Map(spine.map((r, i) => [`${r.from}|${r.type}|${r.to}`, i]));
+const spineKeys = new Set(spine.map((r) => `${r.from}|${r.type}|${r.to}`));
const spineFirst = spine[0] ? byId.get(spine[0].to) : undefined;
const spineLast = spine.at(-1) ? byId.get(spine.at(-1)?.from ?? "") : undefined;
+// the body toggles sit two to a row; a long name takes its short form so each stays one line
+const railLabel = (b: SpecBody) => (BODY_LABEL[b].length > 12 ? BODY_SHORT[b] : BODY_LABEL[b]);
+
const draft = new Set(["individual-draft", "working-group-draft", "candidate"]);
// 13px Geist at weight 500 averages a little under 7px a character
const chipWidth = (text: string) => Math.round(text.length * 7 + 16);
@@ -61,10 +65,10 @@ const unchecked = (name: string, value: string) =>
const filterCss = [
...usedTypes.map(
(t) =>
- `${unchecked("type", t)} :is(#map .edge-${t}, #map .spine-halo[data-type="${t}"]) { visibility: hidden }\n` +
+ `${unchecked("type", t)} #map .edge-${t} { visibility: hidden }\n` +
`${unchecked("type", t)} #spec-list .xl[data-type="${t}"] { display: none }`,
),
- `${scope}:has(input[name="view"][value="recent"]:checked) :is(#map .node:not([data-recent]), #map .band, #map .edge, #map .spine-halo) { opacity: 0.2 }`,
+ `${scope}:has(input[name="view"][value="recent"]:checked) :is(#map .node:not([data-recent]), #map .band, #map .edge) { opacity: 0.2 }`,
`${scope}:has(input[name="view"][value="recent"]:checked) #spec-list li:not([data-recent]) > .row { opacity: 0.35 }`,
...bodies.map(
(b) =>
@@ -98,7 +102,9 @@ const filterCss = [
bodies.map((b) => (
))
}
@@ -182,34 +188,15 @@ const filterCss = [
class="band-box"
aria-hidden="true"
/>
-
- {BODY_LABEL[band.body]}
-
))
}
- {
- edges
- .filter((e) => spineStep.has(key(e)))
- .map((e) => (
-
- ))
- }
{
edges.map((e) => {
- const step = spineStep.get(key(e));
- const onSpine = step !== undefined;
+ const onSpine = spineKeys.has(key(e));
return (
+ {/* the band names sit above the lines, so a line through the gap never runs over one */}
+
+ {
+ bands.map((band) => (
+
+ {BODY_LABEL[band.body]}
+
+ ))
+ }
+
+
{
nodes.map((n) => {
@@ -529,10 +525,15 @@ const filterCss = [
.band-box {
fill: color-mix(in oklch, var(--ink-2) 7%, transparent);
}
+ /* a line that runs through the gap passes behind the label, which keeps a rim of the plate */
.band-label {
font-size: 11px;
font-weight: 500;
fill: var(--ink-2);
+ stroke: var(--ground-2);
+ stroke-width: 4px;
+ stroke-linejoin: round;
+ paint-order: stroke;
}
:global(#map-frame[data-enhanced] .band-label) {
cursor: pointer;
@@ -561,7 +562,7 @@ const filterCss = [
stroke: var(--edge);
stroke-width: 1.5;
transition:
- opacity var(--dur-fast) var(--ease),
+ opacity var(--dur) var(--ease),
stroke var(--dur-fast) var(--ease);
}
.edge-builds-on,
@@ -597,56 +598,16 @@ const filterCss = [
fill: var(--c);
}
- /* the builds-on spine: full colour, drawn in once on load from the oldest end (M4) */
+ /* the builds-on spine: full colour and a little heavier, so the eye finds one path first */
.edge.spine {
--edge: var(--c);
stroke-width: 2;
- stroke-dasharray: 1;
- }
- .spine-halo {
- fill: none;
- stroke: var(--signal-soft);
- stroke-width: 9;
- stroke-linecap: round;
- stroke-dasharray: 1;
- opacity: 0;
- }
- @media (prefers-reduced-motion: no-preference) {
- .edge.spine {
- animation: map-draw calc(var(--dur-draw) / var(--n)) linear
- calc(var(--dur-draw) / var(--n) * var(--i)) both;
- }
- .spine-halo {
- animation:
- map-draw calc(var(--dur-draw) / var(--n)) linear
- calc(var(--dur-draw) / var(--n) * var(--i)) both,
- map-halo calc(var(--dur-draw) + var(--dur-slow)) linear both;
- }
- }
- /* a negative offset reveals the path from its end, where the older document sits */
- @keyframes map-draw {
- from {
- stroke-dashoffset: -1;
- }
- to {
- stroke-dashoffset: 0;
- }
- }
- /* the halo shows while the spine draws (1200 of 1680ms), then fades out */
- @keyframes map-halo {
- 0%,
- 100% {
- opacity: 0;
- }
- 2%,
- 71% {
- opacity: 1;
- }
}
.node {
cursor: pointer;
text-decoration: none;
+ transition: opacity var(--dur) var(--ease);
}
.node-box {
fill: var(--ground);
@@ -712,9 +673,12 @@ const filterCss = [
fill: var(--ink-2);
}
- /* the neighbourhood highlight, driven by the island */
+ /*
+ * the neighbourhood highlight, driven by the island (M19): the rest of the map steps back over
+ * 220ms rather than blinking, and the island holds it while the pointer crosses a gap
+ */
:global(#map .is-far) {
- opacity: 0.18;
+ opacity: 0.22;
}
:global(#map .edge.is-near) {
--edge: var(--c);
diff --git a/src/components/StandardsMap.svelte b/src/components/StandardsMap.svelte
index 5f23ba2..96c2c54 100644
--- a/src/components/StandardsMap.svelte
+++ b/src/components/StandardsMap.svelte
@@ -96,6 +96,7 @@ onMount(() => {
c,
]),
);
+ let shown: string | null = null;
function show(id: string | null) {
if (!card) return;
const next = id ? cards.get(id) : undefined;
@@ -112,6 +113,8 @@ onMount(() => {
(neighbours.get(b) ?? neighbours.set(b, new Set()).get(b))?.add(a);
}
function highlight(id: string | null) {
+ if (id === shown) return;
+ shown = id;
show(id);
if (!id) {
for (const el of [...nodes, ...edges]) el.classList.remove("is-near", "is-far");
@@ -130,11 +133,38 @@ onMount(() => {
edge.classList.toggle("is-far", !isNear);
}
}
+ /*
+ * The pointer crosses empty space between two nodes on its way from one to the next. Letting
+ * go at once would flash the whole map back to full strength in every gap, so the highlight
+ * stays through a short grace and only lifts when the pointer rests off the nodes or leaves.
+ */
+ const GRACE = 280;
+ let release = 0;
+ const hold = () => window.clearTimeout(release);
+ const letGo = (delay: number) => {
+ hold();
+ release = window.setTimeout(() => highlight(null), delay);
+ };
+ const nodeAt = (target: EventTarget | null) =>
+ target instanceof Element ? target.closest("a[data-spec]") : null;
+ svg.addEventListener("pointerover", (event) => {
+ if ((event as PointerEvent).pointerType === "touch") return;
+ const node = nodeAt(event.target);
+ if (!node) return;
+ hold();
+ highlight(node.dataset.spec ?? null);
+ });
+ svg.addEventListener("pointerout", (event) => {
+ if ((event as PointerEvent).pointerType === "touch") return;
+ if (nodeAt(event.target) && !nodeAt((event as PointerEvent).relatedTarget)) letGo(GRACE);
+ });
+ svg.addEventListener("pointerleave", () => letGo(0));
for (const node of nodes) {
const id = node.dataset.spec ?? null;
- node.addEventListener("mouseenter", () => highlight(id));
- node.addEventListener("mouseleave", () => highlight(null));
- node.addEventListener("focus", () => highlight(id));
+ node.addEventListener("focus", () => {
+ hold();
+ highlight(id);
+ });
node.addEventListener("blur", () => highlight(null));
}
diff --git a/src/lib/graph.ts b/src/lib/graph.ts
index 5175a7e..157baa3 100644
--- a/src/lib/graph.ts
+++ b/src/lib/graph.ts
@@ -1,4 +1,4 @@
-import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js";
+import ELK, { type ElkNode } from "elkjs/lib/elk.bundled.js";
import {
isDependency,
isSymmetric,
@@ -7,7 +7,7 @@ import {
type Spec,
type SpecBody,
} from "../schema";
-import { BODY_ORDER, compareSpecs } from "./standards";
+import { compareSpecs } from "./standards";
/**
* Build-time layout for the standards map (research R2). Deterministic: the same nodes and
@@ -18,7 +18,8 @@ import { BODY_ORDER, compareSpecs } from "./standards";
* algorithm puts a spec one layer right of what it builds on and routes those edges
* orthogonally around the nodes. Within a layer the nodes keep model order, which is
* `compareSpecs` order, so each column groups by publishing body in BODY_ORDER; a band behind
- * each group carries the body's name. The cross links (references, competes-with,
+ * each group carries the body's name. Each group of specs joined by dependencies is laid out
+ * on its own and the small ones are packed under the large one, see `layoutGraph`. The cross links (references, competes-with,
* solves-same-problem-as) are not part of the shape and are drawn as curves over it.
*/
@@ -118,7 +119,7 @@ function sideAnchor(node: PlacedNode, towardsY: number): number {
return Math.min(node.y + node.h - inset, Math.max(node.y + inset, towardsY));
}
-function route(from: PlacedNode, to: PlacedNode): { d: string; mid: Point } {
+function route(from: PlacedNode, to: PlacedNode, right: number): { d: string; mid: Point } {
let p0: Point;
let p3: Point;
let p1: Point;
@@ -140,11 +141,12 @@ function route(from: PlacedNode, to: PlacedNode): { d: string; mid: Point } {
p1 = { x: p0.x + dx, y: p0.y };
p2 = { x: p3.x - dx, y: p3.y };
} else {
- // same column: bow out to the right of both nodes
+ // same column: bow out to the right of both nodes, or to the left in the last column
const down = to.y > from.y;
- p0 = { x: from.x + from.w, y: from.y + from.h / 2 + (down ? 10 : -10) };
- p3 = { x: to.x + to.w, y: to.y + to.h / 2 + (down ? -10 : 10) };
- const bow = COL_GAP * 0.55;
+ const side = from.x + from.w >= right ? 0 : from.w;
+ const bow = COL_GAP * 0.55 * (side ? 1 : -1);
+ p0 = { x: from.x + side, y: from.y + from.h / 2 + (down ? 10 : -10) };
+ p3 = { x: to.x + side, y: to.y + to.h / 2 + (down ? -10 : 10) };
p1 = { x: p0.x + bow, y: p0.y };
p2 = { x: p3.x + bow, y: p3.y };
}
@@ -153,6 +155,63 @@ function route(from: PlacedNode, to: PlacedNode): { d: string; mid: Point } {
return { d, mid: { x: round(m.x), y: round(m.y) } };
}
+/** The vertical gap above a packed component: room for its band label and a clear break. */
+const PACK_GAP = 2 * ROW_GAP;
+
+const ELK_OPTIONS: Record = {
+ "elk.algorithm": "layered",
+ "elk.direction": "RIGHT",
+ "elk.edgeRouting": "ORTHOGONAL",
+ "elk.padding": `[top=${PAD + ROW_GAP},left=${PAD},bottom=${PAD},right=${PAD}]`,
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(COL_GAP),
+ "elk.layered.spacing.edgeNodeBetweenLayers": "20",
+ "elk.spacing.nodeNode": String(ROW_GAP),
+ "elk.spacing.edgeNode": "12",
+ "elk.spacing.edgeEdge": "8",
+ "elk.separateConnectedComponents": "false",
+ "elk.layered.crossingMinimization.forceNodeModelOrder": "true",
+ "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
+ "elk.layered.mergeEdges": "true",
+};
+
+/**
+ * The parts of the drawing: specs joined by dependency relations, the largest first and the rest
+ * in `compareSpecs` order of their first spec. Specs with no dependency either way stack in one
+ * part per publishing body, so each body keeps one band.
+ */
+function components(sorted: Spec[], dependencies: Relation[]): Spec[][] {
+ const parent = new Map(sorted.map((s) => [s.id, s.id]));
+ const find = (id: string): string => {
+ const p = parent.get(id) ?? id;
+ if (p === id) return id;
+ const root = find(p);
+ parent.set(id, root);
+ return root;
+ };
+ const linked = new Set();
+ for (const r of dependencies) {
+ linked.add(r.from);
+ linked.add(r.to);
+ parent.set(find(r.from), find(r.to));
+ }
+ const groups = new Map();
+ for (const s of sorted) {
+ const key = linked.has(s.id) ? find(s.id) : `body:${s.body}`;
+ const list = groups.get(key) ?? [];
+ list.push(s);
+ groups.set(key, list);
+ }
+ const parts = [...groups.values()];
+ const largest = parts.reduce((a, b) => (b.length > a.length ? b : a), parts[0] ?? []);
+ return [largest, ...parts.filter((p) => p !== largest)];
+}
+
+/**
+ * Build-time layout. The largest part is laid out first and sets the columns; every other part
+ * is laid out on its own and dropped into the columns where it sits highest, under what is
+ * already there, so the small trees and the standalone specs fill the space the long first
+ * column leaves free instead of hanging below it.
+ */
export async function layoutGraph(specs: Spec[], relations: Relation[]): Promise {
const sorted = [...specs].sort(compareSpecs);
const ids = new Set(sorted.map((s) => s.id));
@@ -160,41 +219,86 @@ export async function layoutGraph(specs: Spec[], relations: Relation[]): Promise
const dependencies = relations.filter(
(r) => isDependency(r.type) && ids.has(r.from) && ids.has(r.to),
);
- const graph: ElkNode = {
- id: "root",
- layoutOptions: {
- "elk.algorithm": "layered",
- "elk.direction": "RIGHT",
- "elk.edgeRouting": "ORTHOGONAL",
- "elk.padding": `[top=${PAD + ROW_GAP},left=${PAD},bottom=${PAD},right=${PAD}]`,
- "elk.layered.spacing.nodeNodeBetweenLayers": String(COL_GAP),
- "elk.layered.spacing.edgeNodeBetweenLayers": "20",
- "elk.spacing.nodeNode": String(ROW_GAP),
- "elk.spacing.edgeNode": "12",
- "elk.spacing.edgeEdge": "8",
- "elk.separateConnectedComponents": "false",
- "elk.layered.crossingMinimization.forceNodeModelOrder": "true",
- "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
- "elk.layered.mergeEdges": "true",
- },
- children: sorted.map((s) => ({ id: s.id, width: NODE_W, height: NODE_H })),
- // the older document is the source, so the drawing reads left to right in time
- edges: dependencies.map((r, i) => ({ id: `d${i}`, sources: [r.to], targets: [r.from] })),
- };
- const out = await new ELK().layout(graph);
+ const elk = new ELK();
const placed = new Map();
- for (const child of out.children ?? []) {
- const spec = sorted.find((s) => s.id === child.id);
- if (!spec) continue;
- placed.set(spec.id, {
- spec,
- x: round(child.x ?? 0),
- y: round(child.y ?? 0),
- w: NODE_W,
- h: NODE_H,
- depth: depth.get(spec.id) ?? 0,
- });
+ const routed = new Map();
+ /** the x of each column, from the first part */
+ let columnX: number[] = [];
+ /** the lowest point drawn so far in each column, and in the gap right of each column */
+ const bottom: number[] = [];
+ const gapBottom: number[] = [];
+ const mark = (x: number, y: number) => {
+ const i = columnX.findIndex((cx) => x <= cx + NODE_W);
+ const c = i === -1 ? columnX.length - 1 : i;
+ const inGap = i === -1 || x < (columnX[c] ?? 0);
+ const list = inGap ? gapBottom : bottom;
+ const at = inGap && i !== -1 ? c - 1 : c;
+ list[at] = Math.max(list[at] ?? 0, y);
+ };
+
+ for (const [index, part] of components(sorted, dependencies).entries()) {
+ const own = new Set(part.map((s) => s.id));
+ const deps = dependencies.filter((r) => own.has(r.from));
+ const graph: ElkNode = {
+ id: "root",
+ layoutOptions: ELK_OPTIONS,
+ children: part.map((s) => ({ id: s.id, width: NODE_W, height: NODE_H })),
+ // the older document is the source, so the drawing reads left to right in time
+ edges: deps.map((r, i) => ({ id: `d${i}`, sources: [r.to], targets: [r.from] })),
+ };
+ const out = await elk.layout(graph);
+ const children = out.children ?? [];
+ const localX = [...new Set(children.map((c) => round(c.x ?? 0)))].sort((a, b) => a - b);
+ const top = Math.min(...children.map((c) => c.y ?? 0));
+ let dx = 0;
+ let dy = 0;
+ if (index === 0) {
+ columnX = localX;
+ } else {
+ // the leftmost run of columns where the part's top can sit highest
+ const span = localX.length;
+ let best = { start: 0, y: Number.POSITIVE_INFINITY };
+ for (let start = 0; start + span <= Math.max(columnX.length, span); start++) {
+ let y = 0;
+ for (let j = 0; j < span; j++) {
+ y = Math.max(y, (bottom[start + j] ?? 0) + PACK_GAP);
+ if (j < span - 1) y = Math.max(y, (gapBottom[start + j] ?? 0) + PACK_GAP);
+ }
+ if (y < best.y) best = { start, y };
+ }
+ dx = (columnX[best.start] ?? PAD) - (localX[0] ?? 0);
+ dy = best.y - top;
+ }
+ for (const child of children) {
+ const spec = part.find((s) => s.id === child.id);
+ if (!spec) continue;
+ const node: PlacedNode = {
+ spec,
+ x: round((child.x ?? 0) + dx),
+ y: round((child.y ?? 0) + dy),
+ w: NODE_W,
+ h: NODE_H,
+ depth: depth.get(spec.id) ?? 0,
+ };
+ placed.set(spec.id, node);
+ }
+ for (const [i, r] of deps.entries()) {
+ const section = out.edges?.find((x) => x.id === `d${i}`)?.sections?.[0];
+ if (!section) continue;
+ // ELK ran the edge from the older document; the arrow points at it, so reverse the points
+ const points = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]
+ .map((p) => ({ x: round(p.x + dx), y: round(p.y + dy) }))
+ .reverse();
+ routed.set(`${r.from}|${r.type}|${r.to}`, points);
+ }
+ for (const spec of part) {
+ const n = placed.get(spec.id);
+ if (n) mark(n.x, n.y + n.h);
+ }
+ for (const r of deps) {
+ for (const p of routed.get(`${r.from}|${r.type}|${r.to}`) ?? []) mark(p.x, p.y);
+ }
}
const nodes = sorted.map((s) => placed.get(s.id)).filter((n): n is PlacedNode => !!n);
@@ -226,38 +330,30 @@ export async function layoutGraph(specs: Spec[], relations: Relation[]): Promise
}
}
+ const right = Math.max(...nodes.map((n) => n.x + n.w), 0);
const edges: PlacedEdge[] = [];
- const routed = new Map();
- for (const [i, r] of dependencies.entries()) {
- const e = out.edges?.find((x) => x.id === `d${i}`);
- if (e) routed.set(`${r.from}|${r.type}|${r.to}`, e);
- }
for (const relation of relations) {
const from = placed.get(relation.from);
const to = placed.get(relation.to);
if (!from || !to) continue;
const symmetric = isSymmetric(relation.type);
- const e = routed.get(`${relation.from}|${relation.type}|${relation.to}`);
- const section = e?.sections?.[0];
- if (section) {
- // ELK ran the edge from the older document; the arrow points at it, so reverse the points
- const points = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]
- .map((p) => ({ x: round(p.x), y: round(p.y) }))
- .reverse();
+ const points = routed.get(`${relation.from}|${relation.type}|${relation.to}`);
+ if (points) {
const d = points.map((p, j) => `${j === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
const m = points[Math.floor(points.length / 2)] ?? points[0] ?? { x: 0, y: 0 };
edges.push({ relation, from, to, symmetric, d, mid: m });
} else {
- edges.push({ relation, from, to, symmetric, ...route(from, to) });
+ edges.push({ relation, from, to, symmetric, ...route(from, to, right) });
}
}
+ const low = Math.max(...nodes.map((n) => n.y + n.h), ...bottom, ...gapBottom, 0);
return {
nodes,
edges,
bands,
columns: columnsX.length,
- width: Math.ceil(out.width ?? 0),
- height: Math.ceil(out.height ?? 0),
+ width: Math.ceil(right + PAD),
+ height: Math.ceil(low + PAD),
};
}
diff --git a/src/pages/accessibility.astro b/src/pages/accessibility.astro
index dfc3501..8f83d3d 100644
--- a/src/pages/accessibility.astro
+++ b/src/pages/accessibility.astro
@@ -16,8 +16,8 @@ const motions = [
},
{
where: "Standards map",
- what: "The longest builds-on path draws itself in once, with a soft halo that fades after.",
- reduced: "The path is drawn complete, with no halo.",
+ what: "When you point at or focus a document, the documents it is not related to fade back over 220 ms. The highlight stays while the pointer moves to the next document.",
+ reduced: "The highlight applies and lifts at once.",
},
{
where: "Explorer, breaches and standard pages",
diff --git a/tests/e2e/__screenshots__/standards.spec.ts/standards-greyscale-dark.png b/tests/e2e/__screenshots__/standards.spec.ts/standards-greyscale-dark.png
index 5931cde..9ac0379 100644
Binary files a/tests/e2e/__screenshots__/standards.spec.ts/standards-greyscale-dark.png and b/tests/e2e/__screenshots__/standards.spec.ts/standards-greyscale-dark.png differ
diff --git a/tests/e2e/__screenshots__/standards.spec.ts/standards-greyscale-light.png b/tests/e2e/__screenshots__/standards.spec.ts/standards-greyscale-light.png
index 87b7bf3..45f0284 100644
Binary files a/tests/e2e/__screenshots__/standards.spec.ts/standards-greyscale-light.png and b/tests/e2e/__screenshots__/standards.spec.ts/standards-greyscale-light.png differ
diff --git a/tests/e2e/standards.spec.ts b/tests/e2e/standards.spec.ts
index 7eae3bd..e0c6111 100644
--- a/tests/e2e/standards.spec.ts
+++ b/tests/e2e/standards.spec.ts
@@ -178,40 +178,71 @@ test.describe("standards map (US1)", () => {
await expect(page).not.toHaveURL(/view=/);
});
- test("the builds-on spine draws in once, from the oldest document (M4)", async ({ page }) => {
+ test("the map does not animate on load: the spine is drawn complete at full strength", async ({
+ page,
+ }) => {
test.skip(spine.length === 0, "no builds-on chain in the seed data");
await page.goto("/standards/");
test.skip((page.viewportSize()?.width ?? 1280) < 640, "phones get the outline");
const edges = page.locator("#map path.edge.spine");
await expect(edges).toHaveCount(spine.length);
- const runs = await edges.evaluateAll((els) =>
- els.flatMap((el) =>
- el.getAnimations().map((a) => ({
- name: (a as CSSAnimation).animationName,
- iterations: a.effect?.getTiming().iterations,
- })),
- ),
+ const runs = await page
+ .locator("#map")
+ .evaluate((svg) => svg.getAnimations({ subtree: true }).length);
+ expect(runs).toBe(0);
+ const dashes = await edges.evaluateAll((els) =>
+ els.map((el) => getComputedStyle(el).strokeDasharray),
);
- expect(runs.length).toBe(spine.length);
- for (const run of runs) expect(run).toEqual({ name: "map-draw", iterations: 1 });
+ for (const dash of dashes) expect(dash).toBe("none");
const first = spine[0];
await expect(
page.locator(`#map path[data-relation="${first?.from}|builds-on|${first?.to}"]`),
).toHaveClass(/spine/);
});
- test("under reduced motion the spine is drawn complete with no halo (M4)", async ({ page }) => {
+ test("the highlight holds while the pointer crosses the gap to the next node", async ({
+ page,
+ }) => {
+ await page.goto("/standards/");
+ test.skip((page.viewportSize()?.width ?? 1280) < 640, "phones get the outline");
+ await expect(page.locator("#map-frame[data-enhanced]")).toBeAttached();
+ const map = page.locator("#map");
+ const [a, b] = await map.locator("a[data-spec]").evaluateAll((els) => {
+ const boxes = els.map((el) => ({
+ id: el.getAttribute("data-spec"),
+ box: el.getBoundingClientRect(),
+ }));
+ // two nodes stacked in one column, so the pointer has a gap to cross between them
+ for (const one of boxes)
+ for (const two of boxes)
+ if (
+ one.box.x === two.box.x &&
+ two.box.y > one.box.bottom &&
+ two.box.y - one.box.bottom < 40
+ )
+ return [one, two];
+ return [];
+ });
+ test.skip(!a || !b, "no two stacked nodes");
+ const x = (a?.box.x ?? 0) + 40;
+ await page.mouse.move(x, (a?.box.y ?? 0) + 20);
+ await expect(map.locator("a.is-far").first()).toBeAttached();
+ // in the gap the map keeps the first node's neighbourhood instead of flashing back
+ await page.mouse.move(x, ((a?.box.bottom ?? 0) + (b?.box.y ?? 0)) / 2);
+ expect(await map.locator("a[data-spec].is-far").count()).toBeGreaterThan(0);
+ await page.mouse.move(x, (b?.box.y ?? 0) + 20);
+ await expect(page.locator("#map-card")).toContainText(
+ nodes.find((s) => s.id === b?.id)?.name ?? "",
+ );
+ // resting off the nodes lets go
+ await page.mouse.move(x, ((a?.box.bottom ?? 0) + (b?.box.y ?? 0)) / 2);
+ await expect(map.locator(".is-far")).toHaveCount(0);
+ });
+
+ test("under reduced motion the map shows its end state", async ({ page }) => {
await page.goto("/standards/");
test.skip((page.viewportSize()?.width ?? 1280) < 640, "phones get the outline");
await expectEndStateUnderReducedMotion(page, "#map-frame");
- const offsets = await page
- .locator("#map path.edge.spine")
- .evaluateAll((els) => els.map((el) => getComputedStyle(el).strokeDashoffset));
- for (const offset of offsets) expect(offset).toBe("0px");
- const halos = await page
- .locator("#map .spine-halo")
- .evaluateAll((els) => els.map((el) => getComputedStyle(el).opacity));
- for (const opacity of halos) expect(opacity).toBe("0");
});
test("filter state in the URL restores the filtered view (FR-007)", async ({ page }) => {
diff --git a/tests/unit/graph.test.ts b/tests/unit/graph.test.ts
index bbd9264..6749187 100644
--- a/tests/unit/graph.test.ts
+++ b/tests/unit/graph.test.ts
@@ -146,6 +146,34 @@ describe("layoutGraph", () => {
expect(ref?.d).toContain(" C");
});
+ it("packs a small tree into the free columns instead of hanging it under the drawing", async () => {
+ const more = [
+ ...specs,
+ spec("par", "ietf-oauth"),
+ spec("jar", "ietf-oauth"),
+ spec("ext", "mcp"),
+ spec("wa", "ietf-wimse"),
+ spec("wb", "ietf-wimse"),
+ ];
+ const packed = await layoutGraph(more, [
+ ...relations,
+ { from: "par", to: "oauth", type: "builds-on" },
+ { from: "jar", to: "oauth", type: "builds-on" },
+ { from: "ext", to: "mcp", type: "builds-on" },
+ { from: "wb", to: "wa", type: "builds-on" },
+ ]);
+ const at = (id: string) => packed.nodes.find((n) => n.spec.id === id);
+ const wa = at("wa");
+ const wb = at("wb");
+ // the bottom of the first part's longest column
+ const first = new Set(["oauth", "pkce", "oidc", "mcp", "par", "jar", "ext"]);
+ const tallest = Math.max(
+ ...packed.nodes.filter((n) => first.has(n.spec.id)).map((n) => n.y + n.h),
+ );
+ expect(wa && wb && wb.x > wa.x).toBe(true);
+ expect(Math.max(wa?.y ?? 0, wb?.y ?? 0)).toBeLessThan(tallest);
+ });
+
it("is deterministic", async () => {
const again = await layoutGraph([...specs].reverse(), [...relations].reverse());
expect(JSON.stringify(again.nodes.map((n) => [n.spec.id, n.x, n.y]))).toBe(