Files
sulthan 3a83161b1c Cinder pass across /admin, the login gate, and the library's a11y floor (#177)
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>
2026-08-27 23:09:42 +07:00

19 KiB
Raw Permalink Blame History

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-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 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 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 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; 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:)
                 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-new only when Status == reading && HasNewChapter; .is-dim for archived. 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 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, .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, remove — toggleConfirmRow(key, kind) in filter.js). Archive asks in .calm grey since it's 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. .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 <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 grey bar sliding across the top hairline (barSlide), the action strip at opacity: .5, and past 2s the word Saving… in ::after (busyWord, §6). filter.js sets aria-busy on 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 --mute not --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-title at 2 — there the title is a reminder, in the list it is the identifier). .is-new .title needs width: fit-content, or -webkit-box stretches the ember underline to the full row and the rule stops being sized to the text.
  • .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 .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 the Saving… word only once a request has outlived a plausible response. The reduced-motion block cancels the animation and so would pin it at opacity: 0; that branch re-declares opacity: 1 to 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 is aria-hidden. Lifecycle buttons also carry aria-expanded + aria-controls pointing at their .confirm-row.
  • Every focusable control carries a visible ring: outline: 2px solid var(--paper) with 2px offset on :focus-visible, since the UA default is a bright blue tuned for neither branch of this palette. .searchbar recolours its border on :focus-within as a resting cue, but that 1px change is not the ring — the .search input declares its own. A new control that suppresses outline must 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 needs min-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, 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/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 under (pointer: coarse), single column), then the ≥720px block. Mono no smaller than 11px, and a :focus-visible ring on anything focusable — both are §7 floors, not preferences.
  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:embeded so the binary must be rebuilt to see markup changes).