# 预览块

[← 返回 README](../README.md) · [文档索引](./index.md)

> **v4 已落地（0.13.0）。** 结构、控件与布局以 [预览块 v4](./preview-v4.md) 为准；本文保留的是
> **为什么这样**（不用 iframe、保真度口径、状态芯片的机制、边界情况），这些没有变。
> 下面「头部 / 三种模式 / 布局」三节已按 v4 重写。

> **状态：设计里的全部四件都已实现** —— 阶段 1（0.8.0：骨架 + 三个按钮 + 单屏 + 对比）、
> 阶段 2 的卡片导航（0.9.0）与状态芯片（0.10.0）、阶段 4 的全屏视图（0.10.0，0.12.0 重新设计：更大的窗口、毛玻璃背景、顶栏上下文与窄窗口堆叠）。
> 讨论后的第三版。相对最初那版变了四件事：
> 右上角**三个按钮**（全屏 / 对比 / 列表）、对比是「**左边多一块**」而不是两个陌生视口、
> **不用 iframe**（理由见下）、以及对比的左边是**「当前」**（你打开这条规则时的文本），
> 不是「去掉这条规则」。名字以 [`docs/naming.md` D 节](./naming.md) 为准。

---

## 它是什么

插在**代码区**与**规则面板**之间的整块 `.dshCc_preview`，**只在打开了某条规则时出现**
（和规则面板同进同退）。不给没在编辑的行增加高度。

它回答的问题是 **「我刚才这一改，把它变成了什么样」** —— 左边是你打开这条规则时的样子，
右边是现在的样子。这也正是调值时的那一圈：改值 → 看效果。

## 工具条：一行，全部 28px 档

v4 把头部收成**一行** `.dshCc_previewBar`，里面的每一个控件都来自组件库（`ui.*`），
高度全是 28px 那一档 —— 这是「和其它组件一样」的具体含义：

| 控件 | 组件 | 管什么 |
| --- | --- | --- |
| 单屏 / 对比 | `ui.segments` | 视图（二选一，不是两个开关） |
| 状态芯片（按需） | `ui.button({ size:'sm', pressed })` | 只在**选择器里真有**那个状态时出现 |
| 第几个命中（>1 才出现） | `ui.choice({ size:'sm' })` | 命中多于一个时选看哪个（替代了原来的「列表 + 卡片导航」） |
| 背景 | `ui.choice({ size:'sm' })` | 样本坐在什么底上 |
| 适应 | `ui.button({ size:'sm', pressed })` | 只缩显示、不改尺寸 |
| 全屏 | `ui.button({ size:'sm' })` | 原生 `<dialog>`（`Esc` / × 退出） |
| 归位 | `ui.button({ size:'sm', icon:true })` | 画布右下角，没拖过时禁用 |

`test/preview-kit`（`tests/audit-preview-kit.mjs`）守着这条：`PreviewBlock` 里出现手写的
`createElement('button')` 就失败。理由是历史 —— 上一版这排按钮全是借来的 `.dshCc_hlToggle`
胶囊，外加三档自创高度（22 / 18 / 17px）。

## 画布：拖动、归位、背景预设

样本坐在一块**画布**里（`.dshCc_canvas`），行内与全屏各一块，**一份平移量**（所有样本层一起走）。

| 交互 | 结果 |
| --- | --- |
| 按住组件拖动 | 两块样本**一起**移动（`transform` 写在样本那层 `.dshCc_previewAfter[data-pane]` 上）：窗格是固定的**窗口**，组件在窗口里移动；只有一块时看起来就是「拖这一块」，对比/全屏下左右始终对齐 |
| 画布聚焦后按方向键 | 每次 8px，按住 `Shift` 32px；`Home` / `0` 归位（拖动之外的第二条路） |
| 右下角「归位」/ 双击画布 | 所有样本回到原点。**没拖过时它是禁用的**——一颗点了也不会变的按钮比没有更让人困惑 |
| 工具栏最左边的下拉（行内、全屏都有） | 换画布背景：**页面**（默认）/ 棋盘 / 方格 / 斜纹 / 点阵 / 渐变 / 纯色 |
| 工具栏最右边的「适应」 | 把整块组件缩进窗口（**只缩显示**，不改它的尺寸），再点回到 100% |
| 换一条规则 | 画布自动归位（上一条拖到哪，不该带到下一条上） |

