From 9df6a619d280022fc10de8180b1d671b221198e6 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sun, 16 Aug 2026 11:03:22 +0200 Subject: [PATCH] Mark visual redesign plan tasks complete Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-16-visual-redesign.md | 569 ++++++++++++++++++ 1 file changed, 569 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-visual-redesign.md diff --git a/docs/superpowers/plans/2026-08-16-visual-redesign.md b/docs/superpowers/plans/2026-08-16-visual-redesign.md new file mode 100644 index 0000000..c186fbd --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-visual-redesign.md @@ -0,0 +1,569 @@ +# Visual Redesign ("Ride Computer" Dark Theme) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Replace the app's light, generic look with a distinctive dark "bike computer cockpit" theme, and evolve the existing next-sync timestamp into a live ticking countdown, without changing any Python route, model, or template markup. + +**Architecture:** A CSS design-token rewrite in `app/web/static/style.css` (color, typography, motion) that every existing template already picks up through shared classes (`.card`, `.badge`, `button`/`.btn`, `table`, `form.stacked-form`, `.sync-status`) — no template edits required. Separately, `app/web/static/app.js` gains a client-side countdown that ticks the already-rendered `time.next-sync[data-utc]` element down to zero, replacing the one-time UTC→local formatting it does today. + +**Tech Stack:** Plain CSS custom properties, vanilla JS (no new dependencies, no build step — matches the existing project convention of zero frontend tooling). + +**Spec:** `docs/superpowers/specs/2026-08-16-visual-redesign-design.md` + +## Global Constraints + +- No new dependencies, no build step — plain CSS and vanilla JS only (spec §2, §3). +- No route, model, or Python business-logic changes (spec §1). +- No template markup changes — every template already consumes the shared classes this plan restyles (spec §2, §6). `base.html`'s `.topbar-right` / `.sync-status` / `time.next-sync[data-utc]` structure from the prior next-sync feature is reused as-is. +- Dark theme only — no light-mode toggle (spec §2 "Out of scope"). +- `data-utc` stays the server↔client contract; `tests/web/test_next_sync_display.py` must keep passing unmodified (spec §4 point 5). +- `base.html` needs **no edits** in this plan: the `.topbar-right` wrapper, the `.sync-status` span, and the `time.next-sync[data-utc]` element it needs already exist from the earlier next-sync feature. The spec's §6 rollout mentions `base.html` as a file touched by this work; in practice the existing markup already satisfies every hook Task 1's CSS and Task 2's JS need, so no template diff is required — this is a positive scope reduction, not a gap. +- CSS token value changes and the countdown's visual behavior have **no meaningful automated test** — the user explicitly chose manual/browser verification (via chrome-devtools screenshots) over introducing a JS test runner for this work. This is a deliberate, human-approved exception to normal TDD practice for these two tasks specifically; it does not extend to any future task that adds real branching logic without the user's sign-off. + +--- + +### Task 1: Dark cockpit color, typography, and component tokens + +**Files:** +- Modify: `app/web/static/style.css` (complete rewrite — every rule below) +- Test: none (pure CSS token values — see Global Constraints) + +**Interfaces:** +- Consumes: nothing (leaf task, no code dependencies from other tasks). +- Produces: the `--bg`, `--surface`, `--surface-raised`, `--border`, `--text`, `--text-muted`, `--accent`, `--danger`, `--danger-bg`, `--success`, `--success-bg`, `--warning`, `--warning-bg`, `--neutral`, `--neutral-bg`, `--info`, `--info-bg`, `--radius`, `--shadow`, `--mono` custom properties and the `.sync-status` / `.sync-status .next-sync` selectors that Task 2's markup (already shipped in `base.html`) relies on for its visual presentation. + +- [x] **Step 1: Replace `app/web/static/style.css` with the new token system and components** + +Replace the entire file content with: + +```css +:root { + --bg: #12151a; + --surface: #1a1f27; + --surface-raised: #232935; + --border: #2a3038; + --text: #e7eaf0; + --text-muted: #8b93a3; + --accent: #c8ff4d; + --danger: #ff5f6d; + --danger-bg: rgba(255, 95, 109, 0.16); + --success: #c8ff4d; + --success-bg: rgba(200, 255, 77, 0.16); + --warning: #ffb454; + --warning-bg: rgba(255, 180, 84, 0.16); + --neutral: #8b93a3; + --neutral-bg: rgba(139, 147, 163, 0.16); + --info: #5fd4ff; + --info-bg: rgba(95, 212, 255, 0.16); + --radius: 10px; + --shadow: none; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +a { + color: var(--info); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +a:focus-visible, +button:focus-visible, +.btn:focus-visible, +input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.topbar { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0.9rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.topbar .brand { + display: flex; + align-items: center; + gap: 0.6rem; + font-weight: 700; + font-size: 1.05rem; + color: var(--text); +} + +.brand-logo { + display: block; + border-radius: 7px; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; +} + +.topbar nav { + display: flex; + gap: 1.25rem; + font-size: 0.9rem; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +h1, h2 { + text-transform: uppercase; + letter-spacing: 0.06em; +} + +h1 { + font-size: 1.4rem; + margin: 0 0 1.25rem; +} + +h2 { + font-size: 1rem; + margin: 2rem 0 0.75rem; + color: var(--text-muted); +} + +.page-actions { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.25rem 1.4rem; + margin-bottom: 1rem; + animation: card-in 150ms ease-out; +} + +@keyframes card-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .card { + animation: none; + } +} + +.user-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.user-card { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1.25rem; +} + +.user-card .user-main { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 220px; +} + +.user-card .user-name { + font-weight: 600; + font-size: 1rem; +} + +.user-card .user-meta { + color: var(--text-muted); + font-size: 0.85rem; + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.9rem; +} + +.user-card .user-actions { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.01em; + white-space: nowrap; +} + +.badge-healthy { + background: var(--success-bg); + color: var(--success); +} + +.badge-syncing { + background: var(--info-bg); + color: var(--info); +} + +.badge-degraded { + background: var(--warning-bg); + color: var(--warning); +} + +.badge-action_required { + background: var(--danger-bg); + color: var(--danger); +} + +.badge-disabled { + background: var(--neutral-bg); + color: var(--neutral); +} + +.badge-success { + background: var(--success-bg); + color: var(--success); +} + +.badge-partial { + background: var(--warning-bg); + color: var(--warning); +} + +.badge-failed, +.badge-error { + background: var(--danger-bg); + color: var(--danger); +} + +.badge-running { + background: var(--info-bg); + color: var(--info); +} + +.action-required { + color: var(--danger); + font-size: 0.85rem; +} + +button, .btn { + font: inherit; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 8px; + padding: 0.45rem 0.9rem; + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; +} + +button:hover, .btn:hover { + filter: brightness(0.88); + text-decoration: none; +} + +button.secondary, .btn.secondary { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); +} + +button.secondary:hover, .btn.secondary:hover { + background: var(--surface-raised); + filter: none; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} + +table th, table td { + text-align: left; + padding: 0.55rem 0.7rem; + border-bottom: 1px solid var(--border); +} + +table th { + color: var(--text-muted); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +table td { + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} + +table tr:last-child td { + border-bottom: none; +} + +dl.info-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.5rem 1.5rem; + margin: 0; +} + +dl.info-grid dt { + color: var(--text-muted); + font-size: 0.85rem; +} + +dl.info-grid dd { + margin: 0; + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} + +.error { + background: var(--danger-bg); + color: var(--danger); + padding: 0.6rem 0.9rem; + border-radius: 8px; + font-size: 0.9rem; +} + +.hint { + color: var(--text-muted); + font-size: 0.8rem; + margin: -0.4rem 0 0.6rem; +} + +.summary-error { + color: var(--danger); + font-size: 0.82rem; +} + +.empty-state { + color: var(--text-muted); + font-size: 0.9rem; + padding: 1rem 0; +} + +form.stacked-form { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-width: 420px; +} + +form.stacked-form label { + font-size: 0.85rem; + font-weight: 500; + margin-top: 0.6rem; +} + +form.stacked-form input[type="text"], +form.stacked-form input[type="password"], +form.stacked-form input[type="email"] { + font: inherit; + color: var(--text); + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +form.stacked-form input[type="checkbox"] { + accent-color: var(--accent); +} + +form.stacked-form button { + margin-top: 1rem; + align-self: flex-start; +} + +.inline-form { + display: inline; +} + +.sync-status { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.8rem; + border-radius: 999px; + background: var(--surface-raised); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + white-space: nowrap; +} + +.sync-status .next-sync { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-weight: 700; + font-size: 0.85rem; + letter-spacing: 0.02em; + color: var(--accent); + text-transform: none; +} + +.sync-status .next-sync::before { + content: "\25b8 "; +} +``` + +Notes on deliberate deviations from a literal reading of the spec, decided during implementation for consistency and to avoid template edits: +- `table td` and `dl.info-grid dd` get monospace/tabular-nums globally (not just "stats" cells) since every table and info-grid in this app is already timestamp/count/state data, and templates aren't being touched to add per-cell classes. Badges inherit this too (they sit inside `td`), which reads like an instrument-panel status readout rather than a problem. +- `--primary`/`--primary-hover` are removed (replaced by `--info` for links and `--accent` for buttons) since nothing in the templates references them directly (verified via grep — no inline `style="var(--...)"` usage anywhere). +- Button hover uses `filter: brightness(0.88)` instead of a second hardcoded accent hex, keeping the palette to the named tokens in the spec. + +- [x] **Step 2: Run the full test suite to confirm no regressions** + +Run: `.venv/Scripts/python -m pytest tests/ -q` +Expected: same pass count as before this change (202 passed; the pre-existing unrelated `tests/mywhoosh/test_tokenstore.py::test_tokenstore_round_trip_and_permissions` failure on Windows is expected and untouched by this task). + +- [x] **Step 3: Commit** + +```bash +git add app/web/static/style.css +git commit -m "Redesign UI with dark cockpit color and typography tokens" +``` + +--- + +### Task 2: Live-ticking sync countdown + +**Files:** +- Modify: `app/web/static/app.js` (complete rewrite) +- Test: none (see Global Constraints — user chose manual verification over introducing a JS test runner) + +**Interfaces:** +- Consumes: the `time.next-sync[data-utc=""]` element already rendered by `base.html` (from the previously shipped next-sync feature) and the `.sync-status` / `.sync-status .next-sync` CSS from Task 1. +- Produces: nothing consumed by a later task — this is the last code task. + +- [x] **Step 1: Replace `app/web/static/app.js` with the countdown implementation** + +```js +function formatCountdown(remainingMs) { + if (remainingMs <= 0) { + return "due now"; + } + const totalSeconds = Math.floor(remainingMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const pad = (n) => String(n).padStart(2, "0"); + if (hours > 0) { + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; + } + return `${pad(minutes)}:${pad(seconds)}`; +} + +function startSyncCountdown(el) { + const target = new Date(el.dataset.utc); + if (Number.isNaN(target.getTime())) { + return; + } + el.title = `${target.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${el.dataset.utc} UTC`; + + let intervalId = null; + const tick = () => { + const remaining = target.getTime() - Date.now(); + el.textContent = formatCountdown(remaining); + if (remaining <= 0 && intervalId !== null) { + clearInterval(intervalId); + } + }; + tick(); + if (target.getTime() - Date.now() > 0) { + intervalId = setInterval(tick, 1000); + } +} + +document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll("time.next-sync[data-utc]").forEach(startSyncCountdown); +}); +``` + +Why `intervalId` is declared with `let` before `tick` runs once synchronously: the first `tick()` call happens before `setInterval` returns, so if the target is already due on page load, `tick` must not call `clearInterval` on a not-yet-assigned `const` — that would throw a `ReferenceError`. Declaring `intervalId` as `let intervalId = null` up front and only scheduling the interval at all when the target is still in the future avoids the bug entirely (an already-due countdown just renders "due now" once and never starts ticking). + +- [x] **Step 2: Manually verify in a real browser via chrome-devtools** + +Start the app locally (same approach as the next-sync feature verification: temp SQLite DB, a valid `CREDENTIAL_ENCRYPTION_KEY` from `Fernet.generate_key()`, `ADMIN_PASSWORD`/`SECRET_KEY` set, `uvicorn app.main:create_app --factory`), then: +1. Navigate to `/login` and confirm the countdown ticks down every second (e.g. `04:58` → `04:57`). +2. Navigate to `/` (dashboard, after admin login) and confirm the same ticking countdown appears there too. +3. Edge case: temporarily set the fake/real scheduler's `next_tick` to a timestamp in the past (or wait past it) and reload — confirm the element shows `due now` instead of a negative or malformed countdown, and confirm no JS error appears in the DevTools console (`list_console_messages`). +4. Take a screenshot of the dashboard and the login page to visually confirm Task 1's dark theme and Task 2's countdown render together correctly. + +Expected: countdown ticks live, "due now" renders cleanly for an already-past target, no console errors, screenshots show the dark cockpit theme with the lime countdown readout in the topbar. + +- [x] **Step 3: Commit** + +```bash +git add app/web/static/app.js +git commit -m "Turn the next-sync indicator into a live ticking countdown" +``` + +--- + +### Task 3: Final full-suite regression check + +**Files:** +- None modified — verification only. + +**Interfaces:** +- Consumes: the completed state of Task 1 and Task 2. +- Produces: nothing (terminal task). + +- [x] **Step 1: Run the full Python test suite** + +Run: `.venv/Scripts/python -m pytest tests/ -q` +Expected: 202 passed, 1 pre-existing unrelated failure (`test_tokenstore_round_trip_and_permissions`, a Windows file-permission-bits issue predating this plan) — identical to the baseline recorded in Task 1 Step 2. + +- [x] **Step 2: Report completion to the user** + +Summarize what changed (dark cockpit theme across every page via shared CSS classes, live-ticking sync countdown) and point at the two screenshots taken in Task 2 Step 2 as evidence.