diff --git a/package.json b/package.json index 9ccf69e..c4b8a15 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@lhci/cli": "^0.15.1", "@playwright/test": "^1.64.0", "@types/node": "^22", + "elkjs": "^0.12.0", "tsx": "^4.23.15", "typescript": "^5.9.0", "vitest": "^5.0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e84f0bf..80825ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@types/node': specifier: ^22 version: 22.20.5 + elkjs: + specifier: ^0.12.0 + version: 0.12.0 tsx: specifier: ^4.23.15 version: 4.23.15 @@ -1383,6 +1386,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + elkjs@0.12.0: + resolution: {integrity: sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -4151,6 +4157,8 @@ snapshots: ee-first@1.1.1: {} + elkjs@0.12.0: {} + emoji-regex@8.0.0: {} encodeurl@2.0.0: {} diff --git a/specs/016-visual-redesign/plan.md b/specs/016-visual-redesign/plan.md index 0abb1e3..290ac3d 100644 --- a/specs/016-visual-redesign/plan.md +++ b/specs/016-visual-redesign/plan.md @@ -70,8 +70,12 @@ Non-goals are refreshed per package so an agent can verify its own change. - VII — European by default: fonts stay self-hosted; no new third party. -No deviation. One intentional change to a documented convention: headlines move from Bricolage -Grotesque to Geist (section 3.3). `CLAUDE.md`, `docs/architecture.md` and `tokens.css` are +One deviation, added by the review follow-up R-04: `elkjs` (the Eclipse Layout Kernel compiled +to JavaScript) lays out the standards map at build time. It is a dev dependency, runs only in +`src/lib/graph.ts` during `astro build`, ships no bytes to visitors and is deterministic for a +fixed input, so screenshot baselines stay stable. The hand-rolled column layout it replaces could +not route edges around nodes, and the map was unreadable with 67 free curves. One intentional +change to a documented convention: headlines move from Bricolage Grotesque to Geist (section 3.3). `CLAUDE.md`, `docs/architecture.md` and `tokens.css` are updated in the same package (WP1), so the repository never contradicts itself. ## 3. Design language @@ -278,7 +282,11 @@ becomes; WP2 builds the shared ones, later packages the product-specific ones. - `StandardsMap.astro` / `.svelte`: nodes as rounded rectangles on `--ground-2` with `--ink` labels; edges in `--ink-2` at 35%; the hovered node's neighbourhood in `--signal`; relation types by dash pattern, not by colour (the legend says so). Path draw-in (M4) on the - `builds-on` spine from the oldest to the newest spec. + `builds-on` spine from the oldest to the newest spec. After R-04: a rail beside the drawing + is legend, filter and reading pane in one (checkboxes filtering through `:has()` rules, no + script needed); references are off by default; the dependency edges are routed by ELK and + the cross links drawn as curves; below 40rem the drawing gives way to `StandardsOutline.astro`, + the dependency forest as an indented outline. - `ChainPage.astro`, `Explorer.svelte`, `TokenView.astro`: lanes as 1px `--rule` lifelines; arrows as 1.5px `--ink-2` lines with the current one in `--signal` 2px; toggled steps get a `--caution`/`--threat` rail; removed steps struck and dimmed. Stepper controls inside an diff --git a/specs/016-visual-redesign/tasks.md b/specs/016-visual-redesign/tasks.md index 38489ec..e42b028 100644 --- a/specs/016-visual-redesign/tasks.md +++ b/specs/016-visual-redesign/tasks.md @@ -165,3 +165,4 @@ Items from the redesign review, one pull request each, in order. - [x] R-02 Small fixes: `.notice`, `.notice.caution` and `.card.notice` tint in oklab, not oklch (the toggle notice in `ChainPage.astro` keeps its local tint in the kind's ink); the link after "in the" on `/explorer/` and after "See" on `/standards/` no longer loses its space to HTML compression; the entry-format `pre` on `/contribute/` is focusable (axe scrollable-region-focusable) - [x] R-02a Imagery on the new system (asked on the R-02 pull request): the social card (`public/og.png`) redrawn as the home hero on flat tokens with the real matrix, replacing the pre-redesign card (glow, shadow, dot grid, slogan); the Apple touch icon on `--ground`; the README hero and how-it-works pictures on the new tokens without glow, shadow, gradient or dot grid, with MatrixThumb's marks and the home headline. The favicon and `public/brand/` already match the supplied logo and are unchanged - [x] R-03 Nav wording: the primary and footer navigation read Servers, Standards, Explorer, Breaches, Contribute (Scanner stays in the footer); every `` trail, the page-foot cross link on `/standards/` and the 404 buttons use the same words; `/mcp/` and `/standards/` take Servers and Standards as `` and eyebrow. "Atlas" stays the product word in ledes and body copy +- [x] R-04 Standards map rework (PR #62): the head is "The standards map" with the counts in the lede. A rail beside the drawing is legend, filter and reading pane in one: relation types as line-sample toggles (references off by default), publishing bodies as toggles (a band label in the drawing toggles the same), maturity chips, the live count, a reset, and a card that names the hovered or focused node's relations in words. The toggles are checkboxes that filter the drawing and the outline through `:has()` rules in the stylesheet, so filtering works without JavaScript; the island keeps the count, mirrors the state in the URL, shows the card and highlights the neighbourhood. "Recently revised" is a view of the map (dated nodes, the rest fades), not a list in the head. The drawing is laid out by `elkjs` at build time (`src/lib/graph.ts`): a spec sits a column right of what it builds on, columns group by body behind tinted bands, dependency edges are routed orthogonally, cross links stay curves. Below 40rem the drawing gives way to `StandardsOutline.astro`: the dependency forest (`buildForest`) as an indented outline, cross links as `<details>` chips, the spine as a hairline; the outline replaces the flat list on every screen and carries body, family, revision and both links (FR-015). Baselines through the `refresh-screenshots` label diff --git a/src/components/StandardsMap.astro b/src/components/StandardsMap.astro index 341004e..edfacf3 100644 --- a/src/components/StandardsMap.astro +++ b/src/components/StandardsMap.astro @@ -8,29 +8,33 @@ import { RELATION_DESCRIPTION, RELATION_LABEL, } from "@/lib/labels"; -import { buildsOnSpine, RELATION_TYPES } from "@/lib/standards"; -import type { RelationType } from "@/schema"; +import { buildsOnSpine, RELATION_TYPES, type RelationGroups } from "@/lib/standards"; +import type { RelationType, SpecBody } from "@/schema"; import MaturityTag from "./MaturityTag.astro"; interface Props { layout: Layout; + /** publishing bodies on the map, in lane order */ + bodies: SpecBody[]; + groupsOf: (id: string) => RelationGroups; + /** the recently revised specs, id to revision date: a view of the map, not a list beside it */ + recent: Map<string, string>; } -const { layout } = Astro.props; -const { nodes, edges, lanes, width, height } = layout; +const { layout, bodies, groupsOf, recent } = Astro.props; +const { nodes, edges, bands, width, height } = layout; const byId = new Map(nodes.map((n) => [n.spec.id, n.spec])); const specs = nodes.map((n) => n.spec); const relations = edges.map((e) => e.relation); const usedTypes = RELATION_TYPES.filter((t) => relations.some((r) => r.type === t)); const directed = usedTypes.filter((t) => t !== "competes-with" && t !== "solves-same-problem-as"); const usedMaturities = MATURITY_ORDER.filter((m) => specs.some((s) => s.maturity === m)); +// 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 const spine = buildsOnSpine(specs, relations); const spineStep = new Map(spine.map((r, i) => [`${r.from}|${r.type}|${r.to}`, i])); -const spineEdges = edges.filter((e) => - spineStep.has(`${e.relation.from}|${e.relation.type}|${e.relation.to}`), -); const spineFirst = spine[0] ? byId.get(spine[0].to) : undefined; const spineLast = spine.at(-1) ? byId.get(spine.at(-1)?.from ?? "") : undefined; @@ -45,233 +49,438 @@ function edgeTitle(type: RelationType, fromId: string, toId: string, note?: stri return note ? `${phrase}. ${note}` : phrase; } const key = (e: (typeof edges)[number]) => `${e.relation.from}|${e.relation.type}|${e.relation.to}`; + +/* + * The filter is the legend: each toggle is a checkbox, and these rules hide what an unchecked + * one covers, in the drawing and in the outline. No script is needed for a filter to take + * effect; the island only writes the state into the URL and keeps the count. + */ +const scope = "#standards"; +const unchecked = (name: string, value: string) => + `${scope}:has(input[name="${name}"][value="${value}"]:not(:checked))`; +const filterCss = [ + ...usedTypes.map( + (t) => + `${unchecked("type", t)} :is(#map .edge-${t}, #map .spine-halo[data-type="${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) #spec-list li:not([data-recent]) > .row { opacity: 0.35 }`, + ...bodies.map( + (b) => + `${unchecked("body", b)} :is(#map .node[data-body="${b}"], #map .band[data-body="${b}"], #map .edge[data-from-body="${b}"], #map .edge[data-to-body="${b}"]) { visibility: hidden }\n` + + `${unchecked("body", b)} #spec-list li[data-body="${b}"] > .row { display: none }`, + ), +].join("\n"); --- -<figure class="map" aria-labelledby="map-caption"> - <div - class="plate map-frame" - id="map-frame" - tabindex="0" - role="region" - aria-label="Standards map, scrolls horizontally" - > - <svg - id="map" - viewBox={`0 0 ${width} ${height}`} - width={width} - height={height} - xmlns="http://www.w3.org/2000/svg" - aria-labelledby="map-title" - data-nodes={nodes.length} - data-edges={edges.length} - > - <title id="map-title"> - Specifications for agent authorization, one node each, grouped by publishing body in - rows and by what they build on in columns, with their relations drawn as lines. - - - { - directed.flatMap((t) => [`arrow-${t}`, `arrow-${t}-full`]).map((id) => ( - - - - )) - } - + - +
+
+
+ Relations + { + usedTypes.map((t) => ( + + )) + } +
+
+ Publishing bodies + { + bodies.map((b) => ( + + )) + } +
+
+ View + +
+
+ Maturity, as the publisher states it + {usedMaturities.map((m) => )} +
+

+ + {nodes.length} of {nodes.length} specifications, {relations.length} of {relations.length}{" "} + relations + + +

+ +
+

Hover or focus a node to read its relations here.

+
+
- + )) + } + -
-

