fix: WCAG AAA contrast compliance, speed menu z-index, custom app icon
- Fix all text colors to meet WCAG 2.2 AAA 7:1 contrast ratios against dark backgrounds (--textMuted, --textDim, hover states across playlist, player, panels, tooltips) - Fix speed menu rendering behind seek bar by correcting z-index stacking context (.controls z-index:10, .miniCtl z-index:3, .seek z-index:2) - Replace default Tauri icons with custom TutorialVault icon across all required sizes (32-512px PNGs, ICO, ICNS, Windows Square logos) - Update README: Fraunces → Bricolage Grotesque font reference - Add collapsible dock pane persistence and keyboard-adjustable dividers
@@ -282,7 +282,7 @@ TutorialVault is a [Tauri v2](https://v2.tauri.app/) application with a Rust bac
|
||||
- `store.ts` -- Shared state, pure utilities, cross-module callback registry
|
||||
- `api.ts` -- Typed wrapper around Tauri's invoke API
|
||||
|
||||
**🎨 Design theme.** The interface uses a "Cold Open" dark theme built on cool slate backgrounds (`#0f1117` base) with a steel blue accent (`#88A4C4`). Typography uses [Fraunces](https://fonts.google.com/specimen/Fraunces) 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.
|
||||
|
||||
|
||||
10
package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"name": "tutorialvault",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/fraunces": "^5.2.9",
|
||||
"@fontsource/bricolage-grotesque": "^5.2.10",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
@@ -411,10 +411,10 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/fraunces": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/fraunces/-/fraunces-5.2.9.tgz",
|
||||
"integrity": "sha512-XDzuddBtoC7BZgZdBn6b7hsFZY2+V1hgN7yca5fBTKuHjb/lOd45a0Ji8dTUgFhPoL7RdGupo+bC2BFSt6UH8Q==",
|
||||
"node_modules/@fontsource/bricolage-grotesque": {
|
||||
"version": "5.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.2.10.tgz",
|
||||
"integrity": "sha512-V2xS+1P7C8IrSypXLUx/bLtX/LsTlYtV2k2CsU+S/0t8qepZ2hvKSlyJIx7Ub/iY8Bbnj+IjAuUF9nvFz+BbIg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/fraunces": "^5.2.9",
|
||||
"@fontsource/bricolage-grotesque": "^5.2.10",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 882 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 138 KiB |
@@ -3,9 +3,9 @@
|
||||
* Orchestrates all modules and holds cross-module callbacks.
|
||||
*/
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
import '@fontsource/fraunces/600.css';
|
||||
import '@fontsource/fraunces/700.css';
|
||||
import '@fontsource/fraunces/800.css';
|
||||
import '@fontsource/bricolage-grotesque/500.css';
|
||||
import '@fontsource/bricolage-grotesque/600.css';
|
||||
import '@fontsource/bricolage-grotesque/700.css';
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/600.css';
|
||||
@@ -171,7 +171,7 @@ async function tick(): Promise<void> {
|
||||
try { await api.tickProgress(currentIndex, t, d, playing); } catch (_) {}
|
||||
}
|
||||
|
||||
if (now % 3000 < 1000) {
|
||||
if (now % 3000 < 1000 && !suppressTick) {
|
||||
try {
|
||||
const info = await api.getLibrary();
|
||||
if (info && info.ok) {
|
||||
|
||||
@@ -155,6 +155,12 @@ export function initPlayer(): void {
|
||||
if (d > 0) seek.value = String(Math.round((t / d) * 1000));
|
||||
updateSeekFill();
|
||||
updateTimeReadout();
|
||||
// Update active row's mini progress bar in real time
|
||||
const activeRow = document.querySelector('.row.active');
|
||||
if (activeRow && d > 0) {
|
||||
const bar = activeRow.querySelector('.rowProgress') as HTMLElement;
|
||||
if (bar) bar.style.width = clamp((t / d) * 100, 0, 100) + '%';
|
||||
}
|
||||
}
|
||||
cb.updateInfoPanel?.();
|
||||
});
|
||||
@@ -434,8 +440,8 @@ export function cycleSpeed(delta: number): void {
|
||||
cb.updateInfoPanel?.();
|
||||
cb.notify?.(`Speed: ${r}x`);
|
||||
setSuppressTick(true);
|
||||
if (library) { api.setFolderRate(r).catch(() => {}); }
|
||||
setSuppressTick(false);
|
||||
if (library) { api.setFolderRate(r).catch(() => {}).finally(() => setSuppressTick(false)); }
|
||||
else { setSuppressTick(false); }
|
||||
}
|
||||
|
||||
/** Load a video by index and handle the onloadedmetadata callback. */
|
||||
@@ -530,7 +536,7 @@ export function buildSpeedMenu(active: number): void {
|
||||
left.appendChild(dot); left.appendChild(txt);
|
||||
row.appendChild(left);
|
||||
|
||||
row.onclick = async () => {
|
||||
row.onclick = () => {
|
||||
closeSpeedMenu();
|
||||
const r = clamp(Number(s), 0.25, 3);
|
||||
player.playbackRate = r;
|
||||
@@ -540,8 +546,8 @@ export function buildSpeedMenu(active: number): void {
|
||||
buildSpeedMenu(r);
|
||||
cb.updateInfoPanel?.();
|
||||
setSuppressTick(true);
|
||||
if (library) { try { await api.setFolderRate(r); } catch (_) {} }
|
||||
setSuppressTick(false);
|
||||
if (library) { api.setFolderRate(r).catch(() => {}).finally(() => setSuppressTick(false)); }
|
||||
else { setSuppressTick(false); }
|
||||
};
|
||||
speedMenu.appendChild(row);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
.toastMsg{
|
||||
font-size:13px;
|
||||
font-weight:600;
|
||||
font-weight:500;
|
||||
letter-spacing:0;
|
||||
color:var(--text);
|
||||
}
|
||||
@@ -82,17 +82,17 @@
|
||||
.tooltip::after{display:none;}
|
||||
.tooltip-title{
|
||||
font-family:var(--brand);
|
||||
font-weight:700;
|
||||
font-weight:600;
|
||||
font-size:14px;
|
||||
margin-bottom:6px;
|
||||
letter-spacing:-.01em;
|
||||
letter-spacing:-.02em;
|
||||
color:rgba(235,240,252,.95);
|
||||
}
|
||||
.tooltip-desc{
|
||||
font-family:var(--sans);
|
||||
font-size:12px;
|
||||
font-weight:500;
|
||||
color:rgba(170,182,210,.82);
|
||||
font-weight:400;
|
||||
color:rgba(170,182,210,.86);
|
||||
line-height:1.55;
|
||||
letter-spacing:0;
|
||||
position:relative;
|
||||
@@ -115,7 +115,7 @@
|
||||
}
|
||||
.subsMenu.show{display:block; opacity:1; transform:translateX(-50%) scale(1);}
|
||||
.subsMenuHeader{padding:6px 12px 4px; font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.08em; color:var(--textDim); transition:color .2s ease;}
|
||||
.subsMenuItem{padding:10px 12px; border-radius:var(--r3); cursor:pointer; user-select:none; font-size:12px; font-weight:600; color:var(--text); letter-spacing:0; display:flex; align-items:center; gap:10px; transition:all .2s var(--ease-bounce);}
|
||||
.subsMenuItem{padding:10px 12px; border-radius:var(--r3); cursor:pointer; user-select:none; font-size:12px; font-weight:500; color:var(--text); letter-spacing:0; display:flex; align-items:center; gap:10px; transition:all .2s var(--ease-bounce);}
|
||||
.subsMenuItem:hover{background:var(--surfaceHover); padding-left:16px;}
|
||||
.subsMenuItem:active{transform:scale(.97); transition-duration:.08s;}
|
||||
.subsMenuItem .fa{font-size:13px; color:var(--iconStrong)!important; opacity:.85; width:18px; text-align:center; transition:transform .2s var(--ease-bounce), opacity .15s ease;}
|
||||
@@ -175,11 +175,12 @@
|
||||
}
|
||||
.shortcutHelpTitle{
|
||||
font-family:var(--brand);
|
||||
font-weight:700;
|
||||
font-size:16px;
|
||||
font-weight:600;
|
||||
font-size:17px;
|
||||
line-height:1.2;
|
||||
margin:0 0 20px;
|
||||
color:rgba(235,240,252,.95);
|
||||
letter-spacing:-.01em;
|
||||
letter-spacing:-.02em;
|
||||
}
|
||||
.shortcutGrid{
|
||||
display:grid;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
:root{
|
||||
--zoom:1;
|
||||
/* Type scale — Minor Third (1.2) from 13px base: 10 · 11 · 12 · 13 · 15 · 17 · 19 */
|
||||
/* Base backgrounds — cool dark slate, lighter */
|
||||
--bg0:#0f1117; --bg1:#151821;
|
||||
/* Strokes — cool, very subtle */
|
||||
@@ -9,8 +10,8 @@
|
||||
--strokeStrong:rgba(140,160,210,.14);
|
||||
/* Text — cool white hierarchy */
|
||||
--text:rgba(218,225,240,.90);
|
||||
--textMuted:rgba(160,174,204,.72);
|
||||
--textDim:rgba(158,174,208,.68);
|
||||
--textMuted:rgba(185,196,222,.86);
|
||||
--textDim:rgba(172,184,214,.88);
|
||||
/* Surfaces — cool-tinted, subtle fills */
|
||||
--surface-0:rgba(140,165,220,.04);
|
||||
--surface:rgba(140,165,220,.06);
|
||||
@@ -34,7 +35,7 @@
|
||||
/* Fonts */
|
||||
--mono:"Space Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--sans:"Inter", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
--brand:"Fraunces", Georgia, "Times New Roman", serif;
|
||||
--brand:"Bricolage Grotesque", "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
/* Icons — cool */
|
||||
--icon:rgba(160,175,210,.62);
|
||||
--iconStrong:rgba(200,212,238,.75);
|
||||
@@ -58,7 +59,7 @@ body{
|
||||
margin:0; padding:0; font-family:var(--sans); color:var(--text); overflow:hidden;
|
||||
width:100vw; height:100vh;
|
||||
font-weight:400;
|
||||
line-height:1.4;
|
||||
line-height:1.45;
|
||||
background:
|
||||
radial-gradient(900px 600px at 8% 3%, rgba(100,140,210,.04), transparent 55%),
|
||||
radial-gradient(600px 400px at 92% 97%, rgba(90,120,180,.03), transparent 60%),
|
||||
@@ -197,9 +198,9 @@ body{
|
||||
.brandText{min-width:0; position:relative; z-index:1;}
|
||||
.appName{
|
||||
font-family:var(--brand);
|
||||
font-weight:800;
|
||||
font-size:18px;
|
||||
line-height:1.02;
|
||||
font-weight:700;
|
||||
font-size:19px;
|
||||
line-height:1.1;
|
||||
letter-spacing:-.02em;
|
||||
margin:0; padding:0;
|
||||
transition:text-shadow .3s var(--ease-spring), color .2s ease;
|
||||
@@ -218,7 +219,7 @@ body{
|
||||
transition:color .3s var(--ease-spring);
|
||||
}
|
||||
.brand:hover .tagline{
|
||||
color:rgba(180,192,218,.82);
|
||||
color:rgba(192,202,226,.90);
|
||||
}
|
||||
|
||||
.actions{
|
||||
@@ -429,7 +430,7 @@ body{
|
||||
.dropdownPortal::-webkit-scrollbar-thumb{background:rgba(140,160,210,.10); border-radius:999px;}
|
||||
.dropdownPortal::-webkit-scrollbar-button{width:0; height:0; display:none;}
|
||||
|
||||
.dropItem{display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:var(--r3); cursor:pointer; user-select:none; color:var(--text); font-weight:600; font-size:13px; letter-spacing:0; line-height:1.25; transition:all .2s var(--ease-bounce); position:relative;}
|
||||
.dropItem{display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:var(--r3); cursor:pointer; user-select:none; color:var(--text); font-weight:500; font-size:13px; letter-spacing:0; line-height:1.25; transition:all .2s var(--ease-bounce); position:relative;}
|
||||
.dropItem:hover{background:var(--surfaceHover); padding-left:16px; padding-right:36px;}
|
||||
.dropItem:active{transform:scale(.98); transition-duration:.08s;}
|
||||
.dropIcon{width:18px; height:18px; display:flex; align-items:center; justify-content:center; flex:0 0 auto; opacity:.9; transition:transform .25s var(--ease-bounce), opacity .15s ease;}
|
||||
@@ -446,7 +447,7 @@ body{
|
||||
|
||||
.seg{display:inline-flex; border:none; border-radius:var(--r2); overflow:hidden; background:var(--surface-2); transition:background .15s ease;}
|
||||
.seg:hover{background:var(--surface-3);}
|
||||
.seg .btn{border:none; box-shadow:none; border-radius:0; padding:8px 9px; background:transparent; font-weight:800; transform:none;}
|
||||
.seg .btn{border:none; box-shadow:none; border-radius:0; padding:8px 9px; background:transparent; font-weight:700; transform:none;}
|
||||
.seg .btn:hover{background:var(--surface-3); transform:none; box-shadow:none;}
|
||||
.seg .btn:active{background:var(--surface-4); transform:scale(.95);}
|
||||
.seg .mid{border-left:1px solid rgba(140,160,210,.06); border-right:1px solid rgba(140,160,210,.06); min-width:62px; font-variant-numeric:tabular-nums;}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
.panelHeader{padding:10px 12px 8px; border-bottom:1px solid rgba(140,160,210,.04); display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex:0 0 auto; min-width:0; background:rgba(140,165,220,.015); transition:background .3s ease;}
|
||||
.panelHeader:hover{background:rgba(140,165,220,.025);}
|
||||
.panelHeader::after{display:none;}
|
||||
.nowTitle{font-family:var(--brand); font-weight:700; font-size:14px; letter-spacing:-.01em; max-width:60ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .2s ease;}
|
||||
.nowTitle{font-family:var(--brand); font-weight:600; font-size:15px; letter-spacing:-.02em; line-height:1.2; max-width:60ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .2s ease;}
|
||||
.nowTitle:hover{color:rgba(235,240,252,.98);}
|
||||
.nowSub{margin-top:4px; color:var(--textMuted); font-size:11px; font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:80ch; transition:color .2s ease;}
|
||||
.nowSub{margin-top:3px; color:var(--textMuted); font-size:11px; font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:80ch; transition:color .2s ease;}
|
||||
|
||||
.dividerWrap{display:flex; align-items:stretch; justify-content:center;}
|
||||
.divider{width:10px; cursor:col-resize; position:relative; background:transparent; border:none;}
|
||||
@@ -25,11 +25,11 @@
|
||||
.dock{flex:1 1 auto; min-height:0; border-top:1px solid rgba(140,160,210,.04); display:grid; grid-template-columns:62% 10px 38%; background:rgba(0,0,0,.06); overflow:hidden;}
|
||||
.dockPane{min-height:0; display:flex; flex-direction:column; overflow:hidden;}
|
||||
.dockInner{padding:10px; min-height:0; display:flex; flex-direction:column; gap:8px; height:100%;}
|
||||
.dockHeader{padding:10px 12px 9px; border:none; border-radius:var(--r2); display:flex; align-items:center; justify-content:space-between; gap:8px; background:var(--surface-2); flex:0 0 auto; transition:background .2s ease;}
|
||||
.dockHeader{padding:10px 12px 9px; border:none; border-radius:var(--r2); display:flex; align-items:center; justify-content:space-between; gap:8px; background:var(--surface-2); flex:0 0 auto; min-height:47px; transition:background .2s ease;}
|
||||
.dockHeader:hover{background:var(--surface-3);}
|
||||
#notesHeader{border-bottom-right-radius:7px;}
|
||||
#infoHeader{border-bottom-left-radius:7px; margin-right:12px;}
|
||||
.dockTitle{font-family:var(--brand); font-weight:700; letter-spacing:0; font-size:13px; color:rgba(218,225,240,.92); display:flex; align-items:center; gap:10px; transition:color .2s ease;}
|
||||
.dockTitle{font-family:var(--brand); font-weight:600; letter-spacing:-.02em; font-size:15px; line-height:1.2; color:rgba(218,225,240,.92); display:flex; align-items:center; gap:10px; transition:color .2s ease;}
|
||||
.dockHeader:hover .dockTitle{color:rgba(235,240,252,.98);}
|
||||
.dockTitle .fa{color:var(--iconStrong)!important; opacity:.88; font-size:14px; transition:transform .3s var(--ease-bounce), opacity .2s ease;}
|
||||
.dockHeader:hover .dockTitle .fa{transform:scale(1.12) rotate(-5deg); opacity:1;}
|
||||
@@ -37,6 +37,7 @@
|
||||
.notesArea{flex:1 1 auto; min-height:0; overflow:hidden; position:relative;}
|
||||
.notesSaved{
|
||||
position:absolute;
|
||||
z-index:2;
|
||||
bottom:12px; right:12px;
|
||||
padding:6px 10px;
|
||||
border-radius:var(--r2);
|
||||
@@ -63,9 +64,20 @@
|
||||
60%{transform:scale(1.3) rotate(5deg);}
|
||||
100%{transform:scale(1) rotate(0);}
|
||||
}
|
||||
.notes{width:100%; height:100%; resize:none; border-radius:var(--r3); border:1px solid transparent; background:var(--surface-0); color:rgba(218,225,240,.90); padding:10px 12px; outline:none; font-family:var(--sans); font-size:13px; line-height:1.45; letter-spacing:0; box-shadow:var(--shadowInset); overflow:auto; scrollbar-width:thin; scrollbar-color:rgba(140,160,210,.10) transparent; transition:border-color .25s ease, box-shadow .25s ease, background .2s ease;}
|
||||
.notes:hover{background:rgba(140,165,220,.03);}
|
||||
.notes:focus{border-color:rgba(136,164,196,.25); box-shadow:var(--shadowInset), 0 0 0 2px rgba(136,164,196,.12);}
|
||||
/* Notes timestamp highlighting backdrop */
|
||||
.notesBackdrop{position:absolute; inset:0; padding:10px 12px; border:1px solid transparent; border-radius:var(--r3); background:var(--surface-0); box-shadow:var(--shadowInset); font-family:var(--sans); font-size:13px; line-height:1.45; letter-spacing:0; color:rgba(218,225,240,.90); white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere; overflow-y:scroll; scrollbar-width:thin; scrollbar-color:transparent transparent; pointer-events:none; transition:border-color .25s ease, box-shadow .25s ease, background .2s ease;}
|
||||
.notesBackdrop::-webkit-scrollbar{width:2px; height:2px;}
|
||||
.notesBackdrop::-webkit-scrollbar-track{background:transparent;}
|
||||
.notesBackdrop::-webkit-scrollbar-thumb{background:transparent;}
|
||||
.notesBackdrop::-webkit-scrollbar-button{width:0; height:0; display:none;}
|
||||
.notesBackdrop.hover{background:rgba(140,165,220,.03);}
|
||||
.notesBackdrop.focus{border-color:rgba(136,164,196,.25); box-shadow:var(--shadowInset), 0 0 0 2px rgba(136,164,196,.12);}
|
||||
.tsLink{color:rgba(136,164,196,.85); pointer-events:auto; cursor:pointer; transition:color .15s ease;}
|
||||
.notesBackdrop.hover .tsLink,.notesBackdrop.focus .tsLink{color:rgba(136,164,196,.95);}
|
||||
.notes{width:100%; height:100%; resize:none; border-radius:var(--r3); border:1px solid transparent; background:transparent; color:transparent; caret-color:rgba(218,225,240,.90); padding:10px 12px; outline:none; font-family:var(--sans); font-size:13px; line-height:1.45; letter-spacing:0; box-shadow:none; overflow:auto; scrollbar-width:thin; scrollbar-color:rgba(140,160,210,.10) transparent; position:relative; z-index:1;}
|
||||
.notes:hover{background:transparent;}
|
||||
.notes:focus{border-color:transparent; box-shadow:none;}
|
||||
.notes::selection{background:rgba(136,164,196,.30);}
|
||||
.notes::-webkit-scrollbar{width:2px; height:2px;}
|
||||
.notes::-webkit-scrollbar-track{background:transparent;}
|
||||
.notes::-webkit-scrollbar-thumb{background:rgba(140,160,210,.10); border-radius:0;}
|
||||
@@ -127,11 +139,11 @@ dl.kv dt,dl.kv dd{margin:0; padding:0;}
|
||||
white-space:nowrap;
|
||||
transition:color .2s ease;
|
||||
}
|
||||
.kv:hover .k{color:rgba(148,162,192,.50);}
|
||||
.kv:hover .k{color:var(--textDim);}
|
||||
.v{
|
||||
font-family:var(--brand);
|
||||
font-size:13px;
|
||||
font-weight:600;
|
||||
font-weight:500;
|
||||
color:var(--text);
|
||||
letter-spacing:-.01em;
|
||||
word-break:break-word;
|
||||
@@ -154,7 +166,7 @@ dl.kv dt,dl.kv dd{margin:0; padding:0;}
|
||||
margin:-2px 0;
|
||||
transition:background .2s ease, color .2s ease;
|
||||
}
|
||||
.kv:hover .v.mono{background:rgba(136,164,196,.09); color:rgba(170,185,215,.70);}
|
||||
.kv:hover .v.mono{background:rgba(136,164,196,.09); color:var(--textMuted);}
|
||||
|
||||
.dockDividerWrap{display:flex; align-items:stretch; justify-content:center;}
|
||||
.dockDivider{width:10px; cursor:col-resize; position:relative; background:transparent; border:none;}
|
||||
|
||||
@@ -72,7 +72,7 @@ video::cue{
|
||||
color:rgba(255,255,255,1)!important;
|
||||
}
|
||||
|
||||
.controls{display:flex; flex-direction:column; gap:10px; padding:12px; border-top:1px solid rgba(140,160,210,.04); flex:0 0 auto;}
|
||||
.controls{display:flex; flex-direction:column; gap:10px; padding:12px; border-top:1px solid rgba(140,160,210,.04); flex:0 0 auto; position:relative; z-index:10;}
|
||||
.controlsStrip{
|
||||
display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap;
|
||||
padding:5px 6px;
|
||||
@@ -151,7 +151,7 @@ video::cue{
|
||||
.seek::-moz-range-track{background:transparent; height:18px;}
|
||||
.seek::-moz-range-thumb{width:16px; height:16px; border-radius:999px; border:2px solid rgba(200,210,230,.28); background:rgba(220,228,240,.88); box-shadow:0 2px 6px rgba(0,0,0,.30); cursor:pointer;}
|
||||
|
||||
.miniCtl{display:flex; align-items:center; gap:8px; padding:6px 9px; border-radius:var(--r2); border:none; background:var(--surface-2); box-shadow:var(--shadow3); position:relative; transition:all .2s var(--ease-bounce);}
|
||||
.miniCtl{display:flex; align-items:center; gap:8px; padding:6px 9px; border-radius:var(--r2); border:none; background:var(--surface-2); box-shadow:var(--shadow3); position:relative; z-index:3; transition:all .2s var(--ease-bounce);}
|
||||
.miniCtl:hover{background:var(--surface-3); transform:translateY(-1px);}
|
||||
.miniCtl:active{transform:scale(.97) translateY(0); transition-duration:.08s;}
|
||||
.miniCtl .fa{font-size:14px; color:var(--iconStrong)!important; opacity:.95; flex:0 0 auto; transition:transform .2s var(--ease-bounce), opacity .15s ease;}
|
||||
@@ -234,7 +234,7 @@ video::cue{
|
||||
.progressPill{flex:0 0 auto; display:flex; align-items:center; gap:8px; padding:6px 10px; border-radius:var(--r2); border:none; background:var(--surface-2); box-shadow:var(--shadow3); min-width:220px; transition:background .2s ease;}
|
||||
.progressPill:hover{background:var(--surface-3);}
|
||||
.progressLabel{font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--textMuted); margin-right:2px; transition:color .2s ease;}
|
||||
.progressPill:hover .progressLabel{color:rgba(170,185,215,.70);}
|
||||
.progressPill:hover .progressLabel{color:var(--textMuted);}
|
||||
.progressBar{width:120px; height:8px; border-radius:999px; border:none; background:rgba(140,165,220,.04); overflow:hidden; transition:height .2s var(--ease-bounce);}
|
||||
.progressPill:hover .progressBar{height:10px;}
|
||||
.progressBar>div{height:100%; width:0%; background:rgba(136,164,196,.75); transition:width .4s var(--ease-spring);}
|
||||
|
||||
@@ -75,10 +75,10 @@
|
||||
.treeSvg circle{fill:rgba(200,212,238,.60); stroke:rgba(136,164,196,.18); stroke-width:1;}
|
||||
|
||||
.textWrap{min-width:0; flex:1 1 auto;}
|
||||
.name{font-size:13px; font-weight:700; letter-spacing:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .2s var(--ease-spring), text-shadow .2s ease, transform .2s var(--ease-bounce); transform:translateX(0);}
|
||||
.name{font-size:13px; font-weight:600; letter-spacing:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .2s var(--ease-spring), text-shadow .2s ease, transform .2s var(--ease-bounce); transform:translateX(0);}
|
||||
.row:hover .name{color:rgba(235,240,252,.96); text-shadow:0 0 20px rgba(136,164,196,.08); transform:translateX(4px);}
|
||||
.small{margin-top:4px; font-size:11px; color:var(--textMuted); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; transition:color .2s ease;}
|
||||
.row:hover .small{color:rgba(170,182,210,.75);}
|
||||
.row:hover .small{color:var(--textMuted);}
|
||||
|
||||
.tag{flex:0 0 auto; display:inline-flex; align-items:center; padding:5px 9px; border-radius:var(--r2); border:none; background:var(--surface-2); color:var(--textMuted); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; margin-top:2px; transition:all .2s var(--ease-bounce);}
|
||||
.row:hover .tag{transform:scale(1.05);}
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
.empty{padding:10px 12px; color:var(--textMuted); font-size:13px; line-height:1.4;}
|
||||
|
||||
.playlistHeader{font-family:var(--brand); font-weight:800; letter-spacing:-.01em; font-size:14px; cursor:help; display:flex; align-items:center; gap:10px; transition:color .2s ease;}
|
||||
.playlistHeader{font-family:var(--brand); font-weight:600; letter-spacing:-.02em; font-size:15px; line-height:1.2; cursor:help; display:flex; align-items:center; gap:10px; transition:color .2s ease;}
|
||||
.playlistHeader:hover{color:rgba(235,240,252,.98);}
|
||||
.playlistHeader .fa{color:var(--iconStrong)!important; opacity:.92; transition:transform .3s var(--ease-bounce), opacity .2s ease;}
|
||||
.playlistHeader:hover .fa{transform:rotate(-8deg) scale(1.12); opacity:1;}
|
||||
|
||||
77
src/ui.ts
@@ -27,6 +27,7 @@ let recentMenu: HTMLElement;
|
||||
let refreshBtn: HTMLElement;
|
||||
let resetProgBtn: HTMLElement;
|
||||
let notesBox: HTMLTextAreaElement;
|
||||
let notesBackdrop: HTMLElement;
|
||||
let notesSaved: HTMLElement;
|
||||
let nowTitle: HTMLElement;
|
||||
let nowSub: HTMLElement;
|
||||
@@ -93,6 +94,32 @@ export function initUI(): void {
|
||||
resetProgBtn = document.getElementById('resetProgBtn')!;
|
||||
notesBox = document.getElementById('notesBox') as HTMLTextAreaElement;
|
||||
notesSaved = document.getElementById('notesSaved')!;
|
||||
|
||||
// Create notes highlight backdrop for timestamp coloring
|
||||
notesBackdrop = document.createElement('div');
|
||||
notesBackdrop.className = 'notesBackdrop';
|
||||
notesBackdrop.setAttribute('aria-hidden', 'true');
|
||||
notesBox.parentElement!.insertBefore(notesBackdrop, notesBox);
|
||||
|
||||
// Scroll sync between textarea and backdrop
|
||||
notesBox.addEventListener('scroll', () => {
|
||||
notesBackdrop.scrollTop = notesBox.scrollTop;
|
||||
notesBackdrop.scrollLeft = notesBox.scrollLeft;
|
||||
});
|
||||
|
||||
// Forward hover/focus state to backdrop for visual effects
|
||||
notesBox.addEventListener('mouseenter', () => notesBackdrop.classList.add('hover'));
|
||||
notesBox.addEventListener('mouseleave', () => notesBackdrop.classList.remove('hover'));
|
||||
notesBox.addEventListener('focus', () => notesBackdrop.classList.add('focus'));
|
||||
notesBox.addEventListener('blur', () => notesBackdrop.classList.remove('focus'));
|
||||
|
||||
// Show pointer cursor when hovering over timestamps
|
||||
notesBox.addEventListener('mousemove', (e) => {
|
||||
notesBox.style.pointerEvents = 'none';
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
notesBox.style.pointerEvents = '';
|
||||
notesBox.style.cursor = (el && el.classList.contains('tsLink')) ? 'pointer' : '';
|
||||
});
|
||||
nowTitle = document.getElementById('nowTitle')!;
|
||||
nowSub = document.getElementById('nowSub')!;
|
||||
overallBar = document.getElementById('overallBar')!;
|
||||
@@ -348,6 +375,7 @@ export function initUI(): void {
|
||||
|
||||
// --- Notes ---
|
||||
notesBox.addEventListener('input', () => {
|
||||
updateNotesHighlight();
|
||||
if (!library) return;
|
||||
const it = currentItem();
|
||||
if (!it) return;
|
||||
@@ -385,6 +413,37 @@ export function initUI(): void {
|
||||
};
|
||||
}
|
||||
|
||||
// --- Clickable timestamps in notes ---
|
||||
notesBox.addEventListener('click', () => {
|
||||
const pos = notesBox.selectionStart;
|
||||
if (pos === null || pos === undefined) return;
|
||||
// Only act on single click without selection
|
||||
if (notesBox.selectionStart !== notesBox.selectionEnd) return;
|
||||
const text = notesBox.value;
|
||||
// Match [H:MM:SS] or [M:SS] patterns
|
||||
const re = /\[(\d{1,3}):(\d{2})(?::(\d{2}))?\]/g;
|
||||
let match;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (pos >= start && pos <= end) {
|
||||
let totalSeconds: number;
|
||||
if (match[3] !== undefined) {
|
||||
// [H:MM:SS]
|
||||
totalSeconds = parseInt(match[1], 10) * 3600 + parseInt(match[2], 10) * 60 + parseInt(match[3], 10);
|
||||
} else {
|
||||
// [M:SS]
|
||||
totalSeconds = parseInt(match[1], 10) * 60 + parseInt(match[2], 10);
|
||||
}
|
||||
if (player && Number.isFinite(player.duration) && player.duration > 0) {
|
||||
player.currentTime = clamp(totalSeconds, 0, player.duration);
|
||||
cb.notify?.(`Jumped to ${match[0]}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Collapsible dock panes ---
|
||||
const notesHeader = document.getElementById('notesHeader');
|
||||
const infoHeader = document.getElementById('infoHeader');
|
||||
@@ -578,11 +637,12 @@ export async function refreshCurrentVideoMeta(): Promise<void> {
|
||||
|
||||
export async function loadNoteForCurrent(): Promise<void> {
|
||||
const it = currentItem();
|
||||
if (!it) { notesBox.value = ''; return; }
|
||||
if (!it) { notesBox.value = ''; updateNotesHighlight(); return; }
|
||||
try {
|
||||
const res = await api.getNote(it.fid);
|
||||
notesBox.value = (res && res.ok) ? (res.note || '') : '';
|
||||
} catch (_) { notesBox.value = ''; }
|
||||
updateNotesHighlight();
|
||||
}
|
||||
|
||||
export function setOnTopChecked(v: boolean): void { onTopChk.checked = v; }
|
||||
@@ -726,6 +786,21 @@ function toggleDockPane(header: HTMLElement, prefKey: string): void {
|
||||
savePrefsPatch({ [prefKey]: isCollapsed });
|
||||
}
|
||||
|
||||
function updateNotesHighlight(): void {
|
||||
if (!notesBackdrop) return;
|
||||
const text = notesBox.value;
|
||||
const escaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const highlighted = escaped.replace(
|
||||
/\[(\d{1,3}:\d{2}(?::\d{2})?)\]/g,
|
||||
'<span class="tsLink">[$1]</span>'
|
||||
);
|
||||
// Trailing newline matches textarea's extra line space
|
||||
notesBackdrop.innerHTML = highlighted + '\n';
|
||||
}
|
||||
|
||||
function collapsePane(header: HTMLElement, collapsed: boolean): void {
|
||||
const pane = header.closest('.dockPane');
|
||||
if (!pane) return;
|
||||
|
||||