# Cinder — BookmarkManager design system Source of truth: the Claude Design project **BookmarkManager Web UI** (`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings `archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`, `style.css`, `filter.js`). 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/internal/web/static/style.css`, `backend/internal/web/templates/{app,card,list,login,chrome,icons}.html`, `backend/internal/web/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, destruction — stays off that one colour. Destruction gets its own token (`--danger`, a duller oxblood) precisely so a remove confirm is never mistaken across the room for an unread chapter. If a new feature wants to be noticed, it does *not* get to borrow the ember; find a typographic answer (weight, italic, a rule) or reach for one of the named action accents (§2). Corollaries: - **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, the action key). Sans for prose only (empty-state body, hints). ## 2. Tokens Defined once in `backend/internal/web/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) | | `--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` | `#877f76` | `#6c655e` | eyebrow labels, hints (must clear 4.5:1 on both `--ink` and `--ash`) | | `--faint` | `#3a3733` | `#c9c2ba` | the `/` separators in a meta line | | `--faint-2` | `#57504b` | `#a8a098` | cover monogram | | `--ember` | `#e0452c` | `#c23a22` | heat — see §1 | | `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface | | `--ember-ink` | `#150907` | `#fff` | text on solid ember | | `--ember-soft` | `#eda798` | `#8d2c17` | text on ember wash | | `--danger` | `#cf5c4d` | `#97362a` | destruction — remove confirm, never the same as `--ember` | | `--danger-wash` | `#211311` | `#fbe9e5` | remove-confirm surface | | `--danger-ink` | `#150808` | `#fff` | text on solid danger | | `--danger-soft` | `#e2aaa1` | `#7c2c22` | text on danger wash | | `--brass` | `#b8912f` | `#8a681c` | favourite — a cooler second metal | | `--slate` | `#7fa0c0` | `#3f6689` | archive accent | | `--moss` | `#7fae86` | `#3d6c46` | finished accent | | `--clay` | `#b5906f` | `#7c5533` | set-chapter accent | | `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 | | `--play-hot-line` | `#3a1d18` | `#f0cfc6` | desktop cell border, play when `.is-new` | | `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on | | `--asura` | `#7d93a5` | `#4f6b80` | site tag | | `--demonic` | `#a98a78` | `#8a6a55` | site tag | | `--comix` | `#8a9a7d` | `#5f7250` | site tag | | `--kagane` | `#9a8aa5` | `#6f5f7d` | site tag | | `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot | `--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately: one accent per action, so a press says which lane it belongs to, with none of them competing with ember. 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/internal/web/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 needs to survive on 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` (`30px` ≥720px), inline SVG mark (§4) + `` in ember italic — `BookmarkManager`. - Row title: `400 21px/1.2 display` (`22px` ≥720px). - Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in `--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on the row's own hairline. - Meta / label / badge / action key: `500 10–11px mono`, `letter-spacing: .04em`–`.2em`, `text-transform: uppercase`. Eyebrows use the widest tracking. - Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`. - Primary button: `--paper` fill, `--ink` text, `400 17–19px display`, no border radius. - Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`. ## 4. Components (web UI) ``` .sheet .topbar .brand (mark + wordmark) + .ghost (log out) .chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:) .keyrow one-line action key: Read / Fav / Chapter / Archive / Done / Delete .recent h2 eyebrow + .recent-strip > a.recent-card main#list article.card … | .empty ``` **Brand mark**: an inline `` (`viewBox="0 0 200 172"`), defined once in `chrome.html`'s `mark` template and reused by `app.html` and `login.html` so it takes the page's `--ink`/`currentColor`/`--ember` rather than shipping as a static asset. The blade at its centre strokes `var(--ember)`, so a surface that needs a different blade colour re-points that token rather than duplicating the SVG. Drawn at a 5px stroke on a 200-unit grid; at brand size that thins out, so `.brand .mark g` nudges `stroke-width` up to `6.5` rather than scaling the artwork down. **Action key** (`.keyrow`): one permanent line under the tabs naming what every icon in `.actions` does — Read / Fav / Chapter / Archive / Done / Delete — so the icon strip on a card is never a guess. The key follows the tab, not the row: Archive becomes Restore under Archived and Finished, and Finished drops Done. On a phone each pair stacks icon-over-word (`flex-direction: column`) so the word gets the full cell width; ≥720px it lays out icon-beside-word at the same wording. `.pair.brass` and `.pair.trash` carry their icon's resting accent so the key itself teaches the colour vocabulary in §1/§2. `article.card` — the row, and the only per-series component: ``` article.card[.is-new|.is-dim]#card-[data-title] .row a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass] .body .title-line (h3.title + svg.fav-mark) , p.meta .actions play, favourite, chapter | lifecycle: archive/restore, finish, remove form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known) .confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel) p.error-inline[hidden] ``` Rules that are easy to break: - `.is-new` only when `Status == reading && HasNewChapter`; `.is-dim` for `archived` and `finished`. Both are set on the `
` — every heat and dim rule is a descendant selector off those two classes, so a new sub-element inherits the state for free. - `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a full-width strip under the row on a phone and a group of 44px 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. - Three clusters by consequence, in this order: navigate (`.play`) | organize (`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.finish`, `.remove`, each carrying the `.lifecycle` class). Lifecycle cells sit on a recessed `--ash` ground so the thumb reads "this one moves the series" before it reads which icon it landed on; ≥720px they separate by a 10px gap instead of the phone's inset hairline. - Every lifecycle button that moves a series out of the list is **confirm-gated**: it opens its own `.confirm-row` (`archive`, `finish`, `remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive and finish ask in `.calm` grey since they're reversible; remove alone gets the `--danger-wash` treatment and names the series in its question. Restore fires instantly — no confirm — because it's the reversal. - Per-action hover/press accent: `.fav` → `--brass`, `.pencil` → `--clay`, `.box` → `--slate`, `.finish` → `--moss`. `.play` stays paper/ember (ember only when `.is-new`). `.remove` stays `--trash` at rest, `--danger` on hover. Desktop cell borders follow the same accent on hover (`border-color: currentColor`); the two coloured *resting* states (`.is-new .play`, `.fav.on`) get their own dim border tokens (`--play-hot-line`, `--fav-line`) instead of the full accent, since a resting border needs less contrast than a hover one. - Icons are `` against the sprite in `templates/icons.html`, included once by `app.html`. htmx-swapped card fragments reference the page's sprite, so a card never inlines a path. New icon → add a `` there, keep `viewBox="0 0 24 24"` and `currentColor`. - Meta line is `site / Ch N [/ Ch M out] [/ state]` with each `/` as `
`. - Cover foot rule: ember when new, brass when favourite-and-not-new. Never both. - `[hidden] { display: none !important; }` is load-bearing — every disclosure panel is a flex container, and `display` beats `hidden`. - Busy state is `.card.htmx-request::before`, a 1px grey bar sliding across the top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a spinner, and deliberately `--mute` not `--ember` — on a list screen ember means "new chapter" and nothing else, so a system state can't borrow it. - `.open` on the pencil / lifecycle cell marks which panel is showing; `filter.js` `togglePanel()`/`toggleConfirmRow()` own that class alongside `hidden`. An open lifecycle cell needs the next surface step up from `--hover` (`--rule`) to stay legible as the panel's owner, since the panel itself already sits on `--ash`. ## 5. Components (userscript panel) 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 hairline (`.spinner` is a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule, `--danger`-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` (pseudo-elements need naming explicitly in that query — `*` does not match `::before`/`::after`, so the busy bar and error dot are listed by name and fall back to their static drawn form): - `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel. - `barSlide` — the sliding hairline, for any busy state. - `mutePulse` — 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 44px desktop cells are pointer-only (≥720px). - Every icon-only control keeps `title` + `aria-label`; the SVG inside is `aria-hidden`. Lifecycle buttons also carry `aria-expanded` + `aria-controls` pointing at their `.confirm-row`. - The cover link is `tabindex="-1" aria-hidden="true"` because the title link 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. - `.confirm-row` and `.error-inline` are `role="group"`/`role="status"` with `aria-live="polite"` so a disclosure opening is announced. - Light and dark are both first-class. Check any new colour in both. ## 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. A new action gets its own named accent (like `--slate`/`--moss`/`--clay`) at the same weight as the existing set — never reuse `--ember` or `--danger` for anything but their one meaning. 3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a third state class. 4. If it removes a series from the current view (archive/finish/remove-shaped), it is confirm-gated via its own `.confirm-row` — no exceptions, restore is the only instant action because it's the one that's reversible by nature. 5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays the one exception (`chrome.html`'s `mark` template), since it takes page-level custom properties the sprite can't carry per-instance. 6. Phone first (44px targets, single column), then the ≥720px block. 7. Verify: `cd backend && go test ./...`, then run the binary and screenshot both widths and both colour schemes (Playwright: `emulateMedia`, `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).