Web UI: Updated tab, inline errors, mobile card fixes (#3)

Third pass on the password-gated web UI, on top of #1 and #2.

## Updated tab
New `?tab=new` tab listing only series with an unread published chapter, plus per-tab empty states for Favourites and Updated. Covered by `TestUIListNewTab`.

## Inline error feedback
htmx does not swap on a non-2xx response, so a failed favourite/chapter/delete looked like an ignored tap. Errors now render in a `.error-inline` slot on the card and clear after 5s. The chapter-edit form and delete-confirm row also close each other — only one per-card panel open at a time.

## Mobile fixes (P0)
`.chapter-form` held three children on one unwrapped flex row, pushing Save off screen: **97px of page overflow at 390px, 127px at 360px**. That broke correcting a chapter number on the primary device class.

- `.chapter-form` and `.confirm-row` wrap; hint and prompt take their own full-width row
- `.chapter-form input` uses `flex: 1 1 0; min-width: 0` — with `flex: 1` (basis auto) a number input holds its ~20ch intrinsic width and refused to shrink, which pushed Save to a third row
- `white-space: nowrap` on the confirm prompt alone reintroduced 26px of overflow; the full-width row is what actually fixes it

Verified live: `document.body.scrollWidth <= window.innerWidth` with every chapter-form and confirm-row open, at 360/390/768/1280, light and dark.

## Icons
`☆ ✎ 🗑 ▶` replaced with hand-authored inline SVG on `currentColor` — the emoji font rendered each in a different face, weight, and colour, ignoring the card's own type and colour system. `.icon.on` / `.icon.danger` / `.primary` keep driving colour. No icon font or library added.

## No-cover empty state
Series whose source site gave no `og:image` render a title-initial monogram (`Bookmark.Initial()`) instead of a blank `--surface-2` rectangle that read as a cover still loading. Shared between the card cover and the Continue-reading strip.

## Verification
- `go build ./... && go test ./...` — pass
- Live interaction run: favourite toggle round-trips, chapter save 210.5→211, All/Updated/Favourites swap, delete-confirm removes the card, no console errors

Reviewed-on: #3
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #3.
This commit is contained in:
2026-07-27 00:42:19 +07:00
committed by sulthan
parent 62772e1eaa
commit a587b16423
12 changed files with 536 additions and 37 deletions
+23
View File
@@ -7,3 +7,26 @@ backend/server
graphify-out/ graphify-out/
plans/ plans/
docs/superpowers/ docs/superpowers/
# impeccable-ignore-start
# Ephemeral output, runtime state, and per-dev overrides.
# Unanchored: .impeccable may sit at the repo root or under a nested
# workspace (apps/web/.impeccable/...); anchored patterns would miss it.
# Shared artifacts stay tracked: config.json, live/config.json,
# design.json, critique/*.md.
.impeccable/config.local.json
.impeccable/hook.cache.json
.impeccable/hook.pending.json
.impeccable/*.png
.impeccable/live/server.json
.impeccable/live/sessions/
.impeccable/live/previews/
.impeccable/live/annotations/
.impeccable/live/cache/
.impeccable/live/manual-edit-apply-transaction.json
.impeccable/live/manual-edit-events.jsonl
.impeccable/live/manual-edit-evidence/
.impeccable/live/pending-manual-edits.json
.impeccable/live/deferred-svelte-component-accepts.json
.impeccable/live/*.png
# impeccable-ignore-end
+11
View File
@@ -87,6 +87,17 @@ Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unl
Smoke test: `curl` the endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight returns CORS headers and `/healthz` returns 200. Smoke test: `curl` the endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight returns CORS headers and `/healthz` returns 200.
## Forge: Gitea, not GitHub
`origin` is a self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` does not work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones:
- Open a PR: `tea pr create --head <branch> --base main --title "..." --description "..."`
- List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>`
- Issues: `tea issue create`, `tea issue list`
- Auth lives in `tea login`, not a `GH_TOKEN` env var.
`tea` prints its output as rendered boxes rather than plain text; the PR URL lands on the last line.
## Security invariants ## Security invariants
- Auth on `/bookmarks*`: require `Authorization: Bearer <API_TOKEN>`, **constant-time compare**, 401 otherwise. - Auth on `/bookmarks*`: require `Authorization: Bearer <API_TOKEN>`, **constant-time compare**, 401 otherwise.
+56
View File
@@ -0,0 +1,56 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
Single user (self-hosted, no accounts, no multi-user planned). Reads manga on **asurascans.com** and **demonicscans.org** primarily via Bromite on mobile, also checks/updates from a desktop browser. The web UI is the cross-device view into progress captured by the userscript while reading.
## Product Purpose
Tracks read-progress ("last chapter read") per manga series across two otherwise-unrelated manga sites that each have their own separate `localStorage`. A Go backend unifies bookmarks into one store; the web UI is a password-gated browser view of that store for reviewing, favouriting, correcting, or removing bookmarks, and jumping back into a series to continue reading. A background poller also refreshes each series' latest-published-chapter so the list can flag "NEW" without the user visiting the site.
## Positioning
Not a public reading tracker or social app — a private, self-hosted sync layer purpose-built for two specific scraped sites, with no server-side account system (single bearer token + one password-gated session).
## Operating Context
- Primary reading device: Bromite (mobile Chromium), where a userscript captures progress automatically.
- Web UI is a secondary surface: checking list state, correcting a wrong chapter number, removing dead bookmarks, jumping to "continue reading."
- Manga cover art and titles come from the source sites' `og:image`/`og:title` — real content, not placeholders.
- List order is driven by `updated_at`, which moves only on real reading progress (not favouriting, not a newly detected chapter) — a UI constraint the redesign must not break.
## Capabilities and Constraints
- Two tabs: All / Favourites. Search-filter by title (client-side, `filter.js`).
- Card actions: continue (opens source site), toggle favourite, manual chapter override, delete (with confirm).
- "Continue reading" horizontal strip for recently-progressed series.
- htmx-driven partial updates (card re-render on favourite/chapter/delete), no client-side framework/build step — templates are Go `html/template`, `go:embed`-ed.
- Mobile-first is a hard functional constraint (primary device is a phone), not just a starting breakpoint.
## Brand Commitments
- Name: **mangaBookmark**.
- **Dark-first is binding**: current dark-by-default / light-follows-system-preference behavior must be preserved as a design constraint, not just a starting default, because reading happens at night.
## Evidence on Hand
- Live templates/CSS at `backend/templates/*.html`, `backend/static/style.css` — current implemented UI, functional but not yet treated as an intentional design system.
- No logo, screenshots, or marketing copy exist; none should be fabricated.
## Product Principles
- Dark-first, night-reading-optimized — never regress to a light-default or high-glare surface.
- Mobile is the primary target; desktop is an enhancement, not the design center.
- Progress data integrity over visual flourish: `updated_at`/list-ordering behavior is a correctness constraint the UI must respect, not decorate over.
- No accounts, no multi-tenant chrome — the whole product is for one reader.
- Prefer native platform affordances (system dark/light, native touch targets) over custom widgetry — this is a lean self-hosted tool, not a product to demo.
## Accessibility & Inclusion
Explicit personal requirement: optimized for **low-light/night reading** — minimize glare and eye strain (true dark surfaces, restrained brightness/contrast on accents, no jarring pure-white flashes), beyond generic touch-target/contrast compliance.
@@ -0,0 +1,52 @@
---
target: backend/templates (web UI)
total_score: 21
max_score: 40
na_heuristics:
p0_count: 0
p1_count: 2
timestamp: 2026-07-26T15-50-42Z
slug: backend-templates-app-html
---
Method: dual-agent (A: ab4734f79f4becbf2 · B: af2c412345f87a2d1)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|---|---|---|
| 1 | Visibility of System Status | 2 | No hx-indicator on any htmx mutation — favourite/save/delete swap silently |
| 2 | Match System/Real World | 3 | Icon-only actions rely on title= tooltips, which never fire on touch |
| 3 | User Control and Freedom | 2 | Favourite-toggle and chapter-save are instant, no undo |
| 4 | Consistency and Standards | 3 | Native unstyled confirm() for delete breaks from themed htmx-swap pattern |
| 5 | Error Prevention | 2 | Chapter input has no max, no reference to LatestChapter while editing |
| 6 | Recognition Rather Than Recall | 3 | Good persistent state; edit form doesn't show current latest chapter |
| 7 | Flexibility and Efficiency | 1 | No bulk actions, no shortcuts, no filter for "has new chapter" |
| 8 | Aesthetic/Minimalist Design | 3 | Lean; .meta/.actions rows lack visual grouping |
| 9 | Error Recovery | 1 | No failed-request UI state anywhere |
| 10 | Help and Documentation | 1 | Zero affordance explains the favourite/NEW-don't-reorder rule |
Total: 21/40 — Acceptable band
## Design Specificity Verdict
Generic-competent. Nothing signals manga/night-reading/two-site-unification beyond the header text. site-{{.Site}} badge (the actual product differentiator) styled as a neutral pill, same weight as everything else.
Deterministic scan: CLI static-HTML detector 0 findings (exit 0). Browser/live detector found: tight-leading (line-height 1.25, real), overused-font/single-font (100% Roboto), flat-type-hierarchy advisory (~1.5:1 ratio). One text-occlusion hit was a false positive (detector's own leftover overlay text).
## Priority Issues
P1 — Silent failure on every htmx mutation (no hx-indicator, no error UI). Fix: hx-indicator + htmx:responseError inline error text.
P1 — NEW-chapter signal has no visual priority/filter despite poller existing for exactly this. Fix: elevate .new styling, add "Updated" quick-filter.
P2 — Native confirm() on delete breaks dark theme, violates "no pure-white flash" a11y requirement. Fix: inline htmx confirm row.
P2 — Chapter input has no upper bound/reference to LatestChapter. Fix: show "Latest: Ch N" inline, soft max warning.
P3 — Single font, flat type scale, line-height under 1.3 floor. Fix: real typographic hierarchy.
## Persona Red Flags
Casey (mobile, primary): 4 stacked chrome regions before content; touch-only tooltips never fire; silent failures invite repeat-tapping.
Riley (stress): webkit-only line-clamp with no fallback; no-cover cards render blank with no placeholder; filter.js untested at scale.
## Minor Observations
Continue-strip not tab-scoped. Fixed accent colors unverified for contrast against light theme. title= tooltips dead weight on touch.
## Questions to Consider
1. Why does the NEW-chapter signal carry less visual weight than the static site badge?
2. Why is confirm() (guaranteed to break theme) still unreplaced given the explicit no-white-flash requirement?
3. Does the ordering rule need an explanation, or does needing one mean the mental model fights the user?
@@ -0,0 +1,51 @@
---
target: backend/templates (web UI)
total_score: 26
max_score: 40
na_heuristics:
p0_count: 1
p1_count: 0
timestamp: 2026-07-26T17-08-41Z
slug: backend-templates-app-html
---
Method: dual-agent (A: a67675dd3039e8156 · B: aa64e48aafb9db799), regression independently re-verified live
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|---|---|---|
| 1 | Visibility of System Status | 3 | htmx dimming/error-inline verified live and working |
| 2 | Match System/Real World | 4 | "Continue reading," "Updated," site names all read naturally |
| 3 | User Control and Freedom | 3 | Cancel works; no undo after delete (acceptable, single-user tool) |
| 4 | Consistency and Standards | 2 | Disclosure-toggle pattern consistent; bare-emoji icons vs. tuned typography mismatch |
| 5 | Error Prevention | 2 | Chapter input still has no max, hint is prose-only |
| 6 | Recognition Rather Than Recall | 2 | The hint meant to aid recall is what causes the overflow bug |
| 7 | Flexibility and Efficiency | 3 | Search, tabs, direct Continue link present |
| 8 | Aesthetic/Minimalist Design | 2 | Clean tokens undercut by a live horizontal-scroll bug and flat no-cover placeholder |
| 9 | Error Recovery | 3 | "No connection - try again" verified firing on network abort |
| 10 | Help and Documentation | 2 | No help affordance beyond hover tooltips |
Total: 26/40 - Acceptable band. Trend: 21 -> 26.
## Design Specificity Verdict
Real measured progress: site-tinted badges and --new ribbon hue verified via computed contrast (5.3-9.0:1), genuine product-specific signal. Working against it: bare OS-emoji icons (star/pencil/trash/play) clash with tuned typography; no-cover placeholder still a flat contentless box.
Deterministic scan: CLI 0 findings (clean). Live detector: overused-font/single-font expected (deliberate single-typeface Operate surface). flat-type-hierarchy already accepted. monotonous-spacing and text-occlusion confirmed FALSE POSITIVES (detector's own accumulated debug overlays). edge-flush-cards on Continue-reading strip likely false positive (deliberate scrollable carousel with edge-bleed). ai-color-palette flagging new site-tint hues - plausible false positive (contrast-verified, restrained) but unconfirmed.
## Priority Issues
P0 - Chapter-edit form causes 97-127px horizontal page overflow on mobile, Save button pushed off-screen. .chapter-form has display:flex with no wrap, now holds 3 children (hint/input/button) in one unwrapped row. Confirmed live at 390px and 360px. Regression from the harden command - hint text was verified to render but never checked for container overflow at mobile width. Fix: flex-wrap:wrap + .hint{flex:1 0 100%}, same pattern already used in .actions/.confirm-row.
P3 - Icon-glyph inconsistency: bare emoji vs tuned type/color system.
P3 - No-cover placeholder reads as stuck loading skeleton, not designed empty state.
P3 - .confirm-row text wraps awkwardly ("Remove/this?") - cosmetic only, no overflow.
## Persona Red Flags
Casey (mobile, primary): chapter-form overflow directly hits her core task (one-handed chapter correction), unexpected sideways scroll needed.
Sam (accessibility): well-served overall, but no custom :focus-visible on icon/action buttons.
## Minor Observations
Continue-reading strip still not tab-scoped. updated_at ordering verified live via actual favourite-toggle round-trip. ai-color-palette on site tints worth a second glance, unconfirmed either way.
## Questions to Consider
1. Did the harden command's verification ever check mobile viewport width with the hint text present, or only that it rendered?
2. Is the emoji-icon look deliberate "undesigned" for a single-user tool, or just expedient?
3. Is a missing og:image common enough in practice to deserve real empty-state design?
+44 -1
View File
@@ -25,9 +25,52 @@ function setActiveTab(el) {
}); });
} }
// The chapter-edit form and the delete confirm row are the two per-card
// disclosure panels; only one makes sense open at a time.
function closeCardPanels(key) {
var form = document.getElementById("chapter-form-" + key);
var confirm = document.getElementById("confirm-row-" + key);
if (form) form.hidden = true;
if (confirm) confirm.hidden = true;
}
function toggleChapterForm(key) { function toggleChapterForm(key) {
var form = document.getElementById("chapter-form-" + key); var form = document.getElementById("chapter-form-" + key);
if (!form) return; if (!form) return;
form.hidden = !form.hidden; var opening = form.hidden;
closeCardPanels(key);
form.hidden = !opening;
if (!form.hidden) form.querySelector("input").focus(); if (!form.hidden) form.querySelector("input").focus();
} }
function toggleConfirmRow(key) {
var row = document.getElementById("confirm-row-" + key);
if (!row) return;
var opening = row.hidden;
closeCardPanels(key);
row.hidden = !opening;
}
// A failed favourite/chapter/delete request leaves the card in place (htmx
// does not swap on a non-2xx response) but otherwise gives no sign anything
// went wrong. Surface it inline instead of leaving the tap looking ignored.
(function () {
function showError(elt, message) {
var card = elt.closest(".card");
var slot = card && card.querySelector(".error-inline");
if (!slot) return;
slot.textContent = message;
slot.hidden = false;
clearTimeout(slot._hideTimer);
slot._hideTimer = setTimeout(function () {
slot.hidden = true;
}, 5000);
}
document.body.addEventListener("htmx:responseError", function (e) {
showError(e.detail.elt, "Couldn't save — try again.");
});
document.body.addEventListener("htmx:sendError", function (e) {
showError(e.detail.elt, "No connection — try again.");
});
})();
+194 -22
View File
@@ -8,11 +8,28 @@
--text: #e8eaed; --text: #e8eaed;
--muted: #9aa1ac; --muted: #9aa1ac;
--accent: #6aa9ff; --accent: #6aa9ff;
--accent-ink: #0b1220;
--danger: #ff6a6a; --danger: #ff6a6a;
--danger-ink: #2a0a0a;
--star: #ffc857; --star: #ffc857;
/* A newly-published chapter is its own signal, distinct from "tap to act"
(accent) or "favourited" (star) — a third hue keeps those three meanings
from collapsing into one overloaded blue. */
--new: #34d8a6;
--new-ink: #0b1220;
/* Per-site tint so the two source sites this app exists to unify are
still tellable apart at a glance, not just by reading the label text. */
--site-asura-bg: #3a2a20;
--site-asura-text: #ffb37a;
--site-demonic-bg: #2e2440;
--site-demonic-text: #c9a8ff;
--radius: 12px; --radius: 12px;
} }
/* Light theme darkens the four semantic hues (and flips their ink) rather
than reusing the dark-theme values verbatim: the originals were tuned to
sit on near-black surfaces and fall well under WCAG AA the moment the
surface goes to white — verified via computed contrast, not eyeballed. */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
--bg: #f4f5f7; --bg: #f4f5f7;
@@ -20,6 +37,17 @@
--surface-2: #eceef2; --surface-2: #eceef2;
--text: #1a1d22; --text: #1a1d22;
--muted: #5d646e; --muted: #5d646e;
--accent: #1f5fd6;
--accent-ink: #ffffff;
--danger: #b42323;
--danger-ink: #ffffff;
--star: #7d5f00;
--new: #087a5c;
--new-ink: #ffffff;
--site-asura-bg: #fde8d8;
--site-asura-text: #8a4a1f;
--site-demonic-bg: #f0e6fa;
--site-demonic-text: #6b2fa8;
} }
} }
@@ -57,7 +85,7 @@ button, .primary, [role="tab"] {
border-radius: var(--radius); border-radius: var(--radius);
} }
.login-card h1 { margin: 0 0 20px; font-size: 1.25rem; } .login-card h1 { margin: 0 0 20px; font-size: 1.25rem; font-weight: 700; letter-spacing: -.01em; }
.login-card label { display: block; margin-bottom: 6px; color: var(--muted); font-size: .875rem; } .login-card label { display: block; margin-bottom: 6px; color: var(--muted); font-size: .875rem; }
.login-card input { .login-card input {
@@ -73,7 +101,7 @@ button, .primary, [role="tab"] {
} }
.login-card input:focus-visible { outline: 2px solid var(--accent); } .login-card input:focus-visible { outline: 2px solid var(--accent); }
.login-card button { width: 100%; background: var(--accent); color: #0b1220; font-weight: 600; } .login-card button { width: 100%; background: var(--accent); color: var(--accent-ink); font-weight: 600; }
.error { margin: 0 0 12px; color: var(--danger); font-size: .875rem; } .error { margin: 0 0 12px; color: var(--danger); font-size: .875rem; }
/* --- chrome --- */ /* --- chrome --- */
@@ -86,7 +114,7 @@ button, .primary, [role="tab"] {
padding: 12px 0; padding: 12px 0;
} }
.topbar h1 { margin: 0; font-size: 1.125rem; } .topbar h1 { margin: 0; font-size: 1.125rem; font-weight: 700; letter-spacing: -.01em; }
.ghost { padding: 0 12px; background: var(--surface-2); color: var(--muted); } .ghost { padding: 0 12px; background: var(--surface-2); color: var(--muted); }
.search { .search {
@@ -114,11 +142,12 @@ button, .primary, [role="tab"] {
text-decoration: none; text-decoration: none;
} }
.tabs [role="tab"].active { background: var(--accent); color: #0b1220; font-weight: 600; } .tabs [role="tab"].active { background: var(--accent); color: var(--accent-ink); font-weight: 600; }
/* --- continue reading --- */ /* --- continue reading --- */
.recent h2 { margin: 0 0 8px; font-size: .8125rem; text-transform: uppercase; color: var(--muted); } .recent h2 { margin: 0 0 8px; font-size: .8125rem; font-weight: 600; text-transform: uppercase;
letter-spacing: .04em; color: var(--muted); }
.recent-strip { .recent-strip {
display: flex; display: flex;
@@ -141,10 +170,21 @@ button, .primary, [role="tab"] {
text-decoration: none; text-decoration: none;
} }
.recent-card img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 8px; } /* A reserved slot even without cover art, so the ribbon always has a
.recent-title { display: block; margin-top: 6px; font-size: .8125rem; line-height: 1.25; dedicated area to sit in instead of overlapping the title text. */
.recent-cover {
position: relative;
display: block;
aspect-ratio: 3 / 4;
background: var(--surface-2);
border-radius: 8px;
overflow: hidden;
}
.recent-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.recent-title { display: block; margin-top: 6px; font-size: .8125rem; font-weight: 600; line-height: 1.35;
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.recent-chapter { display: block; color: var(--muted); font-size: .75rem; } .recent-chapter { display: block; color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
/* --- list --- */ /* --- list --- */
@@ -152,44 +192,140 @@ button, .primary, [role="tab"] {
.card { .card {
display: grid; display: grid;
grid-template-columns: 72px 1fr; grid-template-columns: 96px 1fr;
gap: 12px; gap: 12px;
padding: 10px; padding: 10px;
background: var(--surface); background: var(--surface);
border-radius: var(--radius); border-radius: var(--radius);
} }
.card .cover img { width: 72px; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 8px; } .card .cover {
position: relative;
display: block;
width: 96px;
aspect-ratio: 3 / 4;
background: var(--surface-2);
border-radius: 8px;
overflow: hidden;
}
.card .cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* No og:image was ever scraped for this series. A bare --surface-2 rectangle
read as a cover still loading; the title's initial says the slot is filled
and nothing more is coming. Muted, not accent — a missing cover is an
absence, not a signal, and it must never outrank the New ribbon it sits
under. The two cover contexts share this rule and differ only in size. */
.cover-blank {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
font-size: 2rem;
font-weight: 700;
line-height: 1;
letter-spacing: -.02em;
opacity: .65;
user-select: none;
}
.recent-cover .cover-blank { font-size: 1.625rem; }
.card .body { min-width: 0; } .card .body { min-width: 0; }
.card .title { margin: 0 0 4px; font-size: 1rem; line-height: 1.25; } /* 17px, not the 16px body floor: the title is the one thing per card the
squint test must catch first, so it needs a size step of its own, not
just the browser's default h3 bold riding on an unchanged size. */
.card .title { margin: 0 0 4px; font-size: 1.0625rem; font-weight: 700; line-height: 1.35; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px; .meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px;
font-size: .75rem; color: var(--muted); } font-size: .75rem; color: var(--muted); }
.site { padding: 2px 6px; background: var(--surface-2); border-radius: 6px; text-transform: uppercase; } .site { padding: 2px 6px; background: var(--surface-2); border-radius: 6px; text-transform: uppercase;
.new { padding: 2px 6px; background: var(--accent); color: #0b1220; border-radius: 6px; font-weight: 700; } letter-spacing: .03em; }
/* Per-site tint: the two source sites this app unifies are otherwise
identical chrome, so this is the one glanceable "which site" cue beyond
the label text itself. */
.site-asura { background: var(--site-asura-bg); color: var(--site-asura-text); }
.site-demonic { background: var(--site-demonic-bg); color: var(--site-demonic-text); }
/* Read progress is data, not metadata, so it takes the body text color
instead of the muted tone the label pills around it use. */
.chapter { color: var(--text); font-variant-numeric: tabular-nums; }
.new-chapter { color: var(--new); font-weight: 600; font-variant-numeric: tabular-nums; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; } /* The signal the poller exists to surface: a corner ribbon on the cover
itself outranks any line of card metadata, so it reads on the squint
test even in a dense grid, not just on close inspection. Its own hue
(not accent) keeps "new chapter" distinct from "tap to act". */
.new-ribbon {
position: absolute;
top: 6px;
left: 6px;
padding: 2px 7px;
background: var(--new);
color: var(--new-ink);
font-size: .6875rem; /* 11px: the functional-text legibility floor */
font-weight: 700;
letter-spacing: .02em;
text-transform: uppercase;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, .35);
}
.new-ribbon-sm { top: 4px; left: 4px; padding: 1px 5px; }
/* One row of equal-sized icon buttons: Continue no longer stretches to
fill leftover space (that was both the wrap bug at narrow widths and
what made it read as arbitrarily oversized). It carries the same 44px
footprint as the utility icons and is told apart by fill color alone.
Grid columns capped at 44px, not flex items fixed at 44px: on the
narrowest real Android widths four 44px targets plus gaps don't quite
fit beside a 96px cover, and minmax(0, 44px) lets the row compress a
couple of px instead of wrapping — imperceptible, and never wraps. */
.actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 44px));
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--surface-2);
}
.primary { .primary {
flex: 1 1 auto;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 0 14px;
background: var(--accent); background: var(--accent);
color: #0b1220; color: var(--accent-ink);
font-weight: 600; font-size: 1.125rem;
text-decoration: none; text-decoration: none;
} }
.icon { width: 44px; background: var(--surface-2); color: var(--text); font-size: 1.125rem; } .icon { display: grid; place-items: center; padding: 0;
background: var(--surface-2); color: var(--text); font-size: 1.125rem; }
.icon.on { color: var(--star); } .icon.on { color: var(--star); }
.icon.danger { color: var(--danger); } .icon.danger { color: var(--danger); }
.chapter-form { display: flex; gap: 8px; margin-top: 8px; } /* Hand-drawn inline SVG rather than the OS emoji font: ☆ ✎ 🗑 ▶ each render in
a different face at a different optical weight and colour (some fonts force
their own), which is the one place the card ignored its own type and colour
system. currentColor keeps them on .icon / .icon.on / .icon.danger /
.primary without a second palette. 20px, not the 1.125rem font-size the
glyphs used: a glyph inks about two thirds of its em box, so matching the
old optical size means an SVG a step larger than the font-size it replaces. */
.icon svg, .primary svg { width: 20px; height: 20px; display: block; }
/* Wraps, like .actions and .confirm-row: the hint claims its own full-width
row (flex-basis 100%) so input + Save keep the whole card width below it.
Without the wrap the three children fought for one unwrapped row and pushed
Save off-screen — ~97px of page overflow at 390px, 127px at 360px. */
.chapter-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.chapter-form input { .chapter-form input {
flex: 1; /* flex-basis 0 + min-width 0, not flex: 1: a number input's default
intrinsic width is ~20 characters, so on `flex: 1` (basis auto) it refused
to shrink and pushed Save onto a third row. A chapter number needs five
characters, and the field can have whatever is left over. */
flex: 1 1 0;
min-width: 0;
min-height: 44px; min-height: 44px;
padding: 0 12px; padding: 0 12px;
background: var(--surface-2); background: var(--surface-2);
@@ -197,16 +333,52 @@ button, .primary, [role="tab"] {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius); border-radius: var(--radius);
font: inherit; font: inherit;
font-variant-numeric: tabular-nums;
} }
.chapter-form button { padding: 0 14px; background: var(--accent); color: #0b1220; font-weight: 600; } .chapter-form button { padding: 0 14px; background: var(--accent); color: var(--accent-ink); font-weight: 600; }
/* flex: 1 0 100% — own row above the field. The margin-bottom is 0 because the
parent's 8px gap already spaces it; keeping both double-spaced the hint. */
.hint { flex: 1 0 100%; margin: 0; font-size: .75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
/* Delete confirm, themed in place of a native confirm() dialog: that OS
chrome renders outside the app's own dark surface, breaking the
night-reading brand constraint at the one moment attention matters most. */
.confirm-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
/* Own full-width row, same as .chapter-form's hint. Sharing one row with both
buttons broke the prompt into "Remove / this?" while its own answer sat
whole beside it; nowrap alone only converted that into 26px of page
overflow, because a nowrap flex item will not shrink past its text. */
.confirm-row span { flex: 1 0 100%; font-size: .8125rem; white-space: nowrap; }
.confirm-row .danger-solid { padding: 0 14px; background: var(--danger); color: var(--danger-ink); font-weight: 600; }
.confirm-row button[type="button"] { padding: 0 14px; background: var(--surface-2); color: var(--text); }
.empty { padding: 32px 12px; text-align: center; color: var(--muted); } .empty { padding: 32px 12px; text-align: center; color: var(--muted); }
/* --- mutation feedback --- */
/* htmx adds .htmx-request to the element named by hx-indicator while a
request is in flight; dimming the whole card is the visible "it's working"
signal the templates otherwise never give. */
.card.htmx-request { opacity: .6; }
.error-inline {
margin: 8px 0 0;
padding: 6px 10px;
background: var(--surface-2);
color: var(--danger);
border-radius: 8px;
font-size: .8125rem;
}
.error-inline[hidden] { display: none; }
/* Cards hidden by the search filter. The .chapter-form { display: flex } /* Cards hidden by the search filter. The .chapter-form { display: flex }
rule above has equal specificity to the UA [hidden] rule, so without this rule above has equal specificity to the UA [hidden] rule, so without this
override the form stays visible even when the hidden attribute is set. */ override the form stays visible even when the hidden attribute is set. */
.card[hidden] { display: none; } .card[hidden] { display: none; }
.chapter-form[hidden] { display: none; } .chapter-form[hidden] { display: none; }
.confirm-row[hidden] { display: none; }
/* --- wide screens --- */ /* --- wide screens --- */
+8 -1
View File
@@ -25,6 +25,9 @@
<a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}" <a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML" hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a> hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
<a role="tab" href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated</a>
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}" <a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML" hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a> hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
@@ -36,7 +39,11 @@
<div class="recent-strip"> <div class="recent-strip">
{{range .Recent}} {{range .Recent}}
<a class="recent-card" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"> <a class="recent-card" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}} <span class="recent-cover">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="cover-blank" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="new-ribbon new-ribbon-sm">New</span>{{end}}
</span>
<span class="recent-title">{{.Title}}</span> <span class="recent-title">{{.Title}}</span>
<span class="recent-chapter">Ch {{.LastChapter}}</span> <span class="recent-chapter">Ch {{.LastChapter}}</span>
</a> </a>
+42 -10
View File
@@ -1,37 +1,69 @@
{{define "card"}} {{define "card"}}
<article class="card" id="card-{{.Key}}" data-title="{{.Title}}"> <article class="card" id="card-{{.Key}}" data-title="{{.Title}}">
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"> <a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}} {{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="cover-blank" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="new-ribbon">New</span>{{end}}
</a> </a>
<div class="body"> <div class="body">
<h3 class="title">{{.Title}}</h3> <h3 class="title">{{.Title}}</h3>
<p class="meta"> <p class="meta">
<span class="site site-{{.Site}}">{{.Site}}</span> <span class="site site-{{.Site}}">{{.Site}}</span>
<span class="chapter">Ch {{.LastChapter}}</span> <span class="chapter">Ch {{.LastChapter}}</span>
{{if .HasNewChapter}}<span class="new">NEW {{.LatestChapter}}</span>{{end}} {{if .HasNewChapter}}<span class="new-chapter">Ch {{.LatestChapter}} out</span>{{end}}
</p> </p>
<div class="actions"> <div class="actions">
<a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">Continue</a> <a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
title="Continue reading" aria-label="Continue reading">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M8 4.8a1 1 0 0 1 1.52-.85l10 7.2a1 1 0 0 1 0 1.7l-10 7.2A1 1 0 0 1 8 19.2z"/>
</svg>
</a>
<button class="icon {{if .Favorite}}on{{end}}" <button class="icon {{if .Favorite}}on{{end}}"
title="Favourite" aria-label="Toggle favourite" title="Favourite" aria-label="Toggle favourite"
hx-post="/ui/bookmarks/{{.Key}}/favorite" hx-post="/ui/bookmarks/{{.Key}}/favorite"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"> hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
{{if .Favorite}}★{{else}}☆{{end}} hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="{{if .Favorite}}currentColor{{else}}none{{end}}"
stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"
aria-hidden="true" focusable="false">
<path d="M12 3.7l2.63 5.33 5.87.86-4.25 4.14 1 5.85L12 17.31l-5.25 2.57 1-5.85L3.5 9.89l5.87-.86z"/>
</svg>
</button> </button>
<button class="icon" title="Set chapter" aria-label="Set chapter" <button class="icon" title="Set chapter" aria-label="Set chapter"
onclick="toggleChapterForm('{{.Key}}')">✎</button> onclick="toggleChapterForm('{{.Key}}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 20h4L19.3 8.7a1.5 1.5 0 0 0 0-2.12l-1.88-1.88a1.5 1.5 0 0 0-2.12 0L4 16z"/>
<path d="M13.8 7.2l3 3"/>
</svg>
</button>
<button class="icon danger" title="Remove" aria-label="Remove" <button class="icon danger" title="Remove" aria-label="Remove"
hx-delete="/ui/bookmarks/{{.Key}}" onclick="toggleConfirmRow('{{.Key}}')">
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
hx-confirm="Remove {{.Title}} from the list?">🗑</button> stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4.5 6.8h15M9.8 6.8V4.6h4.4v2.2M7.3 6.8l.9 12.6h7.6l.9-12.6"/>
</svg>
</button>
</div> </div>
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden <form class="chapter-form" id="chapter-form-{{.Key}}" hidden
hx-post="/ui/bookmarks/{{.Key}}/chapter" hx-post="/ui/bookmarks/{{.Key}}/chapter"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"> hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button">
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
<input name="chapter" type="number" step="0.1" min="0" <input name="chapter" type="number" step="0.1" min="0"
value="{{.LastChapterNum}}" aria-label="Chapter number" required> value="{{.LastChapterNum}}" aria-label="Chapter number" required>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
<div class="confirm-row" id="confirm-row-{{.Key}}" hidden>
<span>Remove this?</span>
<button class="danger-solid"
hx-delete="/ui/bookmarks/{{.Key}}"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Remove</button>
<button type="button" onclick="toggleConfirmRow('{{.Key}}')">Cancel</button>
</div>
<p class="error-inline" hidden></p>
</div> </div>
</article> </article>
{{end}} {{end}}
+4
View File
@@ -1,6 +1,10 @@
{{define "list"}} {{define "list"}}
{{if .Items}} {{if .Items}}
{{range .Items}}{{template "card" .}}{{end}} {{range .Items}}{{template "card" .}}{{end}}
{{else if eq .Tab "fav"}}
<p class="empty">No favourites yet. Star a series to pin it here.</p>
{{else if eq .Tab "new"}}
<p class="empty">Nothing new. Every series is caught up to its latest chapter.</p>
{{else}} {{else}}
<p class="empty"> <p class="empty">
Nothing here yet. Bookmarks appear once the userscript records a chapter. Nothing here yet. Bookmarks appear once the userscript records a chapter.
+21 -3
View File
@@ -35,11 +35,21 @@ type webHandler struct {
// listView is what every list-rendering template receives. // listView is what every list-rendering template receives.
type listView struct { type listView struct {
Tab string // "all" or "fav" Tab string // "all", "fav", or "new"
Recent []Bookmark Recent []Bookmark
Items []Bookmark Items []Bookmark
} }
// Initial is the monogram the templates show in place of a cover when the
// source site never gave us an og:image. First rune, uppercased; "?" when even
// the title is missing, so the slot is never empty.
func (b Bookmark) Initial() string {
for _, r := range b.Title {
return strings.ToUpper(string(r))
}
return "?"
}
// loginView is what the login template receives. // loginView is what the login template receives.
type loginView struct { type loginView struct {
Error string Error string
@@ -150,14 +160,22 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
} }
items := all items := all
if tab == "fav" { switch tab {
case "fav":
items = []Bookmark{} items = []Bookmark{}
for _, b := range all { for _, b := range all {
if b.Favorite { if b.Favorite {
items = append(items, b) items = append(items, b)
} }
} }
} else { case "new":
items = []Bookmark{}
for _, b := range all {
if b.HasNewChapter() {
items = append(items, b)
}
}
default:
tab = "all" tab = "all"
} }
return listView{Tab: tab, Recent: recent, Items: items}, nil return listView{Tab: tab, Recent: recent, Items: items}, nil
+30
View File
@@ -483,3 +483,33 @@ func TestUIListFavouritesTab(t *testing.T) {
t.Fatal("favourites tab included a non-favourite") t.Fatal("favourites tab included a non-favourite")
} }
} }
func TestUIListNewTab(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seed(t, store, Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapterNum: 10,
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
UpdatedAt: 2_000_000,
})
seed(t, store, Bookmark{
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
Title: "Tower of God", LastChapterNum: 5,
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
UpdatedAt: 1_000_000,
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=new", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "Solo Leveling") {
t.Fatal("new tab omitted the series with an unread chapter")
}
if strings.Contains(body, "Tower of God") {
t.Fatal("new tab included a series already caught up")
}
}