/* 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 / 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 */ /* 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: #e0452c; /* the only heat */ --ember-wash: #1a1211; /* ember-tinted surface */ --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; --brass: #b8912f; /* favourite — a second, cooler metal */ --trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */ /* Desktop cell borders for the two coloured action states. */ --play-hot-line: #3a1d18; --fav-line: #332b14; --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; /* Cover width + row gap: the disclosure panels indent past the cover on desktop, so both live here rather than as magic numbers. */ --cover-w: 86px; --row-gap: 14px; } /* 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; --rule-soft: #e8e3dc; --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; --ember-wash: #fbeee9; --ember-ink: #fff; --ember-soft: #8d2c17; --danger: #97362a; --danger-wash: #fbe9e5; --danger-ink: #fff; --danger-soft: #7c2c22; --brass: #8a681c; --trash: #8c6558; --play-hot-line: #f0cfc6; --fav-line: #e3d3a4; --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); } } * { 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%; } 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; } /* 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; 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; 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 { 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; } .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); } /* The input drops its own outline, so the bar it sits in carries the focus ring — same move the two other inputs make with their border. */ .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); 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 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); } .tabs a.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: 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 { width: 100%; height: 100%; object-fit: cover; } .cover .monogram { font-size: 30px; } .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(--danger); } .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 .remove.open { background: var(--danger-wash); color: var(--danger); } .is-dim .actions > * { color: var(--mute-2); } /* Three clusters by consequence: navigate (play) | organize (favourite, chapter) | lifecycle (archive/restore, finish, 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 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; } /* Focus follows the .searchbar idiom — paper, not heat: a red border on a valid number field reads as "invalid". */ .chapter-form input:focus { border-color: var(--paper); } .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; 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); } .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: transparent; color: var(--mute); font: 500 12px var(--font-body); } .confirm-row button:hover { color: var(--paper); } .confirm-row .danger-solid { border: none; background: var(--danger); color: var(--danger-ink); font-weight: 600; } /* Archive and finish are reversible, so their 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 { color: var(--ink); } .error-inline { display: flex; align-items: center; gap: 9px; margin: 0; padding: 11px 13px; background: var(--ash); border-left: 2px solid var(--mute); 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(--mute); animation: mutePulse 1.4s ease-in-out infinite; } .error-inline .error-link { border-bottom: 1px solid var(--field-line); color: var(--paper); } /* 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; } /* ---- 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 .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: 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(--paper); } .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: #fff; } /* ---- desktop: same measure, actions fold up beside the row ---- */ @media (min-width: 720px) { :root { --cover-w: 74px; --row-gap: 20px; } .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 a { 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; } .cover .monogram { font-size: 26px; } .title { font-size: 21px; } .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; } .is-new .actions .play { border-color: var(--play-hot-line); } .actions .on { border-color: var(--fav-line); } /* Panels line up with the body text, i.e. past the cover and its gap. */ .chapter-form, .confirm-row, .error-inline { margin-left: calc(var(--cover-w) + var(--row-gap)); } } @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; } }