38 KiB
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 191–226) 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 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:
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 252–338) with a version that adds:
role="option"andaria-selectedon each rowtabindex="0"on each row- Computed
aria-label(e.g. "03. Video Title - 5:30 / 12:00 - Done") keydownhandler: 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 173–178) 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 |