feat: add sketch harness

This commit is contained in:
Xilonng Zhang
2026-03-22 21:07:54 +08:00
parent 552393b2fb
commit 3857d375e1
18 changed files with 5923 additions and 8 deletions

4
.gitignore vendored
View File

@@ -41,6 +41,7 @@
!/shotcut/ !/shotcut/
!/anygen/ !/anygen/
!/zoom/ !/zoom/
!/sketch/
!/drawio/ !/drawio/
!/mermaid/ !/mermaid/
!/adguardhome/ !/adguardhome/
@@ -70,6 +71,8 @@
/anygen/.* /anygen/.*
/zoom/* /zoom/*
/zoom/.* /zoom/.*
/sketch/*
/sketch/.*
/drawio/* /drawio/*
/drawio/.* /drawio/.*
/mermaid/* /mermaid/*
@@ -91,6 +94,7 @@
!/shotcut/agent-harness/ !/shotcut/agent-harness/
!/anygen/agent-harness/ !/anygen/agent-harness/
!/zoom/agent-harness/ !/zoom/agent-harness/
!/sketch/agent-harness/
!/drawio/agent-harness/ !/drawio/agent-harness/
!/mermaid/agent-harness/ !/mermaid/agent-harness/
!/adguardhome/agent-harness/ !/adguardhome/agent-harness/

View File

@@ -638,12 +638,19 @@ Each application received complete, production-ready CLI interfaces — not demo
<td align="center">✅ 98</td> <td align="center">✅ 98</td>
</tr> </tr>
<tr> <tr>
<td align="center"><strong>🎨 Sketch</strong></td>
<td>UI Design</td>
<td><code>sketch-cli</code></td>
<td>sketch-constructor (Node.js)</td>
<td align="center">✅ 19</td>
</tr>
<tr>
<td align="center" colspan="4"><strong>Total</strong></td> <td align="center" colspan="4"><strong>Total</strong></td>
<td align="center"><strong>✅ 1,839</strong></td> <td align="center"><strong>✅ 1,858</strong></td>
</tr> </tr>
</table> </table>
> **100% pass rate** across all 1,839 tests — 1,355 unit tests + 484 end-to-end tests. > **100% pass rate** across all 1,858 tests — 1,355 unit tests + 484 end-to-end tests + 19 Node.js tests.
--- ---
@@ -677,8 +684,9 @@ notebooklm 21 passed ✅ (21 unit + 0 e2e)
comfyui 70 passed ✅ (60 unit + 10 e2e) comfyui 70 passed ✅ (60 unit + 10 e2e)
adguardhome 36 passed ✅ (24 unit + 12 e2e) adguardhome 36 passed ✅ (24 unit + 12 e2e)
ollama 98 passed ✅ (87 unit + 11 e2e) ollama 98 passed ✅ (87 unit + 11 e2e)
sketch 19 passed ✅ (19 jest, Node.js)
────────────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────
TOTAL 1,839 passed ✅ 100% pass rate TOTAL 1,858 passed ✅ 100% pass rate
``` ```
--- ---
@@ -744,7 +752,8 @@ cli-anything/
├── 🧠 notebooklm/agent-harness/ # NotebookLM CLI (experimental, 21 tests) ├── 🧠 notebooklm/agent-harness/ # NotebookLM CLI (experimental, 21 tests)
├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 tests) ├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 tests)
├── 🛡️ adguardhome/agent-harness/ # AdGuard Home CLI (36 tests) ├── 🛡️ adguardhome/agent-harness/ # AdGuard Home CLI (36 tests)
── 🦙 ollama/agent-harness/ # Ollama CLI (98 tests) ── 🦙 ollama/agent-harness/ # Ollama CLI (98 tests)
└── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js)
``` ```
Each `agent-harness/` contains an installable Python package under `cli_anything.<software>/` with Click CLI, core modules, utils (including `repl_skin.py` and backend wrapper), and comprehensive tests. Each `agent-harness/` contains an installable Python package under `cli_anything.<software>/` with Click CLI, core modules, utils (including `repl_skin.py` and backend wrapper), and comprehensive tests.

View File

@@ -543,12 +543,19 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
<td align="center">✅ 50</td> <td align="center">✅ 50</td>
</tr> </tr>
<tr> <tr>
<td align="center"><strong>🎨 Sketch</strong></td>
<td>UI 设计</td>
<td><code>sketch-cli</code></td>
<td>sketch-constructor (Node.js)</td>
<td align="center">✅ 19</td>
</tr>
<tr>
<td align="center" colspan="4"><strong>合计</strong></td> <td align="center" colspan="4"><strong>合计</strong></td>
<td align="center"><strong>✅ 1,508</strong></td> <td align="center"><strong>✅ 1,527</strong></td>
</tr> </tr>
</table> </table>
> 全部 1,508 项测试 **100% 通过** —— 1,073 项单元测试 + 435 项端到端测试。 > 全部 1,527 项测试 **100% 通过** —— 1,073 项单元测试 + 435 项端到端测试 + 19 项 Node.js 测试
--- ---
@@ -576,8 +583,9 @@ shotcut 154 passed ✅ (110 unit + 44 e2e)
zoom 22 passed ✅ (22 unit + 0 e2e) zoom 22 passed ✅ (22 unit + 0 e2e)
drawio 138 passed ✅ (116 unit + 22 e2e) drawio 138 passed ✅ (116 unit + 22 e2e)
anygen 50 passed ✅ (40 unit + 10 e2e) anygen 50 passed ✅ (40 unit + 10 e2e)
sketch 19 passed ✅ (19 jest, Node.js)
────────────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────
TOTAL 1,508 passed ✅ 100% pass rate TOTAL 1,527 passed ✅ 100% pass rate
``` ```
--- ---
@@ -641,7 +649,8 @@ cli-anything/
├── 🎬 shotcut/agent-harness/ # Shotcut CLI154 项测试) ├── 🎬 shotcut/agent-harness/ # Shotcut CLI154 项测试)
├── 📞 zoom/agent-harness/ # Zoom CLI22 项测试) ├── 📞 zoom/agent-harness/ # Zoom CLI22 项测试)
├── 📐 drawio/agent-harness/ # Draw.io CLI138 项测试) ├── 📐 drawio/agent-harness/ # Draw.io CLI138 项测试)
── ✨ anygen/agent-harness/ # AnyGen CLI50 项测试) ── ✨ anygen/agent-harness/ # AnyGen CLI50 项测试)
└── 🎨 sketch/agent-harness/ # Sketch CLI19 项测试Node.js
``` ```
每个 `agent-harness/` 包含一个可安装的 Python 包,位于 `cli_anything.<软件名>/` 下,包含 Click CLI、核心模块、工具类`repl_skin.py` 和后端适配器)以及完整的测试。 每个 `agent-harness/` 包含一个可安装的 Python 包,位于 `cli_anything.<软件名>/` 下,包含 Click CLI、核心模块、工具类`repl_skin.py` 和后端适配器)以及完整的测试。

View File

@@ -254,6 +254,20 @@
"category": "ai", "category": "ai",
"contributor": "Alex-wuhu", "contributor": "Alex-wuhu",
"contributor_url": "https://github.com/Alex-wuhu" "contributor_url": "https://github.com/Alex-wuhu"
},
{
"name": "sketch",
"display_name": "Sketch",
"version": "1.0.0",
"description": "Generate Sketch design files (.sketch) from JSON design specifications via sketch-constructor",
"requires": "Node.js >= 16.0.0",
"homepage": "https://www.sketch.com",
"install_cmd": "cd sketch/agent-harness && npm install && npm link",
"entry_point": "sketch-cli",
"skill_md": null,
"category": "design",
"contributor": "zhangxilong-43",
"contributor_url": "https://github.com/zhangxilong-43"
} }
] ]
} }

3
sketch/agent-harness/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
output/
.sketch-constructor/

View File

@@ -0,0 +1,144 @@
# sketch-harness
CLI tool to generate `.sketch` files from JSON design specs — a [CLI-Anything](https://github.com/anthropics/CLI-Anything) harness for Sketch.
## Installation
```bash
cd sketch/agent-harness
npm install
```
Requires **Node.js >= 16**.
## Commands
### `build` — Generate a .sketch file from a JSON spec
```bash
node src/cli.js build --input <spec.json> --output <output.sketch> [--tokens <tokens.json>]
```
| Flag | Required | Description |
|------|----------|-------------|
| `--input, -i` | Yes | Path to JSON design spec |
| `--output, -o` | Yes | Output .sketch file path |
| `--tokens, -t` | No | Custom tokens file (overrides spec-level tokens) |
### `list-styles` — List available predefined styles
```bash
node src/cli.js list-styles [--tokens <tokens.json>]
```
## JSON Spec Format
```json
{
"tokens": "./tokens/default.json",
"pages": [
{
"name": "Page Name",
"artboards": [
{
"name": "Artboard Name",
"width": 375,
"height": 812,
"backgroundColor": "#FFFFFF",
"layout": { "type": "vertical-stack", "paddingTop": 80, "paddingHorizontal": 24, "gap": 16 },
"layers": [
{ "type": "text", "name": "title", "value": "Hello", "style": "$heading1" },
{ "type": "rectangle", "name": "btn", "width": "fill", "height": 48, "style": "$primaryButton",
"label": { "value": "Submit", "style": "$buttonText" } },
{ "type": "group", "name": "row", "layout": { "type": "horizontal-stack", "gap": 12 },
"children": [ ] }
]
}
]
}
]
}
```
### Layer Types
| Type | Description | Key Fields |
|------|-------------|------------|
| `text` | Text layer | `value`, `style` (fontSize, color, textAlign...) |
| `rectangle` | Rectangle | `style` (backgroundColor, cornerRadius...), `label` |
| `oval` | Ellipse / Circle | Same as rectangle |
| `group` | Container | `children`, `layout` |
| `line` | Line | `color`, `thickness` |
| `spacer` | Invisible spacer | `height` |
### Layout Modes
- **vertical-stack** — Children flow top-to-bottom. Supports `gap`, `paddingTop`/`paddingBottom`/`paddingHorizontal`, `alignItems` (left / center / right).
- **horizontal-stack** — Children flow left-to-right. Supports `gap`, `justifyContent` (start / center / end / space-between), `alignItems` (top / center / bottom).
- **absolute** — Children positioned manually via `x` / `y`.
### Sizing
- `width: "fill"` — Stretch to fill parent container width.
- Text layers without explicit dimensions are auto-sized based on font size.
## Design Tokens
Reference tokens in `style` fields with the `$` prefix:
| Syntax | Resolves to |
|--------|-------------|
| `"style": "$heading1"` | `tokens.styles.$heading1` (full style object) |
| `"color": "$primary"` | `tokens.colors.primary` |
| `"cornerRadius": "$lg"` | `tokens.radius.lg` |
See [`tokens/default.json`](tokens/default.json) for the built-in token set (colors, spacing, radius, shadows, typography styles).
## Examples
Three example specs are included in [`examples/`](examples/):
```bash
# Mobile login page
node src/cli.js build -i examples/login-page.json -o output/login-page.sketch
# Desktop dashboard
node src/cli.js build -i examples/dashboard.json -o output/dashboard.sketch
# Card list
node src/cli.js build -i examples/card-list.json -o output/card-list.sketch
# Build all examples at once
npm run build:all
```
## Tests
```bash
npm test
```
Runs Jest tests that verify `.sketch` output is a valid ZIP containing the expected Sketch document structure.
## Project Structure
```
src/
cli.js # CLI entry point (Commander.js)
builder.js # Orchestrates spec → Sketch file generation
layout.js # Layout engine (vertical/horizontal/absolute stacking)
primitives.js # Layer primitives (text, rectangle, oval, line, group)
tokens/
default.json # Built-in design token set
examples/ # Sample JSON specs
output/ # Generated .sketch files
tests/
build.test.js # Build pipeline tests
```
## Agent Workflow
1. Write a JSON spec describing your design.
2. Run `node src/cli.js build -i spec.json -o design.sketch`.
3. Verify the output file was generated (valid ZIP).
4. Open in Sketch or [Lunacy](https://icons8.com/lunacy) (free, cross-platform) to inspect the result.

View File

@@ -0,0 +1,123 @@
# sketch-harness — AI Agent 使用说明
**一句话**:通过 JSON 设计描述文件生成可在 Sketch / Lunacy 中打开的 `.sketch` 文件。
## 安装
```bash
cd sketch-harness
npm install
```
## 核心命令
### build — 从 JSON 生成 .sketch 文件
```bash
node src/cli.js build --input <spec.json> --output <output.sketch> [--tokens <tokens.json>]
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--input, -i` | 是 | JSON 设计描述文件路径 |
| `--output, -o` | 是 | 输出 .sketch 文件路径 |
| `--tokens, -t` | 否 | 自定义 Token 文件,覆盖 spec 中的 tokens 字段 |
### list-styles — 列出可用的预定义样式
```bash
node src/cli.js list-styles [--tokens <tokens.json>]
```
## JSON Spec 格式
```json
{
"tokens": "./tokens/default.json",
"pages": [
{
"name": "页面名",
"artboards": [
{
"name": "画板名",
"width": 375,
"height": 812,
"backgroundColor": "#FFFFFF",
"layout": { "type": "vertical-stack", "paddingTop": 80, "paddingHorizontal": 24, "gap": 16 },
"layers": [
{ "type": "text", "name": "title", "value": "标题", "style": "$heading1" },
{ "type": "rectangle", "name": "btn", "width": "fill", "height": 48, "style": "$primaryButton",
"label": { "value": "按钮", "style": "$buttonText" } },
{ "type": "group", "name": "row", "layout": { "type": "horizontal-stack", "gap": 12 },
"children": [ ... ] },
{ "type": "spacer", "height": 24 }
]
}
]
}
]
}
```
### 图层类型
| type | 说明 | 特有字段 |
|------|------|---------|
| `text` | 文字 | `value`, `style` (fontSize, color, textAlign...) |
| `rectangle` | 矩形 | `style` (backgroundColor, cornerRadius...), `label` |
| `oval` | 椭圆/圆 | 同 rectangle |
| `group` | 分组容器 | `children`, `layout` |
| `line` | 直线 | `color`, `thickness` |
| `spacer` | 占位间距 | `height` |
### 布局模式
- **vertical-stack**: 子元素从上到下排列。支持 `gap`, `paddingTop/Bottom/Horizontal`, `alignItems` (left/center/right)
- **horizontal-stack**: 子元素从左到右排列。支持 `gap`, `justifyContent` (start/center/end/space-between), `alignItems` (top/center/bottom)
- **absolute**: 子元素手动 x/y 定位
### 尺寸
- `width: "fill"` — 填满父容器可用宽度
- 文本未指定尺寸时自动根据字号估算
## Token 引用语法
`style` 字段中用 `$名称` 引用 tokens 中的预定义值:
- `"style": "$heading1"` — 引用 `tokens.styles.$heading1` 整个样式对象
- `"color": "$primary"` — 引用 `tokens.colors.primary` 颜色值
- `"cornerRadius": "$lg"` — 引用 `tokens.radius.lg` 数值
## 常见用法示例
### 1. 生成移动端登录页
```bash
node src/cli.js build -i examples/login-page.json -o login.sketch
```
### 2. 生成 PC 端数据看板
```bash
node src/cli.js build -i examples/dashboard.json -o dashboard.sketch
```
### 3. 使用自定义 Token 生成
```bash
node src/cli.js build -i my-design.json -o out.sketch --tokens my-brand-tokens.json
```
### 4. 查看所有可用样式
```bash
node src/cli.js list-styles
```
### 5. AI Agent 工作流
1. 根据需求编写 JSON spec 文件
2. 运行 `node src/cli.js build -i spec.json -o design.sketch`
3. 检查输出文件是否生成(验证 ZIP 完整性)
4. 在 Sketch 或 Lunacy 中打开查看效果

View File

@@ -0,0 +1,230 @@
{
"tokens": "../tokens/default.json",
"pages": [
{
"name": "卡片列表",
"artboards": [
{
"name": "Mobile - Card List",
"width": 375,
"height": 900,
"backgroundColor": "#F1F5F9",
"layout": {
"type": "vertical-stack",
"paddingTop": 60,
"paddingHorizontal": 16,
"gap": 12
},
"layers": [
{
"type": "text",
"name": "pageTitle",
"value": "推荐内容",
"style": "$heading2"
},
{
"type": "spacer",
"height": 4
},
{
"type": "group",
"name": "card1",
"width": "fill",
"style": "$card",
"layout": {
"type": "vertical-stack",
"paddingTop": 0,
"gap": 0
},
"children": [
{
"type": "rectangle",
"name": "card1Image",
"width": "fill",
"height": 160,
"style": {
"backgroundColor": "#DBEAFE",
"cornerRadius": 0
}
},
{
"type": "group",
"name": "card1Content",
"width": "fill",
"layout": {
"type": "vertical-stack",
"paddingTop": 12,
"paddingBottom": 16,
"paddingHorizontal": 16,
"gap": 6
},
"children": [
{
"type": "text",
"name": "card1Title",
"value": "设计系统搭建指南",
"style": "$heading3"
},
{
"type": "text",
"name": "card1Desc",
"value": "从零开始搭建企业级设计系统包含组件库、Token 体系和协作流程",
"style": "$bodySecondary",
"width": "fill",
"height": 40
},
{
"type": "group",
"name": "card1Meta",
"layout": {
"type": "horizontal-stack",
"justifyContent": "space-between"
},
"width": "fill",
"children": [
{
"type": "text",
"name": "card1Author",
"value": "张设计",
"style": "$caption"
},
{
"type": "text",
"name": "card1Date",
"value": "2026-03-20",
"style": "$caption"
}
]
}
]
}
]
},
{
"type": "group",
"name": "card2",
"width": "fill",
"style": "$card",
"layout": {
"type": "vertical-stack",
"paddingTop": 0,
"gap": 0
},
"children": [
{
"type": "rectangle",
"name": "card2Image",
"width": "fill",
"height": 160,
"style": {
"backgroundColor": "#FEE2E2",
"cornerRadius": 0
}
},
{
"type": "group",
"name": "card2Content",
"width": "fill",
"layout": {
"type": "vertical-stack",
"paddingTop": 12,
"paddingBottom": 16,
"paddingHorizontal": 16,
"gap": 6
},
"children": [
{
"type": "text",
"name": "card2Title",
"value": "AI 辅助设计工作流",
"style": "$heading3"
},
{
"type": "text",
"name": "card2Desc",
"value": "探索如何用 AI 工具提升设计效率,从灵感生成到设计稿输出的完整流程",
"style": "$bodySecondary",
"width": "fill",
"height": 40
},
{
"type": "group",
"name": "card2Meta",
"layout": {
"type": "horizontal-stack",
"justifyContent": "space-between"
},
"width": "fill",
"children": [
{
"type": "text",
"name": "card2Author",
"value": "李产品",
"style": "$caption"
},
{
"type": "text",
"name": "card2Date",
"value": "2026-03-18",
"style": "$caption"
}
]
}
]
}
]
},
{
"type": "group",
"name": "card3",
"width": "fill",
"style": "$card",
"layout": {
"type": "horizontal-stack",
"gap": 12,
"paddingHorizontal": 16,
"paddingTop": 12,
"paddingBottom": 12,
"alignItems": "center"
},
"children": [
{
"type": "oval",
"name": "card3Avatar",
"width": 48,
"height": 48,
"style": {
"backgroundColor": "#E0E7FF"
}
},
{
"type": "group",
"name": "card3TextGroup",
"width": 240,
"layout": {
"type": "vertical-stack",
"gap": 4
},
"children": [
{
"type": "text",
"name": "card3Title",
"value": "响应式布局最佳实践",
"style": "$body"
},
{
"type": "text",
"name": "card3Subtitle",
"value": "王前端 · 2026-03-15",
"style": "$caption"
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -0,0 +1,238 @@
{
"tokens": "../tokens/default.json",
"pages": [
{
"name": "数据看板",
"artboards": [
{
"name": "PC - Dashboard",
"width": 1440,
"height": 900,
"backgroundColor": "#F1F5F9",
"layout": {
"type": "vertical-stack",
"paddingTop": 32,
"paddingHorizontal": 48,
"gap": 24
},
"layers": [
{
"type": "text",
"name": "pageTitle",
"value": "数据概览",
"style": "$heading1"
},
{
"type": "text",
"name": "pageSubtitle",
"value": "2026年3月 · 实时数据",
"style": "$bodySecondary"
},
{
"type": "group",
"name": "statsRow",
"width": "fill",
"layout": {
"type": "horizontal-stack",
"gap": 24
},
"children": [
{
"type": "group",
"name": "statCard1",
"width": 320,
"height": 120,
"style": "$card",
"layout": {
"type": "vertical-stack",
"paddingTop": 20,
"paddingHorizontal": 24,
"gap": 8
},
"children": [
{
"type": "text",
"name": "stat1Label",
"value": "日活跃用户",
"style": "$caption"
},
{
"type": "text",
"name": "stat1Value",
"value": "128,432",
"style": {
"fontSize": 32,
"fontWeight": "bold",
"color": "$text"
}
},
{
"type": "text",
"name": "stat1Change",
"value": "+12.5% vs 昨日",
"style": {
"fontSize": 13,
"color": "$success"
}
}
]
},
{
"type": "group",
"name": "statCard2",
"width": 320,
"height": 120,
"style": "$card",
"layout": {
"type": "vertical-stack",
"paddingTop": 20,
"paddingHorizontal": 24,
"gap": 8
},
"children": [
{
"type": "text",
"name": "stat2Label",
"value": "营收",
"style": "$caption"
},
{
"type": "text",
"name": "stat2Value",
"value": "¥ 2,847,320",
"style": {
"fontSize": 32,
"fontWeight": "bold",
"color": "$text"
}
},
{
"type": "text",
"name": "stat2Change",
"value": "+8.2% vs 昨日",
"style": {
"fontSize": 13,
"color": "$success"
}
}
]
},
{
"type": "group",
"name": "statCard3",
"width": 320,
"height": 120,
"style": "$card",
"layout": {
"type": "vertical-stack",
"paddingTop": 20,
"paddingHorizontal": 24,
"gap": 8
},
"children": [
{
"type": "text",
"name": "stat3Label",
"value": "转化率",
"style": "$caption"
},
{
"type": "text",
"name": "stat3Value",
"value": "3.24%",
"style": {
"fontSize": 32,
"fontWeight": "bold",
"color": "$text"
}
},
{
"type": "text",
"name": "stat3Change",
"value": "-0.8% vs 昨日",
"style": {
"fontSize": 13,
"color": "$error"
}
}
]
},
{
"type": "group",
"name": "statCard4",
"width": 320,
"height": 120,
"style": "$card",
"layout": {
"type": "vertical-stack",
"paddingTop": 20,
"paddingHorizontal": 24,
"gap": 8
},
"children": [
{
"type": "text",
"name": "stat4Label",
"value": "新增注册",
"style": "$caption"
},
{
"type": "text",
"name": "stat4Value",
"value": "5,218",
"style": {
"fontSize": 32,
"fontWeight": "bold",
"color": "$text"
}
},
{
"type": "text",
"name": "stat4Change",
"value": "+22.1% vs 昨日",
"style": {
"fontSize": 13,
"color": "$success"
}
}
]
}
]
},
{
"type": "group",
"name": "chartSection",
"width": "fill",
"height": 400,
"style": "$card",
"layout": {
"type": "vertical-stack",
"paddingTop": 24,
"paddingHorizontal": 24,
"gap": 16
},
"children": [
{
"type": "text",
"name": "chartTitle",
"value": "近7日趋势",
"style": "$heading3"
},
{
"type": "rectangle",
"name": "chartPlaceholder",
"width": "fill",
"height": 300,
"style": {
"backgroundColor": "$surface",
"cornerRadius": "$sm"
}
}
]
}
]
}
]
}
]
}

View File

@@ -0,0 +1,132 @@
{
"tokens": "../tokens/default.json",
"pages": [
{
"name": "登录页",
"artboards": [
{
"name": "Mobile - Login",
"width": 375,
"height": 812,
"backgroundColor": "#FFFFFF",
"layout": {
"type": "vertical-stack",
"paddingTop": 120,
"paddingHorizontal": 24,
"gap": 16
},
"layers": [
{
"type": "text",
"name": "title",
"value": "欢迎回来",
"style": "$heading1"
},
{
"type": "text",
"name": "subtitle",
"value": "请登录您的账户",
"style": "$bodySecondary"
},
{
"type": "spacer",
"height": 24
},
{
"type": "group",
"name": "emailInput",
"layout": {
"type": "horizontal-stack",
"gap": 12,
"alignItems": "center",
"paddingHorizontal": 16
},
"style": "$inputField",
"children": [
{
"type": "text",
"name": "emailIcon",
"value": "E",
"style": { "fontSize": 18, "color": "#64748B" }
},
{
"type": "text",
"name": "emailPlaceholder",
"value": "请输入邮箱地址",
"style": "$placeholder"
}
]
},
{
"type": "group",
"name": "passwordInput",
"layout": {
"type": "horizontal-stack",
"gap": 12,
"alignItems": "center",
"paddingHorizontal": 16
},
"style": "$inputField",
"children": [
{
"type": "text",
"name": "lockIcon",
"value": "P",
"style": { "fontSize": 18, "color": "#64748B" }
},
{
"type": "text",
"name": "passwordPlaceholder",
"value": "请输入密码",
"style": "$placeholder"
}
]
},
{
"type": "spacer",
"height": 8
},
{
"type": "rectangle",
"name": "loginButton",
"height": 48,
"width": "fill",
"style": "$primaryButton",
"label": {
"value": "登 录",
"style": "$buttonText"
}
},
{
"type": "spacer",
"height": 8
},
{
"type": "group",
"name": "bottomLinks",
"layout": {
"type": "horizontal-stack",
"justifyContent": "space-between"
},
"width": "fill",
"children": [
{
"type": "text",
"name": "forgotPassword",
"value": "忘记密码?",
"style": "$link"
},
{
"type": "text",
"name": "register",
"value": "注册新账户",
"style": "$link"
}
]
}
]
}
]
}
]
}

3848
sketch/agent-harness/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "sketch-harness",
"version": "1.0.0",
"description": "CLI tool to generate Sketch files from JSON design specs — CLI-Anything harness for Sketch",
"main": "src/cli.js",
"bin": {
"sketch-cli": "src/cli.js"
},
"scripts": {
"test": "jest --verbose",
"build:login": "node src/cli.js build --input examples/login-page.json --output output/login-page.sketch",
"build:dashboard": "node src/cli.js build --input examples/dashboard.json --output output/dashboard.sketch",
"build:cards": "node src/cli.js build --input examples/card-list.json --output output/card-list.sketch",
"build:all": "npm run build:login && npm run build:dashboard && npm run build:cards"
},
"dependencies": {
"sketch-constructor": "^1.26.0",
"commander": "^11.0.0"
},
"devDependencies": {
"jest": "^29.0.0"
},
"engines": {
"node": ">=16.0.0"
}
}

View File

@@ -0,0 +1,348 @@
/**
* builder.js — Core builder: JSON design spec → .sketch file.
*
* Responsibilities:
* 1. Load and merge design tokens
* 2. Resolve $-references in styles
* 3. Compute layout for each artboard
* 4. Generate sketch-constructor layers
* 5. Write .sketch file
*/
const fs = require('fs');
const path = require('path');
const JSZip = require('jszip');
const { Sketch, Page, Artboard } = require('sketch-constructor');
const { computeLayout } = require('./layout');
const primitives = require('./primitives');
// ---------------------------------------------------------------------------
// Custom build: bypass sketch-constructor's JsonStreamStringify (corrupts CJK)
// ---------------------------------------------------------------------------
/**
* buildSketchFile — serialize Sketch object to .sketch ZIP with proper UTF-8.
* sketch-constructor's built-in build() uses json-stream-stringify which
* corrupts multi-byte characters (Chinese, Japanese, Korean).
* We use JSON.stringify + Buffer.from to guarantee correct encoding.
*/
async function buildSketchFile(sketch, outputPath) {
const zip = new JSZip();
// Write top-level JSON files as UTF-8 buffers
zip.file('meta.json', Buffer.from(JSON.stringify(sketch.meta), 'utf8'));
zip.file('user.json', Buffer.from(JSON.stringify(sketch.user), 'utf8'));
zip.file('document.json', Buffer.from(JSON.stringify(sketch.document), 'utf8'));
// Pages
zip.folder('pages');
for (const page of sketch.pages) {
zip.file(
`pages/${page.do_objectID}.json`,
Buffer.from(JSON.stringify(page), 'utf8')
);
}
// Previews folder (required by some viewers)
zip.folder('previews');
// Write ZIP to file
const buf = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
fs.writeFileSync(outputPath, buf);
}
// ---------------------------------------------------------------------------
// Token resolution
// ---------------------------------------------------------------------------
function loadTokens(specTokensPath, cliTokensPath, specDir) {
const defaultTokensPath = path.resolve(__dirname, '..', 'tokens', 'default.json');
let tokensPath = defaultTokensPath;
if (cliTokensPath) {
tokensPath = path.resolve(cliTokensPath);
} else if (specTokensPath) {
tokensPath = path.resolve(specDir, specTokensPath);
}
if (!fs.existsSync(tokensPath)) {
console.warn(`Tokens file not found: ${tokensPath}, using defaults`);
tokensPath = defaultTokensPath;
}
return JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
}
/**
* Resolve a single $-reference value against the token bank.
* "$primary" → tokens.colors.primary
* "$md" in radius context → tokens.radius.md
*/
function resolveTokenValue(val, tokens, context) {
if (typeof val !== 'string' || !val.startsWith('$')) return val;
const key = val.slice(1); // strip $
// Color reference
if (tokens.colors && tokens.colors[key]) return tokens.colors[key];
// Radius reference
if (tokens.radius && tokens.radius[key] !== undefined) return tokens.radius[key];
// Spacing reference
if (tokens.spacing && tokens.spacing[key] !== undefined) return tokens.spacing[key];
// Shadow reference
if (tokens.shadows && tokens.shadows[key]) return tokens.shadows[key];
return val; // unchanged
}
/**
* Recursively resolve all $-refs inside an object.
*/
function resolveRefs(obj, tokens) {
if (typeof obj === 'string') return resolveTokenValue(obj, tokens);
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) return obj.map((v) => resolveRefs(v, tokens));
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = resolveRefs(v, tokens);
}
return out;
}
/**
* Resolve the style field on a layer. It may be:
* - "$styleName" → lookup in tokens.styles, then resolve recursively
* - an inline object → resolve recursively
* - absent → {}
*/
function resolveStyle(style, tokens) {
if (!style) return {};
if (typeof style === 'string' && style.startsWith('$')) {
const def = tokens.styles && tokens.styles[style];
if (!def) {
console.warn(`Unknown style token: ${style}`);
return {};
}
return resolveRefs(def, tokens);
}
if (typeof style === 'object') {
return resolveRefs(style, tokens);
}
return {};
}
// ---------------------------------------------------------------------------
// Layer generation (recursive)
// ---------------------------------------------------------------------------
/**
* Build a single Sketch layer from a spec node + computed frame.
*/
function buildLayer(spec, frame, tokens) {
const resolved = spec._resolvedStyle || {};
const props = {
name: spec.name || spec.type,
x: frame.x,
y: frame.y,
width: frame.width,
height: frame.height,
// Style props
backgroundColor: resolved.backgroundColor,
borderColor: resolved.borderColor,
borderWidth: resolved.borderWidth,
cornerRadius: resolved.cornerRadius || 0,
shadow: resolved.shadow,
// Text props
value: spec.value,
fontSize: resolved.fontSize || spec.fontSize,
fontWeight: resolved.fontWeight || spec.fontWeight,
fontFamily: resolved.fontFamily || tokens.typography?.fontFamily,
color: resolved.color || spec.color,
textAlign: resolved.textAlign || spec.textAlign,
lineHeight: resolved.lineHeight || spec.lineHeight,
};
switch (spec.type) {
case 'rectangle': {
if (spec.label) {
const labelStyle = resolveStyle(spec.label.style, tokens);
return primitives.createLabeledRectangle(props, {
value: spec.label.value,
fontSize: labelStyle.fontSize || 16,
fontWeight: labelStyle.fontWeight,
fontFamily: labelStyle.fontFamily || tokens.typography?.fontFamily,
color: labelStyle.color || '#000000',
textAlign: labelStyle.textAlign || 'center',
});
}
return primitives.createRectangle(props);
}
case 'oval':
return primitives.createOval(props);
case 'text':
return primitives.createText(props);
case 'line':
return primitives.createLine(props);
case 'spacer':
// Spacers are invisible — create a transparent rectangle as placeholder
return primitives.createRectangle({
...props,
name: spec.name || 'Spacer',
backgroundColor: undefined,
});
case 'group': {
const children = buildLayerTree(spec.children || [], spec._childLayout || [], tokens);
// If the group has a background/border style, insert a bg rect first
const groupChildren = [];
if (resolved.backgroundColor || resolved.borderColor) {
groupChildren.push(
primitives.createRectangle({
name: (spec.name || 'Group') + '_bg',
x: 0,
y: 0,
width: frame.width,
height: frame.height,
backgroundColor: resolved.backgroundColor,
borderColor: resolved.borderColor,
borderWidth: resolved.borderWidth,
cornerRadius: resolved.cornerRadius || 0,
})
);
}
groupChildren.push(...children);
return primitives.createGroup(props, groupChildren);
}
default:
console.warn(`Unknown layer type: ${spec.type}`);
return primitives.createRectangle(props);
}
}
/**
* Given parallel arrays of specs and layout results, build layer tree.
*/
function buildLayerTree(specs, layoutResults, tokens) {
const layers = [];
for (let i = 0; i < specs.length; i++) {
const spec = specs[i];
const frame = layoutResults[i] || { x: 0, y: 0, width: 100, height: 40 };
layers.push(buildLayer(spec, frame, tokens));
}
return layers;
}
// ---------------------------------------------------------------------------
// Pre-processing: resolve styles & attach to specs
// ---------------------------------------------------------------------------
function preprocessLayers(layers, tokens) {
for (const layer of layers) {
layer._resolvedStyle = resolveStyle(layer.style, tokens);
// Merge explicit props with resolved style for sizing
if (layer.width === 'fill' || layer._resolvedStyle.width === 'fill') {
layer._resolvedStyle.width = 'fill';
}
if (layer.children) {
preprocessLayers(layer.children, tokens);
}
if (layer.label) {
layer.label._resolvedStyle = resolveStyle(layer.label.style, tokens);
}
}
}
// ---------------------------------------------------------------------------
// Main build function
// ---------------------------------------------------------------------------
/**
* build — parse JSON spec, compute layout, generate .sketch file.
*
* @param {string} inputPath - path to JSON spec file
* @param {string} outputPath - path for output .sketch
* @param {object} options - { tokens: optional override path }
*/
async function build(inputPath, outputPath, options = {}) {
const specRaw = fs.readFileSync(inputPath, 'utf-8');
// Strip JSONC comments (// style)
const specClean = specRaw.replace(/\/\/.*$/gm, '');
const spec = JSON.parse(specClean);
const specDir = path.dirname(path.resolve(inputPath));
// Load tokens
const tokens = loadTokens(spec.tokens, options.tokens, specDir);
// Create sketch document
const sketch = new Sketch();
for (const pageSpec of spec.pages) {
const page = new Page({ name: pageSpec.name || 'Page' });
for (const abSpec of pageSpec.artboards) {
const artboard = new Artboard({
name: abSpec.name || 'Artboard',
frame: {
x: 0,
y: 0,
width: abSpec.width || 375,
height: abSpec.height || 812,
},
backgroundColor: abSpec.backgroundColor || '#FFFFFF',
});
// Pre-process: resolve token references in styles
const layers = abSpec.layers || [];
preprocessLayers(layers, tokens);
// Compute layout
const layout = abSpec.layout || { type: 'absolute' };
const layoutResults = computeLayout(
layers,
layout,
abSpec.width || 375,
abSpec.height || 812
);
// Build sketch layers
const sketchLayers = buildLayerTree(layers, layoutResults, tokens);
for (const sl of sketchLayers) {
artboard.addLayer(sl);
}
page.addArtboard(artboard);
}
sketch.addPage(page);
}
// Ensure output directory exists
const outDir = path.dirname(path.resolve(outputPath));
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
await buildSketchFile(sketch, path.resolve(outputPath));
return outputPath;
}
module.exports = { build, resolveStyle, resolveTokenValue, loadTokens };

