Rebuild both UIs on the Cinder design #10

Merged
sulthan merged 6 commits from ui-revamp-cinder into main 2026-07-30 09:47:09 +07:00
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
// 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
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
+22 -7
View File
@@ -7,19 +7,29 @@
<meta name="color-scheme" content="dark light">
<title>mangaBookmark</title>
<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/filter.js" defer></script>
</head>
<body>
{{template "icons" .}}
<div class="sheet">
<header class="topbar">
<h1>mangaBookmark</h1>
<h1 class="brand">manga<em>Bookmark</em></h1>
<form method="post" action="/logout">
<button type="submit" class="ghost">Log out</button>
</form>
</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
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">
<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>
<a role="tab" href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated</a>
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}}"
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>
@@ -38,20 +49,23 @@
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>
</nav>
</div>
{{if .Recent}}
<section class="recent">
<h2>Continue reading</h2>
<div class="recent-strip">
{{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">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="cover-blank" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="new-ribbon new-ribbon-sm">New</span>{{end}}
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="foot-rule"></span>
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
</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>
{{end}}
</div>
@@ -61,6 +75,7 @@
<main id="list" class="list">
{{template "list" .}}
</main>
</div>
</body>
</html>
{{end}}
+52 -49
View File
@@ -1,111 +1,114 @@
{{define "card"}}
<article class="card" id="card-{{.Key}}" data-title="{{.Title}}">
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
{{/* One sheet per series. is-new turns the title crimson over an ember rule;
is-dim sinks archived and finished rows into italic grey. */}}
<article class="card{{if eq .Status "reading"}}{{if .HasNewChapter}} is-new{{end}}{{else}} is-dim{{end}}"
id="card-{{.Key}}" data-title="{{.Title}}">
<div class="row">
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
tabindex="-1" aria-hidden="true">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="cover-blank" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="new-ribbon">New</span>{{end}}
{{else}}<span class="monogram">{{.Initial}}</span>{{end}}
{{if and (eq .Status "reading") .HasNewChapter}}<span class="foot-rule"></span>
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
</a>
<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}}">{{.Site}}</span>
<span class="site-{{.Site}}">{{.Site}}</span>
<span class="sep">/</span>
<span class="chapter">Ch {{.LastChapter}}</span>
{{if .HasNewChapter}}<span class="new-chapter">Ch {{.LatestChapter}} out</span>{{end}}
{{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">
<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">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M8 4.8a1 1 0 0 1 1.52-.85l10 7.2a1 1 0 0 1 0 1.7l-10 7.2A1 1 0 0 1 8 19.2z"/>
</svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg>
</a>
<button class="icon {{if .Favorite}}on{{end}}"
title="Favourite" aria-label="Toggle favourite"
<button class="{{if .Favorite}}on{{end}}"
title="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
aria-label="Toggle favourite"
hx-post="/ui/bookmarks/{{.Key}}/favorite"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="{{if .Favorite}}currentColor{{else}}none{{end}}"
stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"
aria-hidden="true" focusable="false">
<path d="M12 3.7l2.63 5.33 5.87.86-4.25 4.14 1 5.85L12 17.31l-5.25 2.57 1-5.85L3.5 9.89l5.87-.86z"/>
</svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star{{if .Favorite}}-on{{end}}"/></svg>
</button>
<button class="icon" title="Set chapter" aria-label="Set chapter"
<button class="pencil" title="Set chapter" aria-label="Set chapter"
onclick="toggleChapterForm('{{.Key}}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 20h4L19.3 8.7a1.5 1.5 0 0 0 0-2.12l-1.88-1.88a1.5 1.5 0 0 0-2.12 0L4 16z"/>
<path d="M13.8 7.2l3 3"/>
</svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
</button>
{{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-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
</svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
</button>
{{else}}
{{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-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
</svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
</button>
{{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-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M3.5 6.4h17v3.2h-17zM5 9.6h14V19H5zM10 13h4"/>
</svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg>
</button>
{{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-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4.5 12.5l5 5 10-11"/>
</svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
</button>
{{end}}
<button class="icon danger" title="Remove" aria-label="Remove"
<button class="remove" title="Remove" aria-label="Remove"
onclick="toggleConfirmRow('{{.Key}}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4.5 6.8h15M9.8 6.8V4.6h4.4v2.2M7.3 6.8l.9 12.6h7.6l.9-12.6"/>
</svg>
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg>
</button>
</div>
</div>
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden
hx-post="/ui/bookmarks/{{.Key}}/chapter"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button">
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
<div class="field">
<input name="chapter" type="number" step="0.1" min="0"
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
<button type="submit">Save</button>
</div>
</form>
<div class="confirm-row" id="confirm-row-{{.Key}}" hidden>
<span>Remove this?</span>
<div>
<button class="danger-solid"
hx-delete="/ui/bookmarks/{{.Key}}"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Remove</button>
<button type="button" onclick="toggleConfirmRow('{{.Key}}')">Cancel</button>
</div>
<p class="error-inline" hidden></p>
</div>
<p class="error-inline" hidden></p>
</article>
{{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}}
{{range .Items}}{{template "card" .}}{{end}}
{{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"}}
<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"}}
<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"}}
<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}}
<p class="empty">
Nothing here yet. Bookmarks appear once the userscript records a chapter.
</p>
<div class="empty"><strong>Nothing here yet.</strong><p>Bookmarks appear once the userscript records a chapter.</p></div>
{{end}}
{{end}}
+9 -3
View File
@@ -7,15 +7,21 @@
<meta name="color-scheme" content="dark light">
<title>mangaBookmark</title>
<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>
<body class="login-body">
<body>
<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">
<div>
<label for="password">Password</label>
<input id="password" name="password" type="password"
autocomplete="current-password" autofocus required>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
</div>
<p class="error">{{.Error}}</p>
<button type="submit">Sign in</button>
</form>
</main>
+16 -2
View File
@@ -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) {
+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
// 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 @@
<div id="backdrop"></div>
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
<header>
<span>Manga Bookmarks</span>
<button id="closeBtn" aria-label="Close">✕</button>
<span>manga<em>Bookmark</em></span>
<button id="closeBtn" aria-label="Close">close</button>
</header>
<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://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
@@ -1392,7 +1395,7 @@
<section id="context"></section>
<div id="tabs" role="tablist">
<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>
</div>
<div id="list"></div>
@@ -1401,22 +1404,35 @@
`;
const CSS = `
:host { all: initial; }
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
/* Cinder, ported to the panel: same tokens and the same rule that heat is
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 {
position: fixed;
width: 7px; height: 44px; border: none; padding: 0;
border-radius: 4px 0 0 4px;
background: #6d28d9; opacity: .45;
box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
background: var(--ember); opacity: .5;
box-shadow: 0 2px 8px rgba(0,0,0,.5); z-index: 2147483646; cursor: pointer;
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.armed { box-shadow: 0 0 0 2px #c4b5fd; }
#fab.armed { box-shadow: 0 0 0 2px var(--ember-soft); }
#fill {
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.
It grows inward (toward the screen centre) because the tab is docked flush
@@ -1427,90 +1443,132 @@
}
#fab.left #hit { left: 0; right: -21px; }
#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;
}
#backdrop.open { opacity: 1; pointer-events: auto; }
#panel {
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;
z-index: 2147483647; display: flex; flex-direction: column;
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); }
header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
display: flex; align-items: baseline; justify-content: space-between;
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; }
#nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; }
header em { color: var(--ember); font-style: italic; }
#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 {
background: #2a2a33; color: #c4b5fd; text-decoration: none;
padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
background: none; border: none; padding: 0 0 2px;
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;
}
.chip:active { opacity: .8; }
.chip.pending { background: #4c1d95; border: none; cursor: pointer; }
.chip:active { color: var(--paper); border-bottom-color: var(--paper); }
.chip.pending { color: var(--ember); border-bottom-color: var(--ember); }
.chip.pending[hidden] { display: none; }
#context {
padding: 12px 16px; border-bottom: 1px solid #33333d;
display: flex; flex-direction: column; gap: 8px;
padding: 14px 16px; border-bottom: 1px solid var(--rule);
display: flex; flex-direction: column; gap: 9px; align-items: flex-start;
}
.ctx-title { font-weight: 600; font-size: 15px; }
.ctx-sub { color: #9a9aa5; font-size: 13px; }
#tabs { display: flex; gap: 14px; padding: 10px 16px 0; border-bottom: 1px solid #33333d; }
.ctx-title { font: 400 19px/1.2 var(--font-display); color: var(--paper); }
.ctx-sub {
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 {
background: none; border: none; color: #9a9aa5; cursor: pointer;
font-size: 13px; font-weight: 600; padding: 4px 0 9px;
background: none; border: none; cursor: pointer; color: var(--mute);
font: 400 16px var(--font-display); padding: 8px 0 12px;
border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
.tab.active { color: var(--paper); border-bottom-color: var(--paper); }
#list { overflow-y: auto; flex: 1; }
.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 {
width: 26px; height: 26px; margin: 0 auto;
border: 3px solid #33333d; border-top-color: #6d28d9; border-radius: 50%;
animation: spin .8s linear infinite;
width: 100%; height: 1px; background: var(--rule); overflow: hidden; position: relative;
}
@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 {
display: flex; flex-direction: row; gap: 10px;
padding: 10px 16px; border-bottom: 1px solid #2a2a33;
display: flex; flex-direction: row; gap: 12px;
padding: 14px 16px; border-bottom: 1px solid var(--rule);
}
.item.dim { background: var(--dim); }
.go { display: block; flex: none; }
.go:active, .go-t:active { opacity: .7; }
.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.ph { display: block; }
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 6px; }
.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;
.cover {
width: 52px; height: 70px; object-fit: cover; flex: none;
background: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px);
}
.btn:active { opacity: .8; }
.btn.primary { background: #6d28d9; color: #fff; }
.btn.small { padding: 6px 10px; font-size: 12px; }
.btn.danger { background: #7f1d1d; }
.btn.star { line-height: 1; }
.btn.star.on { background: #6d28d9; color: #fff; }
.cover.ph { display: block; }
.item.dim .cover { filter: grayscale(1); opacity: .85; }
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; }
.t {
font: 400 17px/1.2 var(--font-display); color: var(--paper-dim);
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 {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center;
}
.toast {
background: #2f2f3a; color: #fff; padding: 10px 16px; border-radius: 8px;
font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .3s;
box-shadow: 0 4px 14px rgba(0,0,0,.4); max-width: 80vw;
background: var(--ash); color: var(--paper); padding: 12px 16px;
border-left: 2px solid var(--paper);
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.err { background: #7f1d1d; }
.toast.err { background: var(--ember-wash); border-left-color: var(--ember); color: var(--ember-soft); }
`;
// ============================================================