diff --git a/CLAUDE.md b/CLAUDE.md index 283f2bc..97a93c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//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 ``; 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`. diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index eabdfba..616a418 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 2731b54..a6fd103 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 29aba9a..96820fd 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 174d600..1ca372b 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 807cde2..0c37b9b 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/docs/pwa.zh-CN.md b/docs/pwa.zh-CN.md index 455330c..8484748 100644 --- a/docs/pwa.zh-CN.md +++ b/docs/pwa.zh-CN.md @@ -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` 安装。 diff --git a/docs/ui.md b/docs/ui.md index 73b99b3..39c6025 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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` 不模拟安全区,刘海下的按钮在它的截图里是看不出来的。 --- diff --git a/web/index.html b/web/index.html index 7e55454..0eb9b93 100644 --- a/web/index.html +++ b/web/index.html @@ -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 @@ + + diff --git a/web/public/icons/maskable-512.png b/web/public/icons/maskable-512.png new file mode 100644 index 0000000..0035b2a Binary files /dev/null and b/web/public/icons/maskable-512.png differ diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest index dadb80d..6eb6aac 100644 --- a/web/public/manifest.webmanifest +++ b/web/public/manifest.webmanifest @@ -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" } ] } diff --git a/web/public/startup/startup-1125x2436.png b/web/public/startup/startup-1125x2436.png index e6751d6..db38f94 100644 Binary files a/web/public/startup/startup-1125x2436.png and b/web/public/startup/startup-1125x2436.png differ diff --git a/web/public/startup/startup-1136x640.png b/web/public/startup/startup-1136x640.png index 7bb1764..bb1c9bb 100644 Binary files a/web/public/startup/startup-1136x640.png and b/web/public/startup/startup-1136x640.png differ diff --git a/web/public/startup/startup-1170x2532.png b/web/public/startup/startup-1170x2532.png index 28120fc..6f4d84e 100644 Binary files a/web/public/startup/startup-1170x2532.png and b/web/public/startup/startup-1170x2532.png differ diff --git a/web/public/startup/startup-1179x2556.png b/web/public/startup/startup-1179x2556.png index 2f652e9..66ae482 100644 Binary files a/web/public/startup/startup-1179x2556.png and b/web/public/startup/startup-1179x2556.png differ diff --git a/web/public/startup/startup-1206x2622.png b/web/public/startup/startup-1206x2622.png index 5ae9324..f64743c 100644 Binary files a/web/public/startup/startup-1206x2622.png and b/web/public/startup/startup-1206x2622.png differ diff --git a/web/public/startup/startup-1242x2208.png b/web/public/startup/startup-1242x2208.png index 5df8e96..41a4d11 100644 Binary files a/web/public/startup/startup-1242x2208.png and b/web/public/startup/startup-1242x2208.png differ diff --git a/web/public/startup/startup-1242x2688.png b/web/public/startup/startup-1242x2688.png index f9d2cb7..6b25f1b 100644 Binary files a/web/public/startup/startup-1242x2688.png and b/web/public/startup/startup-1242x2688.png differ diff --git a/web/public/startup/startup-1284x2778.png b/web/public/startup/startup-1284x2778.png index c56944d..11e835c 100644 Binary files a/web/public/startup/startup-1284x2778.png and b/web/public/startup/startup-1284x2778.png differ diff --git a/web/public/startup/startup-1290x2796.png b/web/public/startup/startup-1290x2796.png index 29ccc7d..8e0d409 100644 Binary files a/web/public/startup/startup-1290x2796.png and b/web/public/startup/startup-1290x2796.png differ diff --git a/web/public/startup/startup-1320x2868.png b/web/public/startup/startup-1320x2868.png index 2763b05..3fcc5e6 100644 Binary files a/web/public/startup/startup-1320x2868.png and b/web/public/startup/startup-1320x2868.png differ diff --git a/web/public/startup/startup-1334x750.png b/web/public/startup/startup-1334x750.png index 4842458..5792e28 100644 Binary files a/web/public/startup/startup-1334x750.png and b/web/public/startup/startup-1334x750.png differ diff --git a/web/public/startup/startup-1536x2048.png b/web/public/startup/startup-1536x2048.png index ece5074..a8092d8 100644 Binary files a/web/public/startup/startup-1536x2048.png and b/web/public/startup/startup-1536x2048.png differ diff --git a/web/public/startup/startup-1620x2160.png b/web/public/startup/startup-1620x2160.png index 3902d36..6cf3de4 100644 Binary files a/web/public/startup/startup-1620x2160.png and b/web/public/startup/startup-1620x2160.png differ diff --git a/web/public/startup/startup-1640x2360.png b/web/public/startup/startup-1640x2360.png index 6362808..e6ca078 100644 Binary files a/web/public/startup/startup-1640x2360.png and b/web/public/startup/startup-1640x2360.png differ diff --git a/web/public/startup/startup-1668x2388.png b/web/public/startup/startup-1668x2388.png index b836535..3a32fe4 100644 Binary files a/web/public/startup/startup-1668x2388.png and b/web/public/startup/startup-1668x2388.png differ diff --git a/web/public/startup/startup-1792x828.png b/web/public/startup/startup-1792x828.png index 81cfc3e..eaa6f7f 100644 Binary files a/web/public/startup/startup-1792x828.png and b/web/public/startup/startup-1792x828.png differ diff --git a/web/public/startup/startup-2048x1536.png b/web/public/startup/startup-2048x1536.png index 4ab8bc6..ecfc1aa 100644 Binary files a/web/public/startup/startup-2048x1536.png and b/web/public/startup/startup-2048x1536.png differ diff --git a/web/public/startup/startup-2048x2732.png b/web/public/startup/startup-2048x2732.png index e448b4f..9b03473 100644 Binary files a/web/public/startup/startup-2048x2732.png and b/web/public/startup/startup-2048x2732.png differ diff --git a/web/public/startup/startup-2160x1620.png b/web/public/startup/startup-2160x1620.png index 128ffcf..295969c 100644 Binary files a/web/public/startup/startup-2160x1620.png and b/web/public/startup/startup-2160x1620.png differ diff --git a/web/public/startup/startup-2208x1242.png b/web/public/startup/startup-2208x1242.png index f5b96e5..ca3943f 100644 Binary files a/web/public/startup/startup-2208x1242.png and b/web/public/startup/startup-2208x1242.png differ diff --git a/web/public/startup/startup-2360x1640.png b/web/public/startup/startup-2360x1640.png index 92e960d..b5f9193 100644 Binary files a/web/public/startup/startup-2360x1640.png and b/web/public/startup/startup-2360x1640.png differ diff --git a/web/public/startup/startup-2388x1668.png b/web/public/startup/startup-2388x1668.png index 57344ff..64a26a4 100644 Binary files a/web/public/startup/startup-2388x1668.png and b/web/public/startup/startup-2388x1668.png differ diff --git a/web/public/startup/startup-2436x1125.png b/web/public/startup/startup-2436x1125.png index d335ba1..676e507 100644 Binary files a/web/public/startup/startup-2436x1125.png and b/web/public/startup/startup-2436x1125.png differ diff --git a/web/public/startup/startup-2532x1170.png b/web/public/startup/startup-2532x1170.png index b43d87f..228c439 100644 Binary files a/web/public/startup/startup-2532x1170.png and b/web/public/startup/startup-2532x1170.png differ diff --git a/web/public/startup/startup-2556x1179.png b/web/public/startup/startup-2556x1179.png index 8953068..ff2aca0 100644 Binary files a/web/public/startup/startup-2556x1179.png and b/web/public/startup/startup-2556x1179.png differ diff --git a/web/public/startup/startup-2622x1206.png b/web/public/startup/startup-2622x1206.png index 11e8c4c..9ba3baa 100644 Binary files a/web/public/startup/startup-2622x1206.png and b/web/public/startup/startup-2622x1206.png differ diff --git a/web/public/startup/startup-2688x1242.png b/web/public/startup/startup-2688x1242.png index 1cdbb6d..b4f6d79 100644 Binary files a/web/public/startup/startup-2688x1242.png and b/web/public/startup/startup-2688x1242.png differ diff --git a/web/public/startup/startup-2732x2048.png b/web/public/startup/startup-2732x2048.png index 43aeb80..d34ed7d 100644 Binary files a/web/public/startup/startup-2732x2048.png and b/web/public/startup/startup-2732x2048.png differ diff --git a/web/public/startup/startup-2778x1284.png b/web/public/startup/startup-2778x1284.png index e04c21f..54f2372 100644 Binary files a/web/public/startup/startup-2778x1284.png and b/web/public/startup/startup-2778x1284.png differ diff --git a/web/public/startup/startup-2796x1290.png b/web/public/startup/startup-2796x1290.png index f13650c..132d8a7 100644 Binary files a/web/public/startup/startup-2796x1290.png and b/web/public/startup/startup-2796x1290.png differ diff --git a/web/public/startup/startup-2868x1320.png b/web/public/startup/startup-2868x1320.png index b1ee87b..888215d 100644 Binary files a/web/public/startup/startup-2868x1320.png and b/web/public/startup/startup-2868x1320.png differ diff --git a/web/public/startup/startup-640x1136.png b/web/public/startup/startup-640x1136.png index eda9fe1..0b75e24 100644 Binary files a/web/public/startup/startup-640x1136.png and b/web/public/startup/startup-640x1136.png differ diff --git a/web/public/startup/startup-750x1334.png b/web/public/startup/startup-750x1334.png index 2519204..6714dcf 100644 Binary files a/web/public/startup/startup-750x1334.png and b/web/public/startup/startup-750x1334.png differ diff --git a/web/public/startup/startup-828x1792.png b/web/public/startup/startup-828x1792.png index 0a9733d..0dbf836 100644 Binary files a/web/public/startup/startup-828x1792.png and b/web/public/startup/startup-828x1792.png differ diff --git a/web/src/app/shell.css b/web/src/app/shell.css index 30fd29d..b7dcae1 100644 --- a/web/src/app/shell.css +++ b/web/src/app/shell.css @@ -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 ---- */ diff --git a/web/src/features/account/styles/mine.css b/web/src/features/account/styles/mine.css index 661adae..5947df3 100644 --- a/web/src/features/account/styles/mine.css +++ b/web/src/features/account/styles/mine.css @@ -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 { diff --git a/web/src/features/home/styles/home.css b/web/src/features/home/styles/home.css index 8043701..b397006 100644 --- a/web/src/features/home/styles/home.css +++ b/web/src/features/home/styles/home.css @@ -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; } diff --git a/web/src/features/media/styles/dplayer.css b/web/src/features/media/styles/dplayer.css index c4fdaa0..ae6a341 100644 --- a/web/src/features/media/styles/dplayer.css +++ b/web/src/features/media/styles/dplayer.css @@ -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)} diff --git a/web/src/features/media/styles/favorites.css b/web/src/features/media/styles/favorites.css index b8baa3e..0af9247 100644 --- a/web/src/features/media/styles/favorites.css +++ b/web/src/features/media/styles/favorites.css @@ -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} diff --git a/web/src/features/media/styles/presentation.css b/web/src/features/media/styles/presentation.css index 79d5930..e964245 100644 --- a/web/src/features/media/styles/presentation.css +++ b/web/src/features/media/styles/presentation.css @@ -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)} diff --git a/web/src/features/media/styles/v2.css b/web/src/features/media/styles/v2.css index 405ffff..a3e8ce7 100644 --- a/web/src/features/media/styles/v2.css +++ b/web/src/features/media/styles/v2.css @@ -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)} diff --git a/web/src/styles/notifications.css b/web/src/styles/notifications.css index 1e024ed..6879b1f 100644 --- a/web/src/styles/notifications.css +++ b/web/src/styles/notifications.css @@ -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; } diff --git a/web/src/styles/pwa.css b/web/src/styles/pwa.css index c8b74fd..3a0964c 100644 --- a/web/src/styles/pwa.css +++ b/web/src/styles/pwa.css @@ -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; diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 25d556a..a0c8cb3 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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; } diff --git a/web/src/ui/reader-view.ts b/web/src/ui/reader-view.ts index 86ae8a8..5a2fc5b 100644 --- a/web/src/ui/reader-view.ts +++ b/web/src/ui/reader-view.ts @@ -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 | 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. * diff --git a/web/test/design-tokens.test.ts b/web/test/design-tokens.test.ts index 76a97cf..bd7ceaf 100644 --- a/web/test/design-tokens.test.ts +++ b/web/test/design-tokens.test.ts @@ -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([]); + }); }); diff --git a/web/test/pwa.test.ts b/web/test/pwa.test.ts index 6186485..3127a96 100644 --- a/web/test/pwa.test.ts +++ b/web/test/pwa.test.ts @@ -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"'); diff --git a/web/tools/pwa/branding.mjs b/web/tools/pwa/branding.mjs index b9a310d..0c60a38 100644 --- a/web/tools/pwa/branding.mjs +++ b/web/tools/pwa/branding.mjs @@ -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) {