M002/S04: UX review fixes — round 1

- Move Video/Audio toggle to same row as Download button
- Auto-condense toggle to icon-only below 540px
- Move gear icon to right of Download button
- Fix file download URLs: normalize filenames to relative paths in progress hook
- Display filename with visible extension (truncate middle, preserve ext)
- Remove border/box from dark mode toggle — glyph only
- Fix light/dark theme fonts: use monospace display font across all themes
This commit is contained in:
xpltd 2026-03-18 23:01:36 -05:00
parent 9b62d50461
commit fd25ea7d05
7 changed files with 132 additions and 76 deletions

View file

@ -0,0 +1,23 @@
# S04: UX Review + Live Tweaks
**Goal:** Walk through the entire app as a user, identify UX issues, and fix them in real time. This is a guided review session — the user drives the walkthrough and calls out issues, the agent fixes them immediately.
**Demo:** All issues identified during the walkthrough are resolved. App feels polished for a v1.0 release.
## Approach
1. Start backend + frontend dev servers
2. Walk through every user flow in the browser at desktop and mobile viewports
3. User identifies issues — agent fixes each one before moving on
4. Run tests after all fixes to confirm no regressions
5. Commit
## Verification
- `cd frontend && npx vitest run` — all tests pass
- `cd backend && source .venv/Scripts/activate && python -m pytest tests/ -q -m "not integration"` — no regressions
- Browser: all flows verified during the walkthrough
## Tasks
- [ ] **T01: Live UX review and fixes** `est:variable`
- Iterative — tasks emerge from the walkthrough

View file

@ -203,6 +203,18 @@ class DownloadService:
try: try:
event = ProgressEvent.from_yt_dlp(job_id, d) event = ProgressEvent.from_yt_dlp(job_id, d)
# Normalize filename to be relative to the output directory
# so the frontend can construct download URLs correctly.
if event.filename:
from pathlib import PurePosixPath, Path
abs_path = Path(event.filename).resolve()
out_dir = Path(self._config.downloads.output_dir).resolve()
try:
event.filename = str(abs_path.relative_to(out_dir))
except ValueError:
# Not under output_dir — use basename as fallback
event.filename = abs_path.name
# Always publish to SSE broker (cheap, in-memory) # Always publish to SSE broker (cheap, in-memory)
self._broker.publish(session_id, event) self._broker.publish(session_id, event)

View file

@ -39,16 +39,14 @@ const theme = useThemeStore()
height: 40px; height: 40px;
background: transparent; background: transparent;
color: var(--color-text-muted); color: var(--color-text-muted);
border: 1px solid var(--color-border); border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
transition: all var(--transition-normal); transition: color var(--transition-normal);
padding: 0; padding: 0;
} }
.dark-mode-toggle:hover { .dark-mode-toggle:hover {
color: var(--color-accent); color: var(--color-accent);
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
} }
</style> </style>

View file

