From a547cc9769776a7d337e6e489ee1094a37ebb714 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 09:34:44 +0700 Subject: [PATCH] Write down the Cinder design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design lives in a Claude Design doc that an agent working in this repo cannot see, so every future UI change would otherwise be a re-derivation of the same rules from the CSS — and the rules that matter here are the ones a stylesheet cannot state: that heat is reserved for an unread chapter, that favourites get brass instead of borrowing it, that light mode is a re-tuning rather than an inversion. Records the token table for both colour branches, the three type roles, the component anatomy of a row, the load-bearing details that look like noise until they break something (the [hidden] override, the action strip's flex-basis, the icon sprite, rebuilding the binary to see template changes), and how to add a font weight. Co-Authored-By: Claude Opus 5 --- docs/design-system.md | 215 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/design-system.md diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..a19e051 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,215 @@ +# 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 `` in ember italic — `mangaBookmark`. +- 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-[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 `
` — 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 `` 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 `` + there, keep `viewBox="0 0 24 24"` and `currentColor`. +- Meta line is `site / Ch N [/ Ch M out] [/ state]` with each `/` as + ``. +- 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 `.btn`s 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:embed`ed so the binary must be rebuilt + to see markup changes).