many fixes

This commit is contained in:
2026-07-03 11:18:40 +07:00
parent d909618ced
commit 79fd200e57
12 changed files with 348 additions and 18 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"files": ["web/index.html"],
"insertBefore": "</body>",
"commentSyntax": "html",
"cspChecked": true
}
+117
View File
@@ -0,0 +1,117 @@
# Design
Terminal / ops-console visual language for an IMAP migration tool. Near-black,
green-tinted dark surface; a single amber functional accent; monospaced body with
a condensed display face for headings. Dense, instrument-panel layouts where the
system state is always legible. Captured from `web/src/index.css` and
`web/src/app.css` — these tokens are canonical; new work extends them, it does not
replace them.
## Theme
Dark, committed. Not "dark to look cool" — the scene is an operator running a
long-lived migration and watching thousands of counters tick; a dark console
keeps the amber accent and the ok/fail/pending signal colors doing the reading
work with minimal eye strain. Color strategy: **restrained** — tinted neutrals
carry the surface, one amber accent stays under ~10% of the pixels, and the
green/red/yellow/blue roles appear only as status semantics.
Signature textures (keep, don't multiply):
- A faint amber radial glow at the top of the page (`radial-gradient` at 50% -10%).
- A 3px repeating-linear scanline overlay at ~1.2% white — the CRT tell.
- Panels wear a floating uppercase tab label (`.panel-label`) punched through the
border; the log panel mirrors it on the right (`.log-clear`).
## Color
OKLCH is the target space for any new color; existing tokens are hex and stay hex.
### Surfaces (green-tinted neutrals, darkest → raised)
- `--bg` `#0a0d0b` — page base
- `--bg-inset` `#070a08` — insets: inputs, progress track, log pane
- `--bg-panel` `#0f1512` — panels, cards, login card
- `--bg-panel-raised` `#141b17` — topbar, table headers, modal dialog
### Borders
- `--border` `#23342b` — default hairline (1px)
- `--border-bright` `#3a5443` — raised/interactive edges, dashed underlines
### Ink
- `--fg` `#dbe8de` — primary text
- `--fg-dim` `#6f8478` — labels, secondary text, nav idle
- `--fg-faint` `#4a5c50` — hints, empty states, faint indices
### Accent (amber — attention & primary action)
- `--accent` `#ffb238` — primary buttons, active nav, panel labels, progress fill
- `--accent-strong` `#ffd27a` — hover/emphasis
- `--accent-dim` `#7a5a26` — active borders, tinted glows
- Primary-button ink is near-black `#1a1200` on amber, never white.
### Status semantics (color always carries meaning)
- `--ok` `#52e6a0` / `--ok-dim` `#234a37` — success (glowing dot)
- `--fail` `#ff5d5d` / `--fail-dim` `#4a2323` — error
- `--pending` `#f0c419` — waiting/queued
- `--info` `#57c2ff` — in-progress / scan / new-folder marker (pulsing dot)
> Contrast watch: `--fg-dim` on `--bg-panel` is the thinnest pairing — for real
> body copy (not just uppercase micro-labels) prefer `--fg`. Placeholder and
> small print must still clear AA; don't push text below `--fg-faint`.
## Typography
Two families on a real contrast axis (mono vs. condensed display) — never two
similar sans.
- **Body / UI / data:** `--font-mono` = JetBrains Mono (400/500/700), 14px base,
line-height 1.5. Numbers use `font-variant-numeric: tabular-nums`
(`.mono-num`, `.num-cell`, stat values) so columns align.
- **Display / headings / brand:** `--font-display` = Big Shoulders Display
(600/800), uppercase, letter-spacing ~0.30.5px. Page titles 34px, brand 22px,
login brand 30px.
- **Micro-labels:** uppercase, 1012px, letter-spacing 0.080.14em, `--fg-dim`.
This is the workhorse label style (nav, field labels, badges, table headers,
panel tabs). Used as a deliberate system, not a per-section eyebrow.
## Components
- **Panels** (`.panel`): bordered surface, 3px radius, floating uppercase tab
label. The primary content container — used instead of stacked cards.
- **Buttons**: `.btn` (ghost outline, uppercase, 2px radius) · `.btn-primary`
(solid amber, dark ink, amber glow on hover) · `.btn-danger` · `.btn-ghost` ·
`.link-btn` (dashed-underline text button, `.danger` variant).
- **Status badges** (`.badge` + `.badge-ok/-fail/-pending/-info`): dot + uppercase
label, tinted 6%-alpha background, colored dot (glowing/pulsing per state).
- **Tables** (`table.tbl` in `.tbl-wrap`): dense, hairline row borders, raised
uppercase header, right-aligned `.num-cell`, subtle row-hover, `.empty-row` for
empty states. Horizontal scroll lives on `.tbl-wrap`.
- **Forms** (`.field`, `.field-row`): uppercase label above inset input; focus =
amber border + 3px amber-12% ring.
- **Modals** (`.modal-overlay` + `.modal-dialog`, `.modal-md/-lg`): fixed overlay,
backdrop-blur(2px), fade + 6px rise entrance. Folder-mapping grid
(`.map-row`: src → select). z-index scale: topbar 10 → overlay 100.
- **Live progress** (`.acct-progress`, `.pbar`/`.pbar-fill`, `.pmeta`, `.pscan`,
`.acct-error`): thin amber bar + mono meta; scan phase in `--info`; persisted
last-error in `--fail`, ellipsis-truncated.
- **Log pane** (`.log-pane`): reversed-column inset console, tagged lines
(`.tag` amber + `.payload`).
- **Chrome**: sticky `.topbar` with bracketed `.brand`, uppercase `.topnav`
(active = amber), `.session-indicator` with pulsing `.pulse-dot`.
## Layout
- App shell: sticky topbar (56px) over a centered `.main`, `max-width: 1180px`,
padding `32px 24px 64px`.
- Grids: `repeat(auto-fit, minmax(340px, 1fr))` for panel grids — responsive
without breakpoints. Flexbox for 1D rows (`.btn-row`, `.stat-row`, `.upload-row`).
- Radii: tight — 2px (controls) / 3px (surfaces). Nothing pill-shaped.
- Dividers: centered uppercase `.divider-label` with rule lines both sides.
## Motion
Restrained, functional. Ease-out only; no bounce/elastic.
- Entrances: modal fade 0.12s + rise 0.14s.
- Feedback: 0.15s color/border transitions on nav, buttons, inputs.
- Ambient: `pulse` (2.4s) on session/info dots; progress-bar width 0.3s ease-out.
- Amber glow bloom on primary-button hover.
- **Owed:** a `prefers-reduced-motion: reduce` block — the pulsing dots and
progress transitions need a static fallback. Add before shipping motion work.
+63
View File
@@ -0,0 +1,63 @@
# Product
## Register
product
## Users
Системные администраторы, DevOps- и хостинг-инженеры, которые массово мигрируют
почтовые ящики между IMAP-серверами (source → destination). Контекст использования —
рабочая сессия миграции: пользователь заводит задачу, добавляет десятки аккаунтов
(в т.ч. импортом из CSV), запускает копирование и следит за живым прогрессом по
тысячам писем на аккаунт. Работают с плотными таблицами, читают счётчики
Copied / Skipped / Errors и статусы, ждут точности и предсказуемости, а не «красоты».
_(Выведено из README и кода; пользователь был недоступен при уточнении — подтвердить/скорректировать.)_
## Product Purpose
Однобинарный Go-сервер (встраивает React-SPA), который **копирует** содержимое
IMAP-ящиков между аккаунтами: неразрушающе (только APPEND, ничего не удаляет),
с дедупликацией по Message-ID, идемпотентно и с возобновляемостью. Успех =
инженер запускает миграцию, видит достоверный прогресс в реальном времени, а
повторный запуск честно скипает уже перенесённое и показывает ровно то, что
произошло в этом прогоне. Ценность — доверие к цифрам и отсутствие сюрпризов
на больших объёмах.
## Brand Personality
Три слова: **технично, точно, спокойно**. Голос — инструмент оператора, а не
маркетинговый продукт: краткие подписи, честные статусы, никакого хайпа.
Эмоциональная цель — уверенность и контроль. Интерфейс должен читаться как
приборная панель / консоль: плотная информация, монохром с одним функциональным
акцентом, состояние системы всегда очевидно.
## Anti-references
- Яркие SaaS-лендинги с hero-метриками, градиентным текстом и карточками-плитками.
- «Дружелюбные» дашборды в стиле генеративного AI: cream/paper-фон, скруглённые
пастельные карточки, иконка+заголовок+текст в бесконечной сетке.
- Любой декор ради декора (glassmorphism, тени-свечения без функции).
- Разреженные «воздушные» лейауты — здесь плотность данных это фича, а не проблема.
## Design Principles
1. **Числам верят.** Счётчики и статусы обязаны быть достоверны и однозначны;
визуальная неоднозначность статуса — это баг, а не косметика.
2. **Плотность — это уважение к оператору.** Компактные таблицы и моноширинные
табличные цифры важнее «воздуха».
3. **Один акцент, одна семантика.** Янтарь = внимание/действие; зелёный = успех;
красный = ошибка; жёлтый = ожидание. Цвет всегда несёт смысл.
4. **Состояние системы всегда видно.** Прогресс, скип, ошибка и последняя причина
сбоя должны переживать перезагрузку страницы и не теряться.
5. **Спокойная инженерная сдержанность.** Никакого хайпа и лишнего движения;
анимация только там, где отражает реальный процесс.
## Accessibility & Inclusion
Базовый уровень: читаемый контраст (цель — WCAG AA для основного текста, особенно
приглушённого `--fg-dim` на тёмных панелях), корректный `prefers-reduced-motion`
для live-прогресса, различимость статусов не только цветом (иконка/подпись рядом
с цветом для дальтоников). Формальной сертификации не требуется — инструмент для
узкого технического круга, но контраст и reduced-motion соблюдаем осознанно.
+2 -1
View File
@@ -217,7 +217,8 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
"dst_login": a.DstLogin, "dst_host": dstEP.Host, "dst_port": dstEP.Port, "dst_login": a.DstLogin, "dst_host": dstEP.Host, "dst_port": dstEP.Port,
}}) }})
_ = o.store.SetAccountStatus(ctx, a.ID, "running") _ = o.store.SetAccountStatus(ctx, a.ID, "running")
_ = o.store.SetAccountError(ctx, a.ID, "") // clear any error from a previous run _ = o.store.SetAccountError(ctx, a.ID, "") // clear any error from a previous run
_ = o.store.ResetAccountCounters(ctx, a.ID) // start from zero; IncAccountCounters is additive
// Per-account cancellable context: IMAP work uses actx (so CancelAccount // Per-account cancellable context: IMAP work uses actx (so CancelAccount
// stops it); DB writes keep the parent ctx so status/counters persist even // stops it); DB writes keep the parent ctx so status/counters persist even
+9
View File
@@ -79,6 +79,15 @@ func (s *Store) SetAccountStatus(ctx context.Context, id int64, status string) e
return err return err
} }
// ResetAccountCounters zeroes the per-account copied/skipped/error counts at the
// start of a run so a re-run reflects only the current run's totals instead of
// accumulating on top of the previous run (IncAccountCounters is additive).
func (s *Store) ResetAccountCounters(ctx context.Context, id int64) error {
_, err := s.Pool.Exec(ctx,
`UPDATE accounts SET copied_count=0, skipped_count=0, error_count=0 WHERE id=$1`, id)
return err
}
func (s *Store) IncAccountCounters(ctx context.Context, id, copied, skipped, errs int64) error { func (s *Store) IncAccountCounters(ctx context.Context, id, copied, skipped, errs int64) error {
_, err := s.Pool.Exec(ctx, _, err := s.Pool.Exec(ctx,
`UPDATE accounts SET copied_count=copied_count+$2, `UPDATE accounts SET copied_count=copied_count+$2,
+36
View File
@@ -28,3 +28,39 @@ func TestMigratedIdempotency(t *testing.T) {
t.Fatal("unknown key must be false") t.Fatal("unknown key must be false")
} }
} }
// A re-run must start from zero counters, not accumulate on top of the previous
// run's totals — otherwise a run that skips everything still shows the prior
// run's Copied count. ResetAccountCounters is called at the start of runAccount.
func TestResetAccountCounters(t *testing.T) {
s := testStore(t)
ctx := context.Background()
epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"})
epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"})
taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst})
accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "x", DstLogin: "u2", DstPassEnc: "y"})
// First run: everything copied.
if err := s.IncAccountCounters(ctx, accID, 100, 0, 1); err != nil {
t.Fatalf("inc: %v", err)
}
// Second run starts: counters reset to zero.
if err := s.ResetAccountCounters(ctx, accID); err != nil {
t.Fatalf("reset: %v", err)
}
// Second run skips everything.
if err := s.IncAccountCounters(ctx, accID, 0, 100, 0); err != nil {
t.Fatalf("inc2: %v", err)
}
accs, err := s.ListAccountsByTask(ctx, taskID)
if err != nil || len(accs) != 1 {
t.Fatalf("list: %v len=%d", err, len(accs))
}
a := accs[0]
if a.Copied != 0 || a.Skipped != 100 || a.Errors != 0 {
t.Fatalf("after reset+rerun: copied=%d skipped=%d errors=%d want 0/100/0",
a.Copied, a.Skipped, a.Errors)
}
}
+94 -6
View File
@@ -245,8 +245,8 @@
} }
.link-btn.danger:hover { .link-btn.danger:hover {
color: var(--danger, #ff5c5c); color: var(--fail);
border-bottom-color: var(--danger, #ff5c5c); border-bottom-color: var(--fail);
} }
.link-btn:disabled { .link-btn:disabled {
@@ -272,8 +272,11 @@
.pbar-fill { .pbar-fill {
display: block; display: block;
height: 100%; height: 100%;
width: 100%;
transform: scaleX(0);
transform-origin: left;
background: var(--accent); background: var(--accent);
transition: width 0.3s ease-out; transition: transform 0.3s ease-out;
} }
.pmeta { .pmeta {
@@ -473,11 +476,32 @@
box-shadow: 0 0 16px -2px rgba(255, 178, 56, 0.6); box-shadow: 0 0 16px -2px rgba(255, 178, 56, 0.6);
} }
.btn:disabled { .btn:disabled,
.btn.is-disabled {
opacity: 0.35; opacity: 0.35;
cursor: not-allowed; cursor: not-allowed;
} }
/* label styled as .btn (CSV upload) can't use :disabled — block interaction. */
.btn.is-disabled {
pointer-events: none;
}
/* ---------- keyboard focus ---------- */
/* Custom-styled controls drop the faint UA outline; restore a visible, on-brand
focus ring for keyboard users (mouse clicks stay ring-free via :focus-visible). */
.btn:focus-visible,
.link-btn:focus-visible,
.file-btn:focus-within,
.topnav a:focus-visible,
.crumb:focus-visible,
table.tbl a.rowlink:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
color: var(--accent-strong);
}
.btn-row { .btn-row {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -533,14 +557,14 @@
.badge-pending { .badge-pending {
color: var(--pending); color: var(--pending);
border-color: #4a4423; border-color: var(--pending-dim);
background: rgba(240, 196, 25, 0.06); background: rgba(240, 196, 25, 0.06);
} }
.badge-pending .dot { background: var(--pending); } .badge-pending .dot { background: var(--pending); }
.badge-info { .badge-info {
color: var(--info); color: var(--info);
border-color: #234456; border-color: var(--info-dim);
background: rgba(87, 194, 255, 0.06); background: rgba(87, 194, 255, 0.06);
} }
.badge-info .dot { background: var(--info); animation: pulse 1.4s ease-in-out infinite; } .badge-info .dot { background: var(--info); animation: pulse 1.4s ease-in-out infinite; }
@@ -784,3 +808,67 @@ table.tbl a.rowlink:hover {
color: var(--fg-faint); color: var(--fg-faint);
font-size: 12px; font-size: 12px;
} }
/* ---------- responsive ---------- */
/* The shell is desktop-first (density is a feature for operators). These
overrides keep the chrome usable on phones/tablets without thinning the
data-dense surfaces. Intrinsic grids (panel-grid auto-fit, stat-row/page-head
flex-wrap) already reflow; the topbar and paired fields need explicit help. */
@media (max-width: 640px) {
.topbar {
flex-wrap: wrap;
height: auto;
padding: 10px 16px;
gap: 12px 18px;
}
/* Secondary chrome — drop the ambient session pill to save the row on mobile. */
.session-indicator {
display: none;
}
.topnav {
order: 3;
flex-basis: 100%;
flex: 0 0 100%;
}
.main {
padding: 20px 16px 48px;
}
.page-title {
font-size: 26px;
}
/* Stacked src/dst fields — two password inputs side by side are too tight. */
.field-row {
grid-template-columns: 1fr;
}
}
/* ---------- reduced motion ---------- */
/* Neutralize the infinite pulse dots, modal entrance, and progress transition
for users who opt out. State stays legible through color + presence, not
movement. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Coarse pointers (touch) get larger hit areas; fine pointers keep the dense
inline layout. WCAG 2.5.8 target size (AA) is 24px minimum. */
@media (pointer: coarse) {
.link-btn {
display: inline-block;
padding-block: 6px;
min-height: 24px;
}
.topnav a {
padding: 12px 16px;
}
.btn-ghost {
padding: 12px 14px;
}
}
@@ -63,6 +63,7 @@ export function FolderMappingModal({ open, srcFolders, dstFolders, initialMappin
<span className="map-arrow"></span> <span className="map-arrow"></span>
<select <select
className="map-select" className="map-select"
aria-label={`Destination folder for ${src}`}
value={val} value={val}
onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))} onChange={(e) => setChoice((c) => ({ ...c, [src]: e.target.value }))}
> >
+4 -2
View File
@@ -12,8 +12,8 @@
--border: #23342b; --border: #23342b;
--border-bright: #3a5443; --border-bright: #3a5443;
--fg: #dbe8de; --fg: #dbe8de;
--fg-dim: #6f8478; --fg-dim: #7a8f82;
--fg-faint: #4a5c50; --fg-faint: #6f8478;
--accent: #ffb238; --accent: #ffb238;
--accent-strong: #ffd27a; --accent-strong: #ffd27a;
--accent-dim: #7a5a26; --accent-dim: #7a5a26;
@@ -22,7 +22,9 @@
--fail: #ff5d5d; --fail: #ff5d5d;
--fail-dim: #4a2323; --fail-dim: #4a2323;
--pending: #f0c419; --pending: #f0c419;
--pending-dim: #4a4423;
--info: #57c2ff; --info: #57c2ff;
--info-dim: #234456;
--font-display: 'Big Shoulders Display', 'Arial Narrow', sans-serif; --font-display: 'Big Shoulders Display', 'Arial Narrow', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace; --font-mono: 'JetBrains Mono', ui-monospace, monospace;
+1 -1
View File
@@ -111,7 +111,7 @@ export function Endpoints() {
</select> </select>
</div> </div>
</div> </div>
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner" role="alert">{error}</div>}
<div className="btn-row"> <div className="btn-row">
<button className="btn btn-primary" disabled={busy}> <button className="btn btn-primary" disabled={busy}>
{busy ? 'Saving…' : editingId !== null ? 'Save changes' : 'Add endpoint'} {busy ? 'Saving…' : editingId !== null ? 'Save changes' : 'Add endpoint'}
+1 -1
View File
@@ -54,7 +54,7 @@ export function Login({ onSuccess }: { onSuccess: () => void }) {
<button className="btn btn-primary" style={{ width: '100%' }} disabled={busy || !user || !pass}> <button className="btn btn-primary" style={{ width: '100%' }} disabled={busy || !user || !pass}>
{busy ? 'Authenticating…' : 'Sign in'} {busy ? 'Authenticating…' : 'Sign in'}
</button> </button>
<div className="login-error">{error}</div> <div className="login-error" role="alert">{error}</div>
</form> </form>
</div> </div>
) )
+14 -7
View File
@@ -330,7 +330,7 @@ export function TaskDetail({ id }: { id: number }) {
<div className="panel"> <div className="panel">
<span className="panel-label">Run control</span> <span className="panel-label">Run control</span>
<div className="stat-row"> <div className="stat-row" aria-live="polite">
<div className="stat ok"> <div className="stat ok">
<span className="val mono-num">{totals.copied}</span> <span className="val mono-num">{totals.copied}</span>
<span className="lbl">copied</span> <span className="lbl">copied</span>
@@ -348,7 +348,7 @@ export function TaskDetail({ id }: { id: number }) {
<span className="lbl">accounts</span> <span className="lbl">accounts</span>
</div> </div>
</div> </div>
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner" role="alert">{error}</div>}
<div className="btn-row" style={{ marginTop: 16 }}> <div className="btn-row" style={{ marginTop: 16 }}>
<button className="btn" onClick={onTest} disabled={busy !== null || accounts.length === 0}> <button className="btn" onClick={onTest} disabled={busy !== null || accounts.length === 0}>
{busy === 'test' ? 'Testing…' : 'Test connections'} {busy === 'test' ? 'Testing…' : 'Test connections'}
@@ -415,10 +415,10 @@ export function TaskDetail({ id }: { id: number }) {
<div className="divider-label">or bulk import</div> <div className="divider-label">or bulk import</div>
<div className="upload-row"> <div className="upload-row">
<button className="btn file-btn" disabled={busy !== null}> <label className={`btn file-btn${busy !== null ? ' is-disabled' : ''}`}>
{busy === 'import' ? 'Importing…' : 'Upload CSV'} {busy === 'import' ? 'Importing…' : 'Upload CSV'}
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} /> <input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
</button> </label>
<button type="button" className="link-btn" onClick={downloadExampleCSV}> <button type="button" className="link-btn" onClick={downloadExampleCSV}>
download example.csv download example.csv
</button> </button>
@@ -432,7 +432,7 @@ export function TaskDetail({ id }: { id: number }) {
clear log clear log
</button> </button>
)} )}
<div className="log-pane"> <div className="log-pane" role="log" aria-live="polite" aria-relevant="additions" aria-label="Event log">
{log.length === 0 ? ( {log.length === 0 ? (
<div className="log-empty">awaiting events over websocket</div> <div className="log-empty">awaiting events over websocket</div>
) : ( ) : (
@@ -501,8 +501,15 @@ export function TaskDetail({ id }: { id: number }) {
const scanning = lv.scanned != null && lv.scanTotal != null && lv.scanned < lv.scanTotal const scanning = lv.scanned != null && lv.scanTotal != null && lv.scanned < lv.scanTotal
return ( return (
<div className="acct-progress"> <div className="acct-progress">
<div className="pbar"> <div
<span className="pbar-fill" style={{ width: `${pct}%` }} /> className="pbar"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Copy progress${lv.folder ? `: ${lv.folder}` : ''}`}
>
<span className="pbar-fill" style={{ transform: `scaleX(${pct / 100})` }} />
</div> </div>
<span className="pmeta mono-num"> <span className="pmeta mono-num">
{done}/{lv.total} ({pct}%) · {lv.speed >= 1 ? Math.round(lv.speed) : lv.speed.toFixed(1)}/s · ETA {fmtDuration(eta)} {done}/{lv.total} ({pct}%) · {lv.speed >= 1 ? Math.round(lv.speed) : lv.speed.toFixed(1)}/s · ETA {fmtDuration(eta)}