diff --git a/REDEPLOY.md b/REDEPLOY.md new file mode 100644 index 0000000..5b74008 --- /dev/null +++ b/REDEPLOY.md @@ -0,0 +1,342 @@ +# Redeploy runbook + +Shipping new code to a server that is already running. First-time setup (DNS, +`.env`, Traefik, installing the userscript) is `DEPLOY.md` — this file assumes +all of that exists and picks up at "there is a running stack and I want it to +run the new commit." + +Whole thing is ~5 minutes, most of it waiting on `docker build`. Order matters: +**back up before you pull.** A backup taken after a bad migration is a backup of +the damage. + +Paths below assume the checkout is at `/opt/mangabm`; substitute your own. The +one absolute rule about paths: **backups live in `../mangabm-backups/`**, a +sibling of the project directory (`/opt/mangabm-backups`), never inside it. It +sits outside the repo so `git pull`, `git clean -fd` and a bad `rm -rf` inside +the checkout cannot take the backups with them. + +``` +/opt/ +├── mangabm/ <- the checkout (this repo) +└── mangabm-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db +``` + +--- + +## 0. Preflight + +```bash +cd /opt/mangabm + +# Both -f flags, every time. The prod override is not standalone. +COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml" + +$COMPOSE ps # manga-api should be Up +git status --short # expect empty +git log --oneline -1 # note this hash — it is your rollback target +df -h /var/lib/docker | tail -1 # a build needs room +``` + +If `git status` is dirty, someone edited files on the server. Decide before you +pull: `git stash` to keep it, `git checkout -- .` to discard. A `git pull` onto a +dirty tree fails halfway and leaves you in a worse spot than either. + +Create the backup directory once, and make sure it is a sibling, not a child: + +```bash +mkdir -p ../mangabm-backups +BACKUP_DIR="$(cd .. && pwd)/mangabm-backups" # absolute — Docker needs it +echo "$BACKUP_DIR" # -> /opt/mangabm-backups +``` + +--- + +## 1. Back up the database + +The database is a single SQLite file in the named Docker volume, at +`/data/bookmarks.db` inside the container. Find the volume's real name — Compose +prefixes it with the project directory: + +```bash +docker volume ls --filter name=bookmarks-data +# -> local mangabm_bookmarks-data +VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1) +``` + +### Preferred: hot backup, no downtime + +The store runs in **WAL mode**, so recent writes may still be sitting in +`bookmarks.db-wal`. Copying `bookmarks.db` alone while the container runs can +therefore silently drop the newest bookmarks. `VACUUM INTO` folds the WAL in and +writes one consistent file, safe to run against a live database: + +```bash +STAMP=$(date -u +%Y%m%d-%H%M%S) # UTC, sorts chronologically as text +docker run --rm \ + -v "$VOL":/data \ + -v "$BACKUP_DIR":/backup \ + alpine sh -c "apk add -q sqlite && + sqlite3 /data/bookmarks.db \"VACUUM INTO '/backup/bookmarks-$STAMP.db'\"" + +ls -lh "$BACKUP_DIR"/bookmarks-$STAMP.db +``` + +`$STAMP` is the "time in the name" — `bookmarks-20260730-014233.db`. UTC, so the +files sort in real order and never collide across a DST shift. + +Note the source volume is mounted **read-write**, which looks wrong for a backup +and is not. Opening a WAL database requires creating the `-shm` shared-memory +file; with `:ro` the command fails with `unable to open database file` and no +backup is produced. `VACUUM INTO` never writes to the source itself. + +Verify it before you trust it. An unreadable backup is worse than none, because +you will act as though you have one: + +```bash +docker run --rm -v "$BACKUP_DIR":/backup alpine sh -c "apk add -q sqlite && + sqlite3 /backup/bookmarks-$STAMP.db 'PRAGMA integrity_check;' && + sqlite3 /backup/bookmarks-$STAMP.db 'SELECT count(*) FROM bookmarks;'" +# -> ok +# -> 37 +``` + +The count should match what the web UI shows. Zero rows on a server you know has +bookmarks means you backed up the wrong volume. + +### Fallback: cold copy (no network for `apk add sqlite`) + +Stop the service first, then copy the database **and its sidecars** — the `-wal` +is not optional, it is where the newest writes are: + +```bash +$COMPOSE stop +docker run --rm -v "$VOL":/data:ro -v "$BACKUP_DIR":/backup alpine sh -c " + cp /data/bookmarks.db /backup/bookmarks-$STAMP.db + [ -f /data/bookmarks.db-wal ] && cp /data/bookmarks.db-wal /backup/bookmarks-$STAMP.db-wal + [ -f /data/bookmarks.db-shm ] && cp /data/bookmarks.db-shm /backup/bookmarks-$STAMP.db-shm + ls -1 /backup" +$COMPOSE start +``` + +Costs ~10 seconds of downtime. A clean shutdown usually checkpoints the WAL away, +so seeing only the `.db` file is normal and fine — the `[ -f ]` guards exist for +the case where it did not. Restoring this variant means putting whichever files +you got back together, under their original names. + +Read-only is safe here precisely because nothing opens the database: it is a file +copy, not a SQLite connection. + +### Retention + +Keep a month, drop the rest — a bookmark database this small compresses the +decision to "disk is free, but not infinite": + +```bash +ls -1t "$BACKUP_DIR"/bookmarks-*.db | tail -n +31 | xargs -r rm -v +``` + +--- + +## 2. Pull the new code + +```bash +git pull --ff-only +git log --oneline -3 +``` + +`--ff-only` so a diverged history fails loudly instead of opening a merge you +did not plan on the production box. + +Check whether `.env` needs anything new. New config lands in `.env.example`, and +compose fails at start for a missing required var — better to find out now: + +```bash +git diff HEAD@{1} HEAD -- .env.example docker-compose.yml docker-compose.prod.yml +``` + +If a variable was added there, add it to `.env` before continuing. + +--- + +## 3. Rebuild and restart + +```bash +$COMPOSE up -d --build +``` + +**A rebuild is mandatory for any UI change.** The HTML templates, CSS, +JavaScript and fonts are compiled into the binary by `//go:embed`, so editing +them on the server — or pulling them — changes nothing until the image is +rebuilt. The one exception is `userscript/manga-bookmark.user.js`, which is +bindmounted read-only and read fresh per request. + +```bash +$COMPOSE ps # Up, and recently (re)created +docker logs manga-api --tail 20 # -> "listening on :8080 ..." +``` + +Nothing in the log about the database or the poller failing. The image is tagged +`mangabm-backend:latest`, so the previous image is still on disk untagged — +that is what makes the rollback in §6 quick. + +--- + +## 4. Verify the deploy + +Same four API checks as `DEPLOY.md` §3, plus the web UI. Set the host names once: + +```bash +API=https://manga-api.violetcrown.my.id +WEB=https://manga.violetcrown.my.id +TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) + +curl -s $API/healthz # -> ok +curl -s -o /dev/null -w '%{http_code}\n' $API/bookmarks # -> 401 +curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200 + # -> your data, not [] +curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \ + -H 'Access-Control-Request-Method: PUT' \ + $API/bookmarks/x | grep -i access-control # -> allow-origin echoed +``` + +`[]` from the third call is the alarm that matters: the volume is not attached +and you are looking at an empty database. Stop and check `$COMPOSE config +--volumes` before touching anything else. + +Web UI and its assets: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' $WEB/ # -> 200 (login page) +curl -s -o /dev/null -w '%{http_code}\n' $WEB/static/style.css # -> 200 + +# The fonts are self-hosted; this is what tells you they shipped and are typed. +curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \ + $WEB/static/fonts/instrument-serif-400-latin.woff2 +# -> 200 font/woff2 + +# The served userscript, whose @version is its mtime — a pull bumps it. +curl -s $API/u/$TOKEN/manga-bookmark.user.js | grep '@version' +``` + +`404` on the font means `static/fonts/` did not make it into the image; the UI +will still render, in Georgia, which is easy to miss on a phone. `application/ +octet-stream` instead of `font/woff2` means an older binary is running. + +Then open `$WEB` in a browser and confirm, in one glance: + +- Serif brand and serif row titles — not the system sans fallback. +- A series with an unread chapter has a **crimson title on an ember underline**; + everything else is cool grey. That single detail exercises the whole design + path (template class, CSS, and the poller's `latest_chapter`). +- Tapping the pencil opens the chapter form; Save closes it and the row keeps + its place in the list. + +--- + +## 5. Smoke-test the full loop + +The API answering is not the same as the product working. Do this on the phone, +against the real sites — it is the only check that covers the userscript, CORS +and the shared store together: + +1. Open a series on **asurascans.com**, open the panel, **+ Bookmark this**. +2. On the server: `curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks` + → the series is in the JSON. +3. Open a chapter of it, reopen the panel → last-read shows that chapter, and + opening an *older* chapter does not move it backwards. +4. Open **demonicscans.org**, open the panel → the Asura bookmark is listed + there too. Different origin, one store — this is the whole point of the + backend, and the check that fails first when CORS or TLS regressed. +5. Open `$WEB` → the same series appears, with the same chapter. +6. Turn airplane mode on, tap ★ on a row, turn it off, reopen the panel → the + star stuck. That exercises the retry queue. + +If 1–5 pass, the deploy is good. + +--- + +## 6. Rollback + +Two independent things can be wrong, so undo only what broke. + +**Bad code, database fine** — go back to the previous commit and rebuild: + +```bash +git log --oneline -5 +git checkout +$COMPOSE up -d --build +``` + +**Database damaged** — restore the backup from §1. Stop first: the running +process holds the WAL, and dropping a file under a live SQLite connection +corrupts what you were trying to save. + +```bash +$COMPOSE stop + +docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c ' + rm -f /data/bookmarks.db /data/bookmarks.db-wal /data/bookmarks.db-shm && + cp /backup/bookmarks-.db /data/bookmarks.db && + chown 65532:65532 /data/bookmarks.db && + ls -l /data' + +$COMPOSE start +docker logs manga-api --tail 20 +curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200 +``` + +Two steps here are easy to skip and both bite: + +- **Delete the stale `-wal` and `-shm`.** Leaving them beside a restored database + mixes two different histories; SQLite will either refuse to open it or quietly + reapply writes you meant to discard. +- **`chown 65532:65532`.** The image is `distroless/static:nonroot` and runs as + that uid, while the helper container above writes as root. A root-owned + database opens read-only-ish: reads work, so `/bookmarks` looks fine, and then + every write fails. That is the worst possible failure mode — it looks restored. + +--- + +## 7. The whole thing, as one block + +For a routine redeploy where nothing needs deciding: + +```bash +cd /opt/mangabm +COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml" +BACKUP_DIR="$(cd .. && pwd)/mangabm-backups"; mkdir -p "$BACKUP_DIR" +VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1) +STAMP=$(date -u +%Y%m%d-%H%M%S) + +docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c \ + "apk add -q sqlite && sqlite3 /data/bookmarks.db \"VACUUM INTO '/backup/bookmarks-$STAMP.db'\" && + sqlite3 /backup/bookmarks-$STAMP.db 'PRAGMA integrity_check;'" && +git pull --ff-only && +$COMPOSE up -d --build && +sleep 5 && +curl -sf https://manga-api.violetcrown.my.id/healthz && echo " deploy ok" +``` + +The `&&` chain is deliberate: if the backup or its integrity check fails, +nothing is pulled and nothing is rebuilt. Then still do §5 by hand — no shell +command can tell you the panel works on the phone. + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| `/bookmarks` returns `[]` after redeploy | Volume not attached — check `$COMPOSE config --volumes` and that you passed both `-f` files. Do **not** re-bookmark; the data is still in the volume. | +| UI looks like plain Georgia / system sans | `static/fonts/` missing from the image, or the browser cached an old `style.css`. `/static/*` is served `max-age=3600`, so hard-reload or wait an hour. | +| CSS or template change did not appear | You restarted without `--build`. Assets are `//go:embed`ed. | +| Font answers `application/octet-stream` | Old binary — the `.woff2` MIME registration is in `web.go`. Rebuild. | +| Everyone logged out of the web UI | `API_TOKEN` or `WEB_PASSWORD` changed; sessions are derived from both. Expected, just log in again. | +| `compose` errors about `MANGA_WEB_HOST` | Run from the directory holding `.env`. Both host vars are required even when the web UI is unused. | +| Userscript did not update on the phone | Violentmonkey polls on its own schedule; force a check. `@version` comes from the file's mtime, so confirm the pull actually touched it. | +| `apk add sqlite` fails (no network) | Use the cold-copy fallback in §1 — and copy `bookmarks.db-wal` too. | +| Reads work but every write fails after a restore | Restored file is root-owned; the container is uid 65532. `chown 65532:65532` it (§6). | +| Backup command: `unable to open database file` | Source volume mounted `:ro`. WAL needs to create `-shm`; mount it read-write (§1). | + +Full first-time setup: `DEPLOY.md`. Config reference and endpoints: `README.md`. +UI conventions: `docs/design-system.md`. diff --git a/backend/static/filter.js b/backend/static/filter.js index 5595ca9..ee15ec5 100644 --- a/backend/static/filter.js +++ b/backend/static/filter.js @@ -26,29 +26,41 @@ 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. +// disclosure panels; only one makes sense open at a time. The button that +// owns an open panel carries .open, which is how the strip shows which cell +// the panel belongs to. 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; + var card = document.getElementById("card-" + key); + if (card) { + card.querySelectorAll(".actions .open").forEach(function (b) { + b.classList.remove("open"); + }); + } +} + +function togglePanel(key, panelId, buttonSelector) { + var panel = document.getElementById(panelId + key); + if (!panel) return null; + var opening = panel.hidden; + closeCardPanels(key); + panel.hidden = !opening; + var card = document.getElementById("card-" + key); + var button = card && card.querySelector(buttonSelector); + if (button) button.classList.toggle("open", opening); + return panel; } function toggleChapterForm(key) { - var form = document.getElementById("chapter-form-" + key); - if (!form) return; - var opening = form.hidden; - closeCardPanels(key); - form.hidden = !opening; - if (!form.hidden) form.querySelector("input").focus(); + var form = togglePanel(key, "chapter-form-", ".actions .pencil"); + if (form && !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; + togglePanel(key, "confirm-row-", ".actions .remove"); } // A failed favourite/chapter/delete request leaves the card in place (htmx diff --git a/backend/static/fonts/dm-sans-var-latin.woff2 b/backend/static/fonts/dm-sans-var-latin.woff2 new file mode 100644 index 0000000..01383d7 Binary files /dev/null and b/backend/static/fonts/dm-sans-var-latin.woff2 differ diff --git a/backend/static/fonts/ibm-plex-mono-500-latin.woff2 b/backend/static/fonts/ibm-plex-mono-500-latin.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/backend/static/fonts/ibm-plex-mono-500-latin.woff2 differ diff --git a/backend/static/fonts/ibm-plex-mono-600-latin.woff2 b/backend/static/fonts/ibm-plex-mono-600-latin.woff2 new file mode 100644 index 0000000..67aeeb0 Binary files /dev/null and b/backend/static/fonts/ibm-plex-mono-600-latin.woff2 differ diff --git a/backend/static/fonts/instrument-serif-400-italic-latin.woff2 b/backend/static/fonts/instrument-serif-400-italic-latin.woff2 new file mode 100644 index 0000000..197abca Binary files /dev/null and b/backend/static/fonts/instrument-serif-400-italic-latin.woff2 differ diff --git a/backend/static/fonts/instrument-serif-400-latin.woff2 b/backend/static/fonts/instrument-serif-400-latin.woff2 new file mode 100644 index 0000000..0ad6971 Binary files /dev/null and b/backend/static/fonts/instrument-serif-400-latin.woff2 differ diff --git a/backend/static/style.css b/backend/static/style.css index f7d2f93..76692a0 100644 --- a/backend/static/style.css +++ b/backend/static/style.css @@ -1,53 +1,123 @@ -/* Mobile first. Dark by default because manga reading happens at night; the - light branch follows the system preference. */ -:root { - color-scheme: dark light; - --bg: #14161a; - --surface: #1d2026; - --surface-2: #262a32; - --text: #e8eaed; - --muted: #9aa1ac; - --accent: #6aa9ff; - --accent-ink: #0b1220; - --danger: #ff6a6a; - --danger-ink: #2a0a0a; - --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; +/* Fonts are served from this binary, not from Google: this UI is read in + Bromite, where fonts.googleapis.com is routinely blocked, and over a LAN + with no internet route. Latin subsets only, woff2 only — every browser that + runs the app supports it, so there is no second format to ship. Roughly + 120 KB for all five faces, embedded via //go:embed static. */ +@font-face { + font-family: "Instrument Serif"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/fonts/instrument-serif-400-latin.woff2) format("woff2"); +} +@font-face { + font-family: "Instrument Serif"; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url(/static/fonts/instrument-serif-400-italic-latin.woff2) format("woff2"); +} +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(/static/fonts/ibm-plex-mono-500-latin.woff2) format("woff2"); +} +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/static/fonts/ibm-plex-mono-600-latin.woff2) format("woff2"); +} +/* One variable file covers the 400–700 range the design uses. */ +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url(/static/fonts/dm-sans-var-latin.woff2) format("woff2"); } -/* 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. */ +/* Cinder — editorial serif, containerless sheets, ash hairlines. + Heat is purely typographic: a series with an unread chapter turns its title + crimson and gains an ember underline. Everything else stays cool. Dark is + the default because this is read at night on a phone; the light branch + re-tunes the same tokens onto paper. */ +:root { + color-scheme: dark light; + + --font-display: "Instrument Serif", Georgia, "Times New Roman", serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --font-body: "DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif; + + --ink: #100f0e; /* page */ + --ash: #161413; /* recessed panel (chapter form) */ + --dim: #0d0c0b; /* archived / finished rows sink */ + --rule: #221f1d; /* hairline between sheets */ + --rule-soft: #1a1817; /* measure edges */ + --field-line: #2c2926; + --hover: #1a1816; + + --paper: #f2ece5; /* highest-contrast text, primary button */ + --paper-hot: #f0d3cb; /* title of a series with a new chapter */ + --paper-dim: #ddd5cb; /* resting title */ + --mute: #8d857c; /* secondary text, idle icons */ + --mute-2: #5a5450; /* labels, hints */ + --faint: #3a3733; /* meta separators */ + --faint-2: #57504b; /* cover monogram */ + + --ember: #e0452c; /* the only heat */ + --ember-wash: #1a1211; /* ember-tinted surface */ + --ember-ink: #150907; /* text on solid ember */ + --ember-soft: #eda798; /* text on ember wash */ + --brass: #b8912f; /* favourite — a second, cooler metal */ + --trash: #6b5450; /* remove, resting */ + + --asura: #7d93a5; + --demonic: #a98a78; + + /* Covers are often missing; the hatch keeps the slot honest instead of + faking artwork. */ + --hatch: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px); + --hatch-dim: repeating-linear-gradient(135deg, #1b1918 0 5px, #151313 5px 10px); + + --measure: 760px; +} + +/* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the + dark ember is too bright to read as text on off-white. */ @media (prefers-color-scheme: light) { :root { - --bg: #f4f5f7; - --surface: #ffffff; - --surface-2: #eceef2; - --text: #1a1d22; - --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; + --ink: #f7f4ef; + --ash: #efeae3; + --dim: #f1ede7; + --rule: #e0dad2; + --rule-soft: #e8e3dc; + --field-line: #d4cdc4; + --hover: #efeae3; + + --paper: #191715; + --paper-hot: #a33018; + --paper-dim: #191715; + --mute: #6b645d; + --mute-2: #857d75; + --faint: #c9c2ba; + --faint-2: #a8a098; + + --ember: #c23a22; + --ember-wash: #fbeee9; + --ember-ink: #fff; + --ember-soft: #8d2c17; + --brass: #8a681c; + --trash: #a98276; + + --asura: #4f6b80; + --demonic: #8a6a55; + + --hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px); + --hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px); } } @@ -55,350 +125,531 @@ body { margin: 0; - padding: 0 12px calc(24px + env(safe-area-inset-bottom)); - background: var(--bg); - color: var(--text); - font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--ink); + color: var(--paper-dim); + font: 400 15px/1.5 var(--font-body); + display: flex; + justify-content: center; + -webkit-text-size-adjust: 100%; } -/* Every interactive element clears the 44px touch-target floor. */ -button, .primary, [role="tab"] { - min-height: 44px; - border-radius: var(--radius); - border: 0; - font: inherit; - cursor: pointer; -} +a { color: inherit; text-decoration: none; } +button, input { font: inherit; } +button { cursor: pointer; } -/* --- login --- */ +/* Every hideable thing here is a flex container, and display beats hidden. */ +[hidden] { display: none !important; } -.login-body { - display: grid; - place-items: center; - min-height: 100dvh; -} +@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } } +@keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } } +@keyframes emberPulse { 0%, 100% { opacity: .5 } 50% { opacity: 1 } } -.login-card { - width: min(380px, 100%); - padding: 24px; - background: var(--surface); - border-radius: var(--radius); -} - -.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 input { +/* One measured column, edges drawn rather than boxed. */ +.sheet { width: 100%; - min-height: 44px; - padding: 0 12px; - margin-bottom: 12px; - background: var(--surface-2); - color: var(--text); - border: 1px solid transparent; - border-radius: var(--radius); - font: inherit; + max-width: var(--measure); + min-height: 100vh; + display: flex; + flex-direction: column; + border-left: 1px solid var(--rule-soft); + border-right: 1px solid var(--rule-soft); + padding-bottom: env(safe-area-inset-bottom); } -.login-card input:focus-visible { outline: 2px solid var(--accent); } -.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; } - -/* --- chrome --- */ +/* ---- brand + chrome ---- */ +.brand { + margin: 0; + font: 400 26px/1 var(--font-display); + color: var(--paper); +} +.brand em { color: var(--ember); font-style: italic; } .topbar { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 22px 20px 14px; +} +.topbar form { margin: 0; } + +.ghost { + padding: 0; + border: none; + border-bottom: 1px solid var(--field-line); + background: transparent; + color: var(--mute); + font: 500 11px var(--font-mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.ghost:hover { color: var(--paper); border-bottom-color: var(--paper); } + +.chrome { display: flex; flex-direction: column; } + +.searchbar { + display: flex; + align-items: center; + gap: 10px; + height: 44px; + margin: 0 20px 14px; + border-bottom: 1px solid var(--rule); + color: var(--mute-2); +} +.searchbar svg { width: 15px; height: 15px; flex: none; } +.search { + flex: 1; + min-width: 0; + height: 44px; + border: none; + background: transparent; + color: var(--paper); + font: 400 15px var(--font-body); + outline: none; +} +.search::placeholder { color: var(--mute-2); } + +/* Tabs are set in the display serif and underlined, not chipped. */ +.tabs { + display: flex; + gap: 16px; + padding: 2px 20px 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + border-bottom: 1px solid var(--rule); +} +.tabs::-webkit-scrollbar { display: none; } +.tabs [role="tab"] { + flex: none; + display: flex; + align-items: center; + gap: 7px; + padding: 8px 0 12px; + color: var(--mute); + font: 400 17px var(--font-display); + white-space: nowrap; +} +.tabs [role="tab"]:hover { color: var(--paper-dim); } +.tabs [role="tab"].active { + color: var(--paper); + border-bottom: 2px solid var(--paper); + margin-bottom: -1px; +} +/* Updated is the one tab that carries heat. */ +.tabs .tab-new { color: var(--ember); } +.tabs .tab-new.active { border-bottom-color: var(--ember); } +.count { + font: 600 10px var(--font-mono); + letter-spacing: 0; + padding: 2px 5px; + border: 1px solid currentColor; +} + +/* ---- continue reading ---- */ +.recent { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 0 18px; + border-bottom: 1px solid var(--rule); +} +.recent h2 { + margin: 0; + padding: 0 20px; + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; + color: var(--mute-2); +} +.recent-strip { + display: flex; + gap: 14px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + padding: 0 20px 4px; +} +.recent-strip::-webkit-scrollbar { display: none; } +.recent-card { + width: 92px; + flex: none; + display: flex; + flex-direction: column; + gap: 8px; +} +.recent-cover { + position: relative; + width: 92px; + height: 123px; + background: var(--hatch); + display: grid; + place-items: center; + overflow: hidden; +} +.recent-cover img { width: 100%; height: 100%; object-fit: cover; } +.recent-title { + font: 400 15px/1.2 var(--font-display); + color: var(--paper-dim); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.recent-chapter { + font: 500 10px/1 var(--font-mono); + letter-spacing: .1em; + color: var(--mute-2); +} +.recent-card.is-new .recent-title { color: var(--paper-hot); } +.recent-card.is-new .recent-chapter { color: var(--ember); } + +/* The rule at the cover foot is the only cover ornament: ember for a new + chapter, brass for a favourite. */ +.foot-rule { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + background: var(--ember); +} +.foot-rule.brass { background: var(--brass); } + +.monogram { + font: 400 32px var(--font-display); + color: var(--faint-2); +} + +/* ---- rows ---- */ +.list { display: flex; flex-direction: column; } + +.card { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px 20px; + border-bottom: 1px solid var(--rule); + animation: sheetIn .18s ease-out; +} +.card.is-dim { background: var(--dim); } + +.row { display: flex; flex-wrap: wrap; gap: 14px; } + +.cover { + position: relative; + width: 66px; + height: 88px; + flex: none; + background: var(--hatch); + display: grid; + place-items: center; + overflow: hidden; +} +.cover img { width: 100%; height: 100%; object-fit: cover; } +.cover .monogram { font-size: 24px; } +.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; } + +.body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.title-line { display: flex; gap: 8px; align-items: flex-start; } +.title { + margin: 0; + flex: 1; + min-width: 0; + font: 400 19px/1.2 var(--font-display); + color: var(--paper-dim); +} +/* Heat: crimson title over an ember hairline sized to the text, not the row. */ +.is-new .title { + flex: 0 1 auto; + align-self: flex-start; + color: var(--paper-hot); + border-bottom: 1px solid var(--ember); + padding-bottom: 3px; +} +.is-dim .title { font-style: italic; color: var(--mute); } +.is-dim .fav-mark { color: var(--mute-2); } +.fav-mark { flex: none; width: 13px; height: 13px; margin-top: 5px; color: var(--brass); } + +.meta { + margin: 0; + display: flex; + align-items: center; + gap: 9px; + flex-wrap: wrap; + font: 500 10px/1 var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; + color: var(--mute); +} +.meta .sep { color: var(--faint); } +.site-asura { color: var(--asura); } +.site-demonic { color: var(--demonic); } +.new-chapter { color: var(--ember); } +.state { display: flex; align-items: center; gap: 4px; color: var(--mute); } +.state svg { width: 9px; height: 9px; } +.is-dim .meta { color: var(--mute-2); } +.is-dim .site-asura, .is-dim .site-demonic { color: var(--mute); filter: grayscale(.6); } + +/* ---- action strip: full-width on a phone, hairline-divided cells ---- */ +.actions { + flex: 1 0 100%; + display: flex; + border-top: 1px solid var(--rule); +} +.actions > * { + flex: 1 1 0; + min-width: 0; + height: 46px; + display: grid; + place-items: center; + padding: 0; + border: none; + border-right: 1px solid var(--rule); + background: transparent; + color: var(--mute); +} +.actions > *:last-child { border-right: none; } +.actions svg { width: 17px; height: 17px; } +.actions > *:hover { color: var(--paper); } +.actions .play { color: var(--paper); } +.is-new .actions .play { color: var(--ember); } +.actions .play:hover { background: var(--hover); } +.actions .on { color: var(--brass); } +.actions .restore { color: var(--paper); } +.actions .remove { color: var(--trash); } +.actions .remove:hover { color: var(--ember); } +.actions .open { background: var(--hover); color: var(--paper); } +.actions .remove.open { background: var(--ember-wash); color: var(--ember); } +.is-dim .actions > * { color: var(--mute-2); } + +/* ---- disclosure panels ---- */ +.chapter-form, .confirm-row, .error-inline { animation: sheetIn .18s ease-out; } + +.chapter-form { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 14px; + background: var(--ash); +} +.chapter-form .hint { + margin: 0; + font: 500 10px/1 var(--font-mono); + letter-spacing: .14em; + text-transform: uppercase; + color: var(--mute-2); +} +.chapter-form .field { display: flex; gap: 10px; } +.chapter-form input { + flex: 1; + min-width: 0; + height: 46px; + padding: 0 12px; + border: 1px solid var(--field-line); + background: var(--ink); + color: var(--paper); + font: 500 17px var(--font-mono); + outline: none; +} +.chapter-form input:focus { border-color: var(--ember); } +.chapter-form input::-webkit-outer-spin-button, +.chapter-form input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.chapter-form button { + height: 46px; + padding: 0 20px; + border: none; + background: var(--paper); + color: var(--ink); + font: 400 17px var(--font-display); +} + +.confirm-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; - padding: 12px 0; + padding: 14px; + background: var(--ember-wash); } - -.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); } - -.search { - width: 100%; - min-height: 44px; - padding: 0 12px; - margin-bottom: 12px; - background: var(--surface); - color: var(--text); - border: 1px solid transparent; - border-radius: var(--radius); - font: inherit; +.confirm-row span { font: 400 17px var(--font-display); color: var(--ember-soft); } +.confirm-row div { display: flex; gap: 8px; } +.confirm-row button { + height: 40px; + padding: 0 14px; + border: 1px solid var(--field-line); + background: transparent; + color: var(--mute); + font: 500 12px var(--font-body); } - -.search:focus-visible { outline: 2px solid var(--accent); } - -/* Five buttons no longer divide a phone's width legibly, so the row scrolls - sideways instead of squeezing. The scrollbar is hidden: on mobile there is - none anyway, and on desktop it would sit under the tab labels. */ -.tabs { - display: flex; gap: 8px; margin-bottom: 16px; - overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; +.confirm-row button:hover { color: var(--paper); } +.confirm-row .danger-solid { + border: none; + background: var(--ember); + color: var(--ember-ink); + font-weight: 600; } -.tabs::-webkit-scrollbar { display: none; } - -.tabs [role="tab"] { - flex: 1 0 auto; - padding-inline: 14px; - white-space: nowrap; - display: grid; - place-items: center; - background: var(--surface); - color: var(--muted); - text-decoration: none; -} - -.tabs [role="tab"].active { background: var(--accent); color: var(--accent-ink); font-weight: 600; } - -/* --- continue reading --- */ - -.recent h2 { margin: 0 0 8px; font-size: .8125rem; font-weight: 600; text-transform: uppercase; - letter-spacing: .04em; color: var(--muted); } - -.recent-strip { - display: flex; - gap: 10px; - overflow-x: auto; - padding-bottom: 8px; - margin-bottom: 16px; - scroll-snap-type: x mandatory; - -webkit-overflow-scrolling: touch; -} - -.recent-card { - flex: 0 0 110px; - scroll-snap-align: start; - display: block; - padding: 8px; - background: var(--surface); - border-radius: var(--radius); - color: var(--text); - text-decoration: none; -} - -/* A reserved slot even without cover art, so the ribbon always has a - 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; } -.recent-chapter { display: block; color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; } - -/* --- list --- */ - -.list { display: grid; gap: 10px; } - -.card { - display: grid; - grid-template-columns: 96px 1fr; - gap: 12px; - padding: 10px; - background: var(--surface); - border-radius: var(--radius); -} - -.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; } -/* 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; - font-size: .75rem; color: var(--muted); } - -.site { padding: 2px 6px; background: var(--surface-2); border-radius: 6px; text-transform: uppercase; - 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; } - -/* 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 { - display: grid; - place-items: center; - background: var(--accent); - color: var(--accent-ink); - font-size: 1.125rem; - text-decoration: none; -} - -.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.danger { color: var(--danger); } - -/* 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 { - /* 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; - padding: 0 12px; - background: var(--surface-2); - color: var(--text); - border: 1px solid transparent; - border-radius: var(--radius); - font: inherit; - font-variant-numeric: tabular-nums; -} - -.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); } - -/* --- 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; + display: flex; + align-items: center; + gap: 9px; + margin: 0; + padding: 11px 13px; + background: var(--ember-wash); + border-left: 2px solid var(--ember); + font: 400 16px var(--font-display); + color: var(--ember-soft); +} +.error-inline::before { + content: ""; + flex: none; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--ember); + animation: emberPulse 1.4s ease-in-out infinite; } -.error-inline[hidden] { display: none; } - -/* Cards hidden by the search filter. The .chapter-form { display: flex } - 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. */ -.card[hidden] { display: none; } -.chapter-form[hidden] { display: none; } -.confirm-row[hidden] { display: none; } - -/* --- wide screens --- */ - -@media (min-width: 900px) { - body { max-width: 1100px; margin: 0 auto; padding-inline: 24px; } - .list { grid-template-columns: repeat(2, 1fr); } - .recent-card { flex-basis: 140px; } +/* Busy: the hairline at the top of the sheet burns across it. */ +.card.htmx-request { overflow: hidden; } +.card.htmx-request .actions { pointer-events: none; opacity: .5; } +.card.htmx-request::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 30%; + height: 1px; + background: var(--ember); + animation: barSlide 1.15s linear infinite; } -@media (min-width: 1300px) { - .list { grid-template-columns: repeat(3, 1fr); } +/* ---- empty ---- */ +.empty { + display: flex; + flex-direction: column; + gap: 7px; + padding: 40px 20px 48px; +} +.empty strong { font: 400 20px var(--font-display); color: var(--paper); } +.empty.hot strong { color: var(--ember); } +.empty p { + margin: 0; + max-width: 44ch; + font: 400 14px/1.6 var(--font-body); + color: var(--mute); + text-wrap: pretty; +} + +/* ---- login ---- */ +.login-card { + width: 100%; + max-width: 420px; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 52px 28px 40px; +} +.login-card .eyebrow { + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; + color: var(--mute-2); +} +.login-card h1 { + margin: 14px 0 0; + font: 400 44px/1 var(--font-display); + letter-spacing: -.01em; + color: var(--paper); +} +.login-card h1 em { color: var(--ember); font-style: italic; } +.login-card form { display: flex; flex-direction: column; gap: 18px; } +.login-card label { + font: 500 10px/1 var(--font-mono); + letter-spacing: .16em; + text-transform: uppercase; + color: var(--mute); +} +.login-card input { + width: 100%; + height: 54px; + margin-top: 9px; + padding: 0 2px; + border: none; + border-bottom: 1px solid var(--field-line); + background: transparent; + color: var(--paper); + font: 500 20px var(--font-mono); + letter-spacing: .16em; + outline: none; +} +.login-card input:focus { border-bottom-color: var(--ember); } +.login-card .error { + margin: 0; + min-height: 20px; + font: 400 13px/1.4 var(--font-body); + color: var(--ember); +} +.login-card button { + height: 54px; + border: 1px solid var(--paper); + background: var(--paper); + color: var(--ink); + font: 400 19px var(--font-display); +} +.login-card button:hover { + background: var(--ember); + border-color: var(--ember); + color: #fff; +} + +/* ---- desktop: same measure, actions fold up beside the row ---- */ +@media (min-width: 720px) { + .topbar { padding: 26px 32px 18px; } + .brand { font-size: 30px; } + + .chrome { + flex-direction: row; + align-items: center; + gap: 26px; + padding: 0 32px; + border-bottom: 1px solid var(--rule); + } + .tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; } + .tabs [role="tab"] { padding: 10px 0 14px; font-size: 18px; } + .searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; } + .recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; } + .recent-card, .recent-cover { width: 100px; } + .recent-cover { height: 133px; } + + .card { padding: 18px 32px; } + .row { flex-wrap: nowrap; align-items: center; gap: 20px; } + .cover { width: 74px; height: 100px; } + .cover .monogram { font-size: 26px; } + .title { font-size: 21px; } + + .actions { flex: none; gap: 4px; border-top: none; } + .actions > * { + flex: none; + width: 40px; + height: 40px; + border: 1px solid var(--rule); + } + .is-new .actions .play { border-color: #3a1d18; } + .actions .on { border-color: #332b14; } + + .chapter-form, .confirm-row, .error-inline { margin-left: 94px; } } @media (prefers-reduced-motion: reduce) { diff --git a/backend/templates/app.html b/backend/templates/app.html index 1726355..a5df111 100644 --- a/backend/templates/app.html +++ b/backend/templates/app.html @@ -7,19 +7,29 @@ mangaBookmark + + +{{template "icons" .}} +
-

