# 设计稿还原规范 (Design Restoration Standard)

> 通用规范 - 适用于所有视觉化开发场景（跨平台/前端/全栈）

## 📋 概述

本规范定义了从设计工具（Sketch、Figma、Adobe XD 等）还原 UI 到代码的标准流程和注意事项，确保在不同框架（Flutter、Vue、React、小程序等）中都能准确还原设计稿。

**适用场景**：
- 使用 Sketch MCP、Figma MCP 等工具测量设计稿
- 根据设计图片手动还原 UI
- 跨平台/多框架项目的样式统一

---

## 🎯 核心原则

### 1. 测量优先级

按以下优先级获取设计参数：

1. **MCP 工具直接读取** - 最准确，但需注意 API 陷阱
2. **设计工具检查器** - 设计师侧边栏的属性面板
3. **标注插件导出** - 如 Sketch Measure、Zeplin
4. **肉眼估算** - 最后手段，需反复对比验证

### 2. 验证闭环

```
测量 → 编码 → 截图对比 → 微调 → 确认
```

每个元素都必须完成这个闭环。

### 3. 原子化记录

将设计参数按类别记录，便于复用和维护。

---

## 📐 测量要素清单

### 必须测量的参数

| 类别 | 参数 | 说明 |
|------|------|------|
| **尺寸** | width, height | 精确到像素 |
| **位置** | x, y, margin, padding | 相对于父容器 |
| **颜色** | 填充色、边框色、阴影色 | ⚠️ 注意渐变 |
| **圆角** | borderRadius | 四角可能不同 |
| **字体** | family, size, weight, height | 行高很重要 |
| **阴影** | color, blur, offset, spread | 多重阴影 |
| **边框** | color, width, style | 虚线/实线 |

### ⚠️ 常见陷阱

#### 1. 渐变色读取错误

**问题**：API 返回渐变的"代表色"而非实际渐变定义

```javascript
// ❌ 错误方式
layer.style.fills[0].color  // 返回错误的单色

// ✅ 正确方式
if (fill.fillType === 'Gradient') {
  fill.gradient.stops.map(s => s.color)  // 获取所有色标
}
```

#### 2. 透明度丢失

**问题**：颜色值可能不包含透明度信息

```javascript
// 确保读取完整的 RGBA
color: "#1C2B4580"  // 包含透明度
// 而不是
color: "#1C2B45"    // 丢失透明度
```

#### 3. 字体权重映射

设计稿中的字重名称需要映射为数值：

| 设计稿名称 | PostScript 后缀 | 数值 | Flutter | CSS |
|-----------|----------------|------|---------|-----|
| Thin | -Thin | 100 | FontWeight.w100 | font-weight: 100 |
| Ultralight | -Ultralight | 200 | FontWeight.w200 | font-weight: 200 |
| Light | -Light | 300 | FontWeight.w300 | font-weight: 300 |
| Regular | -Regular | 400 | FontWeight.w400 | font-weight: 400 |
| Medium | -Medium | 500 | FontWeight.w500 | font-weight: 500 |
| SemiBold | -Semibold | 600 | FontWeight.w600 | font-weight: 600 |
| Bold | -Bold | 700 | FontWeight.w700 | font-weight: 700 |
| Heavy/ExtraBold | -Heavy | 800 | FontWeight.w800 | font-weight: 800 |
| Black | -Black | 900 | FontWeight.w900 | font-weight: 900 |

> ⚠️ **重要**：Sketch API 的 `fontWeight` 属性返回的数值（如 8）不可靠！必须通过 `sketchObject.font().fontName()` 获取真实的 PostScript 字体名称（如 `PingFangSC-Semibold`），然后根据后缀映射。

#### 4. 图标必须从设计稿导出

**绝对禁止**：AI 自己生成图标 SVG

**正确做法**：使用设计工具 API 导出设计稿中的图标

```javascript
// Sketch 导出示例
const buffer = sketch.export(iconLayer, {
  formats: 'svg',
  output: false,
  scales: '1'
});
const svgString = buffer.toString('utf-8');
```