@ -55,7 +55,17 @@ const sortedJobs = computed<Job[]>(() => {
function displayName(job: Job): string { function displayName(job: Job): string {
if (job.filename) { if (job.filename) {
const parts = job.filename.replace(/\\/g, '/').split('/') const parts = job.filename.replace(/\\/g, '/').split('/')
return parts[parts.length - 1] const name = parts[parts.length - 1]
// Ensure extension is visible: if name is long, truncate the middle
if (name.length > 60) {
const ext = name.lastIndexOf('.')
if (ext > 0) {
const extension = name.slice(ext)
const base = name.slice(0, 55 - extension.length)
return `${base}${extension}`
}
}
return name
} }
try { try {
const u = new URL(job.url) const u = new URL(job.url)
@ -92,11 +102,13 @@ function isCompleted(job: Job): boolean {
return job.status === 'completed' return job.status === 'completed'
} }
// File download URL // File download URL filename is relative to the output directory
// (normalized by the backend). May contain subdirectories for source templates.
function downloadUrl(job: Job): string { function downloadUrl(job: Job): string {
if (!job.filename) return '#' if (!job.filename) return '#'
const name = job.filename.replace(/\\/g, '/').split('/').pop() || '' const normalized = job.filename.replace(/\\/g, '/')
return `/api/downloads/${encodeURIComponent(name)}` // Encode each path segment separately to preserve directory structure
return `/api/downloads/${normalized.split('/').map(encodeURIComponent).join('/')}`
} }
// Copy download link to clipboard // Copy download link to clipboard

View file

@ -84,16 +84,40 @@ function toggleOptions(): void {
<template> <template>
<div class="url-input"> <div class="url-input">
<div class="input-row"> <!-- URL field -->
<input <input
v-model="url" v-model="url"
type="url" type="url"
placeholder="Paste a URL to download…" placeholder="Paste a URL to download…"
class="url-field" class="url-field"
@paste="handlePaste" @paste="handlePaste"
@keydown.enter="submitDownload" @keydown.enter="submitDownload"
:disabled="isExtracting || store.isSubmitting" :disabled="isExtracting || store.isSubmitting"
/> />
<!-- Action row: media toggle, download button, gear -->
<div class="action-row">
<div class="media-toggle">
<button
class="toggle-pill"
:class="{ active: mediaType === 'video' }"
@click="mediaType = 'video'"
:title="'Video'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
<span class="toggle-label">Video</span>
</button>
<button
class="toggle-pill"
:class="{ active: mediaType === 'audio' }"
@click="mediaType = 'audio'"
:title="'Audio'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<span class="toggle-label">Audio</span>
</button>
</div>
<button <button
class="btn-download" class="btn-download"
@click="submitDownload" @click="submitDownload"
@ -101,28 +125,6 @@ function toggleOptions(): void {
> >
{{ store.isSubmitting ? 'Submitting…' : 'Download' }} {{ store.isSubmitting ? 'Submitting…' : 'Download' }}
</button> </button>
</div>
<!-- Controls row: media type toggle + options gear -->
<div class="controls-row">
<div class="media-toggle">
<button
class="toggle-pill"
:class="{ active: mediaType === 'video' }"
@click="mediaType = 'video'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
Video
</button>
<button
class="toggle-pill"
:class="{ active: mediaType === 'audio' }"
@click="mediaType = 'audio'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
Audio
</button>
</div>
<button <button
class="btn-options" class="btn-options"
@ -172,38 +174,15 @@ function toggleOptions(): void {
width: 100%; width: 100%;
} }
.input-row {
display: flex;
gap: var(--space-sm);
}
.url-field { .url-field {
flex: 1; width: 100%;
font-size: var(--font-size-base); font-size: var(--font-size-base);
} }
.btn-download { /* Action row: toggle | download | gear */
white-space: nowrap; .action-row {
padding: var(--space-sm) var(--space-lg);
font-weight: 600;
background: var(--color-accent);
color: var(--color-bg);
}
.btn-download:hover:not(:disabled) {
background: var(--color-accent-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Controls row */
.controls-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--space-sm); gap: var(--space-sm);
} }
@ -213,6 +192,7 @@ button:disabled {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow: hidden; overflow: hidden;
flex-shrink: 0;
} }
.toggle-pill { .toggle-pill {
@ -225,7 +205,7 @@ button:disabled {
border: none; border: none;
border-radius: 0; border-radius: 0;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
min-height: 32px; min-height: 38px;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
@ -243,13 +223,37 @@ button:disabled {
color: var(--color-accent); color: var(--color-accent);
} }
.toggle-label {
/* Visible by default, hidden at narrow widths */
}
.btn-download {
flex: 1;
white-space: nowrap;
padding: var(--space-sm) var(--space-lg);
font-weight: 600;
min-height: 38px;
background: var(--color-accent);
color: var(--color-bg);
}
.btn-download:hover:not(:disabled) {
background: var(--color-accent-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-options { .btn-options {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 38px;
height: 32px; height: 38px;
padding: 0; padding: 0;
flex-shrink: 0;
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text-muted); color: var(--color-text-muted);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -327,14 +331,21 @@ button:disabled {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
/* Mobile: stack vertically */ /* Narrow viewports: hide toggle labels, keep icons only */
@media (max-width: 767px) { @media (max-width: 540px) {
.input-row { .toggle-label {
flex-direction: column; display: none;
} }
.toggle-pill {
padding: var(--space-xs) var(--space-sm);
}
}
/* Mobile: stack URL field above action row */
@media (max-width: 767px) {
.btn-download { .btn-download {
width: 100%; padding: var(--space-sm) var(--space-md);
} }
} }
</style> </style>

View file

@ -42,7 +42,7 @@
/* Typography /* Typography
* System fonts for everything clean and fast. * System fonts for everything clean and fast.
*/ */
--font-display: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font-display: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
/* Effects /* Effects
* All effects disabled for a clean look. * All effects disabled for a clean look.

View file

@ -17,7 +17,7 @@
--color-warning: #d97706; --color-warning: #d97706;
--color-error: #dc2626; --color-error: #dc2626;
--font-display: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font-display: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
--effect-scanlines: none; --effect-scanlines: none;
--effect-grid: none; --effect-grid: none;