# PLAN-ui —— 站点界面：对标 / 组件扩展 / 主题系统（v2）

> 🔵 **2026-10-07 用户决定"全盘重来，先设计好整套系统"** ⇒ **设计依据改看 [`DESIGN-site.md`](DESIGN-site.md)**。
> 本文从此**只当两份账本**：§10（三处硬伤 + 侧栏字段 + 移动端规格）与 §11（其余不如意 + 四条共同根因）。
> **界面怎么长，以 `DESIGN-site.md` 为准**；本文与它冲突的地方，一律以它为准。

> 🔴 **2026-10-07 用户拍了五条板，本文有一半作废。** 现在的权威计划是
> `interest/moobile-现代化落地计划.md`（v2）—— ⚠️ 它**住在两个仓之外的上级目录**，所以这里只写路径不写链接
> （跨仓相对链接在 GitHub 与 mooncakes 上都点不动，判据见 `tools/check-links.py`）。本文保留做**证据与设计底稿**，冲突处以那份为准：
>
> | 决定 | 对本文的影响 |
> |---|---|
> | **不再支持 mdx / 内容里写组件**；**扩展性整体推迟**（`:::` 也不做） | **§5 组件扩展整节作废**（R-UI-2 / D-UI-1 / D-UI-2 / P-5）；构造面只走**标准 markdown 的引擎构造**（链接/图片） |
> | **站点配置放 `.skillpress/` 下** | §6.6 的选项②落定，且**位置**定在实例工程目录里 |
> | **完成定义 = 与 VitePress 站点差不多的效果** | §4 的对标表升级成"完成定义"（见新计划 §1） |
> | **兼容性承诺先不做 / 预渲染先不做** | §7 的 D-UI-5 已作废 |
> | **没有上游/下游之分**（站点是 moobile 的探针） | 本文"落点"两列（moobile / skillpress）改读成"**改动落在哪个文件**"，不是排期归属 |

> ⚠️ **v1 的框架是错的，本文替换它。** v1 把 VitePress 的**架构**当成了目标，
> 甚至把"站点不用 moobile 渲"列成了一个选项 —— 那是被对标对象带偏了。
> **moobile 是地基**：站点界面就是 moobile 应用，`@html` / `@style` / `@cmd` / `@sub` 就是它的语言。
> VitePress 只用来当**能力清单**（"一个文档站该有什么"），**不当架构答案**（"该怎么实现"）。
> v1 里仍然有效的只有**量出来的读数**（第 2 节）与 VitePress 的事实清单（4.2）。
>
> 本文同样是**草稿**（等人拍板）。所有数字都是 2026-10-07 的实测快照。

---

## 0. 三条需求（重写）

| # | 需求 | 一句话 | 落点 |
|---|---|---|---|
| **R-UI-1** | **对标 VitePress** | 抄它的**能力清单**（侧栏 / 目录 / 上下页 / 搜索 / 明暗 / 锚点 / 代码块工具条 / 预渲染 / 无 JS 可读），不抄它的架构 | 全部落在 moobile 与现有管线里 |
| ~~**R-UI-2**~~ | ~~**组件扩展**~~ | 🔴 **作废（用户 2026-10-07：「不再支持 mdx，不允许了」）** —— 内容保持**纯数据投影**；要什么块由**引擎固定构造**决定 | ~~引擎 + 生成物 + 门~~ |
| **R-UI-3** | **主题系统** | **(明, 暗) × [N 种主题]**，而且要用 moobile 的方式做（主题是**值**，不是 CSS） | `shell/theme.mbt` + Model + 宿主模板 |

**三条之间的关系（修订）**：R-UI-2 作废之后，R-UI-1 里"界面能长得像现代文档站"的通道只剩
**引擎固定构造 + 站点包自绘**两条；R-UI-3（主题）不再依赖组件通道。

---

## 1. 参考稿的定位（留着，但改用途）

产物：`_scratch/ui-ref/modern-reference.html`（单文件 58,989 字节，零依赖）；
体检：`node _scratch/ui-ref/probe.mjs`。

| 它是什么 | 它不是什么 |
|---|---|
| **观感靶子** —— 证明"这些事情在一个静态文档站里做得到"，用来当"我们差几枪"的尺子 | ~~"应该抛弃 moobile 的证明"~~（v1 的错就在这儿） |
| 现代文档站该有的**元素清单**（视觉件 → 第 5 节逐个映射到组件） | ~~待搬迁的代码~~（它是 HTML/CSS，与 moobile 是两种语言） |

⚠️ 一条**从它身上学到的事**已经落地了：主题的两轴（明暗 × 色板）与 token 分层，
在它上面实测过（六套组合对比度最低 6.00，见 2.1）—— 这套设计**不依赖**它用什么渲染器。

---

## 2. 实测读数

### 2.1 参考稿体检（真 Chrome，两个视口，全过）

```
横向溢出 0px ｜ JS 报错 0 条 ｜ 顶栏 57 / 侧栏 286 / 右栏 230 / 正文 760
token 70 个（11 条 :root 规则）
对比度 六套组合最低 6.00（paper/light 6.33、paper/dark 6.71、neutral/light 6.00、
                        neutral/dark 7.22、indigo/light 6.33、indigo/dark 7.39）
交付 58,989 字节（单文件）
```

⚠️ 体检栽过一次，记账：第一版读 `body` 的背景色判"主题换得动"，报**换不动** ——
真因是 `transition` 让 `getComputedStyle` 返回动画中间值（**假红**），改读自定义属性才对。

### 2.2 现有站点的读数

| 读数 | 值 | 怎么量的 |
|---|---|---|
| 站点 HTML | **1,160 字节**，`#root` 是**空的** | `…/.skillpress/dist/index.html` |
| 站点 JS | **2,432,243 字节** | 同上 `bundle.js` |
| 首页渲染后的 DOM | 20,248 字节 | `_build/shots/dom.html` |
| 界面代码 | 1,315 行 / 10 个文件 | `wc -l shell/*.mbt` |
| 颜色 | 13 个常量 | `shell/theme.mbt` |
| 契约面 | `Span` **3 个构造器**（Txt/Code/Bold）、`Block` 9 个 | `shell/types.mbt` |

### 2.3 契约面的三个硬缺口

| 缺口 | 证据 |
|---|---|
| 正文里的**链接**点不动 | `Span` 里**没有** Link 构造器（引擎认链接目标，只用在分栏/菜单那一层） |
| **图片**进不来 | 引擎**点名拒绝**：`engine/content/blocks.mbt:192` `不支持的构造（图片）` |
| **容器 / 标注块**没有语法 | `:::` 在 `engine/`、`shell/` 里**一次都没出现** |

### 2.4 ⭐ `@style` 的能力边界（本轮最有用的读数）

`style/style.mbt` 有 **86 个公开方法**。有的（只列要紧的）：

```
flex / flex_direction / flex_wrap / flex_grow / flex_shrink / flex_basis / gap / row_gap / column_gap
align_items / align_self / justify_content / order / position / top|right|bottom|left / z_index
width|height|min|max / aspect_ratio / overflow / opacity / display
margin* / padding* / border*（四边 + 圆角 + 样式）/ background_color / border_color
font_family / font_size / font_weight / font_style / letter_spacing(_em) / line_height(_em)
text_align / text_transform / text_decoration_* / white_space / color / number_of_lines
press（状态样式：把它**前面**的键加 `press:` 前缀）
```

**没有的**（而"现代感"很大一部分正长在这几样上）：

| 缺 | 缺了它，现代文档站里的什么做不出来 |
|---|---|
| `box_shadow` | 卡片、浮层、下拉、搜索面板的层次 —— 现在全靠 1px 边框撑 |
| `transform` | 抽屉 / 下拉的位移、指示条、悬浮抬起 |
| `transition` / `animation` | 所有"不生硬"的过渡（悬停、抽屉、主题切换） |
| **伪类 / `:hover`（web 上的真悬停）** | 每个可点元素的反馈 |
| **媒体查询** | 响应式（窄屏收侧栏）、`prefers-color-scheme` |
| 渐变 / `filter` / `backdrop-filter` | 毛玻璃顶栏、网格纹理 |
| `cursor` / `outline`（焦点环） | "这个东西能点"的手感、键盘可达性 |

⚠️ **这个子集是故意封闭的**，不是漏了（`style/style.mbt` 原文）：

> **属性集是封闭的** —— `display:grid`、`position:sticky`、`::before` 这些**根本没有构造器**，
> 所以"Web 能跑、移动端不能"在**编译期就不可能发生**。（DESIGN 原则 3：可移植子集优先于表达力。）

⇒ "补样式能力"是一个**要拍板的设计决定**（3.1 给了分级），不是"随手加个属性"。

---

## 3. 差距的正确归因（三条，全部在 moobile 内部）

### 3.1 缺的样式能力要按"能不能移植"分三级

| 级 | 判据 | 例子 | 处置 |
|---|---|---|---|
| **甲：各宿主都有对应物**（只是形状不同） | 能进可移植子集 | `box_shadow`（RN 是 `shadowColor/Offset/Opacity/Radius` + Android `elevation`；RNW/CSS 是 `boxShadow`）、`transform`（RN 是数组、CSS 是字符串 —— `StyleValue` 已有"一份数据两个后端"的先例）、`cursor` | 做成**语义构造器**（像 `number_of_lines` 那样"该摘的摘出来"），在 `render.mbt` 按后端翻译 |
| **乙：只有 web 有** | 只能"web 增强 + 原生降级" | 真 `:hover`、`transition`、`backdrop-filter` | **`press:` 前缀就是这条路的先例**：它把"源 CSS 的 `:hover`"降级成"按下态"。新能力照抄这个模式：**一个前缀 + 一份降级表** |
| **丙：哪个宿主都没有** | 只能 Model 驱动 | 媒体查询 / 响应式（P3 已定：视口宽度进 Model，`@sub.current_viewport()` + `on_resize`）、`sticky`（P2 已定：不用 sticky，用结构表达） | 走 Model —— 这也是 moobile 的正统做法 |

### 3.2 "UI 不够现代"里有一半是**消费侧没接**，不是表达不出

证据链（都在本仓）：

| 事实 | 出处 |
|---|---|
| `Style::press()` 存在，语义清楚（"把它前面的键加 `press:` 前缀"） | `style/style.mbt:59-89` |
| `render.mbt` 把 `press:` 键**拆出来**放进 `pressStyle` | `render.mbt:649-688` |
| 但注释写着：**"库这一侧还没有消费它的通道"** | `render.mbt:685` |
| 站点自己的语料也记着：`.press()` 写了没用，`pressStyle` **零消费方**；生成器默认不产 `.press()` | 生成物 `content.generated.mbt` 的"坑"表 |
| 更狠：RNW 的 `View` 用**严格 props 白名单**，白名单外的键**连警告都没有** ⇒ `class=` / `style="…"` / `pressStyle` 全被静默吃掉 | 同上（F 第十八轮） |