**为什么会被裁切**：组件按页面实测宽度排版（这是「不要改变组件大小」），比窗口宽的元素必然有一部分
在窗口外 —— 裁剪本身是对的，拖动可以把它挪进来。但「看不全、又不知道能拖」是个坑，所以加了
**「适应」**：`scale(k)` 只缩显示（`k = min(1, 窗宽/样本宽, 窗高/样本高)`），不重排、不改尺寸，
比例只落在 0–1（放大只会比页面更虚）。它和拖动叠加成 `translate(...) scale(...)`。

画布上 `user-select:none`：拖组件的时候不会顺手把里面的文字刷成蓝色选中。

**默认居中。** `.dshCc_previewViewport` 用 `flex` 把样本摆在窗口正中：比窗口小的居中，比窗口大的
**向两边对称溢出**（拖一下两边都能看到）—— 这比「从左上角开始」更像画布，也对得上「不改变组件大小」。

**组件不压缩、窗格不滚动。** `.dshCc_previewAfter` 的宽度由 `paintModelInto` 按元素在页面上的实测宽度
写死（`box.style.width = 774px` 这种），窗格 `overflow:hidden` 只负责把装不下的部分裁掉 —— 否则克隆件
带着计算样式里的 `max-width:100%` 进到这个窄盒子里会被压成一条，看到的是缩略图而不是画布。

**组件「身后」那层底。** 组件自己的 `background` 常常是 `transparent`，它看起来有底是**页面**给的
（对话里的气泡就是这样）。所以 `page` 档的底色不是写死的页面色，而是 `paintModelInto` 沿祖先链找到的
第一个不透明色（`--dshCc-page-backdrop`）；祖先带图案（渐变/图片）时不猜，交给画布预设。

**为什么只有一份平移量**：用户先要过「全屏左右分开拖」，后来又明确「拖动一个另一个也移动」——
后者才是对照的意义：两块一起走，左右才对得上。所以现在一份平移量写进每个 `[data-pane]`；
舞台自己的 `transform` 每次都清空，免得旧值和新偏移叠在一起。

**提示区**：诊断（会坏）、玻璃（没东西可模糊）、排除名单（用了合成样本）三件事原来各占一行、都用同一种
红色。现在收进 `.dshCc_previewHints` 一块，按语气分两层：`data-tone="warn"` 只留给真的会坏的诊断，
`data-tone="info"` 是「解释 + 出路」（玻璃那条带一个「换成棋盘看」按钮）。

**画布是三层，从下往上**：

| 层 | 是什么 | 谁在用 |
| --- | --- | --- |
| ① 页面底 | `.dshCc_canvas` 的 `background-color` = `--dsw-alias-bg-layer-1` | **所有**档位 —— 画布永远不是「透明的窗口」 |
| ② 图案墨 | `--dshCc-canvas-ink`（就是 `--dsw-alias-border-l2`，10% 黑）画的棋盘/方格/斜纹/点阵/渐变 | 判读用的那几档 |
| ③ 样本 | 克隆件 + 冻结的计算样式 | 一直是它 |

**窗格就是画布，不是一张有底色的卡片。** 这是用户第三次拍板的那句话：「修改后的卡片不应该是画布吗」。
所以 `.dshCc_canvas .dshCc_previewBox` 的 `background` 让开：图案（或默认的页面底）直接铺进窗格里，
面板上看到的那块就是画布的可见部分；组件浮在它上面，自己的底色来自冻结的计算样式。

窗格因此有两条职责，缺一不可：**窗口**（`overflow:hidden`，固定大小，带 0.5px 描边与「当前/修改后」
标签）与**画布**（背景图案）。组件在窗口里按原始尺寸排版、被窗口裁掉超出的部分，拖动把看不见的地方
挪进视野 —— 这也是为什么窗格自己不再滚动（`overflow:auto` 会让它变成「装不下的缩略图盒子」）。

代价是「自己没有底色的组件」会直接透出图案 —— 那本来就是这个元素在图案前的样子；默认档 `page` 的画布
底色是页面色，所以多数情况下看起来与页面一致。

**毛玻璃不需要特殊处理**：底板已经让开了，`blur()` 采样的就是它背后那块图案，模糊看得见。
默认「页面」档下没有图案，玻璃自然没东西可采样，所以面板会给一行提示 + 一个「换成棋盘看」的按钮，
而不是让人以为规则没生效。

要记住的口径：**半透明的东西在图案前就是会透**——这正是图案的用途。真的不透明（alpha = 1）的组件，
任何档位下都不可能透出图案；如果你看到它透了，它的 `background-color` 本来就不是实色。

三件事值得写下来：

