mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
界面图标显示正常但歪,实测每个字形都偏离所在盒子中心 0.496em(半个盒子), 也就是 9px 以上,且每一行歪的幅度还不一样。代价是三条互相独立的缺陷叠在 一起,而此前所有图标测试都只读 `cmap` 和字形名,一条都不覆盖这些位置。 一、`.icon` 把字体自己的行盒换成了一个方块(主因) `line-height: 0; display: inline-block; width: 1em; height: 1em` 会得到 一个**锚在基线上、向下一整个 em** 的盒子,而字形是围绕基线画的。于是墨迹贴在 盒子上沿、盒子下方整片是空的。UI 里每个容器都在居中这个盒子,盒子确实居中了, 字形却差了半个 em——这就是「显示正常但不对齐」,也是每行幅度不同的来源:各行的 容器不同,锚法就不同。 改为用字体自己的行盒(`line-height: 1`,不再给高度),只保留 `width: 1em` 这个字体给不了的东西(可预测的前进宽度),并补 `vertical-align: middle` 给 正文里行内出现的图标。**刻意不用 `transform`**:它能在 flex/grid 里生效 (`vertical-align` 在那里是惰性的),但它改的是绘制期位置,会让「图标占的位置」 和「看起来占的位置」不再一致。 二、字体度量与字形不自洽(次因) 表格声明上升部 960 / 下降部 -64(基线靠近 em 底部),而字形是按 `(GRID/2 - y)` 围绕 em 中心画的。两者各自自洽、互相矛盾,而输的是盒子。 现在上升部与下降部各取半个 em:**对称**不是审美——UI 里每个容器都在居中盒子, 只有度量对称时「居中盒子」和「居中字形」才是一回事。 同时修掉三处字段错位(都会静默通过解析): - `OS/2` 的 `sTypoAscender/Descender` 一直写在 panose 区域,真正的排印窗口是 0/0; - `usWinAscent/Descent`(**无符号**)也写错了位置,读出来是 0 / 65506; - `fsSelection` 原本声明 BOLD,`usWeightClass` 是 500——一个不粗的字体谎称自己粗, 引擎就会合成加粗,而合成出来的图标是糊的。改为 `REGULAR | USE_TYPO_METRICS`。 `head` 的每个字形包围盒补一格下限:`more` 的三个点是用 0.06 单位描边写的, 四舍五入后两个轴各只剩一个点,包围盒为空——空包围盒是栅格化器有权跳过的图形, 于是「更多」按钮画出来是白的,而 `cmap` 依然映射它的码位,所以读表的断言全都 满意。`settings` 的路径解析出了一组跑到网格外的坐标(y 到 24.8、x 到 0.8), 重画成真正的齿轮;`chevron-right` 与 `chevron-left` 共用同一个框却差一个单位。 三、图标按钮有两条构建路径 `manager-more` 是手搭的 `<button class="icon-button">`,而 `.icon-button` 的 对齐靠的是「里面有 `.icon`」这个约定——手搭的那条路径绕过了它。现在一律走 `IconButton`,`label` 必填、字形是名字而不是任意子节点;图标加文字的新组合收进 `IconTextButton`,因为那正是最容易歪、且每次都歪得一样的组合。 验证:新增 10 条断言,覆盖字体度量、字形包围盒与 `.icon` 规则本身;把改动逐条 还原后它们会红(已验证)。真浏览器验收:37 个字形逐个测量,墨迹中心与盒子中心 最大偏差 0.88px(改前是 9px+);书架与阅读器两屏在真实产物上截图核对。客户端 `typecheck` / 317 个测试 / `build`、`check-shell.sh`、`verify-web-assets.sh` 全过。 产物 259.68KB → 259.68KB,字体 16.34KB,CSS 24.54KB。 Ref: #34
2217 lines
61 KiB
CSS
2217 lines
61 KiB
CSS
/*
|
|
* Reader stylesheet.
|
|
*
|
|
* This file is the single most product-defining piece of CSS in the project, so
|
|
* its rules are stated explicitly rather than arrived at by accident.
|
|
*
|
|
* THE STRATEGY: preserve the author's design, override almost nothing.
|
|
*
|
|
* The reason this product exists (product design §9) is that every other
|
|
* self-hosted library renders books at "web reader" quality, and the reason the
|
|
* obvious solution is wrong (§5, decision 2) is that Readium CSS takes over the
|
|
* book's typography to make every book look the same. That is a reasonable
|
|
* trade for a generic reader and the exact opposite of this product's promise to
|
|
* faithfully reproduce a carefully typeset book.
|
|
*
|
|
* So the rules here follow three principles:
|
|
*
|
|
* 1. No property is set unless something actually breaks without it.
|
|
* 2. Every adjustable property is an inheriting custom property whose default
|
|
* is the author's value, so "off" means "untouched", not "our default".
|
|
* 3. Anything that changes the author's intent is a user opt-in that persists,
|
|
* never a default.
|
|
*
|
|
* The comments call out each rule that *does* override, and why it has to.
|
|
*/
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Host shell: the viewer, not the book. Safe to style freely.
|
|
* ------------------------------------------------------------------ */
|
|
|
|
/*
|
|
* The icon font.
|
|
*
|
|
* Declared here rather than in a separate sheet so the face and the token that
|
|
* sizes it are in one place. `format('truetype')` and a relative URL because the
|
|
* bundle is loaded from `file://` inside the Android WebView, where an absolute path
|
|
* resolves to the app's assets root rather than to the bundle.
|
|
*
|
|
* `font-display: block` is deliberate: icons are UI, not text. With `swap` a
|
|
* browser paints the fallback glyph — a literal private-use code point box — for one
|
|
* frame, and a row of tofu boxes flashing on every cold start reads as a broken app.
|
|
* The font is 16KB inside the bundle, so there is no network round trip to hide.
|
|
*/
|
|
@font-face {
|
|
font-family: 'Reader Icons';
|
|
src: url('./reader-icons.ttf') format('truetype');
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
font-display: block;
|
|
}
|
|
|
|
:root {
|
|
--reader-ink: #1a1a1a;
|
|
--reader-paper: #fbfaf7;
|
|
--reader-surface: #ffffff;
|
|
--reader-muted: #6b6b6b;
|
|
--reader-line: #e3e0da;
|
|
--reader-accent: #7a5c3e;
|
|
--reader-radius: 10px;
|
|
|
|
/*
|
|
* The spacing scale.
|
|
*
|
|
* Four steps, not eight. A larger scale reads as precision and behaves as a
|
|
* suggestion: once `1.75rem` and `2rem` both exist, half the stylesheet uses one
|
|
* and half the other and the inconsistency is invisible in any single rule.
|
|
*
|
|
* The values are the ones the stylesheet already used, promoted to names so that
|
|
* "put this a bit further from that" becomes "which step is this" — the question
|
|
* that actually has an answer.
|
|
*/
|
|
--space-1: 0.35rem; /* inline gaps inside a control */
|
|
--space-2: 0.6rem; /* padding inside a control */
|
|
--space-3: 0.9rem; /* gutter between sections */
|
|
--space-4: 1.5rem; /* padding inside a page-level band */
|
|
|
|
/*
|
|
* The type scale. Named by role rather than by size, because the size is the part
|
|
* that changes and the role is the part that does not: `--text-meta` is "the line
|
|
* under a title" whether it is 0.72rem today or 0.75rem later.
|
|
*/
|
|
--text-title: 1.05rem;
|
|
--text-body: 0.95rem;
|
|
--text-label: 0.82rem;
|
|
--text-meta: 0.72rem;
|
|
|
|
/*
|
|
* Motion.
|
|
*
|
|
* One duration and one easing for everything, so a panel, a toast and a scrim
|
|
* move together. Two durations in a UI is the same mistake as two radii: each is
|
|
* defensible alone and the pair is visibly wrong.
|
|
*
|
|
* A `prefers-reduced-motion` block at the end of this file collapses every
|
|
* duration to a hair, including these, so honouring the preference is one rule
|
|
* rather than an audit of every transition.
|
|
*/
|
|
--motion-duration: 0.18s;
|
|
--motion-ease: cubic-bezier(0.32, 0.72, 0, 1);
|
|
|
|
/*
|
|
* The focus ring.
|
|
*
|
|
* Defined as a token because it has to be drawn in three situations — on the
|
|
* paper, on a surface, and over a book's page — and the colour that reads against
|
|
* one does not read against the others. `currentColor` does not work here: the
|
|
* ring must stay visible when the control's own colour is the background.
|
|
*/
|
|
--focus-ring: 2px solid color-mix(in srgb, var(--reader-accent) 70%, transparent);
|
|
--focus-offset: 2px;
|
|
|
|
/*
|
|
* The insets every full-bleed band respects.
|
|
*
|
|
* Defined here, in one place, and *not* read straight from `env()` at each use
|
|
* site. Two reasons, and the second one is the whole point of the indirection:
|
|
* one name per edge means the phone, the browser and the Android shell cannot
|
|
* each end up with a slightly different idea of where the screen starts; and
|
|
* an undefined custom property makes the whole `calc()` invalid, which drops
|
|
* the declaration silently. A `padding: calc(0.9rem + var(--safe-left))` on an
|
|
* element whose variable is unset is not "0.9rem of padding" — it is *no
|
|
* padding at all*, and the fingerprint was a header with zero left inset while
|
|
* the rule that set it looked correct.
|
|
*/
|
|
--safe-top: env(safe-area-inset-top, 0px);
|
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
|
--safe-left: env(safe-area-inset-left, 0px);
|
|
--safe-right: env(safe-area-inset-right, 0px);
|
|
|
|
/*
|
|
* The three user controls, in their default = do-not-touch position.
|
|
*
|
|
* `inherit` rather than a concrete value is deliberate for line-height and
|
|
* font-size: the author set them on `body` or on a specific paragraph, and an
|
|
* explicit value here would flatten a book that varies them by section. When
|
|
* the reader picks a size in the UI, JS writes a concrete value.
|
|
*/
|
|
--reader-font-scale: 1;
|
|
--reader-line-height: inherit;
|
|
--reader-font-family: inherit;
|
|
--reader-text-align: inherit;
|
|
--reader-page-margin: 1.5rem;
|
|
--reader-brightness: 1;
|
|
--reader-measure: 42rem;
|
|
|
|
--reader-page-padding-inline: 1.5rem;
|
|
--reader-page-padding-block: 1.25rem;
|
|
}
|
|
|
|
[data-theme='sepia'] {
|
|
--reader-ink: #3b3327;
|
|
--reader-paper: #f5ecd9;
|
|
--reader-surface: #f9f3e4;
|
|
--reader-line: #e0d3ba;
|
|
}
|
|
|
|
[data-theme='dark'] {
|
|
--reader-ink: #d8d4cc;
|
|
--reader-paper: #16171a;
|
|
--reader-surface: #1e2024;
|
|
--reader-muted: #9a958c;
|
|
--reader-line: #33363b;
|
|
--reader-accent: #b39272;
|
|
}
|
|
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
height: 100%;
|
|
}
|
|
|
|
body {
|
|
background: var(--reader-paper);
|
|
color: var(--reader-ink);
|
|
font-family: system-ui, -apple-system, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
|
-webkit-text-size-adjust: 100%;
|
|
overscroll-behavior: none;
|
|
}
|
|
|
|
#app {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
/*
|
|
* The reader is an app, not a document: a rubber-band overscroll on Android
|
|
* shows a white flash behind the paper colour, and a pull-to-refresh gesture
|
|
* at the top of a chapter reloads the whole SPA.
|
|
*/
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* The book stage
|
|
* ------------------------------------------------------------------ */
|
|
|
|
.stage {
|
|
position: relative;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
background: var(--reader-paper);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/*
|
|
* The stage's slot in the component tree.
|
|
*
|
|
* A wrapper rather than the stage itself, because the stage is owned by the
|
|
* screen and handed in through a ref callback (see `StageHost`): it is a node
|
|
* with its own lifetime, and putting it in a tree that re-renders is how a
|
|
* chapter's shadow root ends up detached from the element the paginator
|
|
* measured. The wrapper does not inherit `.stage`'s rules, so it has to carry
|
|
* the one that matters — *growing*. Without this the slot is a zero-height block
|
|
* in a flex column, and the reader sees the chrome, the header and the footer
|
|
* with no text between them.
|
|
*/
|
|
.stage-host {
|
|
display: flex;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
position: relative;
|
|
}
|
|
|
|
/*
|
|
* The stage fills its slot.
|
|
*
|
|
* `.stage` is `position: relative` with a percentage-free height, which is all it
|
|
* needs once its parent is a flex container — but the parent is `.stage-host`
|
|
* above, added when the chrome became a component tree, and a *block* parent
|
|
* gives an auto-height child its content height instead of the slot's. The stage
|
|
* then measures zero and the book is laid out in a band at the top of the
|
|
* screen. `flex: 1` here is what restores "the stage is the space between the
|
|
* chrome", which is the one thing this layout is supposed to guarantee.
|
|
*/
|
|
.stage-host > .stage {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
}
|
|
|
|
/*
|
|
* The chapter document is rendered into a shadow root so a book's own
|
|
* stylesheet cannot reach the app chrome, and the app chrome cannot reach into
|
|
* the book. The two only meet through the custom properties above.
|
|
*/
|
|
.book-host {
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.book-host[data-layout='reflowable'] {
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
/*
|
|
* Fixed-layout pages are advanced by the pager, not scrolled, so the host must
|
|
* not be a scroll container: a nested scroll area on top of a horizontal pager
|
|
* produces the classic "the page drifts sideways" bug on Android.
|
|
*/
|
|
.book-host[data-layout='fixed'] {
|
|
overflow: hidden;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
|
|
/*
|
|
* The reading column's own rules are in `ui/shadow.ts`, not here — and that
|
|
* separation is load-bearing.
|
|
*
|
|
* `.book-flow`, `.fixed-page`, `.reader-speech-highlight` and the page-turn
|
|
* animations all belong to elements inside the book's *shadow root*, and a
|
|
* selector in a document stylesheet does not reach into a shadow root. Declaring
|
|
* them here is therefore indistinguishable from deleting them: the column
|
|
* rendered at the browser default — no measure, no margins, no font scale, and
|
|
* no columns in paged mode — while the surrounding chrome, which *is* in the
|
|
* document, looked perfectly styled. That asymmetry is what made it read as a
|
|
* missing feature rather than a missing stylesheet.
|
|
*
|
|
* What stays below is everything that is genuinely in the document: the
|
|
* brightness overlay (a pseudo-element on the *host*, which is light DOM), the
|
|
* host element's own box, and the chrome.
|
|
*/
|
|
|
|
/*
|
|
* Brightness.
|
|
*
|
|
* A dimming bed-time overlay rather than a change to the palette: the reader's
|
|
* theme is the author's colours, and darkening them keeps the relationship
|
|
* between paper and ink. An overlay *on top* would darken the text as much as
|
|
* the paper, which is what makes a "brightness" slider feel broken. Applied with
|
|
* `mix-blend-mode: multiply` on a pseudo-element so it costs no extra DOM and
|
|
* cannot be reached by the book.
|
|
*/
|
|
.book-host[data-layout='reflowable']::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
z-index: 5;
|
|
background: #000;
|
|
opacity: calc(1 - var(--reader-brightness));
|
|
mix-blend-mode: multiply;
|
|
}
|
|
|
|
/*
|
|
* The host is the scroll container in scroll mode and a plain frame in paged
|
|
* mode; `book-content` is an unknown element to the browser, so its `display`
|
|
* has to be stated.
|
|
*/
|
|
book-content {
|
|
display: block;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Chrome: header, shelf, panels
|
|
* ------------------------------------------------------------------ */
|
|
|
|
.topbar {
|
|
display: flex;
|
|
/*
|
|
* Centred, not baselined.
|
|
*
|
|
* `align-items: baseline` reads as the more careful choice for a row that mixes a
|
|
* glyph and two lines of text, and it is the wrong one: the title block is two
|
|
* lines, so its first baseline is the *title's*, and the icon buttons would then be
|
|
* aligned to a line of text three quarters of a control away. Centre is the one
|
|
* line every box in this row shares.
|
|
*/
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
min-height: 3rem;
|
|
padding: 0 0.75rem;
|
|
background: var(--reader-surface);
|
|
border-bottom: 1px solid var(--reader-line);
|
|
padding-top: env(safe-area-inset-top);
|
|
}
|
|
|
|
.topbar h1 {
|
|
flex: 1 1 auto;
|
|
margin: 0;
|
|
font-size: 1.05rem;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.topbar .subtitle {
|
|
display: block;
|
|
font-size: 0.72rem;
|
|
font-weight: 400;
|
|
color: var(--reader-muted);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/*
|
|
* 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);
|
|
}
|
|
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
margin-bottom: 0.9rem;
|
|
}
|
|
|
|
.field label {
|
|
font-size: 0.82rem;
|
|
color: var(--reader-muted);
|
|
}
|
|
|
|
.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.6rem 0.7rem;
|
|
/* 16px minimum: anything smaller makes iOS Safari zoom the whole page on
|
|
focus, which then leaves the layout scrolled and half off-screen. */
|
|
font-size: 16px;
|
|
font-family: inherit;
|
|
width: 100%;
|
|
}
|
|
|
|
/* ---- 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) {
|
|
.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 ---- */
|
|
|
|
.panel {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: var(--reader-surface);
|
|
z-index: 20;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.panel[hidden] {
|
|
display: none;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
padding-top: calc(0.5rem + env(safe-area-inset-top));
|
|
border-bottom: 1px solid var(--reader-line);
|
|
}
|
|
|
|
.panel-header h2 {
|
|
flex: 1 1 auto;
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.panel-body {
|
|
flex: 1 1 auto;
|
|
overflow-y: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
padding: 0.75rem;
|
|
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
|
|
}
|
|
|
|
.toc-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.toc-list li button {
|
|
width: 100%;
|
|
text-align: start;
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--reader-ink);
|
|
padding: 0.6rem 0.4rem;
|
|
border-bottom: 1px solid var(--reader-line);
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.toc-list li button[aria-current='true'] {
|
|
color: var(--reader-accent);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ---- footer / progress ---- */
|
|
|
|
.footer {
|
|
background: var(--reader-surface);
|
|
border-top: 1px solid var(--reader-line);
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 2px;
|
|
background: var(--reader-line);
|
|
}
|
|
|
|
.progress-bar span {
|
|
display: block;
|
|
height: 100%;
|
|
background: var(--reader-accent);
|
|
transition: width var(--motion-duration) var(--motion-ease);
|
|
}
|
|
|
|
.footer-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
padding: 0.3rem 0.75rem;
|
|
font-size: 0.75rem;
|
|
color: var(--reader-muted);
|
|
}
|
|
|
|
.footer-row .chapter {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
text-align: center;
|
|
/* The chapter label is the second way into the contents panel, because the ☰
|
|
button beside it is a 2.5rem square and the label is the thing a reader
|
|
actually aims at when they want to pick a chapter. */
|
|
border: 0;
|
|
background: transparent;
|
|
color: inherit;
|
|
font: inherit;
|
|
padding: 0.35rem 0.25rem;
|
|
border-radius: var(--reader-radius);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.footer-row .chapter:active {
|
|
background: var(--reader-line);
|
|
}
|
|
|
|
/*
|
|
* The chapter buttons share the row with the chapter label, so they are a size
|
|
* down from the topbar's icon buttons: four 2.5rem controls plus a label is more
|
|
* than a 360px phone has, and the two that turn pages deserve the room.
|
|
*/
|
|
.footer-row .icon-button {
|
|
width: 2.1rem;
|
|
height: 2.1rem;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.footer-row .icon-button:disabled {
|
|
opacity: 0.35;
|
|
cursor: default;
|
|
}
|
|
|
|
.footer-meta span {
|
|
flex: 1 1 0;
|
|
}
|
|
|
|
.footer-meta span:first-child {
|
|
text-align: start;
|
|
}
|
|
|
|
.footer-meta .progress-label {
|
|
text-align: end;
|
|
}
|
|
|
|
/* ---- 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 ---- */
|
|
|
|
.section-title {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--reader-muted);
|
|
margin: 1.1rem 0 0.5rem;
|
|
}
|
|
|
|
.section-title:first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.field[hidden] {
|
|
display: none;
|
|
}
|
|
|
|
/* ---- status ---- */
|
|
|
|
.status-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.3rem 0.75rem;
|
|
font-size: 0.72rem;
|
|
background: color-mix(in srgb, var(--reader-accent) 12%, var(--reader-surface));
|
|
color: var(--reader-ink);
|
|
}
|
|
|
|
.status-bar[hidden] {
|
|
display: none;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 0.45rem;
|
|
height: 0.45rem;
|
|
border-radius: 50%;
|
|
background: var(--reader-muted);
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|