
前几天在手机上看自己的博客，点开文章头部的「分享」按钮，弹窗的右侧被切掉了一截：URL 显示不全，「Copy link」按钮也明显偏右，整个弹窗不是以屏幕为中心，而是"往右溢出"。

但奇怪的是，同一篇文章在桌面浏览器里把窗口拖到 375px 宽，一切正常——弹窗会乖乖地缩到刚好放下。

这个「桌面窄窗口不复现」的细节，最后成了破案的关键线索之一。

## 一、先复现，再解释

一开始我没有急着改代码，而是先要求 Agent 在**真实的移动端设备模拟**下复现问题，并且给出可验证的数字，而不是先给解释。

复现方式很重要：

- ❌ 把桌面浏览器窗口拖窄 —— 不会触发移动端的布局视口行为，永远复现不了
- ✅ 新建一个移动端上下文（`viewport: 390×844`、`deviceScaleFactor: 3`、`isMobile: true`、`hasTouch: true`），用真实设备规则渲染

复现之后，第一组数字就足够说明问题了：

| 测量项                                  | 实测值  |
| --------------------------------------- | ------- |
| `documentElement.clientWidth`（屏幕宽） | 390     |
| `visualViewport.width`                  | 390     |
| `window.innerWidth`（布局视口）         | **482** |
| `documentElement.scrollWidth`           | **482** |
| `document.body` 宽度                    | 390     |
| `100vw` 实测                            | 390     |

屏幕只有 390px，但 `window.innerWidth` 是 **482px**。文档比屏幕宽了 92px，于是移动端浏览器把**布局视口**撑到了最宽内容——这就是整个问题的源头。

## 二、为什么页头正常，弹窗却错位

最反直觉的一点是：截图里页头完全正常（汉堡菜单、居中的标题、右侧的下拉箭头都在该在的位置），只有弹窗错位。原因是**常规流和 `position: fixed` 用了不同的参照系**：

```text
屏幕可视宽 = 390 CSS px          布局视口 = 482 CSS px
+------------------------------+  +--------------------------------------+
| body / header 按 390 排版     |  | .modal__backdrop  fixed inset-0      |
|  (所以页头看起来完全正常)      |  |  width:100%  ->  482px 宽            |
+------------------------------+  |  +--------------------------------+  |
                                  |  | .modal__dialog  w-full          |  |
    ^ 弹窗右边界 465，超出屏幕 75px |  | max-w-md = 448px (size="md")     |  |
                                  |  | x=17 .............. right=465    |  |
                                  |  +--------------------------------+  |
                                  +--------------------------------------+
```

具体到 HeroUI v3 的 Modal：

- `.modal__backdrop` 是 `fixed inset-0 w-full`，`100%` 相对**初始包含块**（也就是被撑宽的布局视口 482px）解析，而不是屏幕；
- `.modal__dialog` 默认 `size="md"`，即 `max-w-md`（448px）。容器内容盒是 482 − 32（`p-4`）= 450 ≥ 448，于是弹窗取到 448px；
- 我在组件里写的 `sm:max-w-sm` 只在 ≥640px 生效，在 390px 的屏幕上完全不起作用。

结果就是弹窗 448px 宽、以 482px 为居中参照，右边界落在 465px——超出屏幕 75px，右侧被裁掉。

## 三、是谁把文档撑宽的

接下来做逐元素扫描：找出所有右边界超过屏幕宽度、且不在滚动容器内的元素。

| 元素                        | 宽度         | 说明                                               |
| --------------------------- | ------------ | -------------------------------------------------- |
| GFM `<table>`（API 端点表） | 466px        | **唯一撑破文档的元素**（`scrollWidth` 482 就是它） |
| 裸 URL 链接                 | 385px        | 在 385px 以下的屏幕上会独自撑破文档                |
| `<pre>` 代码块              | 554 ~ 1015px | 无事，`CodeBlock` 自己就是横向滚动容器             |

顺手扫了全站：22 个页面（zh/en 各 6 篇文章 + 首页、列表、关于、分类、标签）在 390px 下，**只有 1 篇文章溢出**——就是这篇带 API 表格的 Modbus 文章。

根因也很朴素：`mdx-components.tsx` 映射了 `img`、`a`、`pre`、`code`，**没有映射 `table`**；`globals.css` 里也没有任何针对 `.prose table` 的规则。于是 GFM 表格的 min-content 宽度直接撑破了文档。

## 四、三个被实测否决的方案

排查过程中最有价值的部分，其实是那些"看起来很有道理、但一测就废"的方案。**每个候选方案都先用样式注入验证，再决定要不要写进代码**：

**方案 1：`html { overflow-x: clip }`** —— 无效。

想法是"从根上禁止横向溢出"。实测 `window.innerWidth` 仍然是 482，弹窗仍然溢出。浏览器计算布局视口宽度时并不看 `overflow`。

**方案 2：只给弹窗加宽度上限** —— 无效。

这是 Agent 的第一版建议，也是我一开始最认可的方案：`max-w-[calc(100vw-2rem)]`。实测弹窗确实变窄了（448 → 358），但它**仍然以 482px 为居中参照**：`x=62`、右边界 `420`，依旧在屏幕外。真正的问题不是"弹窗太宽"，而是"居中参照系错了"。