**原因**：
- 设计师精心调整过图标的线条粗细、比例、细节
- AI 生成的图标风格可能与设计稿不一致
- 图标是品牌视觉的重要组成部分

#### 5. 列表间距必须逐个测量

**问题**：同一列表中的元素间距可能不完全一致

**正确做法**：测量每对相邻元素的间距，取多数值或与设计师确认

```javascript
// 间距计算公式
spacing = nextElement.y - (currentElement.y + currentElement.height)
```

#### 6. 行高计算差异

不同框架对行高的处理不同：

```
设计稿行高: 24px, 字号: 16px

Flutter: height = 24 / 16 = 1.5 (比例值)
CSS: line-height: 24px 或 line-height: 1.5
小程序: line-height: 24px
```

#### 7. 🔴 文本框实际渲染高度（关键陷阱）

**问题**：Sketch/Figma 中 Text 的 `frame.height` 包含了完整的渲染高度（含行高），而不仅仅是 fontSize。直接使用 `height: 1.0` 会导致位置偏差。

**正确做法**：用 frame.height 反推 lineHeight

```javascript
// Sketch 测量示例
// 金额文本: fontSize=32, frame.height=38
// 正确的 lineHeight = 38 / 32 = 1.19

// 标签文本: fontSize=14, frame.height=20  
// 正确的 lineHeight = 20 / 14 = 1.43
```

```dart
// ❌ 错误：直接使用 height: 1.0
Text('0.00', style: TextStyle(fontSize: 32, height: 1.0))
// 实际高度 32px，与设计稿 38px 不符

// ✅ 正确：使用测量计算的 height
Text('0.00', style: TextStyle(fontSize: 32, height: 1.19))
// 实际高度 32 * 1.19 = 38px，精确匹配
```

**验证公式**：
```
frame.height = fontSize × lineHeight
lineHeight = frame.height / fontSize
```

#### 8. 间距精确推导（Row/Column 布局必备）

**问题**：使用 Row/Column 布局时，无法直接使用 Positioned 的绝对坐标，必须计算元素间的相对间距。

**推导方法**：

```
元素间距 = 下一元素Y坐标 - (当前元素Y坐标 + 当前元素渲染高度)
```

**完整示例**：

```javascript
// Sketch 测量数据
// 标签: y=124, fontSize=14, frame.height=20 → 底部=144
// 金额: y=145, fontSize=32, frame.height=38 → 底部=183

// 间距计算
标签到金额间距 = 145 - (124 + 20) = 145 - 144 = 1px
```

```dart
// 应用到代码
Column(
  children: [
    Text('账户余额：', style: TextStyle(
      fontSize: 14,
      height: 1.43,  // 20 / 14
    )),
    SizedBox(height: 1),  // 精确间距 1px
    Text('0.00', style: TextStyle(
      fontSize: 32,
      height: 1.19,  // 38 / 32
    )),
  ],
)
```

**注意事项**：
- 必须使用 frame.height 而非 fontSize 计算元素底部位置
- 如果设计稿元素间距为负数，说明元素有重叠，可能需要用 Stack
- 多语言场景下文本宽度会变化，间距需要动态验证

#### 9. 🔴 Icon Group 尺寸陷阱（高频问题）

**问题**：Sketch 中 Icon 通常由外层 Group（含 padding）包裹内部 ShapePath。直接用 Group.frame 尺寸渲染 SVG 会导致 Icon 偏大 2-4px。

**典型结构**：
```
Group "Fee Icon" (16×16)        ← 外层容器 = containerSize
└─ ShapePath "path" (12×12)     ← 实际图形 = contentSize
```

**根因**：设计师用 Group 的 16×16 定义布局槽位，但视觉显示的只是内部 12×12 的路径。

**正确做法**：测量插件 v4.0 输出 `iconContentBounds`，包含：
- `containerSize`：Group 尺寸，用于占位（SizedBox）
- `contentSize`：路径 union 尺寸，用于渲染（SvgPicture width/height）

