many fixes
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": ["web/index.html"],
|
||||||
|
"insertBefore": "</body>",
|
||||||
|
"commentSyntax": "html",
|
||||||
|
"cspChecked": true
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
+63
@@ -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 соблюдаем осознанно.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user