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.
|
**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.
|
**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.
|
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.
|
**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:
|
**Frontend (TypeScript + Vite):** A modular architecture with dedicated modules for each concern:
|
||||||
|
|
||||||
- `main.ts` -- Boot sequence, tick loop, keyboard shortcuts, cross-module wiring
|
- `main.ts` - Boot sequence, tick loop, keyboard shortcuts, cross-module wiring
|
||||||
- `player.ts` -- Video element, seek/volume/speed controls, mute, fullscreen, PiP, error states
|
- `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
|
- `playlist.ts` - Playlist rendering, tree view, drag-and-drop, search/filter, scroll-to-current
|
||||||
- `subtitles.ts` -- Subtitle discovery, menu, embedded track extraction
|
- `subtitles.ts` - Subtitle discovery, menu, embedded track extraction
|
||||||
- `ui.ts` -- Zoom, split ratios, notes, info panel, recent menu, toast, collapsible panes
|
- `ui.ts` - Zoom, split ratios, notes, info panel, recent menu, toast, collapsible panes
|
||||||
- `tooltips.ts` -- Hover tooltips with zoom awareness and viewport clamping
|
- `tooltips.ts` - Hover tooltips with zoom awareness and viewport clamping
|
||||||
- `store.ts` -- Shared state, pure utilities, cross-module callback registry
|
- `store.ts` - Shared state, pure utilities, cross-module callback registry
|
||||||
- `api.ts` -- Typed wrapper around Tauri's invoke API
|
- `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.
|
**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.
|
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.
|
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.
|
/// 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 {
|
fn _sub_url(vtt: &str) -> String {
|
||||||
let filename = vtt.rsplit('/').next().unwrap_or(vtt);
|
let filename = vtt.rsplit('/').next().unwrap_or(vtt);
|
||||||
format!("http://tutdock.localhost/sub/{}", filename)
|
format!("http://tutdock.localhost/sub/{}", filename)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ fn main() {
|
|||||||
// 4. Load preferences.
|
// 4. Load preferences.
|
||||||
let prefs_data = prefs::Prefs::load(&state_dir);
|
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();
|
let mut lib = library::Library::new();
|
||||||
|
|
||||||
// Discover ffmpeg/ffprobe.
|
// Discover ffmpeg/ffprobe.
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ mod tests {
|
|||||||
prefs.window.x = Some(50);
|
prefs.window.x = Some(50);
|
||||||
prefs.window.y = Some(60);
|
prefs.window.y = Some(60);
|
||||||
|
|
||||||
// Patch only width — height, x, y should remain
|
// Patch only width - height, x, y should remain
|
||||||
let patch = json!({
|
let patch = json!({
|
||||||
"window": {"width": 1600}
|
"window": {"width": 1600}
|
||||||
});
|
});
|
||||||
@@ -395,7 +395,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_load_with_missing_fields_uses_defaults() {
|
fn test_load_with_missing_fields_uses_defaults() {
|
||||||
let dir = TempDir::new().unwrap();
|
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 partial = json!({"version": 20});
|
||||||
let path = dir.path().join("prefs.json");
|
let path = dir.path().join("prefs.json");
|
||||||
atomic_write_json(&path, &partial, BACKUP_COUNT);
|
atomic_write_json(&path, &partial, BACKUP_COUNT);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ mod tests {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
push_recent(dir.path(), "/folder/a");
|
push_recent(dir.path(), "/folder/a");
|
||||||
push_recent(dir.path(), "/folder/b");
|
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());
|
let recents = load_recents(dir.path());
|
||||||
assert_eq!(recents.len(), 2);
|
assert_eq!(recents.len(), 2);
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ mod tests {
|
|||||||
let first = json!({"version": 1});
|
let first = json!({"version": 1});
|
||||||
let second = json!({"version": 2});
|
let second = json!({"version": 2});
|
||||||
|
|
||||||
// First write — creates primary + lastgood
|
// First write - creates primary + lastgood
|
||||||
atomic_write_json(&path, &first, BACKUP_COUNT);
|
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);
|
atomic_write_json(&path, &second, BACKUP_COUNT);
|
||||||
|
|
||||||
// Corrupt primary and lastgood
|
// 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.
|
/// - Removes BOM (`\u{FEFF}`) if present.
|
||||||
/// - Adds the `WEBVTT` header.
|
/// - Adds the `WEBVTT` header.
|
||||||
/// - Skips cue index numbers (lines that are just digits).
|
/// - 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.
|
/// - Collects subtitle text between timestamp lines and empty lines.
|
||||||
pub fn srt_to_vtt(srt_text: &str) -> String {
|
pub fn srt_to_vtt(srt_text: &str) -> String {
|
||||||
let text = srt_text.replace('\u{FEFF}', "");
|
let text = srt_text.replace('\u{FEFF}', "");
|
||||||
@@ -77,7 +77,7 @@ pub fn srt_to_vtt(srt_text: &str) -> String {
|
|||||||
while i < lines.len() {
|
while i < lines.len() {
|
||||||
let line = lines[i].trim_end_matches('\r');
|
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() {
|
if line.trim().is_empty() {
|
||||||
out.push(String::new());
|
out.push(String::new());
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -149,7 +149,7 @@ fn normalize_stem(s: &str) -> String {
|
|||||||
|
|
||||||
/// Strip a trailing language suffix from a subtitle stem.
|
/// 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.
|
/// Returns `None` if no known language suffix is found.
|
||||||
fn strip_lang_suffix(stem: &str) -> Option<(String, String)> {
|
fn strip_lang_suffix(stem: &str) -> Option<(String, String)> {
|
||||||
if let Some(dot_pos) = stem.rfind('.') {
|
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
|
/// The output file is written as `{fid}_{sanitized_name}.vtt` inside
|
||||||
/// `subs_dir`. Returns `SubtitleStored` with the relative path (from the
|
/// `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" {
|
let vtt_content = if ext_lower == ".srt" {
|
||||||
srt_to_vtt(&content)
|
srt_to_vtt(&content)
|
||||||
} else {
|
} else {
|
||||||
// Already VTT — use as-is.
|
// Already VTT - use as-is.
|
||||||
content
|
content
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -573,13 +573,13 @@ Second line
|
|||||||
// Should pick priority 0 (exact match) over others.
|
// Should pick priority 0 (exact match) over others.
|
||||||
assert_eq!(result.unwrap(), exact);
|
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();
|
fs::remove_file(&exact).unwrap();
|
||||||
let result = auto_subtitle_sidecar(&video);
|
let result = auto_subtitle_sidecar(&video);
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
assert_eq!(result.unwrap(), en_suffix);
|
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();
|
fs::remove_file(&en_suffix).unwrap();
|
||||||
let result = auto_subtitle_sidecar(&video);
|
let result = auto_subtitle_sidecar(&video);
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
//! On macOS / Linux it would be `tutdock://localhost/<path>`.
|
//! On macOS / Linux it would be `tutdock://localhost/<path>`.
|
||||||
//!
|
//!
|
||||||
//! Routes:
|
//! Routes:
|
||||||
//! /video/{index} — video file with Range support
|
//! /video/{index} - video file with Range support
|
||||||
//! /sub/{libid}/{fid} — stored VTT subtitle
|
//! /sub/{libid}/{fid} - stored VTT subtitle
|
||||||
//! /fonts.css — cached Google Fonts CSS
|
//! /fonts.css - cached Google Fonts CSS
|
||||||
//! /fonts/{filename} — cached font file
|
//! /fonts/{filename} - cached font file
|
||||||
//! /fa.css — cached Font Awesome CSS
|
//! /fa.css - cached Font Awesome CSS
|
||||||
//! /fa/webfonts/{filename} — cached FA webfont
|
//! /fa/webfonts/{filename} - cached FA webfont
|
||||||
|
|
||||||
use std::fs::{self, File};
|
use std::fs::{self, File};
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
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(
|
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.
|
* Orchestrates all modules and holds cross-module callbacks.
|
||||||
*/
|
*/
|
||||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
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.
|
* volume slider, play/pause, fullscreen, and video overlay.
|
||||||
*/
|
*/
|
||||||
import { api } from './api';
|
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.
|
* and drag-and-drop reorder.
|
||||||
*/
|
*/
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Shared mutable state and pure utility functions.
|
* 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';
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
color:var(--text);
|
color:var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar icon buttons — borderless */
|
/* Toolbar icon buttons - borderless */
|
||||||
.toolbarIcon{
|
.toolbarIcon{
|
||||||
width:36px; height:36px;
|
width:36px; height:36px;
|
||||||
border-radius:var(--r2);
|
border-radius:var(--r2);
|
||||||
|
|||||||
+15
-15
@@ -1,18 +1,18 @@
|
|||||||
:root{
|
:root{
|
||||||
--zoom:1;
|
--zoom:1;
|
||||||
/* Type scale — Minor Third (1.2) from 13px base: 10 · 11 · 12 · 13 · 15 · 17 · 19 */
|
/* Type scale - Minor Third (1.2) from 13px base: 10 · 11 · 12 · 13 · 15 · 17 · 19 */
|
||||||
/* Base backgrounds — cool dark slate, lighter */
|
/* Base backgrounds - cool dark slate, lighter */
|
||||||
--bg0:#0f1117; --bg1:#151821;
|
--bg0:#0f1117; --bg1:#151821;
|
||||||
/* Strokes — cool, very subtle */
|
/* Strokes - cool, very subtle */
|
||||||
--stroke:rgba(140,160,210,.07);
|
--stroke:rgba(140,160,210,.07);
|
||||||
--strokeLight:rgba(140,160,210,.04);
|
--strokeLight:rgba(140,160,210,.04);
|
||||||
--strokeMed:rgba(140,160,210,.09);
|
--strokeMed:rgba(140,160,210,.09);
|
||||||
--strokeStrong:rgba(140,160,210,.14);
|
--strokeStrong:rgba(140,160,210,.14);
|
||||||
/* Text — cool white hierarchy */
|
/* Text - cool white hierarchy */
|
||||||
--text:rgba(218,225,240,.90);
|
--text:rgba(218,225,240,.90);
|
||||||
--textMuted:rgba(185,196,222,.86);
|
--textMuted:rgba(185,196,222,.86);
|
||||||
--textDim:rgba(172,184,214,.88);
|
--textDim:rgba(172,184,214,.88);
|
||||||
/* Surfaces — cool-tinted, subtle fills */
|
/* Surfaces - cool-tinted, subtle fills */
|
||||||
--surface-0:rgba(140,165,220,.04);
|
--surface-0:rgba(140,165,220,.04);
|
||||||
--surface:rgba(140,165,220,.06);
|
--surface:rgba(140,165,220,.06);
|
||||||
--surface-2:rgba(140,165,220,.09);
|
--surface-2:rgba(140,165,220,.09);
|
||||||
@@ -20,15 +20,15 @@
|
|||||||
--surface-4:rgba(140,165,220,.15);
|
--surface-4:rgba(140,165,220,.15);
|
||||||
--surfaceHover:rgba(140,165,220,.10);
|
--surfaceHover:rgba(140,165,220,.10);
|
||||||
--surfaceActive:rgba(140,165,220,.13);
|
--surfaceActive:rgba(140,165,220,.13);
|
||||||
/* Shadows — minimal */
|
/* Shadows - minimal */
|
||||||
--shadow:0 8px 24px rgba(0,0,0,.25);
|
--shadow:0 8px 24px rgba(0,0,0,.25);
|
||||||
--shadow2:0 4px 12px rgba(0,0,0,.15);
|
--shadow2:0 4px 12px rgba(0,0,0,.15);
|
||||||
--shadow3:none;
|
--shadow3:none;
|
||||||
--shadowFloat:0 6px 20px rgba(0,0,0,.20);
|
--shadowFloat:0 6px 20px rgba(0,0,0,.20);
|
||||||
--shadowInset:inset 0 1px 3px rgba(0,0,0,.12);
|
--shadowInset:inset 0 1px 3px rgba(0,0,0,.12);
|
||||||
/* Radii — architectural */
|
/* Radii - architectural */
|
||||||
--r:6px; --r2:4px; --r3:3px;
|
--r:6px; --r2:4px; --r3:3px;
|
||||||
/* Easing — refined, springy */
|
/* Easing - refined, springy */
|
||||||
--ease-spring:cubic-bezier(.25,.46,.45,.94);
|
--ease-spring:cubic-bezier(.25,.46,.45,.94);
|
||||||
--ease-bounce:cubic-bezier(.34,1.56,.64,1);
|
--ease-bounce:cubic-bezier(.34,1.56,.64,1);
|
||||||
--ease-out-back:cubic-bezier(.34,1.3,.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;
|
--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;
|
--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;
|
--brand:"Bricolage Grotesque", "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
/* Icons — cool */
|
/* Icons - cool */
|
||||||
--icon:rgba(160,175,210,.62);
|
--icon:rgba(160,175,210,.62);
|
||||||
--iconStrong:rgba(200,212,238,.75);
|
--iconStrong:rgba(200,212,238,.75);
|
||||||
/* Accent — steel blue */
|
/* Accent - steel blue */
|
||||||
--accent:#88A4C4;
|
--accent:#88A4C4;
|
||||||
--accentGlow:rgba(136,164,196,.08);
|
--accentGlow:rgba(136,164,196,.08);
|
||||||
--accentBorder:rgba(136,164,196,.18);
|
--accentBorder:rgba(136,164,196,.18);
|
||||||
--accentBg:rgba(136,164,196,.06);
|
--accentBg:rgba(136,164,196,.06);
|
||||||
/* Success — muted sage */
|
/* Success - muted sage */
|
||||||
--success:rgb(130,170,130);
|
--success:rgb(130,170,130);
|
||||||
--successBg:rgba(130,170,130,.06);
|
--successBg:rgba(130,170,130,.06);
|
||||||
--successBorder:rgba(130,170,130,.15);
|
--successBorder:rgba(130,170,130,.15);
|
||||||
/* Tree — cool */
|
/* Tree - cool */
|
||||||
--tree:rgba(140,165,220,.07);
|
--tree:rgba(140,165,220,.07);
|
||||||
--treeNode:rgba(200,212,238,.40);
|
--treeNode:rgba(200,212,238,.40);
|
||||||
}
|
}
|
||||||
@@ -237,7 +237,7 @@ body{
|
|||||||
transition:background .3s ease;
|
transition:background .3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Zoom control — borderless */
|
/* Zoom control - borderless */
|
||||||
.zoomControl{
|
.zoomControl{
|
||||||
display:flex; align-items:center; gap:0;
|
display:flex; align-items:center; gap:0;
|
||||||
background:var(--surface-2);
|
background:var(--surface-2);
|
||||||
@@ -294,7 +294,7 @@ body{
|
|||||||
transition-duration:.08s;
|
transition-duration:.08s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar buttons — borderless */
|
/* Toolbar buttons - borderless */
|
||||||
.toolbarBtn{
|
.toolbarBtn{
|
||||||
width:34px; height:34px;
|
width:34px; height:34px;
|
||||||
border:none;
|
border:none;
|
||||||
@@ -336,7 +336,7 @@ body{
|
|||||||
}
|
}
|
||||||
.windowControls{gap:4px; margin-left:2px;}
|
.windowControls{gap:4px; margin-left:2px;}
|
||||||
|
|
||||||
/* Primary split button — borderless */
|
/* Primary split button - borderless */
|
||||||
.splitBtn.primary{
|
.splitBtn.primary{
|
||||||
background:rgba(136,164,196,.10);
|
background:rgba(136,164,196,.10);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
background:rgba(136,164,196,.30);
|
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{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:hover{background:var(--surfaceHover); box-shadow:inset 3px 0 0 rgba(136,164,196,.40);}
|
||||||
.row:active{background:var(--surfaceActive);}
|
.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);}
|
.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);}
|
.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{flex:0 0 auto; margin-top:1px; overflow:visible;}
|
||||||
.treeSvg line{stroke:rgb(40,46,60); stroke-width:1.5;}
|
.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;}
|
.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.
|
* notes, toast notifications, and reset/reload buttons.
|
||||||
*/
|
*/
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
@@ -322,7 +322,7 @@ export function initUI(): void {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Second click — actually reset
|
// Second click - actually reset
|
||||||
cancelResetConfirm();
|
cancelResetConfirm();
|
||||||
try {
|
try {
|
||||||
const res = await api.resetWatchProgress();
|
const res = await api.resetWatchProgress();
|
||||||
|
|||||||
Reference in New Issue
Block a user