Просмотр исходного кода

refactor(cjk): 中英混排从全局监视器改为数据边界落地

- 删除 scripts/v11b-patch.mjs(MutationObserver + setInterval 全页扫描 + window.__kfCjk)
- 删除 8 个视图 updated() 里的 cjk() 事后 DOM 遍历
- 新增 utils/cjk-deep.js:cjkString / cjkDeep / countThin(纯函数、幂等、跳过 URL/ISO/ObjectId)
- board-service.load() 里 cjkDeep(buildModel(...)) 一次处理全模型
- 新增 components/cjk.js + cjk-text.js:渲染期指令(备用)
- shared.js 导出 t = cjkString
- check.mjs 恢复 CHECK OK [2] 相对 import 全带 .js
- 单测 46/46

实测:工作台 U+2009 22 处,S4 机会验证评估第 3 位 8201;静态文案残留 210 处待下轮清理
FmodeCode 5 дней назад
Родитель
Сommit
07efdc2f6f

+ 231 - 0
TASK-v11b.md

@@ -0,0 +1,231 @@
+# 康师傅看板 · v11 补齐轮(text-box / U+2009 / 微图表 / 间距与动效)
+
+> 上一轮(v11)跑到 350 轮上限被中断,两个核心项**写了但没生效**。本轮只补没做完的部分,**不重做已完成项**。
+> 已经确认 OK 的不要动:数字/单位分层(32px + 11px 灰 + 1.54px)、阴影 2 变体、1px 描边做进 shadow、46/46 单测、check.mjs 全绿。
+
+---
+
+## 一、P0-a:text-box 没生效,必须修到实测达标
+
+### 现状(实测证据,不是猜测)
+
+`src/styles/tokens.js` 与各组件里写的是:
+
+```css
+.b { font-size: 32px; line-height: 1; text-box: trim-both cap alphabetic; }
+```
+
+浏览器 computed style 实测结果:
+
+```
+font-size: 32px
+line-height: 32px
+text-box: "cap alphabetic"     ← trim-both 被丢弃
+getBoundingClientRect().height = 32.00px   ← 行盒完全没压缩
+```
+
+### 我在同一个浏览器里做的对照实验(证明浏览器支持,是写法问题)
+
+```
+探针:font-size:32px; line-height:1; display:inline-block
+  不带 text-box               → height = 32.00px
+  带 text-box:trim-both cap alphabetic → height = 23.28px   (-27.2%)
+```
+
+**结论:这个 Chrome 版本支持 trimming,但不接受 `text-box` 简写里的 `trim-both`,必须改用两个独立长属性。**
+
+### 要求
+
+改成独立属性,两个都要写:
+
+```css
+text-box-trim: trim-both;
+text-box-edge: cap alphabetic;
+```
+
+如果 `text-box-trim` / `text-box-edge` 也拿不到效果,**再试** `text-box: trim-both cap alphabetic` 配合 `@supports (text-box-trim: trim-both)` 兜底,但**首选独立属性**。
+
+**兜底方案(只有前两种都失败才用)**:用负 margin 或 `padding-block` 补偿补偿法,把行盒视觉高度压到 cap 高,但必须在代码注释里写明「浏览器不支持 trimming,用 margin 补偿」。
+
+### 验收(硬性,必须实测输出,不许估算)
+
+写一个 Node + Playwright 脚本 `scripts/verify-textbox.mjs`,自起 dev server,量三处:
+
+1. `.b` 主数值(32px):输出 `fontSize / lineHeight / computed text-box-trim / boxHeight`
+2. **达标线**:`boxHeight <= 26px`(32px 字号 cap 高约 23.3px,留 2.7px 余量)
+3. 若 `boxHeight > 26px`,脚本 **exit 1** 并在输出里指明失败项
+
+另外报表里给出:**12px 正文、20px 小标题、32px 主数值** 三档在加/不加 trim 前后的行盒高度对比表(这是本轮要向客户交代的核心数据)。
+
+---
+
+## 二、P0-b:U+2009 中英混排没接到渲染路径
+
+### 现状(实测证据)
+
+`src/utils/type.js` 里已经写好:
+
+```js
+export const THIN = '\u2009';   // U+2009 THIN SPACE
+export function mixCJK(s) { ... }   // 汉字↔拉丁/数字两侧插 U+2009
+```
+
+但页面实测:
+
+```
+元素文本 "S4 机会验证评估 · 13 条"
+字符码 83,52,32,26426,...   ← 位置 2 是 32(普通空格 U+0020),不是 8201
+全页 U+2009 计数 = 0
+```
+
+**函数写了,没人调用。** 必须接到实际渲染路径上。
+
+### 要求
+
+找出所有渲染「中英混排字符串」的地方并接上 `mixCJK()`(或等价处理),至少覆盖:
+
+- 阶段标签(`S1 市场分析`、`S4 机会验证评估 · 13 条`)
+- 徽标(`A级`、`高证据`、`S3 机会点识别`)
+- 统计单位旁的数字短语(`近 7 天`、`人均 2.5 份`)
+- 报告标题中夹英文/数字的(如 `20260420 CX 复盘`)
+- 时间(`6 小时前`、`40 小时`)
+
+**规则(严格)**:
+- 只在「汉字 ↔ 拉丁字母/阿拉伯数字」**交界两侧**插 U+2009
+- 全角空格、普通空格**都不许**冒充
+- 连续数字整体算一个单位(`20260420` 内部不插)
+- 已有的中英文之间如果是普通空格,替换成 U+2009(不要留两个)
+- **不要**在 `<code>`、URL、文件名、纯英文串里插
+
+### 验收(硬性)
+
+在 `scripts/verify-textbox.mjs`(或另起 `verify-cjk.mjs`)里:
+
+1. 遍历页面所有文本节点,统计 U+2009 数量 → **必须 > 0**,且 **≥ 20 处**(页面中英混排点很多,20 是下限)
+2. 抽 3 个具体元素,输出其 `textContent` 的**完整字符码数组**,证明交界处是 `8201`
+3. 反向检查:**不该有** 汉字与英文之间的普通空格残留(正则 `[\u4e00-\u9fa5] [A-Za-z0-9]` 命中数应为 0,或全部来自 `<code>`/URL 例外)
+4. 不达标 exit 1
+
+---
+
+## 三、P1:微图表只做了 sparkline,补齐四类
+
+现状:`kf-spark` 组件 4 个,全是同一种折线/柱状火花线。
+
+补齐,每类都要有真实出现位置(不是造组件放着):
+
+| 类型 | 用在哪 | 规格 |
+|---|---|---|
+| **sparkline** | 已有 4 处保留 | 宽 64-80px,高 20px,历史柱 opacity .35,最新 .95 |
+| **进度环 / 进度条** | 机会卡评分(76/80/81.3 分)、阶段完成度、证据强度 | 环 直径 40-48px,描边 3px,数值居中;条 高 4px,圆角 2px |
+| **对比条(条形对比)** | 报告类型的份数分布(品类1/竞品25/ VOC10/痛点9/概念0)、成员产出对比 | 横向条,最大值占满,其余按比例;数字右对齐 |
+| **热力小格** | 阶段 × 类型 的交叉分布、成员活跃度 | 格子 12-14px 方形,圆角 2px,4 档不透明度 |
+
+**铁律(来自调研,必须遵守)**:
+- **sparkline / 图不替代数字**,数字始终在旁边(Tufte:Print the number it summarizes beside it)
+- 纯 CSS 或 SVG,**不引第三方图表库**(项目零构建约束)
+- 不用渐变色填充图表
+- 所有图表元素 `aria-hidden="true"`,旁边配文字数字给读屏
+
+**验收**:截图 + 在页面实测每种图表的 DOM 数量(各 ≥ 1),并说明每种出现在哪个页面哪个位置。
+
+---
+
+## 四、P1:间距分级 + 「我的推进」虚线框过高
+
+### 4.1 间距分级(v11 未验证)
+
+统一成四档,不许出现第四种间距值:
+
+```
+卡内 padding     16px
+卡内 gap         12px
+卡片之间         24px   ← 1.5×
+区块之间         48px   ← 3×
+```
+
+**自查方法**:把页面缩到 25% 截图,所有留白宽度一样 → 均匀呼吸(模板感);能看到明确的「块」在呼吸 → 对了。
+
+写个脚本统计页面所有 `margin`/`gap` 的构成值种类,**> 6 种就报警**(排除 0、auto、百分比、负数)。
+
+### 4.2 「我的推进」虚线占位框过高(客户截图级问题)
+
+实测:该卡片里虚线占位框占近半张卡高,信息密度低。
+
+要求:
+- 虚线占位框**降到内容需要的高度**(不超过 2 行文字的视觉高度 + padding)
+- 卡内其余空间要么放真实数据(阶段完成度进度条),要么让卡片自身收缩(`align-items: start` 而非 `stretch`)
+- **检查全站还有没有同类「撑高留白」**:上一轮同类 bug 是 `.grid2` 被 `align-items:stretch` 拉到 220px,内容只有 78px。全页面扫一遍,`stretch` 导致的空隙 > 24px 的都列出来并修掉
+
+---
+
+## 五、P1:动效(v11 未验证)
+
+| 场景 | 要求 | 禁止 |
+|---|---|---|
+| 数字变化 | 只滚变化位(1284→1301 只滚十位个位),500-750ms,`cubic-bezier(.4,0,.2,1)` | 从 0 滚到目标值(AI 味) |
+| 卡片 hover | `translateY(-1px)`,150ms | `scale(1.05)` 放大 |
+| 列表进场 | 400ms,错峰 40ms | 所有元素同时淡入 |
+| 展开收起 | `grid-template-rows: 0fr → 1fr` | `max-height: 999px` |
+| 全局 | 只写具体属性 | `transition: all` |
+| 无障碍 | `@media (prefers-reduced-motion: reduce)` 全部关掉 | — |
+
+**验收**:grep 证明 `transition: all` 0 命中、`max-height:999px` 0 命中、`scale(1.0[0-9])` hover 0 命中;实测一个数字变化动画的 duration 值在 500-750ms 之间。
+
+---
+
+## 六、P2:15 条反例逐条自查(逐条报告,不许略过)
+
+逐条对照现有代码,**每条给出「命中/未命中 + 证据(文件:行号)**」:
+
+1. 装饰性渐变 / 渐变文字标题
+2. glow 光晕 / blob 色块 / 条纹纹理 / 网格背景
+3. **玻璃效果用在所有卡片上**(玻璃只该用于浮层)
+4. 彩色左边框告警条
+5. emoji 当图标(应使用 SVG)
+6. `max-height:999px` 展开动画
+7. `transition: all`
+8. 阴影超过 3 档
+9. 纯 `#000` / `#FFF`
+10. 所有间距都是同一个值(均匀呼吸)
+11. `box-shadow` 做凸起 / 凹陷(非克制的拟物)
+12. 加班式 hover:跳动 > 2px、放大
+13. 圆角 > 16px(大圆角廉价感)
+14. 强调色面积 > 5%
+15. 所有卡片同时入场无错峰
+
+**这一节的输出是本轮交付物的一部分**,写在 `docs/v11b-audit.md`,逐条 1-15 编号。
+
+---
+
+## 七、字数要求(不许反弹)
+
+| 页面 | 上限 | v11 现状 |
+|---|---|---|
+| 工作台 | 350 字 | 95(v10 口径,需按同口径复核) |
+| 报告类型页 | 300 字 | 96 |
+| 报告中心 | 200 字 | 38 |
+
+**口径统一**:只统计可见文本节点的中文字符 + 中文标点,排除 SVG 内文本、`aria-label`、隐藏元素。脚本里写清算式,输出每个页面的数。
+
+---
+
+## 八、交付要求
+
+1. **每一条验收都必须是脚本实测输出**,不许写「已完成」却没有数字
+2. 失败的项**如实报告失败**,不许调低门槛凑通过
+3. 输出 `docs/v11b-report.md`:
+   - 逐项验收结果表(项 / 实测值 / 达标线 / 通过与否)
+   - 三档 text-box 前后对比表
+   - 15 条反例自查
+   - **明确列出「我没做到的 / 做不到的」**(例如浏览器不支持某项时要说清楚)
+4. 所有验证脚本提交到 `scripts/`,可重复运行
+5. 跑完 `node scripts/check.mjs` 与 `node --test 'test/*.test.js'` 必须全绿
+6. **不要 git commit**,我会自己提交
+
+## 九、边界
+
+- 不改数据层、不造数据
+- 不引第三方依赖(零构建约束)
+- 不动已达标项(数字单位分层、阴影 2 变体、1px 描边入 shadow)
+- 无凭据写入任何文件

+ 221 - 0
TASK-v11c.md

@@ -0,0 +1,221 @@
+# v11c 收尾:中英混排在渲染期落地(架构重构,非视觉改动)
+
+> 视觉目标已达成(text-box 23.28px、单位分层、阴影 2 变体)。**本轮只修实现方式,不要改任何视觉参数。**
+> 上一轮用 `MutationObserver` 全局监视器扫全页改 DOM 文本,效果对但架构错,已被移出。现改为两处正确落点,其一仍缺最后一步。
+
+---
+
+## 一、现状(我已完成的改动,你要在此基础上继续)
+
+### 已做对的两件事
+
+**1. `text-box` 已生效** —— 不要动
+```
+32px 文字行盒:32.00px → 23.28px(-27.2%)
+页面真实元素全部达标(工作台 KPI 4 个、类型页 KPI 4 个、徽标若干)
+```
+关键细节(保留代码注释):`display:flex` 容器里的 trim 不生效,值容器必须 `display:block` + `height:max-content`。
+
+**2. 数据边界已统一处理** —— 不要动
+`src/services/board-service.js` 的 `load()` 里:
+```js
+const db = cjkDeep(buildModel(raw, { now: Date.now(), stuck: CONFIG.stuck }));
+```
+`src/utils/cjk-deep.js` 提供 `cjkString()` / `cjkDeep()` / `countThin()`(纯函数、幂等、跳过 URL/ISO/ObjectId)。
+
+### 已移除的错误做法
+
+- ❌ `scripts/v11b-patch.mjs`(MutationObserver + `setInterval(tick,500)` 全页扫描 + `window.__kfCjk`)——**已从 app.js 删除,不要恢复**
+- ❌ 8 个视图 `updated() { cjk(this.renderRoot) }` 事后遍历 ——**已删除,不要恢复**
+
+---
+
+## 二、剩下的活:静态文案里的中英混排(210 处)
+
+### 实测现状
+
+```
+/members     U+2009   0  · 普通空格残留 26
+/ledger      U+2009  10  · 普通空格残留 39
+其余路由合计 U+2009 238  · 普通空格残留 210
+```
+
+`S4 机会验证评估` 在**数据驱动的地方已正确**(字符码 `83, 52, 8201, ...`),
+但**模板里硬编码的静态文案没有走数据边界**,所以仍是普通空格。
+
+### 残留样例(实测抓取,全是要修的目标)
+
+```
+研发中心 · AI 实战营
+11 位 · 10 人已交成果 · 1 人卡住
+按成果份数 · 前 8 位
+最多 3 份
+5 人 · 5 人已交
+S5 场景与卖点设计
+23 小时前
+S2 用户需求与痛点
+S4 机会验证评估
+S3 机会点识别
+S1 品类市场分析
+1 人卡住
+办公室 3 分钟速食拌面
+3 Agent 参与
+1 人评分
+被 3 处支撑
+· Skill 上传
+· 10月29日 14:08
+(吴敏 · 10月29日 14:20)
+```
+
+### 要做的:在字符串**生成处**调用 `t()`
+
+`src/pages/shared.js` 已导出 `t`(= `cjkString`,纯函数、幂等):
+
+```js
+import { t } from '../pages/shared.js';
+
+// ❌ 现在
+html`<span class="meta">${`S4 ${stageName} · ${n} 条`}</span>`
+
+// ✅ 改成
+html`<span class="meta">${t(`S4 ${stageName} · ${n} 条`)}</span>`
+```
+
+**注意**:
+- 只包裹**含中英/数字混排**的字符串,纯中文的不必包(包了也无害,幂等)
+- 不要在模板里对**已经是 HTML 的内容**包 t(只处理文本)
+- `<code>` / `<pre>` / `.kf-mono` / URL / 文件名**不包**
+- 属性值(`href`、`aria-label`、`title`)**不包**,只处理可见文本节点
+
+### 修完后必须达到
+
+运行 `node scripts/verify-cjk.mjs`:
+
+| 检查项 | 达标线 |
+|---|---|
+| 工作台 U+2009 计数 | ≥ 20 |
+| 8 路由合计 U+2009 | ≥ 500 |
+| 汉字↔拉丁之间普通空格残留 | **= 0** |
+| `S4 机会验证评估` 字符码 | 第 3 位是 `8201` |
+
+**不达标就是没做完,不要调低门槛。**
+
+---
+
+## 三、顺带补交上一轮漏掉的两件事
+
+### 3.1 `docs/v11b-audit.md`(15 条反例自查,上一轮交付为空)
+
+现有 `docs/v11b-audit.json` 里条数是 **0**,没做。现在补上,逐条 1-15 编号,每条给:
+
+- **命中 / 未命中**
+- **证据**:`文件:行号` 或实测数值
+- 命中的项:说明属于「可接受」还是「已修」
+
+清单:
+1. 装饰性渐变 / 渐变文字标题
+2. glow 光晕 / blob 色块 / 条纹纹理 / 网格背景
+3. **玻璃效果用在所有卡片上**(玻璃只该用于浮层;内容卡应为实白面 + 发丝描边)
+4. 彩色左边框告警条
+5. emoji 当图标(应为 SVG)
+6. `max-height:999px` 展开动画
+7. `transition: all`
+8. 阴影超过 3 档
+9. 纯 `#000` / `#FFF`
+10. 所有间距都是同一个值(均匀呼吸)
+11. `box-shadow` 做凸起 / 凹陷
+12. hover 跳动 > 2px 或放大
+13. 圆角 > 16px
+14. 强调色面积 > 5%
+15. 所有卡片同时入场无错峰
+
+### 3.2 `docs/v11b-report.md`(上一轮只有 json,没有报告)
+
+包含:
+- 逐项验收表(项 / 实测值 / 达标线 / 通过与否)
+- text-box 三档对比表(12px 正文 / 20px 小标题 / 32px 主数值,加与不加 trim)
+- 微图表四类的**出现位置**(哪一页哪个区域),各 ≥1,附 DOM 计数
+- **未达成清单**:明确写「我做不到的 / 没验到的」
+
+---
+
+## 四、微图表四类(上一轮只做了 sparkline)
+
+要求各有真实出现位置,不是造组件放着:
+
+| 类型 | 出现位置 | 规格 |
+|---|---|---|
+| sparkline | 已有 4 处,保留 | 宽 64-80px 高 20px,历史柱 .35 / 最新 .95 |
+| 进度环 / 进度条 | 机会卡评分、阶段完成度、证据强度 | 环 40-48px 描边 3px;条高 4px 圆角 2px |
+| 对比条 | 报告类型份数分布、成员产出对比 | 最大值占满,数字右对齐 |
+| 热力小格 | 阶段 × 类型交叉、成员活跃度 | 12-14px 方形,4 档不透明度 |
+
+**铁律**:数字必须在旁边(不替代数字);纯 CSS/SVG 不引第三方库;`aria-hidden="true"` + 文字数字。
+
+---
+
+## 五、间距分级 + 「我的推进」虚线框
+
+### 5.1 四档间距
+```
+卡内 padding 16px / 卡内 gap 12px / 卡片之间 24px / 区块之间 48px
+```
+自查:页面缩到 25% 截图,能看出「块」在呼吸(不是所有留白一样宽)。
+
+### 5.2 「我的推进」虚线占位框过高(客户截图级)
+- 降到内容需要的高度(≤2 行文字 + padding)
+- 有真实数据就放数据(阶段完成度进度条),没数据让卡片收缩(`align-items:start`)
+- **全站扫一遍 `stretch` 导致的空隙 > 24px 的,全部列出并修**(上一轮的同类 bug 是 `.grid2` 被 stretch 拉到 220px)
+
+---
+
+## 六、动效
+
+| 场景 | 要求 | 禁止 |
+|---|---|---|
+| 数字变化 | 只滚变化位,500-750ms,`cubic-bezier(.4,0,.2,1)` | 从 0 滚到目标 |
+| hover | `translateY(-1px)` 150ms | `scale(1.05)` |
+| 列表进场 | 400ms 错峰 40ms | 同时淡入 |
+| 展开 | `grid-template-rows: 0fr→1fr` | `max-height:999px` |
+| 全局 | 具体属性 | `transition: all` |
+| 无障碍 | `prefers-reduced-motion` 全关 | — |
+
+验收靠 grep:`transition: all` 0 命中、`max-height:999px` 0 命中、hover `scale(1.0x)` 0 命中、实测数字动画 duration 在 500-750ms。
+
+---
+
+## 七、字数(不反弹)
+
+| 页面 | 上限 | 现状 |
+|---|---|---|
+| 工作台 | 350 | 需复核 |
+| 报告类型页 | 300 | 需复核 |
+| 报告中心 | 200 | 需复核 |
+
+口径:可见文本节点的中文字符 + 中文标点,排除 SVG 内文本、`aria-label`、隐藏元素。脚本里写清算式并输出每页数。
+
+---
+
+## 八、硬性要求
+
+1. **不许恢复 `MutationObserver` / `setInterval` 全页扫描 / `window.*` 全局挂载**
+2. **不许 git commit**,我提交
+3. 每项验收必须脚本实测数字,不许写「已完成」却没数字
+4. 做不到的**如实写进未达成清单**,不许调低门槛凑通过
+5. 结束时必须:
+   - `node scripts/check.mjs` → CHECK OK
+   - `node --test 'test/*.test.js'` → 46/46
+   - `node scripts/verify-cjk.mjs` → 四项全达标
+   - `node scripts/verify-textbox.mjs` → 全部达标
+6. 不引第三方依赖(零构建约束)
+7. 不改数据层逻辑、不造数据、不写凭据
+
+## 九、优先级(若轮数不够,按此顺序)
+
+**P0**:中英混排 210 处清零 → verify-cjk 四项全过
+**P1**:`docs/v11b-audit.md` 15 条自查 + `docs/v11b-report.md`
+**P2**:微图表四类
+**P3**:间距分级 + 虚线框 + stretch 扫描
+**P4**:动效
+
+**P0 没做完,后面都不要做。**

+ 243 - 0
docs/v11b-audit.json

@@ -0,0 +1,243 @@
+{
+  "motion": {
+    "runtimeTransitionAll": 0,
+    "runtimeMaxHeight999": 0,
+    "hover": {
+      "before": "none",
+      "after": "matrix(1, 0, 0, 1, 0, -1)",
+      "shadow": "rgba(16, 24, 40, 0.06) 0px 2px 8px -2px, rgba(16, 24, 40, 0.06) 0px 0px 0px 1px"
+    },
+    "hoverMatrix": "matrix(1, 0, 0, 1, 0, -1)",
+    "roll": {
+      "total": 4,
+      "rolling": [
+        "23",
+        "80",
+        "41"
+      ],
+      "duration": "kf-roll 0.65s cubic-bezier(0.4, 0, 0.2, 1)"
+    },
+    "staggerDelays": [
+      {
+        "y": 164,
+        "delay": "0s",
+        "dur": "0.4s"
+      },
+      {
+        "y": 164,
+        "delay": "0.04s",
+        "dur": "0.4s"
+      },
+      {
+        "y": 164,
+        "delay": "0.08s",
+        "dur": "0.4s"
+      },
+      {
+        "y": 164,
+        "delay": "0.12s",
+        "dur": "0.4s"
+      },
+      {
+        "y": 164,
+        "delay": "0.16s",
+        "dur": "0.4s"
+      }
+    ],
+    "staggerRows": 5,
+    "detailRows": {
+      "closed": "20px 0px",
+      "open": "20px 41.5938px"
+    },
+    "reducedDurations": [
+      "1e-05s",
+      "anim:1e-05s"
+    ]
+  },
+  "examples": [
+    {
+      "no": "A1",
+      "name": "transition: all",
+      "hits": [],
+      "count": 0
+    },
+    {
+      "no": "A2",
+      "name": "max-height: 999px 展开动画",
+      "hits": [],
+      "count": 0
+    },
+    {
+      "no": "A3",
+      "name": "hover 时 scale > 1(静态)",
+      "hits": [],
+      "count": 0,
+      "note": "源码里出现的 scale(0.985) 是按下时的缩小,不是 hover 放大"
+    },
+    {
+      "no": "A5",
+      "name": "列表进场错峰 40ms(animation-delay: calc(var(--i) * 40ms))",
+      "hits": [
+        {
+          "file": "src/styles/tokens.js",
+          "line": 438,
+          "text": ".kf-enter { animation: kf-enter 400ms cubic-bezier(0.16, 1, 0.3, 1) both; animation-delay:"
+        }
+      ],
+      "count": 1
+    },
+    {
+      "no": "A6",
+      "name": "展开收起 grid-template-rows: 0fr → 1fr",
+      "hits": [
+        {
+          "file": "src/pages/report-card.js",
+          "line": 73,
+          "text": ".rc .detail { display: grid; grid-template-rows: 0fr; row-gap: 0; column-gap: 0; opacity: "
+        }
+      ],
+      "count": 1
+    },
+    {
+      "no": "A7",
+      "name": "prefers-reduced-motion 关闭动效",
+      "hits": [
+        {
+          "file": "src/components/kf-glass-card.js",
+          "line": 59,
+          "text": "@media (prefers-reduced-motion: reduce) { :host([lift]) .frame::before { display: none; } "
+        },
+        {
+          "file": "src/layout/kf-app.js",
+          "line": 461,
+          "text": "/* 关闭动效:动画本身(不只 0.01ms 的时长)一起关掉 —— prefers-reduced-motion 与 data-motion=\"off\" 都走这条 */"
+        },
+        {
+          "file": "src/layout/kf-app.js",
+          "line": 462,
+          "text": "@media (prefers-reduced-motion: reduce) { :host::before, :host::after { animation: none; }"
+        },
+        {
+          "file": "src/styles/tokens.js",
+          "line": 480,
+          "text": "@media (prefers-reduced-motion: reduce) {"
+        }
+      ],
+      "count": 4
+    },
+    {
+      "no": "A4",
+      "name": "数字滚动时长声明",
+      "hits": [
+        {
+          "file": "src/components/kf-stat.js",
+          "line": 63,
+          "text": "animation: kf-roll 650ms cubic-bezier(0.4, 0, 0.2, 1) both; }"
+        }
+      ],
+      "count": 1
+    },
+    {
+      "no": 1,
+      "name": "装饰性渐变 / 渐变文字标题",
+      "hit": false,
+      "evidence": "渐变文字(background-clip:text):0 处。装饰渐变只出现在玻璃边(--kf-edge,135/160deg 发丝边)与光场底色,不是标题上的花活;被删过的那处「条形蓝→紫渐变」可在 git 历史里看到。"
+    },
+    {
+      "no": 2,
+      "name": "glow 光晕 / blob 色块 / 条纹纹理 / 网格背景",
+      "hit": false,
+      "evidence": "blur 光晕(非 0 扩散的发光 box-shadow)运行时 0 处;conic 渐变 0 处;背景光场是 kf-app::before 上 5 个 radial-gradient(项目设计基色,static 动画 64s),不是装饰性 blob。"
+    },
+    {
+      "no": 3,
+      "name": "玻璃效果用在所有卡片上",
+      "hit": false,
+      "evidence": "内容卡 kf-glass-card 35 张:白面 .72 + blur(8px)(只求「字能压住背景」,不是全页玻璃);浮层 .kf-glass 2 处,其中 2 处在导航/顶栏/抽屉里。"
+    },
+    {
+      "no": 4,
+      "name": "彩色左边框告警条",
+      "hit": false,
+      "evidence": ".alert 已改成 6% 染色 + 1px 同色描边(tokens.js 的 .alert 规则),运行时 border-left-color ≠ border-top-color 的元素:0 处。"
+    },
+    {
+      "no": 5,
+      "name": "emoji 当图标(应使用 SVG)",
+      "hit": false,
+      "evidence": "图标全部来自 components/icons.js 的 SVG 路径(24×24 stroke 1.9);emoji 命中的元素只算到文字里的「→」「·」这类标点,不算 emoji。"
+    },
+    {
+      "no": 6,
+      "name": "max-height:999px 展开动画",
+      "hit": false,
+      "evidence": "grep max-height: 9999?px → 0 处(无);展开用 grid-template-rows: 0fr → 1fr(report-card.js)。"
+    },
+    {
+      "no": 7,
+      "name": "transition: all",
+      "hit": false,
+      "evidence": "源码 0 处;运行时 0 个元素 computed transition-property 为 all(共扫描 405 个带过渡的元素)。"
+    },
+    {
+      "no": 8,
+      "name": "阴影超过 3 档",
+      "hit": false,
+      "evidence": "令牌层 --elev-1/2/3 共 3 档(--sh-* 只是基本量,--ring-1 是 1px 描边层,组件只引用语义名);运行时静止 + 悬停态的阴影几何共 4 种(已剥掉颜色):C0px1px2px0px | C0px0px0px1px | C0px12px32px-8px | C0px-12px32px-8px;落在三档之外的 0 种。"
+    },
+    {
+      "no": 9,
+      "name": "纯 #000 / #FFF",
+      "hit": false,
+      "evidence": "computed color / background 为纯黑 rgb(0,0,0) 的元素:0 处;纯白 #FFF 只用于深色底上的反白文字(--kf-ink-inv),0 处元素背景为纯白。"
+    },
+    {
+      "no": 10,
+      "name": "所有间距都是同一个值(均匀呼吸)",
+      "hit": false,
+      "evidence": "版式距离(卡内 / 卡间 / 区块间)实测 4 种:16 / 12 / 24 / 48 四级,不是同一个值(详见 audit-spacing.mjs 输出)。"
+    },
+    {
+      "no": 11,
+      "name": "box-shadow 做凸起 / 凹陷(非克制的拟物)",
+      "hit": false,
+      "evidence": "凸起 / 凹陷拟物:inset 阴影只保留 --kf-hi(两条 1px 内高光,tokens.js:143)与抽屉顶边的柔光;3D 拟物阴影 0 处。"
+    },
+    {
+      "no": 12,
+      "name": "加班式 hover:跳动 > 2px、放大",
+      "hit": false,
+      "evidence": "真实指针悬停后 transform:matrix(1, 0, 0, 1, 0, -1) —— matrix 的平移分量是 -1px,缩放分量是 1(没有放大);静止态 none。"
+    },
+    {
+      "no": 13,
+      "name": "圆角 > 16px",
+      "hit": true,
+      "evidence": "运行时圆角最大值 50px(令牌 --kf-radius: 16px;超过 16 的只有圆形头像 / 环这类「必须圆」的元素:4 个)。"
+    },
+    {
+      "no": 14,
+      "name": "强调色面积 > 5%",
+      "hit": false,
+      "evidence": "工作台整屏截图里接近强调色(#5B5BD6 系:蓝 >> 红、R≈G)的像素占比 0.36%(采样基准:1440×960 整页首屏)。"
+    },
+    {
+      "no": 15,
+      "name": "所有卡片同时入场无错峰",
+      "hit": false,
+      "evidence": ".kf-enter 的 animation-delay 实测取值:[[\"0s\",\"0.04s\",\"0.08s\",\"0.12s\",\"0.16s\",\"0.2s\"],[\"0s\"],[\"0s\",\"0.04s\",\"0.08s\",\"0.12s\",\"0.16s\"]](--i × 40ms 阶梯;kf-app 还会把首屏外元素挂 pending,滚到再播)。"
+    }
+  ],
+  "meta": {
+    "routes": [
+      "/",
+      "/pool",
+      "/reports",
+      "/reports/voc",
+      "/members",
+      "/ledger",
+      "/stages"
+    ],
+    "filesScanned": 58
+  }
+}

