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>
This commit was merged in pull request #10.
This commit is contained in:
@@ -1173,7 +1173,10 @@
|
||||
// Cover and title are the only continue links. The subtitle and the action
|
||||
// row live in the same text column but are not navigation targets — a thumb
|
||||
// that misses ★ must not land on one, and Remove must never be one.
|
||||
return el("div", { class: "item" }, [
|
||||
// hot = a chapter is out that has not been read (crimson title, ember rule);
|
||||
// dim = archived (italic grey). Same two states the web UI paints.
|
||||
const heat = (behind ? " hot" : "") + (statusOf(b) === "archived" ? " dim" : "");
|
||||
return el("div", { class: "item" + heat }, [
|
||||
el("a", { class: "go", href: cont }, [
|
||||
b.cover
|
||||
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
||||
@@ -1380,11 +1383,11 @@
|
||||
<div id="backdrop"></div>
|
||||
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
||||
<header>
|
||||
<span>Manga Bookmarks</span>
|
||||
<button id="closeBtn" aria-label="Close">✕</button>
|
||||
<span>manga<em>Bookmark</em></span>
|
||||
<button id="closeBtn" aria-label="Close">close</button>
|
||||
</header>
|
||||
<div id="nav">
|
||||
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a>
|
||||
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">Web</a>
|
||||
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
||||
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
||||
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
|
||||
@@ -1392,7 +1395,7 @@
|
||||
<section id="context"></section>
|
||||
<div id="tabs" role="tablist">
|
||||
<button id="tabAll" class="tab active" role="tab">All</button>
|
||||
<button id="tabFav" class="tab" role="tab">★ Favourites</button>
|
||||
<button id="tabFav" class="tab" role="tab">Favourites</button>
|
||||
<button id="tabArc" class="tab" role="tab">Archived</button>
|
||||
</div>
|
||||
<div id="list"></div>
|
||||
@@ -1401,22 +1404,35 @@
|
||||
`;
|
||||
|
||||
const CSS = `
|
||||
:host { all: initial; }
|
||||
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||
/* Cinder, ported to the panel: same tokens and the same rule that heat is
|
||||
typographic. Fonts are system stacks, not the web UI's Google fonts — an
|
||||
@import here would be at the mercy of the host page's CSP.
|
||||
See docs/design-system.md. */
|
||||
:host {
|
||||
all: initial;
|
||||
--ink: #100f0e; --ash: #161413; --dim: #0d0c0b;
|
||||
--rule: #221f1d; --field-line: #2c2926; --hover: #1a1816;
|
||||
--paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb;
|
||||
--mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b;
|
||||
--ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907;
|
||||
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450;
|
||||
--font-display: Georgia, "Times New Roman", serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--font-body: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; font-family: var(--font-body); }
|
||||
#fab {
|
||||
position: fixed;
|
||||
width: 7px; height: 44px; border: none; padding: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: #6d28d9; opacity: .45;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
||||
background: var(--ember); opacity: .5;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.5); z-index: 2147483646; cursor: pointer;
|
||||
touch-action: none; user-select: none; -webkit-touch-callout: none;
|
||||
}
|
||||
#fab.left { border-radius: 0 4px 4px 0; }
|
||||
#fab:active, #fab.armed { opacity: 1; }
|
||||
#fab.armed { box-shadow: 0 0 0 2px #c4b5fd; }
|
||||
#fab.armed { box-shadow: 0 0 0 2px var(--ember-soft); }
|
||||
#fill {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; height: 0;
|
||||
background: #c4b5fd; pointer-events: none; border-radius: inherit;
|
||||
background: var(--paper); pointer-events: none;
|
||||
}
|
||||
/* Invisible tap extension: the visible sliver is 7x44, this makes it 28x72.
|
||||
It grows inward (toward the screen centre) because the tab is docked flush
|
||||
@@ -1427,90 +1443,132 @@
|
||||
}
|
||||
#fab.left #hit { left: 0; right: -21px; }
|
||||
#backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||||
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
|
||||
}
|
||||
#backdrop.open { opacity: 1; pointer-events: auto; }
|
||||
#panel {
|
||||
position: fixed; top: 0; right: 0; height: 100%;
|
||||
width: min(400px, 92vw); background: #1b1b22; color: #eee;
|
||||
width: min(400px, 92vw); background: var(--ink); color: var(--paper-dim);
|
||||
transform: translateX(105%); transition: transform .25s ease;
|
||||
z-index: 2147483647; display: flex; flex-direction: column;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,.5);
|
||||
border-left: 1px solid var(--rule);
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,.6);
|
||||
}
|
||||
#panel.open { transform: translateX(0); }
|
||||
header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
padding: 16px; border-bottom: 1px solid var(--rule);
|
||||
font: 400 22px/1 var(--font-display); color: var(--paper);
|
||||
}
|
||||
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
|
||||
#nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; }
|
||||
header em { color: var(--ember); font-style: italic; }
|
||||
#closeBtn {
|
||||
background: none; border: none; color: var(--mute); cursor: pointer;
|
||||
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
border-bottom: 1px solid var(--field-line); padding: 0 0 2px;
|
||||
}
|
||||
#closeBtn:active { color: var(--paper); }
|
||||
#nav { display: flex; gap: 14px; padding: 11px 16px; border-bottom: 1px solid var(--rule); }
|
||||
/* Chips are set as small caps rules, not pills — the sheet has no corners. */
|
||||
.chip {
|
||||
background: #2a2a33; color: #c4b5fd; text-decoration: none;
|
||||
padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
||||
background: none; border: none; padding: 0 0 2px;
|
||||
border-bottom: 1px solid var(--field-line);
|
||||
color: var(--mute); text-decoration: none; cursor: pointer;
|
||||
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip:active { opacity: .8; }
|
||||
.chip.pending { background: #4c1d95; border: none; cursor: pointer; }
|
||||
.chip:active { color: var(--paper); border-bottom-color: var(--paper); }
|
||||
.chip.pending { color: var(--ember); border-bottom-color: var(--ember); }
|
||||
.chip.pending[hidden] { display: none; }
|
||||
#context {
|
||||
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--rule);
|
||||
display: flex; flex-direction: column; gap: 9px; align-items: flex-start;
|
||||
}
|
||||
.ctx-title { font-weight: 600; font-size: 15px; }
|
||||
.ctx-sub { color: #9a9aa5; font-size: 13px; }
|
||||
#tabs { display: flex; gap: 14px; padding: 10px 16px 0; border-bottom: 1px solid #33333d; }
|
||||
.ctx-title { font: 400 19px/1.2 var(--font-display); color: var(--paper); }
|
||||
.ctx-sub {
|
||||
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--mute);
|
||||
}
|
||||
#tabs { display: flex; gap: 16px; padding: 2px 16px 0; border-bottom: 1px solid var(--rule); }
|
||||
.tab {
|
||||
background: none; border: none; color: #9a9aa5; cursor: pointer;
|
||||
font-size: 13px; font-weight: 600; padding: 4px 0 9px;
|
||||
background: none; border: none; cursor: pointer; color: var(--mute);
|
||||
font: 400 16px var(--font-display); padding: 8px 0 12px;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
}
|
||||
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
|
||||
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
||||
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
||||
.tab.active { color: var(--paper); border-bottom-color: var(--paper); }
|
||||
#list { overflow-y: auto; flex: 1; }
|
||||
.empty {
|
||||
color: var(--mute); padding: 34px 16px;
|
||||
font: 400 15px/1.6 var(--font-body);
|
||||
}
|
||||
/* Loading is the web UI's burning hairline, laid flat across the list. */
|
||||
.spinner {
|
||||
width: 26px; height: 26px; margin: 0 auto;
|
||||
border: 3px solid #33333d; border-top-color: #6d28d9; border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
width: 100%; height: 1px; background: var(--rule); overflow: hidden; position: relative;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner::after {
|
||||
content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 30%;
|
||||
background: var(--ember); animation: barSlide 1.15s linear infinite;
|
||||
}
|
||||
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } }
|
||||
.item {
|
||||
display: flex; flex-direction: row; gap: 10px;
|
||||
padding: 10px 16px; border-bottom: 1px solid #2a2a33;
|
||||
display: flex; flex-direction: row; gap: 12px;
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.item.dim { background: var(--dim); }
|
||||
.go { display: block; flex: none; }
|
||||
.go:active, .go-t:active { opacity: .7; }
|
||||
.go-t { display: block; text-decoration: none; color: inherit; }
|
||||
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
|
||||
.cover.ph { display: block; }
|
||||
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||||
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.c { color: #9a9aa5; font-size: 12px; margin: 0; }
|
||||
.c.behind { color: #c4b5fd; }
|
||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.btn {
|
||||
background: #33333d; color: #eee; border: none; border-radius: 6px;
|
||||
padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block;
|
||||
.cover {
|
||||
width: 52px; height: 70px; object-fit: cover; flex: none;
|
||||
background: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px);
|
||||
}
|
||||
.btn:active { opacity: .8; }
|
||||
.btn.primary { background: #6d28d9; color: #fff; }
|
||||
.btn.small { padding: 6px 10px; font-size: 12px; }
|
||||
.btn.danger { background: #7f1d1d; }
|
||||
.btn.star { line-height: 1; }
|
||||
.btn.star.on { background: #6d28d9; color: #fff; }
|
||||
.cover.ph { display: block; }
|
||||
.item.dim .cover { filter: grayscale(1); opacity: .85; }
|
||||
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; }
|
||||
.t {
|
||||
font: 400 17px/1.2 var(--font-display); color: var(--paper-dim);
|
||||
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; }
|
||||
.item.dim .t { font-style: italic; color: var(--mute); }
|
||||
.c {
|
||||
margin: 0; color: var(--mute);
|
||||
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
}
|
||||
.c.behind { color: var(--ember); }
|
||||
.actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; }
|
||||
.btn {
|
||||
background: none; color: var(--mute); border: 1px solid var(--rule);
|
||||
margin: 0 -1px -1px 0; padding: 0 12px; height: 38px; line-height: 36px;
|
||||
cursor: pointer; text-decoration: none; display: inline-block;
|
||||
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
}
|
||||
.btn:active { color: var(--paper); background: var(--hover); }
|
||||
.btn.primary {
|
||||
border-color: var(--paper); background: var(--paper); color: var(--ink);
|
||||
font: 400 16px var(--font-display); letter-spacing: 0; text-transform: none;
|
||||
height: 44px; line-height: 42px; padding: 0 18px;
|
||||
}
|
||||
.btn.small { height: 34px; line-height: 32px; padding: 0 10px; }
|
||||
.btn.danger { color: var(--trash); }
|
||||
.btn.danger:active { color: var(--ember); background: var(--ember-wash); }
|
||||
.btn.star { font-size: 13px; letter-spacing: 0; }
|
||||
.btn.star.on { color: var(--brass); border-color: #332b14; }
|
||||
#toasts {
|
||||
position: fixed; left: 50%; transform: translateX(-50%);
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center;
|
||||
}
|
||||
.toast {
|
||||
background: #2f2f3a; color: #fff; padding: 10px 16px; border-radius: 8px;
|
||||
font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .3s;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.4); max-width: 80vw;
|
||||
background: var(--ash); color: var(--paper); padding: 12px 16px;
|
||||
border-left: 2px solid var(--paper);
|
||||
font: 400 16px var(--font-display);
|
||||
opacity: 0; transform: translateY(10px); transition: all .3s;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 80vw;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.err { background: #7f1d1d; }
|
||||
.toast.err { background: var(--ember-wash); border-left-color: var(--ember); color: var(--ember-soft); }
|
||||
`;
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user