diff --git a/CHANGELOG.md b/CHANGELOG.md index 541f0f6a..e826fd20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-15 — 手机上回答按钮更好点;平板宽度不再串台 + +手机上复制 / 点赞 / 点踩 / 重新生成的可点区域是 44×44,图标看起来还是原来那么大。平板按 1024 切,不再在 901–1023 之间侧栏窄、内容却按桌面排。报告中心和阅读页跟全局移动断点对齐。Skill 版本不变。 + ## 2026-09-15 — 首页不再给主题卡;回答默认只展开口语层 首页「从一个主题开始」整段下线,不再为六张卡调大模型、也不再等它揭幕。聊天里本命类回答默认只看到口语层(一句结论、2–3 条短要点、一句下一步);从「统一参数与原始结构」起的骨架折在「完整分析」里,内容一字不删,复制和导出仍是全文。手机上「完整分析」里的四列表可以横着滑,三列技法表仍是卡片。登录和资料输入框改成 16px,避免 iPhone 一点就整页放大。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1e68e5de..42f08066 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10805,3 +10805,51 @@ - 相关记录:BUG-693、BUG-576 - 复发自:无 - 修复版本:待发布 + +## BUG-695 | 回答操作触屏热区 27×34,点赞容易点成点踩 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:聊天回答下方复制 / 点赞 / 点踩 / 重新生成,以及代码块复制按钮 +- 用户现象:四个图标并排,手机上点赞很容易点成点踩。 +- 触发条件:触屏设备上点助手回答下方的操作行。 +- 根因:复发自 BUG-446 的有意让步。当时中心距只有 27px、下方 follow-up 只有 8px,44×44 会互相吃点击,热区停在 27×34。触屏推荐仍是 44×44。 +- 修复:视觉图标仍是 26×26。`(pointer: coarse)` 下 gap 和底边距收到 20px,伪元素放大到 44×44。细指针几何不变。代码块复制按钮在粗指针下 `min-height: 44px`。 +- 验证:代码级契约测试锁细指针 27×34、粗指针 44×44 且 gap/底边 20px。浏览器级验收见 `docs/testing/mobile-touch-and-breakpoints-20260915.md`。 +- 防复发:`frontend/tests/touch-target-contract.test.ts` 同时锁两套几何。粗指针热区不得与相邻按钮或 follow-up 药丸重叠。 +- 相关记录:BUG-446 +- 复发自:BUG-446(当时为避免重叠把热区停在 27×34) +- 修复版本:待发布 + +## BUG-696 | CSS 平板上限 900px 与侧栏 1024 不一致,901–1023 是混合态 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:兑换码表单、模型选择器、首页产品卡在平板宽度下的栅格 +- 用户现象:窗口拖到大约 iPad 横屏附近时,侧栏已经是窄版平板,内容区却按桌面排。 +- 触发条件:视口宽度 901–1023px。 +- 根因:`sidebarViewportForWidth` 在 1024 切桌面,CSS 另有 `max-width: 900px` 当平板上限。 +- 修复:这些 CSS 切点改为 `max-width: 1023px`。主题卡留下的 `.starter-list > button` 孤儿规则删除,不改断点再留着。 +- 验证:`viewport-breakpoint-contract.test.ts` 断言 `@media` 里不再出现 `max-width: 900px`,且 JS 阈值仍是 768 / 1024。浏览器级验收见 `docs/testing/mobile-touch-and-breakpoints-20260915.md`。 +- 防复发:宽度断点白名单契约测试;新切点必须先改白名单。 +- 相关记录:BUG-697 +- 复发自:无 +- 修复版本:待发布 + +## BUG-697 | 报告域 720 / 760 / 860 三个断点互不对齐 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:报告中心、专业报告阅读页、报告目录 +- 用户现象:761–860px 目录已经塌成抽屉,正文还是桌面内边距;721–760 阅读区已窄、报告中心还是桌面。 +- 触发条件:视口落在那两个混合区间。 +- 根因:报告中心用 720、阅读区用 760、目录用 860,彼此和全局 767 都不对齐。 +- 修复:720 与 760 并入 767。860 保留并就地注释:目录是 1120 阅读页的第三栏,必须比正文更早塌。会员 639/640 与校正 640/641 统一为 640/641。录入卡 620 并入 640。toast / 校正面 430 并入 480。 +- 验证:白名单契约测试抽出的 `@media` 宽度集合等于 480 / 640 / 641 / 767 / 768 / 860 / 1023 / 1024。浏览器级验收见 `docs/testing/mobile-touch-and-breakpoints-20260915.md`。 +- 防复发:`viewport-breakpoint-contract.test.ts`;`globals.css` 顶部白名单注释。 +- 相关记录:BUG-696 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-mobile-touch-and-breakpoints-20260915.md b/docs/tasks/PROGRESS-mobile-touch-and-breakpoints-20260915.md new file mode 100644 index 00000000..4f5dc5c0 --- /dev/null +++ b/docs/tasks/PROGRESS-mobile-touch-and-breakpoints-20260915.md @@ -0,0 +1,48 @@ +# PROGRESS · 消息操作触控目标 + 断点错位(2026-09-15) + +- 执行分支:`codex/mobile-touch-and-breakpoints-20260915` +- 工作树:`.worktrees/mobile-touch-and-breakpoints-20260915` +- 基线:任务书写 `41473261`;实际按串行依赖用合入后的 `origin/staging` @ `8144fca2` + +## 做了什么 + +BUG-695:细指针几何不变(26×26 视觉,27×34 热区,gap 1px,底边 8px)。`(pointer: coarse)` 下 gap 与底边距改为 `--space-5`(20px),热区 44×44。代码块复制按钮粗指针 `min-height: 44px`。注释改为说明为什么现在 44×44 不会抢 follow-up。 + +BUG-696:`max-width: 900px` 的 `@media` 全部改为 `1023px`。主题卡留下的 `.starter-list > button` 孤儿规则删除。`width: min(900px, 100%)` 是栏宽不是切点,未动。JS `sidebarViewportForWidth` 未动。 + +BUG-697:报告中心 720、阅读区 760 并入 767。目录 860 保留,就地注释第三栏内容宽度。会员 639/640 改为 640/641。录入卡 620 并入 640。toast / 校正面 430 并入 480。 + +## 测试数字 + +| | `test(` 声明(`git grep -E '^test\(' -- frontend/tests`) | +| --- | ---: | +| 基线 `8144fca2` | 3208 | +| 改后 | 3213(+4 `viewport-breakpoint-contract`,+1 `touch-target-contract` 粗指针) | + +定向(本机): + +| 套件 | 结果 | +| --- | --- | +| `touch-target-contract` | 3 pass | +| `viewport-breakpoint-contract` | 4 pass | +| `chat-stream-layout` | 6 pass | +| `membership-page` 本轮改的 640/641 栅格 | pass | +| `membership-page`「login redirects…」 | 本机失败:未改的 `page.tsx` 工作区是 CRLF,正则要 `\n`。不是本单回归。 | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | +| `npm run lint` | **0 error** / 120 warning(既有,未动) | + +本机 `next build` 仍受 Windows `SKILL.md` symlink `EPERM` 限制,不作为通过证据。`/` Static 与首屏 gzip 留给 Gitea 门禁。浏览器 / 真机项见 `docs/testing/mobile-touch-and-breakpoints-20260915.md`,不得写成通过。 + +## 既有断言三栏 + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `touch-target-contract` 热区 | 只锁 27×34 | 细指针仍 27×34,另锁粗指针 44×44 | BUG-695 | +| `membership-page` 栅格 | `min-width: 640` / `max-width: 639` | `641` / `640` | BUG-697 与校正那组对齐 | + +## 偏离 + +- 任务 3.2:860 **保留**。目录是 1120 阅读页第三栏,768–860 若并入 1023 会让抽屉和宽正文抢同一段。 +- 任务 3.3:430 并入 480、620 并入 640,未看到必须保留原值的理由。 +- 任务 1 未走让步 3(热区高度仍是 44,不只加宽)。 +- 浏览器 / 真机项见 `docs/testing/mobile-touch-and-breakpoints-20260915.md`,不得写成通过。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e27f93a2..55bcec76 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -118,7 +118,7 @@ | `TASK-unified-loading-20260902.md` | `PROGRESS-unified-loading-20260902.md` | 首页一次等待一次揭幕 | 已验收 | `482796fc`(BUG-479) | | — | — | 初始化后点首页「家庭」报运行合同未完成:本命第一步未强制计算工具、主题卡无入口钉死 | 入口已下线,机制保留供历史会话 | `codex/consultation-family-contract-incomplete-20260909`(BUG-630);主题卡发出点已删,`guided_topic` 枚举仍可读回;走查 `docs/testing/consultation-family-contract-incomplete-20260909.md` 不再可执行 | | `TASK-chat-reading-load-20260915.md` | `PROGRESS-chat-reading-load-20260915.md` | 首页主题卡下线 + 回答两层可见性 + 折叠层宽表可横滑 + 输入框 16px。内容一字不删。⚠️ BUG-630 走查路径失效 | 待验收 | `codex/chat-reading-load-20260915` | -| `TASK-mobile-touch-and-breakpoints-20260915.md` | — | 消息操作按钮触屏命中区 27×34、相邻仅隔 1px(BUG-695);CSS 平板上限 900px 与 `sidebarViewportForWidth` 的 1024 不一致,901–1023 是混合态(BUG-696);报告域 720/760/860 三个断点互不对齐,761–860 目录已塌、正文还是桌面(BUG-697)。含断点白名单契约测试。**串行在 chat-reading-load 之后** | 待领取 | `codex/mobile-touch-and-breakpoints-20260915` | +| `TASK-mobile-touch-and-breakpoints-20260915.md` | `PROGRESS-mobile-touch-and-breakpoints-20260915.md` | 消息操作按钮触屏命中区 27×34、相邻仅隔 1px(BUG-695);CSS 平板上限 900px 与 `sidebarViewportForWidth` 的 1024 不一致,901–1023 是混合态(BUG-696);报告域 720/760/860 三个断点互不对齐,761–860 目录已塌、正文还是桌面(BUG-697)。含断点白名单契约测试。**串行在 chat-reading-load 之后** | 待验收 | `codex/mobile-touch-and-breakpoints-20260915` | ### 个人报告 diff --git a/docs/testing/mobile-touch-and-breakpoints-20260915.md b/docs/testing/mobile-touch-and-breakpoints-20260915.md new file mode 100644 index 00000000..974ead25 --- /dev/null +++ b/docs/testing/mobile-touch-and-breakpoints-20260915.md @@ -0,0 +1,46 @@ +# 真机 / 浏览器清单 · 触控目标与断点(2026-09-15) + +代码级合同在 `frontend/tests/touch-target-contract.test.ts` 与 `viewport-breakpoint-contract.test.ts`。下面这些必须用浏览器或真机看,自动化替代不了。 + +地址:`https://staging.jyotisha.chat`。截图不要带真实姓名、出生资料或会话内容。 + +## A. BUG-695 回答操作按钮 + +### 桌面(鼠标,`pointer: fine`) + +1. 打开一条已有助手回答。 +2. **看:** 复制 / 点赞 / 点踩 / 重新生成四个图标看起来仍是原来那么大、那么密,肉眼无变化。 + +### 手机或开了触屏仿真的 Chrome(`pointer: coarse`) + +1. 同一条回答。 +2. **看:** 四个图标视觉仍是小的,但彼此之间空档更大,和下面的 follow-up 药丸也不贴在一起。 +3. **点:** 点赞不要点成点踩;点复制不要点到点赞。四个都能单独点到。 +4. 若这条回答有代码块,点右上角复制。**看:** 热区够大,不会点空。 + +## B. BUG-696 平板宽度 901 / 1023 / 1024 + +开发者工具设宽度,刷新后再看。 + +| 宽度 | 打开 | 看什么 | +| ---: | --- | --- | +| 901 | 空首页 | 今日星语和生时校正是单栏上下排,不是并排。侧栏是窄版平板(约 240px),不是桌面 288px。 | +| 901 | 设置 → 账户与点数里的兑换码表单 | 两列表单还在,没有突然变成桌面三列或挤成一团。 | +| 901 | 顶栏模型选择器 | 触发器占满当前格,没有和旁边控件抢位置。 | +| 1023 | 同上三处 | 仍按平板排,与 901 同类。 | +| 1024 | 同上三处 | 侧栏切到桌面宽度;首页两张产品卡可以回到两列。 | + +## C. BUG-697 报告页 761 / 800 / 860 + +需要一条已生成的专业报告。没有就先生成再测。 + +| 宽度 | 看什么 | +| ---: | --- | +| 761 | 报告中心列表已是移动内边距;阅读页正文也是窄边距。目录若在阅读页,已经是抽屉而不是侧栏。 | +| 800 | 与 761 同类:目录仍是抽屉,正文用窄边距,不再出现「目录抽屉 + 桌面正文边距」的串台。 | +| 860 | 目录仍可以是抽屉(860 是第三栏内容宽度,允许比 767 更早塌)。正文边距与 767 移动规则一致。 | +| 861 | 目录可以回到侧栏。若这一档看起来挤,记下来,不要自己改 860。 | + +## 结论 + +> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index c899594d..1d34c3bf 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -168,9 +168,9 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and - Chat reading width: 760px for the welcome/composer and 900px for long answers. Both chat surfaces share the 900px transcript width; the rectification session no longer narrows it to 720px. - Admin content width: 1200px, centered. - Desktop shell: 288px sidebar plus flexible reading panel. -- Layout breakpoints: mobile below 768px, tablet 768–1023px, desktop 1024px and above. These three decide the shell — sidebar mode, grid columns, drawer versus rail. -- Component breakpoints are separate and narrower, each owned by one surface rather than the shell: 900px and 480px (redemption and profile forms), 760px and 720px (personal report and report centre), 640px (rectification candidates), 767px (settings dialog mobile full-screen), 620px (onboarding cards), 430px (toasts and the conversational rectification panel). Do not repurpose one for another surface, and do not assume testing the three layout tiers covers them. -- All full-height surfaces use `100dvh`. Touch targets are at least 44px. Where adjacent controls sit closer than 44px, the hit expands to the largest size that does not overlap; those exceptions are locked by `frontend/tests/touch-target-contract.test.ts`. +- Layout breakpoints: mobile below 768px, tablet 768–1023px, desktop 1024px and above. These three decide the shell — sidebar mode, grid columns, drawer versus rail. CSS `@media` width cuts must follow `sidebarViewportForWidth` in `frontend/src/lib/sidebar-state.ts` (768 / 1024), not the other way around. The allowlist lives as a comment at the top of `globals.css` and is locked by `frontend/tests/viewport-breakpoint-contract.test.ts`. +- Allowed `@media` widths: 480 (small phone), 640/641 (content grids: membership, rectification candidates, intake card), 767/768 (mobile/tablet), 860 (report TOC, content width of the third column), 1023/1024 (tablet/desktop). Do not add a new width without updating that list and the contract test in the same change. +- All full-height surfaces use `100dvh`. Touch targets: on a coarse pointer the hit is 44×44; the visual control may stay smaller. Message-action icons stay 26×26; a fine pointer keeps the 27×34 overlay, a coarse pointer opens gap and bottom margin to 20px so a 44×44 overlay does not eat the next button or the follow-up pills. Locked by `frontend/tests/touch-target-contract.test.ts`. ## 5. Components @@ -570,3 +570,9 @@ Admin 的 antd `` 是独立设计系统,不在此表。 - **窄屏表格:** 三列表(技法审计)继续卡片化。四列及以上(Yoga、统一参数)在 `.conversation` 里横向滚动,右缘用 canvas 渐隐提示还可滑;`.conversation` 自身 `overflow-x: hidden`,页面不得出现横向滚动条。个人报告表格不走这套规则。 - **表单输入 16px:** 全局 `input` / `select` 用 `--type-body-md`(16px)。iOS Safari 对小于 16px 的字段聚焦时会整页放大且不缩回。`.otp-input` 仍是 20px,`.composer textarea` 仍是 16px。 +## 14. 断点 + +宽度切点只允许出现在 `globals.css` 顶部注释的白名单里。壳层切点以 `sidebarViewportForWidth` 为准:小于 768 移动,小于 1024 平板,否则桌面。组件级栅格(会员方案卡、校正候选、录入卡)用 640/641。报告目录在 860 塌成抽屉,因为那是第三栏的内容宽度,不是设备宽度。 + +禁止再引入 900 / 720 / 760 / 639 / 620 / 430 这类与壳层或彼此错位的切点。新切点必须先改白名单和 `viewport-breakpoint-contract.test.ts`。 + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index de01103c..d5abe4e2 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -51,6 +51,15 @@ --radius-lg: 12px; } +/* Width breakpoints allowed in @media (min-width / max-width). + CSS must follow sidebar-state.ts (768 / 1024), not the other way around. + 480 small phone + 640/641 content grids (membership cards, rectification candidates, intake card) + 767/768 mobile / tablet, same cut as sidebarViewportForWidth + 860 report TOC (third column; content width, not a device) + 1023/1024 tablet / desktop, same cut as sidebarViewportForWidth + Adding a new width here means updating this list and + frontend/tests/viewport-breakpoint-contract.test.ts in the same change. */ :root { color-scheme: light; --color-action: #85432f; @@ -548,7 +557,7 @@ button:disabled { cursor: default; opacity: .45; } .account-menu-item:hover { background: var(--color-canvas-muted); } } -@media (max-width: 900px) { +@media (max-width: 1023px) { .code-form { grid-template-columns: 1fr 1fr; } .code-form .note-field { grid-column: 1 / -1; } .code-form .button-primary { justify-self: start; } @@ -1272,9 +1281,11 @@ button:disabled { cursor: default; opacity: .45; } opacity: .7; transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; } -/* Visual icon stays 26px. Neighbours sit 27px centre-to-centre (26px + 1px gap) - and follow-ups start after an 8px bottom margin, so a 44×44 overlay would - steal clicks. This is the largest non-overlapping hit: 27×34. */ +/* Visual icon stays 26px. Fine pointer: neighbours sit 27px centre-to-centre + (26px + 1px gap) and follow-ups start after an 8px bottom margin, so the + overlay stays 27×34 — the largest hit that does not overlap. Coarse pointer: + gap opens to 20px (centre-to-centre 46px) and bottom margin to 20px, so a + 44×44 overlay fits without eating the next button or the follow-up pills. */ .message-actions button::after { position: absolute; top: 50%; @@ -1284,6 +1295,19 @@ button:disabled { cursor: default; opacity: .45; } content: ""; transform: translate(-50%, -50%); } +@media (pointer: coarse) { + .message-actions { + gap: var(--space-5); + margin-bottom: var(--space-5); + } + .message-actions button::after { + width: 44px; + height: 44px; + } + .markdown-code-copy { + min-height: 44px; + } +} .message-actions button:hover:not(:disabled) { background: color-mix(in srgb, var(--color-canvas-muted) 72%, transparent); color: var(--color-ink-secondary); @@ -1659,7 +1683,7 @@ button:disabled { cursor: default; opacity: .45; } .model-selector-check { width: 18px; height: 18px; opacity: 0; color: var(--color-action); } .model-selector-option[data-selected] .model-selector-check { opacity: 1; } -@media (max-width: 900px) { +@media (max-width: 1023px) { .model-selector-trigger { max-width: 100%; grid-column: 1; grid-row: 1; } } @@ -1856,16 +1880,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .composer button:not(:disabled):hover, .button-primary:not(:disabled):hover { background: var(--color-action-hover); } .model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); } .model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); } - .starter-list > button:not(:disabled):hover { background: var(--color-canvas-strong); } - .starter-list > button:first-of-type:not(:disabled):hover { background: color-mix(in srgb, var(--color-action-soft) 72%, var(--color-canvas)); } .button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); } .danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); } .birth-time-source-option:hover, .birth-time-answer-list button:not(:disabled):hover { border-color: var(--color-action); } } -@media (min-width: 768px) and (max-width: 900px) { - .starter-list { grid-template-columns: 1fr; grid-template-rows: auto; } - .starter-list > button { min-height: 112px; grid-row: auto; padding: var(--space-4); } +@media (min-width: 768px) and (max-width: 1023px) { .product-entrypoints { grid-template-columns: 1fr; } .auth-shell { grid-template-columns: 1.2fr .8fr; } .auth-story, .auth-panel { padding: var(--space-8); } @@ -1895,7 +1915,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .welcome { padding: var(--space-6) 0 var(--space-10); } .welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-display-sm); } .starter-list { grid-template-columns: 1fr; grid-template-rows: auto; } - .starter-list > button { min-height: 112px; grid-row: auto; padding: var(--space-4); } .product-entrypoints { grid-template-columns: 1fr; } .message-list { width: 100%; padding: var(--space-5) var(--space-4) var(--space-12); } .message-content { max-width: 88%; } @@ -1954,7 +1973,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .auth-brand { display: flex; margin-bottom: var(--space-6); } } -@media (max-width: 430px) { +@media (max-width: 480px) { [data-sonner-toaster] { right: var(--space-4) !important; left: var(--space-4) !important; width: auto !important; } .conversational-rectification { width: 100%; max-width: 100%; gap: var(--space-4); overflow-x: clip; } .conversational-narrative-body, .conversational-composer, .conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-empty-state { padding: var(--space-3); } @@ -2196,9 +2215,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin-inline: auto; } -@media (min-width: 768px) and (max-width: 900px) { +@media (min-width: 768px) and (max-width: 1023px) { .starter-hero { - grid-template-columns: minmax(0, 1fr) 180px; padding: var(--space-8); } @@ -2535,7 +2553,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .onboarding-card-actions { align-items: center; justify-content: space-between; gap: var(--space-4); padding-top: var(--space-2); } .onboarding-card-action-hint { color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.4; } -@media (max-width: 620px) { +@media (max-width: 640px) { .onboarding-card-heading { align-items: flex-start; flex-direction: column; gap: var(--space-2); } .birth-time-source-list, .birth-time-detail-grid { grid-template-columns: 1fr; } .birth-time-window-details .birth-time-skip-button { grid-column: auto; justify-self: stretch; } @@ -3700,7 +3718,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .report-center-message p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); } -@media (max-width: 720px) { +@media (max-width: 767px) { .report-center-shell { padding-inline: var(--space-4); } .report-center-hero { grid-template-columns: 1fr; align-items: start; gap: var(--space-6); padding-block: var(--space-8) var(--space-6); } .report-center-hero h1 { font-size: var(--type-display-md); letter-spacing: -.5px; } @@ -3824,6 +3842,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class max-width: none; margin: var(--space-3) 0 0; } +/* Report TOC is the third column of a 1120px reader. It has to collapse + before the article padding does, or 768–860px puts a rail beside a still-wide + body. 860 is the content width of that rail, not a device cut. */ @media (max-width: 860px) { .personal-report-md-layout { grid-template-columns: 1fr; } .personal-report-toc { position: static; max-height: none; } @@ -4299,7 +4320,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .personal-report-avoid-break { break-inside: avoid-page; } .personal-report-avoid-break-row { break-inside: avoid; } -@media (max-width: 760px) { +@media (max-width: 767px) { .personal-report-reader-body { padding: 0 var(--space-4) var(--space-12); } .personal-report-actions > div { min-height: 50px; padding: var(--space-2) var(--space-4); } .personal-report-action-end > span { display: none; } @@ -4485,12 +4506,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .membership-payment-panel .dialog-actions { grid-column: 1 / -1; } } -@media (max-width: 639px) { +@media (max-width: 640px) { .membership-plan-card--recommended { order: -1; } .membership-payment-error { align-items: flex-start; flex-direction: column; } } -@media (min-width: 640px) { +@media (min-width: 641px) { .membership-plan-grid, .membership-credit-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/frontend/tests/membership-page.test.ts b/frontend/tests/membership-page.test.ts index d1c8c8d5..9b2a4c8e 100644 --- a/frontend/tests/membership-page.test.ts +++ b/frontend/tests/membership-page.test.ts @@ -80,9 +80,12 @@ test("membership hero stays content-led without decorative orbit artwork", () => test("desktop uses three columns, tablet two and mobile one with monthly first", () => { assert.match(globalStyles, /\.membership-plan-grid,[\s\S]*\.membership-credit-grid\s*\{[^}]*grid-template-columns:\s*1fr/); - assert.match(globalStyles, /@media \(min-width:\s*640px\)[\s\S]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/); + // 原值: 会员栅格 min-width 640 / max-width 639,与校正的 640/641 差 1px。 + // 新值: 统一为 640/641。 + // 原因: BUG-697 噪音收敛,白名单只留一组内容级切点。 + assert.match(globalStyles, /@media \(min-width:\s*641px\)[\s\S]*\.membership-plan-grid[\s\S]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/); assert.match(globalStyles, /@media \(min-width:\s*1024px\)[\s\S]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/); - assert.match(globalStyles, /@media \(max-width:\s*639px\)[\s\S]*\.membership-plan-card--recommended\s*\{[^}]*order:\s*-1/); + assert.match(globalStyles, /@media \(max-width:\s*640px\)[\s\S]*\.membership-plan-card--recommended\s*\{[^}]*order:\s*-1/); assert.doesNotMatch(cssBlock(".membership-plan-card--recommended"), /grid-column/); }); diff --git a/frontend/tests/touch-target-contract.test.ts b/frontend/tests/touch-target-contract.test.ts index d5ac5eb7..4ea33b22 100644 --- a/frontend/tests/touch-target-contract.test.ts +++ b/frontend/tests/touch-target-contract.test.ts @@ -32,6 +32,9 @@ test("icon and chip controls keep their visual size and expand the hit with a ps assert.match(actions, /height:\s*26px/); assert.match(cssDeclarations(".message-actions", globalStyles), /gap:\s*1px/); const actionHit = hasHitExpandPseudo(".message-actions button"); + // 原值: 热区锁 27×34(44×44 会与相邻按钮和 follow-up 重叠)。 + // 新值: 细指针仍是 27×34;粗指针见下一条,gap 20px + 底边 20px 后才放大到 44×44。 + // 原因: BUG-695,触屏点赞容易点成点踩。 assert.match(actionHit, /width:\s*27px/); assert.match(actionHit, /height:\s*34px/); @@ -42,3 +45,16 @@ test("icon and chip controls keep their visual size and expand the hit with a ps assert.match(auth, /min-height:\s*32px/); assert.match(hasHitExpandPseudo(".auth-links button"), /inset:\s*-4px/); }); + +test("coarse pointers get a 44×44 message-action hit that does not overlap neighbours", () => { + const coarseStart = globalStyles.indexOf("@media (pointer: coarse)"); + assert.notEqual(coarseStart, -1); + const coarse = globalStyles.slice(coarseStart, globalStyles.indexOf("}", globalStyles.indexOf(".markdown-code-copy", coarseStart)) + 1); + assert.match(coarse, /\.message-actions \{[\s\S]*gap:\s*var\(--space-5\)/); + assert.match(coarse, /margin-bottom:\s*var\(--space-5\)/); + assert.match(coarse, /\.message-actions button::after \{[\s\S]*width:\s*44px;[\s\S]*height:\s*44px;/); + assert.match(coarse, /\.markdown-code-copy \{[\s\S]*min-height:\s*44px;/); + const base = globalStyles.slice(0, coarseStart); + assert.match(base, /\.message-actions \{[\s\S]*gap:\s*1px;/); + assert.match(base, /\.message-actions button::after \{[\s\S]*width:\s*27px;[\s\S]*height:\s*34px;/); +}); diff --git a/frontend/tests/viewport-breakpoint-contract.test.ts b/frontend/tests/viewport-breakpoint-contract.test.ts new file mode 100644 index 00000000..ac9e1a3d --- /dev/null +++ b/frontend/tests/viewport-breakpoint-contract.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const sidebar = readFileSync(new URL("../src/lib/sidebar-state.ts", import.meta.url), "utf8"); + +/** Widths that may appear in @media (min-width / max-width). Keep in sync with the comment at the top of globals.css. */ +const ALLOWED = new Set([480, 640, 641, 767, 768, 860, 1023, 1024]); + +function mediaWidthBreakpoints(source: string): number[] { + const widths = new Set(); + for (const block of source.matchAll(/@media[^{]+/g)) { + for (const hit of block[0].matchAll(/\((?:min|max)-width:\s*(\d+)px\)/g)) { + widths.add(Number(hit[1])); + } + } + return [...widths].sort((a, b) => a - b); +} + +test("CSS width breakpoints stay on the published allowlist", () => { + assert.deepEqual(mediaWidthBreakpoints(css), [...ALLOWED].sort((a, b) => a - b)); +}); + +test("tablet CSS cuts at 1023 so it matches sidebarViewportForWidth", () => { + assert.doesNotMatch(css, /@media[^{]*max-width:\s*900px/); + assert.match(css, /@media \(max-width: 1023px\)/); + assert.match(sidebar, /if \(width < 768\) return "mobile"/); + assert.match(sidebar, /if \(width < 1024\) return "tablet"/); +}); + +test("report centre and reader share the global mobile cut", () => { + assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.report-center-shell/); + assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.personal-report-reader-body/); + assert.doesNotMatch(css, /@media \(max-width: 720px\)/); + assert.doesNotMatch(css, /@media \(max-width: 760px\)/); +}); + +test("report TOC keeps a content-width exception at 860px", () => { + assert.match(css, /Report TOC is the third column/); + assert.match(css, /@media \(max-width: 860px\) \{[\s\S]*\.personal-report-md-layout/); +});