Files
reader/web/src/styles/reader.css
T

1890 lines
48 KiB
CSS

/*
* 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;
}