1. **拖动不经过 React state**。一次 `pointermove` 走一次渲染，等于把整块面板连同克隆出来的样本
   一起重画（同一个判断见 `paintHighlightBoxes`）。平移量放在 ref 里、`transform` 直接写 DOM；
   「拖过没有」只决定归位按钮能不能点，值不变时 React 自己 bail out。
2. **回到原点给空串**，不是 `translate(0px, 0px)` —— 后者仍然会建一个合成层、仍然会改变 `fixed`
   后代的包含块，于是「归位了但其实没归位」。
3. **图案只画在画布上，不画在任何一层「零件」上**：换背景不改注入、不改样本、不改任何计算样式，
   它只换画布那一层的 `background-image`；窗格让开正是为了让这一层露出来。

背景选择存在 `localStorage`（键 `dsh-custom-css:canvas`），**不进设置记录**：它是「看的时候的偏好」，
不参与注入，也不需要 Host 知道。认不出来的值一律退回 `page`。

## 三种模式

| 模式 | 屏幕上是什么 |
| --- | --- |
| **单屏预览**（默认） | 一块「修改后」 |
| **对比模式** | **左「当前」\| 右「修改后」**，各自带左上角小字 |
| **全屏视图** | 同一个组件再摆一次：顶栏（选择器 + 命中读数 + 命中菜单 + 背景 + ×）+ 并排两块 + 同一份规则面板正文 |

窄行（≤900px）下对比的两块**上下堆叠**，不提示「先全屏」。

**状态芯片不是常驻的**：它按选择器推导（`previewChipsFor`），选择器里没有 `:hover` 时那一排
整排不出现 —— 因为那种情况下点下去是空操作（改写后的选择器根本不存在）。这条是 v4 修的：
上一版固定摆五枚，四枚按不动。

**命中多于一个时**，工具条上出现一枚「第几个」的下拉（`div · 2/5`），全屏顶栏里是同一枚 ——
原来的「列表 + 卡片导航」已经去掉：一排卡片和一枚下拉表达的是同一件事，而卡片还要额外占一行高度。

## 样本是怎么做出来的（这一版的核心）

不用 iframe，也不在真页面上反复切换。样本是**克隆 + 冻结计算样式**：

1. **捕获基线**：从**这条规则被打开的那一刻**起，把当时的样式表文本记下来。换规则、关面板时清掉。
2. **读「当前」的计算样式**：把用户表临时换成基线文本 → 读目标元素的 `getComputedStyle` → 换回现在的文本。
   这一步与编辑器每次保存等价（都是给同一个 `<style>` 换 `textContent`），没有额外代价。
3. **读「修改后」的计算样式**：表本来就是现在的文本，直接读。
4. **渲染**：把元素 `cloneNode(true)` 进 `.dshCc_sample`，把第 2/3 步读到的计算样式**写成内联**。
   内联优先级最高，因此样本只受那两份文本影响，不与页面里其他规则打架。

于是两块样本是**同一个元素的两份真实计算样式**，差异只来自「你打开之后改了什么」。

### 为什么左边是「当前」而不是「去掉这条规则」

两种口径都合理，选这一个是因为预览块要服务的是**编辑循环**：

| 口径 | 左边显示 | 适合回答 |
| --- | --- | --- |
| **当前**（本版） | 你打开这条规则时的文本 | 「我刚改的这一下干了什么」——调值时问的就是这个 |
| 去掉这条规则 | 整表减掉它 | 「这条规则本身有什么用」——读别人的样式表时更像这个 |

第二种以后可以作为对比模式里的一个切换，但**第一版不做**：两个口径同时出现，会让人分不清左右
到底在比什么。

### 为什么不用 iframe

两条实测过的理由，第二条是决定性的：

- `--dsw-*` 令牌定义在**宿主 `body`** 上（见 [已知坑](./known-issues.md)），iframe 里没有；
  要用就得把几百个令牌序列化进去。
- **`backdrop-filter` 在 iframe 里模糊的是 iframe 自己的内容。** 一个透明 iframe 里做毛玻璃，
  结果是「什么都模糊」= 一块干净的空盒子 —— 而这正是**预设**要展示的东西。用 iframe 做玻璃预览
  会得出与实际相反的结论。

### 保真度的诚实口径

| 真实 | 不真实 |
| --- | --- |
| 两块都是**真级联**读出来的计算样式（各自在「当前文本」与「现在的文本」下） | **冻结**：尺寸按读那一刻的 `rect` 锁死，改完之后的重排（比如被内容撑高）不在其中 |
| 继承来的字体、父级给的宽度、`!important` 与更高优先级规则的胜负 —— 都在计算样式里 | **交互态**：`:hover` / `:focus` / 伪元素（见下方状态芯片） |
| 元素真实的背景、边框、阴影（含 `backdrop-filter`，因为样本就在真页面上） | 样本与页面重叠处的**视觉关系**：它被摆在预览框里，不在元素原来的位置上 |

