Compare commits
6 Commits
722570c9d3
...
9df6a619d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df6a619d2 | ||
|
|
a9a46e949f | ||
|
|
aa9e289185 | ||
|
|
b6d4be97c1 | ||
|
|
a74ab95f3f | ||
|
|
5d75aa328d |
@@ -15,6 +15,14 @@ router = APIRouter()
|
|||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
def _next_sync_tick(request: Request):
|
||||||
|
scheduler = getattr(request.app.state, "scheduler", None)
|
||||||
|
return getattr(scheduler, "next_tick", None)
|
||||||
|
|
||||||
|
|
||||||
|
templates.env.globals["next_sync_tick"] = _next_sync_tick
|
||||||
|
|
||||||
|
|
||||||
def _get_user_or_404(repository: UserRepository, user_id: int) -> SyncUser:
|
def _get_user_or_404(repository: UserRepository, user_id: int) -> SyncUser:
|
||||||
user = repository.get(user_id)
|
user = repository.get(user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|||||||
39
app/web/static/app.js
Normal file
39
app/web/static/app.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #f4f5f9;
|
--bg: #12151a;
|
||||||
--surface: #ffffff;
|
--surface: #1a1f27;
|
||||||
--border: #e3e6ee;
|
--surface-raised: #232935;
|
||||||
--text: #1c2130;
|
--border: #2a3038;
|
||||||
--text-muted: #6b7185;
|
--text: #e7eaf0;
|
||||||
--primary: #4c5fd5;
|
--text-muted: #8b93a3;
|
||||||
--primary-hover: #3d4dc0;
|
--accent: #c8ff4d;
|
||||||
--danger: #d64550;
|
--danger: #ff5f6d;
|
||||||
--danger-bg: #fdeceb;
|
--danger-bg: rgba(255, 95, 109, 0.16);
|
||||||
--success: #1f9d63;
|
--success: #c8ff4d;
|
||||||
--success-bg: #e7f7ee;
|
--success-bg: rgba(200, 255, 77, 0.16);
|
||||||
--warning: #b8860b;
|
--warning: #ffb454;
|
||||||
--warning-bg: #fdf3d9;
|
--warning-bg: rgba(255, 180, 84, 0.16);
|
||||||
--neutral: #6b7185;
|
--neutral: #8b93a3;
|
||||||
--neutral-bg: #eef0f5;
|
--neutral-bg: rgba(139, 147, 163, 0.16);
|
||||||
--info: #2f7ec2;
|
--info: #5fd4ff;
|
||||||
--info-bg: #e8f2fb;
|
--info-bg: rgba(95, 212, 255, 0.16);
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
--shadow: 0 1px 2px rgba(28, 33, 48, 0.06), 0 1px 8px rgba(28, 33, 48, 0.04);
|
--shadow: none;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -33,7 +34,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--primary);
|
color: var(--info);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,14 @@ a:hover {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
.btn:focus-visible,
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -64,6 +73,13 @@ a:hover {
|
|||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar nav {
|
.topbar nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
@@ -76,14 +92,20 @@ a:hover {
|
|||||||
padding: 2rem 1.5rem 4rem;
|
padding: 2rem 1.5rem 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.4rem;
|
||||||
margin: 0 0 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
margin: 2rem 0 0.75rem;
|
margin: 2rem 0 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-actions {
|
.page-actions {
|
||||||
@@ -101,6 +123,24 @@ h2 {
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
padding: 1.25rem 1.4rem;
|
padding: 1.25rem 1.4rem;
|
||||||
margin-bottom: 1rem;
|
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 {
|
.user-list {
|
||||||
@@ -211,18 +251,18 @@ h2 {
|
|||||||
|
|
||||||
button, .btn {
|
button, .btn {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
background: var(--primary);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: var(--bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.45rem 0.9rem;
|
padding: 0.45rem 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover, .btn:hover {
|
button:hover, .btn:hover {
|
||||||
background: var(--primary-hover);
|
filter: brightness(0.88);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +273,8 @@ button.secondary, .btn.secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button.secondary:hover, .btn.secondary:hover {
|
button.secondary:hover, .btn.secondary:hover {
|
||||||
background: var(--bg);
|
background: var(--surface-raised);
|
||||||
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -256,6 +297,11 @@ table th {
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table td {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
table tr:last-child td {
|
table tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
@@ -274,6 +320,8 @@ dl.info-grid dt {
|
|||||||
|
|
||||||
dl.info-grid dd {
|
dl.info-grid dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
@@ -316,14 +364,20 @@ form.stacked-form label {
|
|||||||
|
|
||||||
form.stacked-form input[type="text"],
|
form.stacked-form input[type="text"],
|
||||||
form.stacked-form input[type="password"],
|
form.stacked-form input[type="password"],
|
||||||
form.stacked-form input[type="email"] {
|
form.stacked-form input[type="email"],
|
||||||
|
form.stacked-form input[type="number"] {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form.stacked-form input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
form.stacked-form button {
|
form.stacked-form button {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@@ -332,3 +386,33 @@ form.stacked-form button {
|
|||||||
.inline-form {
|
.inline-form {
|
||||||
display: inline;
|
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 ";
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,19 +9,26 @@
|
|||||||
<link rel="shortcut icon" href="/static/favicon.ico">
|
<link rel="shortcut icon" href="/static/favicon.ico">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<span class="brand"><img src="/static/logo.png" alt="" class="brand-logo" width="28" height="28">MyWhoosh → Garmin Sync</span>
|
<span class="brand"><img src="/static/logo.png" alt="" class="brand-logo" width="28" height="28">MyWhoosh → Garmin Sync</span>
|
||||||
<nav>
|
<div class="topbar-right">
|
||||||
{% if request.session.get('admin_authenticated') %}
|
<nav>
|
||||||
<a href="/">Dashboard</a>
|
{% if request.session.get('admin_authenticated') %}
|
||||||
<a href="/system">System</a>
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/system">System</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if request.session.get('self_service_user_id') %}
|
||||||
|
<a href="/account">My Account</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% set next_tick = next_sync_tick(request) %}
|
||||||
|
{% if next_tick %}
|
||||||
|
<span class="sync-status">Next sync: <time class="next-sync" datetime="{{ next_tick.isoformat() }}" data-utc="{{ next_tick.isoformat() }}">{{ next_tick.strftime('%Y-%m-%d %H:%M UTC') }}</time></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request.session.get('self_service_user_id') %}
|
</div>
|
||||||
<a href="/account">My Account</a>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|||||||
569
docs/superpowers/plans/2026-08-16-visual-redesign.md
Normal file
569
docs/superpowers/plans/2026-08-16-visual-redesign.md
Normal file
@@ -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="<ISO-8601 instant>"]` 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.
|
||||||
157
docs/superpowers/specs/2026-08-16-visual-redesign-design.md
Normal file
157
docs/superpowers/specs/2026-08-16-visual-redesign-design.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Visual Redesign — "Ride Computer" Dark Theme — Design
|
||||||
|
|
||||||
|
Date: 2026-08-16
|
||||||
|
Status: Draft for user review
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Replace the current light, generic admin-template look with a distinctive dark
|
||||||
|
"bike computer cockpit" theme grounded in the app's own subject matter (indoor
|
||||||
|
cycling sync, Garmin telemetry). The redesign is a shared foundation: it must
|
||||||
|
land before the planned dashboard summary tiles and live-update UX polish, so
|
||||||
|
those can be built directly in the new visual language instead of needing a
|
||||||
|
second pass.
|
||||||
|
|
||||||
|
This spec covers only the visual system and the already-shipped next-sync
|
||||||
|
indicator's evolution into a live countdown. It does not add new pages, new
|
||||||
|
data, or new business logic.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- New CSS design-token system (color, spacing reuse, borders) replacing the
|
||||||
|
current light theme in `app/web/static/style.css`.
|
||||||
|
- Cockpit-style topbar (dark, brand mark, nav, live sync countdown).
|
||||||
|
- Restyled shared components: cards, buttons, badges, tables, forms, empty
|
||||||
|
states — applied globally via shared CSS classes so every existing template
|
||||||
|
(login, account-login, dashboard, account detail/edit, users
|
||||||
|
detail/new/edit, system, sync result fragment) picks it up without
|
||||||
|
structural rewrites.
|
||||||
|
- Typographic treatment: uppercase tracked labels for headings/section labels
|
||||||
|
(extends the existing table-header pattern), tabular monospace numerals for
|
||||||
|
all stats/timestamps/counters.
|
||||||
|
- Evolving the existing static next-sync timestamp (`app/web/static/app.js`,
|
||||||
|
`base.html`) into a live, client-ticking countdown.
|
||||||
|
- Accessible focus states (visible lime outline) and `prefers-reduced-motion`
|
||||||
|
handling for the one animated element (the countdown) and card entrance.
|
||||||
|
|
||||||
|
### Out of scope (separate follow-up specs)
|
||||||
|
|
||||||
|
- Dashboard summary/stat tiles (next phase, builds on this theme).
|
||||||
|
- Live sync results without full page reload (UX-polish phase).
|
||||||
|
- Any light-mode / theme-toggle support (explicitly rejected by user — dark
|
||||||
|
only).
|
||||||
|
- New charts/statistics views.
|
||||||
|
- Any change to routes, models, or sync/business logic.
|
||||||
|
|
||||||
|
## 3. Design tokens
|
||||||
|
|
||||||
|
### Color
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#12151A` | Page background |
|
||||||
|
| `--surface` | `#1A1F27` | Cards, topbar, table zebra-free rows |
|
||||||
|
| `--surface-raised` | `#232935` | Hover states, inputs |
|
||||||
|
| `--border` | `#2A3038` | Hairline dividers/card borders |
|
||||||
|
| `--text` | `#E7EAF0` | Primary text |
|
||||||
|
| `--text-muted` | `#8B93A3` | Secondary text, labels, hints |
|
||||||
|
| `--accent` | `#C8FF4D` | Electric lime — signature countdown, primary buttons, focus rings, "healthy" status |
|
||||||
|
|
||||||
|
Semantic status colors (each with a ~14% opacity tint of the same hue over
|
||||||
|
`--surface` for badge backgrounds, matching the existing `--*-bg` variable
|
||||||
|
pattern already in `style.css`):
|
||||||
|
|
||||||
|
| State | Hex | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| Success / healthy | `--accent` `#C8FF4D` | Reuses the signature accent — a healthy sync *is* the good state the accent celebrates |
|
||||||
|
| Warning / degraded | `#FFB454` | Amber |
|
||||||
|
| Danger / action required / failed | `#FF5F6D` | Coral |
|
||||||
|
| Info / syncing / running | `#5FD4FF` | Cyan |
|
||||||
|
| Neutral / disabled | `#5A6472` | Slate |
|
||||||
|
|
||||||
|
These map 1:1 onto the existing `--success`, `--warning`, `--danger`,
|
||||||
|
`--info`, `--neutral` variable names already used by `.badge-*` classes in
|
||||||
|
`style.css` — only their values change, not the class structure, so templates
|
||||||
|
need no edits for badges.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- Body/UI face: unchanged system stack (`-apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", Roboto, Helvetica, Arial, sans-serif`) — no new font loads,
|
||||||
|
works offline in a self-hosted container.
|
||||||
|
- Monospace face for all numeric data (stats, timestamps, the countdown,
|
||||||
|
table numeric columns): `ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||||
|
"Liberation Mono", monospace`, with `font-variant-numeric: tabular-nums`.
|
||||||
|
- Headings and structural labels (`h1`/`h2`, `.badge`, table `th`, the new
|
||||||
|
topbar labels): uppercase, `letter-spacing: 0.06em`, extending the tracked
|
||||||
|
uppercase style `table th` already has today — applied consistently instead
|
||||||
|
of only in tables.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- Topbar becomes the cockpit header: `--surface` background, hairline bottom
|
||||||
|
border, brand mark left, nav + live countdown grouped right (existing
|
||||||
|
`.topbar-right` wrapper from the next-sync work is reused).
|
||||||
|
- Cards: hairline `--border` outline, `--surface` background, no drop shadow
|
||||||
|
(shadows read poorly on dark; hairlines carry the "device bezel" feel
|
||||||
|
instead). Radius stays at the existing `--radius: 10px`, unchanged.
|
||||||
|
- Buttons: primary uses `--accent` background with dark text (for contrast
|
||||||
|
against the light lime); secondary keeps outline/ghost style against
|
||||||
|
`--surface`.
|
||||||
|
- Focus-visible: 2px `--accent` outline on all interactive elements (links,
|
||||||
|
buttons, inputs) — dark backgrounds need this to stay accessible since the
|
||||||
|
current subtle browser default focus ring is hard to see on `--surface`.
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
|
||||||
|
- The live countdown ticks once per second (text content update only, no
|
||||||
|
layout shift).
|
||||||
|
- Cards fade/slide in ~150ms on initial page load, `translateY(4px) → 0`.
|
||||||
|
- Both respect `prefers-reduced-motion: reduce` (countdown text still updates,
|
||||||
|
since it's informational, not decorative; the card entrance animation is
|
||||||
|
skipped entirely).
|
||||||
|
|
||||||
|
## 4. Signature element: live sync countdown
|
||||||
|
|
||||||
|
`app/web/static/app.js` currently does a one-time UTC→local conversion of the
|
||||||
|
`time[data-utc]` element on `DOMContentLoaded`. It's extended to:
|
||||||
|
|
||||||
|
1. On load, read `data-utc` as the target instant.
|
||||||
|
2. If the target is in the future, start a `setInterval` (1s) that computes
|
||||||
|
the remaining duration and renders it as `HH:MM:SS` (or `MM:SS` under an
|
||||||
|
hour) in monospace, e.g. `NEXT SYNC ▸ 00:12:04`.
|
||||||
|
3. If the target is in the past (page left open past the sync time, or
|
||||||
|
`next_tick` not yet known on first boot), render `due now` instead of a
|
||||||
|
negative countdown.
|
||||||
|
4. The `title` attribute keeps showing the absolute local time (via
|
||||||
|
`toLocaleString()`) and the original UTC instant, so hovering still gives
|
||||||
|
an absolute reference — this preserves today's behavior as a fallback/aid.
|
||||||
|
5. `data-utc` stays the templating contract between server and client (same
|
||||||
|
attribute the current tests assert on), so no server-side route or test
|
||||||
|
changes are needed for this evolution — only `app.js` behavior and the
|
||||||
|
surrounding CSS/markup in `base.html` change.
|
||||||
|
|
||||||
|
No server-side change: `next_sync_tick()` in `app/web/routes.py` and the
|
||||||
|
`base.html` template variable wiring stay as they are; only the visual
|
||||||
|
presentation and `app.js` ticking logic change.
|
||||||
|
|
||||||
|
## 5. Testing
|
||||||
|
|
||||||
|
This is a CSS/JS-presentation change with one markup adjustment (countdown
|
||||||
|
wrapper element/label in `base.html`). Existing server-rendered tests assert
|
||||||
|
on `data-utc="..."` substrings and text content, not on CSS classes or exact
|
||||||
|
visual output, so no test breakage is expected. No new automated test is
|
||||||
|
meaningful for pure CSS token values; the existing
|
||||||
|
`tests/web/test_next_sync_display.py` continues to guard the server-side
|
||||||
|
contract (the attribute and value), and manual verification (screenshot) is
|
||||||
|
used to confirm the visual outcome, consistent with how the next-sync feature
|
||||||
|
was verified.
|
||||||
|
|
||||||
|
## 6. Rollout
|
||||||
|
|
||||||
|
Single pass across `style.css`, `base.html`, `app.js`. No template
|
||||||
|
restructuring needed beyond `base.html`'s topbar, since all other templates
|
||||||
|
already consume the shared `.card`, `.badge`, `button`/`.btn`, `table`, and
|
||||||
|
`form.stacked-form` classes this spec restyles centrally.
|
||||||
36
tests/web/test_next_sync_display.py
Normal file
36
tests/web/test_next_sync_display.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeScheduler:
|
||||||
|
def __init__(self, next_tick=None) -> None:
|
||||||
|
self.last_tick = None
|
||||||
|
self.next_tick = next_tick
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_shows_next_sync_time(app, client: TestClient) -> None:
|
||||||
|
next_tick = datetime(2026, 8, 16, 14, 32, tzinfo=timezone.utc)
|
||||||
|
app.state.scheduler = _FakeScheduler(next_tick=next_tick)
|
||||||
|
|
||||||
|
response = client.get("/login")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'data-utc="2026-08-16T14:32:00+00:00"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_shows_next_sync_time(app, authenticated_client) -> None:
|
||||||
|
next_tick = datetime(2026, 8, 16, 15, 0, tzinfo=timezone.utc)
|
||||||
|
app.state.scheduler = _FakeScheduler(next_tick=next_tick)
|
||||||
|
|
||||||
|
response = authenticated_client.get("/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'data-utc="2026-08-16T15:00:00+00:00"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_renders_without_scheduler(client: TestClient) -> None:
|
||||||
|
response = client.get("/login")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "data-utc" not in response.text
|
||||||
Reference in New Issue
Block a user