Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f94b50d205 | |||
| d993b8610b | |||
| 4fddb02336 | |||
| 2a173c1dff | |||
| 6c02465185 | |||
| e5265a970d | |||
| ac07f69ace | |||
| e0faae61e8 | |||
| dc6942b504 | |||
| f938be82bd | |||
| f05aedf6bc | |||
| 4b39cf751a | |||
| cc6df37309 | |||
| 1d6d761a63 | |||
| 61379d9b64 | |||
| 28cc8491c2 | |||
| e5f5f7985b | |||
| ffa37e6061 | |||
| 4fd04f55c8 | |||
| c0c3a21bd5 | |||
| 5dd817cf37 | |||
| 0b7532acd3 | |||
| 768957103c | |||
| dd9a8082d9 |
@@ -112,7 +112,7 @@ Press **?** at any time to open a help dialog listing all available shortcuts.
|
||||
|
||||
**Resume from where you left off.** Clicking any video in the playlist, or navigating with previous/next, resumes from your last position. Finished videos start from the beginning.
|
||||
|
||||
**Progress reset.** If you want to go through a course again from scratch, a reset button clears all watch progress while preserving your notes, subtitles, volume, and speed settings. Uses a two-click confirmation to prevent accidental resets -- the first click arms the button (it turns red with a warning icon), and the second click within 3 seconds actually resets. If you change your mind, just wait or click elsewhere.
|
||||
**Progress reset.** If you want to go through a course again from scratch, a reset button clears all watch progress while preserving your notes, subtitles, volume, and speed settings. Uses a two-click confirmation to prevent accidental resets - the first click arms the button (it turns red with a warning icon), and the second click within 3 seconds actually resets. If you change your mind, just wait or click elsewhere.
|
||||
|
||||
**Mini progress bars.** Each item in the playlist shows a thin progress bar at the bottom of its row, visualizing how far you've watched. Completed videos get a green bar; in-progress videos show the accent color proportional to watched time.
|
||||
|
||||
@@ -204,7 +204,7 @@ All of this requires ffprobe, which TutorialVault will automatically download if
|
||||
|
||||
TutorialVault targets WCAG 2.2 AAA compliance throughout the interface.
|
||||
|
||||
**Semantic HTML.** The interface uses proper semantic elements -- `<nav>`, `<main>`, `<header>`, `<section>`, `<h2>`, `<h3>` -- so screen readers can navigate the structure meaningfully.
|
||||
**Semantic HTML.** The interface uses proper semantic elements - `<nav>`, `<main>`, `<header>`, `<section>`, `<h2>`, `<h3>` - so screen readers can navigate the structure meaningfully.
|
||||
|
||||
**ARIA attributes.** All interactive elements have appropriate ARIA labels, roles, and state attributes. Menus use `role="menu"` with `aria-haspopup` and `aria-expanded`. The progress bar uses `role="progressbar"` with live value updates. The seek feedback overlay uses `aria-live="assertive"` for screen reader announcements.
|
||||
|
||||
@@ -273,16 +273,16 @@ TutorialVault is a [Tauri v2](https://v2.tauri.app/) application with a Rust bac
|
||||
|
||||
**Frontend (TypeScript + Vite):** A modular architecture with dedicated modules for each concern:
|
||||
|
||||
- `main.ts` -- Boot sequence, tick loop, keyboard shortcuts, cross-module wiring
|
||||
- `player.ts` -- Video element, seek/volume/speed controls, mute, fullscreen, PiP, error states
|
||||
- `playlist.ts` -- Playlist rendering, tree view, drag-and-drop, search/filter, scroll-to-current
|
||||
- `subtitles.ts` -- Subtitle discovery, menu, embedded track extraction
|
||||
- `ui.ts` -- Zoom, split ratios, notes, info panel, recent menu, toast, collapsible panes
|
||||
- `tooltips.ts` -- Hover tooltips with zoom awareness and viewport clamping
|
||||
- `store.ts` -- Shared state, pure utilities, cross-module callback registry
|
||||
- `api.ts` -- Typed wrapper around Tauri's invoke API
|
||||
- `main.ts` - Boot sequence, tick loop, keyboard shortcuts, cross-module wiring
|
||||
- `player.ts` - Video element, seek/volume/speed controls, mute, fullscreen, PiP, error states
|
||||
- `playlist.ts` - Playlist rendering, tree view, drag-and-drop, search/filter, scroll-to-current
|
||||
- `subtitles.ts` - Subtitle discovery, menu, embedded track extraction
|
||||
- `ui.ts` - Zoom, split ratios, notes, info panel, recent menu, toast, collapsible panes
|
||||
- `tooltips.ts` - Hover tooltips with zoom awareness and viewport clamping
|
||||
- `store.ts` - Shared state, pure utilities, cross-module callback registry
|
||||
- `api.ts` - Typed wrapper around Tauri's invoke API
|
||||
|
||||
**Design theme.** The interface uses a "Cold Open" dark theme built on cool slate backgrounds (`#0f1117` base) with a steel blue accent (`#88A4C4`). Typography uses [Bricolage Grotesque](https://fonts.google.com/specimen/Bricolage+Grotesque) for headings, [Inter](https://fonts.google.com/specimen/Inter) for body text, and [Space Mono](https://fonts.google.com/specimen/Space+Mono) for monospace elements. All fonts are bundled -- no external requests at runtime.
|
||||
**Design theme.** The interface uses a "Cold Open" dark theme built on cool slate backgrounds (`#0f1117` base) with a steel blue accent (`#88A4C4`). Typography uses [Bricolage Grotesque](https://fonts.google.com/specimen/Bricolage+Grotesque) for headings, [Inter](https://fonts.google.com/specimen/Inter) for body text, and [Space Mono](https://fonts.google.com/specimen/Space+Mono) for monospace elements. All fonts are bundled - no external requests at runtime.
|
||||
|
||||
**State is local.** All data is stored in JSON files with atomic writes and backup rotation. No database, no server, no network requests beyond the one-time ffmpeg download.
|
||||
|
||||
@@ -295,5 +295,3 @@ TutorialVault is released into the public domain under [CC0 1.0 Universal](https
|
||||
No rights reserved. No permission needed. No conditions. No restrictions.
|
||||
|
||||
Do whatever you want with it. Learn from it. Change it. Share it. Build on it. The whole point is that it's yours.
|
||||
|
||||
This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
# TutorialVault — 15 UI Enhancements Design
|
||||
|
||||
**Goal:** Add 15 real, polished enhancements to the TutorialVault frontend — all WCAG 2.2 AAA compliant.
|
||||
|
||||
**Architecture:** All changes are frontend-only (TypeScript + CSS). No Rust/backend changes needed. Each enhancement is independent — no cross-dependencies between them.
|
||||
|
||||
**Tech Stack:** TypeScript, CSS, HTML (Tauri v2 webview frontend)
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 1: Mute Toggle (M key + volume icon click)
|
||||
|
||||
**What:** Press `M` to toggle mute. Click the volume icon to toggle mute. Muted state shows `fa-volume-xmark`, grays out the slider. Unmuting restores previous volume.
|
||||
|
||||
**Files:** `main.ts`, `player.ts`, `player.css`
|
||||
|
||||
**Behavior:**
|
||||
- Store `lastVolume` before muting. Set volume to 0 on mute.
|
||||
- On unmute, restore `lastVolume` (default 1.0 if none stored).
|
||||
- Volume icon dynamically swaps: `fa-volume-high` (>50%), `fa-volume-low` (>0%), `fa-volume-xmark` (0/muted).
|
||||
- Dragging the volume slider while muted unmutes automatically.
|
||||
|
||||
**WCAG:** Volume icon gets `aria-label="Mute"/"Unmute"` dynamically. Muted visual uses icon change + reduced opacity (not color alone).
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 2: Fullscreen Shortcut (F key)
|
||||
|
||||
**What:** Press `F` to toggle fullscreen. Mirrors the existing fullscreen button behavior.
|
||||
|
||||
**Files:** `main.ts`
|
||||
|
||||
**Behavior:** Add `case 'f':` to the keyboard switch. Calls the same fullscreen toggle as `fsBtn.onclick`.
|
||||
|
||||
**WCAG:** No visual changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 3: Seek Feedback Overlay
|
||||
|
||||
**What:** Flash `−5s` / `+5s` text in the video overlay when seeking via arrow keys. Fades after 600ms.
|
||||
|
||||
**Files:** `main.ts`, `player.ts`, `player.css`
|
||||
|
||||
**Behavior:**
|
||||
- New `showSeekFeedback(delta: number)` function in player.ts.
|
||||
- Reuses the existing video overlay area. Shows text like "−5s" or "+5s" centered.
|
||||
- Uses a CSS class `.seekFeedback` that fades in/out.
|
||||
- Consecutive presses accumulate: pressing ArrowRight 3 times quickly shows "+15s".
|
||||
|
||||
**WCAG:** Overlay area has `aria-live="assertive"` so screen readers announce "Seeked forward 5 seconds". Text meets 7:1 contrast (white on semi-transparent dark).
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 4: Playlist Search/Filter
|
||||
|
||||
**What:** Search input above the playlist. Typing filters by video name or relative path in real-time. Shows "X of Y" while filtering. Clear button (x) resets.
|
||||
|
||||
**Files:** `index.html`, `playlist.ts`, `playlist.css`
|
||||
|
||||
**Behavior:**
|
||||
- Add `<input id="plistSearch">` in the playlist `.panelHeader`.
|
||||
- `renderList()` checks the filter value and skips non-matching items.
|
||||
- Filter is case-insensitive, matches against `it.title`, `it.name`, `it.relpath`.
|
||||
- Clear button appears only when input has text.
|
||||
- Pressing Escape in the search input clears it.
|
||||
|
||||
**WCAG:** `aria-label="Search playlist"`. Filtered count via `aria-live="polite"` region. Clear button has 44x44 target and `aria-label="Clear search"`.
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 5: Scroll-to-Current Button
|
||||
|
||||
**What:** Button in playlist header that scrolls the active item into view. Only visible when the active item is off-screen.
|
||||
|
||||
**Files:** `index.html`, `playlist.ts`, `playlist.css`
|
||||
|
||||
**Behavior:**
|
||||
- Uses `IntersectionObserver` on the `.row.active` element.
|
||||
- Button appears (fades in) when active row is not visible.
|
||||
- Clicking calls `activeRow.scrollIntoView({ block: 'center', behavior: 'smooth' })`.
|
||||
- Icon: `fa-crosshairs` or `fa-location-dot`.
|
||||
|
||||
**WCAG:** `aria-label="Scroll to current video"`. 44x44 touch target. Hidden with `display:none` when not actionable (removed from a11y tree).
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 6: Picture-in-Picture Button
|
||||
|
||||
**What:** PiP toggle button next to the fullscreen button. Uses native `requestPictureInPicture()` API.
|
||||
|
||||
**Files:** `index.html`, `player.ts`
|
||||
|
||||
**Behavior:**
|
||||
- New `iconBtn` after fsBtn: `<button class="iconBtn" id="pipBtn">`.
|
||||
- Icon swaps: `fa-up-right-from-square` (enter PiP) / `fa-down-left-and-up-right-to-center` (exit PiP).
|
||||
- Listen to `enterpictureinpicture` / `leavepictureinpicture` events to update icon.
|
||||
- Feature-detect: hide button if `document.pictureInPictureEnabled` is false.
|
||||
|
||||
**WCAG:** Dynamic `aria-label="Enter picture-in-picture"/"Exit picture-in-picture"`. Same focus-visible ring as all icon buttons.
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 7: Timestamp Insertion in Notes
|
||||
|
||||
**What:** Button in the notes dock header that inserts `[MM:SS] ` at the textarea cursor position using the current video time.
|
||||
|
||||
**Files:** `index.html`, `ui.ts`, `panels.css`
|
||||
|
||||
**Behavior:**
|
||||
- Small button in `#notesHeader`: `<button id="insertTimestamp" aria-label="Insert timestamp">`.
|
||||
- Icon: `fa-clock` or `fa-stopwatch`.
|
||||
- On click: read `player.currentTime`, format as `[M:SS]`, insert at `notesBox.selectionStart`.
|
||||
- After insert, focus the textarea and place cursor after the inserted text.
|
||||
|
||||
**WCAG:** `aria-label="Insert timestamp"`. 44x44 target via `::before` expansion. `data-tooltip` for hover discoverability.
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 8: Keyboard Shortcut Help Panel
|
||||
|
||||
**What:** Press `?` to open a help overlay listing all keyboard shortcuts. Press `?` or `Escape` to close.
|
||||
|
||||
**Files:** `index.html`, `main.ts`, `main.css` or `components.css`
|
||||
|
||||
**Behavior:**
|
||||
- Hidden `<div id="shortcutHelp" role="dialog" aria-label="Keyboard shortcuts">` in HTML.
|
||||
- Two-column grid showing key + description pairs.
|
||||
- Shortcuts shown: Space (play/pause), Left/Right (seek ±5s), Up/Down (volume), M (mute), F (fullscreen), [ / ] (speed), Alt+Arrow (reorder playlist), ? (this help).
|
||||
- Focus trap inside the dialog while open.
|
||||
- Semi-transparent dark backdrop.
|
||||
|
||||
**WCAG:** `role="dialog"` with `aria-label`. Focus trapped. Escape closes. All text 7:1 contrast. Backdrop doesn't remove background content from a11y tree (uses `aria-hidden="true"` on app root while dialog is open).
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 9: Playback Speed Shortcuts ([ and ])
|
||||
|
||||
**What:** Press `[` to decrease speed one step, `]` to increase speed one step through the SPEEDS array.
|
||||
|
||||
**Files:** `main.ts`, `player.ts`
|
||||
|
||||
**Behavior:**
|
||||
- New `cycleSpeed(delta: number)` exported from player.ts.
|
||||
- Finds current speed in SPEEDS array, moves by delta (clamped to bounds).
|
||||
- Updates speed button text, gauge icon, and saves to backend.
|
||||
- Shows toast notification: "Speed: 1.25x".
|
||||
|
||||
**WCAG:** Speed change announced via toast (existing `aria-live="polite"` region).
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 10: Reset Progress Confirmation
|
||||
|
||||
**What:** Two-click confirmation before resetting progress. First click changes button to "Confirm?" state with red tint. Second click within 3 seconds actually resets. Timeout or click-away cancels.
|
||||
|
||||
**Files:** `ui.ts`, `main.css`
|
||||
|
||||
**Behavior:**
|
||||
- First click: add `.confirming` class, change icon to `fa-exclamation-triangle`, start 3s timer.
|
||||
- Second click while `.confirming`: execute the actual reset.
|
||||
- Timer expiry or blur: remove `.confirming`, restore original icon.
|
||||
- `.confirming` state: red-tinted background (`rgba(255,70,70,.14)`).
|
||||
|
||||
**WCAG:** `aria-label` changes to "Confirm reset progress" in confirm state. Visual change uses both color AND icon change (not color alone).
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 11: Video Load Error State
|
||||
|
||||
**What:** When the `<video>` fires an error event, show a centered overlay with warning icon, error message, and "Try next" button.
|
||||
|
||||
**Files:** `player.ts`, `player.css`
|
||||
|
||||
**Behavior:**
|
||||
- Listen to `player.addEventListener('error', ...)`.
|
||||
- Create/show error overlay inside `.videoWrap` with `fa-triangle-exclamation` icon.
|
||||
- Message: "This file format may not be supported. Try MP4 (H.264/AAC) or WebM."
|
||||
- "Try next" button calls `nextPrev(+1)`.
|
||||
- Overlay clears on next successful `loadedmetadata`.
|
||||
|
||||
**WCAG:** `role="alert"` for screen reader announcement. "Try next" button has 44x44 target. Error text meets 7:1 contrast.
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 12: Double-Click to Fullscreen
|
||||
|
||||
**What:** Double-clicking the video toggles fullscreen. Single click still toggles play/pause.
|
||||
|
||||
**Files:** `player.ts`
|
||||
|
||||
**Behavior:**
|
||||
- Replace simple click handler with a click/double-click discriminator.
|
||||
- On click: set a 300ms timer. If no second click arrives, toggle play/pause.
|
||||
- On double-click: cancel the timer, toggle fullscreen instead.
|
||||
- `dblclick` event on the video overlay area.
|
||||
|
||||
**WCAG:** Mouse/touch enhancement only. Fullscreen remains keyboard-accessible via F key and button.
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 13: Playlist Stats in Header
|
||||
|
||||
**What:** Show video count and completion info next to "Playlist" header. E.g., "24 videos · 8 done".
|
||||
|
||||
**Files:** `index.html`, `playlist.ts`
|
||||
|
||||
**Behavior:**
|
||||
- Add `<span class="plistStats" id="plistStats"></span>` in the panelHeader.
|
||||
- In `renderList()`: compute total count, done count, update the span.
|
||||
- Styled as `--textMuted`, smaller font (11px), monospace.
|
||||
|
||||
**WCAG:** Stats text meets 7:1 contrast. Updated via DOM so screen readers can read it on demand.
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 14: Mini Progress Bars Per Playlist Item
|
||||
|
||||
**What:** Thin 2px progress bar at the bottom of each playlist row showing watched/total. Green for done, accent blue for in-progress.
|
||||
|
||||
**Files:** `playlist.ts`, `playlist.css`
|
||||
|
||||
**Behavior:**
|
||||
- For each row in `renderList()`, create a `.rowProgress` div.
|
||||
- Width: `(watched / duration) * 100%`. If duration is null, hide.
|
||||
- Color: `--success` for done items, `--accent` for in-progress.
|
||||
- Positioned absolutely at bottom of the row.
|
||||
|
||||
**WCAG:** `aria-hidden="true"` — numeric info already in the row's `aria-label`. Colors pass 3:1 minimum for non-text contrast per WCAG 2.2.
|
||||
|
||||
---
|
||||
|
||||
## Enhancement 15: Collapsible Dock Panes
|
||||
|
||||
**What:** Clicking a dock header collapses that pane, giving the other pane full height. Collapsed state shows just the header with a chevron. Persisted via prefs.
|
||||
|
||||
**Files:** `ui.ts`, `panels.css`, `index.html`
|
||||
|
||||
**Behavior:**
|
||||
- Click on `.dockHeader` toggles `.collapsed` on the parent `.dockPane`.
|
||||
- Collapsed: `flex:0 0 auto`, content inside has `display:none`.
|
||||
- Chevron icon in header rotates to indicate collapsed/expanded.
|
||||
- Collapsing one pane doesn't collapse the other.
|
||||
- State saved in prefs as `notes_collapsed` / `info_collapsed` booleans.
|
||||
|
||||
**WCAG:** Headers get `aria-expanded="true"/"false"`. Chevron is `aria-hidden="true"`. Enter/Space on header toggles. Collapsed content is `display:none` (removed from a11y tree).
|
||||
|
||||
---
|
||||
|
||||
## Approach
|
||||
|
||||
**Single-pass, all frontend:** Every enhancement modifies only TypeScript and CSS files. No backend/Rust changes. Each enhancement is independent and can be implemented in any order.
|
||||
|
||||
**Implementation strategy:** Direct edits to existing files using Edit/Write tools. No subagents for file editing. Each enhancement touches 1-3 files. Build verification after each enhancement or batch.
|
||||
|
||||
**Testing:** `npm run build` (TypeScript compilation + Vite build) after each enhancement to catch errors immediately.
|
||||
@@ -1,184 +0,0 @@
|
||||
# WCAG 2.2 AAA Remediation Design
|
||||
|
||||
**Goal:** Bring TutorialVault to full WCAG 2.2 AAA compliance using surgical, in-place edits that preserve the Cold Open aesthetic.
|
||||
|
||||
**Approach:** 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.
|
||||
|
||||
**Constraints:**
|
||||
- Preserve the Cold Open visual identity (dark slate, steel blue accent, borderless surfaces)
|
||||
- Raise muted text contrast minimally to hit AAA 7:1
|
||||
- Focus rings use the existing accent color, subtle but compliant
|
||||
- No new dependencies
|
||||
|
||||
---
|
||||
|
||||
## 1. Semantic HTML & Landmarks
|
||||
|
||||
**File: `src/index.html`**
|
||||
|
||||
- Add `lang="en"` to `<html>`
|
||||
- Wrap `.topbar` in `<header role="banner">`
|
||||
- Wrap `.content` in `<main role="main">`
|
||||
- Add `role="region" aria-label="Video player"` to the left `.panel`
|
||||
- Add `role="region" aria-label="Playlist"` to the right `.panel`
|
||||
- Add `role="complementary" aria-label="Details"` to the `.dock`
|
||||
- Change `.appName` from `<div>` to `<h1>` (keep existing class)
|
||||
- Change `.nowTitle` from `<div>` to `<h2>` (keep existing class)
|
||||
- Change `.dockTitle` elements from `<div>` to `<h3>` (keep existing class)
|
||||
- Change `.playlistHeader` from `<div>` to `<h2>` (keep existing class)
|
||||
- Convert info panel `.kv` blocks from nested `<div>` to `<dl>`/`<dt>`/`<dd>`, styled with same `.kv`/`.k`/`.v` classes
|
||||
|
||||
No visual change. Headings inherit their existing class styles. Landmarks are invisible.
|
||||
|
||||
---
|
||||
|
||||
## 2. Text Alternatives & ARIA
|
||||
|
||||
### Icon-only buttons get `aria-label`
|
||||
|
||||
| Button | `aria-label` |
|
||||
|---|---|
|
||||
| `zoomOutBtn` | "Zoom out" |
|
||||
| `zoomInBtn` | "Zoom in" |
|
||||
| `zoomResetBtn` (change `<span>` to `<button>`) | "Reset zoom" |
|
||||
| `chooseDropBtn` | "Recent folders" |
|
||||
| `resetProgBtn` | "Reset progress" |
|
||||
| `refreshBtn` | "Reload folder" |
|
||||
| `winMinBtn` | "Minimize" |
|
||||
| `winMaxBtn` | "Maximize" |
|
||||
| `winCloseBtn` | "Close" |
|
||||
| `prevBtn` | "Previous video" |
|
||||
| `playPauseBtn` | "Play" (dynamically toggled to "Pause") |
|
||||
| `nextBtn` | "Next video" |
|
||||
| `subsBtn` | "Subtitles" |
|
||||
| `fsBtn` | "Toggle fullscreen" |
|
||||
|
||||
### Other ARIA additions
|
||||
|
||||
- Add `aria-hidden="true"` to all decorative `<i>` icons inside labeled buttons
|
||||
- Add `aria-label="Video player"` to `<video>`
|
||||
- Add `role="progressbar"`, `aria-valuenow`, `aria-valuemin="0"`, `aria-valuemax="100"`, `aria-label="Overall folder progress"` to `.progressBar`. Update dynamically in `updateOverall()`
|
||||
- Add `aria-label="Volume"` to volume slider
|
||||
- Toggle `aria-expanded="true"/"false"` on `subsBtn`, `speedBtn`, `chooseDropBtn` when menus open/close
|
||||
- Toast already has `aria-live="polite"` -- no change needed
|
||||
|
||||
### Playlist rows (in `playlist.ts`)
|
||||
|
||||
- Set `role="listbox"` and `aria-label="Playlist"` on the `#list` container
|
||||
- Each row: `role="option"`, `aria-selected="true"/"false"`, computed `aria-label` (e.g. "03. Video Title - 5:30 / 12:00 - Done")
|
||||
|
||||
---
|
||||
|
||||
## 3. Color Contrast (AAA 7:1)
|
||||
|
||||
Targeted CSS custom property adjustments in `:root`. Hierarchy preserved, just shifted up.
|
||||
|
||||
| Token | Current value | New value | Ratio vs #151821 |
|
||||
|---|---|---|---|
|
||||
| `--textMuted` | `rgba(148,162,192,.55)` ~3.6:1 | `rgba(160,174,204,.72)` ~7.2:1 | AAA pass |
|
||||
| `--textDim` | `rgba(118,132,168,.38)` ~1.8:1 | `rgba(158,174,208,.68)` ~7.1:1 | AAA pass |
|
||||
| `.tagline` color | `rgba(148,162,192,.62)` ~3.7:1 | Use `--textMuted` | AAA pass |
|
||||
| `.notes::placeholder` | `rgba(148,162,192,.40)` ~2.2:1 | `rgba(155,170,200,.65)` ~5.5:1 | Exempt but improved |
|
||||
| Time `/` separator | `rgba(165,172,196,.65)` | `rgba(175,185,210,.78)` ~7.1:1 | AAA pass |
|
||||
| `--icon` | `rgba(148,162,195,.48)` | `rgba(160,175,210,.62)` | Non-text 3:1 pass |
|
||||
|
||||
---
|
||||
|
||||
## 4. Focus Indicators (AAA 2.4.13)
|
||||
|
||||
Global `:focus-visible` rule in `main.css`:
|
||||
|
||||
```css
|
||||
*:focus-visible {
|
||||
outline: 2px solid rgba(136,164,196,.65);
|
||||
outline-offset: 2px;
|
||||
border-radius: inherit;
|
||||
}
|
||||
```
|
||||
|
||||
Component refinements:
|
||||
- Buttons: outline around button shape (default behavior)
|
||||
- Playlist rows: `outline-offset: -2px` to stay within bounds
|
||||
- Sliders: `:focus-visible::-webkit-slider-thumb` box-shadow glow
|
||||
- Notes textarea: enhance existing `:focus` border to 3:1 contrast
|
||||
- Switch labels: outline around entire label
|
||||
- Menu items: background highlight + outline on focus
|
||||
|
||||
`rgba(136,164,196,.65)` vs `#151821` gives ~5.5:1 -- passes the 3:1 requirement for focus indicators.
|
||||
|
||||
---
|
||||
|
||||
## 5. Keyboard Accessibility
|
||||
|
||||
### Interactive `<div>` elements become keyboard-accessible
|
||||
|
||||
1. **Playlist rows**: `tabindex="0"`, `keydown` Enter/Space to activate, Arrow Up/Down to navigate rows
|
||||
2. **Menu items** (subtitle, speed, recent): Use `<button>` elements or `tabindex="0"` with `role="menuitem"`. Enter/Space to activate, Arrow Up/Down to navigate, Escape to close and return focus to trigger
|
||||
3. **`zoomResetBtn`**: Change from `<span>` to `<button>`
|
||||
4. **Resize dividers**: `tabindex="0"`, `role="separator"`, `aria-valuenow`, `aria-orientation="vertical"`. Arrow Left/Right to resize in 2% increments
|
||||
|
||||
### Menu keyboard pattern (subtitles, speed, recent)
|
||||
|
||||
- Trigger button: `aria-haspopup="true"`, `aria-expanded="false"/"true"`
|
||||
- On open: focus moves to first menu item
|
||||
- Arrow Up/Down: navigate items
|
||||
- Enter/Space: activate item
|
||||
- Escape: close menu, return focus to trigger
|
||||
- Tab: close menu
|
||||
|
||||
### Playlist keyboard reorder (WCAG 2.5.7)
|
||||
|
||||
- Focused row: Alt+ArrowUp / Alt+ArrowDown moves it
|
||||
- Small move-up/move-down `<button>` elements appear on row focus/hover (right side, before tag)
|
||||
- After move, focus follows the moved row to its new position
|
||||
- A live region announces "Moved to position X"
|
||||
|
||||
---
|
||||
|
||||
## 6. Remaining AAA Criteria
|
||||
|
||||
### Dynamic page title (2.4.2)
|
||||
|
||||
Update `document.title` in `loadIndex()` and `onLibraryLoaded()`:
|
||||
- Playing: `"Video Title - TutorialVault"`
|
||||
- No video: `"TutorialVault - Open a folder"`
|
||||
|
||||
### Abbreviations (3.1.4)
|
||||
|
||||
Wrap abbreviations in info panel with `<abbr title="...">`:
|
||||
- ETA -> `<abbr title="Estimated Time of Arrival">ETA</abbr>`
|
||||
- FPS -> `<abbr title="Frames Per Second">FPS</abbr>`
|
||||
- kbps/Mbps -> `<abbr title="kilobits per second">kbps</abbr>`
|
||||
|
||||
Update `updateInfoPanel()` and `refreshCurrentVideoMeta()` in `ui.ts`.
|
||||
|
||||
### Target size (2.5.5 AAA -- 44x44px)
|
||||
|
||||
Expand hit areas of small buttons using padding + negative margin:
|
||||
- `.zoomBtn` (28x28): add padding to reach 44x44 effective, negative margin to keep visual layout
|
||||
- `.winBtn` (30x30): same technique
|
||||
- `.dropRemove` (24x24): same technique
|
||||
|
||||
Visual size stays the same; click/touch area grows.
|
||||
|
||||
### Reduced motion (2.3.3)
|
||||
|
||||
Already handled -- `prefers-reduced-motion` media query disables all animations/transitions. No change needed.
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `src/index.html` | `lang`, landmarks, headings, `aria-label`s, `aria-hidden`, `<button>` for zoomReset, `<dl>` for info panel, `role`/`aria` on progress bar and video |
|
||||
| `src/styles/main.css` | `:root` contrast values, `:focus-visible` rules, hit area expansion for small buttons |
|
||||
| `src/styles/player.css` | Focus styles for sliders and control buttons |
|
||||
| `src/styles/playlist.css` | Focus styles for rows, move button styling |
|
||||
| `src/styles/panels.css` | Focus styles for notes, divider roles |
|
||||
| `src/styles/components.css` | Focus styles for menu items, tooltip improvements |
|
||||
| `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, Escape handler |
|
||||
| `src/subtitles.ts` | `aria-expanded` on subs button, menu items as buttons, keyboard nav, Escape handler |
|
||||
| `src/ui.ts` | `aria-expanded` on recent dropdown, menu keyboard nav, Escape handler, divider keyboard resize, progress bar ARIA updates, dynamic `document.title`, abbreviation wrapping |
|
||||
| `src/main.ts` | Dynamic `document.title` updates |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -964,7 +964,7 @@ impl Library {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a protocol URL for a stored subtitle VTT path.
|
||||
/// The `vtt` field is like `"subtitles/{fid}_{name}.vtt"` — extract the filename.
|
||||
/// The `vtt` field is like `"subtitles/{fid}_{name}.vtt"` - extract the filename.
|
||||
fn _sub_url(vtt: &str) -> String {
|
||||
let filename = vtt.rsplit('/').next().unwrap_or(vtt);
|
||||
format!("http://tutdock.localhost/sub/{}", filename)
|
||||
|
||||
@@ -36,7 +36,7 @@ fn main() {
|
||||
// 4. Load preferences.
|
||||
let prefs_data = prefs::Prefs::load(&state_dir);
|
||||
|
||||
// 5. Initialize library (empty — loaded from last folder or frontend action).
|
||||
// 5. Initialize library (empty - loaded from last folder or frontend action).
|
||||
let mut lib = library::Library::new();
|
||||
|
||||
// Discover ffmpeg/ffprobe.
|
||||
|
||||
@@ -364,7 +364,7 @@ mod tests {
|
||||
prefs.window.x = Some(50);
|
||||
prefs.window.y = Some(60);
|
||||
|
||||
// Patch only width — height, x, y should remain
|
||||
// Patch only width - height, x, y should remain
|
||||
let patch = json!({
|
||||
"window": {"width": 1600}
|
||||
});
|
||||
@@ -395,7 +395,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_load_with_missing_fields_uses_defaults() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Write a JSON with only version — everything else should be defaults
|
||||
// Write a JSON with only version - everything else should be defaults
|
||||
let partial = json!({"version": 20});
|
||||
let path = dir.path().join("prefs.json");
|
||||
atomic_write_json(&path, &partial, BACKUP_COUNT);
|
||||
|
||||
@@ -127,7 +127,7 @@ mod tests {
|
||||
let dir = TempDir::new().unwrap();
|
||||
push_recent(dir.path(), "/folder/a");
|
||||
push_recent(dir.path(), "/folder/b");
|
||||
push_recent(dir.path(), "/folder/a"); // duplicate — should move to front
|
||||
push_recent(dir.path(), "/folder/a"); // duplicate - should move to front
|
||||
|
||||
let recents = load_recents(dir.path());
|
||||
assert_eq!(recents.len(), 2);
|
||||
|
||||
@@ -124,9 +124,9 @@ mod tests {
|
||||
let first = json!({"version": 1});
|
||||
let second = json!({"version": 2});
|
||||
|
||||
// First write — creates primary + lastgood
|
||||
// First write - creates primary + lastgood
|
||||
atomic_write_json(&path, &first, BACKUP_COUNT);
|
||||
// Second write — rotates first to .bak1, writes second as primary + lastgood
|
||||
// Second write - rotates first to .bak1, writes second as primary + lastgood
|
||||
atomic_write_json(&path, &second, BACKUP_COUNT);
|
||||
|
||||
// Corrupt primary and lastgood
|
||||
|
||||
@@ -66,7 +66,7 @@ static NORMALIZE_SEP_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[-_\s]+").unwra
|
||||
/// - Removes BOM (`\u{FEFF}`) if present.
|
||||
/// - Adds the `WEBVTT` header.
|
||||
/// - Skips cue index numbers (lines that are just digits).
|
||||
/// - Converts timestamp separators: comma → dot.
|
||||
/// - Converts timestamp separators: comma -> dot.
|
||||
/// - Collects subtitle text between timestamp lines and empty lines.
|
||||
pub fn srt_to_vtt(srt_text: &str) -> String {
|
||||
let text = srt_text.replace('\u{FEFF}', "");
|
||||
@@ -77,7 +77,7 @@ pub fn srt_to_vtt(srt_text: &str) -> String {
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim_end_matches('\r');
|
||||
|
||||
// Empty line → blank line in output
|
||||
// Empty line -> blank line in output
|
||||
if line.trim().is_empty() {
|
||||
out.push(String::new());
|
||||
i += 1;
|
||||
@@ -149,7 +149,7 @@ fn normalize_stem(s: &str) -> String {
|
||||
|
||||
/// Strip a trailing language suffix from a subtitle stem.
|
||||
///
|
||||
/// For example, `"video.en"` → `Some(("video", "en"))`.
|
||||
/// For example, `"video.en"` -> `Some(("video", "en"))`.
|
||||
/// Returns `None` if no known language suffix is found.
|
||||
fn strip_lang_suffix(stem: &str) -> Option<(String, String)> {
|
||||
if let Some(dot_pos) = stem.rfind('.') {
|
||||
@@ -297,7 +297,7 @@ fn sanitize_name(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a subtitle file for a given fid. Converts SRT→VTT if needed.
|
||||
/// Store a subtitle file for a given fid. Converts SRT->VTT if needed.
|
||||
///
|
||||
/// The output file is written as `{fid}_{sanitized_name}.vtt` inside
|
||||
/// `subs_dir`. Returns `SubtitleStored` with the relative path (from the
|
||||
@@ -341,7 +341,7 @@ pub fn store_subtitle_for_fid(
|
||||
let vtt_content = if ext_lower == ".srt" {
|
||||
srt_to_vtt(&content)
|
||||
} else {
|
||||
// Already VTT — use as-is.
|
||||
// Already VTT - use as-is.
|
||||
content
|
||||
};
|
||||
|
||||
@@ -573,13 +573,13 @@ Second line
|
||||
// Should pick priority 0 (exact match) over others.
|
||||
assert_eq!(result.unwrap(), exact);
|
||||
|
||||
// Remove exact match → should pick English suffix (priority 2).
|
||||
// Remove exact match -> should pick English suffix (priority 2).
|
||||
fs::remove_file(&exact).unwrap();
|
||||
let result = auto_subtitle_sidecar(&video);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap(), en_suffix);
|
||||
|
||||
// Remove English suffix → should pick French suffix (priority 4).
|
||||
// Remove English suffix -> should pick French suffix (priority 4).
|
||||
fs::remove_file(&en_suffix).unwrap();
|
||||
let result = auto_subtitle_sidecar(&video);
|
||||
assert!(result.is_some());
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
//! On macOS / Linux it would be `tutdock://localhost/<path>`.
|
||||
//!
|
||||
//! Routes:
|
||||
//! /video/{index} — video file with Range support
|
||||
//! /sub/{libid}/{fid} — stored VTT subtitle
|
||||
//! /fonts.css — cached Google Fonts CSS
|
||||
//! /fonts/{filename} — cached font file
|
||||
//! /fa.css — cached Font Awesome CSS
|
||||
//! /fa/webfonts/{filename} — cached FA webfont
|
||||
//! /video/{index} - video file with Range support
|
||||
//! /sub/{libid}/{fid} - stored VTT subtitle
|
||||
//! /fonts.css - cached Google Fonts CSS
|
||||
//! /fonts/{filename} - cached font file
|
||||
//! /fa.css - cached Font Awesome CSS
|
||||
//! /fa/webfonts/{filename} - cached FA webfont
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
@@ -75,7 +75,7 @@ fn handle_request(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video — with Range request support
|
||||
// Video - with Range request support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_video(
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* TutorialDock frontend — boot sequence, tick loop, global wiring.
|
||||
* TutorialDock frontend - boot sequence, tick loop, global wiring.
|
||||
* Orchestrates all modules and holds cross-module callbacks.
|
||||
*/
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Video playback controls — manages the <video> element, seek bar,
|
||||
* Video playback controls - manages the <video> element, seek bar,
|
||||
* volume slider, play/pause, fullscreen, and video overlay.
|
||||
*/
|
||||
import { api } from './api';
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Playlist rendering — list items, tree SVG connectors, custom scrollbar,
|
||||
* Playlist rendering - list items, tree SVG connectors, custom scrollbar,
|
||||
* and drag-and-drop reorder.
|
||||
*/
|
||||
import { api } from './api';
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Shared mutable state and pure utility functions.
|
||||
* All modules import from here — no circular dependencies.
|
||||
* All modules import from here - no circular dependencies.
|
||||
*/
|
||||
import type { LibraryInfo, VideoItem } from './types';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Reduced motion — respect user preference */
|
||||
/* Reduced motion - respect user preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
color:var(--text);
|
||||
}
|
||||
|
||||
/* Toolbar icon buttons — borderless */
|
||||
/* Toolbar icon buttons - borderless */
|
||||
.toolbarIcon{
|
||||
width:36px; height:36px;
|
||||
border-radius:var(--r2);
|
||||
|
||||
+15
-15
@@ -1,18 +1,18 @@
|
||||
:root{
|
||||
--zoom:1;
|
||||
/* Type scale — Minor Third (1.2) from 13px base: 10 · 11 · 12 · 13 · 15 · 17 · 19 */
|
||||
/* Base backgrounds — cool dark slate, lighter */
|
||||
/* Type scale - Minor Third (1.2) from 13px base: 10 · 11 · 12 · 13 · 15 · 17 · 19 */
|
||||
/* Base backgrounds - cool dark slate, lighter */
|
||||
--bg0:#0f1117; --bg1:#151821;
|
||||
/* Strokes — cool, very subtle */
|
||||
/* Strokes - cool, very subtle */
|
||||
--stroke:rgba(140,160,210,.07);
|
||||
--strokeLight:rgba(140,160,210,.04);
|
||||
--strokeMed:rgba(140,160,210,.09);
|
||||
--strokeStrong:rgba(140,160,210,.14);
|
||||
/* Text — cool white hierarchy */
|
||||
/* Text - cool white hierarchy */
|
||||
--text:rgba(218,225,240,.90);
|
||||
--textMuted:rgba(185,196,222,.86);
|
||||
--textDim:rgba(172,184,214,.88);
|
||||
/* Surfaces — cool-tinted, subtle fills */
|
||||
/* Surfaces - cool-tinted, subtle fills */
|
||||
--surface-0:rgba(140,165,220,.04);
|
||||
--surface:rgba(140,165,220,.06);
|
||||
--surface-2:rgba(140,165,220,.09);
|
||||
@@ -20,15 +20,15 @@
|
||||
--surface-4:rgba(140,165,220,.15);
|
||||
--surfaceHover:rgba(140,165,220,.10);
|
||||
--surfaceActive:rgba(140,165,220,.13);
|
||||
/* Shadows — minimal */
|
||||
/* Shadows - minimal */
|
||||
--shadow:0 8px 24px rgba(0,0,0,.25);
|
||||
--shadow2:0 4px 12px rgba(0,0,0,.15);
|
||||
--shadow3:none;
|
||||
--shadowFloat:0 6px 20px rgba(0,0,0,.20);
|
||||
--shadowInset:inset 0 1px 3px rgba(0,0,0,.12);
|
||||
/* Radii — architectural */
|
||||
/* Radii - architectural */
|
||||
--r:6px; --r2:4px; --r3:3px;
|
||||
/* Easing — refined, springy */
|
||||
/* Easing - refined, springy */
|
||||
--ease-spring:cubic-bezier(.25,.46,.45,.94);
|
||||
--ease-bounce:cubic-bezier(.34,1.56,.64,1);
|
||||
--ease-out-back:cubic-bezier(.34,1.3,.64,1);
|
||||
@@ -36,19 +36,19 @@
|
||||
--mono:"Space Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--sans:"Inter", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
--brand:"Bricolage Grotesque", "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
/* Icons — cool */
|
||||
/* Icons - cool */
|
||||
--icon:rgba(160,175,210,.62);
|
||||
--iconStrong:rgba(200,212,238,.75);
|
||||
/* Accent — steel blue */
|
||||
/* Accent - steel blue */
|
||||
--accent:#88A4C4;
|
||||
--accentGlow:rgba(136,164,196,.08);
|
||||
--accentBorder:rgba(136,164,196,.18);
|
||||
--accentBg:rgba(136,164,196,.06);
|
||||
/* Success — muted sage */
|
||||
/* Success - muted sage */
|
||||
--success:rgb(130,170,130);
|
||||
--successBg:rgba(130,170,130,.06);
|
||||
--successBorder:rgba(130,170,130,.15);
|
||||
/* Tree — cool */
|
||||
/* Tree - cool */
|
||||
--tree:rgba(140,165,220,.07);
|
||||
--treeNode:rgba(200,212,238,.40);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ body{
|
||||
transition:background .3s ease;
|
||||
}
|
||||
|
||||
/* Zoom control — borderless */
|
||||
/* Zoom control - borderless */
|
||||
.zoomControl{
|
||||
display:flex; align-items:center; gap:0;
|
||||
background:var(--surface-2);
|
||||
@@ -294,7 +294,7 @@ body{
|
||||
transition-duration:.08s;
|
||||
}
|
||||
|
||||
/* Toolbar buttons — borderless */
|
||||
/* Toolbar buttons - borderless */
|
||||
.toolbarBtn{
|
||||
width:34px; height:34px;
|
||||
border:none;
|
||||
@@ -336,7 +336,7 @@ body{
|
||||
}
|
||||
.windowControls{gap:4px; margin-left:2px;}
|
||||
|
||||
/* Primary split button — borderless */
|
||||
/* Primary split button - borderless */
|
||||
.splitBtn.primary{
|
||||
background:rgba(136,164,196,.10);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
background:rgba(136,164,196,.30);
|
||||
}
|
||||
|
||||
/* Row — no transform/padding changes (preserves drag-and-drop) */
|
||||
/* Row - no transform/padding changes (preserves drag-and-drop) */
|
||||
.row{position:relative; display:flex; align-items:flex-start; justify-content:space-between; gap:10px; padding:9px 12px; border-bottom:1px solid var(--strokeLight); cursor:pointer; user-select:none; transition:background .2s ease, box-shadow .2s ease; box-shadow:inset 3px 0 0 transparent;}
|
||||
.row:hover{background:var(--surfaceHover); box-shadow:inset 3px 0 0 rgba(136,164,196,.40);}
|
||||
.row:active{background:var(--surfaceActive);}
|
||||
@@ -69,7 +69,7 @@
|
||||
.numBadge{flex:0 0 auto; min-width:38px; height:22px; padding:0 8px; border-radius:var(--r2); border:none; background:var(--surface-2); box-shadow:var(--shadow3); display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:12px; letter-spacing:.02em; color:var(--text); font-variant-numeric:tabular-nums; margin-top:1px; transition:all .2s var(--ease-bounce);}
|
||||
.row:hover .numBadge{background:var(--surface-3); transform:scale(1.06);}
|
||||
|
||||
/* Tree connectors — fully static, opaque, dark lines */
|
||||
/* Tree connectors - fully static, opaque, dark lines */
|
||||
.treeSvg{flex:0 0 auto; margin-top:1px; overflow:visible;}
|
||||
.treeSvg line{stroke:rgb(40,46,60); stroke-width:1.5;}
|
||||
.treeSvg circle{fill:rgba(200,212,238,.60); stroke:rgba(136,164,196,.18); stroke-width:1;}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* UI controls — zoom, split ratios, topbar, recent menu, info panel,
|
||||
* UI controls - zoom, split ratios, topbar, recent menu, info panel,
|
||||
* notes, toast notifications, and reset/reload buttons.
|
||||
*/
|
||||
import { api } from './api';
|
||||
@@ -322,7 +322,7 @@ export function initUI(): void {
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
// Second click — actually reset
|
||||
// Second click - actually reset
|
||||
cancelResetConfirm();
|
||||
try {
|
||||
const res = await api.resetWatchProgress();
|
||||
|
||||
Reference in New Issue
Block a user