/* Fonts are served from this binary, not from Google: this UI is read in Bromite, where fonts.googleapis.com is routinely blocked, and over a LAN with no internet route. Latin subsets only, woff2 only — every browser that runs the app supports it, so there is no second format to ship. Roughly 120 KB for all five faces, embedded via //go:embed static. */ @font-face { font-family: "Instrument Serif"; font-style: normal; font-weight: 400; font-display: swap; src: url(/static/fonts/instrument-serif-400-latin.woff2) format("woff2"); } @font-face { font-family: "Instrument Serif"; font-style: italic; font-weight: 400; font-display: swap; src: url(/static/fonts/instrument-serif-400-italic-latin.woff2) format("woff2"); } @font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 500; font-display: swap; src: url(/static/fonts/ibm-plex-mono-500-latin.woff2) format("woff2"); } @font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600; font-display: swap; src: url(/static/fonts/ibm-plex-mono-600-latin.woff2) format("woff2"); } /* One variable file covers the 400–700 range the design uses. */ @font-face { font-family: "DM Sans"; font-style: normal; font-weight: 400 700; font-display: swap; src: url(/static/fonts/dm-sans-var-latin.woff2) format("woff2"); } /* 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; --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 rows sink */ --rule: #221f1d; /* decorative hairlines — not interactive — hairline between sheets */ --rule-soft: #1a1817; /* decorative hairlines — not interactive — measure edges */ --field-line: #4a4540; /* ≈■ #4a4540 — interactive border, distinct from decorative --rule/--rule-soft */ --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 */ /* Every mute-2 use is 10px mono, so it has to clear 4.5:1 on --ink even though it reads as the quietest step. */ --mute-2: #877f76; /* labels, hints */ --faint: #3a3733; /* meta separators */ --faint-2: #57504b; /* cover monogram */ --ember: #e85a41; /* the only heat — verified against --ember-wash, not --ink — ≈■ #e85a41 — verified 5.31:1 on --ember-wash #1c0f0d, not --ink */ --ember-wash: #1c0f0d; /* ■ #1c0f0d — verified against this wash for is-new card/Recent/Updated/libswitch */ --ember-ink: #150907; /* text on solid ember */ --ember-soft: #eda798; /* text on ember wash */ /* Destruction is hot but not ember: a duller oxblood, so a remove confirm is never mistaken across the room for an unread chapter. */ --danger: #cf5c4d; --danger-wash: #211311; --danger-ink: #150808; --danger-soft: #e2aaa1; --danger-line: #4a2e2b; /* resting hairline for Remove — muted danger, not alarm */ --brass: #b8912f; /* favourite — a second, cooler metal */ /* One accent per action, so a press says which lane it belongs to. All three are held at the same weight as --brass: muted, no ember competition. */ --slate: #7fa0c0; /* archive */ --moss: #7fae86; /* finished */ --clay: #b5906f; /* set chapter */ --trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */ /* A Lane needing attention: the admin page's only accent. Verdigris — cool, the far side of the wheel from ember's crimson, and clear of the archive blue. Neither ember (new chapter) nor danger (destruction) may say "system unhealthy". */ --patina: #5fb3a6; --patina-wash: #12201e; --patina-soft: #9ad6cd; --patina-line: #1e3a37; --brass-wash: #231e0f; --brass-soft: #e0c495; --slate-wash: #14202a; --slate-soft: #a8bdd2; --clay-wash: #241a13; --clay-soft: #d9b79d; --moss-wash: #13211a; --moss-soft: #a9ceb1; /* Desktop cell borders for the two coloured action states. */ --play-hot-line: #3a1d18; --fav-line: #332b14; --asura: #7d93a5; --demonic: #a98a78; --comix: #8a9a7d; --kagane: #9a8aa5; /* novel sources: same muted family, two hues the manga sites do not use */ --novelfull: #a59a7d; --lightnovelworld: #7da59a; /* 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; /* Cover width + row gap: the disclosure panels indent past the cover on desktop, so both live here rather than as magic numbers. */ --cover-w: 93px; --row-gap: 14px; /* The desktop scale-up lives in a token because `zoom` multiplies viewport units too: anything sized in vh has to divide it back out. */ --zoom: 1; } /* 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 { --ink: #f7f4ef; --ash: #efeae3; --dim: #f1ede7; --rule: #e0dad2; /* decorative hairlines — not interactive */ --rule-soft: #e8e3dc; /* decorative hairlines — not interactive */ --field-line: #d4cdc4; --hover: #efeae3; --paper: #191715; --paper-hot: #a33018; --paper-dim: #191715; --mute: #6b645d; --mute-2: #6c655e; /* clears 4.5:1 on --ash too, not just --ink */ --faint: #c9c2ba; --faint-2: #a8a098; --ember: #c23a22; /* verified against --ember-wash, not --ink */ --ember-wash: #fbeee9; /* verified against --ember-wash, not --ink — wash ground */ --ember-ink: #fff; --ember-soft: #7c2314; /* from #8d2c17 — crimson not brown, 4.9:1 on #fbeee9 */ --danger: #97362a; --danger-wash: #fbe9e5; --danger-ink: #fff; --danger-soft: #7c2c22; --danger-line: #d8b8b0; /* resting hairline for Remove — light counterpart to #4a2e2b */ --brass: #8a681c; --slate: #3f6689; --moss: #3d6c46; --clay: #7c5533; --trash: #8c6558; --patina: #1f6f66; --patina-wash: #def3f0; --patina-soft: #144a43; --patina-line: #b8ddd8; --brass-wash: #fdf6e3; --brass-soft: #5a4812; --slate-wash: #e8eef5; --slate-soft: #24445f; --clay-wash: #fdf0e6; --clay-soft: #5a3520; --moss-wash: #e7f3e8; --moss-soft: #23482a; --play-hot-line: #f0cfc6; --fav-line: #e3d3a4; --asura: #4f6b80; --demonic: #7a5c4a; /* from #8a6a55 — 5.1:1 on #f7f4ef, ~4.7:1 on #efeae3 */ --comix: #5a6c4b; /* from #5f7250 — 5.2:1 on #f7f4ef, 4.77:1 on #efeae3 — was 4.38:1 on ash, fails AA */ --kagane: #6f5f7d; --novelfull: #6f5f3f; /* from #7d6f4f — same bar */ --lightnovelworld: #3d6b5e; /* from #4f7d70 — lifts 3.90→4.8:1 on ash */ --hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px); --hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px); } } * { box-sizing: border-box; } body { margin: 0; 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 shell sets viewport-fit=cover, which puts the sheet's own hairline borders and its 20px gutter under a landscape cutout on a notched phone. Zero on every other device, so it costs nothing to honour here. */ padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); } a { color: inherit; text-decoration: none; } button, input { font: inherit; } button { cursor: pointer; } /* Every hideable thing here is a flex container, and display beats hidden. */ [hidden] { display: none !important; } /* Visually hidden but still announced — the live region for mutation and filter results. Display:none would silence it. */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* Ends flush with the right edge of the card (30% wide × 233% travel), so the card itself never needs overflow: hidden to contain it. */ @keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(233%) } } @keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } } /* The law: ember means "new chapter", and nothing else on a list screen — busy, error and destruction all stay off it, so a tired glance never misreads a system state as unread heat. The one exception is the brand itself (the wordmark, and the login screen, which shows no series at all). Destruction has its own token, --danger. */ @keyframes mutePulse { 0%, 100% { opacity: .35 } 50% { opacity: 1 } } /* One measured column, edges drawn rather than boxed. */ .sheet { width: 100%; max-width: var(--measure); min-height: 100vh; /* The URL bar is inside 100vh on mobile Chromium, so the flush bottom border sits under it. dvh is the visible box; vh above is the fallback. */ min-height: 100dvh; 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); } /* ---- brand + chrome ---- */ .brand { margin: 0; display: flex; align-items: center; gap: 10px; font: 400 26px/1 var(--font-display); color: var(--paper); } .brand em { color: var(--ember); font-style: italic; } .brand .mark { width: 30px; height: 26px; flex: none; overflow: visible; } /* The line art is drawn at a 5px stroke on a 200-unit grid; at brand size that thins out, so it is nudged up rather than scaled down blindly. */ .brand .mark g { stroke-width: 6.5; } .topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 22px 20px 14px; } .topbar form { margin: 0; } .ghost { position: relative; 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); } /* The label is 15px tall by design; the thumb gets 44 without moving it. */ .ghost::after { content: ""; position: absolute; inset: -15px -12px; } /* Nav and standalone controls share one paper ring. The per-action cells in the card strip carry their own accent rings instead; everything here would otherwise fall back to the UA default, which is a bright blue on a palette tuned for a dark room. */ .ghost:focus-visible, .tabs a:focus-visible, .libswitch a:focus-visible, .empty .clear-search:focus-visible, .login-card button:focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; } /* ---- userscript setup: collapsed by default, one hairline, no card ---- */ .setup { margin: 0 20px; padding: 12px 0 0; border-bottom: 1px solid var(--rule); color: var(--mute); } .setup summary { display: flex; align-items: center; min-height: 44px; padding: 0; font: 500 11px/1 var(--font-mono); letter-spacing: .2em; text-transform: uppercase; color: var(--mute-2); cursor: pointer; list-style: none; } .setup summary::-webkit-details-marker { display: none; } .setup summary:hover { color: var(--paper); } .setup[open] { padding-bottom: 16px; } .setup-copy { margin: 0; padding: 4px 0 12px; font: 14px/1.55 var(--font-body); color: var(--mute); } .setup-links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin: 0 0 14px; } .setup-links .ghost { font-size: 11px; } .setup-rotate { margin: 0; } /* Rotation confirmation: the one hot state the panel wears, and it is destruction, not new-chapter signal — danger, never ember. */ .setup-warn { margin: 0; padding: 10px 12px; border: 1px solid var(--danger); color: var(--danger); font: 500 12px/1.5 var(--font-mono); letter-spacing: .04em; } /* Sticky: the buckets and the search box are how a large library is triaged, and a phone three screens in otherwise has to scroll back to the top to reach either. The keyrow below is deliberately left to scroll away — it teaches the icon strip once, and 150px of permanent chrome on an 844px phone costs more than re-scrolling for a reminder does. */ .chrome { display: flex; flex-direction: column; position: sticky; top: 0; /* Cards are position: relative, so without this they paint over it. */ z-index: 2; background: var(--ink); padding-top: env(safe-area-inset-top); } .searchbar { display: flex; align-items: center; gap: 10px; height: 44px; margin: 0 20px 14px; border-bottom: 1px solid var(--rule); color: var(--mute-2); } /* The bar carries a resting recolour on focus-within, and the input carries its own ring: the 1px border change alone is a weaker indicator than every other control on the sheet gets, on the control most worth finding. */ .searchbar:focus-within { border-bottom-color: var(--paper); color: var(--paper); } .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); } .search:focus-visible { outline: 2px solid var(--paper); outline-offset: 3px; } .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); /* Right-edge fade — the pills and 4th tab crop invisibly without it. */ -webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent); mask-image: linear-gradient(to right, black calc(100% - 24px), transparent); } .tabs::-webkit-scrollbar { display: none; } .tabs a { flex: none; display: flex; align-items: center; justify-content: center; gap: 7px; /* "All" is two characters in a 17px serif — 15px of target without this. */ min-width: 44px; padding: 8px 0 12px; color: var(--mute); font: 400 17px var(--font-display); white-space: nowrap; } .tabs a:hover { color: var(--paper-dim); background: var(--hover); border-radius: 2px 2px 0 0; } .tabs a.active { color: var(--paper); border-bottom: 2px solid var(--paper); margin-bottom: -1px; background: var(--hover); } /* Updated is the one tab that carries heat. */ .tabs .tab-new { color: var(--ember); } .tabs .tab-new.active { border-bottom-color: var(--ember); background: var(--ember-wash); color: var(--ember); } .tabs .tab-new:hover { background: var(--ember-wash); color: var(--ember); } .count { font: 600 10px var(--font-mono); letter-spacing: 0; padding: 2px 5px; border: 1px solid currentColor; background: var(--ink); } .tab-new .count { background: var(--ember-wash); } /* ---- action key: one permanent line under the tabs, so the icon strip below never has to be guessed at. Lean on a phone (28px, edge to edge), a step bigger on desktop where there is room to read it. ---- */ .keyrow { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 9px 18px 10px; border-bottom: 1px solid var(--rule); } /* On a phone each key stacks: icon over word, so the word gets the full cell width and can stay the long form. */ .keyrow .pair { display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--mute); } .keyrow .pair svg { width: 14px; height: 14px; flex: none; } .keyrow .pair span { font: 500 11px/1 var(--font-mono); letter-spacing: .04em; text-transform: uppercase; } .keyrow .pair.brass svg { color: var(--brass); } .keyrow .pair.slate svg { color: var(--slate); } .keyrow .pair.clay svg { color: var(--clay); } .keyrow .pair.trash svg { color: var(--trash); } .keyrow .pair.brass span { color: var(--brass); } .keyrow .pair.slate span { color: var(--slate); } .keyrow .pair.clay span { color: var(--clay); } .keyrow .pair.trash span { color: var(--trash); } /* ---- 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 11px/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; scroll-snap-type: x proximity; /* Static right-edge fade — newest card at 93px+GAP is otherwise invisible at 360px. */ -webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent); mask-image: linear-gradient(to right, black calc(100% - 24px), transparent); } .recent-strip::-webkit-scrollbar { display: none; } .recent-card { width: var(--cover-w); flex: none; display: flex; flex-direction: column; gap: 8px; scroll-snap-align: start; } .recent-cover { position: relative; width: var(--cover-w); height: calc(var(--cover-w) * 4 / 3); background: var(--hatch); display: grid; place-items: center; overflow: hidden; } /* Cover and monogram share one grid cell rather than stacking: the monogram is the fallback for a cover that fails to load, not only for one that was never acquired, and an image that fails to decode otherwise renders the browser's broken-image glyph over the hatch. */ .recent-cover > img, .recent-cover > .monogram { grid-area: 1 / 1; } .recent-cover img { width: 100%; height: 100%; object-fit: cover; } .recent-title { font: 400 16px/1.25 var(--font-display); color: var(--paper-dim); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; } .recent-chapter { font: 500 11px/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); } .recent-card.is-new .recent-cover { outline: 1px solid color-mix(in oklch, var(--ember) 26%, transparent); outline-offset: -1px; } /* 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); } .card.is-new { background: var(--ember-wash); border-bottom-color: color-mix(in oklch, var(--ember) 14%, var(--rule)); } .card.is-new .cover { outline: 1px solid color-mix(in oklch, var(--ember) 22%, transparent); outline-offset: -1px; } .row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--row-gap); } .cover { position: relative; width: var(--cover-w); height: calc(var(--cover-w) * 4 / 3); flex: none; background: var(--hatch); display: grid; place-items: center; overflow: hidden; } .cover > img, .cover > .monogram { grid-area: 1 / 1; } .cover img { width: 100%; height: 100%; object-fit: cover; } .cover .monogram { font-size: 32px; } .is-dim .cover { background: var(--hatch-dim); } .is-dim .cover img { filter: grayscale(1); opacity: .85; } .is-dim .cover .monogram { color: var(--mute-2); } .body { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 8px; } .title-line { display: flex; gap: 8px; align-items: flex-start; } .title { margin: 0; flex: 1; min-width: 0; font: 400 21px/1.2 var(--font-display); color: var(--paper-dim); /* Titles come from a third party's og:title. One unbroken 90-character token measured 789px of document on a 390px viewport — the whole sheet scrolled sideways — so every slot that prints one breaks mid-word. */ overflow-wrap: anywhere; /* Three lines, so one scraped 90-character title cannot set the rhythm of the whole list. Deeper than the strip's two: this is where the title is the primary identifier, not a thumbnail caption. */ display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } /* 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; /* -webkit-box fills its line box, which would stretch the ember hairline to the full row; the underline is sized to the text. */ width: fit-content; } .is-dim .title { font-style: italic; color: var(--mute); } .is-dim .fav-mark { color: var(--mute-2); } /* The mark always sits at the right edge of the measure, not after the last word — a new-chapter title shrink-wraps, so without this it drifts. */ .fav-mark { flex: none; margin-left: auto; width: 14px; height: 14px; margin-top: 5px; color: var(--brass); } .meta { margin: 0; display: flex; align-items: center; gap: 9px; flex-wrap: wrap; font: 500 11px/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); } .site-comix { color: var(--comix); } .site-kagane { color: var(--kagane); } .site-novelfull { color: var(--novelfull); } .site-lightnovelworld { color: var(--lightnovelworld); } .new-chapter { color: var(--ember); } .state { display: flex; align-items: center; gap: 4px; color: var(--mute); } .state svg { width: 10px; height: 10px; } .state.finished { color: var(--moss); } .is-dim .meta { color: var(--mute-2); } .is-dim .site-asura, .is-dim .site-demonic, .is-dim .site-comix, .is-dim .site-kagane, .is-dim .site-novelfull, .is-dim .site-lightnovelworld { color: var(--mute); filter: grayscale(.6); } /* ---- library switch: manga and novels are separate libraries, so the pair sits in the topbar next to the wordmark rather than among the buckets. ---- */ .libswitch { display: flex; margin-left: auto; border: 1px solid var(--field-line); } .libswitch a { padding: 7px 13px; font: 500 11px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--mute); text-decoration: none; } .libswitch a + a { border-left: 1px solid var(--field-line); } .libswitch a:hover { color: var(--paper-dim); } /* The library you are in carries the ember, the same heat the wordmark and the Updated tab use — it is the one piece of chrome that has to be unmistakable. */ .libswitch a.active { background: var(--ember-wash); color: var(--ember); box-shadow: inset 0 -2px 0 var(--ember); } .topbar form { margin-left: 18px; } /* At phone width brand + switch + Log out do not fit on one line, so the switch takes its own row under the wordmark rather than pushing Log out off-screen. */ @media (max-width: 719px) { .topbar { flex-wrap: wrap; row-gap: 12px; } .brand { flex: 1 1 auto; min-width: 0; } .libswitch { order: 3; margin-left: 0; } .libswitch a { flex: 1; text-align: center; padding: 8px 14px; } .topbar form { margin-left: 12px; } } /* ---- 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); } /* Per-action accent on hover and press: gold favourite, slate archive, clay chapter. Remove keeps --danger, play keeps paper/ember. */ .actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); background: var(--brass-wash); } .actions .fav:focus-visible { outline: 2px solid var(--brass); outline-offset: -2px; } .actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); background: var(--clay-wash); } .actions .pencil:focus-visible { outline: 2px solid var(--clay); outline-offset: -2px; } .actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); background: var(--slate-wash); } .actions .box:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; } .actions .play { color: var(--paper); } .is-new .actions .play { color: var(--ember); } .actions .play:hover { background: var(--hover); } .is-new .actions .play:hover { background: var(--ember-wash); color: var(--ember); } .actions .play:focus-visible { background: var(--hover); outline: 2px solid var(--paper); outline-offset: -2px; } .is-new .actions .play:focus-visible { background: var(--ember-wash); color: var(--ember); outline-color: var(--ember); } .actions .on { color: var(--brass); background: var(--brass-wash); } .actions .restore { color: var(--paper); } .actions .restore:hover, .actions .restore:active, .actions .restore:focus-visible { color: var(--slate); background: var(--slate-wash); } .actions .restore:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; } .actions .remove { color: var(--trash); box-shadow: inset 1px 0 0 var(--danger-line); } .actions .remove:hover, .actions .remove:active, .actions .remove:focus-visible { color: var(--danger); background: var(--danger-wash); } .actions .remove:focus-visible { outline: 2px solid var(--danger); outline-offset: -2px; } .actions .open { background: var(--hover); color: var(--paper); } /* Lifecycle cells already sit on --ash, which is what --hover resolves to, so an open one needs the next step up to stay legible as the panel's owner. */ .actions .lifecycle.open { background: var(--rule); } .actions .lifecycle.open.box, .actions .lifecycle.open.box:hover { background: var(--slate-wash); color: var(--slate); } .actions .remove.open { background: var(--danger-wash); color: var(--danger); } .is-dim .actions > * { color: var(--mute-2); } .is-dim .actions > *:hover { background: transparent; color: var(--mute); } /* Two clusters by consequence: navigate (play) | organize (favourite, chapter) | lifecycle (archive/restore, remove). The lifecycle cells sit on a recessed ground so the thumb reads "this one moves the series" before it reads which icon it landed on. */ .actions > .lifecycle { background: var(--ash); } .actions > .play + *, .actions > *:not(.lifecycle) + .lifecycle { box-shadow: inset 1px 0 0 var(--field-line); } /* ---- 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 11px/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; } /* Clay carries the chapter action -- paper flashes on a dimmed OLED and ember reads as "invalid" on a valid number. */ .chapter-form input:focus { border-color: var(--clay); } .chapter-form input:focus-visible { border-color: var(--clay); outline: 2px solid var(--clay); outline-offset: 2px; } .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); } .chapter-form button:focus-visible { outline: 2px solid var(--clay); outline-offset: 2px; } .confirm-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 14px; background: var(--danger-wash); } /* The remove question names the series, so it has to be able to take the row to itself and push the buttons onto their own line. */ .confirm-row span { flex: 1 1 16ch; font: 400 17px/1.3 var(--font-display); color: var(--danger-soft); overflow-wrap: anywhere; } .confirm-row div { display: flex; flex: none; gap: 12px; margin-left: auto; } .confirm-row button { height: 46px; padding: 0 14px; border: 1px solid var(--field-line); background: var(--ash); color: var(--paper-dim); font: 500 12px var(--font-body); } .confirm-row button:hover { color: var(--paper); border-color: var(--paper); } .confirm-row button:focus-visible { color: var(--paper); border-color: var(--paper); outline: 2px solid var(--paper); outline-offset: 2px; } .confirm-row .danger-solid { border: none; background: var(--danger); color: var(--danger-ink); font-weight: 600; } .confirm-row .danger-solid:focus-visible { outline-color: var(--danger); } /* Archive is reversible, so its confirm asks in grey — only the irreversible remove gets the danger wash. */ .confirm-row.calm { background: var(--ash); } .confirm-row.calm span { color: var(--paper-dim); } .confirm-row .go { border: none; background: var(--paper); color: var(--ink); font-weight: 600; } .confirm-row .go:hover, .confirm-row .go:focus-visible { color: var(--ink); } /* The same notice in two positions: .error-inline belongs to one card and reports a write that failed, .notice sits under the chrome and reports the requests that have no card to belong to — a tab switch, a rotation. */ .error-inline { display: flex; align-items: center; gap: 9px; margin: 0; padding: 11px 13px; background: var(--danger-wash); border-left: 2px solid var(--danger); font: 400 16px var(--font-display); color: var(--danger-soft); } .notice { display: flex; align-items: center; gap: 9px; margin: 0; padding: 11px 20px; background: var(--ash); border-left: 2px solid var(--patina); border-bottom: 1px solid var(--rule); font: 400 16px var(--font-display); color: var(--paper-dim); } .error-inline::before { content: ""; flex: none; width: 5px; height: 5px; border-radius: 50%; background: var(--danger); } .notice::before { content: ""; flex: none; width: 5px; height: 5px; border-radius: 50%; background: var(--patina); animation: mutePulse 1.4s ease-in-out infinite; } .error-inline .error-link, .notice .error-link { border-bottom: 1px solid var(--field-line); color: var(--paper); } .error-inline .error-link { border-bottom-color: color-mix(in oklch, var(--danger) 26%, transparent); color: var(--danger-soft); } /* Busy: a grey hairline slides across the top of the sheet — deliberately not ember, which on a list screen only ever means "new chapter". */ .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(--mute); animation: barSlide 1.15s linear infinite; } /* Past about two seconds the bar alone stops reading as "working" and starts reading as "ignored" — and htmx's own timeout is 15s. Delayed rather than immediate: every response on a live connection lands well inside it. */ @keyframes busyWord { to { opacity: 1 } } .card.htmx-request::after { content: "Saving…"; position: absolute; top: 6px; right: 20px; font: 500 11px/1 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--mute); opacity: 0; animation: busyWord 0s 2s forwards; } /* ---- empty ---- */ .empty { display: flex; flex-direction: column; gap: 7px; padding: 40px 20px 48px; } .empty strong { font: 400 20px var(--font-display); color: var(--paper); overflow-wrap: anywhere; } .empty .clear-search { align-self: flex-start; margin-top: 4px; height: 44px; padding: 0 16px; border: 1px solid var(--field-line); background: transparent; color: var(--paper-dim); font: 400 16px var(--font-display); } .empty .clear-search:hover { color: var(--paper); border-color: var(--paper); } .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: calc(100vh / var(--zoom)); min-height: calc(100dvh / var(--zoom)); display: flex; flex-direction: column; justify-content: space-between; padding: 52px 28px 40px; } .login-card .eyebrow { font: 500 11px/1 var(--font-mono); letter-spacing: .2em; text-transform: uppercase; color: var(--mute-2); } .login-card h1 { margin: 14px 0 0; /* The wordmark is the widest unbreakable run in the app; at 44px it is wider than a 360px phone, and the page has no other content to scroll to, so the overflow reads as a broken page. Fluid to the measured fit (30px clears a 320px viewport), locked at the design size from 520px up. */ font: 400 clamp(30px, 8.5vw, 44px)/1 var(--font-display); letter-spacing: -.01em; color: var(--paper); } .login-card h1 em { color: var(--ember); font-style: italic; } .login-art { margin: 8px auto 0; width: 240px; aspect-ratio: 1; display: grid; place-items: center; background: radial-gradient(circle, var(--ember-wash) 0%, transparent 70%); } .login-art img { width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 0 34px var(--ember-wash)) drop-shadow(0 18px 24px rgba(0,0,0,.5)); } .login-card form { display: flex; flex-direction: column; gap: 18px; } .login-card .error { margin: 0; min-height: 20px; font: 400 13px/1.4 var(--font-body); color: var(--danger); } .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: var(--ember-ink); } .login-card .login-note { margin: 14px 0 0; text-align: center; font: 400 12px/1.4 var(--font-body); color: var(--mute); } /* ---- laptop and up: the whole sheet is drawn 20% larger, which is what reading it at 120% zoom on a 1920-wide screen was doing by hand. Everything in this file is sized in px, so scaling the root is the one adjustment that keeps every proportion — hairlines, cover ratios, hit targets — intact. ---- */ @media (min-width: 1280px) { :root { --zoom: 1.2; zoom: var(--zoom); } } /* ---- desktop: same measure, actions fold up beside the row ---- */ @media (min-width: 720px) { :root { --cover-w: 80px; --row-gap: 20px; } .topbar { padding: 26px 32px 18px; } .brand { font-size: 30px; } .brand .mark { width: 35px; height: 30px; } .libswitch a { padding: 9px 16px; font-size: 11px; } .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 a { padding: 10px 0 14px; font-size: 18px; } .tabs, .recent-strip { -webkit-mask-image: none; mask-image: none; } .searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; } .recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; } .keyrow { align-items: center; justify-content: flex-start; gap: 26px; height: 36px; padding: 0 32px; } .keyrow .pair { flex-direction: row; gap: 7px; } .keyrow .pair svg { width: 14px; height: 14px; } .keyrow .pair span { letter-spacing: .1em; } .recent-card, .recent-cover { width: var(--cover-w); } .recent-cover { height: calc(var(--cover-w) * 4 / 3); } .recent-title { font-size: 17px; } .card { padding: 18px 32px; } .row { flex-wrap: nowrap; align-items: center; } .cover .monogram { font-size: 28px; } .title { font-size: 22px; } .actions { flex: none; gap: 4px; border-top: none; } .actions > * { flex: none; width: 44px; height: 44px; border: 1px solid var(--rule); } /* Cells are already gapped here, so the clusters separate by space rather than by the hairline the phone layout uses. */ .actions > .play + *, .actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; } .actions .remove { box-shadow: none; border-left-color: var(--danger-line); } .is-new .actions .play { border-color: var(--play-hot-line); } .actions .on { border-color: var(--fav-line); } /* The cell border follows the icon on hover, so the accent reads as a state rather than a stray colour. */ .actions .fav:hover, .actions .pencil:hover, .actions .box:hover { border-color: currentColor; } .chapter-form, .confirm-row, .error-inline { margin-left: calc(var(--cover-w) + var(--row-gap)); } /* Not the panel indent: the notice is chrome, so it keeps the sheet gutter every other full-width row uses. */ .notice { padding-left: 32px; padding-right: 32px; } } /* Every other control on this sheet is already 44 or borrows a .ghost::after to get there; the library switch is the one sized by its own padding in both layouts, and padding plus an 11px line lands at 43. min-height states the target instead of arithmetic on the font. Gated on the pointer rather than the width — a touch laptop lands on the desktop layout with the same thumb. */ @media (pointer: coarse) { .libswitch a { min-height: 44px; display: grid; place-items: center; } } @media (prefers-reduced-motion: reduce) { /* Pseudo-elements need naming explicitly — `*` does not match them, and the busy bar and error dot are both ::before. Their static form still reads: the bar stays drawn and .actions stays dimmed. */ *, *::before, *::after { animation: none !important; transition: none !important; } /* The busy word is revealed by a zero-duration delayed animation, which the rule above cancels — show it from the start instead. */ .card.htmx-request::after { opacity: 1; } }