From e940b96eae25597f56e7da56d8ff8405d609f07f Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 09:33:41 +0700 Subject: [PATCH] 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}}