/* Mobile first. Dark by default because manga reading happens at night; the light branch follows the system preference. */ :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; } /* 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. */ @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; } } * { box-sizing: border-box; } 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; } /* 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; } /* --- login --- */ .login-body { display: grid; place-items: center; min-height: 100dvh; } .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 { 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; } .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 --- */ .topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; } .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; } .search:focus-visible { outline: 2px solid var(--accent); } .tabs { display: flex; gap: 8px; margin-bottom: 16px; } .tabs [role="tab"] { flex: 1; 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; } .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; } } @media (min-width: 1300px) { .list { grid-template-columns: repeat(3, 1fr); } } @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }