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

feat(v15): 工作台 Bento 骨架 A/B → C/D/E → F + 深色卡 + 内边距 20 + verify-v15 测量脚本

- 12 栅格:A 8 列 / B 4 列同高;C/D/E 三张短卡等高;F 通栏最近动过
- B 卡改深色(--kf-ink 底 + 反白),去掉粉红整块底
- kf-glass-card 新增 dark 变体、内边距默认 20
- verify-v15.mjs:--from HEAD 对着旧代码量基线,打印代码 sha1
FmodeCode 4 дней назад
Родитель
Сommit
969795bce8
4 измененных файлов с 681 добавлено и 186 удалено
  1. 1 0
      .gitignore
  2. 448 0
      scripts/verify-v15.mjs
  3. 11 1
      src/components/kf-glass-card.js
  4. 221 185
      src/pages/view-workbench.js

+ 1 - 0
.gitignore

@@ -28,3 +28,4 @@ docs/screenshots/**/*.png
 .vscode/
 .idea/
 *.swp
+docs/v15-before.json

+ 448 - 0
scripts/verify-v15.mjs

@@ -0,0 +1,448 @@
+#!/usr/bin/env node
+/**
+ * scripts/verify-v15.mjs —— v15 Bento 重构验收(只打印、不判分;任务书「四」逐项数字)。
+ *
+ *   node scripts/verify-v15.mjs --tag before           改前基线
+ *   node scripts/verify-v15.mjs --tag after            改后
+ *   node scripts/verify-v15.mjs --json out.json        结果落盘(docs/v15-before-after.md 从这里抄数字)
+ *   --me 赵磊                                          量「有数据」的我的推进(默认赵磊,仓库里有 2 份成果)
+ *   --no-regress                                       顺带跑 verify-visual-v12.mjs 再打一遍
+ *
+ * 数据源:dev server(默认 127.0.0.1:4173,--port 改)→ 生产 Parse 只读。localhost 是安全上下文,
+ * 所以 localStorage 里写 `kf-board:me` 等价于真实用户点过「我是」。
+ *
+ * 量什么(工作台 /,1440×900 与 393×852 两档):
+ *   1. A 卡宽 / B 卡宽 / 高度差(≤2px);A 占 12 栅格里的几列
+ *   2. B 卡底色亮度(<0.35)与反白文字最差对比度(≥7:1)
+ *   3. C/D/E 三卡宽高差(高差 ≤2px)
+ *   4. 页面主列直接子块的相邻 gap(剔除页头与页脚)
+ *   5. 卡片内边距实测(.face 的 computed padding)
+ *   6. 全页可见文字字号档位(≤5 档、无 11px)
+ *   7. kf-spark 残留(工作台 0;全站单独打印)
+ *   8. 393px:scrollWidth === clientWidth、A/B 上下堆叠、字号档、无 11px
+ *   9. 全站普通卡片:backdrop-filter 个数、box-shadow 非 none 个数(跨 shadow)
+ *  10. --no-regress:调用 verify-visual-v12.mjs --tag before,把摘要原样打印
+ */
+import { writeFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const argv = process.argv.slice(2);
+const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
+const TAG = arg('--tag', 'after');
+const JSON_OUT = arg('--json', '');
+const PORT = Number(arg('--port', process.env.PORT || 4173));
+const ME = arg('--me', '赵磊');
+const BASE = `http://127.0.0.1:${PORT + 30}`;
+
+if (!process.env.CHROME_PATH) {
+  const pinned = '/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell';
+  const { existsSync } = await import('node:fs');
+  if (existsSync(pinned)) process.env.CHROME_PATH = pinned;
+}
+
+/* 「改前基线」必须对着改前的代码跑:
+ *   默认 --from HEAD:把 git 里的整棵树导出到临时目录,dev server 指向那里(工作区改了也不影响);
+ *   --from worktree:跑工作区当前代码(改完自检用)。
+ * 每次运行都打印 /src/pages/view-workbench.js 的 sha1 写进 JSON —— before / after 两份能直接核对跑的是哪版。
+ */
+const FROM = arg('--from', 'HEAD');
+const { createHash } = await import('node:crypto');
+const { readFileSync, mkdtempSync, cpSync, rmSync } = await import('node:fs');
+const { tmpdir } = await import('node:os');
+
+let codeDir = ROOT;
+if (FROM !== 'worktree') {
+  codeDir = mkdtempSync(join(tmpdir(), 'kf-v15-'));
+  execFileSync('sh', ['-c', `git archive ${JSON.stringify(FROM)} | tar -x -C ${JSON.stringify(codeDir)}`], { cwd: ROOT, stdio: 'ignore' });
+  /* lib/vendor(lit 等)没进 git,要和工作区的一起拷;serve.mjs 的静态根 = 它自己所在的 src/../..,
+     所以把 serve.mjs 挪到临时根的 src/dev/ 下即可整体切换 */
+  cpSync(join(ROOT, 'src/dev/serve.mjs'), join(codeDir, 'src/dev/serve.mjs'));
+  cpSync(join(ROOT, 'lib'), join(codeDir, 'lib'), { recursive: true });
+}
+const codeSha = createHash('sha1').update(readFileSync(join(codeDir, 'src/pages/view-workbench.js'))).digest('hex').slice(0, 12);
+const cleanup = () => { if (FROM !== 'worktree') rmSync(codeDir, { recursive: true, force: true }); };
+
+const { launch, DEEP } = await import('./v11b-lib.mjs');
+const browser = await launch();
+/* 独立的 dev server:静态根指向 codeDir,/parse 仍代理生产(只读) */
+const { spawn } = await import('node:child_process');
+const srv = spawn(process.execPath, [join(codeDir, 'src/dev/serve.mjs'), '--port', String(PORT + 30), '--mount', '/'], { stdio: 'ignore', cwd: codeDir });
+/* 起不来就报错退出,不要悄悄连到别的 4173 */
+const SBASE = `http://127.0.0.1:${PORT + 30}`;
+for (let i = 0; i < 60; i++) {
+  await new Promise((r) => setTimeout(r, 100));
+  try { if ((await fetch(SBASE + '/index.html')).ok) break; } catch { /* 还没起来 */ }
+}
+
+const main = async () => {
+  /* ---------------- 桌面 1440 ---------------- */
+  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
+  /* 只在第一次导航(about:blank,同源之前)种一次:addInitScript 会在每次导航时重跑,
+     那样后面「清掉我是」再刷新会被它写回去(实测:空态 A 量到的是赵磊)。 */
+  await ctx.addInitScript((name) => { try { if (!localStorage.getItem('kf-v15-seeded')) { localStorage.setItem('kf-v15-seeded', '1'); localStorage.setItem('kf-board:me', JSON.stringify(name)); } } catch { /* 首次在 about:blank 里没有 localStorage,忽略 */ } }, ME);
+  const page = await ctx.newPage();
+  const errors = [];
+  page.on('pageerror', (e) => errors.push(e.message));
+  page.on('console', (m) => { if (m.type() === 'error' && !/favicon/.test(m.text())) errors.push(m.text()); });
+
+  const mount = async (p) => {
+    await page.goto(new URL(p, BASE).href, { waitUntil: 'networkidle' });
+    await page.waitForFunction(() => {
+      const a = document.querySelector('kf-app');
+      const v = a?.shadowRoot?.querySelector('main#view')?.firstElementChild;
+      return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
+    }, null, { timeout: 20000 });
+    await page.waitForTimeout(600);
+  };
+
+  const DESKTOP = `(() => { ${DEEP}
+    const view = document.getElementById('v15-det') || appR().querySelector('main#view').firstElementChild;
+    const vr = view.shadowRoot;
+    const R = (el) => { const b = el.getBoundingClientRect(); return { x: +b.x.toFixed(1), y: +b.y.toFixed(1), w: +b.width.toFixed(1), h: +b.height.toFixed(1), top: +b.top.toFixed(1), bottom: +b.bottom.toFixed(1) }; };
+    const rgb = (s) => { const m = s.match(/rgba?\\(([\\d.]+), ([\\d.]+), ([\\d.]+)(?:, ([\\d.]+))?\\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] == null ? 1 : +m[4] } : null; };
+    const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
+    const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return +(x + 0.05) / (y + 0.05); };
+    /* 跨 shadow 向上找第一个不透明底色 */
+    const bgOf = (el) => { for (let q = el; q; q = q.parentElement || q.getRootNode()?.host) { const c = rgb(getComputedStyle(q).backgroundColor); if (c && c.a >= 0.99) return c; } return { r: 255, g: 255, b: 255 }; };
+    /* 一个元素「画出来」的底色 = 自身背景色;没有就看最近的不透明祖先 */
+    const paintOf = (el) => { const own = rgb(getComputedStyle(el).backgroundColor); return own && own.a >= 0.5 ? own : bgOf(el.parentElement || el.getRootNode()?.host); };
+
+    const host = (sel) => vr.querySelector(sel);
+    const frameOf = (card) => card?.shadowRoot?.querySelector('.frame') || null;
+    const cards = [...vr.querySelectorAll('kf-glass-card')].filter((c) => c.getBoundingClientRect().height > 0);
+
+    /* ---------- 栅格:找行容器 ---------- */
+    const gridRows = [...vr.children].filter((el) => {
+      const s = getComputedStyle(el); return /grid/.test(s.display) && el.querySelector(':scope > kf-glass-card');
+    }).map((el) => {
+      const kids = [...el.querySelectorAll(':scope > kf-glass-card')].map((k) => ({ rect: R(k), role: k.dataset.role || '' }));
+      return { cls: el.className, cols: getComputedStyle(el).gridTemplateColumns, kids };
+    });
+    const byRole = (r) => gridRows.flatMap((g) => g.kids).find((k) => k.role === r)?.rect || null;
+    const A = byRole('a'), B = byRole('b'), C = byRole('c'), D = byRole('d'), E = byRole('e'), F = byRole('f');
+    /* 列数:找 A 所在的栅格容器,读它的 grid-template-columns 与 column-gap,算出单列宽,再数 A 占几列 */
+    const aEl = vr.querySelector('kf-glass-card[data-role="a"]');
+    const rowABC = aEl && [...aEl.parentElement.children].find((el) => /grid/.test(getComputedStyle(el).display));
+    let gapRow = null, colW = null;
+    if (rowABC) {
+      gapRow = parseFloat(getComputedStyle(rowABC).columnGap) || 0;
+      const tracks = getComputedStyle(rowABC).gridTemplateColumns.split(/\\s+/).map(parseFloat);
+      colW = tracks.length ? (rowABC.getBoundingClientRect().width - gapRow * (tracks.length - 1)) / tracks.length : null;
+    }
+    const aColsExact = rowABC && rowABC.querySelectorAll(':scope > kf-glass-card').length ? Math.round((rowABC.querySelector(':scope > kf-glass-card').getBoundingClientRect().width + gapRow) / (colW + gapRow)) : null;
+
+    /* ---------- B 卡深色 ---------- */
+    let Bbg = null, BbgText = '', Bcontrast = null;
+    const bc = cards.find((c) => c.dataset.role === 'b');
+    if (bc) {
+      const frame = frameOf(bc);
+      Bbg = rgb(getComputedStyle(frame).backgroundColor);
+      const texts = [...bc.shadowRoot.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()));
+      const worst = texts.map((e) => { const fg = rgb(getComputedStyle(e).color); return { t: e.textContent.trim().slice(0, 16), fg, c: +contrast(fg, Bbg).toFixed(2) }; }).sort((p, q) => p.c - q.c);
+      Bcontrast = worst[0] || null;
+      /* 原始字符串另存,供打印比对 */
+    }
+
+    /* ---------- 主列直接子块间距 ---------- */
+    const blocks = [...vr.children].filter((el) => {
+      const s = getComputedStyle(el); const b = el.getBoundingClientRect();
+      return s.display !== 'none' && b.height > 0;
+    }).map((el) => ({ el, tag: el.localName + (el.className ? '.' + String(el.className).split(' ')[0] : ''), rect: R(el) }));
+    /* 间距 = 两块之间真实露出的竖直空白。box 底边不含 margin-bottom / padding-bottom / border-bottom,
+       下一块 box 顶边也不含它自己的 border-top(外边框在 rect 内,margin 折叠也已体现在 rect 里)。
+       两者相减后扣掉上一块自带的尾随边距,再扣掉下一块自己的 border-top —— 得到「眼睛看到的空白」。 */
+    const gaps = [];
+    for (let i = 1; i < blocks.length; i++) {
+      const prev = blocks[i - 1], cur = blocks[i];
+      const sp = getComputedStyle(prev.el), sc = getComputedStyle(cur.el);
+      const trail = (parseFloat(sp.marginBottom) || 0) + (parseFloat(sp.paddingBottom) || 0) + (parseFloat(sp.borderBottomWidth) || 0)
+        + (parseFloat(sc.borderTopWidth) || 0);
+      gaps.push({ between: prev.tag + ' → ' + cur.tag, gap: +(cur.rect.top - prev.rect.bottom - trail).toFixed(1) });
+    }
+
+    /* ---------- 卡片内边距 ---------- */
+    const padding = [...new Set([...vr.querySelectorAll('*')].filter((el) => /grid|flex/.test(getComputedStyle(el).display)).map((el) => getComputedStyle(el).paddingTop))];
+    const facePad = cards.map((c) => c.dataset.role || 'card').map((role, i) => ({ role, pad: getComputedStyle(frameOf(cards[i]).querySelector('.face')).padding }));
+
+    /* ---------- 字号档 ---------- */
+    const fsCount = {};
+    const fsWhere = {};
+    walk(vr, (el) => {
+      if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
+      if (!visible(el)) return;
+      const s = getComputedStyle(el);
+      const k = Math.round(parseFloat(s.fontSize) * 10) / 10 + 'px';
+      fsCount[k] = (fsCount[k] || 0) + 1;
+      (fsWhere[k] = fsWhere[k] || new Set()).add(el.localName + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/)[0] : ''));
+    });
+
+    return {
+      view: view.localName,
+      rows: gridRows.map((g) => ({ cls: g.cls, cols: g.cols, kids: g.kids.map((k) => ({ role: k.role, ...k.rect })) })),
+      A, B, C, D, E, F,
+      bento: {
+        A: A?.w ?? null, B: B?.w ?? null, aCols: aColsExact,
+        ABheightDiff: A && B ? +Math.abs(A.h - B.h).toFixed(1) : null,
+        CDE: C && D && E ? { w: [C.w, D.w, E.w], h: [C.h, D.h, E.h], hDiff: +(Math.max(C.h, D.h, E.h) - Math.min(C.h, D.h, E.h)).toFixed(1) } : null,
+        F: F || null,
+      },
+      Bbg, BbgText, Bcontrast,
+      gaps,
+      facePad,
+      fonts: { tiers: Object.keys(fsCount).sort((p, q) => parseFloat(p) - parseFloat(q)), count: fsCount, where: Object.fromEntries(Object.entries(fsWhere).map(([k, v]) => [k, [...v]])) },
+      sparks: { inView: vr.querySelectorAll('kf-spark').length, inPage: document.querySelectorAll('*').length && (() => { let n = 0; walk(document, (e) => { if (e.localName === 'kf-spark') n++; }); return n; })() },
+      overflow: { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth },
+    };
+  })()`;
+
+  /* 确定性:实例化一个 view-workbench(自定义元素,不需要 import 模块),挂到主列最前面;
+     .db 是页面已加载的同一份,但卡池只留前 5 张——「5 张」是改前数据快照里的真实值。
+     之前想 new ViewWorkbench() 是死路:模块里的类没 define,Lit 的属性表是空的(见 git 记录)。 */
+  const DET_FN = async () => {
+    const main = document.querySelector('kf-app').shadowRoot.querySelector('main#view');
+    const tpl = document.createElement('template');
+    tpl.innerHTML = '<view-workbench id="v15-det"></view-workbench>';
+    const v = tpl.content.firstElementChild;
+    /* evaluate 每次拿到的是不同的 JS realm,顶层 import 拿到的是模块的**另一份实例**(Lit 会直接拒绝构造)。
+       所以不 import 任何模块:从 realm 的全局拿到页面里同一个 kf-app,再顺着它的 shadow root 取。 */
+    const app = document.querySelector('kf-app');
+    const view = app.shadowRoot.querySelector('main#view').firstElementChild;
+    const db = view.db;
+    v.db = { ...db, opportunities: db.opportunities.slice(0, 5) };
+    v.me = '赵磊';
+    main.prepend(v);
+    await new Promise((r) => setTimeout(r, 600));
+    return { kids: v.shadowRoot?.childElementCount ?? -1 };
+  };
+
+  /* 数据的随机部分(卡池状态 / 进程数)会随时间漂:卡多一张,首行卡就高一截。所以每个状态都量两次:
+     - live:页面真身(刷新即变)
+     - det(确定性):同一页面、再跑一次,只把卡池那一截的随机变量抹平(见 DET_FN),用于 before/after 对比 */
+  await mount('/');
+  const desktop = await page.evaluate(DESKTOP);
+  const probeInfo = await page.evaluate(DET_FN);
+  await page.waitForTimeout(500);
+  const desktopDet = await page.evaluate(DESKTOP);
+  if (!probeInfo || probeInfo.kids <= 0) console.log('[probe] 确定性实例未挂上:', JSON.stringify(probeInfo));
+
+  /* 全站普通卡片:backdrop-filter / box-shadow 计数(四页) */
+  const glassScan = async () => {
+    await page.goto(new URL('/', BASE).href, { waitUntil: 'networkidle' }); // 同源,占位
+    const SCAN = `(() => { ${DEEP}
+      let blur = 0, shadow = 0; const blurWhere = new Set(), shadowWhere = new Set();
+      walk(document, (el) => {
+        const s = getComputedStyle(el);
+        const bf = s.backdropFilter || s.webkitBackdropFilter;
+        const vis = el.getBoundingClientRect().width > 8 && el.getBoundingClientRect().height > 4 && shownByStyle(el);
+        if (!vis) return;
+        if (bf && bf !== 'none') { blur++; blurWhere.add(elLabel(el)); }
+        if (s.boxShadow && s.boxShadow !== 'none') { shadow++; shadowWhere.add(elLabel(el)); }
+      });
+      return { blur, shadow, blurWhere: [...blurWhere], shadowWhere: [...shadowWhere] };
+    })()`;
+    const out = {};
+    for (const p of ['/', '/pool', '/stages', '/reports']) {
+      await mount(p);
+      out[p] = await page.evaluate(SCAN);
+    }
+    return out;
+  };
+
+  const glass = await glassScan();
+
+  /* 空状态 B:本月没有我的成果 —— 真实数据里每个人都有成果,所以就地实例化一个 view-workbench
+     (同一份 db,me 给一个不存在的名字 = 「选了个本月没交东西的人」)。用 customElements 里已注册的类,
+     不要 import 模块 —— 模块里的类没有 define(自定义元素已存在),new 它出来 render 不会生成任何节点。 */
+  await page.evaluate(() => localStorage.setItem('kf-board:me', JSON.stringify('赵磊')));
+  await mount('/');
+  const nobodyProbe = await page.evaluate(async () => {
+    const main = document.querySelector('kf-app').shadowRoot.querySelector('main#view');
+    const tpl = document.createElement('template');
+    tpl.innerHTML = '<view-workbench id="v15-probe"></view-workbench>';
+    const v = tpl.content.firstElementChild;
+    v.db = main.querySelector('view-workbench').db; v.me = '_nobody_';
+    main.prepend(v);
+    await new Promise((r) => setTimeout(r, 600));
+    return { kids: v.shadowRoot?.childElementCount ?? -1 };
+  });
+  const nobody = await page.evaluate(`(() => { ${DEEP}
+    const vr = (document.getElementById('v15-probe') || appR().querySelector('main#view').firstElementChild).shadowRoot;
+    const a = vr.querySelector('kf-glass-card[data-role="a"]');
+    return { kids: vr.childElementCount,
+      roles: [...vr.querySelectorAll('kf-glass-card')].map((c) => c.dataset.role || '').filter(Boolean),
+      Aheight: a ? Math.round(a.getBoundingClientRect().height) : null,
+      text: vr.textContent.replace(/\\s+/g, ' ').trim().slice(0, 240) };
+  })()`);
+  nobody.probe = nobodyProbe;
+
+  /* 空状态 A:未选身份 */
+  await page.evaluate(() => document.getElementById('v15-probe')?.remove());
+  await page.waitForTimeout(300);
+  /* 清掉「我是」再重新挂载:kf-app 在 constructor 里读 localStorage,只清值不刷新不会生效 */
+  await page.evaluate(() => localStorage.removeItem('kf-board:me'));
+  await mount('/');
+  const noMe = await page.evaluate(`(() => { ${DEEP}
+    const vr = (document.getElementById('v15-probe') || appR().querySelector('main#view').firstElementChild).shadowRoot;
+    const a = vr.querySelector('kf-glass-card[data-role="a"]');
+    return { roles: [...vr.querySelectorAll('kf-glass-card')].map((c) => c.dataset.role || '').filter(Boolean),
+      Aheight: a ? Math.round(a.getBoundingClientRect().height) : null,
+      allstages: vr.querySelectorAll('[data-allstages] [data-stage]').length,
+      text: vr.textContent.replace(/\\s+/g, ' ').trim().slice(0, 240) };
+  })()`);
+
+  /* 空状态 C:没人卡住 —— 用真数据克隆一份 db,把每个人的 stuck 置 false(等于「8 小时内都有新成果」),
+     同一个页面里挂第二个实例(设成 overflow:hidden 的离屏盒里量高度,不影响页面)。 */
+  const noStall = await page.evaluate(async () => {
+    const main = document.querySelector('kf-app').shadowRoot.querySelector('main#view');
+    const live = main.querySelector('view-workbench');
+    const db = live.db;
+    const members = db.members.map((m) => ({ ...m, stuck: false, stuckHours: 0 }));
+    const box = document.createElement('div');
+    box.id = 'v15-ok';
+    box.style.cssText = 'position:absolute;left:-9999px;top:0;width:1200px';
+    document.body.append(box);
+    const tpl = document.createElement('template');
+    tpl.innerHTML = '<view-workbench></view-workbench>';
+    const v = tpl.content.firstElementChild;
+    v.db = { ...db, members, memberByName: Object.fromEntries(members.map((m) => [m.name, m])) };
+    v.me = '赵磊';
+    box.append(v);
+    await new Promise((r) => setTimeout(r, 700));
+    const vr = v.shadowRoot;
+    const a = vr.querySelector('kf-glass-card[data-role="a"]') || vr.querySelector('.grid2 > kf-glass-card');
+    const b = vr.querySelector('kf-glass-card[data-role="b"]') || vr.querySelector('.grid2 > kf-glass-card:nth-child(2)');
+    const out = { stalledItems: vr.querySelectorAll('[data-stalled]').length,
+      /* 改前代码没有 data-role:B 卡是首行第二张 kf-glass-card */
+      bText: b ? b.textContent.replace(/\s+/g, ' ').trim().slice(0, 120) : '',
+      aH: a ? Math.round(a.getBoundingClientRect().height) : null, bH: b ? Math.round(b.getBoundingClientRect().height) : null,
+      dot: b?.querySelector('.dot') ? getComputedStyle(b.querySelector('.dot')).backgroundColor : '',
+      bg: b ? getComputedStyle(b.shadowRoot.querySelector('.frame')).backgroundColor : '' };
+    /* 不摘离屏实例:摘掉的一瞬间它的 Lit 更新周期会把空的更新排队,影响到页面里同一组件的其它实例 */
+    window.__v15ok = box;
+    return out;
+  });
+  const stalled = await page.evaluate(() => (document.querySelector('kf-app')?.shadowRoot?.querySelector('main#view')?.firstElementChild?.shadowRoot?.querySelectorAll('[data-stalled]').length) ?? -1);
+
+  /* ---------------- 手机 393 ---------------- */
+  const mctx = await browser.newContext({ viewport: { width: 393, height: 852 }, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
+  await mctx.addInitScript((name) => { try { localStorage.setItem('kf-board:me', JSON.stringify(name)); } catch { /* ignore */ } }, ME);
+  /* 手机上下文只加载一次,initScript 不会重跑,不需要上面那个一次性开关 */
+  const mpage = await mctx.newPage();
+  mpage.on('pageerror', (e) => errors.push('mobile: ' + e.message));
+  await mpage.goto(new URL('/', BASE).href, { waitUntil: 'networkidle' });
+  await mpage.waitForFunction(() => {
+    const a = document.querySelector('kf-app');
+    const v = a?.shadowRoot?.querySelector('main#view')?.firstElementChild;
+    return !!v && v.shadowRoot && v.shadowRoot.childElementCount > 0;
+  }, null, { timeout: 20000 });
+  await mpage.waitForTimeout(600);
+  await mpage.evaluate(DET_FN);
+  const mobile = await mpage.evaluate(`(() => { ${DEEP}
+    const vr = (document.getElementById('v15-det') || appR().querySelector('main#view').firstElementChild).shadowRoot;
+    const R = (el) => { const b = el.getBoundingClientRect(); return { x: +b.x.toFixed(1), y: +b.y.toFixed(1), w: +b.width.toFixed(1), h: +b.height.toFixed(1) }; };
+    const a = vr.querySelector('kf-glass-card[data-role="a"]'), b = vr.querySelector('kf-glass-card[data-role="b"]');
+    const fsCount = {};
+    walk(vr, (el) => {
+      if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
+      if (!visible(el)) return;
+      const k = Math.round(parseFloat(getComputedStyle(el).fontSize) * 10) / 10 + 'px';
+      fsCount[k] = (fsCount[k] || 0) + 1;
+    });
+    /* 横向溢出:任一可见元素右边超出视口 */
+    let over = [];
+    walk(vr, (el) => { const r = el.getBoundingClientRect(); if (r.width > 0 && r.right > document.documentElement.clientWidth + 1) over.push(elLabel(el) + '@' + Math.round(r.right)); });
+    return { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth,
+      A: a ? R(a) : null, B: b ? R(b) : null, stacked: a && b ? b.y >= a.y + a.h - 2 : null,
+      CDE: ['c', 'd', 'e'].map((r) => { const k = vr.querySelector('kf-glass-card[data-role="' + r + '"]'); return k ? R(k) : null; }),
+      fonts: Object.keys(fsCount).sort((p, q) => parseFloat(p) - parseFloat(q)), fontSizeCount: fsCount,
+      overflowing: [...new Set(over)].slice(0, 10) };
+  })()`);
+
+  /* ---------------- v12 回归 ---------------- */
+  let v12 = null;
+  if (argv.includes('--no-regress')) {
+    for (const p of ['v15/v12-desktop.txt', 'v15/v12-mobile.txt']) void p;
+    try {
+      const out = execFileSync(process.execPath, [join(ROOT, 'scripts/verify-visual-v12.mjs'), '--tag', 'before'], { cwd: ROOT, encoding: 'utf8', timeout: 300000, env: { ...process.env, VERIFY_PORT: '4199' } });
+      v12 = out;
+    } catch (e) {
+      v12 = (e.stdout || '') + (e.stderr || '') + `\n[exit ${e.status}]`;
+    }
+  }
+
+  const result = { codeFrom: FROM, codeSha, probeInfo, tag: TAG, at: new Date().toISOString(), desktop, desktopDet, glass, noMe, nobody, noStall, stalledCount: stalled, mobile, v12, errors };
+
+  /* ---------------- 打印 ---------------- */
+  const L = (s = '') => console.log(s);
+  L(`\n===== verify-v15 · ${TAG} · ${result.at} =====`);
+  L(`代码来源 ${FROM} · view-workbench.js sha1 ${codeSha}`);
+  L(`\n[1] Bento 栅格(1440,确定性数据:我的成果=2,卡池状态/份数保持数据快照「before」时刻)`);
+  const bt = desktopDet.bento;
+  L(`  A 宽 ${bt.A ?? '—'}px / B 宽 ${bt.B ?? '—'}px → 视觉 ${bt.aCols ?? '—'} 列 : ${bt.A && bt.B ? 12 - bt.aCols : '—'} 列`);
+  L(`  A/B 高度差 ${bt.ABheightDiff}px  ${bt.ABheightDiff != null && bt.ABheightDiff <= 2 ? '✓' : '✗(要求 ≤2)'}`);
+  L(`  C/D/E 宽 ${bt.CDE?.w.join(' / ')}  高 ${bt.CDE?.h.join(' / ')}  高差 ${bt.CDE?.hDiff}px  ${bt.CDE?.hDiff != null && bt.CDE.hDiff <= 2 ? '✓' : '✗(要求 ≤2)'}`);
+  L(`  F 通栏 ${bt.F ? `宽 ${bt.F.w}px 高 ${bt.F.h}px` : '(无)'}`);
+  L(`\n[2] B 卡深色 / C/D/E/F 在确定性数据下`);
+  L(`  B 背景 ${desktopDet.BbgText || '(无 data-role="b" 的卡)'}`);
+  L(`  C/D/E ${desktopDet.bento.CDE ? `宽 ${desktopDet.bento.CDE.w.join(' / ')}  高 ${desktopDet.bento.CDE.h.join(' / ')}  高差 ${desktopDet.bento.CDE.hDiff}px` : '(无 data-role=c/d/e 的卡)'}`);
+  L(`  F 通栏 ${desktopDet.bento.F ? `宽 ${desktopDet.bento.F.w}px 高 ${desktopDet.bento.F.h}px` : '(无 data-role="f" 的卡)'}`);
+  L(`  — 实时数据(页面真身):A ${desktop.bento.A}×? B ${desktop.bento.B}×? 高差 ${desktop.bento.ABheightDiff}px;C/D/E 高差 ${desktop.bento.CDE?.hDiff}px`);
+  L(`  B 背景 ${desktop.BbgText}`);
+  if (desktop.Bbg) {
+    const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
+    L(`  亮度 ${lum(desktop.Bbg).toFixed(3)}  ${lum(desktop.Bbg) < 0.35 ? '✓(<0.35)' : '✗'}`);
+  }
+  if (desktop.Bcontrast) L(`  最差对比度 ${desktop.Bcontrast.c}:1(“${desktop.Bcontrast.t}”)  ${desktop.Bcontrast.c >= 7 ? '✓(≥7)' : '✗(要求 ≥7)'}`);
+  L(`\n[3] 主列相邻块间距(上→下)`);
+  for (const g of desktopDet.gaps) L(`  ${String(g.gap).padStart(6)}px  ${g.between}`);
+  L(`\n[4] 卡片 .face 内边距`);
+  const padCount = {};
+  for (const p of desktopDet.facePad) padCount[p.pad] = (padCount[p.pad] || 0) + 1;
+  L(`  ${JSON.stringify(padCount)}  ${Object.keys(padCount).length === 1 && Object.keys(padCount)[0] === '20px' ? '✓' : '✗(要求 20px)'}`);
+  L(`  逐卡:${desktopDet.facePad.map((p) => `${p.role || '?'}=${p.pad}`).join('  ')}`);
+  L(`\n[5] 字号档(工作台可见文字,1440)`);
+  L(`  ${desktopDet.fonts.tiers.length} 档:${desktopDet.fonts.tiers.join(' / ')}  ${desktopDet.fonts.tiers.length <= 5 && !desktopDet.fonts.tiers.includes('11px') ? '✓' : '✗'}`);
+  for (const k of desktopDet.fonts.tiers) L(`    ${k.padStart(5)} × ${desktopDet.fonts.count[k]}   ${desktopDet.fonts.where[k].join(', ')}`);
+  if (desktop.fonts.tiers.join() !== desktopDet.fonts.tiers.join()) L(`  (实时数据下的档位:${desktop.fonts.tiers.join(' / ')})`);
+  L(`\n[6] kf-spark:工作台 ${desktop.sparks.inView} 个,全站 ${desktop.sparks.inPage} 个`);
+  L(`  评分是实时数据:B 卡当前 ${stalled} 人卡住;C/D/E 数值随数据变化,对比只看结构与高差`);
+  L(`\n[7] 普通卡片玻璃/阴影(跨 shadow,可见元素)`);
+  for (const [p, v] of Object.entries(glass)) L(`  ${p.padEnd(9)} backdrop-filter ${String(v.blur).padStart(3)}  box-shadow ${String(v.shadow).padStart(3)}  ${v.blur ? '[' + v.blurWhere.slice(0, 6).join(', ') + ']' : ''}`);
+  L(`\n[8] 空状态`);
+  L(`  未选身份:roles=${noMe.roles.join(',')}  A 高 ${noMe.Aheight}px  [data-allstages] ${noMe.allstages}`);
+  L(`    「${noMe.text.slice(0, 140)}」`);
+  L(`  不存在的人(本月无成果):roles=${nobody.roles.join(',')}  A 高 ${nobody.Aheight}px`);
+  L(`    「${nobody.text.slice(0, 160)}」`);
+  L(`  没人卡住(克隆 db,stuck 全置 false):[data-stalled] ${noStall.stalledItems}  A 高 ${noStall.aH} / B 高 ${noStall.bH}(差 ${Math.abs(noStall.aH - noStall.bH)})`);
+  L(`    B 底 ${noStall.bg}  点 ${noStall.dot}  「${noStall.bText}」`);
+  L(`  当前实时数据里卡住的人:[data-stalled] ${stalled}`);
+  L(`\n[9] 移动端 393×852`);
+  const m = mobile;
+  L(`  scrollWidth ${m.scrollWidth} / clientWidth ${m.clientWidth}  ${m.scrollWidth === m.clientWidth ? '✓' : '✗ 横向溢出'}`);
+  if (m.A && m.B) L(`  A ${m.A.w}×${m.A.h} @y${m.A.y}  B ${m.B.w}×${m.B.h} @y${m.B.y}  堆叠 ${m.stacked ? '✓' : '✗'}`);
+  L(`  C/D/E ${m.CDE.map((k) => k ? `${k.w}×${k.h}@y${k.y}` : '—').join('  ')}`);
+  L(`  字号档 ${m.fonts.join(' / ')}  ${m.fonts.length <= 5 && !m.fonts.includes('11px') ? '✓' : '✗'}`);
+  if (m.overflowing.length) L(`  超出视口:${m.overflowing.join(', ')}`);
+  if (errors.length) L(`\n[!] 页面错误 ${errors.length}:\n  ${errors.join('\n  ')}`);
+  if (v12) {
+    L(`\n[10] v12 回归(verify-visual-v12 --tag before)`);
+    L(v12.split('\n').filter((l) => /✗|FAIL|失败|exit|总|汇总|项/.test(l)).slice(0, 60).join('\n'));
+  }
+
+  if (JSON_OUT) {
+    const p = JSON_OUT.startsWith('/') ? JSON_OUT : join(ROOT, JSON_OUT);
+    writeFileSync(p, JSON.stringify(result, null, 2));
+    L(`\n→ ${p}`);
+  }
+};
+
+try {
+  await main();
+} finally {
+  await browser.close();
+  try { srv.kill(); } catch { /* ignore */ }
+  cleanup();
+}

+ 11 - 1
src/components/kf-glass-card.js

@@ -9,6 +9,7 @@
  *   lift     boolean        是否可点击(hover 上浮),可点击时整卡触发 card-activate 事件
  *   tone     accent|warn|ok 左侧 3px 色调条(状态提示用)
  *   padding  boolean        默认 true
+ *   dark     boolean        v15:深色卡面(--kf-ink 底 + 反白字)。全页只允许一块,用来锚注意力
  * 事件:
  *   card-activate  { }      lift 时点击(内部 a / button / 标记 data-no-activate 的区域不触发)
  */
@@ -20,6 +21,9 @@ export class KfGlassCard extends LitElement {
     surface: { type: Number, reflect: true },
     lift: { type: Boolean, reflect: true },
     tone: { type: String, reflect: true },
+    dark: { type: Boolean, reflect: true },
+    /** v15:卡片内边距默认 20(dr1 的呼吸感)。仍可用 CSS 覆盖 --kf-card-pad */
+    pad: { type: Number, attribute: 'pad' },
   };
 
   constructor() {
@@ -27,6 +31,8 @@ export class KfGlassCard extends LitElement {
     this.surface = 2;
     this.lift = false;
     this.tone = '';
+    this.dark = false;
+    this.pad = 20;
   }
 
   connectedCallback() {
@@ -51,7 +57,7 @@ export class KfGlassCard extends LitElement {
     :host { height: 100%; }
     .frame { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; }
     .frame { box-sizing: border-box; isolation: isolate; overflow: clip; min-height: 0; transition-property: none; }
-    .frame > .face { position: relative; z-index: 2; box-sizing: border-box; padding: var(--kf-space-card); border-radius: var(--r-md); flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
+    .frame > .face { position: relative; z-index: 2; box-sizing: border-box; padding: var(--kf-card-pad, 20px); border-radius: var(--r-md); flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
     /* v14 定稿:slot 必须是 flex column 且撑满 .face,否则 slotted 子元素(机会卡 .wrap)
        的 height/min-height:100% 解析不出包含块高度 → 无描述卡片内容只有 303px,底部差 53.6px。
        改成 flex 后 slotted 子元素自动 stretch 到 .face 的高度。 */
@@ -81,6 +87,10 @@ export class KfGlassCard extends LitElement {
     .frame.tone-warn { --tone: var(--kf-warn); }
     .frame.tone-ok { --tone: var(--kf-ok); }
     .frame.tone-danger { --tone: var(--kf-danger); }
+    /* v15 深色卡:不透明深底 + 反白;优先级压在 tone 之上(深卡不做状态染底) */
+    :host([dark]) .frame, :host([dark]) .frame.tone-accent, :host([dark]) .frame.tone-warn, :host([dark]) .frame.tone-ok, :host([dark]) .frame.tone-danger {
+      background: var(--kf-ink); color: var(--kf-ink-inv);
+    }
     /* 状态色只染「底」,描边仍是中性发丝线:box-shadow 不新增变体(板块四的 3 档断言要过) */
     .frame.tone-accent, .frame.tone-warn, .frame.tone-ok, .frame.tone-danger {
       background: linear-gradient(color-mix(in srgb, var(--tone) 6%, rgba(255, 255, 255, 0.72)), color-mix(in srgb, var(--tone) 6%, rgba(255, 255, 255, 0.72))) padding-box,

+ 221 - 185
src/pages/view-workbench.js

@@ -1,8 +1,13 @@
 /**
- * pages/view-workbench.js —— <view-workbench> 工作台首页。
+ * pages/view-workbench.js —— <view-workbench> 工作台首页(v15 Bento 重构)。
  *
- * 三块:我的进度(阶段色块)/ 机会卡池(最新的卡)/ 现场(谁卡住了)。
- * 页面只放数字和卡,不放口径说明;口径在 core.stats。
+ * 骨架(12 栅格):
+ *   A 我的推进(8 列,浅色) | B 现场(4 列,深色:全页唯一一块深卡,锚定注意力)
+ *   C 卡池 | D 最高分 | E 已决策       ← 三张短数据卡,一行等高:标签 / 大数字 / 一行说明
+ *   F 最近动过(通栏,最多 3 条)
+ *
+ * 视觉纪律(v15):浅底嵌深卡、单一强调色(--kf-accent 只给一个关键动作)、字号只留 12/14/16/20/32、
+ * 区块之间 32、卡与卡 24、卡内边距 20、卡内 gap 12。不再放柱状装饰(kf-spark 已移除)。
  */
 import { LitElement, css, html } from 'lit';
 import { useTokens } from '../components/base.js';
@@ -15,6 +20,17 @@ import { cardFooter } from './report-card.js';
 import { icon } from '../components/icons.js';
 
 const TONE = { S1: 'blue', S2: 'violet', S3: 'teal', S4: 'amber', S5: 'rose' };
+/** 「最近动过」的时间写法:今天 / 昨天 / N 天前 / 日期 */
+const ago = (iso) => {
+  const t0 = Date.parse(iso || '');
+  if (!t0) return '';
+  const d = Math.floor((Date.now() - t0) / 86400000);
+  if (d <= 0) return '今天';
+  if (d === 1) return '昨天';
+  if (d < 7) return `${d} 天前`;
+  const dt = new Date(t0);
+  return `${dt.getMonth() + 1}月${dt.getDate()}日`;
+};
 
 export class ViewWorkbench extends LitElement {
   static properties = {
@@ -33,136 +49,137 @@ export class ViewWorkbench extends LitElement {
     useTokens(this);
   }
 
-
   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) */
-    /* v12 P3:左右两卡等高(栅格 stretch,kf-glass-card 宿主 height:100% 吃满行高) */
-    .grid2 > kf-glass-card { height: 100%; }
-    /* 页顶 → 首个区块 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(--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(--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: 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); }
+    /* 纵向节奏(v15):区块之间 32 / 卡与卡 24 / 卡内元素 12。页头到第一块也是 32(Bento 第一块就是内容)。 */
+    .bento { display: flex; flex-direction: column; gap: var(--kf-space-section); margin-top: var(--kf-space-section); }
+    .bento > .row { display: grid; gap: var(--kf-space-card-gap); align-items: stretch; }
+    .bento > .row.ab { grid-template-columns: repeat(12, minmax(0, 1fr)); }
+    .bento > .row.ab > kf-glass-card { height: auto; }
+    .bento > .row.ab > kf-glass-card[data-role="a"] { grid-column: span 8; }
+    .bento > .row.ab > kf-glass-card[data-role="b"] { grid-column: span 4; }
+    .bento > .row.cde { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+    .p-head { padding-top: var(--kf-space-top); padding-bottom: 0; }
+
+    /* ---------- 通用小件 ---------- */
+    .lbl { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 500; line-height: 1.4; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); }
+    .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; display: inline-block; }
+    .go { display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 500; color: var(--kf-accent-ink); text-decoration: none; }
+    .go:hover { color: var(--kf-accent); }
+    /* 全页唯一一个强调色实心元素:C 卡上的「全部机会卡 →」 */
+    .btn-key { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: var(--r-sm);
+      background: var(--kf-accent); color: #fff; font-size: 14px; font-weight: 500; text-decoration: none; flex: none; }
+    .btn-key:hover { background: var(--kf-accent-strong); }
+    .empty { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 12px 14px; color: var(--kf-ink-3); font-size: 14px; line-height: 1.5;
+      border: 1px dashed color-mix(in srgb, var(--kf-ink-3) 24%, transparent); border-radius: var(--r-md); }
+    .empty .go { margin-left: auto; }
+
+    /* ---------- A 卡:我的推进 ---------- */
+    .me { display: grid; gap: var(--kf-space-in); height: 100%; align-content: start; }
+    .me-id { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
     .avatar { width: 44px; height: 44px; border-radius: var(--r-md); display: grid; place-items: center; font-size: 20px; font-weight: 600; color: #fff;
-      background: var(--kf-accent); flex: none; }
-    .hd h3 { font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
-    .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; }
-    /* 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); }
-    .kpi .lbl { grid-area: l; font-size: 11px; font-weight: 500; line-height: 1.4; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3);
-      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 份」,徽标 + 数值同行 */
+      background: var(--kf-ink); flex: none; }
+    .avatar.q { background: var(--kf-fill); color: var(--kf-ink-3); border: 1px dashed color-mix(in srgb, var(--kf-ink-3) 34%, transparent); }
+    .me-id h3 { margin: 0; font-size: 20px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
+    .me-id .sub { font-size: 14px; color: var(--kf-ink-3); }
+    .me-id .go { margin-left: auto; }
+    .me-nums { margin-left: auto; display: flex; gap: 24px; }
+    .me-nums div { text-align: right; }
+    .me-nums 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; }
+    .me-nums span { font-size: 12px; font-weight: 500; color: var(--kf-ink-3); }
+
+    /* 阶段条:五格一格一句「S1 · 份数」,徽标 + 大数字 + 一根细进度条 */
     .stagebar { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--kf-space-in); }
-    .stg { padding: var(--kf-space-in); border-radius: var(--r-md); 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: var(--r-sm); letter-spacing: 0.02em; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
+    .stg { padding: 12px; border-radius: var(--r-md); background: var(--kf-fill); border: 1px solid transparent;
+      display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 4px 8px; min-width: 0; }
+    .stg .b { font-size: 12px; font-weight: 600; line-height: 1; color: #fff; padding: 4px 6px; border-radius: var(--r-sm); letter-spacing: 0.02em; justify-self: start; }
     .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: 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 .n { justify-self: end; font-size: 20px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); color: var(--gray-12); font-variant-numeric: tabular-nums; }
+    .stg .f { grid-column: 1 / -1; height: 4px; border-radius: 2px; background: rgba(16, 24, 40, 0.07); overflow: hidden; margin-top: 4px; }
     .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; }
-    .stg.todo .n { color: var(--kf-ink-3); font-weight: 500; white-space: nowrap; }
-    .stg .n.ph { letter-spacing: 0.1em; }
     .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: 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; }
-    .recent .kv .u { margin-left: 0.10em; font-size: 11px; color: var(--kf-ink-3); }
-    /* 现场:一行一人(名字 · 阶段 · 时长),不再每个都套一个红色小盒子 */
-    .stalled { margin-top: var(--kf-space-in); border-top: 1px solid var(--kf-line); }
-    .stalled li { display: flex; gap: 8px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--kf-line); font-size: 13px; }
-    .stalled .nm { font-weight: 500; color: var(--kf-ink); }
-    .stalled .meta { color: var(--kf-ink-3); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-    .stalled .h { margin-left: auto; font-weight: 600; color: var(--kf-s); font-variant-numeric: tabular-nums; }
-    .ok { display: flex; align-items: center; gap: 8px; margin-top: var(--kf-space-in); font-size: 13px; color: var(--kf-ok-text); font-weight: 500; }
-    .todo { display: grid; gap: 0; margin-top: var(--kf-space-in); }
-    .todo li { display: flex; align-items: center; gap: 8px; font-size: 13px; min-height: 28px; }
-    .todo li a { color: var(--kf-ink); font-weight: 500; text-decoration: none; border-bottom: 1px dashed var(--kf-line-strong); }
-    .todo li .s { margin-left: auto; font-size: 13px; font-weight: 600; color: var(--kf-accent-ink); font-variant-numeric: tabular-nums; }
-    .go { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; color: var(--kf-accent-ink); text-decoration: none; }
-    .go:hover { color: var(--kf-accent); }
+    .stg.todo { color: var(--kf-ink-3); background: transparent; border-style: dashed; border-color: color-mix(in srgb, var(--kf-ink-3) 22%, transparent); }
+    .stg.todo .n { color: var(--kf-ink-3); font-weight: 500; }
+    /* 待评分:只有这几行时 A 卡会矮一截,min-height 让 A 在有卡住的人时仍撑得起来 */
+    .pending { display: grid; gap: 0; }
+    .pending li { display: flex; align-items: baseline; gap: 8px; min-height: 32px; font-size: 14px; }
+    .pending li a { color: var(--kf-ink); text-decoration: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .pending li a:hover { color: var(--kf-accent-ink); }
+    .pending li .s { margin-left: auto; font-weight: 600; color: var(--kf-accent-ink); font-variant-numeric: tabular-nums; }
+
+    /* ---------- B 卡:现场(深色) ---------- */
+    .live { display: grid; gap: var(--kf-space-in); height: 100%; align-content: start;
+      --live-ink: #F4F4F5; --live-ink-3: #A1A1AA; --live-line: rgba(255, 255, 255, 0.12); }
+    .live .lbl { color: var(--live-ink-3); }
+    .live-head { display: flex; align-items: center; gap: 8px; }
+    .live-head .go { margin-left: auto; color: var(--live-ink-3); }
+    .live-head .go:hover { color: var(--live-ink); }
+    .live-num { display: flex; align-items: baseline; gap: 8px; color: var(--live-ink); }
+    .live-num b { font-size: 32px; font-weight: 600; line-height: 1; letter-spacing: var(--ls-cn-display); font-variant-numeric: tabular-nums; }
+    .live-num span { font-size: 14px; color: var(--live-ink-3); }
+    .live-title { font-size: 16px; font-weight: 600; line-height: 1.3; color: var(--live-ink); }
+    .stalled { border-top: 1px solid var(--live-line); }
+    .stalled li { display: flex; gap: 8px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--live-line); font-size: 14px; color: var(--live-ink); }
+    .stalled li:last-child { border-bottom: 0; }
+    .stalled .nm { font-weight: 500; }
+    .stalled .meta { color: var(--live-ink-3); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .stalled .h { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; }
+    .live-ok { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 14px; font-weight: 500; color: var(--live-ink); }
+    .live-foot { margin-top: auto; font-size: 12px; color: var(--live-ink-3); }
+
+    /* ---------- C/D/E:三张短数据卡 ---------- */
+    .stat { display: grid; grid-template-rows: auto auto auto; gap: 8px; height: 100%; align-content: start; }
+    .stat .top { display: flex; align-items: center; gap: 8px; min-height: 24px; }
+    .stat .top .go { margin-left: auto; font-size: 14px; }
+    .stat 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; white-space: nowrap; min-width: 0; }
+    .stat b.dim { color: var(--kf-ink-3); }
+    .stat b .u { margin-left: 0.14em; font-size: 14px; font-weight: 500; color: var(--kf-ink-3); letter-spacing: 0; }
+    .stat .note { font-size: 12px; line-height: 1.5; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3);
+      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-height: 18px; }
+
+    /* ---------- F:最近动过(通栏) ---------- */
+    .feed { display: grid; gap: 0; }
+    .feed li { display: flex; align-items: baseline; gap: 12px; min-height: 44px; padding: 10px 0; border-top: 1px solid var(--kf-line); font-size: 14px; }
+    .feed li:first-child { border-top: 0; padding-top: 0; }
+    .feed li a { color: var(--kf-ink); font-weight: 500; text-decoration: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .feed li a:hover { color: var(--kf-accent-ink); }
+    .feed .st { font-size: 12px; color: var(--kf-ink-3); flex: none; }
+    .feed .tm { margin-left: auto; font-size: 12px; color: var(--kf-ink-3); font-variant-numeric: tabular-nums; flex: none; }
+    .feed-head { display: flex; align-items: center; gap: 8px; }
+    .feed-head .go { margin-left: auto; }
+    .feed-empty { font-size: 14px; color: var(--kf-ink-3); padding: 8px 0; }
     .foot-links { display: none; }
-    /* 空态(板块八 8.1):虚线框与有数据时近似等高,不闪、不跳 */
-    /* 虚线占位(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: var(--r-md); }
-    .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: stretch; 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 */
+
+    /* 平板:A/B 变 7/5 太挤,直接上下堆;C/D/E 保持一行 */
+    @media (max-width: 1100px) {
+      .bento > .row.ab { grid-template-columns: minmax(0, 1fr); }
+      .bento > .row.ab > kf-glass-card[data-role="a"], .bento > .row.ab > kf-glass-card[data-role="b"] { grid-column: auto; }
+    }
+    /* 手机 393:A/B 竖排,C/D/E 三卡一行(每卡 3 行文字仍放得下),可点区域 44px */
     @media (max-width: 767px) {
-      .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; }
-      /* 手机:五格放不下一行(39.3 宽 × 5 = 197px 还挤着环),改成两行三列 —— 不再把第五格挤出卡片 */
-      .stagebar { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
-      .stg { padding: 8px; grid-template-columns: auto 1fr auto; }
-      .stg kf-ring { width: 32px; height: 32px; }
-      .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; }
+      .bento { gap: var(--kf-space-card); margin-top: var(--kf-space-card); }
+      .bento > .row { gap: var(--kf-space-card); }
+      .bento > .row.cde { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+      .stagebar { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
+      .stg { padding: 8px 6px; grid-template-columns: auto; gap: 4px; justify-items: start; }
+      .stg .n { justify-self: start; font-size: 16px; }
+      .me-nums { margin-left: 0; width: 100%; justify-content: space-between; gap: 12px; }
+      .me-nums div { text-align: left; }
+      .stat b { font-size: 24px; }
+      .stat .top .go { display: none; }
+      .btn-key { display: none; }
+      .feed li { flex-wrap: wrap; }
+      .feed li a { white-space: normal; flex: 1 1 100%; }
+      .go, .hd .go, .me-id .go { min-height: 44px; min-width: 44px; justify-content: center; }
+      .pending li { min-height: 44px; }
       .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: var(--r-md); 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; }
+        color: var(--kf-accent-ink); text-decoration: none; background: #fff; border: 1px solid rgba(0, 0, 0, 0.06); }
     }
   `];
 
-  /**
-   * 近 7 天逐日新增(sparkline 的数据)。按页面时钟算,不落库、不改 schema。
-   * by: 'opp' 机会卡 / 'artifact' 成果。
-   */
-  #weekly(by = 'opp') {
-    const DAY = 86400000;
-    const now = Date.now();
-    const src = by === 'opp'
-      ? this.db.opportunities.map((o) => o.upload?.uploadedAt || o.createdAt)
-      : this.db.artifacts.map((a) => a.createdAt);
-    const days = Array.from({ length: 7 }, (_, i) => {
-      const start = now - (6 - i) * DAY;
-      return src.filter((t) => { const v = Date.parse(t || ''); return v > start - DAY && v <= start && v > now - 7 * DAY; }).length;
-    });
-    // 首日补齐:把更早的都算进第 1 天,形状才不撒谎
-    const oldest = now - 7 * DAY;
-    days[0] = src.filter((t) => { const v = Date.parse(t || ''); return v > 0 && v <= now - 6 * DAY; }).length;
-    const max = Math.max(1, ...days);
-    void oldest;
-    return { days, max, recent: days.reduce((a, b) => a + b, 0) };
-  }
-
   render() {
     const db = this.db;
     if (!db) return html`<kf-empty kind="loading" title="工作台"></kf-empty>`;
@@ -172,26 +189,20 @@ export class ViewWorkbench extends LitElement {
     const latest = sortByNewest(pool.latest3);
     return html`
       ${head('我的推进', '五阶段进度与现场状态', html`<a class="go" href=${href('/pool')} data-link>机会卡池 →</a>`)}
-      <div class="grid2 kf-enter" style="margin-top:0; --i:0">
-        <kf-glass-card surface="2">${this.#mine(me, pool, live)}</kf-glass-card>
-        <kf-glass-card surface="2" tone=${live.stalled.length ? 'danger' : 'ok'}>
-          <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}>
-              <span class="nm">${s.name}</span><span class="meta">${t(`${s.stage} · ${s.msgCount} 条`)}</span>
-              <span class="h">${s.stuckHours}h</span></li>`)}
-          </ul>` : html`<p class="ok">${icon('check', 16)} 大家都在动</p>`}
-        </kf-glass-card>
-      </div>
+      <div class="bento">
+        <div class="row ab kf-enter" style="--i:0">
+          <kf-glass-card data-role="a">${this.#mine(me, pool, live)}</kf-glass-card>
+          <kf-glass-card data-role="b" dark>${this.#live(live, pool)}</kf-glass-card>
+        </div>
 
-      <div class="hd"><h3>机会卡池</h3><a class="go" href=${href('/pool')} data-link aria-label="全部机会卡">${pool.total} ${icon('arrow', 14)}</a></div>
-      ${this.#pool(pool)}
+        <div class="row cde kf-enter" style="--i:1">
+          <kf-glass-card data-role="c">${this.#stat('open', pool)}</kf-glass-card>
+          <kf-glass-card data-role="d">${this.#stat('top', pool)}</kf-glass-card>
+          <kf-glass-card data-role="e">${this.#stat('decided', pool)}</kf-glass-card>
+        </div>
 
-      <div class="latest" style="--i:2">
-        ${latest.map((o) => html`<kf-opportunity-card .opp=${o} base="/pool" @card-open=${(e) => this.#open(e.detail.id)}></kf-opportunity-card>`)}
+        <kf-glass-card data-role="f" class="kf-enter" style="--i:2">${this.#feed(latest)}</kf-glass-card>
       </div>
-      ${/* v12 P2 (b):底部收尾 —— 本页概况的一句收口 + 回顶部,吃掉页尾空白 */ ''}
       ${cardFooter({ shown: latest.length, all: pool.total, filtered: false, label: '张机会卡,最近 3 张列在上方', onTop: () => window.scrollTo({ top: 0, behavior: 'smooth' }) })}
       <!-- 只有手机显示:证据台账从底部 Tab 移到工作台页脚(真实链接,部署前缀同源) -->
       <nav class="foot-links" aria-label="更多入口">
@@ -200,85 +211,110 @@ export class ViewWorkbench extends LitElement {
     `;
   }
 
+  /** A 卡:选了「我是」→ 我的进度;没选 → 全员进度(v12 定稿:空态也放真实内容,不摆空框) */
   #mine(me, pool, live) {
     if (!this.me) {
-      // 空态(板块八):说清为什么空 + 给一个动作 + 卡片框不消失。
-      // v12 P3:「未选」不再是一张空框 —— 左卡改放全员的真实进度(五阶段 × 在阶段人数 / 已交人数),
-      // 选人动作在卡片右上角;选完之后同一张卡换成「我的进度」。左右两卡等高(栅格 stretch)。
-      return html`<div class="who"><div class="avatar">?</div><h3>还没选「我是」</h3>
-          <a class="go" style="margin-left:auto" href=${href('/members')} data-link>${icon('user', 14)} 选我</a></div>
-        <div class="recent-h" style="margin-top:12px"><span>${t(`先看全员:${live.memberCount} 人`)}</span><span style="margin-left:auto;color:var(--kf-ink)">${t(`已交 ${live.submittedPeople} 人`)}</span></div>
+      const maxSub = Math.max(1, ...live.stages.map((r) => r.submitted));
+      return html`<div class="me" data-empty="no-me">
+        <div class="me-id"><div class="avatar q">?</div>
+          <div><h3>还没选「我是」</h3><span class="sub">先看全员:${live.memberCount} 人</span></div>
+          <a class="go" href=${href('/members')} data-link>${icon('user', 14)} 选我</a></div>
         <div class="stagebar" data-allstages>
           ${live.stages.map((r, i) => html`<div class="stg ${TONE[r.stage.code]}" style=${`--i:${i + 1}`} data-stage=${r.stage.code}>
             <span class="b ${TONE[r.stage.code]}">${r.stage.code}</span>
             <span class="n">${r.submitted || '—'}</span>
-            <kf-ring size=${40} .pct=${(r.submitted / Math.max(1, live.memberCount)) * 100} label=${String(r.submitted)}></kf-ring>
-            <span class="f" aria-hidden="true"><i style=${`--v:${(r.submitted / Math.max(1, live.memberCount)).toFixed(2)}`}></i></span></div>`)}
+            <span class="f" aria-hidden="true"><i style=${`--v:${(r.submitted / maxSub).toFixed(2)}`}></i></span></div>`)}
         </div>
-        <div class="empty" style="margin-top:12px"><span>选了自己,这张卡就换成你的阶段进度与待办</span></div>`;
+        <div class="empty"><span>已交 ${live.submittedPeople} 人 · 选了自己,这里换成你的阶段进度与待办</span></div>
+      </div>`;
     }
-    const done = STAGES.filter((s) => me.stagesDone.includes(s.code));
+    const done = new Set(me.stagesDone);
     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">
+    const pending = me.pendingScores || [];
+    return html`<div class="me" data-empty=${me.count === 0 ? 'no-result' : 'none'}>
+      <div class="me-id">
         <div class="avatar">${Array.from(me.me).slice(-1).join('')}</div>
-        <h3>${me.me}</h3>
-        <div class="n">
+        <div><h3>${me.me}</h3><span class="sub">已完成 ${me.stagesDone.length} / 5 个阶段</span></div>
+        <div class="me-nums">
           <div><b>${me.count}</b><span>份成果</span></div>
           <div><b>${me.opps.length}</b><span>张卡</span></div>
-          <div><b>${me.pendingScores.length}</b><span>待评分</span></div>
+          <div><b>${pending.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, i) => {
           const n = me.mine.filter((a) => a.stageObj?.code === s.code).length;
-          const ok = done.includes(s.code);
-          // 阶段完成度环(v11b 2.2):环 40px / 描边 3px / 环心印读数;旁边的 n 是「份」的精确数,环只说形状
+          const ok = done.has(s.code);
           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>
-      ${me.count === 0 ? html`<div class="empty" style="margin-top:12px"><span>本月还没有你的成果</span>
+      ${me.count === 0 ? html`<div class="empty"><span>本月还没有你的成果</span>
         <a class="go" href=${href('/stages')} data-link>看五阶段产出 →</a></div>` : ''}
-      <div class="recent">${this.#recent(pool)}</div>
-      ${me.pendingScores.length ? html`<div class="recent-h" style="margin-top:12px"><span>待评分 ${me.pendingScores.length}</span>
-        <a class="go" href=${href('/pool')} data-link aria-label="待评分卡">${icon('arrow', 14)}</a></div>
-        <ul class="todo">${me.pendingScores.slice(0, 3).map((o) => html`<li>
-          <a href=${href(`/pool/${o.id}`)} data-link>${o.title}</a><span class="s">${o.stats.avg != null ? Math.round(o.stats.avg) : '—'}</span></li>`)}</ul>`
-        : html`<div class="recent-h" style="margin-top:12px"><span>近 7 天成果</span><kf-spark small .values=${sp.days.map((d) => d / sp.max)}></kf-spark><span style="margin-left:auto;color:var(--kf-ink)">${sp.recent}</span></div>`}`;
+      ${pending.length ? html`<div>
+        <div class="lbl">待评分 ${pending.length}</div>
+        <ul class="pending">${pending.slice(0, 3).map((o) => html`<li>
+          <a href=${href(`/pool/${o.id}`)} data-link>${o.title}</a><span class="s">${o.stats.avg != null ? Math.round(o.stats.avg) : '—'}</span></li>`)}</ul>
+      </div>` : ''}
+    </div>`;
   }
 
-  /** 「最近动过」:标签 ···· 值(2.5)——三行讲清池子的状态构成,不写一句话 */
-  #recent(pool) {
-    const s = pool.byStatus;
-    const total = Math.max(1, pool.total);
-    return html`<div class="recent-h"><span>最近动过</span></div>
-      <div class="kv"><span class="k">候选</span><span class="v"><b>${s.candidate}</b> <span class="u">张</span>${Math.round((s.candidate / total) * 100)}<span class="u">%</span></span></div>
-      <div class="kv"><span class="k">已立项</span><span class="v"><b>${s.approved}</b> <span class="u">张</span>${Math.round((s.approved / total) * 100)}<span class="u">%</span></span></div>
-      <div class="kv"><span class="k">已否决</span><span class="v"><b>${s.rejected}</b> <span class="u">张</span>${Math.round((s.rejected / total) * 100)}<span class="u">%</span></span></div>`;
+  /** B 卡:现场。深色底 + 反白字;有卡住的人 → 橙点 + 人数 + 名单;没人卡住 → 绿点 + 正向文案。 */
+  #live(live, pool) {
+    const stalled = live.stalled;
+    return html`<div class="live" data-stalled-count=${stalled.length}>
+      <div class="live-head"><span class="lbl">${stalled.length
+        ? html`<i class="dot" style="background:#F59E0B"></i>现场`
+        : html`<i class="dot" style="background:#4ADE80"></i>现场`}</span>
+        <a class="go" href=${href('/pool')} data-link aria-label="去机会卡池">${icon('arrow', 14)}</a></div>
+      ${stalled.length ? html`
+        <div class="live-num"><b>${stalled.length}</b><span>人卡住</span></div>
+        <ul class="stalled">${stalled.slice(0, 4).map((s) => html`<li data-stalled=${s.name}>
+          <span class="nm">${s.name}</span><span class="meta">${t(`${s.stage} · ${s.msgCount} 条`)}</span>
+          <span class="h">${s.stuckHours}h</span></li>`)}</ul>`
+      : html`
+        <div class="live-title">大家都在动</div>
+        <p class="live-ok">${icon('check', 16)} 没有超过 8 小时没交成果的人</p>`}
+      <div class="live-foot">${t(`${live.memberCount} 人 · ${pool.total} 张卡在推进`)}</div>
+    </div>`;
   }
 
-  #pool(pool) {
-    const sp = this.#weekly('opp');
-    const spark = html`<kf-spark small .values=${sp.days.map((d) => d / sp.max)}></kf-spark>`;
-    const avg = pool.top?.stats?.avg;
-    return html`<div class="grid4">
-      <kf-glass-card surface="2"><div class="kpi" data-kpi="total"><b>${pool.total}<span class="u">张</span></b>${spark}<span class="lbl">机会卡</span></div></kf-glass-card>
-      <kf-glass-card surface="2"><div class="kpi" data-kpi="avg"><b class=${avg == null ? 'dim' : ''}>${avg != null ? Math.round(avg) : '—'}<span class="u">分</span></b>${spark}<span class="lbl">${pool.top?.title || '暂无评分'}</span></div></kf-glass-card>
-      <kf-glass-card surface="2"><div class="kpi" data-kpi="people"><b>${pool.uploaders}<span class="u">人</span></b>${spark}<span class="lbl">${t(`${pool.agents} Agent 参与`)}</span></div></kf-glass-card>
-      <kf-glass-card surface="2"><div class="kpi" data-kpi="decided"><b>${pool.byStatus.approved + pool.byStatus.rejected}<span class="u">张</span></b>${spark}<span class="lbl">已决策</span></div></kf-glass-card>
-    </div>`;
+  /** C/D/E:标签 → 大数字 → 一行说明 */
+  #stat(kind, pool) {
+    if (kind === 'open') {
+      return html`<div class="stat" data-kpi="total">
+        <div class="top"><span class="lbl">卡池</span><a class="btn-key" href=${href('/pool')} data-link>全部 ${icon('arrow', 14)}</a></div>
+        <b>${pool.total}<span class="u">张</span></b>
+        <span class="note">${pool.uploaders} 人提交 · ${pool.agents} 个 Agent</span></div>`;
+    }
+    if (kind === 'top') {
+      const top = pool.top;
+      const avg = top?.stats?.avg;
+      return html`<div class="stat" data-kpi="avg">
+        <div class="top"><span class="lbl">最高分</span></div>
+        <b class=${avg == null ? 'dim' : ''}>${avg != null ? Math.round(avg) : '—'}<span class="u">分</span></b>
+        <span class="note" title=${top?.title || ''}>${top?.title || '暂无评分'}</span></div>`;
+    }
+    const dec = pool.byStatus.approved + pool.byStatus.rejected;
+    return html`<div class="stat" data-kpi="decided">
+      <div class="top"><span class="lbl">已决策</span></div>
+      <b>${dec}<span class="u">张</span></b>
+      <span class="note">立项 ${pool.byStatus.approved} · 否决 ${pool.byStatus.rejected}</span></div>`;
   }
 
-  #open(id) {
-    this.dispatchEvent(new CustomEvent('card-open', { detail: { id }, bubbles: true, composed: true }));
+  /** F:最近动过(最多 3 条,右对齐时间) */
+  #feed(latest) {
+    return html`<div data-feed>
+      <div class="feed-head"><span class="lbl">最近动过</span>
+        <a class="go" href=${href('/pool')} data-link aria-label="全部机会卡">全部 →</a></div>
+      ${latest.length ? html`<ul class="feed" style="margin-top:8px">${latest.map((o) => html`<li>
+        <a href=${href(`/pool/${o.id}`)} data-link>${o.title}</a>
+        <span class="st">${o.stageObj ? o.stageObj.code + ' ' + o.stageObj.name : ''}</span>
+        <span class="tm">${ago(o.upload?.uploadedAt || o.createdAt)}</span></li>`)}</ul>`
+      : html`<p class="feed-empty">还没有机会卡</p>`}
+    </div>`;
   }
 }