# 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 `` tag**
In `src/index.html`, line 2, change:
```html
```
to:
```html
```
**Step 2: Wrap `.topbar` in ``.
**Step 3: Wrap `.content` in `` landmark**
Change line 73 from:
```html
```
to:
```html
```
And its closing tag (line 250) from `
` to ` `.
**Step 4: Add region roles to panels**
Left panel (line 74): add `role="region" aria-label="Video player"`:
```html
```
Right panel (line 237): add `role="region" aria-label="Playlist"`:
```html
```
Dock (line 169): add `role="complementary" aria-label="Details"`:
```html
```
**Step 5: Change key elements to semantic headings**
`.appName` (line 15) — change `
` to `
`:
```html
TutorialVault
```
`.nowTitle` (line 77) — change `` to `
`:
```html
No video loaded
```
`.playlistHeader` (line 239) — change `` to `
`:
```html
```
Both `.dockTitle` elements (lines 173, 189) — change `` to `
`:
```html
Notes
```
```html
Info
```
**Step 6: Change `zoomResetBtn` from `` to ``**
Line 40, change:
```html
100%
```
to:
```html
100%
```
**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 `` icons inside labeled buttons.
`zoomOutBtn` (line 39):
```html
```
`zoomInBtn` (line 41):
```html
```
`chooseDropBtn` (line 50):
```html
```
`chooseBtn` (line 49) — already has text "Open folder", just add `aria-hidden` to icon:
```html
Open folder
```
`resetProgBtn` (line 57):
```html
```
`refreshBtn` (line 58):
```html
```
`winMinBtn` (line 64):
```html
```
`winMaxBtn` (line 65):
```html
```
`winCloseBtn` (line 66):
```html
```
`prevBtn` (line 107):
```html
```
`playPauseBtn` (line 109):
```html
```
`nextBtn` (line 113):
```html
```
`subsBtn` (line 125):
```html
```
`fsBtn` (line 164):
```html
```
**Step 2: Add `aria-label` to video element and volume slider**
Video (line 89):
```html
```
Volume slider (line 137):
```html
```
**Step 3: Add ARIA to the progress bar**
Progress bar container (line 83):
```html
```
**Step 4: Add `aria-hidden="true"` to remaining decorative icons**
Volume icon (line 132):
```html
```
Toast icon (line 256):
```html
```
Notes saved check icon (line 177):
```html
Saved
```
Playlist header icon (line 239):
```html
```
Notes dock icon:
```html
Notes
```
Info dock icon:
```html
Info
```
Speed caret (line 156):
```html
```
**Step 5: Add `aria-label` to notes textarea**
Line 176:
```html
```
**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 `// `**
Replace the entire `...
` block (lines 191–226) with:
```html
Folder -
Next up -
Structure -
Title -
Relpath -
Position -
File -
Video -
Audio -
Subtitles -
Finished -
Remaining -
ETA -
Volume -
Speed -
Durations -
Top folders -
```
**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 1–53):
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
00:00 / 00:00
```
to:
```html
00:00 / 00:00
```
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 252–338) 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 = ' ';
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 = ' ';
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 173–178) 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 ` ` label "ETA" in the HTML should be wrapped with ``. Since it's static HTML, do this in `src/index.html`:
```html
ETA
```
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 `` description values), and the context makes them clear, further `` 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`, `` for zoomReset, ``/``/` ` for info, progress bar ARIA, `` 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 |