From 4d69e54c24502741304063153d907a97fe5d05c1 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 09:32:58 +0700 Subject: [PATCH 1/6] Count new chapters for the Updated tab badge The tab strip needs to say how many series have an unread chapter without the user opening the tab to find out, so listView carries the number alongside the filtered items. The count is taken over the whole reading set rather than the rendered items, so it means the same thing on every tab instead of collapsing to len(Items) on Updated and 0 everywhere else. Archived and finished are excluded, matching what the Updated tab itself shows. Co-Authored-By: Claude Opus 5 --- backend/web.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/web.go b/backend/web.go index 61f5840..59a7f79 100644 --- a/backend/web.go +++ b/backend/web.go @@ -38,6 +38,11 @@ type listView struct { Tab string // "all", "fav", or "new" Recent []Bookmark Items []Bookmark + // NewCount is the badge on the Updated tab: how many series being read + // have a chapter out that has not been read. It is counted over the whole + // reading set, not the active tab, so the badge does not change meaning as + // the user moves between tabs. + NewCount int } // Initial is the monogram the templates show in place of a cover when the @@ -178,12 +183,14 @@ func (h *webHandler) buildListView(tab string) (listView, error) { recent = recent[:recentCount] } + withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() }) + var items []Bookmark switch tab { case "fav": items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite }) case "new": - items = filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() }) + items = withNew case "archived": items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived }) case "finished": @@ -192,7 +199,7 @@ func (h *webHandler) buildListView(tab string) (listView, error) { tab = "all" items = reading } - return listView{Tab: tab, Recent: recent, Items: items}, nil + return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil } func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) { -- 2.52.0 From e940b96eae25597f56e7da56d8ff8405d609f07f Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 09:33:41 +0700 Subject: [PATCH 2/6] Rebuild the web UI on the Cinder design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the card-and-chip visual language with the Cinder direction from the design doc: 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 allowed to be crimson — title, cover foot rule, "Ch N out", play icon — so the one thing worth acting on is the one thing that draws the eye. Favourites get brass instead of borrowing the accent, which is what made the old three-hue palette read as noise. Both states hang off two classes on the article (is-new, is-dim), so a new sub-element inherits the state rather than re-deriving it. The action strip is flex-basis 100% inside the row, which is what lets the same markup be a full-width six-cell strip under the row on a phone (46px targets) and a group of 40px squares beside it on a desktop, with no duplicate template branches. Icons move to a sprite in icons.html: cards are swapped in by htmx and reference the page's symbols, so a row no longer carries a screenful of inline SVG paths. [hidden] gets a display:none override because every disclosure panel is now a flex container and display beats the attribute. filter.js grows an .open class alongside hidden so the strip can show which cell the open panel belongs to. Co-Authored-By: Claude Opus 5 --- backend/static/filter.js | 36 +- backend/static/style.css | 939 +++++++++++++++++++++-------------- backend/templates/app.html | 32 +- backend/templates/card.html | 131 ++--- backend/templates/icons.html | 15 + backend/templates/list.html | 12 +- backend/templates/login.html | 20 +- 7 files changed, 723 insertions(+), 462 deletions(-) create mode 100644 backend/templates/icons.html diff --git a/backend/static/filter.js b/backend/static/filter.js index 5595ca9..ee15ec5 100644 --- a/backend/static/filter.js +++ b/backend/static/filter.js @@ -26,29 +26,41 @@ function setActiveTab(el) { } // The chapter-edit form and the delete confirm row are the two per-card -// disclosure panels; only one makes sense open at a time. +// disclosure panels; only one makes sense open at a time. The button that +// owns an open panel carries .open, which is how the strip shows which cell +// the panel belongs to. function closeCardPanels(key) { var form = document.getElementById("chapter-form-" + key); var confirm = document.getElementById("confirm-row-" + key); if (form) form.hidden = true; if (confirm) confirm.hidden = true; + var card = document.getElementById("card-" + key); + if (card) { + card.querySelectorAll(".actions .open").forEach(function (b) { + b.classList.remove("open"); + }); + } +} + +function togglePanel(key, panelId, buttonSelector) { + var panel = document.getElementById(panelId + key); + if (!panel) return null; + var opening = panel.hidden; + closeCardPanels(key); + panel.hidden = !opening; + var card = document.getElementById("card-" + key); + var button = card && card.querySelector(buttonSelector); + if (button) button.classList.toggle("open", opening); + return panel; } function toggleChapterForm(key) { - var form = document.getElementById("chapter-form-" + key); - if (!form) return; - var opening = form.hidden; - closeCardPanels(key); - form.hidden = !opening; - if (!form.hidden) form.querySelector("input").focus(); + var form = togglePanel(key, "chapter-form-", ".actions .pencil"); + if (form && !form.hidden) form.querySelector("input").focus(); } function toggleConfirmRow(key) { - var row = document.getElementById("confirm-row-" + key); - if (!row) return; - var opening = row.hidden; - closeCardPanels(key); - row.hidden = !opening; + togglePanel(key, "confirm-row-", ".actions .remove"); } // A failed favourite/chapter/delete request leaves the card in place (htmx diff --git a/backend/static/style.css b/backend/static/style.css index f7d2f93..af65075 100644 --- a/backend/static/style.css +++ b/backend/static/style.css @@ -1,53 +1,81 @@ -/* Mobile first. Dark by default because manga reading happens at night; the - light branch follows the system preference. */ +/* Cinder — editorial serif, containerless sheets, ash hairlines. + Heat is purely typographic: a series with an unread chapter turns its title + crimson and gains an ember underline. Everything else stays cool. Dark is + the default because this is read at night on a phone; the light branch + re-tunes the same tokens onto paper. */ :root { color-scheme: dark light; - --bg: #14161a; - --surface: #1d2026; - --surface-2: #262a32; - --text: #e8eaed; - --muted: #9aa1ac; - --accent: #6aa9ff; - --accent-ink: #0b1220; - --danger: #ff6a6a; - --danger-ink: #2a0a0a; - --star: #ffc857; - /* A newly-published chapter is its own signal, distinct from "tap to act" - (accent) or "favourited" (star) — a third hue keeps those three meanings - from collapsing into one overloaded blue. */ - --new: #34d8a6; - --new-ink: #0b1220; - /* Per-site tint so the two source sites this app exists to unify are - still tellable apart at a glance, not just by reading the label text. */ - --site-asura-bg: #3a2a20; - --site-asura-text: #ffb37a; - --site-demonic-bg: #2e2440; - --site-demonic-text: #c9a8ff; - --radius: 12px; + + --font-display: "Instrument Serif", Georgia, "Times New Roman", serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --font-body: "DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif; + + --ink: #100f0e; /* page */ + --ash: #161413; /* recessed panel (chapter form) */ + --dim: #0d0c0b; /* archived / finished rows sink */ + --rule: #221f1d; /* hairline between sheets */ + --rule-soft: #1a1817; /* measure edges */ + --field-line: #2c2926; + --hover: #1a1816; + + --paper: #f2ece5; /* highest-contrast text, primary button */ + --paper-hot: #f0d3cb; /* title of a series with a new chapter */ + --paper-dim: #ddd5cb; /* resting title */ + --mute: #8d857c; /* secondary text, idle icons */ + --mute-2: #5a5450; /* labels, hints */ + --faint: #3a3733; /* meta separators */ + --faint-2: #57504b; /* cover monogram */ + + --ember: #e0452c; /* the only heat */ + --ember-wash: #1a1211; /* ember-tinted surface */ + --ember-ink: #150907; /* text on solid ember */ + --ember-soft: #eda798; /* text on ember wash */ + --brass: #b8912f; /* favourite — a second, cooler metal */ + --trash: #6b5450; /* remove, resting */ + + --asura: #7d93a5; + --demonic: #a98a78; + + /* Covers are often missing; the hatch keeps the slot honest instead of + faking artwork. */ + --hatch: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px); + --hatch-dim: repeating-linear-gradient(135deg, #1b1918 0 5px, #151313 5px 10px); + + --measure: 760px; } -/* Light theme darkens the four semantic hues (and flips their ink) rather - than reusing the dark-theme values verbatim: the originals were tuned to - sit on near-black surfaces and fall well under WCAG AA the moment the - surface goes to white — verified via computed contrast, not eyeballed. */ +/* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the + dark ember is too bright to read as text on off-white. */ @media (prefers-color-scheme: light) { :root { - --bg: #f4f5f7; - --surface: #ffffff; - --surface-2: #eceef2; - --text: #1a1d22; - --muted: #5d646e; - --accent: #1f5fd6; - --accent-ink: #ffffff; - --danger: #b42323; - --danger-ink: #ffffff; - --star: #7d5f00; - --new: #087a5c; - --new-ink: #ffffff; - --site-asura-bg: #fde8d8; - --site-asura-text: #8a4a1f; - --site-demonic-bg: #f0e6fa; - --site-demonic-text: #6b2fa8; + --ink: #f7f4ef; + --ash: #efeae3; + --dim: #f1ede7; + --rule: #e0dad2; + --rule-soft: #e8e3dc; + --field-line: #d4cdc4; + --hover: #efeae3; + + --paper: #191715; + --paper-hot: #a33018; + --paper-dim: #191715; + --mute: #6b645d; + --mute-2: #857d75; + --faint: #c9c2ba; + --faint-2: #a8a098; + + --ember: #c23a22; + --ember-wash: #fbeee9; + --ember-ink: #fff; + --ember-soft: #8d2c17; + --brass: #8a681c; + --trash: #a98276; + + --asura: #4f6b80; + --demonic: #8a6a55; + + --hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px); + --hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px); } } @@ -55,350 +83,531 @@ body { margin: 0; - padding: 0 12px calc(24px + env(safe-area-inset-bottom)); - background: var(--bg); - color: var(--text); - font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--ink); + color: var(--paper-dim); + font: 400 15px/1.5 var(--font-body); + display: flex; + justify-content: center; + -webkit-text-size-adjust: 100%; } -/* Every interactive element clears the 44px touch-target floor. */ -button, .primary, [role="tab"] { - min-height: 44px; - border-radius: var(--radius); - border: 0; - font: inherit; - cursor: pointer; -} +a { color: inherit; text-decoration: none; } +button, input { font: inherit; } +button { cursor: pointer; } -/* --- login --- */ +/* Every hideable thing here is a flex container, and display beats hidden. */ +[hidden] { display: none !important; } -.login-body { - display: grid; - place-items: center; - min-height: 100dvh; -} +@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } } +@keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } } +@keyframes emberPulse { 0%, 100% { opacity: .5 } 50% { opacity: 1 } } -.login-card { - width: min(380px, 100%); - padding: 24px; - background: var(--surface); - border-radius: var(--radius); -} - -.login-card h1 { margin: 0 0 20px; font-size: 1.25rem; font-weight: 700; letter-spacing: -.01em; } -.login-card label { display: block; margin-bottom: 6px; color: var(--muted); font-size: .875rem; } - -.login-card input { +/* One measured column, edges drawn rather than boxed. */ +.sheet { width: 100%; - min-height: 44px; - padding: 0 12px; - margin-bottom: 12px; - background: var(--surface-2); - color: var(--text); - border: 1px solid transparent; - border-radius: var(--radius); - font: inherit; + max-width: var(--measure); + min-height: 100vh; + display: flex; + flex-direction: column; + border-left: 1px solid var(--rule-soft); + border-right: 1px solid var(--rule-soft); + padding-bottom: env(safe-area-inset-bottom); } -.login-card input:focus-visible { outline: 2px solid var(--accent); } -.login-card button { width: 100%; background: var(--accent); color: var(--accent-ink); font-weight: 600; } -.error { margin: 0 0 12px; color: var(--danger); font-size: .875rem; } - -/* --- chrome --- */ +/* ---- brand + chrome ---- */ +.brand { + margin: 0; + font: 400 26px/1 var(--font-display); + color: var(--paper); +} +.brand em { color: var(--ember); font-style: italic; } .topbar { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 22px 20px 14px; +} +.topbar form { margin: 0; } + +.ghost { + padding: 0; + border: none; + border-bottom: 1px solid var(--field-line); + background: transparent; + color: var(--mute); + font: 500 11px var(--font-mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.ghost:hover { color: var(--paper); border-bottom-color: var(--paper); } + +.chrome { display: flex; flex-direction: column; } + +.searchbar { + display: flex; + align-items: center; + gap: 10px; + height: 44px; + margin: 0 20px 14px; + border-bottom: 1px solid var(--rule); + color: var(--mute-2); +} +.searchbar svg { width: 15px; height: 15px; flex: none; } +.search { + flex: 1; + min-width: 0; + height: 44px; + border: none; + background: transparent; + color: var(--paper); + font: 400 15px var(--font-body); + outline: none; +} +.search::placeholder { color: var(--mute-2); } + +/* Tabs are set in the display serif and underlined, not chipped. */ +.tabs { + display: flex; + gap: 16px; + padding: 2px 20px 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + border-bottom: 1px solid var(--rule); +} +.tabs::-webkit-scrollbar { display: none; } +.tabs [role="tab"] { + flex: none; + display: flex; + align-items: center; + gap: 7px; + padding: 8px 0 12px; + color: var(--mute); + font: 400 17px var(--font-display); + white-space: nowrap; +} +.tabs [role="tab"]:hover { color: var(--paper-dim); } +.tabs [role="tab"].active { + color: var(--paper); + border-bottom: 2px solid var(--paper); + margin-bottom: -1px; +} +/* Updated is the one tab that carries heat. */ +.tabs .tab-new { color: var(--ember); } +.tabs .tab-new.active { border-bottom-color: var(--ember); } +.count { + font: 600 10px var(--font-mono); + letter-spacing: 0; + padding: 2px 5px; + border: 1px solid currentColor; +} + +/* ---- continue reading ---- */ +.recent { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 0 18px; + border-bottom: 1px solid var(--rule); +} +.recent h2 { + margin: 0; + padding: 0 20px; + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; + color: var(--mute-2); +} +.recent-strip { + display: flex; + gap: 14px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + padding: 0 20px 4px; +} +.recent-strip::-webkit-scrollbar { display: none; } +.recent-card { + width: 92px; + flex: none; + display: flex; + flex-direction: column; + gap: 8px; +} +.recent-cover { + position: relative; + width: 92px; + height: 123px; + background: var(--hatch); + display: grid; + place-items: center; + overflow: hidden; +} +.recent-cover img { width: 100%; height: 100%; object-fit: cover; } +.recent-title { + font: 400 15px/1.2 var(--font-display); + color: var(--paper-dim); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.recent-chapter { + font: 500 10px/1 var(--font-mono); + letter-spacing: .1em; + color: var(--mute-2); +} +.recent-card.is-new .recent-title { color: var(--paper-hot); } +.recent-card.is-new .recent-chapter { color: var(--ember); } + +/* The rule at the cover foot is the only cover ornament: ember for a new + chapter, brass for a favourite. */ +.foot-rule { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + background: var(--ember); +} +.foot-rule.brass { background: var(--brass); } + +.monogram { + font: 400 32px var(--font-display); + color: var(--faint-2); +} + +/* ---- rows ---- */ +.list { display: flex; flex-direction: column; } + +.card { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px 20px; + border-bottom: 1px solid var(--rule); + animation: sheetIn .18s ease-out; +} +.card.is-dim { background: var(--dim); } + +.row { display: flex; flex-wrap: wrap; gap: 14px; } + +.cover { + position: relative; + width: 66px; + height: 88px; + flex: none; + background: var(--hatch); + display: grid; + place-items: center; + overflow: hidden; +} +.cover img { width: 100%; height: 100%; object-fit: cover; } +.cover .monogram { font-size: 24px; } +.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; } + +.body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.title-line { display: flex; gap: 8px; align-items: flex-start; } +.title { + margin: 0; + flex: 1; + min-width: 0; + font: 400 19px/1.2 var(--font-display); + color: var(--paper-dim); +} +/* Heat: crimson title over an ember hairline sized to the text, not the row. */ +.is-new .title { + flex: 0 1 auto; + align-self: flex-start; + color: var(--paper-hot); + border-bottom: 1px solid var(--ember); + padding-bottom: 3px; +} +.is-dim .title { font-style: italic; color: var(--mute); } +.is-dim .fav-mark { color: var(--mute-2); } +.fav-mark { flex: none; width: 13px; height: 13px; margin-top: 5px; color: var(--brass); } + +.meta { + margin: 0; + display: flex; + align-items: center; + gap: 9px; + flex-wrap: wrap; + font: 500 10px/1 var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; + color: var(--mute); +} +.meta .sep { color: var(--faint); } +.site-asura { color: var(--asura); } +.site-demonic { color: var(--demonic); } +.new-chapter { color: var(--ember); } +.state { display: flex; align-items: center; gap: 4px; color: var(--mute); } +.state svg { width: 9px; height: 9px; } +.is-dim .meta { color: var(--mute-2); } +.is-dim .site-asura, .is-dim .site-demonic { color: var(--mute); filter: grayscale(.6); } + +/* ---- action strip: full-width on a phone, hairline-divided cells ---- */ +.actions { + flex: 1 0 100%; + display: flex; + border-top: 1px solid var(--rule); +} +.actions > * { + flex: 1 1 0; + min-width: 0; + height: 46px; + display: grid; + place-items: center; + padding: 0; + border: none; + border-right: 1px solid var(--rule); + background: transparent; + color: var(--mute); +} +.actions > *:last-child { border-right: none; } +.actions svg { width: 17px; height: 17px; } +.actions > *:hover { color: var(--paper); } +.actions .play { color: var(--paper); } +.is-new .actions .play { color: var(--ember); } +.actions .play:hover { background: var(--hover); } +.actions .on { color: var(--brass); } +.actions .restore { color: var(--paper); } +.actions .remove { color: var(--trash); } +.actions .remove:hover { color: var(--ember); } +.actions .open { background: var(--hover); color: var(--paper); } +.actions .remove.open { background: var(--ember-wash); color: var(--ember); } +.is-dim .actions > * { color: var(--mute-2); } + +/* ---- disclosure panels ---- */ +.chapter-form, .confirm-row, .error-inline { animation: sheetIn .18s ease-out; } + +.chapter-form { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 14px; + background: var(--ash); +} +.chapter-form .hint { + margin: 0; + font: 500 10px/1 var(--font-mono); + letter-spacing: .14em; + text-transform: uppercase; + color: var(--mute-2); +} +.chapter-form .field { display: flex; gap: 10px; } +.chapter-form input { + flex: 1; + min-width: 0; + height: 46px; + padding: 0 12px; + border: 1px solid var(--field-line); + background: var(--ink); + color: var(--paper); + font: 500 17px var(--font-mono); + outline: none; +} +.chapter-form input:focus { border-color: var(--ember); } +.chapter-form input::-webkit-outer-spin-button, +.chapter-form input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.chapter-form button { + height: 46px; + padding: 0 20px; + border: none; + background: var(--paper); + color: var(--ink); + font: 400 17px var(--font-display); +} + +.confirm-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; - padding: 12px 0; + padding: 14px; + background: var(--ember-wash); } - -.topbar h1 { margin: 0; font-size: 1.125rem; font-weight: 700; letter-spacing: -.01em; } -.ghost { padding: 0 12px; background: var(--surface-2); color: var(--muted); } - -.search { - width: 100%; - min-height: 44px; - padding: 0 12px; - margin-bottom: 12px; - background: var(--surface); - color: var(--text); - border: 1px solid transparent; - border-radius: var(--radius); - font: inherit; +.confirm-row span { font: 400 17px var(--font-display); color: var(--ember-soft); } +.confirm-row div { display: flex; gap: 8px; } +.confirm-row button { + height: 40px; + padding: 0 14px; + border: 1px solid var(--field-line); + background: transparent; + color: var(--mute); + font: 500 12px var(--font-body); } - -.search:focus-visible { outline: 2px solid var(--accent); } - -/* Five buttons no longer divide a phone's width legibly, so the row scrolls - sideways instead of squeezing. The scrollbar is hidden: on mobile there is - none anyway, and on desktop it would sit under the tab labels. */ -.tabs { - display: flex; gap: 8px; margin-bottom: 16px; - overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; +.confirm-row button:hover { color: var(--paper); } +.confirm-row .danger-solid { + border: none; + background: var(--ember); + color: var(--ember-ink); + font-weight: 600; } -.tabs::-webkit-scrollbar { display: none; } - -.tabs [role="tab"] { - flex: 1 0 auto; - padding-inline: 14px; - white-space: nowrap; - display: grid; - place-items: center; - background: var(--surface); - color: var(--muted); - text-decoration: none; -} - -.tabs [role="tab"].active { background: var(--accent); color: var(--accent-ink); font-weight: 600; } - -/* --- continue reading --- */ - -.recent h2 { margin: 0 0 8px; font-size: .8125rem; font-weight: 600; text-transform: uppercase; - letter-spacing: .04em; color: var(--muted); } - -.recent-strip { - display: flex; - gap: 10px; - overflow-x: auto; - padding-bottom: 8px; - margin-bottom: 16px; - scroll-snap-type: x mandatory; - -webkit-overflow-scrolling: touch; -} - -.recent-card { - flex: 0 0 110px; - scroll-snap-align: start; - display: block; - padding: 8px; - background: var(--surface); - border-radius: var(--radius); - color: var(--text); - text-decoration: none; -} - -/* A reserved slot even without cover art, so the ribbon always has a - dedicated area to sit in instead of overlapping the title text. */ -.recent-cover { - position: relative; - display: block; - aspect-ratio: 3 / 4; - background: var(--surface-2); - border-radius: 8px; - overflow: hidden; -} - -.recent-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } -.recent-title { display: block; margin-top: 6px; font-size: .8125rem; font-weight: 600; line-height: 1.35; - overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } -.recent-chapter { display: block; color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; } - -/* --- list --- */ - -.list { display: grid; gap: 10px; } - -.card { - display: grid; - grid-template-columns: 96px 1fr; - gap: 12px; - padding: 10px; - background: var(--surface); - border-radius: var(--radius); -} - -.card .cover { - position: relative; - display: block; - width: 96px; - aspect-ratio: 3 / 4; - background: var(--surface-2); - border-radius: 8px; - overflow: hidden; -} - -.card .cover img { width: 100%; height: 100%; object-fit: cover; display: block; } - -/* No og:image was ever scraped for this series. A bare --surface-2 rectangle - read as a cover still loading; the title's initial says the slot is filled - and nothing more is coming. Muted, not accent — a missing cover is an - absence, not a signal, and it must never outrank the New ribbon it sits - under. The two cover contexts share this rule and differ only in size. */ -.cover-blank { - position: absolute; - inset: 0; - display: grid; - place-items: center; - color: var(--muted); - font-size: 2rem; - font-weight: 700; - line-height: 1; - letter-spacing: -.02em; - opacity: .65; - user-select: none; -} - -.recent-cover .cover-blank { font-size: 1.625rem; } -.card .body { min-width: 0; } -/* 17px, not the 16px body floor: the title is the one thing per card the - squint test must catch first, so it needs a size step of its own, not - just the browser's default h3 bold riding on an unchanged size. */ -.card .title { margin: 0 0 4px; font-size: 1.0625rem; font-weight: 700; line-height: 1.35; } - -.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px; - font-size: .75rem; color: var(--muted); } - -.site { padding: 2px 6px; background: var(--surface-2); border-radius: 6px; text-transform: uppercase; - letter-spacing: .03em; } -/* Per-site tint: the two source sites this app unifies are otherwise - identical chrome, so this is the one glanceable "which site" cue beyond - the label text itself. */ -.site-asura { background: var(--site-asura-bg); color: var(--site-asura-text); } -.site-demonic { background: var(--site-demonic-bg); color: var(--site-demonic-text); } -/* Read progress is data, not metadata, so it takes the body text color - instead of the muted tone the label pills around it use. */ -.chapter { color: var(--text); font-variant-numeric: tabular-nums; } -.new-chapter { color: var(--new); font-weight: 600; font-variant-numeric: tabular-nums; } - -/* The signal the poller exists to surface: a corner ribbon on the cover - itself outranks any line of card metadata, so it reads on the squint - test even in a dense grid, not just on close inspection. Its own hue - (not accent) keeps "new chapter" distinct from "tap to act". */ -.new-ribbon { - position: absolute; - top: 6px; - left: 6px; - padding: 2px 7px; - background: var(--new); - color: var(--new-ink); - font-size: .6875rem; /* 11px: the functional-text legibility floor */ - font-weight: 700; - letter-spacing: .02em; - text-transform: uppercase; - border-radius: 6px; - box-shadow: 0 2px 6px rgba(0, 0, 0, .35); -} - -.new-ribbon-sm { top: 4px; left: 4px; padding: 1px 5px; } - -/* One row of equal-sized icon buttons: Continue no longer stretches to - fill leftover space (that was both the wrap bug at narrow widths and - what made it read as arbitrarily oversized). It carries the same 44px - footprint as the utility icons and is told apart by fill color alone. - Grid columns capped at 44px, not flex items fixed at 44px: on the - narrowest real Android widths four 44px targets plus gaps don't quite - fit beside a 96px cover, and minmax(0, 44px) lets the row compress a - couple of px instead of wrapping — imperceptible, and never wraps. */ -.actions { - display: grid; - grid-template-columns: repeat(4, minmax(0, 44px)); - gap: 8px; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--surface-2); -} - -.primary { - display: grid; - place-items: center; - background: var(--accent); - color: var(--accent-ink); - font-size: 1.125rem; - text-decoration: none; -} - -.icon { display: grid; place-items: center; padding: 0; - background: var(--surface-2); color: var(--text); font-size: 1.125rem; } -.icon.on { color: var(--star); } -.icon.danger { color: var(--danger); } - -/* Hand-drawn inline SVG rather than the OS emoji font: ☆ ✎ 🗑 ▶ each render in - a different face at a different optical weight and colour (some fonts force - their own), which is the one place the card ignored its own type and colour - system. currentColor keeps them on .icon / .icon.on / .icon.danger / - .primary without a second palette. 20px, not the 1.125rem font-size the - glyphs used: a glyph inks about two thirds of its em box, so matching the - old optical size means an SVG a step larger than the font-size it replaces. */ -.icon svg, .primary svg { width: 20px; height: 20px; display: block; } - -/* Wraps, like .actions and .confirm-row: the hint claims its own full-width - row (flex-basis 100%) so input + Save keep the whole card width below it. - Without the wrap the three children fought for one unwrapped row and pushed - Save off-screen — ~97px of page overflow at 390px, 127px at 360px. */ -.chapter-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } - -.chapter-form input { - /* flex-basis 0 + min-width 0, not flex: 1: a number input's default - intrinsic width is ~20 characters, so on `flex: 1` (basis auto) it refused - to shrink and pushed Save onto a third row. A chapter number needs five - characters, and the field can have whatever is left over. */ - flex: 1 1 0; - min-width: 0; - min-height: 44px; - padding: 0 12px; - background: var(--surface-2); - color: var(--text); - border: 1px solid transparent; - border-radius: var(--radius); - font: inherit; - font-variant-numeric: tabular-nums; -} - -.chapter-form button { padding: 0 14px; background: var(--accent); color: var(--accent-ink); font-weight: 600; } -/* flex: 1 0 100% — own row above the field. The margin-bottom is 0 because the - parent's 8px gap already spaces it; keeping both double-spaced the hint. */ -.hint { flex: 1 0 100%; margin: 0; font-size: .75rem; color: var(--muted); font-variant-numeric: tabular-nums; } - -/* Delete confirm, themed in place of a native confirm() dialog: that OS - chrome renders outside the app's own dark surface, breaking the - night-reading brand constraint at the one moment attention matters most. */ -.confirm-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; } -/* Own full-width row, same as .chapter-form's hint. Sharing one row with both - buttons broke the prompt into "Remove / this?" while its own answer sat - whole beside it; nowrap alone only converted that into 26px of page - overflow, because a nowrap flex item will not shrink past its text. */ -.confirm-row span { flex: 1 0 100%; font-size: .8125rem; white-space: nowrap; } -.confirm-row .danger-solid { padding: 0 14px; background: var(--danger); color: var(--danger-ink); font-weight: 600; } -.confirm-row button[type="button"] { padding: 0 14px; background: var(--surface-2); color: var(--text); } - -.empty { padding: 32px 12px; text-align: center; color: var(--muted); } - -/* --- mutation feedback --- */ - -/* htmx adds .htmx-request to the element named by hx-indicator while a - request is in flight; dimming the whole card is the visible "it's working" - signal the templates otherwise never give. */ -.card.htmx-request { opacity: .6; } .error-inline { - margin: 8px 0 0; - padding: 6px 10px; - background: var(--surface-2); - color: var(--danger); - border-radius: 8px; - font-size: .8125rem; + display: flex; + align-items: center; + gap: 9px; + margin: 0; + padding: 11px 13px; + background: var(--ember-wash); + border-left: 2px solid var(--ember); + font: 400 16px var(--font-display); + color: var(--ember-soft); +} +.error-inline::before { + content: ""; + flex: none; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--ember); + animation: emberPulse 1.4s ease-in-out infinite; } -.error-inline[hidden] { display: none; } - -/* Cards hidden by the search filter. The .chapter-form { display: flex } - rule above has equal specificity to the UA [hidden] rule, so without this - override the form stays visible even when the hidden attribute is set. */ -.card[hidden] { display: none; } -.chapter-form[hidden] { display: none; } -.confirm-row[hidden] { display: none; } - -/* --- wide screens --- */ - -@media (min-width: 900px) { - body { max-width: 1100px; margin: 0 auto; padding-inline: 24px; } - .list { grid-template-columns: repeat(2, 1fr); } - .recent-card { flex-basis: 140px; } +/* Busy: the hairline at the top of the sheet burns across it. */ +.card.htmx-request { overflow: hidden; } +.card.htmx-request .actions { pointer-events: none; opacity: .5; } +.card.htmx-request::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 30%; + height: 1px; + background: var(--ember); + animation: barSlide 1.15s linear infinite; } -@media (min-width: 1300px) { - .list { grid-template-columns: repeat(3, 1fr); } +/* ---- empty ---- */ +.empty { + display: flex; + flex-direction: column; + gap: 7px; + padding: 40px 20px 48px; +} +.empty strong { font: 400 20px var(--font-display); color: var(--paper); } +.empty.hot strong { color: var(--ember); } +.empty p { + margin: 0; + max-width: 44ch; + font: 400 14px/1.6 var(--font-body); + color: var(--mute); + text-wrap: pretty; +} + +/* ---- login ---- */ +.login-card { + width: 100%; + max-width: 420px; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 52px 28px 40px; +} +.login-card .eyebrow { + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; + color: var(--mute-2); +} +.login-card h1 { + margin: 14px 0 0; + font: 400 44px/1 var(--font-display); + letter-spacing: -.01em; + color: var(--paper); +} +.login-card h1 em { color: var(--ember); font-style: italic; } +.login-card form { display: flex; flex-direction: column; gap: 18px; } +.login-card label { + font: 500 10px/1 var(--font-mono); + letter-spacing: .16em; + text-transform: uppercase; + color: var(--mute); +} +.login-card input { + width: 100%; + height: 54px; + margin-top: 9px; + padding: 0 2px; + border: none; + border-bottom: 1px solid var(--field-line); + background: transparent; + color: var(--paper); + font: 500 20px var(--font-mono); + letter-spacing: .16em; + outline: none; +} +.login-card input:focus { border-bottom-color: var(--ember); } +.login-card .error { + margin: 0; + min-height: 20px; + font: 400 13px/1.4 var(--font-body); + color: var(--ember); +} +.login-card button { + height: 54px; + border: 1px solid var(--paper); + background: var(--paper); + color: var(--ink); + font: 400 19px var(--font-display); +} +.login-card button:hover { + background: var(--ember); + border-color: var(--ember); + color: #fff; +} + +/* ---- desktop: same measure, actions fold up beside the row ---- */ +@media (min-width: 720px) { + .topbar { padding: 26px 32px 18px; } + .brand { font-size: 30px; } + + .chrome { + flex-direction: row; + align-items: center; + gap: 26px; + padding: 0 32px; + border-bottom: 1px solid var(--rule); + } + .tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; } + .tabs [role="tab"] { padding: 10px 0 14px; font-size: 18px; } + .searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; } + .recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; } + .recent-card, .recent-cover { width: 100px; } + .recent-cover { height: 133px; } + + .card { padding: 18px 32px; } + .row { flex-wrap: nowrap; align-items: center; gap: 20px; } + .cover { width: 74px; height: 100px; } + .cover .monogram { font-size: 26px; } + .title { font-size: 21px; } + + .actions { flex: none; gap: 4px; border-top: none; } + .actions > * { + flex: none; + width: 40px; + height: 40px; + border: 1px solid var(--rule); + } + .is-new .actions .play { border-color: #3a1d18; } + .actions .on { border-color: #332b14; } + + .chapter-form, .confirm-row, .error-inline { margin-left: 94px; } } @media (prefers-reduced-motion: reduce) { diff --git a/backend/templates/app.html b/backend/templates/app.html index 1726355..8555b4b 100644 --- a/backend/templates/app.html +++ b/backend/templates/app.html @@ -6,20 +6,31 @@ mangaBookmark + + + +{{template "icons" .}} +
-

mangaBookmark

+

mangaBookmark

- + {{/* Search sits above the tabs on a phone and folds into the tab row on a + wider screen — one flex container, order swapped in CSS. */}} +
+ +
{{if .Recent}}

Continue reading

@@ -61,6 +76,7 @@
{{template "list" .}}
+
{{end}} diff --git a/backend/templates/card.html b/backend/templates/card.html index 63293b8..35d0e69 100644 --- a/backend/templates/card.html +++ b/backend/templates/card.html @@ -1,111 +1,114 @@ {{define "card"}} -
- - {{if .Cover}} - {{else}}{{end}} - {{if .HasNewChapter}}New{{end}} - -
-

{{.Title}}

-

- {{.Site}} - Ch {{.LastChapter}} - {{if .HasNewChapter}}Ch {{.LatestChapter}} out{{end}} -

+{{/* One sheet per series. is-new turns the title crimson over an ember rule; + is-dim sinks archived and finished rows into italic grey. */}} +
+
+ +
+
+

{{.Title}}

+ {{if .Favorite}} + + {{end}} +
+

+ {{.Site}} + / + Ch {{.LastChapter}} + {{if and (eq .Status "reading") .HasNewChapter}} + / + Ch {{.LatestChapter}} out + {{end}} + {{if eq .Status "archived"}} + / + archived + {{else if eq .Status "finished"}} + / + finished + {{end}} +

+
- - + - - {{if eq .Status "finished"}} - {{else}} {{if eq .Status "archived"}} - {{else}} - {{end}} - {{end}} -
-
+ + {{if .LatestChapter}}

Latest known: Ch {{.LatestChapter}}

{{end}} +
- - + + +
{{end}} diff --git a/backend/templates/icons.html b/backend/templates/icons.html new file mode 100644 index 0000000..57c317e --- /dev/null +++ b/backend/templates/icons.html @@ -0,0 +1,15 @@ +{{define "icons"}} + + +{{end}} diff --git a/backend/templates/list.html b/backend/templates/list.html index 1195154..e6edf97 100644 --- a/backend/templates/list.html +++ b/backend/templates/list.html @@ -2,16 +2,14 @@ {{if .Items}} {{range .Items}}{{template "card" .}}{{end}} {{else if eq .Tab "fav"}} -

No favourites yet. Star a series to pin it here.

+
No favourites yet.

Star a series to pin it here.

{{else if eq .Tab "new"}} -

Nothing new. Every series is caught up to its latest chapter.

+
Nothing new.

Every series is caught up to its latest chapter.

{{else if eq .Tab "archived"}} -

Nothing archived. Shelve a series to park it here — it keeps getting checked for new chapters.

+
Nothing archived.

Shelve a series to park it here — it keeps getting checked for new chapters.

{{else if eq .Tab "finished"}} -

Nothing finished yet.

+
Nothing finished yet.

Mark a series finished and it moves out of your reading list.

{{else}} -

- Nothing here yet. Bookmarks appear once the userscript records a chapter. -

+
Nothing here yet.

Bookmarks appear once the userscript records a chapter.

{{end}} {{end}} diff --git a/backend/templates/login.html b/backend/templates/login.html index d3e9e82..9a70ca0 100644 --- a/backend/templates/login.html +++ b/backend/templates/login.html @@ -6,16 +6,24 @@ mangaBookmark + + + - +
-

mangaBookmark

+
+ Private library +

mangaBookmark

+
- - - {{if .Error}}

{{.Error}}

{{end}} +
+ + +
+

{{.Error}}

-- 2.52.0 From 686fcc19ccbbfdd35013029d72a9bb5b2c6ffcff Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 09:33:56 +0700 Subject: [PATCH 3/6] Repaint the userscript panel in Cinder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 to Cinder. Same tokens, same rule that heat is typographic: .item.hot mirrors the web UI's is-new, .item.dim mirrors is-dim, chips and buttons become mono small-caps with hairline borders instead of pills, and the loading spinner becomes the same burning hairline rather than a rotating ring. Tokens are declared on :host so the panel has one palette block to edit, but the fonts stay system serif and mono — the web UI's webfonts cannot follow, because an @import inside the shadow root is at the mercy of the host site's CSP. Structure, ids and classes are untouched: this is a restyle, and in particular the edge tab keeps its geometry, touch-action and #hit sizing (see docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md). Co-Authored-By: Claude Opus 5 --- userscript/manga-bookmark.user.js | 178 ++++++++++++++++++++---------- 1 file changed, 118 insertions(+), 60 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index ba21e96..1c36ebf 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -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 @@