/* * 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 `` 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 `