Files
mangaBookmark/docs/design-system.md
sulthan 4229c179b0 rebrand: MangaBM → BookmarkManager, add novel library support (#15)
Two intertwined changes — the rebrand and the novel library were developed on
the same branch because the novel UI plumbing is part of the new "Bookmark
Manager" wordmark in the web shell.

## What it does

- **Rebrand**: MangaBM → BookmarkManager across the Go module, compose stack,
  env vars, Traefik hostnames, container/image names, userscript storage
  prefixes (`mangabm:cache` → `bmgr:manga:cache`, `mangabm:queue` → `bmgr:manga:queue`),
  and docs.
- **Novel library**: same backend, two libraries. New `kind` column splits
  bookmarks into `manga` / `novel`; PUT validates it. Two userscripts:
  - `manga-bookmark.user.js` — unchanged behaviour, just stamps its own `kind`.
  - `novel-bookmark.user.js` — separate Violentmonkey install with adapters
    for **novelfull.com** (polled via headless browser — Cloudflare JS
    challenge) and **lightnovelworld.net** (polled via plain TLS).
- **Web UI**: library switch on the app shell. Login art, libswitch, and
  novel-site colours from the Cinder design snapshot.

## Plumbing

- `addedColumns` ALTER for `kind` runs on first start after upgrade; every
  pre-existing row is backfilled to `'manga'`. No manual SQL, no down-time.
- `ALLOWED_ORIGINS` gains the two novel sites.
- New `NOVEL_USERSCRIPT_PATH` env (default `/userscript/novel-bookmark.user.js`),
  bindmounted alongside the manga script.
- Traefik router names `mangabm*` → `bmapi*` / `bmweb*`.

## Test status

- `go test ./...` — green
- `node --test userscript/test/logic.test.js` — 34 pass
- `node --test userscript/test/novel-logic.test.js` — 11 pass
- `node --check` on both userscripts — clean

## Notes for the redeploy

.env keys were renamed (`MANGA_API_HOST` → `BOOKMARK_API_HOST`,
`MANGA_WEB_HOST` → `BOOKMARK_WEB_HOST`). Update DNS / Traefik labels on the
prod override before pulling, otherwise the public hostnames go dark.
See the redeploy instructions I'll post next to this PR.

Reviewed-on: #15
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-06 03:58:23 +07:00

16 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/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/internal/web/static/style.css, backend/internal/web/templates/{app,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 / 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
--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 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/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

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: Archive becomes Restore under Archived and Finished, and Finished drops Done. 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, 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-new only when Status == reading && HasNewChapter; .is-dim for archived and finished. 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, .finish, .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, finish, remove — toggleConfirmRow(key, kind) in filter.js). Archive and finish ask in .calm grey since they're 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, .finish → --moss. .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/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.
  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).