```dart
// ✅ 正确：分离占位与渲染
SizedBox(
  width: 16, height: 16,  // containerSize
  child: Center(
    child: SvgPicture.asset('fee.svg',
      width: 12, height: 12,  // contentSize
    ),
  ),
)

// ❌ 错误：直接用 Group 尺寸
SvgPicture.asset('fee.svg', width: 16, height: 16)  // 实际图形被拉大
```

> 📖 **详细案例**：`mcp-server/troubleshooting/flutter/sketch-图标尺寸.md`

#### 10. 🔴 同行 Icon 对齐陷阱（高频问题）

**问题**：同一行的多个 Icon+文字组合（如手续费行、优惠券行），如果各 Icon 用各自实际尺寸，后面的文字起始位置会不一致。

**典型场景**：
```
手续费行: Icon 14×14 + "手续费 ¥3.00"
优惠券行: Icon 12×12 + "优惠券 -¥1.00"
→ "手续费"从 x=18 开始，"优惠券"从 x=16 开始 → 文字不对齐！
```

**正确做法**：测量插件 v4.0 输出 `siblingIconAlignment.maxSlotSize`，所有同行 Icon 用相同大小的 SizedBox 包裹。

```dart
// ✅ 正确：统一 16×16 占位
// 手续费行
Row(children: [
  SizedBox(width: 16, height: 16, child: Center(
    child: SvgPicture.asset('fee.svg', width: 14, height: 14),
  )),
  Gap(4),
  Text('手续费'),
])
// 优惠券行
Row(children: [
  SizedBox(width: 16, height: 16, child: Center(
    child: SvgPicture.asset('coupon.svg', width: 12, height: 12),
  )),
  Gap(4),
  Text('优惠券'),
])
```

#### 11. 🔴 Tag/标签容器样式陷阱（高频问题）

**问题**：小型 Tag（如"支付宝"、"银联"标签）的 background、padding、cornerRadius 不从测量数据中提取，而是凭感觉猜测，导致每次都需要 2-3 轮修正。

**典型错误链**：
```
第1次: padding: h:6,v:2, bg: 0xFF1677FF, radius: 4  ← 全部猜错
第2次: 用户反馈后修正 → padding: h:4,v:1, bg: 0x991676FE  ← 接近但仍有偏差
第3次: 再次修正 → padding: h:3,v:1, bg: 0x991676FE, radius: 3  ← 终于正确
```

**正确做法**：测量插件 v4.0 的 `detectTagContainer` 自动提取 Tag 样式，输出 `tagStyle`。

```dart
// ✅ 直接使用测量数据
Container(
  padding: EdgeInsets.symmetric(
    horizontal: tagStyle.padding.horizontal,  // 测量值 3
    vertical: tagStyle.padding.vertical,      // 测量值 1
  ),
  decoration: BoxDecoration(
    color: Color(tagStyle.background),  // 测量值 0x991676FE
    borderRadius: BorderRadius.circular(tagStyle.cornerRadius),  // 测量值 3
  ),
)
```

#### 12. 🔴 字体权重平台可用性陷阱

**问题**：Sketch fontWeight=8 映射为 Flutter FontWeight.w800，但 PingFang SC 字体没有 Heavy/w800 字重文件。Flutter 会使用合成加粗（synthetic bold），视觉效果显著偏粗。

**平台字体可用范围**：

| 字体 | 最大真实字重 | 超出后果 |
|------|------------|---------|
| PingFang SC | Semibold (w600) | w700+ 触发合成加粗，视觉过粗 |
| Helvetica / Helvetica Neue | Bold (w700) | w800+ 触发合成加粗 |
| SF Pro | Black (w900) | 全范围可用 |

**正确做法**：
1. 读取 PostScript 字体名（如 `PingFangSC-Semibold`）而非 fontWeight 数值
2. 根据后缀映射（`-Semibold` → w600），忽略 Sketch 的 fontWeight 值
3. 如果超出平台可用范围，降级到最大真实字重

```dart
// Sketch: PingFangSC-Semibold, fontWeight=8
// ✅ 正确
TextStyle(fontWeight: FontWeight.w600)  // 从 PostScript 名映射

// ❌ 错误
TextStyle(fontWeight: FontWeight.w800)  // 从 Sketch fontWeight 值直接映射
```