⇒ **一条推论**（也是对我 v1 里"方案 B：元素挂 class + 真 CSS 表"的判决）：
那条路**已被实测判死**（不是"未验证"）—— `Attrs::class` 写进的是 `attrs` 表，
而 RNW 侧对白名单外的键是静默丢弃。要拿回伪类 / 媒体查询，**只能扩 moobile 的样式层与宿主消费点**。

### 3.3 首屏与体积：预渲染在 moobile 里是"接线问题"

| 事实 | 出处 |
|---|---|
| vendor 里有 **SSR 宿主**（`SSRHost::render_to_string`）与 **hydration 宿主**，还有 transcript（`application/json` 脚本）机制 | `vendor/rabbita/runtime/host_ssr.mbt` / `host_hydration.mbt` |
| 但现役后端是 **`react_host`**（React / RNW），它对 `@dom` **零引用**；`vdom/diff.mbt` + `vdom/hydrate.mbt` 那套 DOM 渲染器被记为**死代码（占全仓 44%）**，调用者只有 `host_browser` / `host_hydration` / `host_ssr` 三个原版宿主 | `moobile/docs/FINDINGS.md:2257` |
| rabbita 的 `server/`（SSR + HTTP）**从未被编译过**（声明 native+wasm，而只跑 js） | `moobile/docs/ARCHITECTURE.md:189` |

⇒ "站点首屏 HTML 里没有内容"的正确说法是：**预渲染要先接一条线**
（web 宿主是 React/RNW，而 RNW 支持 `react-dom/server`），**不是"moobile 不行"**。
`host_ssr` 那套是 vendor 里另一条已死的支线，别把它当现成能力（我 v1 差点这么写）。

### 3.4 契约面窄的归因

`Span` / `Block` 窄**不是设计失误** —— 它是"引擎只产出它能保证的东西"的结果
（`Span::Txt` 里的链接，引擎给了目标却没人渲染它）。
R-UI-2（组件扩展）正好是修这个的正路：**让内容把"要什么块"说出来**，而不是让引擎猜。

---

## 4. 对标 VitePress：只取能力清单

### 4.1 三类表态（修订版）

| 类 | 处理 | 修订点 |
|---|---|---|
| **能力**（侧栏 / 目录 / 上下页 / 搜索 / 明暗 / 锚点 / 代码块工具条） | **抄，落在 moobile 里** | 不变 |
| **地基**（预渲染、构建期算完、无 JS 可读、体积） | **要，但落点是 moobile 的接线**（3.3）；"无 JS 可读"是**目标**，怎么达到由 moobile 决定 | ⬅️ v1 在这儿跑偏（把"换渲染器"当成了地基） |
| **形态**（内容写 Vue 组件、主题是 Vue 项目、插件靠 Vite / markdown-it 生态） | **不抄** —— 但**"内容里能写组件"这一条我们要，只是组件是 MoonBit** | ⬅️ v1 把这一条整个否掉了，错 |

### 4.2 事实清单（2026-10-07 调研，97 条出处）

出处逐条在 [`_scratch/ui-ref/vitepress-调研.md`](_scratch/ui-ref/vitepress-调研.md)；
凡标**实测**的都是本机 `vitepress@1.6.4` 真构建（探针站 `interest/vp-probe`）。

| 块 | 要点 |
|---|---|
| 容器 | 内置 **5 类闭集** `info/tip/warning/danger/details`；可带标题；**嵌套要外层围栏更长**；`customContainers` 注册新名字（无自带样式）；GFM alerts `> [!NOTE]` 一族 |
| 代码块 | Shiki 上色、`{1,4,6-8}` 行高亮、`// [!code focus]`、`// [!code ++/--]` diff、行号、`::: code-group`、片段导入 `<<< @/f.ts` |
| 锚点 / 目录 | 自动锚点 + `{#custom}`、`[[toc]]`、中文 slug 原样保留 |
| 其它 | 数学 opt-in（要自己装包）、图片懒加载、emoji、脚注、任务列表 |
| **内容写 Vue** | 每个 `.md` 先编译成 HTML **再当 Vue SFC 处理**；组件名必须带连字符 / PascalCase，否则 hydration mismatch；SSR 兼容是硬要求（浏览器专属要 `<ClientOnly>`） |
| 主题 / 暗色 | `appearance` 四档；`html.dark` 类（**实测静态 HTML 里没有**，脚本运行时加）；`localStorage` 键 `vitepress-theme-appearance`；**构建期注入内联脚本防闪**；`--vp-*` 变量**浅色在 `:root`、深色在 `.dark` 成对**，238 个唯一变量 / 约 24 组 |
| 编程面 | `extends`、`enhanceApp`、`setup`、**40+ layout slots**、Vite alias 覆写内部组件（官方注明内部组件名 minor 可能改） |
| 产物 | 预渲染 HTML + hydration；每页两份 js；`mpa: true` ⇒ **0kb JS**（代价：关掉客户端导航）；官方原话：`file://` 打开"有样式、可完整导航"，但"搜索等交互保持不激活" |
| 搜索 | `minisearch` 已在依赖里；**按标题切 section**；索引交付成 chunk ⇒ **必须有 JS** |

### 4.3 ⭐ 调研里最值钱的两条：VitePress 自己踩的洞

1. **`<ClientOnly>` 里的文本在 `dist/*.html` 里完全不存在**（实测 grep 计 0）；
2. 更阴的：**本地搜索索引基于 markdown-it 的 HTML、不是 SSR 出来的 DOM**
   ⇒ 被藏起来的词**进了索引却永不出现在页面上**（搜得到、点开没有）。

再加两条"官方没有"：**没有任何"在 markdown 里写组件会损害可移植性"的提醒**；
**llms.txt 也没有**（issue #4590 自 2025-03 开到现在仍 open）。

> ⇒ "同一份内容，人和 Agent 都读"这件事**官方没在做**。
> 这正好是我们的位置 —— 而我们**不靠"不许写组件"来保住它**，
> 靠的是**组件用可读的语言写**（第 5 节）。

### 4.4 明确不抄的三条

| # | 不抄 | 理由 |
|---|---|---|
| 1 | **Vue / JSX 那个运行时** | 与 moobile、与 0 npm 都冲突；组件语言用 MoonBit |
| 2 | **SPA 导航 / hydration 那一层**（每页两份 js + hash map + site data） | 换来顺滑跳转，代价是产物翻倍 + "必须 JS 才完整可用" |
| 3 | **Vite / markdown-it 插件生态当扩展点** | 我们的扩展点是**引擎里的构造 + 内容里的组件**，扩展一次要过门 |

---

## 5. 组件扩展（R-UI-2）—— 🔴 **整节作废**

> **用户 2026-10-07：「不再支持 mdx，不允许了」**。下面整节留作**设计底稿**（它记下了"如果要做会是
> 什么样、代价在哪"），**不是待办**。要标注块/卡片这类东西，走**引擎固定构造**（见新计划 T8）。
> ⚠️ 已定（2026-10-07）：`:::` 这类容器语法**也不做** —— 扩展性整体推迟，将来与 MDX 一次性设计（新计划 O-6 / §6）。

### 5.x（留档）如果要做，形状是这样

### 5.1 两种写法

**定义**（围栏代码块，信息行 = `moonbit moobile`）：

````
```moonbit moobile
fn note(kind : String, title : String, children : Array[@html.Html]) -> @html.Html {
  @html.div(attrs=theme_attrs(theme.callout(kind)), [
    @html.span(attrs=theme_attrs(theme.callout_title(kind)), title),
    @html.div(attrs=theme_attrs(theme.body()), children),
  ])
}
```
````

**使用**（markdown 里的标签，属性 = MDX 那种写法）：

```md
<Note kind="warn" title="别用 .sort()">
要排就自己写比较器。
</Note>
```

小写标签 = 原生 HTML（**继续按现状拒绝 / 转义**）；**首字母大写 = 组件**
（沿用 MDX 的约定，人一眼认得出哪个是扩展、哪个是 HTML）。

### 5.2 翻译方案（生成期做的事）

| 内容里写的 | 生成物里的 MoonBit | 谁检查它 |
|---|---|---|
| ` ```moonbit moobile ` 围栏 | 原样收进**一个模块**（如 `content/components.generated.mbt`），与站点一起编 | **编译器**（语法 / 类型错 = 站点编不过 ⇒ 这条路自带门） |
| `<Note kind="warn" title="…">正文</Note>` | `note(kind="warn", title="…", children=[…])` | 编译器（**属性名拼错 / 类型不对 = 编译错误**） |
| `prop="字符串"` | `prop="字符串"` | 编译器 |
| `prop={表达式}` | `prop=表达式`（原样透传） | 编译器 |
| 裸 `prop` | `prop=true` | 编译器 |
| 标签体（children） | 该段按**现有块解析器**当成 markdown 切成 `Array[Block]` → `Array[@html.Html]` | 现有解析器 + 门 |
| 未知标签 | **生成期就报错并点名**（不留给编译器） | 门（见 5.5） |

名字解析顺序：**本文件 / 本 skill 里定义的 → 站点包提供的 → 都没有 ⇒ 红**。

### 5.3 为什么这条路比 MDX 那条对（三条）

1. **编译器就是门**：MDX / Vue 传错 prop 是**静默忽略**；我们这里是**编译不过**
   —— 连"属性名拼错"都能被逮到，那是 MDX 那一路一辈子拿不到的东西。
2. **内容仍然可读、可执行**：`<Note kind="warn">…</Note>` 是人话；
   ` ```moonbit moobile ` 里是**真源码**（不是 JSX 转译后的语法糖），Agent 读得懂、也能直接拿去跑。
3. **0 npm 不破**：组件是 MoonBit，随站点工程一起编，构建链上不多一个运行时。

### 5.4 要定的边界（建议 v1 收窄）

| 边界 | 建议 | 理由 |
|---|---|---|
| 组件能不能有状态 | **v1 只允许纯函数组件**（props → Html，无 `@sub`、无副作用） | 预渲染友好、判据友好；带状态的（编辑器、标签页）留到 v2，那要先定"预渲染下状态从哪来" |
| 组件代码的形态 | 必须**是一个定义**（`fn` / `let`），不许顶层语句；`import` 走白名单 | 否则生成物会变成"一个任意 MoonBit 文件"，门查不动 |
| 体积 | 复用 R9 的 400 行预算（每个围栏 + 每个文件） | 已有判据，直接复用 |
| 组件从哪来 | 内容里定义（可）/ 站点包提供（可）/ 第三方包（**不做**） | 第三方 = 供应链问题，等有真需求再说 |
| 属性类型面 | v1：`String` / `Bool` / `Int` / `Double` / `Array[@html.Html]`（children） | 够覆盖"标注块 / 卡片 / 徽标 / 目录"；不够再加 |

