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

38 KiB
Raw Permalink Blame History

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>

to:

<html lang="en">

Step 2: Wrap .topbar in <header> landmark

Change the topbar div (line 11) from:

    <div class="topbar" data-tauri-drag-region>

to:

    <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:

    <div class="content" id="contentGrid">

to:

    <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":

      <div class="panel" role="region" aria-label="Video player">

Right panel (line 237): add role="region" aria-label="Playlist":

      <div class="panel" role="region" aria-label="Playlist">

Dock (line 169): add role="complementary" aria-label="Details":

        <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>:

          <h1 class="appName" data-tauri-drag-region>TutorialVault</h1>

.nowTitle (line 77) — change <div> to <h2>:

            <h2 class="nowTitle" id="nowTitle">No video loaded</h2>

.playlistHeader (line 239) — change <div> to <h2>:

          <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>:

                <h3 class="dockTitle"><i class="fa-solid fa-note-sticky"></i> Notes</h3>
                <h3 class="dockTitle"><i class="fa-solid fa-circle-info"></i> Info</h3>

Step 6: Change zoomResetBtn from <span> to <button>

Line 40, change:

            <span class="zoomValue" id="zoomResetBtn">100%</span>

to:

            <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

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):

            <button class="zoomBtn" id="zoomOutBtn" aria-label="Zoom out"><i class="fa-solid fa-minus" aria-hidden="true"></i></button>

zoomInBtn (line 41):

            <button class="zoomBtn" id="zoomInBtn" aria-label="Zoom in"><i class="fa-solid fa-plus" aria-hidden="true"></i></button>

chooseDropBtn (line 50):

            <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:

            <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):

          <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):

          <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):

          <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):

          <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):

          <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):

              <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):

              <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):

              <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):

                <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):

              <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):

          <video id="player" preload="metadata" crossorigin="anonymous" aria-label="Video player"></video>

Volume slider (line 137):

                  <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):

            <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):

                <i class="fa-solid fa-volume-high" aria-hidden="true"></i>

Toast icon (line 256):

    <div class="toastIcon"><i class="fa-solid fa-circle-info" aria-hidden="true"></i></div>

Notes saved check icon (line 177):

                <div class="notesSaved" id="notesSaved"><i class="fa-solid fa-check" aria-hidden="true"></i> Saved</div>

Playlist header icon (line 239):

          <h2 class="playlistHeader" ...><i class="fa-solid fa-list" aria-hidden="true"></i> Playlist</h2>

Notes dock icon:

                <h3 class="dockTitle"><i class="fa-solid fa-note-sticky" aria-hidden="true"></i> Notes</h3>

Info dock icon:

                <h3 class="dockTitle"><i class="fa-solid fa-circle-info" aria-hidden="true"></i> Info</h3>

Speed caret (line 156):

                    <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:

                <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

git add src/index.html
git commit -m "a11y: add aria-labels, aria-hidden on decorative icons, progress bar ARIA"

Task 3: Info panel semantic markup — dl/dt/dd

Files:

  • Modify: src/index.html
  • Modify: src/styles/panels.css

Step 1: Convert info panel .kv blocks from divs to <dl>/<dt>/<dd>

Replace the entire <div class="infoGrid" id="infoGrid">...</div> block (lines 191226) with:

              <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):

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

git add src/index.html src/styles/panels.css
git commit -m "a11y: convert info panel to dl/dt/dd for proper semantics"

Task 4: Color contrast — raise to AAA 7:1

Files:

  • Modify: src/styles/main.css
  • Modify: src/styles/panels.css

Step 1: Update :root custom properties in main.css

In src/styles/main.css, update these values in :root (lines 153):

Change --textMuted (line 12):

  --textMuted:rgba(160,174,204,.72);

Change --textDim (line 13):

  --textDim:rgba(158,174,208,.68);

Change --icon (line 39):

  --icon:rgba(160,175,210,.62);

Step 2: Update tagline color in main.css

Change .tagline color (line 207) from color:rgba(148,162,192,.62) to:

  color:var(--textMuted);

Step 3: Update time separator color

