CLAUDE.md: swap Bromite for Violentmonkey throughout, add installed golang skills to relevant skills, add comment-writing rules, add a design-system pointer rule. docs/design-system.md: rewrite against the current Claude Design project and the tokens/components already shipped in backend/static/style.css (danger/slate/moss/clay/trash, action key, brand mark). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
16 KiB
Cinder — mangaBookmark design system
Source of truth: the Claude Design project mangaBookmark 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/static/style.css, backend/templates/{app,card,list,login,chrome,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, 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-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, the action key). 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) |
--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 |
--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/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) +<em>in ember italic —manga<em>Bookmark</em>. - Row title:
400 21px/1.2 display(22px≥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 / action key:
500 10–11px mono,letter-spacing: .04em–.2em,text-transform: uppercase. Eyebrows use the widest tracking. - Empty-state heading:
400 20px display; body400 14px/1.6 sans,max-width: 44ch. - Primary button:
--paperfill,--inktext,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 <svg class="mark"> (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(--logo-blade, var(--ember)) — override that custom property, don't
duplicate the SVG, if a surface ever needs a different blade colour. 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. On a phone each pair
stacks icon-over-word (flex-direction: column) so the word gets the full
cell width and can stay in long form; ≥720px it lays out icon-beside-word and
switches the .short/.full label pair. .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-<key>[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-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 44px squares beside the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided byborder-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.lifecycleclass). Lifecycle cells sit on a recessed--ashground 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)infilter.js). Archive and finish ask in.calmgrey since they're reversible; remove alone gets the--danger-washtreatment 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..playstays paper/ember (ember only when.is-new)..removestays--trashat rest,--dangeron 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
<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 grey bar sliding across the top hairline (barSlide), plus the action strip atopacity: .5. Never a spinner, and deliberately--mutenot--ember— on a list screen ember means "new chapter" and nothing else, so a system state can't borrow it. .openon the pencil / lifecycle cell marks which panel is showing;filter.jstogglePanel()/toggleConfirmRow()own that class alongsidehidden. 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 .btns 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 isaria-hidden. Lifecycle buttons also carryaria-expanded+aria-controlspointing 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, andsetActiveTab()infilter.jsmaintains it after an htmx swap. .confirm-rowand.error-inlinearerole="group"/role="status"witharia-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
- 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. A new action gets its own named accent
(like
--slate/--moss/--clay) at the same weight as the existing set — never reuse--emberor--dangerfor anything but their one meaning. - If it is per-series, hang it off
.is-new/.is-dimrather than adding a third state class. - 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. - Icon →
templates/icons.html; nothing inlines SVG paths. Brand mark stays the one exception (chrome.html'smarktemplate), since it takes page-level custom properties the sprite can't carry per-instance. - 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).