### 5.5 门要加的条目（每条都要配诱饵）

| # | 规矩 | 反例（诱饵） |
|---|---|---|
| JC1 | 标签必须闭合（同名、配对） | `<Note>没关` |
| JC2 | 标签名必须能解析（内容里定义 / 站点包提供） | `<Notee>` |
| JC3 | 围栏信息行必须是 `moonbit moobile`（不许别的语言混进来） | ` ```moonbit ` 少了 `moobile` |
| JC4 | 组件源码里不许出现白名单外的 `import` / `@js` 直通 | 一个来路不明的 import |
| JC5 | 每个围栏 / 每个文件 ≤ 400 行 | 复用 R9 |
| JC6 | **降级样子**：去掉渲染器，`<Note …>` 与围栏原文仍读得通 | 标签夹在段落中间（解析规矩：**只有在段首 / 独立成段才认**） |

### 5.6 未知（要探）

- ⚠️ **报错位置指不回源文件**：内容代码进生成物之后，编译错误落在 `content/components.generated.mbt`。
  MoonBit 有没有 `#line` 类指令？（**未查实**）没有的话要在生成物里写"这行来自哪个文件第几行"的注释。
- ⚠️ **组件与预渲染的关系**（3.3 那条线的分支）：纯函数组件不妨碍预渲染，但预渲染本身还没接。
- ⚠️ **children 的类型**：块级（`Array[@html.Html]`）还是行内（`Array[Span]`）——
  建议**块级**（能装任何东西），行内需求让组件自己从文本里取。

---

## 6. 主题系统（R-UI-3）—— 用 moobile 的方式

### 6.1 形状：一张二维表 + 一个函数

```
主题 = theme_of(palette : String, scheme : Scheme) -> Theme
        (N 种主题)      ×     (明 / 暗)
```

`Theme` 是**纯数据**（不是样式对象、更不是 CSS）：

```
pub struct Theme {
  name : String
  // 语义色：一个用途一个名字
  canvas, surface, surface2, inset, fg, fg_strong, fg_muted, fg_subtle,
  line, line_strong, accent, accent_hover, accent_fg, accent_soft, accent_ring,
  code_bg, note, tip, warn, danger … : String
  // 色号 → 颜色：高亮那一处的专门处理（见 6.2）
  tok : (Int) -> String
}
```

### 6.2 色号映射放在 Theme 里（这一点比"CSS 变量"更合 moobile）

- 引擎产出的**色号（int）不变** —— 仍然是构建期算的，不依赖主题；
- 站点渲染代码块时查 `theme.tok(run.1)`；
- **明暗两套各自给一份 `tok`** ⇒ **同一份产物在两种主题下都成立，构建期不用重跑**。
- ⚠️ 漏映射是**安静**的（那类 token 变回默认前景色）⇒ 一条判据（6.5 ④）。

### 6.3 主题**进 Model**（而不是藏在样式层里）

moobile 里没有"CSS 变量"这个概念 —— 样式就是值，所以主题**只能是值**，进 Model 是唯一自然的表达。

| 得到 | 代价 |
|---|---|
| 换肤 = `Msg::SetTheme` → 重渲；不需要宿主配合、不需要防闪脚本、不依赖任何浏览器特性 | 换肤要重渲一次（对静态站无所谓）；"首屏用哪套"必须先定（6.4） |
| 主题能被**判据**看见（Model 里的值可断言） | 每个渲染函数要拿到 theme（参数，或 moobile 若有"全局样式 / 上下文"通道则挂在上面） |

⚠️ **要探的一条**：`shell` 现在把颜色当**模块级常量**用（`c_bg` 之类），而主题是**运行期的值**。
两条路：① 一路传参（改动大但显式）；② moobile 若有全局样式通道
（语料里见过 `set_text_style_of(@styles.page())` 这类写法）—— 主题可以挂上去。**未查实。**

### 6.4 首屏与"跟随系统"（两条要探的通路）

| 问题 | 落点 | 状态 |
|---|---|---|
| 首屏用哪套（不闪） | 宿主模板（实例的 `index.html`）里一段**同步脚本**读 `localStorage` / `matchMedia`，把结果写进全局；MoonBit 的 `initial` 读它 | 通道未验证（js 互操作；engine 的 `ts_shim.mbt` 是同仓先例） |
| 跟随系统变化 | `@dom` 里有 `MediaQueryList` 绑定 —— **但现役是 `react_host`，它不用 `@dom`**（3.3）⇒ 要走 React 侧的 `window.matchMedia`，或给 `@sub` 加一个 | 未验证 |

### 6.5 判据（四条，都可自动化）

| # | 判据 | 会红的坏法 |
|---|---|---|
| ① | **颜色只有一处源**：颜色只准出现在主题表里，`shell/` 的渲染代码里不许有字面色号 | 有人顺手写死一个颜色 ⇒ 换主题时那一处不变 |
| ② | **每一套（主题 × 明暗）的对比度达标**（正文 ≥ 4.5，次要文字 / 链接 ≥ 3） | 新加一套主题，深色下正文掉到 3 ⇒ "看着好看、读起来瞎"（参考稿已有这套算法与读数） |
| ③ | **首屏不闪**（首帧就是正确主题） | 主题读取被挪到渲染之后 ⇒ 深色用户每次刷新闪一次 |
| ④ | **色号全覆盖**：引擎产出的每个色号，`Theme.tok` 都有非空返回 | 高亮加了一类 token，站点没跟上 ⇒ 那一类悄悄变回默认前景色 |

### 6.6 N 种主题的落点（要拍板）

| 选项 | 含义 |
|---|---|
| ① 内置几套（暖纸 / 中性 / 靛蓝），站点只能选 | 最简单，够用 |
| ② 主题放**站点的配置文件**（站点作者加一套 = 写一张表） | 与"通用产品"（D16）一致 |
| ③ 主题放**内容仓**（`skillpress/theme.toml` 一类，过门 + 进指纹） | 最一致（内容即配置），但要加一套门 |

🔴 **已拍定（2026-10-07）**：走**②的形态、①的位置** —— 主题与站点配置放**实例工程**（`.skillpress/` 下的一份手写配置），
`attach` 不覆盖它；`press` 读它产出主题表。⚠️ 两条待办：格式（建议**扁平单行标量**，复用 G1 的解析器）与
**进 G8 指纹**（它是站点输入，不进指纹就会静默漂）。

---

## 6.7 甲级样式的形状（D-UI-4 拍定 B 之后的施工单）

**为什么是这三样、以及为什么分三次做**（判据见 §3.1 甲级）：它们**各宿主都有对应物**，
只是形状不同 ⇒ 做成**语义构造器** + **后端翻译表**，不进"web 专有"那条路。

| 能力 | 语义构造器（`style/style.mbt`） | 后端翻译（`render.mbt`） |
|---|---|---|
| `box_shadow` | `box_shadow(color, x, y, blur, spread?)` —— **不暴露 CSS 字符串**（那等于把 web 语法漏进可移植子集） | web/RNW → `boxShadow: "<x>px <y>px <blur>px <spread>px <color>"`；RN → `shadowColor/Offset/Opacity/Radius`（Android 再叠 `elevation`）；两者都缺 ⇒ **不画**（不是抛错：静态站能降级） |
| `transform` | ✅ **已落地（2026-10-07）**：`Style::transform(Array[Transform])`，`Transform::Translate(x, y)` / `Scale(f)` / `Rotate(deg)` —— 语义动作，**调用点给不出裸字符串** | ✅ **实现比这张表写的简单**：实测（读两端真源码）**字符串两端都认** ⇒ 不需要按后端分叉（见下面那条"实测把这张表改了两处"的续记）。真浏览器读数：先移后缩 ⇒ `matrix(2,0,0,2,40,0)`、先缩后移 ⇒ `matrix(2,0,0,2,**80**,0)`（**同一组动作换个顺序结果不同** ⇒ 顺序真的保住了）、`rotate(90deg)` ⇒ `matrix(0,1,-1,0,0,0)` |
| `cursor` | `cursor(Cursor::Pointer \| Text \| Default)` —— 只有"这是可点的 / 这是文字"两三种语义 | web/RNW → `cursor: "pointer"/"text"/"default"`；RN → **无对应物**（触摸屏没有指针）⇒ 静默不画，**但要在 §3.1 的降级表里写明**（"静默"和"漏了"要分得开） |

**管道已经探明（2026-10-07，读码读出来的，不是猜的）**：

```
Style = Array[(键, StyleValue)]        style/style.mbt
    ↓ entries()
render.mbt : styles_to_js()  →  set_style_value(o, k, v, font_size)
    ↓ StyleValue::Str(s) **原样透传**（js_str）
宿主那一侧的 style 对象
```

⇒ 三条推论：
1. **`cursor` 几乎免费**：`.cursor(Cursor::Pointer)` 只要吐 `("cursor", Str("pointer"))`，web 那条路今天就走得通
   （`Str` 透传）——所以它排第一：**用来验证"语义构造器 + 后端翻译"这条管道本身，代价最小**。
2. **`box_shadow` 需要一个"按后端展开"的分支**：RNW 要一条 `boxShadow` 字符串，RN 原生要
   `shadowColor/Offset/Opacity/Radius`（Android 再叠 `elevation`）——**一个语义键展开成不同的键**，
   这正是"语义构造器 + 后端翻译"的定义，也是这条路第一次真的被用到。
3. **`transform` 卡在"值的形状"上**：RN 要**数组**（顺序敏感），web 要**字符串** ⇒ 语义层必须能表达
   "有序的若干动作"。`StyleValue` 现在没有数组/序列变体，**所以这一样要动类型**——
   是这三样里唯一有真实设计选择的一个（`StyleValue` 加变体 vs 语义键 + 编码串）。**做之前先拍这一下。**

**⚠️ 2026-10-07 实测把上面这张表改了两处（原来那两条判断是错的，判据当场逮住）**：