#### 13. 🔴 添加设计稿中不存在的元素

**问题**：AI 根据语义推测（如"汇率"可能需要一个交换图标），自行添加了设计稿中没有的 UI 元素，导致 UI 与设计稿不符。

**真实案例**：
- "汇率"标签前被添加了 `Icons.swap_horiz` 图标，但设计稿中没有任何图标
- "通知"标签前被添加了 `Icons.notification` 图标，而设计稿只有一个小红点

**强制规则**：
- 只还原测量数据中**明确存在**的元素
- 如果某标签的测量数据没有关联 Icon，则不添加 Icon
- 如果不确定某元素是否存在，向用户确认而非自行添加

---

## 🔴 布局选择原则（最高优先级）

> **核心理念：还原的是视觉效果，不是参数**

### 问题本质

使用 `Row`、`Column`、`Flex` 等相对布局时，元素会被强制对齐到同一轴线，**失去独立的位置控制**。这导致即使测量数据正确，视觉效果仍然无法还原。

### 🔴 判断标准（三种情况）

#### 情况 1：元素 Y 坐标各不相同 → 完全使用绝对定位

```
测量结果：
- 返回按钮: y=55
- 标题: y=61  
- 客服图标: y=41

→ 三个元素 Y 值都不同，必须每个单独 Positioned
```

```dart
// ✅ 正确：每个元素独立定位
Stack(
  children: [
    Positioned(left: 0, top: 11, child: backButton),
    Positioned(left: 0, right: 0, top: 17, child: title),
    Positioned(right: 0, top: -3, child: serviceIcon),
  ],
)
```

#### 情况 2：元素 Y 坐标相同 → 绝对定位 Y + 相对布局处理宽度

```
测量结果：
- 充值按钮: y=196, width=145
- 提现按钮: y=196, width=145

→ Y 值相同，但需要响应式宽度
```

```dart
// ✅ 正确：外层 Positioned 定位 Y，内层 Row 处理宽度
Positioned(
  left: 16,
  right: 23,
  top: 96,  // 精确的 Y 坐标
  child: Row(
    children: [
      Expanded(child: 充值按钮),  // 宽度自适应
      SizedBox(width: 21),        // 固定间距
      Expanded(child: 提现按钮),  // 宽度自适应
    ],
  ),
)

// ❌ 错误：每个按钮单独 Positioned + 固定宽度
Positioned(left: 16, top: 96, child: SizedBox(width: 145, child: 充值按钮)),
Positioned(left: 182, top: 96, child: SizedBox(width: 145, child: 提现按钮)),
// 问题：无法响应式适配不同屏幕宽度！
```

#### 情况 3：纯垂直排列 + 水平居中 → 直接使用 Column

```
测量结果：
- 图标容器: 水平居中
- 标题: 水平居中
- 描述: 水平居中
- 按钮: 水平居中

→ 所有元素水平居中，垂直排列
```

```dart
// ✅ 正确：Column + Center，间距用 SizedBox
Column(
  children: [
    SizedBox(height: 40),
    iconContainer,
    SizedBox(height: 20),
    title,
    SizedBox(height: 7),
    description,
    SizedBox(height: 16),
    button,
  ],
)
```

### 完整判断流程

```
1. 测量同组元素的 Y 坐标
   ├─ Y 坐标都不同 → 每个元素单独 Positioned
   └─ Y 坐标相同 → 继续判断
   
2. 判断是否需要响应式宽度
   ├─ 需要（如按钮、输入框）→ Positioned 定位 Y + Row/Expanded 处理宽度
   └─ 不需要（固定尺寸元素）→ 可以直接 Row
   
3. 判断是否需要溢出
   └─ 需要 → clipBehavior: Clip.none
```

### 常见错误

| 错误 | 原因 | 正确做法 |
|------|------|----------|
| Y 相同的按钮用单独 Positioned | 无法响应式适配 | 外层 Positioned + 内层 Row/Expanded |
| Y 不同的元素用 Row | 丢失独立 Y 坐标 | 每个元素单独 Positioned |
| 全部用固定宽度 | 不同屏幕显示错误 | 使用 Expanded 或百分比 |

