/* * Reader stylesheet. * * This file is the single most product-defining piece of CSS in the project, so * its rules are stated explicitly rather than arrived at by accident. * * THE STRATEGY: preserve the author's design, override almost nothing. * * The reason this product exists (product design §9) is that every other * self-hosted library renders books at "web reader" quality, and the reason the * obvious solution is wrong (§5, decision 2) is that Readium CSS takes over the * book's typography to make every book look the same. That is a reasonable * trade for a generic reader and the exact opposite of this product's promise to * faithfully reproduce a carefully typeset book. * * So the rules here follow three principles: * * 1. No property is set unless something actually breaks without it. * 2. Every adjustable property is an inheriting custom property whose default * is the author's value, so "off" means "untouched", not "our default". * 3. Anything that changes the author's intent is a user opt-in that persists, * never a default. * * The comments call out each rule that *does* override, and why it has to. */ /* ------------------------------------------------------------------ * * Host shell: the viewer, not the book. Safe to style freely. * ------------------------------------------------------------------ */ /* * The icon font. * * Declared here rather than in a separate sheet so the face and the token that * sizes it are in one place. `format('truetype')` and a relative URL because the * bundle is loaded from `file://` inside the Android WebView, where an absolute path * resolves to the app's assets root rather than to the bundle. * * `font-display: block` is deliberate: icons are UI, not text. With `swap` a * browser paints the fallback glyph — a literal private-use code point box — for one * frame, and a row of tofu boxes flashing on every cold start reads as a broken app. * The font is 16KB inside the bundle, so there is no network round trip to hide. */ @font-face { font-family: 'Reader Icons'; src: url('./reader-icons.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: block; } :root { --reader-ink: #1a1a1a; --reader-paper: #fbfaf7; --reader-surface: #ffffff; --reader-muted: #6b6b6b; --reader-line: #e3e0da; --reader-accent: #7a5c3e; --reader-radius: 10px; /* * The spacing scale. * * Four steps, not eight. A larger scale reads as precision and behaves as a * suggestion: once `1.75rem` and `2rem` both exist, half the stylesheet uses one * and half the other and the inconsistency is invisible in any single rule. * * The values are the ones the stylesheet already used, promoted to names so that * "put this a bit further from that" becomes "which step is this" — the question * that actually has an answer. */ --space-1: 0.35rem; /* inline gaps inside a control */ --space-2: 0.6rem; /* padding inside a control */ --space-3: 0.9rem; /* gutter between sections */ --space-4: 1.5rem; /* padding inside a page-level band */ /* * The type scale. Named by role rather than by size, because the size is the part * that changes and the role is the part that does not: `--text-meta` is "the line * under a title" whether it is 0.72rem today or 0.75rem later. */ --text-title: 1.05rem; --text-body: 0.95rem; --text-label: 0.82rem; --text-meta: 0.72rem; /* * Motion. * * One duration and one easing for everything, so a panel, a toast and a scrim * move together. Two durations in a UI is the same mistake as two radii: each is * defensible alone and the pair is visibly wrong. * * A `prefers-reduced-motion` block at the end of this file collapses every * duration to a hair, including these, so honouring the preference is one rule * rather than an audit of every transition. */ --motion-duration: 0.18s; --motion-ease: cubic-bezier(0.32, 0.72, 0, 1); /* * The focus ring. * * Defined as a token because it has to be drawn in three situations — on the * paper, on a surface, and over a book's page — and the colour that reads against * one does not read against the others. `currentColor` does not work here: the * ring must stay visible when the control's own colour is the background. */ --focus-ring: 2px solid color-mix(in srgb, var(--reader-accent) 70%, transparent); --focus-offset: 2px; /* * The insets every full-bleed band respects. * * Defined here, in one place, and *not* read straight from `env()` at each use * site. Two reasons, and the second one is the whole point of the indirection: * one name per edge means the phone, the browser and the Android shell cannot * each end up with a slightly different idea of where the screen starts; and * an undefined custom property makes the whole `calc()` invalid, which drops * the declaration silently. A `padding: calc(0.9rem + var(--safe-left))` on an * element whose variable is unset is not "0.9rem of padding" — it is *no * padding at all*, and the fingerprint was a header with zero left inset while * the rule that set it looked correct. */ --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); --safe-left: env(safe-area-inset-left, 0px); --safe-right: env(safe-area-inset-right, 0px); /* * The three user controls, in their default = do-not-touch position. * * `inherit` rather than a concrete value is deliberate for line-height and * font-size: the author set them on `body` or on a specific paragraph, and an * explicit value here would flatten a book that varies them by section. When * the reader picks a size in the UI, JS writes a concrete value. */ --reader-font-scale: 1; --reader-line-height: inherit; --reader-font-family: inherit; --reader-text-align: inherit; --reader-page-margin: 1.5rem; --reader-brightness: 1; --reader-measure: 42rem; --reader-page-padding-inline: 1.5rem; --reader-page-padding-block: 1.25rem; } [data-theme='sepia'] { --reader-ink: #3b3327; --reader-paper: #f5ecd9; --reader-surface: #f9f3e4; --reader-line: #e0d3ba; } [data-theme='dark'] { --reader-ink: #d8d4cc; --reader-paper: #16171a; --reader-surface: #1e2024; --reader-muted: #9a958c; --reader-line: #33363b; --reader-accent: #b39272; } *, *::before, *::after { box-sizing: border-box; } html, body { margin: 0; padding: 0; height: 100%; } body { background: var(--reader-paper); color: var(--reader-ink); font-family: system-ui, -apple-system, 'Segoe UI', 'Noto Sans SC', sans-serif; -webkit-text-size-adjust: 100%; overscroll-behavior: none; } #app { display: flex; flex-direction: column; height: 100%; /* * The reader is an app, not a document: a rubber-band overscroll on Android * shows a white flash behind the paper colour, and a pull-to-refresh gesture * at the top of a chapter reloads the whole SPA. */ overflow: hidden; } /* ------------------------------------------------------------------ * * The book stage * ------------------------------------------------------------------ */ .stage { position: relative; flex: 1 1 auto; min-height: 0; background: var(--reader-paper); overflow: hidden; } /* * The stage's slot in the component tree. * * A wrapper rather than the stage itself, because the stage is owned by the * screen and handed in through a ref callback (see `StageHost`): it is a node * with its own lifetime, and putting it in a tree that re-renders is how a * chapter's shadow root ends up detached from the element the paginator * measured. The wrapper does not inherit `.stage`'s rules, so it has to carry * the one that matters — *growing*. Without this the slot is a zero-height block * in a flex column, and the reader sees the chrome, the header and the footer * with no text between them. */ .stage-host { display: flex; flex: 1 1 auto; min-height: 0; position: relative; } /* * The stage fills its slot. * * `.stage` is `position: relative` with a percentage-free height, which is all it * needs once its parent is a flex container — but the parent is `.stage-host` * above, added when the chrome became a component tree, and a *block* parent * gives an auto-height child its content height instead of the slot's. The stage * then measures zero and the book is laid out in a band at the top of the * screen. `flex: 1` here is what restores "the stage is the space between the * chrome", which is the one thing this layout is supposed to guarantee. */ .stage-host > .stage { flex: 1 1 auto; min-height: 0; } /* * The chapter document is rendered into a shadow root so a book's own * stylesheet cannot reach the app chrome, and the app chrome cannot reach into * the book. The two only meet through the custom properties above. */ .book-host { position: absolute; inset: 0; overflow: hidden; } .book-host[data-layout='reflowable'] { overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; } /* * Fixed-layout pages are advanced by the pager, not scrolled, so the host must * not be a scroll container: a nested scroll area on top of a horizontal pager * produces the classic "the page drifts sideways" bug on Android. */ .book-host[data-layout='fixed'] { overflow: hidden; display: grid; place-items: center; } /* * The reading column's own rules are in `ui/shadow.ts`, not here — and that * separation is load-bearing. * * `.book-flow`, `.fixed-page`, `.reader-speech-highlight` and the page-turn * animations all belong to elements inside the book's *shadow root*, and a * selector in a document stylesheet does not reach into a shadow root. Declaring * them here is therefore indistinguishable from deleting them: the column * rendered at the browser default — no measure, no margins, no font scale, and * no columns in paged mode — while the surrounding chrome, which *is* in the * document, looked perfectly styled. That asymmetry is what made it read as a * missing feature rather than a missing stylesheet. * * What stays below is everything that is genuinely in the document: the * brightness overlay (a pseudo-element on the *host*, which is light DOM), the * host element's own box, and the chrome. */ /* * Brightness. * * A dimming bed-time overlay rather than a change to the palette: the reader's * theme is the author's colours, and darkening them keeps the relationship * between paper and ink. An overlay *on top* would darken the text as much as * the paper, which is what makes a "brightness" slider feel broken. Applied with * `mix-blend-mode: multiply` on a pseudo-element so it costs no extra DOM and * cannot be reached by the book. */ .book-host[data-layout='reflowable']::after { content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 5; background: #000; opacity: calc(1 - var(--reader-brightness)); mix-blend-mode: multiply; } /* * The host is the scroll container in scroll mode and a plain frame in paged * mode; `book-content` is an unknown element to the browser, so its `display` * has to be stated. */ book-content { display: block; } /* ------------------------------------------------------------------ * * Chrome: header, shelf, panels * ------------------------------------------------------------------ */ /* * Whether the chrome is on screen. * * The topbar and the footer are one state, not two, and that is the whole of the * "沉浸式" request: a reader who hides the navigation wants the page and nothing * else, and a header that hides while the footer stays is a footer that keeps * covering the last two lines of every page. So both are toggled by one attribute * on the screen, both animate with the same duration and easing, and neither can be * on screen without the other. * * The animation is a *slide plus a fade*, and it is applied to the chrome's own * reserved space rather than to the chrome alone: the reader has to end up with a * full-height page, and a header that fades out while still occupying 3rem of the * column leaves the text in a band with a gap above it. `position: absolute` would * give the space back instantly and cover the first line on the way out, so the * band keeps its normal flow role and collapses its own height — which is what * makes the page *grow* into the space as the header leaves rather than jump into * it afterwards. */ .reader-screen { display: flex; flex-direction: column; height: 100%; position: relative; /* * The chrome's own reserved space, in one place. The bands animate between it * and zero, and the stage — which is `flex: 1 1 auto` — takes whatever is left, * so a single number here is what keeps the page from being measured mid-slide. */ --reader-chrome-top: 3.4rem; } .topbar { display: flex; /* * `stretch`, so each button owns the full height of the band. The buttons are * icon-over-label now, and a button that only owned its two lines would leave a * strip of the bar above and below that looked identical but did nothing — on * the one row where the reader is aiming at controls rather than at text. */ align-items: stretch; /* * `space-evenly` rather than `space-between`: three controls spread to the two * ends of a wide screen stop reading as one toolbar and start reading as two * stray icons at opposite corners. Even gaps keep them a row at any width. */ justify-content: space-evenly; gap: var(--space-1); min-height: 3.4rem; padding: 0 0.35rem; background: var(--reader-surface); /* Same hairline treatment as the footer, so the two edges of the reading area are the same weight. */ border-bottom: 1px solid color-mix(in srgb, var(--reader-line) 80%, transparent); padding-top: var(--safe-top); /* The band's own height is the reserved space, and it is what the stage gets back when the chrome hides. `min-height` above is what it is at rest; the transition needs an explicit height to animate to zero, so it is stated. */ height: var(--reader-chrome-top); transition: height var(--motion-duration) var(--motion-ease), opacity var(--motion-duration) var(--motion-ease), transform var(--motion-duration) var(--motion-ease), visibility 0s linear var(--motion-duration); } /* * Hidden: the band collapses and the contents slide up out of it. * * `visibility` rather than `display: none` so the slide has something to animate, * and `visibility: hidden` at the end of it so the (invisible) title cannot be * reached by a screen reader or a tap. `overflow: hidden` is what makes the slide a * slide rather than the header visibly hanging over the text. */ .reader-screen[data-chrome='hidden'] .topbar { height: 0; min-height: 0; opacity: 0; visibility: hidden; overflow: hidden; border-bottom-color: transparent; transform: translateY(-100%); } /* * One icon-over-label control in the top bar. * * A column, so the glyph and the word are one hit target rather than two; the word * is drawn rather than left to a tooltip because the glyphs here are not universal * (a list and a slider read as the same thing without it). The label is the small * `--text-meta` size — it is a caption for the glyph, not a second title. */ .top-button { flex: 1 1 0; max-width: 6rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.15rem; padding: 0.3rem 0.25rem; border: 0; border-radius: var(--reader-radius); background: transparent; color: var(--reader-ink); font: inherit; font-size: 1.15rem; cursor: pointer; touch-action: manipulation; transition: background var(--motion-duration) var(--motion-ease); } .top-button:active { background: color-mix(in srgb, var(--reader-ink) 8%, transparent); } .top-button-label { font-size: var(--text-meta); font-weight: 400; color: var(--reader-muted); line-height: 1; white-space: nowrap; } /* * The reading indicator. * * Two quiet readouts pinned to the corners of the stage, drawn only while the * chrome is hidden. `position: absolute` inside `.reader-screen` (which is * `position: relative`) so they can sit over the top and bottom of the text * without being part of the flow — they must not take height from the page, or * showing them would re-measure the columns and move the text. * * `pointer-events: none` because they are readouts, not controls: a tap on the * chapter name has to reach the tap zone underneath it and turn the page (or * bring the chrome back), not be swallowed by a label. */ .reading-indicator { position: absolute; inset: 0; z-index: 12; pointer-events: none; display: flex; flex-direction: column; justify-content: space-between; padding: calc(0.5rem + var(--safe-top)) calc(var(--reader-page-padding-inline) + var(--safe-right)) calc(0.5rem + var(--safe-bottom)) calc(var(--reader-page-padding-inline) + var(--safe-left)); font-size: var(--text-meta); color: var(--reader-muted); /* A slow fade of its own, offset by the chrome's duration: the indicator comes up as the bars go down, so the reader never sees an empty screen. */ animation: indicator-in var(--motion-duration) var(--motion-ease); } @keyframes indicator-in { from { opacity: 0; } to { opacity: 1; } } .indicator-chapter { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--text-label); } .indicator-progress { font-variant-numeric: tabular-nums; color: var(--reader-ink); opacity: 0.72; } @media (prefers-reduced-motion: reduce) { .reading-indicator { animation: none; } } /* * The icon glyph. * * An icon is one character of a private-use font, so it is laid out as text — and the * only thing that decides where a character lands is the font's own metrics. That is why * this rule is three lines and not a geometry puzzle, and it is also what went wrong: * the rule used to say `line-height: 0; display: inline-block; width: 1em; height: 1em`, * which replaces the font's line box with a 1em box anchored at the baseline. The box * then extends *downward* from the baseline while the glyph's ink is drawn *around* it, * so the ink ended up at the box's top edge with the whole em empty beneath it. Every * container in this stylesheet centres its children, the box was centred correctly, and * the ink was half an em away from where the box said it was — measured at 0.496em * across `menu`, `close` and `folder`, i.e. half the box, in every row, at every size. * * So the box is the font's line box (`line-height: 1`) and the font's line box is * symmetric: `tools/icons/build.mjs` declares an ascent and descent of half the em each * and draws every glyph inside it, so centring the line centres the ink. `width: 1em` is * kept because it is the one thing the layout needs that the font cannot supply — a * predictable advance width, so a row of icons and labels is a row and not a function of * which glyphs happen to be in it. * * `vertical-align: middle` is for the rows that are *not* flex or grid — a glyph inline * in a paragraph of text, which is where `baseline` would otherwise apply and put the * icon's baseline on the text's, half an em low. In a flex or grid container (most of * this app's chrome) it is inert and the container's own alignment does the work, which * is the point: the icon needs no special case in either. * * `font-synthesis: none` matters for a private-use glyph: some engines will synthesise a * bold or oblique from the regular face, and a synthesised icon is a smeared icon. */ .icon { font-family: 'Reader Icons', sans-serif; font-size: 1.15rem; font-style: normal; font-weight: 400; font-synthesis: none; line-height: 1; display: inline-block; width: 1em; text-align: center; vertical-align: middle; speak: never; /* The code point itself must never be selectable or copied. */ user-select: none; -webkit-user-select: none; } /* * A square control with its glyph centred in it. * * `inline-grid` rather than `grid`: a `grid` is block-level, so a button in a row of * text starts a line of its own and the row it was supposed to sit in grows a * baseline that has nothing to do with the text. `vertical-align` is what a control * in a text row is aligned with, and it only applies to inline-level boxes. * * The glyph is centred on the box's *centre* — `place-content`, which is relative to * the box — where the old rule used `place-items` on the implicit grid area. The * area is the same box, so the pixels are the same pixels on every browser that * exists; saying it in terms of the box is what keeps that true when the button grows * a border or a padding, because the centre is still the centre. * * This is also why `.icon` must be a *line box* rather than a 1em square: a grid * centres the box it is given, so the ink has to be in the middle of that box for the * centring to be visible. See the derivation on `.icon`. */ .icon-button { appearance: none; border: 0; background: transparent; color: var(--reader-ink); width: 2.5rem; height: 2.5rem; border-radius: var(--reader-radius); font-size: 1.1rem; display: inline-grid; place-content: center; vertical-align: middle; cursor: pointer; touch-action: manipulation; } .icon-button:active { background: color-mix(in srgb, var(--reader-ink) 10%, transparent); } .button { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-surface); color: var(--reader-ink); border-radius: var(--reader-radius); padding: 0.6rem 1rem; font-size: 0.95rem; cursor: pointer; touch-action: manipulation; } .button.primary { background: var(--reader-accent); border-color: var(--reader-accent); color: #fff; } .button:disabled { opacity: 0.55; cursor: default; } /* * A button whose contents are a glyph and a label. * * The gap is the whole reason this is a rule rather than incidental whitespace: the * glyph is one em wide whatever the font-size, so `gap` is the only thing that can * keep the space between icon and text equal across buttons at different sizes. The * flex line centres both boxes on the button's centre, which is where they have to * be, and cannot be written any other way. */ .icon-text-button { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-1); vertical-align: middle; } /* ---- interactive states ---- */ /* * One focus rule for every control in the product. * * This block did not exist. The stylesheet had a `:focus-visible` rule on the * search field and *nothing* on any button, which meant the only way to see where * the keyboard was on a shelf full of controls was to guess. It is written as a * single rule over a list of selectors rather than per-component so that a new * control inherits the behaviour: adding one to this list is the whole job. * * `:focus-visible` rather than `:focus` on purpose — a mouse click on a button * should not leave a ring behind, and a keyboard tab must always show one. */ .button:focus-visible, .icon-button:focus-visible, .chip:focus-visible, .switch:focus-visible, .segmented button:focus-visible, .toc-list li button:focus-visible, .manager-row:focus-visible, .manager-crumb:focus-visible, .search-clear:focus-visible, .tts-bar button:focus-visible, .dialog button:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); } /* * Hover, for the pointer-equipped half of the audience. * * Guarded by `hover: hover` because on a touch device every tap would otherwise * leave the last-tapped control stuck in its hover appearance — the phone shows a * control as "hovered" as soon as the finger lifts, which makes the state look * broken rather than responsive. */ @media (hover: hover) and (pointer: fine) { .icon-button:hover, .button:hover, .chip:hover, .manager-row:hover, .manager-crumb:hover, .search-clear:hover, .toc-list li button:hover { background: color-mix(in srgb, var(--reader-ink) 6%, transparent); } .button.primary:hover { background: color-mix(in srgb, var(--reader-accent) 88%, var(--reader-ink)); border-color: transparent; } .button.danger:hover { background: color-mix(in srgb, #c0392b 10%, var(--reader-surface)); } } /* * Every state transition in one place, using one duration and one easing. * * The individual rules further down used to carry their own `0.15s ease` / * `160ms ease-out` / `120ms ease`, which is three answers to a question that has * one. A control's feedback should be the same speed as every other control's * feedback, or the app feels like it was assembled from several apps. */ .button, .icon-button, .chip, .manager-row, .manager-crumb, .search-clear, .toc-list li button, .segmented button { transition: background-color var(--motion-duration) var(--motion-ease), border-color var(--motion-duration) var(--motion-ease), color var(--motion-duration) var(--motion-ease), opacity var(--motion-duration) var(--motion-ease); } /* * The controls inside a settings row. * * Sized to the row rather than to the sheet: a select that spans the whole sheet is * a control whose value is far from its label, and a range input that does is a * slider the reader has to hunt for the thumb of. The 16px floor is not cosmetic * either — anything smaller makes iOS Safari zoom the page on focus, which leaves * the layout scrolled and half off-screen with no way back except a pinch. */ .field input, .field select, .field textarea { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-surface); color: var(--reader-ink); border-radius: var(--reader-radius); padding: 0.45rem 0.6rem; font-size: 16px; font-family: inherit; width: 100%; } .field input[type='range'] { padding: 0; border: 0; background: transparent; accent-color: var(--reader-accent); } /* ---- shelf: the home screen ---- */ /* * The shelf is the most-opened screen in the product, and it is also the only one * a reader sees before they have decided whether to trust the app. So its layout * is stated rather than arrived at, and the three decisions below are the ones * the previous version got wrong. * * 1. **It reads top-down like a page, not like a toolbar.** The old version led * with a search box, then counted books, then offered four sort chips — three * rows of controls before a single cover. A library's first job is to show the * library, so the title and the covers come first and the controls sit *with* * the section they act on. * 2. **Nothing is centred.** `.centered-form` and `.empty-state` were `text-align: * center` with `padding: 1.5rem`, which is right for an empty state and wrong * for a shelf of two thousand books. The grid is left-aligned and its own edge * is the alignment. * 3. **The header is not sticky, the toolbar is.** A sticky band carrying the * title costs 4rem of a phone's height permanently to say something the reader * already knows. A sticky band carrying the count and the sort control earns * it: those are the two things a reader scrolls back up to change. */ .shelf { overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0 0 var(--safe-bottom); /* * On a wide window the shelf is centred and capped rather than stretched. * * `auto-fill` on a 1600px monitor otherwise produces eleven covers a row, each * with a twelve-character title — a wall of thumbnails that is *harder* to scan * than a phone's three, which is the opposite of what the extra width is for. A * cap also means the search field and the sort chips stop being a metre wide, * so the eye does not have to travel the full window to use them. * * `--shelf-measure` is the maximum content width, not the viewport's: the cover * size below is scaled against it, so widening the window past the cap adds * margin instead of more columns. */ --shelf-measure: 68rem; /* * One place sets the gutter. Every band inside the scroller — the header, the * search box, the toolbar, the sections — reuses it, so they cannot drift apart * and a cover can never sit nearer the screen edge than the title above it. */ --shelf-gutter: 0.9rem; --shelf-gutter-end: calc(0.9rem + var(--safe-right)); } /* ---- header ---- */ .shelf-head { display: flex; align-items: flex-start; gap: 0.75rem; padding: calc(0.9rem + var(--safe-top)) calc(0.9rem + var(--safe-left)) 0.5rem var(--shelf-gutter-end); max-width: var(--shelf-measure); margin-inline: auto; } .shelf-head-text { flex: 1 1 auto; min-width: 0; } .shelf-title { margin: 0; font-size: 1.55rem; font-weight: 650; letter-spacing: -0.015em; line-height: 1.2; } .shelf-subtitle { margin: 0.2rem 0 0; font-size: 0.78rem; } .shelf-head-actions { display: flex; align-items: center; gap: 0.15rem; flex: 0 0 auto; /* Nudged back up so the 40px target of the first button aligns with the cap height of the title rather than with its baseline. */ margin-top: -0.4rem; } /* ---- search ---- */ /* * The field is a pill with the glyph inside it, and it is exactly as wide as the * content column. `font-size: 16px` is not a taste decision — anything smaller * makes iOS Safari zoom the viewport on focus and leave the layout half * off-screen, which on a *search field* means the shelf jumps every time it is * used. */ .shelf-search { /* * `position: relative` and a *centre*-aligned row: both the magnifier and the * clear button are absolutely positioned against this box, and absolute * positioning resolves against the padding box' top edge, so a row aligned any * other way would put the glyph where the *field* is not. */ position: relative; display: flex; align-items: center; margin: 0.35rem auto 0.9rem; max-width: calc(var(--shelf-measure) - 1.8rem - var(--safe-left) - var(--safe-right)); padding-inline: 0; } /* * The magnifier inside the search field. * * Centred on the field's *centre*: `inset-block: 0` with `margin-block: auto` resolves to * the middle of the containing block, which is where the field's own text is centred too. * The glyph is inline and the box is a line box, so "centre the box" and "centre the * glyph" are the same instruction here. * * The old rule anchored only the inline axis and left the block position to the field's * top edge, which put the glyph's box where the field's centre was and the ink half an em * above that — the same defect as the buttons, in a different container. */ .search-glyph { position: absolute; inset-inline-start: 0.7rem; inset-block: 0; margin-block: auto; font-size: 1.05rem; color: var(--reader-muted); pointer-events: none; } .shelf-search input { appearance: none; flex: 1 1 auto; min-width: 0; width: 100%; border: 1px solid var(--reader-line); background: var(--reader-surface); color: var(--reader-ink); border-radius: 999px; /* Room for the glyph on the left and the clear button on the right. */ padding: 0.62rem 2.4rem 0.62rem 2.15rem; /* * A field is text, so it gets the text' baseline and the icons positioned inside it * are centred against its box instead. The two are only the same line while the * field has symmetric padding, which is what the line above guarantees. */ line-height: 1.4; font-size: 16px; font-family: inherit; } .shelf-search input::placeholder { color: color-mix(in srgb, var(--reader-muted) 85%, transparent); } .shelf-search input:focus-visible { outline: 2px solid var(--reader-accent); outline-offset: 1px; } /* * The clear button sits *inside* the field rather than beside it. * * A phone's `type=search` renders its own clear affordance in some browsers and * none in others, and the one it renders is 12px of low-contrast glyph at the * edge of a control the reader has to hit with a thumb. Owning the button is the * only way to get the same thing in both cases. */ .search-clear { position: absolute; inset-inline-end: 0.3rem; /* * Centred on the field by the geometry both boxes share, not by a transform. * * `top: 50%` + `translateY(-50%)` is the usual way to do this, and it is a way * that has to be repeated at every call site and can be forgotten at one of them. * `inset-block: 0` + `margin-block: auto` says the same thing in terms of the * containing block, so it stays true if the field's height changes. */ inset-block: 0; margin-block: auto; width: 1.9rem; height: 1.9rem; border: 0; border-radius: 50%; background: color-mix(in srgb, var(--reader-ink) 12%, transparent); color: var(--reader-ink); font-size: 0.9rem; display: grid; place-content: center; cursor: pointer; } /* ---- sections ---- */ .shelf-section { padding-inline: calc(0.9rem + var(--safe-left)) calc(0.9rem + var(--safe-right)); margin-bottom: 1.35rem; /* * The measure cap, applied to the *content* bands rather than to the scroller. * * `max-width` + `margin-inline: auto` on the scroller would break `position: * sticky` on the toolbar (a scroll container's own box is what sticky is * measured against) and would make the scrollbar sit inside the centred column * instead of at the window edge. Capping each band keeps both behaviours and * still reads as one centred column, because every band shares the same cap. */ max-width: var(--shelf-measure); margin-inline: auto; } .shelf-section-head { display: flex; align-items: baseline; gap: 0.5rem; } .shelf-section-title { margin: 0.85rem 0 0.55rem; font-size: 0.95rem; font-weight: 620; } /* ---- toolbar: the count and the sort, pinned to their section ---- */ /* * Sticky, and deliberately *only* this band. It carries the two controls a reader * scrolls back up to change — how many books there are, and in what order — and it * is the reason the header above it does not need to stick. */ .shelf-toolbar { position: sticky; inset-block-start: 0; z-index: 4; display: flex; align-items: center; gap: 0.5rem; /* * The band is full-bleed *within its section*, and the section is capped, so * the sticky background matches the column rather than the window. Negative * inline margins undo the section's padding so covers scroll under the blur * instead of appearing beside it. */ /* * Full-bleed with a gutter-sized padding rather than a margin: a sticky band * narrower than its scroller lets the covers scroll past *behind* it on either * side, which looks like a rendering bug. */ margin-inline: calc(-0.9rem - var(--safe-left)) calc(-0.9rem - var(--safe-right)); padding: 0.4rem calc(0.9rem + var(--safe-right)) 0.4rem calc(0.9rem + var(--safe-left)); background: color-mix(in srgb, var(--reader-paper) 88%, transparent); backdrop-filter: blur(10px); } .shelf-count { font-size: 0.75rem; white-space: nowrap; flex: 0 0 auto; } .shelf-sort { display: flex; align-items: center; gap: 0.3rem; overflow-x: auto; scrollbar-width: none; flex: 1 1 auto; min-width: 0; /* The chips scroll from the right edge, so the row reads as "more this way". */ justify-content: flex-end; } .shelf-sort::-webkit-scrollbar { display: none; } .shelf-refresh { font-size: 0.78rem; color: var(--reader-muted); text-align: center; padding: 0.35rem 0; } .shelf-refresh[hidden] { display: none; } .shelf-status { font-size: 0.78rem; text-align: center; padding: 0.75rem 0.9rem calc(0.75rem + var(--safe-bottom)); } /* ---- the grid ---- */ /* * Fixed minimum with `auto-fill`: the number of columns follows the container, so * the same markup works on a phone, a tablet and a desktop without a media query * per breakpoint. Density is a *custom property*, not three * `grid-template-columns` rules on three classes — one declaration, one value, and * the reader's choice cannot disagree with the layout. */ .book-grid { --shelf-cover-min: 7rem; display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--shelf-cover-min), 1fr)); /* * The row gap is larger than the column gap on purpose: a cover and its two * lines of text form one block, and equal gaps make the author name look like * it belongs to the book below it. */ gap: 1.1rem 0.75rem; align-items: start; } .book-grid[data-density='compact'] { --shelf-cover-min: 5rem; gap: 0.9rem 0.6rem; } .book-grid[data-density='comfortable'] { --shelf-cover-min: 9.5rem; gap: 1.35rem 1rem; } /* * Two columns at the widest, on a desktop. * * Left alone, `auto-fill` on a 1600px window produces eleven covers a row, each * 130px wide and each with a 12-character title — a wall of thumbnails that is * harder to scan than a phone's three. Capped, the grid stays a *browsing* surface * and the extra width goes to the covers, which is what makes the titles readable * again. * * The number is a `min()` of a count and a width rather than a media query, * because the thing that makes eleven columns wrong is not "it is a phone" — it is * "the covers got too small", and the two are not the same question. */ /* * Wider screens get *bigger* covers, not more of them. * * The failure this avoids is the one every grid has: `auto-fill` turns a wide * window into a contact sheet, and a 130px cover with a two-line title is less * scannable than a 200px one with the same two lines. The breakpoint moves the * size, and the measure cap above bounds the count — the two together are what * make a desktop shelf look like a shelf. */ @media (min-width: 40rem) { .book-grid[data-density='compact'] { --shelf-cover-min: 7rem; } .book-grid:not([data-density='compact']):not([data-density='comfortable']) { --shelf-cover-min: 9.5rem; } .book-grid[data-density='comfortable'] { --shelf-cover-min: 12rem; } } .book-card { border: 0; padding: 0; background: transparent; color: inherit; text-align: start; cursor: pointer; display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; width: 100%; } /* * The cover. * * `aspect-ratio` rather than a fixed height, so the cover scales with the column. * No border: a 1px hairline on a cover that already has a shadow reads as a frame * around a photo, and the whole point of the shadow is that the cover looks like * an object lying on the paper. */ /* * The cover. * * `display: grid` with `place-items: stretch` and *not* `center`, which is a * distinction worth stating because getting it wrong is invisible in the * stylesheet and obvious on screen: a centred grid item makes the implicit column * shrink to its content, so a cover containing only a title collapsed to 45px * inside a 122px card while a cover containing an image filled it. The card was * the right size and the cover was not, so every row looked ragged for no * reason. Stretching the item and centring the *text* inside the placeholder is * what makes both cases fill the card. */ .book-card .cover { position: relative; /* Explicit, because inside a flex column an `aspect-ratio` box with no width shrink-wraps to its content: a cover holding only a title collapsed to 42px in a 122px card, while one holding an image filled it, so every row of an all-TXT shelf came out ragged. */ width: 100%; aspect-ratio: 2 / 3; border-radius: 8px; overflow: hidden; background: color-mix(in srgb, var(--reader-line) 70%, var(--reader-surface)); box-shadow: 0 1px 2px rgb(0 0 0 / 0.14), 0 3px 10px rgb(0 0 0 / 0.06); display: grid; place-items: stretch; transition: transform var(--motion-duration) var(--motion-ease), box-shadow var(--motion-duration) var(--motion-ease); } .book-card:active .cover { transform: scale(0.975); box-shadow: 0 1px 2px rgb(0 0 0 / 0.16); } .book-card .cover img { width: 100%; height: 100%; object-fit: cover; display: block; } /* * A book with no cover art — every TXT file, plenty of PDFs. * * The title is drawn *as* the cover rather than as a broken-image icon, because * "this book has no cover" and "this library is broken" look identical otherwise, * and the first one is a fact about the book. A tinted panel with the title set in * it also makes the shelf scannable at a glance, which a grey box does not. */ .book-card .cover .placeholder { display: flex; align-items: center; justify-content: center; padding: 0.7rem 0.55rem; font-size: 0.78rem; font-weight: 500; line-height: 1.35; text-align: center; color: color-mix(in srgb, var(--reader-ink) 72%, transparent); background: linear-gradient( 150deg, color-mix(in srgb, var(--reader-accent) 16%, var(--reader-surface)) 0%, color-mix(in srgb, var(--reader-accent) 6%, var(--reader-line)) 100% ); /* Clipped rather than scrolled: a title set to fourteen lines stops being a cover and starts being a paragraph in the wrong place. */ overflow: hidden; word-break: break-word; } .book-card .format-badge { position: absolute; inset-block-end: 0.3rem; inset-inline-end: 0.3rem; background: rgb(0 0 0 / 0.58); color: #fff; border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.6rem; letter-spacing: 0.03em; text-transform: uppercase; backdrop-filter: blur(4px); } .book-card .title { font-size: 0.8rem; line-height: 1.3; font-weight: 520; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; /* Two lines are always occupied, so the author names of a row of covers line up even when one title is short — a ragged baseline under a grid is the difference between "a shelf" and "a list". */ min-height: 2.6em; } .book-card .author { font-size: 0.7rem; color: var(--reader-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .book-grid[data-show-author='false'] .book-card .author { display: none; } .book-grid[data-show-progress='false'] .cover-progress { display: none; } /* * Progress on the cover. * * Absolute rather than a row under the title: the point of the bar is to be * readable while skimming a shelf, and a 3px line under 11px of author text is not * glancing distance from a cover. It sits at the bottom edge of the cover art, * where the eye already is. */ .book-card .cover-progress { position: absolute; inset-inline: 0; inset-block-end: 0; height: 4px; background: rgb(0 0 0 / 0.35); } .book-card .cover-progress span { display: block; height: 100%; background: var(--reader-accent); } /* ---- continue reading ---- */ .continue-row { display: flex; gap: 0.65rem; overflow-x: auto; /* Full-bleed, with padding rather than a margin: the first card should start at the gutter, and the last should be able to scroll *to* the gutter. */ padding: 0.1rem calc(0.9rem + var(--safe-right)) 0.4rem calc(0.9rem + var(--safe-left)); margin-inline: calc(-0.9rem - var(--safe-left)) calc(-0.9rem - var(--safe-right)); scrollbar-width: none; scroll-snap-type: x proximity; } .continue-row::-webkit-scrollbar { display: none; } .shelf-section-head + .continue-row { margin-top: 0; } .continue-card { flex: 0 0 6.5rem; width: 6.5rem; border: 0; padding: 0; background: transparent; color: inherit; text-align: start; cursor: pointer; scroll-snap-align: start; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; } .continue-card .cover { position: relative; width: 100%; aspect-ratio: 2 / 3; border-radius: 7px; overflow: hidden; background: color-mix(in srgb, var(--reader-line) 70%, var(--reader-surface)); box-shadow: 0 1px 2px rgb(0 0 0 / 0.14), 0 3px 10px rgb(0 0 0 / 0.06); display: grid; place-items: stretch; } .continue-card .cover img { width: 100%; height: 100%; object-fit: cover; display: block; } .continue-card .placeholder { display: flex; align-items: center; justify-content: center; font-size: 0.7rem; text-align: center; padding: 0.5rem 0.4rem; line-height: 1.3; word-break: break-word; color: color-mix(in srgb, var(--reader-ink) 72%, transparent); background: linear-gradient( 150deg, color-mix(in srgb, var(--reader-accent) 16%, var(--reader-surface)) 0%, color-mix(in srgb, var(--reader-accent) 6%, var(--reader-line)) 100% ); overflow: hidden; } /* * The progress sits *on* the cover, at its bottom edge. * * Two reasons it is not a line under the title. It has to be readable at a * glance while skimming a horizontal row, and a 4px line beneath the chapter * name is not glancing distance from the cover — the eye is on the artwork. And * a tinted track over the art reads as "this cover is part done" even when the * bar underneath is a thumbnail's worth of pixels, because the *darkening* is * what registers. The fill is the accent, so the two rows of the shelf use the * same colour for the same meaning. */ .continue-card .progress-track { position: absolute; inset-inline: 0; inset-block-end: 0; height: 5px; background: rgb(0 0 0 / 0.45); } .continue-card .progress-track span { display: block; height: 100%; background: var(--reader-accent); box-shadow: 0 0 6px color-mix(in srgb, var(--reader-accent) 70%, transparent); } .continue-card .title { font-size: 0.75rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 2.6em; } .continue-card .muted { font-size: 0.68rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* ---- the first paint ---- */ /* * The shelf's shape, before the shelf exists. * * Drawn in the reader's own density so the fill-in is a fill-in: a skeleton in the * wrong grid makes the reader watch the layout move twice and stops trusting the * first one. The shimmer is animation only — under `prefers-reduced-motion` it * becomes a flat tint, which still says "loading" without moving. */ .book-grid.is-skeleton { pointer-events: none; } .book-grid.is-skeleton .cover { box-shadow: none; } .skeleton-line { height: 0.62rem; border-radius: 3px; background: color-mix(in srgb, var(--reader-line) 80%, var(--reader-surface)); min-height: 0; } .skeleton-line.short { width: 55%; } .book-grid.is-skeleton .cover, .skeleton-line { background-image: linear-gradient( 100deg, transparent 20%, color-mix(in srgb, var(--reader-ink) 5%, transparent) 50%, transparent 80% ); background-size: 220% 100%; animation: shelf-shimmer 1.5s linear infinite; } @keyframes shelf-shimmer { from { background-position: 140% 0; } to { background-position: -40% 0; } } /* ---- empty state ---- */ /* * `margin: auto` inside the section, so the box is centred in whatever height is * left rather than pinned under the toolbar — which is where the old version put * it, half a screen above the fold on a phone. */ .empty-state { margin: 2.5rem auto auto; max-width: 20rem; text-align: center; color: var(--reader-ink); display: flex; flex-direction: column; align-items: center; gap: 0.35rem; } .empty-state p { margin: 0; font-size: 0.95rem; font-weight: 550; } .empty-state p.muted { font-size: 0.78rem; font-weight: 400; line-height: 1.5; } .empty-glyph { font-size: 2.4rem; /* * A block of its own, so the flex column's `gap` owns the space between the glyph and * the text rather than the glyph's own line box. */ display: block; opacity: 0.35; } .empty-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; margin-top: 0.85rem; } /* ---- shelf settings sheet ---- */ .shelf-settings .switch-field { flex-direction: row; align-items: center; gap: 0.75rem; } .shelf-settings .switch-text { flex: 1 1 auto; min-width: 0; font-size: 0.9rem; } .shelf-settings .switch-text .muted { font-size: 0.72rem; margin-top: 0.15rem; } /* * The switch. * * A 2.6rem pill with the knob drawn by `::after`. `aria-pressed` drives both the * colour and the knob position, so there is exactly one source of truth for "is * this on" — a separate class would be a second one, and the two would eventually * disagree. */ .shelf-settings .switch { flex: 0 0 auto; position: relative; width: 2.7rem; height: 1.6rem; border-radius: 1rem; border: 0; background: var(--reader-line); cursor: pointer; transition: background var(--motion-duration) var(--motion-ease); } .shelf-settings .switch::after { content: ''; position: absolute; top: 0.2rem; inset-inline-start: 0.2rem; width: 1.2rem; height: 1.2rem; border-radius: 50%; background: var(--reader-surface); box-shadow: 0 1px 2px rgb(0 0 0 / 0.25); transition: inset-inline-start var(--motion-duration) var(--motion-ease); } .shelf-settings .switch[aria-pressed='true'] { background: var(--reader-accent); } .shelf-settings .switch[aria-pressed='true']::after { inset-inline-start: 1.3rem; } @media (prefers-reduced-motion: reduce) { /* The chrome's own show/hide is a state change, not decoration: it still has to happen, it just does not have to be animated. Collapsing every duration to a hair keeps the reader's preference without leaving the bands half-way. */ .topbar, .footer, .reader-rail { transition-duration: 1ms; animation: none; } .shelf-settings .switch, .shelf-settings .switch::after { transition: none; } .book-card .cover, .book-grid.is-skeleton .cover, .book-grid.is-skeleton .skeleton-line { animation: none; transition: none; } } /* ---- panels: a bottom sheet, half the screen ---- * * These used to be `inset: 0` — full-screen opaque pages that replaced the book. * That is a defensible pattern for a *destination* and the wrong one for a * *control*: choosing a chapter or nudging the font size is an adjustment to the * page underneath, and a full-screen panel makes the reader leave the page to make * it — then come back to a re-laid-out chapter with no memory of what it looked * like before. The panel is therefore a sheet over the *lower half* of the reading * area, and the top half stays readable, which is what makes the change visible * while it is being made. * * Three things follow from that and are not decoration: * * - **A scrim**, because a sheet without one reads as part of the page. It is * also the tap target that closes the sheet, which is the second way out, along * with the ✕ in the header. * - **A handle**, because a sheet that rises from the bottom is understood to be * draggable, and a drag target with nothing to grab is a sheet the reader * cannot close the way they expect. (The drag itself is not implemented; the * handle is the *affordance*, and the scrim and the ✕ are the two real ways out. * Promising a drag that does not exist would be worse than not drawing it, so * the handle is a visual cue and the sheet's own scroll is what moves.) * - **A height that leaves the top of the page alone.** `min(70%, ...)` rather * than a fixed viewport fraction: the contents list of a 1200-chapter book wants * all the room it can get, and the settings list of eleven rows does not want * more than it needs. */ .scrim { position: absolute; inset: 0; z-index: 15; background: color-mix(in srgb, #000 42%, transparent); animation: scrim-in var(--motion-duration) var(--motion-ease); } @keyframes scrim-in { from { opacity: 0; } to { opacity: 1; } } .panel { position: absolute; inset-inline: 0; bottom: 0; z-index: 20; display: flex; flex-direction: column; min-height: 0; /* Half the reading area, and never more than three quarters of it: the reader has to be able to see the page they are adjusting, and a sheet that covers everything is the full-screen panel this replaced. */ height: min(62%, 34rem); max-height: 78%; background: var(--reader-surface); border-top: 1px solid var(--reader-line); border-radius: 16px 16px 0 0; box-shadow: 0 -14px 36px color-mix(in srgb, #000 26%, transparent); animation: sheet-in var(--motion-duration) var(--motion-ease); } @keyframes sheet-in { from { transform: translateY(100%); } to { transform: translateY(0); } } .panel[hidden] { display: none; } /* The grab handle. Decorative, so it is `aria-hidden` in the markup. */ .panel-grip { flex: 0 0 auto; width: 2.25rem; height: 0.25rem; margin: 0.5rem auto 0.125rem; border-radius: 999px; background: var(--reader-line); } .panel-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0.5rem 0.5rem 0.9rem; border-bottom: 1px solid var(--reader-line); } .panel-header h2 { margin: 0; font-size: var(--text-title); font-weight: 600; } /* The count beside the title. Right-aligned against the ✕ rather than next to the title, so the header reads as "title … count … close" and the closing control stays exactly where the reader's thumb expects it. */ .panel-subtitle { flex: 1 1 auto; text-align: end; font-size: var(--text-meta); color: var(--reader-muted); font-variant-numeric: tabular-nums; } .panel-body { flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch; /* `overscroll-behavior: contain` stops a flick at the end of the list from scrolling the *chapter* behind the sheet, which is the classic bottom-sheet annoyance: the list stops moving, the text underneath starts, and the reader has lost their place. */ overscroll-behavior: contain; padding: var(--space-3) 0.9rem calc(var(--space-3) + var(--safe-bottom)); } .toc-list { list-style: none; margin: 0; padding: 0; } /* A row is a row, not a full-width button with a divider under it. * * The list is long and the rows are short, so the divider was doing the work a * rhythm should: rows are separated by their own spacing, and the *current* row is * marked once, loudly, instead of every row being marked equally. */ .toc-list li button { width: 100%; display: flex; align-items: center; gap: var(--space-2); text-align: start; border: 0; background: transparent; color: var(--reader-ink); padding: 0.55rem 0.6rem; border-radius: var(--reader-radius); font-size: var(--text-body); line-height: 1.35; cursor: pointer; touch-action: manipulation; } .toc-list li button:hover { background: color-mix(in srgb, var(--reader-ink) 6%, transparent); } .toc-list li button[aria-current='true'] { color: var(--reader-accent); font-weight: 600; background: color-mix(in srgb, var(--reader-accent) 12%, transparent); } /* A row for a chapter the loaded window cannot reach. It is still listed — the book does have that chapter — but it must not look tappable, because tapping it is the case the panel used to accept in silence. */ .toc-list li button[data-unreachable='true'] { opacity: 0.55; cursor: default; } .toc-list li button[data-unreachable='true']:hover { background: transparent; } /* A chapter's title, and the whole-book position beside it. The position is what makes a long list navigable: it is the only number that tells the reader how far in they are while they scroll. */ .toc-list li .toc-index { flex: 0 0 auto; min-width: 2.4rem; font-size: var(--text-meta); color: var(--reader-muted); font-variant-numeric: tabular-nums; } .toc-list li .toc-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* ---- footer / progress ---- * * The footer has two rows and they are doing different jobs, so they are laid out * differently rather than as two rows of the same thing: * * - the *control* row (☰ ‹ chapter › ⚙) is what the reader aims at, so it is * centred and its controls are one size down from the topbar's — four 2.5rem * controls plus a label is more than a 360px phone has; * - the *readout* row (章节 / 页数 / 百分比) is what the reader glances at, so it * is a single line of small tabular numbers spread across the width, with the * three numbers in the three positions they will always be in. A readout that * moves around as its values change is a readout the reader has to search. */ /* * Hidden: the footer collapses and slides down, exactly as the header does, and on * the same attribute — see `.reader-screen[data-chrome='hidden']`. The progress bar * goes with it rather than staying as a hairline: it is part of the chrome, and a * two-pixel band left behind at the bottom of the screen is the one thing that makes * "沉浸式" look broken. */ .reader-screen[data-chrome='hidden'] .footer { height: 0; opacity: 0; visibility: hidden; overflow: hidden; border-top-color: transparent; transform: translateY(100%); } .footer { background: var(--reader-surface); /* A hairline that survives a dark theme and a bright page equally: one solid colour has to fight whichever side it borders, and this is the one place the footer meets the paper. */ border-top: 1px solid color-mix(in srgb, var(--reader-line) 80%, transparent); padding-bottom: var(--safe-bottom); transition: height var(--motion-duration) var(--motion-ease), opacity var(--motion-duration) var(--motion-ease), transform var(--motion-duration) var(--motion-ease), visibility 0s linear var(--motion-duration); } /* * The scrubber row: a draggable progress slider and the page readout beside it. * * The slider replaces the old 3px bar. That bar could only *report* the position; * a reader who wanted the end of a long chapter had to tap "next" forty times. A * range input is the one control that answers "take me there" in a single gesture, * and it is native — the thumb, the track, the drag physics and the accessibility * (arrow keys, `aria-valuenow`) all come with it rather than being reimplemented. */ .progress-row { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) 0.75rem 0.25rem; } .progress-scrubber { flex: 1 1 auto; min-width: 0; /* Native range inputs default to a chunky, theme-blind widget; `appearance: none` is what lets the tokens below draw it. The track is drawn on the element and the thumb on `::-webkit-slider-thumb` / `::-moz-range-thumb`, which are two separate rule sets because the two engines never agreed on a shared one. */ appearance: none; -webkit-appearance: none; height: 1.5rem; background: transparent; cursor: pointer; touch-action: manipulation; } .progress-scrubber::-webkit-slider-runnable-track { height: 0.3rem; border-radius: 0.15rem; background: color-mix(in srgb, var(--reader-line) 70%, transparent); } .progress-scrubber::-moz-range-track { height: 0.3rem; border-radius: 0.15rem; background: color-mix(in srgb, var(--reader-line) 70%, transparent); } .progress-scrubber::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 1.1rem; height: 1.1rem; /* Pull the thumb up onto the middle of the track: the track is 0.3rem and the thumb 1.1rem, so half the difference is the offset. Without this the thumb sits below the line it is supposed to ride. */ margin-top: -0.4rem; border-radius: 50%; background: var(--reader-surface); border: 2px solid var(--reader-accent); box-shadow: 0 1px 4px color-mix(in srgb, #000 18%, transparent); } .progress-scrubber::-moz-range-thumb { width: 1.1rem; height: 1.1rem; border-radius: 50%; background: var(--reader-surface); border: 2px solid var(--reader-accent); box-shadow: 0 1px 4px color-mix(in srgb, #000 18%, transparent); } .progress-scrubber:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-offset); } /* The page readout: tabular so "第 9/32 页" does not twitch sideways as the denominator changes width. */ .progress-page { flex: 0 0 auto; font-size: var(--text-meta); font-variant-numeric: tabular-nums; color: var(--reader-muted); white-space: nowrap; } /* * The chapter navigation row. * * Three cells: previous chapter, the reading progress as a label, next chapter. * The two buttons are text-with-chevron rather than bare chevrons — the whole row * is a labelled destination, and a bare "‹" beside a bare "›" is a guess. */ .footer-row.chapter-nav { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: 0.15rem 0.75rem 0.65rem; } .nav-chapter { display: inline-flex; align-items: center; gap: 0.15rem; border: 0; background: transparent; color: var(--reader-ink); font: inherit; font-size: var(--text-label); padding: 0.45rem 0.4rem; border-radius: var(--reader-radius); cursor: pointer; touch-action: manipulation; white-space: nowrap; } .nav-chapter:active { background: color-mix(in srgb, var(--reader-ink) 8%, transparent); } .nav-chapter:disabled { opacity: 0.35; cursor: default; } .nav-progress { flex: 1 1 auto; text-align: center; font-size: var(--text-label); color: var(--reader-muted); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* ---- quick-action rail ---- * * The column of controls that shows and hides **with** the two bars, not instead of * them. It used to be drawn only while the chrome was hidden, which made it the one * piece of chrome that survived immersion: a reader who tapped to get "just the * page" still had a floating column over the right edge of the text. The rail holds * the same destinations the top bar names (目录 / 主题 / 字号 / 设置), so it belongs to * the same state — the reader has asked for the controls — and it goes with them. * * Positioned in the *page margin* rather than over the text, which is why it can be * on screen while a reader is reading: the reading column is capped by * `--reader-measure`, so on anything wider than a phone there is a genuine gutter to * put it in. On a phone there is not, and the rail floats over the right edge of the * text — deliberately, because the alternative is not having the controls at all, * and the buttons are translucent discs that a reader reads *through* rather than * around. * * Its vertical position is a compromise made visible: roughly a third of the way * down, which is where the thumb of a hand holding a phone at the bottom lands and * where a hand holding a tablet at the side reaches. It is not centred, because the * centre of the screen is where the reading is. * * The collapse is the *same* mechanism as the bars': a height/opacity/visibility * transition gated on `data-chrome`, so the three bands cannot disagree about * whether the chrome is on screen. `animation` for entry was replaced by a * transition for the same reason: an animation cannot be reversed mid-flight, so a * reader who tapped twice quickly left the rail visible over a hidden toolbar. */ .reader-rail { position: absolute; inset-inline-end: calc(0.35rem + var(--safe-right)); /* * Below the top of the screen rather than centred on it. Two reasons, and the * first one is the one that decided the number: the status pill floats over the * top of the reading area at this very inset (see `.status-bar`), and a rail that * started at 12% would put a button underneath it on every sync. The second is * reach: a hand holding a phone at the bottom of the screen reaches this band, and * the centre of the screen is where the reading is. */ top: calc(22% + var(--safe-top)); z-index: 14; display: flex; flex-direction: column; gap: var(--space-1); padding: 0.2rem; opacity: 1; transform: none; transition: opacity var(--motion-duration) var(--motion-ease), transform var(--motion-duration) var(--motion-ease), visibility 0s linear; } /* * Hidden: it slides out to the right and fades, the direction its own entry motion * came from, so a reader who taps twice sees one movement rather than two. * * Gated on the screen's own `data-chrome`, exactly like `.topbar` and `.footer`. * One attribute, three bands: they cannot disagree about whether the chrome is on * screen, which is the failure mode ("only the toolbar collapsed") that a second * source of truth produces. `visibility: hidden` lands at the *end* of the fade so * a control that is on its way off the screen cannot be tapped or reached by a * screen reader, and `pointer-events: none` covers the interval. */ .reader-screen[data-chrome='hidden'] .reader-rail { visibility: hidden; opacity: 0; transform: translateX(0.5rem); pointer-events: none; transition: opacity var(--motion-duration) var(--motion-ease), transform var(--motion-duration) var(--motion-ease), visibility 0s linear var(--motion-duration); } .rail-button { display: grid; place-items: center; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid color-mix(in srgb, var(--reader-line) 70%, transparent); border-radius: 50%; /* * Translucent, because the rail sits over text on a phone. A solid disc is a hole * in the page; a blurred one is a control the reader can see the paragraph * through, which is what makes it possible to leave it there. */ background: color-mix(in srgb, var(--reader-surface) 78%, transparent); backdrop-filter: blur(6px); color: var(--reader-ink); font-size: 1.05rem; cursor: pointer; touch-action: manipulation; box-shadow: 0 2px 8px color-mix(in srgb, #000 12%, transparent); transition: transform var(--motion-duration) var(--motion-ease), background var(--motion-duration) var(--motion-ease); } .rail-button:active { transform: scale(0.92); background: color-mix(in srgb, var(--reader-surface) 96%, transparent); } /* On a wide screen the rail moves into the gutter the measure leaves, so it is beside the text rather than on top of it. `--reader-measure` is the same token the column uses, so the two cannot disagree about where the text ends. */ @media (min-width: 60rem) { .reader-rail { inset-inline-end: max(0.75rem, calc((100% - var(--reader-measure)) / 2 - 3.5rem)); } } /* ---- quick rows in the settings sheet ---- */ .quick-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-2); padding: 0 0 var(--space-2); } .quick-button { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding: 0.55rem 0.3rem; border: 1px solid color-mix(in srgb, var(--reader-line) 80%, transparent); border-radius: var(--reader-radius); background: color-mix(in srgb, var(--reader-ink) 4%, transparent); color: var(--reader-ink); font: inherit; cursor: pointer; touch-action: manipulation; } .quick-button:active { background: color-mix(in srgb, var(--reader-ink) 10%, transparent); } .quick-button:disabled { opacity: 0.55; cursor: default; } .quick-button .quick-label { font-size: var(--text-meta); color: var(--reader-muted); } .quick-button .quick-value { font-size: var(--text-label); font-variant-numeric: tabular-nums; } /* ---- read-aloud bar ---- */ /* * The bar sits above the footer and, unlike the footer, is *not* hidden with the * chrome: a reader listening while walking needs the play/pause without the * header covering the sentence being read. */ .tts-bar { display: flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.5rem; padding-bottom: calc(0.35rem + env(safe-area-inset-bottom)); background: var(--reader-surface); border-top: 1px solid var(--reader-line); } .tts-bar[hidden] { display: none; } .tts-bar .tts-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; } .tts-bar .tts-text { font-size: 0.78rem; color: var(--reader-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tts-range { width: 100%; margin: 0; accent-color: var(--reader-accent); } .chip { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-surface); color: var(--reader-ink); border-radius: 999px; padding: 0.25rem 0.6rem; font-size: 0.72rem; white-space: nowrap; cursor: pointer; } /* ---- settings panel structure ---- */ /* * A section heading inside a sheet. * * Sticky, and that is the one thing it does. The settings sheet is longer than the * half-screen it lives in, so a reader scrolling to find 亮度 passes 排版 and 翻页 * on the way — and without a sticky heading they arrive at a row with no idea which * group it belongs to. It is also the only sticky thing in the sheet, so it does * not compete with the panel header. */ .section-title { position: sticky; top: 0; z-index: 1; margin: 0 -0.9rem var(--space-2); padding: var(--space-2) 0.9rem 0.35rem; background: var(--reader-surface); border-bottom: 1px solid color-mix(in srgb, var(--reader-line) 60%, transparent); font-size: var(--text-label); font-weight: 600; letter-spacing: 0.02em; color: var(--reader-muted); } .section-title:first-child { margin-top: -0.35rem; } /* A row: label on the left, control on the right, on one line where it fits. * * The old rows were a column — a label, then the control under it — which is right * for a text field and wasteful for a segmented control or a slider: eleven rows of * two lines each is a sheet that scrolls twice as far as it needs to, and the reader * has to re-read every label to find the one control they came for. */ .field { display: grid; grid-template-columns: minmax(4.5rem, max-content) 1fr; align-items: center; gap: var(--space-2) var(--space-3); margin-bottom: var(--space-2); } .field:last-child { margin-bottom: 0; } .field label { font-size: var(--text-label); color: var(--reader-muted); white-space: nowrap; } /* A control that needs the full width takes it, by asking for it. */ .field.is-stacked { grid-template-columns: 1fr; } .field > .segmented, .field > select, .field > input[type='range'] { justify-self: stretch; min-width: 0; } .field[hidden] { display: none; } /* ---- status ---- */ /* * The status line: a floating pill, not a band. * * It used to be a full-width strip in the flex column *between* the header and the * page, which meant every status change moved the book down by a line and back. * On a reader that is the worst possible place for a transient message: the text * the reader is looking at jumps, and the jump is caused by a line about syncing * that they did not ask for. * * Floated over the top of the reading area instead, so it can appear and disappear * without the page moving at all. It is centred on the text column rather than the * screen, so it lands under the reader's eye wherever the column is, and it takes no * pointer events — a message that intercepts the tap meant for the page beneath it * is a message that costs the reader a page turn. */ .status-bar { position: absolute; inset-inline: 0; /* * Just *below* the top bar rather than over it. It used to sit at * `--space-3 + safe-top`, i.e. inside the 3.4rem band — which put the middle of * the pill on top of the middle button of the top bar, so a reader who had just * opened a book could not see (or reach) 目录 until the message faded. The pill * belongs over the *page*, which is where the reader is looking, and it must not * cover the controls they might be reaching for. */ top: calc(var(--reader-chrome-top) + var(--space-2)); z-index: 12; display: flex; align-items: center; gap: 0.4rem; width: fit-content; max-width: min(28rem, calc(100% - 1.5rem)); margin-inline: auto; padding: 0.4rem 0.8rem; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--reader-line) 70%, transparent); background: color-mix(in srgb, var(--reader-surface) 92%, transparent); backdrop-filter: blur(8px); box-shadow: 0 4px 14px color-mix(in srgb, #000 12%, transparent); color: var(--reader-ink); font-size: var(--text-meta); pointer-events: none; animation: status-in var(--motion-duration) var(--motion-ease); } @keyframes status-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } } .status-bar[hidden] { display: none; } .status-bar .status-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .status-dot { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--reader-muted); flex: 0 0 auto; } /* A syncing dot that *does* something: the only state here that is a process rather than an outcome, so it is the only one that earns an animation. */ .status-bar[data-state='syncing'] .status-dot { animation: status-pulse 1.4s ease-in-out infinite; } @keyframes status-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } .status-bar[data-state='syncing'] .status-dot { background: #4a8f5f; } .status-bar[data-state='offline'] .status-dot { background: #c08a2e; } .status-bar[data-state='error'] .status-dot { background: #b6453a; } /* * The snackbar. * * On `document.body`, not inside `#app`: it outlives the screen change it is * usually reporting, and a message mounted inside the screen it is about would * leave the DOM exactly when the reader starts reading it. It is `fixed` to the * bottom, above the safe area, and takes no pointer events, so it can never cover * the control the reader is reaching for. */ .app-toast { position: fixed; inset-inline: 0; inset-block-end: calc(1rem + env(safe-area-inset-bottom)); margin-inline: auto; width: fit-content; max-width: min(24rem, calc(100vw - 2rem)); padding: 0.6rem 1rem; border-radius: 999px; background: color-mix(in srgb, var(--reader-ink) 92%, transparent); color: var(--reader-paper); font-size: 0.82rem; line-height: 1.4; text-align: center; z-index: 60; pointer-events: none; opacity: 0; transform: translateY(0.75rem); transition: opacity var(--motion-duration) var(--motion-ease), transform var(--motion-duration) var(--motion-ease); } .app-toast[data-shown='true'] { opacity: 1; transform: none; } @media (prefers-reduced-motion: reduce) { .app-toast { transition: none; transform: none; } } /* ---- misc ---- */ .empty-state, .centered-form { margin: auto; max-width: 24rem; width: 100%; padding: 1.5rem; text-align: center; } .centered-form { text-align: start; } .notice { background: color-mix(in srgb, var(--reader-accent) 12%, var(--reader-surface)); border-radius: var(--reader-radius); padding: 0.6rem 0.75rem; font-size: 0.82rem; margin-bottom: 0.9rem; } .error-text { color: #b6453a; font-size: 0.85rem; margin-top: 0.5rem; } .muted { color: var(--reader-muted); } .spinner { width: 1.5rem; height: 1.5rem; border: 2px solid var(--reader-line); border-top-color: var(--reader-accent); border-radius: 50%; animation: spin 700ms linear infinite; margin: 1rem auto; } @keyframes spin { to { transform: rotate(360deg); } } .segmented { display: flex; border: 1px solid var(--reader-line); border-radius: var(--reader-radius); overflow: hidden; } .segmented button { flex: 1 1 0; border: 0; background: var(--reader-surface); color: var(--reader-ink); padding: 0.5rem; font-size: 0.85rem; cursor: pointer; } .segmented button[aria-pressed='true'] { background: var(--reader-accent); color: #fff; } /* ------------------------------------------------------------------ * * 书库管理 — the library as a file manager * * The one screen that shows the mount rather than the catalog, so its job is to * stay out of the way: a path you can read at the top, rows that navigate, and * the destructive actions behind an explicit selection. * ------------------------------------------------------------------ */ /* * The screen fills the app and scrolls internally, like every other screen. * `#app` is `overflow: hidden`, so a screen that does not claim `min-height: 0` * on the flex chain pushes its own content off the bottom instead of scrolling. * `position: relative` is what the dialogs and the selection pill are placed * against. */ .manager-screen { position: relative; display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } /* * The host of the screen's component tree. * * The screens are component trees mounted into a container, and the container * must not become a layout box of its own: the styles below are written against * `.manager-screen > .manager-body`, and a wrapper in between would break the * `min-height: 0` chain that lets the list scroll instead of pushing the action * bar off the bottom of the phone. `display: contents` makes the host vanish for * layout purposes and keeps the tree's children exactly where they were. */ .manager-ui { display: contents; } .manager-crumbs { flex: 1 1 auto; display: flex; align-items: center; gap: 0.15rem; overflow-x: auto; scrollbar-width: none; min-width: 0; } .manager-crumbs::-webkit-scrollbar { display: none; } .manager-crumb { appearance: none; border: 0; background: transparent; color: var(--reader-ink); font-size: 0.9rem; font-family: inherit; padding: 0.35rem 0.25rem; white-space: nowrap; cursor: pointer; } .manager-crumb[aria-current='true'] { font-weight: 600; } .manager-crumb-sep { color: var(--reader-muted); } .manager-body { flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch; min-height: 0; } .manager-list { padding: 0; } .manager-row { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--reader-line); cursor: pointer; touch-action: pan-y; } .manager-row:active { background: color-mix(in srgb, var(--reader-ink) 6%, transparent); } .manager-row[data-selected='true'] { background: color-mix(in srgb, var(--reader-accent) 14%, transparent); } /* * The checkbox is always laid out and only made visible in selection mode. * * Toggling `display` instead would shift every row's indent the moment a * selection begins, which reads as the list jumping under the finger that just * started the selection. */ .manager-check { width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 1.5px solid var(--reader-line); box-sizing: border-box; /* Always laid out, only visible while something is selected: toggling `display` would shift every row's indent the moment a selection begins, which reads as the list jumping under the finger that started it. */ opacity: 0; } .manager-row[data-selected] .manager-check { opacity: 1; } .manager-row[data-selected='true'] .manager-check { border-color: var(--reader-accent); background: var(--reader-accent); } .manager-name { display: flex; align-items: center; gap: 0.5rem; min-width: 0; } .manager-icon { font-size: 1rem; flex: 0 0 auto; /* * The row's label is a single line of 0.9rem text in a flex box that centres its * items, so the icon's box centre and the label's box centre already coincide; * `.icon`'s `-0.02em` then puts the *ink* on that same line. Nothing to override — * this comment is here because the rule looks like it should need one. */ } .manager-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9rem; } .manager-meta { display: flex; align-items: center; gap: 0.5rem; font-size: 0.72rem; white-space: nowrap; } .manager-badge { border: 1px solid var(--reader-line); border-radius: 4px; padding: 0 0.25rem; font-size: 0.65rem; color: var(--reader-muted); } /* The badge a reader needs to see is the one explaining a missing book. */ .manager-badge.warn { border-color: color-mix(in srgb, var(--reader-accent) 60%, transparent); color: var(--reader-accent); } .manager-more { width: 2rem; height: 2rem; font-size: 1.1rem; /* * It is an `.icon-button`, so it is already inline-grid and already centred; this * only has to state where in the row it sits. Left to `baseline`, the button's * own baseline — the baseline of the glyph — decides its position, and a glyph box * aligned by its baseline is the misalignment this whole change is about. */ vertical-align: middle; align-self: center; } /* * On a read-only mount the row menu offers nothing but "选择", so it is dimmed * rather than removed: the row still has to line up with the others, and a * column that appears and disappears with the deployment reads as a bug. */ .manager-screen[data-writable='false'] .manager-more { opacity: 0.35; } /* * The counts sit *inside* the scroller, not after it. * * Pinned below a scrolling list they were the first thing pushed off-screen by a * directory of forty files — which is exactly when a reader wants to know how * many there are, and exactly when "删除完成" would have gone unread. */ .manager-status { position: sticky; inset-block-end: 0; font-size: 0.75rem; text-align: center; padding: 0.5rem 0.75rem; background: color-mix(in srgb, var(--reader-surface) 92%, transparent); backdrop-filter: blur(6px); border-top: 1px solid transparent; } .manager-status:not(:empty) { border-top-color: var(--reader-line); } /* * The batch bar scrolls horizontally rather than wrapping or squeezing. * * Five actions do not fit across a phone, and neither alternative works: wrapping * makes the bar two rows tall and pushes the list up under the thumb that is * mid-scroll, and `flex: 1` per button shrinks 移动到… to an ellipsis that no * longer says what it does. A scrolling row keeps every label readable and the * destructive action — deliberately last, so it is never the thing under the * finger that just opened the bar — stays off screen until it is looked for. */ .manager-actions { display: flex; gap: 0.5rem; align-items: center; padding: 0.6rem 0.75rem; padding-bottom: calc(0.6rem + env(safe-area-inset-bottom)); border-top: 1px solid var(--reader-line); background: var(--reader-surface); overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; } .manager-actions::-webkit-scrollbar { display: none; } .manager-actions[hidden] { display: none; } .manager-actions .button { /* `flex: 0 0 auto`, so a label is never truncated — the bar scrolls instead. */ flex: 0 0 auto; white-space: nowrap; } .manager-selection { position: absolute; inset-block-start: calc(env(safe-area-inset-top) + 0.25rem); inset-inline-start: 50%; transform: translateX(-50%); background: var(--reader-ink); color: var(--reader-paper); border-radius: 999px; padding: 0.2rem 0.7rem; font-size: 0.72rem; pointer-events: none; z-index: 25; } .manager-selection[hidden] { display: none; } .button.danger { border-color: color-mix(in srgb, #c0392b 55%, transparent); color: #c0392b; } /* ---- dialogs ---- */ .dialog-overlay { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.4); display: grid; place-items: center; padding: 1rem; z-index: 30; } .dialog { background: var(--reader-surface); color: var(--reader-ink); border: 1px solid var(--reader-line); border-radius: var(--reader-radius); padding: 1rem; width: min(24rem, 100%); display: flex; flex-direction: column; gap: 0.75rem; } .dialog h3 { margin: 0; font-size: 1rem; } .dialog p { margin: 0; font-size: 0.82rem; line-height: 1.5; } .dialog input { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-paper); color: var(--reader-ink); border-radius: var(--reader-radius); padding: 0.6rem 0.7rem; /* 16px, so iOS does not zoom the page on focus and leave the dialog off-screen. */ font-size: 16px; font-family: inherit; width: 100%; } .dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } .dialog-actions .button { flex: 0 1 auto; min-width: 5rem; } .dialog-list { display: flex; flex-direction: column; gap: 0.4rem; } /* ---- batch metadata ---- */ /* * The batch dialog has seven optional fields, which do not fit on a phone. * * The fields scroll and the buttons do not. Scrolling the whole dialog instead * puts 保存 below the fold, so the reader fills the form, sees no way to commit * it, and has to work out that the sheet itself moves — and the on-screen * keyboard takes another third of the height while they are typing in it. * * `max-height` on the *fields* rather than on the dialog also keeps the title * visible, so it stays obvious what is being edited after a scroll. */ .dialog:has(.dialog-field) { max-height: min(86vh, 40rem); overflow: hidden; } .dialog:has(.dialog-field) .dialog-fields { display: flex; flex-direction: column; gap: 0.6rem; overflow-y: auto; min-height: 0; /* Room for the last field's label, so it is not flush against the buttons. */ padding-bottom: 0.2rem; } .dialog-field { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.78rem; color: var(--reader-muted); } .dialog-field input { appearance: none; border: 1px solid var(--reader-line); background: var(--reader-paper); color: var(--reader-ink); border-radius: var(--reader-radius); padding: 0.5rem 0.6rem; /* 16px, so iOS does not zoom the page on focus and push the dialog off-screen. */ font-size: 16px; font-family: inherit; width: 100%; } /* * The file input is the trigger for the ⬆ button and is never shown. * * `display: none` rather than `position: absolute` off-screen: a control that is * laid out still takes a tab stop, and this one has no meaning of its own — the * button beside it is what the user operates. */ .manager-upload-input { display: none; } /* ---- reduced motion ---- */ /* * Honouring `prefers-reduced-motion` in one rule. * * The alternative — a media query per animated rule — guarantees that the next * animation added is the one that ignores the preference. Collapsing the *duration * tokens* here means every rule that uses them (which is now all of them) is covered * by construction, and a rule that hard-codes its own duration is the visible * exception rather than the invisible one. * * `!important` is unavoidable and appropriate: this is a user preference, and a * preference has to beat specificity rather than be outranked by a later rule. */ @media (prefers-reduced-motion: reduce) { :root { --motion-duration: 0.001s; } *, *::before, *::after { animation-duration: 0.001s !important; animation-iteration-count: 1 !important; transition-duration: 0.001s !important; scroll-behavior: auto !important; } }