Compare commits
28 Commits
v1.1.0
..
24e57a788f
| Author | SHA1 | Date | |
|---|---|---|---|
| 24e57a788f | |||
| 58847c018f | |||
| c0a8eca955 | |||
| a571a33415 | |||
| 1b2ebd807c | |||
| 715c3c713a | |||
| 98011cf604 | |||
| c2533e8a76 | |||
| a6f1aef489 | |||
| cd362a29b1 | |||
| 600188eb1a | |||
| 17e4ffd28f | |||
| f3aa5f7937 | |||
| 1579e146b5 | |||
| 17b57dbab0 | |||
| 8860188055 | |||
| 26ff5cd95b | |||
| 9c8d7d94cd | |||
| a459efae45 | |||
| 4e454084a8 | |||
| 9c8474d24f | |||
| 4e91fe679f | |||
| 3280d60f71 | |||
| b95094c50f | |||
| 6ecbeb9a9b | |||
| 40d7ce4291 | |||
| 24746bb33d | |||
| 47d4409bcc |
@@ -55,13 +55,13 @@ This is that tool.
|
|||||||
|
|
||||||
**Open any folder as a course.** TutorialVault recursively scans a folder and all its subdirectories for video files, organizing them into a navigable playlist. Supported formats include MP4, MKV, WebM, AVI, MOV, MPEG, M2TS, and OGV.
|
**Open any folder as a course.** TutorialVault recursively scans a folder and all its subdirectories for video files, organizing them into a navigable playlist. Supported formats include MP4, MKV, WebM, AVI, MOV, MPEG, M2TS, and OGV.
|
||||||
|
|
||||||
**🕐 Recent folders.** A dropdown menu keeps track of folders you've opened before. Click any entry to jump back to it instantly. Remove entries you no longer need.
|
**Recent folders.** A dropdown menu keeps track of folders you've opened before. Click any entry to jump back to it instantly. Remove entries you no longer need.
|
||||||
|
|
||||||
**🔄 Automatic restore.** The app remembers the last folder you had open and automatically loads it on startup, resuming exactly where you left off. No setup, no re-navigation.
|
**Automatic restore.** The app remembers the last folder you had open and automatically loads it on startup, resuming exactly where you left off. No setup, no re-navigation.
|
||||||
|
|
||||||
**🔑 Smart file identity.** Files are identified by their content, not their path. Rename or reorganize files within the folder and your progress follows them.
|
**Smart file identity.** Files are identified by their content, not their path. Rename or reorganize files within the folder and your progress follows them.
|
||||||
|
|
||||||
**🌳 Subfolder awareness.** When your course has subdirectories (chapters, sections, modules), TutorialVault detects the structure and displays a visual tree in the playlist, making it easy to see where you are in a large course.
|
**Subfolder awareness.** When your course has subdirectories (chapters, sections, modules), TutorialVault detects the structure and displays a visual tree in the playlist, making it easy to see where you are in a large course.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,19 +69,19 @@ This is that tool.
|
|||||||
|
|
||||||
**Full-featured player.** Play, pause, seek, adjust volume, change playback speed, and go fullscreen. All the controls you'd expect, with a clean minimal interface that stays out of your way.
|
**Full-featured player.** Play, pause, seek, adjust volume, change playback speed, and go fullscreen. All the controls you'd expect, with a clean minimal interface that stays out of your way.
|
||||||
|
|
||||||
**⏩ Playback speed.** Presets from 0.50x to 2.00x, with a visual speedometer that changes color based on your current rate. Speed is saved per folder, so your lecture courses stay at 1.5x while your drawing tutorials stay at 1x. Cycle through speeds with keyboard shortcuts.
|
**Playback speed.** Presets from 0.50x to 2.00x, with a visual speedometer that changes color based on your current rate. Speed is saved per folder, so your lecture courses stay at 1.5x while your drawing tutorials stay at 1x. Cycle through speeds with keyboard shortcuts.
|
||||||
|
|
||||||
**🔊 Volume control.** A slider with a live percentage tooltip. Click the volume icon to mute/unmute instantly. Volume is saved per folder as well.
|
**Volume control.** A slider with a live percentage tooltip. Click the volume icon to mute/unmute instantly. Volume is saved per folder as well.
|
||||||
|
|
||||||
**🖼️ Picture-in-Picture.** Pop the video out into a floating window so you can follow along while working in another application. The PiP button appears automatically when your browser supports it.
|
**Picture-in-Picture.** Pop the video out into a floating window so you can follow along while working in another application. The PiP button appears automatically when your browser supports it.
|
||||||
|
|
||||||
**📺 Fullscreen.** Double-click the video or press F to toggle fullscreen. The player remembers your position and controls remain accessible.
|
**Fullscreen.** Double-click the video or press F to toggle fullscreen. The player remembers your position and controls remain accessible.
|
||||||
|
|
||||||
**⏩ Seek feedback.** When skipping forward or backward, a brief overlay shows the accumulated seek amount (+5s, -10s, etc.) so you always know how far you've jumped.
|
**Seek feedback.** When skipping forward or backward, a brief overlay shows the accumulated seek amount (+5s, -10s, etc.) so you always know how far you've jumped.
|
||||||
|
|
||||||
**🚫 Error handling.** If a video format isn't supported by the player, an error overlay appears with a clear message and a "Try next" button to skip to the next video without interrupting your session.
|
**Error handling.** If a video format isn't supported by the player, an error overlay appears with a clear message and a "Try next" button to skip to the next video without interrupting your session.
|
||||||
|
|
||||||
**⌨️ Keyboard shortcuts:**
|
**Keyboard shortcuts:**
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
@@ -102,19 +102,19 @@ Press **?** at any time to open a help dialog listing all available shortcuts.
|
|||||||
|
|
||||||
### 📊 Progress Tracking
|
### 📊 Progress Tracking
|
||||||
|
|
||||||
**💾 Automatic position saving.** Your playback position is saved continuously as you watch. Close the app, come back tomorrow, and you're right where you stopped.
|
**Automatic position saving.** Your playback position is saved continuously as you watch. Close the app, come back tomorrow, and you're right where you stopped.
|
||||||
|
|
||||||
**📈 High-water mark tracking.** TutorialVault remembers the furthest point you've reached in each video, independent of where you happen to be scrubbing to.
|
**High-water mark tracking.** TutorialVault remembers the furthest point you've reached in each video, independent of where you happen to be scrubbing to.
|
||||||
|
|
||||||
**✅ Automatic completion detection.** When you watch to within 2 seconds of the end, a video is marked as done. Once done, it stays done - no accidental un-finishing.
|
**Automatic completion detection.** When you watch to within 2 seconds of the end, a video is marked as done. Once done, it stays done - no accidental un-finishing.
|
||||||
|
|
||||||
**📉 Overall progress.** A progress bar shows your completion percentage for the entire folder, calculated from actual watched time relative to total duration. Not just "videos finished out of total" - actual time-weighted progress.
|
**Overall progress.** A progress bar shows your completion percentage for the entire folder, calculated from actual watched time relative to total duration. Not just "videos finished out of total" - actual time-weighted progress.
|
||||||
|
|
||||||
**⏪ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,31 +122,31 @@ Press **?** at any time to open a help dialog listing all available shortcuts.
|
|||||||
|
|
||||||
**Visual playlist.** Every video in the folder is listed with its title, watched time, total duration, and status tags. The currently playing video is marked "Now," and completed videos are marked "Done."
|
**Visual playlist.** Every video in the folder is listed with its title, watched time, total duration, and status tags. The currently playing video is marked "Now," and completed videos are marked "Done."
|
||||||
|
|
||||||
**🌲 Tree view.** Courses with subdirectories get a visual tree with SVG connectors showing the hierarchy. Lines, corners, and dots show parent-child relationships at a glance.
|
**Tree view.** Courses with subdirectories get a visual tree with SVG connectors showing the hierarchy. Lines, corners, and dots show parent-child relationships at a glance.
|
||||||
|
|
||||||
**🔀 Drag and drop reorder.** Rearrange the playlist by dragging items. A blue indicator line shows exactly where an item will land. Your custom order is saved and persists between sessions.
|
**Drag and drop reorder.** Rearrange the playlist by dragging items. A blue indicator line shows exactly where an item will land. Your custom order is saved and persists between sessions.
|
||||||
|
|
||||||
**🔢 Smart ordering.** Natural sort by default - "Lesson 2" comes before "Lesson 10." When you reopen a folder that has new files, existing items keep their saved order and new files appear at the end.
|
**Smart ordering.** Natural sort by default - "Lesson 2" comes before "Lesson 10." When you reopen a folder that has new files, existing items keep their saved order and new files appear at the end.
|
||||||
|
|
||||||
**🔍 Search and filter.** A search field in the playlist header lets you filter videos by name in real time. Matches are case-insensitive and update the displayed list instantly. A clear button resets the filter. Statistics next to the header show total video count and how many are done (or "X of Y" when filtering).
|
**Search and filter.** A search field in the playlist header lets you filter videos by name in real time. Matches are case-insensitive and update the displayed list instantly. A clear button resets the filter. Statistics next to the header show total video count and how many are done (or "X of Y" when filtering).
|
||||||
|
|
||||||
**🎯 Scroll to current.** When the currently playing video scrolls out of view in a long playlist, a crosshair button appears in the header. Click it to scroll the active video back into view.
|
**Scroll to current.** When the currently playing video scrolls out of view in a long playlist, a crosshair button appears in the header. Click it to scroll the active video back into view.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 💬 Subtitles
|
### 💬 Subtitles
|
||||||
|
|
||||||
**🔍 Automatic subtitle discovery.** TutorialVault finds subtitles in three ways, in this priority order:
|
**Automatic subtitle discovery.** TutorialVault finds subtitles in three ways, in this priority order:
|
||||||
|
|
||||||
1. **Previously loaded subtitle** - if you've loaded a subtitle for a video before, it's remembered and reloaded automatically.
|
1. **Previously loaded subtitle** - if you've loaded a subtitle for a video before, it's remembered and reloaded automatically.
|
||||||
2. **Sidecar files** - `.srt` or `.vtt` files sitting next to the video with a matching filename (including language variants like `video.en.srt` or `video.french.srt`).
|
2. **Sidecar files** - `.srt` or `.vtt` files sitting next to the video with a matching filename (including language variants like `video.en.srt` or `video.french.srt`).
|
||||||
3. **Embedded tracks** - subtitle tracks inside the video container itself (common in MKV files), extracted on demand using ffmpeg.
|
3. **Embedded tracks** - subtitle tracks inside the video container itself (common in MKV files), extracted on demand using ffmpeg.
|
||||||
|
|
||||||
**📋 Subtitle menu.** A CC button opens a menu listing all available subtitles - external files, embedded tracks, a "load from file" option for picking any subtitle file on your system, and a disable option.
|
**Subtitle menu.** A CC button opens a menu listing all available subtitles - external files, embedded tracks, a "load from file" option for picking any subtitle file on your system, and a disable option.
|
||||||
|
|
||||||
**🌍 Language detection.** Sidecar subtitles with language suffixes are automatically labeled (English, French, German, Spanish, Italian, Portuguese, Russian, Japanese, Korean, Chinese, Arabic, Hindi, Dutch, Swedish, Polish). English subtitles are sorted first.
|
**Language detection.** Sidecar subtitles with language suffixes are automatically labeled (English, French, German, Spanish, Italian, Portuguese, Russian, Japanese, Korean, Chinese, Arabic, Hindi, Dutch, Swedish, Polish). English subtitles are sorted first.
|
||||||
|
|
||||||
**📌 Persistent selection.** Once a subtitle is loaded for a video, it's converted to VTT format, stored locally, and automatically reloaded every time you watch that video.
|
**Persistent selection.** Once a subtitle is loaded for a video, it's converted to VTT format, stored locally, and automatically reloaded every time you watch that video.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -154,11 +154,11 @@ Press **?** at any time to open a help dialog listing all available shortcuts.
|
|||||||
|
|
||||||
**Per-video notes.** A text area in the dock panel lets you write notes for each video. Timestamps, reminders, key takeaways, questions - whatever you need. Notes auto-save as you type.
|
**Per-video notes.** A text area in the dock panel lets you write notes for each video. Timestamps, reminders, key takeaways, questions - whatever you need. Notes auto-save as you type.
|
||||||
|
|
||||||
**⏱️ Timestamp insertion.** Click the clock button in the notes header to insert the current video timestamp at your cursor position in `[M:SS]` format. Useful for marking specific moments while you watch.
|
**Timestamp insertion.** Click the clock button in the notes header to insert the current video timestamp at your cursor position in `[M:SS]` format. Useful for marking specific moments while you watch.
|
||||||
|
|
||||||
**👁️ Visible in the playlist.** Videos with notes show a small indicator in the playlist, so you can quickly spot which videos you've annotated.
|
**Visible in the playlist.** Videos with notes show a small indicator in the playlist, so you can quickly spot which videos you've annotated.
|
||||||
|
|
||||||
**🛡️ Preserved on reset.** Resetting watch progress never touches your notes.
|
**Preserved on reset.** Resetting watch progress never touches your notes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -166,13 +166,13 @@ Press **?** at any time to open a help dialog listing all available shortcuts.
|
|||||||
|
|
||||||
**Detailed metadata panel.** The info panel shows everything about the current video and folder:
|
**Detailed metadata panel.** The info panel shows everything about the current video and folder:
|
||||||
|
|
||||||
- 📁 **Folder info** - path, structure (flat or subfolder), next unfinished video.
|
- **Folder info** - path, structure (flat or subfolder), next unfinished video.
|
||||||
- 🎬 **Video info** - codec, resolution, frame rate, pixel format, bitrate.
|
- **Video info** - codec, resolution, frame rate, pixel format, bitrate.
|
||||||
- 🔈 **Audio info** - codec, channels, sample rate, bitrate.
|
- **Audio info** - codec, channels, sample rate, bitrate.
|
||||||
- 💬 **Subtitle info** - count and details of embedded tracks, or external file status.
|
- **Subtitle info** - count and details of embedded tracks, or external file status.
|
||||||
- 📄 **File info** - extension, file size, modification date, containing folder.
|
- **File info** - extension, file size, modification date, containing folder.
|
||||||
- ⏳ **Progress stats** - finished count, remaining count, estimated time remaining.
|
- **Progress stats** - finished count, remaining count, estimated time remaining.
|
||||||
- 📊 **Per-subfolder breakdown** - completion counts for each top-level subdirectory.
|
- **Per-subfolder breakdown** - completion counts for each top-level subdirectory.
|
||||||
|
|
||||||
All of this requires ffprobe, which TutorialVault will automatically download if it's not already available on your system.
|
All of this requires ffprobe, which TutorialVault will automatically download if it's not already available on your system.
|
||||||
|
|
||||||
@@ -180,23 +180,23 @@ All of this requires ffprobe, which TutorialVault will automatically download if
|
|||||||
|
|
||||||
### 🖥️ Interface
|
### 🖥️ Interface
|
||||||
|
|
||||||
**🪟 Custom titlebar.** The app uses a custom-drawn titlebar with minimize, maximize, and close buttons, replacing the default OS chrome. The titlebar doubles as a drag region for moving the window and integrates the toolbar controls directly.
|
**Custom titlebar.** The app uses a custom-drawn titlebar with minimize, maximize, and close buttons, replacing the default OS chrome. The titlebar doubles as a drag region for moving the window and integrates the toolbar controls directly.
|
||||||
|
|
||||||
**↔️ Resizable panels.** Drag the divider between the video area and the playlist to adjust the split. Drag the divider between the notes and info panels too. Both ratios are saved. Dividers also support keyboard adjustment with arrow keys.
|
**Resizable panels.** Drag the divider between the video area and the playlist to adjust the split. Drag the divider between the notes and info panels too. Both ratios are saved. Dividers also support keyboard adjustment with arrow keys.
|
||||||
|
|
||||||
**📂 Collapsible dock panes.** Click the Notes or Info panel header to collapse it, freeing up vertical space for the other pane. A chevron icon indicates the current state. Collapsed state is saved and restored between sessions.
|
**Collapsible dock panes.** Click the Notes or Info panel header to collapse it, freeing up vertical space for the other pane. A chevron icon indicates the current state. Collapsed state is saved and restored between sessions.
|
||||||
|
|
||||||
**🔎 UI zoom.** Scale the entire interface from 75% to 200% with the zoom controls in the top bar. Useful for high-DPI displays or when you want more real estate for the video.
|
**UI zoom.** Scale the entire interface from 75% to 200% with the zoom controls in the top bar. Useful for high-DPI displays or when you want more real estate for the video.
|
||||||
|
|
||||||
**📌 Always on top.** A toggle to keep the window above all others. Handy when following along with a tutorial in another application.
|
**Always on top.** A toggle to keep the window above all others. Handy when following along with a tutorial in another application.
|
||||||
|
|
||||||
**⏭️ Autoplay.** When enabled, the next video starts playing automatically when the current one ends. Saved per folder.
|
**Autoplay.** When enabled, the next video starts playing automatically when the current one ends. Saved per folder.
|
||||||
|
|
||||||
**🎨 Custom scrollbar.** The playlist has a custom scrollbar that auto-hides when not in use, with fade indicators at the edges when there's more content to scroll.
|
**Custom scrollbar.** The playlist has a custom scrollbar that auto-hides when not in use, with fade indicators at the edges when there's more content to scroll.
|
||||||
|
|
||||||
**💡 Tooltips.** Hover over any control for a two-line tooltip explaining what it does. Tooltips are zoom-aware and stay within the viewport.
|
**Tooltips.** Hover over any control for a two-line tooltip explaining what it does. Tooltips are zoom-aware and stay within the viewport.
|
||||||
|
|
||||||
**🔔 Toast notifications.** Brief confirmations appear at the bottom of the screen for actions like loading folders, resetting progress, and loading subtitles.
|
**Toast notifications.** Brief confirmations appear at the bottom of the screen for actions like loading folders, resetting progress, and loading subtitles.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -204,19 +204,19 @@ 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.
|
||||||
|
|
||||||
**⌨️ Full keyboard navigation.** Every control is reachable and operable via keyboard. Menus support arrow key navigation, Escape to close, and Enter/Space to activate. Dividers can be adjusted with arrow keys. The shortcut help dialog traps focus while open.
|
**Full keyboard navigation.** Every control is reachable and operable via keyboard. Menus support arrow key navigation, Escape to close, and Enter/Space to activate. Dividers can be adjusted with arrow keys. The shortcut help dialog traps focus while open.
|
||||||
|
|
||||||
**🎯 Touch targets.** All interactive elements meet the 44x44px minimum target size. Buttons, checkboxes, and menu items are sized for comfortable interaction on all input methods.
|
**Touch targets.** All interactive elements meet the 44x44px minimum target size. Buttons, checkboxes, and menu items are sized for comfortable interaction on all input methods.
|
||||||
|
|
||||||
**👁️ Focus indicators.** All focusable elements show a visible `focus-visible` ring (2px solid outline) when navigated via keyboard. Focus indicators use the accent color with sufficient contrast.
|
**Focus indicators.** All focusable elements show a visible `focus-visible` ring (2px solid outline) when navigated via keyboard. Focus indicators use the accent color with sufficient contrast.
|
||||||
|
|
||||||
**🎞️ Reduced motion.** The app respects `prefers-reduced-motion` at the OS level. When enabled, all animations and transitions are effectively disabled, ensuring the interface remains usable for people who are sensitive to motion.
|
**Reduced motion.** The app respects `prefers-reduced-motion` at the OS level. When enabled, all animations and transitions are effectively disabled, ensuring the interface remains usable for people who are sensitive to motion.
|
||||||
|
|
||||||
**🔲 Contrast.** Text and UI elements meet AAA contrast ratios (7:1 for normal text, 4.5:1 for large text) against the dark background surfaces.
|
**Contrast.** Text and UI elements meet AAA contrast ratios (7:1 for normal text, 4.5:1 for large text) against the dark background surfaces.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -224,9 +224,9 @@ TutorialVault targets WCAG 2.2 AAA compliance throughout the interface.
|
|||||||
|
|
||||||
**Fully portable.** Everything - preferences, library state, subtitles, ffmpeg binaries, even the WebView2 profile - lives in a `state/` directory next to the executable. Copy the folder to a USB drive and it works anywhere. No registry entries, no AppData folders, no hidden configuration in your home directory.
|
**Fully portable.** Everything - preferences, library state, subtitles, ffmpeg binaries, even the WebView2 profile - lives in a `state/` directory next to the executable. Copy the folder to a USB drive and it works anywhere. No registry entries, no AppData folders, no hidden configuration in your home directory.
|
||||||
|
|
||||||
**🛟 Crash-safe storage.** All state files are written atomically with backup rotation. If something goes wrong mid-write, the app falls back to the most recent valid backup. Nobody should lose their progress to a power outage.
|
**Crash-safe storage.** All state files are written atomically with backup rotation. If something goes wrong mid-write, the app falls back to the most recent valid backup. Nobody should lose their progress to a power outage.
|
||||||
|
|
||||||
**📦 No external dependencies at runtime.** ffmpeg and ffprobe are automatically downloaded on first launch if not found locally. Fonts are bundled. Everything the app needs, it carries with it.
|
**No external dependencies at runtime.** ffmpeg and ffprobe are automatically downloaded on first launch if not found locally. Fonts are bundled. Everything the app needs, it carries with it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -269,9 +269,9 @@ The built executable and all assets will be in `src-tauri/target/release/`.
|
|||||||
|
|
||||||
TutorialVault is a [Tauri v2](https://v2.tauri.app/) application with a Rust backend and a TypeScript frontend.
|
TutorialVault is a [Tauri v2](https://v2.tauri.app/) application with a Rust backend and a TypeScript frontend.
|
||||||
|
|
||||||
**⚙️ Backend (Rust):** Handles file scanning, fingerprinting, state persistence, ffmpeg integration, subtitle processing, and a custom HTTP protocol for serving video and subtitle files to the frontend.
|
**Backend (Rust):** Handles file scanning, fingerprinting, state persistence, ffmpeg integration, subtitle processing, and a custom HTTP protocol for serving video and subtitle files to the frontend.
|
||||||
|
|
||||||
**🖼️ 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
|
||||||
@@ -282,9 +282,9 @@ TutorialVault is a [Tauri v2](https://v2.tauri.app/) application with a Rust bac
|
|||||||
- `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,3 +295,5 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# 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