/* * Reader stylesheet. * * This file is the single most product-defining piece of CSS in the project, so * its rules are stated explicitly rather than arrived at by accident. * * THE STRATEGY: preserve the author's design, override almost nothing. * * The reason this product exists (product design §9) is that every other * self-hosted library renders books at "web reader" quality, and the reason the * obvious solution is wrong (§5, decision 2) is that Readium CSS takes over the * book's typography to make every book look the same. That is a reasonable * trade for a generic reader and the exact opposite of this product's promise to * faithfully reproduce a carefully typeset book. * * So the rules here follow three principles: * * 1. No property is set unless something actually breaks without it. * 2. Every adjustable property is an inheriting custom property whose default * is the author's value, so "off" means "untouched", not "our default". * 3. Anything that changes the author's intent is a user opt-in that persists, * never a default. * * The comments call out each rule that *does* override, and why it has to. */ /* ------------------------------------------------------------------ * * Host shell: the viewer, not the book. Safe to style freely. * ------------------------------------------------------------------ */ :root { --reader-ink: #1a1a1a; --reader-paper: #fbfaf7; --reader-surface: #ffffff; --reader-muted: #6b6b6b; --reader-line: #e3e0da; --reader-accent: #7a5c3e; --reader-radius: 10px; /* * The insets every full-bleed band respects. * * Defined here, in one place, and *not* read straight from `env()` at each use * site. Two reasons, and the second one is the whole point of the indirection: * one name per edge means the phone, the browser and the Android shell cannot * each end up with a slightly different idea of where the screen starts; and * an undefined custom property makes the whole `calc()` invalid, which drops * the declaration silently. A `padding: calc(0.9rem + var(--safe-left))` on an * element whose variable is unset is not "0.9rem of padding" — it is *no * padding at all*, and the fingerprint was a header with zero left inset while * the rule that set it looked correct. */ --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); --safe-left: env(safe-area-inset-left, 0px); --safe-right: env(safe-area-inset-right, 0px); /* * The three user controls, in their default = do-not-touch position. * * `inherit` rather than a concrete value is deliberate for line-height and * font-size: the author set them on `body` or on a specific paragraph, and an * explicit value here would flatten a book that varies them by section. When * the reader picks a size in the UI, JS writes a concrete value. */ --reader-font-scale: 1; --reader-line-height: inherit; --reader-font-family: inherit; --reader-text-align: inherit; --reader-page-margin: 1.5rem; --reader-brightness: 1; --reader-measure: 42rem; --reader-page-padding-inline: 1.5rem; --reader-page-padding-block: 1.25rem; } [data-theme='sepia'] { --reader-ink: #3b3327; --reader-paper: #f5ecd9; --reader-surface: #f9f3e4; --reader-line: #e0d3ba; } [data-theme='dark'] { --reader-ink: #d8d4cc; --reader-paper: #16171a; --reader-surface: #1e2024; --reader-muted: #9a958c; --reader-line: #33363b; --reader-accent: #b39272; } *, *::before, *::after { box-sizing: border-box; } html, body { margin: 0; padding: 0; height: 100%; } body { background: var(--reader-paper); color: var(--reader-ink); font-family: system-ui, -apple-system, 'Segoe UI', 'Noto Sans SC', sans-serif; -webkit-text-size-adjust: 100%; overscroll-behavior: none; } #app { display: flex; flex-direction: column; height: 100%; /* * The reader is an app, not a document: a rubber-band overscroll on Android * shows a white flash behind the paper colour, and a pull-to-refresh gesture * at the top of a chapter reloads the whole SPA. */ overflow: hidden; } /* ------------------------------------------------------------------ * * The book stage * ------------------------------------------------------------------ */ .stage { position: relative; flex: 1 1 auto; min-height: 0; background: var(--reader-paper); overflow: hidden; } /* * The stage's slot in the component tree. * * A wrapper rather than the stage itself, because the stage is owned by the * screen and handed in through a ref callback (see `StageHost`): it is a node * with its own lifetime, and putting it in a tree that re-renders is how a * chapter's shadow root ends up detached from the element the paginator * measured. The wrapper does not inherit `.stage`'s rules, so it has to carry * the one that matters — *growing*. Without this the slot is a zero-height block * in a flex column, and the reader sees the chrome, the header and the footer * with no text between them. */ .stage-host { display: flex; flex: 1 1 auto; min-height: 0; position: relative; } /* * The stage fills its slot. * * `.stage` is `position: relative` with a percentage-free height, which is all it * needs once its parent is a flex container — but the parent is `.stage-host` * above, added when the chrome became a component tree, and a *block* parent * gives an auto-height child its content height instead of the slot's. The stage * then measures zero and the book is laid out in a band at the top of the * screen. `flex: 1` here is what restores "the stage is the space between the * chrome", which is the one thing this layout is supposed to guarantee. */ .stage-host > .stage { flex: 1 1 auto; min-height: 0; } /* * The chapter document is rendered into a shadow root so a book's own * stylesheet cannot reach the app chrome, and the app chrome cannot reach into * the book. The two only meet through the custom properties above. */ .book-host { position: absolute; inset: 0; overflow: hidden; } .book-host[data-layout='reflowable'] { overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; } /* * Fixed-layout pages are advanced by the pager, not scrolled, so the host must * not be a scroll container: a nested scroll area on top of a horizontal pager * produces the classic "the page drifts sideways" bug on Android. */ .book-host[data-layout='fixed'] { overflow: hidden; display: grid; place-items: center; } /* * The reading column's own rules are in `ui/shadow.ts`, not here — and that * separation is load-bearing. * * `.book-flow`, `.fixed-page`, `.reader-speech-highlight` and the page-turn * animations all belong to elements inside the book's *shadow root*, and a * selector in a document stylesheet does not reach into a shadow root. Declaring * them here is therefore indistinguishable from deleting them: the column * rendered at the browser default — no measure, no margins, no font scale, and * no columns in paged mode — while the surrounding chrome, which *is* in the * document, looked perfectly styled. That asymmetry is what made it read as a * missing feature rather than a missing stylesheet. * * What stays below is everything that is genuinely in the document: the * brightness overlay (a pseudo-element on the *host*, which is light DOM), the * host element's own box, and the chrome. */ /* * Brightness. * * A dimming bed-time overlay rather than a change to the palette: the reader's * theme is the author's colours, and darkening them keeps the relationship * between paper and ink. An overlay *on top* would darken the text as much as * the paper, which is what makes a "brightness" slider feel broken. Applied with * `mix-blend-mode: multiply` on a pseudo-element so it costs no extra DOM and * cannot be reached by the book. */ .book-host[data-layout='reflowable']::after { content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 5; background: #000; opacity: calc(1 - var(--reader-brightness)); mix-blend-mode: multiply; } /* * The host is the scroll container in scroll mode and a plain frame in paged * mode; `book-content` is an unknown element to the browser, so its `display` * has to be stated. */ book-content { display: block; } /* ------------------------------------------------------------------ * * Chrome: header, shelf, panels * ------------------------------------------------------------------ */ .topbar { display: flex; align-items: center; gap: 0.5rem; min-height: 3rem; padding: 0 0.75rem; background: var(--reader-surface); border-bottom: 1px solid var(--reader-line); padding-top: env(safe-area-inset-top); } .topbar h1 { flex: 1 1 auto; margin: 0; font-size: 1.05rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .topbar .subtitle { display: block; font-size: 0.72rem; font-weight: 400; color: var(--reader-muted); overflow: hidden; text-overflow: ellipsis; } .icon-button { appearance: none; border: 0; background: transparent; color: var(--reader-ink); width: 2.5rem; height: 2.5rem; border-radius: var(--reader-radius); font-size: 1.1rem; display: grid; place-items: center; cursor: pointer; touch-action: manipulation; } .icon-button:active { background: color-mix(in srgb, var(--reader-ink) 10%, transparent); } .button { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-surface); color: var(--reader-ink); border-radius: var(--reader-radius); padding: 0.6rem 1rem; font-size: 0.95rem; cursor: pointer; touch-action: manipulation; } .button.primary { background: var(--reader-accent); border-color: var(--reader-accent); color: #fff; } .button:disabled { opacity: 0.55; cursor: default; } .field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.9rem; } .field label { font-size: 0.82rem; color: var(--reader-muted); } .field input, .field select, .field textarea { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-surface); color: var(--reader-ink); border-radius: var(--reader-radius); padding: 0.6rem 0.7rem; /* 16px minimum: anything smaller makes iOS Safari zoom the whole page on focus, which then leaves the layout scrolled and half off-screen. */ font-size: 16px; font-family: inherit; width: 100%; } /* ---- shelf: the home screen ---- */ /* * The shelf is the most-opened screen in the product, and it is also the only one * a reader sees before they have decided whether to trust the app. So its layout * is stated rather than arrived at, and the three decisions below are the ones * the previous version got wrong. * * 1. **It reads top-down like a page, not like a toolbar.** The old version led * with a search box, then counted books, then offered four sort chips — three * rows of controls before a single cover. A library's first job is to show the * library, so the title and the covers come first and the controls sit *with* * the section they act on. * 2. **Nothing is centred.** `.centered-form` and `.empty-state` were `text-align: * center` with `padding: 1.5rem`, which is right for an empty state and wrong * for a shelf of two thousand books. The grid is left-aligned and its own edge * is the alignment. * 3. **The header is not sticky, the toolbar is.** A sticky band carrying the * title costs 4rem of a phone's height permanently to say something the reader * already knows. A sticky band carrying the count and the sort control earns * it: those are the two things a reader scrolls back up to change. */ .shelf { overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0 0 var(--safe-bottom); /* * On a wide window the shelf is centred and capped rather than stretched. * * `auto-fill` on a 1600px monitor otherwise produces eleven covers a row, each * with a twelve-character title — a wall of thumbnails that is *harder* to scan * than a phone's three, which is the opposite of what the extra width is for. A * cap also means the search field and the sort chips stop being a metre wide, * so the eye does not have to travel the full window to use them. * * `--shelf-measure` is the maximum content width, not the viewport's: the cover * size below is scaled against it, so widening the window past the cap adds * margin instead of more columns. */ --shelf-measure: 68rem; /* * One place sets the gutter. Every band inside the scroller — the header, the * search box, the toolbar, the sections — reuses it, so they cannot drift apart * and a cover can never sit nearer the screen edge than the title above it. */ --shelf-gutter: 0.9rem; --shelf-gutter-end: calc(0.9rem + var(--safe-right)); } /* ---- header ---- */ .shelf-head { display: flex; align-items: flex-start; gap: 0.75rem; padding: calc(0.9rem + var(--safe-top)) calc(0.9rem + var(--safe-left)) 0.5rem var(--shelf-gutter-end); max-width: var(--shelf-measure); margin-inline: auto; } .shelf-head-text { flex: 1 1 auto; min-width: 0; } .shelf-title { margin: 0; font-size: 1.55rem; font-weight: 650; letter-spacing: -0.015em; line-height: 1.2; } .shelf-subtitle { margin: 0.2rem 0 0; font-size: 0.78rem; } .shelf-head-actions { display: flex; align-items: center; gap: 0.15rem; flex: 0 0 auto; /* Nudged back up so the 40px target of the first button aligns with the cap height of the title rather than with its baseline. */ margin-top: -0.4rem; } /* ---- search ---- */ /* * The field is a pill with the glyph inside it, and it is exactly as wide as the * content column. `font-size: 16px` is not a taste decision — anything smaller * makes iOS Safari zoom the viewport on focus and leave the layout half * off-screen, which on a *search field* means the shelf jumps every time it is * used. */ .shelf-search { position: relative; display: flex; align-items: center; margin: 0.35rem auto 0.9rem; max-width: calc(var(--shelf-measure) - 1.8rem - var(--safe-left) - var(--safe-right)); padding-inline: 0; } .search-glyph { position: absolute; inset-inline-start: 0.7rem; font-size: 1.05rem; line-height: 1; color: var(--reader-muted); pointer-events: none; } .shelf-search input { appearance: none; flex: 1 1 auto; min-width: 0; width: 100%; border: 1px solid var(--reader-line); background: var(--reader-surface); color: var(--reader-ink); border-radius: 999px; /* Room for the glyph on the left and the clear button on the right. */ padding: 0.62rem 2.4rem 0.62rem 2.15rem; font-size: 16px; font-family: inherit; } .shelf-search input::placeholder { color: color-mix(in srgb, var(--reader-muted) 85%, transparent); } .shelf-search input:focus-visible { outline: 2px solid var(--reader-accent); outline-offset: 1px; } /* * The clear button sits *inside* the field rather than beside it. * * A phone's `type=search` renders its own clear affordance in some browsers and * none in others, and the one it renders is 12px of low-contrast glyph at the * edge of a control the reader has to hit with a thumb. Owning the button is the * only way to get the same thing in both cases. */ .search-clear { position: absolute; inset-inline-end: 0.3rem; top: 50%; transform: translateY(-50%); width: 1.9rem; height: 1.9rem; border: 0; border-radius: 50%; background: color-mix(in srgb, var(--reader-ink) 12%, transparent); color: var(--reader-ink); font-size: 0.8rem; line-height: 1; display: grid; place-items: center; cursor: pointer; } /* ---- sections ---- */ .shelf-section { padding-inline: calc(0.9rem + var(--safe-left)) calc(0.9rem + var(--safe-right)); margin-bottom: 1.35rem; /* * The measure cap, applied to the *content* bands rather than to the scroller. * * `max-width` + `margin-inline: auto` on the scroller would break `position: * sticky` on the toolbar (a scroll container's own box is what sticky is * measured against) and would make the scrollbar sit inside the centred column * instead of at the window edge. Capping each band keeps both behaviours and * still reads as one centred column, because every band shares the same cap. */ max-width: var(--shelf-measure); margin-inline: auto; } .shelf-section-head { display: flex; align-items: baseline; gap: 0.5rem; } .shelf-section-title { margin: 0.85rem 0 0.55rem; font-size: 0.95rem; font-weight: 620; } /* ---- toolbar: the count and the sort, pinned to their section ---- */ /* * Sticky, and deliberately *only* this band. It carries the two controls a reader * scrolls back up to change — how many books there are, and in what order — and it * is the reason the header above it does not need to stick. */ .shelf-toolbar { position: sticky; inset-block-start: 0; z-index: 4; display: flex; align-items: center; gap: 0.5rem; /* * The band is full-bleed *within its section*, and the section is capped, so * the sticky background matches the column rather than the window. Negative * inline margins undo the section's padding so covers scroll under the blur * instead of appearing beside it. */ /* * Full-bleed with a gutter-sized padding rather than a margin: a sticky band * narrower than its scroller lets the covers scroll past *behind* it on either * side, which looks like a rendering bug. */ margin-inline: calc(-0.9rem - var(--safe-left)) calc(-0.9rem - var(--safe-right)); padding: 0.4rem calc(0.9rem + var(--safe-right)) 0.4rem calc(0.9rem + var(--safe-left)); background: color-mix(in srgb, var(--reader-paper) 88%, transparent); backdrop-filter: blur(10px); } .shelf-count { font-size: 0.75rem; white-space: nowrap; flex: 0 0 auto; } .shelf-sort { display: flex; align-items: center; gap: 0.3rem; overflow-x: auto; scrollbar-width: none; flex: 1 1 auto; min-width: 0; /* The chips scroll from the right edge, so the row reads as "more this way". */ justify-content: flex-end; } .shelf-sort::-webkit-scrollbar { display: none; } .shelf-refresh { font-size: 0.78rem; color: var(--reader-muted); text-align: center; padding: 0.35rem 0; } .shelf-refresh[hidden] { display: none; } .shelf-status { font-size: 0.78rem; text-align: center; padding: 0.75rem 0.9rem calc(0.75rem + var(--safe-bottom)); } /* ---- the grid ---- */ /* * Fixed minimum with `auto-fill`: the number of columns follows the container, so * the same markup works on a phone, a tablet and a desktop without a media query * per breakpoint. Density is a *custom property*, not three * `grid-template-columns` rules on three classes — one declaration, one value, and * the reader's choice cannot disagree with the layout. */ .book-grid { --shelf-cover-min: 7rem; display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--shelf-cover-min), 1fr)); /* * The row gap is larger than the column gap on purpose: a cover and its two * lines of text form one block, and equal gaps make the author name look like * it belongs to the book below it. */ gap: 1.1rem 0.75rem; align-items: start; } .book-grid[data-density='compact'] { --shelf-cover-min: 5rem; gap: 0.9rem 0.6rem; } .book-grid[data-density='comfortable'] { --shelf-cover-min: 9.5rem; gap: 1.35rem 1rem; } /* * Two columns at the widest, on a desktop. * * Left alone, `auto-fill` on a 1600px window produces eleven covers a row, each * 130px wide and each with a 12-character title — a wall of thumbnails that is * harder to scan than a phone's three. Capped, the grid stays a *browsing* surface * and the extra width goes to the covers, which is what makes the titles readable * again. * * The number is a `min()` of a count and a width rather than a media query, * because the thing that makes eleven columns wrong is not "it is a phone" — it is * "the covers got too small", and the two are not the same question. */ /* * Wider screens get *bigger* covers, not more of them. * * The failure this avoids is the one every grid has: `auto-fill` turns a wide * window into a contact sheet, and a 130px cover with a two-line title is less * scannable than a 200px one with the same two lines. The breakpoint moves the * size, and the measure cap above bounds the count — the two together are what * make a desktop shelf look like a shelf. */ @media (min-width: 40rem) { .book-grid[data-density='compact'] { --shelf-cover-min: 7rem; } .book-grid:not([data-density='compact']):not([data-density='comfortable']) { --shelf-cover-min: 9.5rem; } .book-grid[data-density='comfortable'] { --shelf-cover-min: 12rem; } } .book-card { border: 0; padding: 0; background: transparent; color: inherit; text-align: start; cursor: pointer; display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; width: 100%; } /* * The cover. * * `aspect-ratio` rather than a fixed height, so the cover scales with the column. * No border: a 1px hairline on a cover that already has a shadow reads as a frame * around a photo, and the whole point of the shadow is that the cover looks like * an object lying on the paper. */ /* * The cover. * * `display: grid` with `place-items: stretch` and *not* `center`, which is a * distinction worth stating because getting it wrong is invisible in the * stylesheet and obvious on screen: a centred grid item makes the implicit column * shrink to its content, so a cover containing only a title collapsed to 45px * inside a 122px card while a cover containing an image filled it. The card was * the right size and the cover was not, so every row looked ragged for no * reason. Stretching the item and centring the *text* inside the placeholder is * what makes both cases fill the card. */ .book-card .cover { position: relative; /* Explicit, because inside a flex column an `aspect-ratio` box with no width shrink-wraps to its content: a cover holding only a title collapsed to 42px in a 122px card, while one holding an image filled it, so every row of an all-TXT shelf came out ragged. */ width: 100%; aspect-ratio: 2 / 3; border-radius: 8px; overflow: hidden; background: color-mix(in srgb, var(--reader-line) 70%, var(--reader-surface)); box-shadow: 0 1px 2px rgb(0 0 0 / 0.14), 0 3px 10px rgb(0 0 0 / 0.06); display: grid; place-items: stretch; transition: transform 120ms ease, box-shadow 120ms ease; } .book-card:active .cover { transform: scale(0.975); box-shadow: 0 1px 2px rgb(0 0 0 / 0.16); } .book-card .cover img { width: 100%; height: 100%; object-fit: cover; display: block; } /* * A book with no cover art — every TXT file, plenty of PDFs. * * The title is drawn *as* the cover rather than as a broken-image icon, because * "this book has no cover" and "this library is broken" look identical otherwise, * and the first one is a fact about the book. A tinted panel with the title set in * it also makes the shelf scannable at a glance, which a grey box does not. */ .book-card .cover .placeholder { display: flex; align-items: center; justify-content: center; padding: 0.7rem 0.55rem; font-size: 0.78rem; font-weight: 500; line-height: 1.35; text-align: center; color: color-mix(in srgb, var(--reader-ink) 72%, transparent); background: linear-gradient( 150deg, color-mix(in srgb, var(--reader-accent) 16%, var(--reader-surface)) 0%, color-mix(in srgb, var(--reader-accent) 6%, var(--reader-line)) 100% ); /* Clipped rather than scrolled: a title set to fourteen lines stops being a cover and starts being a paragraph in the wrong place. */ overflow: hidden; word-break: break-word; } .book-card .format-badge { position: absolute; inset-block-end: 0.3rem; inset-inline-end: 0.3rem; background: rgb(0 0 0 / 0.58); color: #fff; border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.6rem; letter-spacing: 0.03em; text-transform: uppercase; backdrop-filter: blur(4px); } .book-card .title { font-size: 0.8rem; line-height: 1.3; font-weight: 520; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; /* Two lines are always occupied, so the author names of a row of covers line up even when one title is short — a ragged baseline under a grid is the difference between "a shelf" and "a list". */ min-height: 2.6em; } .book-card .author { font-size: 0.7rem; color: var(--reader-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .book-grid[data-show-author='false'] .book-card .author { display: none; } .book-grid[data-show-progress='false'] .cover-progress { display: none; } /* * Progress on the cover. * * Absolute rather than a row under the title: the point of the bar is to be * readable while skimming a shelf, and a 3px line under 11px of author text is not * glancing distance from a cover. It sits at the bottom edge of the cover art, * where the eye already is. */ .book-card .cover-progress { position: absolute; inset-inline: 0; inset-block-end: 0; height: 4px; background: rgb(0 0 0 / 0.35); } .book-card .cover-progress span { display: block; height: 100%; background: var(--reader-accent); } /* ---- continue reading ---- */ .continue-row { display: flex; gap: 0.65rem; overflow-x: auto; /* Full-bleed, with padding rather than a margin: the first card should start at the gutter, and the last should be able to scroll *to* the gutter. */ padding: 0.1rem calc(0.9rem + var(--safe-right)) 0.4rem calc(0.9rem + var(--safe-left)); margin-inline: calc(-0.9rem - var(--safe-left)) calc(-0.9rem - var(--safe-right)); scrollbar-width: none; scroll-snap-type: x proximity; } .continue-row::-webkit-scrollbar { display: none; } .shelf-section-head + .continue-row { margin-top: 0; } .continue-card { flex: 0 0 6.5rem; width: 6.5rem; border: 0; padding: 0; background: transparent; color: inherit; text-align: start; cursor: pointer; scroll-snap-align: start; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; } .continue-card .cover { position: relative; width: 100%; aspect-ratio: 2 / 3; border-radius: 7px; overflow: hidden; background: color-mix(in srgb, var(--reader-line) 70%, var(--reader-surface)); box-shadow: 0 1px 2px rgb(0 0 0 / 0.14), 0 3px 10px rgb(0 0 0 / 0.06); display: grid; place-items: stretch; } .continue-card .cover img { width: 100%; height: 100%; object-fit: cover; display: block; } .continue-card .placeholder { display: flex; align-items: center; justify-content: center; font-size: 0.7rem; text-align: center; padding: 0.5rem 0.4rem; line-height: 1.3; word-break: break-word; color: color-mix(in srgb, var(--reader-ink) 72%, transparent); background: linear-gradient( 150deg, color-mix(in srgb, var(--reader-accent) 16%, var(--reader-surface)) 0%, color-mix(in srgb, var(--reader-accent) 6%, var(--reader-line)) 100% ); overflow: hidden; } /* * The progress sits *on* the cover, at its bottom edge. * * Two reasons it is not a line under the title. It has to be readable at a * glance while skimming a horizontal row, and a 4px line beneath the chapter * name is not glancing distance from the cover — the eye is on the artwork. And * a tinted track over the art reads as "this cover is part done" even when the * bar underneath is a thumbnail's worth of pixels, because the *darkening* is * what registers. The fill is the accent, so the two rows of the shelf use the * same colour for the same meaning. */ .continue-card .progress-track { position: absolute; inset-inline: 0; inset-block-end: 0; height: 5px; background: rgb(0 0 0 / 0.45); } .continue-card .progress-track span { display: block; height: 100%; background: var(--reader-accent); box-shadow: 0 0 6px color-mix(in srgb, var(--reader-accent) 70%, transparent); } .continue-card .title { font-size: 0.75rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 2.6em; } .continue-card .muted { font-size: 0.68rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* ---- the first paint ---- */ /* * The shelf's shape, before the shelf exists. * * Drawn in the reader's own density so the fill-in is a fill-in: a skeleton in the * wrong grid makes the reader watch the layout move twice and stops trusting the * first one. The shimmer is animation only — under `prefers-reduced-motion` it * becomes a flat tint, which still says "loading" without moving. */ .book-grid.is-skeleton { pointer-events: none; } .book-grid.is-skeleton .cover { box-shadow: none; } .skeleton-line { height: 0.62rem; border-radius: 3px; background: color-mix(in srgb, var(--reader-line) 80%, var(--reader-surface)); min-height: 0; } .skeleton-line.short { width: 55%; } .book-grid.is-skeleton .cover, .skeleton-line { background-image: linear-gradient( 100deg, transparent 20%, color-mix(in srgb, var(--reader-ink) 5%, transparent) 50%, transparent 80% ); background-size: 220% 100%; animation: shelf-shimmer 1.5s linear infinite; } @keyframes shelf-shimmer { from { background-position: 140% 0; } to { background-position: -40% 0; } } /* ---- empty state ---- */ /* * `margin: auto` inside the section, so the box is centred in whatever height is * left rather than pinned under the toolbar — which is where the old version put * it, half a screen above the fold on a phone. */ .empty-state { margin: 2.5rem auto auto; max-width: 20rem; text-align: center; color: var(--reader-ink); display: flex; flex-direction: column; align-items: center; gap: 0.35rem; } .empty-state p { margin: 0; font-size: 0.95rem; font-weight: 550; } .empty-state p.muted { font-size: 0.78rem; font-weight: 400; line-height: 1.5; } .empty-glyph { font-size: 2.4rem; line-height: 1; opacity: 0.35; margin-bottom: 0.35rem; } .empty-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; margin-top: 0.85rem; } /* ---- shelf settings sheet ---- */ .shelf-settings .switch-field { flex-direction: row; align-items: center; gap: 0.75rem; } .shelf-settings .switch-text { flex: 1 1 auto; min-width: 0; font-size: 0.9rem; } .shelf-settings .switch-text .muted { font-size: 0.72rem; margin-top: 0.15rem; } /* * The switch. * * A 2.6rem pill with the knob drawn by `::after`. `aria-pressed` drives both the * colour and the knob position, so there is exactly one source of truth for "is * this on" — a separate class would be a second one, and the two would eventually * disagree. */ .shelf-settings .switch { flex: 0 0 auto; position: relative; width: 2.7rem; height: 1.6rem; border-radius: 1rem; border: 0; background: var(--reader-line); cursor: pointer; transition: background 0.15s ease; } .shelf-settings .switch::after { content: ''; position: absolute; top: 0.2rem; inset-inline-start: 0.2rem; width: 1.2rem; height: 1.2rem; border-radius: 50%; background: var(--reader-surface); box-shadow: 0 1px 2px rgb(0 0 0 / 0.25); transition: inset-inline-start 0.15s ease; } .shelf-settings .switch[aria-pressed='true'] { background: var(--reader-accent); } .shelf-settings .switch[aria-pressed='true']::after { inset-inline-start: 1.3rem; } @media (prefers-reduced-motion: reduce) { .shelf-settings .switch, .shelf-settings .switch::after { transition: none; } .book-card .cover, .book-grid.is-skeleton .cover, .book-grid.is-skeleton .skeleton-line { animation: none; transition: none; } } /* ---- panels ---- */ .panel { position: absolute; inset: 0; background: var(--reader-surface); z-index: 20; display: flex; flex-direction: column; min-height: 0; } .panel[hidden] { display: none; } .panel-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; padding-top: calc(0.5rem + env(safe-area-inset-top)); border-bottom: 1px solid var(--reader-line); } .panel-header h2 { flex: 1 1 auto; margin: 0; font-size: 1rem; } .panel-body { flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0.75rem; padding-bottom: calc(0.75rem + env(safe-area-inset-bottom)); } .toc-list { list-style: none; margin: 0; padding: 0; } .toc-list li button { width: 100%; text-align: start; border: 0; background: transparent; color: var(--reader-ink); padding: 0.6rem 0.4rem; border-bottom: 1px solid var(--reader-line); font-size: 0.9rem; cursor: pointer; } .toc-list li button[aria-current='true'] { color: var(--reader-accent); font-weight: 600; } /* ---- footer / progress ---- */ .footer { background: var(--reader-surface); border-top: 1px solid var(--reader-line); padding-bottom: env(safe-area-inset-bottom); } .progress-bar { height: 2px; background: var(--reader-line); } .progress-bar span { display: block; height: 100%; background: var(--reader-accent); transition: width 160ms ease-out; } .footer-row { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; color: var(--reader-muted); } .footer-row .chapter { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; /* The chapter label is the second way into the contents panel, because the ☰ button beside it is a 2.5rem square and the label is the thing a reader actually aims at when they want to pick a chapter. */ border: 0; background: transparent; color: inherit; font: inherit; padding: 0.35rem 0.25rem; border-radius: var(--reader-radius); cursor: pointer; } .footer-row .chapter:active { background: var(--reader-line); } /* * The chapter buttons share the row with the chapter label, so they are a size * down from the topbar's icon buttons: four 2.5rem controls plus a label is more * than a 360px phone has, and the two that turn pages deserve the room. */ .footer-row .icon-button { width: 2.1rem; height: 2.1rem; font-size: 1rem; } .footer-row .icon-button:disabled { opacity: 0.35; cursor: default; } .footer-meta span { flex: 1 1 0; } .footer-meta span:first-child { text-align: start; } .footer-meta .progress-label { text-align: end; } /* ---- read-aloud bar ---- */ /* * The bar sits above the footer and, unlike the footer, is *not* hidden with the * chrome: a reader listening while walking needs the play/pause without the * header covering the sentence being read. */ .tts-bar { display: flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.5rem; padding-bottom: calc(0.35rem + env(safe-area-inset-bottom)); background: var(--reader-surface); border-top: 1px solid var(--reader-line); } .tts-bar[hidden] { display: none; } .tts-bar .tts-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; } .tts-bar .tts-text { font-size: 0.78rem; color: var(--reader-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tts-range { width: 100%; margin: 0; accent-color: var(--reader-accent); } .chip { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-surface); color: var(--reader-ink); border-radius: 999px; padding: 0.25rem 0.6rem; font-size: 0.72rem; white-space: nowrap; cursor: pointer; } /* ---- settings panel structure ---- */ .section-title { font-size: 0.8rem; font-weight: 600; color: var(--reader-muted); margin: 1.1rem 0 0.5rem; } .section-title:first-child { margin-top: 0; } .field[hidden] { display: none; } /* ---- status ---- */ .status-bar { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.75rem; font-size: 0.72rem; background: color-mix(in srgb, var(--reader-accent) 12%, var(--reader-surface)); color: var(--reader-ink); } .status-bar[hidden] { display: none; } .status-dot { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--reader-muted); flex: 0 0 auto; } .status-bar[data-state='syncing'] .status-dot { background: #4a8f5f; } .status-bar[data-state='offline'] .status-dot { background: #c08a2e; } .status-bar[data-state='error'] .status-dot { background: #b6453a; } /* * The snackbar. * * On `document.body`, not inside `#app`: it outlives the screen change it is * usually reporting, and a message mounted inside the screen it is about would * leave the DOM exactly when the reader starts reading it. It is `fixed` to the * bottom, above the safe area, and takes no pointer events, so it can never cover * the control the reader is reaching for. */ .app-toast { position: fixed; inset-inline: 0; inset-block-end: calc(1rem + env(safe-area-inset-bottom)); margin-inline: auto; width: fit-content; max-width: min(24rem, calc(100vw - 2rem)); padding: 0.6rem 1rem; border-radius: 999px; background: color-mix(in srgb, var(--reader-ink) 92%, transparent); color: var(--reader-paper); font-size: 0.82rem; line-height: 1.4; text-align: center; z-index: 60; pointer-events: none; opacity: 0; transform: translateY(0.75rem); transition: opacity 0.2s ease, transform 0.2s ease; } .app-toast[data-shown='true'] { opacity: 1; transform: none; } @media (prefers-reduced-motion: reduce) { .app-toast { transition: none; transform: none; } } /* ---- misc ---- */ .empty-state, .centered-form { margin: auto; max-width: 24rem; width: 100%; padding: 1.5rem; text-align: center; } .centered-form { text-align: start; } .notice { background: color-mix(in srgb, var(--reader-accent) 12%, var(--reader-surface)); border-radius: var(--reader-radius); padding: 0.6rem 0.75rem; font-size: 0.82rem; margin-bottom: 0.9rem; } .error-text { color: #b6453a; font-size: 0.85rem; margin-top: 0.5rem; } .muted { color: var(--reader-muted); } .spinner { width: 1.5rem; height: 1.5rem; border: 2px solid var(--reader-line); border-top-color: var(--reader-accent); border-radius: 50%; animation: spin 700ms linear infinite; margin: 1rem auto; } @keyframes spin { to { transform: rotate(360deg); } } .segmented { display: flex; border: 1px solid var(--reader-line); border-radius: var(--reader-radius); overflow: hidden; } .segmented button { flex: 1 1 0; border: 0; background: var(--reader-surface); color: var(--reader-ink); padding: 0.5rem; font-size: 0.85rem; cursor: pointer; } .segmented button[aria-pressed='true'] { background: var(--reader-accent); color: #fff; } /* ------------------------------------------------------------------ * * 书库管理 — the library as a file manager * * The one screen that shows the mount rather than the catalog, so its job is to * stay out of the way: a path you can read at the top, rows that navigate, and * the destructive actions behind an explicit selection. * ------------------------------------------------------------------ */ /* * The screen fills the app and scrolls internally, like every other screen. * `#app` is `overflow: hidden`, so a screen that does not claim `min-height: 0` * on the flex chain pushes its own content off the bottom instead of scrolling. * `position: relative` is what the dialogs and the selection pill are placed * against. */ .manager-screen { position: relative; display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } /* * The host of the screen's component tree. * * The screens are component trees mounted into a container, and the container * must not become a layout box of its own: the styles below are written against * `.manager-screen > .manager-body`, and a wrapper in between would break the * `min-height: 0` chain that lets the list scroll instead of pushing the action * bar off the bottom of the phone. `display: contents` makes the host vanish for * layout purposes and keeps the tree's children exactly where they were. */ .manager-ui { display: contents; } .manager-crumbs { flex: 1 1 auto; display: flex; align-items: center; gap: 0.15rem; overflow-x: auto; scrollbar-width: none; min-width: 0; } .manager-crumbs::-webkit-scrollbar { display: none; } .manager-crumb { appearance: none; border: 0; background: transparent; color: var(--reader-ink); font-size: 0.9rem; font-family: inherit; padding: 0.35rem 0.25rem; white-space: nowrap; cursor: pointer; } .manager-crumb[aria-current='true'] { font-weight: 600; } .manager-crumb-sep { color: var(--reader-muted); } .manager-body { flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch; min-height: 0; } .manager-list { padding: 0; } .manager-row { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--reader-line); cursor: pointer; touch-action: pan-y; } .manager-row:active { background: color-mix(in srgb, var(--reader-ink) 6%, transparent); } .manager-row[data-selected='true'] { background: color-mix(in srgb, var(--reader-accent) 14%, transparent); } /* * The checkbox is always laid out and only made visible in selection mode. * * Toggling `display` instead would shift every row's indent the moment a * selection begins, which reads as the list jumping under the finger that just * started the selection. */ .manager-check { width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 1.5px solid var(--reader-line); box-sizing: border-box; /* Always laid out, only visible while something is selected: toggling `display` would shift every row's indent the moment a selection begins, which reads as the list jumping under the finger that started it. */ opacity: 0; } .manager-row[data-selected] .manager-check { opacity: 1; } .manager-row[data-selected='true'] .manager-check { border-color: var(--reader-accent); background: var(--reader-accent); } .manager-name { display: flex; align-items: center; gap: 0.5rem; min-width: 0; } .manager-icon { font-size: 1rem; flex: 0 0 auto; } .manager-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9rem; } .manager-meta { display: flex; align-items: center; gap: 0.5rem; font-size: 0.72rem; white-space: nowrap; } .manager-badge { border: 1px solid var(--reader-line); border-radius: 4px; padding: 0 0.25rem; font-size: 0.65rem; color: var(--reader-muted); } /* The badge a reader needs to see is the one explaining a missing book. */ .manager-badge.warn { border-color: color-mix(in srgb, var(--reader-accent) 60%, transparent); color: var(--reader-accent); } .manager-more { width: 2rem; height: 2rem; font-size: 1.1rem; } /* * On a read-only mount the row menu offers nothing but "选择", so it is dimmed * rather than removed: the row still has to line up with the others, and a * column that appears and disappears with the deployment reads as a bug. */ .manager-screen[data-writable='false'] .manager-more { opacity: 0.35; } /* * The counts sit *inside* the scroller, not after it. * * Pinned below a scrolling list they were the first thing pushed off-screen by a * directory of forty files — which is exactly when a reader wants to know how * many there are, and exactly when "删除完成" would have gone unread. */ .manager-status { position: sticky; inset-block-end: 0; font-size: 0.75rem; text-align: center; padding: 0.5rem 0.75rem; background: color-mix(in srgb, var(--reader-surface) 92%, transparent); backdrop-filter: blur(6px); border-top: 1px solid transparent; } .manager-status:not(:empty) { border-top-color: var(--reader-line); } /* * The batch bar scrolls horizontally rather than wrapping or squeezing. * * Five actions do not fit across a phone, and neither alternative works: wrapping * makes the bar two rows tall and pushes the list up under the thumb that is * mid-scroll, and `flex: 1` per button shrinks 移动到… to an ellipsis that no * longer says what it does. A scrolling row keeps every label readable and the * destructive action — deliberately last, so it is never the thing under the * finger that just opened the bar — stays off screen until it is looked for. */ .manager-actions { display: flex; gap: 0.5rem; align-items: center; padding: 0.6rem 0.75rem; padding-bottom: calc(0.6rem + env(safe-area-inset-bottom)); border-top: 1px solid var(--reader-line); background: var(--reader-surface); overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; } .manager-actions::-webkit-scrollbar { display: none; } .manager-actions[hidden] { display: none; } .manager-actions .button { /* `flex: 0 0 auto`, so a label is never truncated — the bar scrolls instead. */ flex: 0 0 auto; white-space: nowrap; } .manager-selection { position: absolute; inset-block-start: calc(env(safe-area-inset-top) + 0.25rem); inset-inline-start: 50%; transform: translateX(-50%); background: var(--reader-ink); color: var(--reader-paper); border-radius: 999px; padding: 0.2rem 0.7rem; font-size: 0.72rem; pointer-events: none; z-index: 25; } .manager-selection[hidden] { display: none; } .button.danger { border-color: color-mix(in srgb, #c0392b 55%, transparent); color: #c0392b; } /* ---- dialogs ---- */ .dialog-overlay { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.4); display: grid; place-items: center; padding: 1rem; z-index: 30; } .dialog { background: var(--reader-surface); color: var(--reader-ink); border: 1px solid var(--reader-line); border-radius: var(--reader-radius); padding: 1rem; width: min(24rem, 100%); display: flex; flex-direction: column; gap: 0.75rem; } .dialog h3 { margin: 0; font-size: 1rem; } .dialog p { margin: 0; font-size: 0.82rem; line-height: 1.5; } .dialog input { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-paper); color: var(--reader-ink); border-radius: var(--reader-radius); padding: 0.6rem 0.7rem; /* 16px, so iOS does not zoom the page on focus and leave the dialog off-screen. */ font-size: 16px; font-family: inherit; width: 100%; } .dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } .dialog-actions .button { flex: 0 1 auto; min-width: 5rem; } .dialog-list { display: flex; flex-direction: column; gap: 0.4rem; } /* ---- batch metadata ---- */ /* * The batch dialog has seven optional fields, which do not fit on a phone. * * The fields scroll and the buttons do not. Scrolling the whole dialog instead * puts 保存 below the fold, so the reader fills the form, sees no way to commit * it, and has to work out that the sheet itself moves — and the on-screen * keyboard takes another third of the height while they are typing in it. * * `max-height` on the *fields* rather than on the dialog also keeps the title * visible, so it stays obvious what is being edited after a scroll. */ .dialog:has(.dialog-field) { max-height: min(86vh, 40rem); overflow: hidden; } .dialog:has(.dialog-field) .dialog-fields { display: flex; flex-direction: column; gap: 0.6rem; overflow-y: auto; min-height: 0; /* Room for the last field's label, so it is not flush against the buttons. */ padding-bottom: 0.2rem; } .dialog-field { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.78rem; color: var(--reader-muted); } .dialog-field input { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-paper); color: var(--reader-ink); border-radius: var(--reader-radius); padding: 0.5rem 0.6rem; /* 16px, so iOS does not zoom the page on focus and push the dialog off-screen. */ font-size: 16px; font-family: inherit; width: 100%; } /* * The file input is the trigger for the ⬆ button and is never shown. * * `display: none` rather than `position: absolute` off-screen: a control that is * laid out still takes a tab stop, and this one has no meaning of its own — the * button beside it is what the user operates. */ .manager-upload-input { display: none; }