# 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 191–226) 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 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
                <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 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 = '<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 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 `<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 |
