Files
sulthan 2ef769d421 Open registration to guild members (#27) (#36)
Closes #27.

Guild membership is now the whole gate. `discordCallback` checks membership
(and `DISCORD_REQUIRED_ROLE` when set), then `Store.EnsureReader` creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before `EnsureReader`, so a turned-away sign-in leaves no row
behind. `OWNER_DISCORD_ID` still seeds the owner, but only as the
administrator — it no longer gates login.

The cutover grace path goes with it: `API_TOKEN`, `API_TOKEN_GRACE_UNTIL` and
the legacy branch in `httpmw.ResolveReader` are deleted, so a credential
authenticates exactly one Reader or nothing. `userscript.Handler` drops its
re-derivation too — the resolved path segment is already the credential.

New surfaces: an empty library offers both install links (behind the
tab-specific empty states, so "No favourites yet" still wins), and the owner
alone gets a Readers panel with `POST /readers/{id}/revoke`. The owner's own
row is not revocable — 404, not a self-logout.

Isolation is asserted from both directions for read, modify and delete, and
the shared-series invariant is pinned: two Readers on one series produce one
series row, two independent progresses, one poll per due cycle, and one
Reader's delete leaves the other's bookmark and the poll intact.

Verified: `go test ./...` green; live smoke against a throwaway Postgres —
empty-library state in both colour branches, roster rendering, a real revoke
through the panel (target 401s next request, owner untouched), owner
self-revoke refused 404, per-Reader `/u/<cred>` and bearer auth both 200 with
404 for an unknown credential.

Reviewed-on: #36
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-08 20:23:17 +07:00

954 lines
28 KiB
CSS
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 */
/* 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 */
/* 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;
}
/* 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;
--slate: #3f6689;
--moss: #3d6c46;
--clay: #7c5533;
--trash: #8c6558;
--play-hot-line: #f0cfc6;
--fav-line: #e3d3a4;
--asura: #4f6b80;
--demonic: #8a6a55;
--comix: #5f7250;
--kagane: #6f5f7d;
--novelfull: #7d6f4f;
--lightnovelworld: #4f7d70;
--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;
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; }
/* ---- 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 10px/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;
}
/* ---- reader roster (owner only): same hairline panel, one row per Reader ---- */
.readerlist { margin: 0; padding: 0; list-style: none; }
.readerlist li {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 16px;
min-height: 44px;
border-top: 1px solid var(--rule);
}
.readerlist form { margin: 0 0 0 auto; }
.reader-id {
font: 500 13px/1.4 var(--font-mono);
letter-spacing: .04em;
color: var(--paper);
}
.reader-sessions {
font: 500 10px/1 var(--font-mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--mute);
}
/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for
the new-chapter signal. */
.ghost.danger { color: var(--danger); }
.ghost.danger:hover { color: var(--danger); border-bottom-color: var(--danger); }
.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;
}
/* ---- 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 10px/1 var(--font-mono);
letter-spacing: .04em;
text-transform: uppercase;
}
.keyrow .pair.brass svg { color: var(--brass); }
.keyrow .pair.trash svg { 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 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: var(--cover-w);
flex: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.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;
}
.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;
}
.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); }
/* 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; 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 { width: 100%; height: 100%; object-fit: cover; }
.cover .monogram { font-size: 32px; }
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
.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);
}
/* 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); }
/* 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; }
.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 10px/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, moss
finished, clay chapter. Remove keeps --danger, play keeps paper/ember. */
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); }
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); }
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); }
.actions .finish:hover, .actions .finish:active, .actions .finish:focus-visible { color: var(--moss); }
.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-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; }
}
/* ---- 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; }
.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 { font-size: 11px; letter-spacing: .1em; }
.keyrow .full { display: inline; }
.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; }
.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, .actions .finish:hover { border-color: currentColor; }
/* 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; }
}