diff --git a/docs/design.md b/docs/design.md index 98c72bb..de03d1d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -214,10 +214,10 @@ boundary dash, hairlines for the rest. It never goes on a page with a data view The header scrolls away with the page, so `--header-h` (what sticky content must clear) is 0. -Product surfaces go in `product`. The matrix (`MatrixTable`, `StatusCell`, `ObservationDetail`) +Product surfaces go in `product`. The matrix (`MatrixTable`, `StatusCell`, `ClassIcon`) keeps its styles there through ` diff --git a/src/components/Matrix.svelte b/src/components/Matrix.svelte index d8d0c7d..209ad31 100644 --- a/src/components/Matrix.svelte +++ b/src/components/Matrix.svelte @@ -1,16 +1,11 @@ - - -
-
-

Observation

-
- -
-
-
-
-
- - diff --git a/src/components/MatrixPage.astro b/src/components/MatrixPage.astro index a80755d..2777643 100644 --- a/src/components/MatrixPage.astro +++ b/src/components/MatrixPage.astro @@ -20,6 +20,7 @@ import { import { getDataset } from "@/lib/data"; import { CATEGORY_LABEL, PARTY_LABEL, STATUS_ORDER } from "@/lib/labels"; import { latestScan, passiveChecks } from "@/lib/scanner"; +import ClassIcon from "./ClassIcon.astro"; import Matrix from "./Matrix.svelte"; import MatrixTable from "./MatrixTable.astro"; import StatusCell from "./StatusCell.astro"; @@ -69,7 +70,8 @@ const classWords = (counts: { value: string; count: number }[], label: RecordWho supports what

The public MCP servers we list, against what the MCP Authorization specification requires - of servers and their authorization servers. Each cell opens the observation behind it. + of servers and their authorization servers. Each cell links to the observation behind it on + the server's page.

@@ -194,7 +196,9 @@ const classWords = (counts: { value: string; count: number }[], label: RecordAS name the party the feature is observed on. The nightly scanner reads {scannerChecks} of these requirements from - public metadata; "scan" next to a server's name opens what it asked. + public metadata; the trace mark + + next to a server's name opens what it asked. Rows are alphabetical. Nothing here is scored or ranked.

@@ -204,7 +208,10 @@ const classWords = (counts: { value: string; count: number }[], label: Record ( <> {i > 0 && " · "} - {ACCESS_LABEL[v].toLowerCase()} + + + {ACCESS_LABEL[v].toLowerCase()} + ))} : who can connect, as the server page's evidence says. @@ -216,11 +223,14 @@ const classWords = (counts: { value: string; count: number }[], label: Record ( <> {i > 0 && " · "} - {AUTH_LABEL[v].toLowerCase()} + + + {AUTH_LABEL[v].toLowerCase()} + ))} - : how the endpoint authenticates clients. A dashed chip is a class nobody has stated - yet. Both come from the evidence on the server page. API-key and unauthenticated + : how the endpoint authenticates clients. A faint mark after a server's name is a class + nobody has stated yet. Both come from the evidence on the server page. API-key and unauthenticated servers are not scanned.
@@ -382,6 +392,24 @@ const classWords = (counts: { value: string; count: number }[], label: Record id.split(".").slice(2).join(".") || id; {server.name} + + + + Access: {ACCESS_LABEL[access.value]} + + + + Auth: {AUTH_LABEL[auth.value]} + + {scan && ( id.split(".").slice(2).join(".") || id; data-scan={scan.summary.state} title={scan.summary.label} > - {server.name} scan + + {server.name} scan )} - - - Access: - {ACCESS_LABEL[access.value].toLowerCase()} - - - Auth: - {AUTH_LABEL[auth.value].toLowerCase()} - - {columns.map((feature) => { const cell = cells.get(cellKey(server.id, feature.id)); @@ -245,12 +248,13 @@ const shortLabel = (id: string) => id.split(".").slice(2).join(".") || id; data-status={cell.status} data-derived={cell.derived ? "true" : "false"} > -
- - - - -
+ + + ); })} @@ -418,8 +422,15 @@ const shortLabel = (id: string) => id.split(".").slice(2).join(".") || id; text-underline-offset: 0.2em; } + /* + * The level is quiet text beside the party, not an outlined pill in its level colour: it + * repeats on every column, and the status glyphs below are what the matrix is for. + */ .matrix .feature-head .level { - padding: 0.4em 0.25em; + padding: 0; + border: 0; + color: var(--ink-2); + font-weight: 500; letter-spacing: 0.04em; } @@ -514,35 +525,55 @@ const shortLabel = (id: string) => id.split(".").slice(2).join(".") || id; } /* the row's own cells are the scan's result; this only opens the requests behind them */ + /* + * A small trace glyph rather than the word on every row: faint until its row is under the + * pointer or the focus. Its name is the hidden text, its tooltip the scan's summary. + */ .matrix .scan-link { flex: none; - font-size: var(--tiny); + display: inline-grid; + place-items: center; + width: 1.25rem; + height: 1.25rem; + color: color-mix(in oklch, var(--ink-2) 55%, transparent); + border-radius: var(--radius-focus); + transition: color var(--dur-fast) var(--ease); + } + .matrix .scan-link svg { + width: 0.9rem; + height: 0.9rem; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + } + .matrix tr:is(:hover, :focus-within) .scan-link { color: var(--ink-2); - text-decoration: none; } - .matrix .scan-link:hover { - color: var(--ink); - text-decoration: underline; - text-underline-offset: 0.2em; + .matrix .scan-link:is(:hover, :focus-visible) { + color: var(--signal); } + /* + * The row's classes, two line glyphs after the name in its ink-2 (ClassIcon); the words are + * the tooltip and the hidden text. A class nobody has stated yet is fainter, like the faint + * dot of a derived cell. + */ .matrix .classes { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); - margin-top: var(--space-1); + display: inline-flex; + gap: 0.3rem; + margin-left: auto; + color: var(--ink-2); + font-size: 0.9rem; } - .matrix .classes .chip { - color: var(--ink); - border-color: var(--rule-strong); + .matrix .classes .class { + display: inline-flex; } - /* a class nobody has stated yet: dashed, like a recorded unknown */ - .matrix .classes .chip.derived { - color: var(--ink-2); - border-color: var(--rule-strong); - border-style: dashed; + .matrix .classes .class.derived { + opacity: 0.55; } /* group header rows the island inserts when the reader groups by a class */ @@ -598,58 +629,23 @@ const shortLabel = (id: string) => id.split(".").slice(2).join(".") || id; background-color: var(--signal-soft); } - /* cells */ - .matrix .cell summary { - list-style: none; - cursor: pointer; + /* + * Cells: each glyph links to its feature's row on the server page, where the observation + * behind it (method, date, observer, evidence, earlier records) is set out. The matrix keeps + * no copy of the record: about 900 inline copies were most of the page's weight. + */ + .matrix .cell { display: inline-grid; place-items: center; padding: 0.2rem; border-radius: var(--radius-focus); } - .matrix .cell summary::-webkit-details-marker { - display: none; - } - - .matrix .cell summary:focus-visible { + .matrix .cell:focus-visible { outline: 2px solid var(--signal); outline-offset: 1px; } - /* without JavaScript the observation opens under its cell, above it in the last rows and - leaning left in the last columns, so the table's clip never cuts it */ - .matrix .cell[open] { - position: relative; - } - - .matrix .cell[open] > .obs { - position: absolute; - top: calc(100% + 0.4rem); - left: 50%; - translate: -50% 0; - z-index: 4; - width: 22rem; - max-width: 80vw; - padding: var(--space-4); - text-align: left; - font-weight: 400; - background: var(--ground-2); - border: 1px solid var(--rule-strong); - border-radius: var(--radius); - } - - .matrix tbody tr:nth-last-child(-n + 6) .cell[open] > .obs { - top: auto; - bottom: calc(100% + 0.4rem); - } - - .matrix tbody td:nth-last-child(-n + 4) .cell[open] > .obs { - left: auto; - right: 0; - translate: none; - } - /* * Wide screens: the table fits the column. Its widths come from the group row, so a group * whose label needs more room than its columns (data-narrow) takes the label's width, and @@ -808,7 +804,7 @@ const shortLabel = (id: string) => id.split(".").slice(2).join(".") || id; } /* 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 { + .matrix-view:is([data-view="all"], :not([data-view]):not(:has(.matrix-tabs a:not(#mx-all):target))) .matrix .cell { /* no box, so no 10px tap target: the glyph alone stays */ display: contents; pointer-events: none; diff --git a/src/components/ObservationDetail.astro b/src/components/ObservationDetail.astro deleted file mode 100644 index 3210b29..0000000 --- a/src/components/ObservationDetail.astro +++ /dev/null @@ -1,107 +0,0 @@ ---- -import type { Cell } from "@/lib/atlas"; -import type { Feature, Server } from "@/schema"; -import StatusCell from "./StatusCell.astro"; - -interface Props { - cell: Cell; - server: Server; - feature: Feature; -} - -const { cell, server, feature } = Astro.props; -const obs = cell.observation; -const evidenceMatch = obs ? obs.evidence.match(/^(https:\/\/\S+)(.*)$/s) : null; -const evidenceUrl = evidenceMatch?.[1]; -const evidenceNote = evidenceMatch?.[2]?.trim(); -const date = obs ? obs.observedAt.slice(0, 10) : undefined; ---- - -
-

- {server.name} - - {feature.name} -

-

- { - obs ? ( -
-
Method
-
{obs.method}
-
Observed
-
- by {obs.observedBy} -
-
Evidence
-
- {evidenceUrl ? ( - <> - - {evidenceUrl} - - {evidenceNote && {evidenceNote}} - - ) : ( - obs.evidence - )} -
-
Revision
-
{obs.revision.revision}
-
- ) : ( -

- No observation on record for this server and feature under revision {cell.revision}. - {cell.status === "not-applicable" - ? " The server has no HTTP transport, so the feature does not apply." - : " The status stays unknown until someone records one."} -

- ) - } -
- - diff --git a/src/pages/accessibility.astro b/src/pages/accessibility.astro index 8f83d3d..6dfb114 100644 --- a/src/pages/accessibility.astro +++ b/src/pages/accessibility.astro @@ -47,8 +47,8 @@ const motions = [ }, { where: "Matrix", - what: "The row and column under your pointer or focus take a tint. A cell opens a dialog that fades in and rises 8 px.", - reduced: "The tint and the dialog appear at once.", + what: "The row and column under your pointer or focus take a tint.", + reduced: "The tint appears at once.", }, { where: "Scanner report", diff --git a/src/pages/mcp/servers/[id].astro b/src/pages/mcp/servers/[id].astro index 981bb62..f5cdf54 100644 --- a/src/pages/mcp/servers/[id].astro +++ b/src/pages/mcp/servers/[id].astro @@ -273,7 +273,7 @@ const classes = [ const entry = featureInRevision(feature, revision.id); const obs = cell.observation; return ( - + {feature.name} @@ -521,6 +521,13 @@ const classes = [ .features tbody tr:hover > * { background: var(--signal-soft); } + /* a matrix cell links here: its row takes the tint and keeps clear of the top edge */ + .features tbody tr:target > * { + background: var(--signal-soft); + } + .features tbody tr { + scroll-margin-top: var(--space-6); + } .features th[scope="row"] { font-size: var(--small); font-weight: 500; diff --git a/tests/e2e/__screenshots__/matrix.spec.ts/status-shapes-dark.png b/tests/e2e/__screenshots__/matrix.spec.ts/status-shapes-dark.png index 0002c68..af493ef 100644 Binary files a/tests/e2e/__screenshots__/matrix.spec.ts/status-shapes-dark.png and b/tests/e2e/__screenshots__/matrix.spec.ts/status-shapes-dark.png differ diff --git a/tests/e2e/__screenshots__/matrix.spec.ts/status-shapes-light.png b/tests/e2e/__screenshots__/matrix.spec.ts/status-shapes-light.png index 2b9684c..9d2a9b2 100644 Binary files a/tests/e2e/__screenshots__/matrix.spec.ts/status-shapes-light.png and b/tests/e2e/__screenshots__/matrix.spec.ts/status-shapes-light.png differ diff --git a/tests/e2e/matrix.spec.ts b/tests/e2e/matrix.spec.ts index 18b3ad6..b242d88 100644 --- a/tests/e2e/matrix.spec.ts +++ b/tests/e2e/matrix.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { currentCell, currentRevision, matrixFor } from "../../src/lib/atlas"; import { ACCESS_ORDER, AUTH_ORDER, accessOf, authOf, countBy } from "../../src/lib/classes"; import { getDataset } from "../../src/lib/data"; +import { METHOD_LABEL } from "../../src/lib/labels"; import { latestScan } from "../../src/lib/scanner"; import { expectEndStateUnderReducedMotion, expectNoSeriousA11yViolations } from "./helpers"; @@ -46,29 +47,26 @@ test.describe("matrix page (US1)", () => { expect(statuses.every((s) => s && STATUSES.includes(s))).toBe(true); }); - test("cell detail shows the observation and returns focus on close (FR-002)", async ({ - page, - }) => { + test("a cell links to its observation on the server page (FR-002)", async ({ page }) => { test.skip(!withUnsupported, "no server with an unsupported observation in the seed data"); 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`, + const cell = page.locator( + `td[data-server="${withUnsupported?.id}"][data-feature="mcp.as.dcr"] a.cell`, ); - await summary.click(); - const dialog = page.locator("dialog.cell-dialog"); - await expect(dialog).toBeVisible(); - // focus lands on the dialog's heading (016 WP4) and goes back to the cell on close - await expect(dialog.locator("#cell-dialog-title")).toBeFocused(); - await expect(dialog).toContainText("Unsupported"); + await expect(cell).toHaveAttribute("href", `/mcp/servers/${withUnsupported?.id}/#mcp.as.dcr`); + await cell.click(); + await expect(page).toHaveURL( + new RegExp(`/mcp/servers/${withUnsupported?.id}/#mcp\\.as\\.dcr$`), + ); + const row = page.locator("tr:target"); + await expect(row).toHaveAttribute("data-feature", "mcp.as.dcr"); + await expect(row).toBeInViewport(); + await expect(row).toContainText("Unsupported"); const winning = matrix.cells.get(`${withUnsupported?.id}|mcp.as.dcr`)?.observation; - await expect(dialog).toContainText(winning?.method ?? "vendor-claim"); - await expect(dialog).toContainText(winning?.observedAt.slice(0, 10) ?? ""); - await expect(dialog).toContainText(winning?.observedBy ?? ""); - await expect(dialog.locator("dd a[href^='https://']")).toHaveCount(1); - await page.keyboard.press("Escape"); - await expect(dialog).toBeHidden(); - await expect(summary).toBeFocused(); + await expect(row).toContainText(METHOD_LABEL[winning?.method ?? "vendor-claim"]); + await expect(row).toContainText(winning?.observedAt.slice(0, 10) ?? ""); + await expect(row).toContainText(winning?.observedBy ?? ""); }); test("a server without observations reads unknown everywhere, never unsupported (SC-006)", async ({ @@ -79,7 +77,7 @@ test.describe("matrix page (US1)", () => { const row = page.locator(`#matrix tbody tr[data-server="${allUnknown?.id}"]`); await expect(row.locator("td[data-status='unknown']")).toHaveCount(matrix.columns.length); await expect(row.locator("td[data-status='unsupported']")).toHaveCount(0); - await expect(row.locator("td").first().locator("summary .status-unknown")).toBeVisible(); + await expect(row.locator("td").first().locator("a.cell .status-unknown")).toBeVisible(); }); test("filter state in the URL restores the filtered view (FR-008)", async ({ page }) => { @@ -197,21 +195,17 @@ test.describe("matrix page (US1)", () => { await expect(page.locator("#matrix [data-grade]")).toHaveCount(0); }); - test("keyboard-only walkthrough opens and closes a cell", async ({ page }) => { + test("keyboard-only walkthrough follows a cell", async ({ page }) => { test.skip(!withUnsupported, "no server with an unsupported observation in the seed data"); 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`, + const cell = page.locator( + `td[data-server="${withUnsupported?.id}"][data-feature="mcp.as.dcr"] a.cell`, ); - await summary.focus(); + await cell.focus(); + await expect(cell).toBeFocused(); await page.keyboard.press("Enter"); - const dialog = page.locator("dialog.cell-dialog"); - await expect(dialog).toBeVisible(); - await page.keyboard.press("Tab"); - await page.keyboard.press("Escape"); - await expect(dialog).toBeHidden(); - await expect(summary).toBeFocused(); + await expect(page.locator("tr:target")).toHaveAttribute("data-feature", "mcp.as.dcr"); }); test("has no horizontal page scroll at phone width (constitution V)", async ({ page }) => { @@ -304,23 +298,6 @@ test.describe("matrix page (US1)", () => { await expect(legend).toHaveScreenshot("status-shapes.png"); }); - 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/${dcrGroup}`); - await expect(page.locator("#matrix[data-enhanced]")).toBeAttached(); - await page - .locator(`td[data-server="${withUnsupported?.id}"][data-feature="mcp.as.dcr"] summary`) - .click(); - const dialog = page.locator("dialog.cell-dialog"); - await expect(dialog).toBeVisible(); - const running = await dialog.evaluate( - (el) => el.getAnimations({ subtree: true }).filter((a) => a.playState === "running").length, - ); - expect(running).toBe(0); - expect(await dialog.evaluate((el) => getComputedStyle(el).opacity)).toBe("1"); - }); - test("the crosshair shows at once under reduced motion (M9)", async ({ page }) => { await page.goto("/mcp/"); await expectEndStateUnderReducedMotion(page, "#matrix tbody tr[data-server]"); @@ -355,20 +332,20 @@ test.describe("matrix page (US1)", () => { test.describe("matrix page without JavaScript", () => { test.use({ javaScriptEnabled: false }); - test("renders the full matrix and native cell details", async ({ page }) => { + test("renders the full matrix and cells that link to their observation", async ({ page }) => { test.skip(!withUnsupported, "no server with an unsupported observation in the seed data"); 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, ); - const details = page.locator( - `td[data-server="${withUnsupported?.id}"][data-feature="mcp.as.dcr"] details`, - ); - await details.locator("summary").click(); - await expect(details).toHaveAttribute("open", ""); + await page + .locator(`td[data-server="${withUnsupported?.id}"][data-feature="mcp.as.dcr"] a.cell`) + .click(); + const row = page.locator("tr:target"); + await expect(row).toHaveAttribute("data-feature", "mcp.as.dcr"); const winning = matrix.cells.get(`${withUnsupported?.id}|mcp.as.dcr`)?.observation; - await expect(details.locator("[data-detail]")).toContainText(winning?.method ?? "vendor-claim"); + await expect(row).toContainText(METHOD_LABEL[winning?.method ?? "vendor-claim"]); }); test("on a phone, the group picker works through plain anchors (follow-up 5)", async ({ diff --git a/tests/e2e/standards.spec.ts b/tests/e2e/standards.spec.ts index e0c6111..2417e0e 100644 --- a/tests/e2e/standards.spec.ts +++ b/tests/e2e/standards.spec.ts @@ -169,10 +169,15 @@ test.describe("standards map (US1)", () => { await expect(page.locator("#spec-list li[data-spec][data-recent]")).toHaveCount(5); const narrow = (page.viewportSize()?.width ?? 1280) < 640; if (!narrow) { - const faded = page.locator("#map a[data-spec]:not([data-recent])").first(); - expect(await faded.evaluate((el) => getComputedStyle(el).opacity)).toBe("0.2"); - const kept = page.locator("#map a[data-spec][data-recent]").first(); - expect(await kept.evaluate((el) => getComputedStyle(el).opacity)).toBe("1"); + // the fade is a transition, so wait for it to settle rather than read it mid-way + await expect(page.locator("#map a[data-spec]:not([data-recent])").first()).toHaveCSS( + "opacity", + "0.2", + ); + await expect(page.locator("#map a[data-spec][data-recent]").first()).toHaveCSS( + "opacity", + "1", + ); } await page.locator("#map-filters input[value='recent']").uncheck(); await expect(page).not.toHaveURL(/view=/);