In src/index.html, line 119, change:

                <div><span id="timeNow">00:00</span> <span style="color:rgba(165,172,196,.65)">/</span> <span id="timeDur">00:00</span></div>

to:

                <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:

.timeSep{color:rgba(175,185,210,.78);}

Step 4: Improve notes placeholder contrast

In src/styles/panels.css, line 73, change:

.notes::placeholder{color:rgba(148,162,192,.40); transition:color .2s ease;}

to:

.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

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:

*: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:

.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:

.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:

.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:

.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:

.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

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:

.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:

.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:

.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

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:

  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:

  // Live region for reorder announcements
  const liveRegion = document.createElement('div');
  liveRegion.id = 'playlistLive';
  liveRegion.setAttribute('aria-live', 'polite');
  liveRegion.setAttribute('aria-atomic', 'true');
  liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';
  document.body.appendChild(liveRegion);

Step 3: Update renderList() — add role, tabindex, aria attributes, keyboard nav, and move buttons

Replace the row creation section in renderList() (the for loop, approximately lines 252338) with a version that adds:

  • role="option" and aria-selected on each row
  • tabindex="0" on each row
  • Computed aria-label (e.g. "03. Video Title - 5:30 / 12:00 - Done")
  • keydown handler: Enter/Space to activate, ArrowUp/Down to navigate rows, Alt+ArrowUp/Down to reorder
  • Move-up/move-down buttons that appear on focus/hover

In renderList(), replace the row creation code. After row.dataset.index = String(it.index);, add:

    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:

    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);):

    // 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:

.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

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):

export function openSpeedMenu(): void {
  speedMenu?.classList.add('show');
  speedBtn?.setAttribute('aria-expanded', 'true');
}

Update closeSpeedMenu() (line 331):

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:

  speedBtn.setAttribute('aria-haspopup', 'true');
  speedBtn.setAttribute('aria-expanded', 'false');

Step 3: Add keyboard navigation to speed menu

Replace the speed button click handler (lines 173178) with:

  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:

    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:

  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

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:

  subsBtn.setAttribute('aria-haspopup', 'true');
  subsBtn.setAttribute('aria-expanded', 'false');

Step 2: Update open/close to toggle aria-expanded

Update closeSubsMenu():

export function closeSubsMenu(): void {
  subsMenuOpen = false;
  subsMenu?.classList.remove('show');
  subsBtn?.setAttribute('aria-expanded', 'false');
}

At the end of openSubsMenu(), after subsMenuOpen = true;, add:

  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:

      item.setAttribute('role', 'menuitem');
      item.tabIndex = -1;
      item.addEventListener('keydown', menuItemKeyHandler);

Define the shared keyboard handler at module level (before openSubsMenu):

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:

  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

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:

  chooseDropBtn.setAttribute('aria-haspopup', 'true');
  chooseDropBtn.setAttribute('aria-expanded', 'false');

Step 2: Update open/close to toggle aria-expanded

In closeRecentMenu(), add:

  chooseDropBtn?.setAttribute('aria-expanded', 'false');

In openRecentMenu(), after recentOpen = true;, add:

    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:

        row.setAttribute('role', 'menuitem');
        row.tabIndex = -1;

After the removeBtn setup, add a keyboard handler to each row:

        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:

        const removeBtn = document.createElement('div');

to:

        const removeBtn = document.createElement('button');

And add aria-label:

        removeBtn.setAttribute('aria-label', `Remove ${it.name}`);

Step 5: Add Escape handler on chooseDropBtn

In initUI(), after chooseDropBtn.onclick, add:

  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:

  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:

  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:

  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":

  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:

                  <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:

      if (p.fps) v.push(`${Number(p.fps).toFixed(2)} fps`);

to:

      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:

  document.title = it ? `${it.title || it.name} - TutorialVault` : 'TutorialVault';

In onLibraryLoaded(), add after updateInfoPanel(); (line 122):

  document.title = library?.folder ? `${library.folder} - TutorialVault` : 'TutorialVault';

In boot(), after notify('Open a folder to begin.'); (line 96):

  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

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-labels, 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