### 框架语法映射

| 场景 | Flutter | Vue/CSS | React |
|------|---------|---------|-------|
| 绝对定位容器 | `Stack` | `position: relative` | `position: relative` |
| 绝对定位元素 | `Positioned` | `position: absolute` | `position: absolute` |
| 水平均分 | `Row` + `Expanded` | `display: flex` + `flex: 1` | `display: flex` + `flex: 1` |
| 固定间距 | `SizedBox(width: 21)` | `gap: 21px` | `gap: 21px` |

---

## 🎨 颜色规范

### 颜色提取完整流程

```javascript
function extractColor(fill) {
  if (!fill || !fill.enabled) return null;
  
  switch (fill.fillType) {
    case 'Color':
      return { type: 'solid', color: fill.color };
    
    case 'Gradient':
      return {
        type: 'gradient',
        gradientType: fill.gradient.gradientType,  // Linear/Radial/Angular
        direction: {
          from: fill.gradient.from,  // { x: 0, y: 0 }
          to: fill.gradient.to       // { x: 1, y: 1 }
        },
        stops: fill.gradient.stops.map(s => ({
          position: s.position,  // 0 ~ 1
          color: s.color
        }))
      };
    
    case 'Pattern':
      return { type: 'pattern', image: fill.pattern?.image };
    
    default:
      return null;
  }
}
```

### 渐变方向映射

| 设计稿方向 | Flutter | CSS |
|-----------|---------|-----|
| 左上→右下 | Alignment.topLeft → bottomRight | to bottom right |
| 上→下 | Alignment.topCenter → bottomCenter | to bottom |
| 左→右 | Alignment.centerLeft → centerRight | to right |

### 颜色透明度表示

```dart
// Flutter - 使用 0x 前缀
Color(0x801C2B45)  // 50% 透明度

// CSS - 使用 rgba 或 8位十六进制
rgba(28, 43, 69, 0.5)
#1C2B4580

// 小程序 - 同 CSS
```

---

## 📦 框架语法映射

### 容器/盒模型

| 属性 | Flutter | Vue/CSS | React Native | 小程序 |
|------|---------|---------|--------------|--------|
| 宽度 | `width: 100` | `width: 100px` | `width: 100` | `width: 100rpx` |
| 高度 | `height: 100` | `height: 100px` | `height: 100` | `height: 100rpx` |
| 内边距 | `padding: EdgeInsets.all(16)` | `padding: 16px` | `padding: 16` | `padding: 32rpx` |
| 外边距 | `margin: EdgeInsets.only(top: 8)` | `margin-top: 8px` | `marginTop: 8` | `margin-top: 16rpx` |
| 圆角 | `borderRadius: BorderRadius.circular(12)` | `border-radius: 12px` | `borderRadius: 12` | `border-radius: 24rpx` |

### 颜色

| 场景 | Flutter | Vue/CSS | React Native |
|------|---------|---------|--------------|
| 纯色 | `Color(0xFF1C2B45)` | `#1C2B45` | `'#1C2B45'` |
| 透明色 | `Color(0x801C2B45)` | `rgba(28,43,69,0.5)` | `'rgba(28,43,69,0.5)'` |
| 渐变 | `LinearGradient(colors: [...])` | `linear-gradient(...)` | `LinearGradient` |

### 阴影

```dart
// Flutter
BoxShadow(
  color: Color(0x261C2B45),
  blurRadius: 20,
  offset: Offset(8, 8),
  spreadRadius: 0,
)
```

```css
/* CSS */
box-shadow: 8px 8px 20px 0px rgba(28, 43, 69, 0.15);
```

```javascript
// React Native
shadowColor: '#1C2B45',
shadowOffset: { width: 8, height: 8 },
shadowOpacity: 0.15,
shadowRadius: 20,
elevation: 8,  // Android
```

### 字体