**方案 3：表格用纯 CSS 修复** —— 有副作用。

`.prose table { display: block; max-width: 100%; overflow-x: auto }` 是流传很广的"可滚动表格"写法，实测也确实能消除溢出。但它会让表格内部的匿名 table box 变成 shrink-to-fit，**表格不再撑满栏宽**：桌面端实测表格宽度从 953px 缩到 849px，列宽从 `67/390/497` 变成 `59/347/443`。这是一个没人要求、但会被读者看见的变化。

## 五、最终修复

最终改了三处。

**1. 给 MDX 表格加滚动容器（`mdx-components.tsx`）**

```tsx
table: ({ children, ...rest }: React.TableHTMLAttributes<HTMLTableElement>) => (
  <div className="overflow-x-auto">
    <table {...rest}>{children}</table>
  </div>
),
```

关键是**包裹**而不是**改造表格本身**：表格保持 `display: table`，typography 插件的 `width: 100%` 撑满和上下外边距完全不变（实测修复前后上下间距都是 12px / 48px），溢出交给外层 div 滚动。

**2. 超长 token 允许断行（`app/globals.css`）**

```css
.prose :where(a, :not(pre) > code):not(:where([class~='not-prose'] *)) {
  overflow-wrap: anywhere;
}
```

必须用 `anywhere`，不能用 `break-word`：只有 `anywhere` 会参与 min-content 尺寸计算，也就是只有它能让外层盒子（以及文档）真正收缩回去。

**3. 遮罩宽度跟随屏幕（`ShareButton.tsx` / `SearchDialog.tsx`）**

```tsx
<Modal.Backdrop className="max-w-[100vw]">
```

这里有个关键实测结论：在该状态下 **`100vw` = 390（屏幕），而 `100%` / `innerWidth` = 482（被撑宽的布局视口）**。所以基于 `vw` 的上限有效，基于 `100%` 的上限无效。而且 `max-width` 只可能收窄宽度，在页面没有溢出时它是完全惰性的——桌面端零风险。

顺带一提，HeroUI 自己的抽屉面板就是这么做的：`.drawer__dialog[data-placement="left"]` 用了 `w-80 max-w-[85vw]`。我算是抄了它的作业。

## 六、验证

修复前后的对比（移动端设备模拟 + 桌面端）：

| 场景                       | 修复前                                            | 修复后                 |
| -------------------------- | ------------------------------------------------- | ---------------------- |
| 390px：`window.innerWidth` | 482                                               | **390**                |
| 390px：分享弹窗            | 448px，右边界 465（溢出 75px）                    | 358px，右边界 374 ✅   |
| 390px：搜索弹窗            | 450px，右边界 466（溢出 76px）                    | 358px，右边界 374 ✅   |
| 320px：分享 / 搜索弹窗     | 448 / 450px，右边界 465 / 466（溢出 145 / 146px） | 288px，右边界 304 ✅   |
| 桌面端：分享 / 搜索弹窗    | 384 / 592                                         | 384 / 592（不变）      |
| 桌面端：表格列宽           | 67 / 390 / 497                                    | 67 / 390 / 497（不变） |
| 全站 22 页 × 390/360/320   | 仅 Modbus 文章 2 个语言版本溢出                   | **0 处溢出**           |
| `pnpm build`               | —                                                 | 全部路由仍为 `● (SSG)` |

顺带把这次的结论写进了 OpenSpec 的 `mdx-content`、`post-share`、`post-search` 三个能力文档（新增需求 + scenario），`openspec validate --specs` 13/13 通过。

## 七、复盘

1. **移动端的"屏幕宽度"有多个值，选错一个就会写出看似合理但无效的兜底。** 这次是 `100vw`（390）与 `100%` / `innerWidth`（482）的分歧：同一段 CSS 里换一个单位，修复就从"有效"变成"完全无效"。
2. **页面横向溢出不只是观感问题。** 它会把布局视口撑宽，进而让所有 `position: fixed` 覆盖层（弹窗、抽屉、遮罩）错位——一个"内容排版"的小毛病，最终表现为"组件坏了"。
3. **上游有个可以反馈的缺口：** React Aria 的遮罩高度跟随可视视口（`--visual-viewport-height`），宽度却是 `w-full`（布局视口），两者不一致。结果是每个 Modal / Drawer 都得自己兜一层宽度上限。
4. **测量陷阱：** HeroUI 弹窗的入场动画会带 `zoom-in-105`，也就是给容器加 `transform: scale(1.05)`。此时 `getBoundingClientRect()` 返回的是**放大后**的值（448 → 470），必须在动画结束后（>250ms）再测量，否则整组数据都是错的。
5. **用 Agent 排查 Bug 的正确姿势：让它先给出可验证的数字，而不是先给解释。** 这次它的第一版方案（只压弹窗宽度）就是错的，而推翻它的正是它自己测出来的 `x=62 / 右边界 420`。把"每个候选方案都要用注入实测验证"作为硬性要求，比要求它"给出正确方案"更可靠。
