Files
tutorialvault/docs/plans/2026-02-19-wcag-aaa-plan.md
2026-02-19 16:00:19 +02:00

1155 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WCAG 2.2 AAA Remediation — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Bring TutorialVault to full WCAG 2.2 AAA compliance with surgical, in-place edits that preserve the Cold Open aesthetic.
**Architecture:** Edit existing files directly. No new modules or abstractions. Fix each audit finding with the minimum code change. Contrast values raised just enough to hit 7:1 while keeping the cool, muted hierarchy. Focus rings use the existing accent color. All menus get keyboard navigation with Escape-to-close. Playlist gets listbox semantics and keyboard reorder.
**Tech Stack:** TypeScript, Vite, Tauri v2, HTML5, CSS3
---
## Task 1: Semantic HTML — lang, landmarks, headings
**Files:**
- Modify: `src/index.html`
**Step 1: Add `lang="en"` to the `<html>` tag**
In `src/index.html`, line 2, change:
```html
<html>
```
to:
```html
<html lang="en">
```
**Step 2: Wrap `.topbar` in `<header>` landmark**
Change the topbar div (line 11) from:
```html
<div class="topbar" data-tauri-drag-region>
```
to:
```html
<header class="topbar" data-tauri-drag-region role="banner">
```
And the closing tag (line 69) from `</div>` to `</header>`.
**Step 3: Wrap `.content` in `<main>` landmark**
Change line 73 from:
```html
<div class="content" id="contentGrid">
```
to:
```html
<main class="content" id="contentGrid" role="main">
```
And its closing tag (line 250) from `</div>` to `</main>`.
**Step 4: Add region roles to panels**
Left panel (line 74): add `role="region" aria-label="Video player"`:
```html
<div class="panel" role="region" aria-label="Video player">
```
Right panel (line 237): add `role="region" aria-label="Playlist"`:
```html
<div class="panel" role="region" aria-label="Playlist">
```
Dock (line 169): add `role="complementary" aria-label="Details"`:
```html
<div class="dock" id="dockGrid" role="complementary" aria-label="Details">
```
**Step 5: Change key elements to semantic headings**
`.appName` (line 15) — change `<div>` to `<h1>`:
```html
<h1 class="appName" data-tauri-drag-region>TutorialVault</h1>
```
`.nowTitle` (line 77) — change `<div>` to `<h2>`:
```html
<h2 class="nowTitle" id="nowTitle">No video loaded</h2>
```
`.playlistHeader` (line 239) — change `<div>` to `<h2>`:
```html
<h2 class="playlistHeader" id="plistHeader" data-tooltip="Playlist" data-tooltip-desc="Drag items to reorder. The blue line shows where it will drop."><i class="fa-solid fa-list"></i> Playlist</h2>
```
Both `.dockTitle` elements (lines 173, 189) — change `<div>` to `<h3>`:
```html
<h3 class="dockTitle"><i class="fa-solid fa-note-sticky"></i> Notes</h3>
```
```html
<h3 class="dockTitle"><i class="fa-solid fa-circle-info"></i> Info</h3>
```
**Step 6: Change `zoomResetBtn` from `<span>` to `<button>`**
Line 40, change:
```html
<span class="zoomValue" id="zoomResetBtn">100%</span>
```
to:
```html
<button class="zoomValue" id="zoomResetBtn" aria-label="Reset zoom">100%</button>
```
**Step 7: Build and verify**
Run: `npm run build`
Expected: Build succeeds with no errors. No visual change (headings inherit existing class styles, landmarks are invisible).
**Step 8: Commit**
```bash
git add src/index.html
git commit -m "a11y: add lang, landmarks, semantic headings, button for zoomReset"
```
---
## Task 2: ARIA labels and text alternatives
**Files:**
- Modify: `src/index.html`
**Step 1: Add `aria-label` to all icon-only buttons**
Each button gets an `aria-label`. Add `aria-hidden="true"` to all decorative `<i>` icons inside labeled buttons.
`zoomOutBtn` (line 39):
```html
<button class="zoomBtn" id="zoomOutBtn" aria-label="Zoom out"><i class="fa-solid fa-minus" aria-hidden="true"></i></button>
```
`zoomInBtn` (line 41):
```html
<button class="zoomBtn" id="zoomInBtn" aria-label="Zoom in"><i class="fa-solid fa-plus" aria-hidden="true"></i></button>
```
`chooseDropBtn` (line 50):
```html
<button class="btn drop" id="chooseDropBtn" aria-label="Recent folders"><i class="fa-solid fa-chevron-down" aria-hidden="true"></i></button>
```
`chooseBtn` (line 49) — already has text "Open folder", just add `aria-hidden` to icon:
```html
<button class="btn primary" id="chooseBtn" data-tooltip="Open Folder" data-tooltip-desc="Browse and select a folder containing videos"><i class="fa-solid fa-folder-open" aria-hidden="true"></i> Open folder</button>
```
`resetProgBtn` (line 57):
```html
<button class="toolbarBtn" id="resetProgBtn" aria-label="Reset progress" data-tooltip="Reset Progress" data-tooltip-desc="Reset DONE / NOW progress for this folder (keeps notes, volume, etc.)"><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i></button>
```
`refreshBtn` (line 58):
```html
<button class="toolbarBtn" id="refreshBtn" aria-label="Reload folder" data-tooltip="Reload" data-tooltip-desc="Reload the current folder"><i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i></button>
```
`winMinBtn` (line 64):
```html
<button class="toolbarBtn winBtn" id="winMinBtn" aria-label="Minimize" data-tooltip="Minimize" data-tooltip-desc="Minimize window"><i class="fa-solid fa-minus" aria-hidden="true"></i></button>
```
`winMaxBtn` (line 65):
```html
<button class="toolbarBtn winBtn" id="winMaxBtn" aria-label="Maximize" data-tooltip="Maximize" data-tooltip-desc="Maximize or restore window"><i class="fa-solid fa-square" aria-hidden="true"></i></button>
```
`winCloseBtn` (line 66):
```html
<button class="toolbarBtn winBtn winClose" id="winCloseBtn" aria-label="Close" data-tooltip="Close" data-tooltip-desc="Close window"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button>
```
`prevBtn` (line 107):
```html
<button class="iconBtn" id="prevBtn" aria-label="Previous video" data-tooltip="Previous" data-tooltip-desc="Go to previous video"><i class="fa-solid fa-backward-step" aria-hidden="true"></i></button>
```
`playPauseBtn` (line 109):
```html
<button class="iconBtn primary" id="playPauseBtn" aria-label="Play" data-tooltip="Play/Pause" data-tooltip-desc="Toggle video playback">
<i class="fa-solid fa-play" id="ppIcon" aria-hidden="true"></i>
</button>
```
`nextBtn` (line 113):
```html
<button class="iconBtn" id="nextBtn" aria-label="Next video" data-tooltip="Next" data-tooltip-desc="Go to next video"><i class="fa-solid fa-forward-step" aria-hidden="true"></i></button>
```
`subsBtn` (line 125):
```html
<button class="iconBtn" id="subsBtn" aria-label="Subtitles" data-tooltip="Subtitles" data-tooltip-desc="Load or select subtitles"><i class="fa-regular fa-closed-captioning" aria-hidden="true"></i></button>
```
`fsBtn` (line 164):
```html
<button class="iconBtn" id="fsBtn" aria-label="Toggle fullscreen" data-tooltip="Fullscreen" data-tooltip-desc="Toggle fullscreen mode"><i class="fa-solid fa-expand" aria-hidden="true"></i></button>
```
**Step 2: Add `aria-label` to video element and volume slider**
Video (line 89):
```html
<video id="player" preload="metadata" crossorigin="anonymous" aria-label="Video player"></video>
```
Volume slider (line 137):
```html
<input type="range" id="volSlider" class="vol" min="0" max="1" step="0.01" value="1" aria-label="Volume">
```
**Step 3: Add ARIA to the progress bar**
Progress bar container (line 83):
```html
<div class="progressBar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="Overall folder progress"><div id="overallBar"></div></div>
```
**Step 4: Add `aria-hidden="true"` to remaining decorative icons**
Volume icon (line 132):
```html
<i class="fa-solid fa-volume-high" aria-hidden="true"></i>
```
Toast icon (line 256):
```html
<div class="toastIcon"><i class="fa-solid fa-circle-info" aria-hidden="true"></i></div>
```
Notes saved check icon (line 177):
```html
<div class="notesSaved" id="notesSaved"><i class="fa-solid fa-check" aria-hidden="true"></i> Saved</div>
```
Playlist header icon (line 239):
```html
<h2 class="playlistHeader" ...><i class="fa-solid fa-list" aria-hidden="true"></i> Playlist</h2>
```
Notes dock icon:
```html
<h3 class="dockTitle"><i class="fa-solid fa-note-sticky" aria-hidden="true"></i> Notes</h3>
```
Info dock icon:
```html
<h3 class="dockTitle"><i class="fa-solid fa-circle-info" aria-hidden="true"></i> Info</h3>
```
Speed caret (line 156):
```html
<span class="speedCaret" aria-hidden="true"><i class="fa-solid fa-chevron-up"></i></span>
```
**Step 5: Add `aria-label` to notes textarea**
Line 176:
```html
<textarea class="notes" id="notesBox" aria-label="Notes for current video" placeholder="Write timestamps, TODOs, reminders…"></textarea>
```
**Step 6: Build and verify**
Run: `npm run build`
Expected: Build succeeds. No visual change.
**Step 7: Commit**
```bash
git add src/index.html
git commit -m "a11y: add aria-labels, aria-hidden on decorative icons, progress bar ARIA"
```
---
## Task 3: Info panel semantic markup — dl/dt/dd
**Files:**
- Modify: `src/index.html`
- Modify: `src/styles/panels.css`
**Step 1: Convert info panel `.kv` blocks from divs to `<dl>/<dt>/<dd>`**
Replace the entire `<div class="infoGrid" id="infoGrid">...</div>` block (lines 191226) with:
```html
<div class="infoGrid" id="infoGrid">
<dl class="kv">
<dt class="k">Folder</dt><dd class="v" id="infoFolder">-</dd>
<dt class="k">Next up</dt><dd class="v" id="infoNext">-</dd>
<dt class="k">Structure</dt><dd class="v mono" id="infoStruct">-</dd>
</dl>
<dl class="kv">
<dt class="k">Title</dt><dd class="v" id="infoTitle">-</dd>
<dt class="k">Relpath</dt><dd class="v mono" id="infoRel">-</dd>
<dt class="k">Position</dt><dd class="v mono" id="infoPos">-</dd>
</dl>
<dl class="kv">
<dt class="k">File</dt><dd class="v mono" id="infoFileBits">-</dd>
<dt class="k">Video</dt><dd class="v mono" id="infoVidBits">-</dd>
<dt class="k">Audio</dt><dd class="v mono" id="infoAudBits">-</dd>
<dt class="k">Subtitles</dt><dd class="v mono" id="infoSubsBits">-</dd>
</dl>
<dl class="kv">
<dt class="k">Finished</dt><dd class="v mono" id="infoFinished">-</dd>
<dt class="k">Remaining</dt><dd class="v mono" id="infoRemaining">-</dd>
<dt class="k">ETA</dt><dd class="v mono" id="infoEta">-</dd>
</dl>
<dl class="kv">
<dt class="k">Volume</dt><dd class="v mono" id="infoVolume">-</dd>
<dt class="k">Speed</dt><dd class="v mono" id="infoSpeed">-</dd>
<dt class="k">Durations</dt><dd class="v mono" id="infoKnown">-</dd>
</dl>
<dl class="kv">
<dt class="k">Top folders</dt><dd class="v mono" id="infoTop">-</dd>
</dl>
</div>
```
**Step 2: Reset dl/dt/dd default margins in panels.css**
Add at the top of `.kv` rule in `src/styles/panels.css` (after line 104):
```css
dl.kv{margin:0;}
dl.kv dt,dl.kv dd{margin:0; padding:0;}
```
Then update `.k` and `.v` selectors to also target `dt.k` and `dd.v` (they already match since the classes are preserved).
**Step 3: Build and verify**
Run: `npm run build`
Expected: Build succeeds. Info panel looks identical — same grid layout, same styles.
**Step 4: Commit**
```bash
git add src/index.html src/styles/panels.css
git commit -m "a11y: convert info panel to dl/dt/dd for proper semantics"
```
---
## Task 4: Color contrast — raise to AAA 7:1
**Files:**
- Modify: `src/styles/main.css`
- Modify: `src/styles/panels.css`
**Step 1: Update `:root` custom properties in main.css**
In `src/styles/main.css`, update these values in `:root` (lines 153):
Change `--textMuted` (line 12):
```
--textMuted:rgba(160,174,204,.72);
```
Change `--textDim` (line 13):
```
--textDim:rgba(158,174,208,.68);
```
Change `--icon` (line 39):
```
--icon:rgba(160,175,210,.62);
```
**Step 2: Update tagline color in main.css**
Change `.tagline` color (line 207) from `color:rgba(148,162,192,.62)` to:
```css
color:var(--textMuted);
```
**Step 3: Update time separator color**
In `src/index.html`, line 119, change:
```html
<div><span id="timeNow">00:00</span> <span style="color:rgba(165,172,196,.65)">/</span> <span id="timeDur">00:00</span></div>
```
to:
```html
<div><span id="timeNow">00:00</span> <span class="timeSep">/</span> <span id="timeDur">00:00</span></div>
```
Add in `src/styles/player.css` at the end:
```css
.timeSep{color:rgba(175,185,210,.78);}
```
**Step 4: Improve notes placeholder contrast**
In `src/styles/panels.css`, line 73, change:
```css
.notes::placeholder{color:rgba(148,162,192,.40); transition:color .2s ease;}
```
to:
```css
.notes::placeholder{color:rgba(155,170,200,.65); transition:color .2s ease;}
```
**Step 5: Improve `.k` label contrast in panels.css**
In `src/styles/panels.css`, line 128, the `.kv:hover .k` rule — this is fine as-is since it's the hover state. The base `.k` color uses `--textDim` which we already raised.
**Step 6: Build and verify**
Run: `npm run build`
Expected: Build succeeds. Text is slightly brighter but hierarchy preserved. Muted text now passes AAA 7:1 against `#151821`.
**Step 7: Commit**
```bash
git add src/styles/main.css src/styles/panels.css src/styles/player.css src/index.html
git commit -m "a11y: raise text contrast to AAA 7:1 — textMuted, textDim, icon, tagline, placeholder"
```
---
## Task 5: Focus indicators — global `:focus-visible`
**Files:**
- Modify: `src/styles/main.css`
- Modify: `src/styles/player.css`
- Modify: `src/styles/playlist.css`
- Modify: `src/styles/panels.css`
- Modify: `src/styles/components.css`
**Step 1: Add global `:focus-visible` rule in main.css**
Add after the `body` rule (after line 66) in `src/styles/main.css`:
```css
*:focus-visible{
outline:2px solid rgba(136,164,196,.65);
outline-offset:2px;
border-radius:inherit;
}
*:focus{outline:none;}
```
**Step 2: Playlist row focus style in playlist.css**
Add after the `.row.active` rule (after line 64) in `src/styles/playlist.css`:
```css
.row:focus-visible{outline-offset:-2px; background:var(--surfaceHover); box-shadow:inset 3px 0 0 rgba(136,164,196,.40);}
```
**Step 3: Slider focus styles in player.css**
Add at the end of `src/styles/player.css`:
```css
.seek:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 2px 6px rgba(0,0,0,.30);}
.vol:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 1px 4px rgba(0,0,0,.25);}
.seek:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 2px 6px rgba(0,0,0,.30);}
.vol:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px rgba(136,164,196,.45), 0 1px 4px rgba(0,0,0,.25);}
```
**Step 4: Notes textarea focus enhancement in panels.css**
Update `.notes:focus` rule (line 68) in `src/styles/panels.css`:
```css
.notes:focus{border-color:rgba(136,164,196,.25); box-shadow:var(--shadowInset), 0 0 0 2px rgba(136,164,196,.12);}
```
**Step 5: Switch label focus in main.css**
Add after the `.switch:active` rule (after line 460) in `src/styles/main.css`:
```css
.switch:focus-within{outline:2px solid rgba(136,164,196,.65); outline-offset:2px;}
```
**Step 6: Menu item focus styles in components.css**
Add at the end of `src/styles/components.css`:
```css
.subsMenuItem:focus-visible{background:var(--surfaceHover); padding-left:16px; outline:none;}
.speedItem:focus-visible{background:var(--surfaceHover); padding-left:14px; outline:none;}
.dropItem:focus-visible{background:var(--surfaceHover); padding-left:16px; outline:none;}
```
**Step 7: Build and verify**
Run: `npm run build`
Expected: Build succeeds. Tab through the UI — every interactive element shows a subtle steel-blue outline on keyboard focus. Mouse clicks don't trigger outlines (`:focus-visible` only).
**Step 8: Commit**
```bash
git add src/styles/main.css src/styles/player.css src/styles/playlist.css src/styles/panels.css src/styles/components.css
git commit -m "a11y: add focus-visible indicators — global outline, slider glow, menu highlight"
```
---
## Task 6: Target size expansion — 44x44 hit areas
**Files:**
- Modify: `src/styles/main.css`
**Step 1: Expand `.zoomBtn` hit area (currently 28x28)**
Add after the `.zoomBtn:active` rule in `src/styles/main.css`:
```css
.zoomBtn{position:relative;}
.zoomBtn::before{content:""; position:absolute; inset:-8px; border-radius:var(--r2);}
```
**Step 2: Expand `.winBtn` hit area (currently 30x30)**
Add after the `.winClose:active` rule:
```css
.winBtn{position:relative;}
.winBtn::before{content:""; position:absolute; inset:-7px; border-radius:var(--r2);}
```
**Step 3: Expand `.dropRemove` hit area (currently 24x24)**
The `.dropRemove` is already absolutely positioned. Add to its rule in `src/styles/main.css`:
Add after `.dropRemove:active`:
```css
.dropRemove{position:relative;}
.dropRemove::before{content:""; position:absolute; inset:-10px; border-radius:var(--r3);}
```
**Step 4: Build and verify**
Run: `npm run build`
Expected: Build succeeds. Visual size unchanged. Click/touch areas now reach 44x44 effective.
**Step 5: Commit**
```bash
git add src/styles/main.css
git commit -m "a11y: expand small button hit areas to 44x44 using pseudo-element technique"
```
---
## Task 7: Playlist keyboard accessibility and ARIA
**Files:**
- Modify: `src/playlist.ts`
- Modify: `src/styles/playlist.css`
**Step 1: Add listbox role and aria-label to list container**
In `src/playlist.ts`, in the `initPlaylist()` function, after `listEl = document.getElementById('list')!;` (line 37), add:
```typescript
listEl.setAttribute('role', 'listbox');
listEl.setAttribute('aria-label', 'Playlist');
```
**Step 2: Add a live region for reorder announcements**
In `initPlaylist()`, after the list setup, add:
```typescript
// Live region for reorder announcements
const liveRegion = document.createElement('div');
liveRegion.id = 'playlistLive';
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';
document.body.appendChild(liveRegion);
```
**Step 3: Update `renderList()` — add role, tabindex, aria attributes, keyboard nav, and move buttons**
Replace the row creation section in `renderList()` (the `for` loop, approximately lines 252338) with a version that adds:
- `role="option"` and `aria-selected` on each row
- `tabindex="0"` on each row
- Computed `aria-label` (e.g. "03. Video Title - 5:30 / 12:00 - Done")
- `keydown` handler: Enter/Space to activate, ArrowUp/Down to navigate rows, Alt+ArrowUp/Down to reorder
- Move-up/move-down buttons that appear on focus/hover
In `renderList()`, replace the row creation code. After `row.dataset.index = String(it.index);`, add:
```typescript
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', it.index === currentIndex ? 'true' : 'false');
row.tabIndex = 0;
// Computed aria-label
const durStr = it.duration ? `${fmtTime(it.watched || 0)} / ${fmtTime(it.duration)}` : `${fmtTime(it.watched || 0)} watched`;
const statusStr = it.index === currentIndex ? 'Now playing' : it.finished ? 'Done' : '';
row.setAttribute('aria-label', `${String(displayIndex + 1).padStart(padN, '0')}. ${it.title || it.name} - ${durStr}${statusStr ? ' - ' + statusStr : ''}`);
```
Add keyboard handler after the click handler:
```typescript
row.addEventListener('keydown', (e) => {
const key = e.key;
if (key === 'Enter' || key === ' ') {
e.preventDefault();
cb.loadIndex?.(it.index, computeResumeTime(it), true);
} else if (key === 'ArrowDown') {
e.preventDefault();
const next = row.nextElementSibling as HTMLElement | null;
if (next && next.classList.contains('row')) next.focus();
} else if (key === 'ArrowUp') {
e.preventDefault();
const prev = row.previousElementSibling as HTMLElement | null;
if (prev && prev.classList.contains('row')) prev.focus();
} else if (e.altKey && key === 'ArrowDown' && displayIndex < library!.items.length - 1) {
e.preventDefault();
reorderPlaylistByGap(it.index, library!.items[displayIndex + 1].index, true).then(() => {
setTimeout(() => {
const moved = listEl.querySelector(`[data-index="${it.index}"]`) as HTMLElement | null;
if (moved) moved.focus();
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex + 2}`;
}, 100);
});
} else if (e.altKey && key === 'ArrowUp' && displayIndex > 0) {
e.preventDefault();
reorderPlaylistByGap(it.index, library!.items[displayIndex - 1].index, false).then(() => {
setTimeout(() => {
const moved = listEl.querySelector(`[data-index="${it.index}"]`) as HTMLElement | null;
if (moved) moved.focus();
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex}`;
}, 100);
});
}
});
```
Add move buttons (between `row.appendChild(left);` and `row.appendChild(tag);`):
```typescript
// Move buttons for keyboard reorder alternative
const moveWrap = document.createElement('div');
moveWrap.className = 'moveWrap';
if (displayIndex > 0) {
const moveUp = document.createElement('button');
moveUp.className = 'moveBtn';
moveUp.setAttribute('aria-label', 'Move up');
moveUp.innerHTML = '<i class="fa-solid fa-chevron-up" aria-hidden="true"></i>';
moveUp.tabIndex = -1;
moveUp.addEventListener('click', (e) => {
e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items[displayIndex - 1].index, false).then(() => {
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex}`;
});
});
moveWrap.appendChild(moveUp);
}
if (displayIndex < library!.items.length - 1) {
const moveDown = document.createElement('button');
moveDown.className = 'moveBtn';
moveDown.setAttribute('aria-label', 'Move down');
moveDown.innerHTML = '<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>';
moveDown.tabIndex = -1;
moveDown.addEventListener('click', (e) => {
e.stopPropagation();
reorderPlaylistByGap(it.index, library!.items[displayIndex + 1].index, true).then(() => {
const lr = document.getElementById('playlistLive');
if (lr) lr.textContent = `Moved to position ${displayIndex + 2}`;
});
});
moveWrap.appendChild(moveDown);
}
row.appendChild(left);
row.appendChild(moveWrap);
row.appendChild(tag);
```
Remember to also add the `computeResumeTime` and `fmtTime` imports at the top of the file if not already there (they are already imported from store).
**Step 4: Add move button styles in playlist.css**
Add at the end of `src/styles/playlist.css`:
```css
.moveWrap{display:flex; flex-direction:column; gap:2px; flex:0 0 auto; opacity:0; transition:opacity .2s ease;}
.row:hover .moveWrap, .row:focus-within .moveWrap{opacity:1;}
.moveBtn{width:22px; height:18px; border:none; background:var(--surface-2); border-radius:var(--r3); cursor:pointer; display:flex; align-items:center; justify-content:center; transition:all .15s var(--ease-bounce); padding:0;}
.moveBtn:hover{background:var(--surface-3); transform:scale(1.1);}
.moveBtn:active{transform:scale(.9); transition-duration:.08s;}
.moveBtn .fa{font-size:9px; color:var(--iconStrong)!important; opacity:.7;}
.moveBtn:hover .fa{opacity:1;}
```
**Step 5: Build and verify**
Run: `npm run build`
Expected: Build succeeds. Tab into playlist, arrow keys navigate rows, Enter activates, Alt+Arrow reorders with live announcement. Move buttons appear on hover/focus.
**Step 6: Commit**
```bash
git add src/playlist.ts src/styles/playlist.css
git commit -m "a11y: playlist listbox semantics, keyboard nav, move buttons, Alt+Arrow reorder"
```
---
## Task 8: Player menus — keyboard nav and ARIA
**Files:**
- Modify: `src/player.ts`
**Step 1: Add `aria-expanded` toggle on speed button**
In `src/player.ts`, update `openSpeedMenu()` (line 332):
```typescript
export function openSpeedMenu(): void {
speedMenu?.classList.add('show');
speedBtn?.setAttribute('aria-expanded', 'true');
}
```
Update `closeSpeedMenu()` (line 331):
```typescript
export function closeSpeedMenu(): void {
speedMenu?.classList.remove('show');
speedBtn?.setAttribute('aria-expanded', 'false');
}
```
**Step 2: Add `aria-haspopup` to speed button in initPlayer**
After `speedBtn = document.getElementById('speedBtn')!;` (line 50), add:
```typescript
speedBtn.setAttribute('aria-haspopup', 'true');
speedBtn.setAttribute('aria-expanded', 'false');
```
**Step 3: Add keyboard navigation to speed menu**
Replace the speed button click handler (lines 173178) with:
```typescript
speedBtn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
if (speedMenu.classList.contains('show')) closeSpeedMenu();
else {
openSpeedMenu();
// Focus first menu item
const first = speedMenu.querySelector('[role="menuitem"]') as HTMLElement | null;
if (first) first.focus();
}
});
speedBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeSpeedMenu(); speedBtn.focus(); }
});
```
**Step 4: Add keyboard handlers to speed menu items in `buildSpeedMenu()`**
In the `buildSpeedMenu()` function, after `row.setAttribute('role', 'menuitem');` (line 340), add:
```typescript
row.tabIndex = -1;
row.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = row.nextElementSibling as HTMLElement | null;
if (next) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = row.previousElementSibling as HTMLElement | null;
if (prev) prev.focus();
} else if (e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
closeSpeedMenu();
speedBtn.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
row.click();
}
});
```
**Step 5: Dynamic aria-label on play/pause button**
In `updatePlayPauseIcon()` (line 227), add after the className update:
```typescript
playPauseBtn.setAttribute('aria-label', (player.paused || player.ended) ? 'Play' : 'Pause');
```
**Step 6: Build and verify**
Run: `npm run build`
Expected: Build succeeds. Speed menu opens with keyboard, arrows navigate items, Escape closes and returns focus. Play/pause button label updates dynamically.
**Step 7: Commit**
```bash
git add src/player.ts
git commit -m "a11y: speed menu keyboard nav, aria-expanded, dynamic play/pause label"
```
---
## Task 9: Subtitles menu — keyboard nav and ARIA
**Files:**
- Modify: `src/subtitles.ts`
**Step 1: Add `aria-haspopup` and `aria-expanded` to subtitles button**
In `initSubtitles()`, after `subsMenu = document.getElementById('subsMenu')!;` (line 18), add:
```typescript
subsBtn.setAttribute('aria-haspopup', 'true');
subsBtn.setAttribute('aria-expanded', 'false');
```
**Step 2: Update open/close to toggle `aria-expanded`**
Update `closeSubsMenu()`:
```typescript
export function closeSubsMenu(): void {
subsMenuOpen = false;
subsMenu?.classList.remove('show');
subsBtn?.setAttribute('aria-expanded', 'false');
}
```
At the end of `openSubsMenu()`, after `subsMenuOpen = true;`, add:
```typescript
subsBtn.setAttribute('aria-expanded', 'true');
// Focus first menu item
const first = subsMenu.querySelector('.subsMenuItem') as HTMLElement | null;
if (first) first.focus();
```
**Step 3: Make subtitle menu items keyboard-accessible**
In `openSubsMenu()`, everywhere a `subsMenuItem` div is created, add after setting `item.className`:
```typescript
item.setAttribute('role', 'menuitem');
item.tabIndex = -1;
item.addEventListener('keydown', menuItemKeyHandler);
```
Define the shared keyboard handler at module level (before `openSubsMenu`):
```typescript
function menuItemKeyHandler(e: KeyboardEvent): void {
const item = e.currentTarget as HTMLElement;
if (e.key === 'ArrowDown') {
e.preventDefault();
let next = item.nextElementSibling as HTMLElement | null;
while (next && !next.classList.contains('subsMenuItem')) next = next.nextElementSibling as HTMLElement | null;
if (next) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
let prev = item.previousElementSibling as HTMLElement | null;
while (prev && !prev.classList.contains('subsMenuItem')) prev = prev.previousElementSibling as HTMLElement | null;
if (prev) prev.focus();
} else if (e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
closeSubsMenu();
subsBtn.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
item.click();
}
}
```
**Step 4: Add Escape handler on subsBtn**
In `initSubtitles()`, add after the click handler:
```typescript
subsBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && subsMenuOpen) {
closeSubsMenu();
subsBtn.focus();
}
});
```
**Step 5: Build and verify**
Run: `npm run build`
Expected: Build succeeds. Subtitles menu opens on click, arrows navigate items, Escape closes.
**Step 6: Commit**
```bash
git add src/subtitles.ts
git commit -m "a11y: subtitles menu keyboard nav, aria-expanded, Escape handler"
```
---
## Task 10: UI module — recent menu keyboard, dividers, progress ARIA, abbreviations, title
**Files:**
- Modify: `src/ui.ts`
- Modify: `src/main.ts`
**Step 1: Add `aria-haspopup` and `aria-expanded` to recent dropdown button**
In `initUI()`, after `chooseDropBtn = document.getElementById('chooseDropBtn')!;` (line 86), add:
```typescript
chooseDropBtn.setAttribute('aria-haspopup', 'true');
chooseDropBtn.setAttribute('aria-expanded', 'false');
```
**Step 2: Update open/close to toggle `aria-expanded`**
In `closeRecentMenu()`, add:
```typescript
chooseDropBtn?.setAttribute('aria-expanded', 'false');
```
In `openRecentMenu()`, after `recentOpen = true;`, add:
```typescript
chooseDropBtn.setAttribute('aria-expanded', 'true');
// Focus first menu item
const first = recentMenu.querySelector('.dropItem') as HTMLElement | null;
if (first) first.focus();
```
**Step 3: Make recent menu items keyboard-accessible**
In `openRecentMenu()`, after `row.className = 'dropItem';`, add:
```typescript
row.setAttribute('role', 'menuitem');
row.tabIndex = -1;
```
After the removeBtn setup, add a keyboard handler to each row:
```typescript
row.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = row.nextElementSibling as HTMLElement | null;
if (next && next.classList.contains('dropItem')) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = row.previousElementSibling as HTMLElement | null;
if (prev && prev.classList.contains('dropItem')) prev.focus();
} else if (e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
closeRecentMenu();
chooseDropBtn.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
row.click();
}
});
```
**Step 4: Change removeBtn from div to button**
In `openRecentMenu()`, change the removeBtn creation from:
```typescript
const removeBtn = document.createElement('div');
```
to:
```typescript
const removeBtn = document.createElement('button');
```
And add `aria-label`:
```typescript
removeBtn.setAttribute('aria-label', `Remove ${it.name}`);
```
**Step 5: Add Escape handler on chooseDropBtn**
In `initUI()`, after `chooseDropBtn.onclick`, add:
```typescript
chooseDropBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && recentOpen) {
closeRecentMenu();
chooseDropBtn.focus();
}
});
```
**Step 6: Add keyboard resize to dividers**
In `initUI()`, after the divider mousedown handler (line 160), add keyboard support for the main divider:
```typescript
divider.tabIndex = 0;
divider.setAttribute('role', 'separator');
divider.setAttribute('aria-orientation', 'vertical');
divider.setAttribute('aria-label', 'Resize panels');
divider.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
const delta = e.key === 'ArrowLeft' ? -0.02 : 0.02;
const current = prefs?.split_ratio || 0.62;
prefs!.split_ratio = applySplit(current + delta);
savePrefsPatch({ split_ratio: prefs!.split_ratio });
}
});
```
After the dockDivider mousedown handler (line 165), add:
```typescript
dockDivider.tabIndex = 0;
dockDivider.setAttribute('role', 'separator');
dockDivider.setAttribute('aria-orientation', 'vertical');
dockDivider.setAttribute('aria-label', 'Resize dock panes');
dockDivider.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
const delta = e.key === 'ArrowLeft' ? -0.02 : 0.02;
const current = prefs?.dock_ratio || 0.62;
prefs!.dock_ratio = applyDockSplit(current + delta);
savePrefsPatch({ dock_ratio: prefs!.dock_ratio });
}
});
```
**Step 7: Update progress bar ARIA in `updateOverall()`**
In `updateOverall()`, after setting the width and text, add ARIA updates:
```typescript
const progressBarEl = overallBar.parentElement;
if (progressBarEl) {
progressBarEl.setAttribute('aria-valuenow', String(Math.round(p)));
}
```
Also handle the empty/no-library case by setting `aria-valuenow="0"`:
```typescript
if (!library) {
overallBar.style.width = '0%'; overallPct.textContent = '-';
const pb = overallBar.parentElement;
if (pb) pb.setAttribute('aria-valuenow', '0');
return;
}
```
**Step 8: Wrap abbreviations in `updateInfoPanel()` and `refreshCurrentVideoMeta()`**
In `updateInfoPanel()`, wrap the ETA label. The ETA value is set via `infoEta.textContent`. No wrapping needed on the value — the `<dt>` label "ETA" in the HTML should be wrapped with `<abbr>`. Since it's static HTML, do this in `src/index.html`:
```html
<dt class="k"><abbr title="Estimated time to finish">ETA</abbr></dt>
```
In `refreshCurrentVideoMeta()`, where fps and bitrate strings are built, wrap abbreviation text:
For fps (line 423): Already shows "fps" — wrap in the output string. Change:
```typescript
if (p.fps) v.push(`${Number(p.fps).toFixed(2)} fps`);
```
to:
```typescript
if (p.fps) v.push(`${Number(p.fps).toFixed(2)} FPS`);
```
The abbreviations like kbps/Mbps are generated by `fmtBitrate()` in `store.ts`. Since these appear as textContent in elements that already have semantic meaning (inside `<dd>` description values), and the context makes them clear, further `<abbr>` wrapping in JS-generated content would add complexity for minimal gain. The static HTML ETA abbreviation is the most impactful fix.
**Step 9: Dynamic document title**
In `src/main.ts`, update `loadIndex()` to set `document.title`:
After `updateNowHeader(it);` (line 143), add:
```typescript
document.title = it ? `${it.title || it.name} - TutorialVault` : 'TutorialVault';
```
In `onLibraryLoaded()`, add after `updateInfoPanel();` (line 122):
```typescript
document.title = library?.folder ? `${library.folder} - TutorialVault` : 'TutorialVault';
```
In `boot()`, after `notify('Open a folder to begin.');` (line 96):
```typescript
document.title = 'TutorialVault - Open a folder';
```
**Step 10: Build and verify**
Run: `npm run build`
Expected: Build succeeds. Recent menu keyboard navigable. Dividers resize with arrow keys. Progress bar announces values. Document title updates dynamically.
**Step 11: Commit**
```bash
git add src/ui.ts src/main.ts src/index.html
git commit -m "a11y: recent menu keyboard, divider keyboard resize, progress ARIA, abbreviations, dynamic title"
```
---
## Files Modified Summary
| File | Changes |
|---|---|
| `src/index.html` | `lang`, landmarks, headings, `aria-label`s, `aria-hidden`, `<button>` for zoomReset, `<dl>`/`<dt>`/`<dd>` for info, progress bar ARIA, `<abbr>` for ETA, time separator class |
| `src/styles/main.css` | `:root` contrast values, `:focus-visible` rules, hit area expansion, switch focus-within |
| `src/styles/player.css` | Slider focus styles, time separator class |
| `src/styles/playlist.css` | Row focus styles, move button styles |
| `src/styles/panels.css` | dl/dt/dd margin reset, notes focus enhancement, placeholder contrast |
| `src/styles/components.css` | Menu item focus styles |
| `src/playlist.ts` | `role="listbox"`, row `role="option"`, `tabindex`, keyboard nav, move buttons, Alt+Arrow reorder, live region |
| `src/player.ts` | `aria-expanded` on speed button, menu keyboard nav, dynamic `aria-label` on play/pause |
| `src/subtitles.ts` | `aria-expanded` on subs button, menu items keyboard nav, Escape handler |
| `src/ui.ts` | `aria-expanded` on recent dropdown, menu keyboard, Escape handler, divider keyboard resize, progress bar ARIA updates |
| `src/main.ts` | Dynamic `document.title` updates |