docs: split CLAUDE.md into per-directory guidance #14

Merged
sulthan merged 19 commits from docs/split-claude-md into main 2026-08-04 20:35:17 +07:00
2 changed files with 260 additions and 151 deletions
Showing only changes of commit e250762ea6 - Show all commits
+139 -99
View File
@@ -1,137 +1,137 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Guidance for Claude Code (claude.ai/code) working in this repo.
## Status ## Status
Greenfield. Only `plans/mangaBookmark.md` exists — no code yet. That plan is the spec; read it before building. Two deliverables: a Go sync backend and a single Bromite-compatible userscript. Greenfield. Only `plans/mangaBookmark.md` exist — no code yet. Plan = spec; read before build. Two deliverables: Go sync backend, single Violentmonkey-compatible userscript.
## What this is ## What this is
A manga read-progress tracker for a user reading on **asurascans.com** (the current domain; asuracomic.net 301s here) and **demonicscans.org** from **Bromite** (mobile Chromium). A userscript injects on-page UI (floating button + slide-in panel) and syncs progress to a self-hosted Go backend so bookmarks unify across both sites and across devices. Manga read-progress tracker, user read on **asurascans.com** (current domain; asuracomic.net 301s here) and **demonicscans.org** via **Violentmonkey**. Userscript inject on-page UI (floating button + slide-in panel), sync progress to self-hosted Go backend so bookmarks unify across both sites and devices.
## Hard constraints (these drive the design — do not violate) ## Hard constraints (drive design — don't violate)
Bromite uses Chromium's **native** userscript engine, not Tampermonkey: Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines:
- **No `GM_*` APIs anywhere.** No `GM_setValue`/`GM_getValue` (use page `localStorage`), no `GM_registerMenuCommand` (inject on-page UI), no `GM_xmlhttpRequest` for cross-origin (use plain `fetch()`). Keeping the script GM-free also lets it run in desktop Tampermonkey/Violentmonkey for faster iteration. - **Avoid `GM_*` unless needed.** Prefer page `localStorage` over `GM_setValue`/`GM_getValue`, on-page UI over `GM_registerMenuCommand`, plain `fetch()` over `GM_xmlhttpRequest` for cross-origin.
- Cross-origin `fetch()` works **only** against a CORS-enabled backend. Manga sites are `https://`, so backend **must be HTTPS** (mixed-content block otherwise). - Cross-origin `fetch()` work **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block).
- Asura and Demonic are **separate origins with separate `localStorage`** — a shared remote store is the only way to unify bookmarks. Cloud sync is required, not optional. - Asura and Demonic are **separate origins with separate `localStorage`** — shared remote store only way to unify bookmarks. Cloud sync required, not optional.
- Userscript runs in an **isolated world**, so the embedded API token is safe from the site's JS. - Userscript run in **isolated world**, so embedded API token safe from site's JS.
- Cloudflare's block on fetching the manga sites is **IP-reputation-based, not universal — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both the CGNAT dev machine *and* the deployed VPS got clean 200s with real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. This contradicts an earlier, untested assumption that the CGNAT dev IP would be blocked; it was not, at least on this date. Treat "does curl work right now" as a live, time-varying fact to re-check, not a fixed property of a given machine — Cloudflare's bot scoring can flip a previously-clean IP without notice. Any backend fetcher still needs a graceful-degrade path for when it does get challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, or a direct probe) before finalizing, not assumed from a single earlier test. - Cloudflare's block on manga sites **IP-reputation-based, not universal — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s with real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier untested assumption CGNAT dev IP blocked; wasn't, at least this date. Treat "does curl work right now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare's bot scoring can flip previously-clean IP without notice. Backend fetcher still needs graceful-degrade path for when challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, or direct probe) before finalize, not assumed from single earlier test.
## Architecture ## Architecture
``` ```
Bromite userscript (isolated world, per-site adapters, localStorage cache) Violentmonkey userscript (isolated world, per-site adapters, localStorage cache)
-- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume) -- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume)
``` ```
- **Backend** (`backend/`): stdlib `net/http` (a handful of routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). The reverse proxy terminates TLS; the Go service listens plain `:8080`. - **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`.
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync is **last-write-wins**. Schema and endpoint list are in the plan. - **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync **last-write-wins**. Schema and endpoint list in plan.
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth). - **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
- **Web UI:** the same binary serves a password-gated browser UI on a second - **Web UI:** same binary serve password-gated browser UI on second
hostname — `GET /` (list, or login page when there is no session), hostname — `GET /` (list, or login page when no session),
`POST /login`, `POST /logout`, `GET /static/*`, and htmx fragment endpoints `POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
under `/ui/*`. Templates and assets are `go:embed`-ed, so `backend/Dockerfile` under `/ui/*`. Templates + assets `go:embed`-ed, so `backend/Dockerfile`
must copy `templates/` and `static/` as well as `*.go`. Sessions are stateless must copy `templates/` and `static/` plus `*.go`. Sessions stateless
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them and, when empty, HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, and when empty,
the web routes are not registered at all. UI mutations read-modify-write web routes not registered at all. UI mutations read-modify-write
through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`. place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
**Design-tool caveat:** the templates link `/static/style.css` root-absolutely **Design-tool caveat:** templates link `/static/style.css` root-absolutely
(correct — they are served from `/`), but the impeccable detector resolves a (correct — served from `/`), but impeccable detector resolves
stylesheet href with `path.resolve(fileDir, href)`, which drops the directory stylesheet href with `path.resolve(fileDir, href)`, drops directory
on a leading `/` and silently skips the file. A relative href does not help on leading `/` and silently skip file. Relative href don't help
either: the template's directory is not its served path. So either: template's directory isn't its served path. So
`detect.mjs backend/templates` reports a **false clean** — always pass `detect.mjs backend/templates` reports **false clean** — always pass
`backend/static` too. Its one finding there, `overused-font` on "Instrument `backend/static` too. One finding there, `overused-font` on "Instrument
Serif", is a deliberate identity choice, not debt. Serif", deliberate identity choice, not debt.
- **Every action that moves a series out of the list is confirm-gated.** - **Every action that moves series out of list is confirm-gated.**
Archive, finish, and remove each open their own `.confirm-row` disclosure Archive, finish, remove each open own `.confirm-row` disclosure
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈ (`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
`archive|finish|remove`); restore fires instantly because it is the reversal. `archive|finish|remove`); restore fire instantly since it's the reversal.
Remove's row wears the ember wash, the two reversible ones wear `.calm` grey. Remove's row wear ember wash, two reversible ones wear `.calm` grey.
`--ember` stays reserved for the new-chapter signal: busy bar and inline `--ember` stay reserved for new-chapter signal: busy bar and inline
error use `--mute`. error use `--mute`.
- **Latest-chapter poller:** a ticker goroutine in the same binary re-checks - **Latest-chapter poller:** ticker goroutine in same binary re-check
each bookmarked series' newest published chapter from the backend's own each bookmarked series' newest published chapter from backend's own
network access, so `latest_chapter` stays fresh when the user is not network access, so `latest_chapter` stay fresh when user not
browsing. It is a *second, parallel* signal — the userscript keeps its own browsing. Second, parallel signal — userscript keep own
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged. `maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged.
Two independent clocks: a per-bookmark cooldown (`latest_checked_at` column, Two independent clocks: per-bookmark cooldown (`latest_checked_at` column,
enforced by `Store.DueForLatestCheck`'s WHERE clause) and a wake interval. enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval.
The row is stamped *before* the fetch so a broken series waits out a full Row stamped *before* fetch so broken series wait out full
cooldown instead of retrying every tick, and writes go through cooldown instead of retrying every tick, and writes go through
`Store.Get` + `Store.Upsert` so a new chapter never reorders the list. `Store.Get` + `Store.Upsert` so new chapter never reorders list.
Fetches use `bogdanfinn/tls-client` with a Chrome profile as defence in depth Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth
against fingerprint-based blocking; any failure logs and skips. See against fingerprint-based blocking; any failure log and skip. See
`docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`. `docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`.
The poller's `Store.Get` + `Store.Upsert` is not wrapped in a transaction, so Poller's `Store.Get` + `Store.Upsert` not wrapped in transaction, so
a userscript `PUT` that commits between the two can be overwritten by the userscript `PUT` that commits between the two can get overwritten by
poller's stale re-read — reverting that read progress and, since the stored poller's stale re-read — reverting that read progress and, since stored
value now differs, moving `updated_at` and reordering the list. This is a value now differs, moving `updated_at` and reordering list. Known,
known, accepted limitation for a single-user deployment, not a bug to fix. accepted limitation for single-user deployment, not bug to fix.
- **`updated_at` drives list order, so it moves only on real reading progress:** the server applies its timestamp when the row is new or `last_chapter_num` changes, and otherwise keeps the stored value — favouriting a series or recording a newly published chapter must not reorder the list. `PUT` therefore returns the row **as stored**, and clients must adopt that response rather than their own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4. - **`updated_at` drives list order, so moves only on real reading progress:** server apply its timestamp when row new or `last_chapter_num` changes, else keep stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**, clients must adopt that response rather than own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` | - **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
`finished`, orthogonal to `favorite`. Archived and finished appear only in `finished`, orthogonal to `favorite`. Archived and finished appear only in
their own tab — not in All, Updated, Favourites, or the recent strip. The own tab — not in All, Updated, Favourites, or recent strip. Poller keeps
poller keeps checking archived series and skips finished ones. `finished` is checking archived series and skip finished ones. `finished` settable
settable only from the web UI; `PUT /bookmarks/{key}` rejects it with 400. only from web UI; `PUT /bookmarks/{key}` reject it with 400.
**An empty incoming status means "keep the stored one"** — resolved on the **Empty incoming status means "keep stored one"** — resolved on the
`VALUES` side of `Store.Upsert`, not in the conflict clause, because `VALUES` side of `Store.Upsert`, not conflict clause, since
`excluded.*` is the post-evaluation row and a default applied there would `excluded.*` is post-evaluation row and default applied there would
wipe the bucket on every PUT from a client that predates the column. See wipe bucket on every PUT from client that predates column. See
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`. `docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH` - **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD` (default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
(gates the browser UI; unset disables it), (gates browser UI; unset disable it),
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` `LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`). (background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
`USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`, `USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`,
default `/userscript/manga-bookmark.user.js`, supplied by a bindmount). default `/userscript/manga-bookmark.user.js`, supplied by bindmount).
### Userscript structure (single IIFE, `manga-bookmark.user.js`) ### Userscript structure (single IIFE, `manga-bookmark.user.js`)
1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes. 1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback. 2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback.
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. 3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
4. **Retry queue** — every write goes through `pushBookmark`/`pushDelete`, so a 4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so
failed mutation is parked in `localStorage` (`mangabm:queue`) and replayed on failed mutation park in `localStorage` (`mangabm:queue`) and replayed on
the next navigation, reconnect, or `refresh()`. Entries are markers next navigation, reconnect, or `refresh()`. Entries are markers
(`{key, op, sendStatus, attempts}`), never payloads — the body is read from (`{key, op, sendStatus, attempts}`), never payloads — body read from
the cache at send time, so one entry per key gives ordering and coalescing for cache at send time, so one entry per key give ordering and coalescing for
free. `sendStatus` is **sticky**: while an archive is pending, later writes to free. `sendStatus` is **sticky**: while archive pending, later writes to
that key keep carrying the bucket, which is what stops a successful that key keep carrying bucket, which stop successful
in-between write from silently un-archiving the series. `refresh()` drains in-between write from silently un-archiving series. `refresh()` drains
before it fetches and overlays anything still pending, so the list never before it fetches and overlays anything still pending, so list never
flaps. A 400 drops the entry, a 401 aborts the pass and keeps the queue, and flaps. 400 drops entry, 401 abort pass and keep queue, and
transient failures retry to a cap of 10. Latest-chapter writes deliberately transient failures retry to cap of 10. Latest-chapter writes deliberately
stay out of the queue. See stay out of queue. See
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`. `docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
5. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS 5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS
(critical on mobile). Three tabs (All / Favourites / Archived) and a row of (critical on mobile). Three tabs (All / Favourites / Archived) and row of
link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG
block next to `API_BASE`. The FAB is a `7 × 44` edge tab whose *hit* area is block next to `API_BASE`. FAB is `7 × 44` edge tab whose *hit* area
widened to `28 × 72` by an invisible `#hit` child; `#fab` must keep widened to `28 × 72` by invisible `#hit` child; `#fab` must keep
`touch-action: none` and must **not** regain `overflow: hidden`. Because `touch-action: none` and must **not** regain `overflow: hidden`. Since
`touch-action` is resolved at gesture start, the strip cannot be both `touch-action` resolved at gesture start, strip can't be both
browser-scrolled and script-dragged, so `makeDraggable` splits by intent: a browser-scrolled and script-dragged, so `makeDraggable` splits by intent: swipe
swipe from `#hit` scrolls via `window.scrollBy`, a hold of `ARM_MS` arms a from `#hit` scrolls via `window.scrollBy`, hold of `ARM_MS` arms
reposition drag, and the visible sliver drags with no hold. See reposition drag, visible sliver drags with no hold. See
`docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`. `docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`.
6. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices). 6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fire without reload. Demonic uses classic reloads (initial `document-idle` run suffice).
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting) ### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust)
- **asurascans.com**: series `/comics/<slug>` (slug carries a trailing - **asurascans.com**: series `/comics/<slug>` (slug carries trailing
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip
the hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in the userscript, hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in userscript,
`asuraBuildHash` in the backend); URLs keep the full slug — stale-hash `asuraBuildHash` in backend); URLs keep full slug — stale-hash
URLs 302 to current ones. Astro-rendered; chapter links present in raw URLs 302 to current ones. Astro-rendered; chapter links present in raw
server HTML. server HTML.
- **demonicscans.org**: series `/manga/<slug>` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title/<slug>/chapter/<n>/<page>` (older `chaptered.php?manga=<id>&chapter=<n>` form still exists as redirect, what series-page chapter-list anchors link through). - **demonicscans.org**: series `/manga/<slug>` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title/<slug>/chapter/<n>/<page>` (older `chaptered.php?manga=<id>&chapter=<n>` form still exists as redirect, what series-page chapter-list anchors link through).
Encodings (incl. triple-encoded punctuation like `%25252D`) are identical Encodings (incl. triple-encoded punctuation like `%25252D`) identical
on /manga/ and /title/ pages, so decode-once seriesIds match — verified on /manga/ and /title/ pages, so decode-once seriesIds match — verified
2026-07-28. 2026-07-28.
@@ -144,34 +144,74 @@ Backend (`cd backend`):
Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`). Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`).
Smoke test: `curl` the endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight returns CORS headers and `/healthz` returns 200. Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200.
## Forge: Gitea, not GitHub ## 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: `origin` is self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` don't 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 "..."` - Open PR: `tea pr create --head <branch> --base main --title "..." --description "..."`
- List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>` - List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>`
- Issues: `tea issue create`, `tea issue list` - Issues: `tea issue create`, `tea issue list`
- Auth lives in `tea login`, not a `GH_TOKEN` env var. - Auth lives in `tea login`, not `GH_TOKEN` env var.
`tea` prints its output as rendered boxes rather than plain text; the PR URL lands on the last line. `tea` print output as rendered boxes rather than plain text; PR URL lands on last line.
## Design system
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md`
— source of truth Claude Design project `mangaBookmark Web UI`
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
`backend/static/style.css`, `backend/templates/*`, or userscript
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
state (busy, error, destruction) may use `--ember`; destruction gets
`--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens
only (never hardcode hex outside `:root`), both colour branches touched
together. Any move that pulls series out of list (archive/finish/remove)
must be confirm-gated via its own `.confirm-row`; only restore fires
instantly.
## 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.
- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` with `204`. - CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` with `204`.
## Comments
Comment only if code alone can't carry info. Cost per read — must earn spot.
Write for:
- Why not what. Tradeoffs, non-obvious decisions.
- Load-bearing detail looking incidental — say so if "simplify" breaks it.
- Non-local consequence, invisible from function alone.
- Wire format / encoding / interface contract — save callers re-deriving.
- Gotcha/workaround, with ref if exists.
- Domain/business rule not derivable from code.
Skip:
- Restating code (no `// increment i` above `i++`).
- Trivial getter/setter/pass-through.
- Banners, dividers, `// helpers`.
- Change narration (`// fix bug`, `// as requested`, `// new impl`) — git's job.
- Commented-out code — delete.
- TODO without concrete action.
Style: one dense comment over function beats one per line inside. Tight, no worked example unless bug subtle. Wrong comment worse than none — update/delete on change. Default fewer — sparse+high-signal beats comprehensive.
Test: "competent reader get this from code in few sec?" Yes → skip. Needs detour through another file/spec/git-blame → write it.
## Relevant skills ## Relevant skills
`multi-stage-dockerfile` and `docker-compose-orchestration` for the container work (referenced in the plan). `multi-stage-dockerfile` and `docker-compose-orchestration` for container work (referenced in plan).
`golang-code-style`, `golang-error-handling`, `golang-performance`, `golang-testing` for backend Go work.
## graphify ## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. Project has knowledge graph at graphify-out/ with god nodes, community structure, cross-file relationships.
Rules: Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. - For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. - If graphify-out/wiki/index.md exists, use for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain don't surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). - After modifying code, run `graphify update .` to keep graph current (AST-only, no API cost).
+121 -52
View File
@@ -1,16 +1,16 @@
# Cinder — mangaBookmark design system # Cinder — mangaBookmark design system
Source of truth: the Claude Design doc **Cinder Sheet** Source of truth: the Claude Design project **mangaBookmark Web UI**
(`cfa39183-8874-4f76-987c-afef14dceebb`, files `Cinder Sheet.dc.html` for the (`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings
static spec and `Cinder Sheet App.dc.html` for the interactive one). This file `archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`,
records the rules that got implemented so a future agent can extend the UI `style.css`, `filter.js`). This file records the rules that got implemented so
without re-reading the design. a future agent can extend the UI without re-reading the design.
Implemented in: Implemented in:
| Surface | Files | | Surface | Files |
| --- | --- | | --- | --- |
| Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,icons}.html`, `backend/static/filter.js` | | Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,chrome,icons}.html`, `backend/static/filter.js` |
| Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE | | Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE |
## 1. The one idea ## 1. The one idea
@@ -19,9 +19,12 @@ Implemented in:
allowed to be crimson: its title turns `--paper-hot` and sits on a 1px ember allowed to be crimson: its title turns `--paper-hot` and sits on a 1px ember
underline sized to the text, its cover gains a 3px ember rule at the foot, and underline sized to the text, its cover gains a 3px ember rule at the foot, and
its `Ch N out` meta and play icon go ember. Everything else — favourites, its `Ch N out` meta and play icon go ember. Everything else — favourites,
status, chrome — stays cool. If a new feature wants to be noticed, it does *not* status, chrome, destruction — stays off that one colour. Destruction gets its
get to borrow the ember; find a typographic answer (weight, italic, a rule) or own token (`--danger`, a duller oxblood) precisely so a remove confirm is
use brass, which is already spoken for by favourites. never mistaken across the room for an unread chapter. If a new feature wants
to be noticed, it does *not* get to borrow the ember; find a typographic
answer (weight, italic, a rule) or reach for one of the named action accents
(§2).
Corollaries: Corollaries:
@@ -34,7 +37,7 @@ Corollaries:
- **Three type roles, never mixed.** Display serif for anything a human reads as - **Three type roles, never mixed.** Display serif for anything a human reads as
a name (brand, titles, tabs, primary buttons, empty-state headings). Mono a name (brand, titles, tabs, primary buttons, empty-state headings). Mono
small-caps for machine facts (site, chapter numbers, labels, status, badges, small-caps for machine facts (site, chapter numbers, labels, status, badges,
ghost buttons). Sans for prose only (empty-state body, hints). ghost buttons, the action key). Sans for prose only (empty-state body, hints).
## 2. Tokens ## 2. Tokens
@@ -44,7 +47,7 @@ Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's
| Token | Dark | Light | Use | | Token | Dark | Light | Use |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `--ink` | `#100f0e` | `#f7f4ef` | page | | `--ink` | `#100f0e` | `#f7f4ef` | page |
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form, toast) | | `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form) |
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background | | `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background |
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders | | `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges | | `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
@@ -54,23 +57,34 @@ Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's
| `--paper-hot` | `#f0d3cb` | `#a33018` | title of a series with a new chapter | | `--paper-hot` | `#f0d3cb` | `#a33018` | title of a series with a new chapter |
| `--paper-dim` | `#ddd5cb` | `#191715` | resting title | | `--paper-dim` | `#ddd5cb` | `#191715` | resting title |
| `--mute` | `#8d857c` | `#6b645d` | secondary text, idle icons | | `--mute` | `#8d857c` | `#6b645d` | secondary text, idle icons |
| `--mute-2` | `#5a5450` | `#857d75` | eyebrow labels, hints | | `--mute-2` | `#877f76` | `#6c655e` | eyebrow labels, hints (must clear 4.5:1 on both `--ink` and `--ash`) |
| `--faint` | `#3a3733` | `#c9c2ba` | the `/` separators in a meta line | | `--faint` | `#3a3733` | `#c9c2ba` | the `/` separators in a meta line |
| `--faint-2` | `#57504b` | `#a8a098` | cover monogram | | `--faint-2` | `#57504b` | `#a8a098` | cover monogram |
| `--ember` | `#e0452c` | `#c23a22` | heat — see §1 | | `--ember` | `#e0452c` | `#c23a22` | heat — see §1 |
| `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface (confirm, error) | | `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface |
| `--ember-ink` | `#150907` | `#fff` | text on solid ember | | `--ember-ink` | `#150907` | `#fff` | text on solid ember |
| `--ember-soft` | `#eda798` | `#8d2c17` | text on ember wash | | `--ember-soft` | `#eda798` | `#8d2c17` | text on ember wash |
| `--brass` | `#b8912f` | `#8a681c` | favourites, and only favourites | | `--danger` | `#cf5c4d` | `#97362a` | destruction — remove confirm, never the same as `--ember` |
| `--trash` | `#6b5450` | `#a98276` | remove, at rest | | `--danger-wash` | `#211311` | `#fbe9e5` | remove-confirm surface |
| `--danger-ink` | `#150808` | `#fff` | text on solid danger |
| `--danger-soft` | `#e2aaa1` | `#7c2c22` | text on danger wash |
| `--brass` | `#b8912f` | `#8a681c` | favourite — a cooler second metal |
| `--slate` | `#7fa0c0` | `#3f6689` | archive accent |
| `--moss` | `#7fae86` | `#3d6c46` | finished accent |
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
| `--play-hot-line` | `#3a1d18` | `#f0cfc6` | desktop cell border, play when `.is-new` |
| `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on |
| `--asura` | `#7d93a5` | `#4f6b80` | site tag | | `--asura` | `#7d93a5` | `#4f6b80` | site tag |
| `--demonic` | `#a98a78` | `#8a6a55` | site tag | | `--demonic` | `#a98a78` | `#8a6a55` | site tag |
| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot | | `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
Dark is the default (`color-scheme: dark light`); light is a `--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately:
`@media (prefers-color-scheme: light)` override of the same names. **Any new one accent per action, so a press says which lane it belongs to, with none of
colour must be added in both branches** — light is not a filter over dark, the them competing with ember. Dark is the default (`color-scheme: dark light`);
hues are re-tuned. light is a `@media (prefers-color-scheme: light)` override of the same names.
**Any new colour must be added in both branches** — light is not a filter over
dark, the hues are re-tuned.
## 3. Type ## 3. Type
@@ -83,11 +97,10 @@ hues are re-tuned.
The web UI **self-hosts** all three: five latin-subset woff2 files in The web UI **self-hosts** all three: five latin-subset woff2 files in
`backend/static/fonts/` (~120 KB total), declared by the `@font-face` block at `backend/static/fonts/` (~120 KB total), declared by the `@font-face` block at
the top of `style.css` and embedded in the binary by the existing the top of `style.css` and embedded in the binary by the existing
`//go:embed static`. There is no request to Google — this UI is read in Bromite, `//go:embed static`. There is no request to Google — this UI needs to survive
where `fonts.googleapis.com` is routinely blocked, and over a LAN with no on a LAN with no internet route. `staticHandler()` in `web.go` registers the
internet route. `staticHandler()` in `web.go` registers the `.woff2` MIME type `.woff2` MIME type because Go's built-in table lacks it and the scratch image
because Go's built-in table lacks it and the scratch image has no has no `/etc/mime.types`.
`/etc/mime.types`.
Adding a weight means adding a file: grab the *latin* `@font-face` block from Adding a weight means adding a file: grab the *latin* `@font-face` block from
`https://fonts.googleapis.com/css2?...` **with a browser User-Agent** (Google `https://fonts.googleapis.com/css2?...` **with a browser User-Agent** (Google
@@ -102,37 +115,57 @@ root is at the mercy of the host site's CSP.
Recurring specs (copy these rather than inventing sizes): Recurring specs (copy these rather than inventing sizes):
- Brand: `400 26px/1 display`, with `<em>` in ember italic — `manga<em>Bookmark</em>`. - Brand: `400 26px/1 display` (`30px` ≥720px), inline SVG mark (§4) + `<em>` in
- Row title: `400 19px/1.2 display` (21px ≥720px). ember italic — `manga<em>Bookmark</em>`.
- Tab: `400 17px display` (18px ≥720px), active gets `border-bottom: 2px` in - Row title: `400 21px/1.2 display` (`22px` ≥720px).
- Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in
`--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on `--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on
the row's own hairline. the row's own hairline.
- Meta / label / badge: `500 10px mono`, `letter-spacing: .12em`, - Meta / label / badge / action key: `500 10–11px mono`, `letter-spacing:
`text-transform: uppercase`. Eyebrows ("CONTINUE READING") use `.2em`. .04em`–`.2em`, `text-transform: uppercase`. Eyebrows use the widest tracking.
- Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`. - Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`.
- Primary button: `--paper` fill, `--ink` text, `400 17px display`, no border radius. - Primary button: `--paper` fill, `--ink` text, `400 17–19px display`, no border radius.
- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`. - Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`.
## 4. Components (web UI) ## 4. Components (web UI)
``` ```
.sheet .sheet
.topbar .brand + .ghost (log out) .topbar .brand (mark + wordmark) + .ghost (log out)
.chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:) .chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:)
.keyrow one-line action key: Read / Fav / Chapter / Archive / Done / Delete
.recent h2 eyebrow + .recent-strip > a.recent-card .recent h2 eyebrow + .recent-strip > a.recent-card
main#list article.card … | .empty main#list article.card … | .empty
``` ```
**Brand mark**: an inline `<svg class="mark">` (`viewBox="0 0 200 172"`),
defined once in `chrome.html`'s `mark` template and reused by `app.html` and
`login.html` so it takes the page's `--ink`/`currentColor`/`--ember` rather
than shipping as a static asset. The blade at its centre strokes
`var(--logo-blade, var(--ember))` — override that custom property, don't
duplicate the SVG, if a surface ever needs a different blade colour. Drawn at
a 5px stroke on a 200-unit grid; at brand size that thins out, so `.brand .mark
g` nudges `stroke-width` up to `6.5` rather than scaling the artwork down.
**Action key** (`.keyrow`): one permanent line under the tabs naming what
every icon in `.actions` does — Read / Fav / Chapter / Archive / Done /
Delete — so the icon strip on a card is never a guess. On a phone each pair
stacks icon-over-word (`flex-direction: column`) so the word gets the full
cell width and can stay in long form; ≥720px it lays out icon-beside-word and
switches the `.short`/`.full` label pair. `.pair.brass` and `.pair.trash`
carry their icon's resting accent so the key itself teaches the colour
vocabulary in §1/§2.
`article.card` — the row, and the only per-series component: `article.card` — the row, and the only per-series component:
``` ```
article.card[.is-new|.is-dim]#card-<key>[data-title] article.card[.is-new|.is-dim]#card-<key>[data-title]
.row .row
a.cover img | span.monogram, + span.foot-rule[.brass] a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass]
.body .title-line (h3.title + svg.fav-mark) , p.meta .body .title-line (h3.title + svg.fav-mark) , p.meta
.actions play, favourite, chapter, archive|restore, finish, remove .actions play, favourite, chapter | lifecycle: archive/restore, finish, remove
form.chapter-form[hidden] .hint + .field(input + Save) form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known)
.confirm-row[hidden] span + (Remove, Cancel) .confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel)
p.error-inline[hidden] p.error-inline[hidden]
``` ```
@@ -143,9 +176,29 @@ Rules that are easy to break:
dim rule is a descendant selector off those two classes, so a new sub-element dim rule is a descendant selector off those two classes, so a new sub-element
inherits the state for free. inherits the state for free.
- `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a - `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a
full-width strip under the row on a phone and a group of 40px squares beside full-width strip under the row on a phone and a group of 44px squares beside
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
`border-right: 1px var(--rule)`, last child none. `border-right: 1px var(--rule)`, last child none.
- Three clusters by consequence, in this order: navigate (`.play`) | organize
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.finish`, `.remove`,
each carrying the `.lifecycle` class). Lifecycle cells sit on a recessed
`--ash` ground so the thumb reads "this one moves the series" before it
reads which icon it landed on; ≥720px they separate by a 10px gap instead of
the phone's inset hairline.
- Every lifecycle button that moves a series out of the list is
**confirm-gated**: it opens its own `.confirm-row` (`archive`, `finish`,
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive and finish
ask in `.calm` grey since they're reversible; remove alone gets the
`--danger-wash` treatment and names the series in its question. Restore
fires instantly — no confirm — because it's the reversal.
- Per-action hover/press accent: `.fav` → `--brass`, `.pencil` → `--clay`,
`.box` → `--slate`, `.finish` → `--moss`. `.play` stays paper/ember (ember
only when `.is-new`). `.remove` stays `--trash` at rest, `--danger` on
hover. Desktop cell borders follow the same accent on hover
(`border-color: currentColor`); the two coloured *resting* states
(`.is-new .play`, `.fav.on`) get their own dim border tokens
(`--play-hot-line`, `--fav-line`) instead of the full accent, since a
resting border needs less contrast than a hover one.
- Icons are `<use href="#i-…">` against the sprite in `templates/icons.html`, - Icons are `<use href="#i-…">` against the sprite in `templates/icons.html`,
included once by `app.html`. htmx-swapped card fragments reference the included once by `app.html`. htmx-swapped card fragments reference the
page's sprite, so a card never inlines a path. New icon → add a `<symbol>` page's sprite, so a card never inlines a path. New icon → add a `<symbol>`
@@ -155,11 +208,15 @@ Rules that are easy to break:
- Cover foot rule: ember when new, brass when favourite-and-not-new. Never both. - Cover foot rule: ember when new, brass when favourite-and-not-new. Never both.
- `[hidden] { display: none !important; }` is load-bearing — every disclosure - `[hidden] { display: none !important; }` is load-bearing — every disclosure
panel is a flex container, and `display` beats `hidden`. panel is a flex container, and `display` beats `hidden`.
- Busy state is `.card.htmx-request::before`, a 1px ember bar sliding across the - Busy state is `.card.htmx-request::before`, a 1px grey bar sliding across the
top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a
spinner. spinner, and deliberately `--mute` not `--ember` — on a list screen ember
- `.open` on the pencil / trash cell marks which panel is showing; `filter.js` means "new chapter" and nothing else, so a system state can't borrow it.
`togglePanel()` owns that class alongside `hidden`. - `.open` on the pencil / lifecycle cell marks which panel is showing;
`filter.js` `togglePanel()`/`toggleConfirmRow()` own that class alongside
`hidden`. An open lifecycle cell needs the next surface step up from
`--hover` (`--rule`) to stay legible as the panel's owner, since the panel
itself already sits on `--ash`.
## 5. Components (userscript panel) ## 5. Components (userscript panel)
@@ -167,9 +224,9 @@ Same tokens, same heat rule, structure unchanged from before the revamp
(`#fab`/`#hit`, `#panel`, `#nav` chips, `#context`, `#tabs`, `#list` of `.item`). (`#fab`/`#hit`, `#panel`, `#nav` chips, `#context`, `#tabs`, `#list` of `.item`).
Cinder-specific: `.item.hot` (new chapter) and `.item.dim` (archived) mirror Cinder-specific: `.item.hot` (new chapter) and `.item.dim` (archived) mirror
`.is-new` / `.is-dim`; chips and `.btn`s are mono small-caps with hairline `.is-new` / `.is-dim`; chips and `.btn`s are mono small-caps with hairline
borders instead of pills; loading is the same sliding ember hairline (`.spinner` borders instead of pills; loading is the same sliding hairline (`.spinner`
is now a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule, is a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
ember-washed when `.err`. `--danger`-washed when `.err`.
**Do not touch** the FAB geometry while restyling: `#fab` keeps **Do not touch** the FAB geometry while restyling: `#fab` keeps
`touch-action: none`, must not regain `overflow: hidden`, and `#hit` keeps the `touch-action: none`, must not regain `overflow: hidden`, and `#hit` keeps the
@@ -179,36 +236,48 @@ ember-washed when `.err`.
## 6. Motion ## 6. Motion
Three animations, all ≤ 1.15s and all disabled under Three animations, all ≤ 1.15s and all disabled under
`prefers-reduced-motion: reduce`: `prefers-reduced-motion: reduce` (pseudo-elements need naming explicitly in
that query — `*` does not match `::before`/`::after`, so the busy bar and
error dot are listed by name and fall back to their static drawn form):
- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel. - `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel.
- `barSlide` — the burning hairline, for any busy state. - `barSlide` — the sliding hairline, for any busy state.
- `emberPulse` — the 5px dot on `.error-inline`. - `mutePulse` — the 5px dot on `.error-inline`.
No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`. No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
## 7. Accessibility floor (not negotiable) ## 7. Accessibility floor (not negotiable)
- Touch targets on the phone layout are 44–46px; the 40px desktop cells are - Touch targets on the phone layout are 44–46px; the 44px desktop cells are
pointer-only (≥720px). pointer-only (≥720px).
- Every icon-only control keeps `title` + `aria-label`; the SVG inside is - Every icon-only control keeps `title` + `aria-label`; the SVG inside is
`aria-hidden`. `aria-hidden`. Lifecycle buttons also carry `aria-expanded` +
`aria-controls` pointing at their `.confirm-row`.
- The cover link is `tabindex="-1" aria-hidden="true"` because the title link - The cover link is `tabindex="-1" aria-hidden="true"` because the title link
and the play cell already reach the same URL — do not make it a third tab stop. and the play cell already reach the same URL — do not make it a third tab stop.
- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class, - Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class,
and `setActiveTab()` in `filter.js` maintains it after an htmx swap. and `setActiveTab()` in `filter.js` maintains it after an htmx swap.
- `.confirm-row` and `.error-inline` are `role="group"`/`role="status"` with
`aria-live="polite"` so a disclosure opening is announced.
- Light and dark are both first-class. Check any new colour in both. - Light and dark are both first-class. Check any new colour in both.
## 8. Adding something new — checklist ## 8. Adding something new — checklist
1. Can it be a hairline, a small-caps label, or a serif line instead of a new 1. Can it be a hairline, a small-caps label, or a serif line instead of a new
component? Prefer that. component? Prefer that.
2. Tokens only, both colour branches. 2. Tokens only, both colour branches. A new action gets its own named accent
(like `--slate`/`--moss`/`--clay`) at the same weight as the existing set —
never reuse `--ember` or `--danger` for anything but their one meaning.
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a 3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
third state class. third state class.
4. Icon → `templates/icons.html`; nothing inlines SVG paths. 4. If it removes a series from the current view (archive/finish/remove-shaped),
5. Phone first (44px targets, single column), then the ≥720px block. it is confirm-gated via its own `.confirm-row` — no exceptions, restore is
6. Verify: `cd backend && go test ./...`, then run the binary and screenshot the only instant action because it's the one that's reversible by nature.
5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays
the one exception (`chrome.html`'s `mark` template), since it takes
page-level custom properties the sprite can't carry per-instance.
6. Phone first (44px targets, single column), then the ≥720px block.
7. Verify: `cd backend && go test ./...`, then run the binary and screenshot
both widths and both colour schemes (Playwright: `emulateMedia`, both widths and both colour schemes (Playwright: `emulateMedia`,
`setViewportSize`; disable the browser cache — `/static/*` is served with `setViewportSize`; disable the browser cache — `/static/*` is served with
`max-age=3600`, and templates are `go:embed`ed so the binary must be rebuilt `max-age=3600`, and templates are `go:embed`ed so the binary must be rebuilt