D2: JobErrorLine component + home/jobs page integration

- Add src/lib/components/JobErrorLine.svelte: reusable error banner with
  icon, truncated message, accent retry and danger delete buttons; Svelte 5
  runes; WCAG AA (role=alert/status, aria-busy, aria-label, focus ring)
- Integrate into src/routes/+page.svelte: replace .recent-err-line div with
  JobErrorLine; add handleRetry function updating jobs array in-place
- Integrate into src/routes/jobs/+page.svelte: remove inline .job-err-inline
  and .row-retry-slot; add JobErrorLine inside .job-info for failed/cancelled;
  refactor retryJobById/deleteJobById with clean (jobId: string) signatures;
  done-job delete preserved in row-actions
- Add src/tests/job-error-line.test.ts: 8 component rendering tests via
  @testing-library/svelte + happy-dom covering message, buttons, callbacks,
  fallback text, and ARIA roles
- Install @testing-library/svelte@5 and happy-dom as devDeps for component
  test support; update vitest.config.ts with svelte plugin, happy-dom env
  match glob, and browser resolve condition for Svelte 5 client build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-07-17 08:19:22 +00:00
co-authored by Claude Sonnet 4.6
parent 0810c223e1
commit dd438d2a0b
7 changed files with 585 additions and 69 deletions
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import type { Job } from '$lib/types.js';
interface Props {
job: Job;
accentColor: string;
onRetry: (jobId: string) => Promise<void>;
onDelete?: (jobId: string) => Promise<void>;
}
let { job, accentColor, onRetry, onDelete }: Props = $props();
let retrying = $state(false);
const errorText = $derived(job.error ?? 'Transcription failed');
const isFailed = $derived(job.status === 'failed');
async function handleRetry(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
retrying = true;
try {
await onRetry(job.id);
} finally {
retrying = false;
}
}
async function handleDelete(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (onDelete) await onDelete(job.id);
}
</script>
<div
class="job-error-line"
role={isFailed ? 'alert' : 'status'}
style="--jel-accent: {accentColor}"
>
<svg
class="err-icon"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.3" />
<path d="M8 5v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" />
</svg>
<span class="err-msg" title={errorText}>{errorText}</span>
<div class="err-actions">
<button
class="err-btn retry-btn"
onclick={handleRetry}
aria-label="Retry"
aria-busy={retrying ? 'true' : undefined}
disabled={retrying}
>{retrying ? '…' : '↺'}</button>
{#if onDelete && !retrying}
<button
class="err-btn delete-btn"
onclick={handleDelete}
aria-label="Delete"
>✕</button>
{/if}
</div>
</div>
<style>
.job-error-line {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.err-icon {
flex-shrink: 0;
color: var(--error-text, #ff8a8a);
border-radius: 3px;
}
.err-msg {
flex: 0 1 auto;
min-width: 0;
font-size: 11.5px;
color: var(--error-text, #ff8a8a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-mono, 'JetBrains Mono', 'SF Mono', ui-monospace, monospace);
}
.err-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.err-btn {
width: 24px;
height: 24px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
transition: background 0.15s, border-color 0.15s;
padding: 0;
}
.err-btn:focus-visible {
outline: 2px solid var(--jel-accent, var(--accent, #cdf24e));
outline-offset: 2px;
}
.err-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.retry-btn {
background: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 10%, transparent);
border-color: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 40%, transparent);
color: var(--jel-accent, var(--accent, #cdf24e));
}
.retry-btn:hover:not(:disabled) {
background: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 20%, transparent);
}
.delete-btn {
background: rgba(255, 90, 90, 0.08);
border-color: rgba(255, 90, 90, 0.3);
color: var(--error-text, #ff8a8a);
}
.delete-btn:hover:not(:disabled) {
background: rgba(255, 90, 90, 0.2);
}
@media (prefers-reduced-motion: reduce) {
.err-btn {
transition: none;
}
}
</style>
+13 -12
View File
@@ -5,6 +5,7 @@
import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte';
import RecordButton from '$lib/components/RecordButton.svelte';
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
import { accent } from '$lib/accent.js';
const ACCENT = $derived($accent.value);
@@ -100,6 +101,17 @@
return parts.join(' · ');
}
async function handleRetry(jobId: string) {
const res = await fetch(`/api/jobs/${jobId}/retry`, { method: 'POST' });
if (res.ok) {
const updated = await fetch(`/api/jobs/${jobId}`);
if (updated.ok) {
const j = await updated.json();
jobs = jobs.map((x) => (x.id === jobId ? j : x));
}
}
}
// Decorative waveform bars for the drop zone (80 bars)
const DROPZONE_BARS = 80;
</script>
@@ -271,7 +283,7 @@
<div class="recent-title">{job.title || job.source}</div>
<div class="recent-meta mono">{jobMeta(job)}</div>
{#if job.status === 'failed' && job.error}
<div class="recent-err-line" title={job.error}>{job.error}</div>
<JobErrorLine {job} accentColor={ACCENT} onRetry={handleRetry} />
{/if}
</div>
{#if job.status !== 'done' && job.status !== 'failed' && job.status !== 'cancelled'}
@@ -568,17 +580,6 @@
color: var(--error-text);
}
.recent-err-line {
font-size: 11.5px;
color: var(--error-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 3px;
font-family: var(--font-mono);
max-width: 100%;
}
/* ── Utilities ─────────────────────────────────────────── */
.sr-only {
position: absolute;
+24 -54
View File
@@ -4,6 +4,7 @@
import { getDisplayJobProgress, getJobStatusColor, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte';
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
import { accent } from '$lib/accent.js';
const ACCENT = $derived($accent.value);
@@ -33,23 +34,27 @@
loading = false;
});
async function deleteJobById(jobId: string) {
const job = jobs.find((j) => j.id === jobId);
if (!job) return;
if (!confirm(`Delete "${job.title || job.id}"?`)) return;
const res = await fetch(`/api/jobs/${jobId}`, { method: 'DELETE' });
if (res.ok) jobs = jobs.filter((j) => j.id !== jobId);
}
async function deleteJob(e: MouseEvent, job: Job) {
e.preventDefault();
e.stopPropagation();
if (!confirm(`Delete "${job.title || job.id}"?`)) return;
const res = await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' });
if (res.ok) jobs = jobs.filter((j) => j.id !== job.id);
await deleteJobById(job.id);
}
async function retryJob(e: MouseEvent, job: Job) {
e.preventDefault();
e.stopPropagation();
const res = await fetch(`/api/jobs/${job.id}/retry`, { method: 'POST' });
async function retryJobById(jobId: string) {
const res = await fetch(`/api/jobs/${jobId}/retry`, { method: 'POST' });
if (res.ok) {
const updated = await fetch(`/api/jobs/${job.id}`);
const updated = await fetch(`/api/jobs/${jobId}`);
if (updated.ok) {
const j = await updated.json();
jobs = jobs.map((x) => (x.id === job.id ? j : x));
jobs = jobs.map((x) => (x.id === jobId ? j : x));
}
} else {
const body = await res.json().catch(() => ({}));
@@ -99,11 +104,15 @@
<span>·</span>
<span>{job.audioMode}</span>
{/if}
{#if ['failed', 'cancelled'].includes(job.status) && job.error}
<span>·</span>
<span class="job-err-inline" title={job.error}>{job.error}</span>
{/if}
</div>
{#if ['failed', 'cancelled'].includes(job.status) && job.error}
<JobErrorLine
{job}
accentColor={ACCENT}
onRetry={retryJobById}
onDelete={deleteJobById}
/>
{/if}
</div>
{#if !isTerminalJobStatus(job.status)}
@@ -120,27 +129,11 @@
<div class="job-pct mono" style="color: {ACCENT}">
{getDisplayJobProgress(job, { hasTranscript: Boolean(job.segmentsJson) })}%
</div>
{:else}
<div class="job-pct mono" style="color: {getJobStatusColor(job.status)}">
{getJobStatusLabel(job.status)}
</div>
{/if}
<!-- Retry slot (always visible) -->
{#if ['failed', 'cancelled'].includes(job.status)}
<div class="row-retry-slot">
<button
class="row-btn accent"
onclick={(e) => retryJob(e, job)}
aria-label="Retry"
title="Retry"
>↺</button>
</div>
{/if}
<!-- Row actions (hover-revealed, delete only) -->
<!-- Row actions (hover-revealed, delete for done jobs) -->
<div class="row-actions">
{#if isTerminalJobStatus(job.status)}
{#if job.status === 'done'}
<button
class="row-btn danger"
onclick={(e) => deleteJob(e, job)}
@@ -256,21 +249,6 @@
opacity: 1;
}
.row-retry-slot {
flex-shrink: 0;
}
.job-err-inline {
color: var(--error-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
max-width: 160px;
display: inline-block;
vertical-align: bottom;
}
.job-row[data-status="failed"] .job-name {
color: var(--error-text);
}
@@ -296,14 +274,6 @@
.row-btn.danger:hover {
background: rgba(255, 90, 90, 0.2);
}
.row-btn.accent {
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
background: color-mix(in oklab, var(--accent) 10%, transparent);
color: var(--accent);
}
.row-btn.accent:hover {
background: color-mix(in oklab, var(--accent) 20%, transparent);
}
@media (max-width: 768px) {
.page {
+103
View File
@@ -0,0 +1,103 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
import type { Job } from '$lib/types.js';
function makeJob(overrides: Partial<Job> = {}): Job {
return {
id: 'job-1',
status: 'failed',
title: 'Test Job',
source: 'test.mp3',
audioMode: 'auto',
meanVolume: null,
whisperJobId: null,
progress: 0,
outputDir: null,
segmentsJson: null,
error: 'transcription timeout',
retryCount: 0,
nextRetryAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides
};
}
describe('JobErrorLine component', () => {
it('renders error message for failed job', () => {
const { getByText } = render(JobErrorLine, {
job: makeJob(),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByText('transcription timeout')).toBeTruthy();
});
it('shows retry button labeled ↺', () => {
const { getByRole } = render(JobErrorLine, {
job: makeJob(),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByRole('button', { name: 'Retry' })).toBeTruthy();
});
it('calls onRetry with job id when retry button clicked', async () => {
const onRetry = vi.fn().mockResolvedValue(undefined);
const { getByRole } = render(JobErrorLine, {
job: makeJob({ id: 'job-abc' }),
accentColor: '#cdf24e',
onRetry
});
await fireEvent.click(getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledWith('job-abc');
});
it('shows fallback text when job.error is null', () => {
const { getByText } = render(JobErrorLine, {
job: makeJob({ error: null }),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByText('Transcription failed')).toBeTruthy();
});
it('shows delete button when onDelete provided', () => {
const { getByRole } = render(JobErrorLine, {
job: makeJob(),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined),
onDelete: vi.fn().mockResolvedValue(undefined)
});
expect(getByRole('button', { name: 'Delete' })).toBeTruthy();
});
it('hides delete button when onDelete not provided', () => {
const { queryByRole } = render(JobErrorLine, {
job: makeJob(),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(queryByRole('button', { name: 'Delete' })).toBeNull();
});
it('container has role alert for failed jobs', () => {
const { getByRole } = render(JobErrorLine, {
job: makeJob({ status: 'failed' }),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByRole('alert')).toBeTruthy();
});
it('container has role status for cancelled jobs', () => {
const { getByRole } = render(JobErrorLine, {
job: makeJob({ status: 'cancelled' }),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByRole('status')).toBeTruthy();
});
});