Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG-AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Use one bullet per session (newest first):

## Entries (newest first)

- 2026-06-05 — **Stage 8 / accessibility**: WCAG audit + fixes (Issues #708–#711). Added a skip-to-content link with an e2e assertion (SC 2.4.1, not caught by axe), a global `:focus-visible` fallback (SC 2.4.7), a `<header>` banner landmark with `<nav>` scoped to the route tabs (SC 1.3.1), extended the axe e2e to the `wcag22aa` tag set with 24px clear-button hit areas (SC 2.5.8), a global `prefers-reduced-motion` rule, and darkened `--muted-foreground` for contrast headroom (SC 1.4.3). One commit per issue. (Claude Opus 4.8 via Claude Code)
- **Log:** [log](docs/ai-logs/2026-06-05-wcag-accessibility-audit.md)

- 2026-06-03 — **Docs / Stage 8**: Sync `docs/03-architecture.md` with the Stage 8 codebase (Issue #631). Rewrote the project-structure tree, reactive-state topology, component tree, data-flow diagrams, error-handling, accessibility, and related-docs sections to reflect the real modules — factory-function state (`createEntitySelectionState`/`createCalculatorState`/…) + page orchestrators, `app-init.svelte.ts` bootstrap, the layered URL parser (`url-grammar.peggy` → AST, `urlv=2`), the tabbed `entity-selection/` picker, `results/table-*.svelte`, `unit-anchor-strip.svelte`, and `<svelte:boundary>` page error fallbacks. Removed the dead `EntityDropdown`/`EntityPanel`/`EnergyInput`/`UnitSelector`/`entities.svelte.ts`/`calculation.svelte.ts` references. Annotated the shipped/deferred follow-ups in `entity-selection.md`; verified `calculator.md` already uses the unit-anchor strip. (Claude Opus 4.8 via Claude Code)
- **Log:** [log](docs/ai-logs/2026-06-03-issue-631-docs-sync.md)

Expand Down
83 changes: 83 additions & 0 deletions docs/ai-logs/2026-06-05-wcag-accessibility-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# 2026-06-05 — WCAG Accessibility Audit & Fixes

## Session Narrative

### Prompt 1: Audit the app for full WCAG compatibility and report what is missing

**AI response**: Surveyed the codebase for WCAG 2.1 AA coverage. Found the app
already well-instrumented: axe-core e2e test (`tests/e2e/accessibility.spec.ts`)
across all four routes running in CI, `lang`, per-page titles, an `h1` per route,
`aria-current` nav state, `aria-label`s on icon/ambiguous buttons, 29 live-region
usages plus `selection-live-region.svelte`, `role="dialog"` + focus traps on
modals, `role="img"` + description on the JSROOT plot, and 48px route-tab touch
targets. Identified gaps: **(1)** no skip link (SC 2.4.1, Level A — and not caught
by axe), **(2)** no global `:focus-visible` fallback (SC 2.4.7), **(3)** masthead
is a bare `<nav>` containing non-nav controls (SC 1.3.1), plus a coverage blind
spot (axe only ran `wcag2a`/`wcag2aa`, not `wcag22aa`) and minor hardening
(partial reduced-motion, borderline `--muted-foreground` contrast). Delivered the
report in chat.

### Prompt 2: Create issues describing what to fix and why

**AI response**: Filed four focused issues on `APTG/dedx_web`: #708 (skip link),
#709 (focus-visible), #710 (header landmark), #711 (WCAG 2.2 coverage +
hardening). Each has summary, rationale, concrete proposed fix with file paths,
acceptance criteria, and WCAG references. Only the `enhancement` label existed in
the repo, so severity was encoded in the bodies.

### Prompt 3: Work the issues (one commit each), create labels + retag, add AI log, open a PR

**AI response**: Implemented all four on `claude/wcag-compatibility-audit-CPq46`,
one commit per issue. Determined via axe-core source that `wcag22aa` adds only the
`target-size` rule (SC 2.5.8); proactively gave the two clear-override icon
buttons a 24px hit area. Verified the darkened contrast token direction with a
luminance calc. Ran `pnpm lint` (clean), `pnpm check` (0 errors), `pnpm test`
(1697 pass; the only failures are `guard-forbidden-files.test.ts`, which spawn
nested `git commit`s that hit the sandbox commit-signing server — environmental,
unrelated to these changes).

## Tasks

### #708 — Skip-to-content link (WCAG 2.4.1, Level A)

- **Status**: completed
- **Stage**: Stage 8 (accessibility hardening)
- **Files changed**: `src/routes/+layout.svelte`, `tests/e2e/accessibility.spec.ts`
- **Decision**: Off-screen-until-focused link as first focusable element targeting
`<main id="main-content" tabindex="-1">`; `#main-content:focus { outline: none }`
so the programmatic skip target shows no ring. Added an e2e assertion (first Tab
focuses the link, Enter moves focus to `<main>`) because axe's `bypass` rule does
not reliably detect a missing skip link.

### #709 — Global :focus-visible fallback (WCAG 2.4.7)

- **Status**: completed
- **Stage**: Stage 8
- **Files changed**: `src/app.css`
- **Decision**: `:where(...)` selector keeps specificity at 0 so existing
component focus styles still win; excludes `tabindex="-1"` so skip targets don't
get a ring.

### #710 — `<header>` banner landmark, scoped `<nav>` (WCAG 1.3.1)

- **Status**: completed
- **Stage**: Stage 8
- **Files changed**: `src/routes/+layout.svelte`
- **Decision**: Masthead is now `<header>` (banner); `<nav aria-label="Primary">`
wraps only the route tabs. All `data-testid` hooks preserved; existing
`locator("nav")` / `getByRole("navigation")` e2e checks still resolve to the
route-tabs nav.

### #711 — WCAG 2.2 coverage + hardening

- **Status**: completed
- **Stage**: Stage 8
- **Files changed**: `tests/e2e/accessibility.spec.ts`, `src/app.css`,
`src/lib/components/advanced-options-panel.svelte`
- **Decision**: Added `wcag22aa` to the axe tag set (adds `target-size` / SC
2.5.8 only). Gave the clear-override buttons a 24px hit area. Added a global
`prefers-reduced-motion` rule. Darkened `--muted-foreground` 0.47 → 0.44 for AA
contrast headroom.
- **Issue**: e2e/axe could not be executed in this environment (no built WASM, no
Playwright browsers), so `target-size` was validated by reasoning + proactive
hardening rather than a live run. CI on the PR is the final verifier.
1 change: 1 addition & 0 deletions docs/ai-logs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ summary table of all sessions.

| File | Date | Topic |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [2026-06-05-wcag-accessibility-audit.md](2026-06-05-wcag-accessibility-audit.md) | 5 June 2026 | **WCAG audit & fixes (#708–#711)** (Claude Opus 4.8 via Claude Code): skip-to-content link with e2e assertion (SC 2.4.1, not caught by axe); global `:focus-visible` fallback (SC 2.4.7); `<header>` banner landmark with `<nav>` scoped to route tabs (SC 1.3.1); axe e2e extended to `wcag22aa` + 24px clear-button hit areas (SC 2.5.8); global `prefers-reduced-motion` rule; darkened `--muted-foreground` for contrast headroom (SC 1.4.3). One commit per issue. |
| [2026-06-03-issue-631-docs-sync.md](2026-06-03-issue-631-docs-sync.md) | 3 June 2026 | **Sync architecture & specs with Stage 8 state (#631)** (Claude Opus 4.8 via Claude Code): rewrote `docs/03-architecture.md` (project structure, factory-function + orchestrator state topology, layered URL parser `urlv=2`, tabbed `entity-selection/` + `results/table-*` component tree, data-flow diagrams, `<svelte:boundary>` error fallbacks, accessibility); annotated shipped/deferred follow-ups in `entity-selection.md`; verified `calculator.md` already on the unit-anchor strip. |
| [2026-05-31-inverse-lookups.md](2026-05-31-inverse-lookups.md) | 31 May 2026 | **Second output column for the inverse tabs (#673)** (Claude Opus 4.8 via Claude Code): Range → gains a `→ STP` column (shared `stpOutputUnit` header menu); STP → pairs a `→ Range` column with each energy branch (high-E always, low-E on reveal). Complementary value recovered via a forward `calculate()` at the resolved energies — no new WASM API. State/calc/components + `inverse-lookups.md` v7 / `calculator.md` / wasm contract updated; new unit tests. |
| [2026-05-30-stp-output-units.md](2026-05-30-stp-output-units.md) | 30 May 2026 | **Stopping-power output unit selector in the Advanced calculator (#670)** (Claude Opus 4.8 via Claude Code): STP column header becomes a dropdown (`keV/µm` · `MeV/cm` · `MeV·cm²/g`) with desktop popover + mobile bottom sheet; shared `sunit=` URL param (single source of truth for calculator + plot; legacy `stp_unit=` still read); cross-page sharing via in-memory state, **no localStorage**; single-entity STP conversion moved to render time. Unit/component/E2E tests added. |
Expand Down
32 changes: 31 additions & 1 deletion src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.47 0 0);
/* Darkened from 0.47 → 0.44 for AA contrast headroom on white (WCAG 1.4.3) */
--muted-foreground: oklch(0.44 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
Expand Down Expand Up @@ -80,4 +81,33 @@
body {
@apply bg-background text-foreground;
}

/* Global visible-focus fallback (WCAG 2.4.7). :where() keeps specificity at 0
so components with their own focus styles continue to win. Excludes
tabindex="-1" so programmatic skip targets don't show a ring. */
:where(
a,
button,
input,
select,
textarea,
[role="button"],
[tabindex]:not([tabindex="-1"])
):focus-visible {
outline: 2px solid var(--ring, var(--primary));
outline-offset: 2px;
}

/* Respect reduced-motion globally — neutralizes Tailwind transitions/animations
not already gated by per-component matchMedia checks. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
4 changes: 2 additions & 2 deletions src/lib/components/advanced-options-panel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@
? "Custom compounds carry their own density. Edit the compound to change density."
: undefined}
onclick={clearDensity}
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
class="absolute right-1 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center text-muted-foreground hover:text-foreground"
aria-label="Clear density override"
>
<svg
Expand Down Expand Up @@ -385,7 +385,7 @@
? "Custom compounds carry their own I-value. Edit the compound to change it."
: undefined}
onclick={clearIValue}
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
class="absolute right-1 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center text-muted-foreground hover:text-foreground"
aria-label="Clear I-value override"
>
<svg
Expand Down
42 changes: 37 additions & 5 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
</script>

<div class="min-h-screen bg-background">
<nav class="border-b bg-card" data-testid="app-header">
<a href="#main-content" class="skip-link">Skip to content</a>
<header class="border-b bg-card" data-testid="app-header">
<div class="container mx-auto px-4">
<!-- Row 1: logo + secondary controls (mode toggle, export, share) -->
<div class="flex h-12 items-center justify-between gap-2">
Expand Down Expand Up @@ -167,7 +168,11 @@
</div>

<!-- Row 2: primary route navigation tabs — muted strip so active tab pops out -->
<div class="flex border-t border-border/40 bg-muted/60" data-testid="route-tabs">
<nav
aria-label="Primary"
class="flex border-t border-border/40 bg-muted/60"
data-testid="route-tabs"
>
<a
href={`${base}/calculator`}
class="route-tab"
Expand Down Expand Up @@ -198,9 +203,9 @@
>
Docs
</a>
</div>
</nav>
</div>
</nav>
</header>

{#if wasmError.value}
<div class="bg-destructive/15 border-b border-destructive/20 px-4 py-3">
Expand All @@ -225,7 +230,7 @@
</div>
{/if}

<main class="container mx-auto px-4 pt-3 pb-6 sm:py-6">
<main id="main-content" tabindex="-1" class="container mx-auto px-4 pt-3 pb-6 sm:py-6">
{@render children()}
</main>

Expand Down Expand Up @@ -258,6 +263,33 @@
</div>

<style>
/* Skip link: off-screen until focused, then slides into view (WCAG 2.4.1) */
.skip-link {
position: absolute;
left: 0.5rem;
top: -3.5rem;
z-index: 50;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-md);
background-color: var(--card);
color: var(--foreground);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.2);
transition: top 0.15s ease;
}

.skip-link:focus-visible {
top: 0.5rem;
outline: 2px solid var(--ring, var(--primary));
outline-offset: 2px;
}

/* The skip target is programmatically focusable but should not show a ring */
#main-content:focus {
outline: none;
}

.route-tab {
display: flex;
flex: 1 1 0%;
Expand Down
17 changes: 16 additions & 1 deletion tests/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ for (const { path, label } of ROUTES) {
}

const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa"])
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
// Bits UI Combobox places the search <input role="combobox"> inside
Comment on lines 24 to 26
// div[role="listbox"], which violates aria-required-children. This is a
// known structural limitation of the bits-ui combobox pattern and is not
Expand All @@ -35,3 +35,18 @@ for (const { path, label } of ROUTES) {
expect(results.violations).toEqual([]);
});
}

test("skip link is the first tab stop and moves focus to main @regression", async ({ page }) => {
await page.goto("/calculator");
await page.waitForSelector('[data-testid="result-table"]', { timeout: 15000 });

// First Tab from the top of the document must land on the skip link.
await page.keyboard.press("Tab");
const skipLink = page.getByRole("link", { name: "Skip to content" });
await expect(skipLink).toBeFocused();

// Activating it moves focus to the main content landmark.
await page.keyboard.press("Enter");
const main = page.locator("#main-content");
await expect(main).toBeFocused();
});