Legend

-
-
- { - usedTypes.map((t) => ( - <> -
- - {RELATION_LABEL[t].forward} -
-
{RELATION_DESCRIPTION[t]}
- - )) - } -
-
+ + + + { + nodes.map((n) => { + const lines = wrapLabel(n.spec.name, 24, 2); + const label = `${n.spec.name}, ${BODY_LABEL[n.spec.body]}, ${MATURITY_LABEL[n.spec.maturity]}`; + const tag = MATURITY_TAG[n.spec.maturity]; + return ( + + + {recent.has(n.spec.id) && ( + + {recent.get(n.spec.id)} + + )} + + + {tag} + + + {lines.map((line, i) => ( + + {line} + + ))} + + + ); + }) + } + + +
+
+ A specification sits one column right of what it builds on, profiles or replaces, and the + tinted bands group each column by publishing body.{" "} { - usedMaturities.map((m) => ( + spineFirst && spineLast && ( <> -
- -
-
{MATURITY_LABEL[m]}. The publisher's status, not a grade.
+ The bold teal line is the longest chain of documents that build on each other, from{" "} + {spineFirst.name} to {spineLast.name}.{" "} - )) + ) } - + Each relation type has its own colour and dash pattern, so the map reads without colour. + The outline below carries the same information as text. +
+ + + {/* one card per node, shown in the rail for the node under the pointer or in focus */} + -

- Each relation type has its own colour and its own dash pattern, so the map also reads without - colour. Maturity is the word in the chip, and a dashed chip is a draft. Focusing a node shows - its relations at full strength and fades the rest. -

-
+
diff --git a/src/components/StandardsMap.svelte b/src/components/StandardsMap.svelte index c854461..5f23ba2 100644 --- a/src/components/StandardsMap.svelte +++ b/src/components/StandardsMap.svelte @@ -1,10 +1,12 @@