71
sketch/agent-harness/src/cli.js Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* sketch-cli — Generate Sketch files from JSON design specs.
*/
const { Command } = require('commander');
const path = require('path');
const fs = require('fs');
const { build } = require('./builder');
const program = new Command();
program
.name('sketch-cli')
.description('Generate .sketch files from JSON design specifications')
.version('1.0.0');
program
.command('build')
.description('Build a .sketch file from a JSON design spec')
.requiredOption('-i, --input <path>', 'Path to JSON design spec')
.requiredOption('-o, --output <path>', 'Output .sketch file path')
.option('-t, --tokens <path>', 'Custom design tokens file (overrides spec-level tokens)')
.action(async (opts) => {
try {
const inputPath = path.resolve(opts.input);
if (!fs.existsSync(inputPath)) {
console.error(`Error: Input file not found: ${inputPath}`);
process.exit(1);
}
console.log(`Building: ${opts.input}${opts.output}`);
await build(inputPath, opts.output, { tokens: opts.tokens });
console.log(`Done! Output: ${path.resolve(opts.output)}`);
} catch (err) {
console.error(`Build failed: ${err.message}`);
if (process.env.DEBUG) console.error(err.stack);
process.exit(1);
}
});
program
.command('list-styles')
.description('List all predefined styles in a tokens file')
.option('-t, --tokens <path>', 'Tokens file path', path.resolve(__dirname, '..', 'tokens', 'default.json'))
.action((opts) => {
try {
const tokensPath = path.resolve(opts.tokens);
if (!fs.existsSync(tokensPath)) {
console.error(`Tokens file not found: ${tokensPath}`);
process.exit(1);
}
const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
const styles = tokens.styles || {};
console.log('Available styles:\n');
for (const [name, def] of Object.entries(styles)) {
const props = Object.entries(def)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
.join(', ');
console.log(` ${name} → { ${props} }`);
}
console.log(`\nTotal: ${Object.keys(styles).length} styles`);
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
});
program.parse();

View File

@@ -0,0 +1,248 @@
/**
* layout.js — Layout engine that computes { x, y, width, height } for layers.
*
* Supported layout types:
* - vertical-stack : children flow top→bottom
* - horizontal-stack : children flow left→right
* - absolute : children positioned by their own x/y
*/
// ---------------------------------------------------------------------------
// Text size estimation
// ---------------------------------------------------------------------------
function estimateTextSize(layer) {
const fontSize = resolveFontSize(layer);
const lineHeight = layer.lineHeight
|| layer._resolvedStyle?.lineHeight
|| (layer.style && typeof layer.style === 'object' ? layer.style.lineHeight : undefined)
|| fontSize * 1.4;
const text = layer.value || '';
// rough: each character ~ 0.55 * fontSize wide (CJK ~ 1.0)
const cjkCount = (text.match(/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/g) || []).length;
const asciiCount = text.length - cjkCount;
const estWidth = (asciiCount * 0.55 + cjkCount * 1.0) * fontSize;
const estHeight = lineHeight;
return { width: Math.ceil(estWidth) + 4, height: Math.ceil(estHeight) };
}
function resolveFontSize(layer) {
if (layer.fontSize) return layer.fontSize;
if (layer._resolvedStyle?.fontSize) return layer._resolvedStyle.fontSize;
if (layer.style && typeof layer.style === 'object' && layer.style.fontSize) return layer.style.fontSize;
return 14;
}
// ---------------------------------------------------------------------------
// Resolve intrinsic size of a single layer spec
// ---------------------------------------------------------------------------
function intrinsicSize(layer, parentWidth) {
// Spacer
if (layer.type === 'spacer') {
return {
width: layer.width || parentWidth || 0,
height: layer.height || 0,
};
}
let w = layer.width;
let h = layer.height;
// "fill" means match parent
if (w === 'fill') w = parentWidth || 300;
// Resolve from resolved style
if (!w && layer._resolvedStyle?.width === 'fill') w = parentWidth || 300;
if (!w && layer._resolvedStyle?.width) w = layer._resolvedStyle.width;
if (!h && layer._resolvedStyle?.height) h = layer._resolvedStyle.height;
// Text auto-size
if (layer.type === 'text' && (!w || !h)) {
const est = estimateTextSize(layer);
if (!w) w = est.width;
if (!h) h = est.height;
}
// Group / rectangle defaults
if (!w) w = 100;
if (!h) h = layer.type === 'group' ? 0 : 40; // group height computed from children
return { width: w, height: h };
}
// ---------------------------------------------------------------------------
// Layout algorithms
// ---------------------------------------------------------------------------
function layoutVerticalStack(layers, config, containerWidth, containerHeight) {
const padTop = config.paddingTop || config.paddingVertical || 0;
const padBottom = config.paddingBottom || config.paddingVertical || 0;
const padH = config.paddingHorizontal || 0;
const padLeft = config.paddingLeft || padH;
const padRight = config.paddingRight || padH;
const gap = config.gap || 0;
const align = config.alignItems || 'left';
const availableWidth = containerWidth - padLeft - padRight;
let cursorY = padTop;
const results = [];
for (let i = 0; i < layers.length; i++) {
const layer = layers[i];
const size = intrinsicSize(layer, availableWidth);
// If group, recursively lay out children to get real height
if (layer.type === 'group' && layer.children && layer.layout) {
const childResults = computeLayout(layer.children, layer.layout, size.width, size.height);
// Update group height based on children
let maxBottom = 0;
for (const cr of childResults) {
const bot = cr.y + cr.height;
if (bot > maxBottom) maxBottom = bot;
}
if (size.height === 0 || layer._resolvedStyle?.height === undefined) {
const groupPadBottom = layer.layout.paddingBottom || layer.layout.paddingVertical || 0;
size.height = maxBottom + groupPadBottom;
}
layer._childLayout = childResults;
} else if (layer.type === 'group' && layer.children && !layer.layout) {
// absolute positioning, estimate from children
layer._childLayout = computeLayout(layer.children, { type: 'absolute' }, size.width, size.height);
}
let x = padLeft;
if (align === 'center') x = padLeft + (availableWidth - size.width) / 2;
else if (align === 'right') x = padLeft + availableWidth - size.width;
results.push({
index: i,
x,
y: cursorY,
width: size.width,
height: size.height,
});
cursorY += size.height + gap;
}
return results;
}
function layoutHorizontalStack(layers, config, containerWidth, containerHeight) {
const padH = config.paddingHorizontal || 0;
const padLeft = config.paddingLeft || padH;
const padRight = config.paddingRight || padH;
const padTop = config.paddingTop || config.paddingVertical || 0;
const gap = config.gap || 0;
const justify = config.justifyContent || 'start';
const alignItems = config.alignItems || 'top';
const availableWidth = containerWidth - padLeft - padRight;
// First pass — measure all children
const sizes = layers.map((l) => intrinsicSize(l, undefined));
// Recursively lay out child groups
for (let i = 0; i < layers.length; i++) {
const layer = layers[i];
if (layer.type === 'group' && layer.children) {
const lo = layer.layout || { type: 'absolute' };
layer._childLayout = computeLayout(layer.children, lo, sizes[i].width, sizes[i].height);
// Recalculate height from children if needed
if (sizes[i].height === 0) {
let maxBot = 0;
for (const cr of layer._childLayout) {
const bot = cr.y + cr.height;
if (bot > maxBot) maxBot = bot;
}
sizes[i].height = maxBot;
}
}
}
const totalChildWidth = sizes.reduce((s, sz) => s + sz.width, 0);
const totalGaps = (layers.length - 1) * gap;
const maxChildHeight = Math.max(...sizes.map((s) => s.height), 0);
// Determine starting X and gap override for justify
let startX = padLeft;
let effectiveGap = gap;
if (justify === 'center') {
startX = padLeft + (availableWidth - totalChildWidth - totalGaps) / 2;
} else if (justify === 'end') {
startX = padLeft + availableWidth - totalChildWidth - totalGaps;
} else if (justify === 'space-between' && layers.length > 1) {
effectiveGap = (availableWidth - totalChildWidth) / (layers.length - 1);
}
let cursorX = startX;
const results = [];
for (let i = 0; i < layers.length; i++) {
const sz = sizes[i];
let y = padTop;
if (alignItems === 'center') y = padTop + (maxChildHeight - sz.height) / 2;
else if (alignItems === 'bottom') y = padTop + maxChildHeight - sz.height;
results.push({
index: i,
x: cursorX,
y,
width: sz.width,
height: sz.height,
});
cursorX += sz.width + effectiveGap;
}
return results;
}
function layoutAbsolute(layers, config, containerWidth, containerHeight) {
return layers.map((layer, i) => {
const size = intrinsicSize(layer, containerWidth);
if (layer.type === 'group' && layer.children) {
const lo = layer.layout || { type: 'absolute' };
layer._childLayout = computeLayout(layer.children, lo, size.width, size.height);
}
return {
index: i,
x: layer.x || 0,
y: layer.y || 0,
width: size.width,
height: size.height,
};
});
}
// ---------------------------------------------------------------------------
// Main entry
// ---------------------------------------------------------------------------
/**
* computeLayout — compute positions for an array of layer specs.
*
* @param {Array} layers - layer spec objects from the JSON
* @param {Object} layout - { type, gap, padding*, alignItems, justifyContent }
* @param {number} containerWidth
* @param {number} containerHeight
* @returns {Array<{ index, x, y, width, height }>}
*/
function computeLayout(layers, layout, containerWidth, containerHeight) {
if (!layout || !layout.type || layout.type === 'absolute') {
return layoutAbsolute(layers, layout || {}, containerWidth, containerHeight);
}
if (layout.type === 'vertical-stack') {
return layoutVerticalStack(layers, layout, containerWidth, containerHeight);
}
if (layout.type === 'horizontal-stack') {
return layoutHorizontalStack(layers, layout, containerWidth, containerHeight);
}
// Fallback
return layoutAbsolute(layers, layout, containerWidth, containerHeight);
}
module.exports = { computeLayout, estimateTextSize, intrinsicSize };

View File

@@ -0,0 +1,262 @@
/**
* primitives.js — Shape factory functions wrapping sketch-constructor models.
*
* Every function accepts { x, y, width, height, ...styleProps } and returns
* a sketch-constructor Layer instance ready to be added to an Artboard or Group.
*/
const {
Rectangle,
Oval,
Text,
Group,
ShapePath,
CurvePoint,
Color,
} = require('sketch-constructor');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildStyle(props) {
const style = {};
// Fills
if (props.backgroundColor) {
style.fills = [{ color: props.backgroundColor }];
}
// Borders
if (props.borderColor) {
style.borders = [
{
color: props.borderColor,
thickness: props.borderWidth || 1,
position: 'Inside',
},
];
}
// Shadows
if (props.shadow) {
const s = props.shadow;
style.shadows = [
{
color: s.color || '#00000026',
blurRadius: s.blurRadius || 4,
offsetX: s.offsetX || 0,
offsetY: s.offsetY || 2,
spread: s.spread || 0,
},
];
}
return style;
}
/**
* Map font family + weight to a valid PostScript font name.
* sketch-constructor requires PostScript names (no spaces).
*/
const FONT_PS_MAP = {
'PingFang SC': { regular: 'PingFangSC-Regular', bold: 'PingFangSC-Semibold' },
'Helvetica Neue': { regular: 'HelveticaNeue', bold: 'HelveticaNeue-Bold' },
'Helvetica': { regular: 'Helvetica', bold: 'Helvetica-Bold' },
};
function fontName(fontFamily, fontWeight) {
const family = fontFamily || 'Helvetica Neue';
const map = FONT_PS_MAP[family];
if (map) {
return fontWeight === 'bold' ? map.bold : map.regular;
}
// Fallback: remove spaces and append weight
const base = family.replace(/\s+/g, '');
return fontWeight === 'bold' ? `${base}-Bold` : base;
}
// ---------------------------------------------------------------------------
// Public factories
// ---------------------------------------------------------------------------
/**
* createRectangle — rectangle with optional fill, border, cornerRadius, shadow.
*/
function createRectangle(props) {
const style = buildStyle(props);
const rect = new Rectangle({
name: props.name || 'Rectangle',
x: props.x || 0,
y: props.y || 0,
width: props.width || 100,
height: props.height || 100,
cornerRadius: props.cornerRadius || 0,
style,
});
return rect;
}
/**
* createOval — circle / ellipse.
*/
function createOval(props) {
const style = buildStyle(props);
return new Oval({
name: props.name || 'Oval',
x: props.x || 0,
y: props.y || 0,
width: props.width || 100,
height: props.height || 100,
style,
});
}
/**
* createText — text layer with font, color, alignment.
*
* Workaround for two sketch-constructor bugs:
* 1. Text uses args.frame (not top-level x/y/width/height)
* 2. Style.TextStyle double-wraps TextStyle, losing font/color — fix manually
*/
function createText(props) {
const fn = fontName(props.fontFamily, props.fontWeight);
const fs = props.fontSize || 14;
const clr = props.color || '#000000';
const text = new Text({
string: props.value || props.string || '',
name: props.name || 'Text',
frame: {
x: props.x || 0,
y: props.y || 0,
width: props.width || 200,
height: props.height || 30,
},
fontSize: fs,
fontName: fn,
color: clr,
alignment: props.textAlign || 'left',
lineHeight: props.lineHeight || undefined,
textBehaviour: 'fixed',
});
// Fix style.textStyle — sketch-constructor's Style constructor double-wraps
// the TextStyle, causing it to fall back to Helvetica/16/black.
// Copy the correct values from attributedString into style.textStyle.
const ea = text.style.textStyle.encodedAttributes;
ea.MSAttributedStringFontAttribute = {
_class: 'fontDescriptor',
attributes: { name: fn, size: fs },
};
ea.MSAttributedStringColorAttribute = new Color(clr);
return text;
}
/**
* createLine — a straight line from (0,0) to (width, 0) inside its frame.
*/
function createLine(props) {
const w = props.width || 100;
const style = {};
style.borders = [
{
color: props.color || props.borderColor || '#000000',
thickness: props.thickness || props.borderWidth || 1,
},
];
return new ShapePath({
name: props.name || 'Line',
frame: {
x: props.x || 0,
y: props.y || 0,
width: w,
height: 1,
},
points: [
new CurvePoint({
point: '{0, 0.5}',
curveFrom: '{0, 0.5}',
curveTo: '{0, 0.5}',
}),
new CurvePoint({
point: '{1, 0.5}',
curveFrom: '{1, 0.5}',
curveTo: '{1, 0.5}',
}),
],
style,
isClosed: false,
});
}
/**
* createGroup — wraps child layers.
*/
function createGroup(props, children) {
const group = new Group({
name: props.name || 'Group',
frame: {
x: props.x || 0,
y: props.y || 0,
width: props.width || 100,
height: props.height || 100,
},
});
if (children && children.length) {
for (const child of children) {
group.addLayer(child);
}
}
return group;
}
/**
* createLabeledRectangle — rectangle with centered text overlay.
* Returns a Group containing the rect + text.
*/
function createLabeledRectangle(props, labelProps) {
const rect = createRectangle({
...props,
x: 0,
y: 0,
name: (props.name || 'Button') + '_bg',
});
const textHeight = (labelProps.fontSize || 16) * 1.4;
const textY = ((props.height || 48) - textHeight) / 2;
const text = createText({
...labelProps,
x: 0,
y: textY,
width: props.width || 100,
height: textHeight,
name: (props.name || 'Button') + '_label',
textAlign: labelProps.textAlign || 'center',
});
return createGroup(
{
name: props.name || 'LabeledRect',
x: props.x || 0,
y: props.y || 0,
width: props.width || 100,
height: props.height || 48,
},
[rect, text]
);
}
module.exports = {
createRectangle,
createOval,
createText,
createLine,
createGroup,
createLabeledRectangle,
};

View File

@@ -0,0 +1,89 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const { exec } = require('child_process');
const execAsync = promisify(exec);
const ROOT = path.resolve(__dirname, '..');
const CLI = path.join(ROOT, 'src', 'cli.js');
const OUTPUT_DIR = path.join(ROOT, 'output', 'test');
const EXAMPLES = ['login-page', 'dashboard', 'card-list'];
beforeAll(() => {
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
});
afterAll(() => {
// Clean up test outputs
for (const name of EXAMPLES) {
const p = path.join(OUTPUT_DIR, `${name}.sketch`);
if (fs.existsSync(p)) fs.unlinkSync(p);
}
if (fs.existsSync(OUTPUT_DIR)) {
try { fs.rmdirSync(OUTPUT_DIR); } catch (_) { /* ignore */ }
}
});
describe('sketch-cli build', () => {
for (const name of EXAMPLES) {
describe(`example: ${name}`, () => {
const inputPath = path.join(ROOT, 'examples', `${name}.json`);
const outputPath = path.join(OUTPUT_DIR, `${name}.sketch`);
test('input JSON exists', () => {
expect(fs.existsSync(inputPath)).toBe(true);
});
test('builds without errors', () => {
const result = execSync(
`node "${CLI}" build --input "${inputPath}" --output "${outputPath}"`,
{ encoding: 'utf-8', cwd: ROOT }
);
expect(result).toContain('Done!');
});
test('output file exists', () => {
expect(fs.existsSync(outputPath)).toBe(true);
});
test('output is a valid ZIP file (PK magic bytes)', () => {
const buf = fs.readFileSync(outputPath);
// ZIP magic: 0x50 0x4B (PK)
expect(buf[0]).toBe(0x50);
expect(buf[1]).toBe(0x4b);
});
test('ZIP contains required Sketch structure', () => {
const result = execSync(`unzip -l "${outputPath}"`, { encoding: 'utf-8' });
expect(result).toContain('meta.json');
expect(result).toContain('document.json');
expect(result).toContain('pages/');
});
test('ZIP contains at least one page JSON', () => {
const result = execSync(`unzip -l "${outputPath}"`, { encoding: 'utf-8' });
// pages/ directory should have at least one .json file
const pageFiles = result.split('\n').filter(
(line) => line.includes('pages/') && line.includes('.json')
);
expect(pageFiles.length).toBeGreaterThan(0);
});
});
}
});
describe('sketch-cli list-styles', () => {
test('lists styles from default tokens', () => {
const result = execSync(`node "${CLI}" list-styles`, {
encoding: 'utf-8',
cwd: ROOT,
});
expect(result).toContain('$heading1');
expect(result).toContain('$primaryButton');
expect(result).toContain('Total:');
});
});

View File

@@ -0,0 +1,117 @@
{
"colors": {
"primary": "#3B82F6",
"primaryDark": "#1D4ED8",
"secondary": "#64748B",
"background": "#FFFFFF",
"surface": "#F8FAFC",
"text": "#0F172A",
"textSecondary": "#64748B",
"border": "#E2E8F0",
"error": "#EF4444",
"success": "#10B981"
},
"typography": {
"fontFamily": "PingFang SC",
"fallbackFontFamily": "Helvetica Neue"
},
"spacing": {
"xs": 4,
"sm": 8,
"md": 16,
"lg": 24,
"xl": 32,
"xxl": 48
},
"radius": {
"sm": 4,
"md": 8,
"lg": 12,
"xl": 16,
"full": 9999
},
"shadows": {
"sm": { "offsetX": 0, "offsetY": 1, "blurRadius": 2, "color": "#0000000D" },
"md": { "offsetX": 0, "offsetY": 4, "blurRadius": 6, "color": "#0000001A" },
"lg": { "offsetX": 0, "offsetY": 10, "blurRadius": 15, "color": "#00000026" }
},
"styles": {
"$heading1": {
"fontSize": 28,
"fontWeight": "bold",
"color": "$text",
"lineHeight": 36
},
"$heading2": {
"fontSize": 22,
"fontWeight": "bold",
"color": "$text",
"lineHeight": 30
},
"$heading3": {
"fontSize": 18,
"fontWeight": "bold",
"color": "$text",
"lineHeight": 26
},
"$body": {
"fontSize": 15,
"color": "$text",
"lineHeight": 22
},
"$bodySecondary": {
"fontSize": 15,
"color": "$textSecondary",
"lineHeight": 22
},
"$caption": {
"fontSize": 12,
"color": "$textSecondary",
"lineHeight": 16
},
"$placeholder": {
"fontSize": 15,
"color": "$border",
"lineHeight": 20
},
"$inputField": {
"backgroundColor": "$surface",
"borderColor": "$border",
"borderWidth": 1,
"cornerRadius": "$md",
"height": 48,
"width": "fill",
"paddingHorizontal": 16
},
"$primaryButton": {
"backgroundColor": "$primary",
"cornerRadius": "$lg",
"shadow": "$md"
},
"$secondaryButton": {
"backgroundColor": "$surface",
"borderColor": "$primary",
"borderWidth": 1,
"cornerRadius": "$lg"
},
"$buttonText": {
"fontSize": 16,
"fontWeight": "bold",
"color": "#FFFFFF",
"textAlign": "center"
},
"$link": {
"fontSize": 14,
"color": "$primary"
},
"$card": {
"backgroundColor": "#FFFFFF",
"cornerRadius": "$lg",
"shadow": "$md"
},
"$divider": {
"backgroundColor": "$border",
"height": 1
}
}
}