diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index c8c06498..a1359cd2 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。**一处刻意的取舍**:我一度给基础规则加 `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 155d5de3..3bbd4dba 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -2015,6 +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}", + // 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}", @@ -2058,7 +2065,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 +2182,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 +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{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..081490f8 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,44 @@ 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%) 的轨道下限必须写在基础规则上,只在断点里 + // 写等于给中间带留缺口。本组锁都作用在「剥掉嵌套块之后的顶层规则」上——否则 + // 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 下限会溢出"); + // 图谱的 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 —— 纵向手势会被浏览器拿去滚页面," + + "而图谱的拖拽只有鼠标事件实现,触屏拖拽行为无真机无法验证(见上方注释)"); });