| 属性 | Flutter | CSS | React Native |
|------|---------|-----|--------------|
| 字号 | `fontSize: 16` | `font-size: 16px` | `fontSize: 16` |
| 字重 | `fontWeight: FontWeight.w600` | `font-weight: 600` | `fontWeight: '600'` |
| 行高 | `height: 1.5` (比例) | `line-height: 24px` | `lineHeight: 24` |
| 颜色 | `color: Color(0xFF1C2B45)` | `color: #1C2B45` | `color: '#1C2B45'` |

---

## 🔧 MCP 工具使用规范

### Sketch MCP

#### 正确的测量脚本模板

```javascript
const sketch = require('sketch');
const doc = sketch.getSelectedDocument();
const artboard = doc.selectedLayers.layers[0];

function measureLayer(layer) {
  const result = {
    name: layer.name,
    type: layer.type,
    frame: {
      x: Math.round(layer.frame.x),
      y: Math.round(layer.frame.y),
      width: Math.round(layer.frame.width),
      height: Math.round(layer.frame.height),
    },
  };
  
  // 样式
  if (layer.style) {
    // 填充色 - 正确处理渐变
    if (layer.style.fills?.length > 0) {
      result.fills = layer.style.fills
        .filter(f => f.enabled)
        .map(f => extractColor(f));
    }
    
    // 边框
    if (layer.style.borders?.length > 0) {
      result.borders = layer.style.borders
        .filter(b => b.enabled)
        .map(b => ({
          color: b.color,
          thickness: b.thickness,
        }));
    }
    
    // 阴影
    if (layer.style.shadows?.length > 0) {
      result.shadows = layer.style.shadows
        .filter(s => s.enabled)
        .map(s => ({
          color: s.color,
          blur: s.blur,
          x: s.x,
          y: s.y,
          spread: s.spread,
        }));
    }
    
    // 圆角
    if (layer.style.corners) {
      result.cornerRadius = layer.style.corners.radii;
    }
  }
  
  // 文字特有属性
  if (layer.type === 'Text') {
    result.text = {
      content: layer.text,
      fontSize: layer.style.fontSize,
      fontFamily: layer.style.fontFamily,
      fontWeight: layer.style.fontWeight,
      textColor: layer.style.textColor,
      alignment: layer.style.alignment,
      lineHeight: layer.style.lineHeight,
    };
  }
  
  return result;
}
```

### Figma MCP（预留）

```javascript
// Figma API 颜色读取
function getFigmaColor(paint) {
  if (paint.type === 'SOLID') {
    return {
      type: 'solid',
      color: rgbaToHex(paint.color, paint.opacity),
    };
  } else if (paint.type === 'GRADIENT_LINEAR') {
    return {
      type: 'gradient',
      stops: paint.gradientStops.map(s => ({
        position: s.position,
        color: rgbaToHex(s.color),
      })),
    };
  }
}
```

---

## ✅ 还原检查清单

### 每个元素必查

- [ ] 尺寸是否精确匹配
- [ ] 位置/间距是否正确
- [ ] 颜色是否正确（特别注意渐变）
- [ ] 圆角是否正确
- [ ] 阴影效果是否还原
- [ ] 字体样式是否匹配

### 整体验收

- [ ] 与设计稿截图叠加对比
- [ ] 不同屏幕尺寸下的适配
- [ ] 交互状态（hover、active、disabled）
- [ ] 暗黑模式（如有）

---

## 📚 相关规范

- [Sketch MCP 最佳实践](./mcp-tools/sketch-mcp.md)
- [Figma MCP 最佳实践](./mcp-tools/figma-mcp.md)（计划中）
- [颜色系统规范](./color-system.md)（计划中）

---

## 🔄 版本历史

| 版本 | 日期 | 更新内容 |
|------|------|---------|
| v1.0 | 2026-01-20 | 初始版本，包含渐变色读取问题解决方案 |
| v1.1 | 2026-01-21 | 新增：字重映射完整表（含PostScript后缀）、图标导出强制要求、列表间距测量规范 |
| v2.0 | 2026-02-11 | 新增五大高频陷阱：Icon Group尺寸、同行Icon对齐、Tag容器样式、字重平台可用性、幽灵元素 |

---

**维护者**: MTA工作室  
**创建日期**: 2026-01-20
