From 4564ed91c872361d866341c8a4ad33bd205dccc7 Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 2 Nov 2026 07:20:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(mobile):=20481=E2=80=93519px=20?= =?UTF-8?q?=E4=B8=AD=E9=97=B4=E5=B8=A6=E7=9A=84=E5=8D=A1=E7=89=87=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=E4=B8=8E=E5=9B=BE=E8=B0=B1=E8=A7=A6=E5=B1=8F=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 480 主断点之上、两栏又排不下的一段宽度(481–519px)落回基础规则, 而 minmax(min(252px,100%),1fr) 此前只写在断点里,基础规则仍是裸 minmax(252px,1fr)。结果是卡片轨道仍要求 252px、横向溢出 2–11px 被 外层 overflow:hidden 切掉;实体页属性网格同理。 - 两处 100% 轨道下限上移到基础规则(它的作用域本来就是所有宽度), 480 档里的重复声明删掉 - .mneme-graphsvg 的 touch-action 由 none 改 pan-y:图谱没有触屏手势 实现,none 只让手指在图上竖划时页面滚不动 - 回归测试改锁「剥掉嵌套块后的顶层规则」,新增 topLevel 切片器 实测(Edge headless + 真实 CSS,320–900px 共 24 个页面):修复前 481/490/500/510 各有 1–2 条横向溢出,修复后全部为 0。 --- dsh-mneme/CHANGELOG.md | 1 + dsh-mneme/lib/client.js | 22 ++++++++++++++----- dsh-mneme/test/client.test.js | 40 ++++++++++++++++++++++++++++++++--- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index c8c06498..cc84f5e7 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -24,6 +24,7 @@ ## 🎨 面板 +- **481–519px 是一段两不管的中间带:480 主断点之上、两栏又排不下,卡片横向溢出 2–11px 被外层 `overflow:hidden` 直接切掉(承接 #542,实测发现)**:#542 把 `minmax(252px,1fr)` 换成 `minmax(min(252px,100%),1fr)`,但那两条**只写在 `@container (max-width:480px)` 里**,而基础规则 `lib/client.js` 的 `.mneme-cards` 仍是裸 `minmax(252px,1fr)`、`.mneme-attrgrid` 仍是裸 `minmax(200px,1fr)`。于是 481px 一开始断点失效、左栏回到 216px,卡片被要求至少 252px——`Edge 156` 实测:记忆页 481/490/500/510 各有 1–2 条 `SCROLLX`(`.mneme-cards` 300 > 可用 265),实体页 detail 只剩 257–276px 而属性网格仍要 200px。**兜底必须落在基础规则上**:两处 `min()` 下限上移到基础规则(它们的正确作用域本来就是「所有宽度」,481–519px 只是其中最窄的一段),480 档里的重复声明一并删掉。修完 320–900px 全档 24 个页面 ovf=0,卡片宽 320→300 / 481→217 / 520→256 / 900→287,属性网格 481→209 / 900→628。**同时放开图谱的触屏滚动**:`.mneme-graphsvg` 的 `touch-action:none` 改 `pan-y`——图能拖能缩全靠 `onMouseDown` / `onMouseMove` / `wheel`,全仓 `onPointerDown` / `onTouchStart` **命中 0**,触屏本来就没有自定义手势可抢,`none` 的唯一效果是手指在图上竖划时页面滚不动(一块拖拽区变成一堵墙);鼠标事件不受 `touch-action` 影响,桌面拖拽照旧。**一处刻意的取舍**:我一度给基础规则加 `max-height:min(440px,52vh)` 想把 481–519px 那段 440px 高的图谱压下来,实测确实压到 353px——但 `52vh` 在常见窗口高度(≈680px)下对**桌面也生效**,等于顺手改了大屏观感,已回退。基础规则不带 `max-height`,限高仍只留在 480 档(`min(320px,52vh)`);**代价是 481–519px 中间带的图谱仍是 440px 高,这个中间带高度问题刻意不修**。**回归测试改锁位置**:原来那两条 `body.includes("minmax(min(252px,100%),1fr)")` 锚在 480 档 body 上,现在改成作用在「剥掉所有嵌套块之后的顶层规则」——新增一个 `topLevel` 切片器(遇到 `@` 就按括号配对跳过后整块丢弃),并断言切片长度 > 10000 防止切片器自己坏掉导致假绿;锁四件事:两处基础 `minmax(min(…,100%),1fr)`、基础 `touch-action:pan-y`、且 `doesNotMatch` 顶层规则里出现 `touch-action:none`。变异自证两例(基础 minmax 退回裸 252 / touch-action 改回 none)逐条变红,恢复后 85/85。全量 `npm test` 2609 / 2608 pass / 0 fail / 1 skipped。**已知未覆盖**:仍无真机(`adb devices` 为空,需要设备接上并重启宿主让插件重新探测 adb),宽度结论来自 Edge headless + 真实 CSS 的静态测量,交互(拖拽、滚动链、软键盘)未经真机验证。 - **面板此前根本没有移动端断点:320–430px 的手机上,记忆页与实体页的两栏骨架把主区挤成一条缝,导航项还会压到「⋯」按钮底下**:整份 `lib/client.js` 只有 8 条 `@container` 规则、0 条 `@media`,而且这 8 条全是「把数字改小」式收窄——记忆页左栏 216px + 卡片最小 252px + 沟槽 48px = 面板至少 517px 才放得下,实体页 rail 224px + 属性网格 200px + 48px = 至少 473px,而目标上沿只有 430px。**收窄数字永远追不上,必须动骨架**:新增 `@container (max-width: 480px)` 主断点,把 `.mneme-xmain` 与 `.mneme-ent` 从 `flex-direction:row` 翻成 `column`,左栏 / rail 改成满宽 + 限高可滚(`max-height:min(42vh,300px)` / `min(40vh,280px)` + `overscroll-behavior:contain`,滚到底不漏给页面),卡片与属性网格的 `minmax(252px,1fr)` 改成 `minmax(min(252px,100%),1fr)`(**`min()` 是防溢出的关键:320px 下 `minmax(252px,1fr)` 仍会把轨道撑到 252px 之外**),抽屉从 `width:min(432px,52%)` 改 `100%`(52% 相对的是 `.mneme-xmain`,320 下只剩 166px;抽屉自带 ✕,满宽不丢关闭入口),顶部导航改左对齐 + 右侧留 52px 给 `position:absolute` 的 `⋯`,并新增 `@container (max-width: 360px)` 兜底档(隐藏 14px 图标、导航横向可滚、卡片单列)。**三处刻意的取舍**:① **`@container` 里一个颜色都不写**——`test/contrast.test.js` 的提取器用 `/^([^{]+)\{([^}]*)\}$/` 只匹配单层花括号,`@container` / `@media` 的双层块整串被跳过,在里面写 `color` 就绕过了 #178 的 AA 锁;新增的回归测试会把这条纪律变成闸门(遍历所有双层块,出现 `color` / `fill` / `stroke` / `background` 即失败)。② **overlay 外框只能走 `@media`**:`.mneme-overlay` 不是 `.mneme-x` 的后代(`container-type:inline-size` 在 `.mneme-x` 上),容器查询够不到它,所以它的 `calc(100vw - 16px)` / `calc(100dvh - 16px)` 放在 `@media (max-width:640px)` 里。③ **触屏删除钮只加常显、不动配色**:`.mneme-set-del{opacity:0}` 靠 `:hover` / `:focus-within` 恢复,触屏永远没有 hover——改法是 `@media (hover:none){.mneme-set-del{opacity:1}}`,参照同文件 `.mneme-persona-del` 的常显范式;#457(内联 `opacity:1` 覆写)与 #459(连带点亮规则区真删除钮)两次回滚的教训就是**别顺手把配色也改了**。顺带给 `.mneme-set-del` / `.mneme-footbtn` 补 `min-height:24px`(WCAG 2.5.8 AA 的触控下限)。图谱另修三症:内联 `height:440` 被 `max-height:min(320px,52vh)` 压住(CSS 2.1 §10.7:`max-height` 优先于 `height`)、`touch-action:none` 改 `pan-y` 让页面能纵向滚、`.mneme-glabel` 从 11px 提到 14px 抵消 SVG 缩放(380 用户单位压到 288px 时缩放比 0.758,11px 视觉只剩 8.3px)。弹层固定宽同样越界:`.mneme-sharedialog{width:340px}` 在 320 屏上是硬矛盾,三条都改 `min(原宽,calc(100cqw - 32px))`。**回归测试 +1**(`test/client.test.js` 的 `mobile: the narrow-container breakpoints reflow the two-column skeletons instead of shrinking numbers`):锁两档断点存在、锁骨架翻转与 `minmax(min(...))`、锁抽屉满宽与弹层 `cqw` 上界、锁 overlay 走 `@media`、**锁双层块内不得出现颜色声明**、锁 `hover:none` 的常显。变异自证三例(把 `@container` 换成 `@media`、在 `.mneme-ent` 里插 `color:red`、把 `minmax(min(252px,100%),1fr)` 改回 `minmax(252px,1fr)`)逐条变红,恢复后 90/90 全绿。**已知未覆盖**:实际视觉未在真机或 Playwright 上验证过(本机无 playwright),全部结论是 CSS 级推演;`100vh` 未换成 `dvh` 的位置仍有 3 处(`2108` / `2493` / `2596`),`safe-area-inset-*` 全文件 0 处——这三项留作后续。 ## 🐛 修复 diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 155d5de3..4a260b84 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -2015,7 +2015,13 @@ window.__ModuleLoader__.load({ // --- graph sub-view (fills the content area under the tabs) --- ".mneme-graph{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;padding:12px 16px 16px;box-sizing:border-box}", ".mneme-graphbar{display:flex;gap:8px;align-items:center;flex:none;margin-bottom:10px}", - ".mneme-graphsvg{flex:1;min-height:180px;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-button-elevated-fill);cursor:grab;touch-action:none}", + // touch-action 给 pan-y 而不是 none:图能拖能缩全靠 onMouseDown/onMouseMove/wheel, + // 全仓 onPointerDown/onTouchStart 命中 0,触屏根本没有自定义手势可抢——留着 none + // 只会让手指在图谱上竖划时页面滚不动(一处拖拽区变成一堵墙)。鼠标事件不受 + // touch-action 影响,桌面拖拽照旧。高度不在这里限:480 主断点已压到 + // min(320px,52vh),而桌面要保住内联 height:440 的原高度,把 52vh 写进基础规则 + // 会在常见窗口高度(≈680px)下把它压成 353px,等于顺手改了大屏观感。 + ".mneme-graphsvg{flex:1;min-height:180px;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-button-elevated-fill);cursor:grab;touch-action:pan-y}", ".mneme-gnode{cursor:pointer}", ".mneme-gnode circle{stroke:var(--dsw-alias-bg-layer-2);stroke-width:2;transition:stroke-width .12s}", ".mneme-gnode:hover circle{stroke-width:4}", @@ -2058,7 +2064,9 @@ window.__ModuleLoader__.load({ ".mneme-layer{margin-top:24px}", ".mneme-layerhead{display:flex;align-items:baseline;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:10px}", ".mneme-layercount{font-size:12px;font-weight:400;color:var(--mneme-fg-dim)}", - ".mneme-attrgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}", + // 属性网格同理:min(200px,100%) 的 100% 兜底压在基础规则上,实体页 detail 在 + // 481–519px 只剩 257–276px(rail 仍占 224px 横排),裸 200px 下限会溢出。 + ".mneme-attrgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(200px,100%),1fr));gap:8px}", ".mneme-attrcard{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:8px 12px}", ".mneme-attrkey{font-size:11px;color:var(--mneme-fg-dim);margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", ".mneme-attrval{font-size:13px;line-height:19px;color:var(--dsw-alias-label-primary);word-break:break-word}", @@ -2173,8 +2181,12 @@ window.__ModuleLoader__.load({ ".mneme-seg button{border:none;background:none;color:var(--mneme-fg-dim);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;padding:5px 10px;display:inline-flex;align-items:center;gap:5px}", ".mneme-seg button:hover{color:var(--dsw-alias-label-primary)}", ".mneme-seg button.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);font-weight:500}", - // 卡片网格:响应式 auto-fill,卡片悬停轻浮起 - ".mneme-cards{flex:1;min-height:0;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:14px;padding:20px 24px 32px;align-content:start}", + // 卡片网格:响应式 auto-fill,卡片悬停轻浮起。 + // 轨道下限用 min(252px,100%) 而不是裸 252px:容器(.mneme-xcontent)只有 + // 265–294px 时裸下限仍要求 300px 轨道,网格横向溢出 2–11px 被外层 overflow:hidden + // 切掉。这个洞出现在 481–519px——480 主断点之外、两栏又放不下的中间带,所以 + // 兜底必须落在基础规则上,只在断点里写等于给中间带留缺口。 + ".mneme-cards{flex:1;min-height:0;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(252px,100%),1fr));gap:14px;padding:20px 24px 32px;align-content:start}", ".mneme-card{display:flex;flex-direction:column;gap:7px;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 13px;background:var(--dsw-alias-bg-layer-1);cursor:pointer;font-family:inherit;text-align:left;transition:border-color .12s,box-shadow .12s,transform .12s}", ".mneme-card:hover{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 45%,var(--dsw-alias-border-l2));box-shadow:0 6px 20px color-mix(in srgb,var(--dsw-alias-label-primary) 10%,transparent);transform:translateY(-1px)}", ".mneme-card.mneme-active{border-color:var(--dsw-alias-state-business-primary)}", @@ -2664,7 +2676,7 @@ window.__ModuleLoader__.load({ // sharedialog 自己不带 padding:它和 impdialog 同挂一条 div(见 JSX 里的 // `className:"mneme-impdialog mneme-sharedialog"`),盒模型由 impdialog 带过来, // 所以它同样 +30px;宽度取哪一条由源码顺序决定,故两条都得是 border-box。 - "@container (max-width: 480px){.mneme-xbar{justify-content:flex-start;padding:0 52px 0 10px}.mneme-vtab{padding:0 10px}.mneme-vtabs{min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.mneme-vtabs::-webkit-scrollbar{height:0}.mneme-xmain{flex-direction:column}.mneme-xside{width:100%;flex:0 1 auto;min-height:0;max-height:min(42vh,300px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2);overscroll-behavior:contain}.mneme-xbrowsebar{padding:8px 12px}.mneme-filterbar{padding:8px 12px}.mneme-drawer{width:100%}.mneme-drawerbody{padding:16px 14px 24px}.mneme-cards{grid-template-columns:repeat(auto-fill,minmax(min(252px,100%),1fr));padding:14px 12px 24px;overscroll-behavior:contain}.mneme-ent{flex-direction:column}.mneme-entrail{width:100%;flex:0 1 auto;min-height:0;max-height:min(40vh,280px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.mneme-entrailbody{overscroll-behavior:contain}.mneme-entdetail{padding:16px 14px 24px}.mneme-attrgrid{grid-template-columns:repeat(auto-fill,minmax(min(200px,100%),1fr))}.mneme-set{padding:8px 14px 40px}.mneme-status{padding:16px 14px 32px}.mneme-menu{box-sizing:border-box;min-width:0;width:min(200px,calc(100cqw - 32px))}.mneme-impdialog{box-sizing:border-box;width:min(300px,calc(100cqw - 32px))}.mneme-sharedialog{box-sizing:border-box;width:min(340px,calc(100cqw - 32px))}.mneme-graph{padding:10px 12px 12px}.mneme-graphsvg{max-height:min(320px,52vh);touch-action:pan-y}.mneme-glabel{font-size:14px}.mneme-graphside{position:static;width:auto;max-width:100%;max-height:min(200px,30vh);margin-top:8px}}", + "@container (max-width: 480px){.mneme-xbar{justify-content:flex-start;padding:0 52px 0 10px}.mneme-vtab{padding:0 10px}.mneme-vtabs{min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.mneme-vtabs::-webkit-scrollbar{height:0}.mneme-xmain{flex-direction:column}.mneme-xside{width:100%;flex:0 1 auto;min-height:0;max-height:min(42vh,300px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2);overscroll-behavior:contain}.mneme-xbrowsebar{padding:8px 12px}.mneme-filterbar{padding:8px 12px}.mneme-drawer{width:100%}.mneme-drawerbody{padding:16px 14px 24px}.mneme-cards{padding:14px 12px 24px;overscroll-behavior:contain}.mneme-ent{flex-direction:column}.mneme-entrail{width:100%;flex:0 1 auto;min-height:0;max-height:min(40vh,280px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.mneme-entrailbody{overscroll-behavior:contain}.mneme-entdetail{padding:16px 14px 24px}.mneme-set{padding:8px 14px 40px}.mneme-status{padding:16px 14px 32px}.mneme-menu{box-sizing:border-box;min-width:0;width:min(200px,calc(100cqw - 32px))}.mneme-impdialog{box-sizing:border-box;width:min(300px,calc(100cqw - 32px))}.mneme-sharedialog{box-sizing:border-box;width:min(340px,calc(100cqw - 32px))}.mneme-graph{padding:10px 12px 12px}.mneme-graphsvg{max-height:min(320px,52vh);touch-action:pan-y}.mneme-glabel{font-size:14px}.mneme-graphside{position:static;width:auto;max-width:100%;max-height:min(200px,30vh);margin-top:8px}}", // 360 档只做两件事:隐掉 14px 的 tab 图标(.mneme-vtab 带图标时约 109px/个、 // 4 个约 436px,320px 容器原地就横向溢出),再把正文 padding 收到 12–14px。 // 页签横向滚动不在这一档、属于 480 主断点,理由见 480 档那条算式。 diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 8ce751c2..8a742a2f 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -2454,9 +2454,9 @@ test("mobile: the narrow-container breakpoints reflow the two-column skeletons i ".mneme-entrail 是 overflow:hidden 的非滚动容器,overscroll-behavior 写在它身上是死代码"); // 抽屉是覆盖层,窄容器里 52% 只剩 166px;满宽不丢关闭入口(✕ 在 .mneme-drawerbar)。 assert.ok(body.includes(".mneme-drawer{width:100%}"), "详情抽屉在窄容器里必须满宽"); - // 网格轨道必须有 100% 下限,否则容器比轨道窄时横向溢出被外层 overflow:hidden 切掉。 - assert.ok(body.includes("minmax(min(252px,100%),1fr)"), "卡片网格的最小轨道必须夹到 100%"); - assert.ok(body.includes("minmax(min(200px,100%),1fr)"), "属性网格的最小轨道必须夹到 100%"); + // 网格轨道的 100% 下限不再锚定在本档:它的作用域是「所有宽度」,而 481–519px + // (480 主断点之上、两栏又排不下的中间带)恰恰落回基础规则。锁在基础规则上, + // 见本用例末尾的 topLevel 那组。本档只保留窄屏特有的 padding 与滚动链。 // 固定宽弹层在 320px 会顶边:340px 的分享弹层比视口还宽。 assert.ok(/\.mneme-sharedialog\{[^}]*calc\(100cqw - 32px\)/.test(body), "分享弹层宽度必须跟着容器收"); assert.ok(/\.mneme-impdialog\{[^}]*calc\(100cqw - 32px\)/.test(body), "导入弹层宽度必须跟着容器收"); @@ -2501,4 +2501,38 @@ test("mobile: the narrow-container breakpoints reflow the two-column skeletons i // 这里只许加 opacity:1,不许在同一条里再动 color)。 assert.match(cssBlock, /@media \(hover:none\)\{\.mneme-set-del\{opacity:1/, "触屏设备上 .mneme-set-del 必须常显——它的 base 是 opacity:0,只靠 :hover 恢复,手机上等于永久不可见"); + // 断点之外还有一段中间带:481–519px 容器宽(480 主断点之上、两栏又排不下)。 + // 这一段落回基础规则,所以 min(…,100%) 的轨道下限与图谱的 touch-action 必须写在 + // 基础规则上,只在断点里写等于给中间带留缺口。本组锁都作用在「剥掉嵌套块之后的 + // 顶层规则」上——否则 480 档里的同名声明会让断言假绿。 + const topLevel = (() => { + let out = ""; + let i = 0; + while (i < cssBlock.length) { + const at = cssBlock.indexOf("@", i); + out += cssBlock.slice(i, at === -1 ? cssBlock.length : at); + if (at === -1) break; + const open = cssBlock.indexOf("{", at); + if (open === -1) { out += cssBlock.slice(at); break; } + let depth = 0, j = open; + while (j < cssBlock.length) { + if (cssBlock[j] === "{") depth++; + else if (cssBlock[j] === "}") { depth--; if (depth === 0) { j++; break; } } + j++; + } + i = j; + } + return out; + })(); + assert.ok(topLevel.length > 10000, "顶层规则切片异常(" + topLevel.length + " 字符)——切片实现坏了,下面的锁会假绿"); + assert.match(topLevel, /\.mneme-cards\{[^}]*minmax\(min\(252px,100%\),1fr\)/, + "卡片网格的 100% 轨道下限必须写在基础规则上:481–519px 是 480 主断点之外的中间带," + + "只在断点里兜底等于让这一段横向溢出 2–11px(外层 overflow:hidden 直接切掉)"); + assert.match(topLevel, /\.mneme-attrgrid\{[^}]*minmax\(min\(200px,100%\),1fr\)/, + "属性网格同理:481–519px 时实体页 detail 只剩 257–276px,裸 200px 下限会溢出"); + assert.match(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:pan-y/, + "图谱的 touch-action 必须写在基础规则上:图谱没有触屏手势实现(全仓 onPointerDown/" + + "onTouchStart 命中 0),留 none 会让手指在图上竖划时页面滚不动"); + assert.doesNotMatch(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:none/, + ".mneme-graphsvg 不得回到 touch-action:none —— 那会把图谱变成触屏滚动的死区"); }); From a6fc418db78307335db7da5755190c1a48dbc357 Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 2 Nov 2026 07:38:05 +0800 Subject: [PATCH 2/3] =?UTF-8?q?revert(mobile):=20=E5=9B=BE=E8=B0=B1?= =?UTF-8?q?=E7=9A=84=20touch-action=20=E5=9B=9E=E5=88=B0=20none=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=81=9A=E6=97=A0=E7=9C=9F=E6=9C=BA=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E5=A4=8D=E9=AA=8C=E7=9A=84=E8=A1=8C=E4=B8=BA=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #543 里把 .mneme-graphsvg 的 touch-action 从 none 改成 pan-y,理由是全仓 onPointerDown/onTouchStart 命中 0、图谱的拖拽只挂 mousedown/mousemove/wheel, 触屏上没有自定义手势可抢,留 none 只会让手指在图上竖划时页面滚不动。 评审指出反过来也成立:pan-y 会把纵向手势交给浏览器做页面平移,而图谱的拖拽 只有鼠标事件实现——触屏纵向拖拽到底走哪条路,取决于浏览器在 touch-action:none 下是否派发兼容鼠标事件,没有真机无法定论。这一版不做无法复验的行为变更。 - lib/client.js 基础规则回到 touch-action:none,注释改写成保留理由 - 480 档里 #542 引入的 touch-action:pan-y 一并收回(限高 max-height 保留) - 回归锁翻转:基础规则必须是 none,顶层规则不得出现 pan-y - CHANGELOG 对应段落改写成回退后的结论,保留为什么没做这个改动 复验:Edge 156 headless 静态测量 320–900px 共 32 页,卡片与属性网格零溢出; 图谱高度 ≤480 为 320px(480 档 max-height 生效)、≥481 为 440px(内联高度); touch-action 全段 none。npm test 2609 / 2608 pass / 0 fail / 1 skipped。 --- dsh-mneme/CHANGELOG.md | 2 +- dsh-mneme/lib/client.js | 17 +++++++++-------- dsh-mneme/test/client.test.js | 16 +++++++++++----- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index cc84f5e7..d2633486 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -24,7 +24,7 @@ ## 🎨 面板 -- **481–519px 是一段两不管的中间带:480 主断点之上、两栏又排不下,卡片横向溢出 2–11px 被外层 `overflow:hidden` 直接切掉(承接 #542,实测发现)**:#542 把 `minmax(252px,1fr)` 换成 `minmax(min(252px,100%),1fr)`,但那两条**只写在 `@container (max-width:480px)` 里**,而基础规则 `lib/client.js` 的 `.mneme-cards` 仍是裸 `minmax(252px,1fr)`、`.mneme-attrgrid` 仍是裸 `minmax(200px,1fr)`。于是 481px 一开始断点失效、左栏回到 216px,卡片被要求至少 252px——`Edge 156` 实测:记忆页 481/490/500/510 各有 1–2 条 `SCROLLX`(`.mneme-cards` 300 > 可用 265),实体页 detail 只剩 257–276px 而属性网格仍要 200px。**兜底必须落在基础规则上**:两处 `min()` 下限上移到基础规则(它们的正确作用域本来就是「所有宽度」,481–519px 只是其中最窄的一段),480 档里的重复声明一并删掉。修完 320–900px 全档 24 个页面 ovf=0,卡片宽 320→300 / 481→217 / 520→256 / 900→287,属性网格 481→209 / 900→628。**同时放开图谱的触屏滚动**:`.mneme-graphsvg` 的 `touch-action:none` 改 `pan-y`——图能拖能缩全靠 `onMouseDown` / `onMouseMove` / `wheel`,全仓 `onPointerDown` / `onTouchStart` **命中 0**,触屏本来就没有自定义手势可抢,`none` 的唯一效果是手指在图上竖划时页面滚不动(一块拖拽区变成一堵墙);鼠标事件不受 `touch-action` 影响,桌面拖拽照旧。**一处刻意的取舍**:我一度给基础规则加 `max-height:min(440px,52vh)` 想把 481–519px 那段 440px 高的图谱压下来,实测确实压到 353px——但 `52vh` 在常见窗口高度(≈680px)下对**桌面也生效**,等于顺手改了大屏观感,已回退。基础规则不带 `max-height`,限高仍只留在 480 档(`min(320px,52vh)`);**代价是 481–519px 中间带的图谱仍是 440px 高,这个中间带高度问题刻意不修**。**回归测试改锁位置**:原来那两条 `body.includes("minmax(min(252px,100%),1fr)")` 锚在 480 档 body 上,现在改成作用在「剥掉所有嵌套块之后的顶层规则」——新增一个 `topLevel` 切片器(遇到 `@` 就按括号配对跳过后整块丢弃),并断言切片长度 > 10000 防止切片器自己坏掉导致假绿;锁四件事:两处基础 `minmax(min(…,100%),1fr)`、基础 `touch-action:pan-y`、且 `doesNotMatch` 顶层规则里出现 `touch-action:none`。变异自证两例(基础 minmax 退回裸 252 / touch-action 改回 none)逐条变红,恢复后 85/85。全量 `npm test` 2609 / 2608 pass / 0 fail / 1 skipped。**已知未覆盖**:仍无真机(`adb devices` 为空,需要设备接上并重启宿主让插件重新探测 adb),宽度结论来自 Edge headless + 真实 CSS 的静态测量,交互(拖拽、滚动链、软键盘)未经真机验证。 +- **481–519px 是一段两不管的中间带:480 主断点之上、两栏又排不下,卡片横向溢出 2–11px 被外层 `overflow:hidden` 直接切掉(承接 #542,实测发现)**:#542 把 `minmax(252px,1fr)` 换成 `minmax(min(252px,100%),1fr)`,但那两条**只写在 `@container (max-width:480px)` 里**,而基础规则 `lib/client.js` 的 `.mneme-cards` 仍是裸 `minmax(252px,1fr)`、`.mneme-attrgrid` 仍是裸 `minmax(200px,1fr)`。于是 481px 一开始断点失效、左栏回到 216px,卡片被要求至少 252px——`Edge 156` 实测:记忆页 481/490/500/510 各有 1–2 条 `SCROLLX`(`.mneme-cards` 300 > 可用 265),实体页 detail 只剩 257–276px 而属性网格仍要 200px。**兜底必须落在基础规则上**:两处 `min()` 下限上移到基础规则(它们的正确作用域本来就是「所有宽度」,481–519px 只是其中最窄的一段),480 档里的重复声明一并删掉。修完 320–900px 全档 24 个页面 ovf=0,卡片宽 320→300 / 481→217 / 520→256 / 900→287,属性网格 481→209 / 900→628。**图谱的 `touch-action` 保持 `none`(一处评审后回退的决定,写在这里是因为它值得记)**:我一度把它改成 `pan-y`,理由是图能拖能缩全靠 `onMouseDown` / `onMouseMove` / `wheel`,全仓 `onPointerDown` / `onTouchStart` **命中 0**,触屏上没有自定义手势可抢,`none` 的唯一效果是手指在图上竖划时页面滚不动。CodeRabbit 指出反过来也成立:`pan-y` 会让浏览器接管纵向平移,而图谱的拖拽只有鼠标事件实现——触屏纵向拖拽到底走哪条路,取决于浏览器在 `touch-action:none` 下是否派发兼容鼠标事件,**没有真机无法定论**。这一版不做无法复验的行为变更,已回退 `none`(连 #542 在 480 档写的 `pan-y` 一并收回,全文件 `pan-y` 归零);真要两者都要,得把图谱改成 Pointer Events,另开车道。**一处刻意的取舍**:我一度给基础规则加 `max-height:min(440px,52vh)` 想把 481–519px 那段 440px 高的图谱压下来,实测确实压到 353px——但 `52vh` 在常见窗口高度(≈680px)下对**桌面也生效**,等于顺手改了大屏观感,已回退。基础规则不带 `max-height`,限高仍只留在 480 档(`min(320px,52vh)`);**代价是 481–519px 中间带的图谱仍是 440px 高,这个中间带高度问题刻意不修**。**回归测试改锁位置**:原来那两条 `body.includes("minmax(min(252px,100%),1fr)")` 锚在 480 档 body 上,现在改成作用在「剥掉所有嵌套块之后的顶层规则」——新增一个 `topLevel` 切片器(遇到 `@` 就按括号配对跳过后整块丢弃),并断言切片长度 > 10000 防止切片器自己坏掉导致假绿;锁四件事:两处基础 `minmax(min(…,100%),1fr)`、基础 `touch-action:none`、且 `doesNotMatch` 顶层规则里出现 `touch-action:pan-y`。变异自证两例(基础 minmax 退回裸 252 / touch-action 改回 none)逐条变红,恢复后 85/85。全量 `npm test` 2609 / 2608 pass / 0 fail / 1 skipped。**已知未覆盖**:仍无真机(`adb devices` 为空,需要设备接上并重启宿主让插件重新探测 adb),宽度结论来自 Edge headless + 真实 CSS 的静态测量,交互(拖拽、滚动链、软键盘)未经真机验证。 - **面板此前根本没有移动端断点:320–430px 的手机上,记忆页与实体页的两栏骨架把主区挤成一条缝,导航项还会压到「⋯」按钮底下**:整份 `lib/client.js` 只有 8 条 `@container` 规则、0 条 `@media`,而且这 8 条全是「把数字改小」式收窄——记忆页左栏 216px + 卡片最小 252px + 沟槽 48px = 面板至少 517px 才放得下,实体页 rail 224px + 属性网格 200px + 48px = 至少 473px,而目标上沿只有 430px。**收窄数字永远追不上,必须动骨架**:新增 `@container (max-width: 480px)` 主断点,把 `.mneme-xmain` 与 `.mneme-ent` 从 `flex-direction:row` 翻成 `column`,左栏 / rail 改成满宽 + 限高可滚(`max-height:min(42vh,300px)` / `min(40vh,280px)` + `overscroll-behavior:contain`,滚到底不漏给页面),卡片与属性网格的 `minmax(252px,1fr)` 改成 `minmax(min(252px,100%),1fr)`(**`min()` 是防溢出的关键:320px 下 `minmax(252px,1fr)` 仍会把轨道撑到 252px 之外**),抽屉从 `width:min(432px,52%)` 改 `100%`(52% 相对的是 `.mneme-xmain`,320 下只剩 166px;抽屉自带 ✕,满宽不丢关闭入口),顶部导航改左对齐 + 右侧留 52px 给 `position:absolute` 的 `⋯`,并新增 `@container (max-width: 360px)` 兜底档(隐藏 14px 图标、导航横向可滚、卡片单列)。**三处刻意的取舍**:① **`@container` 里一个颜色都不写**——`test/contrast.test.js` 的提取器用 `/^([^{]+)\{([^}]*)\}$/` 只匹配单层花括号,`@container` / `@media` 的双层块整串被跳过,在里面写 `color` 就绕过了 #178 的 AA 锁;新增的回归测试会把这条纪律变成闸门(遍历所有双层块,出现 `color` / `fill` / `stroke` / `background` 即失败)。② **overlay 外框只能走 `@media`**:`.mneme-overlay` 不是 `.mneme-x` 的后代(`container-type:inline-size` 在 `.mneme-x` 上),容器查询够不到它,所以它的 `calc(100vw - 16px)` / `calc(100dvh - 16px)` 放在 `@media (max-width:640px)` 里。③ **触屏删除钮只加常显、不动配色**:`.mneme-set-del{opacity:0}` 靠 `:hover` / `:focus-within` 恢复,触屏永远没有 hover——改法是 `@media (hover:none){.mneme-set-del{opacity:1}}`,参照同文件 `.mneme-persona-del` 的常显范式;#457(内联 `opacity:1` 覆写)与 #459(连带点亮规则区真删除钮)两次回滚的教训就是**别顺手把配色也改了**。顺带给 `.mneme-set-del` / `.mneme-footbtn` 补 `min-height:24px`(WCAG 2.5.8 AA 的触控下限)。图谱另修三症:内联 `height:440` 被 `max-height:min(320px,52vh)` 压住(CSS 2.1 §10.7:`max-height` 优先于 `height`)、`touch-action:none` 改 `pan-y` 让页面能纵向滚、`.mneme-glabel` 从 11px 提到 14px 抵消 SVG 缩放(380 用户单位压到 288px 时缩放比 0.758,11px 视觉只剩 8.3px)。弹层固定宽同样越界:`.mneme-sharedialog{width:340px}` 在 320 屏上是硬矛盾,三条都改 `min(原宽,calc(100cqw - 32px))`。**回归测试 +1**(`test/client.test.js` 的 `mobile: the narrow-container breakpoints reflow the two-column skeletons instead of shrinking numbers`):锁两档断点存在、锁骨架翻转与 `minmax(min(...))`、锁抽屉满宽与弹层 `cqw` 上界、锁 overlay 走 `@media`、**锁双层块内不得出现颜色声明**、锁 `hover:none` 的常显。变异自证三例(把 `@container` 换成 `@media`、在 `.mneme-ent` 里插 `color:red`、把 `minmax(min(252px,100%),1fr)` 改回 `minmax(252px,1fr)`)逐条变红,恢复后 90/90 全绿。**已知未覆盖**:实际视觉未在真机或 Playwright 上验证过(本机无 playwright),全部结论是 CSS 级推演;`100vh` 未换成 `dvh` 的位置仍有 3 处(`2108` / `2493` / `2596`),`safe-area-inset-*` 全文件 0 处——这三项留作后续。 ## 🐛 修复 diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 4a260b84..df9928f9 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -2015,13 +2015,14 @@ window.__ModuleLoader__.load({ // --- graph sub-view (fills the content area under the tabs) --- ".mneme-graph{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;padding:12px 16px 16px;box-sizing:border-box}", ".mneme-graphbar{display:flex;gap:8px;align-items:center;flex:none;margin-bottom:10px}", - // touch-action 给 pan-y 而不是 none:图能拖能缩全靠 onMouseDown/onMouseMove/wheel, - // 全仓 onPointerDown/onTouchStart 命中 0,触屏根本没有自定义手势可抢——留着 none - // 只会让手指在图谱上竖划时页面滚不动(一处拖拽区变成一堵墙)。鼠标事件不受 - // touch-action 影响,桌面拖拽照旧。高度不在这里限:480 主断点已压到 - // min(320px,52vh),而桌面要保住内联 height:440 的原高度,把 52vh 写进基础规则 - // 会在常见窗口高度(≈680px)下把它压成 353px,等于顺手改了大屏观感。 - ".mneme-graphsvg{flex:1;min-height:180px;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-button-elevated-fill);cursor:grab;touch-action:pan-y}", + // touch-action 保持 none:图谱的拖拽与缩放只挂了 mousedown/mousemove/wheel, + // 全仓 onPointerDown/onTouchStart 命中 0,所以在触屏上它本来就不是一个可交手势的 + // 面。改成 pan-y 会让纵向拖拽退回页面滚动——而浏览器在 touch-action:none 下会派发 + // 兼容鼠标事件,触屏纵向拖拽究竟走哪条路,没有真机无法定论;这一版不做无法复验的 + // 行为变更,留给出指针事件重写的那条车道。高度也不在这里限:480 主断点已压到 + // min(320px,52vh),把 52vh 写进基础规则会在常见窗口高度(≈680px)下把桌面也压成 + // 353px,等于顺手改了大屏观感。 + ".mneme-graphsvg{flex:1;min-height:180px;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-button-elevated-fill);cursor:grab;touch-action:none}", ".mneme-gnode{cursor:pointer}", ".mneme-gnode circle{stroke:var(--dsw-alias-bg-layer-2);stroke-width:2;transition:stroke-width .12s}", ".mneme-gnode:hover circle{stroke-width:4}", @@ -2676,7 +2677,7 @@ window.__ModuleLoader__.load({ // sharedialog 自己不带 padding:它和 impdialog 同挂一条 div(见 JSX 里的 // `className:"mneme-impdialog mneme-sharedialog"`),盒模型由 impdialog 带过来, // 所以它同样 +30px;宽度取哪一条由源码顺序决定,故两条都得是 border-box。 - "@container (max-width: 480px){.mneme-xbar{justify-content:flex-start;padding:0 52px 0 10px}.mneme-vtab{padding:0 10px}.mneme-vtabs{min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.mneme-vtabs::-webkit-scrollbar{height:0}.mneme-xmain{flex-direction:column}.mneme-xside{width:100%;flex:0 1 auto;min-height:0;max-height:min(42vh,300px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2);overscroll-behavior:contain}.mneme-xbrowsebar{padding:8px 12px}.mneme-filterbar{padding:8px 12px}.mneme-drawer{width:100%}.mneme-drawerbody{padding:16px 14px 24px}.mneme-cards{padding:14px 12px 24px;overscroll-behavior:contain}.mneme-ent{flex-direction:column}.mneme-entrail{width:100%;flex:0 1 auto;min-height:0;max-height:min(40vh,280px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.mneme-entrailbody{overscroll-behavior:contain}.mneme-entdetail{padding:16px 14px 24px}.mneme-set{padding:8px 14px 40px}.mneme-status{padding:16px 14px 32px}.mneme-menu{box-sizing:border-box;min-width:0;width:min(200px,calc(100cqw - 32px))}.mneme-impdialog{box-sizing:border-box;width:min(300px,calc(100cqw - 32px))}.mneme-sharedialog{box-sizing:border-box;width:min(340px,calc(100cqw - 32px))}.mneme-graph{padding:10px 12px 12px}.mneme-graphsvg{max-height:min(320px,52vh);touch-action:pan-y}.mneme-glabel{font-size:14px}.mneme-graphside{position:static;width:auto;max-width:100%;max-height:min(200px,30vh);margin-top:8px}}", + "@container (max-width: 480px){.mneme-xbar{justify-content:flex-start;padding:0 52px 0 10px}.mneme-vtab{padding:0 10px}.mneme-vtabs{min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.mneme-vtabs::-webkit-scrollbar{height:0}.mneme-xmain{flex-direction:column}.mneme-xside{width:100%;flex:0 1 auto;min-height:0;max-height:min(42vh,300px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2);overscroll-behavior:contain}.mneme-xbrowsebar{padding:8px 12px}.mneme-filterbar{padding:8px 12px}.mneme-drawer{width:100%}.mneme-drawerbody{padding:16px 14px 24px}.mneme-cards{padding:14px 12px 24px;overscroll-behavior:contain}.mneme-ent{flex-direction:column}.mneme-entrail{width:100%;flex:0 1 auto;min-height:0;max-height:min(40vh,280px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.mneme-entrailbody{overscroll-behavior:contain}.mneme-entdetail{padding:16px 14px 24px}.mneme-set{padding:8px 14px 40px}.mneme-status{padding:16px 14px 32px}.mneme-menu{box-sizing:border-box;min-width:0;width:min(200px,calc(100cqw - 32px))}.mneme-impdialog{box-sizing:border-box;width:min(300px,calc(100cqw - 32px))}.mneme-sharedialog{box-sizing:border-box;width:min(340px,calc(100cqw - 32px))}.mneme-graph{padding:10px 12px 12px}.mneme-graphsvg{max-height:min(320px,52vh)}.mneme-glabel{font-size:14px}.mneme-graphside{position:static;width:auto;max-width:100%;max-height:min(200px,30vh);margin-top:8px}}", // 360 档只做两件事:隐掉 14px 的 tab 图标(.mneme-vtab 带图标时约 109px/个、 // 4 个约 436px,320px 容器原地就横向溢出),再把正文 padding 收到 12–14px。 // 页签横向滚动不在这一档、属于 480 主断点,理由见 480 档那条算式。 diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 8a742a2f..272d56d8 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -2530,9 +2530,15 @@ test("mobile: the narrow-container breakpoints reflow the two-column skeletons i "只在断点里兜底等于让这一段横向溢出 2–11px(外层 overflow:hidden 直接切掉)"); assert.match(topLevel, /\.mneme-attrgrid\{[^}]*minmax\(min\(200px,100%\),1fr\)/, "属性网格同理:481–519px 时实体页 detail 只剩 257–276px,裸 200px 下限会溢出"); - assert.match(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:pan-y/, - "图谱的 touch-action 必须写在基础规则上:图谱没有触屏手势实现(全仓 onPointerDown/" + - "onTouchStart 命中 0),留 none 会让手指在图上竖划时页面滚不动"); - assert.doesNotMatch(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:none/, - ".mneme-graphsvg 不得回到 touch-action:none —— 那会把图谱变成触屏滚动的死区"); + // 图谱的 touch-action 保持 none(这里是从 pan-y 回退后补的锁)。pan-y 那一版想解决 + // 「手指在图上竖划时页面滚不动」,但它同时把纵向手势交给浏览器做页面平移,而图谱的 + // 拖拽只挂了 mousedown/mousemove(全仓 onPointerDown/onTouchStart 命中 0)——触屏上 + // 纵向拖拽到底走哪条路,浏览器是否派发兼容鼠标事件,没有真机无法定论。宁可保留现状 + // 也不做无法复验的行为变更;真要两步都能用,得改成 Pointer Events,另开车道。 + assert.match(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:none/, + "图谱的 touch-action 必须写在基础规则上:481–519px 这段中间带落回基础规则," + + "只在断点里声明等于给中间带留缺口"); + assert.doesNotMatch(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:pan-y/, + ".mneme-graphsvg 不得改回 touch-action:pan-y —— 纵向手势会被浏览器拿去滚页面," + + "而图谱的拖拽只有鼠标事件实现,触屏拖拽行为无真机无法验证(见上方注释)"); }); From f75a4ccb56e8786bba8100bfa1b2e39b51e0da47 Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 2 Nov 2026 07:57:04 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(mobile):=20=E6=9C=AC=20PR=20=E4=B8=8D?= =?UTF-8?q?=E7=A2=B0=E5=9B=BE=E8=B0=B1=E7=9A=84=20touch-action=EF=BC=8C?= =?UTF-8?q?=E5=8F=AA=E4=BF=9D=E7=95=99=20481=E2=80=93519px=20=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E5=B8=A6=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把基础规则的 touch-action 从 none 改成 pan-y,评审两轮指出同一个问题: 这一版既没有真机验证触屏拖拽路径,又顺手删掉了 480 档原有的 pan-y,等于在 没有依据的情况下改了两处行为。现在按最小改动收敛: - 基础规则 .mneme-graphsvg 保持 touch-action:none(与 main 一致),只留一段注释 说明为什么不在这一版动它 - 480 档的 touch-action:pan-y 恢复成 main 的样子,本 PR 不再涉及 - 回归锁只留两处基础 minmax(min(…,100%),1fr),不再锁 touch-action - CHANGELOG 里那段 touch-action 的论证整段删除 本 PR 的实际范围回到一件事:卡片网格与属性网格的 100% 轨道下限从断点内移到 基础规则,补上 481–519px 这段中间带。 复验:Edge 156 headless 静态测量 320–900px 共 32 页零溢出;npm test 2609 / 2608 pass / 0 fail / 1 skipped。 --- dsh-mneme/CHANGELOG.md | 2 +- dsh-mneme/lib/client.js | 2 +- dsh-mneme/test/client.test.js | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index d2633486..a1359cd2 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -24,7 +24,7 @@ ## 🎨 面板 -- **481–519px 是一段两不管的中间带:480 主断点之上、两栏又排不下,卡片横向溢出 2–11px 被外层 `overflow:hidden` 直接切掉(承接 #542,实测发现)**:#542 把 `minmax(252px,1fr)` 换成 `minmax(min(252px,100%),1fr)`,但那两条**只写在 `@container (max-width:480px)` 里**,而基础规则 `lib/client.js` 的 `.mneme-cards` 仍是裸 `minmax(252px,1fr)`、`.mneme-attrgrid` 仍是裸 `minmax(200px,1fr)`。于是 481px 一开始断点失效、左栏回到 216px,卡片被要求至少 252px——`Edge 156` 实测:记忆页 481/490/500/510 各有 1–2 条 `SCROLLX`(`.mneme-cards` 300 > 可用 265),实体页 detail 只剩 257–276px 而属性网格仍要 200px。**兜底必须落在基础规则上**:两处 `min()` 下限上移到基础规则(它们的正确作用域本来就是「所有宽度」,481–519px 只是其中最窄的一段),480 档里的重复声明一并删掉。修完 320–900px 全档 24 个页面 ovf=0,卡片宽 320→300 / 481→217 / 520→256 / 900→287,属性网格 481→209 / 900→628。**图谱的 `touch-action` 保持 `none`(一处评审后回退的决定,写在这里是因为它值得记)**:我一度把它改成 `pan-y`,理由是图能拖能缩全靠 `onMouseDown` / `onMouseMove` / `wheel`,全仓 `onPointerDown` / `onTouchStart` **命中 0**,触屏上没有自定义手势可抢,`none` 的唯一效果是手指在图上竖划时页面滚不动。CodeRabbit 指出反过来也成立:`pan-y` 会让浏览器接管纵向平移,而图谱的拖拽只有鼠标事件实现——触屏纵向拖拽到底走哪条路,取决于浏览器在 `touch-action:none` 下是否派发兼容鼠标事件,**没有真机无法定论**。这一版不做无法复验的行为变更,已回退 `none`(连 #542 在 480 档写的 `pan-y` 一并收回,全文件 `pan-y` 归零);真要两者都要,得把图谱改成 Pointer Events,另开车道。**一处刻意的取舍**:我一度给基础规则加 `max-height:min(440px,52vh)` 想把 481–519px 那段 440px 高的图谱压下来,实测确实压到 353px——但 `52vh` 在常见窗口高度(≈680px)下对**桌面也生效**,等于顺手改了大屏观感,已回退。基础规则不带 `max-height`,限高仍只留在 480 档(`min(320px,52vh)`);**代价是 481–519px 中间带的图谱仍是 440px 高,这个中间带高度问题刻意不修**。**回归测试改锁位置**:原来那两条 `body.includes("minmax(min(252px,100%),1fr)")` 锚在 480 档 body 上,现在改成作用在「剥掉所有嵌套块之后的顶层规则」——新增一个 `topLevel` 切片器(遇到 `@` 就按括号配对跳过后整块丢弃),并断言切片长度 > 10000 防止切片器自己坏掉导致假绿;锁四件事:两处基础 `minmax(min(…,100%),1fr)`、基础 `touch-action:none`、且 `doesNotMatch` 顶层规则里出现 `touch-action:pan-y`。变异自证两例(基础 minmax 退回裸 252 / touch-action 改回 none)逐条变红,恢复后 85/85。全量 `npm test` 2609 / 2608 pass / 0 fail / 1 skipped。**已知未覆盖**:仍无真机(`adb devices` 为空,需要设备接上并重启宿主让插件重新探测 adb),宽度结论来自 Edge headless + 真实 CSS 的静态测量,交互(拖拽、滚动链、软键盘)未经真机验证。 +- **481–519px 是一段两不管的中间带:480 主断点之上、两栏又排不下,卡片横向溢出 2–11px 被外层 `overflow:hidden` 直接切掉(承接 #542,实测发现)**:#542 把 `minmax(252px,1fr)` 换成 `minmax(min(252px,100%),1fr)`,但那两条**只写在 `@container (max-width:480px)` 里**,而基础规则 `lib/client.js` 的 `.mneme-cards` 仍是裸 `minmax(252px,1fr)`、`.mneme-attrgrid` 仍是裸 `minmax(200px,1fr)`。于是 481px 一开始断点失效、左栏回到 216px,卡片被要求至少 252px——`Edge 156` 实测:记忆页 481/490/500/510 各有 1–2 条 `SCROLLX`(`.mneme-cards` 300 > 可用 265),实体页 detail 只剩 257–276px 而属性网格仍要 200px。**兜底必须落在基础规则上**:两处 `min()` 下限上移到基础规则(它们的正确作用域本来就是「所有宽度」,481–519px 只是其中最窄的一段),480 档里的重复声明一并删掉。修完 320–900px 全档 24 个页面 ovf=0,卡片宽 320→300 / 481→217 / 520→256 / 900→287,属性网格 481→209 / 900→628。**一处刻意的取舍**:我一度给基础规则加 `max-height:min(440px,52vh)` 想把 481–519px 那段 440px 高的图谱压下来,实测确实压到 353px——但 `52vh` 在常见窗口高度(≈680px)下对**桌面也生效**,等于顺手改了大屏观感,已回退。基础规则不带 `max-height`,限高仍只留在 480 档(`min(320px,52vh)`);**代价是 481–519px 中间带的图谱仍是 440px 高,这个中间带高度问题刻意不修**。**回归测试改锁位置**:原来那两条 `body.includes("minmax(min(252px,100%),1fr)")` 锚在 480 档 body 上,现在改成作用在「剥掉所有嵌套块之后的顶层规则」——新增一个 `topLevel` 切片器(遇到 `@` 就按括号配对跳过后整块丢弃),并断言切片长度 > 10000 防止切片器自己坏掉导致假绿;锁两件事:两处基础 `minmax(min(…,100%),1fr)`。变异自证(把基础 minmax 退回裸 252 → 红;恢复后 85/85)。全量 `npm test` 2609 / 2608 pass / 0 fail / 1 skipped。**已知未覆盖**:仍无真机(`adb devices` 为空,需要设备接上并重启宿主让插件重新探测 adb),宽度结论来自 Edge headless + 真实 CSS 的静态测量,交互(拖拽、滚动链、软键盘)未经真机验证。 - **面板此前根本没有移动端断点:320–430px 的手机上,记忆页与实体页的两栏骨架把主区挤成一条缝,导航项还会压到「⋯」按钮底下**:整份 `lib/client.js` 只有 8 条 `@container` 规则、0 条 `@media`,而且这 8 条全是「把数字改小」式收窄——记忆页左栏 216px + 卡片最小 252px + 沟槽 48px = 面板至少 517px 才放得下,实体页 rail 224px + 属性网格 200px + 48px = 至少 473px,而目标上沿只有 430px。**收窄数字永远追不上,必须动骨架**:新增 `@container (max-width: 480px)` 主断点,把 `.mneme-xmain` 与 `.mneme-ent` 从 `flex-direction:row` 翻成 `column`,左栏 / rail 改成满宽 + 限高可滚(`max-height:min(42vh,300px)` / `min(40vh,280px)` + `overscroll-behavior:contain`,滚到底不漏给页面),卡片与属性网格的 `minmax(252px,1fr)` 改成 `minmax(min(252px,100%),1fr)`(**`min()` 是防溢出的关键:320px 下 `minmax(252px,1fr)` 仍会把轨道撑到 252px 之外**),抽屉从 `width:min(432px,52%)` 改 `100%`(52% 相对的是 `.mneme-xmain`,320 下只剩 166px;抽屉自带 ✕,满宽不丢关闭入口),顶部导航改左对齐 + 右侧留 52px 给 `position:absolute` 的 `⋯`,并新增 `@container (max-width: 360px)` 兜底档(隐藏 14px 图标、导航横向可滚、卡片单列)。**三处刻意的取舍**:① **`@container` 里一个颜色都不写**——`test/contrast.test.js` 的提取器用 `/^([^{]+)\{([^}]*)\}$/` 只匹配单层花括号,`@container` / `@media` 的双层块整串被跳过,在里面写 `color` 就绕过了 #178 的 AA 锁;新增的回归测试会把这条纪律变成闸门(遍历所有双层块,出现 `color` / `fill` / `stroke` / `background` 即失败)。② **overlay 外框只能走 `@media`**:`.mneme-overlay` 不是 `.mneme-x` 的后代(`container-type:inline-size` 在 `.mneme-x` 上),容器查询够不到它,所以它的 `calc(100vw - 16px)` / `calc(100dvh - 16px)` 放在 `@media (max-width:640px)` 里。③ **触屏删除钮只加常显、不动配色**:`.mneme-set-del{opacity:0}` 靠 `:hover` / `:focus-within` 恢复,触屏永远没有 hover——改法是 `@media (hover:none){.mneme-set-del{opacity:1}}`,参照同文件 `.mneme-persona-del` 的常显范式;#457(内联 `opacity:1` 覆写)与 #459(连带点亮规则区真删除钮)两次回滚的教训就是**别顺手把配色也改了**。顺带给 `.mneme-set-del` / `.mneme-footbtn` 补 `min-height:24px`(WCAG 2.5.8 AA 的触控下限)。图谱另修三症:内联 `height:440` 被 `max-height:min(320px,52vh)` 压住(CSS 2.1 §10.7:`max-height` 优先于 `height`)、`touch-action:none` 改 `pan-y` 让页面能纵向滚、`.mneme-glabel` 从 11px 提到 14px 抵消 SVG 缩放(380 用户单位压到 288px 时缩放比 0.758,11px 视觉只剩 8.3px)。弹层固定宽同样越界:`.mneme-sharedialog{width:340px}` 在 320 屏上是硬矛盾,三条都改 `min(原宽,calc(100cqw - 32px))`。**回归测试 +1**(`test/client.test.js` 的 `mobile: the narrow-container breakpoints reflow the two-column skeletons instead of shrinking numbers`):锁两档断点存在、锁骨架翻转与 `minmax(min(...))`、锁抽屉满宽与弹层 `cqw` 上界、锁 overlay 走 `@media`、**锁双层块内不得出现颜色声明**、锁 `hover:none` 的常显。变异自证三例(把 `@container` 换成 `@media`、在 `.mneme-ent` 里插 `color:red`、把 `minmax(min(252px,100%),1fr)` 改回 `minmax(252px,1fr)`)逐条变红,恢复后 90/90 全绿。**已知未覆盖**:实际视觉未在真机或 Playwright 上验证过(本机无 playwright),全部结论是 CSS 级推演;`100vh` 未换成 `dvh` 的位置仍有 3 处(`2108` / `2493` / `2596`),`safe-area-inset-*` 全文件 0 处——这三项留作后续。 ## 🐛 修复 diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index df9928f9..3bbd4dba 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -2677,7 +2677,7 @@ window.__ModuleLoader__.load({ // sharedialog 自己不带 padding:它和 impdialog 同挂一条 div(见 JSX 里的 // `className:"mneme-impdialog mneme-sharedialog"`),盒模型由 impdialog 带过来, // 所以它同样 +30px;宽度取哪一条由源码顺序决定,故两条都得是 border-box。 - "@container (max-width: 480px){.mneme-xbar{justify-content:flex-start;padding:0 52px 0 10px}.mneme-vtab{padding:0 10px}.mneme-vtabs{min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.mneme-vtabs::-webkit-scrollbar{height:0}.mneme-xmain{flex-direction:column}.mneme-xside{width:100%;flex:0 1 auto;min-height:0;max-height:min(42vh,300px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2);overscroll-behavior:contain}.mneme-xbrowsebar{padding:8px 12px}.mneme-filterbar{padding:8px 12px}.mneme-drawer{width:100%}.mneme-drawerbody{padding:16px 14px 24px}.mneme-cards{padding:14px 12px 24px;overscroll-behavior:contain}.mneme-ent{flex-direction:column}.mneme-entrail{width:100%;flex:0 1 auto;min-height:0;max-height:min(40vh,280px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.mneme-entrailbody{overscroll-behavior:contain}.mneme-entdetail{padding:16px 14px 24px}.mneme-set{padding:8px 14px 40px}.mneme-status{padding:16px 14px 32px}.mneme-menu{box-sizing:border-box;min-width:0;width:min(200px,calc(100cqw - 32px))}.mneme-impdialog{box-sizing:border-box;width:min(300px,calc(100cqw - 32px))}.mneme-sharedialog{box-sizing:border-box;width:min(340px,calc(100cqw - 32px))}.mneme-graph{padding:10px 12px 12px}.mneme-graphsvg{max-height:min(320px,52vh)}.mneme-glabel{font-size:14px}.mneme-graphside{position:static;width:auto;max-width:100%;max-height:min(200px,30vh);margin-top:8px}}", + "@container (max-width: 480px){.mneme-xbar{justify-content:flex-start;padding:0 52px 0 10px}.mneme-vtab{padding:0 10px}.mneme-vtabs{min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.mneme-vtabs::-webkit-scrollbar{height:0}.mneme-xmain{flex-direction:column}.mneme-xside{width:100%;flex:0 1 auto;min-height:0;max-height:min(42vh,300px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2);overscroll-behavior:contain}.mneme-xbrowsebar{padding:8px 12px}.mneme-filterbar{padding:8px 12px}.mneme-drawer{width:100%}.mneme-drawerbody{padding:16px 14px 24px}.mneme-cards{padding:14px 12px 24px;overscroll-behavior:contain}.mneme-ent{flex-direction:column}.mneme-entrail{width:100%;flex:0 1 auto;min-height:0;max-height:min(40vh,280px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.mneme-entrailbody{overscroll-behavior:contain}.mneme-entdetail{padding:16px 14px 24px}.mneme-set{padding:8px 14px 40px}.mneme-status{padding:16px 14px 32px}.mneme-menu{box-sizing:border-box;min-width:0;width:min(200px,calc(100cqw - 32px))}.mneme-impdialog{box-sizing:border-box;width:min(300px,calc(100cqw - 32px))}.mneme-sharedialog{box-sizing:border-box;width:min(340px,calc(100cqw - 32px))}.mneme-graph{padding:10px 12px 12px}.mneme-graphsvg{max-height:min(320px,52vh);touch-action:pan-y}.mneme-glabel{font-size:14px}.mneme-graphside{position:static;width:auto;max-width:100%;max-height:min(200px,30vh);margin-top:8px}}", // 360 档只做两件事:隐掉 14px 的 tab 图标(.mneme-vtab 带图标时约 109px/个、 // 4 个约 436px,320px 容器原地就横向溢出),再把正文 padding 收到 12–14px。 // 页签横向滚动不在这一档、属于 480 主断点,理由见 480 档那条算式。 diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 272d56d8..081490f8 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -2502,9 +2502,9 @@ test("mobile: the narrow-container breakpoints reflow the two-column skeletons i assert.match(cssBlock, /@media \(hover:none\)\{\.mneme-set-del\{opacity:1/, "触屏设备上 .mneme-set-del 必须常显——它的 base 是 opacity:0,只靠 :hover 恢复,手机上等于永久不可见"); // 断点之外还有一段中间带:481–519px 容器宽(480 主断点之上、两栏又排不下)。 - // 这一段落回基础规则,所以 min(…,100%) 的轨道下限与图谱的 touch-action 必须写在 - // 基础规则上,只在断点里写等于给中间带留缺口。本组锁都作用在「剥掉嵌套块之后的 - // 顶层规则」上——否则 480 档里的同名声明会让断言假绿。 + // 这一段落回基础规则,所以 min(…,100%) 的轨道下限必须写在基础规则上,只在断点里 + // 写等于给中间带留缺口。本组锁都作用在「剥掉嵌套块之后的顶层规则」上——否则 + // 480 档里的同名声明会让断言假绿。 const topLevel = (() => { let out = ""; let i = 0;