mangaBookmark

+

mangaBookmark

- + {{/* Search sits above the tabs on a phone and folds into the tab row on a + wider screen — one flex container, order swapped in CSS. */}} +
+ +
{{if .Recent}}

Continue reading

@@ -61,6 +75,7 @@
{{template "list" .}}
+
{{end}} diff --git a/backend/templates/card.html b/backend/templates/card.html index 63293b8..35d0e69 100644 --- a/backend/templates/card.html +++ b/backend/templates/card.html @@ -1,111 +1,114 @@ {{define "card"}} -
- - {{if .Cover}} - {{else}}{{end}} - {{if .HasNewChapter}}New{{end}} - -
-

{{.Title}}

-

- {{.Site}} - Ch {{.LastChapter}} - {{if .HasNewChapter}}Ch {{.LatestChapter}} out{{end}} -

+{{/* One sheet per series. is-new turns the title crimson over an ember rule; + is-dim sinks archived and finished rows into italic grey. */}} +
+
+ +
+
+

{{.Title}}

+ {{if .Favorite}} + + {{end}} +
+

+ {{.Site}} + / + Ch {{.LastChapter}} + {{if and (eq .Status "reading") .HasNewChapter}} + / + Ch {{.LatestChapter}} out + {{end}} + {{if eq .Status "archived"}} + / + archived + {{else if eq .Status "finished"}} + / + finished + {{end}} +

