Files
mangaBookmark/docs/design-system.md
T
sulthan ac3ee9b298 Rebuild both UIs on the Cinder design (#10)
Implements the **Cinder** design (Claude Design doc `cfa39183`) across both UI surfaces, self-hosts the fonts it depends on, and writes down the two documents that keep the result maintainable.

## What changed

**Web UI** — rebuilt on the design's visual language: editorial serif, containerless sheets divided by ash hairlines, one 760px measure, no radii and no shadows. The organising rule is that *heat is typographic*: only a series with an unread chapter is crimson (title on an ember underline, cover foot rule, `Ch N out`, play icon), and favourites get brass rather than borrowing the accent. Both states hang off two classes on the `<article>` (`is-new`, `is-dim`), so sub-elements inherit the state instead of re-deriving it.

The six-cell action strip is `flex-basis: 100%` inside the row, which is what lets one piece of markup be a full-width strip with 46px thumb targets on a phone and a group of 40px squares beside the row on a desktop — no duplicate template branches. Icons moved to a sprite (`templates/icons.html`); htmx-swapped cards reference the page's symbols, so a row no longer carries a screenful of inline SVG.

**Userscript panel** — repainted in the same tokens. The panel and the web UI are the same product on the same phone, and the old purple-on-charcoal panel read as a different application once the web UI moved. Structure, ids and classes are untouched, and the edge tab keeps its geometry, `touch-action` and `#hit` sizing.

**Fonts are self-hosted** — five latin-subset woff2 files (~120 KB) embedded via the existing `//go:embed static`. Loading them from Google would lose the design's character exactly where it is used most: Bromite users routinely block Google's font domains, and the backend is reachable over a LAN with no internet route. `staticHandler` registers the `.woff2` MIME type, which Go's table lacks and the scratch image has no `/etc/mime.types` for.

**Docs** — `docs/design-system.md` records the rules a stylesheet cannot state (what the ember is reserved for, why light mode is a re-tuning rather than an inversion, which details are load-bearing) so a future agent does not re-derive them from the CSS. `REDEPLOY.md` covers the operation actually performed every time, which `DEPLOY.md` reduced to two lines.

## Commits

Each is one logical change and builds on its own:

| | |
|---|---|
| `4d69e54` | `listView.NewCount` — data for the Updated badge, no markup |
| `e940b96` | Web UI rebuilt on Cinder (CSS, templates, sprite, `filter.js`) |
| `686fcc1` | Userscript panel repainted in the same tokens |
| `ce7e93d` | Self-hosted webfonts + `.woff2` MIME registration |
| `a547cc9` | `docs/design-system.md` |
| `b20ecf1` | `REDEPLOY.md` |

## Verification

- `go test ./...` — 187 pass. Userscript logic tests — 14 pass.
- Screenshotted at 390px and 1180px, in dark and light, across All / Archived / Finished / empty / chapter-form / confirm-row.
- Fonts: a run with `fonts.googleapis.com` and `fonts.gstatic.com` blocked still reports all five faces `loaded`; served as `200 font/woff2`.
- The three `REDEPLOY.md` backup/restore commands were run, not assumed. `:ro` on the source volume fails (`unable to open database file` — WAL needs to create `-shm`), and a restored file lands root-owned while the container runs as uid 65532, so reads succeed and writes fail. Both are documented with the reason.

## Note for the reviewer

The panel restyle is token-level only: the design doc covers the web UI, so the panel's *structure* has no reference to follow and was deliberately left alone.

Deploying this needs a rebuild — templates, CSS and fonts are `//go:embed`ed, so pulling alone changes nothing.

Reviewed-on: #10
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-07-30 09:47:08 +07:00

11 KiB
Raw Blame History

Cinder — mangaBookmark design system

Source of truth: the Claude Design doc Cinder Sheet (cfa39183-8874-4f76-987c-afef14dceebb, files Cinder Sheet.dc.html for the static spec and Cinder Sheet App.dc.html for the interactive one). 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,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 — stays cool. 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 use brass, which is already spoken for by favourites.

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). 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, toast)
--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 #5a5450 #857d75 eyebrow labels, hints
--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 (confirm, error)
--ember-ink #150907 #fff text on solid ember
--ember-soft #eda798 #8d2c17 text on ember wash
--brass #b8912f #8a681c favourites, and only favourites
--trash #6b5450 #a98276 remove, at rest
--asura #7d93a5 #4f6b80 site tag
--demonic #a98a78 #8a6a55 site tag
--hatch / --hatch-dim 135° 5px stripe paper stripe missing-cover slot

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 is read in Bromite, where fonts.googleapis.com is routinely blocked, and over 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, with <em> in ember italic — manga<em>Bookmark</em>.
  • Row title: 400 19px/1.2 display (21px ≥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: 500 10px mono, letter-spacing: .12em, text-transform: uppercase. Eyebrows ("CONTINUE READING") use .2em.
  • Empty-state heading: 400 20px display; body 400 14px/1.6 sans, max-width: 44ch.
  • Primary button: --paper fill, --ink text, 400 17px display, no border radius.
  • Ghost button: mono small-caps, transparent, border-bottom: 1px --field-line.

4. Components (web UI)

.sheet
  .topbar        .brand + .ghost (log out)
  .chrome        .searchbar + nav.tabs   (column on phone, row ≥720px via order:)
  .recent        h2 eyebrow + .recent-strip > a.recent-card
  main#list      article.card …  |  .empty

article.card — the row, and the only per-series component:

article.card[.is-new|.is-dim]#card-<key>[data-title]
  .row
    a.cover        img | span.monogram, + span.foot-rule[.brass]
    .body          .title-line (h3.title + svg.fav-mark) , p.meta
    .actions       play, favourite, chapter, archive|restore, finish, remove
  form.chapter-form[hidden]   .hint + .field(input + Save)
  .confirm-row[hidden]        span + (Remove, 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 40px 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.
  • 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 ember bar sliding across the top hairline (barSlide), plus the action strip at opacity: .5. Never a spinner.
  • .open on the pencil / trash cell marks which panel is showing; filter.js togglePanel() owns that class alongside hidden.

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 ember hairline (.spinner is now a 1px bar, not a rotating ring); toasts are --ash with a 2px left rule, ember-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:

  • sheetIn — 180ms fade + 4px rise, on a row and on each disclosure panel.
  • barSlide — the burning hairline, for any busy state.
  • emberPulse — 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 40px desktop cells are pointer-only (≥720px).
  • Every icon-only control keeps title + aria-label; the SVG inside is aria-hidden.
  • 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.
  • 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.
  3. If it is per-series, hang it off .is-new / .is-dim rather than adding a third state class.
  4. Icon → templates/icons.html; nothing inlines SVG paths.
  5. Phone first (44px targets, single column), then the ≥720px block.
  6. 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).