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

2853 lines
85 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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.
* ------------------------------------------------------------------ */
/*
* The icon font.
*
* Declared here rather than in a separate sheet so the face and the token that
* sizes it are in one place. `format('truetype')` and a relative URL because the
* bundle is loaded from `file://` inside the Android WebView, where an absolute path
* resolves to the app's assets root rather than to the bundle.
*
* `font-display: block` is deliberate: icons are UI, not text. With `swap` a
* browser paints the fallback glyph — a literal private-use code point box — for one
* frame, and a row of tofu boxes flashing on every cold start reads as a broken app.
* The font is 16KB inside the bundle, so there is no network round trip to hide.
*/
@font-face {
font-family: 'Reader Icons';
src: url('./reader-icons.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: block;
}
:root {
--reader-ink: #1a1a1a;
--reader-paper: #fbfaf7;
--reader-surface: #ffffff;
--reader-muted: #6b6b6b;
--reader-line: #e3e0da;
--reader-accent: #7a5c3e;
--reader-radius: 10px;
/*
* The spacing scale.
*
* Four steps, not eight. A larger scale reads as precision and behaves as a
* suggestion: once `1.75rem` and `2rem` both exist, half the stylesheet uses one
* and half the other and the inconsistency is invisible in any single rule.
*
* The values are the ones the stylesheet already used, promoted to names so that
* "put this a bit further from that" becomes "which step is this" — the question
* that actually has an answer.
*/
--space-1: 0.35rem; /* inline gaps inside a control */
--space-2: 0.6rem; /* padding inside a control */
--space-3: 0.9rem; /* gutter between sections */
--space-4: 1.5rem; /* padding inside a page-level band */
/*
* The type scale. Named by role rather than by size, because the size is the part
* that changes and the role is the part that does not: `--text-meta` is "the line
* under a title" whether it is 0.72rem today or 0.75rem later.
*/
--text-title: 1.05rem;
--text-body: 0.95rem;
--text-label: 0.82rem;
--text-meta: 0.72rem;
/*
* Motion.
*
* One duration and one easing for everything, so a panel, a toast and a scrim
* move together. Two durations in a UI is the same mistake as two radii: each is
* defensible alone and the pair is visibly wrong.
*
* A `prefers-reduced-motion` block at the end of this file collapses every
* duration to a hair, including these, so honouring the preference is one rule
* rather than an audit of every transition.
*/
--motion-duration: 0.18s;
--motion-ease: cubic-bezier(0.32, 0.72, 0, 1);
/*
* The focus ring.
*
* Defined as a token because it has to be drawn in three situations — on the
* paper, on a surface, and over a book's page — and the colour that reads against
* one does not read against the others. `currentColor` does not work here: the
* ring must stay visible when the control's own colour is the background.
*/
--focus-ring: 2px solid color-mix(in srgb, var(--reader-accent) 70%, transparent);
--focus-offset: 2px;
/*
* 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
* ------------------------------------------------------------------ */
/*
* Whether the chrome is on screen.
*
* The topbar and the footer are one state, not two, and that is the whole of the
* "沉浸式" request: a reader who hides the navigation wants the page and nothing
* else, and a header that hides while the footer stays is a footer that keeps
* covering the last two lines of every page. So both are toggled by one attribute
* on the screen, both animate with the same duration and easing, and neither can be
* on screen without the other.
*
* The animation is a *slide plus a fade*, and it is applied to the chrome's own
* reserved space rather than to the chrome alone: the reader has to end up with a
* full-height page, and a header that fades out while still occupying 3rem of the
* column leaves the text in a band with a gap above it. `position: absolute` would
* give the space back instantly and cover the first line on the way out, so the
* band keeps its normal flow role and collapses its own height — which is what
* makes the page *grow* into the space as the header leaves rather than jump into
* it afterwards.
*/
.reader-screen {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
/*
* The chrome's own reserved space, in one place. The bands animate between it
* and zero, and the stage — which is `flex: 1 1 auto` — takes whatever is left,
* so a single number here is what keeps the page from being measured mid-slide.
*/
--reader-chrome-top: 3.4rem;
}
.topbar {
display: flex;
/*
* `stretch`, so each button owns the full height of the band. The buttons are
* icon-over-label now, and a button that only owned its two lines would leave a
* strip of the bar above and below that looked identical but did nothing — on
* the one row where the reader is aiming at controls rather than at text.
*/
align-items: stretch;
/*
* `space-evenly` rather than `space-between`: three controls spread to the two
* ends of a wide screen stop reading as one toolbar and start reading as two
* stray icons at opposite corners. Even gaps keep them a row at any width.
*/
justify-content: space-evenly;
gap: var(--space-1);
min-height: 3.4rem;
padding: 0 0.35rem;
background: var(--reader-surface);
/* Same hairline treatment as the footer, so the two edges of the reading area
are the same weight. */
border-bottom: 1px solid color-mix(in srgb, var(--reader-line) 80%, transparent);
padding-top: var(--safe-top);
/* The band's own height is the reserved space, and it is what the stage gets
back when the chrome hides. `min-height` above is what it is at rest; the
transition needs an explicit height to animate to zero, so it is stated. */
height: var(--reader-chrome-top);
transition:
height var(--motion-duration) var(--motion-ease),
opacity var(--motion-duration) var(--motion-ease),
transform var(--motion-duration) var(--motion-ease),
visibility 0s linear var(--motion-duration);
}
/*
* Hidden: the band collapses and the contents slide up out of it.
*
* `visibility` rather than `display: none` so the slide has something to animate,
* and `visibility: hidden` at the end of it so the (invisible) title cannot be
* reached by a screen reader or a tap. `overflow: hidden` is what makes the slide a
* slide rather than the header visibly hanging over the text.
*/
.reader-screen[data-chrome='hidden'] .topbar {
height: 0;
min-height: 0;
opacity: 0;
visibility: hidden;
overflow: hidden;
border-bottom-color: transparent;
transform: translateY(-100%);
}
/*
* One icon-over-label control in the top bar.
*
* A column, so the glyph and the word are one hit target rather than two; the word
* is drawn rather than left to a tooltip because the glyphs here are not universal
* (a list and a slider read as the same thing without it). The label is the small
* `--text-meta` size — it is a caption for the glyph, not a second title.
*/
.top-button {
flex: 1 1 0;
max-width: 6rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
padding: 0.3rem 0.25rem;
border: 0;
border-radius: var(--reader-radius);
background: transparent;
color: var(--reader-ink);
font: inherit;
font-size: 1.15rem;
cursor: pointer;
touch-action: manipulation;
transition: background var(--motion-duration) var(--motion-ease);
}
.top-button:active {
background: color-mix(in srgb, var(--reader-ink) 8%, transparent);
}
.top-button-label {
font-size: var(--text-meta);
font-weight: 400;
color: var(--reader-muted);
line-height: 1;
white-space: nowrap;
}
/*
* The reading indicator.
*
* Two quiet readouts pinned to the corners of the stage, drawn only while the
* chrome is hidden. `position: absolute` inside `.reader-screen` (which is
* `position: relative`) so they can sit over the top and bottom of the text
* without being part of the flow — they must not take height from the page, or
* showing them would re-measure the columns and move the text.
*
* `pointer-events: none` because they are readouts, not controls: a tap on the
* chapter name has to reach the tap zone underneath it and turn the page (or
* bring the chrome back), not be swallowed by a label.
*/
.reading-indicator {
position: absolute;
inset: 0;
z-index: 12;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: calc(0.5rem + var(--safe-top)) calc(var(--reader-page-padding-inline) + var(--safe-right))
calc(0.5rem + var(--safe-bottom)) calc(var(--reader-page-padding-inline) + var(--safe-left));
font-size: var(--text-meta);
color: var(--reader-muted);
/* A slow fade of its own, offset by the chrome's duration: the indicator comes
up as the bars go down, so the reader never sees an empty screen. */
animation: indicator-in var(--motion-duration) var(--motion-ease);
}
@keyframes indicator-in {
from { opacity: 0; }
to { opacity: 1; }
}
.indicator-chapter {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--text-label);
}
.indicator-progress {
font-variant-numeric: tabular-nums;
color: var(--reader-ink);
opacity: 0.72;
}
@media (prefers-reduced-motion: reduce) {
.reading-indicator { animation: none; }
}
/*
* The icon glyph.
*
* An icon is one character of a private-use font, so it is laid out as text — and the
* only thing that decides where a character lands is the font's own metrics. That is why
* this rule is three lines and not a geometry puzzle, and it is also what went wrong:
* the rule used to say `line-height: 0; display: inline-block; width: 1em; height: 1em`,
* which replaces the font's line box with a 1em box anchored at the baseline. The box
* then extends *downward* from the baseline while the glyph's ink is drawn *around* it,
* so the ink ended up at the box's top edge with the whole em empty beneath it. Every
* container in this stylesheet centres its children, the box was centred correctly, and
* the ink was half an em away from where the box said it was — measured at 0.496em
* across `menu`, `close` and `folder`, i.e. half the box, in every row, at every size.
*
* So the box is the font's line box (`line-height: 1`) and the font's line box is
* symmetric: `tools/icons/build.mjs` declares an ascent and descent of half the em each
* and draws every glyph inside it, so centring the line centres the ink. `width: 1em` is
* kept because it is the one thing the layout needs that the font cannot supply — a
* predictable advance width, so a row of icons and labels is a row and not a function of
* which glyphs happen to be in it.
*
* `vertical-align: middle` is for the rows that are *not* flex or grid — a glyph inline
* in a paragraph of text, which is where `baseline` would otherwise apply and put the
* icon's baseline on the text's, half an em low. In a flex or grid container (most of
* this app's chrome) it is inert and the container's own alignment does the work, which
* is the point: the icon needs no special case in either.
*
* `font-synthesis: none` matters for a private-use glyph: some engines will synthesise a
* bold or oblique from the regular face, and a synthesised icon is a smeared icon.
*/
.icon {
font-family: 'Reader Icons', sans-serif;
font-size: 1.15rem;
font-style: normal;
font-weight: 400;
font-synthesis: none;
line-height: 1;
display: inline-block;
width: 1em;
text-align: center;
vertical-align: middle;
speak: never;
/* The code point itself must never be selectable or copied. */
user-select: none;
-webkit-user-select: none;
}
/*
* A square control with its glyph centred in it.
*
* `inline-grid` rather than `grid`: a `grid` is block-level, so a button in a row of
* text starts a line of its own and the row it was supposed to sit in grows a
* baseline that has nothing to do with the text. `vertical-align` is what a control
* in a text row is aligned with, and it only applies to inline-level boxes.
*
* The glyph is centred on the box's *centre* — `place-content`, which is relative to
* the box — where the old rule used `place-items` on the implicit grid area. The
* area is the same box, so the pixels are the same pixels on every browser that
* exists; saying it in terms of the box is what keeps that true when the button grows
* a border or a padding, because the centre is still the centre.
*
* This is also why `.icon` must be a *line box* rather than a 1em square: a grid
* centres the box it is given, so the ink has to be in the middle of that box for the
* centring to be visible. See the derivation on `.icon`.
*/
.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: inline-grid;
place-content: center;
vertical-align: middle;
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;
}
/*
* A button whose contents are a glyph and a label.
*
* The gap is the whole reason this is a rule rather than incidental whitespace: the
* glyph is one em wide whatever the font-size, so `gap` is the only thing that can
* keep the space between icon and text equal across buttons at different sizes. The
* flex line centres both boxes on the button's centre, which is where they have to
* be, and cannot be written any other way.
*/
.icon-text-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
vertical-align: middle;
}
/* ---- interactive states ---- */
/*
* One focus rule for every control in the product.
*
* This block did not exist. The stylesheet had a `:focus-visible` rule on the
* search field and *nothing* on any button, which meant the only way to see where
* the keyboard was on a shelf full of controls was to guess. It is written as a
* single rule over a list of selectors rather than per-component so that a new
* control inherits the behaviour: adding one to this list is the whole job.
*
* `:focus-visible` rather than `:focus` on purpose — a mouse click on a button
* should not leave a ring behind, and a keyboard tab must always show one.
*/
.button:focus-visible,
.icon-button:focus-visible,
.chip:focus-visible,
.switch:focus-visible,
.segmented button:focus-visible,
.toc-list li button:focus-visible,
.manager-row:focus-visible,
.manager-crumb:focus-visible,
.search-clear:focus-visible,
.tts-bar button:focus-visible,
.dialog button:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-offset);
}
/*
* Hover, for the pointer-equipped half of the audience.
*
* Guarded by `hover: hover` because on a touch device every tap would otherwise
* leave the last-tapped control stuck in its hover appearance — the phone shows a
* control as "hovered" as soon as the finger lifts, which makes the state look
* broken rather than responsive.
*/
@media (hover: hover) and (pointer: fine) {
.icon-button:hover,
.button:hover,
.chip:hover,
.manager-row:hover,
.manager-crumb:hover,
.search-clear:hover,
.toc-list li button:hover {
background: color-mix(in srgb, var(--reader-ink) 6%, transparent);
}
.button.primary:hover {
background: color-mix(in srgb, var(--reader-accent) 88%, var(--reader-ink));
border-color: transparent;
}
.button.danger:hover {
background: color-mix(in srgb, #c0392b 10%, var(--reader-surface));
}
}
/*
* Every state transition in one place, using one duration and one easing.
*
* The individual rules further down used to carry their own `0.15s ease` /
* `160ms ease-out` / `120ms ease`, which is three answers to a question that has
* one. A control's feedback should be the same speed as every other control's
* feedback, or the app feels like it was assembled from several apps.
*/
.button,
.icon-button,
.chip,
.manager-row,
.manager-crumb,
.search-clear,
.toc-list li button,
.segmented button {
transition:
background-color var(--motion-duration) var(--motion-ease),
border-color var(--motion-duration) var(--motion-ease),
color var(--motion-duration) var(--motion-ease),
opacity var(--motion-duration) var(--motion-ease);
}
/*
* The controls inside a settings row.
*
* Sized to the row rather than to the sheet: a select that spans the whole sheet is
* a control whose value is far from its label, and a range input that does is a
* slider the reader has to hunt for the thumb of. The 16px floor is not cosmetic
* either — anything smaller makes iOS Safari zoom the page on focus, which leaves
* the layout scrolled and half off-screen with no way back except a pinch.
*/
.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.45rem 0.6rem;
font-size: 16px;
font-family: inherit;
width: 100%;
}
.field input[type='range'] {
padding: 0;
border: 0;
background: transparent;
accent-color: var(--reader-accent);
}
/* ---- 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` and a *centre*-aligned row: both the magnifier and the
* clear button are absolutely positioned against this box, and absolute
* positioning resolves against the padding box' top edge, so a row aligned any
* other way would put the glyph where the *field* is not.
*/
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;
}
/*
* The magnifier inside the search field.
*
* Centred on the field's *centre*: `inset-block: 0` with `margin-block: auto` resolves to
* the middle of the containing block, which is where the field's own text is centred too.
* The glyph is inline and the box is a line box, so "centre the box" and "centre the
* glyph" are the same instruction here.
*
* The old rule anchored only the inline axis and left the block position to the field's
* top edge, which put the glyph's box where the field's centre was and the ink half an em
* above that — the same defect as the buttons, in a different container.
*/
.search-glyph {
position: absolute;
inset-inline-start: 0.7rem;
inset-block: 0;
margin-block: auto;
font-size: 1.05rem;
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;
/*
* A field is text, so it gets the text' baseline and the icons positioned inside it
* are centred against its box instead. The two are only the same line while the
* field has symmetric padding, which is what the line above guarantees.
*/
line-height: 1.4;
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;
/*
* Centred on the field by the geometry both boxes share, not by a transform.
*
* `top: 50%` + `translateY(-50%)` is the usual way to do this, and it is a way
* that has to be repeated at every call site and can be forgotten at one of them.
* `inset-block: 0` + `margin-block: auto` says the same thing in terms of the
* containing block, so it stays true if the field's height changes.
*/
inset-block: 0;
margin-block: auto;
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.9rem;
display: grid;
place-content: 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 var(--motion-duration) var(--motion-ease),
box-shadow var(--motion-duration) var(--motion-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;
/*
* A block of its own, so the flex column's `gap` owns the space between the glyph and
* the text rather than the glyph's own line box.
*/
display: block;
opacity: 0.35;
}
.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 var(--motion-duration) var(--motion-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 var(--motion-duration) var(--motion-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) {
/* The chrome's own show/hide is a state change, not decoration: it still has to
happen, it just does not have to be animated. Collapsing every duration to a
hair keeps the reader's preference without leaving the bands half-way. */
.topbar,
.footer,
.reader-rail {
transition-duration: 1ms;
animation: none;
}
.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: a bottom sheet, half the screen ----
*
* These used to be `inset: 0` — full-screen opaque pages that replaced the book.
* That is a defensible pattern for a *destination* and the wrong one for a
* *control*: choosing a chapter or nudging the font size is an adjustment to the
* page underneath, and a full-screen panel makes the reader leave the page to make
* it — then come back to a re-laid-out chapter with no memory of what it looked
* like before. The panel is therefore a sheet over the *lower half* of the reading
* area, and the top half stays readable, which is what makes the change visible
* while it is being made.
*
* Three things follow from that and are not decoration:
*
* - **A scrim**, because a sheet without one reads as part of the page. It is
* also the tap target that closes the sheet, which is the second way out, along
* with the ✕ in the header.
* - **A handle**, because a sheet that rises from the bottom is understood to be
* draggable, and a drag target with nothing to grab is a sheet the reader
* cannot close the way they expect. (The drag itself is not implemented; the
* handle is the *affordance*, and the scrim and the ✕ are the two real ways out.
* Promising a drag that does not exist would be worse than not drawing it, so
* the handle is a visual cue and the sheet's own scroll is what moves.)
* - **A height that leaves the top of the page alone.** `min(70%, ...)` rather
* than a fixed viewport fraction: the contents list of a 1200-chapter book wants
* all the room it can get, and the settings list of eleven rows does not want
* more than it needs.
*/
.scrim {
position: absolute;
inset: 0;
z-index: 15;
background: color-mix(in srgb, #000 42%, transparent);
animation: scrim-in var(--motion-duration) var(--motion-ease);
}
@keyframes scrim-in {
from { opacity: 0; }
to { opacity: 1; }
}
.panel {
position: absolute;
inset-inline: 0;
bottom: 0;
z-index: 20;
display: flex;
flex-direction: column;
min-height: 0;
/* Half the reading area, and never more than three quarters of it: the reader
has to be able to see the page they are adjusting, and a sheet that covers
everything is the full-screen panel this replaced. */
height: min(62%, 34rem);
max-height: 78%;
background: var(--reader-surface);
border-top: 1px solid var(--reader-line);
border-radius: 16px 16px 0 0;
box-shadow: 0 -14px 36px color-mix(in srgb, #000 26%, transparent);
animation: sheet-in var(--motion-duration) var(--motion-ease);
}
@keyframes sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.panel[hidden] {
display: none;
}
/* The grab handle. Decorative, so it is `aria-hidden` in the markup. */
.panel-grip {
flex: 0 0 auto;
width: 2.25rem;
height: 0.25rem;
margin: 0.5rem auto 0.125rem;
border-radius: 999px;
background: var(--reader-line);
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem 0.5rem 0.9rem;
border-bottom: 1px solid var(--reader-line);
}
.panel-header h2 {
margin: 0;
font-size: var(--text-title);
font-weight: 600;
}
/* The count beside the title. Right-aligned against the ✕ rather than next to the
title, so the header reads as "title … count … close" and the closing control
stays exactly where the reader's thumb expects it. */
.panel-subtitle {
flex: 1 1 auto;
text-align: end;
font-size: var(--text-meta);
color: var(--reader-muted);
font-variant-numeric: tabular-nums;
}
.panel-body {
flex: 1 1 auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
/* `overscroll-behavior: contain` stops a flick at the end of the list from
scrolling the *chapter* behind the sheet, which is the classic bottom-sheet
annoyance: the list stops moving, the text underneath starts, and the reader
has lost their place. */
overscroll-behavior: contain;
padding: var(--space-3) 0.9rem calc(var(--space-3) + var(--safe-bottom));
}
.toc-list {
list-style: none;
margin: 0;
padding: 0;
}
/* A row is a row, not a full-width button with a divider under it.
*
* The list is long and the rows are short, so the divider was doing the work a
* rhythm should: rows are separated by their own spacing, and the *current* row is
* marked once, loudly, instead of every row being marked equally. */
.toc-list li button {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-2);
text-align: start;
border: 0;
background: transparent;
color: var(--reader-ink);
padding: 0.55rem 0.6rem;
border-radius: var(--reader-radius);
font-size: var(--text-body);
line-height: 1.35;
cursor: pointer;
touch-action: manipulation;
}
.toc-list li button:hover {
background: color-mix(in srgb, var(--reader-ink) 6%, transparent);
}
.toc-list li button[aria-current='true'] {
color: var(--reader-accent);
font-weight: 600;
background: color-mix(in srgb, var(--reader-accent) 12%, transparent);
}
/* A row for a chapter the loaded window cannot reach. It is still listed — the
book does have that chapter — but it must not look tappable, because tapping it
is the case the panel used to accept in silence. */
.toc-list li button[data-unreachable='true'] {
opacity: 0.55;
cursor: default;
}
.toc-list li button[data-unreachable='true']:hover {
background: transparent;
}
/* A chapter's title, and the whole-book position beside it. The position is what
makes a long list navigable: it is the only number that tells the reader how far
in they are while they scroll. */
.toc-list li .toc-index {
flex: 0 0 auto;
min-width: 2.4rem;
font-size: var(--text-meta);
color: var(--reader-muted);
font-variant-numeric: tabular-nums;
}
.toc-list li .toc-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- footer / progress ----
*
* The footer has two rows and they are doing different jobs, so they are laid out
* differently rather than as two rows of the same thing:
*
* - the *control* row (☰ ‹ chapter › ⚙) is what the reader aims at, so it is
* centred and its controls are one size down from the topbar's — four 2.5rem
* controls plus a label is more than a 360px phone has;
* - the *readout* row (章节 / 页数 / 百分比) is what the reader glances at, so it
* is a single line of small tabular numbers spread across the width, with the
* three numbers in the three positions they will always be in. A readout that
* moves around as its values change is a readout the reader has to search.
*/
/*
* Hidden: the footer collapses and slides down, exactly as the header does, and on
* the same attribute — see `.reader-screen[data-chrome='hidden']`. The progress bar
* goes with it rather than staying as a hairline: it is part of the chrome, and a
* two-pixel band left behind at the bottom of the screen is the one thing that makes
* "沉浸式" look broken.
*/
.reader-screen[data-chrome='hidden'] .footer {
height: 0;
opacity: 0;
visibility: hidden;
overflow: hidden;
border-top-color: transparent;
transform: translateY(100%);
}
.footer {
background: var(--reader-surface);
/* A hairline that survives a dark theme and a bright page equally: one solid
colour has to fight whichever side it borders, and this is the one place the
footer meets the paper. */
border-top: 1px solid color-mix(in srgb, var(--reader-line) 80%, transparent);
padding-bottom: var(--safe-bottom);
transition:
height var(--motion-duration) var(--motion-ease),
opacity var(--motion-duration) var(--motion-ease),
transform var(--motion-duration) var(--motion-ease),
visibility 0s linear var(--motion-duration);
}
/*
* The scrubber row: a draggable progress slider and the page readout beside it.
*
* The slider replaces the old 3px bar. That bar could only *report* the position;
* a reader who wanted the end of a long chapter had to tap "next" forty times. A
* range input is the one control that answers "take me there" in a single gesture,
* and it is native — the thumb, the track, the drag physics and the accessibility
* (arrow keys, `aria-valuenow`) all come with it rather than being reimplemented.
*/
.progress-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0.75rem 0.25rem;
}
.progress-scrubber {
flex: 1 1 auto;
min-width: 0;
/* Native range inputs default to a chunky, theme-blind widget; `appearance: none`
is what lets the tokens below draw it. The track is drawn on the element and
the thumb on `::-webkit-slider-thumb` / `::-moz-range-thumb`, which are two
separate rule sets because the two engines never agreed on a shared one. */
appearance: none;
-webkit-appearance: none;
height: 1.5rem;
background: transparent;
cursor: pointer;
touch-action: manipulation;
}
.progress-scrubber::-webkit-slider-runnable-track {
height: 0.3rem;
border-radius: 0.15rem;
background: color-mix(in srgb, var(--reader-line) 70%, transparent);
}
.progress-scrubber::-moz-range-track {
height: 0.3rem;
border-radius: 0.15rem;
background: color-mix(in srgb, var(--reader-line) 70%, transparent);
}
.progress-scrubber::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 1.1rem;
height: 1.1rem;
/* Pull the thumb up onto the middle of the track: the track is 0.3rem and the
thumb 1.1rem, so half the difference is the offset. Without this the thumb
sits below the line it is supposed to ride. */
margin-top: -0.4rem;
border-radius: 50%;
background: var(--reader-surface);
border: 2px solid var(--reader-accent);
box-shadow: 0 1px 4px color-mix(in srgb, #000 18%, transparent);
}
.progress-scrubber::-moz-range-thumb {
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--reader-surface);
border: 2px solid var(--reader-accent);
box-shadow: 0 1px 4px color-mix(in srgb, #000 18%, transparent);
}
.progress-scrubber:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-offset);
}
/* The page readout: tabular so "第 9/32 页" does not twitch sideways as the
denominator changes width. */
.progress-page {
flex: 0 0 auto;
font-size: var(--text-meta);
font-variant-numeric: tabular-nums;
color: var(--reader-muted);
white-space: nowrap;
}
/*
* The chapter navigation row.
*
* Three cells: previous chapter, the reading progress as a label, next chapter.
* The two buttons are text-with-chevron rather than bare chevrons — the whole row
* is a labelled destination, and a bare "‹" beside a bare "›" is a guess.
*/
.footer-row.chapter-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: 0.15rem 0.75rem 0.65rem;
}
.nav-chapter {
display: inline-flex;
align-items: center;
gap: 0.15rem;
border: 0;
background: transparent;
color: var(--reader-ink);
font: inherit;
font-size: var(--text-label);
padding: 0.45rem 0.4rem;
border-radius: var(--reader-radius);
cursor: pointer;
touch-action: manipulation;
white-space: nowrap;
}
.nav-chapter:active {
background: color-mix(in srgb, var(--reader-ink) 8%, transparent);
}
.nav-chapter:disabled {
opacity: 0.35;
cursor: default;
}
.nav-progress {
flex: 1 1 auto;
text-align: center;
font-size: var(--text-label);
color: var(--reader-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- quick-action rail ----
*
* The column of controls that shows and hides **with** the two bars, not instead of
* them. It used to be drawn only while the chrome was hidden, which made it the one
* piece of chrome that survived immersion: a reader who tapped to get "just the
* page" still had a floating column over the right edge of the text. The rail holds
* the same destinations the top bar names (目录 / 主题 / 字号 / 设置), so it belongs to
* the same state — the reader has asked for the controls — and it goes with them.
*
* Positioned in the *page margin* rather than over the text, which is why it can be
* on screen while a reader is reading: the reading column is capped by
* `--reader-measure`, so on anything wider than a phone there is a genuine gutter to
* put it in. On a phone there is not, and the rail floats over the right edge of the
* text — deliberately, because the alternative is not having the controls at all,
* and the buttons are translucent discs that a reader reads *through* rather than
* around.
*
* Its vertical position is a compromise made visible: roughly a third of the way
* down, which is where the thumb of a hand holding a phone at the bottom lands and
* where a hand holding a tablet at the side reaches. It is not centred, because the
* centre of the screen is where the reading is.
*
* The collapse is the *same* mechanism as the bars': a height/opacity/visibility
* transition gated on `data-chrome`, so the three bands cannot disagree about
* whether the chrome is on screen. `animation` for entry was replaced by a
* transition for the same reason: an animation cannot be reversed mid-flight, so a
* reader who tapped twice quickly left the rail visible over a hidden toolbar.
*/
.reader-rail {
position: absolute;
inset-inline-end: calc(0.35rem + var(--safe-right));
/*
* Below the top of the screen rather than centred on it. Two reasons, and the
* first one is the one that decided the number: the status pill floats over the
* top of the reading area at this very inset (see `.status-bar`), and a rail that
* started at 12% would put a button underneath it on every sync. The second is
* reach: a hand holding a phone at the bottom of the screen reaches this band, and
* the centre of the screen is where the reading is.
*/
top: calc(22% + var(--safe-top));
z-index: 14;
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: 0.2rem;
opacity: 1;
transform: none;
transition:
opacity var(--motion-duration) var(--motion-ease),
transform var(--motion-duration) var(--motion-ease),
visibility 0s linear;
}
/*
* Hidden: it slides out to the right and fades, the direction its own entry motion
* came from, so a reader who taps twice sees one movement rather than two.
*
* Gated on the screen's own `data-chrome`, exactly like `.topbar` and `.footer`.
* One attribute, three bands: they cannot disagree about whether the chrome is on
* screen, which is the failure mode ("only the toolbar collapsed") that a second
* source of truth produces. `visibility: hidden` lands at the *end* of the fade so
* a control that is on its way off the screen cannot be tapped or reached by a
* screen reader, and `pointer-events: none` covers the interval.
*/
.reader-screen[data-chrome='hidden'] .reader-rail {
visibility: hidden;
opacity: 0;
transform: translateX(0.5rem);
pointer-events: none;
transition:
opacity var(--motion-duration) var(--motion-ease),
transform var(--motion-duration) var(--motion-ease),
visibility 0s linear var(--motion-duration);
}
.rail-button {
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border: 1px solid color-mix(in srgb, var(--reader-line) 70%, transparent);
border-radius: 50%;
/*
* Translucent, because the rail sits over text on a phone. A solid disc is a hole
* in the page; a blurred one is a control the reader can see the paragraph
* through, which is what makes it possible to leave it there.
*/
background: color-mix(in srgb, var(--reader-surface) 78%, transparent);
backdrop-filter: blur(6px);
color: var(--reader-ink);
font-size: 1.05rem;
cursor: pointer;
touch-action: manipulation;
box-shadow: 0 2px 8px color-mix(in srgb, #000 12%, transparent);
transition: transform var(--motion-duration) var(--motion-ease), background var(--motion-duration) var(--motion-ease);
}
.rail-button:active {
transform: scale(0.92);
background: color-mix(in srgb, var(--reader-surface) 96%, transparent);
}
/* On a wide screen the rail moves into the gutter the measure leaves, so it is
beside the text rather than on top of it. `--reader-measure` is the same token
the column uses, so the two cannot disagree about where the text ends. */
@media (min-width: 60rem) {
.reader-rail {
inset-inline-end: max(0.75rem, calc((100% - var(--reader-measure)) / 2 - 3.5rem));
}
}
/* ---- quick rows in the settings sheet ---- */
.quick-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
padding: 0 0 var(--space-2);
}
.quick-button {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.55rem 0.3rem;
border: 1px solid color-mix(in srgb, var(--reader-line) 80%, transparent);
border-radius: var(--reader-radius);
background: color-mix(in srgb, var(--reader-ink) 4%, transparent);
color: var(--reader-ink);
font: inherit;
cursor: pointer;
touch-action: manipulation;
}
.quick-button:active {
background: color-mix(in srgb, var(--reader-ink) 10%, transparent);
}
.quick-button:disabled {
opacity: 0.55;
cursor: default;
}
.quick-button .quick-label {
font-size: var(--text-meta);
color: var(--reader-muted);
}
.quick-button .quick-value {
font-size: var(--text-label);
font-variant-numeric: tabular-nums;
}
/* ---- 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 ---- */
/*
* A section heading inside a sheet.
*
* Sticky, and that is the one thing it does. The settings sheet is longer than the
* half-screen it lives in, so a reader scrolling to find 亮度 passes 排版 and 翻页
* on the way — and without a sticky heading they arrive at a row with no idea which
* group it belongs to. It is also the only sticky thing in the sheet, so it does
* not compete with the panel header.
*/
.section-title {
position: sticky;
top: 0;
z-index: 1;
margin: 0 -0.9rem var(--space-2);
padding: var(--space-2) 0.9rem 0.35rem;
background: var(--reader-surface);
border-bottom: 1px solid color-mix(in srgb, var(--reader-line) 60%, transparent);
font-size: var(--text-label);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--reader-muted);
}
.section-title:first-child {
margin-top: -0.35rem;
}
/* A row: label on the left, control on the right, on one line where it fits.
*
* The old rows were a column — a label, then the control under it — which is right
* for a text field and wasteful for a segmented control or a slider: eleven rows of
* two lines each is a sheet that scrolls twice as far as it needs to, and the reader
* has to re-read every label to find the one control they came for. */
.field {
display: grid;
grid-template-columns: minmax(4.5rem, max-content) 1fr;
align-items: center;
gap: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
}
.field:last-child {
margin-bottom: 0;
}
.field label {
font-size: var(--text-label);
color: var(--reader-muted);
white-space: nowrap;
}
/* A control that needs the full width takes it, by asking for it. */
.field.is-stacked {
grid-template-columns: 1fr;
}
.field > .segmented,
.field > select,
.field > input[type='range'] {
justify-self: stretch;
min-width: 0;
}
.field[hidden] {
display: none;
}
/* ---- status ---- */
/*
* The status line: a floating pill, not a band.
*
* It used to be a full-width strip in the flex column *between* the header and the
* page, which meant every status change moved the book down by a line and back.
* On a reader that is the worst possible place for a transient message: the text
* the reader is looking at jumps, and the jump is caused by a line about syncing
* that they did not ask for.
*
* Floated over the top of the reading area instead, so it can appear and disappear
* without the page moving at all. It is centred on the text column rather than the
* screen, so it lands under the reader's eye wherever the column is, and it takes no
* pointer events — a message that intercepts the tap meant for the page beneath it
* is a message that costs the reader a page turn.
*/
.status-bar {
position: absolute;
inset-inline: 0;
/*
* Just *below* the top bar rather than over it. It used to sit at
* `--space-3 + safe-top`, i.e. inside the 3.4rem band — which put the middle of
* the pill on top of the middle button of the top bar, so a reader who had just
* opened a book could not see (or reach) 目录 until the message faded. The pill
* belongs over the *page*, which is where the reader is looking, and it must not
* cover the controls they might be reaching for.
*/
top: calc(var(--reader-chrome-top) + var(--space-2));
z-index: 12;
display: flex;
align-items: center;
gap: 0.4rem;
width: fit-content;
max-width: min(28rem, calc(100% - 1.5rem));
margin-inline: auto;
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--reader-line) 70%, transparent);
background: color-mix(in srgb, var(--reader-surface) 92%, transparent);
backdrop-filter: blur(8px);
box-shadow: 0 4px 14px color-mix(in srgb, #000 12%, transparent);
color: var(--reader-ink);
font-size: var(--text-meta);
pointer-events: none;
animation: status-in var(--motion-duration) var(--motion-ease);
}
@keyframes status-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
.status-bar[hidden] {
display: none;
}
.status-bar .status-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--reader-muted);
flex: 0 0 auto;
}
/* A syncing dot that *does* something: the only state here that is a process
rather than an outcome, so it is the only one that earns an animation. */
.status-bar[data-state='syncing'] .status-dot {
animation: status-pulse 1.4s ease-in-out infinite;
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.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 var(--motion-duration) var(--motion-ease),
transform var(--motion-duration) var(--motion-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;
/*
* The row's label is a single line of 0.9rem text in a flex box that centres its
* items, so the icon's box centre and the label's box centre already coincide;
* `.icon`'s `-0.02em` then puts the *ink* on that same line. Nothing to override —
* this comment is here because the rule looks like it should need one.
*/
}
.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;
/*
* It is an `.icon-button`, so it is already inline-grid and already centred; this
* only has to state where in the row it sits. Left to `baseline`, the button's
* own baseline — the baseline of the glyph — decides its position, and a glyph box
* aligned by its baseline is the misalignment this whole change is about.
*/
vertical-align: middle;
align-self: center;
}
/*
* 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;
}
/* ---- reduced motion ---- */
/*
* Honouring `prefers-reduced-motion` in one rule.
*
* The alternative — a media query per animated rule — guarantees that the next
* animation added is the one that ignores the preference. Collapsing the *duration
* tokens* here means every rule that uses them (which is now all of them) is covered
* by construction, and a rule that hard-codes its own duration is the visible
* exception rather than the invisible one.
*
* `!important` is unavoidable and appropriate: this is a user preference, and a
* preference has to beat specificity rather than be outranked by a later rule.
*/
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration: 0.001s;
}
*,
*::before,
*::after {
animation-duration: 0.001s !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001s !important;
scroll-behavior: auto !important;
}
}