diff --git a/.github/workflows/release-plan-browser.yml b/.github/workflows/release-plan-browser.yml
index 18a0ffb7..cf5a5f74 100644
--- a/.github/workflows/release-plan-browser.yml
+++ b/.github/workflows/release-plan-browser.yml
@@ -6,7 +6,9 @@ on:
- ".github/workflows/release-plan-browser.yml"
- "scripts/render-release-plan-html.mjs"
- "tests/release_plan_report_test.mjs"
+ - "tests/release_plan_accessibility_test.mjs"
- "tests/browser/test_release_plan_report.py"
+ - "tests/browser/test_release_plan_cross_browser.py"
- "tests/fixtures/release-report/**"
- "docs/release-plan-report.md"
- "src/release.rs"
@@ -20,7 +22,9 @@ on:
- ".github/workflows/release-plan-browser.yml"
- "scripts/render-release-plan-html.mjs"
- "tests/release_plan_report_test.mjs"
+ - "tests/release_plan_accessibility_test.mjs"
- "tests/browser/test_release_plan_report.py"
+ - "tests/browser/test_release_plan_cross_browser.py"
- "tests/fixtures/release-report/**"
- "docs/release-plan-report.md"
- "src/release.rs"
@@ -38,10 +42,15 @@ concurrency:
cancel-in-progress: true
jobs:
- chromium:
- name: Generate and review release plan in Chromium
+ browser:
+ name: Generate and review in ${{ matrix.browser }}
runs-on: ubuntu-latest
- timeout-minutes: 35
+ timeout-minutes: 45
+ strategy:
+ fail-fast: false
+ max-parallel: 2
+ matrix:
+ browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
@@ -67,9 +76,12 @@ jobs:
with:
python-version: "3.13"
- - name: Test the fail-closed renderer
+ - name: Test the fail-closed renderer and accessibility structure
working-directory: zed-cli
- run: node --test tests/release_plan_report_test.mjs
+ run: >-
+ node --test
+ tests/release_plan_report_test.mjs
+ tests/release_plan_accessibility_test.mjs
- name: Build the locked Rust CLI
working-directory: zed-cli
@@ -94,31 +106,33 @@ jobs:
--input build/release-plan.json
--output build/release-plan.html
- - name: Install pinned Playwright Chromium
+ - name: Install pinned Playwright ${{ matrix.browser }}
working-directory: zed-cli
run: |
set -euo pipefail
python -m venv .venv-release-browser
.venv-release-browser/bin/python -m pip install --disable-pip-version-check --no-cache-dir playwright==1.55.0
- .venv-release-browser/bin/python -m playwright install --with-deps chromium
+ .venv-release-browser/bin/python -m playwright install --with-deps "${{ matrix.browser }}"
- - name: Exercise the generated report in Chromium
+ - name: Exercise accessibility print and no-JavaScript contracts
working-directory: zed-cli
- run: .venv-release-browser/bin/python tests/browser/test_release_plan_report.py
+ env:
+ PLAYWRIGHT_BROWSER: ${{ matrix.browser }}
+ run: .venv-release-browser/bin/python tests/browser/test_release_plan_cross_browser.py
- name: Upload the reviewed offline report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
- name: zed-release-plan-report
+ name: zed-release-plan-report-${{ matrix.browser }}
path: zed-cli/build/release-plan.html
if-no-files-found: error
retention-days: 7
- - name: Upload browser diagnostics
+ - name: Upload ${{ matrix.browser }} browser diagnostics
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
- name: zed-release-plan-browser-diagnostics
+ name: zed-release-plan-${{ matrix.browser }}-diagnostics
path: zed-cli/build/release-plan-browser-results
if-no-files-found: ignore
retention-days: 7
diff --git a/docs/release-plan-report.md b/docs/release-plan-report.md
index 51a86746..8702e239 100644
--- a/docs/release-plan-report.md
+++ b/docs/release-plan-report.md
@@ -16,7 +16,22 @@ zed release plan --json | \
node scripts/render-release-plan-html.mjs --output build/release-plan.html
```
-Open `build/release-plan.html` directly from disk. The report includes source provenance, exact Zed/native/forge counts, artifact tables, empty states, and client-side filtering. Press `Escape` while the filter is focused to clear it and restore the complete plan.
+Open `build/release-plan.html` directly from disk. The report includes source provenance, exact Zed/native/forge counts, artifact tables, explicit empty states, and progressive client-side filtering. Press `Escape` while the filter is focused to clear it and restore the complete plan.
+
+## Accessible review
+
+- A visible-on-focus skip link moves keyboard users directly to the report.
+- The filter is associated with instructions and its live result status.
+- Every artifact table has a descriptive caption in addition to its visible section heading.
+- Heading order and semantic main/section/table landmarks remain stable.
+- With JavaScript disabled, filtering is hidden and every release artifact remains readable.
+- Forced-colors styling retains visible borders and focus indication.
+
+## Printing and PDF review
+
+Use the browser's print command to archive or review the release plan. Print media intentionally hides the filter and skip link, restores every artifact row even when the screen view is filtered, repeats table headers, avoids splitting rows where practical, and retains provenance plus destination counts.
+
+The generated report remains a single local HTML file. Printing does not fetch remote fonts, styles, scripts, images, or analytics.
## Security properties
@@ -30,4 +45,6 @@ Open `build/release-plan.html` directly from disk. The report includes source pr
## Validation
-`tests/release_plan_report_test.mjs` checks determinism, schema validation, hostile-value escaping, counts, empty states, CSP construction, strict argument handling, atomic replacement, and symbolic-link refusal. `tests/browser/test_release_plan_report.py` generates a real plan through the Rust CLI and verifies the rendered artifact in Chromium, including filtering, Escape reset, keyboard focus, narrow layout, console/page errors, and external network requests.
+`tests/release_plan_report_test.mjs` checks determinism, schema validation, hostile-value escaping, counts, empty states, CSP construction, strict argument handling, atomic replacement, and symbolic-link refusal. `tests/release_plan_accessibility_test.mjs` locks skip navigation, table captions, progressive enhancement, print restoration, and forced-colors rules.
+
+The GitHub Actions browser matrix generates a real release plan through the locked Rust CLI and verifies the same offline file in Chromium, Firefox, and WebKit. It covers filtering and Escape reset, keyboard order, accessible names and captions, print media, no-JavaScript readability, forced-colors behavior, narrow layouts, console/page errors, and external network requests.
diff --git a/scripts/render-release-plan-html.mjs b/scripts/render-release-plan-html.mjs
index 15e996e1..00f06a6d 100644
--- a/scripts/render-release-plan-html.mjs
+++ b/scripts/render-release-plan-html.mjs
@@ -9,6 +9,10 @@ const STYLE = `
:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.5}
*{box-sizing:border-box}
body{margin:0;background:Canvas;color:CanvasText}
+.skip-link{position:absolute;left:.75rem;top:.75rem;z-index:10;padding:.6rem .8rem;background:Canvas;color:CanvasText;border:2px solid CanvasText;border-radius:.4rem;transform:translateY(-200%)}
+.skip-link:focus{transform:translateY(0)}
+.visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
+.no-js .filter-controls{display:none}
main{width:min(88rem,100%);margin:0 auto;padding:clamp(1rem,4vw,3rem)}
header,.panel{border:1px solid color-mix(in srgb,CanvasText 22%,transparent);border-radius:.9rem;padding:clamp(1rem,3vw,1.5rem);margin-block:1rem}
.eyebrow{margin:0;font-size:.78rem;font-weight:750;letter-spacing:.09em;text-transform:uppercase}
@@ -19,10 +23,12 @@ h1,h2{line-height:1.2;overflow-wrap:anywhere}
.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.8rem}
.metric span{font-size:clamp(1.6rem,5vw,2.5rem);font-weight:800}
label{display:block;font-weight:700;margin-bottom:.4rem}
+.filter-help{margin:.35rem 0 .65rem}
input{width:100%;min-height:2.75rem;padding:.65rem .75rem;border:1px solid color-mix(in srgb,CanvasText 28%,transparent);border-radius:.55rem;background:Canvas;color:CanvasText;font:inherit}
input:focus-visible,a:focus-visible{outline:.2rem solid Highlight;outline-offset:.15rem}
.table-wrap{overflow-x:auto;border:1px solid color-mix(in srgb,CanvasText 20%,transparent);border-radius:.65rem}
table{width:100%;border-collapse:collapse;min-width:44rem}
+caption{text-align:left;font-weight:700;padding:.7rem}
th,td{text-align:left;vertical-align:top;padding:.7rem;border-bottom:1px solid color-mix(in srgb,CanvasText 14%,transparent)}
th{font-size:.78rem;letter-spacing:.05em;text-transform:uppercase;background:color-mix(in srgb,Canvas 92%,CanvasText 8%)}
tbody tr:last-child td{border-bottom:0}
@@ -31,14 +37,35 @@ code{overflow-wrap:anywhere;word-break:break-word}
.empty{text-align:center;font-style:italic}
footer{padding:1rem 0;font-size:.9rem}
@media(max-width:42rem){.metrics{grid-template-columns:1fr}.panel,header{border-radius:.65rem}table{min-width:36rem}}
+@media(forced-colors:active){header,.panel,.provenance li,.metric,.table-wrap,input{border-color:CanvasText}.provenance li,.metric,th{background:Canvas}}
+@page{margin:12mm}
+@media print{
+ :root{color-scheme:light}
+ body{background:#fff;color:#000;font-size:10pt}
+ main{width:100%;padding:0}
+ .skip-link,.filter-controls,noscript{display:none!important}
+ header,.panel{border:0;border-radius:0;padding:0;margin:0 0 1rem}
+ .provenance{grid-template-columns:repeat(2,minmax(0,1fr))}
+ .provenance li,.metric{background:#fff;border:1px solid #000}
+ .table-wrap{overflow:visible;border:0}
+ table{min-width:0;font-size:8.5pt}
+ caption{padding:.3rem 0}
+ thead{display:table-header-group}
+ tr,.provenance li,.metric{break-inside:avoid}
+ [hidden]{display:table-row!important}
+ th{background:#fff;color:#000}
+ footer{border-top:1px solid #000;margin-top:1rem}
+}
`;
const SCRIPT = `
(() => {
"use strict";
+ document.documentElement.classList.remove("no-js");
const input = document.querySelector("#artifact-filter");
const status = document.querySelector("#filter-status");
const rows = [...document.querySelectorAll("tbody tr[data-search]")];
+ input.disabled = false;
const apply = () => {
const query = input.value.trim().toLocaleLowerCase();
let visible = 0;
@@ -167,6 +194,7 @@ function tableSection({ id, title, description, headers, rows, empty }) {
${escapeHtml(title)}
${escapeHtml(description)}
+${escapeHtml(title)}. ${escapeHtml(description)}
${headers.map((header) => `| ${escapeHtml(header)} | `).join("")}
${body}
@@ -228,7 +256,7 @@ export function renderReleasePlan(input) {
].join("; ");
return `
-
+
@@ -237,7 +265,8 @@ export function renderReleasePlan(input) {
-
+Skip to release report
+
Credential-free release review
${escapeHtml(plan.release_set)}
@@ -257,10 +286,14 @@ export function renderReleasePlan(input) {
Forge mirrors${counts.forge}
${total} total planned artifacts and mirrors.
+
-
+
Search target, registry, package, tag, or directory. Press Escape to clear.
+
Showing all ${total} artifacts.
+
+
${tableSection({
id: "zed",
title: "Zed artifacts",
diff --git a/tests/browser/test_release_plan_cross_browser.py b/tests/browser/test_release_plan_cross_browser.py
new file mode 100644
index 00000000..c999f024
--- /dev/null
+++ b/tests/browser/test_release_plan_cross_browser.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from playwright.sync_api import Browser, BrowserContext, Page, sync_playwright
+
+ROOT = Path(__file__).resolve().parents[2]
+REPORT = Path(os.environ.get("ZED_RELEASE_REPORT", ROOT / "build/release-plan.html")).resolve()
+RESULTS = ROOT / "build" / "release-plan-browser-results"
+
+
+def attach_guards(page: Page, external_requests: list[str], errors: list[str]) -> None:
+ page.on(
+ "console",
+ lambda message: errors.append(f"console: {message.text}")
+ if message.type == "error"
+ else None,
+ )
+ page.on("pageerror", lambda error: errors.append(f"pageerror: {error}"))
+ page.on(
+ "request",
+ lambda request: external_requests.append(request.url)
+ if request.url.startswith(("http://", "https://"))
+ else None,
+ )
+
+
+def exercise_interactive(context: BrowserContext, engine: str) -> None:
+ context.tracing.start(screenshots=True, snapshots=True, sources=True)
+ page = context.new_page()
+ errors: list[str] = []
+ external_requests: list[str] = []
+ attach_guards(page, external_requests, errors)
+
+ try:
+ page.goto(REPORT.as_uri(), wait_until="load")
+ page.get_by_role("heading", name="acme/browser-report@1.2.3#v1.2.3").wait_for()
+ assert not page.locator("html").evaluate("element => element.classList.contains('no-js')")
+
+ page.keyboard.press("Tab")
+ assert page.evaluate("document.activeElement.classList.contains('skip-link')")
+ assert page.locator(".skip-link").is_visible()
+ page.keyboard.press("Tab")
+ assert page.evaluate("document.activeElement.id") == "artifact-filter"
+
+ assert page.locator("[data-count-kind='zed'] span").inner_text() == "2"
+ assert page.locator("[data-count-kind='native'] span").inner_text() == "2"
+ assert page.locator("[data-count-kind='forge'] span").inner_text() == "3"
+ assert page.locator("[data-total-count]").inner_text() == "7"
+ assert page.locator("tbody tr[data-search]").count() == 7
+ assert page.locator("table caption").count() == 3
+ captions = page.locator("table caption").all_inner_texts()
+ assert captions[0].startswith("Zed artifacts.")
+ assert captions[1].startswith("Native registry artifacts.")
+ assert captions[2].startswith("Forge package mirrors.")
+ assert page.locator("#artifact-filter").get_attribute("aria-describedby") == (
+ "filter-help filter-status"
+ )
+ assert page.locator("h1,h2").evaluate_all(
+ "elements => elements.map(element => element.tagName)"
+ ) == ["H1", "H2", "H2", "H2", "H2"]
+
+ filter_input = page.get_by_label("Filter artifacts")
+ filter_input.fill("npm")
+ assert page.locator("tbody tr[data-search]:visible").count() == 4
+ assert "4 of 7 artifacts" in page.get_by_role("status").inner_text()
+
+ page.emulate_media(media="print")
+ assert page.locator(".filter-controls").evaluate(
+ "element => getComputedStyle(element).display"
+ ) == "none"
+ assert page.locator("thead").first.evaluate(
+ "element => getComputedStyle(element).display"
+ ) == "table-header-group"
+ assert set(
+ page.locator("tbody tr[data-search]").evaluate_all(
+ "elements => elements.map(element => getComputedStyle(element).display)"
+ )
+ ) == {"table-row"}
+ page.emulate_media(media="screen")
+
+ filter_input.press("Escape")
+ assert filter_input.input_value() == ""
+ assert page.locator("tbody tr[data-search]:visible").count() == 7
+ assert page.get_by_role("status").inner_text() == "Showing all 7 artifacts."
+
+ page.emulate_media(forced_colors="active")
+ assert page.locator("header").evaluate(
+ "element => getComputedStyle(element).borderTopStyle"
+ ) != "none"
+ page.emulate_media(forced_colors="none")
+
+ page.set_viewport_size({"width": 390, "height": 844})
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ assert page.get_by_role("main").is_visible()
+ assert page.get_by_role("heading", name="Forge package mirrors").is_visible()
+
+ assert not external_requests, external_requests
+ assert not errors, errors
+ context.tracing.stop()
+ except BaseException:
+ RESULTS.mkdir(parents=True, exist_ok=True)
+ page.screenshot(path=RESULTS / f"{engine}-interactive-failure.png", full_page=True)
+ context.tracing.stop(path=RESULTS / f"{engine}-interactive-trace.zip")
+ raise
+
+
+def exercise_without_javascript(browser: Browser, engine: str) -> None:
+ context = browser.new_context(
+ java_script_enabled=False,
+ viewport={"width": 390, "height": 844},
+ )
+ page = context.new_page()
+ errors: list[str] = []
+ external_requests: list[str] = []
+ attach_guards(page, external_requests, errors)
+ try:
+ page.goto(REPORT.as_uri(), wait_until="load")
+ assert page.locator("html").evaluate("element => element.classList.contains('no-js')")
+ assert page.locator(".filter-controls").evaluate(
+ "element => getComputedStyle(element).display"
+ ) == "none"
+ assert page.locator("noscript").is_visible()
+ assert "all release artifacts remain visible" in page.locator("noscript").inner_text()
+ assert page.locator("tbody tr[data-search]").count() == 7
+ assert page.locator("tbody tr[data-search]:visible").count() == 7
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ assert not external_requests, external_requests
+ assert not errors, errors
+ except BaseException:
+ RESULTS.mkdir(parents=True, exist_ok=True)
+ page.screenshot(path=RESULTS / f"{engine}-no-js-failure.png", full_page=True)
+ raise
+ finally:
+ context.close()
+
+
+def main() -> None:
+ if not REPORT.is_file():
+ raise SystemExit(f"release report does not exist: {REPORT}")
+ engine = os.environ.get("PLAYWRIGHT_BROWSER", "chromium")
+ if engine not in {"chromium", "firefox", "webkit"}:
+ raise SystemExit(f"unsupported PLAYWRIGHT_BROWSER: {engine}")
+
+ RESULTS.mkdir(parents=True, exist_ok=True)
+ with sync_playwright() as playwright:
+ browser = getattr(playwright, engine).launch()
+ try:
+ context = browser.new_context(viewport={"width": 1280, "height": 900})
+ try:
+ exercise_interactive(context, engine)
+ finally:
+ context.close()
+ exercise_without_javascript(browser, engine)
+ finally:
+ browser.close()
+ print(f"zed release-plan {engine} accessibility and print contract passed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/release_plan_accessibility_test.mjs b/tests/release_plan_accessibility_test.mjs
new file mode 100644
index 00000000..a1ed2151
--- /dev/null
+++ b/tests/release_plan_accessibility_test.mjs
@@ -0,0 +1,67 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { renderReleasePlan } from "../scripts/render-release-plan-html.mjs";
+
+const fixture = {
+ release_set: "acme/browser-report@1.2.3#v1.2.3",
+ source: {
+ package: "acme/browser-report",
+ version: "1.2.3",
+ vcs_tag: "v1.2.3",
+ repository: "https://github.com/acme/browser-report",
+ },
+ zed: [
+ { target: "nodejs", package: "acme/browser-report-node", version: "1.2.3", dir: "node" },
+ ],
+ native: [
+ {
+ target: "nodejs",
+ registry: "npm",
+ package: "@acme/browser-report",
+ version: "1.2.3",
+ vcs_tag: "v1.2.3",
+ dir: "node",
+ },
+ ],
+ forge: [],
+};
+
+test("report includes progressive-enhancement accessibility structure", () => {
+ const html = renderReleasePlan(fixture);
+ assert.match(html, //);
+ assert.match(html, /Skip to release report<\/a>/);
+ assert.match(html, //);
+ assert.match(html, /aria-describedby="filter-help filter-status"/);
+ assert.match(html, /