One commit (`af07314`), three strands of browser-UI work against one design system. `docs/design-system.md` was updated to match the CSS, not the reverse. ## Library (Reader-facing) Findings came out of a two-axis design review of the library surface; the fixes are the P1/P2 set plus the cheap P3s. - **`.chrome` sticks at `top: 0`.** Search and the tab row were unreachable three screens into a 300-item library — exactly where they earn their keep. Everything above them (`.topbar`, `.keyrow`, `.recent`) still scrolls away on purpose: another 150px of permanent chrome on an 844px phone costs more than re-scrolling for an icon reminder. - **One `:focus-visible` ring** (`2px solid var(--paper)`, offset 2px) on the nine controls that defined none and fell back to the UA blue — a colour tuned for neither branch of this palette. `.searchbar` keeps its `:focus-within` border recolour as a resting cue but no longer stands in for the ring. - **Mono labels lift 10px → 11px** everywhere (nine rules). PRODUCT.md names night reading and glare as the usage scene; 10px small-caps was the one place taste overrode the brief. 11px is now a documented floor. - **A card in flight past 2s says `Saving…` and carries `aria-busy`.** htmx sets neither, so the wait — up to its own 15s timeout, and this app is used on a phone in dead zones — was silent in both the visual and the assistive channel. Deliberately `--mute`, not `--ember`: ember means "new chapter" and nothing else. - **Titles clamp at 3 lines**; `.is-new .title` takes `width: fit-content`, or `-webkit-box` stretches the ember underline past the text it is supposed to be sized to. - `.libswitch a` reaches a real 44px under `(pointer: coarse)` — padding plus an 11px line landed at 43. ## /admin - Overview routes into Lanes when a lane is unhealthy, prefixes each figure with its column word on the phone layout that drops the `thead`, labels state cells for a screen reader, and has an empty state where the sites table previously assumed rows. - The admin shell picks up the library's chrome: htmx 15s timeout, the shared `#notice` slot, `#sr-announce`, `filter.js`. - `admin_render_test.go` and `card_render_test.go` render the templates directly, so markup regressions in either surface fail without a browser. ## Login `DISCORD_GUILD_NAME` (optional) names the community on the login screen and in the refusal message, so a stranger knows which Discord to ask for an invite. Unset degrades to a generic label. Neither form names the numeric guild id — that was never actionable, and the gate still reveals nothing about whether a given guild exists. ## Handlers `maxChapterNum` (9999) now bounds **both** typed-chapter paths. `uiChapter` and `adminSeriesCorrectLatest` each parsed a `float64` with no ceiling, so a hand-rolled POST stored `1e308` and every later reader of that row — the poller's `HasNewChapter` comparison, the display string — inherited it. Matches the `max` on the card's chapter input. The API PUT path is deliberately untouched: it carries the userscripts' own scraped numbers, not typed input. ## Verification - `cd backend && go test ./...` green (Docker-backed `pgtest`). `TestChapterOverrideRejectsBadInput` gained `"10000"` and `"1e5"` — both parse fine as `float64`, so they only fail if the bound exists. - Visual: 390×844 dark + light, 1000px and 1440px (`zoom: 1.2`) desktop, against the real templates + real CSS. Measured `chromeTop = 0` at `scrollY 950`, `2px solid rgb(242,236,229)` rings, `content: "Saving…"` at `opacity: 1` after 2.4s, `aria-busy` `true` during / cleared after, `libswitchH = 44` in a `hasTouch` context, no horizontal overflow at either width. - `detect.mjs` on `templates/`: `[]`, exit 0. ## Note on shape The three strands landed as one commit because `admin_series.go` and `style.css` each carry hunks from more than one of them; splitting cleanly would have needed hunk-level surgery. Say the word if you want it split before merge. Reviewed-on: #177 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
19 KiB
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/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, admin) | backend/internal/web/static/style.css, backend/internal/web/templates/{app,admin,lanes,readers,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-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/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 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 Series label |
--clay |
#b5906f |
#7c5533 |
set-chapter accent |
--trash |
#977671 |
#8c6558 |
remove, at rest — icons need 3:1, not 4.5:1 |
--patina |
#5fb3a6 |
#1f6f66 |
admin page only — a Poll Lane needing attention, a Reader whose reports are blocked |
--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/--patina are held at the same weight
deliberately: one accent per meaning, so a press says which lane it belongs to,
with none of them competing with ember. --patina is the admin page's only
colour — a cool verdigris, the far side of the wheel from ember's crimson and
clear of the archive blue: system health is neither a new chapter nor
destruction, so it borrows neither --ember nor --danger.
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) +<em>in ember italic —Bookmark<em>Manager</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 11px mono,letter-spacing: .04em–.2em,text-transform: uppercase. Eyebrows use the widest tracking. 11px is the floor — nothing in this UI sets mono below it. The brief names night reading and glare as the usage scene, and a 10px small-caps label at arm's length on a phone is where that scene stops being served. - 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:)
sticky at top: 0, z-index 2, on an --ink ground
.keyrow one-line action key: Read / Fav / Chapter / Archive / Done / Delete
.recent h2 eyebrow + .recent-strip > a.recent-card
main#list article.card … | .empty
Sticky chrome. Search and the tab row are the two controls a 300-item
library needs mid-scroll, so .chrome alone sticks (padding-top: env(safe-area-inset-top) for the notch cutout). Everything above it —
.topbar, .keyrow, .recent — scrolls away on purpose: another 150px of
permanent chrome on an 844px phone costs more than re-scrolling for an icon
reminder. If header height ever grows, unstick .recent/.keyrow further
rather than adding to the sticky region, and keep .chrome above the cards.
The owner's admin page (admin.html) is the same sheet with two sections in
place of the list — .lanes (Poll Lane rows) and .readers (the roster) —
and no library switch: it belongs to neither library, so its topbar carries a
plain .ghost.back link home. Both sections are eyebrow + hairline-separated
rows, the shape the roster already had as a fold-out. .lanes refreshes itself
every 30s via hx-get="/ui/admin/lanes" with hx-swap="outerHTML"; the roster
re-renders only in answer to an action.
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(--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: under Archived, Archive becomes Restore. 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-<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, 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-dimforarchived. 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,.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,remove—toggleConfirmRow(key, kind)infilter.js). Archive asks in.calmgrey since it's 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..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), the action strip atopacity: .5, and past 2s the wordSaving…in::after(busyWord, §6).filter.jssetsaria-busyon the card over the same window because htmx sets none, so the wait is not silent to a screen reader. 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. - Titles clamp at 3 lines (
.recent-titleat 2 — there the title is a reminder, in the list it is the identifier)..is-new .titleneedswidth: fit-content, or-webkit-boxstretches the ember underline to the full row and the rule stops being sized to the text. .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
Four 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.busyWord—0s 2s forwards, a delay rather than a motion: it reveals theSaving…word only once a request has outlived a plausible response. The reduced-motion block cancels the animation and so would pin it atopacity: 0; that branch re-declaresopacity: 1to show it from the start. Any future state revealed this way needs the same two-line pair.
No transforms on hover, no scale, no easing curves beyond ease-out/linear.
7. Accessibility floor (not negotiable)
- Every icon-only control keeps
title+aria-label; the SVG inside isaria-hidden. Lifecycle buttons also carryaria-expanded+aria-controlspointing at their.confirm-row. - Every focusable control carries a visible ring:
outline: 2px solid var(--paper)with2pxoffset on:focus-visible, since the UA default is a bright blue tuned for neither branch of this palette..searchbarrecolours its border on:focus-withinas a resting cue, but that 1px change is not the ring — the.searchinput declares its own. A new control that suppressesoutlinemust replace it, not drop it. - Touch targets are 44–46px under
(pointer: coarse)— including inline text controls like.libswitch a, where padding plus an 11px line lands short of 44 and needsmin-height+place-items: center. The 44px desktop cells are pointer-only (≥720px). - 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/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 under
(pointer: coarse), single column), then the ≥720px block. Mono no smaller than 11px, and a:focus-visiblering on anything focusable — both are §7 floors, not preferences. - 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).