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>
No files matched your search
@@ -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`.
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -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` 安装。
|
||||
@@ -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` 不模拟安全区,刘海下的按钮在它的截图里是看不出来的。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
@@ -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 ---- */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 @@
|
||||
.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,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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
|
||||
@@ -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) {
|
||||
|
||||