From 79fd200e579b406829dd5acf61362bbb2434262d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 11:18:40 +0700 Subject: [PATCH] many fixes --- .impeccable/live/config.json | 6 ++ DESIGN.md | 117 ++++++++++++++++++++++ PRODUCT.md | 63 ++++++++++++ internal/orchestrator/orchestrator.go | 3 +- internal/store/accounts.go | 9 ++ internal/store/accounts_test.go | 36 +++++++ web/src/app.css | 100 ++++++++++++++++-- web/src/components/FolderMappingModal.tsx | 1 + web/src/index.css | 6 +- web/src/pages/Endpoints.tsx | 2 +- web/src/pages/Login.tsx | 2 +- web/src/pages/TaskDetail.tsx | 21 ++-- 12 files changed, 348 insertions(+), 18 deletions(-) create mode 100644 .impeccable/live/config.json create mode 100644 DESIGN.md create mode 100644 PRODUCT.md diff --git a/.impeccable/live/config.json b/.impeccable/live/config.json new file mode 100644 index 0000000..f24af96 --- /dev/null +++ b/.impeccable/live/config.json @@ -0,0 +1,6 @@ +{ + "files": ["web/index.html"], + "insertBefore": "", + "commentSyntax": "html", + "cspChecked": true +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..1bed94e --- /dev/null +++ b/DESIGN.md @@ -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.3–0.5px. Page titles 34px, brand 22px, + login brand 30px. +- **Micro-labels:** uppercase, 10–12px, letter-spacing 0.08–0.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. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..e77cb17 --- /dev/null +++ b/PRODUCT.md @@ -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 соблюдаем осознанно. diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 136a873..8e9cf57 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -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, }}) _ = 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 // stops it); DB writes keep the parent ctx so status/counters persist even diff --git a/internal/store/accounts.go b/internal/store/accounts.go index bddb3a4..015bdaa 100644 --- a/internal/store/accounts.go +++ b/internal/store/accounts.go @@ -79,6 +79,15 @@ func (s *Store) SetAccountStatus(ctx context.Context, id int64, status string) e 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 { _, err := s.Pool.Exec(ctx, `UPDATE accounts SET copied_count=copied_count+$2, diff --git a/internal/store/accounts_test.go b/internal/store/accounts_test.go index 07b4fa9..0057e21 100644 --- a/internal/store/accounts_test.go +++ b/internal/store/accounts_test.go @@ -28,3 +28,39 @@ func TestMigratedIdempotency(t *testing.T) { 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) + } +} diff --git a/web/src/app.css b/web/src/app.css index 8238449..06bf824 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -245,8 +245,8 @@ } .link-btn.danger:hover { - color: var(--danger, #ff5c5c); - border-bottom-color: var(--danger, #ff5c5c); + color: var(--fail); + border-bottom-color: var(--fail); } .link-btn:disabled { @@ -272,8 +272,11 @@ .pbar-fill { display: block; height: 100%; + width: 100%; + transform: scaleX(0); + transform-origin: left; background: var(--accent); - transition: width 0.3s ease-out; + transition: transform 0.3s ease-out; } .pmeta { @@ -473,11 +476,32 @@ box-shadow: 0 0 16px -2px rgba(255, 178, 56, 0.6); } -.btn:disabled { +.btn:disabled, +.btn.is-disabled { opacity: 0.35; 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 { display: flex; gap: 10px; @@ -533,14 +557,14 @@ .badge-pending { color: var(--pending); - border-color: #4a4423; + border-color: var(--pending-dim); background: rgba(240, 196, 25, 0.06); } .badge-pending .dot { background: var(--pending); } .badge-info { color: var(--info); - border-color: #234456; + border-color: var(--info-dim); background: rgba(87, 194, 255, 0.06); } .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); 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; + } +} diff --git a/web/src/components/FolderMappingModal.tsx b/web/src/components/FolderMappingModal.tsx index bf6e03b..317e24f 100644 --- a/web/src/components/FolderMappingModal.tsx +++ b/web/src/components/FolderMappingModal.tsx @@ -63,6 +63,7 @@ export function FolderMappingModal({ open, srcFolders, dstFolders, initialMappin - {error &&
{error}
} + {error &&
{error}
}
-
{error}
+
{error}
) diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index 25fd733..d393809 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -330,7 +330,7 @@ export function TaskDetail({ id }: { id: number }) {
Run control -
+
{totals.copied} copied @@ -348,7 +348,7 @@ export function TaskDetail({ id }: { id: number }) { accounts
- {error &&
{error}
} + {error &&
{error}
}
+ @@ -432,7 +432,7 @@ export function TaskDetail({ id }: { id: number }) { clear log )} -
+
{log.length === 0 ? (
awaiting events over websocket…
) : ( @@ -501,8 +501,15 @@ export function TaskDetail({ id }: { id: number }) { const scanning = lv.scanned != null && lv.scanTotal != null && lv.scanned < lv.scanTotal return (
-
- +
+
{done}/{lv.total} ({pct}%) · {lv.speed >= 1 ? Math.round(lv.speed) : lv.speed.toFixed(1)}/s · ETA {fmtDuration(eta)}