+
- - + - - {{if eq .Status "finished"}} - {{else}} {{if eq .Status "archived"}} - {{else}} - {{end}} - {{end}} -
-
+ + {{if .LatestChapter}}

Latest known: Ch {{.LatestChapter}}

{{end}} +
- - + + +
{{end}} diff --git a/backend/templates/icons.html b/backend/templates/icons.html new file mode 100644 index 0000000..57c317e --- /dev/null +++ b/backend/templates/icons.html @@ -0,0 +1,15 @@ +{{define "icons"}} + + +{{end}} diff --git a/backend/templates/list.html b/backend/templates/list.html index 1195154..e6edf97 100644 --- a/backend/templates/list.html +++ b/backend/templates/list.html @@ -2,16 +2,14 @@ {{if .Items}} {{range .Items}}{{template "card" .}}{{end}} {{else if eq .Tab "fav"}} -

No favourites yet. Star a series to pin it here.

+
No favourites yet.

Star a series to pin it here.

{{else if eq .Tab "new"}} -

Nothing new. Every series is caught up to its latest chapter.

+
Nothing new.

Every series is caught up to its latest chapter.

{{else if eq .Tab "archived"}} -

Nothing archived. Shelve a series to park it here — it keeps getting checked for new chapters.

+
Nothing archived.