所以预览块底部固定一行小字：**「样本冻结了元素的计算样式；改完之后的重排与交互态不在其中」**。

## 状态芯片（已实现，0.10.0）

`:hover` / `:focus` / 占位符 / `::before` / `::after`。前三个**没法用冻结样式展示** ——
浏览器不让你读「悬停时」的计算样式（只有 DevTools 的 CDP `forcePseudoState` 能，页面里没这个权限）。
所以芯片走**真页面强制 + 再读**的路子：

1. 把当前规则选择器里的 `:hover` 改写成 `.dshCcStateHover`（`:focus` → `.dshCcStateFocus`、
   `:placeholder-shown` → `.dshCcStatePlaceholder`），连同规则正文写进一张临时表
   （`#dsh-custom-css-preview-force`，追加到 head 末尾 —— 平局时它要赢）。
2. 把那个类加到**正在预览的那个元素**上。于是真页面进入该状态。
3. 样本重画时读到的就是**被强制之后**的计算样式 —— 预览里是真的悬停效果，不是猜的。

后两个（`::before` / `::after`）**不动页面**：伪元素不是节点，克隆带不走它；能做的是读它的计算样式、
造一个 `span.dshCc_samplePseudo` 把样式抄上去、把 `content` 当文本放进去，插在克隆的首 / 尾。
`content: url(...)` 这类不是文本的情况不合成（芯片 tooltip 里说明了）。

代价写清楚：**前三个用的是真页面**，预览期间页面真的变了（只变这一条规则，且只在你勾选芯片时）。
所以芯片只在**单屏模式**出现，旁边挂一个显眼的「演示中」标记；关掉芯片、换规则、关面板、切对比 / 全屏、
行卸载时都会 `clearPreviewForcing()`（删表 + 摘类）—— 少一处就会在页面上留下一个没主的类。

**一条实现上的必要修正**：`button:hover` 这样的规则在静止页面上**一个元素都匹配不到**，而预览正是要
看它的效果。所以找「宿主元素」时用的是**去掉状态伪类的选择器**（`button:hover` → `button`），再由
强制态把它送回悬停的样子 —— 否则 `:hover` 规则会掉进「命中 0 个」的合成样本路径。

## 命中 0 个时（已实现，0.10.0）

用**合成样本**：把这条规则自己的声明拼成一个方框（有 `width` / `height` 就用，没有给 `240×120`），
里面写着「合成样本（不是页面上的元素）」—— **明确标注**是硬要求：否则用户会去页面上找那个其实不存在
的东西。对比模式下两块都用**同一个**合成样本，所以左右差异仍然只来自你的改动。

## 边界情况

| 情况 | 怎么办 |
| --- | --- |
| 打开之后还没改过 | 两块一样 → 左块位置显示「还没改动：改一个值，左右就会分开」（**已实现**） |
| 当前规则命中 0 个 | 合成样本 + 标注（**已实现**） |
| 同一选择器有多条规则 | 两份文本各自完整应用，所以「当前」与「修改后」的差别天然覆盖所有同选择器规则 |
| 规则写在伪元素上（`a::before`） | 预览宿主元素本身；`::before` / `::after` 芯片把伪元素**合成**到样本里（`content` 是文本时） |
| 规则在 `@media` / `@supports` 里 | 不显示（面板本来也编辑不到，见 [已知限制](./known-issues.md)） |
| 表在别处被改过（外部编辑） | 基线不动、只更新右侧 —— 并在标签上写「当前（打开时）」 |
| 属性面板里的值正在被编辑 | 预览跟随**已写盘的值**，不跟半输入的值（与 [规则面板](./rule-panel.md) 的校验口径一致） |
| 规则命中的元素**本身**在排除名单里（默认 `:root, html, body`） | 不当样本：改用合成样本，并在面板里说明「这个元素在排除名单里（…）」。**只看元素自己，不看祖先** —— 否则 `:root` 会命中页面上所有元素，每条规则都退化成合成样本（真机上量到过） |
| 规则会命中预览自己（`[class*='_tableRow']` 这类宽选择器） | 两块样本容器带 `data-dshcc-owned`，`queryOwned` 会把它们和里面**所有克隆**从匹配里剔掉 —— 否则样本一画出来，计数就自己被自己喂大（真机上就是「会命中预览框内部内容」） |
| 规则命中的是表格行 / 单元格（`tr` / `td` / `th`） | 克隆件补上 `table > tbody` 包装：直接塞进 `div` 的 `<tr>` 没有布局，样本会是一片空白 |
| 命中的是插件自己的控件 | 照常预览（那样才看得出你自己写的规则有没有打到自己），但预览里不响应交互 |

