diff --git a/.claude/skills/design-handoff/rules/interaction-rules.yml b/.claude/skills/design-handoff/rules/interaction-rules.yml
index 2fb6018..1960a56 100644
--- a/.claude/skills/design-handoff/rules/interaction-rules.yml
+++ b/.claude/skills/design-handoff/rules/interaction-rules.yml
@@ -3,142 +3,106 @@ updated: "2026-05-17"
# ============================================================================
# 交互推断规则
-# 用于从静态原型 HTML 自动推断交互行为
-# 每条规则: id / name / patterns(匹配模式) / infer(推断结果) / confidence
+# patterns 使用正则表达式,匹配 HTML/JS 源码中的实际代码模式
+# require_all: true 表示所有 pattern 必须同时匹配(默认 false,任一匹配即可)
# ============================================================================
rules:
- id: swiper-autoplay
name: "自动轮播 + 手动滑动"
patterns:
- - "渐变背景容器内含多张图片/卡片"
- - "底部指示点宽窄交替(当前页宽、其他窄)"
- - "容器设置了 overflow:hidden 且子元素横向排列"
- - "存在 swiper / carousel / banner 类名"
+ - "linear-gradient"
+ - "width.*24.*width.*8"
+ require_all: false
infer:
- interaction: swiper
- trigger:
- - "自动播放(interval 3000-5000ms)"
- - "手势滑动切换"
- feedback:
- - "指示点跟随切换高亮"
- - "平滑过渡动画(cubic-bezier)"
+ component: "Swiper"
+ props: "autoplay circular indicatorDots"
+ behavior: "自动轮播,3-5秒切换"
confidence: high
- id: card-tap
name: "卡片点击跳转"
patterns:
- - "卡片容器(ContentCard/圆角矩形)内含标题 + 摘要 + 配图"
- - "卡片有 hover/active 视觉反馈样式"
- - "卡片内无独立按钮,整体可点击"
- - "存在 card / item 等语义类名"
+ - "\\.map\\("
+ require_all: false
infer:
- interaction: navigate
- trigger:
- - "点击整个卡片区域"
- feedback:
- - "按下时背景色变化或缩放反馈(--tk-touch-feedback-opacity)"
- - "跳转到详情页"
- confidence: high
+ component: "ContentCard"
+ props: "activeFeedback onPress"
+ behavior: "卡片可点击,带触控反馈"
+ confidence: medium
- id: form-submit
name: "表单提交"
patterns:
- - "输入框(input/textarea)与按钮在同一个 form 容器内"
- - "按钮文案为"提交"/"确认"/"保存"等动作词"
- - "输入框有 label + placeholder 配对"
- - "存在 form / input-group 等语义结构"
+ - " 0"
+ - "\\.length === 0"
+ - "暂无"
+ - "没有"
+ require_all: false
infer:
- interaction: empty_state
- trigger:
- - "数据加载完成且列表为空时自动展示"
- feedback:
- - "显示空状态插图 + 引导文案"
- - "可选的引导按钮(点击跳转创建页)"
+ component: null
+ props: ""
+ behavior: "条件渲染:有数据显示列表,无数据显示空状态"
confidence: medium
diff --git a/.claude/skills/design-handoff/scripts/extract-screenshots.mjs b/.claude/skills/design-handoff/scripts/extract-screenshots.mjs
new file mode 100644
index 0000000..f081a06
--- /dev/null
+++ b/.claude/skills/design-handoff/scripts/extract-screenshots.mjs
@@ -0,0 +1,194 @@
+#!/usr/bin/env node
+/**
+ * extract-screenshots.mjs — 从 huashu-design HTML 原型截取 IosFrame 屏幕内容
+ *
+ * 用法: node extract-screenshots.mjs
+ */
+
+import { chromium } from 'playwright';
+import { resolve, basename } from 'node:path';
+import { mkdirSync, existsSync } from 'node:fs';
+
+const IOS_FRAME_TRIM = { statusBar: 54, homeIndicator: 34, padding: 12 };
+const RENDER_WAIT_TIMEOUT = 10_000;
+const RENDER_POLL_INTERVAL = 200;
+
+const ZH_EN_MAP = {
+ '完整页': '', '—': '-', ' ': '-',
+ '首页': 'home', '我的': 'profile', '登录': 'login', '健康': 'health',
+ '消息': 'messages', '咨询': 'consultation', '预约': 'appointment',
+ '商城': 'mall', '设置': 'settings', '访客': 'guest', '轮播': 'slide',
+ '体检': 'checkup', '报告': 'report', '医生': 'doctor', '患者': 'patient',
+ '记录': 'record', '详情': 'detail', '列表': 'list', '管理': 'manage',
+ '编辑': 'edit', '新增': 'add', '搜索': 'search', '筛选': 'filter',
+ '结果': 'result', '历史': 'history', '数据': 'data', '体征': 'vitals',
+ '用药': 'medication', '随访': 'followup', '透析': 'dialysis',
+ '日常': 'daily', '监测': 'monitor', '告警': 'alert', '家庭': 'family',
+ '成员': 'member', '档案': 'profile', '商品': 'product', '兑换': 'exchange',
+ '订单': 'order', '积分': 'points', '活动': 'activity',
+};
+
+function translateLabel(label, fallbackIndex) {
+ let result = label;
+ const sortedKeys = Object.keys(ZH_EN_MAP).sort((a, b) => b.length - a.length);
+ for (const zh of sortedKeys) {
+ result = result.split(zh).join(ZH_EN_MAP[zh]);
+ }
+ result = result.replace(/[^\w\-.]/g, '-');
+ result = result.replace(/^-+|-+$/g, '').replace(/-+/g, '-');
+ return result || `screen-${fallbackIndex}`;
+}
+
+function fail(msg) {
+ process.stderr.write(`ERROR: ${msg}\n`);
+ process.exit(1);
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args.length < 2) fail('用法: node extract-screenshots.mjs ');
+
+ const htmlFile = resolve(args[0]);
+ const outputDir = resolve(args[1]);
+ if (!existsSync(htmlFile)) fail(`HTML 文件不存在: ${htmlFile}`);
+ mkdirSync(outputDir, { recursive: true });
+
+ let browser;
+ try {
+ browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: 2400, height: 1400 },
+ deviceScaleFactor: 2,
+ });
+ const page = await context.newPage();
+
+ const fileUrl = `file:///${htmlFile.replace(/\\/g, '/')}`;
+ await page.goto(fileUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
+
+ // 等待 React 渲染
+ const renderStart = Date.now();
+ let rendered = false;
+ while (Date.now() - renderStart < RENDER_WAIT_TIMEOUT) {
+ rendered = await page.evaluate(() => {
+ const root = document.getElementById('root');
+ return root ? root.children.length > 0 : false;
+ });
+ if (rendered) break;
+ await page.waitForTimeout(RENDER_POLL_INTERVAL);
+ }
+ if (!rendered) fail(`React 渲染超时: ${basename(htmlFile)}`);
+ await page.waitForTimeout(500);
+
+ // 查找 .screen-wrap 容器
+ const screenCount = await page.locator('.screen-wrap').count();
+
+ if (screenCount === 0) {
+ // 降级:整页截图
+ await page.screenshot({ path: resolve(outputDir, 'full-page.png'), fullPage: true });
+ process.stdout.write(JSON.stringify({
+ source: basename(htmlFile), totalScreens: 0, fallback: true,
+ files: [{ label: 'full-page', file: 'full-page.png' }],
+ }, null, 2) + '\n');
+ await browser.close();
+ return;
+ }
+
+ // 使用 page.evaluate 定位并截取每个 screen
+ const screenData = await page.evaluate(() => {
+ const wraps = document.querySelectorAll('.screen-wrap');
+ return Array.from(wraps).map((wrap, i) => {
+ // 获取标签
+ const labelEl = wrap.querySelector('.screen-label');
+ const label = labelEl ? labelEl.textContent.trim() : '';
+
+ // 找 IosFrame wrapper: screen-wrap 下有 borderRadius:60 + background:#000 的 div
+ // 或者用策略:找第一个有 style 含 borderRadius 的 div
+ const allDivs = wrap.querySelectorAll(':scope > div');
+ let wrapperDiv = null;
+ for (const div of allDivs) {
+ const style = div.getAttribute('style') || '';
+ // IosFrame wrapper 特征: background:#000 或 background: black
+ if (style.includes('background:')) {
+ wrapperDiv = div;
+ break;
+ }
+ }
+ // 如果没找到 background 的,取第一个 div
+ if (!wrapperDiv && allDivs.length > 0) wrapperDiv = allDivs[0];
+
+ if (!wrapperDiv) return { label, index: i, found: false };
+
+ const wrapperBox = wrapperDiv.getBoundingClientRect();
+ return {
+ label,
+ index: i,
+ found: true,
+ wrapperBox: {
+ x: wrapperBox.x,
+ y: wrapperBox.y,
+ width: wrapperBox.width,
+ height: wrapperBox.height,
+ },
+ };
+ });
+ });
+
+ const { statusBar, homeIndicator, padding } = IOS_FRAME_TRIM;
+ const files = [];
+ const usedNames = new Set();
+
+ for (const screen of screenData) {
+ if (!screen.found || !screen.wrapperBox) {
+ process.stderr.write(`WARNING: screen ${screen.index + 1} 未找到 IosFrame,跳过\n`);
+ continue;
+ }
+
+ const box = screen.wrapperBox;
+ const clip = {
+ x: box.x + padding,
+ y: box.y + padding + statusBar,
+ width: box.width - padding * 2,
+ height: box.height - padding * 2 - statusBar - homeIndicator,
+ };
+
+ if (clip.width <= 0 || clip.height <= 0) {
+ process.stderr.write(`WARNING: screen ${screen.index + 1} clip 无效 (${clip.width}x${clip.height})\n`);
+ continue;
+ }
+
+ let safeName = translateLabel(screen.label || '', screen.index + 1);
+ if (usedNames.has(safeName)) {
+ let s = 2;
+ while (usedNames.has(`${safeName}-${s}`)) s++;
+ safeName = `${safeName}-${s}`;
+ }
+ usedNames.add(safeName);
+
+ const filename = `${safeName}.png`;
+ await page.screenshot({ path: resolve(outputDir, filename), clip });
+
+ files.push({
+ label: screen.label || `screen-${screen.index + 1}`,
+ file: filename,
+ clip: {
+ x: Math.round(clip.x), y: Math.round(clip.y),
+ width: Math.round(clip.width), height: Math.round(clip.height),
+ },
+ });
+ }
+
+ process.stdout.write(JSON.stringify({
+ source: basename(htmlFile),
+ totalScreens: screenCount,
+ extracted: files.length,
+ files,
+ }, null, 2) + '\n');
+
+ } catch (err) {
+ fail(`Playwright 执行失败: ${err.message}`);
+ } finally {
+ if (browser) await browser.close();
+ }
+}
+
+main();
diff --git a/.claude/skills/design-handoff/scripts/infer-interactions.mjs b/.claude/skills/design-handoff/scripts/infer-interactions.mjs
new file mode 100644
index 0000000..7c70d03
--- /dev/null
+++ b/.claude/skills/design-handoff/scripts/infer-interactions.mjs
@@ -0,0 +1,205 @@
+#!/usr/bin/env node
+/**
+ * infer-interactions.mjs -- 对 HTML 原型源码进行静态模式匹配,推断页面交互行为
+ *
+ * 读取 HTML 文件和 interaction-rules.yml,用正则匹配源码中的模式,
+ * 输出推断的交互组件、props 和行为描述。
+ *
+ * 用法: node infer-interactions.mjs
+ */
+
+import { readFileSync } from 'node:fs';
+import { resolve, basename } from 'node:path';
+import { createRequire } from 'node:module';
+
+// js-yaml 是 CJS 包,需要用 createRequire 加载
+const require = createRequire(import.meta.url);
+const yaml = require('js-yaml');
+
+// -- 工具函数 ----------------------------------------------------------------
+
+/**
+ * 输出错误到 stderr 并以 code 1 退出
+ */
+function fail(message) {
+ process.stderr.write(JSON.stringify({ error: message }, null, 2) + '\n');
+ process.exit(1);
+}
+
+/**
+ * 读取文件内容,失败时调用 fail
+ */
+function readFileOrFail(filePath, description) {
+ try {
+ return readFileSync(filePath, 'utf-8');
+ } catch (err) {
+ fail(`无法读取${description}: ${filePath} -- ${err.message}`);
+ }
+}
+
+// -- 组件函数位置追踪 ---------------------------------------------------------
+
+/**
+ * 提取源码中所有 function/const 组件声明的位置和名称。
+ * 返回 [{ name, start, end }],按 start 排序。
+ *
+ * end 的计算采用简化策略:找到下一个同级 function/const 声明或源码末尾。
+ * 对于 locations 追踪来说已经足够精确——只要知道 pattern 落在哪个函数内即可。
+ */
+function extractFunctionRanges(source) {
+ const ranges = [];
+
+ // 匹配 function 声明: function XxxName(
+ const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
+ let match;
+ while ((match = funcPattern.exec(source)) !== null) {
+ ranges.push({ name: match[1], start: match.index });
+ }
+
+ // 匹配 const 箭头函数: const XxxName = (...
+ const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|\w+)\s*=>/g;
+ while ((match = arrowPattern.exec(source)) !== null) {
+ ranges.push({ name: match[1], start: match.index });
+ }
+
+ // 按 start 排序,计算 end(下一个函数的 start 或源码末尾)
+ ranges.sort((a, b) => a.start - b.start);
+ for (let i = 0; i < ranges.length; i++) {
+ const nextStart = i + 1 < ranges.length ? ranges[i + 1].start : source.length;
+ ranges[i].end = nextStart;
+ }
+
+ return ranges;
+}
+
+/**
+ * 给定 pattern 在 source 中的匹配位置,找到它所属的函数名。
+ * 如果不在任何函数内,返回 '全局'。
+ */
+function findEnclosingFunction(ranges, matchIndex) {
+ for (const range of ranges) {
+ if (matchIndex >= range.start && matchIndex < range.end) {
+ return range.name;
+ }
+ }
+ return '全局';
+}
+
+// -- 核心匹配逻辑 ------------------------------------------------------------
+
+/**
+ * 对单条 rule 执行模式匹配。
+ * 返回 { matched, matchedPatterns, locations }。
+ */
+function matchRule(rule, source, funcRanges) {
+ const requireAll = rule.require_all === true;
+ const matchedPatterns = [];
+ const locationSet = new Set();
+
+ for (const patternStr of rule.patterns) {
+ try {
+ const regex = new RegExp(patternStr, 'g');
+ let match;
+ let patternMatched = false;
+
+ while ((match = regex.exec(source)) !== null) {
+ patternMatched = true;
+ const enclosing = findEnclosingFunction(funcRanges, match.index);
+ locationSet.add(enclosing);
+ }
+
+ if (patternMatched) {
+ matchedPatterns.push(patternStr);
+ }
+ } catch (err) {
+ // 正则语法错误,跳过此 pattern 并报告到 stderr
+ process.stderr.write(`警告: rule "${rule.id}" 的 pattern "${patternStr}" 正则语法错误: ${err.message}\n`);
+ }
+ }
+
+ const allMatched = requireAll
+ ? matchedPatterns.length === rule.patterns.length
+ : matchedPatterns.length > 0;
+
+ return {
+ matched: allMatched,
+ matchedPatterns: allMatched ? matchedPatterns : [],
+ locations: allMatched ? [...locationSet] : [],
+ };
+}
+
+// -- 主流程 ------------------------------------------------------------------
+
+function main() {
+ const args = process.argv.slice(2);
+ if (args.length < 2) {
+ fail('用法: node infer-interactions.mjs ');
+ }
+
+ const htmlPath = resolve(args[0]);
+ const rulesPath = resolve(args[1]);
+
+ // 读取源文件
+ const source = readFileOrFail(htmlPath, 'HTML 文件');
+
+ // 读取并解析 rules YAML
+ const rulesContent = readFileOrFail(rulesPath, 'interaction-rules.yml');
+ let rulesDoc;
+ try {
+ rulesDoc = yaml.load(rulesContent);
+ } catch (err) {
+ fail(`YAML 解析失败: ${err.message}`);
+ }
+
+ if (!rulesDoc || !Array.isArray(rulesDoc.rules)) {
+ fail('interaction-rules.yml 格式错误: 缺少顶层的 rules 数组');
+ }
+
+ const rules = rulesDoc.rules;
+
+ // 提取函数位置范围表(用于 locations 追踪)
+ const funcRanges = extractFunctionRanges(source);
+
+ // 逐条匹配
+ const interactions = [];
+ let matchedCount = 0;
+
+ for (const rule of rules) {
+ const { matched, matchedPatterns, locations } = matchRule(rule, source, funcRanges);
+
+ const entry = {
+ id: rule.id,
+ name: rule.name,
+ matched,
+ };
+
+ if (matched) {
+ matchedCount++;
+ entry.matchedPatterns = matchedPatterns;
+ entry.component = rule.infer?.component ?? null;
+ entry.props = rule.infer?.props ?? '';
+ entry.behavior = rule.infer?.behavior ?? '';
+ entry.confidence = rule.confidence ?? 'medium';
+ if (locations.length > 0) {
+ entry.locations = locations;
+ }
+ }
+
+ interactions.push(entry);
+ }
+
+ // 组装输出
+ const result = {
+ source: basename(htmlPath),
+ interactions,
+ summary: {
+ total: rules.length,
+ matched: matchedCount,
+ unmatched: rules.length - matchedCount,
+ },
+ };
+
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
+}
+
+main();
diff --git a/.claude/skills/design-handoff/templates/spec-template.md b/.claude/skills/design-handoff/templates/spec-template.md
new file mode 100644
index 0000000..ae2fac4
--- /dev/null
+++ b/.claude/skills/design-handoff/templates/spec-template.md
@@ -0,0 +1,73 @@
+# {{pageTitle}} 设计规格
+
+> 来源: {{sourceFile}} | 平台: {{platform}} | 页面数: {{screenCount}} | 生成: {{date}}
+
+## 页面索引
+
+| 页面 | 截图 | 路由 |
+|------|------|------|
+{{#each screens}}
+| {{label}} |  | {{route}} |
+{{/each}}
+
+## 一、Token 映射
+
+| 原型值 | 项目 Token | 状态 |
+|--------|-----------|------|
+{{#each tokenMap}}
+| {{prototypeValue}} ({{key}}) | {{tokenRef}} | {{statusIcon}} |
+{{/each}}
+
+> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需人工复核 | ❌ unmatched 需硬编码或新建 Token
+
+## 二、页面结构
+
+{{#each screens}}
+### {{ordinal}}. {{label}}
+
+
+
+布局层级(从上到下):
+
+{{layoutDescription}}
+
+{{/each}}
+
+## 三、组件映射
+
+| 原型元素 | 推荐组件 | 来源 | 备注 |
+|----------|---------|------|------|
+{{#each componentMap}}
+| {{prototypeElement}} | {{component}} | {{source}} | {{notes}} |
+{{/each}}
+
+{{#each newComponents}}
+> ⚠️ **需新建**: {{name}} — {{reason}}
+{{/each}}
+
+## 四、交互规格
+
+| 元素 | 交互 | 触发 | 反馈 | 备注 |
+|------|------|------|------|------|
+{{#each interactions}}
+| {{element}} | {{type}} | {{trigger}} | {{feedback}} | {{notes}} |
+{{/each}}
+
+## 五、状态变体
+
+{{#each stateVariants}}
+- **{{name}}**: {{description}}
+{{/each}}
+
+## 六、样式清单
+
+{{styleSummary}}
+
+---
+
+> 此规格由 design-handoff skill 自动生成。LLM 实施时请:
+> 1. 先阅读截图建立视觉印象
+> 2. 按 Token 映射表使用项目 Token(✅ 标记的直接用)
+> 3. 优先使用"组件映射"中列出的已有组件
+> 4. 参考"交互规格"实现对应的交互逻辑
+> 5. "需新建"的组件参考截图和布局描述从头实现