Files
mangaBookmark/docs/design-system.md
sulthan 8e4fa6448e Spec #136: Finished belongs to the Series — owner-owned poll gate, Lifecycle bucket dropped (#163)
Closes #136.

Spec #136 end to end: `finished` becomes a fact about the Series, written only by the owner, and the reader-facing Lifecycle bucket is gone.

## What landed

- **#157** — `series.finished_at bigint NOT NULL DEFAULT 0` plus the migration whose statement order is load-bearing (seed from the buckets, then flip them); both Lane queries lose the `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished')` clause and gate on `finished_at = 0` instead, with the due-query/eligible-count force asymmetry kept deliberate and commented; `StatusFinished`, its API special-case 400, the web tab and the templates' Finished bucket deleted.
- **#158** — owner Finish control on the Series detail page: confirm-gated finish, instant un-finish, admin accent (never ember, nothing is destroyed), `Store.SetSeriesFinished`, the two routes behind the owner gate, and the state displayed on the list row without offering the control there.
- **#160** — reader side: derived `finished` bool on the flat Bookmark (`s.finished_at > 0`), rendered as a text-only label in both userscripts and on the web card; read-only inbound by omission from `Upsert`'s explicit `series` column list, same mechanism that already protects `cover`.
- **#161** — glossary and the stale Reader-count divergence note catch up.
- **#159** — `finished` joins the admin filter vocabulary (predicate `finished_at > 0`, label `Finished`, own aggregate count, figure last in the stats block as informational); the four clock-driven hygiene predicates (stale, never-checked, no-cover, no-chapter) exclude finished Series while unpollable, orphan and sighting-raised deliberately do not.

## Verification

`go vet ./...` and `go test ./...` green on the merged branch (Docker-backed, throwaway `postgres:17-alpine` per package). Each ticket also passed a two-axis review (spec + standards) on its own branch before merge.

Reviewed-on: #163
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-22 17:31:52 +07:00

17 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 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

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), 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 .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 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/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:embeded so the binary must be rebuilt to see markup changes).