## 布局（v4）

一条工具条、一块提示（有话说才有）、一块画布、一行脚注；全屏是同一棵树的另一次摆放。

```
.dshCc_panel                       头 + 预览块固定，只有正文滚（max-height 520）
├── .dshCc_panelField             选择器输入 + 命中芯片 + ×
├── .dshCc_preview                预览块（PreviewBlock 组件）
│   ├── .dshCc_previewBar         28px 工具条：分段 / 芯片 / 命中 / 背景 / 适应 / 全屏
│   ├── .dshCc_previewHints       提示区（ui.status warn / ui.hint + ui.button）
│   ├── .dshCc_canvas             固定 200px；全屏 flex:1
│   │   ├── .dshCc_previewStage
│   │   │   └── .dshCc_previewBox           窗格 = 窗口（overflow:hidden，带描边与标签）
│   │   │       ├── .dshCc_previewLabel      「当前 / 修改后」
│   │   │       └── .dshCc_previewViewport   flex 居中，裁掉溢出
│   │   │           └── .dshCc_previewCurrent / .dshCc_previewAfter[data-pane]
│   │   └── .dshCc_canvasReset    归位（右下角 28px 图标钮，没拖过时禁用）
│   └── .dshCc_previewFoot        冻结口径那一行小字
└── .dshCc_panelBody              预设 / 声明 / 加属性 / 模板（自己滚）
```

**真机量到的数字**（1440×900，设置对话框里，`npm run probe -- --eval-file temp/preview.js`）：

| 量的是什么 | 值 |
| --- | --- |
| 面板 | 高 520（上限），`overflow-y: hidden` |
| 预览块 | 302（工具条 28 + 画布 200 + 脚注 + 内边距） |
| 工具条里的控件 | `dshUi_segments` / `dshUi_choiceSm` / `dshUi_buttonSm` 全部 28px |
| 正文 | 高 142，`overflow-y: auto`，内容 474 时自己滚 |

> 面板上限原按估算写成 420，量出来正文被压到 42px 并裁在可视区外 —— 现在 520 + 正文
> `min-height:84px`。**这些数字是量出来的，不是估的**，改布局时请重跑一次探针。

全屏：卡片 `min(1280px,96vw) × min(94vh,960px)`、内边距 20、间距 16；遮罩是毛玻璃
（`--dsw-alias-bg-overlay` 先混成 55% 透明，直接铺会盖死页面）；`::backdrop` 上做
`backdrop-filter: blur(18px) saturate(1.15)`；`prefers-reduced-transparency` 时退回不透明。

## 分阶段

| 阶段 | 内容 | 状态 |
| --- | --- | --- |
| 1 | 骨架 + 单屏 + 对比（基线语义） | 0.8.0 |
| 2 | 卡片导航 + 状态芯片 | 0.9.0 / 0.10.0 |
| 3 | [预设](./presets.md) | 0.7.0 |
| 4 | 全屏视图 | 0.10.0（0.12.0 重新设计） |
| 5 | **v4 重写**：组件化 + 控件全部来自 `ui.*` + 预览固定 + 限制拖动边界 + 焦点管理 | 0.13.0 |

## 令牌

全部取自 DSH 自身（见 [naming §G](./naming.md)）：描边 `--dsw-alias-border-l2`、
底 `--dsw-alias-bg-module-platform`、视口底 `--dsw-alias-bg-layer-1`、
标签与读数 `--dsw-alias-label-caption`、聚焦环 `--dsw-alias-brand-primary`、
选中卡的描边 `--dsw-alias-brand-primary`。

## 相关

- [预设](./presets.md) —— 规则面板上面那一排效果按钮
- [`docs/naming.md`](./naming.md) —— 这套名字的权威来源（D 节 / E 节 / F 节 / G 节）
- [规则面板](./rule-panel.md) —— 预览块下方那块
- [编辑器](./editor.md) —— 预览块上方那块
- [设计一致性](./design-tokens.md) —— 令牌阶梯与「别自创规格」


## 元素诊断（0.13.0）

预览着的那个元素会跑三个「真的会坏」的检查：毛玻璃多层叠加、opacity 把 backdrop-filter 废掉、
position:fixed 被带 transform 的祖先捕获。命中时在预览块的说明末尾多一行提示（.dshCc_previewDemo）。
