",
+ "css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-plates{list-style:none;margin:0;padding:0;display:grid;} .ds-plates li{display:flex;align-items:center;gap:8px;margin-top:-1px;padding:5px 10px;border:1px solid var(--mb-line);font:700 10.5px var(--mb-sans);letter-spacing:.1em;text-transform:uppercase;color:var(--mb-ink-2);transition:background-color .3s,color .3s;} .ds-plates li span{flex:1;} .ds-plates li em{font:500 11.5px var(--mb-sans);font-style:normal;letter-spacing:0;text-transform:none;font-variant-numeric:tabular-nums;} .ds-plates li::before{content:\"\";width:7px;height:7px;border:1px solid currentColor;flex:none;} .ds-plates li[data-state=\"done\"]{color:var(--mb-ink);} .ds-plates li[data-state=\"done\"]::before{background:var(--mb-teal);border-color:var(--mb-teal);} .ds-plates li[data-state=\"active\"]{background:var(--mb-ink);color:var(--mb-page);} .ds-plates li[data-state=\"active\"]::before{background:var(--mb-obi);border-color:var(--mb-obi);animation:ds-blink 1s steps(2) infinite;} .ds-plates li[data-state=\"failed\"]{background:var(--mb-obi);color:var(--mb-obi-ink);} @keyframes ds-blink{50%{opacity:.3;}} @media (prefers-reduced-motion:reduce){.ds-plates li::before{animation:none!important;}}"
+ },
+ {
+ "name": "Host List",
+ "kind": "custom",
+ "refersTo": "host-option",
+ "description": "Rehost targets as a ruled table of radio rows; the chosen row lifts to slip paper.",
+ "html": "",
+ "css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-hosts{margin:0;padding:0;border:0;display:grid;} .ds-hosts label{display:grid;grid-template-columns:auto 1fr;column-gap:8px;align-items:baseline;padding:6px 8px;margin-top:-1px;border:1px solid var(--mb-line);font-size:13px;cursor:pointer;color:var(--mb-ink-2);} .ds-hosts label:hover{color:var(--mb-ink);} .ds-hosts label:has(input:checked){background:var(--mb-slip);color:var(--mb-ink);font-weight:600;} .ds-hosts span,.ds-hosts small{grid-column:2;} .ds-hosts small{font-weight:400;font-size:11.5px;color:var(--mb-ink-2);} .ds-hosts input{appearance:none;-webkit-appearance:none;width:15px;height:15px;margin:2px 0 0;border:1.5px solid var(--mb-ink);border-radius:50%;background:var(--mb-slip);display:inline-grid;place-content:center;} .ds-hosts input::before{content:\"\";width:7px;height:7px;border-radius:50%;background:var(--mb-ink);transform:scale(0);transition:transform .12s;} .ds-hosts input:checked::before{transform:scale(1);} .ds-hosts input:focus-visible{outline:2px solid var(--mb-teal);outline-offset:2px;}"
+ },
+ {
+ "name": "Book with Obi Band",
+ "kind": "card",
+ "refersTo": "obi-band",
+ "description": "Signature: a framed cover standing on a kraft rail, with an include tick and an obi band printing its evidence. Add .is-main for the red main-cover band, .is-off for an unselected book.",
+ "html": "
Poster
510×510 · 20 KB · seen 2015-03
Main cover
480×623 · on the page now
",
+ "css": ":host,.ds-scope{--mb-page:#efe9df;--mb-slip:#f8f4ec;--mb-ink:#1d1b22;--mb-ink-2:#55505a;--mb-obi:#d6362f;--mb-obi-deep:#8f1d18;--mb-obi-ink:#fffaf6;--mb-teal:#2f6f6a;--mb-kraft:#b59d78;--mb-rail:#7d6847;--mb-line:rgba(29,27,34,.2);--mb-frame:rgba(29,27,34,.35);--mb-track:rgba(29,27,34,.12);--mb-shadow:rgba(29,20,10,.5);--mb-sans:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",\"Helvetica Neue\",Arial,sans-serif;font:14px/1.5 var(--mb-sans);color:var(--mb-ink);} .ds-shelf{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));align-items:end;column-gap:0;row-gap:32px;} .ds-book{display:flex;flex-direction:column;justify-content:flex-end;padding:0 10px;border-bottom:10px solid var(--mb-kraft);box-shadow:0 2px 0 var(--mb-rail);} .ds-jacket{position:relative;display:block;cursor:pointer;} .ds-jacket img{display:block;width:100%;height:auto;max-height:250px;object-fit:contain;object-position:bottom;outline:1px solid var(--mb-frame);outline-offset:-1px;box-shadow:0 8px 14px -8px var(--mb-shadow);transition:filter .25s,opacity .25s,transform .3s cubic-bezier(.16,1,.3,1);} .ds-tick{position:absolute;top:0;left:0;margin:0;width:22px;height:22px;appearance:none;-webkit-appearance:none;background:var(--mb-slip);border:1.5px solid var(--mb-ink);display:grid;place-items:center;cursor:pointer;} .ds-tick::after{content:\"\";width:10px;height:10px;background:var(--mb-ink);transform:scale(0);transition:transform .15s;} .ds-tick:checked::after{transform:scale(1);} .ds-tick:focus-visible{outline:2px solid var(--mb-teal);outline-offset:2px;} .ds-obi{position:relative;margin-top:-14px;display:grid;gap:4px;padding:6px 8px 8px;background:var(--mb-slip);border:1px solid var(--mb-line);border-top:3px solid var(--mb-ink);font-size:11.5px;} .ds-obi p{margin:0;} .ds-obi-role{font:800 10.5px var(--mb-sans);letter-spacing:.12em;text-transform:uppercase;} .ds-obi-facts{color:var(--mb-ink-2);font-variant-numeric:tabular-nums;} .ds-seen{position:relative;height:5px;background:var(--mb-track);} .ds-seen i{position:absolute;top:0;bottom:0;background:var(--mb-ink);} .ds-book.is-main .ds-obi{background:var(--mb-obi);color:var(--mb-obi-ink);border-color:var(--mb-obi);border-top-color:var(--mb-obi-deep);} .ds-book.is-main .ds-obi-facts{color:var(--mb-obi-ink);} .ds-book.is-main .ds-seen{background:rgba(255,255,255,.3);} .ds-book.is-main .ds-seen i{background:var(--mb-obi-ink);} .ds-book.is-off .ds-jacket img{filter:grayscale(1) contrast(.85);opacity:.5;transform:translateY(8px);} .ds-jacket:hover img{opacity:.92;}"
+ }
+ ],
+ "narrative": {
+ "northStar": "The Obi Shelf",
+ "overview": "Every cover a series has ever worn stands on one bookshop shelf, each wrapped in an obi band that prints its evidence: role, size, where and when it was seen, how long the archive saw it. The shelf sits inside a working console: a sticky left desk where links are written on paper slips and the run is stamped off, a middle shelf (or, as a second view, an annotated timeline of years), and a sticky right desk for rehosting and filing. The materials are book-jacket paper, sumi ink, obi red, kraft board, and one deep teal for \"done\".\n\nDensity is that of a well-kept tool, not a dashboard: small tracked uppercase labels, printed hairlines, square corners, tabular numerals. Depth comes from the books themselves, which cast a soft shadow onto their rail; nothing else floats except the userscript panel over mangabaka.org. The system refuses the category default (a white card grid of rounded tiles with checkboxes and select boxes) and anything corporate or SaaS-flavoured.\n\nThe whole world is scoped so it can live inside a shadow root on mangabaka.org: every rule hangs off the `mbseed-` scope, there are no webfonts and no external assets, and drawn details (the select chevron) are inline SVG data. Desktop and the userscript panel come first; the narrow, stacked layout exists for the panel, and phone layouts are deferred.",
+ "keyCharacteristics": [
+ "Warm paper (light) and warm umber (dark) grounds; never pure black or white.",
+ "Covers stand on kraft rails, each with an obi band carrying its evidence.",
+ "Obi red marks the main cover and the two stamps; teal marks done, rehosted, added, and focus.",
+ "Square corners and printed hairlines; the radio is the only round form.",
+ "Unselected covers go grayscale and lean back.",
+ "Two views of one set: Shelf and Timeline."
+ ],
+ "rules": [
+ {
+ "name": "The Warm Ground Rule",
+ "body": "No ground is ever pure black or white. Every cover carries a 1px inset frame in `cover-frame`, so black- or white-ground art still shows its edge on either scheme.",
+ "section": "colors"
+ },
+ {
+ "name": "The Red Is a Band Rule",
+ "body": "Obi red is a fill: the main cover's band, the stamps, failure. It appears once per desk as a stamp and once on the shelf as the main cover. Small type in red on paper or umber does not reach 4.5:1 at the current values, so status and error text should not rely on red alone.",
+ "section": "colors"
+ },
+ {
+ "name": "The Teal Means Done Rule",
+ "body": "Teal is the only \"finished/safe\" signal: done plates, progress, rehosted, added, and keyboard focus. Do not use it decoratively.",
+ "section": "colors"
+ },
+ {
+ "name": "The Printed Label Rule",
+ "body": "Uppercase tracking is reserved for things printed on an object: slip labels, band roles, plate names, desk section caps, stamps. Running text and headings stay in sentence case.",
+ "section": "typography"
+ },
+ {
+ "name": "The Tabular Facts Rule",
+ "body": "Every count, size, date and year uses tabular numerals so columns of facts align on the bands and plates.",
+ "section": "typography"
+ },
+ {
+ "name": "The Shelf Shadow Rule",
+ "body": "Only books and the floating panel cast shadows. Cards, buttons, bands, and desks never do.",
+ "section": "elevation"
+ }
+ ],
+ "dos": [
+ "**Do** frame every cover with the 1px inset `cover-frame` so dark and light art keeps its edge on both grounds.",
+ "**Do** keep every select boxed with a 2px ink bottom edge and a drawn chevron; on a band, keep the underline and chevron.",
+ "**Do** label inputs and data source-neutrally (\"Source series link\", \"Series page\"); the source's name comes from data, never from the chrome.",
+ "**Do** scope every rule under the `mbseed-` scope and use only system font stacks and inline assets, so the same CSS runs in the userscript's shadow root.",
+ "**Do** reserve obi red for the main cover's band, the stamps, and failure; use teal for done, rehosted, added, and focus.",
+ "**Do** turn unselected covers grayscale and lower them rather than hiding them.",
+ "**Do** design for the desktop console and the stacked userscript panel first."
+ ],
+ "donts": [
+ "**Don't** use pure black or pure white as a ground in either scheme.",
+ "**Don't** build a white card grid of rounded, shadowed tiles, or anything that reads as corporate or SaaS.",
+ "**Don't** round corners; the radio is the only round form.",
+ "**Don't** add shadows beyond the book cast, the rail lip, and the floating userscript panel.",
+ "**Don't** name WEBTOON (or any one source) in chrome labels or headings; a source name appears only as data or as an honest note of what is supported today.",
+ "**Don't** load webfonts, icon fonts, or external scripts.",
+ "**Don't** set small text in band red (`--mb-obi`): it is 3.9:1 on paper. Small red text (year keys, errors, removed lines, link hover) uses `--mb-obi-text` (#a3261f light, #f2867b dark; 5.5–6.1:1), and never as the only signal of an error or state."
+ ]
+ }
+}
\ No newline at end of file
diff --git a/.impeccable/surfaces/src-web-template-ts.md b/.impeccable/surfaces/src-web-template-ts.md
new file mode 100644
index 0000000..9c55eb9
--- /dev/null
+++ b/.impeccable/surfaces/src-web-template-ts.md
@@ -0,0 +1,20 @@
+---
+version: 1
+slug: "src-web-template-ts"
+primary_target: "src/web/template.ts"
+related_targets: ["src/web/styles.css","src/web/render.ts"]
+---
+
+# Surface brief — the seeder page and userscript panel
+
+Scope: `src/web/` (template.ts, styles.css, render.ts) mounted by the page and, in a shadow root, by the userscript panel on mangabaka.org. Mode: Operate.
+Audience: volunteer MangaBaka catalogue editors, at a desk (desktop first; the panel is equally important; mobile later). Job: turn two links into a reviewed set of covers, rehost them, then paste them into MangaBaka or file an edit request.
+Constraints: keep every `id` in template.ts (test/template.test.ts); all CSS scoped under `.mbseed-app` / `.mbseed-panel` so it works in a shadow root; no external fonts or scripts; light theme first, dark via prefers-color-scheme; must not feel corporate/SaaS (user, 2026-10-14).
+
+## Direction contract
+THESIS: Every cover the series ever wore stands on one shelf, each wrapped in an obi band that carries its evidence. Refuses the category default: a white card grid with checkboxes and select boxes.
+OWN-WORLD: Console layout from Graphite Console; shelf and colours from the Obi Shelf; timeline view from Annotated Edition (user's choice, 2026-10-15). Book-jacket paper ground (#efe9df), sumi ink text (#1d1b22), obi red (#d6362f) for the main cover and the primary action, deep teal (#2f6f6a) for rehosted/done state, kraft (#c9b79c) for the shelf rail and inactive bands. Obi bands are full-width paper strips across the lower third of each cover with a printed label face; the shelf is a heavy rail line under each row. Square corners, printed hairlines, no shadows except the one cast by a book on its shelf. Unselected covers turn grayscale and lean back (desaturated, lowered). Dark mode is warm umber (#2a2520), never pure black or white, and every cover has a hairline frame, so black- or white-ground art stays distinguishable (user, 2026-10-15). Labels never assume WEBTOON is the only source.
+STORY: The editor pastes two links on the "desk" strip, watches the stage plates flip as the run digs through the archive, sees every cover shelved in time order with its dates, unticks landing art, rehosts, and copies or files.
+FIRST VIEWPORT: Three columns (approved round-2 mockup, samples/mockups/6-shelf-console.html, 2026-10-15). Left sticky panel: "MangaBaka series link" and "Source series link" slips, history and rating as visibly editable selects (box + chevron), the red "Find covers" stamp, five stage plates with counts, notes. Middle: series title, Shelf | Timeline switch, main-cover toggle, then the shelf (books on kraft rails, obi bands) or the timeline (newest first round a year rail). Right sticky panel: rehost hosts, Copy all selected, then the token block with the red "File" stamp isolated at the bottom. Under ~1000px the columns stack (userscript panel).
+FORM: Tankōbon shelf with obi bands; my rank 4 of 7; seed key c13b5313. Raises: fixed furniture per cover and grayscale when unselected (character goods); seen-range bar to scale on the obi (labanotation); whole-book reflow on a visible rail grid (Crouwel); stage plates change in place (split-flap).
+FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance
diff --git a/DESIGN.md b/DESIGN.md
index a9364e8..de6df90 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1,7 +1,316 @@
-# Design
+---
+name: MangaBaka Seeder
+description: Every cover a series has worn, standing on one shelf in a three-column console.
+colors:
+ paper-page: "#efe9df"
+ paper-panel: "#e6dfd2"
+ paper-slip: "#f8f4ec"
+ sumi-ink: "#1d1b22"
+ sumi-ink-muted: "#55505a"
+ obi-red: "#d6362f"
+ obi-red-deep: "#8f1d18"
+ obi-ink: "#fffaf6"
+ obi-red-text: "#a3261f"
+ rehost-teal: "#2f6f6a"
+ kraft-rail: "#b59d78"
+ rail-lip: "#7d6847"
+ hairline: "rgba(29, 27, 34, 0.2)"
+ cover-frame: "rgba(29, 27, 34, 0.35)"
+ track: "rgba(29, 27, 34, 0.12)"
+ cast-shadow: "rgba(29, 20, 10, 0.5)"
+ umber-page: "#2a2520"
+ umber-panel: "#332d27"
+ umber-slip: "#3b342d"
+ umber-ink: "#ece4d6"
+ umber-ink-muted: "#bdb2a2"
+ umber-obi-red: "#c42f28"
+ umber-obi-red-deep: "#8e1f1a"
+ umber-obi-red-text: "#f2867b"
+ umber-teal: "#6db3a9"
+ umber-kraft-rail: "#6e5a3f"
+ umber-rail-lip: "#4a3c2a"
+ umber-hairline: "rgba(236, 228, 214, 0.16)"
+ umber-cover-frame: "rgba(236, 228, 214, 0.35)"
+ umber-track: "rgba(236, 228, 214, 0.14)"
+ umber-cast-shadow: "rgba(0, 0, 0, 0.6)"
+typography:
+ display:
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
+ fontSize: "clamp(24px, 3.2cqi, 38px)"
+ fontWeight: 800
+ lineHeight: 1.1
+ letterSpacing: "-0.025em"
+ headline:
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
+ fontSize: "clamp(22px, 3cqi, 30px)"
+ fontWeight: 800
+ letterSpacing: "-0.02em"
+ title:
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
+ fontSize: "17px"
+ fontWeight: 800
+ letterSpacing: "-0.01em"
+ body:
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
+ fontSize: "14px"
+ fontWeight: 400
+ lineHeight: 1.5
+ body-small:
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
+ fontSize: "13px"
+ fontWeight: 400
+ lineHeight: 1.5
+ band:
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
+ fontSize: "11.5px"
+ fontWeight: 400
+ fontFeature: "\"tnum\""
+ label:
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
+ fontSize: "10.5px"
+ fontWeight: 700
+ lineHeight: 1.2
+ letterSpacing: "0.14em"
+ stamp:
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans\", \"Helvetica Neue\", Arial, sans-serif"
+ fontSize: "12.5px"
+ fontWeight: 800
+ letterSpacing: "0.14em"
+ mono:
+ fontFamily: "ui-monospace, \"SFMono-Regular\", Menlo, Consolas, \"Liberation Mono\", monospace"
+ fontSize: "11.5px"
+ lineHeight: 1.45
+rounded:
+ none: "0"
+ radio: "50%"
+spacing:
+ xs: "4px"
+ sm: "8px"
+ md: "12px"
+ lg: "18px"
+ xl: "26px"
+ shelf-row: "32px"
+components:
+ button-outline:
+ backgroundColor: "transparent"
+ textColor: "{colors.sumi-ink}"
+ typography: "{typography.body-small}"
+ rounded: "{rounded.none}"
+ padding: "8px 12px"
+ width: "100%"
+ button-outline-hover:
+ backgroundColor: "{colors.sumi-ink}"
+ textColor: "{colors.paper-page}"
+ button-stamp:
+ backgroundColor: "{colors.obi-red}"
+ textColor: "{colors.obi-ink}"
+ typography: "{typography.stamp}"
+ rounded: "{rounded.none}"
+ padding: "10px 12px"
+ width: "100%"
+ button-stamp-hover:
+ backgroundColor: "{colors.obi-red-deep}"
+ textColor: "{colors.obi-ink}"
+ button-quiet:
+ backgroundColor: "transparent"
+ textColor: "{colors.sumi-ink}"
+ padding: "4px"
+ slip-field:
+ backgroundColor: "{colors.paper-slip}"
+ textColor: "{colors.sumi-ink}"
+ typography: "{typography.body}"
+ rounded: "{rounded.none}"
+ padding: "7px 10px 8px"
+ select:
+ backgroundColor: "{colors.paper-slip}"
+ textColor: "{colors.sumi-ink}"
+ typography: "{typography.body-small}"
+ rounded: "{rounded.none}"
+ padding: "4px 26px 4px 9px"
+ select-hover:
+ backgroundColor: "{colors.paper-page}"
+ view-switch:
+ backgroundColor: "transparent"
+ textColor: "{colors.sumi-ink}"
+ padding: "4px 12px"
+ view-switch-active:
+ backgroundColor: "{colors.sumi-ink}"
+ textColor: "{colors.paper-page}"
+ stage-plate:
+ backgroundColor: "transparent"
+ textColor: "{colors.sumi-ink-muted}"
+ typography: "{typography.label}"
+ padding: "5px 10px"
+ stage-plate-active:
+ backgroundColor: "{colors.sumi-ink}"
+ textColor: "{colors.paper-page}"
+ stage-plate-failed:
+ backgroundColor: "{colors.obi-red}"
+ textColor: "{colors.obi-ink}"
+ obi-band:
+ backgroundColor: "{colors.paper-slip}"
+ textColor: "{colors.sumi-ink}"
+ typography: "{typography.band}"
+ rounded: "{rounded.none}"
+ padding: "6px 8px 8px"
+ obi-band-main:
+ backgroundColor: "{colors.obi-red}"
+ textColor: "{colors.obi-ink}"
+ host-option:
+ backgroundColor: "transparent"
+ textColor: "{colors.sumi-ink-muted}"
+ typography: "{typography.body-small}"
+ padding: "6px 8px"
+ host-option-selected:
+ backgroundColor: "{colors.paper-slip}"
+ textColor: "{colors.sumi-ink}"
+---
-No UI exists yet, so there is no design system to record.
+# Design System: MangaBaka Seeder
-TODO(maintainer): establish the visual direction with impeccable when building `src/web/` (the
-first UI task), then record tokens, typography and components here from what ships. The
-userscript panel lives inside mangabaka.org's pages and should sit comfortably next to them.
+## Overview
+
+**Creative North Star: "The Obi Shelf"**
+
+Every cover a series has ever worn stands on one bookshop shelf, each wrapped in an obi band that prints its evidence: role, size, where and when it was seen, how long the archive saw it. The shelf sits inside a working console: a sticky left desk where links are written on paper slips and the run is stamped off, a middle shelf (or, as a second view, an annotated timeline of years), and a sticky right desk for rehosting and filing. The materials are book-jacket paper, sumi ink, obi red, kraft board, and one deep teal for "done".
+
+Density is that of a well-kept tool, not a dashboard: small tracked uppercase labels, printed hairlines, square corners, tabular numerals. Depth comes from the books themselves, which cast a soft shadow onto their rail; nothing else floats except the userscript panel over mangabaka.org. The system refuses the category default (a white card grid of rounded tiles with checkboxes and select boxes) and anything corporate or SaaS-flavoured.
+
+The whole world is scoped so it can live inside a shadow root on mangabaka.org: every rule hangs off the `mbseed-` scope, there are no webfonts and no external assets, and drawn details (the select chevron) are inline SVG data. Desktop and the userscript panel come first; the narrow, stacked layout exists for the panel, and phone layouts are deferred.
+
+**Key Characteristics:**
+- Warm paper (light) and warm umber (dark) grounds; never pure black or white.
+- Covers stand on kraft rails, each with an obi band carrying its evidence.
+- Obi red marks the main cover and the two stamps; teal marks done, rehosted, added, and focus.
+- Square corners and printed hairlines; the radio is the only round form.
+- Unselected covers go grayscale and lean back.
+- Two views of one set: Shelf and Timeline.
+
+## Colors
+
+A bookshop palette: paper and umber grounds, sumi ink, one red band, one teal for completion, kraft for the furniture. Each light token has a dark (umber) counterpart under `prefers-color-scheme: dark`; `obi-ink` is shared by both schemes.
+
+### Primary
+- **Obi Red** (`obi-red`; dark `umber-obi-red`): the band of the main cover, the stamp buttons ("Find covers", "Rehost and file for review"), a failed stage plate, the active plate's blinking pip, the rule above notices, and the main-cover checkbox. Its depth companion **Obi Red Deep** (`obi-red-deep`) is the stamp's hover and the main band's top rule. **Obi Ink** (`obi-ink`) is the only text colour on red.
+
+### Secondary
+- **Rehost Teal** (`rehost-teal`; dark `umber-teal`): completion and safety. Done-stage pips, the progress fill, the "Rehosted" mark on a band, added lines in the change preview, and the focus ring.
+
+### Tertiary
+- **Kraft Rail** (`kraft-rail`; dark `umber-kraft-rail`): the 10px shelf rail under every book, the note bullets, the side-panel scrollbar. **Rail Lip** (`rail-lip`) is the darker 2px edge under the rail.
+
+### Neutral
+- **Book-Jacket Paper** (`paper-page`) / **Warm Umber** (`umber-page`): the page and the middle column.
+- **Desk Paper** (`paper-panel` / `umber-panel`): the two side desks, one step darker than the page.
+- **Slip Paper** (`paper-slip` / `umber-slip`): raised paper: link slips, obi bands, selects, ticks, the selected host row, text areas.
+- **Sumi Ink** (`sumi-ink` / `umber-ink`): text, the 3px rules (brand underline, obi top edge), outline buttons, the active view and plate.
+- **Faded Ink** (`sumi-ink-muted` / `umber-ink-muted`): labels, hints, facts on the band, inactive plates and host rows.
+- **Hairline** (`hairline`), **Cover Frame** (`cover-frame`), **Track** (`track`): translucent ink for dividers and box edges, the 1px frame on every cover and select box, and the seen-range and progress tracks.
+
+### Named Rules
+**The Warm Ground Rule.** No ground is ever pure black or white. Every cover carries a 1px inset frame in `cover-frame`, so black- or white-ground art still shows its edge on either scheme.
+
+**The Red Is a Band Rule.** Obi red is a fill: the main cover's band, the stamps, failure. It appears once per desk as a stamp and once on the shelf as the main cover. Small type in red on paper or umber does not reach 4.5:1 at the current values, so status and error text should not rely on red alone.
+
+**The Teal Means Done Rule.** Teal is the only "finished/safe" signal: done plates, progress, rehosted, added, and keyboard focus. Do not use it decoratively.
+
+## Typography
+
+**Display Font:** the system UI sans stack (no webfonts; the userscript shadow root forbids external fonts)
+**Label/Mono Font:** the system monospace stack, for the paste-out text and the catbox userhash
+
+**Character:** One family, hierarchy carried by weight and tracking: heavy, tightly set titles (800, negative tracking) against small, widely tracked uppercase labels that read like print on a band or a stamp.
+
+### Hierarchy
+- **Display** (800, clamp 24-38px on container width, 1.1): the series title over the review.
+- **Headline** (800, clamp 22-30px): the empty-state statement; balanced wrapping.
+- **Title** (800, 17px): the brand line at the top of the left desk, over a 3px ink rule.
+- **Body** (400, 14px, 1.5): base text and slip inputs; empty-state paragraphs run at 15px, capped at 62ch.
+- **Body Small** (13px): settings, buttons, host rows, status lines; hints step down to 12.5px.
+- **Band** (11.5px, tabular numerals): obi band facts, notes, and per-cover fields.
+- **Label** (700, 10.5px, 0.14em, uppercase): desk section caps, slip labels (10px, 0.12em), obi role lines (800, 0.12em), stage plates (0.1em).
+- **Stamp** (800, 12.5px, 0.14em, uppercase): red stamp buttons only.
+
+### Named Rules
+**The Printed Label Rule.** Uppercase tracking is reserved for things printed on an object: slip labels, band roles, plate names, desk section caps, stamps. Running text and headings stay in sentence case.
+
+**The Tabular Facts Rule.** Every count, size, date and year uses tabular numerals so columns of facts align on the bands and plates.
+
+## Layout
+
+A three-column console on a container query, not a viewport query: left desk (250-290px), the shelf (fluid), right desk (260-300px). Both desks are sticky, full-height, and scroll on their own with a thin kraft scrollbar; they are separated from the shelf by hairlines. At a container width of 1000px or less the columns stack (left desk, shelf, right desk), the desks stop sticking, and a 3px ink rule opens the right desk. This stacked form is what the userscript panel shows.
+
+The shelf is a dense auto-fill grid of 150px-minimum slots with no column gap, so rails run continuously across a row; rows sit 32px apart and books align to the rail at their bottom. Wide art (banners) spans two slots, one below 420px. The timeline is a centre double rail with a 64px year column, covers alternating left and right (the right side dropped 46px to stagger); at 760px or less the rail moves to a 56px left gutter and every cover stacks to its right.
+
+Spacing rhythm: 4 / 8 / 12 / 18 / 26 / 32px, with tight 2-6px gaps inside slips and bands. Middle column padding is 18px 26px 56px (16px when stacked); desks are 18px 16px 24px with 14px between blocks. Desktop and panel first; phone layouts are deferred.
+
+## Elevation & Depth
+
+Flat paper with exactly three shadows, each with a physical cause. Everything else separates by hairline, ground step (page, desk, slip), and 3px ink rules.
+
+### Shadow Vocabulary
+- **Book cast** (`box-shadow: 0 8px 14px -8px var(--mb-shadow)`): under every cover image, as it stands on its rail.
+- **Rail lip** (`box-shadow: 0 2px 0 var(--mb-rail)`): the edge of the kraft rail under each shelved book. It is shelf furniture, not a card shadow.
+- **Floating panel** (`box-shadow: 0 18px 40px -18px var(--mb-shadow)`): the userscript panel over mangabaka.org only.
+
+### Named Rules
+**The Shelf Shadow Rule.** Only books and the floating panel cast shadows. Cards, buttons, bands, and desks never do.
+
+## Shapes
+
+Square corners throughout (`rounded.none`): slips, bands, buttons, selects, plates, checkboxes, ticks, covers. The one exception is the radio, which is round so single choice reads differently from multiple choice (`rounded.radio`). Edges are printed: 1px hairlines for boxes, 1.5px ink for buttons and checks, 3px ink for rules that open a thing (the brand, the obi's top edge, the stacked right desk). Stacked lists (plates, host rows) overlap their borders by 1px so they print as one ruled table. Inputs are underlined, not boxed: dotted at rest, solid on focus.
+
+## Components
+
+### Buttons
+Printed, square, and decisive.
+- **Shape:** square, 1.5px ink border, full width of the desk.
+- **Outline (default):** transparent with ink text and border; fills ink with page-coloured text on hover (0.15s).
+- **Stamp:** obi red fill, obi-ink tracked uppercase; deepens to obi-red-deep on hover. Used for "Find covers" and the final "Rehost and file for review" only, the latter isolated at the bottom of the right desk below a dashed hairline.
+- **Quiet:** borderless underlined text for secondary run actions (skip history, cancel).
+- **Disabled:** 0.4 opacity, not-allowed cursor.
+
+### Inputs / Fields
+- **Slip:** a slip-paper box with a hairline edge holding a tracked label, an underlined (dotted) input, and an optional hint. On focus the underline goes solid ink and the slip's edge turns ink.
+- **Select:** always visibly editable: slip-paper box, cover-frame edge, a 2px ink bottom edge, and a drawn SVG chevron. Hover darkens the edge to ink and steps the fill to page.
+- **Select on a band:** printed onto the obi: underline and chevron only, boxed on hover or focus. On the red main band the chevron is dropped.
+- **Checkbox and radio:** custom, 15px, 1.5px ink edge on slip, filled with an ink square (radio: dot) when checked; the main-cover checkbox fills red.
+- **Text area:** slip paper, hairline, mono 11.5px.
+
+### Navigation
+- **View switch (Shelf | Timeline):** a segmented pair inside one 1.5px ink border, right-aligned in the review head; the pressed view is ink-filled with page text.
+
+### Stage Plates
+A ruled table of five run stages that change in place like a split-flap board: tracked uppercase name, count at the right in tabular numerals, a 7px square pip. Pending is faded ink; done turns ink with a teal pip; active inverts to ink with a blinking red pip; failed fills red. A 3px teal progress line sits under the table.
+
+### Host List
+Rehost targets as a ruled table of radio rows (name plus a one-line retention fact). The chosen row lifts to slip paper with full ink and weight 600; a warning line appears only under the chosen row.
+
+### Book and Obi Band (signature)
+Each cover is a book: the image (max 250px tall on the shelf, 170px in the timeline, contained, framed, casting the book shadow) with a 22px square include tick in its top-left corner, and an obi band overlapping its foot by 14px. The band is slip paper with a 3px ink top edge and prints: role (tracked uppercase), facts (tabular, " · " separated), a 5px seen-range bar drawn to scale with a teal "Rehosted" mark, the role and rating selects, and an underlined moderator note. The main cover's band is obi red with obi-ink text. An unselected book goes grayscale, fades to half opacity, and drops 8px (on the shelf; it stays put in the timeline).
+
+### Timeline (second view)
+The Annotated Edition: a centre double ink rail with year keys set in heavy tabular numerals on a page-coloured knockout, books beside it as image-plus-band pairs.
+
+### Motion
+One authored motion: when a run finishes, books are shelved left to right (rise 14px, 0.55s, `cubic-bezier(0.16, 1, 0.3, 1)`, 28ms stagger capped at 24 books). The active plate's pip blinks in two steps. Everything else is 0.12-0.3s state transitions. `prefers-reduced-motion` removes all animation and transitions.
+
+## Do's and Don'ts
+
+### Do:
+- **Do** frame every cover with the 1px inset `cover-frame` so dark and light art keeps its edge on both grounds.
+- **Do** keep every select boxed with a 2px ink bottom edge and a drawn chevron; on a band, keep the underline and chevron.
+- **Do** label inputs and data source-neutrally ("Source series link", "Series page"); the source's name comes from data, never from the chrome.
+- **Do** scope every rule under the `mbseed-` scope and use only system font stacks and inline assets, so the same CSS runs in the userscript's shadow root.
+- **Do** reserve obi red for the main cover's band, the stamps, and failure; use teal for done, rehosted, added, and focus.
+- **Do** turn unselected covers grayscale and lower them rather than hiding them.
+- **Do** design for the desktop console and the stacked userscript panel first.
+
+### Don't:
+- **Don't** use pure black or pure white as a ground in either scheme.
+- **Don't** build a white card grid of rounded, shadowed tiles, or anything that reads as corporate or SaaS.
+- **Don't** round corners; the radio is the only round form.
+- **Don't** add shadows beyond the book cast, the rail lip, and the floating userscript panel.
+- **Don't** name WEBTOON (or any one source) in chrome labels or headings; a source name appears only as data or as an honest note of what is supported today.
+- **Don't** load webfonts, icon fonts, or external scripts.
+- **Don't** set small text in band red (`--mb-obi`): it is 3.9:1 on paper. Small red text (year keys, errors, removed lines, link hover) uses `--mb-obi-text` (#a3261f light, #f2867b dark; 5.5–6.1:1), and never as the only signal of an error or state.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0b4f746..331944f 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -13,7 +13,7 @@ self-hosted Bun server (primary), a userscript, and later a Bun CLI. Replace thi
|---|---|
| `src/core/` | Pure pipeline: sources, Wayback history, image inspection, dedupe, seed, MangaBaka client. No DOM, Bun, Node or `GM_*` APIs. |
| `src/core/session.ts` | Review state shared by the page and the userscript: cards, main cover, rehosting, manual text, token flow (preview → confirm → save for review). |
-| `src/web/` | Static page UI. |
+| `src/web/` | Built. The page ("Obi Shelf" design, see `DESIGN.md`): `template.ts` markup shared with the userscript panel, `app.ts` wiring (`mountApp`), `render.ts` shelf and timeline views, `view-model.ts` pure helpers, `transport.ts` (direct to MangaBaka/litterbox, everything else via `/api/fetch`), `styles.css` scoped for a shadow root. |
| `src/server/` | Built. `app.ts`: web-standard handler (`createApp(env)`) — static assets, `/api/config`, `/api/fetch` allowlisted proxy (redirects re-checked, 20 MB cap), `/api/upload` (store or catbox; takes a source URL, never bytes), `/covers/*`. `storage.ts` (interface, in-memory) and `storage-dir.ts` (folder, size cap, 30-day sweep). `config.ts` (env, client IP), `rate-limit.ts`, `assets-path.ts`. `bun.ts`: thin `Bun.serve` entry. Gate and Workers entry deferred. |
| `src/userscript/` | Userscript on mangabaka.org; network via `GM_xmlhttpRequest`. |
| `src/cli/` | Later: Bun CLI. |
diff --git a/src/web/app.ts b/src/web/app.ts
index fe23d43..61c869f 100644
--- a/src/web/app.ts
+++ b/src/web/app.ts
@@ -7,6 +7,7 @@ import { renderCards, renderPresent, renderPreview } from "./render";
import { GRANULARITY_LABELS, defaultHost, hostChoices, stageText, type ServerConfig } from "./view-model";
export const USERHASH_KEY = "mbseed.catbox-userhash";
+export const VIEW_KEY = "mbseed.view";
export interface AppOptions {
root: ParentNode;
@@ -41,6 +42,7 @@ export function mountApp(o: AppOptions): void {
warnings: q("warnings"),
review: q("review"),
seriesTitle: q("series-title"),
+ seriesSource: q("series-source"),
matureWarning: q("mature-warning"),
linkWarning: q("link-warning"),
linkConfirm: q("link-confirm"),
@@ -65,6 +67,22 @@ export function mountApp(o: AppOptions): void {
confirm: q("confirm"),
submitStatus: q("submit-status"),
};
+ const plates = [...o.root.querySelectorAll(".mbseed-plates [data-stage]")];
+ let currentStage = "";
+ /** Stage plates change state in place: done, active, failed, or pending. */
+ /** Each plate shows a short count once its stage has something to say (images, language, n/n…). */
+ const setCount = (stage: string, text: string) => {
+ const em = plates.find((p) => p.dataset.stage === stage)?.querySelector("em");
+ if (em) em.textContent = text;
+ };
+ const markStage = (stage: string, outcome: "running" | "done" | "failed" | "reset" = "running") => {
+ currentStage = stage;
+ const at = plates.findIndex((p) => p.dataset.stage === stage);
+ plates.forEach((plate, i) => {
+ plate.dataset.state =
+ outcome === "reset" ? "" : outcome === "done" ? "done" : i < at ? "done" : i === at ? (outcome === "failed" ? "failed" : "active") : "";
+ });
+ };
let running: AbortController | null = null;
let stopHistory: AbortController | null = null;
let prepared: PreparedSubmission | null = null;
@@ -89,6 +107,20 @@ export function mountApp(o: AppOptions): void {
ui.userhashRemember.checked = true;
}
+ const viewButtons = [...o.root.querySelectorAll(".mbseed-view[data-view]")];
+ const showView = (view: string) => {
+ ui.cards.dataset.view = view === "timeline" ? "timeline" : "shelf";
+ for (const b of viewButtons) b.setAttribute("aria-pressed", String(b.dataset.view === ui.cards.dataset.view));
+ };
+ showView(o.store.get(VIEW_KEY) ?? "shelf");
+ for (const b of viewButtons) {
+ b.addEventListener("click", () => {
+ showView(b.dataset.view ?? "shelf");
+ o.store.set(VIEW_KEY, ui.cards.dataset.view ?? "shelf");
+ draw();
+ });
+ }
+
const hostOptions = (): HostOptions => ({
litterboxTime: ui.litterboxTime.value as LitterboxTime,
catboxUserhash: ui.userhash.value.trim() || undefined,
@@ -105,9 +137,10 @@ export function mountApp(o: AppOptions): void {
type: "radio", name: "mbseed-host", value: c.id, checked: c.id === current, disabled: c.disabled !== null,
});
const label = document.createElement("label");
- label.append(input, ` ${c.label} — ${c.lifetime}`);
+ label.append(input, Object.assign(document.createElement("span"), { textContent: c.label }),
+ Object.assign(document.createElement("small"), { textContent: c.lifetime }));
const why = c.disabled ?? c.warning;
- if (why) label.append(Object.assign(document.createElement("small"), { textContent: ` ${why}` }));
+ if (why) label.append(Object.assign(document.createElement("small"), { className: c.disabled ? "is-why" : "is-warn", textContent: why }));
return label;
}));
}
@@ -137,8 +170,9 @@ export function mountApp(o: AppOptions): void {
ui.makeDefault.checked = o.session.makeDefault;
ui.makeDefault.disabled = !r.suggestedDefault;
ui.defaultNote.textContent = r.suggestedDefault
- ? "MangaBaka has one main cover per series, not per language: this replaces the current main cover for every language."
+ ? "One main cover per series: this replaces it for every language."
: "No current poster was found to suggest as the main cover.";
+ ui.rehost.textContent = `Rehost ${selected} cover${selected === 1 ? "" : "s"}`;
ui.manualText.value = o.session.manualText();
const blocked = selected === 0 || (!r.linkMatches && !ui.linkConfirm.checked);
ui.rehost.disabled = blocked;
@@ -156,6 +190,7 @@ export function mountApp(o: AppOptions): void {
ui.confirm.hidden = true;
ui.previewDiff.replaceChildren();
ui.linkConfirm.checked = false;
+ for (const plate of plates) setCount(plate.dataset.stage ?? "", "");
status(ui.stage, "Starting…");
try {
const r = await o.session.run(
@@ -168,11 +203,13 @@ export function mountApp(o: AppOptions): void {
signal: running.signal,
stopHistory: stopHistory.signal,
onStage: (stage) => {
+ markStage(stage);
status(ui.stage, stageText(stage));
ui.progress.removeAttribute("value");
ui.skipHistory.hidden = stage !== "history";
},
onProgress: (stage, done, total) => {
+ setCount(stage, `${done} / ${total}`);
status(ui.stage, stageText(stage, done, total));
ui.progress.max = total;
ui.progress.value = done;
@@ -181,17 +218,34 @@ export function mountApp(o: AppOptions): void {
},
ui.ratingAll.value as ContentRating,
);
+ markStage("compare", "done");
+ setCount("series", `#${r.seriesId}`);
+ setCount("source", r.series.language);
+ if (ui.historyOn.checked === false) setCount("history", "off");
+ setCount("compare", `${r.present.length} on MangaBaka`);
+ const landing = r.fresh.filter((c) => c.shape === "tall").length;
+ if (landing) {
+ ui.warnings.append(Object.assign(document.createElement("li"), {
+ textContent: `${landing} tall image${landing === 1 ? " looks" : "s look"} like landing-page art and start${landing === 1 ? "s" : ""} unselected.`,
+ }));
+ }
status(ui.stage, `Found ${r.fresh.length} new cover(s); ${r.present.length} already on MangaBaka.`);
ui.progress.max = 1;
ui.progress.value = 1;
- ui.seriesTitle.textContent = `${r.mbSeries.title} ← ${r.source.name} “${r.series.title}” (${r.series.language})`;
+ ui.seriesTitle.textContent = r.mbSeries.title;
+ ui.seriesSource.textContent = `← ${r.source.name} “${r.series.title}” · ${r.series.language}`;
ui.matureWarning.hidden = !r.series.mature;
ui.formLink.href = coverFormUrl(r.seriesId);
ui.review.hidden = false;
draw();
} catch (err) {
- if (running?.signal.aborted) status(ui.stage, "Cancelled.");
- else status(ui.stage, errorText(err), true);
+ if (running?.signal.aborted) {
+ markStage(currentStage, "reset");
+ status(ui.stage, "Cancelled.");
+ } else {
+ markStage(currentStage, "failed");
+ status(ui.stage, errorText(err), true);
+ }
} finally {
running = null;
stopHistory = null;
diff --git a/src/web/index.html b/src/web/index.html
index 0e9e124..f406361 100644
--- a/src/web/index.html
+++ b/src/web/index.html
@@ -3,16 +3,12 @@
+
MangaBaka Seeder
-
-
-
MangaBaka Seeder
-
Find every cover a WEBTOON series has used and turn them into a MangaBaka cover edit request.
-
-
-
+
+
diff --git a/src/web/render.ts b/src/web/render.ts
index d9fc0f6..8f62cf8 100644
--- a/src/web/render.ts
+++ b/src/web/render.ts
@@ -1,10 +1,15 @@
import type { ExistingImage } from "../core/covers";
import type { PreviewResult } from "../core/mangabaka/types";
-import { CONTENT_RATINGS, type ImageType, type InspectedCover } from "../core/model";
-import type { Session } from "../core/session";
-import { cardFacts } from "./view-model";
+import { CONTENT_RATINGS, type CoverRole, type ImageType, type InspectedCover } from "../core/model";
+import type { ReviewCard, Session } from "../core/session";
+import { cardFacts, seenBar, seenSpan, timelineGroups, type Span } from "./view-model";
const IMAGE_TYPES: readonly ImageType[] = ["volume", "volume_back", "chapter", "season", "banner", "audiobook", "other"];
+const ROLE: Readonly> = {
+ poster: "Poster", square: "Square thumbnail", banner: "Banner", foreground: "Foreground art", other: "Image",
+};
+/** Results already shelved once: the shelving motion plays on a new run, not on every edit. */
+const shelved = new WeakSet