1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: non-blocking batch capture dialog, toasts on failure, fix /runs errors

CaptureDialog:
- Replace single textarea with multi-row inputs; + button adds rows
- Submit fires all pending rows in parallel, dialog stays open/usable
- Polling intervals live on a persistent ref (not cleared on close) so
  toasts fire even after the dialog is dismissed
- archiveId stored per item at submit time; page-refresh reconnect uses
  it.archiveId instead of the possibly-null prop
- Completed rows flash green then self-remove; failed rows show inline
  error + retry button
- Cancel becomes Close while jobs are in flight

ToastStack (new component):
- Fixed bottom-right overlay with spring-in animation
- Error toast: truncated locator, View error / Hide toggle expanding
  full error_text in a monospace pre block
- Auto-dismisses after 7 s; timer pauses while detail is expanded

RunsView:
- Failed rows are clickable and expand a full-width detail row showing
  error_summary in a scrollable monospace block

capture.rs (archivr-core):
- Staging dir is now "{millis}-{uuid}" — parallel captures in the same
  millisecond can no longer collide on temp paths
- create_archive_run moved before URL Content-Type probe so every
  attempt appears in /runs regardless of outcome
- Probe failures now call create_archive_run_item with source_metadata
  fallback then fail_run, recording error_text on the item and
  error_summary on the run with correct failed_count

styles.css:
- Capture dialog: header row, multi-row layout, status dots, spinner,
  add-row dashed button, per-row error text
- Toast stack: fixed overlay, error card with coloured left border,
  monospace detail expansion
- Run error rows: clickable hover tint, expand hint chevron, detail pre
This commit is contained in:
TheGeneralist 2026-07-05 13:56:00 +02:00
parent d83faab8e6
commit fb1115a409
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
11 changed files with 698 additions and 223 deletions

View file

@ -13,6 +13,7 @@ import CollectionsView from './components/CollectionsView'
import SettingsView from './components/SettingsView'
import ContextRail from './components/ContextRail'
import { displayPath } from './utils'
import ToastStack from './components/ToastStack'
export const AuthContext = createContext(null);
@ -82,6 +83,9 @@ export default function App() {
return saved === 'true'
})
const [toasts, setToasts] = useState([])
const toastIdRef = useRef(0)
const humanizeTags = currentUser?.humanize_slugs ?? false;
// Persist captureDialogOpen to sessionStorage
@ -234,6 +238,15 @@ export default function App() {
])
}, [archiveId, searchQuery, tagFilter, loadEntries])
const handleToast = useCallback((errorText, locator) => {
const id = ++toastIdRef.current
setToasts(prev => [...prev, { id, errorText, locator }])
}, [])
const handleDismissToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
@ -332,7 +345,9 @@ export default function App() {
archiveId={archiveId}
onClose={handleCaptureClose}
onCaptured={handleCaptured}
onToast={handleToast}
/>
<ToastStack toasts={toasts} onDismiss={handleDismissToast} />
</>
</AuthContext.Provider>
)

View file