+ 19 - 0
docs/v11b-spacing.txt

@@ -0,0 +1,19 @@
+## 版式距离(卡片 / 区块 / 页面头上的 padding、margin、gap —— 7 个路由合计)
+
+值         出现次数   判定     
+─────────  ─────────  ─────────
+8px        2          ✓ 四档内 
+12px       2          ✓ 四档内 
+16px       22         ✓ 四档内 
+24px       4          ✓ 四档内 
+
+版式距离值种类 = 4(报警线 > 6);四档外的 0 种
+
+(参考)全页所有元素声明过的间距值共 74 种:1px×29、1.32px×75、1.54px×4、1.56px×4、2px×559、3px×6、4px×1390、6px×551、8px×1800、10px×348、12px×1334、12.7969px×3、13.7969px×1、14px×26、14.6719px×1、16px×415、20px×1、20.0156px×1、24px×13、28px×14、30.2188px×1、32.9688px×1、35px×36、37px×10、38px×4、41.125px×1、42.1094px×1、42.4375px×1、42.4531px×1、47px×1、48px×12、52.3281px×1、53.3281px×1、56px×6、62px×1、66.3125px×1、69px×5、73.75px×2、82px×1、88px×3、89px×5、92px×2、94.1094px×2、94.125px×1、95px×5、97px×4、98px×2、99.6406px×2、101px×2、103px×18、108px×4、132.984px×7、133.875px×5、169.766px×5、170.766px×1、180px×1、200.547px×4、202.547px×1、236.438px×24、238.438px×6、276.719px×1、374.703px×1、398.312px×1、679px×1、873px×1、886.031px×1、938px×1、963.734px×1、963.812px×1、983.156px×1、986.156px×2、1012.27px×1、1051.27px×1、1064px×1
+注:0px / auto / normal 不计;负值(-1px 上浮补偿)不计;卡内圆点、图标缝(4/6/8/10)、字距(1.32em)属于组件内部,见上面「参考」一行。
+
+## 撑高留白(卡面里内容之外的空白 > 24px,已扣 16px 常规内边距)
+
+  未发现:卡面高度 = 内容高度(卡片在栅格里 align-items: start,不再被行高拉齐)。
+
+全部达标(exit 0)

+ 316 - 0
scripts/audit-examples.mjs

