Files
mangaBookmark/docs/design-system.md
sulthan 58014eb8dd review: verdigris patina, honest Lane figures, tests that can fail (#102)
Two-axis review found the shipped colour and three weak tests.

- --patina was a warm gold at the same hue family as --brass; the spec
  asked for a cool blue-green so an unhealthy Lane is unmistakably not
  ember. Now verdigris in both branches, with docs/design-system.md
  stating why the far side of the wheel is the point.
- A Lane pass that returns before computing its figures carries the
  previous pass's due count and gap forward instead of recording zeroes,
  and Checked rides beside Due so a stopped Lane is distinguishable from
  a quiet one.
- TestAdminPageWithoutAPollerSaysSo now separates the two causes it
  conflated, TestOwnerClearsReaderMarks seeds real counters so the
  clearing assertion can fail, and TestLaneStatus asserts Checked and
  the carry-forward.
- backend/AGENTS.md records the one deliberate owner comparison outside
  requireOwner and the carry-forward rule.
2026-08-16 14:31:00 +07:00

301 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, 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 / 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 |
| `--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: 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 `.btn`s 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:embed`ed so the binary must be rebuilt
to see markup changes).