| 我原先写的 | 实测 | 出处 |
|---|---|---|
| `cursor` 是"web 专有、原生不认" ⇒ 登记进 `RN_UNSUPPORTED` | **错**。moobile 的 `tools/style_platform_check.mjs` 读**装在仓里的真 RN 登记表**（0.83.10 / 0.86.3 两份），登记后门**当场红**："已经支持了：cursor" ⇒ **现代 RN 认这个键**。正解是**不登记**，并把"RN 认这个键 ≠ 移动端看得见效果（可点性仍靠 `Pressable`/`on_click`/`press:`）"写进注释 | moobile `style/style.mbt` 的 `Cursor` 注释 + 那条门 |
| `box_shadow` 要**按后端分叉**（RN 走 `shadowColor/Offset/Opacity/Radius` + `elevation`） | **不必**。现代 RN（New Architecture）与 RNW **都认一条字符串 `boxShadow`** ⇒ 分两套只会让"同一个阴影在两个后端长得不一样"。实现改为：语义参数合成**一条**字符串；**RN 哪天撤了这个键，那条判据会红**（由门盯着，不靠记性） | 同上 |

⇒ 这两条一起说明一件事：**"这个能力各端有没有"不该靠推断，该靠读各端自己的登记表**（本仓已经有那种机器校验的门，用它）。

**2026-10-07 落 `transform` 时，同一句话又对了一次（第三处更正）**：§6.7 原写"RN 要**数组**、web 要字符串
⇒ 语义层必须能表达有序序列 ⇒ 要动 `StyleValue`"。**读两端源码之后不成立**：

| 原写 | 实测（读源码） |
|---|---|
| RN 只吃数组 | ❌ `Libraries/StyleSheet/processTransform.js` 的签名就是 `Array<Object> \| string`；传字符串时用 `/(\w+)\(([^)]+)\)/g` 拆成**单键对象**、**保序** ⇒ 字符串是它的一等输入 |
| web 只吃字符串 | ❌ RNW 0.21.3 的 `preprocess.js`：`Async`… 那一支只在 `Array.isArray` 时才翻译，**字符串原样落进 CSS** ⇒ 也认 |

⇒ 于是实现与 `box_shadow` 同一形状：**一条字符串**，`StyleValue` 一个变体都没动。
⚠️ 但有一条**必须守住**（RN 的硬校验 + 本仓 canvas 的前科）：**一个动作一个函数** ——
`Translate(x, y)` 吐 `translateX(…) translateY(…)` **两个**函数，绝不吐 `translate(x, y)`；
因为 RN 的 `_validateTransforms` 要求每个 transform 对象**恰好一个键**，而 RN Skia 只读
`Object.keys(val)[0]`（第二键静默作废 —— 罗盘曾被画成椭圆）。判据：`style/style_wbtest.mbt` 数函数个数。

**⑤ 的真正卡点（2026-10-07 探明，比"缺滚动 API"更靠前一层）**：
目录"可点 + 跟读高亮"要的不是滚动函数（web `scrollIntoView` / RN `ScrollView.scrollTo` 都在），而是**节点寻址** ——
**moobile 今天没有节点句柄 / ref 通道**（拿不到节点 ⇒ 既滚不了也量不了）。
⇒ 顺序必须是：**先探"有没有节点句柄 / `id` 绑定通道" → 没有就先补这一层（甲级：RN 有 `ref` + `measure()`，web 有 DOM 节点）→ 再谈"滚到节点"与"测量"**。
在它落地之前，目录就是**纯文字骨架**（§12.2 已记）。

**`transform` 为什么单独一件做**（⑧ 给出取舍）：给 `StyleValue` 加**序列变体**（方案 A）代价落在**编译期**（每处 `match` 都要处理新分支，漏了编不过）；
方案 B（语义键 + 编码串）不动类型，但 render 侧要**把自己吐出去的串再解析回来** ⇒ **解析失败是静默的**（丢一个动作 = 位置尺寸错，最难查）。
**选 A**（与本仓"封闭属性集 + 编译期挡住"一致）。它还要连带核 `render_wbtest.mbt` 的白盒判据与 `style_platform_check` 的**键集推导**（从源码字面量推键，不落在 `self.kw(...)` 形态里会漏键），并守 canvas 门那条真机规矩：**每个变换项只许一个键**。

**三条硬约束（照抄 `press:` 那条先例的教训）**：
1. **不动 `StyleValue` 的变体集**：加变体会让每个构造器都得处理"带不带状态"，污染整个类型化属性集
   （`style.mbt` 的 `press` 文档里写着这条）。新能力要么进 `StyleValue::Str` 的**语义封装**，要么新开一个
   平级字段 —— 结论：**新开**（`box_shadow` 等是低频键，不值得为它们再动 `entries` 的形状）。
2. **`.press()` 的语义是"给当前已有的键加前缀"** ⇒ 新能力必须能落在链的**任意位置**（同一件事的按下态与常态要能共存）。
3. **判据**（写进 moobile 自己的账，本仓只记"我们依赖它"）：每个能力在 **web 后端有非空产物**、
   在 RN 后端有对应表达（或**明确记录**"该端不画"），且 `moon test` 里有对照读数。

**顺序**：`cursor`（最小、能立刻验证"语义构造器 + 后端翻译"这条管道）→ `box_shadow`（层次，DESIGN §3.3 的 `--shadow-*` 五档等着它）
→ `transform`（位移 = 窄屏"浮出"与抽屉；DESIGN §4.3 的两个【实现依赖】里它占一条）—— ✅ **2026-10-07 三样齐了**。

⚠️ **代价照写**：这三样落在 **moobile 仓**（另一个模块），要**升版本 + 跑它自己的 `verify_all` / `cap_platform`**；
skillpress 这边的 `moon.mod` 依赖版本也得跟着抬。**跨仓改动，本仓只做消费者与判据。**

---

## 7. 要拍板的决定（v2）

| # | 决定 | 选项 | 我倾向 |
|---|---|---|---|
| ~~**D-UI-1**~~ | ~~组件边界~~ 🔴 作废（不允许组件） | ① 只允许纯函数组件 ② 允许带状态 | ①（可预渲染、可判据；带状态的等预渲染那条线通了再说） |
| ~~**D-UI-2**~~ | ~~组件定义放哪~~ 🔴 作废 | ① 只内容里（围栏） ② 只站点包 ③ 两者，内容优先 | ③（引擎 / 主题带一套常用的，站点能自己加） |
| **D-UI-3** | 主题的 N 从哪来 | ① 内置 ② 站点配置 ③ 内容仓（过门 + 进指纹） | ② 先做，③ 是它的下一步 |
| **D-UI-4** | 样式子集补哪几个（3.1） | 甲级（shadow / transform / cursor）优先；乙级（hover 消费侧 / transition）看预算 | 🔴 **已拍定（2026-10-07，用户）：走 B —— 先补甲级三样**（`box_shadow` / `transform` / `cursor`），乙级（真 hover / transition）暂不做 |
| ~~**D-UI-5**~~ | ~~预渲染要不要现在做~~ 🔴 **用户定：先不做**（"我们还在开发啊"）；账记在新计划 T10 | —— | —— |
| **D-UI-6** | `whenToUse` 写法 | ① frontmatter（现状） ② 改成组件 `<When>…</When>` | ②（组件通道打通后，frontmatter 的特殊地位就该收回） |

---

## 8. 探针清单（"先探索方向"的下一步就是这些）

| # | 探针 | 能回答什么 | 成本 |
|---|---|---|---|
| **P-0** | ✅ **已做**：`@style` 能力面枚举 | 86 个方法、缺哪七类（2.4） | 一条 grep |
| **P-1** | `press:` / 悬停要在 RNW 上接消费侧，得改哪几处（`render.mbt` + rnw 宿主 + `Pressable`） | 乙级能不能做、要多少改动 | 小（读码 + 一次真跑） |
| **P-2** | `box_shadow` / `transform` 在三个宿主（RN / RNW / webview）各自的对应物与降级 | 甲级能不能进子集、API 长什么样 | 中（查 RN 文档 + 三宿主各跑一次） |
| **P-3** | 主题换肤：theme 放进 Model 切一次，实测**首帧时序**与重渲代价 | R-UI-3 的形状（要不要全局通道） | 中 |
| **P-4** | `localStorage` / `matchMedia` 在 moobile js 目标里的读写通路（`@sub`？`@js`？宿主模板？） | 6.4 两条通路 | 小 |
| **P-5** | ```moonbit moobile``` 组件**最小闭环**：手写一份"围栏 + 标签"的生成物，编过、在浏览器里渲出来 | R-UI-2 的可行性、报错体验（5.6） | 中 |
| **P-6** | 预渲染：把站点 bundle 用 `react-dom/server` 渲一次，看产物与 hydration 现状 | 2.33 MiB / 空 `#root` 那条线怎么走 | 中 |

**建议顺序**：P-4 → P-5 → P-1 → P-3 → P-2 → P-6
（先做"小且决定形状"的：通路两条 + 组件闭环；再做尺寸 / 接线那两条）。

---

## 9. 坑与未知（诚实清单）

- ⚠️ **v1 的教训（记账）**：对标对象**只提供能力清单，不提供架构**。
  一旦把对标对象的架构当成目标，就会得出"把地基换掉"这种结论 ——
  而地基是这个项目**唯一**不可换的东西。
- ⚠️ **`Attrs::class` 那条路已被实测判死**（3.2）：RNW 的 `View` 对白名单外的 prop **静默丢弃**。
  所以"挂 class + 写 CSS 文件"不是"未验证"，是**此路不通**。
- ⚠️ **`host_ssr` 不是现成能力**：vendor 里有，但它是已死的支线（3.3）。
  别拿它当"我们支持 SSR"的证据。
- ⚠️ **`@style` 的子集是故意封闭的**：往里加属性 = 动 DESIGN 原则 3 的边界，
  每加一个都要回答"三个宿主各自怎么办"。**不许悄悄加。**
- ⚠️ **生成物里塞内容代码**的报错位置（5.6，未查实）。
- ⚠️ **参考稿的内容是手写的**：它证明"排得出来"，不证明"内容源支持这么排"。
- ⚠️ **环境坑**：本机 `NODE_ENV=production` 是全局预设的，`npm i -D` 会**静默跳过**
  （装 devDependency 要 `NODE_ENV=development npm i -D --include=dev`）。
- ⚠️ **调研探针站 `interest/vp-probe` 有 98 MB**（含 `node_modules`，可由 lock 重装）。

---

## 10. 骨架缺陷与移动端规格（用户实测，2026-10-07）

用户对着现在的站点点了四条问题 + 给了一套移动端交互。**下面每条都先给证据**（本仓规矩：别只记现象）。

### 10.1 tab 的指示条不在 tab 的底边（真因已定位）

**现象**：顶栏那条"当前项"的指示条（`c_accent` 的 2px 条）**不在 tab 的正下方，偏左**。

**结构证据**（`_build/shots/dom.html` 的真实 DOM，指示条那一段）：

```html
<div tabindex="0" style="padding: 9px 11px; background-color: transparent;">   ← 按钮
  <div style="align-items: center;">                                          ← 内层 wrapper（无显式 position）
    <div>首页</div>                                                            ← 文字
    <div style="position: absolute; left: 7px; right: 7px; bottom: 0px; height: 2px; …"></div>  ← 指示条
  </div>
</div>
```

