1155 lines
38 KiB
Markdown
1155 lines
38 KiB
Markdown
# 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 |
|