mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
3885 lines
149 KiB
CSS
3885 lines
149 KiB
CSS
/*
|
||
* Reader stylesheet.
|
||
*
|
||
* This file is the single most product-defining piece of CSS in the project, so
|
||
* its rules are stated explicitly rather than arrived at by accident.
|
||
*
|
||
* THE STRATEGY: preserve the author's design, override almost nothing.
|
||
*
|
||
* The reason this product exists (product design §9) is that every other
|
||
* self-hosted library renders books at "web reader" quality, and the reason the
|
||
* obvious solution is wrong (§5, decision 2) is that Readium CSS takes over the
|
||
* book's typography to make every book look the same. That is a reasonable
|
||
* trade for a generic reader and the exact opposite of this product's promise to
|
||
* faithfully reproduce a carefully typeset book.
|
||
*
|
||
* So the rules here follow three principles:
|
||
*
|
||
* 1. No property is set unless something actually breaks without it.
|
||
* 2. Every adjustable property is an inheriting custom property whose default
|
||
* is the author's value, so "off" means "untouched", not "our default".
|
||
* 3. Anything that changes the author's intent is a user opt-in that persists,
|
||
* never a default.
|
||
*
|
||
* The comments call out each rule that *does* override, and why it has to.
|
||
*/
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Host shell: the viewer, not the book. Safe to style freely.
|
||
* ------------------------------------------------------------------ */
|
||
|
||
:root {
|
||
--reader-ink: #1a1a1a;
|
||
--reader-paper: #fbfaf7;
|
||
--reader-surface: #ffffff;
|
||
--reader-muted: #6b6b6b;
|
||
--reader-line: #e3e0da;
|
||
--reader-accent: #7a5c3e;
|
||
--reader-radius: 10px;
|
||
--collection-measure: 80rem;
|
||
|
||
/*
|
||
* How far a search field is inset inside its content column, on *both* sides.
|
||
*
|
||
* One token rather than two margins, because the two have to be equal for the field
|
||
* to stay centred — and it is declared here rather than on `.shelf` because the
|
||
* *library* has the same field and it is not inside a shelf: a token that lives on
|
||
* one screen's element and is read by another screen's rule resolves to nothing, and
|
||
* `padding-inline: var(--undefined)` is `padding-inline: 0` — i.e. the gutters this
|
||
* token exists to create silently do not appear, which is exactly how it shipped for
|
||
* one round of this review.
|
||
*/
|
||
--shelf-search-inset: 0.75rem;
|
||
|
||
/*
|
||
* 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;
|
||
|
||
/*
|
||
* The inset every *band* — the top bar's row of buttons, the footer's two rows,
|
||
* the reading indicator's two readouts — is drawn to.
|
||
*
|
||
* Declared once because the three of them sit at the two edges of the same page
|
||
* and the reader reads their edges as one line. They were separate numbers
|
||
* before: the top bar had 0.35rem of padding, the scrubber row 0.75rem and the
|
||
* chapter row 0.75rem, so nothing lined up with anything and the mismatch read
|
||
* as three unrelated bars rather than one frame around the text. The two
|
||
* readouts at the bottom (page count and chapter navigation) are the pair the
|
||
* reader looks at together, and they were 0.4rem apart from each other.
|
||
*
|
||
* `0.75rem` rather than the page's own 1.5rem margin: a band is chrome, and the
|
||
* controls inside it are what the reader aims at, so its inset is the gap
|
||
* between the outermost control and the device edge rather than the measure the
|
||
* book is set to.
|
||
*/
|
||
--reader-band-inset: 0.75rem;
|
||
|
||
/*
|
||
* The surface a floating band is drawn on.
|
||
*
|
||
* Translucent, with a blur, and that is what makes a bar over the text usable
|
||
* now that the page is not inset for it: the reader can see the line the bar
|
||
* covers *through* the bar, so the toolbar stops being a thing that hides a
|
||
* paragraph. `color-mix` against the theme's own surface rather than a fixed
|
||
* rgba, so the three themes get their own tint from one value; the fallback is
|
||
* the opaque surface, which is what a host without `color-mix` draws and which
|
||
* is exactly the old behaviour.
|
||
*/
|
||
--reader-surface-float: color-mix(in srgb, var(--reader-surface) 88%, transparent);
|
||
|
||
/*
|
||
* The reader's own paint order, weakest first.
|
||
*
|
||
* The three numbers exist because the reading surface and the chrome are
|
||
* *positioned siblings*, and positioned siblings with no `z-index` are painted in
|
||
* document order — which is how the chapter's own text came to be drawn over the
|
||
* chapter name in immersion. The immersion readouts in particular have to be above
|
||
* the page, because the whole point of them is that the reader can still see where
|
||
* they are; the page cannot be above the thing that describes it. Declared as a
|
||
* named scale rather than as three literals so the relationship is readable and a
|
||
* fourth band has an obvious place to go.
|
||
*/
|
||
--reader-layer-page: 1; /* the reading surface: below everything else here */
|
||
--reader-layer-bar: 10; /* the two floating bars, over the page */
|
||
--reader-layer-rail: 14; /* the quick-action column */
|
||
--reader-layer-panel: 20; /* sheets and their scrim */
|
||
--reader-layer-readout: 5; /* above the page, below bars and sheets */
|
||
|
||
/*
|
||
* The reading indicator's own inset.
|
||
*
|
||
* Wider than a band's, because the indicator is not chrome: it is text, drawn
|
||
* over the page while the reader is reading, so it is set to the *page margin* —
|
||
* the same measure the book is typeset to, which is what makes it look like a
|
||
* caption on the page rather than a label stuck to the screen. `--reader-page-margin`
|
||
* is the reader's own control (0 by default would be flush with the band, 4rem
|
||
* would be a well-inset caption) and `--reader-page-padding-inline` is the floor,
|
||
* so the readouts never overlap the paragraph underneath on a device where the
|
||
* margin is set to nothing.
|
||
*/
|
||
--reader-indicator-inset: max(
|
||
var(--reader-page-margin),
|
||
var(--reader-page-padding-inline)
|
||
);
|
||
}
|
||
|
||
[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.
|
||
*
|
||
* Pinned to the *screen* rather than to whatever the flex column had left over,
|
||
* and that is the whole of the "内容会上下滑动" fix: with the stage in flow, the
|
||
* two bars took height out of it, so showing or hiding the chrome resized the page
|
||
* and moved every line on it. Here the page is the screen, always, in both states.
|
||
*
|
||
* ## No block inset: the page *is* the screen
|
||
* —
|
||
* This rule used to reserve the bars' own heights as `padding-block`, on the
|
||
* reasoning that a floating bar must not cover a line the reader is reading. The
|
||
* reasoning was sound and the result was the report: "内容要铺满,不要预留 stage".
|
||
* Reserving the bars' height means the reading column is permanently 9rem shorter
|
||
* than the phone, in *both* chrome states — so a reader who has hidden the toolbar
|
||
* and wants the whole screen for the book still loses the top and bottom bands to
|
||
* empty paper, and the last line of every page sits 5.6rem above the bottom edge
|
||
* for no reason at all.
|
||
*
|
||
* The column keeps its own `padding-block` (see `FLOW_STYLESHEET`), which is the
|
||
* reader's *page* padding and is symmetric — the text is not flush with the glass
|
||
* even when the bars are gone. What it is not is a variable reserve for chrome
|
||
* that may not be there: the page occupies the whole slot and the bars are drawn
|
||
* over it, which is what "浮动" means and what makes the text stand still when
|
||
* they come and go.
|
||
*/
|
||
.stage-host {
|
||
display: flex;
|
||
position: absolute;
|
||
inset: 0;
|
||
}
|
||
|
||
/*
|
||
* 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.
|
||
*/
|
||
/*
|
||
* The reading surface, and its place in the paint order.
|
||
*
|
||
* It is `position: absolute` inside `.stage`, so it is a *positioned* box — and a
|
||
* positioned box with no `z-index` paints in **document order** against its
|
||
* positioned siblings. It is appended before the chrome is rendered, so it comes
|
||
* after the immersion readouts, which is how the chapter's own text came to be
|
||
* drawn over the chapter name that describes it. One declared number fixes that,
|
||
* and it is the page's place in the layer scale declared with the other tokens.
|
||
*/
|
||
.book-host {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
z-index: var(--reader-layer-page);
|
||
}
|
||
|
||
.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.
|
||
*
|
||
* Both bars *float*. They are positioned overlays rather than rows in a column, so
|
||
* the page beneath them is the whole screen whichever state they are in — which is
|
||
* the difference between "the toolbar appears" and "the toolbar appears *and* pushes
|
||
* everything the reader was looking at up by 53px". It was the second, and it was
|
||
* reported as such: a tap on the middle third slid the text up and a second tap slid
|
||
* it back down. The reader's own diagnosis was the right one — a toolbar is a
|
||
* floating layer, and a layout that lets it displace content has the chrome and the
|
||
* page in the same flow when they are not.
|
||
*
|
||
* The cost of floating is that the bars cover the top and bottom of the page, so the
|
||
* reading column keeps a constant inset clear of them (`--reader-chrome-inset-*` on
|
||
* the flow, in `ui/shadow.ts`). Constant is the operative word: the inset does not
|
||
* change with the chrome's state, because a page that re-typesets itself when the
|
||
* reader taps is the same defect wearing a different hat.
|
||
*/
|
||
.reader-screen {
|
||
display: block;
|
||
height: 100%;
|
||
position: relative;
|
||
/*
|
||
* The chrome's own height, in one place.
|
||
*
|
||
* These are *measurements of the bars*, not space the layout reserves. The bars
|
||
* float over the page (see `.topbar`), so the page is the whole screen in both
|
||
* states and the numbers are only what the reading column has to keep clear of.
|
||
*
|
||
* They were previously the stage's own height arithmetic — the bands grew and
|
||
* shrank and the stage, being `flex: 1`, took whatever was left. That is what made
|
||
* a tap on the middle third *move the text*: every time the reader asked for the
|
||
* controls the page re-laid out 3.4rem shorter, and the line they were reading
|
||
* slid up the screen by 53px and back down again when they hid it. On a reader
|
||
* that is the worst possible motion — it is the text moving, and the text is the
|
||
* one thing that must stay still. So the geometry is constant and the bars are
|
||
* drawn over it.
|
||
*/
|
||
--reader-chrome-top: 3.4rem;
|
||
--reader-chrome-bottom: 5.6rem;
|
||
}
|
||
|
||
.topbar {
|
||
display: flex;
|
||
/*
|
||
* Over the page, not above it. `position: absolute` is what makes the bars a
|
||
* layer rather than a row: with them in flow, the stage was `flex: 1` of what was
|
||
* left, so the page grew and shrank by the bars' height on every toggle and every
|
||
* line of the chapter moved with it.
|
||
*/
|
||
position: absolute;
|
||
inset-inline: 0;
|
||
top: 0;
|
||
z-index: var(--reader-layer-bar);
|
||
/*
|
||
* `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;
|
||
/*
|
||
* Three columns, and the outer two are pinned to their own edges.
|
||
*
|
||
* This is the alignment the reader reads *vertically*: 返回 at the top of the
|
||
* screen and 上一章 at the bottom are the same thing in the reader's head — "the
|
||
* leftmost control" — so their left edges have to be one line. They were not.
|
||
* The bar was `space-evenly` over three equal-width buttons, so 返回's glyph sat
|
||
* 70px in, centred in its own third, while 上一章 began 12px from the device edge.
|
||
* Two rows framing the same text with corners that do not meet read as broken,
|
||
* without the reader being able to name what is wrong.
|
||
*
|
||
* A grid rather than `space-between`, and that distinction is the whole fix:
|
||
* `space-between` distributes *free* space, and with `flex: 1 1 0` buttons there
|
||
* is none — three equal columns fill the row exactly, so the property has nothing
|
||
* to do and the buttons stay centred in their boxes. The grid says what is
|
||
* actually wanted: three tracks, the first item flush start, the last flush end,
|
||
* the middle one centred. Those are the same three positions the footer's two
|
||
* chapter buttons and its progress readout occupy, and they are stated once, on
|
||
* the same `--reader-band-inset` both rows take their padding from.
|
||
*/
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
justify-items: center;
|
||
gap: var(--space-1);
|
||
min-height: 3.4rem;
|
||
/* The band inset on both sides, so the outermost glyphs are the same distance
|
||
from the device edge as every control in the footer below them. */
|
||
padding-inline: var(--reader-band-inset);
|
||
/* A floating band over the page, not a solid shelf above it: the reader has to be
|
||
able to read the line it covers. See `--reader-surface-float`. */
|
||
background: var(--reader-surface-float);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
/* 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, which the reading column also reads as the inset it has
|
||
to keep clear of. One number, so the bar and the space reserved for it cannot
|
||
disagree. */
|
||
height: var(--reader-chrome-top);
|
||
/* Nothing is animated but the band's own opacity and position: no layout property
|
||
is touched, which is what makes the motion free of any effect on the page. */
|
||
transition:
|
||
opacity var(--motion-duration) var(--motion-ease),
|
||
transform var(--motion-duration) var(--motion-ease),
|
||
visibility 0s linear var(--motion-duration);
|
||
}
|
||
|
||
/*
|
||
* Hidden: the band slides up out of the way and fades.
|
||
*
|
||
* `visibility` rather than `display: none` so the slide has something to animate,
|
||
* and `visibility: hidden` at the *end* of it so the (invisible) bar cannot be
|
||
* reached by a screen reader or a tap. `pointer-events: none` covers the interval
|
||
* itself, which is the part `visibility` alone does not: a control on its way off
|
||
* the screen is still a control for the duration of the fade.
|
||
*
|
||
* No height is animated, because there is no height to give back — the page never
|
||
* had less of it. That is the point of the bars being a layer.
|
||
*/
|
||
.reader-screen[data-chrome='hidden'] .topbar {
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
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.
|
||
*/
|
||
/*
|
||
* One control of the top bar.
|
||
*
|
||
* `flex: 1 1 0` gives all three the same *width*, and that is the whole of how the
|
||
* row comes out symmetric: with three equal columns there is no free space left for
|
||
* `space-evenly` to distribute as gaps, so the two outer buttons start and end
|
||
* exactly on the bar's own inset and the two interior gaps are equal. The reader's
|
||
* "顶部栏三个按钮需要两边对齐" was not achievable while the columns had a
|
||
* `max-width`: the row was `space-evenly` *with* a 6rem cap, so the free space left
|
||
* over grew with the screen and was inserted as *gaps* — which are computed from
|
||
* what is left, and therefore came out unequal for labels of unequal width. The
|
||
* measured result was outer edges 29px in while the footer's were 12px in, on the
|
||
* same screen.
|
||
*
|
||
* The ceiling is gone rather than moved. It read as a sensible tablet refinement
|
||
* ("three 6rem buttons across 800px is a lot of air") and it was precisely what
|
||
* broke the alignment, because a row narrower than its bar always has space to
|
||
* insert somewhere. A row at `100%` has no leftover space at any width, so its
|
||
* edges are the bar's inset everywhere — which is the property that makes the top
|
||
* bar and the footer share one frame. `flex: 1` is already the ceiling; the row
|
||
* cannot exceed the bar.
|
||
*
|
||
* `flex-basis: 0` also means a long label cannot make its own column wider than its
|
||
* neighbours: 返回 and 设置 are the same target whatever the words say.
|
||
*/
|
||
.top-button {
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
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);
|
||
}
|
||
|
||
/* The outer two flush to the band's own inset, which is where the footer's two
|
||
chapter buttons put their edges — see the grid comment above. The middle button
|
||
stays centred, which is where the reader expects the middle control. */
|
||
.topbar > .top-button:first-child {
|
||
justify-self: start;
|
||
}
|
||
|
||
.topbar > .top-button:last-child {
|
||
justify-self: end;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* Shared sizing for the SVG icon primitive. */
|
||
.icon {
|
||
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;
|
||
/*
|
||
* The label does not wrap, and the padding is the header's rather than the body's.
|
||
*
|
||
* In a *body* (an empty state, a dialog) a `.button` is a standalone control and the
|
||
* roomy padding is right. In a *header* it sits in a fixed-height row beside a
|
||
* breadcrumb, and the two together decide what the row looks like: a four-character
|
||
* Chinese label at `0.95rem` with `1rem` of padding either side is 90px, which on a
|
||
* 390px screen pushed it onto two lines and made the header taller than the icon
|
||
* buttons beside it. `nowrap` is the second half of the same fix — a label that wraps
|
||
* is the failure this rule exists to prevent, not a way to fit.
|
||
*/
|
||
white-space: nowrap;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.panel-header .icon-text-button {
|
||
padding: 0.45rem 0.6rem;
|
||
font-size: 0.82rem;
|
||
}
|
||
|
||
/* ---- 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,
|
||
.pager-page:focus-visible,
|
||
.pager-step: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,
|
||
.pager-page:hover,
|
||
.pager-step:hover,
|
||
.toc-list li button:hover {
|
||
background: color-mix(in srgb, var(--reader-ink) 6%, transparent);
|
||
}
|
||
|
||
/* Hover must not paint over the one page the reader is *on*: the fill is the
|
||
state, and a hover that replaces it hides exactly the fact the control carries. */
|
||
.pager-page[aria-current='page']:hover {
|
||
background: var(--reader-accent);
|
||
}
|
||
|
||
.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,
|
||
.pager-page,
|
||
.pager-step,
|
||
.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;
|
||
}
|
||
|
||
/*
|
||
* The title block.
|
||
*
|
||
* One screen title, its count beneath it, and two actions on the right — which is
|
||
* what the reference稿 shows and what the previous arrangement did not. The count was
|
||
* a separate row *below* the search field, so the same fact ("共 12 本") appeared two
|
||
* rows away from the thing it counts and had to be re-found after every search.
|
||
*/
|
||
.shelf-title {
|
||
margin: 0;
|
||
font-size: 1.5rem;
|
||
font-weight: 650;
|
||
letter-spacing: -0.02em;
|
||
line-height: 1.15;
|
||
/* The title is the one piece of text on this screen at a display size, so it gets
|
||
the optical correction the smaller sizes do not need: `-0.02em` closes the gaps
|
||
that a large weight opens between Chinese glyphs and Latin capitals alike. */
|
||
}
|
||
|
||
.shelf-subtitle {
|
||
margin: 0.25rem 0 0;
|
||
font-size: 0.76rem;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.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.45rem;
|
||
}
|
||
|
||
/* ---- 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;
|
||
/*
|
||
* The field is inset from the covers above and below it, on both sides.
|
||
*
|
||
* This was reported as 「书架的搜索框两边需要增加 margin」 and it is a *gutter*
|
||
* problem rather than a width problem: the field was exactly as wide as the grid,
|
||
* so its left and right edges — where the pill's border is, and where the clear
|
||
* button's 32px circle sits — landed on the same vertical line as the covers. At
|
||
* that distance a 1px border reads as part of the artwork beside it, and the clear
|
||
* button looks like it is inside the first cover rather than inside the field.
|
||
*
|
||
* `--shelf-search-inset` is one number used by *both* sides, so the field stays
|
||
* centred on the column: inset one side only would move the whole control, which is
|
||
* the other way to make a row look wrong without anything being at fault.
|
||
*
|
||
* `max-width` subtracts the inset as well, so the field cannot grow back to the
|
||
* full column width on a wide window — a cap that forgot the inset would leave the
|
||
* gutters at the widest size, which is exactly the size where they are least
|
||
* needed.
|
||
*/
|
||
margin: 0.35rem auto 0.9rem;
|
||
max-width: calc(
|
||
var(--shelf-measure) - 1.8rem - var(--safe-left) - var(--safe-right) - 2 * var(--shelf-search-inset)
|
||
);
|
||
/*
|
||
* The gutters themselves, and they have to be on the *row* rather than as
|
||
* `padding-inline` on the input: the magnifier and the clear button are absolutely
|
||
* positioned against this box, so padding here would move them out of the field they
|
||
* are drawn inside and leave them floating in the margin.
|
||
*/
|
||
padding-inline: var(--shelf-search-inset);
|
||
/*
|
||
* `box-sizing` is what keeps the inset from being *added* to the max-width above:
|
||
* with the default (`content-box`) the field would be `max-width + 2 × inset` wide,
|
||
* i.e. exactly the width it had before — the margin would be paid for by growing the
|
||
* control, and the covers beside it would sit under the clear button again.
|
||
*/
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/*
|
||
* The magnifier inside the search field.
|
||
*
|
||
* ## The bug this rule exists to remove
|
||
*
|
||
* The previous version said `inset-block: 0; margin-block: auto` and the comment above
|
||
* it claimed "the glyph is inline and the box is a line box, so centring the box and
|
||
* centring the glyph are the same instruction". That premise is false, and it is false
|
||
* for a reason that has nothing to do with this glyph: **`position: absolute` forces
|
||
* `display: block`**. The `<span>` was therefore a *block* box, not a line box, and a
|
||
* block box does not centre its line box vertically — it puts it at the top.
|
||
*
|
||
* So the rule did exactly what it said (the box was centred: measured 75.98..120.19 in
|
||
* a field of 75.98..120.19, dead centre) and produced exactly the wrong picture: the
|
||
* line box is 1em tall and sits at the box's top, so a 1.05rem glyph's ink landed with
|
||
* its centre at y=84 while the field's centre was y=98. Fourteen pixels high — the
|
||
* reported "搜索按钮没有垂直对齐".
|
||
*
|
||
* ## Why `grid` and not a transform
|
||
*
|
||
* `place-content: center` centres the *line box* inside the box, which — with the icon
|
||
* font's symmetric ascent/descent (see `tools/icons/build.mjs`) — centres the ink too.
|
||
* That is the same idiom `.icon-button` already uses, so "a glyph in a box" has one
|
||
* answer in this stylesheet rather than two.
|
||
*
|
||
* The alternative, `top: 50%; transform: translateY(-50%)`, also works and is worse: it
|
||
* has to be repeated at every call site and can be omitted at one, it makes the offset a
|
||
* fact about a *transform* rather than about the layout, and it interacts badly with any
|
||
* future padding (the percentage resolves against the padding box while the ink does
|
||
* not). Saying it with the layout system means the field can change height and the
|
||
* magnifier follows without this rule being touched.
|
||
*/
|
||
.search-glyph {
|
||
position: absolute;
|
||
inset-inline-start: 1.5rem;
|
||
inset-block: 0;
|
||
margin-block: auto;
|
||
/*
|
||
* The box is only as wide as the glyph and as tall as the field. `grid` +
|
||
* `place-content` is what turns "the box is centred" into "the ink is centred" — see
|
||
* `.icon-button` for the same construction.
|
||
*/
|
||
display: grid;
|
||
place-content: center;
|
||
width: 1.2rem;
|
||
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;
|
||
}
|
||
|
||
/*
|
||
* The platform's own clear affordance, suppressed.
|
||
*
|
||
* A `type=search` input renders a decoration of its own in some browsers and none in
|
||
* others, and the one it renders is *beside* the button this app draws — so the field
|
||
* showed two clear controls, one of them 12px of the browser's own blue at the edge of
|
||
* a control the reader has to hit with a thumb. The app owns this affordance (see
|
||
* `.search-clear`), so the platform's is removed rather than left to duplicate it.
|
||
*/
|
||
.shelf-search input::-webkit-search-cancel-button,
|
||
.shelf-search input::-webkit-search-decoration {
|
||
appearance: none;
|
||
display: none;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/*
|
||
* A section heading that reads as a heading rather than as bold body text.
|
||
*
|
||
* The reference稿 sets these slightly apart from the content under them with tracking
|
||
* and a muted colour, which is what makes a screen with two lists feel like two
|
||
* sections instead of one long list with a heading in the middle of it. It is a
|
||
* heading *of* the section, so it also loses the underline the toolbar used to carry:
|
||
* two horizontal rules 40px apart made the count row look like a separate band.
|
||
*/
|
||
.shelf-section-title {
|
||
margin: 0.9rem 0 0.55rem;
|
||
font-size: 0.83rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
color: var(--reader-muted);
|
||
}
|
||
|
||
/* ---- 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;
|
||
/*
|
||
* A hairline under the sticky band, so the covers scrolling beneath it have an
|
||
* edge to pass. Without it the band is invisible until something slides under it,
|
||
* and a translucent band with no boundary reads as a rendering artefact.
|
||
*/
|
||
border-bottom: 1px solid color-mix(in srgb, var(--reader-line) 70%, transparent);
|
||
/*
|
||
* 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;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.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;
|
||
color: var(--reader-muted);
|
||
padding: 0.75rem 0.9rem calc(0.75rem + var(--safe-bottom));
|
||
}
|
||
|
||
|
||
/* ---- the pager ----
|
||
*
|
||
* One control, two lists. It lives here rather than beside either list because the
|
||
* shelf and the library must page *identically*: two pagers that are 2px apart, or
|
||
* one with a chevron and one with a triangle, is the drift this stylesheet exists to
|
||
* prevent, and it is the kind of drift nobody notices until they are side by side in
|
||
* a screenshot.
|
||
*
|
||
* ## Why the numbers are pills and the arrows are not
|
||
*
|
||
* The current page is a *state* (where the reader is) and the others are *targets*
|
||
* (where they can go). Drawing all of them the same makes the one fact the control
|
||
* carries — "you are here" — invisible, and the reader has to read every number to
|
||
* find it. So the current page is filled with the accent, and the rest are quiet.
|
||
*
|
||
* ## Why the step arrows are on both sides
|
||
*
|
||
* A long list is read left-to-right and turned the same way: the right chevron is
|
||
* "next", which is the gesture the whole control exists for, and the left one is
|
||
* there so the reader who overshot does not have to find their page number and aim
|
||
* at it.
|
||
*/
|
||
.shelf-pager,
|
||
.manager-pager {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
gap: 0.3rem;
|
||
padding: 0.9rem 0.35rem 0.25rem;
|
||
}
|
||
|
||
/*
|
||
* A page number, sized to the 28px floor rather than to its text.
|
||
*
|
||
* It is a control the reader aims at repeatedly, and 0.72rem of digits in a 16px box
|
||
* is a target that needs the eye rather than the thumb. The box is what is sized, so
|
||
* the numbers stay quiet while the hit area is not.
|
||
*/
|
||
.pager-page,
|
||
.pager-step {
|
||
appearance: none;
|
||
border: 1px solid transparent;
|
||
background: transparent;
|
||
color: var(--reader-ink);
|
||
border-radius: var(--reader-radius);
|
||
min-width: 2rem;
|
||
height: 2rem;
|
||
padding: 0 0.4rem;
|
||
font-size: var(--text-label);
|
||
font-variant-numeric: tabular-nums;
|
||
display: inline-grid;
|
||
place-content: center;
|
||
cursor: pointer;
|
||
touch-action: manipulation;
|
||
}
|
||
|
||
/*
|
||
* The current page: a state, in the same colour the shelf uses for "this is the one".
|
||
*
|
||
* `aria-current="page"` rather than a class, for the same reason the switch uses
|
||
* `aria-pressed`: a class would be a second source of truth for "which page am I on",
|
||
* and the two would eventually disagree — with the disagreement showing up only to a
|
||
* screen reader, which is the worst place for it.
|
||
*/
|
||
.pager-page[aria-current='page'] {
|
||
background: var(--reader-accent);
|
||
border-color: var(--reader-accent);
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.pager-page:disabled,
|
||
.pager-step:disabled {
|
||
/*
|
||
* `disabled` rather than hidden: the buttons sit in fixed positions, so a control
|
||
* that disappears at page one moves the numbers sideways under the thumb that is
|
||
* on its way to page two.
|
||
*/
|
||
opacity: 0.35;
|
||
cursor: default;
|
||
}
|
||
|
||
/*
|
||
* The step arrows carry a glyph, so they take the icon's own line box rules. The
|
||
* `.icon` box is 1em wide with the ink centred in it, and the button centres that
|
||
* box — which is why there is no vertical nudge here and why there must not be one.
|
||
*/
|
||
.pager-step {
|
||
width: 2rem;
|
||
min-width: 2rem;
|
||
padding: 0;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
/* A run of pages the window collapsed. Not a target: there is nothing here to press
|
||
— the two ends of the run are the two pages the reader can reach from here. */
|
||
.pager-gap {
|
||
min-width: 1.4rem;
|
||
height: 2rem;
|
||
display: inline-grid;
|
||
place-content: center;
|
||
font-size: var(--text-meta);
|
||
color: var(--reader-muted);
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
/* Nothing animates here, and that is deliberate: a page turn is a new list, and a
|
||
list that fades is a list the reader waits for. This block exists so the next
|
||
person adding a transition to this control has to delete it on purpose. */
|
||
.pager-page,
|
||
.pager-step {
|
||
transition: none;
|
||
}
|
||
}
|
||
|
||
/* ---- the shelf's own section header row ---- */
|
||
|
||
/* ---- 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;
|
||
}
|
||
}
|
||
|
||
/*
|
||
* The card is a *container*, and the press target is the `.book-open` inside it.
|
||
*
|
||
* It has to be this way once a card carries a second action: a `<button>` inside a
|
||
* `<button>` is invalid markup, and what a browser does with it — hoist the inner one
|
||
* out of its parent — is a layout nobody designed. Both screens that draw a card
|
||
* therefore put the open-target and the extra control as siblings here, which is also
|
||
* why `.is-row` (the library's variant) no longer has to override the whole thing.
|
||
*
|
||
* `position: relative` is what the extra control is placed against.
|
||
*/
|
||
.book-card {
|
||
position: relative;
|
||
border: 0;
|
||
padding: 0;
|
||
background: transparent;
|
||
color: inherit;
|
||
text-align: start;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
min-width: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
/* The press target: the cover and the two lines under it, and nothing else. */
|
||
.book-card .book-open {
|
||
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%;
|
||
font: inherit;
|
||
}
|
||
|
||
/*
|
||
* The card's own actions, as a glyph in the top corner.
|
||
*
|
||
* `opacity` rather than `display`, and always *focusable*: a control that only exists
|
||
* on hover does not exist on the device most of these readers use, and one that is
|
||
* `display: none` until touched cannot be reached by a keyboard or a screen reader at
|
||
* all. Dimmed is legible enough to find and quiet enough not to compete with the
|
||
* cover, which is the thing the reader came for.
|
||
*/
|
||
.book-card .book-more {
|
||
position: absolute;
|
||
inset-block-start: 0.3rem;
|
||
inset-inline-end: 0.3rem;
|
||
/* 1.75rem = 28px at the root size, which is the smallest hit area the UI review
|
||
accepts and the same floor the top bar's own controls are held to. A corner glyph
|
||
over a cover is exactly the control a finger misses. */
|
||
width: 1.75rem;
|
||
height: 1.75rem;
|
||
border-radius: 999px;
|
||
background: color-mix(in srgb, var(--reader-surface) 82%, transparent);
|
||
color: var(--reader-ink);
|
||
opacity: 0.55;
|
||
backdrop-filter: blur(4px);
|
||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.18);
|
||
transition: opacity var(--motion-duration) var(--motion-ease);
|
||
}
|
||
|
||
.book-card .book-more:hover,
|
||
.book-card .book-more:focus-visible,
|
||
.book-card .book-more:active {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* A pointer can hover, so the glyph can afford to be invisible until it does; a
|
||
finger cannot, so the rule is scoped to hosts that have one. */
|
||
@media (hover: hover) {
|
||
.book-card .book-more {
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
/*
|
||
* 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 .book-open: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: removed, on purpose ----
|
||
*
|
||
* There used to be a horizontal 继续阅读 row above the grid here: ten cards, the same
|
||
* ten books that are already on the shelf below, drawn a second time in a strip.
|
||
* It is gone, and the space it took is the point — the shelf is a grid of covers and
|
||
* the question the strip answered ("where was I") is now answered by the *order* of
|
||
* that grid, because 最近阅读 is the default sort. A row that duplicates ten of the
|
||
* sixty covers under it is not a shortcut, it is the first thing the reader has to
|
||
* scroll past to reach the books.
|
||
*
|
||
* The progress bar it drew is still drawn: on the cover of every card that has been
|
||
* started, from the same local mirror, which is where it was always more useful.
|
||
* The timestamps the strip was built from are still fetched, and are now the sort
|
||
* key (see `shelf-screen.tsx`), so nothing came off the wire.
|
||
*/
|
||
|
||
/* ---- 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-card .book-more,
|
||
.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: var(--reader-layer-panel);
|
||
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: var(--reader-layer-panel);
|
||
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 slides down and fades, 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.
|
||
*
|
||
* Like the header it gives no height back, because it took none: the reading column
|
||
* keeps the same block inset in both states, so the last line of a page is in the
|
||
* same place whether the toolbar is up or down. It is drawn as a layer over the
|
||
* page, with its own surface colour, so the lines it covers are *behind* it rather
|
||
* than pushed above it.
|
||
*/
|
||
.reader-screen[data-chrome='hidden'] .footer {
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
border-top-color: transparent;
|
||
transform: translateY(100%);
|
||
}
|
||
|
||
.footer {
|
||
/* Over the page at the bottom edge, the same arrangement as the top bar. */
|
||
position: absolute;
|
||
inset-inline: 0;
|
||
bottom: 0;
|
||
z-index: var(--reader-layer-bar);
|
||
/* The same floating surface as the top bar — over the page, see above. */
|
||
background: var(--reader-surface-float);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
/* 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);
|
||
/* The same two properties as the header, and for the same reason: nothing here
|
||
changes the page's geometry. */
|
||
transition:
|
||
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) var(--reader-band-inset) 0.25rem;
|
||
}
|
||
|
||
.progress-scrubber {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
/* A range input carries a user-agent inline margin, which put the track 2px
|
||
inside the row's own padding while the page readout opposite it sat flush with
|
||
it — a visible hairline of misalignment on the row the reader asked to have
|
||
aligned. The row's padding is the inset; the control adds none of its own. */
|
||
margin-inline: 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 var(--reader-band-inset) 0.65rem;
|
||
}
|
||
|
||
/*
|
||
* The chapter buttons' horizontal padding is *zero*.
|
||
*
|
||
* They used to carry `0.4rem` of their own on top of the row's padding, so the
|
||
* label "上一章" started 0.4rem further in than the page readout above it and the
|
||
* row's two ends disagreed with the scrubber row's. The padding was there to give
|
||
* the button a bigger hit area — which it still gets, vertically: the row's height
|
||
* is 0.45rem of padding plus a line, and the *width* is now the label's own. A
|
||
* button whose target is as wide as its text is the right target for a row that is
|
||
* read as one line of controls.
|
||
*/
|
||
.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;
|
||
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 two adjustments a reader makes from the page itself (主题 / 字号) plus 听书, so it
|
||
* belongs to the same state the bars do — the reader has asked for the controls — and
|
||
* it goes with them. The five destinations that used to be here (目录 / 字体 / 行距 /
|
||
* 页边距 / 设置) are gone: all five open or reach a sheet, and a second, worse copy of
|
||
* the settings panel floating over the text is not a shortcut.
|
||
*
|
||
* 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, within comfortable
|
||
* reach: a hand holding a phone at the bottom of the screen reaches this band.
|
||
* Keeping controls at the side also leaves the centre clear for reading.
|
||
*/
|
||
top: calc(22% + var(--safe-top));
|
||
/*
|
||
* A ceiling, and it is the whole of why the rail can be trusted to show what it
|
||
* contains.
|
||
*
|
||
* Without one the column was as tall as its buttons and simply ran off the bottom
|
||
* of a short screen: a phone in landscape, or a phone whose status bar and
|
||
* browser chrome leave 500px, showed the first four controls and nothing else —
|
||
* with no scrollbar, no fade and no hint that there was more. That is the
|
||
* standing report ("竖排工具栏显示为 主题切换、听书") described exactly: a reader who
|
||
* counts what they can see and concludes that is what the rail does.
|
||
*
|
||
* `--reader-chrome-bottom` and `--reader-chrome-top` are the same two numbers the
|
||
* reading column reserves for the bars, so the rail cannot extend under either of
|
||
* them however many controls it holds; `max-height` rather than `height` so a rail
|
||
* with three buttons is three buttons tall and does not float in an empty column.
|
||
*/
|
||
max-height: calc(100% - 22% - var(--safe-top) - var(--reader-chrome-bottom) - 0.5rem);
|
||
overflow-y: auto;
|
||
/*
|
||
* `overscroll-behavior: contain`, so reaching the end of the rail does not hand
|
||
* the rest of the gesture to the page underneath it. Without it, a flick that
|
||
* finds the last button turns into a chapter scroll — the reader's finger was on a
|
||
* control and the book moved.
|
||
*/
|
||
overscroll-behavior: contain;
|
||
z-index: var(--reader-layer-rail);
|
||
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;
|
||
}
|
||
|
||
/*
|
||
* The rail's scrollbar is *drawn*, not hidden.
|
||
*
|
||
* `scrollbar-width: none` is the usual treatment for a decorative strip, and it is
|
||
* precisely wrong here: a control that can only be found by scrolling has to look
|
||
* scrollable, or the reader never learns it is there — which is the bug above. A
|
||
* hairline on a translucent column is also the honest amount of chrome for it: the
|
||
* rail floats over the text, and a 12px opaque gutter would be a band of the page
|
||
* the reader cannot see through for no information.
|
||
*/
|
||
.reader-rail::-webkit-scrollbar {
|
||
inline-size: 3px;
|
||
}
|
||
|
||
.reader-rail::-webkit-scrollbar-thumb {
|
||
background: color-mix(in srgb, var(--reader-ink) 22%, transparent);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
/*
|
||
* 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 floats over the bottom of the page, and that is a *fix*, not a style
|
||
* choice.
|
||
*
|
||
* It used to be an ordinary flex child of `.reader-screen`. The screen is a flex
|
||
* column whose first child is `.stage-host`, and the stage host is
|
||
* `position: absolute; inset: 0` — it covers the whole screen so the page does not
|
||
* resize when the bars come and go. A static sibling in that column is therefore
|
||
* laid out *behind* the reading surface, which paints at `--reader-layer-page`.
|
||
* The bar was drawn, its buttons were focusable, and the stage was painted over
|
||
* all of it: the reader could see the sentence being read and could not press
|
||
* anything. That is the whole of "没法停止" — the stop button existed and was
|
||
* unreachable, which reads as a control that does nothing.
|
||
*
|
||
* So it is a layer with the bars' own z-index (`--reader-layer-bar`), pinned above
|
||
* the bottom edge and above the footer's own height, which is where its controls
|
||
* have to be to be hit by a thumb. Unlike the footer it is *not* hidden with the
|
||
* chrome: a reader listening while walking needs play/pause without the header
|
||
* covering the sentence being read.
|
||
*
|
||
* `--reader-chrome-bottom` is the footer's own height, read here rather than
|
||
* guessed, so the two bands cannot overlap however the footer's rows change.
|
||
*/
|
||
.tts-bar {
|
||
position: absolute;
|
||
inset-inline: 0;
|
||
bottom: var(--reader-chrome-bottom);
|
||
z-index: var(--reader-layer-bar);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
padding: 0.35rem 0.5rem;
|
||
background: var(--reader-surface-float);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
border-top: 1px solid var(--reader-line);
|
||
}
|
||
|
||
.tts-bar[hidden] {
|
||
display: none;
|
||
}
|
||
|
||
/*
|
||
* Two rows, stacked: the transport controls, then the sentence with its scrubber.
|
||
*
|
||
* A column rather than a single flex row because the single row could not fit —
|
||
* see the comment on `.tts-bar`. The two rows take the full width of the bar, so
|
||
* the five controls split it evenly and none of them is ever clipped.
|
||
*/
|
||
.tts-bar .tts-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.tts-bar .tts-controls {
|
||
/* Every control the same width, including the chip: a row whose cells are
|
||
sized by their contents leaves the widest one (the ✕, whose label is a
|
||
glyph) flush to the edge and the others bunched. Equal cells read as one
|
||
transport, which is what the row is. */
|
||
gap: var(--space-1);
|
||
}
|
||
|
||
.tts-bar .tts-controls > * {
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* The chip is a text control in a row of icons, so its own padding is dropped:
|
||
the cell's width is the equal share, not the label's. */
|
||
.tts-bar .tts-chip {
|
||
appearance: none;
|
||
border: 1px solid color-mix(in srgb, var(--reader-line) 90%, transparent);
|
||
background: var(--reader-surface);
|
||
color: var(--reader-muted);
|
||
border-radius: 999px;
|
||
height: 2.75rem;
|
||
min-width: 0;
|
||
padding: 0 0.4rem;
|
||
font: inherit;
|
||
font-size: var(--text-meta);
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
cursor: pointer;
|
||
touch-action: manipulation;
|
||
}
|
||
|
||
.tts-bar .tts-chip:active {
|
||
background: color-mix(in srgb, var(--reader-ink) 8%, transparent);
|
||
}
|
||
|
||
/*
|
||
* Stop is the one destructive control on the bar and it is drawn as such.
|
||
*
|
||
* It is not a warning colour: this is a reading app, and a red button in the
|
||
* middle of a page is a louder statement than "stop the voice". The accent fill
|
||
* is enough to separate it from the three transport buttons beside it, which is
|
||
* the whole job — the reader who wants the voice to stop has to be able to pick
|
||
* this control out of a row of five at a glance.
|
||
*/
|
||
.tts-bar .tts-stop {
|
||
background: color-mix(in srgb, var(--reader-accent) 16%, transparent);
|
||
border-color: color-mix(in srgb, var(--reader-accent) 45%, transparent);
|
||
color: var(--reader-ink);
|
||
}
|
||
|
||
.tts-bar .tts-progress {
|
||
gap: var(--space-2);
|
||
padding-inline: 0.15rem;
|
||
}
|
||
|
||
.tts-bar .tts-text {
|
||
flex: 0 1 auto;
|
||
max-width: 40%;
|
||
font-size: 0.78rem;
|
||
color: var(--reader-muted);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.tts-range {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
margin: 0;
|
||
accent-color: var(--reader-accent);
|
||
}
|
||
|
||
/*
|
||
* A chip: a small, pressable *choice*.
|
||
*
|
||
* The pressed state did not exist. `aria-pressed` was set correctly and read by
|
||
* screen readers, and nothing drew it — so the shelf's four sort chips looked
|
||
* identical whichever one was in force, and the only way to find out how the list
|
||
* was ordered was to read the subtitle above it. `aria-pressed` drives the fill for
|
||
* the same reason the switch uses it: one source of truth for "is this on".
|
||
*/
|
||
.chip {
|
||
appearance: none;
|
||
border: 1px solid color-mix(in srgb, var(--reader-line) 90%, transparent);
|
||
background: var(--reader-surface);
|
||
color: var(--reader-muted);
|
||
border-radius: 999px;
|
||
padding: 0.3rem 0.7rem;
|
||
font-size: 0.72rem;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.chip[aria-pressed='true'] {
|
||
background: color-mix(in srgb, var(--reader-accent) 12%, var(--reader-surface));
|
||
border-color: color-mix(in srgb, var(--reader-accent) 45%, transparent);
|
||
color: var(--reader-accent);
|
||
font-weight: 600;
|
||
}
|
||
|
||
@media (hover: hover) and (pointer: fine) {
|
||
.chip[aria-pressed='true']:hover {
|
||
/* The pressed chip keeps its own fill: hover must not erase the state, which is
|
||
the one fact the control carries. */
|
||
background: color-mix(in srgb, var(--reader-accent) 18%, var(--reader-surface));
|
||
}
|
||
}
|
||
|
||
/* ---- 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 ---- */
|
||
|
||
/* ---- 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;
|
||
}
|
||
|
||
.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 mount, as a browsing page over a file manager
|
||
*
|
||
* The one screen that shows the mount rather than the catalog. It has two halves —
|
||
* a grid of the books in a folder, and the file rows — and they share a breadcrumb, a
|
||
* folder, a page and a route. So its job is to stay out of the way: a path you can
|
||
* read at the top, a grid or rows that navigate below it, 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` is the screen's *former* name and is kept as an alias, because
|
||
* the only thing the two names share is this one rule — the row, the pager and the
|
||
* action bar are all still called `.manager-*`, and those names are what the UI
|
||
* review's scenes and selectors reach for. Deleting the alias would be a rename of
|
||
* the wrapper with no reader-visible effect and one more way for a stale selector to
|
||
* silently stop matching.
|
||
*/
|
||
.library-screen,
|
||
.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
|
||
* `.library-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.
|
||
*/
|
||
.library-ui,
|
||
.manager-ui {
|
||
display: contents;
|
||
}
|
||
|
||
/* ---- library: the header's two-page switch ---- */
|
||
|
||
/*
|
||
* 书库 · 浏览 — the shop.
|
||
*
|
||
* Everything here exists because this half of the library is a *different kind of
|
||
* page* from the file list beside it, which is what the review's second round asked
|
||
* for (「两者列表显示逻辑不一样」): a shop puts the search at the top, fills the
|
||
* window with covers, and gives each one a single action. The file page puts a
|
||
* breadcrumb at the top and gives each row a menu.
|
||
*/
|
||
|
||
/*
|
||
* The search field, on the shop's own scroller.
|
||
*
|
||
* The field itself is the shelf's control — same pill, same glyph, same 16px — so
|
||
* this rule only says where it sits and how wide it is allowed to be. It is inset by
|
||
* the same token the shelf's is (`--shelf-search-inset`), which is the way two screens
|
||
* keep their one shared control looking like one control.
|
||
*
|
||
* It is *not* capped at `--shelf-measure`: the shop's grid is uncapped (see below),
|
||
* and a search field that stopped growing while the covers beside it kept going would
|
||
* be the row that disagrees with the page.
|
||
*/
|
||
.library-search {
|
||
max-width: none;
|
||
/*
|
||
* The same gutter the shelf's field has, and *only* that: the field is as wide as
|
||
* the page, so there is no measure to subtract and no second inset to add. The
|
||
* covers below use the same token through their padding (see `.library-browse-grid`),
|
||
* which is what makes the field and the grid share a column.
|
||
*/
|
||
padding-inline: var(--shelf-search-inset);
|
||
box-sizing: border-box;
|
||
margin: calc(var(--shelf-search-inset) + 0.4rem) 0;
|
||
}
|
||
|
||
/*
|
||
* …and the panels that hang off the row are placed against the *input*, not the row.
|
||
*
|
||
* The magnifier and the clear button are `position: absolute` against this box, which
|
||
* resolves against its *padding box* — so a row with a 0.75rem gutter would put both of
|
||
* them in the gutter, i.e. the glyph would float in the margin and the clear button
|
||
* would sit on top of the cover beside it. Their own offsets are therefore the field's
|
||
* border plus the row's padding, which is one number rather than a coincidence.
|
||
*/
|
||
.library-search .search-glyph {
|
||
inset-inline-start: calc(var(--shelf-search-inset) + 0.7rem);
|
||
}
|
||
|
||
.library-search .search-clear {
|
||
inset-inline-end: calc(var(--shelf-search-inset) + 0.3rem);
|
||
}
|
||
|
||
/*
|
||
* The covers, laid out as a shop rather than as a shelf.
|
||
*
|
||
* The one difference from `.book-grid` that matters: this grid has **no measure
|
||
* cap**. The shelf caps its width because a shelf is the reader's own collection at a
|
||
* density they chose, and eleven covers across a desktop window is a wall rather than
|
||
* a list. The library is the *server's* folder: its size is a fact about the disk, and
|
||
* a reader browsing it on a wide screen wants more of it visible, not more margin. So
|
||
* the columns are `auto-fill` with no maximum, and growing the window shows more books.
|
||
*
|
||
* The padding is the file listing's own gutter plus the search inset, because the
|
||
* field above and the covers below it share the column and have to line up.
|
||
*/
|
||
/*
|
||
* The covers' own gutters, and they are *wider* than the field's by the page's own
|
||
* padding: the field is the one control that spans the full content box (it is a
|
||
* single row, and a search box narrower than the list it filters reads as decoration),
|
||
* while the covers keep the page gutter they share with every other grid. The
|
||
* difference is the point of the check below — the field must be inset from the
|
||
* covers, not aligned with them.
|
||
*/
|
||
.library-browse-grid {
|
||
padding: 0.25rem calc(0.9rem + var(--shelf-search-inset)) 0.75rem;
|
||
}
|
||
|
||
/*
|
||
* …and the covers themselves get the shop's own size ladder.
|
||
*
|
||
* The shelf's ladder is tuned for a *capped* column, and the cap is what keeps it
|
||
* from producing eleven covers a row. With the cap removed (this grid has none — see
|
||
* above) the same minimum would tile a 1600px window with covers 130px wide, which is
|
||
* the failure the ladder exists to prevent and would come back the moment the two
|
||
* grids stop sharing a width rule. So the shop's minimums are the next step up, and
|
||
* they keep climbing with the window: more of the folder on screen, and every cover
|
||
* still big enough to read a title on.
|
||
*/
|
||
.library-browse-grid[data-density='compact'] {
|
||
--shelf-cover-min: 6.5rem;
|
||
}
|
||
|
||
.library-browse-grid[data-density='comfortable'] {
|
||
--shelf-cover-min: 11rem;
|
||
}
|
||
|
||
@media (min-width: 40rem) {
|
||
.library-browse-grid {
|
||
--shelf-cover-min: 10.5rem;
|
||
}
|
||
|
||
.library-browse-grid[data-density='compact'] {
|
||
--shelf-cover-min: 8rem;
|
||
}
|
||
|
||
.library-browse-grid[data-density='comfortable'] {
|
||
--shelf-cover-min: 13rem;
|
||
}
|
||
}
|
||
|
||
@media (min-width: 72rem) {
|
||
/*
|
||
* Past the shelf's own measure the shop keeps adding columns instead of margin,
|
||
* because there is nothing to cap to: the covers are already at a readable size
|
||
* and the folder is genuinely large. This is the one place in the product where a
|
||
* wider window shows *more*, and it is the point of an uncapped grid.
|
||
*/
|
||
.library-browse-grid {
|
||
--shelf-cover-min: 11.5rem;
|
||
}
|
||
}
|
||
|
||
/*
|
||
* The one line above the grid, when there is something to say about it.
|
||
*
|
||
* `:empty` rather than a `hidden` attribute, because the component decides the
|
||
* sentence and an empty one should cost no space at all: a blank 18px row above a
|
||
* grid of covers is a gap the reader reads as a rendering mistake.
|
||
*/
|
||
.library-preview-hint {
|
||
padding: 0.5rem calc(0.9rem + var(--shelf-search-inset)) 0;
|
||
font-size: 0.75rem;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.library-preview-hint:empty {
|
||
display: none;
|
||
}
|
||
|
||
/*
|
||
* A preview card: the shelf's card, with a *second* control on it.
|
||
*
|
||
* The card was a `<button>` on the shelf, and it cannot be one here — a button
|
||
* inside a button is invalid, and the browser fixes it by hoisting the inner one
|
||
* out of the outer, which puts the shelve control somewhere else on the page. So
|
||
* the open target is an inner button and the card is a plain box that positions it.
|
||
*
|
||
* `position: relative` is what the shelve control is placed against, and the control
|
||
* sits at the cover's *bottom-left*, opposite the format badge: the two would
|
||
* otherwise overlap on a compact cover, and the badge is the quieter of the two so
|
||
* it is the one that keeps its corner.
|
||
*/
|
||
.book-card.is-row {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
}
|
||
|
||
/*
|
||
* 加入书架, on the cover of a book that is not on the shelf.
|
||
*
|
||
* A pill over the artwork rather than a row under the title: the row under the title
|
||
* is where the author lives, and a control that appears and disappears there would
|
||
* make one card in a row taller than its neighbours — which is the ragged baseline
|
||
* the grid's two-line title exists to prevent. Over the cover it costs nothing, and
|
||
* it is on the artwork the reader is already looking at.
|
||
*
|
||
* It is *absent* rather than disabled for a book that is already shelved: a control
|
||
* that can only be pressed to no effect is a control the reader learns to distrust,
|
||
* and "this one is mine already" is not a state that needs saying on every card.
|
||
*/
|
||
.book-card .book-shelve {
|
||
position: absolute;
|
||
inset-block-start: 0.3rem;
|
||
inset-inline-start: 0.3rem;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.15rem;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
padding: 0.15rem 0.4rem;
|
||
background: color-mix(in srgb, var(--reader-accent) 92%, transparent);
|
||
color: #fff;
|
||
font-size: 0.6rem;
|
||
font-family: inherit;
|
||
line-height: 1.5;
|
||
backdrop-filter: blur(4px);
|
||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
|
||
cursor: pointer;
|
||
touch-action: manipulation;
|
||
}
|
||
|
||
.book-card .book-shelve .icon {
|
||
font-size: 0.7rem;
|
||
}
|
||
|
||
.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;
|
||
/*
|
||
* Rows are separated by a *hairline*, and the last one has none.
|
||
*
|
||
* A divider under the final row of a list draws a line between the content and
|
||
* nothing, which reads as "there is more, scrolled off" — the exact opposite of
|
||
* what the end of a directory means. `:last-child` rather than a class because
|
||
* every list here is rendered from an array and the end of it is the end of it.
|
||
*/
|
||
border-bottom: 1px solid color-mix(in srgb, var(--reader-line) 75%, transparent);
|
||
cursor: pointer;
|
||
touch-action: pan-y;
|
||
}
|
||
|
||
.manager-row:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
|
||
.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.
|
||
*
|
||
* A *circle* with a drawn tick rather than a square with a filled box: the tick is
|
||
* what says "this one is in", and it is the one part of the control that has to be
|
||
* readable at 20px against an accent fill. `::after` rather than an inline glyph so
|
||
* it scales with the box instead of with the font the row happens to inherit.
|
||
*/
|
||
.manager-check {
|
||
position: relative;
|
||
width: 1.25rem;
|
||
height: 1.25rem;
|
||
border-radius: 50%;
|
||
border: 1.5px solid color-mix(in srgb, var(--reader-muted) 55%, transparent);
|
||
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);
|
||
}
|
||
|
||
/*
|
||
* The tick: two borders on a rotated box.
|
||
*
|
||
* Not a glyph from the icon font, because the font is an *application* typeface and
|
||
* applying it inside a row that is 20px tall would mean managing two font sizes in
|
||
* one control. Not an SVG either, for the same reason the rest of this file is not:
|
||
* one shape is not a set, and a set of one has no consistency to enforce.
|
||
*/
|
||
.manager-check::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
margin: auto;
|
||
width: 0.28rem;
|
||
height: 0.52rem;
|
||
border: solid #fff;
|
||
border-width: 0 1.5px 1.5px 0;
|
||
transform: translateY(-0.05rem) rotate(45deg);
|
||
opacity: 0;
|
||
}
|
||
|
||
.manager-row[data-selected='true'] .manager-check::after {
|
||
opacity: 1;
|
||
}
|
||
|
||
.manager-name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
/*
|
||
* A folder and a file, told apart by more than their outline.
|
||
*
|
||
* `folder` and `file-lines` are the same weight and the same 24-unit grid, so in a
|
||
* single column they read as the same kind of thing — which is a real problem in a
|
||
* list whose primary gesture *is* "this one opens, that one does not". The folder
|
||
* takes the accent and the file stays ink: colour is the fastest thing in the row to
|
||
* scan, and it costs nothing that the name beside it does not already say out loud.
|
||
*/
|
||
.manager-row[data-type='dir'] .manager-icon {
|
||
color: var(--reader-accent);
|
||
}
|
||
|
||
.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 color-mix(in srgb, var(--reader-line) 90%, transparent);
|
||
border-radius: 999px;
|
||
padding: 0.02rem 0.35rem;
|
||
font-size: 0.62rem;
|
||
letter-spacing: 0.01em;
|
||
color: var(--reader-muted);
|
||
background: color-mix(in srgb, var(--reader-ink) 3%, transparent);
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
|
||
/*
|
||
* "不在书架" — the exception mark.
|
||
*
|
||
* Filled rather than outlined, and in the accent colour, because it is the one
|
||
* badge in the row that means "you are looking at the reason this book is missing
|
||
* from your shelf". The `warn` style is an outline with accent text and is the
|
||
* other explainer badge; this one is solid so the two are distinguishable at a
|
||
* glance in a list where both can appear.
|
||
*/
|
||
.manager-badge.off {
|
||
border-color: transparent;
|
||
background: color-mix(in srgb, var(--reader-accent) 16%, 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.
|
||
*/
|
||
/* The read-only dimming, on the screen element the library actually uses. The
|
||
`.manager-screen` alias above is kept only for the base layout rule, which the two
|
||
names share; a state selector belongs to the screen, not to the alias. */
|
||
.library-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;
|
||
}
|
||
}
|
||
/* Source management shares the reader's type and colour tokens. */
|
||
.sources-screen { display:flex; flex-direction:column; min-height:0; height:100%; flex:1; }
|
||
.sources-header { display:flex; align-items:center; gap:1rem; padding:1rem; border-bottom:1px solid var(--reader-line); }
|
||
.sources-header h1 { font-size:1.25rem; flex:1; margin:0; }
|
||
.sources-tabs { display:flex; flex-wrap:wrap; gap:.5rem; padding:.75rem 1rem; }
|
||
.sources-body { overflow:auto; padding:0 1rem 2rem; flex:1; }
|
||
.sources-card { max-width:64rem; margin:0 auto 1rem; padding:1rem; border:1px solid var(--reader-line); border-radius:1rem; }
|
||
.sources-card h2 { margin:.25rem 0 1rem; font-size:1.1rem; }
|
||
.sources-card label { display:flex; flex-direction:column; gap:.4rem; margin:.75rem 0; }
|
||
.sources-card input:not([type=checkbox]), .sources-card select, .sources-card textarea { width:100%; box-sizing:border-box; padding:.65rem; border:1px solid var(--reader-line); border-radius:.5rem; background:var(--reader-surface); color:inherit; font:inherit; }
|
||
.sources-card textarea { min-height:6rem; }
|
||
.sources-row { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; justify-content:space-between; padding:1rem 0; border-top:1px solid var(--reader-line); }
|
||
.sources-row > div:first-child { flex:1 1 15rem; min-width:0; overflow-wrap:anywhere; }
|
||
.sources-row small { display:block; margin-top:.35rem; opacity:.75; }
|
||
.sources-actions { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; }
|
||
.sources-actions label { margin:0; }
|
||
.sources-card .sources-consent { flex-direction:row; align-items:center; }
|
||
.sources-search { display:flex; flex-wrap:wrap; align-items:end; gap:.85rem; margin:1rem 0 1.5rem; }
|
||
.sources-search label { flex:1 1 10rem; min-width:0; margin:0; font-size:.85rem; color:var(--reader-muted); }
|
||
.sources-search .source-keyword { flex:2 1 18rem; }
|
||
.sources-search button { min-height:44px; gap:.5rem; }
|
||
.sources-search input, .sources-search select { min-height:44px; color:var(--reader-ink); }
|
||
.catalog-feedback { margin:1rem 0; padding-top:1.25rem; border-top:1px solid var(--reader-line); }
|
||
.search-progress { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem 1rem; padding:1rem; border-radius:.75rem; background:color-mix(in srgb,var(--reader-accent) 6%,var(--reader-paper)); font-size:.9rem; }
|
||
.search-progress progress { width:10rem; max-width:100%; height:.45rem; accent-color:var(--reader-accent); }
|
||
.search-progress small { color:var(--reader-muted); }
|
||
.search-stop { border-color:var(--reader-accent); }
|
||
.search-split { display:flex; position:relative; flex:none; }
|
||
.search-split > .primary:first-child { border-radius:var(--reader-radius, .75rem) 0 0 var(--reader-radius, .75rem); }
|
||
.search-split > .primary:only-child { border-radius:var(--reader-radius, .75rem); }
|
||
.search-split .search-menu-toggle { border-radius:0 var(--reader-radius, .75rem) var(--reader-radius, .75rem) 0; padding-inline:.65rem; border-left:1px solid var(--reader-line); }
|
||
.search-menu-toggle .icon { transform:rotate(90deg); }
|
||
.search-menu { position:absolute; right:0; top:calc(100% + .45rem); z-index:5; width:min(17rem, calc(100vw - 4rem)); padding:.35rem; border:1px solid var(--reader-line); border-radius:.8rem; background:var(--reader-paper); box-shadow:0 8px 24px #0002; }
|
||
.search-menu button { display:flex; align-items:center; width:100%; padding:.65rem .75rem; text-align:left; border:0; background:transparent; color:var(--reader-ink); border-radius:.5rem; }
|
||
.search-menu button:hover, .search-menu button:focus-visible { background:color-mix(in srgb, var(--reader-accent) 12%, transparent); }
|
||
.search-menu small { display:block; margin-top:.2rem; color:var(--reader-muted); font-size:.75rem; }
|
||
.catalog-summary { display:flex; flex-wrap:wrap; gap:.5rem 1rem; justify-content:space-between; align-items:center; }
|
||
.catalog-summary strong { font-size:1rem; }
|
||
.catalog-summary span { display:flex; align-items:center; gap:.4rem; color:var(--reader-muted); font-size:.85rem; }
|
||
.catalog-context { color:var(--reader-muted); font-size:.82rem; line-height:1.7; overflow-wrap:anywhere; margin:.4rem 0 .75rem; }
|
||
.catalog-errors { padding:.7rem 1rem; border:1px solid var(--reader-line); border-radius:.75rem; background:color-mix(in srgb,var(--reader-muted) 5%,var(--reader-paper)); }
|
||
.catalog-errors summary { cursor:pointer; font-size:.85rem; color:var(--reader-muted); }
|
||
.catalog-errors summary:focus-visible { outline:2px solid var(--reader-accent); outline-offset:4px; }
|
||
.catalog-errors ul { list-style:none; margin:.6rem 0 0; padding:0; }
|
||
.catalog-errors li { display:grid; grid-template-columns:minmax(7rem,1fr) minmax(0,3fr) auto; align-items:baseline; gap:.3rem 1rem; padding:.6rem 0; border-top:1px solid var(--reader-line); font-size:.84rem; overflow-wrap:anywhere; }
|
||
.catalog-errors li strong { font-weight:500; }
|
||
.catalog-errors li p { margin:0; line-height:1.6; color:var(--reader-muted); }
|
||
.catalog-errors code { font-size:.7rem; color:var(--reader-muted); }
|
||
.catalog-empty { display:flex; flex-direction:column; align-items:center; gap:.8rem; text-align:center; padding:2.5rem 1rem; }
|
||
.catalog-empty > .icon { font-size:1.7rem; color:var(--reader-muted); }
|
||
.catalog-empty p { margin:0; font-size:.9rem; color:var(--reader-muted); line-height:1.7; }
|
||
.catalog-book { padding:1.4rem 0; align-items:flex-start; }
|
||
.catalog-book strong { font-size:1.05rem; }
|
||
.catalog-book .source-description { line-height:1.75; color:var(--reader-muted); margin:.7rem 0 0; }
|
||
.catalog-book .sources-actions { padding-top:.2rem; }
|
||
.catalog-pagination { display:flex; justify-content:flex-end; gap:.6rem; padding-top:1rem; border-top:1px solid var(--reader-line); }
|
||
@media (max-width: 640px) {
|
||
.catalog-errors li { grid-template-columns:minmax(0,1fr); }
|
||
.sources-search .source-keyword { flex-basis:100%; }
|
||
.sources-search > button { width:100%; }
|
||
.sources-search > .search-split { width:100%; }
|
||
.search-split > .primary:first-child { flex:1; }
|
||
}
|
||
.source-description { max-height:6rem; overflow:auto; font-size:.9rem; white-space:pre-wrap; }
|
||
|
||
/* Mobile reading: the surface and its information strip never resize with chrome. */
|
||
[data-theme='green'] {
|
||
--reader-ink: #23392c;
|
||
--reader-paper: #d5ecd7;
|
||
--reader-surface: #e8f2e8;
|
||
--reader-muted: #617a65;
|
||
--reader-line: #bdd6bf;
|
||
--reader-accent: #497553;
|
||
}
|
||
svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; vertical-align: middle; }
|
||
.reader-screen { --reader-footer-height: calc(8.5rem + var(--safe-bottom)); }
|
||
.reader-screen .book-host {
|
||
inset-block-start: calc(1.8rem + var(--safe-top));
|
||
inset-block-end: calc(1.8rem + var(--safe-bottom) + var(--reader-page-trim, 0px));
|
||
}
|
||
.reading-indicator {
|
||
position: absolute;
|
||
inset-inline: 0;
|
||
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
color: var(--reader-muted);
|
||
background: var(--reader-paper);
|
||
font-size: 0.72rem;
|
||
line-height: 1.2;
|
||
z-index: var(--reader-layer-readout);
|
||
pointer-events: none;
|
||
}
|
||
.reading-indicator-top { top: 0; height: calc(1.8rem + var(--safe-top)); padding-top: calc(0.5rem + var(--safe-top)); }
|
||
.reading-indicator-bottom { bottom: 0; height: calc(1.8rem + var(--safe-bottom)); padding-top: 0.35rem; padding-bottom: var(--safe-bottom); }
|
||
.reading-indicator > span { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
|
||
.indicator-left { text-align: start; }
|
||
.indicator-right { text-align: end; }
|
||
.reading-indicator > span:empty { flex: 0; }
|
||
.reader-screen .topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
gap: 0.5rem;
|
||
height: auto;
|
||
min-height: calc(4.5rem + var(--safe-top));
|
||
padding: calc(0.6rem + var(--safe-top)) 1rem 0.6rem;
|
||
background: var(--reader-surface);
|
||
border: 0;
|
||
box-shadow: 0 2px 12px #0000000a;
|
||
}
|
||
.reader-heading { min-width: 0; display: grid; gap: 0.3rem; }
|
||
.reader-heading strong { font-size: 1.05rem; font-weight: 600; }
|
||
.reader-heading span { font-size: 0.75rem; color: var(--reader-muted); }
|
||
.reader-heading strong, .reader-heading span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.reader-screen .footer {
|
||
height: var(--reader-footer-height);
|
||
padding: 0.5rem 1rem calc(0.4rem + var(--safe-bottom));
|
||
background: var(--reader-surface);
|
||
border: 0;
|
||
box-shadow: 0 -2px 12px #0000000a;
|
||
}
|
||
.reader-screen .progress-row { display: flex; align-items: center; gap: 0.75rem; padding: 0; min-height: 3rem; }
|
||
.reader-screen .nav-chapter { flex: 0 0 auto; font-size: 0.8rem; padding: 0.35rem 0; min-height: 44px; }
|
||
.reader-scrub { flex: 1; min-width: 0; display: grid; text-align: center; }
|
||
.reader-screen .progress-scrubber { width: 100%; min-height: 28px; margin: 0; }
|
||
.reader-screen .progress-page { font-size: 0.65rem; color: var(--reader-muted); }
|
||
.reader-actions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.25rem; }
|
||
.reader-actions .top-button { min-width: 0; max-width: none; min-height: 60px; padding: 0.45rem 0; gap: 0.45rem; }
|
||
.reader-actions .icon { font-size: 1.4rem; }
|
||
.reader-actions .top-button-label { font-size: 0.75rem; font-weight: 400; }
|
||
.reader-screen .reader-rail {
|
||
top: auto;
|
||
bottom: calc(var(--reader-footer-height) + 1rem);
|
||
left: auto;
|
||
right: max(1rem, var(--safe-right));
|
||
width: auto;
|
||
max-height: none;
|
||
padding: 0;
|
||
transform: none;
|
||
border: none;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
}
|
||
.reader-screen .rail-button { width: 48px; height: 48px; border-radius: 50%; background: var(--reader-surface); box-shadow: 0 3px 12px #00000020; }
|
||
.reader-screen .rail-button .icon { font-size: 1.5rem; }
|
||
.reader-screen .panel { border-radius: 20px 20px 0 0; max-height: 62%; background: var(--reader-surface); box-shadow: 0 -6px 30px #00000018; }
|
||
.reader-screen .panel-header { padding: 0.5rem 1.25rem 0.75rem; }
|
||
.reader-screen .panel-body { padding-inline: 1.25rem; }
|
||
.reader-screen .panel .field { padding-block: 0.8rem; }
|
||
.reader-screen .panel .tts-bar { position: static; border-radius: 12px; width: auto; transform: none; margin-bottom: 1rem; }
|
||
.reader-screen .panel-header > .icon-button { margin-inline-start: auto; }
|
||
.reader-screen .panel .field { grid-template-columns: minmax(5.5rem, max-content) minmax(0, 1fr); gap: 0.75rem; padding-block: 0.5rem; }
|
||
.reader-screen .field input[type='range'] { min-height: 32px; }
|
||
.reader-screen .field input[type='range']::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--reader-line); }
|
||
.reader-screen .field input[type='range']::-webkit-slider-thumb { appearance: none; width: 18px; height: 18px; margin-top: -7px; border-radius: 50%; background: var(--reader-accent); }
|
||
.reader-screen .field input[type='range']::-moz-range-track { height: 4px; border-radius: 2px; background: var(--reader-line); }
|
||
.reader-screen .field input[type='range']::-moz-range-thumb { width: 18px; height: 18px; border: 0; border-radius: 50%; background: var(--reader-accent); }
|
||
|
||
.sources-card h3, .sources-body > p { overflow-wrap:anywhere; }
|
||
.sources-card form + form { margin-top:.65rem; }
|
||
section[aria-label="切换书源"] { padding:.75rem 0; overflow-wrap:anywhere; }
|
||
section[aria-label="切换书源"] select { display:block; width:100%; padding:.6rem; margin:.5rem 0; color:inherit; background:var(--reader-surface); }
|
||
section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; padding:.5rem; }
|
||
|
||
/* Generic declarative extension pages, scoped to the selected source instance. */
|
||
.extension-screen .sources-body { width: 100%; max-width: 64rem; margin-inline: auto; box-sizing: border-box; }
|
||
.extension-description { color: var(--muted, #777); margin: 0 0 16px; line-height: 1.6; }
|
||
.extension-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border, #ddd); margin-bottom: 20px; }
|
||
.extension-tabs button { flex: 1; padding: 12px 8px; background: transparent; color: inherit; border: 0; border-bottom: 3px solid transparent; cursor: pointer; font: inherit; }
|
||
.extension-tabs button[aria-selected="true"] { border-bottom-color: currentColor; font-weight: 700; }
|
||
.extension-tabs button:focus-visible { outline: 2px solid currentColor; outline-offset: -3px; }
|
||
.extension-screen h2 { font-size: 1rem; margin: 20px 0 12px; }
|
||
.extension-screen h3 { margin: 0 0 8px; }
|
||
.extension-item-heading { display: flex; gap: 16px; align-items: start; justify-content: space-between; }
|
||
.extension-item-heading > div { min-width: 0; }
|
||
.extension-item-heading > button { flex-shrink: 0; }
|
||
.extension-item .source-description { margin: 0; overflow-wrap: anywhere; }
|
||
.extension-actions:not([hidden]) { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||
.extension-actions form:has(label) { flex-basis: 100%; }
|
||
.extension-screen input:not([type=checkbox]), .extension-screen textarea, .extension-screen select { box-sizing: border-box; max-width: 100%; }
|
||
.extension-screen textarea { min-height: 9rem; }
|
||
.extension-empty { padding: 28px 16px; text-align: center; color: var(--muted, #777); line-height: 1.7; }
|
||
|
||
.extension-toolbar { flex: none; width: 100%; max-width: 64rem; margin-inline: auto; padding: 0 1rem; box-sizing: border-box; }
|
||
.extension-toolbar .extension-tabs { margin-bottom: .75rem; border-color: var(--reader-line); }
|
||
.extension-toolbar .notice { margin: 0 0 .75rem; overflow-wrap: anywhere; }
|
||
.extension-description, .extension-empty { color: var(--reader-muted); }
|
||
.extension-screen .sources-header h1 { min-width: 0; overflow-wrap: anywhere; }
|
||
.extension-form-inline { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; }
|
||
.extension-form-inline h3 { flex-basis: 100%; }
|
||
.extension-screen .extension-form-inline label { flex: 1 1 14rem; min-width: 0; margin: 0; }
|
||
.extension-screen .extension-form-inline label[data-field-type=number] { flex: 0 1 15rem; }
|
||
.extension-screen label[data-field-type=boolean] { flex-direction: row; align-items: center; }
|
||
.extension-screen input[type=checkbox] { width: 1.25rem; height: 1.25rem; }
|
||
.extension-actions form + form { margin-top: 0; }
|
||
.extension-actions .extension-form-inline { border-top: 1px solid var(--reader-line); padding-top: .75rem; }
|
||
.extension-confirm { border: 1px solid var(--reader-line); border-radius: .5rem; padding: .75rem; margin-top: .5rem; }
|
||
.extension-confirm p { margin: 0 0 .75rem; }
|
||
.extension-confirm button + button { margin-inline-start: .5rem; }
|
||
.extension-screen button, .source-next-step .button { min-height: 44px; }
|
||
.source-catalog, .source-editor { scroll-margin-top: 1rem; }
|
||
@media (max-width: 600px) {
|
||
.extension-screen .extension-form-inline label { flex-basis: 100%; }
|
||
.extension-screen .extension-form-inline label[data-field-type=number] { flex: 1 1 9rem; }
|
||
.extension-item-heading { gap: .75rem; }
|
||
}
|
||
.extension-tabs button:disabled { opacity: .55; cursor: default; }
|
||
.extension-output { min-width: 0; }
|
||
.extension-output summary { font-weight: 650; cursor: pointer; padding-block: .35rem; }
|
||
.extension-output pre { max-height: 26rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; margin: .75rem 0 0; padding: 1rem; background: var(--reader-surface); border: 1px solid var(--reader-line); border-radius: .5rem; font: .8125rem/1.7 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||
.extension-output pre:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||
|
||
/* Source hub: navigation tabs, one primary action per row, secondary settings on demand. */
|
||
.sources-hub .sources-tabs { flex: none; flex-wrap: nowrap; gap: 0; padding: 0 1rem; width: 100%; max-width: 66rem; margin: 0 auto 1rem; }
|
||
.sources-hub .sources-tabs [role=tab] { flex: 1; min-width: 0; min-height: 52px; padding: .85rem .35rem; border: 0; border-bottom: 2px solid var(--reader-line); background: transparent; color: var(--reader-muted); font: inherit; cursor: pointer; }
|
||
.sources-hub .sources-tabs [aria-selected=true] { color: var(--reader-ink); border-bottom-color: var(--reader-accent); font-weight: 650; }
|
||
.sources-hub .sources-tabs [role=tab]:focus-visible, .source-manage-trigger:focus-visible, .source-management-link:focus-visible { outline: var(--focus-ring); outline-offset: -2px; }
|
||
.sources-tab-dot { display: inline-block; width: 6px; height: 6px; margin: 0 0 .2rem .35rem; border-radius: 50%; background: var(--reader-accent); }
|
||
.sources-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .5rem; }
|
||
.sources-list-heading h2 { margin: 0; }
|
||
.source-count { margin-inline-start: .3rem; font-size: .8rem; color: var(--reader-muted); font-weight: 400; }
|
||
.source-add { display: inline-flex; align-items: center; gap: .3rem; min-height: 44px; padding: .5rem .7rem; }
|
||
.sources-list .source-entry { display: block; padding: 1rem 0; }
|
||
.sources-list .source-entry:last-child { padding-bottom: 0; }
|
||
.source-entry-top { display: flex; align-items: center; gap: 1rem; }
|
||
.source-entry-info { flex: 1; min-width: 0; }
|
||
.source-entry-info strong { display: block; line-height: 1.5; }
|
||
.source-entry-info small { font-size: .8rem; color: var(--reader-muted); opacity: 1; }
|
||
.source-state { display: inline-block; font-size: .72rem; color: var(--reader-muted); margin-top: .5rem; }
|
||
.source-state::before { content: ''; display: inline-block; height: 5px; width: 5px; border-radius: 50%; background: currentColor; vertical-align: middle; margin-right: .35rem; }
|
||
.source-state.is-enabled { color: var(--reader-accent); }
|
||
.source-entry-actions { display: flex; align-items: center; gap: .6rem; flex-shrink: 0; }
|
||
.source-manage-trigger { display: inline-flex; align-items: center; justify-content: center; gap: .25rem; min-height: 44px; padding: .4rem; border: 0; background: transparent; font: inherit; font-size: .85rem; color: var(--reader-muted); cursor: pointer; }
|
||
.source-manage-trigger[aria-expanded=true] .icon { transform: rotate(90deg); }
|
||
.source-management { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem 1rem; margin-top: .85rem; padding: .5rem .75rem; border-radius: .6rem; background: color-mix(in srgb, var(--reader-ink) 4%, transparent); }
|
||
.source-management-link { display: inline-flex; align-items: center; justify-content: space-between; gap: .5rem; min-height: 44px; padding: .5rem 0; border: 0; background: transparent; color: var(--reader-ink); font: inherit; font-size: .85rem; text-decoration: none; cursor: pointer; }
|
||
.source-management-link:disabled, .source-manage-trigger:disabled, .sources-hub [role=tab]:disabled { opacity: .5; cursor: default; }
|
||
.source-toggle { color: var(--reader-muted); }
|
||
.sources-hub .sources-tabs [role=tab] { font-size: .9rem; }
|
||
.source-picker label { max-width: 32rem; }
|
||
@media (min-width: 768px) {
|
||
.source-picker { display:flex; align-items:center; flex-wrap:wrap; gap:1rem 2rem; }
|
||
.source-picker > div { flex:1; }
|
||
.source-picker > div h2 { margin:0 0 .35rem; }
|
||
.source-picker > div p { margin:0; font-size:.85rem; }
|
||
.source-picker > label { flex:1; margin:0; font-size:.85rem; color:var(--reader-muted); }
|
||
.source-picker > p { flex-basis:100%; margin:0; }
|
||
}
|
||
.source-modal { color: var(--reader-ink); background: var(--reader-paper); border: 1px solid var(--reader-line); border-radius: 1rem; padding: 0; width: min(36rem, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow: hidden; }
|
||
.source-modal[open] { display: flex; flex-direction: column; }
|
||
.source-modal::backdrop { background: #0007; }
|
||
.source-modal-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .75rem 1rem; border-bottom: 1px solid var(--reader-line); flex: none; }
|
||
.source-modal-header h2 { margin: 0; font-size: 1.15rem; }
|
||
.source-modal-form.sources-card { display: flex; flex-direction: column; margin: 0; border: 0; padding: 0; width: 100%; min-height: 0; overflow: hidden; }
|
||
.source-modal-content { overflow-y: auto; overscroll-behavior: contain; padding: 0 1rem 1rem; }
|
||
.source-modal-actions { display: flex; flex-wrap: wrap; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid var(--reader-line); flex: none; }
|
||
.source-modal-actions button { min-height: 44px; }
|
||
|
||
/* Collection pages share a quiet heading, labelled destinations and useful empty states. */
|
||
.shelf-head { align-items: center; padding-top: calc(1.25rem + var(--safe-top)); padding-bottom: .85rem; }
|
||
.shelf-head-actions { margin-top: 0; }
|
||
.shelf-head .icon-button, .collection-heading .icon-button { width: 44px; height: 44px; border-radius: .8rem; }
|
||
.shelf-subtitle { margin-top: .45rem; line-height: 1.5; }
|
||
.collection-header { padding: calc(1.25rem + var(--safe-top)) calc(.9rem + var(--safe-right)) .75rem calc(.9rem + var(--safe-left)); flex: none; }
|
||
.collection-heading { display: flex; align-items: center; gap: .5rem; }
|
||
.collection-heading-text { flex: 1; min-width: 0; }
|
||
.collection-heading h1 { margin: 0; font-size: 1.5rem; font-weight: 650; letter-spacing: -.02em; line-height: 1.15; }
|
||
.collection-heading p { margin: .45rem 0 0; font-size: .78rem; line-height: 1.5; }
|
||
.collection-links { display: flex; gap: .65rem; margin-top: .85rem; }
|
||
.shelf-links { max-width: calc(var(--shelf-measure) - 1.8rem); margin: 0 auto .9rem; padding-inline: calc(.9rem + var(--safe-left)) calc(.9rem + var(--safe-right)); }
|
||
.collection-link.button { display: flex; align-items: center; gap: .6rem; flex: 1; min-width: 0; min-height: 48px; padding: .7rem .85rem; font-size: .875rem; border-radius: .8rem; background: color-mix(in srgb, var(--reader-accent) 4%, var(--reader-paper)); border-color: color-mix(in srgb, var(--reader-line) 65%, transparent); text-align: start; }
|
||
.collection-link > span { flex: 1; }
|
||
.collection-link[hidden] { display: none; }
|
||
.collection-link > .icon:first-child { color: var(--reader-accent); }
|
||
.collection-link > .icon:last-child { color: var(--reader-muted); font-size: .8rem; }
|
||
.shelf-screen .shelf-search input, .library-search input { border-radius: .85rem; min-height: 48px; }
|
||
.library-browse-screen .library-search { margin-top: .35rem; }
|
||
.library-path { margin-top: .75rem; padding: .25rem 0 0; }
|
||
.library-path .manager-crumb { min-height: 44px; }
|
||
.collection-empty.empty-state { width: calc(100% - 2rem); max-width: 30rem; margin: clamp(1.5rem, 7vh, 4rem) auto 2rem; padding: 1.75rem 1.25rem; gap: .7rem; border: 1px solid var(--reader-line); border-radius: 1.25rem; background: color-mix(in srgb, var(--reader-surface) 45%, var(--reader-paper)); }
|
||
.collection-empty .empty-glyph { box-sizing: content-box; width: 30px; height: 30px; padding: 1rem; border-radius: 1.1rem; background: color-mix(in srgb, var(--reader-accent) 9%, transparent); color: var(--reader-accent); opacity: 1; margin-bottom: .5rem; }
|
||
.collection-empty p { font-size: 1.1rem; line-height: 1.5; }
|
||
.collection-empty p.muted { max-width: 23rem; font-size: .875rem; line-height: 1.8; text-wrap: pretty; }
|
||
.collection-empty .empty-actions { margin-top: .6rem; gap: .65rem; }
|
||
.collection-empty .button { min-height: 44px; font-size: .875rem; }
|
||
.library-browse-body > .manager-status { background: transparent; border: 0; margin: .75rem auto; }
|
||
.file-manager-header .manager-crumbs { margin-top: .75rem; padding: .25rem 0; }
|
||
.file-manager-header .manager-crumb { min-height: 44px; }
|
||
.library-files-screen .manager-list { margin: .5rem 1rem; border: 1px solid var(--reader-line); border-radius: 1rem; overflow: hidden; }
|
||
.library-files-screen .manager-row { grid-template-columns: 24px minmax(0, 1fr) 44px; gap: .4rem .65rem; padding: .9rem .75rem; }
|
||
.library-files-screen .manager-check { grid-column: 1; grid-row: 1 / 3; }
|
||
.library-files-screen .manager-name { grid-column: 2; grid-row: 1; }
|
||
.library-files-screen .manager-label { white-space: normal; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.5; }
|
||
.library-files-screen .manager-meta { grid-column: 2; grid-row: 2; flex-wrap: wrap; gap: .3rem .65rem; white-space: normal; padding-left: 1.5rem; }
|
||
.library-files-screen .manager-more { grid-column: 3; grid-row: 1 / 3; width: 44px; height: 44px; }
|
||
.library-files-screen .manager-status { position: static; border: 0; background: transparent; padding: .5rem 1rem 1rem; }
|
||
.shelf-settings.panel { position: fixed; }
|
||
.shelf-settings .panel-header { padding: .75rem 1rem; flex: none; }
|
||
.shelf-settings .panel-header h2 { flex: 1; }
|
||
.shelf-settings .panel-header > .icon-button { flex: none; margin-inline-start: auto; width: 44px; height: 44px; }
|
||
.shelf-settings .panel-body { padding-top: 0; }
|
||
.shelf-settings .section-title:first-child { margin-top: 0; }
|
||
.shelf-discovery { display: contents; }
|
||
.plugin-install { margin-block: 1rem 1.5rem; border: 1px solid var(--reader-line); border-radius: .85rem; overflow: hidden; }
|
||
.plugin-install-tabs { display: flex; border-bottom: 1px solid var(--reader-line); }
|
||
.plugin-install-tabs button { flex: 1; min-height: 44px; padding: .75rem; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--reader-muted); font: inherit; cursor: pointer; }
|
||
.plugin-install-tabs button[aria-selected=true] { border-bottom-color: var(--reader-accent); color: var(--reader-ink); font-weight: 600; }
|
||
.plugin-install form { display: flex; flex-direction: column; gap: .85rem; padding: 1.25rem; margin: 0; min-width: 0; }
|
||
.plugin-install form[hidden] { display: none; }
|
||
.plugin-install small { color: var(--reader-muted); line-height: 1.5; }
|
||
.plugin-install input { width: 100%; min-width: 0; box-sizing: border-box; }
|
||
.plugin-install input[type=file] { padding: .5rem; }
|
||
.plugin-install .button { align-self: flex-start; }
|
||
/* Desktop keeps navigation compact and settings beside the collection. */
|
||
@media (min-width: 64rem) {
|
||
.shelf { --shelf-measure: var(--collection-measure); }
|
||
.shelf-head, .shelf-discovery, .shelf-section { width: calc(100% - 3rem); }
|
||
.shelf-head { padding-top: 2rem; padding-bottom: 1.25rem; }
|
||
.shelf-discovery { display: flex; align-items: center; gap: 1.5rem; max-width: var(--shelf-measure); margin: 0 auto 1rem; padding-inline: .9rem; }
|
||
.shelf-discovery .shelf-links { flex: none; margin: 0; padding: 0; }
|
||
.shelf-discovery .shelf-search { flex: 1; max-width: 36rem; margin: 0 0 0 auto; }
|
||
.collection-link.button { flex: none; min-width: 9rem; min-height: 44px; }
|
||
.collection-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .5rem 2rem; padding-top: 2rem; }
|
||
.collection-header .collection-links { margin-top: 0; }
|
||
.collection-header .manager-crumbs { grid-column: 1 / -1; }
|
||
.collection-header, .library-browse-body, .library-files-screen .manager-body { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; }
|
||
.library-files-screen .manager-row { grid-template-columns: 24px minmax(0, 1fr) minmax(15rem, 22rem) 44px; min-height: 64px; padding-block: .5rem; }
|
||
.library-files-screen .manager-check { grid-row: 1; }
|
||
.library-files-screen .manager-meta { grid-column: 3; grid-row: 1; justify-content: flex-end; padding-left: 0; }
|
||
.library-files-screen .manager-more { grid-column: 4; grid-row: 1; }
|
||
.shelf-settings.panel { inset: 1rem 1rem auto auto; width: 26rem; height: min(42rem, calc(100dvh - 2rem)); max-height: calc(100dvh - 2rem); border: 1px solid var(--reader-line); border-radius: 1rem; box-shadow: 0 12px 48px #0003; overflow: hidden; animation-name: shelf-settings-in; }
|
||
.shelf-settings .switch-field { grid-template-columns: minmax(0, 1fr) auto; }
|
||
.sources-header { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; padding: 2rem .9rem .75rem; border-bottom: 0; }
|
||
.sources-header h1 { font-size: 1.5rem; font-weight: 650; }
|
||
.sources-hub .sources-tabs [role=tab], .extension-tabs button { flex: 0 1 auto; min-width: 9rem; padding-inline: 1.5rem; }
|
||
.sources-hub .sources-body, .extension-screen .sources-body, .sources-hub .sources-tabs, .extension-toolbar { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; padding-inline: .9rem; }
|
||
.sources-body .sources-card { max-width: none; }
|
||
.reader-screen { --reader-footer-height: 5.5rem; }
|
||
.reader-screen { --desktop-page-width: 52rem; }
|
||
.reader-screen .stage { background: color-mix(in srgb, var(--reader-paper) 95%, var(--reader-ink)); }
|
||
.reader-screen .stage::before { content: ''; position: absolute; inset: 0; width: var(--desktop-page-width); max-width: 100%; margin-inline: auto; background: var(--reader-paper); border-inline: 1px solid var(--reader-line); box-shadow: 0 0 40px #00000005; pointer-events: none; }
|
||
.reader-screen .book-host[data-layout='reflowable'] { width: var(--desktop-page-width); max-width: 100%; margin-inline: auto; }
|
||
.reader-screen .topbar { inset-inline: 2rem; width: calc(100% - 4rem); max-width: var(--desktop-page-width); min-height: 4.5rem; margin-inline: auto; padding-inline: 1.5rem; border-bottom: 1px solid var(--reader-line); background: var(--reader-paper); box-shadow: none; }
|
||
.reader-screen .topbar .reader-heading strong { font-size: 1rem; }
|
||
.reader-screen .topbar .reader-heading span { font-size: .72rem; }
|
||
.reader-screen .footer { display: flex; align-items: center; gap: 1rem; inset-inline: 2rem; bottom: 1.5rem; width: calc(100% - 4rem); max-width: 50rem; height: 4.5rem; margin-inline: auto; padding: .55rem 1rem; border: 1px solid var(--reader-line); border-radius: .85rem; background: var(--reader-surface); box-shadow: 0 8px 32px #00000012; }
|
||
.reader-screen .progress-row { flex: 1; min-width: 0; }
|
||
.reader-screen .reader-actions { display: flex; gap: .25rem; padding-left: .75rem; border-left: 1px solid var(--reader-line); }
|
||
.reader-screen .reader-actions .top-button { flex-direction: row; min-width: 4.75rem; min-height: 44px; padding: .5rem .7rem; border-radius: .7rem; }
|
||
.reader-screen .reader-actions .top-button:hover { background: color-mix(in srgb, var(--reader-accent) 10%, transparent); }
|
||
.reader-screen .reader-actions .icon { font-size: 1.1rem; }
|
||
.reader-screen .reader-rail { right: max(1rem, calc((100% - var(--desktop-page-width)) / 2 - 4.5rem)); bottom: 2.25rem; }
|
||
.reader-screen .reading-indicator { width: var(--desktop-page-width); max-width: 100%; margin-inline: auto; padding-inline: 1.5rem; font-size: .75rem; }
|
||
.reader-screen .scrim { background: color-mix(in srgb, var(--reader-ink) 12%, transparent); }
|
||
.reader-screen .panel { inset: 1rem 1rem 1rem auto; width: 26rem; height: calc(100% - 2rem); max-height: none; border: 1px solid var(--reader-line); border-radius: 1rem; overflow: hidden; animation-name: shelf-settings-in; }
|
||
.reader-screen .panel[data-placement='start'] { inset-inline: 1rem auto; width: 23rem; animation-name: reader-contents-in; }
|
||
.reader-screen .panel-grip { display: none; }
|
||
.reader-screen .panel-header { flex: none; padding: .75rem 1.25rem; }
|
||
.reader-screen .panel-header .icon-button { width: 40px; height: 40px; border-radius: .6rem; }
|
||
.reader-screen .panel .section-title { font-size: .78rem; font-weight: 650; letter-spacing: .05em; color: var(--reader-accent); padding-top: .9rem; padding-bottom: .65rem; }
|
||
.reader-screen .panel-body { padding-top: 0; }
|
||
.reader-screen .section-title:first-child { margin-top: 0; }
|
||
}
|
||
@keyframes reader-contents-in {
|
||
from { opacity: 0; transform: translateX(-1rem); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
@keyframes shelf-settings-in {
|
||
from { opacity: 0; transform: translateX(1rem); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
@media (max-width: 600px) {
|
||
.source-entry-actions { flex-direction: column; gap: 0; }
|
||
.source-entry-top { align-items: center; }
|
||
.source-management-link { width: 100%; }
|
||
.source-management-link + .source-management-link { border-top: 1px solid var(--reader-line); }
|
||
}
|