diff --git a/specs/016-visual-redesign/tasks.md b/specs/016-visual-redesign/tasks.md index e42b028..4e501e0 100644 --- a/specs/016-visual-redesign/tasks.md +++ b/specs/016-visual-redesign/tasks.md @@ -166,3 +166,4 @@ Items from the redesign review, one pull request each, in order. - [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 +- [x] R-05 Matrix on phones: below 40rem the matrix is read one way at a time instead of the desktop table squeezed. A row of plain anchors (All and one per requirement group, with counts) picks the view through `:target`, so it works without JavaScript and every group stays in the HTML; the island turns the choice into `data-view` with `aria-current`, so switching keeps the scroll and adds no history entry. "All" draws every requirement as a strip of small glyphs per server, sized from the column with container units, so the whole atlas fits without sideways scroll; its glyphs are a picture and a group's view opens them. A group shows its own columns (one generated `nth-child` range each) with two-line horizontal labels instead of `vertical-rl`, two and a half columns in view so the next one peeks in, and scroll shadows at the frame edges. The server column is 7rem, names wrap to two lines and link to the server page, the transposed view. Desktop is unchanged. Baselines through the `refresh-screenshots` label diff --git a/src/components/Matrix.svelte b/src/components/Matrix.svelte index 1317daf..5270526 100644 --- a/src/components/Matrix.svelte +++ b/src/components/Matrix.svelte @@ -152,7 +152,7 @@ onMount(() => { for (const key of PARAMS) if (s[key]) params.set(key, key === "q" ? (controls.q?.value ?? "").trim() : s[key]); const qs = params.toString(); - history.replaceState(null, "", qs ? `?${qs}` : location.pathname); + history.replaceState(null, "", `${qs ? `?${qs}` : location.pathname}${location.hash}`); } } @@ -175,6 +175,37 @@ onMount(() => { } table.dataset.enhanced = "true"; + // the phone view's group picker: the anchors work through :target without the island; with it + // the choice is a data attribute, so switching keeps the scroll position and the history + const view = document.querySelector<HTMLElement>("[data-matrix-view]"); + const tabs = Array.from(view?.querySelectorAll<HTMLAnchorElement>(".matrix-tabs a") ?? []); + const region = view?.querySelector<HTMLElement>(".matrix-scroll"); + function showView(id: string) { + if (!view) return; + view.dataset.view = id; + for (const a of tabs) { + if (a.id === `mx-${id}`) a.setAttribute("aria-current", "true"); + else a.removeAttribute("aria-current"); + } + } + const viewFromHash = () => { + const id = location.hash.startsWith("#mx-") ? location.hash.slice(4) : ""; + showView(tabs.some((a) => a.id === `mx-${id}`) ? id : "all"); + }; + viewFromHash(); + // a link from elsewhere on the page, or the back button, still picks a group + window.addEventListener("hashchange", viewFromHash); + for (const a of tabs) { + a.addEventListener("click", (e) => { + e.preventDefault(); + const id = a.id.slice(3); + showView(id); + if (region) region.scrollLeft = 0; + const hash = id === "all" ? "" : `#${a.id}`; + history.replaceState(null, "", `${location.pathname}${location.search}${hash}`); + }); + } + let timer: ReturnType<typeof setTimeout> | undefined; controls.q?.addEventListener("input", () => { clearTimeout(timer); diff --git a/src/components/MatrixTable.astro b/src/components/MatrixTable.astro index c24384d..e59b767 100644 --- a/src/components/MatrixTable.astro +++ b/src/components/MatrixTable.astro @@ -30,6 +30,33 @@ for (const f of columns) { else groups.push({ category: f.category, count: 1 }); } +/* + * On phones (below 40rem) the matrix is read one way at a time: "All" draws every requirement + * as a dense strip per server, and each group shows only its own columns at a readable width. + * The choice is a row of plain anchors, so it works without JavaScript through :target; the + * island records it as data-view instead, so it can switch without a scroll jump. Each group's + * columns are a contiguous run, so hiding the others is one nth-child range per group. + */ +let firstColumn = 1; +const ranges = groups.map((g) => { + const range = { ...g, from: firstColumn, to: firstColumn + g.count - 1 }; + firstColumn += g.count; + return range; +}); +const inGroup = (c: string) => + `.matrix-view:is([data-view="${c}"], :not([data-view]):has(#mx-${c}:target))`; +const atAll = + '.matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target)))'; +const phoneCss = `@layer product{@media (max-width: 40rem){${ranges + .map( + (r) => + `${inGroup(r.category)} tr[data-server]>td:not(:nth-child(n+${r.from + 1}):nth-child(-n+${r.to + 1})),${inGroup(r.category)} thead th[data-category]:not([data-category="${r.category}"]){display:none}`, + ) + .join("")}${atAll} tr[data-server]>td:is(${ranges + .slice(1) + .map((r) => `:nth-child(${r.from + 1})`) + .join(",")}){border-left:1px solid var(--rule)}}}`; + const changedIds = new Map(diff?.changed.map((c) => [c.feature.id, c]) ?? []); const addedIds = new Set(diff?.added.map((f) => f.id) ?? []); const changesHref = diff ? `/mcp/changes/${diff.from.id}/${diff.to.id}/` : undefined; @@ -37,6 +64,23 @@ const columnTitle = (name: string, party: string, level: string) => `${name}, ${ const shortLabel = (id: string) => id.split(".").slice(2).join(".") || id; --- +<div class="matrix-view" data-matrix-view> +<nav class="matrix-tabs" aria-label="Requirement groups"> + <a id="mx-all" href="#mx-all">All <span class="matrix-tabs__count">{columns.length}</span></a> + { + groups.map((g) => ( + <a id={`mx-${g.category}`} href={`#mx-${g.category}`}> + {CATEGORY_LABEL[g.category as keyof typeof CATEGORY_LABEL] ?? g.category}{" "} + <span class="matrix-tabs__count">{g.count}</span> + </a> + )) + } +</nav> +<p class="matrix-tabs__hint"> + All shows every requirement at a glance. A group shows its cells, and each cell opens its + observation. A server's name opens its page with every requirement. +</p> +<style is:inline set:html={phoneCss}></style> <div class="matrix-scroll" tabindex="0" role="region" aria-label="Support matrix, scrolls horizontally"> <div class="matrix-clip"> <table @@ -203,6 +247,7 @@ const shortLabel = (id: string) => id.split(".").slice(2).join(".") || id; </table> </div> </div> +</div> <style is:global> @layer reset, tokens, base, layout, components, product, utilities, print; @@ -583,5 +628,214 @@ const shortLabel = (id: string) => id.split(".").slice(2).join(".") || id; right: 0; translate: none; } + + /* the group picker and its hint exist for phones only */ + .matrix-tabs, + .matrix-tabs__hint { + display: none; + } + + /* + * Phones (follow-up 5). Two views instead of the desktop table squeezed: "All" fits every + * requirement into the column as a strip of small glyphs per server, with no sideways scroll; + * a group shows its own columns at a readable width, with horizontal two-line headers, a + * peek of the next column and shadows at the edges that scroll. The names link to the server + * page, which is the same data transposed. .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) is the "All" view, .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) a group's. + */ + @media (max-width: 40rem) { + .matrix-view { + --server-w: 7rem; + container-type: inline-size; + } + + .matrix-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin: 0 0 var(--space-2); + /* the anchors are their own targets; keep the jump from hiding the row under the top */ + scroll-margin-top: var(--space-4); + } + + .matrix-tabs a { + display: inline-flex; + gap: 0.35em; + padding: 0.35em 0.75em; + min-height: 2.25rem; + box-sizing: border-box; + align-items: center; + font-size: var(--small); + font-weight: 500; + color: var(--ink); + text-decoration: none; + border: 1px solid var(--rule-strong); + border-radius: 999px; + scroll-margin-top: var(--space-4); + } + + .matrix-tabs__count { + font-family: var(--font-mono); + font-size: var(--tiny); + color: var(--ink-2); + } + + /* the chosen one: from the island's aria-current, or without it from :target */ + .matrix-tabs a[aria-current="true"], + .matrix-view:not([data-view]) .matrix-tabs a:target, + .matrix-view:not([data-view]) .matrix-tabs:not(:has(:target)) #mx-all { + background: var(--ink); + border-color: var(--ink); + color: var(--ground); + } + + .matrix-tabs a[aria-current="true"] .matrix-tabs__count, + .matrix-view:not([data-view]) .matrix-tabs a:target .matrix-tabs__count, + .matrix-view:not([data-view]) .matrix-tabs:not(:has(:target)) #mx-all .matrix-tabs__count { + color: inherit; + } + + .matrix-tabs__hint { + display: block; + margin: 0 0 var(--space-3); + font-size: var(--tiny); + color: var(--ink-2); + line-height: 1.5; + } + + .matrix { + --server-w: inherit; + } + + .matrix .classes { + display: none; + } + + .matrix .server-head, + .matrix .server-cell { + padding-inline: var(--space-2); + } + + /* All: every requirement in the column's width, no sideways scroll */ + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix { + --feature-w: max(0.4rem, calc((100cqi - var(--server-w)) / var(--cols))); + } + + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix thead tr.features { + display: none; + } + + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix .groups th { + padding: var(--space-1) 0 var(--space-1) 0.2rem; + text-align: left; + } + + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix .groups th span { + text-overflow: clip; + } + + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix tbody td { + padding: 0.3rem 0; + } + + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix .server-cell { + font-size: var(--tiny); + padding-block: 0.3rem; + } + + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix .server-cell__row { + gap: var(--space-1); + } + + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix .server-link { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* the glyphs are a picture of the row here; a group's view opens them */ + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix .cell summary { + /* no box, so no 10px tap target: the glyph alone stays */ + display: contents; + pointer-events: none; + } + + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix .status-cell { + --size: min(calc(var(--feature-w) - 2px), 0.875rem); + } + + /* a group: its own columns, wide enough for a two-line label written across */ + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix { + /* two and a half columns in view, so the next one always peeks in */ + --feature-w: max(4.5rem, calc((100cqi - var(--server-w)) / 2.5)); + } + + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix thead tr.groups { + display: none; + } + + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix .features th[data-feature] { + padding: var(--space-2) var(--space-1); + } + + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix .feature-head { + gap: var(--space-1); + } + + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix .feature-link, + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix .feature-head .level { + writing-mode: horizontal-tb; + transform: none; + } + + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix .feature-link { + height: auto; + padding-inline: 0.15rem; + } + + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix .feature-short { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.3; + } + + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix .feature-head .level { + padding: 0.1em 0.4em; + } + + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix .server-cell__row { + align-items: flex-start; + gap: var(--space-1); + } + + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix .server-link { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + overflow-wrap: anywhere; + } + + /* + * Scroll shadows: a shadow fixed at each edge of the frame, and a ground-coloured cover that + * scrolls with the table and sits over the shadow while that edge is reached. The left pair + * starts after the sticky server column. + */ + .matrix-view:is([data-view]:not([data-view="all"]), :not([data-view]):has(.matrix-tabs a:not(#mx-all):target)) .matrix-scroll { + --shade: color-mix(in oklch, var(--ink) 16%, transparent); + background: + linear-gradient(var(--ground), var(--ground)) var(--server-w) 0 / 1rem 100% no-repeat local, + linear-gradient(to left, var(--ground), var(--ground)) right 0 / 1rem 100% no-repeat local, + linear-gradient(to right, var(--shade), transparent) var(--server-w) 0 / 0.75rem 100% + no-repeat scroll, + linear-gradient(to left, var(--shade), transparent) right 0 / 0.75rem 100% no-repeat scroll, + var(--ground); + } + } } </style> diff --git a/tests/e2e/matrix.spec.ts b/tests/e2e/matrix.spec.ts index f52acda..009466b 100644 --- a/tests/e2e/matrix.spec.ts +++ b/tests/e2e/matrix.spec.ts @@ -14,6 +14,9 @@ const STATUSES = ["supported", "partial", "unsupported", "unknown", "not-applica const withUnsupported = matrix.rows.find( (s) => matrix.cells.get(`${s.id}|mcp.as.dcr`)?.status === "unsupported", ); +// on a phone the cells open from their group's view, so the cell tests open that group; on a +// wide screen the anchor changes nothing +const dcrGroup = `#mx-${matrix.columns.find((f) => f.id === "mcp.as.dcr")?.category}`; // the most common access and auth classes, for the filter tests (009) const topAccess = [...countBy(matrix.rows, ACCESS_ORDER, accessOf)].sort( (a, b) => b.count - a.count, @@ -47,7 +50,7 @@ test.describe("matrix page (US1)", () => { page, }) => { test.skip(!withUnsupported, "no server with an unsupported observation in the seed data"); - await page.goto("/mcp/"); + await page.goto(`/mcp/${dcrGroup}`); await expect(page.locator("#matrix[data-enhanced]")).toBeAttached(); const summary = page.locator( `td[data-server="${withUnsupported?.id}"][data-feature="mcp.as.dcr"] summary`, @@ -198,7 +201,7 @@ test.describe("matrix page (US1)", () => { test("keyboard-only walkthrough opens and closes a cell", async ({ page }) => { test.skip(!withUnsupported, "no server with an unsupported observation in the seed data"); - await page.goto("/mcp/"); + await page.goto(`/mcp/${dcrGroup}`); await expect(page.locator("#matrix[data-enhanced]")).toBeAttached(); const summary = page.locator( `td[data-server="${withUnsupported?.id}"][data-feature="mcp.as.dcr"] summary`, @@ -224,11 +227,11 @@ test.describe("matrix page (US1)", () => { expect(scroll).toBeLessThanOrEqual(inner ?? 0); }); - test("scrolls inside its region at 320px with the server column sticky (016 WP4)", async ({ + test("a group scrolls inside its region at 320px with the server column sticky (016 WP4)", async ({ page, }) => { await page.setViewportSize({ width: 320, height: 800 }); - await page.goto("/mcp/"); + await page.goto("/mcp/#mx-discovery"); await expect(page.locator("#matrix[data-enhanced]")).toBeAttached(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( 320, @@ -244,6 +247,38 @@ test.describe("matrix page (US1)", () => { expect(after?.x).toBe(before?.x); }); + test("on a phone, All fits every requirement without sideways scroll (follow-up 5)", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/mcp/"); + await expect(page.locator("#matrix[data-enhanced]")).toBeAttached(); + await expect(page.locator("#mx-all")).toHaveAttribute("aria-current", "true"); + const region = page.locator(".matrix-scroll"); + expect(await region.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); + const row = page.locator("#matrix tbody tr[data-server]").first(); + for (const td of await row.locator("td[data-feature]").all()) await expect(td).toBeVisible(); + }); + + test("on a phone, a group shows only its columns with labels written across (follow-up 5)", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/mcp/"); + await expect(page.locator("#matrix[data-enhanced]")).toBeAttached(); + const category = matrix.columns[0]?.category ?? ""; + const inGroup = matrix.columns.filter((f) => f.category === category); + await page.locator(`#mx-${category}`).click(); + await expect(page.locator(`#mx-${category}`)).toHaveAttribute("aria-current", "true"); + await expect(page).toHaveURL(new RegExp(`#mx-${category}$`)); + const row = page.locator("#matrix tbody tr[data-server]").first(); + await expect(row.locator("td[data-feature]:visible")).toHaveCount(inGroup.length); + const head = page.locator(`#matrix thead th[data-feature="${inGroup[0]?.id}"] .feature-link`); + expect(await head.evaluate((el) => getComputedStyle(el).writingMode)).toBe("horizontal-tb"); + await page.locator("#mx-all").click(); + await expect(row.locator("td[data-feature]:visible")).toHaveCount(matrix.columns.length); + }); + test("looks right in greyscale: the five status forms differ in shape (016 WP4)", async ({ page, }) => { @@ -258,7 +293,7 @@ test.describe("matrix page (US1)", () => { test("cell detail opens without motion under reduced motion (M10)", async ({ page }) => { test.skip(!withUnsupported, "no server with an unsupported observation in the seed data"); await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/mcp/"); + await page.goto(`/mcp/${dcrGroup}`); await expect(page.locator("#matrix[data-enhanced]")).toBeAttached(); await page .locator(`td[data-server="${withUnsupported?.id}"][data-feature="mcp.as.dcr"] summary`) @@ -308,7 +343,7 @@ test.describe("matrix page without JavaScript", () => { test("renders the full matrix and native cell details", async ({ page }) => { test.skip(!withUnsupported, "no server with an unsupported observation in the seed data"); - await page.goto("/mcp/?level=MUST"); + await page.goto(`/mcp/?level=MUST${dcrGroup}`); await expect(page.locator("#matrix tbody tr[data-server]")).toHaveCount(matrix.rows.length); await expect(page.locator("#matrix tbody tr[data-server]:not([hidden])")).toHaveCount( matrix.rows.length, @@ -322,6 +357,21 @@ test.describe("matrix page without JavaScript", () => { await expect(details.locator("[data-detail]")).toContainText(winning?.method ?? "vendor-claim"); }); + test("on a phone, the group picker works through plain anchors (follow-up 5)", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/mcp/"); + const category = matrix.columns[0]?.category ?? ""; + const inGroup = matrix.columns.filter((f) => f.category === category); + const row = page.locator("#matrix tbody tr[data-server]").first(); + await expect(row.locator("td[data-feature]:visible")).toHaveCount(matrix.columns.length); + await page.locator(`#mx-${category}`).click(); + await expect(row.locator("td[data-feature]:visible")).toHaveCount(inGroup.length); + // every group is still in the HTML + await expect(row.locator("td[data-feature]")).toHaveCount(matrix.columns.length); + }); + test("highlights the row and column under the pointer without the island (M9)", async ({ page, }) => {