The design lives in a Claude Design doc that an agent working in this repo cannot see, so every future UI change would otherwise be a re-derivation of the same rules from the CSS — and the rules that matter here are the ones a stylesheet cannot state: that heat is reserved for an unread chapter, that favourites get brass instead of borrowing it, that light mode is a re-tuning rather than an inversion. Records the token table for both colour branches, the three type roles, the component anatomy of a row, the load-bearing details that look like noise until they break something (the [hidden] override, the action strip's flex-basis, the icon sprite, rebuilding the binary to see template changes), and how to add a font weight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
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-radiusis0everywhere. Depth comes from a recessed background (--ash,--dim), never from elevation. - One measure. The app is a single
max-width: 760pxcolumn 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 getsborder-bottom: 2pxin--paper(--emberfor Updated) plusmargin-bottom: -1pxso 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; body400 14px/1.6 sans,max-width: 44ch. - Primary button:
--paperfill,--inktext,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-newonly whenStatus == reading && HasNewChapter;.is-dimforarchivedandfinished. 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..actionsisflex: 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 byborder-right: 1px var(--rule), last child none.- Icons are
<use href="#i-…">against the sprite intemplates/icons.html, included once byapp.html. htmx-swapped card fragments reference the page's sprite, so a card never inlines a path. New icon → add a<symbol>there, keepviewBox="0 0 24 24"andcurrentColor. - 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, anddisplaybeatshidden.- Busy state is
.card.htmx-request::before, a 1px ember bar sliding across the top hairline (barSlide), plus the action strip atopacity: .5. Never a spinner. .openon the pencil / trash cell marks which panel is showing;filter.jstogglePanel()owns that class alongsidehidden.
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 .btns 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 isaria-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, andsetActiveTab()infilter.jsmaintains it after an htmx swap. - Light and dark are both first-class. Check any new colour in both.
8. Adding something new — checklist
- Can it be a hairline, a small-caps label, or a serif line instead of a new component? Prefer that.
- Tokens only, both colour branches.
- If it is per-series, hang it off
.is-new/.is-dimrather than adding a third state class. - Icon →
templates/icons.html; nothing inlines SVG paths. - Phone first (44px targets, single column), then the ≥720px block.
- 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 withmax-age=3600, and templates arego:embeded so the binary must be rebuilt to see markup changes).