diff --git a/docs/plans/2026-02-19-wcag-aaa-plan.md b/docs/plans/2026-02-19-wcag-aaa-plan.md
new file mode 100644
index 0000000..7590b29
--- /dev/null
+++ b/docs/plans/2026-02-19-wcag-aaa-plan.md
@@ -0,0 +1,1154 @@
+# 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 |