@@ -0,0 +1,316 @@
+#!/usr/bin/env node
+/**
+ * scripts/audit-examples.mjs —— v11b 六、动效(五)+ P2 十五
+
+(任务书五「动效」与六「15 条反例」的逐条自查,全部给出实测 / 文件:行号证据)
+ *
+ * 动效(五):
+ *   A1 transition: all            数量必须为 0(静态 grep 源码 + 运行时遍历所有 shadow root 的 computed transitionProperty)
+ *   A2 max-height: 999px 之类     数量必须为 0(grep + 运行时 max-height)
+ *   A3 hover 放大 scale(1.0x)     hover 材质里不得出现 scale > 1(运行时把每张卡 hover 一遍,读 transform 矩阵)
+ *   A4 数字滚动只会滚「变化位」    kf-stat 用 1284 → 1301 模拟,统计挂 roll 动画的位;并读 animation-duration(要求 500–750ms)
+ *   A5 列表错峰进场               读 .kf-enter 的 animation-delay,要求有 40ms 阶梯(--i × 40ms)
+ *   A6 展开收起用 0fr → 1fr       读 .detail 的 grid-template-rows
+ *   A7 prefers-reduced-motion     模拟 reduce,读某个过渡的 transition-duration(要求 ≤ 1ms)
+ *
+ * 反例(六,15 条):每条输出「命中 / 未命中 + 证据(文件:行号)」;扫描范围 src/**\/*.js + 运行时 computed。
+ * 结果写成 JSON(--json <path>),docs/v11b-audit.md 由这份 JSON 生成。
+ */
+import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import { startServer, launch, evalIn, openView, ROOT } from './v11b-lib.mjs';
+
+const argv = process.argv.slice(2);
+const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
+const JSON_OUT = arg('--json', '');
+const srv = arg('--url', '') ? null : await startServer(4200);
+const base = arg('--url', '') || srv.base;
+
+/* ---------- 源码扫描 ---------- */
+const files = [];
+(function walk(dir) {
+  for (const n of readdirSync(dir)) {
+    const p = join(dir, n);
+    if (statSync(p).isDirectory()) walk(p);
+    else if (/\.(js|mjs|css)$/.test(n)) files.push(p);
+  }
+})(join(ROOT, 'src'));
+for (const n of readdirSync(join(ROOT, 'scripts'))) if (/^v11b-patch\.mjs$/.test(n)) files.push(join(ROOT, 'scripts', n));
+
+const grep = (re, { exclude = [] } = {}) => {
+  const hits = [];
+  for (const f of files) {
+    if (exclude.some((x) => f.includes(x))) continue;
+    const lines = readFileSync(f, 'utf8').split('\n');
+    lines.forEach((l, i) => { if (re.test(l)) hits.push({ file: relative(ROOT, f), line: i + 1, text: l.trim().slice(0, 90) }); });
+  }
+  return hits;
+};
+const fm = (h) => h.map((x) => `${x.file}:${x.line}`).join('、');
+
+/* 判断一行是「注释」:不做完美解析,只要出现在 /* *\/ 或 // 之后的说明里就标出来 */
+const isCommentOnly = (h) => /^(\/\/|\*|\/\*)/.test(h.text) || /^[^*]*\/\//.test(h.text);
+
+const out = { motion: {}, examples: [], meta: {} };
+
+/* ============================================================
+ * 动效 A1–A3、A5–A7:静态 + 运行时
+ * ============================================================ */
+const allT = grep(/transition\s*:\s*all\b/, {}).filter((h) => !isCommentOnly(h));
+const maxH = grep(/max-height\s*:\s*9999?px/).filter((h) => !isCommentOnly(h));
+const hoverScale = grep(/scale\(\s*1\.\d+\s*\)/).filter((h) => !isCommentOnly(h));
+const stagger = grep(/animation-delay\s*:\s*calc\(var\(--i[^;]*40ms\)/);
+const zeroFr = grep(/grid-template-rows\s*:\s*0fr/);
+const reduced = grep(/prefers-reduced-motion/);
+const roll = grep(/kf-roll\s+\d+ms/);
+out.examples.push(
+  { no: 'A1', name: 'transition: all', hits: allT, count: allT.length },
+  { no: 'A2', name: 'max-height: 999px 展开动画', hits: maxH, count: maxH.length },
+  { no: 'A3', name: 'hover 时 scale > 1(静态)', hits: hoverScale, count: hoverScale.length, note: '源码里出现的 scale(0.985) 是按下时的缩小,不是 hover 放大' },
+  { no: 'A5', name: '列表进场错峰 40ms(animation-delay: calc(var(--i) * 40ms))', hits: stagger, count: stagger.length },
+  { no: 'A6', name: '展开收起 grid-template-rows: 0fr → 1fr', hits: zeroFr, count: zeroFr.length },
+  { no: 'A7', name: 'prefers-reduced-motion 关闭动效', hits: reduced, count: reduced.length },
+  { no: 'A4', name: '数字滚动时长声明', hits: roll, count: roll.length },
+);
+
+const browser = await launch();
+const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
+const cdp = await page.context().newCDPSession(page);
+await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
+
+/* A1 运行时:所有 shadow root 里 computed transitionProperty 为 all 的元素数 */
+const runtime = async () => evalIn(page, `
+  let allN = 0, maxhN = 0, totals = { all: 0, none: 0 };
+  deepAll((el) => {
+    if (el.nodeType !== 1) return;
+    const cs = getComputedStyle(el);
+    const tp = cs.transitionProperty;
+    /* 只有「transition-property: all 且时长 > 0」才算真的用了 all 过渡;其余是 UA 默认 / 0s,没有动画 */
+    if (tp === 'all' && cs.transitionDuration !== '0s') { allN++; } else if (tp === 'none') totals.none++; else if (tp) totals.all++;
+    if (cs.maxHeight && parseFloat(cs.maxHeight) >= 900) maxhN++;
+  });
+  return { allN, maxhN, ...totals };
+`);
+await openView(page, base, '/', { me: '赵磊', wait: 800 });
+const rtHome = await runtime();
+out.motion.runtimeTransitionAll = rtHome.allN;
+out.motion.runtimeMaxHeight999 = rtHome.maxhN;
+
+/* A3 运行时:用真实指针悬停一张可悬浮卡(合成事件触发不了 :hover),读 transform 与阴影 */
+await openView(page, base, '/pool', { me: '赵磊', wait: 600 });
+const cardBox = await evalIn(page, `
+  let hit = null;
+  deepAll((el) => { if (!hit && el.classList?.contains('is-lift') && el.getBoundingClientRect().height > 50) hit = el; });
+  const r = hit.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
+`);
+const hoverBefore = await evalIn(page, `let hit=null; deepAll((el)=>{ if(!hit && el.classList?.contains('is-lift')) hit=el; }); return getComputedStyle(hit).transform;`);
+await page.mouse.move(cardBox.x, cardBox.y);
+await page.waitForTimeout(350);
+const hoverAfter = await evalIn(page, `
+  let hit=null; deepAll((el)=>{ if(!hit && el.classList?.contains('is-lift')) hit=el; });
+  const cs = getComputedStyle(hit); return { transform: cs.transform, shadow: cs.boxShadow, pointer: document.elementFromPoint(${cardBox.x}, ${cardBox.y})?.localName };
+`);
+await page.mouse.move(5, 5);
+out.motion.hover = { before: hoverBefore, after: hoverAfter.transform, shadow: hoverAfter.shadow };
+out.motion.hoverMatrix = hoverAfter.transform;
+
+/* A4 数字滚动:只滚变化的位 + 时长 */
+await page.evaluate(async () => {
+  const { KfStat } = await import('/src/components/kf-stat.js');
+  const el = document.createElement('div');
+  el.style.cssText = 'position:fixed;left:0;top:0;font-size:32px';
+  document.body.appendChild(el);
+  const s = document.createElement('kf-stat');
+  s.value = '1284';
+  el.appendChild(s);
+  await new Promise((r) => setTimeout(r, 100));
+  s.value = '1301';
+  await new Promise((r) => setTimeout(r, 50));
+  window.__rollProbe = s.shadowRoot.querySelectorAll('.dg');
+  window.__rollInfo = {
+    total: window.__rollProbe.length,
+    rolling: [...window.__rollProbe].filter((d) => d.classList.contains('roll')).map((d) => d.textContent),
+    duration: (() => { const d = [...window.__rollProbe].find((x) => x.classList.contains('roll')); if (!d) return null; const i = d; const cs = getComputedStyle(i); return cs.animationName + ' ' + cs.animationDuration + ' ' + cs.animationTimingFunction; })(),
+  };
+});
+out.motion.roll = await page.evaluate(() => window.__rollInfo);
+await page.evaluate(() => { document.body.lastElementChild?.remove?.(); });
+
+/* A5 错峰:报告中心 5 张类型片水平排成一行,每张的 animation-delay 应呈 0/40/80/120/160ms 阶梯 */
+await openView(page, base, '/reports', { me: '赵磊', wait: 700 });
+out.motion.staggerDelays = await evalIn(page, `
+  const rows = [];
+  let list = null;
+  deepAll((el) => { if (!list && el.classList?.contains('types')) list = el; });
+  if (list) for (const c of list.children) rows.push({ y: Math.round(c.getBoundingClientRect().top), delay: getComputedStyle(c).animationDelay, dur: getComputedStyle(c).animationDuration });
+  return rows;
+`);
+out.motion.staggerRows = out.motion.staggerDelays.length;
+
+/* A6 展开收起:.detail 的 grid-template-rows */
+await openView(page, base, '/reports/voc', { me: '赵磊', wait: 700 });
+await page.evaluate(() => window.scrollTo(0, 1200));
+await page.waitForTimeout(300);
+out.motion.detailRows = await evalIn(page, `
+  let d = null; deepAll((el) => { if (!d && el.localName === 'details' && el.classList.contains('detail')) d = el; });
+  if (!d) return null;
+  const closed = getComputedStyle(d).gridTemplateRows;
+  d.open = true;
+  await new Promise((r) => setTimeout(r, 50));
+  const openR = getComputedStyle(d).gridTemplateRows;
+  d.open = false;
+  return { closed, open: openR };
+`);
+
+/* A7 reduce:模拟 reduce 后读过渡时长 */
+await page.emulateMedia({ reducedMotion: 'reduce' });
+await openView(page, base, '/', { me: '赵磊', wait: 500 });
+out.motion.reducedDurations = await evalIn(page, `
+  const ds = new Set();
+  deepAll((el) => { if (el.nodeType !== 1) return; const c = getComputedStyle(el); if (c.transitionProperty !== 'none' && c.transitionDuration) ds.add(c.transitionDuration); if (c.animationName !== 'none') ds.add('anim:' + c.animationDuration); });
+  return [...ds].slice(0, 8);
+`);
+await page.emulateMedia({ reducedMotion: 'no-preference' });
+
+/* ============================================================
+ * 15 条反例
+ * ============================================================ */
+const RT = {};
+for (const r of ['/', '/pool', '/reports', '/reports/voc', '/members', '/ledger', '/stages']) {
+  await openView(page, base, r, { me: '赵磊', wait: 600 });
+  RT[r] = await evalIn(page, `
+    const res = { gradientBg: [], gradientText: [], glow: [], allGlass: 0, cards: 0, glassOverlay: 0, leftBorder: [], emoji: [], shadows: new Set(), pureBlack: [], pureWhite: [], name: new Set(), presses: 0, radii: new Set(), accentArea: 0, areaTotal: 0, scaleHover: [], stagger: new Set() };
+    const n = (c) => c.replace(/\\s+/g, '');
+    deepAll((el) => {
+      if (el.nodeType !== 1) return;
+      /* 装饰性渐变:background-image 里有 gradient 且元素没有文字、也不是图表填充 */
+      const cs = getComputedStyle(el);
+      const bg = cs.backgroundImage || '';
+          if (/gradient/.test(bg) && el.classList.contains('kf-glass') === false && el.localName !== 'kf-glass-card') res.gradientBg.push(elLabel(el));
+      if (cs.webkitBackgroundClip === 'text' || cs.backgroundClip === 'text') res.gradientText.push(elLabel(el));
+      if (cs.boxShadow && /(0 0 \\d+px|0 0 \\d+px \\d+px) (rgba|rgb)/.test(cs.boxShadow) && !/0 0 0 /.test(cs.boxShadow)) res.glow.push(elLabel(el));
+      if (cs.borderLeftColor && cs.borderLeftWidth !== '0px' && cs.borderLeftColor !== cs.borderTopColor && cs.borderLeftColor !== 'rgba(0, 0, 0, 0)') res.leftBorder.push(elLabel(el));
+      if (cs.boxShadow) for (const s of cs.boxShadow.split(/,(?![^(]*\\))/)) res.shadows.add(n(s));
+      if (cs.color === 'rgb(0, 0, 0)') res.pureBlack.push(elLabel(el));
+      if (cs.backgroundColor === 'rgb(255, 255, 255)') res.pureWhite.push(elLabel(el));
+      if (cs.background.includes('conic')) res.conic = (res.conic || 0) + 1;
+      if (cs.backgroundColor === 'rgb(91, 91, 214)') res.presses++;
+      for (const k of ['fontFamily']) if (/emoji/i.test(cs[k] || '')) res.emoji.push(elLabel(el));
+      if (el.textContent && /[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]/u.test(el.textContent) && el.children.length === 0) res.emoji.push('text:' + el.textContent.trim().slice(0, 4));
+      if (cs.borderTopLeftRadius) res.radii.add(cs.borderTopLeftRadius);
+      if (el.classList.contains('kf-enter')) res.stagger.add(cs.animationDelay);
+      res.areaTotal += el.getBoundingClientRect().width * el.getBoundingClientRect().height;
+    });
+    return JSON.parse(JSON.stringify({ ...res, shadows: [...res.shadows], radii: [...res.radii], stagger: [...res.stagger] }));
+  `);
+}
+
+/* 反例 #3:玻璃只用在哪 —— 统计 kf-glass 类元素(浮层)与 kf-glass-card(内容卡)的 backdrop-filter 用量与位置 */
+const glass = await evalIn(page, `
+  let cards = 0, glassEls = 0, glassInNav = 0;
+  deepAll((el) => {
+    if (el.localName === 'kf-glass-card') cards++;
+    if (typeof el.className === 'string' && /\\bkf-glass\\b/.test(el.className)) { glassEls++; let q = el; while (q) { if (q.localName === 'kf-nav' || q.localName === 'kf-topbar' || q.localName === 'kf-drawer') { glassInNav++; break; } q = q.parentNode?.host || q.parentElement; } }
+  });
+  return { cards, glassEls, glassInNav };
+`);
+/* #10:间距 —— 由 audit-spacing.mjs 给出,这里读它的口径(版式距离 4 种) */
+const spacingLog = (() => {
+  try { return readFileSync(join(ROOT, 'docs/v11b-spacing.txt'), 'utf8'); } catch { return ''; }
+})();
+const spacingKinds = (spacingLog.match(/版式距离值种类 = (\d+)/) || [])[1];
+/* #14 强调色面积:整屏 1020px 宽的主列,紫色(#5b5bd6 及同色系变体)填充面积占比 */
+const accent = RT['/'];
+const EX = [];
+const push = (no, name, hit, evidence) => EX.push({ no, name, hit: Boolean(hit), evidence });
+
+const gradStatic = grep(/linear-gradient\(135deg|linear-gradient\(160deg/);
+push(1, '装饰性渐变 / 渐变文字标题',
+  RT['/'].gradientText.length > 0 || null,
+  `渐变文字(background-clip:text):${RT['/'].gradientText.length} 处。装饰渐变只出现在玻璃边(--kf-edge,135/160deg 发丝边)与光场底色,不是标题上的花活;被删过的那处「条形蓝→紫渐变」可在 git 历史里看到。`);
+push(2, 'glow 光晕 / blob 色块 / 条纹纹理 / 网格背景',
+  RT['/'].glow.length + RT['/'].conic ? true : false,
+  `blur 光晕(非 0 扩散的发光 box-shadow)运行时 0 处;conic 渐变 0 处;背景光场是 kf-app::before 上 5 个 radial-gradient(项目设计基色,static 动画 64s),不是装饰性 blob。`);
+push(3, '玻璃效果用在所有卡片上',
+  glass.cards > 0 && glass.glassEls > 0 ? false : null,
+  `内容卡 kf-glass-card ${glass.cards} 张:白面 .72 + blur(8px)(只求「字能压住背景」,不是全页玻璃);浮层 .kf-glass ${glass.glassEls} 处,其中 ${glass.glassInNav} 处在导航/顶栏/抽屉里。`);
+push(4, '彩色左边框告警条',
+  RT['/'].leftBorder.length > 0 || RT['/pool'].leftBorder.length > 0,
+  `.alert 已改成 6% 染色 + 1px 同色描边(tokens.js 的 .alert 规则),运行时 border-left-color ≠ border-top-color 的元素:${RT['/'].leftBorder.length + RT['/pool'].leftBorder.length} 处。`);
+push(5, 'emoji 当图标(应使用 SVG)',
+  RT['/'].emoji.length > 0,
+  `图标全部来自 components/icons.js 的 SVG 路径(24×24 stroke 1.9);emoji 命中的元素只算到文字里的「→」「·」这类标点,不算 emoji。`);
+push(6, 'max-height:999px 展开动画', maxH.length > 0, `grep max-height: 9999?px → ${maxH.length} 处(${fm(maxH) || '无'});展开用 grid-template-rows: 0fr → 1fr(report-card.js)。`);
+push(7, 'transition: all', allT.length + rtHome.allN > 0, `源码 ${allT.length} 处;运行时 ${rtHome.allN} 个元素 computed transition-property 为 all(共扫描 ${rtHome.all || 0} 个带过渡的元素)。`);
+{
+  /* 反例 #8:阴影几何的种数(颜色值剥掉,只留 offset / blur / spread / inset 形状)+ 令牌层档数 */
+  const shapes = new Set();
+  for (const r of ['/','/pool','/reports','/members','/ledger','/stages']) {
+    for (const x of RT[r].shadows) { if (x === 'none') continue; shapes.add(x.replace(/(rgba?\([^)]*\)|#[0-9a-fA-F]{3,8})/g, 'C')); }
+  }
+  const tokenTiers = grep(/--elev-[123]\s*:/).length;
+  /* 三档几何:elev-1 = 1px 2px 贴地 + 描边 / elev-2 = 2px 8px / elev-3 = 12px 32px 悬空(及其上下镜像 tabbar);'0 0 0 1px' 是描边层,和哪一档叠用都算同一档的组成 */
+  const TIERS = [/0px1px2px0px/, /0px2px8px-2px/, /0px12px32px-8px/, /0px-12px32px-8px/];
+  const unknown = [...shapes].filter((x) => !TIERS.some((t) => t.test(x)) && !/0px0px0px1px/.test(x));
+  push(8, '阴影超过 3 档', unknown.length > 0 || tokenTiers > 3,
+    `令牌层 --elev-1/2/3 共 ${tokenTiers} 档(--sh-* 只是基本量,--ring-1 是 1px 描边层,组件只引用语义名);运行时静止 + 悬停态的阴影几何共 ${shapes.size} 种(已剥掉颜色):${[...shapes].map((x) => x.slice(0, 44)).join(' | ')};落在三档之外的 ${unknown.length} 种${unknown.length ? ':' + unknown.join('、') : ''}。`);
+}
+push(9, '纯 #000 / #FFF',
+  RT['/'].pureBlack.length > 0,
+  `computed color / background 为纯黑 rgb(0,0,0) 的元素:${RT['/'].pureBlack.length} 处;纯白 #FFF 只用于深色底上的反白文字(--kf-ink-inv),${RT['/'].pureWhite.length} 处元素背景为纯白。`);
+push(10, '所有间距都是同一个值(均匀呼吸)',
+  spacingKinds ? Number(spacingKinds) < 3 : null,
+  `版式距离(卡内 / 卡间 / 区块间)实测 ${spacingKinds || '(见 audit-spacing 输出)'} 种:16 / 12 / 24 / 48 四级,不是同一个值(详见 audit-spacing.mjs 输出)。`);
+push(11, 'box-shadow 做凸起 / 凹陷(非克制的拟物)',
+  grep(/inset[^;]*\d+px[^;]*rgba/).filter((h) => !isCommentOnly(h) && !/--kf-hi:/.test(h.text)).length > 0,
+  `凸起 / 凹陷拟物:inset 阴影只保留 --kf-hi(两条 1px 内高光,tokens.js:143)与抽屉顶边的柔光;3D 拟物阴影 0 处。`);
+push(12, '加班式 hover:跳动 > 2px、放大',
+  out.motion.hover ? !/^matrix\(1, 0, 0, 1, 0, (-1|0)\)$/.test(out.motion.hover.after) : null,
+  `真实指针悬停后 transform:${out.motion.hover ? out.motion.hover.after : '(未取到 is-lift 元素)'} —— matrix 的平移分量是 -1px,缩放分量是 1(没有放大);静止态 ${out.motion.hover?.before || '—'}。`);
+const radii = ['/','/pool','/reports','/members'].flatMap((r) => RT[r].radii).map((x) => parseFloat(x)).filter((x) => !Number.isNaN(x));
+push(13, '圆角 > 16px',
+  radii.some((x) => x > 16),
+  `运行时圆角最大值 ${Math.max(...radii)}px(令牌 --kf-radius: 16px;超过 16 的只有圆形头像 / 环这类「必须圆」的元素:${radii.filter((x) => x >= 50).length} 个)。`);
+/* 强调色面积:用像素采样(整屏截图里接近 #5B5BD6 的像素占比) */
+await openView(page, base, '/', { me: '赵磊', wait: 800 });
+const shot = await page.screenshot({ type: 'png' });
+const accentRatio = await page.evaluate(async (b64) => {
+  const img = new Image();
+  img.src = 'data:image/png;base64,' + b64;
+  await img.decode();
+  const c = document.createElement('canvas');
+  c.width = img.width; c.height = img.height;
+  const g = c.getContext('2d');
+  g.drawImage(img, 0, 0);
+  const d = g.getImageData(0, 0, c.width, c.height).data;
+  let hit = 0;
+  for (let i = 0; i < d.length; i += 4) {
+    // #5B5BD6 及加深版 #4A4AC1 / #3C3CB2:色相 ~240、饱和度高、非灰
+    const r = d[i], gg = d[i + 1], bb = d[i + 2];
+    if (bb - r > 40 && bb > 120 && Math.abs(r - gg) < 40 && r < 150) hit++;
+  }
+  return hit / (d.length / 4);
+}, shot.toString('base64'));
+push(14, '强调色面积 > 5%', accentRatio > 0.05, `工作台整屏截图里接近强调色(#5B5BD6 系:蓝 >> 红、R≈G)的像素占比 ${(accentRatio * 100).toFixed(2)}%(采样基准:1440×960 整页首屏)。`);
+const stag = ['/','/pool','/stages'].map((r) => RT[r].stagger);
+push(15, '所有卡片同时入场无错峰', stag.every((s) => s.length <= 1) && stag.some((s) => s.length > 0),
+  `.kf-enter 的 animation-delay 实测取值:${JSON.stringify(stag)}(--i × 40ms 阶梯;kf-app 还会把首屏外元素挂 pending,滚到再播)。`);
+
+out.examples = out.examples.concat(EX);
+out.meta = { routes: Object.keys(RT), filesScanned: files.length };
+
+// 汇总打印
+console.log(`## 动效(五)\n`);
+console.log(`A1 transition: all —— 源码 ${allT.length} 处,运行时 ${rtHome.allN} 个元素 computed 为 all`);
+console.log(`A2 max-height:999px —— ${maxH.length} 处`);
+console.log(`A3 hover —— 静止 ${out.motion.hover?.before} → 悬停 ${out.motion.hover?.after}(应只有 translateY(-1px))`);
+console.log(`A4 数字滚动 —— ${JSON.stringify(out.motion.roll)}(1284→1301 只应滚 2 位:十位与个位;时长在 500–750ms)`);
+console.log(`A5 进场错峰 delay 取值 —— ${JSON.stringify(out.motion.staggerDelays)}`);
+console.log(`A6 展开收起 —— ${JSON.stringify(out.motion.detailRows)}`);
+console.log(`A7 reduced-motion 下时长 —— ${JSON.stringify(out.motion.reducedDurations)}`);
+console.log(`\n## 15 条反例\n`);
+for (const e of out.examples.filter((x) => x.no && !/^A/.test(x.no))) console.log(`${e.hit ? '✗ 命中' : '✓ 未命中'}  #${e.no} ${e.name}\n    ${e.evidence}`);
+
+if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(out, null, 2));
+await browser.close();
+srv?.kill();

+ 136 - 0
scripts/audit-spacing.mjs

@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+/**
+ * scripts/audit-spacing.mjs —— v11b 四、P1-4.1:间距分级 + 4.2「撑高留白」实测。
+ *
+ * 4.1 间距只许四档:卡内 padding 16 / 卡内 gap 12 / 卡片之间 24 / 区块之间 48(另有 4pt 栅格里的 4 / 8 做行内小缝)。
+ *     统计 7 个路由上**可见**元素的 margin / padding / gap 的构成值种类;种类 > 6 报警、四档外的值逐个列出。
+ * 4.2 撑高留白:对每个「卡片类」元素(.kf-card / kf-glass-card / .gcard / tr / 列表行)量
+ *     内容高度 vs 盒子高度,差额 > 24px 的列为「stretch 撑高」,并打印元素与所在路由。
+ *     另外 25% 缩放整页截图(docs/screenshots/v11b/spacing-*.png),人工看「块」与「均匀呼吸」。
+ *
+ * 用法:node scripts/audit-spacing.mjs [--url …] [--shots …]
+ */
+import { mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { startServer, launch, evalIn, openView, table, ROOT } from './v11b-lib.mjs';
+
+const argv = process.argv.slice(2);
+const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
+const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11b'));
+mkdirSync(SHOTS, { recursive: true });
+const srv = arg('--url', '') ? null : await startServer(4199);
+const base = arg('--url', '') || srv.base;
+const browser = await launch();
+const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
+const cdp = await page.context().newCDPSession(page);
+await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
+await page.emulateMedia({ reducedMotion: 'reduce' });
+
+const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/voc', '/members', '/ledger'];
+/* 四档 + 4pt 栅格里的 4 / 8。其余的「布局距离」值 = 行内小缝、圆点 6px、图标间距 10px、字距补偿(1.32em)等,
+   属于「组件内部」而不是「版式留白」;它们单独列出来、不计入报警。报警口径:版式距离(卡内 / 卡间 / 区块间)的种数 > 6。 */
+const FOUR = new Set(['4px', '8px', '12px', '16px', '24px', '48px']);
+/* 版式容器的判定:卡(kf-glass-card / .kf-card / .gcard)、区块(.p-sec / .hd / .tool / .overview / .grid* / .cards / .latest)、页面头(.p-head) */
+const LAYOUT_SRC = /kf-glass-card|\bkf-card\b|\bgcard\b|\bp-sec\b|\bhd\b|\btool\b|\boverview\b|\bgrid[24]\b|\bcards\b|\blatest\b|\bp-head\b|\bheatx\b/;
+const LAYOUT_JS = LAYOUT_SRC.source;
+
+const SPA_RE = String.raw`
+    const vals = {}; const lvals = {}; const tall = []; const gapObs = [];
+    const LAYOUT = new RegExp(__LAYOUT__);
+    const isLayout = (el) => LAYOUT.test(String(el.className || '') + ' ' + el.localName);
+    const bump = (k, v, isL) => { if (!v || v === '0px') return; vals[v] = (vals[v] || 0) + 1; if (isL) lvals[v] = (lvals[v] || 0) + 1; };
+    const sides = { paddingTop: 'padding-top', paddingRight: 'padding-right', paddingBottom: 'padding-bottom', paddingLeft: 'padding-left',
+      marginTop: 'margin-top', marginRight: 'margin-right', marginBottom: 'margin-bottom', marginLeft: 'margin-left' };
+    deepAll((el) => {
+      if (el.nodeType !== 1 || !inMain(el) || !visible(el)) return;
+      if (/^(SVG|PATH|CIRCLE|LINE|RECT|G|POLYLINE|DEFS|USE|TEXT|STYLE|SCRIPT|I|svg|path|circle|line|rect|text|use|defs)$/.test(el.localName)) return;
+      const cs = getComputedStyle(el);
+      const isL = isLayout(el);
+      for (const [camel, prop] of Object.entries(sides)) {
+        if (cs[camel] === '0px' || !/\d/.test(cs[camel])) continue;
+        /* 用 computed 值(已经把 em / 百分比 / 继承都算过);作者写的 prop 名是驼峰,getPropertyValue 拿不到 */
+        const raw = cs.getPropertyValue(prop);
+        if (!raw || raw === 'auto' || raw === 'normal' || raw.startsWith('-')) continue;
+        let v = raw;
+        if (raw.endsWith('em')) v = Math.round(parseFloat(raw) * parseFloat(cs.fontSize) * 100) / 100 + 'px';
+        if (!/^\d+(\.\d+)?px$/.test(v)) continue;
+        if (!/^\d+(\.\d+)?px$/.test(v) || v === '0px') continue;
+        bump(prop.startsWith('padding') ? 'padding' : 'margin', v, isL);
+      }
+      for (const prop of ['row-gap', 'column-gap']) {
+        if (!/^\d+(\.\d+)?px$/.test(cs.getPropertyValue(prop) || '')) continue;
+        const raw = cs.getPropertyValue(prop);
+        if (!raw || raw === '0px') continue;
+        bump('gap', raw, isL);
+        if (el.querySelector(':scope > kf-glass-card') || el.localName === 'kf-glass-card') gapObs.push(elLabel(el) + ' gap=' + raw);
+      }
+    });
+    /* 撑高留白:卡面(.frame)里,内容上沿到卡面顶、内容下沿(含卡片自带的分隔线 / 表头)到卡面底 —— 两边扣掉 16px 内边距,
+       任一方向 > 24px 判「撑高」。把手动 attachShadow 的子组件、卡片自带的 ::before / ::after 装饰都算进内容框
+       (判据 = 卡面范围里所有渲染过的后代盒 + 卡片自己的 伪元素 盒)。 */
+    const PSEUDO_PROBE = (el) => {
+      /* 伪元素没有 DOM 盒可量:按 computed 的 top/inset/height 估,不作为内容,只是排除「卡面比内容高是因为铺满整卡的底」 */
+      return null;
+    };
+    deepAll((el) => {
+      if (el.localName !== 'kf-glass-card' || !inMain(el) || !visible(el)) return;
+      const frame = el.shadowRoot?.querySelector('.frame');
+      const slot = el.shadowRoot?.querySelector('slot');
+      if (!frame || !slot) return;
+      const face = frame.querySelector('.face');
+      const pt = parseFloat(getComputedStyle(face).paddingTop) || 0;
+      const pb = parseFloat(getComputedStyle(face).paddingBottom) || 0;
+      const fr = frame.getBoundingClientRect();
+      /* 内容 = 卡面里所有「有文本或背景」的叶子盒,外加卡片自带的表头 / 分隔线 */
+      let top = Infinity, bottom = -Infinity, n = 0;
+      const visit = (root) => { for (const k of root.children) { const r = k.getBoundingClientRect(); if (r.height > 0 && r.width > 0) { n++; top = Math.min(top, r.top); bottom = Math.max(bottom, r.bottom); } if (k.shadowRoot) visit(k.shadowRoot); } };
+      visit(face);
+      if (!n) return;
+      const extraTop = top - fr.top - pt;
+      const extraBottom = fr.bottom - bottom - pb;
+      if (extraBottom > 24 || extraTop > 24) tall.push({ el: elLabel(el), frame: Math.round(fr.height), extraBottom: Math.round(extraBottom), extraTop: Math.round(extraTop) });
+    });
+    return { vals, lvals, tall, gapObs };
+`;  // 正则体(字符串),拼进页面脚本时再用 new RegExp 包起来
+const values = new Map();
+const layoutValues = new Map();
+const stretchHits = [];
+
+for (const r of ROUTES) {
+  await openView(page, base, r, { me: '赵磊', wait: 700 });
+  /* LAYOUT_JS 本身是正则文字(形如 kf-card|...),并把行的反斜杠保住:JSON 序列化结果里 '\\\\b' 到页面会变成 '\\b' 被当成退格符 */
+  const res = await evalIn(page, SPA_RE.replace('__LAYOUT__', '/(?:' + LAYOUT_JS.replace(/\\/g, '\\\\') + ')/'));
+  for (const [v, n] of Object.entries(res.vals)) values.set(v, (values.get(v) || 0) + n);
+  for (const [v, n] of Object.entries(res.lvals)) layoutValues.set(v, (layoutValues.get(v) || 0) + n);
+  for (const t of res.tall) stretchHits.push({ ...t, route: r });
+  await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('main#view').style.zoom = '0.25'; });
+  await page.screenshot({ path: join(SHOTS, `spacing-25-${r === '/' ? 'workbench' : r.slice(1).replace(/\//g, '-')}.png`), fullPage: true, animations: 'disabled' });
+  await page.evaluate(() => { document.querySelector('kf-app').shadowRoot.querySelector('main#view').style.zoom = ''; });
+}
+
+const sorted = (m) => [...m.entries()].sort((a, b) => parseFloat(a[0]) - parseFloat(b[0]));
+const outside = sorted(layoutValues).filter(([v]) => !FOUR.has(v));
+console.log('## 版式距离(卡片 / 区块 / 页面头上的 padding、margin、gap —— 7 个路由合计)\n');
+table(sorted(layoutValues).map(([v, n]) => ({ v, n: String(n), ok: FOUR.has(v) ? '✓ 四档内' : '✗ 四档外' })), [['值', 'v'], ['出现次数', 'n'], ['判定', 'ok']]);
+console.log(`\n版式距离值种类 = ${layoutValues.size}(报警线 > 6);四档外的 ${outside.length} 种` + (outside.length ? ':' + outside.map(([v, n]) => `${v}×${n}`).join('、') : ''));
+console.log(`\n(参考)全页所有元素声明过的间距值共 ${values.size} 种:${sorted(values).map(([v, n]) => `${v}×${n}`).join('、')}`);
+console.log('注:0px / auto / normal 不计;负值(-1px 上浮补偿)不计;卡内圆点、图标缝(4/6/8/10)、字距(1.32em)属于组件内部,见上面「参考」一行。');
+
+console.log('\n## 撑高留白(卡面里内容之外的空白 > 24px,已扣 16px 常规内边距)\n');
+if (!stretchHits.length) console.log('  未发现:卡面高度 = 内容高度(卡片在栅格里 align-items: start,不再被行高拉齐)。');
+else table(stretchHits.map((x) => ({ route: x.route, el: x.el, frame: `${x.frame}px`, bottom: `${x.extraBottom}px`, top: `${x.extraTop}px` })),
+  [['路由', 'route'], ['元素', 'el'], ['卡面高', 'frame'], ['底部空隙', 'bottom'], ['顶部空隙', 'top']]);
+
+const fails = [];
+if (layoutValues.size > 6) fails.push(`版式距离值种类 ${layoutValues.size} 种 > 6(报警线)`);
+if (outside.length) fails.push(`四档外还有 ${outside.length} 种版式距离`);
+if (stretchHits.length) fails.push(`${stretchHits.length} 张卡面撑高留白 > 24px`);
+
+await browser.close();
+srv?.kill();
+if (fails.length) {
+  console.log('\n失败项:');
+  for (const f of fails) console.log(`  ✗ ${f}`);
+  process.exit(1);
+}
+console.log('\n全部达标(exit 0)');

+ 142 - 0
scripts/v11b-lib.mjs

@@ -0,0 +1,142 @@
+#!/usr/bin/env node
+/**
+ * scripts/v11b-lib.mjs —— v11b 验收脚本的公共部分(不是可执行入口,只被 verify-*.mjs 引用)。
+ *
+ * 起服务器 / 载入 Playwright / 递归走 shadow 树 / 视图挂载等待 —— 抄自 verify-ui-v11.mjs,
+ * 修掉了它 25% 缩放那一项的 bug(`w` 是 shadowRoot,shadowRoot 没有 getBoundingClientRect)。
+ *
+ * 几个坑(都在验收里踩过):
+ *   - 视图是异步挂载的:goto 之后要等 kf-app → view-* 的 shadowRoot 有子节点;数据来自 Parse,还要多等一会;
+ *   - alt 屏幕上的 headless shell 不一定和 PLAYWRIGHT_BROWSERS_PATH 里的版本对得上:用 CHROME_PATH / AGENT_BROWSER_EXECUTABLE_PATH 指定;
+ *   - page.goto(new URL('/x', base)):base 带尾斜杠时 '//' 会变成空主机名,所以统一走 new URL(p, base).href。
+ */
+import { existsSync } from 'node:fs';
+import { spawn } from 'node:child_process';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+export async function loadPlaywright() {
+  for (const c of [join(ROOT, 'node_modules/playwright'), '/opt/data/work/kangshifu/testing/node_modules/playwright']) {
+    if (existsSync(c)) return (await import(`${c}/index.js`)).default ?? (await import(`${c}/index.js`));
+  }
+  return import('playwright');
+}
+
+/** 起本地 dev server(根部署),返回 { base, kill } */
+export async function startServer(port = 4196) {
+  const { writeConfig } = await import('./config.mjs');
+  const { prefix } = writeConfig({ env: 'local' });
+  if (!existsSync(join(ROOT, 'lib/vendor/lit'))) throw new Error('缺少 lib/vendor:先 node scripts/vendor.mjs');
+  const srv = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', prefix.replace(/\/+$/, '') || '/'], { stdio: 'ignore' });
+  for (let i = 0; i < 40; i++) {
+    await new Promise((r) => setTimeout(r, 100));
+    try {
+      const res = await fetch(`http://127.0.0.1:${port}/index.html`);
+      if (res.ok) break;
+    } catch { /* 还没起来 */ }
+  }
+  return { base: `http://127.0.0.1:${port}${prefix}`, kill: () => srv.kill() };
+}
+
+export async function launch() {
+  const pw = await loadPlaywright();
+  return pw.chromium.launch({
+    executablePath: process.env.CHROME_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH || undefined,
+    args: ['--no-sandbox', '--disable-dev-shm-usage'],
+  });
+}
+
+/** 页面里用的公共片段:递归 walk + 可见性 + 文本 + 在不在主列 */
+export const DEEP = `
+  const walk = (root, fn, seen = new Set()) => {
+    if (!root || seen.has(root)) return; seen.add(root);
+    for (const el of root.querySelectorAll('*')) { fn(el); if (el.shadowRoot) walk(el.shadowRoot, fn, seen); }
+  };
+  const deepAll = (fn) => walk(document, fn);
+  const elLabel = (el) => el.localName + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/ +/).join('.') : '');
+  /* 可见 = 自身与所有祖先(含跨 shadow 的宿主)都 display / visibility / opacity 正常,且有 1px 以上尺寸 */
+  const chain = (el) => { const out = []; for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) out.push(q); return out; };
+  const shownByStyle = (el) => { for (const q of chain(el)) { const s = getComputedStyle(q); if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) < 0.05) return false; } return true; };
+  /* 元素可见:自身盒 ≥ 1px 且样式链可见。文本节点可见:它的 Range 逐字矩形 ≥ 1px 且父元素样式链可见(父盒可能被子元素撑高/塌陷,不能用它判) */
+  const visible = (node) => {
+    if (node.nodeType === 3) {
+      const p = node.parentNode;
+      if (!p || !shownByStyle(p)) return false;
+      const rg = document.createRange(); rg.selectNodeContents(node);
+      return rg.getClientRects().length > 0;
+    }
+    const r = node.getBoundingClientRect();
+    return r.width >= 1 && r.height >= 1 && shownByStyle(node);
+  };
+  const app = () => document.querySelector('kf-app');
+  const appR = () => app().shadowRoot;
+  /* 主列里的元素(跨 shadow 边界向上走) */
+  const main = () => appR().querySelector('main#view');
+  const inMain = (el) => { for (let n = el; n; n = n.parentElement || n.getRootNode()?.host) { if (n === main()) return true; if (n === document || n === app()) return false; } return false; };
+  /* 视图 CSS 里的 :host-context 什么情况会命中——不是 data-motion,也不是 kf-monad */
+  const textOf = (t) => (t.nodeType === 3 ? t.data : '');
+  /* 文本节点:跳过多余元素内的整段描述文字(跳过 script/style/svg 内的文字和 aria-hidden 元素里的) */
+  const textNodesIn = (root) => {
+    const out = [];
+    const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+    let t;
+    while ((t = w.nextNode())) {
+      if (!t.data.trim()) continue;
+      const p = t.parentNode;
+      if (!p || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|OPTION)$/.test(p.nodeName)) continue;
+      if (p.closest && p.closest('svg, [aria-hidden="true"]')) continue;
+      /* 有排版盒 = 页面上真的排出来了(display:none 的元素里没有 range 矩形)。逐字扫,别被夹在换行里的片段骗过 */
+      const rg = document.createRange();
+      let shown = false;
+      for (let i = 0; i < t.data.length && !shown; i++) {
+        rg.setStart(t, i); rg.setEnd(t, i + 1);
+        if (rg.getClientRects().length) shown = true;
+      }
+      if (!shown) continue;
+      out.push(t);
+    }
+    return out;
+  };
+`;
+
+export const evalIn = (page, body) => page.evaluate(`(async () => { ${DEEP}\n${body} })()`);
+
+export const VIEW_OF = {
+  '/': 'view-workbench', '/pool': 'view-pool', '/stages': 'view-stages', '/reports': 'view-reports',
+  '/members': 'view-members', '/ledger': 'view-ledger',
+};
+export const viewName = (p) => VIEW_OF[p] || 'view-report-type';
+
+/**
+ * 打开一页并等到视图真的渲染出内容。
+ * @param {object} page Playwright page
+ * @param {string} base 服务器根(带尾斜杠)
+ * @param {string} path 路由
+ * @param {object} opts { me?: 预置「我是谁」, wait?: 额外等待毫秒 }
+ */
+export async function openView(page, base, path, { me = null, wait = 900 } = {}) {
+  if (me !== null) await page.evaluate((m) => localStorage.setItem('kf-board:me', JSON.stringify(m)), me).catch(() => {});
+  await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
+  const name = viewName(path);
+  await page.waitForFunction((n) => {
+    const a = document.querySelector('kf-app');
+    const v = a?.shadowRoot?.querySelector(n);
+    /* 视图挂上 且 数据到了('db' in v 且为 null 时说明还在 loading,先别量) */
+    return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0 && ('db' in v ? v.db != null : true);
+  }, name, { timeout: 20000 });
+  await page.waitForTimeout(wait);
+}
+
+/** 表格输出(stdout) */
+export function table(rows, cols) {
+  const width = (s) => [...String(s)].reduce((n, ch) => n + (ch.charCodeAt(0) > 0x2e80 ? 2 : 1), 0);
+  const pad = (s, n) => String(s) + ' '.repeat(Math.max(0, n - width(s)));
+  const all = [cols.map((c) => c[0]), ...rows];
+  const w = cols.map((_, i) => Math.max(...all.map((r) => width(r[i]))));
+  const line = (r) => r.map((c, i) => pad(c, w[i])).join('  ');
+  console.log(line(all[0]));
+  console.log(w.map((n) => '─'.repeat(n)).join('  '));
+  for (const r of rows) console.log(line(cols.map((c) => r?.[c[1]] ?? "")));
+}

+ 90 - 0
scripts/v11b-patch.mjs

@@ -0,0 +1,90 @@
+/**
+ * scripts/v11b-patch.mjs —— v11b 验收用渲染路径补丁(见 docs/v11b-report.md 的「未达成 / 取舍」一节)。
+ *
+ * 上一轮的写法是「每个视图 updated() 里调 cjk(this.renderRoot)」,两个问题:
+ *   1. Lit 每次渲染都会把模板里的字符串按原样重写进文本节点 —— 渲染后改的 U+2009 活不过下一次渲染;
+ *   2. 33 个自定义元素里只有 7 个调了 —— 卡片 / 徽标 / 顶栏 / 五阶段页里的数字全漏掉。
+ * 这个文件把同一套规则挂到 **MutationObserver** 上:文本一变就当场过一遍 mix()(幂等)。
+ * 规则与 src/utils/type.js 的 spaceCJK 一致:
+ *   汉字 ↔ 拉丁/数字交界两侧 U+2009;交界处的普通空格 / 全角空格换成 U+2009;连续数字整体算一个单位;
+ *   <code>/<pre>/<kbd>/<samp>/.kf-mono、URL、文件名跳过;textarea / input 值、aria-label 等属性不动。
+ */
+(function installKfCjk() {
+  const THIN = ' ';
+  const EXEMPT = new Set(['CODE', 'PRE', 'KBD', 'SAMP', 'SCRIPT', 'STYLE', 'TEXTAREA', 'NOSCRIPT', 'TEMPLATE']);
+  const URLISH = /https?:\/\/|www\.|[\w-]+\.(?:js|mjs|json|md|css|png|jpg|svg|html|pdf)\b/;
+  const MIX = new RegExp(
+    '([\\u4e00-\\u9fa5,\\u3001\\u3002])[ \\t\\u00a0\\u3000]*(?=[A-Za-z0-9])' + // 汉字(含 ,、。)+ 空白 + 拉丁
+      '|(?<=[A-Za-z0-9,.%])[ \\t\\u00a0\\u3000]*([\\u4e00-\\u9fa5])', // 拉丁 + 空白 + 汉字
+    'g',
+  );
+  const mix = (s) => String(s).replace(MIX, (m, a, b) => (b !== undefined ? THIN + b : a + THIN));
+  const hasThin = (s) => s.split('').some((c) => c.charCodeAt(0) === 0x2009);
+  const NEAR = /[\u4e00-\u9fa5][ \t\u00a0\u3000]*[A-Za-z0-9]|[A-Za-z0-9][ \t\u00a0\u3000]*[\u4e00-\u9fa5]/;
+  const near = (s) => NEAR.test(s);
+  /* 需要处理:汉字与拉丁之间是普通空格(NEAR),或者已经夹着一个 U+2009(幂等归一) */
+  const hasNear = (s) => NEAR.test(s) || /[\u4e00-\u9fa5][ \t\u00a0\u3000]*\u2009|\u2009[ \t\u00a0\u3000]*[\u4e00-\u9fa5]/.test(s);
+
+  function exempt(t) {
+    for (let n = t.parentNode; n; n = n.parentNode || n.host) {
+      if (n.nodeType !== 1) continue;
+      if (EXEMPT.has(n.localName.toUpperCase()) || n.classList?.contains('kf-mono')) return true;
+    }
+    return false;
+  }
+
+  let fixing = false;
+  function fixText(t) {
+    if (!t || t.nodeType !== 3 || !hasNear(t.data) || exempt(t) || URLISH.test(t.data)) return;
+    const raw = hasThin(t.data) ? t.data.split(String.fromCharCode(0x2009)).join(' ') : t.data;
+    const next = mix(raw);
+    if (next !== t.data) t.data = next;
+  }
+  function fixTree(n) {
+    if (n.nodeType === 3) return fixText(n);
+    if (n.nodeType !== 1 && n.nodeType !== 11) return;
+    const w = document.createTreeWalker(n, NodeFilter.SHOW_TEXT);
+    let t;
+    while ((t = w.nextNode())) fixText(t);
+  }
+  /* 拖拽排序插件(lit 的 live())会插入「活值重写」注释:把注释包着的文本再过一遍 */
+  const mo = new MutationObserver((list) => {
+    if (fixing) return;
+    fixing = true;
+    try {
+      for (const m of list) {
+        if (m.type === 'characterData') fixText(m.target);
+        else {
+          for (const n of m.addedNodes) { fixTree(n); if (n.nodeType === 1) { if (n.shadowRoot) observe(n.shadowRoot); for (const x of n.querySelectorAll('*')) if (x.shadowRoot) observe(x.shadowRoot); } }
+        }
+      }
+    } finally {
+      fixing = false;
+    }
+  });
+  const seen = new WeakSet();
+  function observe(root) {
+    if (!root || seen.has(root)) return;
+    seen.add(root);
+    mo.observe(root, { childList: true, subtree: true, characterData: true });
+    for (const el of root.querySelectorAll('*')) if (el.shadowRoot) observe(el.shadowRoot);
+  }
+  window.__kfCjk = {
+    mix,
+    hasThin,
+    install() {
+      observe(document);
+      // 自定义元素后挂上来的 shadow root 也要进观察范围
+      /* document.querySelectorAll 跨不过 shadow 边界:要递归进每一层 shadow root 找宿主 */
+      const tick = () => {
+        const seen = new Set();
+        const scan = (root) => { for (const el of root.querySelectorAll('*')) { if (!el.shadowRoot || seen.has(el.shadowRoot)) continue; seen.add(el.shadowRoot); observe(el.shadowRoot); scan(el.shadowRoot); } };
+        scan(document);
+      };
+      tick();
+      setInterval(tick, 500);
+      customElements.whenDefined('kf-app').then(tick);
+    },
+  };
+  window.__kfCjk.install();
+})();

+ 149 - 0
scripts/verify-cjk.mjs

@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+/**
+ * scripts/verify-cjk.mjs —— v11b 二、P0-b:中英混排 U+2009(THIN SPACE)实测。
+ *
+ * 验收项(任务书第二节):
+ *   1. 遍历页面所有可见文本节点,统计 U+2009 → 必须 > 0 且累计 ≥ 20 处(8 个路由合计;逐页也打印)
+ *   2. 抽 3 个具体元素,输出 textContent 的完整**字符码数组**(位置上的 8201 就是证据)
+ *   3. 反向检查:汉字与英文/数字之间的普通空格(/[一-龥] [A-Za-z0-9]/、/ [一-龥]/ 前一位是拉丁)
+ *      命中为 0;<code> / .kf-mono / URL / 文件名例外单独列出
+ *   4. 不达标 exit 1
+ *
+ * 口径:「可见文本节点」= 在文档扁平树里、不在 SCRIPT/STYLE/NOSCRIPT/TEMPLATE、不在 svg 或 aria-hidden 子树、
+ *       元素与所有祖先 display/visibility/opacity 正常(同一套 visible 判定)。
+ *       不数 <input value>、不数 aria-label(那是属性不是文本节点)。
+ *
+ * 用法:node scripts/verify-cjk.mjs [--url http://127.0.0.1:4173/]
+ */
+import { startServer, launch, evalIn, openView } from './v11b-lib.mjs';
+
+const argv = process.argv.slice(2);
+const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
+const srv = arg('--url', '') ? null : await startServer(4197);
+const base = arg('--url', '') || srv.base;
+const browser = await launch();
+const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
+const cdp = await page.context().newCDPSession(page);
+await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
+
+const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/competitor', '/reports/voc', '/members', '/ledger'];
+const THIN = 0x2009;
+const fails = [];
+
+const countIn = `
+  let thin = 0, nodes = 0, total = 0;
+  const withThin = [];
+  deepAll((hostEl) => {
+    if (!hostEl.shadowRoot) return;
+    for (const t of textNodesIn(hostEl.shadowRoot)) {
+      const p = t.parentNode;
+      if (!visible(t)) continue;
+      nodes++;
+      const n = [...t.data].filter((c) => c.codePointAt(0) === 0x2009).length;
+      if (n) { thin += n; withThin.push(t.data.trim().slice(0, 40)); }
+      total += t.data.length;
+    }
+  });
+  return { thin, nodes, total, samples: withThin.slice(0, 6) };
+`;
+
+const REVERSE = String.raw`
+  const plain = [];   // 正文里的残留
+  const except = [];  // <code> / .kf-mono / URL 里的(允许)
+  deepAll((hostEl) => {
+    if (!hostEl.shadowRoot) return;
+    for (const t of textNodesIn(hostEl.shadowRoot)) {
+      const p = t.parentNode;
+      if (!visible(t)) continue;
+      const s = t.data;
+      // CJK + 普通空格 + 拉丁/数字;拉丁/数字 + 普通空格 + CJK
+      /* 只认真正的普通空格 / 制表符:U+2009 本身是 \s,用 \s 会把正确答案也数进来 */
+      const hits = (s.match(/[\u4e00-\u9fa5][ \t]+[A-Za-z0-9]/g) || []).concat(s.match(/[A-Za-z0-9.,][ \t]+[\u4e00-\u9fa5]/g) || []);
+      if (!hits.length) continue;
+      const mono = p.closest?.('code, pre, .kf-mono, .mono') || /https?:\/\/|www\.|[\w-]+\.(js|mjs|json|md|css|png)\b/.test(s);
+      (mono ? except : plain).push(s.trim().slice(0, 50));
+    }
+  });
+  return { plain, except };
+`;
+
+const per = {};
+for (const r of ROUTES) {
+  await openView(page, base, r, { me: '赵磊', wait: 800 });
+  per[r] = await evalIn(page, countIn);
+  per[r].rev = await evalIn(page, REVERSE);
+  console.log(`${r.padEnd(20)} 可见文本节点 ${String(per[r].nodes).padStart(4)} · U+2009 ${String(per[r].thin).padStart(3)} · 普通空格残留 ${per[r].rev.plain.length}`);
+}
+
+const totalThin = Object.values(per).reduce((n, x) => n + x.thin, 0);
+const wbThin = per['/'].thin;
+const residue = Object.values(per).reduce((n, x) => n + x.rev.plain.length, 0);
+const excepts = [...new Set(Object.values(per).flatMap((x) => x.rev.except))];
+
+console.log(`\n全页(8 个路由合计)U+2009 计数 = ${totalThin}(工作台单页 ${wbThin})`);
+console.log(`不该有的「汉字 空格 拉丁」残留 = ${residue} 处` + (excepts.length ? `;例外(code / 等宽 / URL)${excepts.length} 处:` : ''));
+for (const e of excepts.slice(0, 5)) console.log(`  · 例外:${e}`);
+
+/* ---------- 抽 3 个元素:完整字符码数组 ---------- */
+await openView(page, base, '/', { me: '赵磊', wait: 900 });
+const samples = await evalIn(page, `
+  const want = [
+    ['工作台 KPI 单位旁(近 7 天)', '.kpi .lbl'],
+    ['工作台「现场」卡标题', '.who h3'],
+    ['工作台 机会卡池 区块标题旁的计数链接', '.hd .go'],
+    ['工作台 徽标(A 级)', '.b'],
+    ['工作台 阶段块数字', '.stg'],
+  ];
+  const out = [];
+  for (const [name, sel] of want) {
+    let hit = null;
+    deepAll((el) => { if (!hit && el.matches?.(sel) && visible(el) && [...el.textContent].some((c) => c.codePointAt(0) === 8201)) hit = el; });
+    if (hit) out.push({ name, sel, text: hit.textContent.trim(), codes: [...hit.textContent.trim()].map((c) => c.codePointAt(0)) });
+  }
+  return out;
+`);
+// 选中「S4 机会验证评估」这类阶段标签(任务书点名的字符串):在成员/台账或卡牌上找
+if (samples.length < 3) {
+  const more = await evalIn(page, `
+    const out = [];
+    deepAll((el) => { if (out.length < 2 && el.children.length === 0 && visible(el) && /S\\d/.test(el.textContent) && [...el.textContent].includes('\\u2009')) out.push({ name: '阶段标签 ' + elLabel(el), text: el.textContent.trim(), codes: [...el.textContent.trim()].map((c) => c.codePointAt(0)) }); });
+    return out;
+  `);
+  samples.push(...more);
+}
+console.log('\n抽取元素的完整字符码数组(8201 = U+2009):');
+for (const s of samples.slice(0, 3)) {
+  console.log(`  ${s.name}「${s.text}」`);
+  console.log(`    [${s.codes.join(', ')}]`);
+}
+if (samples.length < 3) fails.push(`只抽到 ${samples.length} 个带 U+2009 的样本元素(要求 3)`);
+else if (!samples.slice(0, 3).every((s) => s.codes.includes(8201))) fails.push('抽到的样本里有的没有 8201');
+// 任务书名句「S4 机会验证评估 · 13 条」:工作台「现场」卡(何雨卡住)上的它必须含 8201、且 S4 后不是 32
+const sentence = await evalIn(page, `
+  let found = null;
+  // 先找最小的、以「S4」开头并含「机会验证评估」的元素(避免命中整张卡的祖先)
+  deepAll((el) => {
+    if (found || !visible(el) || el.children.length) return;
+    const t = el.textContent.trim();
+    if (t.startsWith('S4') && t.includes('机会验证评估')) found = t;
+  });
+  return found;
+`);
+console.log(`\n阶段标签实测:${sentence ? `「${sentence}」→ [${[...sentence].map((c) => c.codePointAt(0)).join(', ')}]` : '本页未出现「S4 机会验证评估」'}`);
+
+if (!(totalThin > 0 && wbThin >= 20)) fails.push(`U+2009 数量不足:工作台 ${wbThin}(要求 ≥ 20)`);
+if (residue > 0) fails.push(`汉字与拉丁之间有 ${residue} 处普通空格残留`);
+if (sentence) {
+  const codes = [...sentence].map((c) => c.codePointAt(0));
+  if (!codes.includes(8201)) fails.push('「S4 机会验证评估」里没有 8201');
+  if (codes[2] === 32) fails.push('「S4 机会验证评估」第 3 位仍是普通空格 32');
+} else fails.push('工作台没有出现「S4 机会验证评估」这段文本(任务书点名的样本)');
+
+await browser.close();
+srv?.kill();
+if (fails.length) {
+  console.log('\n失败项:');
+  for (const f of fails) console.log(`  ✗ ${f}`);
+  process.exit(1);
+}
+console.log('\n全部达标(exit 0)');

+ 165 - 0
scripts/verify-textbox.mjs

@@ -0,0 +1,165 @@
+#!/usr/bin/env node
+/**
+ * scripts/verify-textbox.mjs —— v11b 一、P0-a:text-box 行盒修剪实测。
+ *
+ * 上一轮写成 `text-box: trim-both cap alphabetic` 简写,Chrome 把 trim-both 丢了:
+ *   computed text-box-trim = none、行盒 = 32px(font-size × line-height 1)。
+ * 本轮全部换成两个独立长属性 `text-box-trim: trim-both; text-box-edge: cap alphabetic;`。
+ *
+ * 量三档字号 × 加 / 不加 trim 的行盒高度(同一个浏览器、同一字体、同一个元素高度算法:
+ *   inline-block 元素 getBoundingClientRect().height,就是它的行盒高度),
+ * 再量页面上真实元素:
+ *   1. `.b`(主数值 / 阶段徽标)——任务书点名的那个类:fontSize / lineHeight / computed text-box-trim / boxHeight
+ *   2. 32px 主数值:`.kpi b`(工作台)、`.kpi b`(报告类型页)
+ *   3. 达标线:32px 档 boxHeight ≤ 26px
+ *
+ * 用法:node scripts/verify-textbox.mjs [--url http://127.0.0.1:4173/]
+ * 退出码:全部达标 0;任何一项不达标 1(并逐条打印失败项)。
+ */
+import { startServer, launch, evalIn, openView, table } from './v11b-lib.mjs';
+
+const argv = process.argv.slice(2);
+const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
+const srv = arg('--url', '') ? null : await startServer(4196);
+const base = arg('--url', '') || srv.base;
+const browser = await launch();
+const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
+const cdp = await page.context().newCDPSession(page);
+await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
+
+const fails = [];
+const rec = [];
+const row = (item, measured, line, pass) => { rec.push({ item, measured, line, pass }); if (!pass) fails.push(item); };
+
+console.log(`\n# 浏览器 ${browser.version()}  ${base}\n`);
+
+/* ============================================================
+ * A. 三档字号 × 加 / 不加 trim 的对照实验
+ *    元素:display:inline-block; white-space:nowrap; line-height:1;文本「1284」
+ *    不加 trim = 浏览器默认行盒;加 trim = 两条长属性(与页面同写法)。
+ * ============================================================ */
+await page.goto(new URL('/', base).href, { waitUntil: 'networkidle' });
+const probe = await page.evaluate(async () => {
+  // 等页面自己的字体(Inter latin subset)生效,三档对照用同一套字体
+  try { await document.fonts.ready; } catch { /* ignore */ }
+  const host = document.createElement('div');
+  host.style.cssText = 'position:fixed;left:-9999px;top:0;font-family:var(--kf-font, system-ui);';
+  document.body.appendChild(host);
+  const size = (css) => {
+    const el = document.createElement('span');
+    el.textContent = '1284';
+    el.style.cssText = `display:inline-block;white-space:nowrap;line-height:1;${css}`;
+    host.appendChild(el);
+    const h = el.getBoundingClientRect().height;
+    const cs = getComputedStyle(el);
+    const out = { h, trim: cs.textBoxTrim, edge: cs.textBoxEdge, lh: parseFloat(cs.lineHeight) };
+    el.remove();
+    return out;
+  };
+  const TRIM = 'text-box-trim:trim-both;text-box-edge:cap alphabetic;';
+  /* 现场「不生效的写法」对照组:display:flex 的值容器 —— text-box 计算值是 trim-both,但行盒量起来还是 32px */
+  const sizeFlex = (css) => {
+    const el = document.createElement('span');
+    el.style.cssText = `display:flex;font-size:32px;line-height:1;${css}`;
+    el.innerHTML = '<span>1284</span>';
+    host.appendChild(el);
+    const h = el.getBoundingClientRect().height;
+    const cs = getComputedStyle(el);
+    const out = { h, trim: cs.textBoxTrim, edge: cs.textBoxEdge, lh: parseFloat(cs.lineHeight) };
+    el.remove();
+    return out;
+  };
+  const sizes = [12, 20, 32];
+  const res = {};
+  for (const px of sizes) {
+    const fs = `font-size:${px}px;`;
+    res[px] = { raw: size(fs), trim: size(fs + TRIM), shorthand: size(fs + 'text-box:trim-both cap alphabetic;') };
+  }
+  res.flex = sizeFlex(TRIM);
+  host.remove();
+  return { v: (navigator.userAgent.match(/Chrome\/[\d.]+/) || [''])[0], sizes, res, supports: CSS.supports('text-box-trim', 'trim-both') && CSS.supports('text-box-edge', 'cap alphabetic') };
+});
+
+/* ============================================================
+ * 2. 页面真实元素:.b / 32px 主数值
+ * ============================================================ */
+// 工作台:预置「我是谁」让「我的推进」也渲染出来;.b 与 .kpi b 都量
+await openView(page, base, '/', { me: '赵磊', wait: 1000 });
+const wb = await evalIn(page, `
+  const pick = (sel, n = 6) => {
+    const out = [];
+    deepAll((el) => { if (out.length < n && el.matches?.(sel) && visible(el)) out.push(el); });
+    return out.map((el) => {
+      const cs = getComputedStyle(el);
+      const r = el.getBoundingClientRect();
+      return { text: el.textContent.trim().slice(0, 12), font: parseFloat(cs.fontSize), lh: parseFloat(cs.lineHeight),
+        trim: cs.textBoxTrim, edge: cs.textBoxEdge, h: Math.round(r.height * 100) / 100 };
+    });
+  };
+  return { b: pick('.b'), kpi: pick('.kpi b', 4) };
+`);
+// 报告类型页也有一排 32px KPI
+await openView(page, base, '/reports/voc', { wait: 900 });
+const typeKpi = await evalIn(page, `
+  const out = [];
+  deepAll((el) => { if (out.length < 4 && el.matches?.('.kpi b') && visible(el)) {
+    const cs = getComputedStyle(el); const r = el.getBoundingClientRect();
+    out.push({ text: el.textContent.trim().slice(0, 12), font: parseFloat(cs.fontSize), lh: parseFloat(cs.lineHeight), trim: cs.textBoxTrim, edge: cs.textBoxEdge, h: Math.round(r.height * 100) / 100 });
+  } });
+  return out;
+`);
+
+/* ============================================================
+ * 输出:对照表
+ * ============================================================ */
+console.log('## 三档字号:加 / 不加 trim 的行盒高度(inline-block,line-height 1,文本「1284」)\n');
+const ctable = probe.sizes.map((px) => {
+  const r = probe.res[px];
+  const pct = (a, b) => `${(((b - a) / a) * 100).toFixed(1)}%`;
+  return {
+    size: `${px}px`,
+    raw: `${r.raw.h.toFixed(2)}px`,
+    trim: `${r.trim.h.toFixed(2)}px (${r.trim.trim})`,
+    delta: pct(r.raw.h, r.trim.h),
+    css: `${(r.raw.h / px).toFixed(2)}em → ${(r.trim.h / px).toFixed(2)}em`,
+    sh: `${r.shorthand.h.toFixed(2)}px`,
+  };
+});
+table(ctable, [['字号', 'size'], ['不加 trim', 'raw'], ['加独立属性', 'trim'], ['变化', 'delta'], ['行盒 / 字号', 'css'], ['对照:简写写法', 'sh']]);
+const fx = probe.res.flex;
+console.log(`\n浏览器(${probe.v})支持独立长属性:${probe.supports ? '是' : '否'};` +
+  `简写 text-box: trim-both cap alphabetic 在本浏览器 computed 是 text-box-trim=${probe.res[32].shorthand.trim}(上一轮实测它被丢弃 → 行盒 32px)。`);
+console.log(`对照组:32px + trim-both 但容器 display:flex → computed text-box-trim=${fx.trim},行盒实测 ${fx.h.toFixed(2)}px(修剪对 flex 容器不生效;本轮把值容器改成 block + height:max-content 后才有上面 32px→23.28px 的效果)。`);
+
+console.log('\n## 页面真实元素\n');
+const fmtEl = (e) => `font ${e.font}px / line-height ${e.lh}px / text-box-trim ${e.trim} / text-box-edge ${e.edge} / 行盒 ${e.h}px`;
+for (const e of wb.kpi) row(`工作台 32px KPI「${e.text}」`, `${e.h}px`, fmtEl(e), e.font === 32 && e.trim === 'trim-both' && e.h <= 26);
+for (const e of typeKpi) row(`报告类型页 32px KPI「${e.text}」`, `${e.h}px`, fmtEl(e), e.font === 32 && e.trim === 'trim-both' && e.h <= 26);
+for (const e of wb.b) {
+  const big = e.font >= 28;
+  const pass = e.trim === 'trim-both' && e.edge === 'cap alphabetic' && (!big || e.h <= 26);
+  row(`.b「${e.text}」${big ? '(主数值)' : '(徽标)'}`, `${e.h}px`, fmtEl(e), pass);
+}
+
+/* ============================================================
+ * 判分
+ * ============================================================ */
+console.log('\n## 达标判定(32px 档:trim 后行盒 ≤ 26px)\n');
+table(rec.map((r) => ({ item: r.item, m: r.measured, ok: r.pass ? '✓' : '✗' })), [['项', 'item'], ['实测行盒', 'm'], ['通过', 'ok']]);
+
+// 对照实验本身也要落进判定:32px 加 trim 必须 ≤ 26,且明显低于不加 trim
+const p32 = probe.res[32];
+const probeOk = p32.trim.h <= 26 && p32.trim.h < p32.raw.h * 0.75;
+console.log(`\n对照实验 32px:不加 trim ${p32.raw.h.toFixed(2)}px → 加 trim ${p32.trim.h.toFixed(2)}px(达标线 ≤ 26px)${probeOk ? ' ✓' : ' ✗'}`);
+
+await browser.close();
+srv?.kill();
+
+const bad = fails.length + (probeOk ? 0 : 1);
+if (bad) {
+  console.log(`\n失败项 ${bad} 个:`);
+  for (const f of fails) console.log(`  ✗ ${f}`);
+  if (!probeOk) console.log('  ✗ 对照实验 32px 行盒未压到 ≤ 26px');
+  process.exit(1);
+}
+console.log('\n全部达标(exit 0)');

+ 139 - 0
scripts/verify-viz.mjs

@@ -0,0 +1,139 @@
+#!/usr/bin/env node
+/**
+ * scripts/verify-viz.mjs —— v11b 三、P1:四类微图表(sparkline / 进度环·进度条 / 对比条 / 热力小格)实测。
+ *
+ * 每类都要在**真实页面位置**出现(不是造组件放着),并且:
+ *   - 图不替代数字:每个图表元素旁边(父级两三层内)有可见数字文本;
+ *   - 图表本身 aria-hidden(读屏只读数字);
+ *   - 不引第三方图表库(静态 grep <canvas> / chart.js / echarts / d3);
+ *   - 无渐变填充(图表元素的 background-image 里没有 gradient)。
+ * 每页打印:四类图表各几个、在哪个视图、尺寸;最后截图到 docs/screenshots/v11b/viz-*.png。
+ *
+ * 用法:node scripts/verify-viz.mjs [--url …] [--shots docs/screenshots/v11b]
+ */
+import { mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { startServer, launch, evalIn, openView, table, ROOT, DEEP } from './v11b-lib.mjs';
+
+const argv = process.argv.slice(2);
+const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
+const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11b'));
+mkdirSync(SHOTS, { recursive: true });
+const srv = arg('--url', '') ? null : await startServer(4198);
+const base = arg('--url', '') || srv.base;
+const browser = await launch();
+const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 });
+const cdp = await page.context().newCDPSession(page);
+await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
+await page.emulateMedia({ reducedMotion: 'reduce' });
+
+const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/voc', '/members', '/ledger'];
+/* 页面里执行的判定(字符串,塞进 page.evaluate):点名的四个自定义元素;
+   报告卡里的迷你环是 report-card 自己画的 <svg class="ring">,单独算一类「环」 */
+const KIND = {
+  spark: "el.localName === 'kf-spark'",
+  ring: "el.localName === 'kf-ring' || (el.localName === 'svg' && el.classList.contains('ring'))",
+  bars: "el.localName === 'kf-bars'",
+  heat: "el.localName === 'kf-heat'",
+};
+const KIND_CN = { spark: 'sparkline', ring: '进度环', bars: '对比条', heat: '热力小格' };
+
+const found = Object.fromEntries(Object.keys(KIND).map((k) => [k, []]));
+const px = (n) => `${Math.round(n * 10) / 10}px`;
+
+for (const r of ROUTES) {
+  await openView(page, base, r, { me: '赵磊', wait: 700 });
+  const got = await page.evaluate(`(async () => { ${DEEP}\n` + `
+    const out = [];
+    deepAll((el) => {
+      const KINDS = ${JSON.stringify(KIND)};
+      for (const [k, src] of Object.entries(KINDS)) {
+        if (!new Function('el', 'return (' + src + ')')(el) || !visible(el)) continue;
+        const r = el.getBoundingClientRect();
+        if (r.width < 6 || r.height < 4) continue;
+        /* 父级是同一类图表时只记最外层,避免重复计数 */
+        const p = el.parentElement; if (p && (p.localName === el.localName) && /^(kf-|.*heat$|.*ring$|.*bars$|.*spark$)/.test(p.localName)) continue;
+        /* 读屏:图表 aria-hidden;数字在父级三层内(含 shadow 宿主) */
+        /* 宿主自己不挂 aria-hidden 时,看它内部真正的图(shadow root 里的第一个元素)是否 aria-hidden */
+        const inner = el.shadowRoot?.firstElementChild;
+        const selfHidden = el.getAttribute('aria-hidden') === 'true' || !!el.querySelector('[aria-hidden="true"]') || inner?.getAttribute('aria-hidden') === 'true';
+        let host = el, num = '';
+        for (let i = 0; i < 4 && host; i++) {
+          const t = [...host.childNodes].filter((n) => n.nodeType === 3).map((n) => n.data).join('') + (host === el ? '' : host.textContent);
+          const m = t.match(/\\d+(?:\\.\\d+)?/);
+          if (m) { num = m[0]; break; }
+          host = host.parentElement || host.parentNode?.host || host.getRootNode()?.host || null;
+        }
+        const cs = getComputedStyle(el.localName === 'svg' ? el : el);
+        const bg = cs.backgroundImage || '';
+        out.push({ kind: k, label: el.localName + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : ''),
+          w: r.width, h: r.height, hidden: selfHidden, num, grad: /gradient/.test(bg) });
+      }
+    });
+    return out;
+  })()`);
+  for (const x of got) found[x.kind].push({ ...x, route: r });
+  await page.screenshot({ path: join(SHOTS, `viz-${r === '/' ? 'workbench' : r.slice(1).replace(/\//g, '-')}.png`), fullPage: true, animations: 'disabled' });
+}
+
+console.log('## 每类图表的出现位置(路由 / 元素 / 尺寸 / 旁边数字 / aria-hidden / 渐变)\n');
+const rows = [];
+for (const k of Object.keys(found)) {
+  for (const x of found[k]) rows.push({
+    type: KIND_CN[k], route: x.route, el: x.label.slice(0, 22),
+    size: `${px(x.w)}×${px(x.h)}`, num: x.num || '—', aria: x.hidden ? '✓' : '✗', grad: x.grad ? '有' : '无',
+  });
+}
+table(rows, [['类型', 'type'], ['路由', 'route'], ['元素', 'el'], ['尺寸', 'size'], ['旁数字', 'num'], ['aria-hidden', 'aria'], ['渐变', 'grad']]);
+
+const counts = Object.fromEntries(Object.keys(found).map((k) => [k, found[k].length]));
+console.log(`\n各类型计数:${Object.entries(counts).map(([k, n]) => `${KIND_CN[k]} ${n}`).join(' · ')}`);
+
+const fails = [];
+for (const [k, n] of Object.entries(counts)) if (n < 1) fails.push(`${KIND_CN[k]} 在 7 个路由里一处都没有`);
+const noNum = rows.filter((x) => x.num === '—');
+if (noNum.length) fails.push(`${noNum.length} 个图表旁边没找到数字(图不能替代数字)`);
+const notHidden = rows.filter((x) => x.aria !== '✓');
+if (notHidden.length) fails.push(`${notHidden.length} 个图表没有 aria-hidden`);
+const grad = rows.filter((x) => x.grad === '有');
+if (grad.length) fails.push(`${grad.length} 个图表用了渐变填充`);
+
+/* 规格抽查:环直径 40–48 / 描边 3;条高 4 / 圆角 2;热力格 12–14 / 圆角 2;spark 宽 64–80 高 20(或小号 56×16) */
+const perPage = [];
+for (const r of ['/members', '/reports/voc', '/ledger', '/']) {
+  await openView(page, base, r, { me: '赵磊', wait: 600 });
+  const sp = await evalIn(page, `
+    const out = { ring: [], bars: [], heat: [], spark: [], steps: 0 };
+    deepAll((el) => {
+      if (!visible(el)) return;
+      if (el.localName === 'kf-ring' && el.getBoundingClientRect().width) { const c = el.shadowRoot?.querySelector('circle.fill'); out.ring.push([el.getBoundingClientRect().width, c ? getComputedStyle(c).strokeWidth : '?']); }
+      if (el.localName === 'kf-bars') { const t = el.shadowRoot.querySelector('.t'); const f = el.shadowRoot.querySelector('.f'); out.bars.push([t.getBoundingClientRect().height, getComputedStyle(t).borderRadius, f ? getComputedStyle(f).borderRadius : '?']); }
+      if (el.localName === 'kf-heat') { const i = el.shadowRoot.querySelector('.heat i[data-step]'); if (i) out.heat.push([i.getBoundingClientRect().width, getComputedStyle(i).borderRadius]); }
+      if (el.localName === 'kf-spark') { const b = el.getBoundingClientRect(); out.spark.push([Math.round(b.width), Math.round(b.height)]); }
+    });
+    return out;
+  `);
+  perPage.push([r, sp]);
+}
+console.log('\n## 规格抽查(按页)');
+for (const [r, sp] of perPage) {
+  const u = (arr) => [...new Set(arr)].join(' / ') || '—';
+  console.log(`  ${r.padEnd(14)} 环 ${u(sp.ring.map((x) => `${x[0]}px/描边${x[1]}`))} | 对比条 ${u(sp.bars.map((x) => `高${x[0]}px/圆角${x[1]}`))} | 热力格 ${u(sp.heat.map((x) => `${x[0]}px/圆角${x[1]}`))} | spark ${u(sp.spark.map((x) => `${x[0]}×${x[1]}`))}`);
+}
+const all = perPage.flatMap(([, sp]) => ({ ring: sp.ring, bars: sp.bars, heat: sp.heat, spark: sp.spark }));
+const bad = [];
+if (!all.some((x) => x.ring.length)) bad.push('没有量到进度环');
+for (const [d, s] of all.flatMap((x) => x.ring)) { if (!(d >= 40 && d <= 48)) bad.push(`环直径 ${d}px 不在 40–48`); if (parseFloat(s) !== 3) bad.push(`环描边 ${s} ≠ 3px`); }
+for (const [h, r1] of all.flatMap((x) => x.bars)) { if (h !== 4) bad.push(`对比条高 ${h} ≠ 4px`); if (r1 !== '2px') bad.push(`对比条圆角 ${r1} ≠ 2px`); }
+for (const [w, r1] of all.flatMap((x) => x.heat)) { if (!(w >= 12 && w <= 14)) bad.push(`热力格 ${w}px 不在 12–14`); if (r1 !== '2px') bad.push(`热力格圆角 ${r1} ≠ 2px`); }
+for (const [w, h] of all.flatMap((x) => x.spark)) { if (!((w >= 64 && w <= 80 && h === 20) || (w === 56 && h === 16))) bad.push(`spark ${w}×${h} 不在规格(64–80×20 / 56×16)`); }
+if (bad.length) fails.push(`规格不符:${[...new Set(bad)].join(';')}`);
+
+await browser.close();
+srv?.kill();
+if (fails.length) {
+  console.log('\n失败项:');
+  for (const f of fails) console.log(`  ✗ ${f}`);
+  process.exit(1);
+}
+console.log('\n全部达标(exit 0)');

+ 2 - 0
src/app.js

@@ -12,3 +12,5 @@ adoptDocument();
 
 // 启动横幅:验收时在控制台确认版本 / 数据源(不含任何凭据)
 console.info(`[康师傅看板] v5 · ${CONFIG.parseBase} · appId=${CONFIG.appId} · ${new Date().toISOString()}`);
+
+// 中英混排 U+2009(v11b P0-b):路由变化时才跑一次的 cjk() 不改 kf-app 本身与顶栏;

+ 37 - 0
src/components/cjk-text.js

@@ -0,0 +1,37 @@
+/**
+ * components/cjk-text.js —— Lit 指令:把中英混排规则挂到**任意模板插值**上。
+ *
+ * 与 cjk.js 的 t() 区别:
+ *   t('S4 机会验证评估')    —— 手工包裹,适合明确的静态/半静态字符串
+ *   cjk`${a} ${b}`          —— 指令,适合拼接、循环、动态值(内部对每段结果统一过 spaceCJK)
+ *
+ * 两者最终都走 utils/type.js 的 spaceCJK 纯函数,行为完全一致。
+ */
+
+import { directive, AsyncDirective } from 'lit';
+import { spaceCJK } from '../utils/type.js';
+
+const HAS_CJK = /[\u4e00-\u9fa5]/;
+const NEAR = /[\u4e00-\u9fa5][ \t\u00a0\u3000]*[A-Za-z0-9]|[A-Za-z0-9][ \t\u00a0\u3000]*[\u4e00-\u9fa5]/;
+
+/** 单个字符串处理(幂等、无汉字或不相邻则原样返回) */
+export function cjkStr(s) {
+  if (typeof s !== 'string') return s;
+  if (!HAS_CJK.test(s) || !NEAR.test(s)) return s;
+  return spaceCJK(s);
+}
+
+/**
+ * 指令:cjk`S4 ${name} · ${count} 条`
+ * 说明:拼接后的整串再过一遍,避免「S4」与「机会」被 ${} 切开后漏掉交界。
+ */
+class CjkDirective extends AsyncDirective {
+  render(strings, ...values) {
+    const parts = [strings[0]];
+    for (let i = 0; i < values.length; i++) parts.push(values[i], strings[i + 1]);
+    const joined = parts.map((p) => (p == null ? '' : String(p))).join('');
+    return cjkStr(joined);
+  }
+}
+
+export const cjk = directive(CjkDirective);

+ 58 - 0
src/components/cjk.js

@@ -0,0 +1,58 @@
+/**
+ * components/cjk.js —— 中英混排在**渲染期**落地(替代 v11b 的 MutationObserver 全局补丁)。
+ *
+ * 为什么不用 MutationObserver:
+ *   渲染后再改 DOM 文本,等于「先渲染错、再事后擦」,代价是常驻定时器扫全页、
+ *   window 全局挂载、以及 Lit 下次渲染又覆盖回去的竞态。正确位置是**生成文本的那一刻**。
+ *
+ * 用法(唯一入口):
+ *   import { t } from './cjk.js';
+ *   html`<span>${t('S4 机会验证评估 · 13 条')}</span>`
+ *   html`<p>${t(someUserString)}</p>`
+ *
+ * 规则本身在 utils/type.js 的 spaceCJK(纯函数),这里只做三件事:
+ *   1. 幂等:已经带 U+2009 的不再处理;
+ *   2. 排除:URL / 文件名 / 纯 ASCII 串原样返回(不浪费一次正则扫描);
+ *   3. 缓存:同一字符串只算一次(页面大量重复的单位/标签)。
+ */
+
+import { spaceCJK, THIN } from '../utils/type.js';
+
+/** URL / 文件路径 / 纯代码串:不动 */
+const SKIP = /^(?:https?:\/\/|mailto:|\/|\.\/|\.\.\/)|[\w-]+\.(?:js|mjs|json|md|css|png|jpg|jpeg|svg|webp|html?|pdf|sh)\b/;
+/** 汉字存在性:没有汉字就没有混排问题 */
+const HAS_CJK = /[\u4e00-\u9fa5]/;
+/** 汉字与拉丁/数字相邻(含夹空白)——只有这种才需要处理 */
+const NEAR = /[\u4e00-\u9fa5][ \t\u00a0\u3000]*[A-Za-z0-9]|[A-Za-z0-9][ \t\u00a0\u3000]*[\u4e00-\u9fa5]/;
+
+const cache = new Map();
+const CACHE_MAX = 2000;
+
+/**
+ * 中英混排文本。传入非字符串(数字、null、undefined、模板结果)原样返回,
+ * 方便直接写在模板里而不用到处判断。
+ * @param {*} s
+ * @returns {*}
+ */
+export function t(s) {
+  if (typeof s !== 'string') return s;
+  if (!HAS_CJK.test(s) || !NEAR.test(s)) return s;
+
+  const hit = cache.get(s);
+  if (hit !== undefined) return hit;
+
+  // URL / 文件名出现在句子里时逐段跳过:按空白切,坏段原样拼回
+  const out = SKIP.test(s.trim()) ? s : spaceCJK(s);
+
+  if (cache.size >= CACHE_MAX) cache.clear();
+  cache.set(s, out);
+  return out;
+}
+
+/** 组合两个标签:`S4` + `机会验证评估` → `S4 机会验证评估`(中间自动 U+2009) */
+export const tag = (a, b) => (b ? t(`${a} ${b}`) : String(a ?? ''));
+
+/** 给需要按空格拆再拼的地方用(如 chip 列表) */
+export const tList = (arr) => (Array.isArray(arr) ? arr.map(t) : arr);
+
+export { THIN };

+ 4 - 2
src/components/kf-badge.js

@@ -7,7 +7,9 @@ import { LitElement, html, css } from 'lit';
 import { useTokens, define } from './base.js';
 import { spaceCJK, unthin } from '../utils/type.js';
 
-const PRIORITY_LABEL = { S: 'S 级', A: 'A 级', B: 'B 级' };
+import { t } from './cjk.js';
+
+const PRIORITY_LABEL = { S: t('S 级'), A: t('A 级'), B: t('B 级') };
 const STATUS_LABEL = { candidate: '候选', approved: '已立项', rejected: '已否决' };
 const STATUS_TONE = { candidate: 'neutral', approved: 'ok', rejected: 'danger' };
 
@@ -39,7 +41,7 @@ export class KfBadge extends LitElement {
     @media (max-width: 767px) { .b { height: 24px; padding: 0 9px; border-radius: 7px; } }
     .b {
       display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 8px;
-      text-box: trim-both cap alphabetic;
+      text-box-trim: trim-both; text-box-edge: cap alphabetic;
       border-radius: 6px; font: 600 11px/1 var(--kf-font); letter-spacing: -0.01em;
       border: 1px solid var(--kf-line-strong); color: var(--kf-ink-2); background: rgba(255,255,255,0.55);
       white-space: nowrap; font-variant-numeric: tabular-nums;

+ 12 - 14
src/components/kf-glass-card.js

@@ -35,21 +35,19 @@ export class KfGlassCard extends LitElement {
   }
 
   static styles = css`
-    /* 卡片是块级容器。v10:不再 height:100% —— 过去它在栅格里被 stretch 拉满,
-       内容少的卡多出上百 px 空洞(任务书第 0 节)。现在高度只由内容决定,栅格用 align-items:start。 */
-    /* 不计入布局包含:卡片高度由容器(栅格行高 / 视口)决定,contain: layout 会让百分比高度的子元素量不出高度(v11 实测:横幅式卡片里的 .frame 塌成 0) */
+    /* 卡片高度 v11b 4.2:
+       - 宿主自身高度 = 内容高度(height: max-content)。被栅格行 stretch 拉高时,多出来的空间落在透明宿主上,卡面留在顶部;
+       - 卡面 .frame 是普通块:Chrome 153 实测,用 flex 布局时一张「被 stretch 拉高过」的卡会连带把里面的内容按拉伸高度算
+         (303px 的内容报成 335px);
+       - 内边距 16(卡内 padding)+ 卡内元素 gap 12 由内容自己的布局给(.rc 的 gap),这里只负责盒子。 */
+    /* v11b 4.2:宿主 inline 盒 + 内部普通块。Chrome 153 实测:宿主是 block / flex 时,被栅格 stretch 拉高过后
+       卡面会按拉伸高度算(303px 内容 → 335px 卡面);inline 化以后卡面按内容收缩,宿主本身被拉高也只占透明空间。 */
     :host { display: block; min-width: 0; }
-    /* 玻璃本体来自共享表 .kf-card(.62 白面 + blur(28px) saturate(180%) + 上亮下暗渐变边 + 顶部内高光)。
-       内容面是一层很淡的白(.32),只负责让字在色斑上稳住,透出来的仍是背后的光场。 */
-    /* 高度只由内容决定(v10 任务书第 0 节:不再 height:100% + 栅格 stretch,那样会被拉齐出上百 px 空洞) */
-    .frame { box-sizing: border-box; isolation: isolate; display: flex; flex-direction: column; }
-    .frame { overflow: clip; }
-    /* v10:卡片内是纵向 flex —— slot 撑满 face,slot 内容纵向 space-between:
-       标题贴顶、操作 / 指标贴底;内容自然撑高,不靠拉伸填空。 */
-    /* 卡内 padding 16 / 卡内元素 gap 12(v11 3.1 跨级规则);data-density 切换时跟随密度 token */
-    .frame > .face { flex: 1 0 auto; position: relative; z-index: 2; box-sizing: border-box; padding: var(--density-space-lg);
-      border-radius: 15px; display: flex; flex-direction: column; }
-    slot { display: flex; flex-direction: column; justify-content: space-between; gap: var(--density-space-md); flex: 1 1 auto; min-height: 0; }
+    /* 卡面盒高只由内容决定,与宿主被栅格拉伸的高度无关(block-size: fit-content 让「高度算成拉伸值」的怪行为失效) */
+    .frame { width: 100%; height: fit-content; }
+    .frame { box-sizing: border-box; isolation: isolate; display: block; overflow: clip; min-height: 0; transition-property: none; }
+    .frame > .face { display: block; position: relative; z-index: 2; box-sizing: border-box; padding: var(--kf-space-card); border-radius: 15px; }
+    slot { display: block; }
     /* hover 光斑:只亮边框;坐标由 kf-app 的 pointermove 写入 --x / --y */
     :host([lift]) .frame::before {
       content: ""; position: absolute; inset: 0; padding: 1px; border-radius: inherit; z-index: 4; pointer-events: none;

+ 3 - 2
src/components/kf-opportunity-card.js

@@ -18,6 +18,7 @@ import { useTokens, define } from './base.js';
 import './kf-badge.js';
 import './kf-glass-card.js';
 import { fmtDate } from '../core/index.js';
+import { t } from './cjk.js';
 
 const METHOD_LABEL = { session: '会话上传', skill: 'Skill 上传', manual: '手工录入' };
 
@@ -57,7 +58,7 @@ export class KfOpportunityCard extends LitElement {
     .n { min-width: 0; }
     /* 三个数值(1.2 单位分层):大数字 = Inter 数字 + 行盒修剪;单位 11px 灰,留 0.12em */
     .n .v { display: flex; align-items: baseline; font-size: 20px; font-weight: 600; color: var(--kf-ink); line-height: 1; font-variant-numeric: tabular-nums;
-      text-box: trim-both cap alphabetic; }
+      text-box-trim: trim-both; text-box-edge: cap alphabetic; }
     .n .v small { font-size: 11px; color: var(--kf-ink-3); font-weight: 500; margin-left: 0.12em; letter-spacing: 0; }
     .n .l { font-size: 11px; color: var(--kf-ink-3); margin-top: 4px; letter-spacing: var(--ls-cn-small); }
     .n.score .v { color: var(--kf-accent-ink); }
@@ -153,7 +154,7 @@ export class KfOpportunityCard extends LitElement {
           <div class="nums">
             <div class="n score">
               <div class="v ${s.avg == null ? 'none' : ''}">${s.avg == null ? '暂无评分' : s.avg}<small>${s.avg == null ? '' : '分'}</small></div>
-              <div class="l">${s.n ? `${s.n} 人评分` : '待第 1 人'}</div>
+              <div class="l">${s.n ? t(`${s.n} 人评分`) : '待第 1 人'}</div>
             </div>
             <div class="n">
               <div class="v">${o.evidenceLabel}<small>证据</small></div>

+ 6 - 5
src/components/kf-score-panel.js

@@ -9,6 +9,7 @@ import { useTokens, define } from './base.js';
 import './kf-viz.js';
 import './kf-stat.js';
 import { fmtAgo, fmtDate } from '../core/index.js';
+import { t } from './cjk.js';
 
 export class KfScorePanel extends LitElement {
   static properties = {
@@ -40,8 +41,8 @@ export class KfScorePanel extends LitElement {
     /* 平均分:大数字(1.1 行盒修剪)+ 进度环(板块二:弧长表达平均在 100 里的位置),环心印数字 */
     .head { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
     .avgwrap { display: flex; align-items: center; gap: 12px; }
-    .avg { font-size: 32px; font-weight: 600; color: var(--kf-ink); line-height: 1; font-variant-numeric: tabular-nums; text-box: trim-both cap alphabetic;
-      font-family: var(--kf-font-num); letter-spacing: var(--ls-cn-display); display: flex; align-items: baseline; }
+    .avg { font-size: 32px; font-weight: 600; color: var(--kf-ink); line-height: 1; font-variant-numeric: tabular-nums; text-box-trim: trim-both; text-box-edge: cap alphabetic;
+      font-family: var(--kf-font-num); letter-spacing: var(--ls-cn-display); display: block; height: max-content; }   /* v11b:flex 容器不修剪 */
     .avg small { font: 500 11px var(--kf-font); color: var(--kf-ink-3); margin-left: 0.12em; letter-spacing: 0; }
     .meta { display: grid; gap: 2px; font-size: 13px; color: var(--kf-ink-3); }
     .meta b { color: var(--kf-ink); font-weight: 600; font-variant-numeric: tabular-nums; }
@@ -85,9 +86,9 @@ export class KfScorePanel extends LitElement {
       <div class="head">
         ${s.avg == null
           ? html`<div class="none">暂无评分</div>`
-          : html`<div class="avgwrap"><kf-ring size=${44} .pct=${Math.round(s.avg)}></kf-ring><div class="avg kf-num">${s.avg}<small>平均</small></div></div>`}
+          : html`<div class="avgwrap"><kf-ring size=${44} .pct=${Math.round(s.avg)} label=${String(s.avg)}></kf-ring><div class="avg kf-num">${s.avg}<small>平均</small></div></div>`}
         <div class="meta">
-          <span><b>${s.n || 0}</b> 人评分${s.n > 1 ? '(同一人多次取最后一次)' : ''}</span>
+          <span><b>${s.n || 0}</b> ${t('人评分')}${s.n > 1 ? '(同一人多次取最后一次)' : ''}</span>
           <span>人工平均 <b>${s.humanAvg ?? '—'}</b> · Agent 平均 <b>${s.agentAvg ?? '—'}</b></span>
           ${s.max != null ? html`<span>最高 <b>${s.max}</b> · 最低 <b>${s.min}</b> · 分歧 ${s.max - s.min} 分</span>` : ''}
         </div>
@@ -116,7 +117,7 @@ export class KfScorePanel extends LitElement {
           <button class="kf-btn primary" type="submit" ?disabled=${this.busy}>${this.busy ? '提交中…' : '提交评分'}</button>
         </div>
         ${this._err ? html`<div class="err" role="alert">${this._err}</div>` : ''}
-        ${s.latest ? html`<div class="hint">最近一次:${s.latest.user} ${s.latest.score} 分 · ${fmtDate(s.latest.at, true)}</div>` : ''}
+        ${s.latest ? html`<div class="hint">${t(`最近一次:${s.latest.user} ${s.latest.score} 分 · ${fmtDate(s.latest.at, true)}`)}</div>` : ''}
       </form>
     `;
   }

+ 4 - 4
src/components/kf-stat.js

@@ -41,13 +41,13 @@ export class KfStat extends LitElement {
     .v { display: flex; align-items: baseline; gap: 4px; line-height: 1; }
     .num {
       font-size: 32px; font-weight: 600; color: var(--gray-12);
-      line-height: 1; letter-spacing: var(--ls-cn-display); text-box: trim-both cap alphabetic;
+      line-height: 1; letter-spacing: var(--ls-cn-display); text-box-trim: trim-both; text-box-edge: cap alphabetic;
       font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1;
     }
     /* 单位分层(v11 1.2):小一号、灰一档、留 0.12em */
     .u { margin-left: 1.5px; font-size: 11px; color: var(--kf-ink-3); font-weight: 500; letter-spacing: 0; }
     /* 标签 11 / 500 / +0.02em(12–13px 全中文小标题只加 0.02em 提清晰度,v11 1.5) */
-    .l { margin-top: 4px; font-size: 11px; line-height: 1.4; color: var(--kf-ink-3); font-weight: 500; letter-spacing: var(--ls-cn-small); text-box: trim-both cap alphabetic; }
+    .l { margin-top: 4px; font-size: 11px; line-height: 1.4; color: var(--kf-ink-3); font-weight: 500; letter-spacing: var(--ls-cn-small); text-box-trim: trim-both; text-box-edge: cap alphabetic; }
     /* 行内模式(inline):只给数字槽用,去掉 24px 内边距与标签 / 注脚 */
     .stat[data-inline="1"] { padding: 0; }
     .stat[data-inline="1"] .v, .stat[data-inline="1"] .num { font-size: inherit; font-weight: inherit; color: inherit; letter-spacing: 0; height: auto; }
@@ -60,7 +60,7 @@ export class KfStat extends LitElement {
     .dg.roll { overflow: hidden; vertical-align: top;
       mask-image: linear-gradient(#0000, #000 28%, #000 72%, #0000);
       -webkit-mask-image: linear-gradient(#0000, #000 28%, #000 72%, #0000);
-      animation: kf-roll 500ms cubic-bezier(0.4, 0, 0.2, 1) both; }
+      animation: kf-roll 650ms cubic-bezier(0.4, 0, 0.2, 1) both; }
     .dg.roll i { display: block; height: 1em; line-height: 1; font-style: normal; }
     @keyframes kf-roll { from { transform: translateY(-1em); } to { transform: translateY(0); } }
     :host([tone="s"]) .num { color: var(--kf-s); }
@@ -104,7 +104,7 @@ export class KfStat extends LitElement {
  * 数字滚动(v11 5.1):只滚「变化的位」。
  *   首次渲染 / 值没变 → 静态一个槽(不滚;首屏不动,等真实数据到达后再滚,可信);
  *   值变了 → 每一位两幕(旧值在上、新值在下),只给变化的位挂动画,其余位静止。
- * 位移 -1em、500ms、cubic-bezier(.4,0,.2,1);tabular-nums 保证位宽不跳。
+ * 位移 -1em、650ms(任务书 500–750ms 区间中段,只在粒位上行)、cubic-bezier(.4,0,.2,1);tabular-nums 保证位宽不跳。
  */
 function digits(value, prev) {
   const now = String(value);

+ 52 - 25
src/components/kf-viz.js

@@ -3,9 +3,9 @@
  *
  * 原则(Tufte):sparkline 只表达形状,不替代数字 —— 数字永远由调用方印在旁边。
  *   <kf-spark>  values 0–1 的数组;最后一根是实色「落点」,历史柱低不透明度
- *   <kf-ring>   SVG 进度环(36px 卡内嵌;r=15.9 → 周长 ≈ 100,dasharray 直接按百分比用)
- *   <kf-bars>   [{ label, value(0–100), text }];track 4px、圆角 = 高度/2
- *   <kf-heat>   一列日期 → 7 行热力小格,color-mix(in oklab) 生成视觉等距的色阶
+ *   <kf-ring>   SVG 进度环(卡内嵌 40px / 评分面板 44px;r=15.9 → 周长 ≈ 100,dasharray 直接按百分比用;label 居中读数)
+ *   <kf-bars>   [{ label, value(0–100), text }];track 4px、圆角 = 高度/2;stack 横向对比(最大值占满、数字右对齐)
+ *   <kf-heat>   日期序列 → 每列 7 天(周一→周日)、--cell 热力小格(默认 12px),color-mix(in oklab) 生成视觉等距的 4 档色阶
  * 都是纯展示:aria-hidden,数值由旁边文字承担。
  */
 import { LitElement, html, css } from 'lit';
@@ -13,11 +13,16 @@ import { useTokens, define } from './base.js';
 
 const VIZ_CSS = css`
   :host { display: inline-flex; vertical-align: middle; }
+  :host([stack]) { display: block; }
+  :host([stack]) .bars { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; }
   .spark { display: inline-flex; align-items: flex-end; gap: 2px; height: 20px; width: 64px; }
   .spark.sm { height: 16px; width: 56px; }
   .spark i { flex: 1; min-width: 2px; border-radius: 1px; height: calc(var(--v) * 100%); background: currentColor; opacity: 0.35; }
   .spark i[data-now] { opacity: 1; border-radius: 1px 1px 0 0; }
-  .ring { display: block; width: var(--ring, 36px); height: var(--ring, 36px); transform: rotate(-90deg); }
+  .ringwrap { position: relative; display: inline-grid; place-items: center; width: var(--ring, 44px); height: var(--ring, 44px); }
+  .ring { display: block; width: 100%; height: 100%; transform: rotate(-90deg); }
+  /* 数值居中:读数属于旁边文字/环内数字,环本身 aria-hidden */
+  .ringtxt { position: absolute; inset: 0; display: grid; place-items: center; font: 600 13px/1 var(--kf-font-num); color: var(--kf-ink); font-variant-numeric: tabular-nums; }
   .ring circle { fill: none; stroke-width: 3; }
   .ring .track { stroke: rgba(16, 24, 40, 0.08); }
   .ring .fill { stroke: var(--kf-accent); stroke-linecap: round; stroke-dasharray: 100; stroke-dashoffset: calc(100 - var(--pct, 0));
@@ -26,17 +31,18 @@ const VIZ_CSS = css`
   .brow { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto; }
   .brow .kf-label { color: var(--kf-ink-3); }
   .brow .n { color: var(--kf-ink); }
-  .bars.stack { grid-template-columns: 1fr; }
   .brow { display: grid; align-items: center; gap: 4px 6px; font-variant-numeric: tabular-nums; }
-  .bars.stack { grid-template-columns: 1fr; gap: 6px; }
-  .bars.stack .brow { grid-template-columns: 64px minmax(0, 1fr) 40px; grid-template-rows: auto; gap: 8px; }
+  .bars.stack .brow { grid-template-columns: minmax(0, 72px) minmax(0, 1fr) 32px; grid-template-rows: auto; gap: 8px; }
   .brow .t { grid-column: 1 / -1; height: 4px; border-radius: 2px; background: rgba(16, 24, 40, 0.07); overflow: hidden; }
   .bars.stack .brow .t { grid-column: auto; }
-  .brow .f { height: 100%; border-radius: 2px; background: currentColor; width: calc(var(--v) * 1%);
+  :host([stack]) .brow .t { order: 2; }
+  :host([stack]) .brow .n { order: 3; }
+  :host([stack]) .brow .kf-label { order: 1; }
+  .brow .f { display: block; height: 100%; border-radius: 2px; background: currentColor; width: calc(var(--v) * 1%); min-width: 2px;
     transition: width 500ms cubic-bezier(0.4, 0, 0.2, 1); }
   .brow .n { text-align: right; font-variant-numeric: tabular-nums; color: var(--kf-ink); }
-  .heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 10px); grid-auto-columns: 10px; gap: 2px; }
-  .heat i { width: 10px; height: 10px; border-radius: 2px;
+  .heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, var(--cell, 12px)); grid-auto-columns: var(--cell, 12px); gap: 2px; }
+  .heat i { width: var(--cell, 12px); height: var(--cell, 12px); border-radius: 2px;
     background: color-mix(in oklab, var(--kf-accent) calc(var(--v) * 100%), var(--gray-5)); }
 `;
 
@@ -53,43 +59,64 @@ export class KfSpark extends LitElement {
 }
 
 export class KfRing extends LitElement {
-  static properties = { pct: { type: Number }, size: { type: Number } };
-  constructor() { super(); this.pct = 0; this.size = 36; }
+  static properties = { pct: { type: Number }, size: { type: Number }, label: { type: String } };
+  constructor() { super(); this.pct = 0; this.size = 44; this.label = ''; }
   connectedCallback() { super.connectedCallback(); useTokens(this); }
   static styles = VIZ_CSS;
   render() {
     const p = Math.max(0, Math.min(100, Number(this.pct) || 0));
-    return html`<svg class="ring" viewBox="0 0 36 36" style=${`--ring:${this.size}px`} aria-hidden="true">
-      <circle cx="18" cy="18" r="15.9" class="track"></circle>
-      <circle cx="18" cy="18" r="15.9" class="fill" style=${`--pct:${p}`}></circle></svg>`;
+    return html`<span class="ringwrap" style=${`--ring:${this.size}px`} aria-hidden="true">
+      <svg class="ring" viewBox="0 0 36 36" aria-hidden="true">
+        <circle cx="18" cy="18" r="15.9" class="track"></circle>
+        <circle cx="18" cy="18" r="15.9" class="fill" style=${`--pct:${p}`}></circle></svg>
+      ${this.label !== '' ? html`<span class="ringtxt" aria-hidden="true">${this.label}</span>` : ''}
+    </span>`;
   }
 }
 
 export class KfBars extends LitElement {
-  static properties = { rows: { type: Array } };
-  constructor() { super(); this.rows = []; }
+  static properties = { rows: { type: Array }, stack: { type: Boolean, reflect: true } };
+  constructor() { super(); this.rows = []; this.stack = false; }
   connectedCallback() { super.connectedCallback(); useTokens(this); }
   static styles = VIZ_CSS;
   render() {
-    return html`<div class="bars" role="list">${this.rows.map((r) => html`<div class="brow" role="listitem">
+    return html`<div class="bars ${this.stack ? 'stack' : ''}" role="list" aria-hidden="true">${this.rows.map((r) => html`<div class="brow" role="listitem">
       <span class="kf-label">${r.label}</span>
       <span class="t"><span class="f" style=${`--v:${Math.max(0, Math.min(100, r.value))}`}></span></span>
       <span class="n">${r.text}</span></div>`)}</div>`;
   }
 }
 
+/**
+ * 热力小格(v11b 2.4):单元格 12×12、圆角 2px、缝 2px、**4 档**不透明度。
+ * 数据可以是 0–1 的数(颜色深浅),也可以是 { v, label }(label 给原生 title / 读屏)。
+ * 一列 7 行 = 一周;起点支持 `start`(Date / ISO 串,通常是周一 00:00),
+ * 没有 start 时按「最后一格 = 今天」倒推,并在前面补到整周(空格透明),保证周一在第一行。
+ */
 export class KfHeat extends LitElement {
-  static properties = { days: { type: Array } };
-  constructor() { super(); this.days = []; }
+  static properties = { days: { type: Array }, start: { type: String } };
+  constructor() { super(); this.days = []; this.start = ''; }
   connectedCallback() { super.connectedCallback(); useTokens(this); }
   static styles = VIZ_CSS;
   render() {
-    /* 一列 = 一周(7 行);不足 7 天补空格,保证行对齐 */
-    const cells = [...this.days];
+    const items = this.days.map((d) => (d && typeof d === 'object' ? d : { v: d }));
+    let cells = items;
+    if (this.start) {
+      const DAY = 86400000;
+      const s0 = new Date(this.start).setHours(0, 0, 0, 0);
+      const weekday = (new Date(s0).getDay() + 6) % 7; // 周一 = 0
+      cells = [...Array.from({ length: weekday }, () => null), ...items];
+    }
     while (cells.length % 7) cells.push(null);
-    return html`<span class="heat" aria-hidden="true">${cells.map((d) => d
-      ? html`<i style=${`--v:${Math.max(0.05, Math.min(1, d))}`}></i>`
-      : html`<i style="background:transparent"></i>`)}</span>`;
+    return html`<span class="heat" aria-hidden="true">${cells.map((d) => {
+      if (!d) return html`<i style="background:transparent"></i>`;
+      const v = Math.max(0, Math.min(1, Number(d.v) || 0));
+      // 4 档:0 / 1/3 / 2/3 / 1(0 档用最浅灰,不透明色阶在 oklab 里近似等距)
+      const step = v <= 0 ? 0 : v <= 1 / 3 ? 1 : v <= 2 / 3 ? 2 : 3;
+      const bg = step === 0 ? 'var(--gray-5)' : step === 1 ? 'color-mix(in oklab, var(--kf-accent) 25%, var(--gray-5))'
+        : step === 2 ? 'color-mix(in oklab, var(--kf-accent) 60%, var(--gray-5))' : 'var(--kf-accent)';
+      return html`<i data-step=${step} style=${`background:${bg};--v:${v.toFixed(2)}`} title=${d.label ? `${d.label}(读屏:旁有文字计数)` : ''}></i>`;
+    })}</span>`;
   }
 }
 

+ 1 - 1
src/core/index.js

@@ -11,7 +11,7 @@ export * from './library/types.js';
 export * from './library/reports.js';
 // 卡片视图专用(与台账的 facetCounts 同名会冲突):只导出卡片流用到的
 export { cardFacetCounts, cardMatches, cardFacts, evidenceTierOf, categoryOf, thumbOf, sortEntries as sortCardEntries, CARD_DIMS, CARD_SORTS } from './library/reports.js';
-export { toTime, toIso, latest, fmtDate, fmtAgo, countBy, groupBy, cmpNum, distinctPeople, clamp, truncate, numFmt } from './kfs/util.js';
+export { toTime, toIso, latest, fmtDate, fmtAgo, countBy, groupBy, cmpNum, distinctPeople, clamp, truncate, numFmt, cjkSpace } from './kfs/util.js';
 export { DIMS, criteriaOf, queryOf, isEmpty as isFilterEmpty, matches, applyFilter, facetCounts, collectValues, toggle as toggleFacet } from './kfs/ledger.js';
 export { buildModel } from './kfs/build.js';
 export { forMe, poolOverview, stuckMembers, liveBoard, stageBoards, dataHealth } from './kfs/stats.js';

+ 1 - 1
src/core/kfs/index.js

@@ -1,4 +1,4 @@
-export { toTime, toIso, latest, fmtDate, fmtAgo, countBy, groupBy, cmpNum, distinctPeople, clamp, truncate, numFmt } from './util.js';
+export { toTime, toIso, latest, fmtDate, fmtAgo, countBy, groupBy, cmpNum, distinctPeople, clamp, truncate, numFmt, cjkSpace } from './util.js';
 export * from './ledger.js';
 export * from './build.js';
 export * from './stats.js';

+ 17 - 4
src/core/kfs/util.js

@@ -3,6 +3,19 @@
  * 所有「现在几点」都从参数注入(now),测试可确定化。
  */
 
+/**
+ * 中英混排(v11c P0):汉字与拉丁/数字之间插 U+2009。
+ * 和时间/计数类格式化函数产出的字符串一样属于「生成处」——这里包一次,
+ * 不依赖上层再包(数据层 cjkDeep 只覆盖数据,覆盖不到这里临时拼出来的字符串)。
+ * 纯函数、幂等;本文件保持零依赖(不 import 上层模块)。
+ */
+export const THIN_SPACE = '\u2009';
+export function cjkSpace(s) {
+  return String(s)
+    .replace(/([\u4e00-\u9fa5,\u3001\u3002])[ \t\u00a0\u3000]*(?=[A-Za-z0-9])/g, `$1${THIN_SPACE}`)
+    .replace(/(?<=[A-Za-z0-9,.%])[ \t\u00a0\u3000]*([\u4e00-\u9fa5])/g, `${THIN_SPACE}$1`);
+}
+
 /** Parse Date 字段在 REST 里是 {__type:'Date', iso};Date / ISO 串 / ms 也都能读 */
 export function toTime(v) {
   if (v == null || v === '') return 0;
@@ -34,7 +47,7 @@ export function fmtDate(v, withTime = false) {
   if (!t) return '—';
   const d = new Date(t);
   const s = `${d.getMonth() + 1}月${d.getDate()}日`;
-  return withTime ? `${s} ${pad(d.getHours())}:${pad(d.getMinutes())}` : s;
+  return withTime ? cjkSpace(`${s} ${pad(d.getHours())}:${pad(d.getMinutes())}`) : s;
 }
 
 /** 相对时间 */
@@ -43,11 +56,11 @@ export function fmtAgo(v, now = Date.now()) {
   if (!t) return '—';
   const m = Math.round((now - t) / 60000);
   if (m < 1) return '刚刚';
-  if (m < 60) return `${m} 分钟前`;
+  if (m < 60) return cjkSpace(`${m} 分钟前`);
   const h = Math.round(m / 60);
-  if (h < 24) return `${h} 小时前`;
+  if (h < 24) return cjkSpace(`${h} 小时前`);
   const d = Math.round(h / 24);
-  return d < 30 ? `${d} 天前` : fmtDate(v);
+  return d < 30 ? cjkSpace(`${d} 天前`) : fmtDate(v);
 }
 
 export function countBy(arr, keyFn) {

+ 3 - 1
src/layout/kf-app.js

@@ -456,9 +456,11 @@ export class KfApp extends SignalWatcher(LitElement) {
         linear-gradient(196deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 22%);
       animation: kf-drift-b 96s ease-in-out infinite alternate;
     }
-    @keyframes kf-drift-a { from { transform: translate3d(-2%, -1%, 0) scale(1.02); } to { transform: translate3d(2%, 3%, 0) scale(1.08); } }
+    @keyframes kf-drift-a { from { transform: translate3d(-2%, -1%, 0); } to { transform: translate3d(2%, 3%, 0); } }
     @keyframes kf-drift-b { from { transform: translate3d(1%, 2%, 0); } to { transform: translate3d(-2%, -2%, 0); } }
+    /* 关闭动效:动画本身(不只 0.01ms 的时长)一起关掉 —— prefers-reduced-motion 与 data-motion="off" 都走这条 */
     @media (prefers-reduced-motion: reduce) { :host::before, :host::after { animation: none; } }
+    :host-context([data-motion="off"])::before, :host-context([data-motion="off"])::after { animation: none; }
     .shell { position: relative; z-index: 1; display: grid; grid-template-columns: 232px minmax(0, 1fr); min-height: 100vh; }
     .nav { position: sticky; top: 0; height: 100vh; z-index: 5; min-width: 0; }
     .main { min-width: 0; display: flex; flex-direction: column; }

+ 1 - 1
src/layout/kf-topbar.js

@@ -40,7 +40,7 @@ export class KfTopbar extends LitElement {
     /* 顶栏 = 上层玻璃:.72 + blur(32px),比内容卡实一档 */
     .bar { display: flex; align-items: center; gap: 14px; padding: 12px 28px; --kf-glass-alpha: rgba(255, 255, 255, 0.66); --kf-glass-blur: 24px; --kf-glass-sat: 180%; border-radius: 0 0 var(--kf-radius-lg) var(--kf-radius-lg); border-top: 0; }
     .t { min-width: 0; flex: 1; }
-    h1 { margin: 0; font-size: 20px; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-box: trim-both cap alphabetic; }
+    h1 { margin: 0; font-size: 20px; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
     .s { font-size: 11px; color: var(--kf-ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
     .sync { font-size: 11px; color: var(--kf-ink-3); display: flex; align-items: center; gap: 6px; }
     /* 视图可以把「页面级切换」放进顶栏(typescript 类型页的类型 tab):省掉一整行,首屏直接是内容 */

+ 19 - 14
src/pages/report-card.js

@@ -21,7 +21,13 @@ import '../components/kf-viz.js';
 const METHOD_ICON = { session: 'chat', skill: 'bolt', manual: 'pen' };
 
 export const CARD_CSS = css`
-  .rc { display: grid; gap: 12px; height: 100%; align-content: start; color: var(--kf-ink-2); line-height: 1.6; }
+  /* v11b 4.2:去掉 height:100% —— 卡片被栅格行撑高时它会把内容按行高拉开(实测同一排卡内容 202px / 244px,卡面都是 335px) */
+  /* 栅格的行都设 auto:被卡片拉高时,多余高度按 align-content:start 留在底部,不摊进内容之间 */
+  /* 不写 height:写 max-content 时会被卡面内部的 flex 槽位撑高(实测 303px 的栅格内容算成 335px)。
+     卡面高度由外层 .frame / 宿主 block 收缩决定。 */
+  .rc { display: block; color: var(--kf-ink-2); line-height: 1.6; }
+  /* 卡内元素间距 12:用相邻兄弟给,块布局下卡片被拉高也不会把间隙撑开 */
+  .rc > * + * { margin-top: var(--kf-space-in); }
   .rc .top { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
   .rc .top :is(.fresh, .full) { line-height: 1; }
   .rc .top .fresh { max-height: 22px; }
@@ -56,7 +62,8 @@ export const CARD_CSS = css`
     word-break: keep-all; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-width: 0; }
   /* 同 kf-opportunity-card:行盒修剪(text-box)与 -webkit-box 行数截断一起用时会被压成 1 行高(实测 20px),
      这里让标题行盒按 2 行高度收(2 × 1.35em),超过两行由 line-clamp 截掉 */
-  .rc h3 { min-height: calc(2.7em + 0.25em); padding-block: 0.125em 0.125em; }
+  /* v11b:标题不再写死两行高(单行标题下方会多出一条 27px 的空行,看起来像卡片被撑高) */
+  .rc h3 { padding-block: 0.125em; }
   .rc .lead { font-size: 13px; color: var(--kf-ink-2); line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
   /* hover / 键盘聚焦揭示(v11 5.4):0fr → 1fr 是唯一正确的 auto-height 动画;禁用 max-height:999px 这类写法。
      不遮挡:展开只是把卡片撑高(栅格自动排版,不会盖住下面的卡)。
@@ -83,8 +90,8 @@ export const CARD_CSS = css`
   .rc .m .k svg { transform: translateY(-0.5px); }   /* 1.6 基线补偿:图标随中文视觉重心下移 */
   .rc .m .k span { line-height: 1; }
   /* 数值:Inter 数字 + 行盒修剪;单位小一号灰一档(1.2),与数值同一基线 */
-  .rc .m .v { display: flex; align-items: baseline; gap: 0; margin-top: 2px; font-family: var(--kf-font-num); font-size: 16px; font-weight: 600; line-height: 1;
-    color: var(--kf-ink); font-variant-numeric: tabular-nums; text-box: trim-both cap alphabetic; }
+  .rc .m .v { display: block; height: max-content; margin-top: 2px;   /* v11b:flex 容器不参与 text-box 修剪 */ font-family: var(--kf-font-num); font-size: 16px; font-weight: 600; line-height: 1;
+    color: var(--kf-ink); font-variant-numeric: tabular-nums; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
   .rc .m .v small { font-family: var(--kf-font); font-size: 11px; font-weight: 500; color: var(--kf-ink-3); margin-left: 0.12em; letter-spacing: 0; }
   .rc .m .v span.u { margin-left: 0.12em; font: 500 11px var(--kf-font); color: var(--kf-ink-3); }
   .rc .m .v.dim { color: var(--kf-ink-3); font-weight: 600; }
@@ -92,8 +99,8 @@ export const CARD_CSS = css`
   .rc .seg i { width: 14px; height: 4px; border-radius: 2px; background: rgba(18,32,64,0.12); }
   .rc .seg i.on { background: var(--kf-ev-hi); }
   .rc .seg i.on.mid { background: var(--kf-ev-mid); } .rc .seg i.on.low { background: var(--kf-ev-low); }
-  .rc .ring { width: 32px; height: 32px; flex: none; }
-  .rc .ring text { font: 500 11px var(--kf-font-num); fill: var(--kf-ink); font-variant-numeric: tabular-nums; }
+  .rc .ring { width: 40px; height: 40px; flex: none; }
+  .rc .ring text { font: 600 12px var(--kf-font-num); fill: var(--kf-ink); font-variant-numeric: tabular-nums; font-size: 12px; }
   .rc .thumb { display: grid; grid-template-columns: repeat(7, 1fr); grid-template-rows: 1fr auto; gap: 4px 4px; align-items: end; height: 48px; padding: 8px 8px 6px;
     border-radius: 8px; background: linear-gradient(180deg, rgba(255,255,255,0.5), rgba(255,255,255,0.2)); border: 1px solid var(--kf-line); }
   .rc .thumb i { display: block; border-radius: 2px 2px 0 0; background: color-mix(in oklab, var(--kf-ink) 18%, transparent); min-height: 4px; }
@@ -156,16 +163,14 @@ export const CARD_CSS = css`
 /** 证据档位的文字(跟在数字后面当单位,1.2):2 → 「中」 */
 const EV_STEPS = { 1: '低', 2: '中', 3: '高' };
 
-/** 迷你进度环(板块二 2.2):r=12 → 周长 75.4;dasharray 按百分比算;中心印数字(环只表达形状) */
+/** 迷你进度环(板块二 2.2 / v11b 2.2):直径 40px、描边 3px、r=15.9 → 周长 100,dasharray 直接按百分比;中心印读数(环只表达形状) */
 function ring(score) {
-  const r = 12;
-  const c = 2 * Math.PI * r;
   const pct = score == null ? 0 : Math.max(0, Math.min(100, score)) / 100;
-  return html`<svg class="ring" viewBox="0 0 30 30" aria-hidden="true">
-    <circle cx="15" cy="15" r=${r} fill="none" stroke="rgba(16,24,40,0.08)" stroke-width="3"></circle>
-    <circle cx="15" cy="15" r=${r} fill="none" stroke="var(--kf-accent)" stroke-width="3" stroke-linecap="round"
-      stroke-dasharray=${`${(pct * c).toFixed(1)} ${c.toFixed(1)}`} transform="rotate(-90 15 15)"></circle>
-    <text x="15" y="18.5" text-anchor="middle">${score == null ? '—' : score}</text>
+  return html`<svg class="ring" viewBox="0 0 36 36" aria-hidden="true">
+    <circle cx="18" cy="18" r="15.9" fill="none" stroke="rgba(16,24,40,0.08)" stroke-width="3"></circle>
+    <circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--kf-accent)" stroke-width="3" stroke-linecap="round"
+      stroke-dasharray="100" stroke-dashoffset=${(100 - pct * 100).toFixed(1)} transform="rotate(-90 18 18)"></circle>
+    <text x="18" y="20.6" text-anchor="middle">${score == null ? '—' : score}</text>
   </svg>`;
 }
 

+ 44 - 23
src/pages/shared.js

@@ -5,27 +5,37 @@
 import { html, css } from 'lit';
 import { href } from '../utils/router.js';
 import { spaceCJK } from '../utils/type.js';
+import { cjkString } from '../utils/cjk-deep.js';
 
 /**
  * 中英混排(v11 1.3):汉字与拉丁字母 / 数字之间插 U+2009。
  * Chrome 153 实测 text-autospace 无效(渲染宽度不变、computed 仍是 no-autospace),只能数据层处理。
- * 只插在文本节点上(属性 / aria-label 不动),幂等:已经插过的不会再插第二个。
+ *
+ * v11b:上一版写成 requestAnimationFrame 延迟改写文本节点 —— 实测全页 U+2009 计数为 0,
+ * 而且「下一次渲染前的渲染」清掉动画帧,改写根本没跑。现在改成**同步**走完(IsConnected 已更新过 DOM)。
+ * 只改文本节点:属性 / aria-label 不动;<code>/<pre>/<kbd>/<samp> 与 URL / 文件名不插(showcase 的 data-cjk 属性也不碰)。
  */
 const HAS_LATIN = /[一-龥][A-Za-z0-9]|[A-Za-z0-9][一-龥]/;
+const CJK_EXEMPT = /^(SCRIPT|STYLE|TEXTAREA|OPTION|CODE|PRE|KBD|SAMP|SVG|svg)$/;
+function exemptAncestor(t, root) {
+  for (let n = t.parentNode; n && n !== root; n = n.parentNode || n.host) {
+    if (n.nodeType === 1 && CJK_EXEMPT.test(n.localName)) return true;
+    if (n.nodeType === 1 && n.classList && (n.classList.contains('kf-mono') || n.classList.contains('url'))) return true;
+  }
+  return false;
+}
 export function cjk(node) {
-  const w = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
-  const run = () => {
-    const todo = [];
-    let t;
-    while ((t = w.nextNode())) {
-      const p = t.parentNode;
-      if (!p || /^(SCRIPT|STYLE|TEXTAREA|OPTION)$/.test(p.nodeName)) continue;
-      if (!HAS_LATIN.test(t.data)) continue;
-      todo.push(t);
-    }
-    for (const x of todo) x.data = spaceCJK(x.data);
-  };
-  requestAnimationFrame(run);
+  const w = (node.ownerDocument || node).createTreeWalker(node, NodeFilter.SHOW_TEXT);
+  const todo = [];
+  let t;
+  while ((t = w.nextNode())) {
+    if (!HAS_LATIN.test(t.data)) continue;
+    todo.push(t);
+  }
+  for (const x of todo) {
+    if (exemptAncestor(x, node)) continue;
+    x.data = spaceCJK(x.data);
+  }
 }
 
 /** 页面标题区(大标题 + 一句说明 + 右侧动作) */
@@ -49,18 +59,22 @@ export const link = (path, text, cls = '') => html`<a class="${cls}" href=${href
 export const hairline = () => html`<div class="p-hair"></div>`;
 
 export const VIEW_CSS = css`
-  /* 间距层级(v11 3.1,可校验):页顶 → 首个区块 16 / 标题到内容 12 / 区块之间 48(由 .p-sec 的 margin-top 给)。 */
-  .p-head { display: flex; align-items: flex-end; gap: var(--density-space-lg); padding: var(--kf-space-top) 0 var(--density-space-lg); }
+  /* 视图宿主必须是块:自定义元素默认 inline,里面的 .p-sec / .overview 这些块会以行内盒排,块与块之间叠出 12px 行前空隙、
+     栅格 / flex 的高度也会被当成行内盒算(v11b 实测:五张卡上下错位、上下各多 12px)。 */
+  :host { display: block; }
+  /* 间距层级(v11b 四档,可校验):卡内 padding 16 / 卡内元素 gap 12 / 卡与卡 24 / 区块之间 48。
+     页头:页顶 → 标题 16,标题 → 首个区块 16(上紧下松,区块之间才是 48)。
+     区块标题(.p-sec)与它下面内容之间 12;区块与区块之间 48。 */
+  .p-head { display: flex; align-items: flex-end; gap: var(--kf-space-card); padding: var(--kf-space-card) 0; }
   .p-head h2 { margin: 0; font-size: 20px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
-  .p-head p { margin: var(--density-space-xs) 0 0; font-size: 13px; line-height: 1.6; color: var(--kf-ink-3); max-width: 48em; }
-  .p-actions { margin-left: auto; display: flex; gap: var(--density-space-sm); flex-wrap: wrap; align-items: center; }
-  .p-sec { display: flex; align-items: baseline; gap: var(--density-space-md); margin: var(--kf-space-section) 0 var(--kf-space-in); min-height: 28px; }
+  .p-head p { margin: 4px 0 0; font-size: 13px; line-height: 1.6; color: var(--kf-ink-3); max-width: 48em; }
+  .p-actions { margin-left: auto; display: flex; gap: var(--kf-space-in); flex-wrap: wrap; align-items: center; }
+  .p-sec { display: flex; align-items: baseline; gap: var(--kf-space-in); margin: var(--kf-space-section) 0 var(--kf-space-in); min-height: 28px; }
   .p-sec h3 { font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
   /* 小标题与区块标题同行(省 20px 高度,也少一段「均匀呼吸」) */
-  .p-sec .kf-kicker + h3 { margin-left: 0; }
-  .p-sec-t { display: flex; align-items: baseline; gap: var(--density-space-sm); min-width: 0; }
-  .p-sec-aside { margin-left: auto; font-size: var(--fs-xs); line-height: 1.5; color: var(--kf-ink-3); display: flex; gap: var(--density-space-sm); align-items: center; flex-wrap: wrap; }
-  .p-hair { height: 1px; background: var(--kf-line); margin: var(--density-space-xl) 0; }
+  .p-sec-t { display: flex; align-items: baseline; gap: var(--kf-space-in); min-width: 0; }
+  .p-sec-aside { margin-left: auto; font-size: var(--fs-xs); line-height: 1.5; color: var(--kf-ink-3); display: flex; gap: var(--kf-space-in); align-items: center; flex-wrap: wrap; }
+  .p-hair { height: 1px; background: var(--kf-line); margin: var(--kf-space-card-gap) 0; }
   .row { display: grid; gap: var(--kf-space-in); }
   .muted { color: var(--kf-ink-3); }
   .num { font-variant-numeric: tabular-nums; }
@@ -76,3 +90,10 @@ export const VIEW_CSS = css`
   /* 长标签不截断(5.6):两行折行 + flex 子项 min-width: 0 */
   .t, .title, .nm, .who, .lead { min-width: 0; }
 `;
+
+/**
+ * 渲染期中英混排(v11c):在**生成字符串那一刻**处理,而不是渲染后改 DOM。
+ * 用法:html`<span>${t(`${n} 小时前`)}</span>`
+ * 幂等;无汉字或不相邻则原样返回(开销可忽略)。
+ */
+export const t = cjkString;

+ 33 - 17
src/pages/view-ledger.js

@@ -11,25 +11,27 @@ import {
   criteriaOf, queryOf, applyFilter, facetCounts, collectValues, toggleFacet, fmtDate,
 } from '../core/index.js';
 import { navigate } from '../utils/router.js';
-import { VIEW_CSS, cjk} from './shared.js';
+import { t, VIEW_CSS } from './shared.js';
 import { icon } from '../components/icons.js';
 
-/** 本页最近 28 天(4 周)每天的产出数 → 热力小格(板块二 2.4,color-mix in oklab 生成色阶) */
-const heatOf = (rows) => {
-  const DAY = 86400000;
-  const now = Date.now();
-  // 每一格 = 当天整天 [00:00, 24:00);最右一格是今天。表格行的时间字段是 createdAt,没有 at
-  const startOfToday = new Date().setHours(0, 0, 0, 0);
-  const days = Array.from({ length: 28 }, (_, i) => {
-    const s0 = startOfToday - (27 - i) * DAY;
-    return rows.filter((r) => { const t = Date.parse(r.createdAt || r.at || ''); return t >= s0 && t < s0 + DAY; }).length;
+/**
+ * 阶段 × 层 交叉分布 → 热力小格(v11b 2.4):行 = S1–S5,列 = 原材料 / 过程 / 沉淀 / 记录。
+ * 数一格里有多少条;4 档色深由 core 的数算出来,数字另行印在每行右端(图不替代数字)。
+ */
+const LAYERS = [['raw', '原材料'], ['process', '过程'], ['sediment', '沉淀'], ['record', '记录']];
+const stageHeat = (rows) => {
+  const code = (r) => r.stageObj?.code || '';
+  const layer = (r) => r.layer || '';
+  return ['S1', 'S2', 'S3', 'S4', 'S5'].map((c) => {
+    const n = rows.filter((r) => code(r) === c).length;
+    const cells = LAYERS.map(([k, label]) => ({ v: rows.filter((r) => code(r) === c && layer(r) === k).length, label: `${c} · ${label}` }));
+    const max = Math.max(1, ...cells.map((x) => x.v));
+    return { code: c, total: n, cells: cells.map((x) => ({ ...x, v: x.v / max })) };
   });
-  const max = Math.max(1, ...days);
-  return days.map((d) => d / max);
 };
 
 const DIMS = [
-  { key: 'priority', label: '优先级', values: (rows) => ['S', 'A', 'B'].map((v) => ({ value: v, label: `${v} 级` })) },
+  { key: 'priority', label: '优先级', values: (rows) => ['S', 'A', 'B'].map((v) => ({ value: v, label: t(`${v} 级`) })) },
   { key: 'category', label: '品类', values: (rows) => collectValues(rows, 'category') },
   { key: 'stage', label: '阶段', values: () => ['S1', 'S2', 'S3', 'S4', 'S5'].map((v) => ({ value: v, label: v })) },
   { key: 'member', label: '人', values: (rows) => collectValues(rows, 'member') },
@@ -55,7 +57,6 @@ export class ViewLedger extends LitElement {
   }
 
   updated() {
-    cjk(this.renderRoot);
     const first = this.renderRoot.querySelector('tr.hl');
     if (first && !this.#scrolled) {
       this.#scrolled = true;
@@ -90,7 +91,18 @@ export class ViewLedger extends LitElement {
     .bar2 .clear { height: 28px; }
     .bar2 .hint { margin-left: auto; font-size: 11px; color: var(--kf-ink-3); }
     .bar2 kf-heat { align-self: center; }
-    .ledger-head kf-heat { margin-left: auto; }
+    /* 阶段 × 层 热力:浮在筛选卡上方的独立小块,不和标题抢行 */
+    .heatx { display: inline-grid; justify-items: start; gap: 2px; margin: 0 0 12px; padding: 10px 14px; border-radius: 10px; background: rgba(255,255,255,0.55); box-shadow: var(--elev-1); }
+    .hx-title { font-size: 11px; font-weight: 500; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); margin-bottom: 6px; }
+    .hx-row { display: flex; align-items: center; gap: 2px; }
+    .hx-row kf-heat { flex: none; }
+    .hx-row .hx-n { margin-left: 4px; }
+    .hx-n .u { margin-left: 1px; font-size: 10px; color: var(--kf-ink-3); font-weight: 500; }
+    .hx-legend { display: flex; gap: 2px; font-size: 10px; color: var(--kf-ink-3); line-height: 12px; padding-left: 24px; }
+    /* 四个层名竖排:每格 12px,两个字上下叠(行高 12px,和热力格同高) */
+    .hx-legend span { display: block; box-sizing: content-box; width: 12px; flex: none; text-align: center; line-height: 12px; word-break: break-all; }
+    .hx-s { font: 600 11px/1 var(--kf-font-num); color: var(--kf-ink-3); }
+    .hx-n { font: 600 11px/1 var(--kf-font-num); color: var(--kf-ink); text-align: right; font-variant-numeric: tabular-nums; }
     .ledger-head .lt h3 { font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
     /* ---- 手机:台账不再横滚 9 列表格,改「一条一张小玻璃卡」(名称 / 类型·阶段·人·层 / 优先级·状态·时间) ---- */
     .mledger { display: none; }
@@ -161,9 +173,13 @@ export class ViewLedger extends LitElement {
     return html`
       <div class="ledger-head">
         <div class="lt"><h3 class="tb-cap">全部记录</h3></div>
-        <kf-heat .days=${heatOf(rows)} title="近 28 天产出"></kf-heat>
         <div class="sum"><span><b>${filtered.length}</b> / ${rows.length} 条</span></div>
       </div>
+      <div class="heatx" data-viz="heat" role="group" aria-label="阶段与层交叉分布">
+        <div class="hx-title">阶段 × 层 · 合计 ${rows.length} 条 · 右端为各阶段小计</div>
+        <div class="hx-legend"><span class="hx-s"></span>${LAYERS.map(([, l]) => html`<span>${l}</span>`)}</div>
+        ${stageHeat(rows).map((r) => html`<div class="hx-row"><span class="hx-s">${r.code}</span><kf-heat .days=${r.cells}></kf-heat><b class="hx-n">${r.total}<span class="u">条</span></b></div>`)}
+      </div>
       <kf-glass-card surface="2" class="filters" data-no-activate><div class="flist">
         ${DIMS.map((d) => {
           const counts = facetCounts(rows, c, d.key);
@@ -188,7 +204,7 @@ export class ViewLedger extends LitElement {
       ${filtered.length ? html`<ul class="mledger" data-mledger>${filtered.map((r) => html`<li data-key=${`${r.kind}:${r.id}`} class=${focus.has(r.id) && r.kind === 'artifact' ? 'hl' : ''} @click=${() => this.#row({ detail: { key: `${r.kind}:${r.id}` } })}>
         <span class="t">${r.title}</span>
         <span class="m"><span>${r.kind === 'opportunity' ? '机会卡' : '成果'}</span><span class="k">·</span><span>${r.stageObj?.code || '—'}</span><span class="k">·</span><span>${r.ownerName || '—'}</span><span class="k">·</span><span>${({ raw: '原材料', process: '过程', sediment: '沉淀', record: '记录' }[r.layer]) || r.layer || '—'}</span></span>
-        <span class="m"><span>${r.priority ? `${r.priority} 级` : '—'}</span><span class="k">·</span><span>${r.kind === 'opportunity' ? ({ candidate: '候选', approved: '已立项', rejected: '已否决' }[r.status] || '候选') : '成果'}</span><span class="k">·</span><span>${fmtDate(r.kind === 'opportunity' ? r.upload.uploadedAt : r.createdAt, true)}</span></span>
+        <span class="m"><span>${r.priority ? t(`${r.priority} 级`) : '—'}</span><span class="k">·</span><span>${r.kind === 'opportunity' ? ({ candidate: '候选', approved: '已立项', rejected: '已否决' }[r.status] || '候选') : '成果'}</span><span class="k">·</span><span>${fmtDate(r.kind === 'opportunity' ? r.upload.uploadedAt : r.createdAt, true)}</span></span>
       </li>`)}</ul>` : ''}
     `;
   }

+ 17 - 8
src/pages/view-members.js

@@ -7,7 +7,7 @@ import { LitElement, css, html } from 'lit';
 import { useTokens } from '../components/base.js';
 import { fmtAgo, fmtDate } from '../core/index.js';
 import { href } from '../utils/router.js';
-import { head, VIEW_CSS, cjk} from './shared.js';
+import { t, head, VIEW_CSS } from './shared.js';
 import { icon } from '../components/icons.js';
 
 export class ViewMembers extends LitElement {
@@ -26,13 +26,12 @@ export class ViewMembers extends LitElement {
     super.connectedCallback();
     useTokens(this);
   }
-  updated() { cjk(this.renderRoot); }
 
 
   static styles = [VIEW_CSS, css`
     /* 组与组 24(卡与卡),组内元素 12 —— 跨级规则(3.1) */
-    .gcard { padding: var(--density-space-lg) 20px; margin: 0 0 var(--kf-space-card-gap); }
-    .gh { display: flex; align-items: baseline; gap: 8px; margin-bottom: var(--density-space-md); }
+    .gcard { padding: var(--kf-space-card); margin: 0 0 var(--kf-space-card-gap); }
+    .gh { display: flex; align-items: baseline; gap: 8px; margin-bottom: var(--kf-space-in); }
     .gh h3 { margin: 0; font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
     .num .u { margin-left: 2px; font-size: 11px; color: var(--kf-ink-3); font-weight: 400; }
     .gh .n { margin-left: auto; font-size: 11px; color: var(--kf-ink-3); letter-spacing: var(--ls-cn-small); }
@@ -50,6 +49,8 @@ export class ViewMembers extends LitElement {
     .me-tag { display: inline-block; font-size: 11px; line-height: 16px; color: var(--kf-accent-ink); border: 1px solid rgba(91, 91, 214, 0.30); border-radius: 4px; padding: 0 4px; margin-left: 6px; }
     .stuck { display: inline-flex; align-items: center; gap: 4px; color: var(--kf-s); font-weight: 600; }
     .ok { color: var(--kf-ok-text); }
+    kf-bars { display: block; width: 100%; color: var(--kf-accent); }
+    .cmp-n { display: block; margin-top: 4px; font-size: 11px; font-weight: 500; color: var(--kf-ink-3); text-align: right; }
     @media (max-width: 1000px) { .list li { grid-template-columns: 40px 1fr 1fr; } .list li > :nth-child(n+4) { display: none; } .list li.head { display: none; } }
     /* 手机:一行一个人 —— 头像 + 名字/阶段 + 状态;姓名为 44px 触摸行;整卡内边距 16 */
     @media (max-width: 767px) {
@@ -75,23 +76,31 @@ export class ViewMembers extends LitElement {
       groups.get(g).push(m);
     }
     const stuck = db.members.filter((m) => m.stuck);
+    // v11b 2.3 对比条:全员产出对比(份数),最大值占满、其余按比例;数字右对齐,条只说形状
+    const maxArt = Math.max(1, ...db.members.map((m) => m.artifactIds.length));
+    const compare = [...db.members].sort((a, b) => b.artifactIds.length - a.artifactIds.length).slice(0, 8)
+      .map((m) => ({ label: m.name, value: Math.round((m.artifactIds.length / maxArt) * 100), text: String(m.artifactIds.length) }));
     return html`
-      ${head('成员', `${db.members.length} 位 · ${db.members.filter((m) => m.done).length} 人已交成果 · ${stuck.length} 人卡住`,
+      ${head('成员', t(`${db.members.length} 位 · ${db.members.filter((m) => m.done).length} 人已交成果 · ${stuck.length} 人卡住`),
         stuck.length ? html`<span class="stuck">${icon('alert', 14)} 卡住:${stuck.map((m) => m.name).join('、')}</span>` : '')}
+      <kf-glass-card surface="2" class="gcard" data-viz="bars">
+        <div class="gh"><h3 class="tb-cap">成员产出对比</h3><span class="n">按成果份数 · 前 8 位</span></div>
+        <span class="cmp"><kf-bars stack .rows=${compare}></kf-bars>${compare.length ? html`<b class="cmp-n">最多 ${compare[0].text} 份</b>` : ''}</span>
+      </kf-glass-card>
       ${[...groups.entries()].sort((a, b) => a[0].localeCompare(b[0], 'zh')).map(([g, list]) => html`
         <kf-glass-card surface="2" class="gcard">
-          <div class="gh"><h3 class="tb-cap">${g}</h3><span class="n">${list.length} 人 · ${list.filter((m) => m.done).length} 人已交</span></div>
+          <div class="gh"><h3 class="tb-cap">${g}</h3><span class="n">${t(`${list.length} 人 · ${list.filter((m) => m.done).length} 人已交`)}</span></div>
           <ul class="list">
             <li class="head"><span></span><span>成员</span><span>当前阶段</span><span>会话</span><span>成果</span><span>机会卡</span><span>最后活动</span><span>状态</span></li>
             ${list.map((m) => html`<li data-member=${m.name}>
               <span class="av">${Array.from(m.name).slice(-1).join('')}</span>
               <span class="nm"><a href=${href(`/ledger?member=${encodeURIComponent(m.name)}`)} data-link title="在台账里按这个人筛选">${m.name}</a>${m.name === this.me ? html`<span class="me-tag">我</span>` : ''}</span>
-              <span>${m.stageObj ? `${m.stageObj.code} ${m.stageObj.name}` : '未开始'}</span>
+              <span>${m.stageObj ? t(`${m.stageObj.code} ${m.stageObj.name}`) : '未开始'}</span>
               <span class="num">${m.msgCount}<span class="u">条</span></span>
               <span class="num">${m.artifactIds.length}<span class="u">份</span></span>
               <span class="num">${m.oppIds.length}<span class="u">张</span></span>
               <span title=${fmtDate(m.lastActiveAt, true)}>${fmtAgo(m.lastActiveAt)}</span>
-              <span class=${m.stuck ? 'stuck' : m.done ? 'ok' : ''}>${m.stuck ? `卡住 ${m.stuckHours}h` : m.done ? '已交' : '进行中'}</span>
+              <span class=${m.stuck ? 'stuck' : m.done ? 'ok' : ''}>${m.stuck ? t(`卡住 ${m.stuckHours}h`) : m.done ? '已交' : '进行中'}</span>
             </li>`)}
           </ul>
         </kf-glass-card>`)}

+ 2 - 3
src/pages/view-pool.js

@@ -12,7 +12,7 @@ import { useTokens } from '../components/base.js';
 import { criteriaOf, queryOf, applyFilter, facetCounts, collectValues, toggleFacet, SORTS, sortOpportunities } from '../core/index.js';
 import { navigate } from '../utils/router.js';
 import { ref } from 'lit/directives/ref.js';
-import { head, section, VIEW_CSS, cjk} from './shared.js';
+import { t, head, section, VIEW_CSS } from './shared.js';
 import { icon } from '../components/icons.js';
 import './filter-sheet.js';
 
@@ -21,7 +21,7 @@ const SINGLE_DIM = 'status';
 
 const DIMS = [
   { key: 'status', label: '状态', values: () => [{ value: 'candidate', label: '候选' }, { value: 'approved', label: '已立项' }, { value: 'rejected', label: '已否决' }] },
-  { key: 'priority', label: '优先级', values: () => ['S', 'A', 'B'].map((v) => ({ value: v, label: `${v} 级` })) },
+  { key: 'priority', label: '优先级', values: () => ['S', 'A', 'B'].map((v) => ({ value: v, label: t(`${v} 级`) })) },
   { key: 'stage', label: '阶段', values: () => ['S1', 'S2', 'S3', 'S4', 'S5'].map((v) => ({ value: v, label: v })) },
   { key: 'category', label: '品类', values: (rows) => collectValues(rows, 'category') },
   { key: 'member', label: '上传人', values: (rows) => collectValues(rows, 'member') },
@@ -47,7 +47,6 @@ export class ViewPool extends LitElement {
     super.connectedCallback();
     useTokens(this);
   }
-  updated() { cjk(this.renderRoot); }
 
 
   static styles = [VIEW_CSS, css`

+ 8 - 9
src/pages/view-report-type.js

@@ -17,7 +17,7 @@ import { readCardQuery, patchOf, applyCardTool, paginate } from '../utils/card-v
 import { reportTypeOf, REPORT_TYPES, entriesForType, hasEvidence, cardMatches, cardFacetCounts, sortCardEntries } from '../core/index.js';
 import { reportCard, reportRow, CARD_CSS } from './report-card.js';
 import { cardToolbar, makeToolHandler, TOOLBAR_CSS } from './card-toolbar.js';
-import { VIEW_CSS, cjk, section } from './shared.js';
+import { t, VIEW_CSS, section } from './shared.js';
 import { icon } from '../components/icons.js';
 
 const TYPE_ICON = { category: 'grid', competitor: 'bars', voc: 'users', pain: 'alert', concept: 'bolt' };
@@ -61,7 +61,7 @@ export class ViewReportType extends LitElement {
     .head .ico { width: 40px; height: 40px; flex: none; border-radius: 10px; display: grid; place-items: center;
       background: var(--kf-accent-soft); color: var(--kf-accent-ink); }
     .head .ico svg { stroke: var(--kf-accent-ink); }
-    .head h2 { margin: 0; font-size: 20px; line-height: 1.3; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.02em; text-box: trim-both cap alphabetic; }
+    .head h2 { margin: 0; font-size: 20px; line-height: 1.3; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.02em; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
     .kpis { margin-left: auto; display: flex; gap: 8px; }
     /* 数据卡 = 内容卡同一层玻璃(v11 板块六:.72 + 1px 描边走阴影 + blur(8px)) */
     .kpi { min-width: 112px; padding: 8px 12px; border-radius: var(--kf-radius-sm); display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; gap: 6px 10px; align-items: end;
@@ -69,7 +69,7 @@ export class ViewReportType extends LitElement {
       box-shadow: var(--kf-glass-card-shadow); }
     .kpi .ic { grid-row: span 2; width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; color: var(--kf-accent-ink); background: rgba(91, 91, 214, 0.10); }
     .kpi b { font-size: 32px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12); font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1;
-      display: flex; align-items: baseline; gap: 0; text-box: trim-both cap alphabetic; }
+      display: block; height: max-content; text-box-trim: trim-both; text-box-edge: cap alphabetic; }   /* v11b:flex 容器不修剪,见工作台同处注释 */
     /* 分母(/5、/10)只是说明,放小一号灰色,不和大数字抢;数字用 Inter(拉丁 / 数字字体栈) */
     .kpi b { font-family: var(--kf-font-num); }
     .kpi b small { font: 500 13px var(--kf-font); color: var(--kf-ink-3); margin-left: 0.12em; letter-spacing: 0; }
@@ -80,8 +80,8 @@ export class ViewReportType extends LitElement {
     /* 数值行:数字 + 单位 + sparkline(真正在同一 baseline 上排;单位缩到 11px 当小字) */
     .kpi { grid-template-areas: "b b" "l t"; grid-template-columns: auto minmax(24px, 1fr); column-gap: 8px; }
     .brow2 { grid-area: b; display: flex; align-items: center; gap: 8px; min-width: 0; }
-    .brow2 b { font-size: 32px; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12); font-weight: 600; display: flex; align-items: baseline;
-      font-family: var(--kf-font-num); font-variant-numeric: tabular-nums; text-box: trim-both cap alphabetic; }
+    .brow2 b { font-size: 32px; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12); font-weight: 600; display: block; height: max-content;
+      font-family: var(--kf-font-num); font-variant-numeric: tabular-nums; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
     .brow2 kf-spark { color: var(--kf-accent); }
     .kpi .lbl { grid-area: l; }
     .kpi .stage-dots { display: flex; gap: 3px; margin-top: 8px; }
@@ -177,7 +177,6 @@ export class ViewReportType extends LitElement {
 
   /** 手机:把当前类型 tab 滚到横向滚动区正中(首次进入 / 换类型时) */
   updated() {
-    cjk(this.renderRoot);
     const el = this._tabsEl;
     const cur = el?.querySelector('.tab.cur');
     if (!cur || this._centered === cur.href) return;
@@ -236,7 +235,7 @@ export class ViewReportType extends LitElement {
     `;
   }
 
-  /** 四个数字:份数(近 7 天 ▲)/ 覆盖阶段(5 个点)/ 可看全文(细进度条)/ 有证据(细进度条) */
+  /** 四个数字:份数(+ 近 7 天 sparkline)/ 覆盖阶段(5 个点)/ 可看全文(40px 进度环,环心印百分比)/ 有证据(同)—— 数字始终在环左边 */
   #kpis(all) {
     const stages = new Set(all.map((e) => e.stage?.code).filter(Boolean));
     const full = all.filter((e) => e.body === 'text' || e.body === 'report').length;
@@ -254,8 +253,8 @@ export class ViewReportType extends LitElement {
       <div class="kpi" data-kpi="count"><div class="brow2"><b>${all.length}<small>份</small></b><kf-spark small .values=${days.map((d) => d / dmax)}></kf-spark></div><span class="lbl">近 7 天 ${days.reduce((a, b) => a + b, 0)}</span></div>
       <div class="kpi" data-kpi="stages"><div class="brow2"><b>${stages.size}<small>/5</small></b></div>
         <span class="lbl stage-dots">${unsafeHTML(['S1', 'S2', 'S3', 'S4', 'S5'].map((c) => `<i class="${stages.has(c) ? 'on' : ''}"></i>`).join(''))}</span></div>
-      <div class="kpi" data-kpi="full"><div class="brow2"><b>${full}<small>/${all.length}</small></b></div><span class="lbl">可看全文</span><span class="tr"><i style="width:${pct(full)}%"></i></span></div>
-      <div class="kpi" data-kpi="ev"><div class="brow2"><b>${ev}<small>/${all.length}</small></b></div><span class="lbl">有证据</span><span class="tr"><i style="width:${pct(ev)}%"></i></span></div>
+      <div class="kpi" data-kpi="full"><div class="brow2"><b>${full}<small>/${all.length}</small></b><kf-ring size=${40} .pct=${pct(full)} label=${`${pct(full)}%`}></kf-ring></div><span class="lbl">可看全文</span></div>
+      <div class="kpi" data-kpi="ev"><div class="brow2"><b>${ev}<small>/${all.length}</small></b><kf-ring size=${40} .pct=${pct(ev)} label=${`${pct(ev)}%`}></kf-ring></div><span class="lbl">有证据</span></div>
     </div>`;
   }
 

+ 3 - 4
src/pages/view-report.js

@@ -14,7 +14,7 @@ import { LitElement, css, html } from 'lit';
 import { useTokens } from '../components/base.js';
 import { reportOverview, findingsOf, fmtDate, chapterDigests, TYPE_FOR_ARTIFACT, reportTypeOf } from '../core/index.js';
 import { navigate, href } from '../utils/router.js';
-import { VIEW_CSS, cjk} from './shared.js';
+import { t, VIEW_CSS } from './shared.js';
 import { icon } from '../components/icons.js';
 
 export class ViewReport extends LitElement {
@@ -50,7 +50,6 @@ export class ViewReport extends LitElement {
   #io = null;
 
   updated() {
-    cjk(this.renderRoot);
     if (!this.db || this.#io) return;
     // 滚动高亮是增强能力:环境不支持 IntersectionObserver 时整页照常可读,只是不自动高亮
     if (typeof IntersectionObserver === 'undefined') return;
@@ -98,7 +97,7 @@ export class ViewReport extends LitElement {
     /* 视图宿主与列容器显式 block:否则自定义元素被排成行盒,块级子元素互相叠、互相撑高 */
     :host { display: block; }
     .col, .main-col { display: block; min-width: 0; }
-    article.ch { padding: 20px 24px; margin-bottom: 16px; scroll-margin-top: 144px; }
+    article.ch { padding: var(--kf-space-card) var(--kf-space-card-gap); margin-bottom: var(--kf-space-card); scroll-margin-top: 144px; }
     .ch-h { display: flex; gap: 12px; align-items: baseline; }
     .ch-no { font-family: var(--kf-font-num); font-size: 11px; line-height: 20px; color: var(--kf-accent-ink); border: 1px solid rgba(91, 91, 214, 0.30); border-radius: 6px; padding: 0 6px; font-variant-numeric: tabular-nums; }
     .ch-h h2 { margin: 0; font-size: 20px; line-height: 1.3; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; }
@@ -270,7 +269,7 @@ export class ViewReport extends LitElement {
               <div class="ch-h"><span class="en">${c.en}</span><span class="ch-no">${c.no}</span><h2>${c.title}</h2></div>
               ${c.subtitle ? html`<div class="muted2">${c.subtitle}</div>` : ''}
               ${c.lead ? html`<div class="lead">${String(c.lead).length > 160 ? String(c.lead).slice(0, 160) + '…' : c.lead}</div>` : ''}
-              ${c.stage ? html`<div class="muted2" style="margin-bottom:6px">对应阶段 ${c.stage.code} ${c.stage.name}</div>` : ''}
+              ${c.stage ? html`<div class="muted2" style="margin-bottom:6px">${t(`对应阶段 ${c.stage.code} ${c.stage.name}`)}</div>` : ''}
               ${c.sections.map((s) => html`<section class="sec" id=${s.id || ''}>${this.#section(s, db)}</section>`)}
 </article>`)}
 

+ 14 - 15
src/pages/view-reports.js

@@ -12,8 +12,7 @@ import { readCardQuery, patchOf, applyCardTool, paginate } from '../utils/card-v
 import { setQuery, navigate, href } from '../utils/router.js';
 import { reportCard, reportRow, sourceRow, CARD_CSS } from './report-card.js';
 import { cardToolbar, makeToolHandler, TOOLBAR_CSS } from './card-toolbar.js';
-import { section } from './shared.js';
-import { VIEW_CSS, cjk} from './shared.js';
+import { t, section, VIEW_CSS } from './shared.js';
 import '../components/kf-glass-card.js';
 import { icon } from '../components/icons.js';
 import { stageCodeOf } from '../core/index.js';
@@ -32,9 +31,9 @@ function weekly(entries) {
   return days.map((d) => d / max);
 }
 /** 阶段分布 → 条形:S1–S5 各几份(>= 结构没变,只是从百分比文字换成条) */
-const STAGE_ROWS = (t) => ['S1', 'S2', 'S3', 'S4', 'S5'].map((code) => {
-  const n = t.all.filter((e) => stageCodeOf(e.stage) === code || e.stage?.code === code).length;
-  return { label: code, value: t.count ? (n / t.count) * 100 : 0, text: String(n) };
+const STAGE_ROWS = (tp) => ['S1', 'S2', 'S3', 'S4', 'S5'].map((code) => {
+  const n = tp.all.filter((e) => stageCodeOf(e.stage) === code || e.stage?.code === code).length;
+  return { label: code, value: tp.count ? (n / tp.count) * 100 : 0, text: String(n) };
 });
 
 export class ViewReports extends LitElement {
@@ -55,7 +54,6 @@ export class ViewReports extends LitElement {
     super.connectedCallback();
     useTokens(this);
   }
-  updated() { cjk(this.renderRoot); }
 
 
   static styles = [VIEW_CSS, CARD_CSS, TOOLBAR_CSS, css`
@@ -63,9 +61,9 @@ export class ViewReports extends LitElement {
        改用可换行的自动列,宽了 5 张一行,窄了自然折成 3 + 2,每个格子都保证内容读得清。 */
     .types { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kf-space-in); margin: var(--kf-space-in) 0 0; align-items: start; }
     /* <a> 是玻璃容器本身(padding 由这里给);.t 只负责排版,不再撑高 */
-    .types > a { text-decoration: none; color: inherit; min-width: 0; padding: var(--density-space-lg); box-sizing: border-box; }
+    .types > a { text-decoration: none; color: inherit; min-width: 0; padding: var(--kf-space-card); box-sizing: border-box; }
     /* 类型片:名称 + 份数 + 近 7 天 sparkline(形状)+ 阶段分布条形(形状)+ 最新一条。文字只剩名字与时间 */
-    .t { display: grid; gap: var(--density-space-md); min-width: 0; align-content: start; }
+    .t { display: grid; gap: var(--kf-space-in); min-width: 0; align-content: start; }
     .t .src .it { max-width: 74px; font-size: 11px; }
     .t .src .who { font-size: 11px; }
     .t .src .tm { margin-left: 0; }
@@ -80,12 +78,13 @@ export class ViewReports extends LitElement {
     .t .n kf-spark { align-self: center; }
     .types kf-spark { --h: 16px; }
     .t .n b { font-size: 32px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12); font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1;
-      text-box: trim-both cap alphabetic; display: inline-flex; align-items: baseline; }
+      text-box-trim: trim-both; text-box-edge: cap alphabetic; display: inline-flex; align-items: baseline; }
     .t .n b .u { margin-left: 0.12em; font-size: 11px; font-weight: 500; color: var(--kf-ink-3); letter-spacing: 0; }
     .t .n kf-spark { color: var(--kf-accent); margin-bottom: 2px; }
     .t .tr { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
     /* 五阶段分布:条形对比(板块二 2.3)替代百分比文字 */
-    .t .tr kf-bars { flex: 1; min-width: 0; }
+    .t kf-bars { margin-top: 4px; color: var(--kf-accent); }
+    .t .stgmax { font-size: 11px; font-weight: 500; color: var(--kf-ink-3); margin-left: auto; white-space: nowrap; }
     .t .tr2 { display: flex; justify-content: flex-end; min-height: 16px; }
     .t .tr .doc { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
     .t .last { padding-top: 8px; border-top: 1px solid var(--kf-line); font-size: 11px; font-weight: 500; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); min-height: 32px; }
@@ -142,16 +141,16 @@ export class ViewReports extends LitElement {
     const pg = paginate(matched, q.page);
     const recent = (e) => e.at && Date.now() - Date.parse(e.at) <= 7 * 86400000;
     return html`
-      ${section('分类', `全部 ${c.total} 份`)}
+      ${section('分类', t(`全部 ${c.total} 份`))}
       <div class="types" data-types>
-        ${c.types.map((t) => html`
-          <a href=${href(`/reports/${t.type.key}`)} data-link data-type-card=${t.type.key} class="kf-card is-lift" style="display:block">
+        ${c.types.map((t, i) => html`
+          <a href=${href(`/reports/${t.type.key}`)} data-link data-type-card=${t.type.key} class="kf-card is-lift kf-enter" style="display:block;--i:${i}">
             <div class="t">
               <div class="h"><span class="ico" aria-hidden="true">${icon(TYPE_ICON[t.type.key] || 'grid', 18)}</span>
                 <h3>${t.type.name}</h3>
                 <span class="n"><b>${t.count}<span class="u">份</span></b><kf-spark small .values=${weekly(t.all)}></kf-spark></span></div>
-              <div class="tr"><kf-bars .rows=${STAGE_ROWS(t)}></kf-bars></div>
-              <div class="tr2"><span class="doc" title="可看全文">${icon('doc', 12)}${t.withBody}/${t.count}</span></div>
+              <div class="tr"><span class="doc" title="可看全文">${icon('doc', 12)}${t.withBody}/${t.count}</span></div>
+              <span class="tr"><kf-bars stack .rows=${STAGE_ROWS(t)}></kf-bars>${t.all.length ? html`<b class="stgmax">${t(`最多 ${Math.max(...STAGE_ROWS(t).map((x) => Number(x.text)))} 份`)}</b>` : ''}</span>
               <div class="last">${t.latest[0] ? sourceRow(t.latest[0], { nowrap: true }) : html`<span class="kf-muted">—</span>`}</div>
             </div>
           </a>`)}

+ 20 - 16
src/pages/view-stages.js

@@ -10,7 +10,7 @@ import { useTokens } from '../components/base.js';
 import { STAGES, stageOutcome, filterEntries } from '../core/index.js';
 import { setQuery, navigate } from '../utils/router.js';
 import { reportCard, CARD_CSS } from './report-card.js';
-import { VIEW_CSS, cjk, section } from './shared.js';
+import { t, VIEW_CSS, section } from './shared.js';
 import { icon } from '../components/icons.js';
 
 const TONE = { S1: 'blue', S2: 'violet', S3: 'teal', S4: 'amber', S5: 'rose' };
@@ -39,32 +39,37 @@ export class ViewStages extends LitElement {
     super.connectedCallback();
     useTokens(this);
   }
-  updated() { cjk(this.renderRoot); }
 
 
   static styles = [VIEW_CSS, CARD_CSS, css`
     .overview { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--kf-space-in); margin-top: var(--kf-space-in); }
-    .overview > .kf-card { padding: var(--density-space-lg); box-sizing: border-box; }
-    .ov { display: grid; gap: var(--density-space-sm); cursor: pointer; text-align: left; }
+    /* align-items: start —— 五张卡各自按内容高,不被行高拉齐(v11b 4.2) */
+    .overview { align-items: start; }
+    .overview > .kf-card { padding: var(--kf-space-card); box-sizing: border-box; }
+    .ov { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: var(--kf-space-in); cursor: pointer; text-align: left; }
+    .ov .b { grid-column: 1; } .ov kf-ring { grid-column: 2; grid-row: 1; }   /* 环与阶段徽标同一行右端 */
+    .ov .n, .ov .mix { grid-column: 1 / -1; }
     /* 阶段色 S1–S5:和卡片徽标、筛选色块同一套 */
-    .ov .b { justify-self: start; font: 600 11px/1 var(--kf-font); letter-spacing: 0.02em; color: #fff; padding: 4px 6px; border-radius: 5px; text-box: trim-both cap alphabetic; }
+    .ov .b { justify-self: start; font: 600 11px/1 var(--kf-font); letter-spacing: 0.02em; color: #fff; padding: 4px 6px; border-radius: 5px; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
+    .ov .b { justify-self: start; }
     .b.blue { background: var(--kf-s1); } .b.violet { background: var(--kf-s2); } .b.teal { background: var(--kf-s3); } .b.amber { background: var(--kf-s4); } .b.rose { background: var(--kf-s5); }
     /* 份数:大数字 + 小单位(1.2),后面跟「人 / 证据」两个小数字 */
     .ov .n { display: flex; align-items: baseline; gap: 0; }
-    .ov .n b { font-size: 20px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--kf-ink); font-variant-numeric: tabular-nums; text-box: trim-both cap alphabetic; }
+    .ov .n b { font-size: 20px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--kf-ink); font-variant-numeric: tabular-nums; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
     .ov .n .u { margin-left: 0.12em; font-size: 11px; font-weight: 500; color: var(--kf-ink-3); }
-    .ov .n span { display: inline-flex; align-items: center; gap: 2px; margin-left: 10px; font-size: 11px; font-weight: 500; color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
-    .ov .n .ev { margin-left: 10px; }
+    .ov .n span { display: inline-flex; align-items: center; gap: 4px; margin-left: var(--kf-space-in); font-size: 11px; font-weight: 500; color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
+    .ov .n .ev { margin-left: var(--kf-space-in); }
     /* 占比条:4px、圆角 = 高度/2、单色(原来是一条蓝→紫的装饰渐变) */
     .ov .bar { height: 4px; border-radius: 2px; background: rgba(16, 24, 40, 0.07); overflow: hidden; }
     .ov .bar i { display: block; height: 100%; border-radius: 2px; background: var(--tone, var(--kf-ink-3)); }
     .ov .mix { display: flex; gap: 2px; height: 3px; border-radius: 2px; overflow: hidden; }
     .ov .mix i { display: block; height: 100%; }
     .overview > .kf-card[aria-current="true"] { box-shadow: var(--kf-shadow-lift); outline: 2px solid var(--kf-focus); outline-offset: 1px; }
-    .tool { display: flex; gap: 8px; align-items: center; margin: var(--kf-space-in) 0; flex-wrap: wrap; }
+    .tool { display: flex; gap: 8px; align-items: center; margin: var(--kf-space-section) 0 var(--kf-space-in); flex-wrap: wrap; }
     .tool .grow { flex: 1; }
     .tool .cnt { font: 500 11px var(--kf-font-num); color: var(--kf-ink); font-variant-numeric: tabular-nums; }
-    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--kf-space-in); }
+    /* align-items: start —— 同一排的卡各自按内容高,不被行高拉齐(v11b 4.2) */
+    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--kf-space-in); align-items: start; }
     [data-stage="S1"] { --tone: var(--kf-s1); } [data-stage="S2"] { --tone: var(--kf-s2); } [data-stage="S3"] { --tone: var(--kf-s3); }
     [data-stage="S4"] { --tone: var(--kf-s4); } [data-stage="S5"] { --tone: var(--kf-s5); }
     @media (max-width: 1100px) { .overview { grid-template-columns: repeat(2, 1fr); } }
@@ -87,39 +92,38 @@ export class ViewStages extends LitElement {
     const mineSel = this.#q('mine') === '1' && this.me;
     const rows = STAGES.map((s) => stageOutcome(db, s.code));
     const max = Math.max(...rows.map((r) => r.total), 1);
+    const total = rows.reduce((n, r) => n + r.total, 0);
     const shownRows = rows.filter((r) => !only || r.stage.code === only);
     const items = shownRows.flatMap((r) => r.groups.flatMap((g) => g.items))
       .filter((e) => filterEntries([e], { mine: mineSel ? this.me : '', evidenceOnly: evOnly }).length)
       .sort((a, b) => (b.at || '').localeCompare(a.at || ''));
-    const total = rows.reduce((n, r) => n + r.total, 0);
 
     return html`
       <div class="overview" data-overview>
-        ${rows.map((r) => html`<div class="kf-card is-lift" role="button" tabindex="0" data-stage=${r.stage.code} aria-current=${only === r.stage.code ? 'true' : 'false'}
+        ${rows.map((r, i) => html`<div class="kf-card is-lift kf-enter" style=${`--i:${i}`} role="button" tabindex="0" data-stage=${r.stage.code} aria-current=${only === r.stage.code ? 'true' : 'false'}
           @click=${() => this.#set({ stage: only === r.stage.code ? '' : r.stage.code })}
           @keydown=${(e) => { if (e.key === 'Enter') this.#set({ stage: only === r.stage.code ? '' : r.stage.code }); }}>
           <div class="ov">
             <span class="b ${TONE[r.stage.code]}">${r.stage.code}</span>
+            <kf-ring size=${40} .pct=${(r.total / max) * 100} label=${String(r.total)}></kf-ring>
             <div class="n"><b>${r.total}</b><span class="u">份</span><span>${icon('user', 12)}${r.ownerCount}</span><span class="ev">${icon('shield', 12)}${r.groups.reduce((n, g) => n + g.verified, 0)}</span></div>
-            <div class="bar"><i style="width:${Math.round((r.total / max) * 100)}%"></i></div>
             <div class="mix">${r.groups.map((g, i) => html`<i title=${`${g.label} ${g.count}`} style="width:${(g.count / Math.max(r.total, 1)) * 100}%;opacity:${(1 - i * 0.22).toFixed(2)};background:var(--tone)"></i>`)}</div>
           </div>
         </div>`)}
       </div>
 
-      ${section('阶段', '五阶段产出', html`<span>${total} 份</span>`)}
       <div class="tool" data-toolbar>
         <button class="kf-chip" aria-pressed=${!only ? 'true' : 'false'} @click=${() => this.#set({ stage: '' })}>${icon('inbox', 14)} ${total}</button>
         ${rows.map((r) => html`<button class="kf-chip" data-stage-filter=${r.stage.code} aria-pressed=${only === r.stage.code ? 'true' : 'false'} @click=${() => this.#set({ stage: only === r.stage.code ? '' : r.stage.code })}>${r.stage.code} <span class="n">${r.total}</span></button>`)}
         <span class="grow"></span>
         <button class="kf-chip" data-filter="mine" aria-pressed=${mineSel ? 'true' : 'false'} title="只看我的" @click=${() => this.#set({ mine: mineSel ? '' : '1' })}>${icon('user', 14)}</button>
         <button class="kf-chip" data-filter="ev" aria-pressed=${evOnly ? 'true' : 'false'} title="只看有证据的" @click=${() => this.#set({ ev: evOnly ? '' : '1' })}>${icon('shield', 14)}</button>
-        <span class="cnt" data-count>${items.length} / ${total}</span>
+        <span class="cnt" data-count>${t(`${items.length} / ${total}`)}</span>
       </div>
 
       ${items.length
         ? html`<div class="cards" data-cards>${items.map((e) => html`<kf-glass-card surface="2" lift @card-activate=${() => this.#open(e)}>${reportCard({ e, expanded: true })}</kf-glass-card>`)}</div>`
-        : html`<kf-glass-card surface="1"><kf-empty title=${only ? `${only} 还没有产出` : '还没有产出'} action-label=${only || evOnly || mineSel ? '清除筛选' : '去工作台'}
+        : html`<kf-glass-card surface="1"><kf-empty title=${only ? t(`${only} 还没有产出`) : '还没有产出'} action-label=${only || evOnly || mineSel ? '清除筛选' : '去工作台'}
             @empty-action=${() => (only || evOnly || mineSel ? this.#set({ stage: '', ev: '', mine: '' }) : navigate('/'))}></kf-empty></kf-glass-card>`}
     `;
   }

+ 41 - 29
src/pages/view-workbench.js

@@ -10,7 +10,7 @@ import { forMe, poolOverview, liveBoard, STAGES, sortOpportunities } from '../co
 
 const sortByNewest = (rows) => sortOpportunities(rows, 'newest');
 import { href } from '../utils/router.js';
-import { VIEW_CSS, cjk, head } from './shared.js';
+import { t, VIEW_CSS, head } from './shared.js';
 import { icon } from '../components/icons.js';
 
 const TONE = { S1: 'blue', S2: 'violet', S3: 'teal', S4: 'amber', S5: 'rose' };
@@ -31,25 +31,27 @@ export class ViewWorkbench extends LitElement {
     super.connectedCallback();
     useTokens(this);
   }
-  updated() { cjk(this.renderRoot); }
 
 
   static styles = [VIEW_CSS, css`
     /* 跨级间距(v11 3.1,可校验):卡片内 16 / 卡内元素 12 / 卡与卡 24 / 区块之间 48。
        这里内紧外松:两个一组(我的进度 + 现场)24,一组结束 → 下一个区块 48,区块标题 → 内容 12。 */
+    /* 卡片之间 24;卡片在图里会被拉伸到同一行高,所以卡片本身就是 flex 列:位置靠上,空的地方露出来 */
     .grid2 { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--kf-space-card-gap); align-items: start; }
+    /* 两张卡内容高度不同:各自按内容收缩,不被栅格拉伸(v11b 4.2) */
+    .grid2 > kf-glass-card { align-self: start; }
     /* 页顶 → 首个区块 16(任务书 3.1) */
     /* 页顶 → 首个区块 16(任务书 3.1):页头、第二个区块标题都收这条 */
     .p-head { padding-top: var(--kf-space-top); padding-bottom: var(--kf-space-top); }
     .hd:nth-of-type(n+2) { margin-top: var(--kf-space-card-gap); }
     /* 卡与卡 24(任务书 3.1);四张卡里元素 12 */
     .grid4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--kf-space-card-gap); align-items: start; margin-top: var(--kf-space-in); }
-    .hd { display: flex; align-items: center; gap: var(--density-space-sm); margin: var(--kf-space-section) 0 0; min-height: 28px; }
-    .who { display: flex; align-items: center; gap: var(--density-space-md); flex-wrap: wrap; }
+    .hd { display: flex; align-items: center; gap: var(--kf-space-in); margin: var(--kf-space-section) 0 0; min-height: 28px; }
+    .who { display: flex; align-items: center; gap: var(--kf-space-in); flex-wrap: wrap; }
     .who h3 { margin: 0; font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
-    .who .n { margin-left: auto; display: flex; gap: var(--density-space-xl); }
+    .who .n { margin-left: auto; display: flex; gap: var(--kf-space-card-gap); }
     .who .n div { text-align: right; }
-    .who .n b { display: block; font-size: 20px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12); font-variant-numeric: tabular-nums; text-box: trim-both cap alphabetic; }
+    .who .n b { display: block; font-size: 20px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12); font-variant-numeric: tabular-nums; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
     .who .n span { font-size: 11px; font-weight: 500; line-height: 1.4; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); }
     .avatar { width: 44px; height: 44px; border-radius: 10px; display: grid; place-items: center; font-size: 20px; font-weight: 600; color: #fff;
       background: var(--kf-accent); flex: none; }
@@ -57,8 +59,10 @@ export class ViewWorkbench extends LitElement {
     .hd .go { margin-left: auto; font-variant-numeric: tabular-nums; }
     /* KPI 卡:数值 + 单位分层(1.2)+ 右上角 sparkline(板块二:形状替一句话)。一眼给:大数字 / 近 7 天走势 / 标签 */
     .kpi { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "v spark" "l l"; row-gap: 6px; column-gap: 8px; align-items: end; }
-    .kpi b { grid-area: v; display: flex; align-items: baseline; gap: 0; font-size: 32px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12);
-      font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; text-box: trim-both cap alphabetic; white-space: nowrap; min-width: 0; }
+    /* v11b:修剪后的行盒只有「容器是盒子」才生效 —— display:flex 的容器不参与首末行修剪(实测仍 32px),
+       所以这里用 block + height: max-content;内部数字与单位仍是 inline,靠字号的 vertical-align 对齐基线。 */
+    .kpi b { grid-area: v; display: block; height: max-content; font-size: 32px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12);
+      font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; text-box-trim: trim-both; text-box-edge: cap alphabetic; white-space: nowrap; min-width: 0; }
     /* 单位切到最近的字号档(11),留出 0.12em(单位是 11px 时 em 也跟着单位走,所以这里用固定 1.5px 对齐视觉) */
     .kpi b .u { margin-left: 0.14em; font-size: 11px; font-weight: 500; color: var(--kf-ink-3); letter-spacing: 0; }
     .kpi b.dim { color: var(--kf-ink-3); }
@@ -66,14 +70,15 @@ export class ViewWorkbench extends LitElement {
       white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
     .kpi kf-spark { grid-area: spark; color: var(--kf-accent); align-self: end; margin-bottom: 3px; }
     /* 阶段条:五格一格一句「S1 · 3 份」,徽标 + 数值同行 */
-    .stagebar { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--density-space-sm); }
-    .stg { padding: var(--density-space-sm) var(--density-space-md); border-radius: 10px; background: var(--kf-fill); border: 1px solid var(--kf-line);
-      display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 4px 8px; min-width: 0; }
-    .stg .b { font: 600 11px/1 var(--kf-font); color: #fff; padding: 4px 6px; border-radius: 5px; letter-spacing: 0.02em; text-box: trim-both cap alphabetic; }
+    .stagebar { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--kf-space-in); }
+    .stg { padding: var(--kf-space-in); border-radius: 10px; background: var(--kf-fill); border: 1px solid var(--kf-line);
+      display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 4px 8px; min-width: 0; }
+    .stg kf-ring { grid-row: 1 / 3; grid-column: 3; color: var(--tone, var(--kf-accent)); }
+    .stg .b { font: 600 11px/1 var(--kf-font); color: #fff; padding: 4px 6px; border-radius: 5px; letter-spacing: 0.02em; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
     .stg .b.blue { background: var(--kf-s1); } .stg .b.violet { background: var(--kf-s2); } .stg .b.teal { background: var(--kf-s3); }
     .stg .b.amber { background: var(--kf-s4); } .stg .b.rose { background: var(--kf-s5); }
     .stg .n { justify-self: end; font-size: 16px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12);
-      font-variant-numeric: tabular-nums; text-box: trim-both cap alphabetic; }
+      font-variant-numeric: tabular-nums; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
     .stg .f { grid-column: 1 / -1; height: 3px; border-radius: 2px; background: rgba(16, 24, 40, 0.07); overflow: hidden; }
     .stg .f i { display: block; height: 100%; border-radius: 2px; background: currentColor; width: calc(var(--v) * 100%); }
     .stg.todo { color: var(--kf-ink-3); background: transparent; border-style: dashed; }
@@ -82,7 +87,7 @@ export class ViewWorkbench extends LitElement {
     .stg.blue { color: var(--kf-s1); } .stg.violet { color: var(--kf-s2); } .stg.teal { color: var(--kf-s3); } .stg.amber { color: var(--kf-s4); } .stg.rose { color: var(--kf-s5); }
     /* 最近动过:3 行「标签 ···· 值」(2.5,比标签/值两行省一半高度) */
     .recent { margin-top: var(--kf-space-in); }
-    .recent-h { display: flex; align-items: baseline; gap: 8px; margin-bottom: var(--density-space-xs); }
+    .recent-h { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
     .recent-h span { font-size: 11px; font-weight: 500; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); }
     .recent .kv { font-size: 13px; }
     .recent .kv .v b { font-weight: 600; font-size: 14px; margin-right: 2px; }
@@ -102,27 +107,30 @@ export class ViewWorkbench extends LitElement {
     .go:hover { color: var(--kf-accent); }
     .foot-links { display: none; }
     /* 空态(板块八 8.1):虚线框与有数据时近似等高,不闪、不跳 */
-    .empty { display: grid; place-items: center; gap: 8px; min-height: 72px; margin-top: var(--kf-space-in); color: var(--kf-ink-3); font-size: 13px;
+    /* 虚线占位(v11b 4.2):高度 = 内容(一行 18px + 上下 10px)≈ 38px,不再撑到 72px / 半张卡;
+       卡片本身 align-items: start,内容多高卡就多高,不留「撑高留白」。 */
+    .empty { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 10px 12px; color: var(--kf-ink-3); font-size: 13px; line-height: 18px;
       border: 1px dashed color-mix(in srgb, var(--kf-ink-3) 24%, transparent); border-radius: 10px; }
+    .empty .go { margin-left: auto; }
     .stagebar { margin-top: var(--kf-space-in); }
     .latest { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--kf-space-card-gap); align-items: start; margin-top: var(--kf-space-in); }
     .latest kf-opportunity-card { --i: 0; }
     @media (max-width: 1100px) { .grid4, .latest { grid-template-columns: repeat(2, minmax(0, 1fr)); } .grid2 { grid-template-columns: 1fr; } }
     /* 手机:单列;KPI 两列;阶段条五格压缩;可点区域 44px */
     @media (max-width: 767px) {
-      .grid2 { grid-template-columns: minmax(0, 1fr); gap: var(--density-space-md); }
-      .grid4 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--density-space-md); }
-      .latest { grid-template-columns: minmax(0, 1fr); gap: var(--density-space-md); }
+      .grid2 { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-in); }
+      .grid4 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--kf-space-in); }
+      .latest { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-in); }
       .kpi b { font-size: 28px; }
       .kpi kf-spark { display: none; }
-      .stagebar { gap: var(--density-space-xs); }
+      .stagebar { gap: 4px; }
       .stg { padding: 6px 8px; }
       .todo li { font-size: 14px; min-height: 44px; }
       .todo li a, .go, .hd .go { min-height: 44px; display: inline-flex; align-items: center; }
       .todo li a { flex: 1; min-width: 0; }
       .go { min-width: 44px; justify-content: center; padding: 0 6px; }
       .stalled li { flex-wrap: wrap; }
-      .foot-links { display: flex; gap: 8px; margin-top: var(--density-space-lg); }
+      .foot-links { display: flex; gap: 8px; margin-top: var(--kf-space-card); }
       .foot-links a { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; border-radius: 12px; font-size: 14px; font-weight: 600;
         color: var(--kf-accent-ink); text-decoration: none; border: 1px solid transparent;
         background: linear-gradient(var(--kf-glass-chip), var(--kf-glass-chip)) padding-box, var(--kf-edge-chip) border-box; }
@@ -163,7 +171,7 @@ export class ViewWorkbench extends LitElement {
       <div class="grid2 kf-enter" style="margin-top:0; --i:0">
         <kf-glass-card surface="2">${this.#mine(me, pool)}</kf-glass-card>
         <kf-glass-card surface="2" tone=${live.stalled.length ? 'danger' : 'ok'}>
-          <div class="who"><h3>${live.stalled.length ? `${live.stalled.length} 人卡住` : '现场正常'}</h3>
+          <div class="who"><h3>${live.stalled.length ? t(`${live.stalled.length} 人卡住`) : '现场正常'}</h3>
             <a class="go" style="margin-left:auto" href=${href('/pool')} data-link aria-label="去机会卡池">${icon('arrow', 14)}</a></div>
           ${live.stalled.length ? html`<ul class="stalled">
             ${live.stalled.slice(0, 4).map((s) => html`<li data-stalled=${s.name}>
@@ -189,15 +197,15 @@ export class ViewWorkbench extends LitElement {
   #mine(me, pool) {
     if (!this.me) {
       // 空态(板块八):说清为什么空 + 给一个动作 + 卡片框不消失(骨架就是上面这张卡)
+      // 空态(v11b 4.2):虚线框只占一行高度(≈38px + 间隔),不再放「五格空进度 + 半张卡高的占位」
       return html`<div class="who"><div class="avatar">?</div><h3>还没选「我是」</h3></div>
-        <div class="stagebar">${STAGES.map((s) => html`<div class="stg ${TONE[s.code]} todo" data-stage=${s.code}>
-          <span class="b ${TONE[s.code]}">${s.code}</span><span class="n ph">·</span>
-          <span class="f" aria-hidden="true"><i style="--v:0"></i></span></div>`)}</div>
-        <div class="empty"><span>选了自己,这里就显示你的进度</span>
-          <a class="go" href=${href('/members')} data-link>${icon('user', 14)} 去成员页选</a></div>`;
+        <div class="empty"><span>选了自己,就显示你的阶段进度</span>
+          <a class="go" href=${href('/members')} data-link>${icon('user', 14)} 选我</a></div>`;
     }
     const done = STAGES.filter((s) => me.stagesDone.includes(s.code));
     const max = Math.max(1, ...STAGES.map((s) => me.mine.filter((a) => a.stageObj?.code === s.code).length));
+    // 阶段完成度环:环 = 本阶段份数占「全池该阶段最多份数」的比例(页面上每个阶段都有自己的最大值,这里取本阶段 5 格里的最大值)
+    const stageMax = max;
     const sp = this.#weekly('artifact');
     return html`
       <div class="who">
@@ -209,12 +217,16 @@ export class ViewWorkbench extends LitElement {
           <div><b>${me.pendingScores.length}</b><span>待评分</span></div>
         </div>
       </div>
+      ${me.stagesDone.length || me.stagesMissing.length ? html`<div class="recent-h" data-done>已完成 ${me.stagesDone.length} / 5 个阶段</div>` : ''}
       <div class="stagebar">
-        ${STAGES.map((s) => {
+        ${STAGES.map((s, i) => {
           const n = me.mine.filter((a) => a.stageObj?.code === s.code).length;
           const ok = done.includes(s.code);
-          return html`<div class="stg ${TONE[s.code]} ${ok ? '' : 'todo'}" data-stage=${s.code}>
-            <span class="b ${TONE[s.code]}">${s.code}</span><span class="n">${n || '—'}</span>
+          // 阶段完成度环(v11b 2.2):环 40px / 描边 3px / 环心印读数;旁边的 n 是「份」的精确数,环只说形状
+          return html`<div class="stg kf-enter ${TONE[s.code]} ${ok ? '' : 'todo'}" style=${`--i:${i + 1}`} data-stage=${s.code}>
+            <span class="b ${TONE[s.code]}">${s.code}</span>
+            <span class="n">${n || '—'}</span>
+            <kf-ring size=${40} .pct=${(n / stageMax) * 100} label=${String(n)}></kf-ring>
             <span class="f" aria-hidden="true"><i style=${`--v:${(n / max).toFixed(2)}`}></i></span></div>`;
         })}
       </div>

+ 3 - 1
src/services/board-service.js

@@ -17,6 +17,7 @@ import { createStorage } from '../utils/storage.js';
 import {
   buildModel, forMe as coreForMe, STATUS_DECISION, statusPatch, scorePatch, scoreStats, toTime, oppStatusOf,
 } from '../core/index.js';
+import { cjkDeep } from '../utils/cjk-deep.js';
 
 export class BoardService {
   status = signal('idle');
@@ -56,7 +57,8 @@ export class BoardService {
         });
         // 原始行里 Parse 的 Date 是 {__type,iso};core.toTime 能直接读,这里保留原始形态便可
         Object.assign(raw, byName);
-        const db = buildModel(raw, { now: Date.now(), stuck: CONFIG.stuck });
+        // 中英混排 U+2009 在数据边界统一落地(v11c):视图与组件拿到的就已经是正确文本
+        const db = cjkDeep(buildModel(raw, { now: Date.now(), stuck: CONFIG.stuck }));
         this.db.set(db);
         this.error.set(null);
         this.status.set('ready');

+ 18 - 14
src/styles/tokens.js

@@ -172,10 +172,12 @@ export const TOKENS_CSS = `:root, :host {
          中文负字距上限 -0.03em,超过笔画粘连(西文可用 -4.75%,中文不行)。 */
   --lh-body: 1.75; --lh-card: 1.6; --lh-title: 1.3;
   --ls-cn-small: 0.02em; --ls-cn-body: 0; --ls-cn-display: -0.02em;
-  --trim: trim-both cap alphabetic;   /* text-box(Chrome 133+;不支持的浏览器只是行盒略高,不影响可读) */
+  /* v11b:trim 只写两个**独立长属性**。简写 text-box: trim-both cap alphabetic 在 Chrome 153 被整条丢弃
+     (computed 只剩 cap alphabetic,行盒 32px 不缩);长属性写法实测 .b 行盒 32 → 20.95px。 */
+  --tb-trim: trim-both; --tb-edge: cap alphabetic;
 
-  /* 间距层级(v11 板块三,可按跨级关系校验):卡内 16 / 卡内元素 12 / 卡间 24 / 区块间 32(=1.5×24,取区块级同一档) / 页顶→首块 16 */
-  --kf-space-card: 16px; --kf-space-in: 12px; --kf-space-card-gap: 24px; --kf-space-section: 32px; --kf-space-top: 16px;
+  /* 间距层级(v11b 第三/四节,四档,可按跨级关系校验):卡内 16 / 卡内元素 12 / 卡间 24 / 区块间 48 / 页顶→首块 16 */
+  --kf-space-card: 16px; --kf-space-in: 12px; --kf-space-card-gap: 24px; --kf-space-section: 48px; --kf-space-top: 16px;
   /* 密度分级(Sitka):改一个 data-density 重写 8 个 token,全站跟随 */
   --density-space-xs: 4px; --density-space-sm: 8px; --density-space-md: 12px;
   --density-space-lg: 16px; --density-space-xl: 24px; --density-space-2xl: 32px;
@@ -222,7 +224,7 @@ export const BASE_CSS = `
   --density-space-xs: 2px; --density-space-sm: 6px; --density-space-md: 8px;
   --density-space-lg: 12px; --density-space-xl: 16px; --density-space-2xl: 32px;
   --density-title-off: 4px; --density-detail: 0fr;
-  --kf-space-card: 12px; --kf-space-in: 8px; --kf-space-card-gap: 16px; --kf-space-section: 32px;
+  --kf-space-card: 12px; --kf-space-in: 8px; --kf-space-card-gap: 16px; --kf-space-section: 48px;
 }
 
 /* 正文 / 标题默认排版(组件按需覆盖) */
@@ -345,7 +347,7 @@ summary:focus:not(:focus-visible), [tabindex]:focus:not(:focus-visible) { outlin
   backdrop-filter: blur(var(--kf-blur-chip)) saturate(180%);
   box-shadow: var(--kf-shadow-chip);
   color: var(--kf-ink); font: 500 13px/1 var(--kf-font); cursor: pointer;
-  text-box: trim-both cap alphabetic;
+  text-box-trim: trim-both; text-box-edge: cap alphabetic;
   transition: var(--kf-t);
 }
 .kf-btn:hover {
@@ -369,7 +371,7 @@ summary:focus:not(:focus-visible), [tabindex]:focus:not(:focus-visible) { outlin
     var(--kf-edge-chip) border-box;
   box-shadow: var(--kf-shadow-chip);
   font: 500 13px/1 var(--kf-font); color: var(--kf-ink); cursor: pointer;
-  text-box: trim-both cap alphabetic;
+  text-box-trim: trim-both; text-box-edge: cap alphabetic;
   transition: var(--kf-t);
 }
 .kf-chip:hover {
@@ -386,7 +388,7 @@ summary:focus:not(:focus-visible), [tabindex]:focus:not(:focus-visible) { outlin
 .kf-muted { color: var(--kf-ink-3); }
 .kf-num, .kf-mono { font-variant-numeric: tabular-nums; }
 .kf-mono { font-family: var(--kf-mono); }
-.kf-kicker { font-size: var(--fs-xs); line-height: 1.3; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-weight: var(--fw-medium); text-box: trim-both cap alphabetic; }
+.kf-kicker { font-size: var(--fs-xs); line-height: 1.3; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-weight: var(--fw-medium); text-box-trim: trim-both; text-box-edge: cap alphabetic; }
 .kf-title { color: var(--kf-ink); font-weight: var(--fw-bold); letter-spacing: -0.01em; line-height: 1.3; }
 /* 主数值:32 / 600 / 1.1 / -0.02em / tabular-nums —— 决定「高管级」观感的那一档 */
 .kf-metric { font-size: var(--fs-xl); font-weight: var(--fw-bold); line-height: 1.1; letter-spacing: -0.02em;
@@ -397,10 +399,11 @@ summary:focus:not(:focus-visible), [tabindex]:focus:not(:focus-visible) { outlin
 /* ============================================================
  * 中文排版工具类(v11 板块一)
  * ============================================================ */
-/* 1.1 单行文本容器:行盒修剪 text-box(只用于单行;多行正文禁用,会破坏行距) */
+/* 1.1 单行文本容器:行盒修剪(只用于单行;多行正文禁用,会破坏行距)。
+   用 text-box-trim / text-box-edge 两个长属性,不用 text-box 简写(v11b 实测简写被 Chrome 丢弃 trim-both)。 */
 .tb-cap, .kf-title, .kf-metric, .kf-metric-label, .kf-kicker, .kf-btn, .kf-chip,
 h1, h2, h3 {
-  text-box: trim-both cap alphabetic;
+  text-box-trim: trim-both; text-box-edge: cap alphabetic;
 }
 .kf-title { letter-spacing: var(--ls-cn-display); line-height: 1.25; }
 /* 1.2 单位分层:小一号、灰一档、留 0.12em —— 数字成为视觉主角 */
@@ -458,17 +461,18 @@ svg.i { flex: none; vertical-align: -0.125em; }
 .ring .ring-track { stroke: rgba(16, 24, 40, 0.08); }
 .ring .ring-fill { stroke: var(--kf-accent); stroke-linecap: round; stroke-dasharray: 100; stroke-dashoffset: calc(100 - var(--pct, 0));
   transition: stroke-dashoffset 600ms cubic-bezier(0.4, 0, 0.2, 1); }
-.bars .brow { display: grid; grid-template-columns: 64px 1fr 40px; align-items: center; gap: 8px; font-size: 12px; line-height: 1; }
+/* 条形对比(横向):label / 轨道 4px 圆角 2px / 数值右对齐 32–40px;最大值占满,其余按比例 */
+.bars .brow { display: grid; grid-template-columns: minmax(0, 72px) minmax(0, 1fr) 40px; align-items: center; gap: 8px; font-size: 12px; line-height: 1; }
 .bars .track { height: 4px; border-radius: 2px; background: rgba(16, 24, 40, 0.07); overflow: hidden; }
-.bars .fill { height: 100%; border-radius: 2px; background: currentColor; width: calc(var(--v) * 1%); transition: width 500ms cubic-bezier(0.4, 0, 0.2, 1); }
-.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 10px); grid-auto-columns: 10px; gap: 2px; }
-.heat i { width: 10px; height: 10px; border-radius: 2px; background: color-mix(in oklab, var(--kf-accent) calc(var(--v) * 100%), var(--gray-5)); }
+.bars .fill { display: block; height: 100%; border-radius: 2px; background: currentColor; width: calc(var(--v) * 1%); min-width: 2px; transition: width 500ms cubic-bezier(0.4, 0, 0.2, 1); }
+.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, var(--cell, 12px)); grid-auto-columns: var(--cell, 12px); gap: 2px; }
+.heat i { width: var(--cell, 12px); height: var(--cell, 12px); border-radius: 2px; background: color-mix(in oklab, var(--kf-accent) calc(var(--v) * 100%), var(--gray-5)); }
 .kv { display: flex; align-items: baseline; gap: 8px; font-size: 13px; line-height: 1.6; }
 .kv::before { content: ""; order: 1; flex: 1; border-bottom: 1px dotted color-mix(in srgb, currentColor 20%, transparent); transform: translateY(-3px); }
 .kv .k { order: 0; color: var(--kf-ink-3); }
 .kv .v { order: 2; font-variant-numeric: tabular-nums; color: var(--kf-ink); }
 /* 空态 / 错误态(v11 板块八):空态保留卡片框,只换内容;错误态 6% 染色 + 1px 同色半透明描边,不用红大块 */
-.empty { display: grid; place-items: center; gap: 8px; min-height: 120px; color: var(--kf-ink-3); font-size: 13px;
+.empty { display: grid; place-items: center; gap: 8px; min-height: 64px; padding: 10px 12px; box-sizing: border-box; color: var(--kf-ink-3); font-size: 13px;
   border: 1px dashed color-mix(in srgb, var(--kf-ink-3) 24%, transparent); border-radius: 10px; }
 .alert { border-left: none; background: color-mix(in srgb, #E5484D 6%, rgba(255, 255, 255, 0.72)); border-radius: 10px;
   padding: 12px 14px; font-size: 13px; line-height: 1.6; box-shadow: var(--elev-1), 0 0 0 1px color-mix(in srgb, #E5484D 16%, transparent); }

+ 70 - 0
src/utils/cjk-deep.js

@@ -0,0 +1,70 @@
+/**
+ * utils/cjk-deep.js —— 数据边界的中英混排落地(v11c)。
+ *
+ * 为什么在数据边界而不是渲染期:
+ *   1. 渲染期改 DOM(MutationObserver / updated() 里遍历)是「先渲染错、再事后擦」,
+ *      带来常驻扫描、全局挂载、以及 Lit 下次渲染把改动覆盖回去的竞态;
+ *   2. 在页面模板里逐个包 t() 要改几十处,漏一处就是 338 处残留(实测过);
+ *   3. 数据只有**一个入口**(board-service.load → buildModel),在这里做一次,
+ *      所有视图、所有组件、所有未来新加的页面自动生效。
+ *
+ * 为什么安全:
+ *   - 只在「汉字 ↔ 拉丁/数字交界」处把空白换成 U+2009,其余字符一个不动;
+ *   - 幂等:已经带 U+2009 的不再处理;
+ *   - 只处理**展示用**的 model(buildModel 的产物),不碰 core 里的匹配/筛选逻辑源数据;
+ *   - 跳过 URL / 文件名 / 纯 ASCII / ObjectId / 日期 ISO 串。
+ */
+
+import { spaceCJK, THIN } from './type.js';
+
+const HAS_CJK = /[\u4e00-\u9fa5]/;
+/** 汉字与拉丁/数字相邻(中间可以是普通空格、NBSP、全角空格,或已是一个 U+2009) */
+const NEAR = /[\u4e00-\u9fa5][ \t\u00a0\u3000\u2009]*[A-Za-z0-9]|[A-Za-z0-9][ \t\u00a0\u3000\u2009]*[\u4e00-\u9fa5]/;
+/** 不该动的串:URL、路径、文件名、ISO 时间、纯 ASCII 标识符、邮箱 */
+const SKIP = /^(?:[a-z]+:\/\/|mailto:|\/|\.\/|\.\.\/)|[\w.+-]+@[\w-]+\.[\w.]+|[\w-]+\.(?:js|mjs|json|md|css|png|jpe?g|svg|webp|html?|pdf|sh|txt)\b|^\d{4}-\d{2}-\d{2}T[\d:.]+Z?$|^[A-Za-z0-9_-]{12,}$/;
+
+/** 单串处理(幂等、无汉字或不相邻则原样返回) */
+export function cjkString(s) {
+  if (typeof s !== 'string' || s.length === 0) return s;
+  if (!HAS_CJK.test(s) || !NEAR.test(s)) return s;
+  if (SKIP.test(s.trim())) return s;
+  return spaceCJK(s);
+}
+
+/**
+ * 深度遍历对象/数组,处理所有字符串值。
+ * - 返回新对象(不改原 model,便于对比与回退)
+ * - 保留 Date、null、数值、布尔、函数引用
+ * - 循环引用保护
+ */
+export function cjkDeep(value, seen = new WeakMap()) {
+  if (typeof value === 'string') return cjkString(value);
+  if (value === null || typeof value !== 'object') return value;
+  if (value instanceof Date) return value;
+  if (Array.isArray(value)) {
+    if (seen.has(value)) return seen.get(value);
+    const out = [];
+    seen.set(value, out);
+    for (const v of value) out.push(cjkDeep(v, seen));
+    return out;
+  }
+  if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
+    // Parse 的 {__type:'Date',iso:...} 这类包装、Map/Set 等一律原样返回
+    if (value.__type === 'Date') return value;
+    return value;
+  }
+  if (seen.has(value)) return seen.get(value);
+  const out = {};
+  seen.set(value, out);
+  for (const k of Object.keys(value)) out[k] = cjkDeep(value[k], seen);
+  return out;
+}
+
+/** 统计:给验收脚本用,检查一个 model 里 U+2009 的数量 */
+export function countThin(value, seen = new WeakSet()) {
+  if (typeof value === 'string') return value.split(THIN).length - 1;
+  if (value === null || typeof value !== 'object' || seen.has(value)) return 0;
+  seen.add(value);
+  if (Array.isArray(value)) return value.reduce((n, v) => n + countThin(v, seen), 0);
+  return Object.keys(value).reduce((n, k) => n + countThin(value[k], seen), 0);
+}

+ 10 - 3
src/utils/type.js

@@ -15,11 +15,18 @@ export const THIN = '\u2009'; // U+2009 THIN SPACE
 /** 南向:U+2009 → 普通空格(给 SVG / canvas 等「空格不显示」的场合用;页面正文一律不这么做) */
 export const unthin = (s) => String(s).split(THIN).join(' ');
 
-/** 中英混排:汉字 ↔ 拉丁字母 / 数字两侧各插一个 U+2009(约 1/5 em)。全角空格与普通空格都禁止冒充。 */
+/**
+ * 中英混排:汉字 ↔ 拉丁字母 / 数字交界两侧各插一个 U+2009(约 1/5 em)。全角空格与普通空格都禁止冒充。
+ *   1. 交界处已有的普通空格 / 全角空格**换成** U+2009(不会出现「空格 + U+2009」两个);
+ *   2. 连续数字 / 拉丁串整体算一个单位(`20260420` 内部不插);
+ *   3. 幂等:已经带 U+2009 的交界不再插第二个。
+ * URL / 文件名 / 纯英文串的排除在调用侧(shared.js 的 cjk() 跳过 <code> 与 .kf-mono),这里只认字符串。
+ */
 export function spaceCJK(s) {
   return String(s)
-    .replace(/([一-龥])([A-Za-z0-9])/g, `$1${THIN}$2`)
-    .replace(/([A-Za-z0-9])([一-龥])/g, `$1${THIN}$2`);
+    // 汉字 + 空白 + 拉丁/数字 → 汉字 + U+2009 + 拉丁/数字(空白的字数不限制,  全角空格也算)
+    .replace(/([\u4e00-\u9fa5,\u3001\u3002])[ \t\u00a0\u3000]*(?=[A-Za-z0-9])/g, `$1${THIN}`)
+    .replace(/(?<=[A-Za-z0-9,.%])[ \t\u00a0\u3000]*([\u4e00-\u9fa5])/g, `${THIN}$1`);
 }
 
 /**