**真因**：指示条挂在内层 wrapper 上，而 `left/right/bottom` 的**包含块**就是那个 wrapper
（RNW 给每个 View 都加了 `position: relative` —— `node_modules/react-native-web/dist/exports/View/index.js:132`，所以包含块是 wrapper 而不是顶栏）。
⇒ 它落在**文字正下方**（按钮 9px 下内边距之上），宽度只有"文字 ±7px"，而不是 tab 的底边通栏。
⇒ 叠加"当前项默认是最左边的「首页」"（快照里 `首页` 是 `font-weight:600` + 亮色），
看上去就是"条在最左边、不在 tab 下面"。

**两条可能的诉求（要你确认是哪一条）**：
- **(a) 位置要修**：指示条应当贴 tab 的**底边通栏** ⇒ 把它挂到**按钮自己**身上（按钮加 `position: relative`），
  或改成"按钮下内边距留出 2px + 子条贴底"；
- **(b) 行为要改**：你期望**悬停时指示条跟着指针走** ⇒ 那是**设计决定 D2**（现在指示条 = **选中项**，
  悬停只负责展开下拉菜单），改它要动 D2，不是修 bug。

### 10.2 侧栏没有固定、没有独立滚动（与 P2 是同一个根）

**现象**：侧栏不吸顶、不能自己滚，只能跟着整页滚。

**证据**：`shell/docs.mbt` 里侧栏是
`width(292) + background_color(c_side) + border_right + padding(10)`，
内部是 `[标题块, @html.node("scroll", flex(1.0), 树)]` —— **外层容器没有高度上限**（没有 `height`、也没有被弹性约束），
而 PLAN 的 **P2 实测**早就记着："正文那个 `scroll` 容器被撑成内容那么高 ⇒ 实际滚的是整个 document、顶栏会跟着走"。
⇒ `flex(1.0)` 拿不到可分配空间 ⇒ ScrollView 长到内容高 ⇒ 侧栏没有自己的滚动。

**要什么**：左栏与右栏各自 **sticky + 独立滚动**（`top = 顶栏高`、`height = 视口 - 顶栏高`、`overscroll-behavior: contain`）。
⚠️ 已知约束：`@style` **没有 `sticky`**（`style.mbt` 刻意不加）⇒ 只能靠"根容器固定高度 + 三栏各自滚动"这套
结构来表达（正是 P2 那条未决项的解法），**不是加一个属性就能完事**。

### 10.3 目录（右栏）还没做 —— 但**有计划**

`PLAN.md` 的 **P3 目录（TOC）** 就是它：生成期为每个 md 算 TOC（层级/文本/锚点 id），宽屏挂右栏、
窄屏收进侧栏、当前节高亮。**状态：未做**。它同时是对标表里的第 3 格，属"站点侧能独立走完"那一类。

✅ **原型侧已按这句话做出可点形态**（2026-10-07 第二轮）：`_scratch/ui-ref/demo.html` 现在是三档阶梯 ——
`rail 3`（≥1280）目录挂右栏轨 / **`rail 2`（1024–1279）目录塞进侧栏、而且挂在"正在读的那个文件"节点下面**（树的第三级，
缩进比同级条目再进 16px）/ `rail 1`（<1024）侧栏整个进 sheet。
**"窄屏收进侧栏"就是用户这条诉求**（原话："当屏幕宽度不够时，先把目录塞进侧边栏里"，随后补一句
"应该塞在导航里，对应文件的下面，而不是直接塞到侧边栏底部"）——它和 P3 原本写的是同一件事，
缺的只是**中间那一档**：原型原来三栏一直挂到 1024，1100px 下正文被压到 586px = **37.8 字**（破 A2）。
细节与读数见 `DESIGN-site.md` §4.1.1（行语言第 5 条）、§4.2（阶梯）与 §11。

### 10.4 侧栏"太丑、没有层级感" —— 数据层证据

拿**真实语料**抽了一遍（`content/content.generated.mbt`），侧栏现在显示的东西是：

| 位置 | 现在显示 | 证据（生成物原文） |
|---|---|---|
| skill 行 | **英文 slug** + **一长句 `desc`** | `slug: "moobile-app-development"` ／ `desc: "用 moobile 写应用侧代码（不改库）的入口：TEA 四件套 + 单导出 app()、首帧入口、@html DSL…"` |
| 子页行 | `§ `/`⌗ ` + **英文 name** + 一长句 `desc` | `name: "events-and-subs"` ／ `desc: "点按 / 输入 / 手势各走哪条通道、Payload 的五个提取器、以及订阅 Sub（定时…"` |
| —— | **`title` 字段（中文人话，如「事件与订阅：要"真实值"就得挑对通道」）在侧栏里一次都没用** | `shell/docs.mbt:225` `nav_button(d.slug, d.desc, …)` 与 `:235` `…+ kid.name, kid.desc`（`title` 只用在**页内标题**，见 `page_of`） |

⇒ 侧栏是"**英文机器名 + 70 字截断句**"两行制 × 最多 **33 行**
（**实测**当前生成物：`grep -c 'slug: "'` = **6** 份 skill、`kind: "ref"` = **27** 个子页、script **0**；
⚠️ 与 `PLAN.md` 里"7 份 / 29 refs"的旧快照不一致 —— 以生成物为准，那说明语料侧动过），
没有分组（D5 定"不分组"）、没有激活色条、没有缩进引导线、没有 hover 反馈（行内样式表达不了）。
这就是"没层级感"的来源 —— **不是样式没调好，是喂进去的字段不对**。

**候选处置**（要拍）：① 侧栏改用 `title`（中文人话）当主标签、去掉长 `desc`（或截到 ~18 字）；
② 一级/二级用**字号 + 缩进 + 引导线 + 分组标题**拉开三档；③ 收起态只显示一级（默认折叠）；
④ 当前项加左侧色条 + 淡底（`c_accent` 系）。

⚠️ 顺带一条：`desc` 在生成物里是**给 Agent 读的摘要**（它同时是"人机共读"的一部分），
所以"侧栏别显示它"≠"删掉它"。

✅ **原型侧的第二例：同一类病（2026-10-07 第二轮，已修）**。用户看完原型说"侧栏不够好看"，一量才发现不是审美问题：

| 症状 | 实测证据（改之前） | 真因 |
|---|---|---|
| 侧栏"不好看、没层级感" | 首行 `a`：`color rgb(79,70,229)`（链接蓝）、`font-size 15.5px`（跟正文一样大）、`display inline`、`padding 0`；当前项 `background transparent`、**零高亮** | `renderSidebar()` 吐的是**裸 `<li>`**，没有 `<ul class="tree">` 外壳 ⇒ `.tree a / .txt / .nm / .mj / [aria-current]` **五条规则一条都没命中**（写了 CSS ≠ 生效） |
| 正文挤到屏幕边 | `#main` 的 `padding-left` = **0** | HTML 里是 `<main class="col" id="main">`，CSS 写的是 `.main{…}` —— 类名不存在 |

⇒ 与 §10.4 的结论**同向、不同层**：真站那边是"**喂进侧栏的字段不对**"（slug + 长 desc），
原型这边是"**样式根本没接上**"。合起来一句话：**侧栏是"字 + 行 + 状态"三件事，缺一件都不成立**，
而这三件都只能靠**生效读数**验（`DESIGN-site.md` §4.1.1 把行语言写死了，§11 记了改前/改后的数）。

### 10.5 移动端规格（用户口述，待确认两处语义）

**用户原话**：
> 在移动端时，顶部栏应该收缩变成顶部左侧的一个浮动的按钮，点击后变成下往上弹出的 sheet，hoverbar 变成下一级；
> 左侧边栏则变成底部左侧的浮动按钮，右侧边栏则是底部右侧的浮动按钮，三个按钮都是下往上弹出的 sheet，
> 平时都是贴边停靠，点击后先浮出来，再点一下弹出来

**我的复述**（请确认，尤其标 ❓ 的两处）：

| 元素 | 窄屏形态 | 位置 | 打开后 |
|---|---|---|---|
| 顶部栏（站名 + 一级导航） | 浮动按钮 ❓**贴边停靠**在**顶部左侧** | 屏幕上边左 | 下往上弹出的 sheet；**hoverbar（tab 那排）是它的"下一级"** |
| 左侧边栏（skill 树） | 浮动按钮，**贴边停靠**在**底部左侧** | 屏幕下边左 | 下往上弹出的 sheet |
| 右侧边栏（本页目录） | 浮动按钮，**贴边停靠**在**底部右侧** | 屏幕下边右 | 下往上弹出的 sheet |

**交互（两级）**：平时三个按钮**贴边停靠**（半藏/贴边，不挡内容）→ **第一次点：按钮浮出来**（离开边缘、进入可见状态）
→ **第二次点：弹层从下往上出来**。

**要你确认的 ❓**：
1. "**先浮出来**"指的是 **按钮自己从边缘挪出来**（贴边半藏 → 完全可见），还是 **面板先露出一条预览**？
2. 三个 sheet 是否**互斥**（开一个自动关掉另一个）？顶栏 sheet 里"hoverbar 变成下一级"是
   **推入二级列表**（返回上一级）还是**手风琴展开**？

**⚠️ 两条实现约束（今天做不出来的那一半，必须先记账）**：
- **没有 portal / 遮罩 / `pointerEvents`**：`@style` 里 `pointer` 命中 0、无 `createPortal`、
  `wrapRoot` 只是 Provider 包装（审计 §3.3）⇒ 浮层与浮动按钮只能"挂根 + 自绘遮罩"，
  否则会被父容器 `overflow` 裁剪；
- **没有过渡动画**：`transition` 是**刻意不加**的（`style.mbt:774`：让它在编译期就写不出来）
  ⇒ "**下往上弹出**""**先浮出来再弹**"这两步**今天只能是硬切**。
  要真的动画，只有三条路：① Model 驱动逐帧（重、但跨端）；② 给 web 加过渡（原生硬切，跨端分叉）；
  ③ 不做动画。**这一条正好是审计里"乙级能力"的典型**，也是个要拍的板。

---

## 11. 整体不如意的地方（两轮汇总；**不重复 §10 的硬伤与交互**）

§10 记的是"三处硬伤 + 侧栏字段 + 移动端规格"，交互（悬停/按下/过渡/焦点）已单独记账。
这里记**其余**的 —— 按"读者能感知"的顺序。★ = 本轮新发现（之前没写过）。

### 11.1 可达与地址（★ 最硬的一条，之前完全没记）

