mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 14:01:00 +08:00
2853 lines
85 KiB
CSS
2853 lines
85 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.
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/*
|
||
* 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;
|
||
}
|
||
}
|