mirror of
https://fastgit.cc/github.com/HKUDS/CLI-Anything
synced 2026-04-20 12:50:25 +08:00
feat: add sketch harness
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -41,6 +41,7 @@
|
||||
!/shotcut/
|
||||
!/anygen/
|
||||
!/zoom/
|
||||
!/sketch/
|
||||
!/drawio/
|
||||
!/mermaid/
|
||||
!/adguardhome/
|
||||
@@ -70,6 +71,8 @@
|
||||
/anygen/.*
|
||||
/zoom/*
|
||||
/zoom/.*
|
||||
/sketch/*
|
||||
/sketch/.*
|
||||
/drawio/*
|
||||
/drawio/.*
|
||||
/mermaid/*
|
||||
@@ -91,6 +94,7 @@
|
||||
!/shotcut/agent-harness/
|
||||
!/anygen/agent-harness/
|
||||
!/zoom/agent-harness/
|
||||
!/sketch/agent-harness/
|
||||
!/drawio/agent-harness/
|
||||
!/mermaid/agent-harness/
|
||||
!/adguardhome/agent-harness/
|
||||
|
||||
17
README.md
17
README.md
@@ -638,12 +638,19 @@ Each application received complete, production-ready CLI interfaces — not demo
|
||||
<td align="center">✅ 98</td>
|
||||
</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"><strong>✅ 1,839</strong></td>
|
||||
<td align="center"><strong>✅ 1,858</strong></td>
|
||||
</tr>
|
||||
</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)
|
||||
adguardhome 36 passed ✅ (24 unit + 12 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)
|
||||
├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 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.
|
||||
|
||||
17
README_CN.md
17
README_CN.md
@@ -543,12 +543,19 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
|
||||
<td align="center">✅ 50</td>
|
||||
</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"><strong>✅ 1,508</strong></td>
|
||||
<td align="center"><strong>✅ 1,527</strong></td>
|
||||
</tr>
|
||||
</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)
|
||||
drawio 138 passed ✅ (116 unit + 22 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 CLI(154 项测试)
|
||||
├── 📞 zoom/agent-harness/ # Zoom CLI(22 项测试)
|
||||
├── 📐 drawio/agent-harness/ # Draw.io CLI(138 项测试)
|
||||
└── ✨ anygen/agent-harness/ # AnyGen CLI(50 项测试)
|
||||
├── ✨ anygen/agent-harness/ # AnyGen CLI(50 项测试)
|
||||
└── 🎨 sketch/agent-harness/ # Sketch CLI(19 项测试,Node.js)
|
||||
```
|
||||
|
||||
每个 `agent-harness/` 包含一个可安装的 Python 包,位于 `cli_anything.<软件名>/` 下,包含 Click CLI、核心模块、工具类(含 `repl_skin.py` 和后端适配器)以及完整的测试。
|
||||
|
||||
@@ -254,6 +254,20 @@
|
||||
"category": "ai",
|
||||
"contributor": "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
3
sketch/agent-harness/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
output/
|
||||
.sketch-constructor/
|
||||
144
sketch/agent-harness/README.md
Normal file
144
sketch/agent-harness/README.md
Normal 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.
|
||||
123
sketch/agent-harness/README_zh.md
Normal file
123
sketch/agent-harness/README_zh.md
Normal 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 中打开查看效果
|
||||
230
sketch/agent-harness/examples/card-list.json
Normal file
230
sketch/agent-harness/examples/card-list.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
238
sketch/agent-harness/examples/dashboard.json
Normal file
238
sketch/agent-harness/examples/dashboard.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
132
sketch/agent-harness/examples/login-page.json
Normal file
132
sketch/agent-harness/examples/login-page.json
Normal 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
3848
sketch/agent-harness/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
sketch/agent-harness/package.json
Normal file
26
sketch/agent-harness/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
348
sketch/agent-harness/src/builder.js
Normal file
348
sketch/agent-harness/src/builder.js
Normal 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
71
sketch/agent-harness/src/cli.js
Executable 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();
|
||||
248
sketch/agent-harness/src/layout.js
Normal file
248
sketch/agent-harness/src/layout.js
Normal 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 };
|
||||
262
sketch/agent-harness/src/primitives.js
Normal file
262
sketch/agent-harness/src/primitives.js
Normal 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,
|
||||
};
|
||||
89
sketch/agent-harness/tests/build.test.js
Normal file
89
sketch/agent-harness/tests/build.test.js
Normal 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:');
|
||||
});
|
||||
});
|
||||
117
sketch/agent-harness/tokens/default.json
Normal file
117
sketch/agent-harness/tokens/default.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user