Rebuild both UIs on the Cinder design (#10)

Implements the **Cinder** design (Claude Design doc `cfa39183`) across both UI surfaces, self-hosts the fonts it depends on, and writes down the two documents that keep the result maintainable.

## What changed

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

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

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

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

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

## Commits

Each is one logical change and builds on its own:

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

## Verification

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

## Note for the reviewer

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

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

Reviewed-on: #10
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #10.
This commit is contained in:
2026-07-30 09:47:08 +07:00
committed by sulthan
parent 1dc5b2ab65
commit ac3ee9b298
16 changed files with 1455 additions and 526 deletions
+342
View File
@@ -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 <previous-hash>
$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-<STAMP>.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`.
+24 -12
View File
@@ -26,29 +26,41 @@ function setActiveTab(el) {
} }
// The chapter-edit form and the delete confirm row are the two per-card // 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) { function closeCardPanels(key) {
var form = document.getElementById("chapter-form-" + key); var form = document.getElementById("chapter-form-" + key);
var confirm = document.getElementById("confirm-row-" + key); var confirm = document.getElementById("confirm-row-" + key);
if (form) form.hidden = true; if (form) form.hidden = true;
if (confirm) confirm.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) { function toggleChapterForm(key) {
var form = document.getElementById("chapter-form-" + key); var form = togglePanel(key, "chapter-form-", ".actions .pencil");
if (!form) return; if (form && !form.hidden) form.querySelector("input").focus();
var opening = form.hidden;
closeCardPanels(key);
form.hidden = !opening;
if (!form.hidden) form.querySelector("input").focus();
} }
function toggleConfirmRow(key) { function toggleConfirmRow(key) {
var row = document.getElementById("confirm-row-" + key); togglePanel(key, "confirm-row-", ".actions .remove");
if (!row) return;
var opening = row.hidden;
closeCardPanels(key);
row.hidden = !opening;
} }
// A failed favourite/chapter/delete request leaves the card in place (htmx // A failed favourite/chapter/delete request leaves the card in place (htmx
Binary file not shown.
Binary file not shown.
Binary file not shown.
+618 -367
View File
File diff suppressed because it is too large Load Diff
+23 -8
View File
@@ -7,19 +7,29 @@
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>mangaBookmark</title> <title>mangaBookmark</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/static/fonts/ibm-plex-mono-500-latin.woff2" as="font" type="font/woff2" crossorigin>
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/filter.js" defer></script> <script src="/static/filter.js" defer></script>
</head> </head>
<body> <body>
{{template "icons" .}}
<div class="sheet">
<header class="topbar"> <header class="topbar">
<h1>mangaBookmark</h1> <h1 class="brand">manga<em>Bookmark</em></h1>
<form method="post" action="/logout"> <form method="post" action="/logout">
<button type="submit" class="ghost">Log out</button> <button type="submit" class="ghost">Log out</button>
</form> </form>
</header> </header>
<input id="search" class="search" type="search" placeholder="Search titles…" {{/* Search sits above the tabs on a phone and folds into the tab row on a
autocomplete="off" aria-label="Search titles"> wider screen — one flex container, order swapped in CSS. */}}
<div class="chrome">
<div class="searchbar">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-search"/></svg>
<input id="search" class="search" type="search" placeholder="Find a title"
autocomplete="off" aria-label="Search titles">
</div>
<nav class="tabs" role="tablist"> <nav class="tabs" role="tablist">
<a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}" <a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
@@ -27,7 +37,8 @@
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a> hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
<a role="tab" href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}" <a role="tab" href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML" hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated</a> hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated{{if .NewCount}}
<span class="count">{{.NewCount}}</span>{{end}}</a>
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}" <a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML" hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a> hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
@@ -38,20 +49,23 @@
hx-get="/ui/list?tab=finished" hx-target="#list" hx-swap="innerHTML" hx-get="/ui/list?tab=finished" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=finished" hx-on::after-request="setActiveTab(this)">Finished</a> hx-push-url="/?tab=finished" hx-on::after-request="setActiveTab(this)">Finished</a>
</nav> </nav>
</div>
{{if .Recent}} {{if .Recent}}
<section class="recent"> <section class="recent">
<h2>Continue reading</h2> <h2>Continue reading</h2>
<div class="recent-strip"> <div class="recent-strip">
{{range .Recent}} {{range .Recent}}
<a class="recent-card" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"> <a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
target="_blank" rel="noopener noreferrer">
<span class="recent-cover"> <span class="recent-cover">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy"> {{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="cover-blank" aria-hidden="true">{{.Initial}}</span>{{end}} {{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="new-ribbon new-ribbon-sm">New</span>{{end}} {{if .HasNewChapter}}<span class="foot-rule"></span>
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
</span> </span>
<span class="recent-title">{{.Title}}</span> <span class="recent-title">{{.Title}}</span>
<span class="recent-chapter">Ch {{.LastChapter}}</span> <span class="recent-chapter">Ch {{.LastChapter}}{{if .HasNewChapter}} · New{{end}}</span>
</a> </a>
{{end}} {{end}}
</div> </div>
@@ -61,6 +75,7 @@
<main id="list" class="list"> <main id="list" class="list">
{{template "list" .}} {{template "list" .}}
</main> </main>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}
+67 -64
View File
@@ -1,111 +1,114 @@
{{define "card"}} {{define "card"}}
<article class="card" id="card-{{.Key}}" data-title="{{.Title}}"> {{/* One sheet per series. is-new turns the title crimson over an ember rule;
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"> is-dim sinks archived and finished rows into italic grey. */}}
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy"> <article class="card{{if eq .Status "reading"}}{{if .HasNewChapter}} is-new{{end}}{{else}} is-dim{{end}}"
{{else}}<span class="cover-blank" aria-hidden="true">{{.Initial}}</span>{{end}} id="card-{{.Key}}" data-title="{{.Title}}">
{{if .HasNewChapter}}<span class="new-ribbon">New</span>{{end}} <div class="row">
</a> <a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
<div class="body"> tabindex="-1" aria-hidden="true">
<h3 class="title">{{.Title}}</h3> {{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
<p class="meta"> {{else}}<span class="monogram">{{.Initial}}</span>{{end}}
<span class="site site-{{.Site}}">{{.Site}}</span> {{if and (eq .Status "reading") .HasNewChapter}}<span class="foot-rule"></span>
<span class="chapter">Ch {{.LastChapter}}</span> {{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
{{if .HasNewChapter}}<span class="new-chapter">Ch {{.LatestChapter}} out</span>{{end}} </a>
</p> <div class="body">
<div class="title-line">
<h3 class="title">{{.Title}}</h3>
{{if .Favorite}}
<svg class="fav-mark" viewBox="0 0 24 24" aria-label="Favourite" role="img"><use href="#i-star-on"/></svg>
{{end}}
</div>
<p class="meta">
<span class="site-{{.Site}}">{{.Site}}</span>
<span class="sep">/</span>
<span class="chapter">Ch {{.LastChapter}}</span>
{{if and (eq .Status "reading") .HasNewChapter}}
<span class="sep">/</span>
<span class="new-chapter">Ch {{.LatestChapter}} out</span>
{{end}}
{{if eq .Status "archived"}}
<span class="sep">/</span>
<span class="state">archived</span>
{{else if eq .Status "finished"}}
<span class="sep">/</span>
<span class="state"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>finished</span>
{{end}}
</p>
</div>
<div class="actions"> <div class="actions">
<a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer" <a class="play" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
title="Continue reading" aria-label="Continue reading"> title="Continue reading" aria-label="Continue reading">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"> <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg>
<path d="M8 4.8a1 1 0 0 1 1.52-.85l10 7.2a1 1 0 0 1 0 1.7l-10 7.2A1 1 0 0 1 8 19.2z"/>
</svg>
</a> </a>
<button class="icon {{if .Favorite}}on{{end}}" <button class="{{if .Favorite}}on{{end}}"
title="Favourite" aria-label="Toggle favourite" title="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
aria-label="Toggle favourite"
hx-post="/ui/bookmarks/{{.Key}}/favorite" hx-post="/ui/bookmarks/{{.Key}}/favorite"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this"> hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="{{if .Favorite}}currentColor{{else}}none{{end}}" <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star{{if .Favorite}}-on{{end}}"/></svg>
stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"
aria-hidden="true" focusable="false">
<path d="M12 3.7l2.63 5.33 5.87.86-4.25 4.14 1 5.85L12 17.31l-5.25 2.57 1-5.85L3.5 9.89l5.87-.86z"/>
</svg>
</button> </button>
<button class="icon" title="Set chapter" aria-label="Set chapter" <button class="pencil" title="Set chapter" aria-label="Set chapter"
onclick="toggleChapterForm('{{.Key}}')"> onclick="toggleChapterForm('{{.Key}}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 20h4L19.3 8.7a1.5 1.5 0 0 0 0-2.12l-1.88-1.88a1.5 1.5 0 0 0-2.12 0L4 16z"/>
<path d="M13.8 7.2l3 3"/>
</svg>
</button> </button>
{{if eq .Status "finished"}} {{if eq .Status "finished"}}
<button class="icon on" title="Restore to reading" aria-label="Restore to reading" <button class="restore" title="Restore to reading" aria-label="Restore to reading"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}' hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this"> hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
</svg>
</button> </button>
{{else}} {{else}}
{{if eq .Status "archived"}} {{if eq .Status "archived"}}
<button class="icon on" title="Restore to reading" aria-label="Restore to reading" <button class="restore" title="Restore to reading" aria-label="Restore to reading"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}' hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this"> hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
</svg>
</button> </button>
{{else}} {{else}}
<button class="icon" title="Archive" aria-label="Archive" <button title="Archive" aria-label="Archive"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"archived"}' hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"archived"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this"> hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg>
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M3.5 6.4h17v3.2h-17zM5 9.6h14V19H5zM10 13h4"/>
</svg>
</button> </button>
{{end}} {{end}}
<button class="icon" title="Mark finished" aria-label="Mark finished" <button title="Mark finished" aria-label="Mark finished"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}' hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this"> hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4.5 12.5l5 5 10-11"/>
</svg>
</button> </button>
{{end}} {{end}}
<button class="icon danger" title="Remove" aria-label="Remove" <button class="remove" title="Remove" aria-label="Remove"
onclick="toggleConfirmRow('{{.Key}}')"> onclick="toggleConfirmRow('{{.Key}}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg>
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4.5 6.8h15M9.8 6.8V4.6h4.4v2.2M7.3 6.8l.9 12.6h7.6l.9-12.6"/>
</svg>
</button> </button>
</div> </div>
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden </div>
hx-post="/ui/bookmarks/{{.Key}}/chapter" <form class="chapter-form" id="chapter-form-{{.Key}}" hidden
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" hx-post="/ui/bookmarks/{{.Key}}/chapter"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button"> hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}} hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button">
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
<div class="field">
<input name="chapter" type="number" step="0.1" min="0" <input name="chapter" type="number" step="0.1" min="0"
value="{{.LastChapterNum}}" aria-label="Chapter number" required> value="{{.LastChapterNum}}" aria-label="Chapter number" required>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </div>
<div class="confirm-row" id="confirm-row-{{.Key}}" hidden> </form>
<span>Remove this?</span> <div class="confirm-row" id="confirm-row-{{.Key}}" hidden>
<span>Remove this?</span>
<div>
<button class="danger-solid" <button class="danger-solid"
hx-delete="/ui/bookmarks/{{.Key}}" hx-delete="/ui/bookmarks/{{.Key}}"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Remove</button> hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Remove</button>
<button type="button" onclick="toggleConfirmRow('{{.Key}}')">Cancel</button> <button type="button" onclick="toggleConfirmRow('{{.Key}}')">Cancel</button>
</div> </div>
<p class="error-inline" hidden></p>
</div> </div>
<p class="error-inline" hidden></p>
</article> </article>
{{end}} {{end}}
+15
View File
@@ -0,0 +1,15 @@
{{define "icons"}}
<!-- One sprite per page. Card fragments swapped in by htmx reference these
symbols by id, so the markup for a row stays a handful of <use> tags. -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5l11 7-11 7z" fill="currentColor"/></symbol>
<symbol id="i-star" viewBox="0 0 24 24"><path d="M12 3.6l2.6 5.6 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
<symbol id="i-star-on" viewBox="0 0 24 24"><path d="M12 3.6l2.6 5.6 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8z" fill="currentColor" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="M3 7h18v4H3zM5 11v9h14v-9M10 15h4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M4 12.5l5.2 5.5L20 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V4h5v3M6.5 7l1 13h9l1-13M10.5 11v6M13.5 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M4 9h9.5a5 5 0 010 10H8M4 9l4.2-4.2M4 9l4.2 4.2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M15.3 15.3L20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
</svg>
{{end}}
+5 -7
View File
@@ -2,16 +2,14 @@
{{if .Items}} {{if .Items}}
{{range .Items}}{{template "card" .}}{{end}} {{range .Items}}{{template "card" .}}{{end}}
{{else if eq .Tab "fav"}} {{else if eq .Tab "fav"}}
<p class="empty">No favourites yet. Star a series to pin it here.</p> <div class="empty"><strong>No favourites yet.</strong><p>Star a series to pin it here.</p></div>
{{else if eq .Tab "new"}} {{else if eq .Tab "new"}}
<p class="empty">Nothing new. Every series is caught up to its latest chapter.</p> <div class="empty hot"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
{{else if eq .Tab "archived"}} {{else if eq .Tab "archived"}}
<p class="empty">Nothing archived. Shelve a series to park it here — it keeps getting checked for new chapters.</p> <div class="empty"><strong>Nothing archived.</strong><p>Shelve a series to park it here — it keeps getting checked for new chapters.</p></div>
{{else if eq .Tab "finished"}} {{else if eq .Tab "finished"}}
<p class="empty">Nothing finished yet.</p> <div class="empty"><strong>Nothing finished yet.</strong><p>Mark a series finished and it moves out of your reading list.</p></div>
{{else}} {{else}}
<p class="empty"> <div class="empty"><strong>Nothing here yet.</strong><p>Bookmarks appear once the userscript records a chapter.</p></div>
Nothing here yet. Bookmarks appear once the userscript records a chapter.
</p>
{{end}} {{end}}
{{end}} {{end}}
+12 -6
View File
@@ -7,15 +7,21 @@
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>mangaBookmark</title> <title>mangaBookmark</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
</head> </head>
<body class="login-body"> <body>
<main class="login-card"> <main class="login-card">
<h1>mangaBookmark</h1> <div>
<span class="eyebrow">Private library</span>
<h1>manga<em>Bookmark</em></h1>
</div>
<form method="post" action="/login"> <form method="post" action="/login">
<label for="password">Password</label> <div>
<input id="password" name="password" type="password" <label for="password">Password</label>
autocomplete="current-password" autofocus required> <input id="password" name="password" type="password"
{{if .Error}}<p class="error">{{.Error}}</p>{{end}} autocomplete="current-password" autofocus required>
</div>
<p class="error">{{.Error}}</p>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
</form> </form>
</main> </main>
+16 -2
View File
@@ -7,6 +7,7 @@ import (
"io/fs" "io/fs"
"log" "log"
"math" "math"
"mime"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -38,6 +39,11 @@ type listView struct {
Tab string // "all", "fav", or "new" Tab string // "all", "fav", or "new"
Recent []Bookmark Recent []Bookmark
Items []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 // 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 { if err != nil {
panic("embed static: " + err.Error()) 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)) files := http.FileServer(http.FS(sub))
return http.StripPrefix("/static/", http.HandlerFunc( return http.StripPrefix("/static/", http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
@@ -178,12 +190,14 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
recent = recent[:recentCount] recent = recent[:recentCount]
} }
withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
var items []Bookmark var items []Bookmark
switch tab { switch tab {
case "fav": case "fav":
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite }) items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
case "new": case "new":
items = filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() }) items = withNew
case "archived": case "archived":
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived }) items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
case "finished": case "finished":
@@ -192,7 +206,7 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
tab = "all" tab = "all"
items = reading 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) { func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
+215
View File
@@ -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 `<em>` in ember italic — `manga<em>Bookmark</em>`.
- 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-<key>[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 `<article>` — 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 `<use href="#i-…">` 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 `<symbol>`
there, keep `viewBox="0 0 24 24"` and `currentColor`.
- Meta line is `site / Ch N [/ Ch M out] [/ state]` with each `/` as
`<span class="sep">`.
- 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).
+118 -60
View File
@@ -1173,7 +1173,10 @@
// Cover and title are the only continue links. The subtitle and the action // 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 // 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. // 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 }, [ el("a", { class: "go", href: cont }, [
b.cover b.cover
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" }) ? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
@@ -1380,11 +1383,11 @@
<div id="backdrop"></div> <div id="backdrop"></div>
<aside id="panel" role="dialog" aria-label="Manga bookmarks"> <aside id="panel" role="dialog" aria-label="Manga bookmarks">
<header> <header>
<span>Manga Bookmarks</span> <span>manga<em>Bookmark</em></span>
<button id="closeBtn" aria-label="Close">✕</button> <button id="closeBtn" aria-label="Close">close</button>
</header> </header>
<div id="nav"> <div id="nav">
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a> <a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">Web</a>
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a> <a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a> <a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button> <button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
@@ -1392,7 +1395,7 @@
<section id="context"></section> <section id="context"></section>
<div id="tabs" role="tablist"> <div id="tabs" role="tablist">
<button id="tabAll" class="tab active" role="tab">All</button> <button id="tabAll" class="tab active" role="tab">All</button>
<button id="tabFav" class="tab" role="tab">★ Favourites</button> <button id="tabFav" class="tab" role="tab">Favourites</button>
<button id="tabArc" class="tab" role="tab">Archived</button> <button id="tabArc" class="tab" role="tab">Archived</button>
</div> </div>
<div id="list"></div> <div id="list"></div>
@@ -1401,22 +1404,35 @@
`; `;
const CSS = ` const CSS = `
:host { all: initial; } /* Cinder, ported to the panel: same tokens and the same rule that heat is
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; } typographic. Fonts are system stacks, not the web UI's Google fonts — an
@import here would be at the mercy of the host page's CSP.
See docs/design-system.md. */
:host {
all: initial;
--ink: #100f0e; --ash: #161413; --dim: #0d0c0b;
--rule: #221f1d; --field-line: #2c2926; --hover: #1a1816;
--paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb;
--mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b;
--ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907;
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450;
--font-display: Georgia, "Times New Roman", serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
--font-body: system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; font-family: var(--font-body); }
#fab { #fab {
position: fixed; position: fixed;
width: 7px; height: 44px; border: none; padding: 0; width: 7px; height: 44px; border: none; padding: 0;
border-radius: 4px 0 0 4px; background: var(--ember); opacity: .5;
background: #6d28d9; opacity: .45; box-shadow: 0 2px 8px rgba(0,0,0,.5); z-index: 2147483646; cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
touch-action: none; user-select: none; -webkit-touch-callout: none; touch-action: none; user-select: none; -webkit-touch-callout: none;
} }
#fab.left { border-radius: 0 4px 4px 0; }
#fab:active, #fab.armed { opacity: 1; } #fab:active, #fab.armed { opacity: 1; }
#fab.armed { box-shadow: 0 0 0 2px #c4b5fd; } #fab.armed { box-shadow: 0 0 0 2px var(--ember-soft); }
#fill { #fill {
position: absolute; left: 0; right: 0; bottom: 0; height: 0; position: absolute; left: 0; right: 0; bottom: 0; height: 0;
background: #c4b5fd; pointer-events: none; border-radius: inherit; background: var(--paper); pointer-events: none;
} }
/* Invisible tap extension: the visible sliver is 7x44, this makes it 28x72. /* Invisible tap extension: the visible sliver is 7x44, this makes it 28x72.
It grows inward (toward the screen centre) because the tab is docked flush It grows inward (toward the screen centre) because the tab is docked flush
@@ -1427,90 +1443,132 @@
} }
#fab.left #hit { left: 0; right: -21px; } #fab.left #hit { left: 0; right: -21px; }
#backdrop { #backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.5); position: fixed; inset: 0; background: rgba(0,0,0,.6);
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
} }
#backdrop.open { opacity: 1; pointer-events: auto; } #backdrop.open { opacity: 1; pointer-events: auto; }
#panel { #panel {
position: fixed; top: 0; right: 0; height: 100%; position: fixed; top: 0; right: 0; height: 100%;
width: min(400px, 92vw); background: #1b1b22; color: #eee; width: min(400px, 92vw); background: var(--ink); color: var(--paper-dim);
transform: translateX(105%); transition: transform .25s ease; transform: translateX(105%); transition: transform .25s ease;
z-index: 2147483647; display: flex; flex-direction: column; z-index: 2147483647; display: flex; flex-direction: column;
padding-bottom: env(safe-area-inset-bottom, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);
box-shadow: -4px 0 20px rgba(0,0,0,.5); border-left: 1px solid var(--rule);
box-shadow: -4px 0 20px rgba(0,0,0,.6);
} }
#panel.open { transform: translateX(0); } #panel.open { transform: translateX(0); }
header { header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: baseline; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px; padding: 16px; border-bottom: 1px solid var(--rule);
font: 400 22px/1 var(--font-display); color: var(--paper);
} }
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; } header em { color: var(--ember); font-style: italic; }
#nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; } #closeBtn {
background: none; border: none; color: var(--mute); cursor: pointer;
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
border-bottom: 1px solid var(--field-line); padding: 0 0 2px;
}
#closeBtn:active { color: var(--paper); }
#nav { display: flex; gap: 14px; padding: 11px 16px; border-bottom: 1px solid var(--rule); }
/* Chips are set as small caps rules, not pills — the sheet has no corners. */
.chip { .chip {
background: #2a2a33; color: #c4b5fd; text-decoration: none; background: none; border: none; padding: 0 0 2px;
padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600; border-bottom: 1px solid var(--field-line);
color: var(--mute); text-decoration: none; cursor: pointer;
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
white-space: nowrap; white-space: nowrap;
} }
.chip:active { opacity: .8; } .chip:active { color: var(--paper); border-bottom-color: var(--paper); }
.chip.pending { background: #4c1d95; border: none; cursor: pointer; } .chip.pending { color: var(--ember); border-bottom-color: var(--ember); }
.chip.pending[hidden] { display: none; } .chip.pending[hidden] { display: none; }
#context { #context {
padding: 12px 16px; border-bottom: 1px solid #33333d; padding: 14px 16px; border-bottom: 1px solid var(--rule);
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 9px; align-items: flex-start;
} }
.ctx-title { font-weight: 600; font-size: 15px; } .ctx-title { font: 400 19px/1.2 var(--font-display); color: var(--paper); }
.ctx-sub { color: #9a9aa5; font-size: 13px; } .ctx-sub {
#tabs { display: flex; gap: 14px; padding: 10px 16px 0; border-bottom: 1px solid #33333d; } font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em;
text-transform: uppercase; color: var(--mute);
}
#tabs { display: flex; gap: 16px; padding: 2px 16px 0; border-bottom: 1px solid var(--rule); }
.tab { .tab {
background: none; border: none; color: #9a9aa5; cursor: pointer; background: none; border: none; cursor: pointer; color: var(--mute);
font-size: 13px; font-weight: 600; padding: 4px 0 9px; font: 400 16px var(--font-display); padding: 8px 0 12px;
border-bottom: 2px solid transparent; margin-bottom: -1px; border-bottom: 2px solid transparent; margin-bottom: -1px;
} }
.tab.active { color: #eee; border-bottom-color: #6d28d9; } .tab.active { color: var(--paper); border-bottom-color: var(--paper); }
#list { overflow-y: auto; flex: 1; padding: 8px 0; } #list { overflow-y: auto; flex: 1; }
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; } .empty {
color: var(--mute); padding: 34px 16px;
font: 400 15px/1.6 var(--font-body);
}
/* Loading is the web UI's burning hairline, laid flat across the list. */
.spinner { .spinner {
width: 26px; height: 26px; margin: 0 auto; width: 100%; height: 1px; background: var(--rule); overflow: hidden; position: relative;
border: 3px solid #33333d; border-top-color: #6d28d9; border-radius: 50%;
animation: spin .8s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } .spinner::after {
content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 30%;
background: var(--ember); animation: barSlide 1.15s linear infinite;
}
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } }
.item { .item {
display: flex; flex-direction: row; gap: 10px; display: flex; flex-direction: row; gap: 12px;
padding: 10px 16px; border-bottom: 1px solid #2a2a33; padding: 14px 16px; border-bottom: 1px solid var(--rule);
} }
.item.dim { background: var(--dim); }
.go { display: block; flex: none; } .go { display: block; flex: none; }
.go:active, .go-t:active { opacity: .7; } .go:active, .go-t:active { opacity: .7; }
.go-t { display: block; text-decoration: none; color: inherit; } .go-t { display: block; text-decoration: none; color: inherit; }
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; } .cover {
.cover.ph { display: block; } width: 52px; height: 70px; object-fit: cover; flex: none;
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 6px; } background: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px);
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.c { color: #9a9aa5; font-size: 12px; margin: 0; }
.c.behind { color: #c4b5fd; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.btn {
background: #33333d; color: #eee; border: none; border-radius: 6px;
padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block;
} }
.btn:active { opacity: .8; } .cover.ph { display: block; }
.btn.primary { background: #6d28d9; color: #fff; } .item.dim .cover { filter: grayscale(1); opacity: .85; }
.btn.small { padding: 6px 10px; font-size: 12px; } .meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; }
.btn.danger { background: #7f1d1d; } .t {
.btn.star { line-height: 1; } font: 400 17px/1.2 var(--font-display); color: var(--paper-dim);
.btn.star.on { background: #6d28d9; color: #fff; } max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; }
.item.dim .t { font-style: italic; color: var(--mute); }
.c {
margin: 0; color: var(--mute);
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
}
.c.behind { color: var(--ember); }
.actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; }
.btn {
background: none; color: var(--mute); border: 1px solid var(--rule);
margin: 0 -1px -1px 0; padding: 0 12px; height: 38px; line-height: 36px;
cursor: pointer; text-decoration: none; display: inline-block;
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
}
.btn:active { color: var(--paper); background: var(--hover); }
.btn.primary {
border-color: var(--paper); background: var(--paper); color: var(--ink);
font: 400 16px var(--font-display); letter-spacing: 0; text-transform: none;
height: 44px; line-height: 42px; padding: 0 18px;
}
.btn.small { height: 34px; line-height: 32px; padding: 0 10px; }
.btn.danger { color: var(--trash); }
.btn.danger:active { color: var(--ember); background: var(--ember-wash); }
.btn.star { font-size: 13px; letter-spacing: 0; }
.btn.star.on { color: var(--brass); border-color: #332b14; }
#toasts { #toasts {
position: fixed; left: 50%; transform: translateX(-50%); position: fixed; left: 50%; transform: translateX(-50%);
bottom: calc(80px + env(safe-area-inset-bottom, 0px)); bottom: calc(80px + env(safe-area-inset-bottom, 0px));
z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center; z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center;
} }
.toast { .toast {
background: #2f2f3a; color: #fff; padding: 10px 16px; border-radius: 8px; background: var(--ash); color: var(--paper); padding: 12px 16px;
font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .3s; border-left: 2px solid var(--paper);
box-shadow: 0 4px 14px rgba(0,0,0,.4); max-width: 80vw; font: 400 16px var(--font-display);
opacity: 0; transform: translateY(10px); transition: all .3s;
box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 80vw;
} }
.toast.show { opacity: 1; transform: translateY(0); } .toast.show { opacity: 1; transform: translateY(0); }
.toast.err { background: #7f1d1d; } .toast.err { background: var(--ember-wash); border-left-color: var(--ember); color: var(--ember-soft); }
`; `;
// ============================================================ // ============================================================