Commit Graph

13 Commits

Author SHA1 Message Date
sulthan a6d245c25e Label the chapter field for the number it holds
The edit panel's only visible text was "Latest known: Ch 30" sitting directly
above a field pre-filled with your own progress — so on the one occasion the
form is worth opening, when those two numbers differ, the visible label named
the wrong one. Correcting a chapter number is this UI's stated primary job.

The field gets a real label ("Chapter you're on", tied by for/id, replacing the
screen-reader-only aria-label) and "Latest known" moves below it as context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:25:04 +07:00
sulthan b00b532635 Refresh the strip and the badge out of band
The "Continue reading" strip and the Updated badge describe the whole library
but live outside the #list htmx swaps, so nothing corrected them after a
mutation: archiving a series left it sitting in the strip under "Continue
reading", with the badge still counting it. A tab switch had the same problem
from the other side — the strip was server-gated to All but could not be
re-rendered client-side, so /?tab=all reached by htmx and the same URL reloaded
produced different DOM.

Both regions move into chrome.html and always render, hidden when they have
nothing to say, so an out-of-band swap always has an id to target. /ui/list and
every mutation append them; the tab the reader is on comes from HX-Current-URL.
filter.js keeps only the job the server cannot do — hiding the strip while a
filter is active.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:21:29 +07:00
sulthan f22db44c35 Batch of accessibility and touch fixes from the audit
- Esc closes whichever panel a card has open and returns focus to the cell that
  owns it; opening a confirm row moves focus to its primary button, so the
  announcement is deterministic and tab order stays inside the confirm.
- The inline error scrolls itself into view and no longer self-destructs after
  5s: this reader gets interrupted mid-tap, and an expiring notice left a failed
  write with no trace at all. It clears on the next request from that card.
- Every tab clears 44px wide (All was 15.25); desktop action cells go 40 -> 44.
- login.html's error gets role="alert".
- The card monogram carries its own aria-hidden — Chromium exposes it through
  the cover link's, because that link is programmatically focusable.
- The busy bar ends its travel flush with the card edge, so .card.htmx-request
  no longer needs overflow: hidden and cannot clip a panel mid-request.

Verified at 390 and 1280 in both themes; detector over templates+static reports
only the two known Instrument Serif false positives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:14:59 +07:00
sulthan aacf57170c Make the remove confirm name the stake and take a thumb
The one irreversible path was defended with 40px buttons 8px apart and a
question — "Remove this?" — that never said what was leaving or what it cost.

Confirm buttons go to 46px with a 12px gap (all three rows), and the remove
question names the series and the loss. The row wraps, so a long title takes
the width it needs instead of overflowing; verified at 320 and 390 in both
themes with a 90-character title.

Button order is unchanged: the group is right-aligned with Cancel outermost, so
the destructive button already sits further from a right thumb's arc than
Cancel does, and all three rows keep the same shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:07:05 +07:00
sulthan cb00c1b306 Give destruction its own colour, take ember back
The stylesheet asserted twice that --ember only ever means "new chapter" while
spending it on eight things, including "Nothing new." — the heat signal firing
on its own negation.

Add a --danger token (a duller oxblood, tuned per theme) and move remove hover,
the open remove cell, the confirm wash and the login error onto it. Text-input
focus follows the .searchbar idiom and turns --paper, so a valid number field
no longer paints itself red. Drop .empty.hot. Ember now survives on the is-new
group, the Updated tab and the brand, which the law comment now says out loud.

Contrast, both themes: --danger on --ink 4.82 dark / 6.65 light, --danger-ink
on the solid button 4.94 / 7.30, --danger-soft on the wash 9.00 / 7.98.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:05:35 +07:00
sulthan f739f6acbe Give the search a zero-result state, hide the strip while filtering
The client filter only toggles card.hidden, so a query matching nothing left
the list area blank while the unfiltered "Continue reading" strip stayed
populated above it — the screen contradicted itself and read as a crash.

list.html now carries a hidden no-match block; applyFilter counts visible cards,
fills the query in with textContent, and hides .recent for the duration of a
filter. The Clear-search button is handled by a delegated click listener so it
survives an htmx tab swap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:01:03 +07:00
sulthan d16789226c Render one chapter format, not "Ch Chapter 250"
The userscript writes last_chapter/latest_chapter as "Chapter N" and the
poller writes the same label, so the templates' own "Ch " prefix doubled it on
every real row. A manual edit through the web UI stores a bare "250", so the
list carried two formats at once and stopped being scannable.