@ -1,202 +1,298 @@
import { useRef, useEffect, useState } from 'react'
import { submitCapture, pollCaptureJob } from '../api'
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
let nextItemId = 1
function makeItem(locator = '') {
return { id: nextItemId++, locator, status: 'idle', error: null, jobUid: null, archiveId: null }
}
function hasActiveJobs(items) {
return items.some(it => it.status === 'submitting' || it.status === 'running')
}
export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast }) {
const dialogRef = useRef(null)
const isFirstRenderRef = useRef(true)
const hasResumedPollingRef = useRef(false)
const [locator, setLocator] = useState(() => {
const saved = sessionStorage.getItem('captureDialogLocator')
return saved || ''
})
const [error, setError] = useState(() => {
const saved = sessionStorage.getItem('captureDialogError')
return saved || null
})
const [busy, setBusy] = useState(() => {
const saved = sessionStorage.getItem('captureDialogBusy')
return saved === 'true'
})
const [jobStatus, setJobStatus] = useState(() => {
const saved = sessionStorage.getItem('captureDialogJobStatus')
return saved || null
})
const [jobUid, setJobUid] = useState(() => {
const saved = sessionStorage.getItem('captureDialogJobUid')
return saved || null
})
const pollRef = useRef(null)
// jobUid intervalId; survives dialog close since component stays mounted
const pollIntervals = useRef(new Map())
// Persist state to sessionStorage
useEffect(() => {
sessionStorage.setItem('captureDialogLocator', locator)
}, [locator])
// Stable refs so polling callbacks always use the latest prop values
const onCapturedRef = useRef(onCaptured)
const onToastRef = useRef(onToast)
useEffect(() => { onCapturedRef.current = onCaptured }, [onCaptured])
useEffect(() => { onToastRef.current = onToast }, [onToast])
useEffect(() => {
sessionStorage.setItem('captureDialogError', error || '')
}, [error])
useEffect(() => {
sessionStorage.setItem('captureDialogBusy', busy)
}, [busy])
useEffect(() => {
sessionStorage.setItem('captureDialogJobStatus', jobStatus || '')
}, [jobStatus])
useEffect(() => {
sessionStorage.setItem('captureDialogJobUid', jobUid || '')
}, [jobUid])
// On mount, resume polling if a job was in progress before page refresh
useEffect(() => {
if (hasResumedPollingRef.current) return
if (!jobUid || jobStatus !== 'running' || !archiveId) return
hasResumedPollingRef.current = true
// Resume polling for the saved job
pollRef.current = setInterval(async () => {
try {
const updated = await pollCaptureJob(archiveId, jobUid)
if (updated.status === 'completed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('completed')
clearCaptureState()
dialogRef.current?.close()
onCaptured()
} else if (updated.status === 'failed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('failed')
setError(updated.error_text || 'Capture failed.')
}
// pending / running: keep polling
} catch (pollErr) {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setError(pollErr.message)
const [items, setItems] = useState(() => {
try {
const saved = JSON.parse(sessionStorage.getItem('captureItems') || 'null')
if (Array.isArray(saved) && saved.length > 0) {
// Ensure nextItemId stays ahead of restored ids
saved.forEach(it => { if (it.id >= nextItemId) nextItemId = it.id + 1 })
return saved
}
}, 500)
}, [jobUid, jobStatus, archiveId, onCaptured])
} catch {}
return [makeItem()]
})
// Handle dialog close event
// Persist items to sessionStorage on every change
useEffect(() => {
sessionStorage.setItem('captureItems', JSON.stringify(items))
}, [items])
// On mount: clean up old single-locator sessionStorage keys; reconnect running jobs
useEffect(() => {
;['captureDialogLocator','captureDialogError','captureDialogBusy',
'captureDialogJobStatus','captureDialogJobUid'].forEach(k => sessionStorage.removeItem(k))
setItems(prev => prev.map(it =>
// 'submitting' means page was refreshed mid-fetch reset to idle so user can retry
it.status === 'submitting' ? { ...it, status: 'idle', error: null } : it
))
// Reconnect polling for any jobs still running from a previous session
items.forEach(it => {
if (it.status === 'running' && it.jobUid && it.archiveId && !pollIntervals.current.has(it.jobUid)) {
startPolling(it.id, it.jobUid, it.locator, it.archiveId)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // intentionally runs once on mount with initial values
// Handle native dialog 'close' event (Escape key or programmatic .close())
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
const handleClose = () => {
clearInterval(pollRef.current)
onClose()
}
dialog.addEventListener('close', handleClose)
return () => dialog.removeEventListener('close', handleClose)
const handler = () => onClose()
dialog.addEventListener('close', handler)
return () => dialog.removeEventListener('close', handler)
}, [onClose])
// Handle open/close from parent
// Open/close driven by parent; don't reset if active jobs are in flight
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open) {
// Only clear state if this is a fresh open from user click (not a restore on first render)
if (!isFirstRenderRef.current) {
setLocator('')
setError(null)
setJobStatus(null)
setBusy(false)
setJobUid(null)
clearInterval(pollRef.current)
if (!isFirstRenderRef.current && !hasActiveJobs(items)) {
setItems([makeItem()])
}
isFirstRenderRef.current = false
if (!dialog.open) dialog.showModal()
} else {
if (dialog.open) dialog.close()
}
}, [open])
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
function clearCaptureState() {
sessionStorage.removeItem('captureDialogLocator')
sessionStorage.removeItem('captureDialogError')
sessionStorage.removeItem('captureDialogBusy')
sessionStorage.removeItem('captureDialogJobStatus')
sessionStorage.removeItem('captureDialogJobUid')
setLocator('')
setError(null)
setBusy(false)
setJobStatus(null)
setJobUid(null)
// Clear all intervals on unmount (component teardown)
useEffect(() => {
return () => pollIntervals.current.forEach(id => clearInterval(id))
}, [])
function startPolling(itemId, jobUid, locator, aid) {
if (pollIntervals.current.has(jobUid)) return // already polling
const intervalId = setInterval(async () => {
try {
const updated = await pollCaptureJob(aid, jobUid)
if (updated.status === 'completed') {
clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid)
// Show briefly then remove the row; if last row add a fresh one
setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'completed' } : it))
setTimeout(() => {
setItems(prev => {
const next = prev.filter(it => it.id !== itemId)
return next.length === 0 ? [makeItem()] : next
})
}, 1400)
onCapturedRef.current()
} else if (updated.status === 'failed') {
clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid)
const errText = updated.error_text || 'Capture failed.'
setItems(prev => prev.map(it =>
it.id === itemId ? { ...it, status: 'failed', error: errText } : it
))
onToastRef.current(errText, locator)
}
// 'pending' / 'running': keep polling
} catch (e) {
clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid)
const msg = e.message || 'Network error'
setItems(prev => prev.map(it =>
it.id === itemId ? { ...it, status: 'failed', error: msg } : it
))
onToastRef.current(msg, locator)
}
}, 500)
pollIntervals.current.set(jobUid, intervalId)
}
async function handleSubmit() {
if (!locator.trim()) { setError('Enter a locator.'); return }
setBusy(true)
setError(null)
setJobStatus(null)
async function submitItem(item) {
if (!item.locator.trim()) return
const aid = archiveId // capture at submit time
const loc = item.locator.trim()
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
try {
const job = await submitCapture(archiveId, locator.trim())
setJobUid(job.job_uid)
setJobStatus('running')
pollRef.current = setInterval(async () => {
try {
const updated = await pollCaptureJob(archiveId, job.job_uid)
if (updated.status === 'completed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('completed')
clearCaptureState()
dialogRef.current?.close()
onCaptured()
} else if (updated.status === 'failed') {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setJobStatus('failed')
setError(updated.error_text || 'Capture failed.')
}
// pending / running: keep polling
} catch (pollErr) {
clearInterval(pollRef.current)
pollRef.current = null
setBusy(false)
setError(pollErr.message)
}
}, 500)
const job = await submitCapture(aid, loc)
setItems(prev => prev.map(it =>
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
))
startPolling(item.id, job.job_uid, loc, aid)
} catch (e) {
setError(e.message)
setBusy(false)
const msg = e.message || 'Submission failed.'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'failed', error: msg } : it))
onToastRef.current(msg, loc)
}
}
function buttonLabel() {
if (!busy) return 'Capture'
if (jobStatus === 'running') return 'Running\u2026'
return 'Capturing\u2026'
function handleArchive() {
const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim())
toSubmit.forEach(it => submitItem(it))
}
function addRow() {
setItems(prev => [...prev, makeItem()])
}
function removeRow(id) {
setItems(prev => {
const next = prev.filter(it => it.id !== id)
return next.length === 0 ? [makeItem()] : next
})
}
function resetRow(id) {
setItems(prev => prev.map(it =>
it.id === id ? { ...it, status: 'idle', error: null } : it
))
}
function updateLocator(id, val) {
setItems(prev => prev.map(it => it.id === id ? { ...it, locator: val } : it))
}
const pendingCount = items.filter(it => it.status === 'idle' && it.locator.trim()).length
const anyActive = hasActiveJobs(items)
return (
<dialog ref={dialogRef} className="capture-dialog">
<div className="capture-dialog-inner">
<h2 className="capture-dialog-title">Capture</h2>
<label htmlFor="capture-locator" className="capture-label">Locator</label>
<input id="capture-locator" className="capture-input" type="text"
placeholder="tweet:1234567890 or https://..."
value={locator} onChange={e => setLocator(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }}
autoComplete="off" />
{error && <div className="capture-error">{error}</div>}
<div className="capture-dialog-header">
<h2 className="capture-dialog-title">Capture</h2>
<button
type="button"
className="capture-dialog-close"
onClick={() => dialogRef.current?.close()}
aria-label="Close"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
</svg>
</button>
</div>
<div className="capture-rows">
{items.map((item, idx) => (
<CaptureRow
key={item.id}
item={item}
autoFocus={idx === items.length - 1 && item.status === 'idle'}
onLocatorChange={val => updateLocator(item.id, val)}
onRemove={() => removeRow(item.id)}
onReset={() => resetRow(item.id)}
onSubmit={handleArchive}
/>
))}
</div>
<button type="button" className="capture-add-row" onClick={addRow}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/>
</svg>
Add another
</button>
<div className="capture-actions">
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>Cancel</button>
<button type="button" className="capture-submit" onClick={handleSubmit} disabled={busy}>
{buttonLabel()}
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className="capture-submit"
onClick={handleArchive}
disabled={pendingCount === 0}
>
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button>
</div>
</div>
</dialog>
)
}
function CaptureRow({ item, autoFocus, onLocatorChange, onRemove, onReset, onSubmit }) {
const inputRef = useRef(null)
const isActive = item.status === 'submitting' || item.status === 'running'
useEffect(() => {
if (autoFocus && item.status === 'idle') {
inputRef.current?.focus()
}
}, [autoFocus]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className={`capture-row capture-row--${item.status}`}>
<div className="capture-row-main">
<CapStatusDot status={item.status} />
<input
ref={inputRef}
className="capture-input"
type="text"
placeholder="https://… · yt:ID · tweet:ID · x:ID"
value={item.locator}
onChange={e => onLocatorChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') onSubmit() }}
disabled={isActive || item.status === 'completed'}
autoComplete="off"
spellCheck={false}
/>
{item.status === 'failed' && (
<button type="button" className="capture-row-action" onClick={onReset} title="Retry">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2.5A7 7 0 1 1 6.5 1"/>
<polyline points="6.5 1 4 3.5 6.5 6"/>
</svg>
</button>
)}
{!isActive && item.status !== 'completed' && item.status !== 'failed' && (
<button type="button" className="capture-row-action capture-row-remove" onClick={onRemove} aria-label="Remove">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
</svg>
</button>
)}
</div>
{item.error && (
<p className="capture-row-error">{item.error}</p>
)}
</div>
)
}
function CapStatusDot({ status }) {
if (status === 'submitting' || status === 'running') {
return (
<span className="cap-dot cap-dot--running" aria-label="Running">
<span className="cap-spinner" />
</span>
)
}
if (status === 'completed') {
return <span className="cap-dot cap-dot--ok" aria-label="Done"></span>
}
if (status === 'failed') {
return <span className="cap-dot cap-dot--err" aria-label="Failed"></span>
}
return <span className="cap-dot cap-dot--idle" aria-hidden="true" />
}

View file

@ -1,3 +1,5 @@
import { useState } from 'react'
function fmtDate(iso) {
if (!iso) return '—';
try {
@ -20,6 +22,12 @@ function StatusBadge({ status }) {
}
export default function RunsView({ runs }) {
const [expanded, setExpanded] = useState(null) // run_uid of the expanded row
function toggle(uid) {
setExpanded(prev => prev === uid ? null : uid)
}
return (
<section id="runs-view" className="view is-active">
<table className="entry-table">
@ -34,16 +42,43 @@ export default function RunsView({ runs }) {
</thead>
<tbody>
{runs.length === 0 ? (
<tr><td colSpan={5} style={{ color: 'var(--muted)', padding: '24px 16px', textAlign: 'center' }}>No runs yet.</td></tr>
) : runs.map((run, i) => (
<tr key={i}>
<td>{fmtDate(run.started_at)}</td>
<td><StatusBadge status={run.status} /></td>
<td>{run.requested_count ?? '—'}</td>
<td>{run.completed_count ?? '—'}</td>
<td>{run.failed_count ?? '—'}</td>
<tr>
<td colSpan={5} style={{ color: 'var(--muted)', padding: '24px 16px', textAlign: 'center' }}>
No runs yet.
</td>
</tr>
))}
) : runs.map(run => {
const hasError = run.status === 'failed' && run.error_summary
const isExpanded = expanded === run.run_uid
return [
<tr
key={run.run_uid}
className={hasError ? 'run-row run-row--failed' : 'run-row'}
onClick={hasError ? () => toggle(run.run_uid) : undefined}
title={hasError ? (isExpanded ? 'Click to hide error' : 'Click to view error') : undefined}
>
<td>{fmtDate(run.started_at)}</td>
<td>
<StatusBadge status={run.status} />
{hasError && (
<span className="run-expand-hint" aria-hidden="true">
{isExpanded ? '▴' : '▾'}
</span>
)}
</td>
<td>{run.requested_count ?? '—'}</td>
<td>{run.completed_count ?? '—'}</td>
<td>{run.failed_count ?? '—'}</td>
</tr>,
hasError && isExpanded && (
<tr key={`${run.run_uid}-detail`} className="run-error-row">
<td colSpan={5}>
<pre className="run-error-detail">{run.error_summary}</pre>
</td>
</tr>
),
]
})}
</tbody>
</table>
</section>

View file

@ -0,0 +1,63 @@
import { useState, useEffect } from 'react'
const TOAST_TTL = 7000 // ms before auto-dismiss; paused when error is expanded
export default function ToastStack({ toasts, onDismiss }) {
if (!toasts.length) return null
return (
<div className="toast-stack" role="log" aria-live="polite" aria-label="Notifications">
{toasts.map(t => (
<Toast key={t.id} toast={t} onDismiss={onDismiss} />
))}
</div>
)
}
function Toast({ toast, onDismiss }) {
const [expanded, setExpanded] = useState(false)
// Auto-dismiss after TTL; paused while error detail is expanded
useEffect(() => {
if (expanded) return
const timer = setTimeout(() => onDismiss(toast.id), TOAST_TTL)
return () => clearTimeout(timer)
}, [expanded, toast.id, onDismiss])
const short = toast.locator
? (toast.locator.length > 48 ? toast.locator.slice(0, 45) + '\u2026' : toast.locator)
: null
return (
<div className="toast toast--error" role="alert" aria-atomic="true">
<div className="toast-top">
<span className="toast-icon" aria-hidden="true"></span>
<div className="toast-body">
<span className="toast-headline">Capture failed</span>
{short && <span className="toast-locator">{short}</span>}
</div>
<div className="toast-btns">
{toast.errorText && (
<button
type="button"
className="toast-view-btn"
onClick={() => setExpanded(v => !v)}
>
{expanded ? 'Hide' : 'View error'}
</button>
)}
<button
type="button"
className="toast-dismiss"
onClick={() => onDismiss(toast.id)}
aria-label="Dismiss"
>
×
</button>
</div>
</div>
{expanded && toast.errorText && (
<pre className="toast-error-detail">{toast.errorText}</pre>
)}
</div>
)
}

View file

@ -557,8 +557,7 @@ select {
border: 1px solid var(--line);
background: var(--paper);
padding: 0;
min-width: 360px;
max-width: 520px;
width: min(540px, calc(100vw - 32px));
border-radius: var(--r3);
box-shadow: 0 20px 60px rgba(20, 29, 24, 0.28);
}
@ -569,7 +568,7 @@ select {
padding: 28px;
}
.capture-dialog-title {
margin: 0 0 16px;
margin: 0;
font-size: 22px;
font-family: var(--display);
font-weight: 600;
@ -634,6 +633,233 @@ select {
.capture-submit:hover { opacity: 0.85; }
.capture-submit:disabled { opacity: 0.45; cursor: default; }
/* ── Capture dialog: header + multi-row ─────────────────────────────────── */
.capture-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.capture-dialog-close {
flex-shrink: 0;
background: none;
border: 0;
cursor: pointer;
color: var(--muted);
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--r);
padding: 0;
}
.capture-dialog-close svg { width: 14px; height: 14px; }
.capture-dialog-close:hover { background: var(--paper-2); color: var(--ink); }
.capture-rows {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.capture-row {
display: flex;
flex-direction: column;
gap: 3px;
}
.capture-row-main {
display: flex;
align-items: center;
gap: 8px;
}
/* No bottom-margin on inputs inside rows; gap handles spacing */
.capture-row-main .capture-input { margin-bottom: 0; }
/* Status dot */
.cap-dot {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.cap-dot--idle { background: var(--paper-2); }
.cap-dot--running { background: #fdefd8; color: #8a4f10; }
.cap-dot--ok { background: #d8eddf; color: #235c35; }
.cap-dot--err { background: #f5ddd8; color: #8d3f30; }
/* Spinner inside the running dot */
.cap-spinner {
display: block;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: cap-spin 0.7s linear infinite;
}
@keyframes cap-spin { to { transform: rotate(360deg); } }
/* Per-row action buttons (remove / retry) */
.capture-row-action {
flex-shrink: 0;
background: none;
border: 0;
cursor: pointer;
color: var(--muted);
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--r);
padding: 0;
}
.capture-row-action svg { width: 12px; height: 12px; }
.capture-row-action:hover { color: var(--accent); background: var(--paper-2); }
.capture-row-remove:hover { color: var(--accent); }
/* Inline error under a failed row */
.capture-row-error {
margin: 0;
padding-left: 28px; /* align past the status dot */
font-size: 12px;
color: var(--accent);
line-height: 1.45;
}
/* Add-another button */
.capture-add-row {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
background: none;
border: 1px dashed var(--line);
border-radius: var(--r);
color: var(--muted);
font-size: 13px;
cursor: pointer;
padding: 7px 12px;
margin-top: 4px;
margin-bottom: 18px;
transition: border-color .15s, color .15s, background .15s;
}
.capture-add-row svg { width: 13px; height: 13px; flex-shrink: 0; }
.capture-add-row:hover { border-color: var(--accent-2); color: var(--ink); background: var(--paper-2); }
/* ── Toast stack ─────────────────────────────────────────────────────────── */
.toast-stack {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9000;
display: flex;
flex-direction: column;
gap: 10px;
width: 340px;
max-width: calc(100vw - 32px);
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r2);
box-shadow: 0 6px 24px rgba(20, 29, 24, 0.18), 0 1px 4px rgba(20, 29, 24, 0.1);
overflow: hidden;
animation: toast-slide-in 0.22s cubic-bezier(.22,.68,0,1.2);
}
@keyframes toast-slide-in {
from { opacity: 0; transform: translateY(10px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.toast--error { border-left: 3px solid var(--accent); }
.toast-top {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 12px 12px 14px;
}
.toast-icon {
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
color: var(--accent);
margin-top: 2px;
}
.toast-body {
flex: 1;
min-width: 0;
}
.toast-headline {
display: block;
font-size: 13.5px;
font-weight: 600;
color: var(--ink);
line-height: 1.3;
}
.toast-locator {
display: block;
font-size: 12px;
color: var(--muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toast-btns {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.toast-view-btn {
background: none;
border: 1px solid var(--line);
color: var(--link);
font-size: 12px;
cursor: pointer;
padding: 3px 9px;
border-radius: var(--r);
white-space: nowrap;
}
.toast-view-btn:hover { background: var(--paper-2); border-color: var(--link); }
.toast-dismiss {
background: none;
border: 0;
cursor: pointer;
color: var(--muted);
font-size: 20px;
line-height: 1;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: var(--r);
padding: 0;
}
.toast-dismiss:hover { color: var(--ink); background: var(--paper-2); }
.toast-error-detail {
margin: 0;
padding: 10px 14px 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11.5px;
line-height: 1.55;
color: var(--muted);
background: var(--paper-2);
border-top: 1px solid var(--line);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
/* ── Utility ─────────────────────────────────────────────────────────────── */
.muted { color: var(--muted); }
@ -1311,6 +1537,32 @@ select {
.run-status--failed { background: #f5ddd8; color: #8d3f30; border: 1px solid #e0b8b0; }
.run-status--in-progress { background: #fdefd8; color: #8a4f10; border: 1px solid #f0cfa0; }
/* Clickable failed run rows and expandable error detail */
.run-row--failed { cursor: pointer; }
.run-row--failed:hover td { background: #fdf0ed !important; }
.run-expand-hint {
margin-left: 6px;
font-size: 10px;
color: var(--accent);
vertical-align: middle;
}
.run-error-row td {
padding: 0 !important;
background: var(--paper-2) !important;
}
.run-error-detail {
margin: 0;
padding: 10px 16px 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11.5px;
line-height: 1.55;
color: var(--muted);
white-space: pre-wrap;
word-break: break-word;
max-height: 220px;
overflow-y: auto;
}
/* ── Token created banner ────────────────────────────────────────────────── */
.token-banner {
background: #d8eddf;