From 0db602ebd0ccbaacb2eaceeea81ff9ff8f4a3efb Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 09:26:45 -0700 Subject: [PATCH] feat(a11y): keyboard + ARIA slider semantics for the Altitude dial role=slider + tabindex on the dial; Arrow/Home/End stepping; labels become Enter/Space buttons; aria-valuenow/valuetext track the committed altitude via renderDial. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/a11y.spec.ts | 18 +++++++++++++++++ simulator/static/app.js | 34 ++++++++++++++++++++++++++++++++ simulator/static/index.html | 4 +++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/simulator/e2e/tests/a11y.spec.ts b/simulator/e2e/tests/a11y.spec.ts index d247ec0..23962a4 100644 --- a/simulator/e2e/tests/a11y.spec.ts +++ b/simulator/e2e/tests/a11y.spec.ts @@ -60,3 +60,21 @@ test.describe("Task 5 — Reduced motion", () => { .toBe(true); // reduced motion → frozen }); }); + +test.describe("Task 6 — Keyboard + ARIA dial", () => { + test("altitude dial is a keyboard-operable slider", async ({ page }) => { + await page.goto("/"); + const dial = page.locator("#dial"); + await expect(dial).toHaveAttribute("role", "slider"); + await expect(dial).toHaveAttribute("tabindex", "0"); + await dial.focus(); + const before = await page.locator("#scale-name").textContent(); + await dial.press("ArrowDown"); // descend one altitude + await expect + .poll(async () => page.locator("#scale-name").textContent()) + .not.toBe(before); + await dial.press("Home"); // jump to the top scale (cosmos) + await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i); + await expect(dial).toHaveAttribute("aria-valuenow", "0"); + }); +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index ded8267..b9bc60d 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -753,6 +753,8 @@ function buildDial() { const t = svg("text", { x: lx, y: ly, "text-anchor": "middle", "dominant-baseline": "central", class: "dial-label", "data-index": String(i), + role: "button", tabindex: "0", + "aria-label": HEFi18n.pickUiString("scale." + ring.scales[i].id, activeLang), }, dial); t.textContent = ring.scales[i].id; } @@ -778,6 +780,16 @@ function renderDial() { for (const el of dial.querySelectorAll(".dial-label")) { el.classList.toggle("active", +el.getAttribute("data-index") === ringIndex); } + setDialAria(); +} + +// Reflect the committed altitude into the dial's slider semantics for assistive tech. +function setDialAria() { + if (!dial || !ring) return; + dial.setAttribute("aria-valuemax", String(ring.scales.length - 1)); + dial.setAttribute("aria-valuenow", String(ringIndex)); + const s = ring.scales[ringIndex]; + if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang)); } function dialAngle(e) { @@ -918,6 +930,27 @@ function jumpToScale(idx) { if (d) autoScrub(Math.round(pos) + d); } +// Keyboard operation of the dial (role="slider"): arrows step one altitude, Home/End +// jump to the ends. Enter/Space on a focused label jumps to that scale. +function onDialKey(e) { + if (!ring || ring.scales.length < 2) return; + const t = e.target; + if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + jumpToScale(+t.getAttribute("data-index")); + return; + } + let handled = true; + switch (e.key) { + case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break; // descend + case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break; // ascend + case "Home": jumpToScale(0); break; + case "End": jumpToScale(ring.scales.length - 1); break; + default: handled = false; + } + if (handled) e.preventDefault(); +} + // --- Dev Mode: pool picker + live analysis, all under one toggle --- // Off by default, state persisted in localStorage. Everything here is read from // data the renderer already has (clips, ring, the alteration response, the preload @@ -1351,6 +1384,7 @@ async function main() { window.addEventListener("pointermove", onDialMove); window.addEventListener("pointerup", onDialUp); dial.addEventListener("wheel", onWheel, { passive: false }); + dial.addEventListener("keydown", onDialKey); // arrows/Home/End + Enter/Space on labels $("stage").addEventListener("wheel", onWheel, { passive: false }); update(); // render the initial state (both toggles off → black, silent) await preloadAllMedia(); // download all media, updating the loading bar diff --git a/simulator/static/index.html b/simulator/static/index.html index 5784125..b8e0b1d 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -65,7 +65,9 @@
Altitude
- +

Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.