Move the formatting out of the templates into DisplayChapter/DisplayLatest on
Bookmark, which strip a leading "Chapter"/"Ch." lead-in and re-add exactly one
"Ch ", falling back to the numeric column when the label is empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:53:17 +07:00
sulthan 9cb9b51b41 Fix the accessibility findings from the audit (#12)
Contrast: --mute-2 and --trash both failed AA in both themes — every
10px mono label (recent chapter, section headings, chapter hint) sat
between 2.5:1 and 3.7:1, and the trash icon, the one irreversible
control, was the least visible thing in the strip. Retuned per theme:
labels now 4.8-5.2:1, trash 4.3-4.5:1 against a 3:1 requirement.

Focus: the search input clears its own outline and the underline lives
on the parent, so :focus-within on .searchbar carries the ring.

The inline error slot gets role=status, so a failed write is announced
rather than looking like an ignored tap.

The tab strip drops role=tablist/tab: these are real links that change
the URL, and the roles promised a tabpanel contract that was never
implemented, leaving aria-selected unset on every one. aria-current
carries the state instead.

prefers-reduced-motion now names pseudo-elements — `*` does not match
them, so the busy bar and the error dot, both ::before, kept animating
for the users who asked them not to.

Log out was a 54x15 target; an ::after pad makes it 78x45 without
moving the label.

Also: the two dark-tuned hex borders in the desktop block become
theme-aware tokens, and the preload swaps mono for DM Sans, the larger
face and the one the body text needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:43:34 +07:00
sulthan a736b4afa5 Confirm-gate lifecycle actions, cluster the action strip (#11)
Fixes the 2026-07-30 impeccable critique of the web UI.

Archive and finish now open their own .confirm-row disclosure, the
pattern delete already used, so no single tap moves a series out of the
list. Restore stays instant — it is the reversal. The two reversible
confirms wear grey; remove keeps the ember wash.

The six action cells cluster by consequence: navigate (play) | organize
(favourite, chapter) | lifecycle (archive/restore, finish, remove). The
lifecycle cells sit on a recessed ground, separated by a hairline on a
phone and by a gap on desktop, so the 4th-slot icon swapping between
.box and .restore no longer breaks position memory.

htmx:responseError branches on status: 401 offers a login link, 400
surfaces the server's own reason, everything else keeps the retry copy.

Busy bar and inline error move to --mute, leaving --ember exclusive to
the new-chapter signal.

Also: aria-live on the confirm rows, max on the chapter input, cover
width and row gap as tokens instead of a hardcoded 94px indent, and
mobile covers 30% larger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:28:31 +07:00
sulthan ac3ee9b298 Rebuild both UIs on the Cinder design (#10)
Implements the **Cinder** design (Claude Design doc `cfa39183`) across both UI surfaces, self-hosts the fonts it depends on, and writes down the two documents that keep the result maintainable.

## What changed

**Web UI** — rebuilt on the design's visual language: editorial serif, containerless sheets divided by ash hairlines, one 760px measure, no radii and no shadows. The organising rule is that *heat is typographic*: only a series with an unread chapter is crimson (title on an ember underline, cover foot rule, `Ch N out`, play icon), and favourites get brass rather than borrowing the accent. Both states hang off two classes on the `<article>` (`is-new`, `is-dim`), so sub-elements inherit the state instead of re-deriving it.

The six-cell action strip is `flex-basis: 100%` inside the row, which is what lets one piece of markup be a full-width strip with 46px thumb targets on a phone and a group of 40px squares beside the row on a desktop — no duplicate template branches. Icons moved to a sprite (`templates/icons.html`); htmx-swapped cards reference the page's symbols, so a row no longer carries a screenful of inline SVG.

**Userscript panel** — repainted in the same tokens. The panel and the web UI are the same product on the same phone, and the old purple-on-charcoal panel read as a different application once the web UI moved. Structure, ids and classes are untouched, and the edge tab keeps its geometry, `touch-action` and `#hit` sizing.

**Fonts are self-hosted** — five latin-subset woff2 files (~120 KB) embedded via the existing `//go:embed static`. Loading them from Google would lose the design's character exactly where it is used most: Bromite users routinely block Google's font domains, and the backend is reachable over a LAN with no internet route. `staticHandler` registers the `.woff2` MIME type, which Go's table lacks and the scratch image has no `/etc/mime.types` for.

**Docs** — `docs/design-system.md` records the rules a stylesheet cannot state (what the ember is reserved for, why light mode is a re-tuning rather than an inversion, which details are load-bearing) so a future agent does not re-derive them from the CSS. `REDEPLOY.md` covers the operation actually performed every time, which `DEPLOY.md` reduced to two lines.

## Commits

Each is one logical change and builds on its own:

| | |
|---|---|
| `4d69e54` | `listView.NewCount` — data for the Updated badge, no markup |
| `e940b96` | Web UI rebuilt on Cinder (CSS, templates, sprite, `filter.js`) |
| `686fcc1` | Userscript panel repainted in the same tokens |
| `ce7e93d` | Self-hosted webfonts + `.woff2` MIME registration |
| `a547cc9` | `docs/design-system.md` |
| `b20ecf1` | `REDEPLOY.md` |

## Verification

- `go test ./...` — 187 pass. Userscript logic tests — 14 pass.
- Screenshotted at 390px and 1180px, in dark and light, across All / Archived / Finished / empty / chapter-form / confirm-row.
- Fonts: a run with `fonts.googleapis.com` and `fonts.gstatic.com` blocked still reports all five faces `loaded`; served as `200 font/woff2`.
- The three `REDEPLOY.md` backup/restore commands were run, not assumed. `:ro` on the source volume fails (`unable to open database file` — WAL needs to create `-shm`), and a restored file lands root-owned while the container runs as uid 65532, so reads succeed and writes fail. Both are documented with the reason.

## Note for the reviewer

The panel restyle is token-level only: the design doc covers the web UI, so the panel's *structure* has no reference to follow and was deliberately left alone.

Deploying this needs a rebuild — templates, CSS and fonts are `//go:embed`ed, so pulling alone changes nothing.

Reviewed-on: #10
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-07-30 09:47:08 +07:00
sulthan 6af49e6790 Archived and finished buckets, userscript nav chips (#4)
Gives every bookmark a lifecycle bucket — `reading`, `archived`, or `finished` — so on-hold series leave the main list while still being polled for new chapters, completed series get a web-only bucket, and the userscript panel gains quick links to the web UI and both manga sites.

Design: `docs/superpowers/specs/2026-07-27-status-buckets-design.md`

## Data model

One additive column through the existing `addedColumns` migration list:

```sql
ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'
```

The `DEFAULT` backfills every pre-existing row as `reading`, so there is no separate migration step. `favorite` is unchanged and orthogonal — a series can be an archived favourite.

Rollback is safe: an old binary against the new database omits `status` from its INSERT (it gets the DEFAULT) and never mentions it in the conflict clause, so buckets survive.

## The write rule

`PUT /bookmarks/{key}` decodes a whole `Bookmark` and `Upsert` writes every column it knows about. `latest_checked_at` escaped this by staying out of `bookmarkColumns` entirely — `status` cannot, because the userscript must be able to archive and restore.

So an empty incoming status means **"no opinion"**, not a value, and resolves on the `VALUES` side of the upsert:

```sql
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')
```

with `DO UPDATE SET status = excluded.status`.

It has to be this way round. `excluded.*` is the row *after* the `VALUES` expressions are evaluated, so applying the default there and then reading `excluded.status` in the conflict clause would see `'reading'` rather than the empty string — and would overwrite an archived row on every progress PUT from a client that knows nothing about the column. One expression, evaluated once, covers insert and update alike. The subquery runs inside the transaction, so it sees the row the statement is about to conflict with.

`TestUpsertEmptyStatusPreservesStored` is the guard on this.

`updated_at` behaviour is unchanged: it moves only when `last_chapter_num` changes, so archiving, finishing, restoring, and favouriting never reorder the list.

## Validation

`PUT /bookmarks/{key}` returns 400 for any status outside `{"", "reading", "archived", "finished"}`, and for `"finished"` specifically. Finishing a series is a web-UI decision, enforced server-side rather than by trusting every client to leave the value alone. The `/ui/*` endpoints have their own session-guarded route and are unaffected.

## Visibility

| Surface | All | Updated | Favourites | Archived | Finished |
|---|---|---|---|---|---|
| Web | reading | reading | reading | archived | finished |
| Userscript | reading | — | reading | archived | not shown |

Archived and finished appear in their own tab and nowhere else — including the web UI's "Continue reading" strip, which is now built from reading-only rows before tab filtering. An archived favourite shows up under Archived only: Favourites means "favourites I am currently reading".

## Backend

- **`store.go`** — `Bookmark.Status`, the column in `schema` / `addedColumns` / `bookmarkColumns` / `scanBookmark` / `Upsert`. `scanBookmark` normalises anything outside the three known buckets to `reading`, so no row can land in no list at all.
- **`store.go`** — `DueForLatestCheck` gains `AND status IS NOT 'finished'`. Archived series keep being polled; that is the whole point of archiving rather than deleting. Finished ones have nothing coming, so polling them only burns fetches and risks a spurious "new chapter" badge. `IS NOT` is null-safe, so a hand-edited NULL still qualifies.
- **`handlers.go`** — status validation on `PUT`, before any write.
- **`web.go`** — `buildListView` filters the new tabs and excludes both buckets from `all` / `new` / `fav` and the recent strip; new `POST /ui/bookmarks/{key}/status`, session-guarded like its siblings, read-modify-writing through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one place.
- **`templates/`** — two more tabs; per-card controls (reading → Archive + Finish, archived → Restore + Finish, finished → Restore); empty-state copy for both new tabs.
- **`static/style.css`** — five tabs no longer divide a phone's width legibly, so the row scrolls sideways instead of squeezing.

## Userscript (1.3.0)

- Third tab **Archived** beside All and Favourites. A missing `status` reads as `reading`, so a list cached by the previous version still renders. `finished` matches no tab and is invisible everywhere.
- Per-item **Archive / Unarchive** button on the existing optimistic path: mutate local state and cache, `apiPut`, adopt the server's returned row.
- Header chip row linking the web UI and both manga sites, each `target="_blank" rel="noopener"`.
- `apiPut` now omits `status` unless the caller opts in — see below.

Still free of every `GM_*` API: plain `fetch`, page `localStorage`, on-page UI only.

## One bug worth calling out

The userscript's other mutations (`updateToCurrentChapter`, `setChapterManual`, `toggleFavorite`, `applyLatestChapterIfChanged`) build their payload with `Object.assign({}, existing, …)`, so they echoed the cached `status` back to the server. `GET /bookmarks` has no status filter — finished rows are in `state.list` and only hidden at render time — which made two failures reachable:

1. Reading a chapter of a series marked finished sent `"status":"finished"`, which the API rejects with 400. Progress never synced, behind a misleading "Offline — saved locally, will retry" toast, permanently.
2. Archiving on desktop and then reading on a phone whose cache predated the archive sent `"status":"reading"` and silently un-archived the series — contradicting the README's "reading an archived series leaves it archived".

Fixed at the single choke point: `apiPut(key, obj, { sendStatus = false })` strips `status` from a copy of the body unless the caller opts in, and only `toggleArchive` opts in. Only an explicit archive/restore has an opinion about the bucket; everything else omits the field so the server's keep-on-empty rule applies. Stripping merely the *invalid* values would not have been enough — a stale cached `"reading"` still clobbers a remote archive.

Also: the userscript's `backgroundRefreshLatest` now skips finished series, matching the server poller, instead of spending batch slots fetching pages for a series that has nothing coming.

## Known limitations, deliberate

Both are marked in-code with `ponytail:` comments naming the ceiling and the upgrade path:

- The poller's `Store.Get` + `Store.Upsert` is not wrapped in a transaction, so a client PUT that commits between the two is lost to the stale re-read. Already documented for read progress in `CLAUDE.md`; it now costs a status change too. Accepted for a single-user deployment.
- A card whose new status no longer matches the active tab stays on screen until the next list load. The alternative is an out-of-band swap or a full list refresh per toggle, and the card visibly showing its new state is enough feedback.

## Testing

`go test ./...` passes; `CGO_ENABLED=0 go build ./...` clean.

- **`store_test.go`** — a fresh row defaults to `reading`; a legacy database gains the column with every row `reading`; an `Upsert` carrying `""` preserves the stored bucket while a value replaces it; a status change does not move `updated_at`; `DueForLatestCheck` returns archived and skips finished; the poller's `Get` → `Upsert` round trip preserves `archived`.
- **`main_test.go`** — `PUT` with `finished` or garbage is 400, `""` / `reading` / `archived` round-trip; a PUT that omits the `status` key entirely (what a pre-1.3.0 userscript sends) preserves an archived bucket *and* applies the chapter progress in the same request.
- **`web_test.go`** — each tab returns only its bucket; the recent strip excludes archived and finished; the status endpoint requires a session, rejects unknown values, and does not move `updated_at`; the card renders the right controls per bucket.

Userscript has no automated harness, so it was checked against a live `https://asurascans.com` page: the chips resolve, Archive moves a series out of All and Favourites into Archived, the state survives a full reload (so it came from the server, not local optimism), Unarchive returns it, a series marked finished in the web UI appears in no tab, and — captured on the wire — the archive PUT carries `"status":"archived"` while a favourite toggle on that same archived series carries no `status` key at all.

Reviewed-on: #4
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-07-27 16:58:05 +07:00
sulthan a587b16423 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>
2026-07-27 00:42:19 +07:00
sulthan ebc7a546c5 feat: password-gated web UI on the same backend (#1)
Adds a password-gated browser UI for the bookmark list, served by the same Go
binary and container as the userscript API.

## What

- `GET /` — list page, or the login page when there is no session (200, no redirect).
- `POST /login`, `POST /logout` — stateless HMAC session cookie, 60-day Max-Age.
- `GET /ui/list?tab=all|fav`, `POST /ui/bookmarks/{key}/favorite`,
  `POST /ui/bookmarks/{key}/chapter`, `DELETE /ui/bookmarks/{key}` — htmx fragments.
- `GET /static/*` — embedded `style.css`, `htmx.min.js`, `filter.js`.

Mobile-first dark CSS, 2–3 column grid at ≥900px, "Continue reading" strip of the
five most recent series, NEW badge, client-side title search, no build step.

## Stack

Go `html/template` + htmx 2.0.4 (vendored, 50 KB) + plain CSS. No npm, no bundler.
Templates and assets are `go:embed`-ed, so `CGO_ENABLED=0` and the distroless
image still hold.

## Auth

`WEB_PASSWORD` gates the UI; unset means the web routes are never registered and
`/` returns 404. Session cookie is `HttpOnly`, `SameSite=Lax`, `Secure` when the
request is HTTPS. The signing key derives from `API_TOKEN` + `WEB_PASSWORD`, so
rotating either logs every browser out. Login is rate-limited to 10 failures per
20 minutes per client IP, keyed on the **rightmost** `X-Forwarded-For` entry
(Traefik appends the observed peer, so the leftmost is client-spoofable). CGNAT
lockout is a known, accepted limitation — the window self-heals.

## Invariants preserved

- A session cookie never authenticates `/bookmarks*`. That API stays JSON +
  bearer token, unchanged, as does the userscript.
- `Store.Upsert` is byte-for-byte unmodified. Every UI write goes
  read-modify-write through the new `Store.Get`, so the conditional-`updated_at`
  rule (favouriting must not reorder the list, a chapter override must) lives in
  exactly one function.

## Deployment

`docker-compose.prod.yml` gains a second Traefik router on `MANGA_WEB_HOST`
pointing at the same service — one container, one certificate resolver, no second
service. Both `MANGA_API_HOST` and `MANGA_WEB_HOST` are required (`:?`), with no
example fallback in `.env.example`: a placeholder there would make Traefik
silently publish the UI on a domain you do not own. Needs a DNS A/AAAA record for
`manga.<domain>`. See `DEPLOY.md` §1b.

## Docs

- Design: `docs/superpowers/specs/2026-07-25-web-ui-design.md`
- Plan: `plans/2026-07-25-web-ui-implementation-plan.md`

## Verification

`gofmt` clean, `go vet`, `go test -race ./...`, `CGO_ENABLED=0 go build`, a real
`docker build` + curl smoke test, and a Playwright pass covering login
reject/accept, favourite-without-reorder, chapter edit, delete-with-confirm,
search, tab switch + back button, 390px with no horizontal overflow, and zero JS
console errors.

Reviewed-on: #1
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-07-26 03:59:58 +07:00