| 现象 | 证据 |
|---|---|
| **站点没有 URL** —— 切页不改地址栏、**不能分享到某一页**、**浏览器后退无效**、**刷新回到首页** | `grep -rn "history\|location\|hash\|push_url" shell/*.mbt` → **空**；切页只发 `Msg::Go(...)` 改 Model。而 moobile 的 `nav` 包在 RN 上不可用、站点也没接 |
| 页面 `<title>` 恒为 `moobile · SKILL` | `dist/index.html` 原文；shell 里没有一处设置标题 |
| 没有 favicon | `rel="icon" href="data:,"`（当时为了省一条 404） |
| 没有 `meta description` / `og:` | 产物里没有；分享出去是一张空白卡片 |

⚠️ 这一条与"预渲染"是**两回事**：哪怕不预渲染，SPA 也能做 URL（hash 或 history）。
它是"文档站"最基础的一条，而我们连它都没有 —— 比首屏空壳更早暴露。

### 11.2 信息架构：内容模型被直接当成 UI 模型

| 现象 | 根因 / 证据 |
|---|---|
| 顶栏吃的是正文的 `##` 标题（"一、站点的形状"），**导航读起来像目录**，序号还会随内容增删漂 | D3 定的"首页二级标题 = 一栏"，但**没有对栏名提要求**（SPEC §8 只说"要写短名"，无人守） |
| **同一条数据三种写法各行其是**：一级用 `slug`（英文机器名）、二级用 `name`（文件名）、中文 `title` 只在页内用 | `shell/docs.mbt:225/235`；`title` 在侧栏一次未用 |
| 一级就 **27 条子页**，而 D5 定了"不分组" —— 没有"多了怎么办"的后手 | 生成物实测：6 份 skill / 27 个 ref |
| 首页与文档区**内容重叠却渲染不同**（首页讲站的形状、文档区讲 skill），读者不知道从哪读 | `home.mbt` 的 `site_pane` vs `docs.mbt` 的 `pane` |
| 没有面包屑；只有 kicker `slug · skill`，读者不知道自己在这棵树的哪儿 | `docs.mbt` 的 `pane()` |

### 11.3 文档站的"标准件"一个都没有

| 缺 | 对照（VitePress） |
|---|---|
| 页脚（来源 / 许可 / 版本 / 生成时间） | `footer.message` / `copyright` |
| "最后更新" | `lastUpdated` |
| "在 GitHub 上编辑此页" | `editLink` |
| "这一页来自哪个文件" | 它没有（是我们的**差异点**，但也没做） |
| 上下页（prev / next） | `docFooter` |
| 右栏目录 | `outline`（§10.3 已记，未做） |
| 搜索 | local search ✅ **已做（2026-10-07）**：顶栏入口 + `⌘K`，命中分整页/小节两种粒度并**能跳到对应节** |
| 404 / 空态 / 加载态 | 引擎侧"内容根为空"会退 2，但**站点界面没有对应画面**；2.4 MB JS 加载期间是**纯白** |
| 深色模式 | 未做（已记） |

### 11.4 排版与阅读

| 现象 | 证据 / 对照 |
|---|---|
| 正文 **14px / 行高 1.85**、行宽 **880px** | `theme.mbt` 的 `body_style()`、`docs.mbt` 的 `max_width(880)`；对照 VitePress 是 16px / 1.75 / 688px |
| **等宽字体用错场合**：站名是一整句中文、页标题 22px 也是 mono | `topbar.mbt` 的 `font_family(mono)` 打在 `home.title` 上；`docs.mbt` 的 `pane()` 同理 |
| 标题层级只有 22 / 25 / 17 三档，且间距是**字面常量**（4/6/8/10/14/18 各处拍脑袋），没有节奏体系 | `docs.mbt`、`home.mbt` 里逐处 `margin_*` |
| **表格是 div 拼的，列宽等分**：`flex(1.0)` 每格 → 短列被撑、长列被挤；无横向滚动、无斑马纹；单元格 13px 与页面 14px 不统一 | `shell/blocks.mbt` 的 `table_view`（表头 2px 下边框、每行 1px 下边框） |
| 行内样式只剩两种能用（粗体、行内码）—— 链接根本不是链接 | `Span` 只有 `Txt` / `Code` / `Bold`（§2.3 已记，这里是它的观感后果） |
| 列表只有缩进与序号前缀，没有项目符号样式、没有嵌套层级视觉 | `Block::Ul(indent, num, spans)` |

### 11.5 视觉系统：没有 token，所以"东一处西一处"

| 现象 | 证据 |
|---|---|
| **0 档阴影** —— 卡片/下拉/浮层的"海拔"全靠 1px 边框 | `style.mbt` 里根本没有 shadow（§2.4 已记）；对照 VitePress 有 5 档（4%–16% 透明） |
| 圆角只有一个值 `8`，间距常量混用 `6/7/10/12/14/24/32` | 各处 `.border_radius(8.0)`、`.padding(...)` 字面量 |
| **颜色有两个家**：`theme.mbt` 13 个常量 + `blocks.mbt` 的 `tok_color` 9 个高亮色 | 换皮/深色**必然漏一处** —— 这也是为什么深色做不出来 |
| 侧栏与正文的**底层次不足**（#eae3d5 vs #fffdf8），分区靠边框而不是底色 | 配色那轮已确认；对照 VitePress 用 `bg` / `bg-alt` 拉开 |

### 11.6 无障碍的"结构"面（属性面已在硬伤里记）

`h1/ul/li/table` 在标签表里被压成 `Text` / `View` / `Text` ⇒ **屏幕阅读器读不出文档结构**；
没有 skip link、没有 Tab 序设计、没有键盘快捷键（VitePress 有 ⌘K）。

### 11.7 语料的"版本可见性"

站点没有暴露"你正在看哪一版语料"（内容根 / 快照时间 / 生成器版本）——
而 `PLAN.md` 记的"7 份 skill / 29 refs"与当前生成物的 **6 份 / 27 个**已经不一致，
读者无从判断自己看的是什么。页脚 + 生成时间 + 内容根指纹可以把这条补上（与"标准件"同一批做）。

### 11.8 移动端**现状**（规格已在 §10.5，这里记"现在长什么样"）

292px 的侧栏在 375px 宽的屏上占 **78%**；顶栏那一排靠**横向滚动**（内容加一节就多一条）；
正文被挤成窄条 —— 也就是说：**现在窄屏基本不可用**。

---

### 11.9 四条共同根因（比逐条修更重要）

1. **没有设计层**：颜色 / 间距 / 字号 / 圆角全是渲染代码里的字面常量 ⇒ 必然"东一处西一处"。
   解：一张 token 表（原语 → 语义），组件只准引用语义名。
2. **内容模型被直接当 UI 模型用**：顶栏吃 `##`、侧栏吃 `slug`/`desc` ⇒ 信息架构错位。
   解：给界面字段立规矩（导航名 ≤6 字、无序号；侧栏主标签用中文 `title`；截图/门守）。
3. **文档站的"标准件"缺失**：URL / 页脚 / 上下页 / 目录 / 搜索 / 更新与编辑 / og / 404 —— 一个都没有。
   这是"文档站"这个词的默认值，不缺才怪。
4. **没有"看起来怎么样"的验收**：所以能一路做到今天才被看见（这条是我要认的账）。
   解：每轮出截图、与参考稿并排；"丑到不敢给人看"= 红。


---

## 12. 真站落地进度（M0–M4）

设计依据是 `DESIGN-site.md`；**读数与"缺什么"记在它 §12**，这里只记进度与下一步。

| 档 | 内容 | 状态 |
|---|---|---|
| **M0** | 侦察：`@style` 子集边界、`sub.current_viewport` / `on_resize`、R9（≤400 行）、判据绑旧 IA | ✅ 2026-10-07 |
| **M1** | `tokens.mbt` / `theme.mbt`（主题是值）/ `layout.mbt`（三档阶梯 + 三栏各自滚）+ Model 收视口 | ✅ 三档全绿 |
| **M2** | `sidebar.mbt`（行语言五条）/ `toc.mbt`（挂当前文件节点）+ 契约补 `Doc.title` + 生成物重跑 | ✅ 三档全绿 |
| **M3** | 正文块与文档页骨架换主题（`blocks.mbt` / `article.mbt`） | 🔄 进行中 |
| **M4** | 顶栏（白底 + 1px 线 + **高 56** + 站名回首页 + nav + **主题三态开关**）与页面模板（首页门户 / 书架 / 404 / 页脚） | ✅ 2026-10-07 三档全绿（读数见 DESIGN-site §12.1；`nav_items(ctx)` 是"导航来源"的接缝，配置做出来后只改它） |
| **M5a** | **窄屏入口**（§4.3）：三个贴边按钮（导航/书架/目录）+ 两级交互（浮出→sheet）+ 遮罩互斥 + Esc/点遮罩关；状态进 Model（`sheet`/`peek`） | ✅ `shell/mobile.mbt`（243 行）；读数：三按钮在、**第一次点浮出 34px**、第二次才弹 sheet、**再点同一按钮收起**、Esc 关 |
| **M5b** | **探针换 CDP 真输入**（`tools/cdp.mjs` 188 行，Node 自带 WebSocket ⇒ 零依赖；真鼠标/真按键 + `Emulation.setDeviceMetricsOverride` 真视口） | ✅ 三档全绿 ×3；**当场逮到两条假绿**（见 DESIGN §12.1） |
| **M5c** | **页脚三样**（许可/生成时间/内容根指纹）：补 `Ctx` 契约 + `press` 填真值 | 🔄 排队 |
| **M5d** | **主题「跟随系统」两条通路**（首屏不闪 + 系统变化） | 🔄 排队 |
| **M5e** | native 判据换血（`engine/site/**` + `tools/acceptance.sh`；施工单 §13.5） | ⏸ **按用户指示挂着**：先齐功能，等界面定形再一次性换（wip 在 `_scratch/m5-native-wip/`，不编译、不占 R9） |

### 12.1 另一条轨道：**路由（R）** —— 属于 moobile，skillpress 是它的第一个用例

设计稿落在 moobile 自己的文档体系：`docs/design/DESIGN-ROUTER.md`（三层分离 / 三类参数 / 单向环 + 唯一写者 / sheet 三条定论 / 深链 / 后台两种 / 四模块切法 / **不接管应用的 Model** / §8 未查实清单）+ `docs/FINDINGS.md` + `PLAN.md §3.7.5（R）` + 试金石 `examples/apps/route-spike/`。

**地基真读数**（真 Chrome + CDP，13 组动作）：

| 通道 | 判决 |
|---|---|
| `on_url_changed` | **通，但只有 popstate 那一档**（改 hash/后退/前进/点同文档 `<a>` 都推；**`pushState`/`replaceState` 不推**；**首屏不推**） |
| `on_url_request` | **不通**：Web 上**一个调用点都没有** ⇒ 深链今天没通道 |
| `on_visibility_change` | 通（DOM 回退接上、方向对）；**真切后台**无头给不了读数 ⇒ 仍只有真机 |