Shelve a series to park it here — it keeps getting checked for new chapters.

{{else if eq .Tab "finished"}} -

Nothing finished yet.

+
Nothing finished yet.

Mark a series finished and it moves out of your reading list.

{{else}} -

- Nothing here yet. Bookmarks appear once the userscript records a chapter. -

+
Nothing here yet.

Bookmarks appear once the userscript records a chapter.

{{end}} {{end}} diff --git a/backend/templates/login.html b/backend/templates/login.html index d3e9e82..0f6fdfe 100644 --- a/backend/templates/login.html +++ b/backend/templates/login.html @@ -7,15 +7,21 @@ mangaBookmark + - +
-

mangaBookmark

+
+ Private library +

mangaBookmark

+
- - - {{if .Error}}

{{.Error}}

{{end}} +
+ + +
+

{{.Error}}

diff --git a/backend/web.go b/backend/web.go index 61f5840..62c7dee 100644 --- a/backend/web.go +++ b/backend/web.go @@ -7,6 +7,7 @@ import ( "io/fs" "log" "math" + "mime" "net/http" "strconv" "strings" @@ -38,6 +39,11 @@ type listView struct { Tab string // "all", "fav", or "new" Recent []Bookmark Items []Bookmark + // NewCount is the badge on the Updated tab: how many series being read + // have a chapter out that has not been read. It is counted over the whole + // reading set, not the active tab, so the badge does not change meaning as + // the user moves between tabs. + NewCount int } // Initial is the monogram the templates show in place of a cover when the @@ -93,6 +99,12 @@ func staticHandler() http.Handler { if err != nil { panic("embed static: " + err.Error()) } + // Go's built-in table has no .woff2 and the scratch image has no + // /etc/mime.types, so without this the fonts go out as + // application/octet-stream. + if err := mime.AddExtensionType(".woff2", "font/woff2"); err != nil { + panic("woff2 mime: " + err.Error()) + } files := http.FileServer(http.FS(sub)) return http.StripPrefix("/static/", http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -178,12 +190,14 @@ func (h *webHandler) buildListView(tab string) (listView, error) { recent = recent[:recentCount] } + withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() }) + var items []Bookmark switch tab { case "fav": items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite }) case "new": - items = filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() }) + items = withNew case "archived": items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived }) case "finished": @@ -192,7 +206,7 @@ func (h *webHandler) buildListView(tab string) (listView, error) { tab = "all" items = reading } - return listView{Tab: tab, Recent: recent, Items: items}, nil + return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil } func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) { diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..a19e051 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,215 @@ +# Cinder — mangaBookmark design system + +Source of truth: the Claude Design doc **Cinder Sheet** +(`cfa39183-8874-4f76-987c-afef14dceebb`, files `Cinder Sheet.dc.html` for the +static spec and `Cinder Sheet App.dc.html` for the interactive one). This file +records the rules that got implemented so a future agent can extend the UI +without re-reading the design. + +Implemented in: + +| 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` | +| Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE | + +## 1. The one idea + +**Heat is typographic.** A series with an unread chapter is the only thing +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 +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* +get to borrow the ember; find a typographic answer (weight, italic, a rule) or +use brass, which is already spoken for by favourites. + +Corollaries: + +- **No cards, no corners, no shadows.** Rows are *sheets* separated by 1px ash + hairlines (`--rule`). `border-radius` is `0` everywhere. Depth comes from a + recessed background (`--ash`, `--dim`), never from elevation. +- **One measure.** The app is a single `max-width: 760px` column with drawn side + edges (`.sheet`), identical on phone and desktop. There is no multi-column + grid and no sidebar. +- **Three type roles, never mixed.** Display serif for anything a human reads as + a name (brand, titles, tabs, primary buttons, empty-state headings). Mono + small-caps for machine facts (site, chapter numbers, labels, status, badges, + ghost buttons). Sans for prose only (empty-state body, hints). + +## 2. Tokens + +Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's +`:host`. **Never hardcode a hex outside those two blocks.** + +| Token | Dark | Light | Use | +| --- | --- | --- | --- | +| `--ink` | `#100f0e` | `#f7f4ef` | page | +| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form, toast) | +| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background | +| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders | +| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges | +| `--field-line` | `#2c2926` | `#d4cdc4` | input borders, ghost-button underline | +| `--hover` | `#1a1816` | `#efeae3` | neutral pressed/open surface | +| `--paper` | `#f2ece5` | `#191715` | highest-contrast text, primary button fill | +| `--paper-hot` | `#f0d3cb` | `#a33018` | title of a series with a new chapter | +| `--paper-dim` | `#ddd5cb` | `#191715` | resting title | +| `--mute` | `#8d857c` | `#6b645d` | secondary text, idle icons | +| `--mute-2` | `#5a5450` | `#857d75` | eyebrow labels, hints | +| `--faint` | `#3a3733` | `#c9c2ba` | the `/` separators in a meta line | +| `--faint-2` | `#57504b` | `#a8a098` | cover monogram | +| `--ember` | `#e0452c` | `#c23a22` | heat — see §1 | +| `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface (confirm, error) | +| `--ember-ink` | `#150907` | `#fff` | text on solid ember | +| `--ember-soft` | `#eda798` | `#8d2c17` | text on ember wash | +| `--brass` | `#b8912f` | `#8a681c` | favourites, and only favourites | +| `--trash` | `#6b5450` | `#a98276` | remove, at rest | +| `--asura` | `#7d93a5` | `#4f6b80` | site tag | +| `--demonic` | `#a98a78` | `#8a6a55` | site tag | +| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot | + +Dark is the default (`color-scheme: dark light`); 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 + +| Role | Web UI | Userscript panel | +| --- | --- | --- | +| Display | `Instrument Serif` → `Georgia, serif` | `Georgia, serif` (no webfont) | +| Mono | `IBM Plex Mono` → system mono | system mono | +| Sans | `DM Sans` → system UI | system UI | + +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 +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, +where `fonts.googleapis.com` is routinely blocked, and over a LAN with no +internet route. `staticHandler()` in `web.go` registers the `.woff2` MIME type +because Go's built-in table lacks it and the scratch image has no +`/etc/mime.types`. + +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 +serves woff2 only to modern UAs; the latin block is the last one per family), +download that URL into `static/fonts/`, and add a matching `@font-face`. DM Sans +is a variable file covering `400 700`, so sans weights in that range are free. +`app.html` and `login.html` preload only the two faces above the fold — +Instrument Serif 400 and, for the app, IBM Plex Mono 500. + +The userscript deliberately ships **no** webfont: an `@import` inside the shadow +root is at the mercy of the host site's CSP. + +Recurring specs (copy these rather than inventing sizes): + +- Brand: `400 26px/1 display`, with `` in ember italic — `mangaBookmark`. +- Row title: `400 19px/1.2 display` (21px ≥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 + the row's own hairline. +- Meta / label / badge: `500 10px mono`, `letter-spacing: .12em`, + `text-transform: uppercase`. Eyebrows ("CONTINUE READING") use `.2em`. +- 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. +- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`. + +## 4. Components (web UI) + +``` +.sheet + .topbar .brand + .ghost (log out) + .chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:) + .recent h2 eyebrow + .recent-strip > a.recent-card + main#list article.card … | .empty +``` + +`article.card` — the row, and the only per-series component: + +``` +article.card[.is-new|.is-dim]#card-[data-title] + .row + a.cover img | span.monogram, + span.foot-rule[.brass] + .body .title-line (h3.title + svg.fav-mark) , p.meta + .actions play, favourite, chapter, archive|restore, finish, remove + form.chapter-form[hidden] .hint + .field(input + Save) + .confirm-row[hidden] span + (Remove, Cancel) + p.error-inline[hidden] +``` + +Rules that are easy to break: + +- `.is-new` only when `Status == reading && HasNewChapter`; `.is-dim` for + `archived` and `finished`. Both are set on the `
` — every heat and + dim rule is a descendant selector off those two classes, so a new sub-element + inherits the state for free. +- `.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 + the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by + `border-right: 1px var(--rule)`, last child none. +- Icons are `` against the sprite in `templates/icons.html`, + 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 `` + there, keep `viewBox="0 0 24 24"` and `currentColor`. +- Meta line is `site / Ch N [/ Ch M out] [/ state]` with each `/` as + ``. +- Cover foot rule: ember when new, brass when favourite-and-not-new. Never both. +- `[hidden] { display: none !important; }` is load-bearing — every disclosure + panel is a flex container, and `display` beats `hidden`. +- Busy state is `.card.htmx-request::before`, a 1px ember bar sliding across the + top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a + spinner. +- `.open` on the pencil / trash cell marks which panel is showing; `filter.js` + `togglePanel()` owns that class alongside `hidden`. + +## 5. Components (userscript panel) + +Same tokens, same heat rule, structure unchanged from before the revamp +(`#fab`/`#hit`, `#panel`, `#nav` chips, `#context`, `#tabs`, `#list` of `.item`). +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 +borders instead of pills; loading is the same sliding ember hairline (`.spinner` +is now a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule, +ember-washed when `.err`. + +**Do not touch** the FAB geometry while restyling: `#fab` keeps +`touch-action: none`, must not regain `overflow: hidden`, and `#hit` keeps the +28×72 inward hit area. See +`docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`. + +## 6. Motion + +Three animations, all ≤ 1.15s and all disabled under +`prefers-reduced-motion: reduce`: + +- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel. +- `barSlide` — the burning hairline, for any busy state. +- `emberPulse` — the 5px dot on `.error-inline`. + +No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`. + +## 7. Accessibility floor (not negotiable) + +- Touch targets on the phone layout are 44–46px; the 40px desktop cells are + pointer-only (≥720px). +- Every icon-only control keeps `title` + `aria-label`; the SVG inside is + `aria-hidden`. +- 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. +- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class, + and `setActiveTab()` in `filter.js` maintains it after an htmx swap. +- Light and dark are both first-class. Check any new colour in both. + +## 8. Adding something new — checklist + +1. Can it be a hairline, a small-caps label, or a serif line instead of a new + component? Prefer that. +2. Tokens only, both colour branches. +3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a + third state class. +4. Icon → `templates/icons.html`; nothing inlines SVG paths. +5. Phone first (44px targets, single column), then the ≥720px block. +6. Verify: `cd backend && go test ./...`, then run the binary and screenshot + both widths and both colour schemes (Playwright: `emulateMedia`, + `setViewportSize`; disable the browser cache — `/static/*` is served with + `max-age=3600`, and templates are `go:embed`ed so the binary must be rebuilt + to see markup changes). diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index ba21e96..1c36ebf 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -1173,7 +1173,10 @@ // Cover and title are the only continue links. The subtitle and the action // row live in the same text column but are not navigation targets — a thumb // that misses ★ must not land on one, and Remove must never be one. - return el("div", { class: "item" }, [ + // hot = a chapter is out that has not been read (crimson title, ember rule); + // dim = archived (italic grey). Same two states the web UI paints. + const heat = (behind ? " hot" : "") + (statusOf(b) === "archived" ? " dim" : ""); + return el("div", { class: "item" + heat }, [ el("a", { class: "go", href: cont }, [ b.cover ? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" }) @@ -1380,11 +1383,11 @@