fix(web): mobile PWA style review fixes

- Reader (scroll paging): mask the line the page's bottom edge cuts through
  (clip-path, --reader-page-mask) so no page shows a half-sliced last line;
  re-measured after page-turn animations.
- Landscape sidebar layout: the sidebar owns the left safe-area inset, the page
  column the right one; fix .shelf-head's swapped left/right padding; reader
  top/bottom bars respect left/right insets.
- Guard all :hover rules with (hover: hover) and (pointer: fine), enforced by
  test/design-tokens.test.ts.
- PWA update banner sits above the phone tab bar and reader toolbar.
- Top safe-area on sources header and phone media pages; 16px sidebar search
  on touch (iOS focus zoom); larger hit areas for chips and cover controls.
- Manifest: unified launch colour #FDF9F6, maskable icon, drop
  window-controls-overlay; regenerate branding assets.
- Docs: ui.md safe-area/touch/hover/reader rules, pwa.zh-CN.md icons and
  window notes, CLAUDE.md stale bundle notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-28 07:35:51 +00:00
1 parent 88c264e5ca
commit 2f784d1d4e
59 files changed
+298 -70

No files matched your search

+3 -2
View File
@@ -54,11 +54,12 @@ Icons: add path to `web/tools/icons/paths.mjs` (use this project's own English n
## Web architecture
- `main.tsx` creates `AppRuntime` (`app/runtime.ts`: platform choice Web/Android, `ReaderApi`, `MediaApi`, player, `SyncEngine`, `OfflineStore`, settings, QueryClient), then renders providers + `RouterProvider`.
- **Hash routing only** (`createHashRouter`, `app/router/routes.tsx`): neither the server nor the `file://` WebView can serve client-side fallbacks. New links use `#/...`.
- **Single bundle, no code splitting, `base: './'`** (see `vite.config.ts`) because Android loads it from `file://`. Don't introduce dynamic chunks.
- **Hash routing only** (`createHashRouter`, `app/router/routes.tsx`): neither the server nor the Android asset loader (`WebHost.kt`, `https://appassets.androidplatform.net/`) can serve client-side fallbacks. New links use `#/...`.
- **`base: './'`, fixed entry `assets/client.js`, one stylesheet (`cssCodeSplit: false`)** (see `vite.config.ts`). Rarely used screens (reader, admin pages) are lazy content-hashed chunks via `app/router/lazy-pages.tsx`; add new lazy screens there rather than ad-hoc `import()`. The service worker precaches every emitted file automatically.
- Server data via React Query; shared client state via Zustand; long-lived resources (player, theme, sync) owned by Providers/services and cleaned up there. New pages go in `features/<domain>/pages`; cross-domain code goes through `shared/` or `app/`. Don't add to the old Screen dispatch (`app.ts`, `app/legacy-compat.ts` exist only for Android diagnostics/old tests).
- **Reader is imperative**: `features/reader/pages/ReaderPage.tsx` mounts `ReaderScreen` (`ui/reader-screen.tsx`) in an effect. Book content lives in a Shadow DOM with pagination measured via the DOM (`ui/reader-view.ts`, `render/`). Don't rewrite the stage as a React subtree; only the chrome is React.
- **Theming**: `ThemeController` (`shared/theme/theme.ts`) writes `data-theme`/`data-mode` on `<html>`; components use `--color-*` tokens from `styles/theme-tokens.css` (`--reader-*` and media aliases map onto them). Read/save theme via `readThemePreference`/`saveThemePreference`/`resolveAppTheme` (and `useThemeState` from `shared/theme/use-theme.ts` in React), never storage directly. New colors/spacing/durations become tokens, not literals.
- UI is built from the project's own components (`ui/toolkit.tsx`, `ui/sheet.tsx` as the only modal type, `ui/action-menu.tsx` for every ⋯ menu, `ui/dialog.tsx`, `ui/pager.tsx`) with Lucide/own icon font. Mantine was removed; some docs still mention it.
- Lists are server-paginated with the page in the URL (`docs/ui.md` §3.5). After a write, re-fetch and only then report. The client must not reconstruct facts the server already knows (e.g. book↔file joins); write by book id.
- Offline mirror (`store/offline.ts`) is additive-only; removals must call `offline.removeBooks` explicitly.
- **Mobile/PWA layout** (`docs/ui.md` §2.5, §3.1, §3.3): every `:hover` goes inside `@media (hover: hover) and (pointer: fine)` (enforced by `test/design-tokens.test.ts`); safe areas via the `--safe-*` vars, added only by the layer touching that screen edge (in sidebar mode the sidebar owns the left inset and `.app-shell-body` the right one); finger-reachable inputs ≥16px (`--text-title`) or iOS zooms on focus. `ui:review` does not emulate safe areas — check edge-touching bars with CDP `Emulation.setSafeAreaInsetsOverride`. Manifest/icons/launch colours: `docs/pwa.zh-CN.md`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

+8
View File
@@ -11,6 +11,14 @@
- 首次从没有更新提示的旧版本升级,仍需关闭该站点的所有页面及独立应用窗口后重新打开一次,之后即可使用“立即更新”。
- Service Worker 仅缓存应用静态资源,不缓存 API、令牌或书籍响应;书籍和阅读数据继续使用原有按账号隔离的存储。
## 图标、启动画面与独立窗口
- 清单的 `background_color` / `theme_color`、`index.html` 的首屏底色与 `theme-color`、iOS 启动图的底色都是默认主题(暖陶浅色)的 `--color-bg` `#FDF9F6`。三处不一致时,启动时会先后看到两种颜色。运行起来之后 `theme-color` 由 `ThemeController` 按当前主题改写。
- 图标:`icon-192.png` / `icon-512.png`(`purpose: any`,圆角方块)和 `maskable-512.png`(`purpose: maskable`,底色铺满,图形在中央 80% 安全区内)。没有 maskable 图标时,Android 启动器会把圆角方块缩小后塞进一个白色圆形。
- 改了 `public/icons/reader.svg` 或底色后,运行 `node web/tools/pwa/branding.mjs`,一次重新生成 maskable 图标、APK 启动器图标和 iOS 启动图。
- `display_override` 只写 `standalone`,不用 `window-controls-overlay`:页面没有绘制标题栏区域(没有 `env(titlebar-area-*)` 和 `app-region: drag`),开了它,桌面版窗口会拖不动,窗口按钮也会盖住页面右上角。
- iOS 状态栏样式用 `default`(理由见 `docs/ui.md` §3.1)。手机上贴边的页头、底栏、侧栏怎么处理安全区,也在那一节。
开发服务器不注册 Service Worker;Android 原生 WebView 继续使用本地资源。清理站点数据会同时删除离线页面及本地阅读缓存。
验证:`cd web && npm run build && node tools/pwa/smoke.mjs`。测试需要 Playwright 的 Chromium,可通过 `npx playwright install chromium` 安装。
+39
View File
@@ -94,6 +94,10 @@
每次点击都会让最后点的控件停在「悬停」外观上——手指抬起就进入 hover,看起来
像坏了,而不是像有反馈。
`test/design-tokens.test.ts` 检查 `src/` 下每条带 `:hover` 的规则都在这样一个块里。
`.x:hover, .x:focus-visible` 这类混写要拆开:`:focus-visible` 留在外面(键盘在触屏
设备上也要有反馈),`:hover` 那一半紧跟其后放进守卫块,层叠顺序不变。
### 2.6 圆角:六档
```
@@ -133,6 +137,30 @@ surface。这是「组件不写死颜色」的唯一例外,理由与封面图
`calc()` 失效并静默丢弃声明**——`padding: calc(0.9rem + var(--safe-left))`
在变量未设置时不是「0.9rem 的内边距」,而是**完全没有内边距**。
**谁贴着哪条边,谁加哪个安全区。** 只有真正贴着屏幕边的那一层加,而且只加一次:
- **顶边**:贴顶的页头加 `--safe-top`(书架/书库 `.shelf-head`、`.collection-header`,
书源 `.sources-header`,手机上的影音页 `.media-screen`,首页与我的,阅读器顶栏与页眉)。
- **底边**:手机底栏 `.app-nav`、Sheet 的底部、阅读器底栏与页脚加 `--safe-bottom`;
浮在底部的东西(Notyf、`.pwa-update` 更新条、迷你播放器)放在这些栏的**上方**,
偏移量与 `styles/notifications.css` 一致,不能盖住导航或阅读器底栏。
- **左右边(横屏刘海)**:宽度 ≥701px 时手机横屏也会进入侧栏布局。侧栏贴左边,
由它加 `--safe-left`;内容列贴右边,由 `.app-shell-body` 用 `padding-right` 统一
承担右侧安全区,并在内部把 `--safe-left` / `--safe-right` 置为 0——内容列里各条带
自己也会加安全区,不置 0 就会被缩进两次,或被一个它根本不挨着的刘海缩进。
阅读器不在外壳里,它的顶栏和底栏左右各加 `--safe-left` / `--safe-right`。
- 一条规则同时写四个方向的 `padding` 时,核对左右没有写反(`.shelf-head` 曾经把
`--safe-right` 加在左边)。
iOS 独立窗口用 `apple-mobile-web-app-status-bar-style: default`:页面从状态栏下方开始,
竖屏 `--safe-top` 为 0。`black-translucent` 会让状态栏文字恒为白色,在默认浅色主题上
看不见,所以不用;上面的顶边规则仍然保留,给会报告顶部安全区的浏览器和方向用。
Android 壳在原生层处理系统栏(`MainActivity.applyInsets`),WebView 里四个值都是 0。
验证安全区不用真机:Chromium 的 CDP `Emulation.setSafeAreaInsetsOverride` 可以给
`env(safe-area-inset-*)` 填任意值。改动贴边的条时,至少用竖屏(上 59 / 下 34)和
横屏(左右 59 / 下 21)各看一遍。
### 3.2 一列,封顶
首页、书架、影音、我的共用一条内容列:内容宽度封顶 `--page-measure`(`--collection-measure` 80rem 减去书架带内边距,约 1251px),
@@ -154,6 +182,14 @@ surface。这是「组件不写死颜色」的唯一例外,理由与封面图
底线是 28px,由 `npm run ui:review` 逐屏量过(§8):小于这个数的控件在
拇指上不是「偏小」,而是需要瞄准。
在 `(pointer: coarse)` 设备上,外观必须小的控件用透明的 `::before` 把命中区扩到
约 44px,外观不变:筛选/排序芯片 `.chip`(29px 高;水平方向只扩一点,免得相邻芯片
互相抢点击)、封面角上的 `⋯`(`.book-more`)和「加入书架」(`.book-shelve`)。
手指能点到的输入框,字号至少 16px(`--text-title`)。iOS 不理会
`maximum-scale=1`,聚焦小于 16px 的输入框会把整页放大;侧栏的全局搜索在
`(pointer: coarse)` 下因此改用 `--text-title`。
### 3.4 两个列表屏:书架与书库
**这是两个屏幕,不是两个标签页。** 书架回答「我能读什么」,书库回答「服务端有什么」,
@@ -605,6 +641,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- 中间滑杆始终控制本章页数,不能变成全书进度。固定页章节只有一页时禁用滑杆。
- 顶部左/右、底部左/右阅读信息可在“外观”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
- 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
- 每页底部不出现被切掉一半的行。滚动翻页模式下,页高先按行高取整(`--reader-page-trim`,整章一个值,页数由它算);但段间距和标题会让后面的行偏离这个网格,所以每翻一页还要把底边切过的那一行用 `clip-path` 遮住(`--reader-page-mask`,`ReaderView.syncPageMask`)。下一页从这一行的行首开始,不会漏字。遮罩不改变 `clientHeight`,所以页数和翻页位置都不受影响;翻页动画期间不测量(行框还带着 `translateY`),动画结束后再测。
- 四个面板使用统一底部半屏布局;外观放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
- “外观”面板按使用频率排序:纸色(跟随主题、白纸、暖黄、浅绿、灰调、夜间)、字号、行距、主题(阅读主题:“跟随应用”或黑白灰,用各主题真实的底色与墨色绘制的色块)、明暗(跟随系统/浅色/深色)、亮度排在首屏;“更多排版”(页边距、对齐、字体,以及 TXT 的缩进、段间距、编码)和“页眉页脚”(四个阅读信息)收进默认折叠的区块。漫画不显示文字排版,只保留主题、明暗、亮度和“页眉页脚”。
- 纸色是阅读页单独的设置(`AppSettings.paper`,按设备保存,与字号一样),只改页面的底色、文字和页眉页脚(`.reader-screen[data-paper]` 覆盖 `--reader-paper/ink/muted/line`),工具栏和面板仍用阅读主题。夜间纸色让阅读器整体变深;浅色纸色在应用为深色时默认让位给主题的深色取值,可在“跟随系统深色模式”开关关掉。外观与主题页也提供同一设置。V3 之前的 sepia/green/dark 页面主题首次读取时迁移成暖黄/浅绿/夜间。
@@ -714,6 +751,8 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
11. **加一个列表页 → 页数由服务端给,页由服务端切,页是 URL 的一部分**(§3.5)。
在客户端切好一页再丢掉、或把页码只放在组件状态里,都会在 Back、Forward 和
刷新这三件事上分别坏一次,而这三件事没有一条是功能测试会先撞上的。
12. **动贴边的条(页头、底栏、侧栏、浮在底部的提示)→ 按 §3.1 用模拟安全区看一遍**
竖屏和横屏。`ui:review` 不模拟安全区,刘海下的按钮在它的截图里是看不出来的。
---
+9 -1
View File
@@ -11,7 +11,8 @@
maximum-scale=1 disables pinch-zoom. That is deliberate: text size belongs
to the reader's own control, and a visual zoom fights CSS pagination, which
is measured in viewport units. Android WebView honours this; iOS Safari
ignores it, which is acceptable since iOS is out of scope for v1.
ignores it, and also zooms into any focused field whose text is under 16px,
so fields a finger can reach are 16px or more (see docs/ui.md, PWA).
No external font: a self-hosted reader must work with no outbound network
at all, and a webfont would also fail on the LAN with no internet uplink.
@@ -27,8 +28,15 @@
<link rel="icon" type="image/svg+xml" href="./icons/reader.svg" />
<link rel="alternate icon" type="image/png" href="./icons/icon-192.png" />
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="reader" />
<!--
`default`, not `black-translucent`: translucent draws the status bar over the page
with *white* text whatever the theme, which is unreadable on the light default. So
on iOS the page starts below the status bar and --safe-top is 0 in portrait; the
top-edge headers still add it for browsers and orientations that report one.
-->
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!-- startup-images:start -->
<link rel="apple-touch-startup-image" href="./startup/startup-640x1136.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+5 -4
View File
@@ -6,14 +6,15 @@
"lang": "zh-CN",
"start_url": "./",
"scope": "./",
"display_override": ["window-controls-overlay", "standalone"],
"display_override": ["standalone"],
"display": "standalone",
"orientation": "any",
"launch_handler": { "client_mode": "navigate-existing" },
"background_color": "#fbfaf7",
"theme_color": "#fbfaf7",
"background_color": "#fdf9f6",
"theme_color": "#fdf9f6",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+16 -1
View File
@@ -68,9 +68,24 @@
.app-nav-status[data-state='offline'] { background: var(--nav-muted); }
.app-nav-status[data-state='error'] { background: var(--color-danger); }
.app-nav-search { display: flex; align-items: center; gap: 8px; margin: 0 0 16px; padding: 0 12px; border-radius: var(--radius-md); background: var(--color-surface-2); color: var(--nav-muted); }
.app-nav-search input { flex: 1; min-width: 0; height: 40px; border: 0; background: transparent; color: var(--nav-ink); font: inherit; font-size: var(--text-label); outline: none; }
.app-nav-search input { flex: 1; min-width: 0; height: 40px; border: 0; background: transparent; color: var(--nav-ink); font: inherit; font-size: var(--text-label); text-overflow: ellipsis; outline: none; }
.app-nav-search input::-webkit-search-cancel-button { display: none; }
.app-nav-search:focus-within { box-shadow: 0 0 0 2px var(--nav-accent); }
/* iOS zooms the page into any field under 16px when it is focused (it ignores
maximum-scale), so a finger-driven sidebar (iPad, phone in landscape) gets 16px. */
@media (pointer: coarse) { .app-nav-search input { font-size: var(--text-title); } }
/* A phone in landscape is wide enough for the sidebar, with a notch on either side.
The sidebar takes the left inset; the page column takes the right one once, here,
because its centred bands are sized from the column and do not add --safe-right.
Inside the column both insets are then zero, or a band that does add them would be
indented a second time (or by a notch it does not touch). */
.app-nav { padding-left: calc(14px + var(--safe-left)); }
body[data-app-nav] .app-shell-body {
--safe-left: 0px;
--safe-right: 0px;
box-sizing: border-box;
padding-right: env(safe-area-inset-right, 0px);
}
}
/* ---- the persistent mini player sits clear of the bar / sidebar ---- */
+1 -1
View File
@@ -80,7 +80,7 @@
right: 0;
border-top: 1px solid var(--color-line);
}
.mine-page .mine-row:hover { background: var(--color-surface-2); }
@media (hover: hover) and (pointer: fine) { .mine-page .mine-row:hover { background: var(--color-surface-2); }}
.mine-page .mine-row:focus-visible { outline: 2px solid var(--color-brand-text); outline-offset: -2px; border-radius: var(--radius-lg); }
.mine-row-icon {
+3 -3
View File
@@ -23,7 +23,7 @@
text-decoration: none;
transition: border-color .15s ease, box-shadow .15s ease;
}
.home-hero:hover { border-color: var(--color-ch-read); box-shadow: 0 8px 24px color-mix(in srgb, var(--color-ink) 8%, transparent); }
@media (hover: hover) and (pointer: fine) { .home-hero:hover { border-color: var(--color-ch-read); box-shadow: 0 8px 24px color-mix(in srgb, var(--color-ink) 8%, transparent); }}
.home-hero:focus-visible, .home-card:focus-visible, .home-empty a:focus-visible { outline: 2px solid var(--color-brand-text); outline-offset: 3px; }
.home-hero-cover {
@@ -73,7 +73,7 @@
--ch: var(--color-ch-video); --ch-soft: var(--color-ch-video-soft); --ch-text: var(--color-ch-video-text);
}
.home-card[data-channel='audiobook'] { --ch: var(--color-ch-audio); --ch-soft: var(--color-ch-audio-soft); --ch-text: var(--color-ch-audio-text); }
.home-card:hover { border-color: var(--ch); }
@media (hover: hover) and (pointer: fine) { .home-card:hover { border-color: var(--ch); }}
.home-card-icon { display: grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: var(--radius-lg); background: var(--ch-soft); color: var(--ch-text); }
.home-card-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.home-card-label { font-size: var(--text-meta); font-weight: 600; color: var(--ch-text); }
@@ -85,7 +85,7 @@
.home-section { display: flex; align-items: baseline; margin: 32px 0 12px; }
.home-section h2 { flex: 1; margin: 0; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: var(--text-title); font-weight: 600; }
.home-section a { display: inline-flex; align-items: center; color: var(--color-muted); font-size: var(--text-label); text-decoration: none; }
.home-section a:hover { color: var(--color-brand-text); }
@media (hover: hover) and (pointer: fine) { .home-section a:hover { color: var(--color-brand-text); }}
.home-shelf-row { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.home-shelf-row .book-card { flex: none; width: 112px; }
+1 -1
View File
@@ -1 +1 @@
.dplayer-host{position:relative;width:100%;height:100%;background:#050705;overflow:hidden}.dplayer-host .dplayer-video-wrap{height:100%}.dplayer-host video{display:block;width:100%;height:100%;object-fit:contain}.dplayer-host .dplayer-controller{font-family:inherit}.dplayer-host .dplayer-controller-mask{background:linear-gradient(transparent,#050705cc)}.dplayer-host .dplayer-icons{color:#f4f6ee}.dplayer-host .dplayer-bar-wrap .dplayer-played,.dplayer-host .dplayer-volume-bar-inner{background:#b9d98a!important}.dplayer-host .dplayer-setting-box{background:#182017;color:#f4f6ee}.dplayer-host .dplayer-setting-item{color:#f4f6ee}.dplayer-host .dplayer-setting-item:hover{background:#2a3829}.dplayer-host .dplayer-controller .dplayer-time{font-size:var(--text-meta)}
.dplayer-host{position:relative;width:100%;height:100%;background:#050705;overflow:hidden}.dplayer-host .dplayer-video-wrap{height:100%}.dplayer-host video{display:block;width:100%;height:100%;object-fit:contain}.dplayer-host .dplayer-controller{font-family:inherit}.dplayer-host .dplayer-controller-mask{background:linear-gradient(transparent,#050705cc)}.dplayer-host .dplayer-icons{color:#f4f6ee}.dplayer-host .dplayer-bar-wrap .dplayer-played,.dplayer-host .dplayer-volume-bar-inner{background:#b9d98a!important}.dplayer-host .dplayer-setting-box{background:#182017;color:#f4f6ee}.dplayer-host .dplayer-setting-item{color:#f4f6ee}@media (hover: hover) and (pointer: fine) { .dplayer-host .dplayer-setting-item:hover{background:#2a3829}}.dplayer-host .dplayer-controller .dplayer-time{font-size:var(--text-meta)}
+4 -3
View File
@@ -4,16 +4,17 @@
.media-screen .media-favorite-filter-menu {position:relative;display:inline-block;color:var(--media-accent);font-size:var(--text-meta)}
.media-screen .media-favorite-filter-menu>summary {display:inline-flex;align-items:center;gap:6px;min-height:44px;padding:0;border:0;border-radius:0;background:transparent;color:inherit;cursor:pointer;list-style:none;white-space:nowrap}
.media-screen .media-favorite-filter-menu>summary::-webkit-details-marker {display:none}
.media-screen .media-favorite-filter-menu>summary:hover {color:var(--text)}
@media (hover: hover) and (pointer: fine) { .media-screen .media-favorite-filter-menu>summary:hover {color:var(--text)}}
.media-screen .media-favorite-filter-menu>summary:focus-visible {outline:2px solid var(--media-accent);outline-offset:3px;border-radius:var(--radius-xs)}
.media-screen .media-favorite-filter-options {position:absolute;z-index:40;top:calc(100% + 4px);right:0;display:grid;min-width:148px;padding:5px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--media-paper,var(--surface));box-shadow:0 8px 24px #0002}
.media-screen .media-favorite-filter-options button {display:flex;align-items:center;justify-content:space-between;gap:14px;width:100%;min-height:40px;padding:9px 10px;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--text);font:inherit;font-size:var(--text-meta);line-height:1.4;text-align:left;cursor:pointer}
.media-screen .media-favorite-filter-options button:hover,.media-screen .media-favorite-filter-options button:focus-visible {background:var(--media-selection);color:var(--media-accent);outline:0}
.media-screen .media-favorite-filter-options button:focus-visible {background:var(--media-selection);color:var(--media-accent);outline:0}
@media (hover: hover) and (pointer: fine) { .media-screen .media-favorite-filter-options button:hover {background:var(--media-selection);color:var(--media-accent);outline:0}}
.media-screen .media-favorite-filter-options button[aria-selected=true] {color:var(--media-accent);font-weight:600}
.media-screen .media-favorite-filter-options button svg {flex:none;color:var(--media-accent)}
.media-screen .media-favorites .media-favorite-list {max-width:none}
.media-screen .media-favorites .media-favorite-row {display:flex;align-items:center;gap:12px;width:100%;min-height:60px;padding:0;border:0;border-bottom:1px solid var(--border);border-radius:0;background:transparent;text-align:left;color:var(--text)}
.media-screen .media-favorites .media-favorite-row:hover {background:var(--surface)}
@media (hover: hover) and (pointer: fine) { .media-screen .media-favorites .media-favorite-row:hover {background:var(--surface)}}
.media-favorites .media-favorite-row>.media-cover {width:42px;min-width:42px;flex:none}
.media-screen .media-favorites .media-favorite-row>.media-portrait {width:44px;min-width:44px;height:44px;border-radius:50%}
.media-favorites .media-favorite-row>.media-cover:not(.has-image) .media-cover-title {writing-mode:vertical-rl;white-space:normal;letter-spacing:.08em;line-height:1.4}
+13 -12
View File
@@ -29,7 +29,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-screen button, .media-screen input, .media-screen select, .media-screen textarea { font:inherit; }
/* The shared sheet's header and footer use the app's own buttons, not media's. */
.media-screen button:not(:where(.sheet-header *, .sheet-footer *, .sheet-list *, .action-menu *, .action-menu-trigger)) { border:0; background:transparent; border-radius:var(--radius-sm); min-height:38px; padding:9px 12px; }
.media-screen button:not(:where(.sheet-header *, .sheet-footer *, .sheet-list *, .action-menu *, .action-menu-trigger)):hover:not(:disabled) { background:var(--surface); }
@media (hover: hover) and (pointer: fine) { .media-screen button:not(:where(.sheet-header *, .sheet-footer *, .sheet-list *, .action-menu *, .action-menu-trigger)):hover:not(:disabled) { background:var(--surface); }}
.media-screen button:focus-visible, .media-channel-entry a:focus-visible { outline:2px solid var(--media-accent); outline-offset:3px; }
.media-screen input:not([type=range]):not([type=checkbox]):not([type=radio]), .media-screen textarea {
background:var(--media-paper); border:1px solid var(--border); border-radius:var(--radius-sm);
@@ -69,7 +69,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-screen .media-browse-tools button {display:flex;align-items:center;gap:6px;font-size:var(--text-meta);padding:6px 8px;min-height:34px}
.media-screen .media-browse-tools select {font-size:var(--text-meta);max-width:200px}
.media-screen .media-grid {gap:22px 17px}
.media-screen .media-tile:hover {background:transparent}
@media (hover: hover) and (pointer: fine) { .media-screen .media-tile:hover {background:transparent}}
.media-tile strong {font-size:var(--text-label);margin-top:8px;font-weight:500}
.media-tile small {color:var(--muted);opacity:1;font-size:var(--text-meta);margin-top:3px}
.media-read-loading {margin:18px 0 24px}
@@ -113,7 +113,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-hero {gap:26px;margin-top:24px}
.media-hero .media-cover {width:170px;min-width:170px}
.media-screen .media-primary {background:var(--media-primary);color:var(--media-on-primary)}
.media-screen button.media-primary:hover:not(:disabled) {background:var(--media-primary);color:var(--media-on-primary);filter:brightness(.94)}
@media (hover: hover) and (pointer: fine) { .media-screen button.media-primary:hover:not(:disabled) {background:var(--media-primary);color:var(--media-on-primary);filter:brightness(.94)}}
.media-task-retry {display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:40px;padding:0 10px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--media-selection);color:var(--media-accent);font-size:var(--text-meta);font-weight:600}
.media-task-retry:disabled {opacity:.5}
@media(max-width:430px){.media-task-retry span{display:inline}.media-task-retry{min-width:64px}.media-scan-job .media-task-icon{min-width:40px}}
@@ -126,7 +126,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-channel-entry a {color:inherit;text-decoration:none;padding:8px 14px;font-size:var(--text-ui);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;gap:6px;min-height:38px;white-space:nowrap}
.media-channel-entry .media-channel-icon {display:none}
.media-channel-entry a[aria-current=page] {background:var(--media-selection);color:var(--media-accent);font-weight:500}
.media-channel-entry a:hover {background:var(--surface)}
@media (hover: hover) and (pointer: fine) { .media-channel-entry a:hover {background:var(--surface)}}
/* Audio player: the prototype's 420px column, square cover and 58px play control. */
.media-playback-page {max-width:1120px}
@@ -162,7 +162,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-transport {display:flex;align-items:center;justify-content:center;gap:25px;margin:18px 0}
.media-screen .media-symbol-button {display:grid;place-items:center;width:44px;height:44px;padding:10px;flex-shrink:0;border:0;background:transparent;color:var(--text)}
.media-screen .media-toggle-play {display:grid;place-items:center;width:58px;height:58px;min-height:58px;padding:0;flex-shrink:0;border:0;border-radius:50%;background:var(--media-fill);color:var(--media-on-accent)}
.media-screen .media-toggle-play:hover {background:var(--media-fill);filter:brightness(.95)}
@media (hover: hover) and (pointer: fine) { .media-screen .media-toggle-play:hover {background:var(--media-fill);filter:brightness(.95)}}
.media-toggle-play svg {width:25px;height:25px}
.media-screen .media-skip {position:relative}
.media-skip small {position:absolute;inset:18px 0 auto;text-align:center;font-size:var(--text-micro);font-weight:600}
@@ -292,7 +292,8 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-channel-switcher nav {position:absolute;left:-60px;top:46px;z-index:100;width:176px;padding:6px;background:var(--media-paper);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:0 8px 28px #0002}
.media-channel-switcher a {display:flex;align-items:center;gap:12px;min-height:44px;padding:0 12px;color:var(--text);text-decoration:none;border-radius:var(--radius-sm);font-size:var(--text-ui)}
.media-channel-switcher a span {flex:1}
.media-channel-switcher a:hover,.media-channel-switcher a[aria-current=page] {background:var(--media-selection);color:var(--media-accent)}
.media-channel-switcher a[aria-current=page] {background:var(--media-selection);color:var(--media-accent)}
@media (hover: hover) and (pointer: fine) { .media-channel-switcher a:hover {background:var(--media-selection);color:var(--media-accent)}}
.media-channel-switcher :focus-visible {outline:2px solid var(--media-accent);outline-offset:2px}
.media-page-context {font-size:var(--text-meta);color:var(--muted);font-weight:400;margin-left:8px;white-space:nowrap}
.media-heading-actions[hidden] {display:none!important}
@@ -361,7 +362,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-library-actions>summary::-webkit-details-marker {display:none}
.media-library-actions>nav {position:absolute;z-index:10;right:0;top:42px;display:grid;min-width:150px;padding:6px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--media-paper);box-shadow:0 5px 18px #0002}
.media-manager .media-library-actions button {text-align:left;background:transparent;border-radius:var(--radius-xs);padding:10px 12px}
.media-manager .media-library-actions button:hover {background:var(--surface)}
@media (hover: hover) and (pointer: fine) { .media-manager .media-library-actions button:hover {background:var(--surface)}}
.media-manager-note {padding:12px 14px;background:var(--surface);border-radius:var(--radius-sm);color:var(--muted);font-size:var(--text-meta);line-height:1.8;margin:20px 0}
.media-connection-row {display:flex;gap:12px;align-items:center;padding:14px 0;border-bottom:1px solid var(--border)}
.media-connection-row>span {flex:1;font-size:var(--text-label)}
@@ -530,7 +531,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-person-placeholder:after {content:'';position:absolute;width:62%;aspect-ratio:1;border:1px solid #ede3ca55;border-radius:50%;right:-13%;top:23%;z-index:-1}
.media-person-placeholder .media-cover-title {font:400 24px/1.6 Georgia,"Songti SC",SimSun,serif;letter-spacing:2px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
.media-screen .media-person-tile {padding:0;border:0;background:transparent;text-align:left;min-width:0;display:block}
.media-screen .media-person-tile:hover {background:transparent}
@media (hover: hover) and (pointer: fine) { .media-screen .media-person-tile:hover {background:transparent}}
.media-person-tile strong,.media-person-tile small {display:block;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}
.media-person-tile strong {font-weight:500;font-size:var(--text-label);margin-top:8px}
.media-person-tile small {color:var(--muted);font-size:var(--text-meta);margin-top:3px}
@@ -747,7 +748,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-video-workspace button,.media-video-workspace select,.media-video-sheet .sheet-body button,.media-video-sheet .sheet-body input,.media-video-sheet .sheet-body select {font:inherit;color:inherit;box-sizing:border-box;min-height:44px;border:0;border-radius:var(--radius-sm);padding:9px 12px;background:transparent}
.media-video-workspace button,.media-video-sheet .sheet-body button {cursor:pointer}
.media-video-workspace button:disabled,.media-video-sheet .sheet-body button:disabled {opacity:.45;cursor:default}
.media-video-workspace button:hover:not(:disabled),.media-video-sheet .sheet-body button:hover:not(:disabled) {background:var(--surface)}
@media (hover: hover) and (pointer: fine) { .media-video-workspace button:hover:not(:disabled),.media-video-sheet .sheet-body button:hover:not(:disabled) {background:var(--surface)}}
.media-video-workspace :focus-visible,.media-video-sheet :focus-visible {outline:2px solid var(--media-accent);outline-offset:2px}
.media-video-toolbar,.media-video-toolbar>div {display:flex;align-items:center;gap:6px;min-width:0}
.media-video-toolbar {justify-content:space-between;margin:5px 0}
@@ -892,7 +893,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-mini .media-mini-toggle,.media-mini .media-mini-close {display:grid;place-items:center;width:40px;min-width:40px;height:40px;min-height:40px;padding:8px;border-radius:50%}
.media-mini .media-mini-toggle {background:var(--media-fill);color:var(--media-on-accent)}
.media-mini .media-mini-close {color:var(--muted)}
.media-mini .media-mini-close:hover {background:var(--border)}
@media (hover: hover) and (pointer: fine) { .media-mini .media-mini-close:hover {background:var(--border)}}
.media-resource-panel>header {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:20px}
.media-screen .media-resource-panel>header h2 {margin:0;font-size:var(--text-title)}
@@ -979,7 +980,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.theme-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.media-screen .theme-card {display:block;min-width:0;padding:10px;border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--media-paper);color:var(--text);text-align:left}
.media-screen .theme-card[aria-pressed=true] {border-color:var(--media-accent);box-shadow:0 0 0 1px var(--media-accent)}
.media-screen .theme-card:hover:not(:disabled) {background:var(--media-paper);border-color:var(--media-accent)}
@media (hover: hover) and (pointer: fine) { .media-screen .theme-card:hover:not(:disabled) {background:var(--media-paper);border-color:var(--media-accent)}}
.theme-preview {display:flex;height:64px;overflow:hidden;border:1px solid var(--border);border-radius:var(--radius-md)}
.theme-preview>span {flex:1;padding:10px 8px;box-sizing:border-box}
.theme-preview-title {display:block;width:28px;height:6px;border-radius:var(--radius-xs)}
@@ -1038,7 +1039,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-video-landscape button:focus-visible {outline:2px solid #fff;outline-offset:2px}
/* Hide with DPlayer's own controller after a few idle seconds. */
.media-player:fullscreen:has(.dplayer-hide-controller) .media-video-landscape:not([data-locked]) {opacity:0}
.media-player:fullscreen:has(.dplayer-hide-controller) .media-video-landscape:not([data-locked]):hover {opacity:1}
@media (hover: hover) and (pointer: fine) { .media-player:fullscreen:has(.dplayer-hide-controller) .media-video-landscape:not([data-locked]):hover {opacity:1}}
/* 锁定: taps no longer reach the player; only the unlock button remains. */
.media-video-landscape[data-locked] {inset:0 auto 0 0;background:none;padding:0 0 0 max(16px,env(safe-area-inset-left))}
.media-video-landscape .media-video-unlock {width:48px;height:48px;padding:0;justify-content:center;border-radius:50%;background:rgb(0 0 0 / .45)}
+30 -19
View File
@@ -185,7 +185,7 @@
.media-episode-rail {box-shadow:0 10px 30px #0002}
.media-episode-rail header {position:sticky;top:0;z-index:1;padding-bottom:12px;background:var(--media-paper)}
.media-episode-rail .media-video-episodes button {display:grid;grid-template-columns:32px minmax(0,1fr) 18px;gap:10px;align-items:center;padding:10px 8px;border:0;border-radius:var(--radius-sm);color:var(--text);background:transparent}
.media-episode-rail .media-video-episodes button:hover:not(:disabled) {background:var(--media-selection)}
@media (hover: hover) and (pointer: fine) { .media-episode-rail .media-video-episodes button:hover:not(:disabled) {background:var(--media-selection)}}
.media-episode-rail .media-video-episodes button[aria-current=true] {background:var(--media-selection);color:var(--media-accent);font-weight:600}
.media-episode-rail .media-video-episodes button>span:first-child {font-variant-numeric:tabular-nums;color:var(--muted);text-align:center}
.media-episode-rail .media-video-episodes button[aria-current=true]>span:first-child {color:var(--media-accent)}
@@ -257,7 +257,8 @@
.searchable-select-search input {min-width:0;width:100%;border:0;outline:0;background:transparent;color:var(--text);font:inherit}
.searchable-select-options {max-height:260px;overflow:auto;padding-top:4px}
.searchable-select-options button {display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;min-height:38px;padding:8px 10px;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--text);text-align:left}
.searchable-select-options button:hover,.searchable-select-options button[aria-selected=true] {background:var(--media-selection);color:var(--media-accent)}
.searchable-select-options button[aria-selected=true] {background:var(--media-selection);color:var(--media-accent)}
@media (hover: hover) and (pointer: fine) { .searchable-select-options button:hover {background:var(--media-selection);color:var(--media-accent)}}
.searchable-select-options p {padding:12px;color:var(--muted);font-size:var(--text-meta)}
@media(min-width:701px){
.media-screen>.media-heading:has(.media-tabs:not([hidden])) {display:block;height:auto;min-height:0;margin:0;border:0;padding:0}
@@ -315,13 +316,20 @@
right: 0;
pointer-events: auto;
}
.media-screen.media-catalog-page > .media-heading .media-heading-actions .media-icon-button,
.media-screen.media-catalog-page > .media-heading .media-heading-actions .media-icon-button:hover {
.media-screen.media-catalog-page > .media-heading .media-heading-actions .media-icon-button {
color: var(--text) !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
}
@media (hover: hover) and (pointer: fine) {
.media-screen.media-catalog-page > .media-heading .media-heading-actions .media-icon-button:hover {
color: var(--text) !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
}
}
.media-screen.media-catalog-page > .media-heading .media-heading-actions .media-icon-button svg {
color: currentColor;
stroke: currentColor;
@@ -398,7 +406,7 @@
text-decoration: none;
border-radius: var(--radius-sm);
}
.media-screen.media-catalog-page .media-actions nav a:hover { background: var(--surface); }
@media (hover: hover) and (pointer: fine) { .media-screen.media-catalog-page .media-actions nav a:hover { background: var(--surface); }}
.media-screen.media-catalog-page .media-catalog-actions nav a {
display: flex;
align-items: center;
@@ -410,9 +418,11 @@
border-radius: var(--radius-sm);
font-size: var(--text-label);
}
.media-screen.media-catalog-page .media-catalog-actions nav a:hover {
background: var(--media-selection);
color: var(--media-accent);
@media (hover: hover) and (pointer: fine) {
.media-screen.media-catalog-page .media-catalog-actions nav a:hover {
background: var(--media-selection);
color: var(--media-accent);
}
}
@media(max-width:700px){
@@ -553,7 +563,7 @@
.media-video-workspace:has(.media-episode-rail) .media-episode-rail {position:absolute;right:0;top:16px;width:300px;box-sizing:border-box;margin:0}
}
@media(max-width:700px){
.media-screen {padding:0 16px calc(24px + env(safe-area-inset-bottom))}
.media-screen {padding:var(--safe-top) 16px calc(24px + env(safe-area-inset-bottom))}
.media-screen.media-catalog-page .media-heading:has(.media-mobile-title) {display:flex;grid-template-rows:none;min-height:56px;height:56px}
.media-screen .media-heading:has(.media-mobile-title) {grid-template-rows:56px 44px;min-height:100px}
.media-screen .media-mobile-title {height:56px;font-size:var(--text-title)}
@@ -602,7 +612,7 @@
.media-screen .media-detail-favorite {background:transparent;border-radius:50%}
.media-ai-jobs{margin-top:28px;border-top:1px solid var(--border);padding-top:18px}.media-ai-job-actions{display:flex;align-items:center;justify-content:flex-start;gap:12px;margin-top:10px}.media-ai-batches{margin-top:10px;padding-left:10px}.media-ai-batch{border-top:1px solid var(--border);padding:8px 0}.media-ai-batch-toggle{display:flex;align-items:center;gap:5px;background:transparent;border:0;padding:4px;color:inherit}.media-ai-batch-detail{padding:8px 20px;font-size:var(--text-label)}.media-ai-batch-detail pre{max-height:360px;overflow:auto;white-space:pre-wrap;word-break:break-word;background:color-mix(in srgb,var(--text) 5%,var(--media-paper));padding:10px}
.media-task-tabs{display:inline-flex;align-items:center;gap:3px;margin:8px 0 28px;padding:4px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface-muted,#f3f4ef);box-shadow:inset 0 1px 2px rgba(31,42,34,.06)}.media-task-tabs button{min-height:36px;border:0;border-radius:var(--radius-sm);background:transparent;padding:0 18px;color:var(--muted);font-size:var(--text-ui);font-weight:500;white-space:nowrap;transition:background .15s ease,color .15s ease,box-shadow .15s ease}.media-task-tabs button:hover{color:var(--text);background:rgba(255,255,255,.65)}.media-task-tabs button[aria-current=page]{background:var(--surface,#fff);color:var(--text);font-weight:650;box-shadow:0 1px 3px rgba(31,42,34,.14)}.media-task-actions{display:flex;gap:6px}.media-task-actions .media-task-icon{position:static}
.media-task-tabs{display:inline-flex;align-items:center;gap:3px;margin:8px 0 28px;padding:4px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface-muted,#f3f4ef);box-shadow:inset 0 1px 2px rgba(31,42,34,.06)}.media-task-tabs button{min-height:36px;border:0;border-radius:var(--radius-sm);background:transparent;padding:0 18px;color:var(--muted);font-size:var(--text-ui);font-weight:500;white-space:nowrap;transition:background .15s ease,color .15s ease,box-shadow .15s ease}@media (hover: hover) and (pointer: fine) { .media-task-tabs button:hover{color:var(--text);background:rgba(255,255,255,.65)}}.media-task-tabs button[aria-current=page]{background:var(--surface,#fff);color:var(--text);font-weight:650;box-shadow:0 1px 3px rgba(31,42,34,.14)}.media-task-actions{display:flex;gap:6px}.media-task-actions .media-task-icon{position:static}
.media-manager-toolbar>span,.media-task-library>label{min-width:0}
.media-ai-jobs .media-scan-job>.media-task-copy{width:auto;min-width:0}
.media-ai-jobs .media-scan-job{grid-template-columns:minmax(0,1fr) auto 40px}
@@ -718,7 +728,7 @@
/* Shared media interaction tokens also cover layers outside the screen. */
:root body {--surface-muted:var(--color-surface-2)}
.media-task-tabs button:hover {background:var(--media-selection)}
@media (hover: hover) and (pointer: fine) { .media-task-tabs button:hover {background:var(--media-selection)}}
.media-screen .media-task-tabs {
display:flex;
align-items:stretch;
@@ -745,7 +755,7 @@
font-weight:500;
transition:color .15s ease,border-color .15s ease;
}
.media-screen .media-task-tabs button:hover {background:transparent;color:var(--text)}
@media (hover: hover) and (pointer: fine) { .media-screen .media-task-tabs button:hover {background:transparent;color:var(--text)}}
.media-screen .media-task-tabs button[aria-current=page] {
background:transparent;
color:var(--media-accent);
@@ -762,12 +772,12 @@
.media-player .media-playback-problem .media-primary {background:var(--media-primary);color:var(--media-on-primary)}
.media-player .dplayer-host .dplayer-bar-wrap .dplayer-played,.media-player .dplayer-host .dplayer-volume-bar-inner {background:var(--media-fill)!important}
.media-player .dplayer-host .dplayer-setting-box,.media-player .dplayer-host .dplayer-setting-item {background:var(--media-paper);color:var(--text)}
.media-player .dplayer-host .dplayer-setting-item:hover {background:var(--media-selection)}
@media (hover: hover) and (pointer: fine) { .media-player .dplayer-host .dplayer-setting-item:hover {background:var(--media-selection)}}
.media-screen .media-folder-operation{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;margin:10px 0;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface)}
.media-folder-operation>span{display:grid;gap:5px;font-size:var(--text-ui)}.media-folder-operation small{color:var(--muted);font-size:var(--text-meta)}
.media-screen .media-folder-operation>button{display:inline-flex;align-items:center;justify-content:center;gap:8px;flex-shrink:0;min-height:40px;padding:8px 14px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--media-selection);color:var(--text)}
.media-screen .media-folder-operation>button:hover{border-color:var(--media-accent)}
@media (hover: hover) and (pointer: fine) { .media-screen .media-folder-operation>button:hover{border-color:var(--media-accent)}}
.media-scrape-manager>.media-task-create{margin-top:0}.media-scrape-manager>.media-task-history{margin-top:28px}
@media(max-width:520px){.media-screen .media-folder-operation{align-items:stretch;flex-direction:column}.media-folder-operation>button{align-self:flex-start}}
@@ -777,7 +787,7 @@
/* Keep task navigation quiet and aligned with the rest of the theme. */
.media-screen .media-task-tabs{display:flex;gap:26px;margin:8px 0 24px;padding:0;border:0;border-bottom:1px solid var(--border);border-radius:0;background:transparent;box-shadow:none;overflow-x:auto}
.media-screen .media-task-tabs button{flex:0 0 auto;min-height:44px;padding:0 2px;border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;color:var(--muted);font-weight:550}
.media-screen .media-task-tabs button:hover{background:transparent;color:var(--text)}
@media (hover: hover) and (pointer: fine) { .media-screen .media-task-tabs button:hover{background:transparent;color:var(--text)}}
.media-screen .media-task-tabs button[aria-current=page]{background:transparent;color:var(--media-accent);border-bottom-color:var(--media-accent);box-shadow:none;font-weight:700}
.media-task-intro{margin:0 0 24px;padding:0 0 18px;border:0;border-bottom:1px solid var(--border);border-radius:0;background:transparent}
.media-task-intro h2{font-size:var(--text-title);letter-spacing:-.01em}
@@ -786,7 +796,7 @@
.media-task-intro button{min-height:40px}
.media-item-actions>nav{min-width:184px;padding:7px}
.media-item-actions>nav>button,.media-item-actions>nav>.media-menu-submenu>summary{box-sizing:border-box;width:100%;min-height:40px;padding:9px 10px;border-radius:var(--radius-sm);text-align:left;line-height:1.4;white-space:nowrap}
.media-item-actions>nav>button:hover,.media-item-actions>nav>.media-menu-submenu>summary:hover{background:var(--media-selection)}
@media (hover: hover) and (pointer: fine) { .media-item-actions>nav>button:hover,.media-item-actions>nav>.media-menu-submenu>summary:hover{background:var(--media-selection)}}
.media-menu-submenu>summary{display:block!important;width:100%!important;min-width:0!important;height:auto!important;min-height:40px!important;padding:9px 10px!important;box-sizing:border-box!important;justify-content:flex-start!important;white-space:nowrap!important}
.media-menu-submenu>nav{right:calc(100% + 8px);top:-7px;min-width:160px;padding:7px;background:var(--media-paper,var(--surface));border-color:var(--border);box-shadow:0 10px 26px #0002}
.media-menu-submenu>nav button{width:100%;min-height:40px;padding:9px 10px;border-radius:var(--radius-sm);white-space:nowrap}
@@ -804,9 +814,10 @@
@media(max-width:700px){.media-screen .media-manager .media-library-list{gap:10px}.media-screen .media-manager .media-library-row{grid-template-columns:28px minmax(0,1fr) 36px;padding:13px 14px;border-radius:var(--radius-md)}.media-screen .media-manager .media-library-footer{margin-top:7px;padding-top:9px}.media-screen .media-manager .media-library-footer button{min-height:34px}}
.media-screen .media-continue{cursor:pointer;transition:border-color .15s ease,box-shadow .15s ease}
.media-screen .media-continue:hover,.media-screen .media-continue:focus-visible{border-color:var(--media-accent);box-shadow:0 3px 12px rgba(31,42,34,.08);outline:none}
.media-screen .media-continue:focus-visible{border-color:var(--media-accent);box-shadow:0 3px 12px rgba(31,42,34,.08);outline:none}
@media (hover: hover) and (pointer: fine) { .media-screen .media-continue:hover{border-color:var(--media-accent);box-shadow:0 3px 12px rgba(31,42,34,.08);outline:none}}
.media-screen .media-continue-play{display:grid!important;place-items:center;width:38px!important;min-width:38px!important;height:38px!important;min-height:38px!important;padding:0!important;border:1px solid color-mix(in srgb,var(--media-accent) 35%,var(--border))!important;border-radius:50%!important;background:transparent!important;color:var(--media-accent)!important}
.media-screen .media-continue-play:hover{background:var(--media-selection)!important}
@media (hover: hover) and (pointer: fine) { .media-screen .media-continue-play:hover{background:var(--media-selection)!important}}
.media-screen:has(>.media-read-loading-detail)>.media-heading{align-items:center;border-bottom:0;box-shadow:none}
.media-screen:has(>.media-read-loading-detail)>.media-heading .media-page-heading{display:flex;align-items:center;gap:4px}
.media-screen:has(>.media-read-loading-detail)>.media-heading .media-page-heading h2{margin:0;font-size:var(--text-title);line-height:1.4}
@@ -883,8 +894,8 @@
text-decoration:none;
white-space:nowrap;
}
.media-screen .media-actions nav :is(a,button):hover:not(:disabled),
.media-screen .media-actions nav :is(a,button):focus-visible { background:var(--media-selection); color:var(--media-accent); }
@media (hover: hover) and (pointer: fine) { .media-screen .media-actions nav :is(a,button):hover:not(:disabled) { background:var(--media-selection); color:var(--media-accent); }}
.media-screen .media-actions nav :is(a,button) svg {flex:none;stroke:currentColor}
.media-screen .media-library-management-actions>summary {color:var(--text)}
.media-screen .media-library-management-actions[open]>summary {background:var(--media-selection);color:var(--media-accent)}
+1 -1
View File
@@ -35,7 +35,7 @@ body:has(.reader-screen[data-chrome='visible']) .notyf { padding-bottom: calc(8.
.global-notice .notyf__dismiss { position: relative; width: 32px; height: 32px; margin: 0; flex: none; animation: none; opacity: 1; }
.global-notice .notyf__dismiss-btn { padding: 0; border-radius: var(--radius-sm); color: var(--reader-muted); background: transparent; opacity: 1; pointer-events: auto; }
.global-notice .notyf__dismiss-btn::before, .global-notice .notyf__dismiss-btn::after { background: currentColor; }
.global-notice .notyf__dismiss-btn:hover { background: var(--reader-line); }
@media (hover: hover) and (pointer: fine) { .global-notice .notyf__dismiss-btn:hover { background: var(--reader-line); }}
.global-notice .notyf__dismiss-btn:focus-visible { outline: 2px solid var(--reader-accent); outline-offset: 2px; }
.global-notice[data-kind='error'] { color: #a32c22; }
[data-mode='dark'] .global-notice[data-kind='error'] { color: #ffaaa0; }
+11 -2
View File
@@ -1,11 +1,12 @@
.pwa-update {
position: fixed;
z-index: 1000;
bottom: max(1rem, env(safe-area-inset-bottom));
/* Clear of whatever owns the bottom edge; the same offsets as styles/notifications.css. */
bottom: calc(1rem + var(--safe-bottom));
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: calc(100vw - 2rem);
max-width: calc(100vw - 2rem - var(--safe-left) - var(--safe-right));
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
@@ -21,6 +22,14 @@
font-size: var(--text-ui);
line-height: 1.5;
}
@media (max-width: 700px) {
body[data-app-nav] .pwa-update { bottom: calc(var(--app-nav-bar) + var(--safe-bottom) + .75rem); }
}
body:has(.reader-screen) .pwa-update { bottom: calc(1.8rem + var(--safe-bottom) + .5rem); }
body:has(.reader-screen[data-chrome='visible']) .pwa-update { bottom: calc(8.5rem + var(--safe-bottom) + .5rem); }
@media (min-width: 64rem) {
body:has(.reader-screen[data-chrome='visible']) .pwa-update { bottom: calc(5.5rem + .5rem); }
}
.pwa-update .button {
min-height: 44px;
flex: none;
+48 -18
View File
@@ -441,6 +441,9 @@ book-content {
*/
.book-host[data-layout='reflowable']:not([data-paginated='true']) {
touch-action: pan-y;
/* The line the page's bottom edge cuts through; see ReaderView.syncPageMask. A clip,
not a height, so the page geometry the turns are computed from does not move. */
clip-path: inset(0 0 var(--reader-page-mask, 0px) 0);
}
.book-host[data-layout='reflowable'][data-paginated='true'] {
touch-action: none;
@@ -967,8 +970,8 @@ book-content {
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);
padding: calc(0.9rem + var(--safe-top)) var(--shelf-gutter-end) 0.5rem
calc(0.9rem + var(--safe-left));
max-width: var(--shelf-measure);
margin-inline: auto;
}
@@ -1567,11 +1570,15 @@ book-content {
transition: opacity var(--motion-duration) var(--motion-ease);
}
.book-card .book-more:hover,
.book-card .book-more:focus-visible,
.book-card .book-more:active {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.book-card .book-more:hover {
opacity: 1;
}
}
/* A pointer can hover, so the glyph can afford to be invisible until it does; a
finger cannot, so the rule is scoped to hosts that have one. */
@@ -1973,8 +1980,10 @@ book-content {
touch-action: manipulation;
}
.toc-list li button:hover {
background: color-mix(in srgb, var(--reader-ink) 6%, transparent);
@media (hover: hover) and (pointer: fine) {
.toc-list li button:hover {
background: color-mix(in srgb, var(--reader-ink) 6%, transparent);
}
}
.toc-list li button[aria-current='true'] {
@@ -1991,8 +2000,10 @@ book-content {
cursor: default;
}
.toc-list li button[data-unreachable='true']:hover {
background: transparent;
@media (hover: hover) and (pointer: fine) {
.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
@@ -2575,6 +2586,24 @@ book-content {
cursor: pointer;
}
/*
* Small controls keep their look but get a finger-sized hit area: a chip is 29px and the
* card corner controls 28px or less, against the 44px a fingertip needs. Horizontally the
* chip grows only a little, so neighbours in a row do not steal each other's taps.
*/
@media (pointer: coarse) {
.chip { position: relative; }
.chip::before,
.book-card .book-more::before,
.book-card .book-shelve::before {
content: '';
position: absolute;
inset: -0.5rem;
}
.chip::before { inset-inline: -0.15rem; }
.book-card .book-shelve::before { inset-block: -0.75rem; }
}
.chip[aria-pressed='true'] {
background: color-mix(in srgb, var(--reader-accent) 12%, var(--reader-surface));
border-color: color-mix(in srgb, var(--reader-accent) 45%, transparent);
@@ -2807,9 +2836,9 @@ book-content {
.login-password-input { position:relative; }
.login-password-input input { padding-right:3rem; }
.login-password-toggle { position:absolute; right:.15rem; top:50%; transform:translateY(-50%); display:grid; place-items:center; width:2.5rem; height:2.5rem; min-height:0; padding:0; border:0; background:transparent; color:var(--reader-muted); cursor:pointer; }
.login-password-toggle:hover { color:var(--reader-ink); }
@media (hover: hover) and (pointer: fine) { .login-password-toggle:hover { color:var(--reader-ink); }}
.login-submit { width: 100%; min-height: 50px; margin-top: .8rem; font-size: var(--text-title); border-radius: var(--radius-sm); background:var(--reader-fill); border-color:var(--reader-fill); color:var(--reader-on-fill); }
.login-submit:hover { background:color-mix(in srgb, var(--reader-fill) 88%, var(--reader-ink)); border-color:transparent; }
@media (hover: hover) and (pointer: fine) { .login-submit:hover { background:color-mix(in srgb, var(--reader-fill) 88%, var(--reader-ink)); border-color:transparent; }}
.login-card .login-notice { margin-top:.95rem; padding:1rem 1.1rem; border-radius:var(--radius-xs); background:var(--color-brand-soft); color:var(--reader-ink); font-size:var(--text-title); line-height:1.6; }
.login-card .login-notice.is-warning { background:var(--color-surface-2); color:var(--reader-ink); }
.login-card .login-notice.is-error { background:var(--color-danger-soft); color:var(--color-danger); }
@@ -3572,7 +3601,7 @@ book-content {
}
/* Source management shares the reader's type and colour tokens. */
.sources-screen { display:flex; flex-direction:column; min-height:0; height:100%; flex:1; }
.sources-header { display:flex; align-items:center; gap:1rem; padding:1rem; border-bottom:1px solid var(--reader-line); }
.sources-header { display:flex; align-items:center; gap:1rem; padding:calc(1rem + var(--safe-top)) calc(1rem + var(--safe-right)) 1rem calc(1rem + var(--safe-left)); border-bottom:1px solid var(--reader-line); }
.sources-header h1 { font-size:var(--text-display); flex:1; margin:0; }
.sources-header-text { flex:1; min-width:0; }
.sources-header-text p { margin:.25rem 0 0; font-size:var(--text-meta); }
@@ -3612,7 +3641,8 @@ book-content {
.search-menu-toggle .icon { transform:rotate(90deg); }
.search-menu { position:absolute; right:0; top:calc(100% + .45rem); z-index:5; width:min(17rem, calc(100vw - 4rem)); padding:.35rem; border:1px solid var(--reader-line); border-radius:var(--radius-md); background:var(--reader-paper); box-shadow:0 8px 24px #0002; }
.search-menu button { display:flex; align-items:center; width:100%; padding:.65rem .75rem; text-align:left; border:0; background:transparent; color:var(--reader-ink); border-radius:var(--radius-sm); }
.search-menu button:hover, .search-menu button:focus-visible { background:color-mix(in srgb, var(--reader-accent) 12%, transparent); }
.search-menu button:focus-visible { background:color-mix(in srgb, var(--reader-accent) 12%, transparent); }
@media (hover: hover) and (pointer: fine) { .search-menu button:hover { background:color-mix(in srgb, var(--reader-accent) 12%, transparent); }}
.search-menu small { display:block; margin-top:.2rem; color:var(--reader-muted); font-size:var(--text-meta); }
.catalog-summary { display:flex; flex-wrap:wrap; gap:.5rem 1rem; justify-content:space-between; align-items:center; }
.catalog-summary strong { font-size:var(--text-title); }
@@ -3678,7 +3708,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
gap: 0.5rem;
height: auto;
min-height: calc(4.5rem + var(--safe-top));
padding: calc(0.6rem + var(--safe-top)) 1rem 0.6rem;
padding: calc(0.6rem + var(--safe-top)) calc(1rem + var(--safe-right)) 0.6rem calc(1rem + var(--safe-left));
background: var(--reader-surface);
border: 0;
box-shadow: 0 2px 12px #0000000a;
@@ -3689,7 +3719,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.reader-heading strong, .reader-heading span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reader-screen .footer {
height: var(--reader-footer-height);
padding: 0.5rem 1rem calc(0.4rem + var(--safe-bottom));
padding: 0.5rem calc(1rem + var(--safe-right)) calc(0.4rem + var(--safe-bottom)) calc(1rem + var(--safe-left));
background: var(--reader-surface);
border: 0;
box-shadow: 0 -2px 12px #0000000a;
@@ -3926,7 +3956,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.reader-screen .progress-row { flex: 1; min-width: 0; }
.reader-screen .reader-actions { display: flex; gap: .25rem; padding-left: .75rem; border-left: 1px solid var(--reader-line); }
.reader-screen .reader-actions .top-button { flex-direction: row; min-width: 4.75rem; min-height: 44px; padding: .5rem .7rem; border-radius: var(--radius-md); }
.reader-screen .reader-actions .top-button:hover { background: color-mix(in srgb, var(--reader-accent) 10%, transparent); }
@media (hover: hover) and (pointer: fine) { .reader-screen .reader-actions .top-button:hover { background: color-mix(in srgb, var(--reader-accent) 10%, transparent); }}
.reader-screen .reader-actions .icon { font-size: var(--text-title); }
.reader-screen .reader-rail { right: max(1rem, calc((100% - var(--desktop-page-width)) / 2 - 4.5rem)); bottom: 2.25rem; }
.reader-screen .reading-indicator { width: var(--desktop-page-width); max-width: 100%; margin-inline: auto; padding-inline: 1.5rem; font-size: var(--text-meta); }
@@ -3935,7 +3965,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.reader-screen .section-title:first-child { margin-top: 0; }
}
@media (max-width: 600px) {
.sources-header { gap:.6rem; padding-inline:.75rem; }
.sources-header { gap:.6rem; padding-inline:calc(.75rem + var(--safe-left)) calc(.75rem + var(--safe-right)); }
.sources-header h1 { font-size:var(--text-title); }
.source-entry-top { align-items:flex-start; flex-direction:column; gap:.6rem; }
.source-entry-actions, .sources-actions { width:100%; flex-wrap:wrap; }
@@ -3958,7 +3988,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.reader-source-info { flex: 1 0 100%; display: flex; gap: .65rem; align-items: center; min-width: 0; font-size: var(--text-meta); color: var(--reader-muted); border-top: 1px solid var(--reader-line); padding-top: .45rem; }
.reader-source-info span { flex: 0 1 auto; max-width: 35%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.reader-source-info a { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: inherit; text-decoration: none; }
.reader-source-info a:hover { text-decoration: underline; }
@media (hover: hover) and (pointer: fine) { .reader-source-info a:hover { text-decoration: underline; }}
.book-detail { display: grid; gap: .5rem; }
.book-detail h3 { margin: 0; font-size: var(--text-display); overflow-wrap: anywhere; }
.book-detail-author { color: var(--reader-muted); }
@@ -4001,7 +4031,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.source-switch-hint { margin: .65rem 0 1rem; color: var(--reader-muted); font-size: var(--text-meta); line-height: 1.6; }
.source-switch-list { display: grid; gap: .7rem; }
.source-switch-card { display: flex; flex-direction: column; gap: .55rem; width: 100%; min-width: 0; padding: 1rem; border: 1px solid var(--reader-line); border-radius: var(--radius-lg); background: var(--reader-paper); color: var(--reader-ink); text-align: start; font: inherit; cursor: pointer; }
.source-switch-card:hover { border-color: var(--reader-accent); background: color-mix(in srgb, var(--reader-accent) 4%, var(--reader-paper)); }
@media (hover: hover) and (pointer: fine) { .source-switch-card:hover { border-color: var(--reader-accent); background: color-mix(in srgb, var(--reader-accent) 4%, var(--reader-paper)); }}
.source-switch-card:focus-visible { outline: var(--focus-ring); outline-offset: 2px; }
.source-switch-card:disabled { opacity: .55; cursor: default; }
.source-switch-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; font-size: var(--text-body); }
@@ -4048,7 +4078,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
.annotation-popover { position: fixed; z-index: 60; box-sizing: border-box; padding: .6rem; border: 1px solid var(--reader-line); border-radius: var(--radius-md); box-shadow: 0 6px 28px #0003; background: var(--reader-paper); color: var(--reader-ink); font: var(--text-ui)/1.5 system-ui,sans-serif; }
.annotation-actions { display: flex; gap: .25rem; }
.annotation-actions button { flex: 1; min-height: 44px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--reader-accent); font: inherit; cursor: pointer; }
.annotation-actions button:hover { background: var(--reader-surface); }
@media (hover: hover) and (pointer: fine) { .annotation-actions button:hover { background: var(--reader-surface); }}
.annotation-popover blockquote, .annotation-editor blockquote { margin: .3rem 0 .75rem; padding: .5rem .75rem; border-inline-start: 3px solid var(--reader-accent); max-height: 6rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.annotation-comment { margin: .5rem; max-height: 8rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.annotation-editor { display: grid; gap: 1rem; }
+64
View File
@@ -206,6 +206,9 @@ export class ReaderView {
* value it would write is the one already there.
*/
private trimApplied: number | null = null;
/** The scroll offset `syncPageMask` last measured, so its own scroll event is not a stray. */
private maskedAt: number | null = null;
private maskTimer: ReturnType<typeof setTimeout> | undefined;
/**
* What the highlight boxes were painted from, kept so they can be painted again.
*
@@ -234,6 +237,7 @@ export class ReaderView {
this.container.append(this.host);
this.bindChapterLinks();
this.host.flow.addEventListener('scroll', () => this.snapStrayScroll(), { passive: true });
this.host.addEventListener('scroll', () => this.pageMaskScrolled(), { passive: true });
// `load` does not bubble, so it is caught on the way down. A picture that
// arrives after the highlights were painted pushes the text below it.
this.host.flow.addEventListener('load', (event) => {
@@ -484,6 +488,8 @@ export class ReaderView {
this.animationTimer = setTimeout(() => {
this.animationTimer = null;
this.host.removeAttribute('data-animating');
// A measurement taken mid-turn was skipped (see syncPageMask); take it now.
this.syncPageMask();
}, 240);
}
@@ -779,6 +785,7 @@ export class ReaderView {
const total = this.screenCount();
const target = Math.min(total - 1, Math.max(0, Math.round(page)));
this.scroller.scrollTop = this.screenOffset(target);
this.syncPageMask();
this.emitPosition();
}
@@ -786,6 +793,7 @@ export class ReaderView {
this.detachSelection?.(); this.detachSelection = null;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
clearTimeout(this.maskTimer);
if (this.animationTimer !== null) clearTimeout(this.animationTimer);
this.animationTimer = null;
this.detachChapterLinks?.();
@@ -1072,10 +1080,12 @@ export class ReaderView {
const columns = this.columnCount();
const page = Math.round(this.sectionOffset * (columns - 1));
scroller.scrollLeft = page * this.columnStride(columns);
this.syncPageMask();
return;
}
const limit = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
scroller.scrollTop = limit * this.sectionOffset;
this.syncPageMask();
}
private emitPosition(): void {
@@ -1151,6 +1161,7 @@ export class ReaderView {
const before = scroller.scrollTop;
scroller.scrollTop = this.screenOffset(target);
this.pageTurnDistance = Math.abs(scroller.scrollTop - before);
this.syncPageMask();
this.emitPosition();
return true;
}
@@ -1280,6 +1291,59 @@ export class ReaderView {
this.host.style.setProperty('--reader-page-trim', `${trim}px`);
}
/**
* Hides the line the page's bottom edge cuts through, in scroll mode.
*
* The trim makes the page a whole number of *lines*, but a page is not a whole
* number of lines: paragraph margins and headings sit between them and move every
* line after them off the grid the trim was computed on. So on almost every page the
* last line still crossed the bottom edge and was drawn sliced in half — and the
* next page starts at that line's top (`snapToLine`), so nothing is lost by hiding
* it. The trim cannot do this itself: it is constant per chapter because the page
* count is derived from it, while the line the edge cuts depends on the page.
*
* Applied as a `clip-path` inset (reader.css) rather than a height, because a clip
* does not change `clientHeight`, so the page count, the page offsets and the
* ResizeObserver all stay exactly where the trim put them.
*/
private syncPageMask(): void {
clearTimeout(this.maskTimer);
const host = this.host;
// A turn in scroll mode slides the flow with translateY, so every line box is
// displaced until it lands; the end of the animation measures again.
if (host.hasAttribute('data-animating')) return;
let mask = 0;
if (this.doc.layout !== 'fixed' && this.settings.mode !== 'paged') {
const line = this.lineHeight();
const top = host.scrollTop;
const bottom = top + host.clientHeight;
let cut = Number.POSITIVE_INFINITY;
// Line-sized rects only: a range over a block also reports the boxes of the
// elements inside it, and a paragraph box crossing the edge is not a cut line.
if (line > 0) for (const box of this.lineBoxes()) {
if (box.bottom - box.top > line * 1.5) continue;
if (box.top >= top && box.top < bottom - 0.5 && box.bottom > bottom + 0.5 && box.top < cut) cut = box.top;
}
if (Number.isFinite(cut)) mask = Math.max(0, bottom - cut);
}
this.maskedAt = host.scrollTop;
if (mask > 0) host.style.setProperty('--reader-page-mask', `${mask}px`);
else host.style.removeProperty('--reader-page-mask');
}
/**
* A scroll the reader made by hand (scroll mode lets them drag): show everything
* while it moves, and mask again once it has settled. Measuring every frame would
* walk the chapter's line boxes per frame.
*/
private pageMaskScrolled(): void {
if (this.maskedAt !== null && Math.abs(this.host.scrollTop - this.maskedAt) < 1) return;
this.maskedAt = null;
this.host.style.removeProperty('--reader-page-mask');
clearTimeout(this.maskTimer);
this.maskTimer = setTimeout(() => this.syncPageMask(), 150);
}
/**
* One line's height, from the boxes that are on screen.
*
+24
View File
@@ -33,4 +33,28 @@ describe('design tokens', () => {
});
expect(offenders).toEqual([]);
});
it('only styles :hover where a pointer can hover (ui.md §2.5)', () => {
// On a touch screen a tap leaves the control in its hover look until the next tap
// elsewhere, so every :hover rule sits inside a `@media (hover: hover)` block.
const offenders = stylesheets(root).flatMap(file => {
const source = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
const open: string[] = [];
const found: string[] = [];
let start = 0;
for (let i = 0; i < source.length; i += 1) {
const char = source[i];
if (char !== '{' && char !== '}') continue;
if (char === '}') { open.pop(); start = i + 1; continue; }
const prelude = source.slice(start, i).replace(/^[\s\S]*;/, '').trim();
if (prelude.includes(':hover') && !open.some(block => block.startsWith('@media') && block.includes('hover'))) {
found.push(`${relative(root, file)}: ${prelude}`);
}
open.push(prelude);
start = i + 1;
}
return found;
});
expect(offenders).toEqual([]);
});
});
+5
View File
@@ -132,6 +132,11 @@ it('ships standalone launch metadata and a first-paint startup shell', () => {
const manifest = JSON.parse(readFileSync(resolve(process.cwd(), 'public/manifest.webmanifest'), 'utf8'));
expect(manifest.display).toBe('standalone');
expect(manifest.display_override).toContain('standalone');
// The page draws nothing in a title-bar area, so the overlay would hide the drag
// region and put the window controls over the content.
expect(manifest.display_override).not.toContain('window-controls-overlay');
// Android masks launcher icons; without a maskable one it shrinks ours into a disc.
expect(manifest.icons.some((icon: { purpose?: string }) => icon.purpose === 'maskable')).toBe(true);
expect(manifest.launch_handler.client_mode).toBe('navigate-existing');
const html = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
expect(html).toContain('id="app-startup"');
+13 -2
View File
@@ -1,4 +1,4 @@
// One PWA icon supplies APK launcher sizes and iOS startup images.
// One PWA icon supplies APK launcher sizes, the maskable PWA icon and iOS startup images.
// Run: node web/tools/pwa/branding.mjs
import { chromium } from 'playwright';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
@@ -13,7 +13,7 @@ try {
const bytes = await page.evaluate(async ({ source, width, height, size }) => {
const img = new Image(); img.src = source; await img.decode();
const canvas = document.querySelector('canvas'); canvas.width = width; canvas.height = height;
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fbfaf7'; ctx.fillRect(0, 0, width, height);
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fdf9f6'; ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, (width - size) / 2, (height - size) / 2, size, size);
return canvas.toDataURL('image/png').split(',')[1];
}, { source: `data:image/png;base64,${icon.toString('base64')}`, width, height, size });
@@ -23,6 +23,17 @@ try {
for (const [density, size] of Object.entries({ mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 })) {
await render(`../android/app/src/main/res/mipmap-${density}/ic_launcher.png`, size, size, size);
}
// Maskable: the tile's own colour to the edge, so the launcher's mask (circle,
// squircle…) cuts the background and never the glyph, which sits well inside the
// central 80% safe zone.
const svg = (await readFile(new URL('public/icons/reader.svg', root), 'utf8')).replace(/ rx="[^"]*"/, '');
const maskable = await page.evaluate(async (source) => {
const img = new Image(); img.src = source; await img.decode();
const canvas = document.querySelector('canvas'); canvas.width = 512; canvas.height = 512;
canvas.getContext('2d').drawImage(img, 0, 0, 512, 512);
return canvas.toDataURL('image/png').split(',')[1];
}, `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`);
await writeFile(new URL('public/icons/maskable-512.png', root), Buffer.from(maskable, 'base64'));
const devices = [[320,568,2],[375,667,2],[414,736,3],[375,812,3],[390,844,3],[393,852,3],[402,874,3],[414,896,2],[414,896,3],[428,926,3],[430,932,3],[440,956,3],[768,1024,2],[810,1080,2],[820,1180,2],[834,1194,2],[1024,1366,2]];
const links = [];
for (const [w,h,dpr] of devices) {