**已落地**：根转发包 `url/` + `common/`（消费者终于**能命名** `Url`/`Viewport`/`Keyboard`，「可命名类型不许漏」已是独立机检项）· **`@sub.current_url()`**（首屏同步读 == 地址栏，真浏览器验过；缺它首屏就是瞎的）· **`@url.parse` 修好**（hash 里的查询串曾拆错位置，破往返一致）+ 往返判据 · `cap_platform` **自己拦住了未登记的新 API** · `vendor_sync --capture` 跳 `*.mbti`（带"确实跳过 22 个"的正面读数）。

**已拍的两板**：① 路由**不自带** URL 类型（加根转发包）② `nav` 不转发且新模块不许依赖它（它内部 `@dom.push_url` **RN 上会抛**）⇒「推」走**宿主能力**；③ 能力协议**加第三类「动作」**（读/订阅/**动作**），但动作必须**具名 + 声明载荷 + 声明哪端实现 + 进 `cap_platform`** ⇒ 未声明的动作是**判据能看见的缺口**。**不选**「函数注册」（能力表是数据才可被机检）。

**下一刀**：`invoke` 形状 + `url.push`/`url.replace` → `router/core` 纯函数 + `moon test`（三类参数 + sheet 三条定论 + 默认值不入地址/顺序固定/认不出就抹掉），`apply(model, route)` 签名先定、**先不接 skillpress**。

**复验清单（M4 交回时按这个逐条跑，不许跳）**：

```bash
cd D:/ai_project/interest/skillpress
moon check --target js                      # 0 errors，warnings 不许比 19 多
cd skills/skillpress/scripts/.skillpress && npm run build
cd /d/ai_project/interest/skillpress
node tools/ui-probe.mjs                     # 三档全绿 + 三条新增：顶栏===56 / 点站名回首页 / nav 无「一、」序号条目
bash tools/theme-check.sh && bash tools/line-budget.sh
wc -l shell/*.mbt                           # 每个 ≤400；实例 app.mbt 仍 ≤20 行
```

**卡在 moobile 侧的两件**（都要先补能力，见 §6.7 与 DESIGN §12.2）：
① `cursor` / `box_shadow` / `transform`（甲级样式，D-UI-4 已拍定 B）；
② **滚到某个节点 + 元素测量**（甲级；缺它目录只能是个静态骨架 —— M2 就是这么做出来的）。

**内环判据**：`node tools/ui-probe.mjs`（真 Chrome × 3 档，量"生效的读数"）。
**官方判据**：`engine/site/` 那批断言**2026-10-07 已按新 IA 换血完毕**（22 → 23 条，`verify.sh` 全绿；
    逐条对照与读数见 `PLAN.md` 的 D45）。⚠️ 仓里凡是提"22 条判的是旧 IA"的地方（含本文件的旧版本、
    `DESIGN-site.md` §…、以及 PLAN 里 D36–D43 的历史记录）都是**换血之前**的坐标，别当现状读。


---

## 13. 判据换血：旧 IA → 新 IA（M4 的施工单）

`engine/site/`（native `skillpress verify`）那 15 条判据是**照着旧 IA 写的** ——
它们断言的正是新设计要拆掉的东西（"顶栏的分栏 = 首页 README 的 `##`"、"文档 / SKILL" 那条模式开关、
"首屏没有侧栏"）。**界面换了而判据不换 = 判据在守一个已经不存在的形状**（而且会一直红，训练人忽略它）。

### 13.1 旧判据的结局（逐条）

⚠️ **第一版这一节我写错了，2026-10-07 用户当场纠回来**：我把"形状变了"直接判成"删"。
**判据守的不是那条 `##`，是它背后的意图**（顶栏有入口、点了真的有反应、能回首页、能进文档区）。
形状该换，意图不能没人守 —— 否则"删掉一条判据"就等于**悄悄放掉一个承诺**。下列五条一律**改口径**：

| 旧判据（大意） | 结局 | 新口径（意图保留，形状换成新的） |
|---|---|---|
| 顶栏有「首页」这一条 | **改口径** | **点站名回首页**（新顶栏里"首页"是品牌那一条，不是普通 nav 条）——比原来更严：它同时验了"品牌可点" |
| 顶栏 N 个分栏（分栏 = 首页 README 的 `##`） | **改口径** | 顶栏有 N 条 nav（**来源是站点配置**，见 13.1b）；每条 `label` 点了**正文真的变**（H1 变）。见 13.1c：那条序号探针是**空判据，已作废** |
| 切到某一栏后首屏引言退出正文 | **改口径** | 意图是"点了要真的换内容，不是滚动到某处"⇒ 断言：点某条 nav 之后**正文 H1 变了**（且首屏引言不再在正文里） |
| 点「首页」回到官网首屏 | **改口径** | 同上第一条（点站名）—— 回首页后 H1 回到首页源那一句 |
| 点到顶栏的「文档 / SKILL」 | **改口径** | 意图是"从官网能进文档区"⇒ 断言：**从首页两次操作以内能到某一份 skill 的页**（A3 那条"找得到"的落地）。**"模式开关"这个形状本身删掉**——首页 / 书架 / 主题页是四类页面，不是两个模式（§2.1） |

### 13.1b 顶栏**由什么定义**（第一版漏了这块，是这一节最该先写清的东西）

判据只是"守门"，**门后得先有个定义**。新顶栏三段，各自的来源与今天的落点：

| 段 | 内容 | 来源 | 今天的状态 |
|---|---|---|---|
| 左 | 站名（= 回首页的入口） | 站点配置 `title`（§7） | 暂时取首页源的 H1（`Home.title`）——**已可跑** |
| 中 | nav 条目（≤6 字、无序号、无标点，§2.3） | **站点配置 `nav[].label` / `nav[].href`**（§7，**【实现依赖】：这份配置今天不存在**） | **必须做决定**：① 现在就把 `.skillpress/` 配置做出来（进 G8 指纹、`attach` 不覆盖）；② 过渡期继续从 `home.sections` 取，但**必须在注释与判据里写明"这是过渡"**，且 §13.1 那条"不许出现序号条目"的反证要**跟着放宽成警告**（不然守的是一个我们还没做到的形状） |
| 右 | 搜索 / 主题 / 等（§5 顶栏那一行） | 各自的能力 | 主题开关**今天能做**（三态进 Model）；**搜索**要构建期索引 + 浮层（portal），**llms.txt** 要生成期多产一份 —— 两样都是【实现依赖】⇒ **不画没有反应的按钮**（§1 反面验收） |

⇒ **M4 开工的第一件事是给"中段"定来源**（上面那个二选一）。**我倾向 ①**：配置很薄（几条 `nav.N.label` / `nav.N.href`），
而"导航来自配置"正是 §2.3 立的那条规矩；不做它，顶栏就还得从正文里长出来 —— 那 D3 就没真的修掉。
### 13.1c 那条「反证」是**空判据**，作废（2026-10-07 用户戳穿 → M4 实现钉死）

我先把它当「反证式判据、专逮 D3 回潮」，还写进了 `DESIGN-site.md §12.1` 的读数。它不成立，而且比「弱」更糟：

1. 用户先指出：只要把 `WEBSITE.md` 的 `##` 改写成「文档 / 书架 / 规矩」，**耦合照旧、这条却绿**；
2. M4 的实现把它彻底钉死：`nav_items()` 从 `home.sections` 推时**把序号剥掉**（「一、两份 skill」→「两份 skill」）才合规
   ⇒ 这条探针测的是**渲染函数自己的字符串处理**，**构造上恒绿、永远不会红**；
3. 而 nav 的真来源仍是内容 ⇒ **它绿着，性质却没成立**。

**删掉它**（不是降级）。更一般的形状值得记：**「渲染层把上游的病擦干净」会让下游判据永远看不到那个病** ——
要守性质，就在**来源**上量或在**扰动**上量，不能在「擦干净之后」的输出上量。

**要对的性质**：*nav 的来源不是正文的 `##`*。两种真测法（已进 §13.5 的 M5 清单）：

| # | 测法 | 何时做 |
|---|---|---|
| 1 | **配置一致性门**：渲染出的每条 nav label 都在站点配置 `nav[]` 里且顺序一致；再补「配置是这些 label 的唯一声明处」 | 站点配置做出来之后 |
| 2 | **扰动测试（唯一真证明）**：fixture 里改/增删首页源的 `##` ⇒ 重跑生成 ⇒ 断言 **nav 一字不变** | 与 1 同时 |

⚠️ **过渡期诚实状态**：`nav_items()` 今天「从 `home.sections` 推 + 剥序号」是一个**兼容垫片**（让顶栏不做配置也不违反 §2.3）。
**垫片在，耦合就在** ⇒ 今天「解耦」不成立；报告里不许拿任何绿去暗示它成立。配置落地：删垫片 + 上 1、2。

### 13.2 新判据（M4 要写进 `engine/site/checks.mbt` 的）

分组写，**每条都必须是"生效的读数"**（computed style / 几何 / 点完之后真的变了），
不许用"属性在不在"糊过去（§11 那笔账：旧体检读 `aria-current` **属性**、而样式从没生效）。

| 组 | 判据 |
|---|---|
| **阶梯** | 1200+/1024+/<1024 三档下：栏数、侧栏 272、目录轨 220（rail 3）、rail 2/1 目录轨**不在** |
| **侧栏** | 存在性（rail 1 整栏不画）；行高一致；父子标题同一列；**当前项有左条**且全列只有一条；**没有 slug**（机器名不进侧栏） |
| **目录** | rail 3 在右栏轨；rail 2/1 **挂在当前文件节点下面**（是那个节点的后代、缩进再进一格）；点进某页后它跟着换 |
| **文章** | kicker 是机器名（mono）、H1 是**中文 title**、面包屑各级是中文 + 末位才是机器名；上下页**点了真的换页**（H1 变） |
| **顶栏** | 白底 + 1px 底线；窄屏（`data-top=narrow`）导航进 sheet、搜索收成图标；右端三件都在 |
| **主题** | 三态轮换后 `--canvas` 真的变（亮 `#ffffff` / 暗 `#18181b`）；`tok(1..9)` 都有非空色（明暗各一遍） |
| **结构** | 三栏各自 `overflow: scroll`；**页面本身不滚**；无横向溢出；无 JS 报错 |

### 13.3 内环与官方：两边都要留，分工写死

| | 内环 `tools/ui-probe.mjs`（js） | 官方 `engine/site/`（native） |
|---|---|---|
| 跑一次 | `moon build`（秒级）+ 无头 Chrome | 要 C 工具链编一次 native CLI |
| 视口宽度 | **iframe 给真宽度**（窗口有最小宽，§11 那笔账） | CDP |
| 覆盖 | 阶梯 / 侧栏 / 目录 / 文章 / 主题 的**读数** | 同口径 + **上线前的最终账**，并进 `tools/acceptance.sh` 的 A3 |
| 为什么不能只留一个 | 只留官方 ⇒ 改一次界面等一次 native 编译（内环会烂掉）；只留内环 ⇒ 官方验收没人守（A3 会变成空话） | 同上 |

⚠️ **不许两边判据口径不一致**：同一条判据在两处出现时，措辞与阈值必须一样 ——
否则"内环绿、官方红"会变成常态，而那是最容易让人放弃判据的形状。

### 13.7 功能队列的规格与依赖（目标 ③–⑧；2026-10-07 定"先功能、判据挂起"）

#### ③ 主题"跟随系统"两条通路（`PLAN-ui §6.4` 那两条"未验证"）

| 通路 | 要什么 | 探不通怎么办 |
|---|---|---|
| **首屏不闪** | 宿主模板（实例的 `index.html`）里一段**同步脚本**读 `localStorage` 的三态偏好 + `matchMedia('(prefers-color-scheme: dark)')`，把**解析结果**写进一个全局；MoonBit 的 `initial` 读它（与引擎 `ts_shim.mbt` 读全局同形） | 如实写"探了、结果是没通"，**不许造一个"看起来接了"的假通道** |
| **跟随系统变化** | 现役是 `react_host`（**不用 `@dom`**）⇒ 要走 React 侧，或给 `@sub` 加一条订阅 | 同上；实在不行就**只保留手动三态**并把理由写下 |

⚠️ 今天顶栏的主题开关只做手动三态轮换（`Pref::Auto/Light/Dark`）⇒ **`Auto` 实际上不知道系统是暗的**，这是本件要补的缺口。
⚠️ **改动面**：`state.mbt`（`initial` 读那格全局）+ 实例 `index.html`（那段同步脚本）。**与 ② 路由改同一批文件 ⇒ 串行**。

#### ④ 首页第三张入口卡 —— 卡在一个**内容侧决定**

内容根里唯一像"规矩"的是被 `skillpress.ignore.md` **有意忽略**的那份 `skillpress`（"怎么写一份 skill、怎么跑那几道门"，忽略理由：读者是改内容的人，不是来看站点的人）。
两条路：① **取消那条忽略**（换来这一页，代价是推翻那条理由）；② **内容侧新写一页**"规矩"（讲站点命名规矩 / 门 / 判据）。
⚠️ 顺带一笔内容侧的账：首页源的纯链接节 `## [先读哪一份](skillpress-user/SKILL.md)` 指向的页**不在这份货架上**，生成器已回落到 `moobile-app-development` 并打印了提示。

#### ⑤⑥⑦⑧ —— 三件卡在 moobile / 生成期，一件是观感大头

| # | 件 | 卡在哪 |
|---|---|---|
| ⑤ | 目录**可点** + **跟读高亮** | ✅ **两半都通了（2026-10-07）**：moobile 补了节点寻址（`Attrs::id` + `@sub.scroll_to_node` / `node_rect` / `node_scroll_top`）与**容器级滚动订阅**（`@sub.on_node_scroll`，捕获阶段监听 + 装载补一次当前值）；站点侧目录行改 `@html.button` + **量一遍偏移 → 纯算术算当前节**。读数（`_scratch/toc-click.mjs`，真 Chrome）：点最后一行 ⇒ sec-7 顶到上沿差 0px、再点第一行回 sec-0；装载后**恰好一条**高亮在第 0 条、滚到第 4 节 ⇒ 高亮换到第 4 条 |
| ⑥ | 搜索 ⌘K | ✅ **已落地（2026-10-07）**，而且**两处前提都被实测推翻**：① 索引**不必构建期产出** —— `ctx` 里本来就有每页的全部块，**渲染期现算**够用（只在浮层打开时算；站点长到几百页才需要换回构建期）；② **不必 portal** —— 站点那套"根不滚 + 绝对定位挂根上"就够（遮罩**只在开着时画**，关着时一个多余可点区域都没有）。交了什么：顶栏入口 + `⌘K`/`Ctrl+K` + 输入即筛（子串、折 ASCII 大小写）+ 命中**两种粒度**（整页 / 小节）+ 点一下**切页并跳节** + Esc/点遮罩关。⚠️ 它顺带逼出**两条库侧的修**（moobile `FINDINGS.md` 十一/十二）：`on_key_down` 挪**捕获阶段**（浮层里有输入框时非捕获收不到 keydown）、新增 `node_exists`（跨页跳锚点要"等新页面挂上"，而锚点名**每页重名** ⇒ 得靠**页专属**节点名） |
| ⑦ | `llms.txt` + 每页 `.md` 原文出口 | 要**生成期产物**（`press` 多产一份 + 每页 `.md` 的地址）；与 ② 的 URL 方案共用地址 |
| ⑧ | 甲级样式 `cursor` / `box_shadow` / `transform` | **跨仓 moobile**（D-UI-4 已拍 B）。观感大头（阴影 / 抽屉位移 / 悬停手感）全在这一格里；做完 ⑤ 的"浮出"与 ⑥ 的浮层也都沾光 |

**moobile 侧要补的能力清单（照 ⑤⑧ 攒着，一起做更省）**：`cursor` ✅ · `box_shadow` ✅ · **滚到节点** ✅ ·
**元素测量** ✅ · **容器级滚动订阅** ✅ · **`transform`** ✅（2026-10-07 落地 —— **这一格清空了**）。

### 13.6 路由接线（功能排期，2026-10-07 用户定"先齐功能、判据挂起"）

**moobile 侧已经齐了**（另一个会话做的，2026-10-07 有真 Chrome + CDP 实测，落点 `vendor/rabbita/sub/sub.mbt:588` 起 + `examples/apps/route-spike/drive.mjs`）：
`current_url()`（**同步读一次**）、`on_url_changed`（推送）、`on_url_request`（拦链接）。

⚠️ **两个必须照做的实测坑**：
1. `on_url_changed` **只在 popstate 那一档推**（改 hash / 后退 / 前进 / 点同文档 `<a>` 都推），
   而 **`pushState`/`replaceState` 不推、首屏也不推** ⇒ **只订它，"进来的时候我在哪"永远拿不到**（首屏是瞎的）
   ⇒ **`initial` 里必须 `current_url()` 同步读一次**（`current_viewport()` 那条同款）。
2. RN 上 `window` 在而 `location` 不在（直接读会抛）⇒ 取值走它的口径，别自己摸 `window.location`。

**要落的三件（纯 shell 侧）**：
1. `initial` 读 `current_url()` → 解析出页面种类与 `sel`（**URL 是首屏的真源**；认不出才回落默认首页，并把这回落记进注释）；
2. 每次导航（`Home` / `Shelf` / `Go(kind,i,k)` / 上下页）**写回 hash**，方案见 `DESIGN-site.md §2.2`：
   `#/` · `#/shelf/` · `#/s/<slug>/` · `#/s/<slug>/<kid>/` —— **URL 只用机器名，界面名字一律中文**（§2.3）；
3. 订 `on_url_changed` → 解析 → Msg（**后退/前进就活了**）；链接走 `on_url_request` 拦下来交给应用。

**验收（要贴真实读数）**：① 首屏**直接打开深链**（`…/index.html#/s/<slug>/<kid>/`）⇒ 一进去就是那一页，**不是先首页再跳**；
② 点侧栏进一页 ⇒ hash 跟着变；按**后退** ⇒ 回上一页；③ `#/nope` ⇒ **渲染 404**（顺带还掉"404 没有到达路径"那笔欠账）；
④ 刷新同一 hash ⇒ 还是那一页。

**串行约束**：路由与窄屏入口改的是同一批文件（`state.mbt` / `site.mbt`）⇒ **先窄屏、再路由**，不并线（两个写手改同一文件必冲突）。

### 13.5 M5 施工单：native 判据怎么换（实现面前的那一版）

**结构（照现状，别另起炉灶）**：`engine/site/` 里
`entry.mbt`（`verify` 的入口：起服务 → 起无头 Chrome → 走 CDP → 收起）→
`checks.mbt` / `home.mbt` / `docs_area.mbt` / `inline.mbt` / `blocks.mbt`（四组断言）→
`report.mbt`（`Results::check(ok, label, detail)` + 尾句"全部通过（N 条）"）。
**换血 = 把断言那几组按 §13.1/§13.2 重写，`entry` 的组织方式不动。**

步骤（每条都要留下读数）：

1. **先删**：`docs_area.mbt` / `home.mbt` 里按旧 IA 写的断言（"顶栏分栏 = `##`"、"文档 / SKILL"、"切栏后引言退出"、
   "首屏没有侧栏"）——**删之前先把 §13.1 那张新口径表逐条抄成新断言**（别删完再想）。
2. **再按 §13.2 分组重写**：阶梯 / 侧栏 / 目录 / 文章 / 顶栏 / 主题 / 结构。
   一组的断言放一个文件（`rail.mbt` / `sidebar.mbt` / `toc.mbt` / `article.mbt` / `topbar.mbt` / `theme.mbt` / `shell.mbt`），
   每个 ≤400 行（R9 也管 `engine/site/`）。
3. **量法要和内环一致**（§13.3）：能进文档页的动作（点侧栏标题区）、能换页的动作（点下一页）、
   三档视口（`1024`/`1280` 两侧各取一个宽度）、"页面本身不滚"那条**必须先把自己的探针节点从版面里摘掉**
   （`display:none` —— 内环踩过，见 `tools/ui-probe.mjs` 文件头）。
4. **尾句**：`全部通过（N 条）` 的 N 会变 ⇒ `tools/acceptance.sh` 的 A3 段跟着改，**不许写成"≥N 条"**。
5. **判据自证**：至少给两条断言各造一个诱饵（把某一栏宽度改错 / 把页面改成可滚），
   证明它们**会红**——"永远绿"的判据比没有判据更糟。

⚠️ **顺序**：M5 的 native 换血**要等 M4 的顶栏与页面落定**（否则断言对着一个还在动的形状写）。

- `tools/acceptance.sh` 的 A3 段：它现在 grep 的是旧 verify 的尾句（`全部通过（N 条）`）——条数变了要跟着改，
  且**不许写成"只要 ≥N 条"**（那是把判据变成摆设）。
- `engine/site/` 里 `docs_area.mbt` / `home.mbt` 这类**按旧 IA 分文件**的检查要合并或重写（新 IA 里"模式"不存在了）。
