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
+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).