diff --git a/README.tr.md b/README.tr.md
index 2f1d08dc..967e149d 100644
--- a/README.tr.md
+++ b/README.tr.md
@@ -1,16 +1,16 @@
-
+
OpenPencil
- Dunyanin ilk acik kaynakli AI-yerel vektor tasarim araci.
- Eszamanli Ajan Ekipleri • Kod Olarak Tasarim • Yerlesik MCP Sunucusu • Coklu Model Zekasi
+ Dünyanın ilk açık kaynaklı AI-yerel vektör tasarım aracı.
+ Eşzamanlı Ajan Ekipleri • Kod Olarak Tasarım • Yerleşik MCP Sunucusu • Çoklu Model Zekası
- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia
+ English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia
@@ -102,7 +102,7 @@ bun run electron:dev
> **Ön koşullar:** [Bun](https://bun.sh/) >= 1.0 ve [Node.js](https://nodejs.org/) >= 18
-### Docker ile Dağıtım
+### Docker
Birden fazla görüntü varyantı mevcuttur — ihtiyaçlarınıza uygun olanı seçin:
@@ -113,6 +113,7 @@ Birden fazla görüntü varyantı mevcuttur — ihtiyaçlarınıza uygun olanı
| `openpencil-codex:latest` | — | + Codex CLI |
| `openpencil-opencode:latest` | — | + OpenCode CLI |
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
+| `openpencil-gemini:latest` | — | + Gemini CLI |
| `openpencil-full:latest` | ~1 GB | Tüm CLI araçları |
**Çalıştır (yalnızca web):**
@@ -167,6 +168,7 @@ docker build --target full -t openpencil-full .
| **Codex CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
| **OpenCode** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
| **GitHub Copilot** | `copilot login` ardından Ajan Ayarlarından bağlanın (`Cmd+,`) |
+| **Gemini CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
**Model Yetenek Profilleri** — promptları, düşünme modunu ve zaman aşımlarını model katmanına göre otomatik olarak uyarlar. Tam katman modeller (Claude) eksiksiz promptlar alır; standart katman (GPT-4o, Gemini, DeepSeek) düşünme modunu devre dışı bırakır; temel katman (MiniMax, Qwen, Llama, Mistral) maksimum güvenilirlik için basitleştirilmiş iç içe JSON promptları alır.
@@ -212,7 +214,7 @@ docker build --target full -t openpencil-full .
| | |
| --- | --- |
| **Ön Uç** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui |
-| **Kanvas** | CanvasKit/Skia (WASM, GPU hizlandirmali) |
+| **Kanvas** | CanvasKit/Skia (WASM, GPU hızlandırmalı) |
| **Durum Yönetimi** | Zustand v5 |
| **Sunucu** | Nitro |
| **Masaüstü** | Electron 35 |
@@ -223,22 +225,32 @@ docker build --target full -t openpencil-full .
## Proje Yapısı
```text
-src/
- canvas/ CanvasKit/Skia motoru — çizim, senkronizasyon, düzen, kılavuzlar, kalem aracı
- components/ React UI — editör, paneller, paylaşılan iletişim kutuları, simgeler
- services/ai/ AI sohbet, orkestratör, tasarım üretimi, akış
- services/figma/ Figma .fig ikili içe aktarma ardışık düzeni
- services/codegen React+Tailwind ve HTML+CSS kod üreticileri
- stores/ Zustand — kanvas, belge, sayfalar, geçmiş, AI, ayarlar
- variables/ Tasarım token çözümleme ve referans yönetimi
- mcp/ Harici CLI entegrasyonu için MCP sunucu araçları
- uikit/ Yeniden kullanılabilir bileşen kiti sistemi
-server/
- api/ai/ Nitro API — akış sohbet, üretim, doğrulama
- utils/ Claude CLI, OpenCode, Codex, Copilot istemci sarmalayıcıları
-electron/
- main.ts Pencere, Nitro çatallanması, yerel menü, otomatik güncelleyici
- preload.ts IPC köprüsü
+openpencil/
+├── apps/
+│ ├── web/ TanStack Start web uygulaması
+│ │ ├── src/
+│ │ │ ├── canvas/ CanvasKit/Skia motoru — çizim, senkronizasyon, düzen
+│ │ │ ├── components/ React UI — editör, paneller, paylaşılan iletişim kutuları, simgeler
+│ │ │ ├── services/ai/ AI sohbet, orkestratör, tasarım üretimi, akış
+│ │ │ ├── stores/ Zustand — kanvas, belge, sayfalar, geçmiş, AI
+│ │ │ ├── mcp/ Harici CLI entegrasyonu için MCP sunucu araçları
+│ │ │ ├── hooks/ Klavye kısayolları, dosya bırakma, Figma yapıştırma
+│ │ │ └── uikit/ Yeniden kullanılabilir bileşen kiti sistemi
+│ │ └── server/
+│ │ ├── api/ai/ Nitro API — akış sohbet, üretim, doğrulama
+│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot sarmalayıcıları
+│ └── desktop/ Electron masaüstü uygulaması
+│ ├── main.ts Pencere, Nitro çatallanması, yerel menü, otomatik güncelleyici
+│ ├── ipc-handlers.ts Yerel dosya diyalogları, tema senkronizasyonu, tercihler IPC
+│ └── preload.ts IPC köprüsü
+├── packages/
+│ ├── pen-types/ PenDocument modeli için tür tanımları
+│ ├── pen-core/ Belge ağacı işlemleri, düzen motoru, değişkenler
+│ ├── pen-codegen/ Kod oluşturucular (React, HTML, Vue, Flutter, ...)
+│ ├── pen-figma/ Figma .fig dosya ayrıştırıcı ve dönüştürücü
+│ ├── pen-renderer/ Bağımsız CanvasKit/Skia işleyici
+│ └── pen-sdk/ Şemsiye SDK (tüm paketleri yeniden dışa aktarır)
+└── .githooks/ Dal adından ön-commit sürüm eşitleme
```
## Klavye Kısayolları
@@ -266,6 +278,7 @@ bun --bun run dev # Geliştirme sunucusu (port 3000)
bun --bun run build # Üretim derlemesi
bun --bun run test # Testleri çalıştır (Vitest)
npx tsc --noEmit # Tür denetimi
+bun run bump # Tüm package.json dosyalarında sürümü eşitle
bun run electron:dev # Electron geliştirme modu
bun run electron:build # Electron paketleme
```
@@ -275,10 +288,11 @@ bun run electron:build # Electron paketleme
Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md](./CLAUDE.md) dosyasına bakın.
1. Fork'layın ve klonlayın
-2. Dal oluşturun: `git checkout -b feat/my-feature`
-3. Kontrolleri çalıştırın: `npx tsc --noEmit && bun --bun run test`
-4. [Conventional Commits](https://www.conventionalcommits.org/) formatıyla commit yapın: `feat(canvas): add rotation snapping`
-5. `main` dalına PR açın
+2. Sürüm eşitlemeyi ayarlayın: `git config core.hooksPath .githooks`
+3. Dal oluşturun: `git checkout -b feat/my-feature`
+4. Kontrolleri çalıştırın: `npx tsc --noEmit && bun --bun run test`
+5. [Conventional Commits](https://www.conventionalcommits.org/) formatıyla commit yapın: `feat(canvas): add rotation snapping`
+6. `main` dalına PR açın
## Yol Haritası
@@ -290,6 +304,7 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md]
- [x] Figma `.fig` içe aktarma
- [x] Boolean işlemler (birleştirme, çıkarma, kesişim)
- [x] Çoklu model yetenek profilleri
+- [x] Yeniden kullanılabilir paketlerle monorepo yapılandırması
- [ ] Ortak düzenleme
- [ ] Eklenti sistemi
@@ -302,12 +317,11 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md]
## Topluluk
-
+
Discord'umuza katılın
— Soru sorun, tasarımlarınızı paylaşın, özellik önerin.
-
## Star History
diff --git a/README.vi.md b/README.vi.md
index 4464faad..3e9b419c 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -1,5 +1,5 @@
-
+
OpenPencil
@@ -10,7 +10,7 @@
- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia
+ English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia
@@ -102,7 +102,7 @@ bun run electron:dev
> **Yêu cầu:** [Bun](https://bun.sh/) >= 1.0 và [Node.js](https://nodejs.org/) >= 18
-### Triển khai bằng Docker
+### Docker
Có nhiều biến thể image khác nhau — chọn loại phù hợp với nhu cầu của bạn:
@@ -113,6 +113,7 @@ Có nhiều biến thể image khác nhau — chọn loại phù hợp với nhu
| `openpencil-codex:latest` | — | + Codex CLI |
| `openpencil-opencode:latest` | — | + OpenCode CLI |
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
+| `openpencil-gemini:latest` | — | + Gemini CLI |
| `openpencil-full:latest` | ~1 GB | Tất cả công cụ CLI |
**Chạy (chỉ web):**
@@ -167,6 +168,7 @@ docker build --target full -t openpencil-full .
| **Codex CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
| **OpenCode** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
| **GitHub Copilot** | `copilot login` rồi kết nối trong Cài đặt tác nhân (`Cmd+,`) |
+| **Gemini CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
**Hồ sơ Năng lực Mô hình** — tự động thích ứng prompt, chế độ thinking và thời gian chờ theo từng cấp mô hình. Mô hình cấp đầy đủ (Claude) nhận prompt hoàn chỉnh; cấp tiêu chuẩn (GPT-4o, Gemini, DeepSeek) tắt thinking; cấp cơ bản (MiniMax, Qwen, Llama, Mistral) nhận prompt JSON lồng nhau đơn giản hóa để đảm bảo độ tin cậy tối đa.
@@ -223,22 +225,32 @@ docker build --target full -t openpencil-full .
## Cấu trúc dự án
```text
-src/
- canvas/ CanvasKit/Skia engine — vẽ, đồng bộ, layout, hướng dẫn, công cụ bút
- components/ React UI — editor, panels, hộp thoại dùng chung, icons
- services/ai/ AI chat, orchestrator, tạo thiết kế, streaming
- services/figma/ Pipeline nhập binary Figma .fig
- services/codegen Bộ tạo mã React+Tailwind và HTML+CSS
- stores/ Zustand — canvas, document, pages, history, AI, settings
- variables/ Giải quyết token thiết kế và quản lý tham chiếu
- mcp/ Công cụ máy chủ MCP để tích hợp CLI bên ngoài
- uikit/ Hệ thống kit component có thể tái sử dụng
-server/
- api/ai/ Nitro API — streaming chat, generation, validation
- utils/ Claude CLI, OpenCode, Codex, Copilot client wrappers
-electron/
- main.ts Cửa sổ, Nitro fork, menu gốc, auto-updater
- preload.ts IPC bridge
+openpencil/
+├── apps/
+│ ├── web/ Ứng dụng web TanStack Start
+│ │ ├── src/
+│ │ │ ├── canvas/ Engine CanvasKit/Skia — vẽ, đồng bộ, layout
+│ │ │ ├── components/ React UI — editor, panels, hộp thoại dùng chung, icons
+│ │ │ ├── services/ai/ AI chat, orchestrator, tạo thiết kế, streaming
+│ │ │ ├── stores/ Zustand — canvas, document, pages, history, AI
+│ │ │ ├── mcp/ Công cụ máy chủ MCP để tích hợp CLI bên ngoài
+│ │ │ ├── hooks/ Phím tắt, kéo thả tệp, dán từ Figma
+│ │ │ └── uikit/ Hệ thống kit component có thể tái sử dụng
+│ │ └── server/
+│ │ ├── api/ai/ Nitro API — streaming chat, generation, validation
+│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot wrappers
+│ └── desktop/ Ứng dụng desktop Electron
+│ ├── main.ts Cửa sổ, Nitro fork, menu gốc, auto-updater
+│ ├── ipc-handlers.ts Hộp thoại file gốc, đồng bộ theme, tùy chọn IPC
+│ └── preload.ts IPC bridge
+├── packages/
+│ ├── pen-types/ Định nghĩa kiểu cho mô hình PenDocument
+│ ├── pen-core/ Thao tác cây tài liệu, layout engine, biến
+│ ├── pen-codegen/ Bộ tạo mã (React, HTML, Vue, Flutter, ...)
+│ ├── pen-figma/ Trình phân tích và chuyển đổi tệp Figma .fig
+│ ├── pen-renderer/ Bộ dựng hình CanvasKit/Skia độc lập
+│ └── pen-sdk/ SDK tổng hợp (tái xuất tất cả các gói)
+└── .githooks/ Pre-commit đồng bộ phiên bản từ tên nhánh
```
## Phím tắt
@@ -266,6 +278,7 @@ bun --bun run dev # Máy chủ phát triển (cổng 3000)
bun --bun run build # Build production
bun --bun run test # Chạy kiểm thử (Vitest)
npx tsc --noEmit # Kiểm tra kiểu
+bun run bump # Đồng bộ phiên bản trên tất cả package.json
bun run electron:dev # Electron dev
bun run electron:build # Đóng gói Electron
```
@@ -275,10 +288,11 @@ bun run electron:build # Đóng gói Electron
Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết về kiến trúc và phong cách mã.
1. Fork và clone
-2. Tạo branch: `git checkout -b feat/my-feature`
-3. Chạy kiểm tra: `npx tsc --noEmit && bun --bun run test`
-4. Commit theo [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping`
-5. Mở PR vào nhánh `main`
+2. Thiết lập đồng bộ phiên bản: `git config core.hooksPath .githooks`
+3. Tạo branch: `git checkout -b feat/my-feature`
+4. Chạy kiểm tra: `npx tsc --noEmit && bun --bun run test`
+5. Commit theo [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping`
+6. Mở PR vào nhánh `main`
## Lộ trình
@@ -290,6 +304,7 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v
- [x] Nhập Figma `.fig`
- [x] Phép toán Boolean (hợp nhất, trừ, giao)
- [x] Hồ sơ năng lực đa mô hình
+- [x] Tái cấu trúc monorepo với các gói tái sử dụng
- [ ] Chỉnh sửa cộng tác
- [ ] Hệ thống plugin
@@ -302,12 +317,11 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v
## Cộng đồng
-
+
Tham gia Discord của chúng tôi
— Đặt câu hỏi, chia sẻ thiết kế, đề xuất tính năng.
-
## Star History
diff --git a/README.zh-TW.md b/README.zh-TW.md
index 8040f443..bf9ba7f5 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -1,5 +1,5 @@
-
+
OpenPencil
@@ -10,7 +10,7 @@
- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia
+ English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia
@@ -102,7 +102,7 @@ bun run electron:dev
> **前置條件:** [Bun](https://bun.sh/) >= 1.0 以及 [Node.js](https://nodejs.org/) >= 18
-### Docker 部署
+### Docker
提供多種映像檔變體 — 選擇適合您需求的版本:
@@ -113,6 +113,7 @@ bun run electron:dev
| `openpencil-codex:latest` | — | + Codex CLI |
| `openpencil-opencode:latest` | — | + OpenCode CLI |
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
+| `openpencil-gemini:latest` | — | + Gemini CLI |
| `openpencil-full:latest` | ~1 GB | 所有 CLI 工具 |
**執行(僅 Web):**
@@ -167,6 +168,7 @@ docker build --target full -t openpencil-full .
| **Codex CLI** | 在 Agent 設定中連接(`Cmd+,`) |
| **OpenCode** | 在 Agent 設定中連接(`Cmd+,`) |
| **GitHub Copilot** | 執行 `copilot login` 後在 Agent 設定中連接(`Cmd+,`) |
+| **Gemini CLI** | 在 Agent 設定中連接(`Cmd+,`) |
**模型能力設定檔** — 自動依據模型層級調整提示詞、思考模式和逾時設定。完整層級模型(Claude)獲得完整提示詞;標準層級(GPT-4o、Gemini、DeepSeek)停用思考模式;基礎層級(MiniMax、Qwen、Llama、Mistral)獲得精簡巢狀 JSON 提示詞,確保最大可靠性。
@@ -223,22 +225,32 @@ docker build --target full -t openpencil-full .
## 專案結構
```text
-src/
- canvas/ CanvasKit/Skia 引擎 — 繪圖、同步、版面配置、參考線、鋼筆工具
- components/ React UI — 編輯器、面板、共用對話框、圖示
- services/ai/ AI 聊天、編排器、設計生成、串流處理
- services/figma/ Figma .fig 二進位檔案匯入管線
- services/codegen React+Tailwind 和 HTML+CSS 程式碼生成器
- stores/ Zustand — 畫布、文件、頁面、歷程、AI、設定
- variables/ 設計令牌解析與參照管理
- mcp/ 供外部 CLI 整合使用的 MCP 伺服器工具
- uikit/ 可重複使用元件套件系統
-server/
- api/ai/ Nitro API — 串流聊天、生成、驗證
- utils/ Claude CLI、OpenCode、Codex、Copilot 客戶端封裝
-electron/
- main.ts 視窗、Nitro 子處理序、原生選單、自動更新
- preload.ts IPC 橋接
+openpencil/
+├── apps/
+│ ├── web/ TanStack Start Web 應用程式
+│ │ ├── src/
+│ │ │ ├── canvas/ CanvasKit/Skia 引擎 — 繪圖、同步、版面配置
+│ │ │ ├── components/ React UI — 編輯器、面板、共用對話框、圖示
+│ │ │ ├── services/ai/ AI 聊天、編排器、設計生成、串流處理
+│ │ │ ├── stores/ Zustand — 畫布、文件、頁面、歷程、AI
+│ │ │ ├── mcp/ 供外部 CLI 整合使用的 MCP 伺服器工具
+│ │ │ ├── hooks/ 鍵盤快捷鍵、檔案拖放、Figma 貼上
+│ │ │ └── uikit/ 可重複使用元件套件系統
+│ │ └── server/
+│ │ ├── api/ai/ Nitro API — 串流聊天、生成、驗證
+│ │ └── utils/ Claude CLI、OpenCode、Codex、Copilot 客戶端封裝
+│ └── desktop/ Electron 桌面應用程式
+│ ├── main.ts 視窗、Nitro 子處理序、原生選單、自動更新
+│ ├── ipc-handlers.ts 原生檔案對話框、主題同步、偏好設定 IPC
+│ └── preload.ts IPC 橋接
+├── packages/
+│ ├── pen-types/ PenDocument 模型型別定義
+│ ├── pen-core/ 文件樹操作、版面配置引擎、變數
+│ ├── pen-codegen/ 程式碼生成器(React、HTML、Vue、Flutter...)
+│ ├── pen-figma/ Figma .fig 檔案解析器與轉換器
+│ ├── pen-renderer/ 獨立 CanvasKit/Skia 渲染器
+│ └── pen-sdk/ 整合 SDK(重新匯出所有套件)
+└── .githooks/ Pre-commit 版本號同步(從分支名稱)
```
## 鍵盤快捷鍵
@@ -266,6 +278,7 @@ bun --bun run dev # 開發伺服器(連接埠 3000)
bun --bun run build # 正式版建置
bun --bun run test # 執行測試(Vitest)
npx tsc --noEmit # 型別檢查
+bun run bump # 在所有 package.json 間同步版本號
bun run electron:dev # Electron 開發模式
bun run electron:build # Electron 封裝
```
@@ -275,10 +288,11 @@ bun run electron:build # Electron 封裝
歡迎貢獻!請查閱 [CLAUDE.md](./CLAUDE.md) 了解架構細節和程式碼風格。
1. Fork 並複製存放庫
-2. 建立分支:`git checkout -b feat/my-feature`
-3. 執行檢查:`npx tsc --noEmit && bun --bun run test`
-4. 使用 [Conventional Commits](https://www.conventionalcommits.org/) 提交:`feat(canvas): add rotation snapping`
-5. 向 `main` 分支發起 PR
+2. 設定版本同步:`git config core.hooksPath .githooks`
+3. 建立分支:`git checkout -b feat/my-feature`
+4. 執行檢查:`npx tsc --noEmit && bun --bun run test`
+5. 使用 [Conventional Commits](https://www.conventionalcommits.org/) 提交:`feat(canvas): add rotation snapping`
+6. 向 `main` 分支發起 PR
## 路線圖
@@ -290,6 +304,7 @@ bun run electron:build # Electron 封裝
- [x] Figma `.fig` 匯入
- [x] 布林運算(聯集、減去、交集)
- [x] 多模型能力設定檔
+- [x] Monorepo 重構,支援可重複使用套件
- [ ] 協同編輯
- [ ] 外掛程式系統
@@ -302,12 +317,11 @@ bun run electron:build # Electron 封裝
## 社群
-
+
加入我們的 Discord
— 提問、分享設計、提出功能建議。
-
## Star History
diff --git a/README.zh.md b/README.zh.md
index 48c6b314..2b477801 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -1,5 +1,5 @@
-
+
OpenPencil
@@ -55,7 +55,7 @@
### 🧠 多模型智能
-自动适配每个模型的能力。Claude 获得完整提示词和思考模式;GPT-4o/Gemini 关闭思考模式;小模型(MiniMax、通义千问、Llama)使用简化提示词以确保输出可靠性。
+自动适配每个模型的能力。Claude 获得完整提示词和思考模式;GPT-4o/Gemini 关闭思考模式;小模型(MiniMax、Qwen、Llama)使用简化提示词以确保输出可靠性。
@@ -102,9 +102,9 @@ bun run electron:dev
> **前置条件:** [Bun](https://bun.sh/) >= 1.0 以及 [Node.js](https://nodejs.org/) >= 18
-### Docker 部署
+### Docker
-提供多个镜像变体,按需选择:
+提供多个镜像变体 — 按需选择:
| 镜像 | 大小 | 包含 |
| --- | --- | --- |
@@ -113,6 +113,7 @@ bun run electron:dev
| `openpencil-codex:latest` | — | + Codex CLI |
| `openpencil-opencode:latest` | — | + OpenCode CLI |
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
+| `openpencil-gemini:latest` | — | + Gemini CLI |
| `openpencil-full:latest` | ~1 GB | 全部 CLI 工具 |
**运行(仅 Web):**
@@ -123,7 +124,7 @@ docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest
**运行 AI CLI(以 Claude Code 为例):**
-AI 聊天依赖 Claude CLI 的 OAuth 登录,使用 Docker volume 持久化登录状态:
+AI 聊天依赖 Claude CLI 的 OAuth 登录。使用 Docker volume 持久化登录会话:
```bash
# 第一步 — 登录(仅需一次)
@@ -167,8 +168,9 @@ docker build --target full -t openpencil-full .
| **Codex CLI** | 在 Agent 设置中连接(`Cmd+,`) |
| **OpenCode** | 在 Agent 设置中连接(`Cmd+,`) |
| **GitHub Copilot** | 运行 `copilot login` 后在 Agent 设置中连接(`Cmd+,`) |
+| **Gemini CLI** | 在 Agent 设置中连接(`Cmd+,`) |
-**模型能力配置** — 自动根据模型层级适配提示词、思考模式和超时时间。完整层级模型(Claude)获得完整提示词;标准层级模型(GPT-4o、Gemini、DeepSeek)关闭思考模式;基础层级模型(MiniMax、通义千问、Llama、Mistral)使用简化的嵌套 JSON 提示词以确保最大可靠性。
+**模型能力配置** — 自动根据模型层级适配提示词、思考模式和超时时间。完整层级模型(Claude)获得完整提示词;标准层级模型(GPT-4o、Gemini、DeepSeek)关闭思考模式;基础层级模型(MiniMax、Qwen、Llama、Mistral)使用简化的嵌套 JSON 提示词以确保最大可靠性。
**MCP 服务器**
- 内置 MCP 服务器 — 一键安装到 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
@@ -181,7 +183,6 @@ docker build --target full -t openpencil-full .
**代码生成**
- React + Tailwind CSS、HTML + CSS、CSS Variables
- Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native
-- 从设计令牌生成 CSS Variables
## 功能特性
@@ -224,22 +225,32 @@ docker build --target full -t openpencil-full .
## 项目结构
```text
-src/
- canvas/ CanvasKit/Skia 引擎 — 绘图、同步、布局、参考线、钢笔工具
- components/ React UI — 编辑器、面板、共享对话框、图标
- services/ai/ AI 聊天、编排器、设计生成、流式处理
- services/figma/ Figma .fig 二进制文件导入流水线
- services/codegen 多平台代码生成器(React、HTML、Vue、Svelte、Flutter、SwiftUI、Compose、React Native)
- stores/ Zustand — 画布、文档、页面、历史、AI、设置
- variables/ 设计令牌解析与引用管理
- mcp/ 供外部 CLI 集成使用的 MCP 服务器工具
- uikit/ 可复用组件套件系统
-server/
- api/ai/ Nitro API — 流式聊天、生成、验证
- utils/ Claude CLI、OpenCode、Codex、Copilot 客户端封装
-electron/
- main.ts 窗口、Nitro 子进程、原生菜单、自动更新
- preload.ts IPC 桥接
+openpencil/
+├── apps/
+│ ├── web/ TanStack Start Web 应用
+│ │ ├── src/
+│ │ │ ├── canvas/ CanvasKit/Skia 引擎 — 绘图、同步、布局
+│ │ │ ├── components/ React UI — 编辑器、面板、共享对话框、图标
+│ │ │ ├── services/ai/ AI 聊天、编排器、设计生成、流式处理
+│ │ │ ├── stores/ Zustand — 画布、文档、页面、历史、AI
+│ │ │ ├── mcp/ 供外部 CLI 集成使用的 MCP 服务器工具
+│ │ │ ├── hooks/ 键盘快捷键、文件拖放、Figma 粘贴
+│ │ │ └── uikit/ 可复用组件套件系统
+│ │ └── server/
+│ │ ├── api/ai/ Nitro API — 流式聊天、生成、验证
+│ │ └── utils/ Claude CLI、OpenCode、Codex、Copilot 客户端封装
+│ └── desktop/ Electron 桌面应用
+│ ├── main.ts 窗口、Nitro 子进程、原生菜单、自动更新
+│ ├── ipc-handlers.ts 原生文件对话框、主题同步、偏好设置 IPC
+│ └── preload.ts IPC 桥接
+├── packages/
+│ ├── pen-types/ PenDocument 模型类型定义
+│ ├── pen-core/ 文档树操作、布局引擎、变量
+│ ├── pen-codegen/ 代码生成器(React、HTML、Vue、Flutter 等)
+│ ├── pen-figma/ Figma .fig 文件解析与转换
+│ ├── pen-renderer/ 独立 CanvasKit/Skia 渲染器
+│ └── pen-sdk/ 聚合 SDK(重新导出所有包)
+└── .githooks/ 预提交钩子:从分支名同步版本号
```
## 键盘快捷键
@@ -267,6 +278,7 @@ bun --bun run dev # 开发服务器(端口 3000)
bun --bun run build # 生产构建
bun --bun run test # 运行测试(Vitest)
npx tsc --noEmit # 类型检查
+bun run bump # 同步所有 package.json 的版本号
bun run electron:dev # Electron 开发模式
bun run electron:build # Electron 打包
```
@@ -276,10 +288,11 @@ bun run electron:build # Electron 打包
欢迎贡献!请查阅 [CLAUDE.md](./CLAUDE.md) 了解架构细节和代码风格。
1. Fork 并克隆仓库
-2. 创建分支:`git checkout -b feat/my-feature`
-3. 运行检查:`npx tsc --noEmit && bun --bun run test`
-4. 使用 [Conventional Commits](https://www.conventionalcommits.org/) 提交:`feat(canvas): add rotation snapping`
-5. 向 `main` 分支发起 PR
+2. 设置版本同步:`git config core.hooksPath .githooks`
+3. 创建分支:`git checkout -b feat/my-feature`
+4. 运行检查:`npx tsc --noEmit && bun --bun run test`
+5. 使用 [Conventional Commits](https://www.conventionalcommits.org/) 提交:`feat(canvas): add rotation snapping`
+6. 向 `main` 分支发起 PR
## 路线图
@@ -291,6 +304,7 @@ bun run electron:build # Electron 打包
- [x] Figma `.fig` 导入
- [x] 布尔运算(合并、减去、相交)
- [x] 多模型能力配置
+- [x] Monorepo 重构与可复用包
- [ ] 协同编辑
- [ ] 插件系统
@@ -303,16 +317,11 @@ bun run electron:build # Electron 打包
## 社区
-
+
加入我们的 Discord
— 提问、分享设计、提出功能建议。
-**飞书交流群**
-
-
-
-
## Star History
diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md
new file mode 100644
index 00000000..cacb0697
--- /dev/null
+++ b/apps/desktop/CLAUDE.md
@@ -0,0 +1,37 @@
+# Desktop App
+
+Electron desktop app for macOS, Windows, and Linux.
+
+## Files
+
+- **`main.ts`** — Main process: window creation, Nitro server fork, preferences, `.op` file association handling (`open-file` event on macOS, CLI args + single-instance lock on Windows/Linux)
+- **`ipc-handlers.ts`** — IPC handler setup: native file dialogs (`dialog:openFile`, `dialog:saveFile`, `dialog:saveToPath`), theme sync for title bar overlay, renderer preferences (replaces origin-scoped localStorage), auto-updater IPC
+- **`preload.ts`** — Context bridge for renderer <-> main IPC (file dialogs, menu actions, updater state, `onOpenFile`/`readFile` for file association)
+- **`app-menu.ts`** — Native application menu configuration (File, Edit, View, Help)
+- **`auto-updater.ts`** — Auto-updater: checks GitHub Releases on startup and periodically
+- **`constants.ts`** — Electron-specific constants (port, window dimensions, platform padding)
+- **`logger.ts`** — Main process logging
+- **`dev.ts`** — Dev workflow: starts Vite -> waits for port 3000 -> compiles MCP -> compiles Electron -> launches Electron
+
+## Build Flow
+
+```text
+BUILD_TARGET=electron bun run build
+ -> bun run electron:compile (esbuild electron/ to out/desktop/)
+ -> bun run mcp:compile
+ -> npx electron-builder --config apps/desktop/electron-builder.yml
+```
+
+- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb), `.op` file association
+- **`build/`** — Platform icons (.icns, .ico, .png)
+- In production, Nitro server is forked as a child process on a random port; Electron loads `http://127.0.0.1:{port}/editor`
+
+## File Association
+
+`.op` files are registered as OpenPencil documents via `fileAssociations` in `electron-builder.yml`:
+- macOS: `open-file` app event handles double-click/drag
+- Windows/Linux: `requestSingleInstanceLock` + `second-instance` event forwards CLI args to existing window
+
+## Auto-updater
+
+Checks GitHub Releases on startup and every hour. `update-ready-banner.tsx` (in web app) shows download progress and "Restart & Install" prompt.
diff --git a/electron/app-menu.ts b/apps/desktop/app-menu.ts
similarity index 100%
rename from electron/app-menu.ts
rename to apps/desktop/app-menu.ts
diff --git a/electron/auto-updater.ts b/apps/desktop/auto-updater.ts
similarity index 100%
rename from electron/auto-updater.ts
rename to apps/desktop/auto-updater.ts
diff --git a/build/icon.icns b/apps/desktop/build/icon.icns
similarity index 100%
rename from build/icon.icns
rename to apps/desktop/build/icon.icns
diff --git a/build/icon.ico b/apps/desktop/build/icon.ico
similarity index 100%
rename from build/icon.ico
rename to apps/desktop/build/icon.ico
diff --git a/build/icon.png b/apps/desktop/build/icon.png
similarity index 100%
rename from build/icon.png
rename to apps/desktop/build/icon.png
diff --git a/electron/constants.ts b/apps/desktop/constants.ts
similarity index 100%
rename from electron/constants.ts
rename to apps/desktop/constants.ts
diff --git a/scripts/electron-dev.ts b/apps/desktop/dev.ts
similarity index 86%
rename from scripts/electron-dev.ts
rename to apps/desktop/dev.ts
index 928c6272..9ac910c2 100644
--- a/scripts/electron-dev.ts
+++ b/apps/desktop/dev.ts
@@ -11,7 +11,8 @@ import { spawn, execSync, type ChildProcess } from 'node:child_process'
import { build } from 'esbuild'
import { join } from 'node:path'
-const ROOT = join(import.meta.dirname, '..')
+const DESKTOP_DIR = import.meta.dirname
+const ROOT = join(DESKTOP_DIR, '..', '..')
const VITE_DEV_PORT = 3000
// ---------------------------------------------------------------------------
@@ -42,7 +43,7 @@ async function compileElectron(): Promise {
sourcemap: true,
external: ['electron'],
target: 'node20',
- outdir: join(ROOT, 'electron-dist'),
+ outdir: join(ROOT, 'out', 'desktop'),
outExtension: { '.js': '.cjs' },
format: 'cjs' as const,
}
@@ -50,11 +51,11 @@ async function compileElectron(): Promise {
await Promise.all([
build({
...common,
- entryPoints: [join(ROOT, 'electron', 'main.ts')],
+ entryPoints: [join(DESKTOP_DIR, 'main.ts')],
}),
build({
...common,
- entryPoints: [join(ROOT, 'electron', 'preload.ts')],
+ entryPoints: [join(DESKTOP_DIR, 'preload.ts')],
}),
])
@@ -77,7 +78,6 @@ async function main(): Promise {
// Ensure cleanup on exit
const cleanup = () => {
if (process.platform === 'win32' && vite.pid) {
- // SIGTERM is unreliable on Windows; use taskkill for proper tree-kill
try {
execSync(`taskkill /pid ${vite.pid} /T /F`, { stdio: 'ignore' })
} catch { /* ignore */ }
@@ -102,10 +102,11 @@ async function main(): Promise {
sourcemap: true,
target: 'node20',
format: 'cjs',
- entryPoints: [join(ROOT, 'src', 'mcp', 'server.ts')],
- outfile: join(ROOT, 'dist', 'mcp-server.cjs'),
- alias: { '@': join(ROOT, 'src') },
+ entryPoints: [join(ROOT, 'apps', 'web', 'src', 'mcp', 'server.ts')],
+ outfile: join(ROOT, 'out', 'mcp-server.cjs'),
+ alias: { '@': join(ROOT, 'apps', 'web', 'src') },
define: { 'import.meta.env': '{}' },
+ external: ['canvas', 'paper'],
})
console.log('[electron-dev] MCP server compiled')
@@ -114,7 +115,7 @@ async function main(): Promise {
// 4. Launch Electron
console.log('[electron-dev] Starting Electron...')
const electronBin = join(ROOT, 'node_modules', '.bin', 'electron')
- const electron = spawn(electronBin, [join(ROOT, 'electron-dist', 'main.cjs')], {
+ const electron = spawn(electronBin, [join(ROOT, 'out', 'desktop', 'main.cjs')], {
cwd: ROOT,
stdio: 'inherit',
env: { ...process.env },
diff --git a/electron-builder.yml b/apps/desktop/electron-builder.yml
similarity index 80%
rename from electron-builder.yml
rename to apps/desktop/electron-builder.yml
index 179f42cb..c0ec0db9 100644
--- a/electron-builder.yml
+++ b/apps/desktop/electron-builder.yml
@@ -3,24 +3,27 @@ productName: OpenPencil
copyright: Copyright (c) 2024-2026 OpenPencil contributors
directories:
- output: dist-electron
- buildResources: build
+ output: out/release
+ buildResources: apps/desktop/build
files:
- - electron-dist/**/*
+ - from: out/desktop
+ to: .
+ filter:
+ - "**/*"
- "!node_modules"
extraResources:
- - from: .output/server
+ - from: out/web/server
to: server
- - from: .output/public
+ - from: out/web/public
to: public
- - from: dist/mcp-server.cjs
+ - from: out/mcp-server.cjs
to: mcp-server.cjs
mac:
category: public.app-category.graphics-design
- icon: build/icon.icns
+ icon: apps/desktop/build/icon.icns
artifactName: "${productName}-${version}-${arch}-mac.${ext}"
target:
- dmg
@@ -33,7 +36,7 @@ dmg:
title: "${productName} ${version}"
win:
- icon: build/icon.ico
+ icon: apps/desktop/build/icon.ico
artifactName: "${productName}-${version}-${arch}-win.${ext}"
target:
- nsis
@@ -48,7 +51,7 @@ nsis:
createStartMenuShortcut: true
linux:
- icon: build/icon.png
+ icon: apps/desktop/build/icon.png
category: Graphics
artifactName: "${productName}-${version}-${arch}-linux.${ext}"
desktop:
@@ -72,6 +75,6 @@ fileAssociations:
description: OpenPencil Design File
mimeType: application/x-openpencil
role: Editor
- icon: build/icon
+ icon: apps/desktop/build/icon
asar: true
diff --git a/apps/desktop/ipc-handlers.ts b/apps/desktop/ipc-handlers.ts
new file mode 100644
index 00000000..88dc158c
--- /dev/null
+++ b/apps/desktop/ipc-handlers.ts
@@ -0,0 +1,163 @@
+import {
+ ipcMain,
+ dialog,
+ type BrowserWindow,
+} from 'electron'
+import { resolve, extname, sep } from 'node:path'
+import { readFile, writeFile } from 'node:fs/promises'
+import { app } from 'electron'
+
+import {
+ getUpdaterState,
+ checkForAppUpdates,
+ quitAndInstall,
+ getAutoUpdateEnabled,
+ setAutoUpdateEnabled,
+ setUpdaterState,
+ clearUpdateTimer,
+ startUpdateTimer,
+} from './auto-updater'
+import { getLogDir } from './logger'
+
+interface IpcDeps {
+ getMainWindow: () => BrowserWindow | null
+ getPendingFilePath: () => string | null
+ clearPendingFilePath: () => void
+ prefsCache: Record
+ schedulePrefsWrite: () => void
+ writeAppSettings: (patch: { autoUpdate?: boolean }) => Promise
+}
+
+export function setupIPC(deps: IpcDeps): void {
+ const { getMainWindow, getPendingFilePath, clearPendingFilePath, prefsCache, schedulePrefsWrite, writeAppSettings } = deps
+
+ ipcMain.handle('dialog:openFile', async () => {
+ const mainWindow = getMainWindow()
+ if (!mainWindow) return null
+ const result = await dialog.showOpenDialog(mainWindow, {
+ title: 'Open .op file',
+ filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }],
+ properties: ['openFile'],
+ })
+ if (result.canceled || result.filePaths.length === 0) return null
+ const filePath = result.filePaths[0]
+ const content = await readFile(filePath, 'utf-8')
+ return { filePath, content }
+ })
+
+ ipcMain.handle(
+ 'dialog:saveFile',
+ async (_event, payload: { content: string; defaultPath?: string }) => {
+ const mainWindow = getMainWindow()
+ if (!mainWindow) return null
+ const result = await dialog.showSaveDialog(mainWindow, {
+ title: 'Save .op file',
+ defaultPath: payload.defaultPath,
+ filters: [{ name: 'OpenPencil Files', extensions: ['op'] }],
+ })
+ if (result.canceled || !result.filePath) return null
+ await writeFile(result.filePath, payload.content, 'utf-8')
+ return result.filePath
+ },
+ )
+
+ ipcMain.handle(
+ 'dialog:saveToPath',
+ async (_event, payload: { filePath: string; content: string }) => {
+ const resolved = resolve(payload.filePath)
+ if (resolved.includes('\0')) {
+ throw new Error('Invalid file path')
+ }
+ const ext = extname(resolved).toLowerCase()
+ if (ext !== '.op' && ext !== '.pen') {
+ throw new Error('Only .op and .pen file extensions are allowed')
+ }
+ const allowedRoots = [app.getPath('home'), app.getPath('temp')]
+ const inAllowedDir = allowedRoots.some(
+ (root) => resolved === root || resolved.startsWith(root + sep),
+ )
+ if (!inAllowedDir) {
+ throw new Error('File path must be within the user home or temp directory')
+ }
+ await writeFile(resolved, payload.content, 'utf-8')
+ return resolved
+ },
+ )
+
+ ipcMain.handle('file:getPending', () => {
+ const filePath = getPendingFilePath()
+ if (filePath) {
+ clearPendingFilePath()
+ return filePath
+ }
+ return null
+ })
+
+ ipcMain.handle('file:read', async (_event, filePath: string) => {
+ const resolved = resolve(filePath)
+ const ext = extname(resolved).toLowerCase()
+ if (ext !== '.op' && ext !== '.pen') return null
+ try {
+ const content = await readFile(resolved, 'utf-8')
+ return { filePath: resolved, content }
+ } catch {
+ return null
+ }
+ })
+
+ // Theme sync for Windows/Linux title bar overlay
+ ipcMain.handle(
+ 'theme:set',
+ (_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => {
+ const mainWindow = getMainWindow()
+ if (!mainWindow || mainWindow.isDestroyed()) return
+ const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'
+ if (!isWinOrLinux) return
+ const isLinux = process.platform === 'linux'
+ const fallbackBg = theme === 'dark' ? '#111' : '#fff'
+ const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46'
+ mainWindow.setTitleBarOverlay({
+ color: isLinux ? (colors?.bg || fallbackBg) : 'rgba(0,0,0,0)',
+ symbolColor: colors?.fg || fallbackFg,
+ })
+ },
+ )
+
+ // Renderer preferences
+ ipcMain.handle('prefs:getAll', () => ({ ...prefsCache }))
+
+ ipcMain.handle('prefs:set', (_event, key: string, value: string) => {
+ prefsCache[key] = value
+ schedulePrefsWrite()
+ })
+
+ ipcMain.handle('prefs:remove', (_event, key: string) => {
+ delete prefsCache[key]
+ schedulePrefsWrite()
+ })
+
+ ipcMain.handle('log:getDir', () => getLogDir())
+
+ // Updater IPC
+ ipcMain.handle('updater:getState', () => getUpdaterState())
+ ipcMain.handle('updater:checkForUpdates', async () => {
+ await checkForAppUpdates(true)
+ return getUpdaterState()
+ })
+ ipcMain.handle('updater:quitAndInstall', () => quitAndInstall())
+ ipcMain.handle('updater:getAutoCheck', () => getAutoUpdateEnabled())
+
+ ipcMain.handle('updater:setAutoCheck', async (_event, enabled: boolean) => {
+ setAutoUpdateEnabled(enabled)
+ await writeAppSettings({ autoUpdate: enabled })
+
+ if (enabled) {
+ startUpdateTimer()
+ setUpdaterState({ status: 'idle' })
+ } else {
+ clearUpdateTimer()
+ setUpdaterState({ status: 'disabled' })
+ }
+ return enabled
+ })
+}
diff --git a/electron/logger.ts b/apps/desktop/logger.ts
similarity index 100%
rename from electron/logger.ts
rename to apps/desktop/logger.ts
diff --git a/electron/main.ts b/apps/desktop/main.ts
similarity index 80%
rename from electron/main.ts
rename to apps/desktop/main.ts
index 9c5b689d..77ddc3e2 100644
--- a/electron/main.ts
+++ b/apps/desktop/main.ts
@@ -8,7 +8,7 @@ import {
import { execSync } from 'node:child_process'
import { fork, type ChildProcess } from 'node:child_process'
import { createServer } from 'node:net'
-import { join, resolve, extname, sep } from 'node:path'
+import { join, extname } from 'node:path'
import { homedir } from 'node:os'
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
@@ -33,16 +33,12 @@ import {
import {
setupAutoUpdater,
broadcastUpdaterState,
- getUpdaterState,
setUpdaterState,
- checkForAppUpdates,
clearUpdateTimer,
- startUpdateTimer,
- quitAndInstall,
- getAutoUpdateEnabled,
setAutoUpdateEnabled,
} from './auto-updater'
-import { initLogger, log, getLogDir } from './logger'
+import { initLogger, log } from './logger'
+import { setupIPC } from './ipc-handlers'
let mainWindow: BrowserWindow | null = null
let nitroProcess: ChildProcess | null = null
@@ -448,143 +444,91 @@ function createWindow(): void {
mainWindow.webContents.openDevTools({ mode: 'detach' })
}
+ // ---------------------------------------------------------------------------
+ // Unsaved-changes close confirmation
+ // ---------------------------------------------------------------------------
+ let forceClose = false
+
+ mainWindow.on('close', (event) => {
+ if (forceClose || !mainWindow || mainWindow.isDestroyed()) return
+
+ // preventDefault must be called synchronously — check dirty state after
+ event.preventDefault()
+
+ const win = mainWindow
+ win.webContents
+ .executeJavaScript(
+ '({ dirty: window.__documentIsDirty === true,' +
+ ' message: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmMessage") : "",' +
+ ' detail: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmDetail") : "",' +
+ ' save: typeof window.__i18nT === "function" ? window.__i18nT("common.save") : "",' +
+ ' dontSave: typeof window.__i18nT === "function" ? window.__i18nT("topbar.dontSave") : "",' +
+ ' cancel: typeof window.__i18nT === "function" ? window.__i18nT("common.cancel") : "" })',
+ )
+ .then((result: { dirty: boolean; message: string; detail: string; save: string; dontSave: string; cancel: string }) => {
+ if (!result.dirty) {
+ // Not dirty — allow close
+ forceClose = true
+ win.close()
+ return
+ }
+
+ // Show native save dialog with i18n strings
+ return dialog
+ .showMessageBox(win, {
+ type: 'question',
+ buttons: [
+ result.save || 'Save',
+ result.dontSave || "Don't Save",
+ result.cancel || 'Cancel',
+ ],
+ defaultId: 0,
+ cancelId: 2,
+ message: result.message || 'Do you want to save changes before closing?',
+ detail: result.detail || 'Your changes will be lost if you don\'t save them.',
+ })
+ .then(({ response }) => {
+ if (response === 0) {
+ // Save — tell renderer to save then confirm close
+ win.webContents.send('menu:action', 'save-and-close')
+ } else if (response === 1) {
+ // Don't Save — force close
+ forceClose = true
+ win.close()
+ }
+ // Cancel (response === 2) — do nothing, window stays open
+ })
+ })
+ .catch(() => {
+ // Page not loaded or crashed — allow close
+ forceClose = true
+ win.close()
+ })
+ })
+
+ // Renderer confirms save completed → force close
+ ipcMain.on('window:confirmClose', () => {
+ forceClose = true
+ mainWindow?.close()
+ })
+
mainWindow.on('closed', () => {
mainWindow = null
})
}
// ---------------------------------------------------------------------------
-// IPC: native file dialogs & updater
+// IPC: native file dialogs & updater (extracted to ipc-handlers.ts)
// ---------------------------------------------------------------------------
-function setupIPC(): void {
- ipcMain.handle('dialog:openFile', async () => {
- if (!mainWindow) return null
- const result = await dialog.showOpenDialog(mainWindow, {
- title: 'Open .op file',
- filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }],
- properties: ['openFile'],
- })
- if (result.canceled || result.filePaths.length === 0) return null
- const filePath = result.filePaths[0]
- const content = await readFile(filePath, 'utf-8')
- return { filePath, content }
- })
-
- ipcMain.handle(
- 'dialog:saveFile',
- async (_event, payload: { content: string; defaultPath?: string }) => {
- if (!mainWindow) return null
- const result = await dialog.showSaveDialog(mainWindow, {
- title: 'Save .op file',
- defaultPath: payload.defaultPath,
- filters: [{ name: 'OpenPencil Files', extensions: ['op'] }],
- })
- if (result.canceled || !result.filePath) return null
- await writeFile(result.filePath, payload.content, 'utf-8')
- return result.filePath
- },
- )
-
- ipcMain.handle(
- 'dialog:saveToPath',
- async (_event, payload: { filePath: string; content: string }) => {
- const resolved = resolve(payload.filePath)
- if (resolved.includes('\0')) {
- throw new Error('Invalid file path')
- }
- const ext = extname(resolved).toLowerCase()
- if (ext !== '.op' && ext !== '.pen') {
- throw new Error('Only .op and .pen file extensions are allowed')
- }
- // Directory allowlist: only allow writes under user home or OS temp
- const allowedRoots = [app.getPath('home'), app.getPath('temp')]
- const inAllowedDir = allowedRoots.some(
- (root) => resolved === root || resolved.startsWith(root + sep),
- )
- if (!inAllowedDir) {
- throw new Error('File path must be within the user home or temp directory')
- }
- await writeFile(resolved, payload.content, 'utf-8')
- return resolved
- },
- )
-
- ipcMain.handle('file:getPending', () => {
- if (pendingFilePath) {
- const filePath = pendingFilePath
- pendingFilePath = null
- return filePath
- }
- return null
- })
-
- ipcMain.handle('file:read', async (_event, filePath: string) => {
- const resolved = resolve(filePath)
- const ext = extname(resolved).toLowerCase()
- if (ext !== '.op' && ext !== '.pen') return null
- try {
- const content = await readFile(resolved, 'utf-8')
- return { filePath: resolved, content }
- } catch {
- return null
- }
- })
-
- // Theme sync for Windows/Linux title bar overlay
- ipcMain.handle(
- 'theme:set',
- (_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => {
- if (!mainWindow || mainWindow.isDestroyed()) return
- const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'
- if (!isWinOrLinux) return
- const isLinux = process.platform === 'linux'
- const fallbackBg = theme === 'dark' ? '#111' : '#fff'
- const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46'
- mainWindow.setTitleBarOverlay({
- // Windows supports transparent overlay; Linux uses actual CSS card color
- color: isLinux ? (colors?.bg || fallbackBg) : 'rgba(0,0,0,0)',
- symbolColor: colors?.fg || fallbackFg,
- })
- },
- )
-
- // Generic renderer preferences (replaces localStorage which is origin-scoped
- // and lost when Nitro server restarts on a different random port)
- ipcMain.handle('prefs:getAll', () => ({ ...prefsCache }))
-
- ipcMain.handle('prefs:set', (_event, key: string, value: string) => {
- prefsCache[key] = value
- schedulePrefsWrite()
- })
-
- ipcMain.handle('prefs:remove', (_event, key: string) => {
- delete prefsCache[key]
- schedulePrefsWrite()
- })
-
- ipcMain.handle('log:getDir', () => getLogDir())
-
- ipcMain.handle('updater:getState', () => getUpdaterState())
- ipcMain.handle('updater:checkForUpdates', async () => {
- await checkForAppUpdates(true)
- return getUpdaterState()
- })
- ipcMain.handle('updater:quitAndInstall', () => quitAndInstall())
- ipcMain.handle('updater:getAutoCheck', () => getAutoUpdateEnabled())
-
- ipcMain.handle('updater:setAutoCheck', async (_event, enabled: boolean) => {
- setAutoUpdateEnabled(enabled)
- await writeAppSettings({ autoUpdate: enabled })
-
- if (enabled) {
- startUpdateTimer()
- setUpdaterState({ status: 'idle' })
- } else {
- clearUpdateTimer()
- setUpdaterState({ status: 'disabled' })
- }
- return enabled
+function initIPC(): void {
+ setupIPC({
+ getMainWindow: () => mainWindow,
+ getPendingFilePath: () => pendingFilePath,
+ clearPendingFilePath: () => { pendingFilePath = null },
+ prefsCache,
+ schedulePrefsWrite,
+ writeAppSettings,
})
}
@@ -651,7 +595,7 @@ app.on('ready', async () => {
await initLogger(app.getPath('userData'))
fixPath()
await loadPrefs()
- setupIPC()
+ initIPC()
buildAppMenu()
if (!isDev) {
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
new file mode 100644
index 00000000..3a165265
--- /dev/null
+++ b/apps/desktop/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@zseven-w/desktop",
+ "version": "0.5.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/electron/preload.ts b/apps/desktop/preload.ts
similarity index 97%
rename from electron/preload.ts
rename to apps/desktop/preload.ts
index 27487ca4..e95a4c00 100644
--- a/electron/preload.ts
+++ b/apps/desktop/preload.ts
@@ -36,6 +36,7 @@ export interface ElectronAPI {
getPreferences: () => Promise>
setPreference: (key: string, value: string) => Promise
removePreference: (key: string) => Promise
+ confirmClose: () => void
updater: {
getState: () => Promise
checkForUpdates: () => Promise
@@ -90,6 +91,8 @@ const api: ElectronAPI = {
getPendingFile: () => ipcRenderer.invoke('file:getPending'),
+ confirmClose: () => ipcRenderer.send('window:confirmClose'),
+
getLogDir: () => ipcRenderer.invoke('log:getDir'),
updater: {
diff --git a/electron/tsconfig.json b/apps/desktop/tsconfig.json
similarity index 89%
rename from electron/tsconfig.json
rename to apps/desktop/tsconfig.json
index 0c951eab..0fdbff59 100644
--- a/electron/tsconfig.json
+++ b/apps/desktop/tsconfig.json
@@ -3,7 +3,7 @@
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
- "outDir": "../electron-dist",
+ "outDir": "../../out/desktop",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md
new file mode 100644
index 00000000..9c2111de
--- /dev/null
+++ b/apps/web/CLAUDE.md
@@ -0,0 +1,116 @@
+# Web App
+
+TanStack Start full-stack React app (Vite + Nitro). Routes in `src/routes/`, auto-generated tree in `src/routeTree.gen.ts` (do not edit).
+
+- `/` — Landing page
+- `/editor` — Main design editor
+
+## Canvas Engine (`src/canvas/`)
+
+14 files + `skia/` subdir with 14 files.
+
+### CanvasKit/Skia Architecture
+
+- **GPU-accelerated WASM rendering** — CanvasKit (Skia compiled to WASM) renders all canvas content via WebGL surface
+- **SkiaEngine class** (`skia-engine.ts`) is the core: owns the render loop, viewport transforms, node flattening, and `SpatialIndex` for hit testing
+- **Dirty-flag rendering** — `markDirty()` schedules a `requestAnimationFrame` redraw; no continuous rendering loop
+- **Node flattening** — `syncFromDocument()` walks the PenDocument tree, resolves auto-layout positions via layout engine, and produces flat `RenderNode[]` with absolute coordinates
+- **SpatialIndex** (`skia-hit-test.ts`) — R-tree backed spatial queries for `hitTest()` (click) and `searchRect()` (marquee selection)
+- **Coordinate conversion** — `screenToScene()` / `sceneToScreen()` in `skia-viewport.ts` handle viewport ↔ scene transforms
+- **Event handling** — mouse/keyboard events managed by `SkiaInteractionManager` (`skia-interaction.ts`); hit testing for resize/rotate/arc handles in `skia-hit-handlers.ts`; `skia-canvas.tsx` is the React component (lifecycle, sync, rendering)
+- **Parent-child transforms** — nodes are flattened to absolute coordinates; transforms propagate to descendants during drag/scale/rotate
+
+### `skia/` Files
+
+- `skia-canvas.tsx` — React component: lifecycle, sync effects, wheel zoom, text editing overlay; delegates interaction to `SkiaInteractionManager`
+- `skia-interaction.ts` — `SkiaInteractionManager` class: all mouse/keyboard interaction state and handlers (select, drag, resize, rotate, draw, marquee, pen tool, arc editing, hover cursor)
+- `skia-hit-handlers.ts` — Hit test functions: `hitTestHandle` (resize), `hitTestRotation` (rotation zone), `hitTestArcHandle` (ellipse arc)
+- `skia-engine.ts` — Core rendering engine: `SkiaEngine` class, `syncFromDocument()`, viewport, node flattening, zoom/pan, dirty-flag loop
+- `skia-renderer.ts` — GPU draw calls: shapes, text, paths, images, selection handles, guides, agent indicators
+- `skia-init.ts` — CanvasKit WASM loader with CDN fallback
+- `skia-hit-test.ts` — `SpatialIndex` R-tree for spatial queries
+- `skia-viewport.ts` — Viewport math
+- `skia-paint-utils.ts` — Color parsing, gradient creation, text line wrapping
+- `skia-path-utils.ts` — SVG path to CanvasKit Path conversion
+- `skia-image-loader.ts` — Async image loading and caching
+- `skia-overlays.ts` — Selection overlays, hover highlights, dimension labels
+- `skia-pen-tool.ts` — Pen tool: anchor points, control handles, path building
+- `skia-font-manager.ts` — Font management
+
+### Shared Canvas Modules
+
+- `canvas-sync-lock.ts` — Prevents circular sync loops
+- `canvas-sync-utils.ts` — `forcePageResync()` utility
+- `canvas-constants.ts` — Default colors, zoom limits, stroke widths
+- `canvas-node-creator.ts` — `createNodeForTool`, `isDrawingTool`
+- `canvas-layout-engine.ts` — Auto-layout (delegates to `@zseven-w/pen-core`)
+- `canvas-text-measure.ts` — Text width/height estimation, CJK detection
+- `font-utils.ts`, `node-helpers.ts` — Re-exports from pen-core
+- `insertion-indicator.ts`, `selection-context.ts`, `agent-indicator.ts`, `use-layout-indicator.ts`, `skia-engine-ref.ts`
+
+## Zustand Stores (`src/stores/`)
+
+- `canvas-store.ts` — UI/tool/selection/viewport/clipboard/interaction state, `activePageId`
+- `document-store.ts` — PenDocument tree CRUD, variable CRUD, component management (all with history)
+- `document-store-pages.ts` — Page actions: add, remove, rename, reorder, duplicate
+- `document-tree-utils.ts` — Re-exports tree helpers and clone utilities from `@zseven-w/pen-core`
+- `history-store.ts` — Undo/redo (max 300 states), batch mode
+- `ai-store.ts` — Chat messages, streaming state, model selection
+- `agent-settings-store.ts` — AI provider config, MCP CLI integrations, localStorage persistence
+- `uikit-store.ts` — UIKit management
+- `theme-preset-store.ts` — Theme preset management
+
+## Components (`src/components/`)
+
+- **`editor/`** — Editor UI: editor-layout, toolbar, boolean-toolbar, tool-button, shape-tool-dropdown, top-bar, status-bar, page-tabs, update-ready-banner
+- **`panels/`** — 32 files: layer panel, property panel, fill/stroke/corner/size/text/effects/export/layout/appearance sections, AI chat panel, code panel, component browser, variables panel
+- **`shared/`** — Reusable UI: ColorPicker, NumberInput, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker, FigmaImportDialog, FontPicker, LanguageSelector
+- **`icons/`** — Provider/brand logos
+- **`ui/`** — shadcn/ui primitives
+
+## AI Services (`src/services/ai/`)
+
+35 files + `role-definitions/` + `design-principles/` subdirs:
+- `ai-service.ts` — Main AI chat API wrapper, model negotiation, provider selection
+- `ai-prompts.ts` — System prompts for design generation
+- `ai-types.ts` — ChatMessage, ChatAttachment, AIDesignRequest, OrchestratorPlan
+- `model-profiles.ts` — Adapts thinking mode, effort, timeouts per model tier
+- `design-generator.ts` — Top-level `generateDesign`/`generateDesignModification`
+- `design-parser.ts` — JSON/JSONL parsing
+- `design-canvas-ops.ts` — Canvas mutation operations
+- `design-node-sanitization.ts` — Node merging (re-exports `deepCloneNode` from pen-core)
+- `design-validation.ts` / `design-pre-validation.ts` / `design-validation-fixes.ts` — Post-generation validation
+- `icon-resolver.ts` — Auto-resolves icon names to Lucide SVG paths
+- `orchestrator.ts` / `orchestrator-sub-agent.ts` / `orchestrator-prompts.ts` — Spatial decomposition orchestrator
+- `context-optimizer.ts` — Chat history trimming
+
+## Hooks (`src/hooks/`)
+
+- `use-keyboard-shortcuts.ts` — Global keyboard: tools, clipboard, undo/redo, save, z-order, boolean ops
+- `use-electron-menu.ts` — Electron native menu IPC listener
+- `use-figma-paste.ts` — Figma clipboard paste
+- `use-file-drop.ts` — File drag-and-drop
+- `use-mcp-sync.ts` — MCP live canvas sync
+- `use-system-fonts.ts` — System font detection
+
+## MCP Server (`src/mcp/`)
+
+- `server.ts` — MCP server entry point, tool registration (stdio + HTTP modes)
+- `document-manager.ts` — Document read/write/cache; live canvas sync via Nitro API
+- `tools/` — Core (open-document, batch-get, get-selection, batch-design, node-crud), Layout (snapshot-layout, find-empty-space, import-svg), Variables, Pages, Layered design (design-prompt, design-skeleton, design-content, design-refine)
+- `utils/` — `id.ts`, `node-operations.ts` (re-exports `cloneNodeWithNewIds` from pen-core), `sanitize.ts`, `svg-node-parser.ts`
+
+## UIKit (`src/uikit/`)
+
+- `built-in-registry.ts` — Default built-in UIKit
+- `kit-import-export.ts` — Import/export UIKits from .pen files
+- `kit-utils.ts` — Extract components, find reusable nodes (re-exports `deepCloneNode` from pen-core)
+
+## Utilities (`src/utils/`)
+
+File operations: save/open .pen, export PNG/SVG, node clone (re-exports `cloneNodesWithNewIds` from pen-core), pen file normalization, SVG parser, syntax highlight, boolean operations, `app-storage.ts`, `arc-path.ts`, `theme-preset-io.ts`, `id.ts`
+
+## Server API (`server/`)
+
+- **`api/ai/`** — Nitro API (11 files): streaming chat, generation, agent connection, validation, MCP install, icon resolution, image generation/search. Supports Anthropic API key or Claude Agent SDK (local OAuth)
+- **`utils/`** — Server utilities: Claude CLI resolver, OpenCode/Codex/Copilot clients, MCP server manager, sync state, server logger
diff --git a/components.json b/apps/web/components.json
similarity index 100%
rename from components.json
rename to apps/web/components.json
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 00000000..b76e1e0b
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@zseven-w/web",
+ "version": "0.5.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/public/canvaskit/canvaskit.wasm b/apps/web/public/canvaskit/canvaskit.wasm
similarity index 100%
rename from public/canvaskit/canvaskit.wasm
rename to apps/web/public/canvaskit/canvaskit.wasm
diff --git a/public/fonts/dm-sans-400.woff2 b/apps/web/public/fonts/dm-sans-400.woff2
similarity index 100%
rename from public/fonts/dm-sans-400.woff2
rename to apps/web/public/fonts/dm-sans-400.woff2
diff --git a/public/fonts/dm-sans-500.woff2 b/apps/web/public/fonts/dm-sans-500.woff2
similarity index 100%
rename from public/fonts/dm-sans-500.woff2
rename to apps/web/public/fonts/dm-sans-500.woff2
diff --git a/public/fonts/dm-sans-700.woff2 b/apps/web/public/fonts/dm-sans-700.woff2
similarity index 100%
rename from public/fonts/dm-sans-700.woff2
rename to apps/web/public/fonts/dm-sans-700.woff2
diff --git a/public/fonts/inter-400.woff2 b/apps/web/public/fonts/inter-400.woff2
similarity index 100%
rename from public/fonts/inter-400.woff2
rename to apps/web/public/fonts/inter-400.woff2
diff --git a/public/fonts/inter-500.woff2 b/apps/web/public/fonts/inter-500.woff2
similarity index 100%
rename from public/fonts/inter-500.woff2
rename to apps/web/public/fonts/inter-500.woff2
diff --git a/public/fonts/inter-600.woff2 b/apps/web/public/fonts/inter-600.woff2
similarity index 100%
rename from public/fonts/inter-600.woff2
rename to apps/web/public/fonts/inter-600.woff2
diff --git a/public/fonts/inter-700.woff2 b/apps/web/public/fonts/inter-700.woff2
similarity index 100%
rename from public/fonts/inter-700.woff2
rename to apps/web/public/fonts/inter-700.woff2
diff --git a/public/fonts/inter-ext-400.woff2 b/apps/web/public/fonts/inter-ext-400.woff2
similarity index 100%
rename from public/fonts/inter-ext-400.woff2
rename to apps/web/public/fonts/inter-ext-400.woff2
diff --git a/public/fonts/inter-ext-500.woff2 b/apps/web/public/fonts/inter-ext-500.woff2
similarity index 100%
rename from public/fonts/inter-ext-500.woff2
rename to apps/web/public/fonts/inter-ext-500.woff2
diff --git a/public/fonts/inter-ext-600.woff2 b/apps/web/public/fonts/inter-ext-600.woff2
similarity index 100%
rename from public/fonts/inter-ext-600.woff2
rename to apps/web/public/fonts/inter-ext-600.woff2
diff --git a/public/fonts/inter-ext-700.woff2 b/apps/web/public/fonts/inter-ext-700.woff2
similarity index 100%
rename from public/fonts/inter-ext-700.woff2
rename to apps/web/public/fonts/inter-ext-700.woff2
diff --git a/public/fonts/inter-latin.woff2 b/apps/web/public/fonts/inter-latin.woff2
similarity index 100%
rename from public/fonts/inter-latin.woff2
rename to apps/web/public/fonts/inter-latin.woff2
diff --git a/public/fonts/lato-400.woff2 b/apps/web/public/fonts/lato-400.woff2
similarity index 100%
rename from public/fonts/lato-400.woff2
rename to apps/web/public/fonts/lato-400.woff2
diff --git a/public/fonts/lato-700.woff2 b/apps/web/public/fonts/lato-700.woff2
similarity index 100%
rename from public/fonts/lato-700.woff2
rename to apps/web/public/fonts/lato-700.woff2
diff --git a/public/fonts/montserrat-400.woff2 b/apps/web/public/fonts/montserrat-400.woff2
similarity index 100%
rename from public/fonts/montserrat-400.woff2
rename to apps/web/public/fonts/montserrat-400.woff2
diff --git a/public/fonts/montserrat-500.woff2 b/apps/web/public/fonts/montserrat-500.woff2
similarity index 100%
rename from public/fonts/montserrat-500.woff2
rename to apps/web/public/fonts/montserrat-500.woff2
diff --git a/public/fonts/montserrat-600.woff2 b/apps/web/public/fonts/montserrat-600.woff2
similarity index 100%
rename from public/fonts/montserrat-600.woff2
rename to apps/web/public/fonts/montserrat-600.woff2
diff --git a/public/fonts/montserrat-700.woff2 b/apps/web/public/fonts/montserrat-700.woff2
similarity index 100%
rename from public/fonts/montserrat-700.woff2
rename to apps/web/public/fonts/montserrat-700.woff2
diff --git a/public/fonts/noto-sans-sc-400.woff2 b/apps/web/public/fonts/noto-sans-sc-400.woff2
similarity index 100%
rename from public/fonts/noto-sans-sc-400.woff2
rename to apps/web/public/fonts/noto-sans-sc-400.woff2
diff --git a/public/fonts/noto-sans-sc-700.woff2 b/apps/web/public/fonts/noto-sans-sc-700.woff2
similarity index 100%
rename from public/fonts/noto-sans-sc-700.woff2
rename to apps/web/public/fonts/noto-sans-sc-700.woff2
diff --git a/public/fonts/noto-sans-sc-latin-400.woff2 b/apps/web/public/fonts/noto-sans-sc-latin-400.woff2
similarity index 100%
rename from public/fonts/noto-sans-sc-latin-400.woff2
rename to apps/web/public/fonts/noto-sans-sc-latin-400.woff2
diff --git a/public/fonts/noto-sans-sc-latin-700.woff2 b/apps/web/public/fonts/noto-sans-sc-latin-700.woff2
similarity index 100%
rename from public/fonts/noto-sans-sc-latin-700.woff2
rename to apps/web/public/fonts/noto-sans-sc-latin-700.woff2
diff --git a/public/fonts/nunito-400.woff2 b/apps/web/public/fonts/nunito-400.woff2
similarity index 100%
rename from public/fonts/nunito-400.woff2
rename to apps/web/public/fonts/nunito-400.woff2
diff --git a/public/fonts/nunito-600.woff2 b/apps/web/public/fonts/nunito-600.woff2
similarity index 100%
rename from public/fonts/nunito-600.woff2
rename to apps/web/public/fonts/nunito-600.woff2
diff --git a/public/fonts/nunito-700.woff2 b/apps/web/public/fonts/nunito-700.woff2
similarity index 100%
rename from public/fonts/nunito-700.woff2
rename to apps/web/public/fonts/nunito-700.woff2
diff --git a/public/fonts/open-sans-400.woff2 b/apps/web/public/fonts/open-sans-400.woff2
similarity index 100%
rename from public/fonts/open-sans-400.woff2
rename to apps/web/public/fonts/open-sans-400.woff2
diff --git a/public/fonts/open-sans-600.woff2 b/apps/web/public/fonts/open-sans-600.woff2
similarity index 100%
rename from public/fonts/open-sans-600.woff2
rename to apps/web/public/fonts/open-sans-600.woff2
diff --git a/public/fonts/open-sans-700.woff2 b/apps/web/public/fonts/open-sans-700.woff2
similarity index 100%
rename from public/fonts/open-sans-700.woff2
rename to apps/web/public/fonts/open-sans-700.woff2
diff --git a/public/fonts/playfair-display-400.woff2 b/apps/web/public/fonts/playfair-display-400.woff2
similarity index 100%
rename from public/fonts/playfair-display-400.woff2
rename to apps/web/public/fonts/playfair-display-400.woff2
diff --git a/public/fonts/playfair-display-700.woff2 b/apps/web/public/fonts/playfair-display-700.woff2
similarity index 100%
rename from public/fonts/playfair-display-700.woff2
rename to apps/web/public/fonts/playfair-display-700.woff2
diff --git a/public/fonts/poppins-400.woff2 b/apps/web/public/fonts/poppins-400.woff2
similarity index 100%
rename from public/fonts/poppins-400.woff2
rename to apps/web/public/fonts/poppins-400.woff2
diff --git a/public/fonts/poppins-500.woff2 b/apps/web/public/fonts/poppins-500.woff2
similarity index 100%
rename from public/fonts/poppins-500.woff2
rename to apps/web/public/fonts/poppins-500.woff2
diff --git a/public/fonts/poppins-600.woff2 b/apps/web/public/fonts/poppins-600.woff2
similarity index 100%
rename from public/fonts/poppins-600.woff2
rename to apps/web/public/fonts/poppins-600.woff2
diff --git a/public/fonts/poppins-700.woff2 b/apps/web/public/fonts/poppins-700.woff2
similarity index 100%
rename from public/fonts/poppins-700.woff2
rename to apps/web/public/fonts/poppins-700.woff2
diff --git a/public/fonts/raleway-400.woff2 b/apps/web/public/fonts/raleway-400.woff2
similarity index 100%
rename from public/fonts/raleway-400.woff2
rename to apps/web/public/fonts/raleway-400.woff2
diff --git a/public/fonts/raleway-500.woff2 b/apps/web/public/fonts/raleway-500.woff2
similarity index 100%
rename from public/fonts/raleway-500.woff2
rename to apps/web/public/fonts/raleway-500.woff2
diff --git a/public/fonts/raleway-600.woff2 b/apps/web/public/fonts/raleway-600.woff2
similarity index 100%
rename from public/fonts/raleway-600.woff2
rename to apps/web/public/fonts/raleway-600.woff2
diff --git a/public/fonts/raleway-700.woff2 b/apps/web/public/fonts/raleway-700.woff2
similarity index 100%
rename from public/fonts/raleway-700.woff2
rename to apps/web/public/fonts/raleway-700.woff2
diff --git a/public/fonts/roboto-400.woff2 b/apps/web/public/fonts/roboto-400.woff2
similarity index 100%
rename from public/fonts/roboto-400.woff2
rename to apps/web/public/fonts/roboto-400.woff2
diff --git a/public/fonts/roboto-500.woff2 b/apps/web/public/fonts/roboto-500.woff2
similarity index 100%
rename from public/fonts/roboto-500.woff2
rename to apps/web/public/fonts/roboto-500.woff2
diff --git a/public/fonts/roboto-700.woff2 b/apps/web/public/fonts/roboto-700.woff2
similarity index 100%
rename from public/fonts/roboto-700.woff2
rename to apps/web/public/fonts/roboto-700.woff2
diff --git a/public/fonts/source-sans-3-400.woff2 b/apps/web/public/fonts/source-sans-3-400.woff2
similarity index 100%
rename from public/fonts/source-sans-3-400.woff2
rename to apps/web/public/fonts/source-sans-3-400.woff2
diff --git a/public/fonts/source-sans-3-600.woff2 b/apps/web/public/fonts/source-sans-3-600.woff2
similarity index 100%
rename from public/fonts/source-sans-3-600.woff2
rename to apps/web/public/fonts/source-sans-3-600.woff2
diff --git a/public/fonts/source-sans-3-700.woff2 b/apps/web/public/fonts/source-sans-3-700.woff2
similarity index 100%
rename from public/fonts/source-sans-3-700.woff2
rename to apps/web/public/fonts/source-sans-3-700.woff2
diff --git a/electron/icon.png b/apps/web/public/icon.png
similarity index 100%
rename from electron/icon.png
rename to apps/web/public/icon.png
diff --git a/public/logo-claude.svg b/apps/web/public/logo-claude.svg
similarity index 100%
rename from public/logo-claude.svg
rename to apps/web/public/logo-claude.svg
diff --git a/public/logo-discord.svg b/apps/web/public/logo-discord.svg
similarity index 100%
rename from public/logo-discord.svg
rename to apps/web/public/logo-discord.svg
diff --git a/public/logo-openai.svg b/apps/web/public/logo-openai.svg
similarity index 100%
rename from public/logo-openai.svg
rename to apps/web/public/logo-openai.svg
diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json
new file mode 100644
index 00000000..116271b2
--- /dev/null
+++ b/apps/web/public/manifest.json
@@ -0,0 +1,16 @@
+{
+ "short_name": "OpenPencil",
+ "name": "OpenPencil",
+ "description": "Open-source vector design tool with Design-as-Code philosophy",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#000000"
+}
diff --git a/public/robots.txt b/apps/web/public/robots.txt
similarity index 100%
rename from public/robots.txt
rename to apps/web/public/robots.txt
diff --git a/server/__tests__/security.test.ts b/apps/web/server/__tests__/security.test.ts
similarity index 100%
rename from server/__tests__/security.test.ts
rename to apps/web/server/__tests__/security.test.ts
diff --git a/server/api/ai/chat.ts b/apps/web/server/api/ai/chat.ts
similarity index 93%
rename from server/api/ai/chat.ts
rename to apps/web/server/api/ai/chat.ts
index 48bf2819..65b79c8d 100644
--- a/server/api/ai/chat.ts
+++ b/apps/web/server/api/ai/chat.ts
@@ -31,7 +31,7 @@ interface ChatBody {
system: string
messages: Array<{ role: 'user' | 'assistant'; content: string; attachments?: ChatAttachmentWire[] }>
model?: string
- provider?: 'anthropic' | 'openai' | 'opencode' | 'copilot'
+ provider?: 'anthropic' | 'openai' | 'opencode' | 'copilot' | 'gemini'
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
thinkingBudgetTokens?: number
effort?: 'low' | 'medium' | 'high' | 'max'
@@ -93,7 +93,7 @@ export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
return { error: 'Missing model. Model fallback is disabled.' }
}
- if (body.provider !== 'anthropic' && body.provider !== 'openai' && body.provider !== 'opencode' && body.provider !== 'copilot') {
+ if (body.provider !== 'anthropic' && body.provider !== 'openai' && body.provider !== 'opencode' && body.provider !== 'copilot' && body.provider !== 'gemini') {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
return { error: 'Missing or unsupported provider. Provider fallback is disabled.' }
}
@@ -107,6 +107,7 @@ export default defineEventHandler(async (event) => {
if (body.provider === 'anthropic') return streamViaAgentSDK(body, body.model)
if (body.provider === 'opencode') return streamViaOpenCode(body, body.model)
if (body.provider === 'copilot') return streamViaCopilot(body, body.model)
+ if (body.provider === 'gemini') return streamViaGemini(body, body.model)
return streamViaCodex(body, body.model)
})
@@ -720,6 +721,61 @@ function mapCopilotReasoningEffort(
return effort
}
+/** Stream via Gemini CLI (`gemini -p -o stream-json`) — CLI handles its own auth */
+function streamViaGemini(body: ChatBody, model?: string) {
+ const stream = new ReadableStream({
+ async start(controller) {
+ const encoder = new TextEncoder()
+ const pingTimer = setInterval(() => {
+ try {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`))
+ } catch { /* stream already closed */ }
+ }, KEEPALIVE_INTERVAL_MS)
+
+ try {
+ const { streamGeminiExec } = await import('../../utils/gemini-client')
+
+ // Build prompt from messages
+ const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
+ const prompt = lastUserMsg?.content ?? ''
+
+ const { stream: geminiStream } = streamGeminiExec(prompt, {
+ model,
+ systemPrompt: body.system,
+ })
+
+ for await (const event of geminiStream) {
+ clearInterval(pingTimer)
+ if (event.type === 'text') {
+ const data = JSON.stringify({ type: 'text', content: event.content })
+ try {
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`))
+ } catch { /* stream closed */ }
+ } else if (event.type === 'error') {
+ const data = JSON.stringify({ type: 'error', content: event.content })
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`))
+ }
+ // 'done' is handled after loop
+ }
+
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
+ )
+ } catch (error) {
+ const content = error instanceof Error ? error.message : 'Unknown error'
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
+ )
+ } finally {
+ clearInterval(pingTimer)
+ controller.close()
+ }
+ },
+ })
+
+ return new Response(stream)
+}
+
/** Stream via GitHub Copilot SDK (@github/copilot-sdk) */
function streamViaCopilot(body: ChatBody, model?: string) {
const stream = new ReadableStream({
diff --git a/server/api/ai/connect-agent.ts b/apps/web/server/api/ai/connect-agent.ts
similarity index 79%
rename from server/api/ai/connect-agent.ts
rename to apps/web/server/api/ai/connect-agent.ts
index 5941dc2b..cffe7fdb 100644
--- a/server/api/ai/connect-agent.ts
+++ b/apps/web/server/api/ai/connect-agent.ts
@@ -39,7 +39,7 @@ function buildExecCmd(binPath: string, args: string): string {
}
interface ConnectBody {
- agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot'
+ agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli'
}
interface ConnectResult {
@@ -82,6 +82,10 @@ export default defineEventHandler(async (event) => {
return connectCopilot()
}
+ if (body.agent === 'gemini-cli') {
+ return connectGeminiCli()
+ }
+
return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult
})
@@ -667,3 +671,174 @@ function friendlyOpenCodeError(raw: string): string {
}
return raw
}
+
+/** Fallback model list when dynamic fetch fails */
+const FALLBACK_GEMINI_MODELS: GroupedModel[] = [
+ { value: 'gemini-3-pro-preview', displayName: 'Gemini 3 Pro', description: 'Most capable', provider: 'gemini' },
+ { value: 'gemini-3-flash-preview', displayName: 'Gemini 3 Flash', description: 'Fast + capable', provider: 'gemini' },
+ { value: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro', description: 'Thinking model', provider: 'gemini' },
+ { value: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', description: 'Fast + thinking', provider: 'gemini' },
+ { value: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash', description: 'Fast model', provider: 'gemini' },
+]
+
+/** Fetch available models from Gemini API using local auth credentials */
+async function fetchGeminiModels(): Promise {
+ const { readFile } = await import('node:fs/promises')
+ const { homedir } = await import('node:os')
+ const { join } = await import('node:path')
+
+ // Build auth header — try API key first, then OAuth token
+ let authUrl: (base: string) => string
+ let headers: Record = {}
+
+ const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
+ if (envKey) {
+ authUrl = (base) => `${base}?key=${envKey}`
+ } else {
+ // Read OAuth token
+ const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json')
+ const raw = await readFile(oauthPath, 'utf-8')
+ const creds = JSON.parse(raw) as { access_token?: string; expiry_date?: number }
+ if (!creds.access_token) throw new Error('No access token')
+ if (creds.expiry_date && Date.now() > creds.expiry_date - 60_000) throw new Error('Token expired')
+ authUrl = (base) => base
+ headers = { Authorization: `Bearer ${creds.access_token}` }
+ }
+
+ const res = await fetch(authUrl('https://generativelanguage.googleapis.com/v1beta/models'), { headers })
+ if (!res.ok) throw new Error(`API ${res.status}`)
+
+ const data = await res.json() as {
+ models?: Array<{
+ name?: string
+ displayName?: string
+ description?: string
+ supportedGenerationMethods?: string[]
+ }>
+ }
+
+ const models: GroupedModel[] = []
+ const seen = new Set()
+ for (const m of data.models ?? []) {
+ // Only include models that support generateContent (text generation)
+ if (!m.supportedGenerationMethods?.includes('generateContent')) continue
+ const id = m.name?.replace('models/', '') ?? ''
+ if (!id || seen.has(id)) continue
+ // Skip embedding, AQA, and legacy models
+ if (/embed|aqa|^chat-bison|^text-bison|^gemini-1\.0/i.test(id)) continue
+ seen.add(id)
+ models.push({
+ value: id,
+ displayName: m.displayName ?? id,
+ description: m.description?.slice(0, 60) ?? '',
+ provider: 'gemini' as const,
+ })
+ }
+
+ // Sort: gemini-3 first, then 2.5, then others
+ models.sort((a, b) => {
+ const order = (v: string) => {
+ if (v.includes('gemini-3')) return 0
+ if (v.includes('gemini-2.5-pro')) return 1
+ if (v.includes('gemini-2.5-flash')) return 2
+ if (v.includes('gemini-2.0')) return 3
+ return 4
+ }
+ return order(a.value) - order(b.value)
+ })
+
+ return models
+}
+
+/** Connect to Gemini CLI and return available models. */
+async function connectGeminiCli(): Promise {
+ serverLog.info('[connect-agent] connecting to Gemini CLI...')
+ try {
+ const { resolveGeminiCli } = await import('../../utils/resolve-gemini-cli')
+ const binPath = resolveGeminiCli()
+ serverLog.info(`[connect-agent] resolved gemini path: ${binPath ?? 'NOT FOUND'}`)
+ if (!binPath) {
+ return { connected: false, models: [], notInstalled: true, error: 'Gemini CLI not found' }
+ }
+
+ // Verify binary responds
+ const { execSync } = await import('node:child_process')
+ const versionCmd = buildExecCmd(binPath, '--version')
+ try {
+ const ver = execSync(`${versionCmd} 2>&1`, { encoding: 'utf-8', timeout: 10000 }).trim()
+ serverLog.info(`[connect-agent] gemini version: ${ver}`)
+ } catch (err) {
+ serverLog.error(`[connect-agent] gemini --version failed: ${err instanceof Error ? err.message : err}`)
+ return { connected: false, models: [], error: 'Gemini CLI not responding' }
+ }
+
+ // Dynamically fetch models, fallback to hardcoded list
+ let models: GroupedModel[]
+ try {
+ models = await fetchGeminiModels()
+ serverLog.info(`[connect-agent] gemini: fetched ${models.length} models from API`)
+ } catch (err) {
+ serverLog.info(`[connect-agent] gemini: model fetch failed (${err instanceof Error ? err.message : err}), using fallback`)
+ models = FALLBACK_GEMINI_MODELS
+ }
+
+ const geminiInfo = await buildGeminiConnectionInfo()
+ const warning = models.length === 0 ? 'No models found. Try running "gemini" once to authenticate.' : undefined
+ if (models.length === 0) models = FALLBACK_GEMINI_MODELS
+ serverLog.info(`[connect-agent] gemini connected, ${models.length} models`)
+ return { connected: true, models, warning, ...geminiInfo }
+ } catch (error) {
+ const raw = error instanceof Error ? error.message : 'Failed to connect'
+ serverLog.error(`[connect-agent] gemini connection error: ${raw}`)
+ return { connected: false, models: [], error: friendlyGeminiError(raw) }
+ }
+}
+
+/** Build Gemini CLI connection info from local config files */
+async function buildGeminiConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> {
+ const { readFile } = await import('node:fs/promises')
+ const { homedir } = await import('node:os')
+ const { join } = await import('node:path')
+ const hp = configPath('~/.gemini/settings.json', '%USERPROFILE%\\.gemini\\settings.json')
+
+ // Check env for API key
+ const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
+ if (envKey) {
+ const masked = envKey.length > 12 ? `${envKey.slice(0, 8)}...` : '***'
+ return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp }
+ }
+
+ // Check OAuth creds (Gemini CLI login)
+ try {
+ const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json')
+ await readFile(oauthPath, 'utf-8') // Check existence
+
+ // Try to get account email
+ try {
+ const accountsPath = join(homedir(), '.gemini', 'google_accounts.json')
+ const accountsRaw = await readFile(accountsPath, 'utf-8')
+ const accounts = JSON.parse(accountsRaw) as { active?: string }
+ if (accounts.active) {
+ return { connectionInfo: `Connected via Google (${accounts.active})`, hintPath: hp }
+ }
+ } catch { /* no accounts file */ }
+
+ return { connectionInfo: 'Connected via Google OAuth', hintPath: hp }
+ } catch { /* no OAuth creds */ }
+
+ return { connectionInfo: 'Connected via Gemini CLI', hintPath: hp }
+}
+
+/** Map Gemini CLI errors to user-friendly messages */
+function friendlyGeminiError(raw: string): string {
+ if (/not found|ENOENT/i.test(raw)) {
+ return 'Gemini CLI not found. Install it with: npm install -g @anthropic-ai/gemini-cli'
+ }
+ if (/not authenticated|authenticate|auth|login/i.test(raw)) {
+ return 'Not authenticated. Run "gemini" in your terminal first to set up authentication.'
+ }
+ if (/timed?\s*out/i.test(raw)) {
+ return 'Connection timed out. Please try again.'
+ }
+ return raw
+}
diff --git a/server/api/ai/generate.ts b/apps/web/server/api/ai/generate.ts
similarity index 93%
rename from server/api/ai/generate.ts
rename to apps/web/server/api/ai/generate.ts
index 521669b3..a901bb53 100644
--- a/server/api/ai/generate.ts
+++ b/apps/web/server/api/ai/generate.ts
@@ -12,7 +12,7 @@ interface GenerateBody {
system: string
message: string
model?: string
- provider?: 'anthropic' | 'openai' | 'opencode'
+ provider?: 'anthropic' | 'openai' | 'opencode' | 'gemini'
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
thinkingBudgetTokens?: number
effort?: 'low' | 'medium' | 'high' | 'max'
@@ -48,6 +48,9 @@ export default defineEventHandler(async (event) => {
if (body.provider === 'openai') {
return generateViaCodex(body, body.model)
}
+ if (body.provider === 'gemini') {
+ return generateViaGemini(body, body.model)
+ }
return { error: 'Missing or unsupported provider. Provider fallback is disabled.' }
})
@@ -261,3 +264,15 @@ async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<
releaseOpencodeServer(ocServer)
}
}
+
+/** Generate via Gemini CLI (`gemini -p -o json`) — CLI handles its own auth */
+async function generateViaGemini(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> {
+ const { runGeminiExec } = await import('../../utils/gemini-client')
+ return runGeminiExec(body.message, {
+ model,
+ systemPrompt: body.system,
+ thinkingMode: body.thinkingMode,
+ thinkingBudgetTokens: body.thinkingBudgetTokens,
+ effort: body.effort,
+ })
+}
diff --git a/server/api/ai/icon.ts b/apps/web/server/api/ai/icon.ts
similarity index 100%
rename from server/api/ai/icon.ts
rename to apps/web/server/api/ai/icon.ts
diff --git a/apps/web/server/api/ai/image-generate.ts b/apps/web/server/api/ai/image-generate.ts
new file mode 100644
index 00000000..c986d712
--- /dev/null
+++ b/apps/web/server/api/ai/image-generate.ts
@@ -0,0 +1,311 @@
+import { defineEventHandler, readBody, setResponseHeaders, createError } from 'h3'
+
+interface ImageGenerateBody {
+ prompt: string
+ provider: 'openai' | 'custom' | 'gemini' | 'replicate'
+ model: string
+ apiKey: string
+ baseUrl?: string
+ width?: number
+ height?: number
+}
+
+/**
+ * POST /api/ai/image-generate
+ *
+ * Multi-provider image generation endpoint.
+ * Supports OpenAI (dall-e-3/dall-e-2), Gemini (imagen), and Replicate.
+ * Returns { url: string } — either a remote URL or a base64 data URL.
+ */
+export default defineEventHandler(async (event) => {
+ setResponseHeaders(event, { 'Content-Type': 'application/json' })
+
+ const body = await readBody(event)
+
+ if (!body?.prompt?.trim()) {
+ throw createError({ statusCode: 400, message: 'Missing required field: prompt' })
+ }
+ if (!body?.provider) {
+ throw createError({ statusCode: 400, message: 'Missing required field: provider' })
+ }
+ if (!body?.apiKey?.trim()) {
+ throw createError({ statusCode: 400, message: 'Missing required field: apiKey' })
+ }
+
+ const { prompt, provider, model, apiKey, baseUrl, width, height } = body
+
+ if (provider === 'openai' || provider === 'custom') {
+ return await generateOpenAI({ prompt, model, apiKey, baseUrl, width, height })
+ }
+
+ if (provider === 'gemini') {
+ return await generateGemini({ prompt, model, apiKey, baseUrl, width, height })
+ }
+
+ if (provider === 'replicate') {
+ return await generateReplicate({ prompt, model, apiKey, baseUrl, width, height })
+ }
+
+ throw createError({ statusCode: 400, message: `Unsupported provider: ${provider}` })
+})
+
+// ---------------------------------------------------------------------------
+// Size mapping
+// ---------------------------------------------------------------------------
+
+function mapToOpenAISize(w?: number, h?: number): string {
+ if (!w || !h) return '1024x1024'
+ const ratio = w / h
+ if (ratio > 1.3) return '1792x1024'
+ if (ratio < 0.77) return '1024x1792'
+ return '1024x1024'
+}
+
+// ---------------------------------------------------------------------------
+// OpenAI / custom OpenAI-compatible provider
+// ---------------------------------------------------------------------------
+
+async function generateOpenAI(opts: {
+ prompt: string
+ model: string
+ apiKey: string
+ baseUrl?: string
+ width?: number
+ height?: number
+}): Promise<{ url: string }> {
+ const { prompt, model, apiKey, baseUrl, width, height } = opts
+ const size = mapToOpenAISize(width, height)
+ const endpoint = `${baseUrl ?? 'https://api.openai.com'}/v1/images/generations`
+
+ let res: Response
+ try {
+ res = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({ model, prompt, n: 1, size, response_format: 'url' }),
+ })
+ } catch (err) {
+ throw createError({ statusCode: 502, message: `OpenAI request failed: ${String(err)}` })
+ }
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ let msg = `OpenAI returned ${res.status}`
+ try {
+ const errJson = JSON.parse(text) as { error?: { message?: string } }
+ if (errJson.error?.message) msg = errJson.error.message
+ } catch {
+ if (text) msg += `: ${text.slice(0, 150)}`
+ }
+ throw createError({ statusCode: 502, message: msg })
+ }
+
+ const data = (await res.json()) as { data?: { url?: string }[] }
+ const url = data?.data?.[0]?.url
+ if (!url) {
+ throw createError({ statusCode: 502, message: 'OpenAI response missing image URL' })
+ }
+
+ return { url }
+}
+
+// ---------------------------------------------------------------------------
+// Gemini image generation
+// ---------------------------------------------------------------------------
+
+function mapToGeminiAspectRatio(w?: number, h?: number): string | undefined {
+ if (!w || !h) return undefined
+ const ratio = w / h
+ if (ratio > 1.6) return '16:9'
+ if (ratio > 1.3) return '4:3'
+ if (ratio < 0.625) return '9:16'
+ if (ratio < 0.77) return '3:4'
+ return '1:1'
+}
+
+async function generateGemini(opts: {
+ prompt: string
+ model: string
+ apiKey: string
+ baseUrl?: string
+ width?: number
+ height?: number
+}): Promise<{ url: string }> {
+ const { prompt, model, apiKey, baseUrl, width, height } = opts
+ const base = baseUrl ?? 'https://generativelanguage.googleapis.com'
+ const endpoint = `${base}/v1beta/models/${model}:generateContent?key=${apiKey}`
+
+ const generationConfig: Record = { responseModalities: ['TEXT', 'IMAGE'] }
+ const aspectRatio = mapToGeminiAspectRatio(width, height)
+ if (aspectRatio) {
+ generationConfig.imageConfig = { aspectRatio }
+ }
+
+ let res: Response
+ try {
+ res = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig,
+ }),
+ })
+ } catch (err) {
+ throw createError({ statusCode: 502, message: `Gemini request failed: ${String(err)}` })
+ }
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ let msg = `Gemini returned ${res.status}`
+ try {
+ const errJson = JSON.parse(text) as { error?: { message?: string } }
+ if (errJson.error?.message) msg = errJson.error.message
+ } catch {
+ if (text) msg += `: ${text.slice(0, 150)}`
+ }
+ throw createError({ statusCode: 502, message: msg })
+ }
+
+ const data = (await res.json()) as {
+ candidates?: {
+ content?: {
+ parts?: {
+ inlineData?: { mimeType?: string; data?: string }
+ text?: string
+ }[]
+ }
+ }[]
+ }
+
+ const parts = data?.candidates?.[0]?.content?.parts ?? []
+ const imagePart = parts.find((p) => p.inlineData?.mimeType?.startsWith('image/'))
+
+ if (!imagePart?.inlineData?.data || !imagePart.inlineData.mimeType) {
+ throw createError({ statusCode: 502, message: 'Gemini response missing inline image data' })
+ }
+
+ const { mimeType, data: base64data } = imagePart.inlineData
+ return { url: `data:${mimeType};base64,${base64data}` }
+}
+
+// ---------------------------------------------------------------------------
+// Replicate
+// ---------------------------------------------------------------------------
+
+async function generateReplicate(opts: {
+ prompt: string
+ model: string
+ apiKey: string
+ baseUrl?: string
+ width?: number
+ height?: number
+}): Promise<{ url: string }> {
+ const { prompt, model, apiKey, baseUrl, width, height } = opts
+ const base = baseUrl ?? 'https://api.replicate.com'
+
+ // Start prediction
+ let createRes: Response
+ try {
+ const input: Record = { prompt }
+ if (width) input.width = width
+ if (height) input.height = height
+
+ createRes = await fetch(`${base}/v1/predictions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({ model, input }),
+ })
+ } catch (err) {
+ throw createError({ statusCode: 502, message: `Replicate request failed: ${String(err)}` })
+ }
+
+ if (!createRes.ok) {
+ const text = await createRes.text().catch(() => '')
+ let msg = `Replicate returned ${createRes.status}`
+ try {
+ const errJson = JSON.parse(text) as { detail?: string }
+ if (errJson.detail) msg = errJson.detail
+ } catch {
+ if (text) msg += `: ${text.slice(0, 150)}`
+ }
+ throw createError({ statusCode: 502, message: msg })
+ }
+
+ const prediction = (await createRes.json()) as { id?: string; status?: string }
+ const predictionId = prediction?.id
+ if (!predictionId) {
+ throw createError({ statusCode: 502, message: 'Replicate response missing prediction ID' })
+ }
+
+ // Poll until succeeded or failed (max 120s, polling every 2s)
+ const maxAttempts = 60
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ await new Promise((resolve) => setTimeout(resolve, 2000))
+
+ let pollRes: Response
+ try {
+ pollRes = await fetch(`${base}/v1/predictions/${predictionId}`, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ })
+ } catch (err) {
+ throw createError({
+ statusCode: 502,
+ message: `Replicate poll request failed: ${String(err)}`,
+ })
+ }
+
+ if (!pollRes.ok) {
+ const text = await pollRes.text().catch(() => '')
+ throw createError({
+ statusCode: 502,
+ message: `Replicate poll returned ${pollRes.status}: ${text.slice(0, 200)}`,
+ })
+ }
+
+ const status = (await pollRes.json()) as {
+ id?: string
+ status?: string
+ output?: string | string[]
+ error?: string
+ }
+
+ if (status.status === 'succeeded') {
+ const output = status.output
+ if (Array.isArray(output)) {
+ const first = output[0]
+ if (!first) {
+ throw createError({
+ statusCode: 502,
+ message: 'Replicate succeeded but output array is empty',
+ })
+ }
+ return { url: first }
+ }
+ if (typeof output === 'string') {
+ return { url: output }
+ }
+ throw createError({ statusCode: 502, message: 'Replicate succeeded but output is missing' })
+ }
+
+ if (status.status === 'failed' || status.status === 'canceled') {
+ throw createError({
+ statusCode: 502,
+ message: `Replicate prediction ${status.status}: ${status.error ?? 'unknown error'}`,
+ })
+ }
+
+ // Still starting/processing — keep polling
+ }
+
+ throw createError({
+ statusCode: 502,
+ message: 'Replicate prediction timed out after 120 seconds',
+ })
+}
diff --git a/apps/web/server/api/ai/image-search.ts b/apps/web/server/api/ai/image-search.ts
new file mode 100644
index 00000000..6fe5c64e
--- /dev/null
+++ b/apps/web/server/api/ai/image-search.ts
@@ -0,0 +1,276 @@
+import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
+import type { ImageSearchResult, ImageSearchResponse } from '../../../src/types/image-service'
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface OpenverseImageResult {
+ id: string
+ url: string
+ thumbnail: string
+ width: number
+ height: number
+ license: string
+ license_version: string
+ attribution: string
+}
+
+interface OpenverseSearchResponse {
+ results: OpenverseImageResult[]
+}
+
+interface WikimediaImageInfo {
+ url: string
+ thumburl: string
+ width: number
+ height: number
+ mime: string
+ extmetadata?: {
+ LicenseShortName?: { value: string }
+ }
+}
+
+interface WikimediaPage {
+ pageid: number
+ title: string
+ imageinfo?: WikimediaImageInfo[]
+}
+
+interface WikimediaQueryResponse {
+ query?: {
+ pages?: Record
+ }
+}
+
+// ---------------------------------------------------------------------------
+// OAuth token cache
+// ---------------------------------------------------------------------------
+
+let cachedToken: string | null = null
+let tokenExpiresAt = 0
+
+async function getOpenverseToken(clientId: string, clientSecret: string): Promise {
+ const now = Date.now()
+ if (cachedToken && now < tokenExpiresAt) {
+ return cachedToken
+ }
+
+ try {
+ const body = new URLSearchParams({
+ grant_type: 'client_credentials',
+ client_id: clientId,
+ client_secret: clientSecret,
+ })
+ const res = await fetch('https://api.openverse.org/v1/auth_tokens/token/', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ })
+ if (!res.ok) return null
+ const data = (await res.json()) as { access_token: string; expires_in: number }
+ cachedToken = data.access_token
+ // Refresh 60 seconds before expiry
+ tokenExpiresAt = now + (data.expires_in - 60) * 1000
+ return cachedToken
+ } catch {
+ return null
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Query simplification — convert verbose AI prompts to search keywords
+// ---------------------------------------------------------------------------
+
+const STOP_WORDS = new Set([
+ 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
+ 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
+ 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
+ 'could', 'should', 'may', 'might', 'shall', 'can', 'that', 'this',
+ 'these', 'those', 'it', 'its', 'very', 'really', 'just', 'also',
+ 'about', 'above', 'after', 'before', 'between', 'into', 'through',
+ 'during', 'each', 'some', 'such', 'no', 'not', 'only', 'same', 'so',
+ 'than', 'too', 'up', 'out', 'if', 'then', 'once', 'here', 'there',
+ 'when', 'where', 'how', 'all', 'both', 'few', 'more', 'most', 'other',
+ 'any', 'as', 'while', 'using', 'showing', 'featuring', 'looking',
+ 'style', 'styled', 'inspired', 'based',
+])
+
+/**
+ * Simplify a verbose image generation prompt into 2-4 search keywords.
+ * "delicious burger with fries and fresh vegetables" → "burger fries vegetables"
+ * "modern office workspace with natural lighting" → "modern office workspace"
+ */
+export function simplifySearchQuery(prompt: string): string {
+ const words = prompt
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, ' ')
+ .split(/\s+/)
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w))
+
+ // Take up to 4 keywords
+ const keywords = words.slice(0, 4)
+ return keywords.join(' ') || prompt.slice(0, 30)
+}
+
+// ---------------------------------------------------------------------------
+// Mapping helpers (exported for testing)
+// ---------------------------------------------------------------------------
+
+export function mapOpenverseResult(r: OpenverseImageResult): ImageSearchResult {
+ return {
+ id: r.id,
+ url: r.url,
+ thumbUrl: r.thumbnail,
+ width: r.width,
+ height: r.height,
+ source: 'openverse',
+ license: `${r.license} ${r.license_version}`.trim(),
+ attribution: r.attribution,
+ }
+}
+
+export function mapWikimediaPages(
+ pages: Record,
+): ImageSearchResult[] {
+ const results: ImageSearchResult[] = []
+ for (const page of Object.values(pages)) {
+ const info = page.imageinfo?.[0]
+ if (!info) continue
+ results.push({
+ id: String(page.pageid),
+ url: info.url,
+ thumbUrl: info.thumburl ?? info.url,
+ width: info.width,
+ height: info.height,
+ source: 'wikimedia',
+ license: info.extmetadata?.LicenseShortName?.value ?? '',
+ })
+ }
+ return results
+}
+
+// ---------------------------------------------------------------------------
+// Source fetchers
+// ---------------------------------------------------------------------------
+
+async function fetchFromOpenverse(
+ query: string,
+ count: number,
+ aspectRatio: string | undefined,
+ clientId: string | undefined,
+ clientSecret: string | undefined,
+): Promise {
+ const url = new URL('https://api.openverse.org/v1/images/')
+ url.searchParams.set('q', query)
+ url.searchParams.set('page_size', String(count))
+ if (aspectRatio) {
+ url.searchParams.set('aspect_ratio', aspectRatio)
+ }
+
+ const headers: Record = {}
+ if (clientId && clientSecret) {
+ const token = await getOpenverseToken(clientId, clientSecret)
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`
+ }
+ }
+
+ const res = await fetch(url.toString(), { headers })
+ if (res.status === 429) {
+ // Rate limited — signal fallback
+ return null
+ }
+ if (!res.ok) {
+ return null
+ }
+
+ const data = (await res.json()) as OpenverseSearchResponse
+ return (data.results ?? []).map(mapOpenverseResult)
+}
+
+async function fetchFromWikimedia(
+ query: string,
+ count: number,
+): Promise {
+ const url = new URL('https://commons.wikimedia.org/w/api.php')
+ url.searchParams.set('action', 'query')
+ url.searchParams.set('generator', 'search')
+ url.searchParams.set('gsrsearch', query)
+ url.searchParams.set('gsrnamespace', '6')
+ url.searchParams.set('gsrlimit', String(count))
+ url.searchParams.set('prop', 'imageinfo')
+ url.searchParams.set('iiprop', 'url|size|mime|extmetadata')
+ url.searchParams.set('iiurlwidth', '800')
+ url.searchParams.set('format', 'json')
+ url.searchParams.set('origin', '*')
+
+ const res = await fetch(url.toString())
+ if (!res.ok) return []
+
+ const data = (await res.json()) as WikimediaQueryResponse
+ const pages = data.query?.pages
+ if (!pages) return []
+
+ return mapWikimediaPages(pages)
+}
+
+// ---------------------------------------------------------------------------
+// Endpoint
+// ---------------------------------------------------------------------------
+
+/**
+ * POST /api/ai/image-search
+ *
+ * Searches for freely-licensed images.
+ * Primary source: Openverse. Falls back to Wikimedia Commons on 429.
+ *
+ * Body: { query, count?, aspectRatio?, openverseClientId?, openverseClientSecret? }
+ */
+export default defineEventHandler(async (event) => {
+ setResponseHeaders(event, { 'Content-Type': 'application/json' })
+
+ const body = await readBody(event) as {
+ query?: string
+ count?: number
+ aspectRatio?: string
+ openverseClientId?: string
+ openverseClientSecret?: string
+ }
+
+ const rawQuery = body?.query?.trim() ?? ''
+ if (!rawQuery) {
+ return { error: 'Missing required field: query' }
+ }
+
+ // Simplify verbose AI prompts into search-friendly keywords
+ const query = simplifySearchQuery(rawQuery)
+
+ const count = Math.min(Math.max(Number(body?.count ?? 10), 1), 50)
+ const aspectRatio = body?.aspectRatio
+ const clientId = body?.openverseClientId
+ const clientSecret = body?.openverseClientSecret
+
+ // Try Openverse first
+ const openverseResults = await fetchFromOpenverse(
+ query,
+ count,
+ aspectRatio,
+ clientId,
+ clientSecret,
+ )
+
+ if (openverseResults !== null) {
+ return {
+ results: openverseResults,
+ source: 'openverse',
+ } satisfies ImageSearchResponse
+ }
+
+ // Openverse returned 429 or failed — fall back to Wikimedia
+ const wikimediaResults = await fetchFromWikimedia(query, count)
+ return {
+ results: wikimediaResults,
+ source: 'wikimedia',
+ } satisfies ImageSearchResponse
+})
diff --git a/apps/web/server/api/ai/image-service-test.ts b/apps/web/server/api/ai/image-service-test.ts
new file mode 100644
index 00000000..4e82118e
--- /dev/null
+++ b/apps/web/server/api/ai/image-service-test.ts
@@ -0,0 +1,104 @@
+import { defineEventHandler, readBody } from 'h3'
+
+interface ImageServiceTestRequest {
+ service: string
+ apiKey?: string
+ model?: string
+ baseUrl?: string
+ clientId?: string
+ clientSecret?: string
+}
+
+interface ImageServiceTestResponse {
+ valid: boolean
+ error?: string
+}
+
+/**
+ * POST /api/ai/image-service-test
+ *
+ * Validates API keys for image generation services.
+ * Returns { valid: boolean, error?: string }
+ */
+export default defineEventHandler(async (event): Promise => {
+ const body = await readBody(event)
+ const { service, apiKey, baseUrl, clientId, clientSecret } = body ?? {}
+
+ if (!service) {
+ return { valid: false, error: 'Missing required field: service' }
+ }
+
+ try {
+ switch (service) {
+ case 'openverse': {
+ if (!clientId || !clientSecret) {
+ return { valid: false, error: 'Openverse requires clientId and clientSecret' }
+ }
+ const formData = new URLSearchParams()
+ formData.set('grant_type', 'client_credentials')
+ formData.set('client_id', clientId)
+ formData.set('client_secret', clientSecret)
+ const res = await fetch('https://api.openverse.org/v1/auth_tokens/token/', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: formData.toString(),
+ })
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ return { valid: false, error: `Openverse auth failed (${res.status}): ${text}` }
+ }
+ return { valid: true }
+ }
+
+ case 'openai':
+ case 'custom': {
+ if (!apiKey) {
+ return { valid: false, error: 'Missing required field: apiKey' }
+ }
+ const origin = baseUrl ?? 'https://api.openai.com'
+ const res = await fetch(`${origin}/v1/models`, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ })
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ return { valid: false, error: `Models request failed (${res.status}): ${text}` }
+ }
+ return { valid: true }
+ }
+
+ case 'gemini': {
+ if (!apiKey) {
+ return { valid: false, error: 'Missing required field: apiKey' }
+ }
+ const origin = baseUrl ?? 'https://generativelanguage.googleapis.com'
+ const res = await fetch(`${origin}/v1beta/models?key=${encodeURIComponent(apiKey)}`)
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ return { valid: false, error: `Gemini models request failed (${res.status}): ${text}` }
+ }
+ return { valid: true }
+ }
+
+ case 'replicate': {
+ if (!apiKey) {
+ return { valid: false, error: 'Missing required field: apiKey' }
+ }
+ const origin = baseUrl ?? 'https://api.replicate.com'
+ const res = await fetch(`${origin}/v1/models`, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ })
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ return { valid: false, error: `Replicate models request failed (${res.status}): ${text}` }
+ }
+ return { valid: true }
+ }
+
+ default:
+ return { valid: false, error: `Unknown service: ${service}` }
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ return { valid: false, error: message }
+ }
+})
diff --git a/server/api/ai/install-agent.ts b/apps/web/server/api/ai/install-agent.ts
similarity index 93%
rename from server/api/ai/install-agent.ts
rename to apps/web/server/api/ai/install-agent.ts
index 27de7971..da7426b8 100644
--- a/server/api/ai/install-agent.ts
+++ b/apps/web/server/api/ai/install-agent.ts
@@ -2,7 +2,7 @@ import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { execSync } from 'node:child_process'
interface InstallBody {
- agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot'
+ agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli'
}
interface InstallResult {
@@ -17,6 +17,7 @@ const BINARY_MAP: Record = {
'codex-cli': 'codex',
'opencode': 'opencode',
'copilot': 'copilot',
+ 'gemini-cli': 'gemini',
}
function checkBinary(binary: string): boolean {
@@ -65,6 +66,11 @@ function getInstallInfo(agent: string): { command: string; docsUrl: string } {
: 'See documentation',
docsUrl: 'https://docs.github.com/copilot/how-tos/copilot-cli',
}
+ case 'gemini-cli':
+ return {
+ command: 'npm install -g @anthropic-ai/gemini-cli',
+ docsUrl: 'https://github.com/anthropics/gemini-cli',
+ }
default:
return { command: '', docsUrl: '' }
}
@@ -117,6 +123,8 @@ async function tryAutoInstall(agent: string, binary: string): Promise {
if (body.provider === 'opencode') {
return await validateViaOpenCode(body, body.model)
}
+ if (body.provider === 'gemini') {
+ return await validateViaGemini(body, body.model)
+ }
return { error: 'Missing or unsupported provider. Provider fallback is disabled.' }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
@@ -240,3 +243,22 @@ async function validateViaOpenCode(
releaseOpencodeServer(ocServer)
}
}
+
+/** Validate via Gemini CLI — saves screenshot to temp file, asks CLI to read it */
+async function validateViaGemini(
+ body: ValidateBody,
+ model?: string,
+): Promise<{ text: string; skipped?: boolean; error?: string }> {
+ return await withTempImageFile(body.imageBase64, async (tempPath) => {
+ const { runGeminiExec } = await import('../../utils/gemini-client')
+ const prompt = `Read the image file at "${tempPath}". This is a PNG screenshot of a UI design.\n\n${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`
+ const result = await runGeminiExec(prompt, {
+ model,
+ systemPrompt: body.system,
+ })
+ if (result.error) {
+ return { text: '', error: result.error }
+ }
+ return { text: result.text ?? '' }
+ })
+}
diff --git a/server/api/mcp/document.get.ts b/apps/web/server/api/mcp/document.get.ts
similarity index 100%
rename from server/api/mcp/document.get.ts
rename to apps/web/server/api/mcp/document.get.ts
diff --git a/server/api/mcp/document.post.ts b/apps/web/server/api/mcp/document.post.ts
similarity index 100%
rename from server/api/mcp/document.post.ts
rename to apps/web/server/api/mcp/document.post.ts
diff --git a/server/api/mcp/events.get.ts b/apps/web/server/api/mcp/events.get.ts
similarity index 100%
rename from server/api/mcp/events.get.ts
rename to apps/web/server/api/mcp/events.get.ts
diff --git a/server/api/mcp/selection.get.ts b/apps/web/server/api/mcp/selection.get.ts
similarity index 100%
rename from server/api/mcp/selection.get.ts
rename to apps/web/server/api/mcp/selection.get.ts
diff --git a/server/api/mcp/selection.post.ts b/apps/web/server/api/mcp/selection.post.ts
similarity index 100%
rename from server/api/mcp/selection.post.ts
rename to apps/web/server/api/mcp/selection.post.ts
diff --git a/server/api/mcp/server.get.ts b/apps/web/server/api/mcp/server.get.ts
similarity index 100%
rename from server/api/mcp/server.get.ts
rename to apps/web/server/api/mcp/server.get.ts
diff --git a/server/api/mcp/server.post.ts b/apps/web/server/api/mcp/server.post.ts
similarity index 100%
rename from server/api/mcp/server.post.ts
rename to apps/web/server/api/mcp/server.post.ts
diff --git a/server/opencode/client.ts b/apps/web/server/opencode/client.ts
similarity index 100%
rename from server/opencode/client.ts
rename to apps/web/server/opencode/client.ts
diff --git a/server/opencode/gen/client.gen.ts b/apps/web/server/opencode/gen/client.gen.ts
similarity index 100%
rename from server/opencode/gen/client.gen.ts
rename to apps/web/server/opencode/gen/client.gen.ts
diff --git a/server/opencode/gen/client/client.gen.ts b/apps/web/server/opencode/gen/client/client.gen.ts
similarity index 100%
rename from server/opencode/gen/client/client.gen.ts
rename to apps/web/server/opencode/gen/client/client.gen.ts
diff --git a/server/opencode/gen/client/index.ts b/apps/web/server/opencode/gen/client/index.ts
similarity index 100%
rename from server/opencode/gen/client/index.ts
rename to apps/web/server/opencode/gen/client/index.ts
diff --git a/server/opencode/gen/client/types.gen.ts b/apps/web/server/opencode/gen/client/types.gen.ts
similarity index 100%
rename from server/opencode/gen/client/types.gen.ts
rename to apps/web/server/opencode/gen/client/types.gen.ts
diff --git a/server/opencode/gen/client/utils.gen.ts b/apps/web/server/opencode/gen/client/utils.gen.ts
similarity index 100%
rename from server/opencode/gen/client/utils.gen.ts
rename to apps/web/server/opencode/gen/client/utils.gen.ts
diff --git a/server/opencode/gen/core/auth.gen.ts b/apps/web/server/opencode/gen/core/auth.gen.ts
similarity index 100%
rename from server/opencode/gen/core/auth.gen.ts
rename to apps/web/server/opencode/gen/core/auth.gen.ts
diff --git a/server/opencode/gen/core/bodySerializer.gen.ts b/apps/web/server/opencode/gen/core/bodySerializer.gen.ts
similarity index 100%
rename from server/opencode/gen/core/bodySerializer.gen.ts
rename to apps/web/server/opencode/gen/core/bodySerializer.gen.ts
diff --git a/server/opencode/gen/core/params.gen.ts b/apps/web/server/opencode/gen/core/params.gen.ts
similarity index 100%
rename from server/opencode/gen/core/params.gen.ts
rename to apps/web/server/opencode/gen/core/params.gen.ts
diff --git a/server/opencode/gen/core/pathSerializer.gen.ts b/apps/web/server/opencode/gen/core/pathSerializer.gen.ts
similarity index 100%
rename from server/opencode/gen/core/pathSerializer.gen.ts
rename to apps/web/server/opencode/gen/core/pathSerializer.gen.ts
diff --git a/server/opencode/gen/core/queryKeySerializer.gen.ts b/apps/web/server/opencode/gen/core/queryKeySerializer.gen.ts
similarity index 100%
rename from server/opencode/gen/core/queryKeySerializer.gen.ts
rename to apps/web/server/opencode/gen/core/queryKeySerializer.gen.ts
diff --git a/server/opencode/gen/core/serverSentEvents.gen.ts b/apps/web/server/opencode/gen/core/serverSentEvents.gen.ts
similarity index 100%
rename from server/opencode/gen/core/serverSentEvents.gen.ts
rename to apps/web/server/opencode/gen/core/serverSentEvents.gen.ts
diff --git a/server/opencode/gen/core/types.gen.ts b/apps/web/server/opencode/gen/core/types.gen.ts
similarity index 100%
rename from server/opencode/gen/core/types.gen.ts
rename to apps/web/server/opencode/gen/core/types.gen.ts
diff --git a/server/opencode/gen/core/utils.gen.ts b/apps/web/server/opencode/gen/core/utils.gen.ts
similarity index 100%
rename from server/opencode/gen/core/utils.gen.ts
rename to apps/web/server/opencode/gen/core/utils.gen.ts
diff --git a/server/opencode/gen/sdk.gen.ts b/apps/web/server/opencode/gen/sdk.gen.ts
similarity index 100%
rename from server/opencode/gen/sdk.gen.ts
rename to apps/web/server/opencode/gen/sdk.gen.ts
diff --git a/server/opencode/gen/types.gen.ts b/apps/web/server/opencode/gen/types.gen.ts
similarity index 100%
rename from server/opencode/gen/types.gen.ts
rename to apps/web/server/opencode/gen/types.gen.ts
diff --git a/server/opencode/index.ts b/apps/web/server/opencode/index.ts
similarity index 100%
rename from server/opencode/index.ts
rename to apps/web/server/opencode/index.ts
diff --git a/server/opencode/server.ts b/apps/web/server/opencode/server.ts
similarity index 100%
rename from server/opencode/server.ts
rename to apps/web/server/opencode/server.ts
diff --git a/server/opencode/v2/client.ts b/apps/web/server/opencode/v2/client.ts
similarity index 100%
rename from server/opencode/v2/client.ts
rename to apps/web/server/opencode/v2/client.ts
diff --git a/server/opencode/v2/gen/client.gen.ts b/apps/web/server/opencode/v2/gen/client.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/client.gen.ts
rename to apps/web/server/opencode/v2/gen/client.gen.ts
diff --git a/server/opencode/v2/gen/client/client.gen.ts b/apps/web/server/opencode/v2/gen/client/client.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/client/client.gen.ts
rename to apps/web/server/opencode/v2/gen/client/client.gen.ts
diff --git a/server/opencode/v2/gen/client/index.ts b/apps/web/server/opencode/v2/gen/client/index.ts
similarity index 100%
rename from server/opencode/v2/gen/client/index.ts
rename to apps/web/server/opencode/v2/gen/client/index.ts
diff --git a/server/opencode/v2/gen/client/types.gen.ts b/apps/web/server/opencode/v2/gen/client/types.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/client/types.gen.ts
rename to apps/web/server/opencode/v2/gen/client/types.gen.ts
diff --git a/server/opencode/v2/gen/client/utils.gen.ts b/apps/web/server/opencode/v2/gen/client/utils.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/client/utils.gen.ts
rename to apps/web/server/opencode/v2/gen/client/utils.gen.ts
diff --git a/server/opencode/v2/gen/core/auth.gen.ts b/apps/web/server/opencode/v2/gen/core/auth.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/core/auth.gen.ts
rename to apps/web/server/opencode/v2/gen/core/auth.gen.ts
diff --git a/server/opencode/v2/gen/core/bodySerializer.gen.ts b/apps/web/server/opencode/v2/gen/core/bodySerializer.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/core/bodySerializer.gen.ts
rename to apps/web/server/opencode/v2/gen/core/bodySerializer.gen.ts
diff --git a/server/opencode/v2/gen/core/params.gen.ts b/apps/web/server/opencode/v2/gen/core/params.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/core/params.gen.ts
rename to apps/web/server/opencode/v2/gen/core/params.gen.ts
diff --git a/server/opencode/v2/gen/core/pathSerializer.gen.ts b/apps/web/server/opencode/v2/gen/core/pathSerializer.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/core/pathSerializer.gen.ts
rename to apps/web/server/opencode/v2/gen/core/pathSerializer.gen.ts
diff --git a/server/opencode/v2/gen/core/queryKeySerializer.gen.ts b/apps/web/server/opencode/v2/gen/core/queryKeySerializer.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/core/queryKeySerializer.gen.ts
rename to apps/web/server/opencode/v2/gen/core/queryKeySerializer.gen.ts
diff --git a/server/opencode/v2/gen/core/serverSentEvents.gen.ts b/apps/web/server/opencode/v2/gen/core/serverSentEvents.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/core/serverSentEvents.gen.ts
rename to apps/web/server/opencode/v2/gen/core/serverSentEvents.gen.ts
diff --git a/server/opencode/v2/gen/core/types.gen.ts b/apps/web/server/opencode/v2/gen/core/types.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/core/types.gen.ts
rename to apps/web/server/opencode/v2/gen/core/types.gen.ts
diff --git a/server/opencode/v2/gen/core/utils.gen.ts b/apps/web/server/opencode/v2/gen/core/utils.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/core/utils.gen.ts
rename to apps/web/server/opencode/v2/gen/core/utils.gen.ts
diff --git a/server/opencode/v2/gen/sdk.gen.ts b/apps/web/server/opencode/v2/gen/sdk.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/sdk.gen.ts
rename to apps/web/server/opencode/v2/gen/sdk.gen.ts
diff --git a/server/opencode/v2/gen/types.gen.ts b/apps/web/server/opencode/v2/gen/types.gen.ts
similarity index 100%
rename from server/opencode/v2/gen/types.gen.ts
rename to apps/web/server/opencode/v2/gen/types.gen.ts
diff --git a/server/opencode/v2/index.ts b/apps/web/server/opencode/v2/index.ts
similarity index 100%
rename from server/opencode/v2/index.ts
rename to apps/web/server/opencode/v2/index.ts
diff --git a/server/opencode/v2/server.ts b/apps/web/server/opencode/v2/server.ts
similarity index 100%
rename from server/opencode/v2/server.ts
rename to apps/web/server/opencode/v2/server.ts
diff --git a/server/plugins/port-file.ts b/apps/web/server/plugins/port-file.ts
similarity index 100%
rename from server/plugins/port-file.ts
rename to apps/web/server/plugins/port-file.ts
diff --git a/server/utils/codex-client.ts b/apps/web/server/utils/codex-client.ts
similarity index 100%
rename from server/utils/codex-client.ts
rename to apps/web/server/utils/codex-client.ts
diff --git a/server/utils/copilot-client.ts b/apps/web/server/utils/copilot-client.ts
similarity index 100%
rename from server/utils/copilot-client.ts
rename to apps/web/server/utils/copilot-client.ts
diff --git a/apps/web/server/utils/gemini-client.ts b/apps/web/server/utils/gemini-client.ts
new file mode 100644
index 00000000..4c9538d2
--- /dev/null
+++ b/apps/web/server/utils/gemini-client.ts
@@ -0,0 +1,393 @@
+import { spawn } from 'node:child_process'
+import { resolveGeminiCli } from './resolve-gemini-cli'
+
+type ThinkingMode = 'adaptive' | 'disabled' | 'enabled'
+type ThinkingEffort = 'low' | 'medium' | 'high' | 'max'
+
+export interface GeminiExecOptions {
+ model?: string
+ systemPrompt?: string
+ thinkingMode?: ThinkingMode
+ thinkingBudgetTokens?: number
+ effort?: ThinkingEffort
+ timeoutMs?: number
+}
+
+interface GeminiCliResult {
+ text?: string
+ error?: string
+}
+
+const DEFAULT_GEMINI_TIMEOUT_MS = 15 * 60 * 1000
+
+/**
+ * Allowlist-based env filter for Gemini CLI subprocess.
+ * Passes through safe system vars and Google/Gemini-specific prefixes.
+ */
+const GEMINI_ENV_ALLOWLIST = new Set([
+ 'PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR',
+ // Windows-essential
+ 'SYSTEMROOT', 'COMSPEC', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
+ 'PATHEXT', 'SYSTEMDRIVE', 'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH',
+])
+
+function filterGeminiEnv(
+ env: Record,
+): Record {
+ const result: Record = {}
+ for (const [k, v] of Object.entries(env)) {
+ if (
+ GEMINI_ENV_ALLOWLIST.has(k)
+ || k.startsWith('GOOGLE_')
+ || k.startsWith('GEMINI_')
+ || k.startsWith('GCLOUD_')
+ ) {
+ result[k] = v
+ }
+ }
+ return result
+}
+
+/**
+ * Run Gemini CLI in non-interactive mode with JSON output.
+ * Passes prompt via stdin to avoid command-line length limits.
+ * The CLI handles its own authentication (OAuth or API key).
+ */
+export async function runGeminiExec(
+ userPrompt: string,
+ options: GeminiExecOptions = {},
+): Promise {
+ const binPath = resolveGeminiCli()
+ if (!binPath) {
+ return { error: 'Gemini CLI not found. Install it first.' }
+ }
+
+ const prompt = buildPrompt(options.systemPrompt, userPrompt)
+
+ const args = [
+ '-o', 'json',
+ '--approval-mode', 'plan',
+ ]
+
+ if (options.model) {
+ args.push('-m', options.model)
+ }
+
+ // Use -p with a minimal marker; full prompt piped via stdin.
+ // Gemini CLI appends -p value after stdin content.
+ args.push('-p', ' ')
+
+ try {
+ const result = await executeGeminiCommand(
+ binPath,
+ args,
+ options.timeoutMs ?? DEFAULT_GEMINI_TIMEOUT_MS,
+ prompt,
+ )
+ return result
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : 'Gemini execution failed' }
+ }
+}
+
+/**
+ * Stream Gemini CLI output in real-time using `stream-json` format.
+ * Passes prompt via stdin. Yields text deltas as they arrive.
+ */
+export function streamGeminiExec(
+ userPrompt: string,
+ options: GeminiExecOptions = {},
+): {
+ stream: AsyncGenerator<{ type: 'text' | 'error' | 'done'; content: string }>
+ kill: () => void
+} {
+ const binPath = resolveGeminiCli()
+ if (!binPath) {
+ return {
+ stream: (async function* () {
+ yield { type: 'error' as const, content: 'Gemini CLI not found.' }
+ })(),
+ kill: () => {},
+ }
+ }
+
+ const prompt = buildPrompt(options.systemPrompt, userPrompt)
+
+ const args = [
+ '-o', 'stream-json',
+ '--approval-mode', 'plan',
+ ]
+
+ if (options.model) {
+ args.push('-m', options.model)
+ }
+
+ // Use -p with minimal marker; full prompt piped via stdin.
+ args.push('-p', ' ')
+
+ const child = spawn(binPath, args, {
+ env: filterGeminiEnv(process.env as Record),
+ stdio: ['pipe', 'pipe', 'pipe'],
+ ...(process.platform === 'win32' && { shell: true }),
+ })
+
+ // Pipe prompt via stdin
+ if (child.stdin) {
+ child.stdin.write(prompt)
+ child.stdin.end()
+ }
+
+ const timeoutMs = options.timeoutMs ?? DEFAULT_GEMINI_TIMEOUT_MS
+ const timer = setTimeout(() => {
+ child.kill('SIGTERM')
+ }, timeoutMs)
+
+ async function* generateStream(): AsyncGenerator<{ type: 'text' | 'error' | 'done'; content: string }> {
+ let buffer = ''
+
+ child.stderr?.on('data', () => { /* discard stderr */ })
+
+ try {
+ for await (const chunk of child.stdout!) {
+ buffer += chunk.toString('utf-8')
+ let idx = buffer.indexOf('\n')
+ while (idx >= 0) {
+ const line = buffer.slice(0, idx).trim()
+ buffer = buffer.slice(idx + 1)
+ if (line) {
+ const event = parseStreamJsonLine(line)
+ if (event) yield event
+ }
+ idx = buffer.indexOf('\n')
+ }
+ }
+
+ // Flush remaining buffer
+ const tail = buffer.trim()
+ if (tail) {
+ const event = parseStreamJsonLine(tail)
+ if (event) yield event
+ }
+
+ yield { type: 'done', content: '' }
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : 'Stream error'
+ yield { type: 'error', content: msg }
+ } finally {
+ clearTimeout(timer)
+ }
+ }
+
+ return {
+ stream: generateStream(),
+ kill: () => {
+ clearTimeout(timer)
+ child.kill('SIGTERM')
+ },
+ }
+}
+
+function buildPrompt(systemPrompt: string | undefined, userPrompt: string): string {
+ const userText = userPrompt.trim()
+ if (!systemPrompt?.trim()) return userText
+
+ return [
+ 'You are a design generation assistant. Follow the guidelines below to produce the requested output.',
+ '',
+ '--- GUIDELINES ---',
+ systemPrompt.trim(),
+ '',
+ '--- TASK ---',
+ userText,
+ ].join('\n')
+}
+
+async function executeGeminiCommand(
+ binPath: string,
+ args: string[],
+ timeoutMs: number,
+ stdinText?: string,
+): Promise {
+ return await new Promise((resolve, reject) => {
+ const child = spawn(binPath, args, {
+ env: filterGeminiEnv(process.env as Record),
+ stdio: [stdinText ? 'pipe' : 'ignore', 'pipe', 'pipe'],
+ ...(process.platform === 'win32' && { shell: true }),
+ })
+
+ // Pipe prompt via stdin
+ if (stdinText && child.stdin) {
+ child.stdin.write(stdinText)
+ child.stdin.end()
+ }
+
+ let stdoutBuffer = ''
+ let stderrBuffer = ''
+
+ const timer = setTimeout(() => {
+ child.kill('SIGTERM')
+ reject(new Error(`Gemini request timed out after ${Math.round(timeoutMs / 1000)}s.`))
+ }, timeoutMs)
+
+ child.stdout!.on('data', (chunk: Buffer) => {
+ stdoutBuffer += chunk.toString('utf-8')
+ })
+
+ child.stderr!.on('data', (chunk: Buffer) => {
+ stderrBuffer += chunk.toString('utf-8')
+ })
+
+ child.on('error', (err) => {
+ clearTimeout(timer)
+ reject(err)
+ })
+
+ child.on('close', (code) => {
+ clearTimeout(timer)
+
+ // Parse JSON output — Gemini CLI always outputs a JSON object at the end of stdout.
+ // Error text / stack traces may appear before it.
+ const parsed = parseGeminiJsonOutput(stdoutBuffer)
+
+ if (parsed) {
+ if (parsed.response) {
+ resolve({ text: parsed.response })
+ return
+ }
+ if (parsed.errorMessage) {
+ resolve({ error: friendlyGeminiApiError(parsed.errorMessage) })
+ return
+ }
+ }
+
+ if (code !== 0) {
+ // Extract meaningful error from stderr or stdout
+ const errorMsg = extractGeminiError(stdoutBuffer, stderrBuffer)
+ resolve({ error: errorMsg || `Gemini exited with code ${code ?? 'unknown'}.` })
+ return
+ }
+
+ const raw = stdoutBuffer.trim()
+ resolve(raw ? { text: raw } : { error: 'Gemini returned no output.' })
+ })
+ })
+}
+
+/**
+ * Parse Gemini CLI JSON output.
+ * The CLI may print error text before the final JSON object.
+ * We search from the END of stdout for the last valid JSON block.
+ */
+function parseGeminiJsonOutput(raw: string): { response?: string; errorMessage?: string } | null {
+ const trimmed = raw.trim()
+ if (!trimmed) return null
+
+ // Search backwards for the last top-level JSON object (starts with `{` at line beginning)
+ const lines = trimmed.split('\n')
+ for (let i = lines.length - 1; i >= 0; i--) {
+ const line = lines[i].trim()
+ if (!line.startsWith('{')) continue
+
+ // Try to parse from this line to the end
+ const candidate = lines.slice(i).join('\n').trim()
+ try {
+ const data = JSON.parse(candidate) as Record
+ // Must have session_id to be a valid Gemini CLI response
+ if (!data.session_id && !data.response && !data.error) continue
+
+ const response = typeof data.response === 'string' ? data.response : undefined
+
+ // error can be a string or an object { type, message, code }
+ let errorMessage: string | undefined
+ if (data.error) {
+ if (typeof data.error === 'string') {
+ errorMessage = data.error
+ } else if (typeof data.error === 'object' && data.error !== null) {
+ const errObj = data.error as Record
+ errorMessage = typeof errObj.message === 'string' ? errObj.message : JSON.stringify(data.error)
+ }
+ }
+
+ return { response, errorMessage }
+ } catch { /* not valid JSON from this point */ }
+ }
+
+ return null
+}
+
+/** Extract a human-readable error from Gemini CLI stdout/stderr */
+function extractGeminiError(stdout: string, stderr: string): string | null {
+ // Look for quota errors
+ const quotaMatch = stdout.match(/quota will reset after (\S+)/i)
+ || stderr.match(/quota will reset after (\S+)/i)
+ if (quotaMatch) {
+ return `Gemini quota exhausted. Resets after ${quotaMatch[1]}.`
+ }
+
+ // Look for TerminalQuotaError or other named errors
+ const namedError = stdout.match(/(Terminal\w+Error|ApiError|AuthError):\s*(.+)/m)
+ if (namedError) {
+ return namedError[2].trim()
+ }
+
+ // Stderr fallback
+ const stderrTrimmed = stderr.trim()
+ if (stderrTrimmed) return stderrTrimmed
+
+ return null
+}
+
+/** Map raw Gemini API errors to user-friendly messages */
+function friendlyGeminiApiError(raw: string): string {
+ if (/quota|exhausted|429|capacity/i.test(raw)) {
+ const resetMatch = raw.match(/reset after (\S+)/i)
+ return resetMatch
+ ? `Gemini quota exhausted. Resets after ${resetMatch[1]}.`
+ : 'Gemini quota exhausted. Please wait and try again.'
+ }
+ if (/401|unauthenticated|auth/i.test(raw)) {
+ return 'Gemini auth expired. Run "gemini" in your terminal to re-authenticate.'
+ }
+ if (/\[object Object\]/.test(raw)) {
+ return 'Gemini API error. Check your quota or try a different model.'
+ }
+ return raw
+}
+
+function parseStreamJsonLine(
+ line: string,
+): { type: 'text' | 'error' | 'done'; content: string } | null {
+ // Skip non-JSON lines (e.g. "Loaded cached credentials.")
+ if (!line.startsWith('{')) return null
+
+ let parsed: Record
+ try {
+ parsed = JSON.parse(line) as Record
+ } catch {
+ return null
+ }
+
+ const type = typeof parsed.type === 'string' ? parsed.type : ''
+
+ if (type === 'message' && parsed.role === 'assistant') {
+ const content = typeof parsed.content === 'string' ? parsed.content : ''
+ if (content) return { type: 'text', content }
+ }
+
+ if (type === 'result') {
+ // Check for error in result event
+ if (parsed.status === 'error' && parsed.error) {
+ const errObj = parsed.error as Record
+ const msg = typeof errObj.message === 'string' ? errObj.message : 'Unknown error'
+ return { type: 'error', content: friendlyGeminiApiError(msg) }
+ }
+ return null
+ }
+
+ if (type === 'error') {
+ const content = typeof parsed.message === 'string' ? parsed.message : 'Unknown error'
+ return { type: 'error', content: friendlyGeminiApiError(content) }
+ }
+
+ return null
+}
diff --git a/server/utils/mcp-server-manager.ts b/apps/web/server/utils/mcp-server-manager.ts
similarity index 100%
rename from server/utils/mcp-server-manager.ts
rename to apps/web/server/utils/mcp-server-manager.ts
diff --git a/server/utils/mcp-sync-state.ts b/apps/web/server/utils/mcp-sync-state.ts
similarity index 100%
rename from server/utils/mcp-sync-state.ts
rename to apps/web/server/utils/mcp-sync-state.ts
diff --git a/server/utils/opencode-client.ts b/apps/web/server/utils/opencode-client.ts
similarity index 100%
rename from server/utils/opencode-client.ts
rename to apps/web/server/utils/opencode-client.ts
diff --git a/server/utils/resolve-claude-agent-env.ts b/apps/web/server/utils/resolve-claude-agent-env.ts
similarity index 84%
rename from server/utils/resolve-claude-agent-env.ts
rename to apps/web/server/utils/resolve-claude-agent-env.ts
index 8c86fdc4..a1adcecf 100644
--- a/server/utils/resolve-claude-agent-env.ts
+++ b/apps/web/server/utils/resolve-claude-agent-env.ts
@@ -122,17 +122,36 @@ export function getClaudeAgentDebugFilePath(): string | undefined {
/**
* Custom spawnClaudeCodeProcess for Windows.
* On Windows, npm-installed CLIs are .cmd/.ps1 scripts that can't be spawned
- * directly without a shell. Uses PowerShell to avoid cmd.exe's 8191-char limit.
+ * directly without a shell.
+ *
+ * - `.cmd` files: use `cmd.exe /c` (PowerShell can't run .cmd directly)
+ * - `.ps1` files: use `powershell.exe`
+ * - `.exe` files or others: use `cmd.exe /c` as safe default
*/
export function buildSpawnClaudeCodeProcess() {
if (process.platform !== 'win32') return undefined
return (options: { command: string; args: string[]; cwd?: string; env: Record; signal: AbortSignal }) => {
- return spawn(options.command, options.args, {
+ const cmd = options.command
+ const isPowerShell = cmd.endsWith('.ps1')
+
+ if (isPowerShell) {
+ // For .ps1 scripts, invoke via PowerShell
+ const psArgs = ['-ExecutionPolicy', 'Bypass', '-File', cmd, ...options.args]
+ return spawn('powershell.exe', psArgs, {
+ cwd: options.cwd,
+ env: options.env as NodeJS.ProcessEnv,
+ signal: options.signal,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ })
+ }
+
+ // For .cmd, .exe, or extensionless binaries, use cmd.exe /c
+ return spawn(cmd, options.args, {
cwd: options.cwd,
env: options.env as NodeJS.ProcessEnv,
signal: options.signal,
stdio: ['pipe', 'pipe', 'pipe'],
- shell: 'powershell.exe',
+ shell: true,
})
}
}
diff --git a/server/utils/resolve-claude-cli.ts b/apps/web/server/utils/resolve-claude-cli.ts
similarity index 100%
rename from server/utils/resolve-claude-cli.ts
rename to apps/web/server/utils/resolve-claude-cli.ts
diff --git a/apps/web/server/utils/resolve-gemini-cli.ts b/apps/web/server/utils/resolve-gemini-cli.ts
new file mode 100644
index 00000000..44e3c286
--- /dev/null
+++ b/apps/web/server/utils/resolve-gemini-cli.ts
@@ -0,0 +1,93 @@
+import { execSync } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { homedir } from 'node:os'
+import { serverLog } from './server-logger'
+
+const isWindows = process.platform === 'win32'
+
+/** Windows npm global installs may create .cmd or .ps1 wrappers — try both */
+function winNpmCandidates(dir: string, name: string): string[] {
+ return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]
+}
+
+/** On Windows, `where` may return an extensionless shell script — prefer .cmd/.ps1 */
+function resolveWinExtension(binPath: string): string {
+ if (!isWindows) return binPath
+ if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath
+ for (const ext of ['.cmd', '.ps1']) {
+ if (existsSync(binPath + ext)) return binPath + ext
+ }
+ return binPath
+}
+
+/** Resolve the Gemini CLI binary path across macOS, Linux, and Windows. */
+export function resolveGeminiCli(): string | undefined {
+ serverLog.info(`[resolve-gemini] platform=${process.platform}, isWindows=${isWindows}`)
+
+ // 1. Try PATH lookup
+ try {
+ const cmd = isWindows ? 'where gemini 2>nul' : 'which gemini 2>/dev/null'
+ serverLog.info(`[resolve-gemini] PATH lookup: ${cmd}`)
+ const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim()
+ // `where` on Windows may return multiple lines
+ const path = result.split(/\r?\n/)[0]?.trim()
+ serverLog.info(`[resolve-gemini] PATH result: "${path}" (exists=${path ? existsSync(path) : false})`)
+ if (path && existsSync(path)) return resolveWinExtension(path)
+ } catch (err) {
+ serverLog.info(`[resolve-gemini] PATH lookup failed: ${err instanceof Error ? err.message : err}`)
+ }
+
+ // 2. Try `npm prefix -g` (Windows uses npm.cmd; Unix uses npm)
+ try {
+ const npmCmd = isWindows ? 'npm.cmd prefix -g' : 'npm prefix -g'
+ serverLog.info(`[resolve-gemini] npm prefix lookup: ${npmCmd}`)
+ const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim()
+ serverLog.info(`[resolve-gemini] npm global prefix: "${prefix}"`)
+ if (prefix) {
+ if (isWindows) {
+ for (const bin of winNpmCandidates(prefix, 'gemini')) {
+ serverLog.info(`[resolve-gemini] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
+ if (existsSync(bin)) return bin
+ }
+ } else {
+ const bin = join(prefix, 'bin', 'gemini')
+ serverLog.info(`[resolve-gemini] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
+ if (existsSync(bin)) return bin
+ }
+ }
+ } catch (err) {
+ serverLog.info(`[resolve-gemini] npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
+ }
+
+ // 3. Common install locations
+ const home = homedir()
+ const candidates = isWindows
+ ? [
+ // npm global (.cmd + .ps1)
+ ...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'gemini'),
+ // nvm-windows / fnm
+ ...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'gemini'),
+ ...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'gemini'),
+ // winget / native
+ join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'gemini.exe'),
+ ]
+ : [
+ // npm global
+ '/usr/local/bin/gemini',
+ // Homebrew (macOS)
+ '/opt/homebrew/bin/gemini',
+ // User-local
+ join(home, '.local', 'bin', 'gemini'),
+ join(home, '.npm-global', 'bin', 'gemini'),
+ ]
+
+ for (const c of candidates) {
+ const exists = c ? existsSync(c) : false
+ serverLog.info(`[resolve-gemini] candidate: "${c}" (exists=${exists})`)
+ if (c && exists) return c
+ }
+
+ serverLog.warn('[resolve-gemini] no gemini binary found')
+ return undefined
+}
diff --git a/server/utils/server-logger.ts b/apps/web/server/utils/server-logger.ts
similarity index 100%
rename from server/utils/server-logger.ts
rename to apps/web/server/utils/server-logger.ts
diff --git a/src/canvas/agent-indicator.ts b/apps/web/src/canvas/agent-indicator.ts
similarity index 100%
rename from src/canvas/agent-indicator.ts
rename to apps/web/src/canvas/agent-indicator.ts
diff --git a/apps/web/src/canvas/canvas-constants.ts b/apps/web/src/canvas/canvas-constants.ts
new file mode 100644
index 00000000..0a264953
--- /dev/null
+++ b/apps/web/src/canvas/canvas-constants.ts
@@ -0,0 +1,49 @@
+// Re-export pure constants from @zseven-w/pen-core
+export {
+ MIN_ZOOM,
+ MAX_ZOOM,
+ ZOOM_STEP,
+ SNAP_THRESHOLD,
+ DEFAULT_FILL,
+ DEFAULT_STROKE,
+ DEFAULT_STROKE_WIDTH,
+ CANVAS_BACKGROUND_LIGHT,
+ CANVAS_BACKGROUND_DARK,
+ SELECTION_BLUE,
+ COMPONENT_COLOR,
+ INSTANCE_COLOR,
+ HOVER_BLUE,
+ HOVER_LINE_WIDTH,
+ HOVER_DASH,
+ INDICATOR_BLUE,
+ INDICATOR_LINE_WIDTH,
+ INDICATOR_DASH,
+ INDICATOR_ENDPOINT_RADIUS,
+ FRAME_LABEL_FONT_SIZE,
+ FRAME_LABEL_OFFSET_Y,
+ FRAME_LABEL_COLOR,
+ PEN_ANCHOR_FILL,
+ PEN_ANCHOR_RADIUS,
+ PEN_ANCHOR_FIRST_RADIUS,
+ PEN_HANDLE_DOT_RADIUS,
+ PEN_HANDLE_LINE_STROKE,
+ PEN_RUBBER_BAND_STROKE,
+ PEN_RUBBER_BAND_DASH,
+ PEN_CLOSE_HIT_THRESHOLD,
+ DIMENSION_LABEL_OFFSET_Y,
+ DEFAULT_FRAME_FILL,
+ DEFAULT_TEXT_FILL,
+ GUIDE_COLOR,
+ GUIDE_LINE_WIDTH,
+ GUIDE_DASH,
+} from '@zseven-w/pen-core'
+
+import { CANVAS_BACKGROUND_LIGHT, CANVAS_BACKGROUND_DARK } from '@zseven-w/pen-core'
+
+// Browser-only function — not in pen-core
+export function getCanvasBackground(): string {
+ if (typeof document === 'undefined') return CANVAS_BACKGROUND_DARK
+ return document.documentElement.classList.contains('light')
+ ? CANVAS_BACKGROUND_LIGHT
+ : CANVAS_BACKGROUND_DARK
+}
diff --git a/apps/web/src/canvas/canvas-layout-engine.ts b/apps/web/src/canvas/canvas-layout-engine.ts
new file mode 100644
index 00000000..1c45ec9c
--- /dev/null
+++ b/apps/web/src/canvas/canvas-layout-engine.ts
@@ -0,0 +1,15 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export {
+ type Padding,
+ resolvePadding,
+ isNodeVisible,
+ setRootChildrenProvider,
+ getRootFillWidthFallback,
+ inferLayout,
+ fitContentWidth,
+ fitContentHeight,
+ getNodeWidth,
+ getNodeHeight,
+ computeLayoutPositions,
+ estimateLineWidth,
+} from '@zseven-w/pen-core'
diff --git a/src/canvas/canvas-node-creator.ts b/apps/web/src/canvas/canvas-node-creator.ts
similarity index 100%
rename from src/canvas/canvas-node-creator.ts
rename to apps/web/src/canvas/canvas-node-creator.ts
diff --git a/apps/web/src/canvas/canvas-sync-lock.ts b/apps/web/src/canvas/canvas-sync-lock.ts
new file mode 100644
index 00000000..f584423f
--- /dev/null
+++ b/apps/web/src/canvas/canvas-sync-lock.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export { isFabricSyncLocked, setFabricSyncLock } from '@zseven-w/pen-core'
diff --git a/src/canvas/canvas-sync-utils.ts b/apps/web/src/canvas/canvas-sync-utils.ts
similarity index 100%
rename from src/canvas/canvas-sync-utils.ts
rename to apps/web/src/canvas/canvas-sync-utils.ts
diff --git a/apps/web/src/canvas/canvas-text-measure.ts b/apps/web/src/canvas/canvas-text-measure.ts
new file mode 100644
index 00000000..f1de4fc0
--- /dev/null
+++ b/apps/web/src/canvas/canvas-text-measure.ts
@@ -0,0 +1,110 @@
+// Re-export pure functions from @zseven-w/pen-core
+export {
+ parseSizing,
+ defaultLineHeight,
+ isCjkCodePoint,
+ hasCjkText,
+ estimateGlyphWidth,
+ estimateLineWidth,
+ widthSafetyFactor,
+ estimateTextWidth,
+ estimateTextWidthPrecise,
+ resolveTextContent,
+ countExplicitTextLines,
+ getTextOpticalCenterYOffset,
+ estimateTextHeight,
+ setWrappedLineCounter,
+} from '@zseven-w/pen-core'
+
+import {
+ isCjkCodePoint,
+ estimateLineWidth,
+ widthSafetyFactor,
+ setWrappedLineCounter,
+} from '@zseven-w/pen-core'
+import { cssFontFamily } from './font-utils'
+
+// ---------------------------------------------------------------------------
+// Canvas 2D measurement context (lazy singleton, browser-only)
+// Wire up the browser-based wrapped line counter at module load time.
+// ---------------------------------------------------------------------------
+
+let _textMeasureCtx: CanvasRenderingContext2D | null = null
+function getTextMeasureCtx(): CanvasRenderingContext2D | null {
+ if (typeof document === 'undefined') return null
+ if (!_textMeasureCtx) {
+ const c = document.createElement('canvas')
+ _textMeasureCtx = c.getContext('2d')
+ }
+ return _textMeasureCtx
+}
+
+function countWrappedLinesCanvas2D(
+ rawLines: string[],
+ wrapWidth: number,
+ fontSize: number,
+ fontWeight: string | number | undefined,
+ fontFamily: string,
+ letterSpacing: number,
+): number {
+ const ctx = getTextMeasureCtx()
+ if (!ctx) {
+ return rawLines.reduce((sum, line) => {
+ const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line)
+ return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth))
+ }, 0)
+ }
+
+ const fw = typeof fontWeight === 'number' ? String(fontWeight) : (fontWeight ?? '400')
+ ctx.font = `${fw} ${fontSize}px ${cssFontFamily(fontFamily)}`
+
+ let total = 0
+ for (const rawLine of rawLines) {
+ if (!rawLine) { total += 1; continue }
+ if (ctx.measureText(rawLine).width <= wrapWidth) { total += 1; continue }
+ let lineCount = 0
+ let current = ''
+ let i = 0
+ while (i < rawLine.length) {
+ const ch = rawLine[i]
+ if (isCjkCodePoint(ch.codePointAt(0) ?? 0)) {
+ const test = current + ch
+ if (ctx.measureText(test).width > wrapWidth && current) {
+ lineCount++
+ current = ch
+ } else {
+ current = test
+ }
+ i++
+ } else if (ch === ' ') {
+ const test = current + ch
+ if (ctx.measureText(test).width > wrapWidth && current) {
+ lineCount++
+ current = ''
+ } else {
+ current = test
+ }
+ i++
+ } else {
+ let word = ''
+ while (i < rawLine.length && rawLine[i] !== ' ' && !isCjkCodePoint(rawLine[i].codePointAt(0) ?? 0)) {
+ word += rawLine[i]
+ i++
+ }
+ const test = current + word
+ if (ctx.measureText(test).width > wrapWidth && current) {
+ lineCount++
+ current = word
+ } else {
+ current = test
+ }
+ }
+ }
+ if (current) lineCount++
+ total += Math.max(1, lineCount)
+ }
+ return total
+}
+
+// Register the Canvas 2D counter so pen-core's estimateTextHeight uses it
+setWrappedLineCounter(countWrappedLinesCanvas2D)
diff --git a/apps/web/src/canvas/font-utils.ts b/apps/web/src/canvas/font-utils.ts
new file mode 100644
index 00000000..d9f6c33f
--- /dev/null
+++ b/apps/web/src/canvas/font-utils.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export { cssFontFamily } from '@zseven-w/pen-core'
diff --git a/src/canvas/insertion-indicator.ts b/apps/web/src/canvas/insertion-indicator.ts
similarity index 100%
rename from src/canvas/insertion-indicator.ts
rename to apps/web/src/canvas/insertion-indicator.ts
diff --git a/apps/web/src/canvas/node-helpers.ts b/apps/web/src/canvas/node-helpers.ts
new file mode 100644
index 00000000..f6f80e24
--- /dev/null
+++ b/apps/web/src/canvas/node-helpers.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export { isBadgeOverlayNode } from '@zseven-w/pen-core'
diff --git a/src/canvas/selection-context.ts b/apps/web/src/canvas/selection-context.ts
similarity index 100%
rename from src/canvas/selection-context.ts
rename to apps/web/src/canvas/selection-context.ts
diff --git a/src/canvas/skia-engine-ref.ts b/apps/web/src/canvas/skia-engine-ref.ts
similarity index 100%
rename from src/canvas/skia-engine-ref.ts
rename to apps/web/src/canvas/skia-engine-ref.ts
diff --git a/apps/web/src/canvas/skia/skia-canvas.tsx b/apps/web/src/canvas/skia/skia-canvas.tsx
new file mode 100644
index 00000000..79dd1655
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-canvas.tsx
@@ -0,0 +1,199 @@
+import { useRef, useEffect, useState } from 'react'
+import { loadCanvasKit } from './skia-init'
+import { SkiaEngine } from './skia-engine'
+import { useCanvasStore } from '@/stores/canvas-store'
+import { useDocumentStore } from '@/stores/document-store'
+import { setSkiaEngineRef } from '../skia-engine-ref'
+import type { PenNode } from '@/types/pen'
+import { SkiaInteractionManager, type TextEditState } from './skia-interaction'
+
+export default function SkiaCanvas() {
+ const canvasRef = useRef(null)
+ const containerRef = useRef(null)
+ const engineRef = useRef(null)
+ const [error, setError] = useState(null)
+ const [editingText, setEditingText] = useState(null)
+
+ // Initialize CanvasKit + engine
+ useEffect(() => {
+ let disposed = false
+
+ async function init() {
+ try {
+ const ck = await loadCanvasKit()
+ if (disposed) return
+
+ const canvasEl = canvasRef.current
+ if (!canvasEl) return
+
+ const engine = new SkiaEngine(ck)
+ engine.init(canvasEl)
+ engineRef.current = engine
+ setSkiaEngineRef(engine)
+
+ // Initial sync
+ engine.syncFromDocument()
+ requestAnimationFrame(() => engine.zoomToFitContent())
+
+ } catch (err) {
+ console.error('SkiaCanvas init failed:', err)
+ setError(String(err))
+ }
+ }
+
+ init()
+
+ return () => {
+ disposed = true
+ setSkiaEngineRef(null)
+ engineRef.current?.dispose()
+ engineRef.current = null
+ }
+ }, [])
+
+ // Resize observer
+ useEffect(() => {
+ const container = containerRef.current
+ if (!container) return
+ const observer = new ResizeObserver((entries) => {
+ const engine = engineRef.current
+ if (!engine) return
+ for (const entry of entries) {
+ const { width, height } = entry.contentRect
+ engine.resize(width, height)
+ }
+ })
+ observer.observe(container)
+ return () => observer.disconnect()
+ }, [])
+
+ // Document sync: re-render when document changes
+ useEffect(() => {
+ const unsub = useDocumentStore.subscribe(() => {
+ engineRef.current?.syncFromDocument()
+ })
+ return unsub
+ }, [])
+
+ // Page sync: re-render when active page changes
+ useEffect(() => {
+ let prevPageId = useCanvasStore.getState().activePageId
+ const unsub = useCanvasStore.subscribe((state) => {
+ if (state.activePageId !== prevPageId) {
+ prevPageId = state.activePageId
+ engineRef.current?.syncFromDocument()
+ }
+ })
+ return unsub
+ }, [])
+
+ // Selection sync: re-render when selection changes
+ useEffect(() => {
+ let prevIds = useCanvasStore.getState().selection.selectedIds
+ const unsub = useCanvasStore.subscribe((state) => {
+ if (state.selection.selectedIds !== prevIds) {
+ prevIds = state.selection.selectedIds
+ engineRef.current?.markDirty()
+ }
+ })
+ return unsub
+ }, [])
+
+ // Wheel: zoom + pan
+ useEffect(() => {
+ const canvasEl = canvasRef.current
+ if (!canvasEl) return
+
+ const handleWheel = (e: WheelEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ const engine = engineRef.current
+ if (!engine) return
+
+ if (e.ctrlKey || e.metaKey) {
+ let delta = -e.deltaY
+ if (e.deltaMode === 1) delta *= 40
+ const factor = Math.pow(1.005, delta)
+ const newZoom = engine.zoom * factor
+ engine.zoomToPoint(e.clientX, e.clientY, newZoom)
+ } else {
+ let dx = -e.deltaX
+ let dy = -e.deltaY
+ if (e.deltaMode === 1) { dx *= 40; dy *= 40 }
+ engine.pan(dx, dy)
+ }
+ }
+
+ canvasEl.addEventListener('wheel', handleWheel, { passive: false })
+ return () => canvasEl.removeEventListener('wheel', handleWheel)
+ }, [])
+
+ // Mouse/keyboard interactions (select, move, resize, draw, hover, etc.)
+ useEffect(() => {
+ const canvasEl = canvasRef.current
+ if (!canvasEl) return
+
+ const manager = new SkiaInteractionManager(engineRef, canvasEl, setEditingText)
+ return manager.attach()
+ }, [])
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/canvas/skia/skia-engine.ts b/apps/web/src/canvas/skia/skia-engine.ts
new file mode 100644
index 00000000..5cc675d5
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-engine.ts
@@ -0,0 +1,424 @@
+import type { CanvasKit, Surface } from 'canvaskit-wasm'
+import type { EllipseNode } from '@/types/pen'
+import { useCanvasStore } from '@/stores/canvas-store'
+import { useDocumentStore, getActivePageChildren, getAllChildren } from '@/stores/document-store'
+import { resolveNodeForCanvas, getDefaultTheme } from '@/variables/resolve-variables'
+import { getCanvasBackground, MIN_ZOOM, MAX_ZOOM } from '../canvas-constants'
+import { setRootChildrenProvider } from '../canvas-layout-engine'
+import { SkiaRenderer, type RenderNode } from './skia-renderer'
+import {
+ SpatialIndex,
+ parseColor,
+ viewportMatrix,
+ zoomToPoint as vpZoomToPoint,
+ flattenToRenderNodes,
+ resolveRefs,
+ premeasureTextHeights,
+ collectReusableIds,
+ collectInstanceIds,
+ getViewportBounds,
+ isRectInViewport,
+} from '@zseven-w/pen-renderer'
+import {
+ getActiveAgentIndicators,
+ getActiveAgentFrames,
+ isPreviewNode,
+} from '../agent-indicator'
+import { isNodeBorderReady, getNodeRevealTime } from '@/services/ai/design-animation'
+import { lookupIconByName } from '@/services/ai/icon-resolver'
+
+// Re-export for use by canvas component
+export { screenToScene } from '@zseven-w/pen-renderer'
+export { SpatialIndex } from '@zseven-w/pen-renderer'
+
+// ---------------------------------------------------------------------------
+// SkiaEngine — ties rendering, viewport, hit testing together
+// ---------------------------------------------------------------------------
+
+export class SkiaEngine {
+ ck: CanvasKit
+ surface: Surface | null = null
+ renderer: SkiaRenderer
+ spatialIndex = new SpatialIndex()
+ renderNodes: RenderNode[] = []
+
+ // Component/instance IDs for colored frame labels
+ private reusableIds = new Set()
+ private instanceIds = new Set()
+
+ // Agent animation: track start time so glow only pulses ~2 times
+ private agentAnimStart = 0
+
+ private canvasEl: HTMLCanvasElement | null = null
+ private animFrameId = 0
+ private dirty = true
+
+ // Viewport
+ zoom = 1
+ panX = 0
+ panY = 0
+
+ // Drag suppression — prevents syncFromDocument during drag
+ // so the layout engine doesn't override visual positions
+ dragSyncSuppressed = false
+
+ // Interaction state
+ hoveredNodeId: string | null = null
+ marquee: { x1: number; y1: number; x2: number; y2: number } | null = null
+ previewShape: {
+ type: 'rectangle' | 'ellipse' | 'frame' | 'line' | 'polygon'
+ x: number; y: number; w: number; h: number
+ } | null = null
+ penPreview: import('./skia-overlays').PenPreviewData | null = null
+
+ constructor(ck: CanvasKit) {
+ this.ck = ck
+ this.renderer = new SkiaRenderer(ck)
+ // Wire up icon lookup for icon_font nodes
+ this.renderer.setIconLookup(lookupIconByName)
+ // Wire up root children provider for layout engine fill-width fallback
+ setRootChildrenProvider(() => useDocumentStore.getState().document.children)
+ }
+
+ // ---------------------------------------------------------------------------
+ // Lifecycle
+ // ---------------------------------------------------------------------------
+
+ init(canvasEl: HTMLCanvasElement) {
+ this.canvasEl = canvasEl
+ const dpr = window.devicePixelRatio || 1
+ canvasEl.width = canvasEl.clientWidth * dpr
+ canvasEl.height = canvasEl.clientHeight * dpr
+
+ this.surface = this.ck.MakeWebGLCanvasSurface(canvasEl)
+ if (!this.surface) {
+ // Fallback to software
+ this.surface = this.ck.MakeSWCanvasSurface(canvasEl)
+ }
+ if (!this.surface) {
+ console.error('SkiaEngine: Failed to create surface')
+ return
+ }
+
+ this.renderer.init()
+ this.renderer.setRedrawCallback(() => this.markDirty())
+ // Re-render when async font loading completes
+ ;(this.renderer as any)._onFontLoaded = () => this.markDirty()
+ // Pre-load default fonts for vector text rendering.
+ // Noto Sans SC is loaded alongside Inter so CJK glyphs are always available
+ // in the fallback chain — system CJK fonts (PingFang SC, Microsoft YaHei, etc.)
+ // are skipped from Google Fonts, and without Noto Sans SC the fallback chain
+ // would only contain Inter which has no CJK coverage, causing tofu.
+ this.renderer.fontManager.ensureFont('Inter').then(() => this.markDirty())
+ this.renderer.fontManager.ensureFont('Noto Sans SC').then(() => this.markDirty())
+ this.startRenderLoop()
+ }
+
+ dispose() {
+ if (this.animFrameId) cancelAnimationFrame(this.animFrameId)
+ this.renderer.dispose()
+ this.surface?.delete()
+ this.surface = null
+ }
+
+ resize(width: number, height: number) {
+ if (!this.canvasEl) return
+ const dpr = window.devicePixelRatio || 1
+ this.canvasEl.width = width * dpr
+ this.canvasEl.height = height * dpr
+
+ // Recreate surface
+ this.surface?.delete()
+ this.surface = this.ck.MakeWebGLCanvasSurface(this.canvasEl)
+ if (!this.surface) {
+ this.surface = this.ck.MakeSWCanvasSurface(this.canvasEl)
+ }
+ this.render()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Document sync
+ // ---------------------------------------------------------------------------
+
+ syncFromDocument() {
+ if (this.dragSyncSuppressed) return
+ const docState = useDocumentStore.getState()
+ const activePageId = useCanvasStore.getState().activePageId
+ const pageChildren = getActivePageChildren(docState.document, activePageId)
+ const allNodes = getAllChildren(docState.document)
+
+ // Collect reusable/instance IDs from raw tree (before ref resolution strips them)
+ this.reusableIds.clear()
+ this.instanceIds.clear()
+ collectReusableIds(pageChildren, this.reusableIds)
+ collectInstanceIds(pageChildren, this.instanceIds)
+
+ // Resolve refs, variables, then flatten
+ const resolved = resolveRefs(pageChildren, allNodes)
+
+ // Resolve design variables
+ const variables = docState.document.variables ?? {}
+ const themes = docState.document.themes
+ const defaultTheme = getDefaultTheme(themes)
+ const variableResolved = resolved.map((n) =>
+ resolveNodeForCanvas(n, variables, defaultTheme),
+ )
+
+ // Only premeasure text HEIGHTS for fixed-width text (where wrapping
+ // estimation may differ from Canvas 2D). Never touch widths or
+ // container-relative sizing to maintain layout consistency with Fabric.js.
+ const measured = premeasureTextHeights(variableResolved)
+
+ this.renderNodes = flattenToRenderNodes(measured)
+
+ this.spatialIndex.rebuild(this.renderNodes)
+ this.markDirty()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Render loop
+ // ---------------------------------------------------------------------------
+
+ markDirty() {
+ this.dirty = true
+ }
+
+ private startRenderLoop() {
+ const loop = () => {
+ this.animFrameId = requestAnimationFrame(loop)
+ if (!this.dirty || !this.surface) return
+ this.dirty = false
+ this.render()
+ }
+ this.animFrameId = requestAnimationFrame(loop)
+ }
+
+ private render() {
+ if (!this.surface || !this.canvasEl) return
+ const canvas = this.surface.getCanvas()
+ const ck = this.ck
+
+ const dpr = window.devicePixelRatio || 1
+ const selectedIds = new Set(useCanvasStore.getState().selection.selectedIds)
+
+ // Clear
+ const bgColor = getCanvasBackground()
+ canvas.clear(parseColor(ck, bgColor))
+
+ // Apply viewport transform
+ canvas.save()
+ canvas.scale(dpr, dpr)
+ canvas.concat(viewportMatrix({ zoom: this.zoom, panX: this.panX, panY: this.panY }))
+
+ // Pass current zoom to renderer for zoom-aware text rasterization
+ this.renderer.zoom = this.zoom
+
+ const vpBounds = getViewportBounds(
+ { zoom: this.zoom, panX: this.panX, panY: this.panY },
+ this.canvasEl.clientWidth,
+ this.canvasEl.clientHeight,
+ 64 / this.zoom
+ )
+ // Draw all render nodes
+ for (const rn of this.renderNodes) {
+ // Skip nodes outside the viewport
+ if (!isRectInViewport({ x: rn.absX, y: rn.absY, w: rn.absW, h: rn.absH }, vpBounds)) continue
+ this.renderer.drawNodeWithSelection(canvas, rn, selectedIds)
+ }
+
+ // Draw frame labels (root frames + reusable components + instances at any depth)
+ for (const rn of this.renderNodes) {
+ if (!rn.node.name) continue
+ const isRootFrame = rn.node.type === 'frame' && !rn.clipRect
+ const isReusable = this.reusableIds.has(rn.node.id)
+ const isInstance = this.instanceIds.has(rn.node.id)
+ if (!isRootFrame && !isReusable && !isInstance) continue
+ this.renderer.drawFrameLabelColored(
+ canvas, rn.node.name, rn.absX, rn.absY,
+ isReusable, isInstance, this.zoom,
+ )
+ }
+
+ // Draw agent indicators (glow, badges, node borders, preview fills)
+ const agentIndicators = getActiveAgentIndicators()
+ const agentFrames = getActiveAgentFrames()
+ const hasAgentOverlays = agentIndicators.size > 0 || agentFrames.size > 0
+
+ if (!hasAgentOverlays) {
+ this.agentAnimStart = 0
+ }
+
+ if (hasAgentOverlays) {
+ const now = Date.now()
+ if (this.agentAnimStart === 0) this.agentAnimStart = now
+ const elapsed = now - this.agentAnimStart
+ // Frame glow: smooth fade-in → fade-out (single bell, ~1.2s)
+ const GLOW_DURATION = 1200
+ const glowT = Math.min(1, elapsed / GLOW_DURATION)
+ const breath = Math.sin(glowT * Math.PI) // 0 → 1 → 0
+
+ // Agent node borders and preview fills (per-element fade-in → fade-out)
+ const NODE_FADE_DURATION = 1000
+ for (const rn of this.renderNodes) {
+ const indicator = agentIndicators.get(rn.node.id)
+ if (!indicator) continue
+ if (!isNodeBorderReady(rn.node.id)) continue
+
+ const revealAt = getNodeRevealTime(rn.node.id)
+ if (revealAt === undefined) continue
+ const nodeElapsed = now - revealAt
+ if (nodeElapsed > NODE_FADE_DURATION) continue
+
+ // Smooth bell curve: fade in then fade out
+ const nodeT = Math.min(1, nodeElapsed / NODE_FADE_DURATION)
+ const nodeBreath = Math.sin(nodeT * Math.PI)
+
+ if (isPreviewNode(rn.node.id)) {
+ this.renderer.drawAgentPreviewFill(
+ canvas, rn.absX, rn.absY, rn.absW, rn.absH,
+ indicator.color, now,
+ )
+ }
+
+ this.renderer.drawAgentNodeBorder(
+ canvas, rn.absX, rn.absY, rn.absW, rn.absH,
+ indicator.color, nodeBreath, this.zoom,
+ )
+ }
+
+ // Agent frame glow and badges
+ for (const rn of this.renderNodes) {
+ const frame = agentFrames.get(rn.node.id)
+ if (!frame) continue
+
+ this.renderer.drawAgentGlow(
+ canvas, rn.absX, rn.absY, rn.absW, rn.absH,
+ frame.color, breath, this.zoom,
+ )
+ this.renderer.drawAgentBadge(
+ canvas, frame.name,
+ rn.absX, rn.absY, rn.absW,
+ frame.color, this.zoom, now,
+ )
+ }
+ }
+
+ // Hover outline
+ if (this.hoveredNodeId && !selectedIds.has(this.hoveredNodeId)) {
+ const hovered = this.spatialIndex.get(this.hoveredNodeId)
+ if (hovered) {
+ this.renderer.drawHoverOutline(canvas, hovered.absX, hovered.absY, hovered.absW, hovered.absH)
+ }
+ }
+
+ // Arc handles for selected ellipse
+ if (selectedIds.size === 1) {
+ const selId = selectedIds.values().next().value as string
+ const selRN = this.spatialIndex.get(selId)
+ if (selRN && selRN.node.type === 'ellipse') {
+ const eNode = selRN.node as EllipseNode
+ this.renderer.drawArcHandles(
+ canvas,
+ selRN.absX, selRN.absY, selRN.absW, selRN.absH,
+ eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0,
+ this.zoom,
+ )
+ }
+ }
+
+ // Drawing preview shape
+ if (this.previewShape) {
+ this.renderer.drawPreview(canvas, this.previewShape)
+ }
+
+ // Pen tool preview
+ if (this.penPreview) {
+ this.renderer.drawPenPreview(canvas, this.penPreview, this.zoom)
+ }
+
+ // Selection marquee
+ if (this.marquee) {
+ this.renderer.drawSelectionMarquee(
+ canvas,
+ this.marquee.x1, this.marquee.y1,
+ this.marquee.x2, this.marquee.y2,
+ )
+ }
+
+ canvas.restore()
+ this.surface.flush()
+
+ // Keep animating while agent overlays are active (spinning dot + node flashes)
+ if (hasAgentOverlays) {
+ this.markDirty()
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Viewport control
+ // ---------------------------------------------------------------------------
+
+ setViewport(zoom: number, panX: number, panY: number) {
+ this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
+ this.panX = panX
+ this.panY = panY
+ useCanvasStore.getState().setZoom(this.zoom)
+ useCanvasStore.getState().setPan(this.panX, this.panY)
+ this.markDirty()
+ }
+
+ zoomToPoint(screenX: number, screenY: number, newZoom: number) {
+ if (!this.canvasEl) return
+ const rect = this.canvasEl.getBoundingClientRect()
+ const vp = vpZoomToPoint(
+ { zoom: this.zoom, panX: this.panX, panY: this.panY },
+ screenX, screenY, rect, newZoom,
+ )
+ this.setViewport(vp.zoom, vp.panX, vp.panY)
+ }
+
+ pan(dx: number, dy: number) {
+ this.setViewport(this.zoom, this.panX + dx, this.panY + dy)
+ }
+
+ getCanvasRect(): DOMRect | null {
+ return this.canvasEl?.getBoundingClientRect() ?? null
+ }
+
+ getCanvasSize(): { width: number; height: number } {
+ return {
+ width: this.canvasEl?.clientWidth ?? 800,
+ height: this.canvasEl?.clientHeight ?? 600,
+ }
+ }
+
+ zoomToFitContent() {
+ if (!this.canvasEl || this.renderNodes.length === 0) return
+ const FIT_PADDING = 64
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
+ for (const rn of this.renderNodes) {
+ if (rn.clipRect) continue // skip children, only root bounds
+ minX = Math.min(minX, rn.absX)
+ minY = Math.min(minY, rn.absY)
+ maxX = Math.max(maxX, rn.absX + rn.absW)
+ maxY = Math.max(maxY, rn.absY + rn.absH)
+ }
+ if (!isFinite(minX)) return
+ const contentW = maxX - minX
+ const contentH = maxY - minY
+ const cw = this.canvasEl.clientWidth
+ const ch = this.canvasEl.clientHeight
+ const scaleX = (cw - FIT_PADDING * 2) / contentW
+ const scaleY = (ch - FIT_PADDING * 2) / contentH
+ let zoom = Math.min(scaleX, scaleY, 1)
+ zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
+ const centerX = (minX + maxX) / 2
+ const centerY = (minY + maxY) / 2
+ this.setViewport(
+ zoom,
+ cw / 2 - centerX * zoom,
+ ch / 2 - centerY * zoom,
+ )
+ }
+}
+
diff --git a/apps/web/src/canvas/skia/skia-font-manager.ts b/apps/web/src/canvas/skia/skia-font-manager.ts
new file mode 100644
index 00000000..e520c755
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-font-manager.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-renderer
+export { SkiaFontManager, BUNDLED_FONT_FAMILIES } from '@zseven-w/pen-renderer'
diff --git a/apps/web/src/canvas/skia/skia-hit-handlers.ts b/apps/web/src/canvas/skia/skia-hit-handlers.ts
new file mode 100644
index 00000000..47fe1977
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-hit-handlers.ts
@@ -0,0 +1,93 @@
+import type { SkiaEngine } from './skia-engine'
+import { useCanvasStore } from '@/stores/canvas-store'
+import type { EllipseNode } from '@/types/pen'
+import { computeArcHandles } from './skia-overlays'
+
+type HandleDir = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
+type ArcHandleType = 'start' | 'end' | 'inner'
+
+export type { HandleDir, ArcHandleType }
+
+export const HANDLE_HIT_RADIUS = 8
+export const ROTATE_OUTER_RADIUS = 16
+export const ARC_HANDLE_HIT_RADIUS = 8
+export const DRAG_THRESHOLD = 3
+
+export const handleCursors: Record = {
+ nw: 'nwse-resize', n: 'ns-resize', ne: 'nesw-resize', e: 'ew-resize',
+ se: 'nwse-resize', s: 'ns-resize', sw: 'nesw-resize', w: 'ew-resize',
+}
+
+function getSelectedRN(engine: SkiaEngine) {
+ const { selectedIds } = useCanvasStore.getState().selection
+ if (selectedIds.length !== 1) return null
+ return engine.spatialIndex.get(selectedIds[0]) ?? null
+}
+
+export function hitTestHandle(
+ engine: SkiaEngine,
+ sceneX: number,
+ sceneY: number,
+): { dir: HandleDir; nodeId: string } | null {
+ const rn = getSelectedRN(engine)
+ if (!rn) return null
+
+ const hitR = HANDLE_HIT_RADIUS / engine.zoom
+ const { absX: x, absY: y, absW: w, absH: h } = rn
+ const handles: [HandleDir, number, number][] = [
+ ['nw', x, y], ['n', x + w / 2, y], ['ne', x + w, y],
+ ['w', x, y + h / 2], ['e', x + w, y + h / 2],
+ ['sw', x, y + h], ['s', x + w / 2, y + h], ['se', x + w, y + h],
+ ]
+ for (const [dir, hx, hy] of handles) {
+ if (Math.abs(sceneX - hx) <= hitR && Math.abs(sceneY - hy) <= hitR) {
+ return { dir, nodeId: rn.node.id }
+ }
+ }
+ return null
+}
+
+export function hitTestRotation(
+ engine: SkiaEngine,
+ sceneX: number,
+ sceneY: number,
+): { nodeId: string } | null {
+ const rn = getSelectedRN(engine)
+ if (!rn) return null
+
+ const innerR = HANDLE_HIT_RADIUS / engine.zoom
+ const outerR = ROTATE_OUTER_RADIUS / engine.zoom
+ const { absX: x, absY: y, absW: w, absH: h } = rn
+ const corners = [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]
+ for (const [cx, cy] of corners) {
+ const dist = Math.hypot(sceneX - cx, sceneY - cy)
+ if (dist > innerR && dist <= outerR) {
+ return { nodeId: rn.node.id }
+ }
+ }
+ return null
+}
+
+export function hitTestArcHandle(
+ engine: SkiaEngine,
+ sceneX: number,
+ sceneY: number,
+): { type: ArcHandleType; nodeId: string } | null {
+ const { selectedIds } = useCanvasStore.getState().selection
+ if (selectedIds.length !== 1) return null
+ const rn = engine.spatialIndex.get(selectedIds[0])
+ if (!rn || rn.node.type !== 'ellipse') return null
+ const eNode = rn.node as EllipseNode
+ const handles = computeArcHandles(
+ rn.absX, rn.absY, rn.absW, rn.absH,
+ eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0,
+ )
+ const hitR = ARC_HANDLE_HIT_RADIUS / engine.zoom
+ for (const key of ['start', 'end', 'inner'] as ArcHandleType[]) {
+ const h = handles[key]
+ if (Math.hypot(sceneX - h.x, sceneY - h.y) <= hitR) {
+ return { type: key, nodeId: rn.node.id }
+ }
+ }
+ return null
+}
diff --git a/apps/web/src/canvas/skia/skia-hit-test.ts b/apps/web/src/canvas/skia/skia-hit-test.ts
new file mode 100644
index 00000000..8630ab43
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-hit-test.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-renderer
+export { SpatialIndex } from '@zseven-w/pen-renderer'
diff --git a/apps/web/src/canvas/skia/skia-image-loader.ts b/apps/web/src/canvas/skia/skia-image-loader.ts
new file mode 100644
index 00000000..364c791d
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-image-loader.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-renderer
+export { SkiaImageLoader } from '@zseven-w/pen-renderer'
diff --git a/apps/web/src/canvas/skia/skia-init.ts b/apps/web/src/canvas/skia/skia-init.ts
new file mode 100644
index 00000000..f883c369
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-init.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-renderer
+export { loadCanvasKit, getCanvasKit } from '@zseven-w/pen-renderer'
diff --git a/apps/web/src/canvas/skia/skia-interaction.ts b/apps/web/src/canvas/skia/skia-interaction.ts
new file mode 100644
index 00000000..507aca90
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-interaction.ts
@@ -0,0 +1,792 @@
+import { screenToScene } from './skia-engine'
+import type { SkiaEngine } from './skia-engine'
+import { useCanvasStore } from '@/stores/canvas-store'
+import { useDocumentStore } from '@/stores/document-store'
+import { createNodeForTool, isDrawingTool } from '../canvas-node-creator'
+import { inferLayout } from '../canvas-layout-engine'
+import { SkiaPenTool } from './skia-pen-tool'
+import type { ToolType } from '@/types/canvas'
+import type { PenNode, ContainerProps, TextNode, EllipseNode } from '@/types/pen'
+import {
+ type HandleDir, type ArcHandleType,
+ DRAG_THRESHOLD, handleCursors,
+ hitTestHandle, hitTestRotation, hitTestArcHandle,
+} from './skia-hit-handlers'
+
+export interface TextEditState {
+ nodeId: string
+ x: number; y: number; w: number; h: number
+ content: string
+ fontSize: number
+ fontFamily: string
+ fontWeight: string
+ textAlign: string
+ color: string
+ lineHeight: number
+}
+
+
+export function toolToCursor(tool: ToolType): string {
+ switch (tool) {
+ case 'hand': return 'grab'
+ case 'text': return 'text'
+ case 'select': return 'default'
+ default: return 'crosshair'
+ }
+}
+
+/**
+ * Encapsulates all canvas mouse/keyboard interaction state and handlers.
+ * Extracted from SkiaCanvas to keep the component focused on lifecycle and rendering.
+ */
+export class SkiaInteractionManager {
+ private engineRef: { current: SkiaEngine | null }
+ private canvasEl: HTMLCanvasElement
+ private onEditText: (state: TextEditState | null) => void
+
+ // Shared state
+ private isPanning = false
+ private spacePressed = false
+ private lastX = 0
+ private lastY = 0
+
+ // Select tool state
+ private isDragging = false
+ private dragMoved = false
+ private isMarquee = false
+ private dragNodeIds: string[] = []
+ private dragStartSceneX = 0
+ private dragStartSceneY = 0
+ private dragOrigPositions: { id: string; x: number; y: number }[] = []
+ private dragPrevDx = 0
+ private dragPrevDy = 0
+ private dragAllIds: Set | null = null
+
+ // Resize handle state
+ private isResizing = false
+ private resizeHandle: HandleDir | null = null
+ private resizeNodeId: string | null = null
+ private resizeOrigX = 0
+ private resizeOrigY = 0
+ private resizeOrigW = 0
+ private resizeOrigH = 0
+ private resizeStartSceneX = 0
+ private resizeStartSceneY = 0
+
+ // Rotation state
+ private isRotating = false
+ private rotateNodeId: string | null = null
+ private rotateOrigAngle = 0
+ private rotateCenterX = 0
+ private rotateCenterY = 0
+ private rotateStartAngle = 0
+
+ // Arc handle state
+ private isDraggingArc = false
+ private arcHandleType: ArcHandleType | null = null
+ private arcNodeId: string | null = null
+
+ // Drawing tool state
+ private isDrawing = false
+ private drawTool: ToolType = 'select'
+ private drawStartX = 0
+ private drawStartY = 0
+
+ // Pen tool
+ private penTool: SkiaPenTool
+
+ constructor(
+ engineRef: { current: SkiaEngine | null },
+ canvasEl: HTMLCanvasElement,
+ onEditText: (state: TextEditState | null) => void,
+ ) {
+ this.engineRef = engineRef
+ this.canvasEl = canvasEl
+ this.onEditText = onEditText
+ this.penTool = new SkiaPenTool(() => this.engineRef.current)
+ }
+
+ private getEngine() { return this.engineRef.current }
+ private getTool() { return useCanvasStore.getState().activeTool }
+
+ private getScene(e: MouseEvent) {
+ const engine = this.getEngine()
+ if (!engine) return null
+ const rect = engine.getCanvasRect()
+ if (!rect) return null
+ return screenToScene(e.clientX, e.clientY, rect, {
+ zoom: engine.zoom, panX: engine.panX, panY: engine.panY,
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+ // Mouse down
+ // ---------------------------------------------------------------------------
+
+ private onMouseDown = (e: MouseEvent) => {
+ const engine = this.getEngine()
+ if (!engine) return
+
+ if (e.button === 2) return
+
+ // Pan: space+click, hand tool, or middle mouse
+ if (this.spacePressed || this.getTool() === 'hand' || e.button === 1) {
+ this.isPanning = true
+ this.lastX = e.clientX
+ this.lastY = e.clientY
+ this.canvasEl.style.cursor = 'grabbing'
+ return
+ }
+
+ const tool = this.getTool()
+ const scene = this.getScene(e)
+ if (!scene) return
+
+ // Text tool: click to create immediately
+ if (tool === 'text') {
+ const node = createNodeForTool('text', scene.x, scene.y, 0, 0)
+ if (node) {
+ useDocumentStore.getState().addNode(null, node)
+ useCanvasStore.getState().setSelection([node.id], node.id)
+ }
+ useCanvasStore.getState().setActiveTool('select')
+ return
+ }
+
+ // Pen tool
+ if (tool === 'path') {
+ this.penTool.onMouseDown(scene, engine.zoom || 1)
+ return
+ }
+
+ // Drawing tools: start rubber-band
+ if (isDrawingTool(tool)) {
+ this.isDrawing = true
+ this.drawTool = tool
+ this.drawStartX = scene.x
+ this.drawStartY = scene.y
+ engine.previewShape = {
+ type: tool as 'rectangle' | 'ellipse' | 'frame' | 'line' | 'polygon',
+ x: scene.x, y: scene.y, w: 0, h: 0,
+ }
+ engine.markDirty()
+ return
+ }
+
+ // Select tool
+ if (tool === 'select') {
+ this.handleSelectMouseDown(e, scene, engine)
+ }
+ }
+
+ private handleSelectMouseDown(
+ e: MouseEvent,
+ scene: { x: number; y: number },
+ engine: SkiaEngine,
+ ) {
+ // Check arc handles first
+ const arcHit = hitTestArcHandle(engine,scene.x, scene.y)
+ if (arcHit) {
+ this.isDraggingArc = true
+ this.arcHandleType = arcHit.type
+ this.arcNodeId = arcHit.nodeId
+ this.canvasEl.style.cursor = 'pointer'
+ return
+ }
+
+ // Check resize handle
+ const handleHit = hitTestHandle(engine,scene.x, scene.y)
+ if (handleHit) {
+ this.isResizing = true
+ this.resizeHandle = handleHit.dir
+ this.resizeNodeId = handleHit.nodeId
+ this.resizeStartSceneX = scene.x
+ this.resizeStartSceneY = scene.y
+ const docNode = useDocumentStore.getState().getNodeById(handleHit.nodeId)
+ this.resizeOrigX = docNode?.x ?? 0
+ this.resizeOrigY = docNode?.y ?? 0
+ const resizeRN = engine.spatialIndex.get(handleHit.nodeId)
+ const docNodeAny = docNode as (PenNode & ContainerProps) | undefined
+ this.resizeOrigW = resizeRN?.absW ?? (typeof docNodeAny?.width === 'number' ? docNodeAny.width : 100)
+ this.resizeOrigH = resizeRN?.absH ?? (typeof docNodeAny?.height === 'number' ? docNodeAny.height : 100)
+ this.canvasEl.style.cursor = handleCursors[handleHit.dir]
+ return
+ }
+
+ // Check rotation zone
+ const rotHit = hitTestRotation(engine,scene.x, scene.y)
+ if (rotHit) {
+ this.isRotating = true
+ this.rotateNodeId = rotHit.nodeId
+ const docNode = useDocumentStore.getState().getNodeById(rotHit.nodeId)
+ this.rotateOrigAngle = docNode?.rotation ?? 0
+ const rn = engine.spatialIndex.get(rotHit.nodeId)!
+ this.rotateCenterX = rn.absX + rn.absW / 2
+ this.rotateCenterY = rn.absY + rn.absH / 2
+ this.rotateStartAngle = Math.atan2(scene.y - this.rotateCenterY, scene.x - this.rotateCenterX) * 180 / Math.PI
+ this.canvasEl.style.cursor = 'grabbing'
+ return
+ }
+
+ const hits = engine.spatialIndex.hitTest(scene.x, scene.y)
+
+ if (hits.length > 0) {
+ const topHit = hits[0]
+ let nodeId = topHit.node.id
+ const currentSelection = useCanvasStore.getState().selection.selectedIds
+ const docStore = useDocumentStore.getState()
+
+ const isChildOfSelected = currentSelection.some(
+ (selId) => selId !== nodeId && docStore.isDescendantOf(nodeId, selId),
+ )
+ if (isChildOfSelected) {
+ // Don't change selection
+ } else if (!currentSelection.includes(nodeId)) {
+ const parent = docStore.getParentOf(nodeId)
+ if (parent && (parent.type === 'frame' || parent.type === 'group')) {
+ const grandparent = docStore.getParentOf(parent.id)
+ if (!grandparent || grandparent.type === 'frame') {
+ nodeId = parent.id
+ }
+ }
+
+ if (e.shiftKey) {
+ if (currentSelection.includes(nodeId)) {
+ const next = currentSelection.filter((id) => id !== nodeId)
+ useCanvasStore.getState().setSelection(next, next[0] ?? null)
+ } else {
+ useCanvasStore.getState().setSelection([...currentSelection, nodeId], nodeId)
+ }
+ } else {
+ useCanvasStore.getState().setSelection([nodeId], nodeId)
+ }
+ }
+
+ // Start drag
+ const selectedIds = useCanvasStore.getState().selection.selectedIds
+ this.isDragging = true
+ this.dragMoved = false
+ this.dragNodeIds = selectedIds
+ this.dragStartSceneX = scene.x
+ this.dragStartSceneY = scene.y
+ this.dragOrigPositions = selectedIds.map((id) => {
+ const n = useDocumentStore.getState().getNodeById(id)
+ return { id, x: n?.x ?? 0, y: n?.y ?? 0 }
+ })
+ } else {
+ // Empty space → start marquee or clear selection
+ if (!e.shiftKey) {
+ useCanvasStore.getState().clearSelection()
+ }
+ this.isMarquee = true
+ this.lastX = scene.x
+ this.lastY = scene.y
+ engine.marquee = { x1: scene.x, y1: scene.y, x2: scene.x, y2: scene.y }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Mouse move
+ // ---------------------------------------------------------------------------
+
+ private onMouseMove = (e: MouseEvent) => {
+ const engine = this.getEngine()
+ if (!engine) return
+
+ if (this.isPanning) {
+ const dx = e.clientX - this.lastX
+ const dy = e.clientY - this.lastY
+ this.lastX = e.clientX
+ this.lastY = e.clientY
+ engine.pan(dx, dy)
+ return
+ }
+
+ const scene = this.getScene(e)
+ if (!scene) return
+
+ if (this.penTool.onMouseMove(scene)) return
+
+ if (this.isResizing && this.resizeHandle && this.resizeNodeId) {
+ this.handleResizeMove(scene, engine)
+ return
+ }
+
+ if (this.isRotating && this.rotateNodeId) {
+ this.handleRotateMove(scene, e.shiftKey)
+ return
+ }
+
+ if (this.isDraggingArc && this.arcNodeId && this.arcHandleType) {
+ this.handleArcMove(scene, engine)
+ return
+ }
+
+ if (this.isDrawing && engine.previewShape) {
+ this.handleDrawingMove(scene, engine)
+ return
+ }
+
+ if (this.isDragging && this.dragNodeIds.length > 0) {
+ this.handleDragMove(scene, engine)
+ return
+ }
+
+ if (this.isMarquee && engine.marquee) {
+ this.handleMarqueeMove(scene, engine)
+ return
+ }
+
+ // Hover + handle cursor (select tool only)
+ if (this.getTool() === 'select' && !this.spacePressed) {
+ this.handleHoverCursor(scene, engine)
+ }
+ }
+
+ private handleResizeMove(scene: { x: number; y: number }, engine: SkiaEngine) {
+ const dx = scene.x - this.resizeStartSceneX
+ const dy = scene.y - this.resizeStartSceneY
+ let newX = this.resizeOrigX
+ let newY = this.resizeOrigY
+ let newW = this.resizeOrigW
+ let newH = this.resizeOrigH
+
+ const dir = this.resizeHandle!
+ if (dir.includes('w')) { newX = this.resizeOrigX + dx; newW = this.resizeOrigW - dx }
+ if (dir.includes('e')) { newW = this.resizeOrigW + dx }
+ if (dir.includes('n')) { newY = this.resizeOrigY + dy; newH = this.resizeOrigH - dy }
+ if (dir.includes('s')) { newH = this.resizeOrigH + dy }
+
+ const MIN = 2
+ if (newW < MIN) { if (dir.includes('w')) newX = this.resizeOrigX + this.resizeOrigW - MIN; newW = MIN }
+ if (newH < MIN) { if (dir.includes('n')) newY = this.resizeOrigY + this.resizeOrigH - MIN; newH = MIN }
+
+ const resizedNode = useDocumentStore.getState().getNodeById(this.resizeNodeId!)
+ const updates: Record = { x: newX, y: newY, width: newW, height: newH }
+ if (resizedNode?.type === 'text' && !(resizedNode as TextNode).textGrowth) {
+ updates.textGrowth = 'fixed-width'
+ }
+ useDocumentStore.getState().updateNode(this.resizeNodeId!, updates as Partial)
+
+ if (
+ resizedNode
+ && 'children' in resizedNode
+ && resizedNode.children?.length
+ ) {
+ const resizeRN2 = engine.spatialIndex.get(this.resizeNodeId!)
+ const resizedContainer = resizedNode as PenNode & ContainerProps
+ const oldW = resizeRN2?.absW ?? (typeof resizedContainer.width === 'number' ? resizedContainer.width : 0)
+ const oldH = resizeRN2?.absH ?? (typeof resizedContainer.height === 'number' ? resizedContainer.height : 0)
+ if (oldW > 0 && oldH > 0) {
+ const scaleX = newW / oldW
+ const scaleY = newH / oldH
+ useDocumentStore.getState().scaleDescendantsInStore(this.resizeNodeId!, scaleX, scaleY)
+ }
+ }
+ }
+
+ private handleRotateMove(scene: { x: number; y: number }, shiftKey: boolean) {
+ const currentAngle = Math.atan2(scene.y - this.rotateCenterY, scene.x - this.rotateCenterX) * 180 / Math.PI
+ let newAngle = this.rotateOrigAngle + (currentAngle - this.rotateStartAngle)
+ if (shiftKey) {
+ newAngle = Math.round(newAngle / 15) * 15
+ }
+ useDocumentStore.getState().updateNode(this.rotateNodeId!, { rotation: newAngle } as Partial)
+ }
+
+ private handleArcMove(scene: { x: number; y: number }, engine: SkiaEngine) {
+ const rn = engine.spatialIndex.get(this.arcNodeId!)
+ if (!rn) return
+
+ const cx = rn.absX + rn.absW / 2
+ const cy = rn.absY + rn.absH / 2
+ const angle = Math.atan2(scene.y - cy, scene.x - cx) * 180 / Math.PI
+ const normalizedAngle = ((angle % 360) + 360) % 360
+ const eNode = rn.node as EllipseNode
+
+ if (this.arcHandleType === 'start') {
+ const oldStart = eNode.startAngle ?? 0
+ const oldEnd = oldStart + (eNode.sweepAngle ?? 360)
+ const newSweep = ((oldEnd - normalizedAngle) % 360 + 360) % 360
+ useDocumentStore.getState().updateNode(this.arcNodeId!, {
+ startAngle: normalizedAngle,
+ sweepAngle: newSweep || 360,
+ } as Partial)
+ } else if (this.arcHandleType === 'end') {
+ const startA = eNode.startAngle ?? 0
+ const newSweep = ((normalizedAngle - startA) % 360 + 360) % 360
+ useDocumentStore.getState().updateNode(this.arcNodeId!, {
+ sweepAngle: newSweep || 360,
+ } as Partial)
+ } else if (this.arcHandleType === 'inner') {
+ const rx = rn.absW / 2
+ const ry = rn.absH / 2
+ const dist = Math.hypot((scene.x - cx) / rx, (scene.y - cy) / ry)
+ const newInner = Math.max(0, Math.min(0.99, dist))
+ useDocumentStore.getState().updateNode(this.arcNodeId!, {
+ innerRadius: newInner,
+ } as Partial)
+ }
+ }
+
+ private handleDrawingMove(scene: { x: number; y: number }, engine: SkiaEngine) {
+ const dx = scene.x - this.drawStartX
+ const dy = scene.y - this.drawStartY
+
+ if (this.drawTool === 'line') {
+ engine.previewShape = {
+ type: 'line',
+ x: this.drawStartX, y: this.drawStartY,
+ w: dx, h: dy,
+ }
+ } else {
+ engine.previewShape = {
+ type: this.drawTool as 'rectangle' | 'ellipse' | 'frame' | 'line' | 'polygon',
+ x: dx < 0 ? scene.x : this.drawStartX,
+ y: dy < 0 ? scene.y : this.drawStartY,
+ w: Math.abs(dx),
+ h: Math.abs(dy),
+ }
+ }
+ engine.markDirty()
+ }
+
+ private handleDragMove(scene: { x: number; y: number }, engine: SkiaEngine) {
+ const dx = scene.x - this.dragStartSceneX
+ const dy = scene.y - this.dragStartSceneY
+
+ if (!this.dragMoved) {
+ const screenDist = Math.hypot(dx * engine.zoom, dy * engine.zoom)
+ if (screenDist < DRAG_THRESHOLD) return
+ this.dragMoved = true
+ engine.dragSyncSuppressed = true
+ this.dragPrevDx = 0
+ this.dragPrevDy = 0
+ this.dragAllIds = new Set(this.dragNodeIds)
+ for (const id of this.dragNodeIds) {
+ const collectDescs = (nodeId: string) => {
+ const n = useDocumentStore.getState().getNodeById(nodeId)
+ if (n && 'children' in n && n.children) {
+ for (const child of n.children) {
+ this.dragAllIds!.add(child.id)
+ collectDescs(child.id)
+ }
+ }
+ }
+ collectDescs(id)
+ }
+ }
+
+ const incrDx = dx - this.dragPrevDx
+ const incrDy = dy - this.dragPrevDy
+ this.dragPrevDx = dx
+ this.dragPrevDy = dy
+
+ for (const rn of engine.renderNodes) {
+ if (this.dragAllIds!.has(rn.node.id)) {
+ rn.absX += incrDx
+ rn.absY += incrDy
+ rn.node = { ...rn.node, x: rn.absX, y: rn.absY }
+ }
+ }
+ engine.spatialIndex.rebuild(engine.renderNodes)
+ engine.markDirty()
+ }
+
+ private handleMarqueeMove(scene: { x: number; y: number }, engine: SkiaEngine) {
+ engine.marquee!.x2 = scene.x
+ engine.marquee!.y2 = scene.y
+ engine.markDirty()
+
+ const marqueeHits = engine.spatialIndex.searchRect(
+ engine.marquee!.x1, engine.marquee!.y1,
+ engine.marquee!.x2, engine.marquee!.y2,
+ )
+ const ids = marqueeHits.map((rn) => rn.node.id)
+ useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
+ }
+
+ private handleHoverCursor(scene: { x: number; y: number }, engine: SkiaEngine) {
+ const arcHoverHit = hitTestArcHandle(engine,scene.x, scene.y)
+ if (arcHoverHit) {
+ this.canvasEl.style.cursor = 'pointer'
+ return
+ }
+ const handleHit = hitTestHandle(engine,scene.x, scene.y)
+ if (handleHit) {
+ this.canvasEl.style.cursor = handleCursors[handleHit.dir]
+ } else if (hitTestRotation(engine,scene.x, scene.y)) {
+ this.canvasEl.style.cursor = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'24\' height=\'24\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'black\' stroke-width=\'2\'%3E%3Cpath d=\'M21 2v6h-6\'/%3E%3Cpath d=\'M21 13a9 9 0 1 1-3-7.7L21 8\'/%3E%3C/svg%3E") 12 12, crosshair'
+ } else {
+ const hoverHits = engine.spatialIndex.hitTest(scene.x, scene.y)
+ const newHoveredId = hoverHits.length > 0 ? hoverHits[0].node.id : null
+ this.canvasEl.style.cursor = newHoveredId ? 'move' : 'default'
+ if (newHoveredId !== engine.hoveredNodeId) {
+ engine.hoveredNodeId = newHoveredId
+ useCanvasStore.getState().setHoveredId(newHoveredId)
+ engine.markDirty()
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Mouse up
+ // ---------------------------------------------------------------------------
+
+ private onMouseUp = () => {
+ const engine = this.getEngine()
+
+ if (this.penTool.onMouseUp()) return
+
+ if (this.isPanning) {
+ this.isPanning = false
+ this.canvasEl.style.cursor = this.spacePressed ? 'grab' : toolToCursor(this.getTool())
+ }
+
+ if (this.isResizing) {
+ this.isResizing = false
+ this.resizeHandle = null
+ this.resizeNodeId = null
+ this.canvasEl.style.cursor = toolToCursor(this.getTool())
+ }
+
+ if (this.isDraggingArc) {
+ this.isDraggingArc = false
+ this.arcHandleType = null
+ this.arcNodeId = null
+ this.canvasEl.style.cursor = toolToCursor(this.getTool())
+ }
+
+ if (this.isRotating) {
+ this.isRotating = false
+ this.rotateNodeId = null
+ this.canvasEl.style.cursor = toolToCursor(this.getTool())
+ }
+
+ if (this.isDrawing && engine?.previewShape) {
+ const { type, x, y, w, h } = engine.previewShape
+ engine.previewShape = null
+ engine.markDirty()
+ this.isDrawing = false
+
+ const minSize = type === 'line'
+ ? Math.hypot(w, h) >= 2
+ : w >= 2 && h >= 2
+ if (minSize) {
+ const node = createNodeForTool(this.drawTool, x, y, w, h)
+ if (node) {
+ useDocumentStore.getState().addNode(null, node)
+ useCanvasStore.getState().setSelection([node.id], node.id)
+ }
+ }
+ useCanvasStore.getState().setActiveTool('select')
+ return
+ }
+ this.isDrawing = false
+
+ // Select tool: end drag / marquee
+ if (this.isDragging && this.dragMoved && this.dragOrigPositions.length > 0 && engine) {
+ this.handleDragEnd(engine)
+ } else if (engine) {
+ engine.dragSyncSuppressed = false
+ }
+ this.isDragging = false
+ this.dragNodeIds = []
+ this.dragOrigPositions = []
+ this.dragAllIds = null
+ if (this.isMarquee && engine) {
+ engine.marquee = null
+ engine.markDirty()
+ }
+ this.isMarquee = false
+ }
+
+ private handleDragEnd(engine: SkiaEngine) {
+ const dx = this.dragPrevDx
+ const dy = this.dragPrevDy
+ const docStore = useDocumentStore.getState()
+
+ for (const orig of this.dragOrigPositions) {
+ const parent = docStore.getParentOf(orig.id)
+ const draggedRN = engine.renderNodes.find((rn) => rn.node.id === orig.id)
+ const objBounds = draggedRN
+ ? { x: draggedRN.absX, y: draggedRN.absY, w: draggedRN.absW, h: draggedRN.absH }
+ : { x: orig.x + dx, y: orig.y + dy, w: 100, h: 100 }
+
+ // Check if dragged completely outside parent → reparent
+ if (parent) {
+ const parentRN = engine.renderNodes.find((rn) => rn.node.id === parent.id)
+ if (parentRN) {
+ const pBounds = { x: parentRN.absX, y: parentRN.absY, w: parentRN.absW, h: parentRN.absH }
+ const outside =
+ objBounds.x + objBounds.w <= pBounds.x ||
+ objBounds.x >= pBounds.x + pBounds.w ||
+ objBounds.y + objBounds.h <= pBounds.y ||
+ objBounds.y >= pBounds.y + pBounds.h
+
+ if (outside) {
+ docStore.updateNode(orig.id, { x: objBounds.x, y: objBounds.y } as Partial)
+ docStore.moveNode(orig.id, null, 0)
+ continue
+ }
+ }
+ }
+
+ const parentLayout = parent
+ ? ((parent as PenNode & ContainerProps).layout || inferLayout(parent))
+ : undefined
+
+ if (parentLayout && parentLayout !== 'none' && parent) {
+ const siblings = ('children' in parent ? parent.children ?? [] : [])
+ .filter((c) => c.id !== orig.id)
+ const isVertical = parentLayout === 'vertical'
+
+ let newIndex = siblings.length
+ for (let i = 0; i < siblings.length; i++) {
+ const sibRN = engine.renderNodes.find((rn) => rn.node.id === siblings[i].id)
+ const sibMid = sibRN
+ ? (isVertical ? sibRN.absY + sibRN.absH / 2 : sibRN.absX + sibRN.absW / 2)
+ : 0
+ const dragMid = isVertical
+ ? objBounds.y + objBounds.h / 2
+ : objBounds.x + objBounds.w / 2
+ if (dragMid < sibMid) {
+ newIndex = i
+ break
+ }
+ }
+ docStore.moveNode(orig.id, parent.id, newIndex)
+ } else {
+ docStore.updateNode(orig.id, {
+ x: orig.x + dx,
+ y: orig.y + dy,
+ } as Partial)
+ }
+ }
+
+ engine.dragSyncSuppressed = false
+ engine.syncFromDocument()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Double click — text editing
+ // ---------------------------------------------------------------------------
+
+ private onDblClick = (e: MouseEvent) => {
+ const engine = this.getEngine()
+ if (!engine) return
+
+ if (this.penTool.onDblClick()) return
+
+ if (this.getTool() !== 'select') return
+
+ const scene = this.getScene(e)
+ if (!scene) return
+
+ const hits = engine.spatialIndex.hitTest(scene.x, scene.y)
+ if (hits.length === 0) return
+
+ const topHit = hits[0]
+ const currentSelection = useCanvasStore.getState().selection.selectedIds
+
+ // Double-click on a selected group/frame → enter it and select the child
+ if (currentSelection.length === 1) {
+ const selectedNode = useDocumentStore.getState().getNodeById(currentSelection[0])
+ if (
+ selectedNode
+ && (selectedNode.type === 'frame' || selectedNode.type === 'group')
+ && 'children' in selectedNode && selectedNode.children?.length
+ ) {
+ const childId = topHit.node.id
+ if (childId !== currentSelection[0]) {
+ useCanvasStore.getState().setSelection([childId], childId)
+ return
+ }
+ }
+ }
+
+ if (topHit.node.type !== 'text') return
+
+ const tNode = topHit.node as TextNode
+ const fills = tNode.fill
+ const firstFill = Array.isArray(fills) ? fills[0] : undefined
+ const color = firstFill?.type === 'solid' ? firstFill.color : '#000000'
+
+ this.onEditText({
+ nodeId: topHit.node.id,
+ x: topHit.absX * engine.zoom + engine.panX,
+ y: topHit.absY * engine.zoom + engine.panY,
+ w: topHit.absW * engine.zoom,
+ h: topHit.absH * engine.zoom,
+ content: typeof tNode.content === 'string'
+ ? tNode.content
+ : Array.isArray(tNode.content)
+ ? tNode.content.map((s) => s.text ?? '').join('')
+ : '',
+ fontSize: (tNode.fontSize ?? 16) * engine.zoom,
+ fontFamily: tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif',
+ fontWeight: String(tNode.fontWeight ?? '400'),
+ textAlign: tNode.textAlign ?? 'left',
+ color,
+ lineHeight: tNode.lineHeight ?? 1.4,
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+ // Keyboard: space for panning
+ // ---------------------------------------------------------------------------
+
+ private onKeyDown = (e: KeyboardEvent) => {
+ if (this.penTool.onKeyDown(e.key)) {
+ e.preventDefault()
+ return
+ }
+ if (e.code === 'Space' && !e.repeat) {
+ this.spacePressed = true
+ this.canvasEl.style.cursor = 'grab'
+ }
+ }
+
+ private onKeyUp = (e: KeyboardEvent) => {
+ if (e.code === 'Space') {
+ this.spacePressed = false
+ this.isPanning = false
+ this.canvasEl.style.cursor = toolToCursor(this.getTool())
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Attach / detach event listeners
+ // ---------------------------------------------------------------------------
+
+ attach(): () => void {
+ const canvasEl = this.canvasEl
+
+ const onContextMenu = (e: MouseEvent) => e.preventDefault()
+
+ // Tool change → cursor + cancel pen if switching away
+ const unsubTool = useCanvasStore.subscribe((state) => {
+ if (!this.spacePressed && !this.isResizing) canvasEl.style.cursor = toolToCursor(state.activeTool)
+ this.penTool.onToolChange(state.activeTool)
+ })
+
+ document.addEventListener('keydown', this.onKeyDown)
+ document.addEventListener('keyup', this.onKeyUp)
+ canvasEl.addEventListener('mousedown', this.onMouseDown)
+ canvasEl.addEventListener('dblclick', this.onDblClick)
+ canvasEl.addEventListener('contextmenu', onContextMenu)
+ window.addEventListener('mousemove', this.onMouseMove)
+ window.addEventListener('mouseup', this.onMouseUp)
+
+ return () => {
+ document.removeEventListener('keydown', this.onKeyDown)
+ document.removeEventListener('keyup', this.onKeyUp)
+ canvasEl.removeEventListener('mousedown', this.onMouseDown)
+ canvasEl.removeEventListener('dblclick', this.onDblClick)
+ canvasEl.removeEventListener('contextmenu', onContextMenu)
+ window.removeEventListener('mousemove', this.onMouseMove)
+ window.removeEventListener('mouseup', this.onMouseUp)
+ unsubTool()
+ }
+ }
+}
diff --git a/src/canvas/skia/skia-overlays.ts b/apps/web/src/canvas/skia/skia-overlays.ts
similarity index 100%
rename from src/canvas/skia/skia-overlays.ts
rename to apps/web/src/canvas/skia/skia-overlays.ts
diff --git a/apps/web/src/canvas/skia/skia-paint-utils.ts b/apps/web/src/canvas/skia/skia-paint-utils.ts
new file mode 100644
index 00000000..45eec504
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-paint-utils.ts
@@ -0,0 +1,11 @@
+// Re-export from @zseven-w/pen-renderer
+export {
+ parseColor,
+ cornerRadiusValue,
+ cornerRadii,
+ resolveFillColor,
+ resolveStrokeColor,
+ resolveStrokeWidth,
+ wrapLine,
+ cssFontFamily,
+} from '@zseven-w/pen-renderer'
diff --git a/apps/web/src/canvas/skia/skia-path-utils.ts b/apps/web/src/canvas/skia/skia-path-utils.ts
new file mode 100644
index 00000000..0f76f826
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-path-utils.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-renderer
+export { sanitizeSvgPath, hasInvalidNumbers, tryManualPathParse } from '@zseven-w/pen-renderer'
diff --git a/src/canvas/skia/skia-pen-tool.ts b/apps/web/src/canvas/skia/skia-pen-tool.ts
similarity index 100%
rename from src/canvas/skia/skia-pen-tool.ts
rename to apps/web/src/canvas/skia/skia-pen-tool.ts
diff --git a/apps/web/src/canvas/skia/skia-renderer.ts b/apps/web/src/canvas/skia/skia-renderer.ts
new file mode 100644
index 00000000..a1a2baf8
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-renderer.ts
@@ -0,0 +1,172 @@
+import type { CanvasKit, Canvas } from 'canvaskit-wasm'
+import { SkiaNodeRenderer, type RenderNode } from '@zseven-w/pen-renderer'
+import { parseColor } from '@zseven-w/pen-renderer'
+import {
+ drawSelectionBorder as _drawSelectionBorder,
+ drawFrameLabel as _drawFrameLabel,
+ drawFrameLabelColored as _drawFrameLabelColored,
+ drawHoverOutline as _drawHoverOutline,
+ drawSelectionMarquee as _drawSelectionMarquee,
+ drawGuide as _drawGuide,
+ drawPenPreview as _drawPenPreview,
+ drawAgentGlow as _drawAgentGlow,
+ drawAgentBadge as _drawAgentBadge,
+ drawAgentNodeBorder as _drawAgentNodeBorder,
+ drawAgentPreviewFill as _drawAgentPreviewFill,
+ drawArcHandles as _drawArcHandles,
+ type PenPreviewData,
+} from './skia-overlays'
+
+export type { RenderNode } from '@zseven-w/pen-renderer'
+
+/**
+ * Editor-specific renderer that extends the core SkiaNodeRenderer
+ * with selection borders, hover outlines, agent indicators, and other
+ * interactive overlays.
+ */
+export class SkiaRenderer extends SkiaNodeRenderer {
+ constructor(ck: CanvasKit) {
+ super(ck)
+ }
+
+ /**
+ * Draw a single render node with optional selection highlight.
+ */
+ drawNodeWithSelection(canvas: Canvas, rn: RenderNode, selectedIds: Set) {
+ super.drawNode(canvas, rn)
+ if (selectedIds.has(rn.node.id)) {
+ this.drawSelectionBorder(canvas, rn.absX, rn.absY, rn.absW, rn.absH)
+ }
+ }
+
+ // Drawing preview (semi-transparent shape while user drags to create)
+ drawPreview(
+ canvas: Canvas,
+ shape: { type: string; x: number; y: number; w: number; h: number },
+ ) {
+ const ck = this.ck
+ const fillPaint = new ck.Paint()
+ fillPaint.setStyle(ck.PaintStyle.Fill)
+ fillPaint.setAntiAlias(true)
+ fillPaint.setColor(parseColor(ck, 'rgba(59, 130, 246, 0.1)'))
+
+ const strokePaint = new ck.Paint()
+ strokePaint.setStyle(ck.PaintStyle.Stroke)
+ strokePaint.setAntiAlias(true)
+ strokePaint.setStrokeWidth(1)
+ strokePaint.setColor(parseColor(ck, '#3b82f6'))
+
+ const { x, y, w, h } = shape
+ if (shape.type === 'line') {
+ canvas.drawLine(x, y, x + w, y + h, strokePaint)
+ } else if (shape.type === 'ellipse') {
+ canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
+ canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), strokePaint)
+ } else if (shape.type === 'polygon') {
+ const count = 3
+ const raw: [number, number][] = []
+ for (let i = 0; i < count; i++) {
+ const angle = (i * 2 * Math.PI) / count - Math.PI / 2
+ raw.push([Math.cos(angle), Math.sin(angle)])
+ }
+ let pMinX = Infinity, pMaxX = -Infinity, pMinY = Infinity, pMaxY = -Infinity
+ for (const [rx, ry] of raw) {
+ if (rx < pMinX) pMinX = rx
+ if (rx > pMaxX) pMaxX = rx
+ if (ry < pMinY) pMinY = ry
+ if (ry > pMaxY) pMaxY = ry
+ }
+ const rw = pMaxX - pMinX
+ const rh = pMaxY - pMinY
+ const path = new ck.Path()
+ for (let i = 0; i < count; i++) {
+ const px = x + ((raw[i][0] - pMinX) / rw) * w
+ const py = y + ((raw[i][1] - pMinY) / rh) * h
+ if (i === 0) path.moveTo(px, py)
+ else path.lineTo(px, py)
+ }
+ path.close()
+ canvas.drawPath(path, fillPaint)
+ canvas.drawPath(path, strokePaint)
+ path.delete()
+ } else {
+ // rectangle / frame
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), strokePaint)
+ }
+
+ fillPaint.delete()
+ strokePaint.delete()
+ }
+
+ // Overlay drawing (delegated to skia-overlays.ts)
+
+ drawSelectionBorder(canvas: Canvas, x: number, y: number, w: number, h: number) {
+ _drawSelectionBorder(this.ck, canvas, x, y, w, h)
+ }
+
+ drawFrameLabel(canvas: Canvas, name: string, x: number, y: number) {
+ _drawFrameLabel(this.ck, canvas, name, x, y)
+ }
+
+ drawHoverOutline(canvas: Canvas, x: number, y: number, w: number, h: number) {
+ _drawHoverOutline(this.ck, canvas, x, y, w, h)
+ }
+
+ drawSelectionMarquee(canvas: Canvas, x1: number, y1: number, x2: number, y2: number) {
+ _drawSelectionMarquee(this.ck, canvas, x1, y1, x2, y2)
+ }
+
+ drawGuide(canvas: Canvas, x1: number, y1: number, x2: number, y2: number, zoom: number) {
+ _drawGuide(this.ck, canvas, x1, y1, x2, y2, zoom)
+ }
+
+ drawPenPreview(canvas: Canvas, data: PenPreviewData, zoom: number) {
+ _drawPenPreview(this.ck, canvas, data, zoom)
+ }
+
+ drawFrameLabelColored(
+ canvas: Canvas, name: string, x: number, y: number,
+ isReusable: boolean, isInstance: boolean, zoom = 1,
+ ) {
+ _drawFrameLabelColored(this.ck, canvas, name, x, y, isReusable, isInstance, zoom)
+ }
+
+ drawAgentGlow(
+ canvas: Canvas, x: number, y: number, w: number, h: number,
+ color: string, breath: number, zoom: number,
+ ) {
+ _drawAgentGlow(this.ck, canvas, x, y, w, h, color, breath, zoom)
+ }
+
+ drawAgentBadge(
+ canvas: Canvas, name: string,
+ frameX: number, frameY: number, frameW: number,
+ color: string, zoom: number, time: number,
+ ) {
+ _drawAgentBadge(this.ck, canvas, name, frameX, frameY, frameW, color, zoom, time)
+ }
+
+ drawAgentNodeBorder(
+ canvas: Canvas, x: number, y: number, w: number, h: number,
+ color: string, breath: number, zoom: number,
+ ) {
+ _drawAgentNodeBorder(this.ck, canvas, x, y, w, h, color, breath, zoom)
+ }
+
+ drawAgentPreviewFill(
+ canvas: Canvas, x: number, y: number, w: number, h: number,
+ color: string, time: number,
+ ) {
+ _drawAgentPreviewFill(this.ck, canvas, x, y, w, h, color, time)
+ }
+
+ drawArcHandles(
+ canvas: Canvas,
+ x: number, y: number, w: number, h: number,
+ startAngle: number, sweepAngle: number, innerRadius: number,
+ zoom: number,
+ ) {
+ _drawArcHandles(this.ck, canvas, x, y, w, h, startAngle, sweepAngle, innerRadius, zoom)
+ }
+}
diff --git a/apps/web/src/canvas/skia/skia-viewport.ts b/apps/web/src/canvas/skia/skia-viewport.ts
new file mode 100644
index 00000000..80832fcc
--- /dev/null
+++ b/apps/web/src/canvas/skia/skia-viewport.ts
@@ -0,0 +1,10 @@
+// Re-export from @zseven-w/pen-renderer
+export {
+ viewportMatrix,
+ screenToScene,
+ sceneToScreen,
+ zoomToPoint,
+ getViewportBounds,
+ isRectInViewport,
+} from '@zseven-w/pen-renderer'
+export type { ViewportState } from '@zseven-w/pen-renderer'
diff --git a/src/canvas/use-layout-indicator.ts b/apps/web/src/canvas/use-layout-indicator.ts
similarity index 100%
rename from src/canvas/use-layout-indicator.ts
rename to apps/web/src/canvas/use-layout-indicator.ts
diff --git a/src/components/editor/boolean-toolbar.tsx b/apps/web/src/components/editor/boolean-toolbar.tsx
similarity index 100%
rename from src/components/editor/boolean-toolbar.tsx
rename to apps/web/src/components/editor/boolean-toolbar.tsx
diff --git a/src/components/editor/editor-layout.tsx b/apps/web/src/components/editor/editor-layout.tsx
similarity index 92%
rename from src/components/editor/editor-layout.tsx
rename to apps/web/src/components/editor/editor-layout.tsx
index 794ebff0..875767f9 100644
--- a/src/components/editor/editor-layout.tsx
+++ b/apps/web/src/components/editor/editor-layout.tsx
@@ -8,6 +8,7 @@ import LayerPanel from '@/components/panels/layer-panel'
import RightPanel from '@/components/panels/right-panel'
import AIChatPanel, { AIChatMinimizedBar } from '@/components/panels/ai-chat-panel'
import VariablesPanel from '@/components/panels/variables-panel'
+import DesignMdPanel from '@/components/panels/design-md-panel'
import ComponentBrowserPanel from '@/components/panels/component-browser-panel'
import ExportDialog from '@/components/shared/export-dialog'
import SaveDialog from '@/components/shared/save-dialog'
@@ -20,6 +21,7 @@ import { useDocumentStore } from '@/stores/document-store'
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import { useUIKitStore } from '@/stores/uikit-store'
import { useThemePresetStore } from '@/stores/theme-preset-store'
+import { useDesignMdStore } from '@/stores/design-md-store'
import { useElectronMenu } from '@/hooks/use-electron-menu'
import { useFigmaPaste } from '@/hooks/use-figma-paste'
import { useMcpSync } from '@/hooks/use-mcp-sync'
@@ -32,6 +34,7 @@ export default function EditorLayout() {
const hasSelection = useCanvasStore((s) => s.selection.activeId !== null)
const layerPanelOpen = useCanvasStore((s) => s.layerPanelOpen)
const variablesPanelOpen = useCanvasStore((s) => s.variablesPanelOpen)
+ const designMdPanelOpen = useCanvasStore((s) => s.designMdPanelOpen)
const figmaImportOpen = useCanvasStore((s) => s.figmaImportDialogOpen)
const closeFigmaImport = useCallback(() => {
useCanvasStore.getState().setFigmaImportDialogOpen(false)
@@ -79,6 +82,13 @@ export default function EditorLayout() {
return
}
+ // Cmd+Shift+D: toggle design system panel
+ if (isMod && e.shiftKey && e.key.toLowerCase() === 'd') {
+ e.preventDefault()
+ useCanvasStore.getState().toggleDesignMdPanel()
+ return
+ }
+
// Cmd+Shift+K: toggle UIKit browser
if (isMod && e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
@@ -123,6 +133,7 @@ export default function EditorLayout() {
useUIKitStore.getState().hydrate()
useCanvasStore.getState().hydrate()
useThemePresetStore.getState().hydrate()
+ useDesignMdStore.getState().hydrate()
})
}, [])
@@ -142,6 +153,9 @@ export default function EditorLayout() {
{/* Floating variables panel — anchored to the right of the toolbar */}
{variablesPanelOpen && }
+ {/* Floating design system panel */}
+ {designMdPanelOpen && }
+
{/* Floating UIKit browser panel */}
{browserOpen && }
diff --git a/src/components/editor/page-tabs.tsx b/apps/web/src/components/editor/page-tabs.tsx
similarity index 100%
rename from src/components/editor/page-tabs.tsx
rename to apps/web/src/components/editor/page-tabs.tsx
diff --git a/src/components/editor/shape-tool-dropdown.tsx b/apps/web/src/components/editor/shape-tool-dropdown.tsx
similarity index 100%
rename from src/components/editor/shape-tool-dropdown.tsx
rename to apps/web/src/components/editor/shape-tool-dropdown.tsx
diff --git a/src/components/editor/status-bar.tsx b/apps/web/src/components/editor/status-bar.tsx
similarity index 100%
rename from src/components/editor/status-bar.tsx
rename to apps/web/src/components/editor/status-bar.tsx
diff --git a/src/components/editor/tool-button.tsx b/apps/web/src/components/editor/tool-button.tsx
similarity index 100%
rename from src/components/editor/tool-button.tsx
rename to apps/web/src/components/editor/tool-button.tsx
diff --git a/src/components/editor/toolbar.tsx b/apps/web/src/components/editor/toolbar.tsx
similarity index 89%
rename from src/components/editor/toolbar.tsx
rename to apps/web/src/components/editor/toolbar.tsx
index 0a577474..4acec1fc 100644
--- a/src/components/editor/toolbar.tsx
+++ b/apps/web/src/components/editor/toolbar.tsx
@@ -7,6 +7,7 @@ import {
Undo2,
Redo2,
Braces,
+ BookOpen,
LayoutGrid,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -33,6 +34,8 @@ export default function Toolbar() {
const canRedo = useHistoryStore((s) => s.redoStack.length > 0)
const variablesPanelOpen = useCanvasStore((s) => s.variablesPanelOpen)
const toggleVariablesPanel = useCanvasStore((s) => s.toggleVariablesPanel)
+ const designMdPanelOpen = useCanvasStore((s) => s.designMdPanelOpen)
+ const toggleDesignMdPanel = useCanvasStore((s) => s.toggleDesignMdPanel)
const browserOpen = useUIKitStore((s) => s.browserOpen)
const toggleBrowser = useUIKitStore((s) => s.toggleBrowser)
const fileInputRef = useRef(null)
@@ -248,6 +251,31 @@ export default function Toolbar() {
+ {/* Design.md */}
+
+
+
+
+
+
+
+ {t('toolbar.designSystem')}
+
+ {'\u2318\u21e7'}D
+
+
+
+
{/* UIKit Browser */}
diff --git a/src/components/editor/top-bar.tsx b/apps/web/src/components/editor/top-bar.tsx
similarity index 97%
rename from src/components/editor/top-bar.tsx
rename to apps/web/src/components/editor/top-bar.tsx
index 8fc4872c..f35a28f8 100644
--- a/src/components/editor/top-bar.tsx
+++ b/apps/web/src/components/editor/top-bar.tsx
@@ -17,6 +17,7 @@ import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
import OpenCodeLogo from '@/components/icons/opencode-logo'
import CopilotLogo from '@/components/icons/copilot-logo'
+import GeminiLogo from '@/components/icons/gemini-logo'
import FigmaLogo from '@/components/icons/figma-logo'
import LanguageSelector from '@/components/shared/language-selector'
import { cn } from '@/lib/utils'
@@ -64,9 +65,10 @@ const PROVIDER_ICONS: Record {
+ if (useDocumentStore.getState().isDirty) {
+ if (!window.confirm(t('topbar.closeConfirmMessage'))) return
+ }
useDocumentStore.getState().newDocument()
requestAnimationFrame(() => zoomToFitContent())
- }, [])
+ }, [t])
/**
* Unified save: if the current file is .op with a known handle/path, save
@@ -281,6 +286,9 @@ export default function TopBar() {
}, [])
const handleOpen = useCallback(() => {
+ if (useDocumentStore.getState().isDirty) {
+ if (!window.confirm(t('topbar.closeConfirmMessage'))) return
+ }
if (isElectron()) {
window.electronAPI!.openFile().then((result) => {
if (!result) return
@@ -310,7 +318,7 @@ export default function TopBar() {
}
})
}
- }, [])
+ }, [t])
const displayName = fileName ?? t('common.untitled')
diff --git a/src/components/editor/update-ready-banner.tsx b/apps/web/src/components/editor/update-ready-banner.tsx
similarity index 100%
rename from src/components/editor/update-ready-banner.tsx
rename to apps/web/src/components/editor/update-ready-banner.tsx
diff --git a/src/components/icons/claude-logo.tsx b/apps/web/src/components/icons/claude-logo.tsx
similarity index 100%
rename from src/components/icons/claude-logo.tsx
rename to apps/web/src/components/icons/claude-logo.tsx
diff --git a/src/components/icons/copilot-logo.tsx b/apps/web/src/components/icons/copilot-logo.tsx
similarity index 100%
rename from src/components/icons/copilot-logo.tsx
rename to apps/web/src/components/icons/copilot-logo.tsx
diff --git a/src/components/icons/figma-logo.tsx b/apps/web/src/components/icons/figma-logo.tsx
similarity index 100%
rename from src/components/icons/figma-logo.tsx
rename to apps/web/src/components/icons/figma-logo.tsx
diff --git a/apps/web/src/components/icons/gemini-logo.tsx b/apps/web/src/components/icons/gemini-logo.tsx
new file mode 100644
index 00000000..43ae9ae1
--- /dev/null
+++ b/apps/web/src/components/icons/gemini-logo.tsx
@@ -0,0 +1,17 @@
+import type { SVGProps } from 'react'
+
+export default function GeminiLogo(props: SVGProps) {
+ return (
+
+
+
+ )
+}
diff --git a/src/components/icons/openai-logo.tsx b/apps/web/src/components/icons/openai-logo.tsx
similarity index 100%
rename from src/components/icons/openai-logo.tsx
rename to apps/web/src/components/icons/openai-logo.tsx
diff --git a/src/components/icons/opencode-logo.tsx b/apps/web/src/components/icons/opencode-logo.tsx
similarity index 100%
rename from src/components/icons/opencode-logo.tsx
rename to apps/web/src/components/icons/opencode-logo.tsx
diff --git a/src/components/panels/ai-chat-checklist.tsx b/apps/web/src/components/panels/ai-chat-checklist.tsx
similarity index 100%
rename from src/components/panels/ai-chat-checklist.tsx
rename to apps/web/src/components/panels/ai-chat-checklist.tsx
diff --git a/src/components/panels/ai-chat-handlers.ts b/apps/web/src/components/panels/ai-chat-handlers.ts
similarity index 93%
rename from src/components/panels/ai-chat-handlers.ts
rename to apps/web/src/components/panels/ai-chat-handlers.ts
index 1c57353d..37b30bd7 100644
--- a/src/components/panels/ai-chat-handlers.ts
+++ b/apps/web/src/components/panels/ai-chat-handlers.ts
@@ -3,9 +3,11 @@ import { nanoid } from 'nanoid'
import { useAIStore } from '@/stores/ai-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
+import { useDesignMdStore } from '@/stores/design-md-store'
import { getActivePageChildren } from '@/stores/document-tree-utils'
import { streamChat } from '@/services/ai/ai-service'
-import { CHAT_SYSTEM_PROMPT } from '@/services/ai/ai-prompts'
+import { buildChatSystemPrompt } from '@/services/ai/ai-prompts'
+import { detectSections } from '@/services/ai/ai-prompt-sections'
import {
generateDesign,
generateDesignModification,
@@ -218,6 +220,7 @@ export function useChatHandlers() {
const { rawResponse, nodes } = await generateDesignModification(modTargets, messageText, {
variables: modDoc.variables,
themes: modDoc.themes,
+ designMd: useDesignMdStore.getState().designMd,
model,
provider: currentProvider,
}, abortController.signal)
@@ -241,6 +244,7 @@ export function useChatHandlers() {
documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`,
variables: doc.variables,
themes: doc.themes,
+ designMd: useDesignMdStore.getState().designMd,
},
}, {
animated: true,
@@ -268,9 +272,17 @@ export function useChatHandlers() {
})
// Trim history to prevent unbounded context growth
const trimmedHistory = trimChatHistory(chatHistory)
+ // Progressive section loading: detect needed sections from user message
+ const chatDoc = useDocumentStore.getState().document
+ const chatDesignMd = useDesignMdStore.getState().designMd
+ const chatSections = detectSections(fullUserMessage, {
+ hasDesignMd: !!chatDesignMd,
+ hasVariables: !!chatDoc.variables && Object.keys(chatDoc.variables).length > 0,
+ })
+ const chatSystemPrompt = buildChatSystemPrompt(chatSections, chatDesignMd)
let chatThinking = ''
for await (const chunk of streamChat(
- CHAT_SYSTEM_PROMPT,
+ chatSystemPrompt,
trimmedHistory,
model,
CHAT_STREAM_THINKING_CONFIG,
diff --git a/src/components/panels/ai-chat-panel.tsx b/apps/web/src/components/panels/ai-chat-panel.tsx
similarity index 99%
rename from src/components/panels/ai-chat-panel.tsx
rename to apps/web/src/components/panels/ai-chat-panel.tsx
index 4512a2cf..e58d7dcc 100644
--- a/src/components/panels/ai-chat-panel.tsx
+++ b/apps/web/src/components/panels/ai-chat-panel.tsx
@@ -16,6 +16,7 @@ import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
import OpenCodeLogo from '@/components/icons/opencode-logo'
import CopilotLogo from '@/components/icons/copilot-logo'
+import GeminiLogo from '@/components/icons/gemini-logo'
import ChatMessage from './chat-message'
import { useChatHandlers } from './ai-chat-handlers'
import { FixedChecklist } from './ai-chat-checklist'
@@ -25,6 +26,7 @@ const PROVIDER_ICON: Record = {
openai: OpenAILogo,
opencode: OpenCodeLogo,
copilot: CopilotLogo,
+ gemini: GeminiLogo,
}
const QUICK_ACTIONS = [
@@ -197,6 +199,7 @@ export default function AIChatPanel() {
openai: 'OpenAI',
opencode: 'OpenCode',
copilot: 'GitHub Copilot',
+ gemini: 'Google Gemini',
}
const groups = connectedProviders.map((p) => ({
provider: p,
diff --git a/src/components/panels/appearance-section.tsx b/apps/web/src/components/panels/appearance-section.tsx
similarity index 100%
rename from src/components/panels/appearance-section.tsx
rename to apps/web/src/components/panels/appearance-section.tsx
diff --git a/src/components/panels/chat-message.tsx b/apps/web/src/components/panels/chat-message.tsx
similarity index 100%
rename from src/components/panels/chat-message.tsx
rename to apps/web/src/components/panels/chat-message.tsx
diff --git a/src/components/panels/code-panel.tsx b/apps/web/src/components/panels/code-panel.tsx
similarity index 100%
rename from src/components/panels/code-panel.tsx
rename to apps/web/src/components/panels/code-panel.tsx
diff --git a/src/components/panels/component-browser-card.tsx b/apps/web/src/components/panels/component-browser-card.tsx
similarity index 85%
rename from src/components/panels/component-browser-card.tsx
rename to apps/web/src/components/panels/component-browser-card.tsx
index 1970e931..5c354969 100644
--- a/src/components/panels/component-browser-card.tsx
+++ b/apps/web/src/components/panels/component-browser-card.tsx
@@ -1,10 +1,9 @@
import { useCallback } from 'react'
-import { nanoid } from 'nanoid'
import type { KitComponent, UIKit } from '@/types/uikit'
-import type { PenNode } from '@/types/pen'
import { useDocumentStore } from '@/stores/document-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { getCanvasSize } from '@/canvas/skia-engine-ref'
+import { cloneNodeWithNewIds } from '@/stores/document-tree-utils'
import { findReusableNode, deepCloneNode, collectVariableRefs } from '@/uikit/kit-utils'
import NodePreviewSvg from './node-preview-svg'
@@ -13,15 +12,6 @@ interface ComponentBrowserCardProps {
kit: UIKit
}
-/** Recursively assign new IDs to a node tree so each insert is independent. */
-function reassignIds(node: PenNode): PenNode {
- const clone = { ...node, id: nanoid() }
- if ('children' in clone && Array.isArray(clone.children)) {
- clone.children = clone.children.map(reassignIds)
- }
- return clone as PenNode
-}
-
export default function ComponentBrowserCard({ component, kit }: ComponentBrowserCardProps) {
const handleInsert = useCallback(() => {
const { addNode, document } = useDocumentStore.getState()
@@ -44,7 +34,7 @@ export default function ComponentBrowserCard({ component, kit }: ComponentBrowse
}
// Deep clone with new IDs, remove reusable flag so it's standalone
- const cloned = reassignIds(deepCloneNode(kitNode))
+ const cloned = cloneNodeWithNewIds(deepCloneNode(kitNode))
if ('reusable' in cloned) {
delete (cloned as unknown as Record).reusable
}
diff --git a/src/components/panels/component-browser-grid.tsx b/apps/web/src/components/panels/component-browser-grid.tsx
similarity index 100%
rename from src/components/panels/component-browser-grid.tsx
rename to apps/web/src/components/panels/component-browser-grid.tsx
diff --git a/src/components/panels/component-browser-panel.tsx b/apps/web/src/components/panels/component-browser-panel.tsx
similarity index 100%
rename from src/components/panels/component-browser-panel.tsx
rename to apps/web/src/components/panels/component-browser-panel.tsx
diff --git a/src/components/panels/corner-radius-section.tsx b/apps/web/src/components/panels/corner-radius-section.tsx
similarity index 100%
rename from src/components/panels/corner-radius-section.tsx
rename to apps/web/src/components/panels/corner-radius-section.tsx
diff --git a/apps/web/src/components/panels/design-md-panel.tsx b/apps/web/src/components/panels/design-md-panel.tsx
new file mode 100644
index 00000000..183424c2
--- /dev/null
+++ b/apps/web/src/components/panels/design-md-panel.tsx
@@ -0,0 +1,704 @@
+import { useState, useRef, useEffect, useCallback } from 'react'
+import { X, Upload, Download, Sparkles, ChevronDown, ChevronRight, Copy, Loader2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { cn } from '@/lib/utils'
+import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
+import { useDesignMdStore } from '@/stores/design-md-store'
+import { useCanvasStore } from '@/stores/canvas-store'
+import { useAIStore } from '@/stores/ai-store'
+import { streamChat } from '@/services/ai/ai-service'
+import { importDesignMd, exportDesignMd } from '@/utils/design-md-io'
+import { parseDesignMd, designMdColorsToVariables, extractDesignMdFromDocument } from '@/utils/design-md-parser'
+import type { DesignMdColor } from '@/types/design-md'
+import type { PenNode } from '@/types/pen'
+
+const MIN_WIDTH = 420
+const MIN_HEIGHT = 300
+const DEFAULT_WIDTH = 560
+const DEFAULT_HEIGHT = 520
+
+type SectionId = 'theme' | 'colors' | 'typography' | 'components' | 'layout' | 'notes'
+
+// ---------------------------------------------------------------------------
+// AI auto-generate prompt
+// ---------------------------------------------------------------------------
+
+const DESIGN_MD_SYSTEM_PROMPT = `You are a Design Systems Lead. Analyze the provided PenNode design tree and generate a comprehensive design.md in the Google Stitch format.
+
+OUTPUT FORMAT — a complete markdown document with these sections:
+
+# Design System: [Project Name]
+
+## 1. Visual Theme & Atmosphere
+Describe the mood, density, and aesthetic philosophy using evocative adjectives.
+
+## 2. Color Palette & Roles
+For each color found in the design:
+- **Descriptive Name** (#HEX) — Functional role (e.g. "Primary CTA", "Background", "Body text")
+
+## 3. Typography Rules
+- Font families used, weight hierarchy, size scale, line-height conventions.
+
+## 4. Component Stylings
+- **Buttons**: shape, colors, padding, states
+- **Cards**: corners, shadows, internal padding
+- **Inputs**: borders, backgrounds
+- **Navigation**: layout, spacing
+
+## 5. Layout Principles
+- Grid system, whitespace strategy, spacing units, responsive breakpoints.
+
+## 6. Design System Notes
+- Key language/terms to use when generating new designs in this style.
+
+RULES:
+- Use descriptive natural language, NOT technical jargon (e.g. "subtly rounded corners" not "rounded-lg").
+- Pair ALL colors with exact hex codes.
+- Explain functional roles for every design element.
+- Output ONLY the markdown document, starting with "# Design System:".
+- NO preamble, NO commentary, NO tool calls, NO code fences around the output.
+- Do NOT use tags or any tool invocations. Just output the markdown text directly.`
+
+// ---------------------------------------------------------------------------
+// Clean AI response artifacts
+// ---------------------------------------------------------------------------
+
+function cleanAIResult(raw: string): string {
+ let text = raw.trim()
+
+ // Remove ... blocks (XML-style tool calls)
+ text = text.replace(/[\s\S]*?<\/tool_call>/g, '')
+
+ // Remove preamble before the first markdown heading
+ const headingIdx = text.search(/^#\s+/m)
+ if (headingIdx > 0) {
+ text = text.substring(headingIdx)
+ }
+
+ // Strip wrapping code fences
+ if (text.startsWith('```')) {
+ text = text.replace(/^```(?:markdown|md)?\n?/, '').replace(/\n?```$/, '')
+ }
+
+ // Remove JSON tool-call artifacts (e.g. {"name":"Write","arguments":...})
+ text = text.replace(/\{"name"\s*:\s*"(?:Write|Read|Edit|Bash)"[^}]*\}\s*/g, '')
+
+ // Remove lines that are tool call fragments or AI narration
+ text = text
+ .split('\n')
+ .filter((line) => {
+ const trimmed = line.trim()
+ if (trimmed.startsWith('{"name"') || trimmed.startsWith('{"tool_use_id"')) return false
+ if (/^\{"file_path"\s*:/.test(trimmed)) return false
+ // Drop leftover tool_call tags
+ if (trimmed === '' || trimmed === ' ') return false
+ return true
+ })
+ .join('\n')
+
+ // Strip code fence blocks containing JSON tool calls
+ text = text.replace(/```json\s*\{[^`]*?"(?:file_path|name|arguments)"[^`]*?```/gs, '')
+
+ // Collapse excessive blank lines
+ text = text.replace(/\n{4,}/g, '\n\n\n')
+
+ return text.trim()
+}
+
+// ---------------------------------------------------------------------------
+// Panel component
+// ---------------------------------------------------------------------------
+
+export default function DesignMdPanel() {
+ const { t } = useTranslation()
+ const designMd = useDesignMdStore((s) => s.designMd)
+ const setDesignMd = useDesignMdStore((s) => s.setDesignMd)
+ const setVariable = useDocumentStore((s) => s.setVariable)
+ const togglePanel = useCanvasStore((s) => s.toggleDesignMdPanel)
+
+ const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH)
+ const [panelHeight, setPanelHeight] = useState(DEFAULT_HEIGHT)
+ const [panelX, setPanelX] = useState(() => Math.round((window.innerWidth - DEFAULT_WIDTH) / 2))
+ const [panelY, setPanelY] = useState(() => Math.round((window.innerHeight - DEFAULT_HEIGHT) / 2))
+ const [expandedSections, setExpandedSections] = useState>(
+ new Set(['theme', 'colors', 'typography']),
+ )
+ const [copiedHex, setCopiedHex] = useState(null)
+ const [isGenerating, setIsGenerating] = useState(false)
+
+ const panelRef = useRef(null)
+ const generateAbortRef = useRef(null)
+ const resizeRef = useRef<{
+ edge: 'right' | 'bottom' | 'corner'
+ startX: number; startY: number; startW: number; startH: number
+ } | null>(null)
+ const dragRef = useRef<{
+ startX: number; startY: number; startPanelX: number; startPanelY: number
+ } | null>(null)
+
+ // Drag + resize handlers
+ useEffect(() => {
+ const onMove = (e: PointerEvent) => {
+ const d = dragRef.current
+ if (d) {
+ setPanelX(Math.max(0, Math.min(window.innerWidth - 100, d.startPanelX + (e.clientX - d.startX))))
+ setPanelY(Math.max(0, Math.min(window.innerHeight - 40, d.startPanelY + (e.clientY - d.startY))))
+ return
+ }
+ const r = resizeRef.current
+ if (!r) return
+ const maxW = window.innerWidth - 72
+ const maxH = window.innerHeight - 72
+ if (r.edge === 'right' || r.edge === 'corner')
+ setPanelWidth(Math.min(maxW, Math.max(MIN_WIDTH, r.startW + (e.clientX - r.startX))))
+ if (r.edge === 'bottom' || r.edge === 'corner')
+ setPanelHeight(Math.min(maxH, Math.max(MIN_HEIGHT, r.startH + (e.clientY - r.startY))))
+ }
+ const onUp = () => { dragRef.current = null; resizeRef.current = null }
+ window.addEventListener('pointermove', onMove)
+ window.addEventListener('pointerup', onUp)
+ return () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp) }
+ }, [])
+
+ // Cleanup abort on unmount
+ useEffect(() => () => { generateAbortRef.current?.abort() }, [])
+
+ const startDrag = useCallback((e: React.PointerEvent) => {
+ if ((e.target as HTMLElement).closest('button')) return
+ e.preventDefault()
+ dragRef.current = { startX: e.clientX, startY: e.clientY, startPanelX: panelX, startPanelY: panelY }
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
+ }, [panelX, panelY])
+
+ const startResize = useCallback((edge: 'right' | 'bottom' | 'corner', e: React.PointerEvent) => {
+ e.preventDefault()
+ resizeRef.current = { edge, startX: e.clientX, startY: e.clientY, startW: panelWidth, startH: panelHeight }
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
+ }, [panelWidth, panelHeight])
+
+ const toggleSection = (id: SectionId) => {
+ setExpandedSections((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id); else next.add(id)
+ return next
+ })
+ }
+
+ // --- Actions ---
+
+ const handleImport = async () => {
+ const spec = await importDesignMd()
+ if (spec) setDesignMd(spec)
+ }
+
+ const handleExport = async () => {
+ const spec = designMd ?? extractDesignMdFromDocument(useDocumentStore.getState().document)
+ await exportDesignMd(spec)
+ }
+
+ const handleAutoGenerate = useCallback(async () => {
+ if (isGenerating) {
+ generateAbortRef.current?.abort()
+ setIsGenerating(false)
+ return
+ }
+
+ const model = useAIStore.getState().model
+ const modelGroups = useAIStore.getState().modelGroups
+ const provider = modelGroups.find((g) =>
+ g.models.some((m) => m.value === model),
+ )?.provider
+ if (!model || !provider) return
+
+ const doc = useDocumentStore.getState().document
+ const activePageId = useCanvasStore.getState().activePageId
+
+ // Get nodes from the active page
+ const nodes = getActivePageChildren(doc, activePageId)
+ if (nodes.length === 0) return
+
+ // Build a compact summary of the design tree
+ const summarizeNode = (n: PenNode, depth = 0): string => {
+ const indent = ' '.repeat(depth)
+ const props: string[] = []
+ if (n.name) props.push(`"${n.name}"`)
+ if (n.role) props.push(`role=${n.role}`)
+ if ('fill' in n && Array.isArray(n.fill)) {
+ for (const f of n.fill) {
+ if (f.type === 'solid' && f.color) props.push(`fill:${f.color}`)
+ if (f.type === 'linear_gradient') props.push('fill:gradient')
+ }
+ }
+ if ('stroke' in n && n.stroke) {
+ const sf = n.stroke.fill?.[0]
+ if (sf?.type === 'solid' && sf.color) props.push(`stroke:${sf.color}/${n.stroke.thickness}`)
+ }
+ if ('content' in n && n.content) props.push(`"${String(n.content).slice(0, 40)}"`)
+ if ('fontSize' in n) props.push(`${n.fontSize}px`)
+ if ('fontFamily' in n) props.push(`font:${n.fontFamily}`)
+ if ('fontWeight' in n) props.push(`w:${n.fontWeight}`)
+ if ('width' in n) props.push(`w=${n.width}`)
+ if ('height' in n) props.push(`h=${n.height}`)
+ if ('cornerRadius' in n && n.cornerRadius) props.push(`r=${n.cornerRadius}`)
+ if ('gap' in n && n.gap) props.push(`gap=${n.gap}`)
+ if ('padding' in n && n.padding) props.push(`pad=${JSON.stringify(n.padding)}`)
+ if ('layout' in n && n.layout && n.layout !== 'none') props.push(`layout=${n.layout}`)
+ if ('justifyContent' in n && n.justifyContent) props.push(`justify=${n.justifyContent}`)
+ if ('alignItems' in n && n.alignItems) props.push(`align=${n.alignItems}`)
+ if ('effects' in n && Array.isArray(n.effects) && n.effects.length > 0) {
+ props.push(`effects=${n.effects.map(e => e.type).join(',')}`)
+ }
+ if ('opacity' in n && n.opacity !== undefined && n.opacity !== 1) props.push(`opacity=${n.opacity}`)
+
+ const line = `${indent}${n.type} ${props.join(' ')}`
+ const childLines: string[] = []
+ if ('children' in n && Array.isArray(n.children) && depth < 5) {
+ for (const child of n.children.slice(0, 40)) {
+ childLines.push(summarizeNode(child as PenNode, depth + 1))
+ }
+ }
+ return [line, ...childLines].join('\n')
+ }
+
+ const treeSummary = nodes.slice(0, 10).map((n) => summarizeNode(n as PenNode)).join('\n\n')
+
+ // Variable summary
+ let varSummary = ''
+ if (doc.variables && Object.keys(doc.variables).length > 0) {
+ varSummary = '\n\nDESIGN VARIABLES:\n' + Object.entries(doc.variables)
+ .map(([name, def]) => {
+ const val = Array.isArray(def.value) ? String(def.value[0]?.value ?? '') : String(def.value)
+ return `- ${name} (${def.type}): ${val}`
+ })
+ .join('\n')
+ }
+
+ const userMessage = `Analyze this PenNode design tree and generate a comprehensive design.md:\n\nProject: ${doc.name ?? 'Untitled'}\n\nDesign tree (PenNode format — type followed by properties):\n${treeSummary}${varSummary}`
+
+ setIsGenerating(true)
+ const abortController = new AbortController()
+ generateAbortRef.current = abortController
+
+ try {
+ let result = ''
+ for await (const chunk of streamChat(
+ DESIGN_MD_SYSTEM_PROMPT,
+ [{ role: 'user', content: userMessage }],
+ model,
+ { thinkingMode: 'disabled', effort: 'high' },
+ provider,
+ abortController.signal,
+ )) {
+ if (chunk.type === 'text') {
+ result += chunk.content
+ }
+ if (chunk.type === 'error') break
+ }
+ if (result.trim()) {
+ const cleaned = cleanAIResult(result)
+ if (cleaned) {
+ const spec = parseDesignMd(cleaned)
+ setDesignMd(spec)
+ }
+ }
+ } finally {
+ setIsGenerating(false)
+ generateAbortRef.current = null
+ }
+ }, [isGenerating, setDesignMd])
+
+ const handleCopyHex = (hex: string) => {
+ navigator.clipboard.writeText(hex)
+ setCopiedHex(hex)
+ setTimeout(() => setCopiedHex(null), 1500)
+ }
+
+ const handleSyncColor = (color: DesignMdColor) => {
+ const vars = designMdColorsToVariables([color])
+ for (const [name, def] of Object.entries(vars)) setVariable(name, def)
+ }
+
+ const handleSyncAllColors = () => {
+ if (!designMd?.colorPalette) return
+ const vars = designMdColorsToVariables(designMd.colorPalette)
+ for (const [name, def] of Object.entries(vars)) setVariable(name, def)
+ }
+
+ const handleClear = () => setDesignMd(undefined)
+
+ const hasAI = useAIStore((s) => s.availableModels.length > 0)
+
+ // Check if designMd has any meaningful content beyond just raw text
+ const hasContent = designMd && (
+ designMd.visualTheme ||
+ (designMd.colorPalette && designMd.colorPalette.length > 0) ||
+ designMd.typography ||
+ designMd.componentStyles ||
+ designMd.layoutPrinciples ||
+ designMd.generationNotes
+ )
+
+ // --- Render ---
+
+ return (
+
+ {/* Header — draggable */}
+
+
{t('designMd.title')}
+
+
+
+
+
+
+
+ {hasAI && (
+
+ {isGenerating ? : }
+
+ )}
+
+
+
+
+
+
+ {/* Content */}
+
+ {!hasContent ? (
+
+ {designMd?.raw ? (
+ /* designMd exists but parser couldn't extract sections — show raw */
+
+
+ {designMd.raw}
+
+
+
+ {isGenerating ? : }
+ {t('designMd.autoGenerateCta')}
+
+
+ {t('designMd.remove')}
+
+
+
+ ) : (
+ /* No designMd at all — empty state */
+ <>
+
+
+
+
{t('designMd.empty')}
+
+
+ {t('designMd.importCta')}
+
+ {hasAI && (
+
+ {isGenerating ? : }
+ {t('designMd.autoGenerateCta')}
+
+ )}
+
+ >
+ )}
+
+ ) : (
+
+ {/* Project name */}
+ {designMd.projectName && (
+
+
{designMd.projectName}
+
+ )}
+
+ {/* Visual Theme */}
+ {designMd.visualTheme && (
+
toggleSection('theme')}
+ >
+
+
+ )}
+
+ {/* Color Palette */}
+ {designMd.colorPalette && designMd.colorPalette.length > 0 && (
+
toggleSection('colors')}
+ action={
+ { e.stopPropagation(); handleSyncAllColors() }}
+ className="text-[10px] text-primary hover:text-primary/80 transition-colors"
+ >
+ {t('designMd.syncAllToVariables')}
+
+ }
+ >
+
+ {designMd.colorPalette.map((color, i) => (
+
+
handleCopyHex(color.hex)}
+ title={t('designMd.copyHex')}
+ />
+
+ {color.name}
+ {color.hex} — {color.role}
+
+
+ handleCopyHex(color.hex)} className="p-1 rounded-md hover:bg-muted" title={t('designMd.copyHex')}>
+
+
+ handleSyncColor(color)}
+ className="text-[9px] px-1.5 py-0.5 rounded-md hover:bg-muted text-muted-foreground font-medium"
+ title={t('designMd.addAsVariable')}
+ >
+ {t('designMd.addAsVariable')}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Typography */}
+ {designMd.typography && (
+
toggleSection('typography')}
+ >
+ {designMd.typography.scale ? (
+
+ ) : (
+
+ {designMd.typography.fontFamily && (
+
{t('designMd.font')}: {designMd.typography.fontFamily}
+ )}
+ {designMd.typography.headings && (
+
{t('designMd.headings')}: {renderInline(designMd.typography.headings)}
+ )}
+ {designMd.typography.body && (
+
{t('designMd.body')}: {renderInline(designMd.typography.body)}
+ )}
+
+ )}
+
+ )}
+
+ {/* Component Styles */}
+ {designMd.componentStyles && (
+
toggleSection('components')}>
+
+
+ )}
+
+ {/* Layout Principles */}
+ {designMd.layoutPrinciples && (
+
toggleSection('layout')}>
+
+
+ )}
+
+ {/* Generation Notes */}
+ {designMd.generationNotes && (
+
toggleSection('notes')}>
+
+
+ )}
+
+ {/* Footer: Remove */}
+
+
+ {t('designMd.remove')}
+
+
+
+ )}
+
+
+ {/* Generating overlay */}
+ {isGenerating && (
+
+
+
{t('ai.generating')}
+
+ {t('ai.stopGenerating')}
+
+
+ )}
+
+ {/* Resize handles */}
+
startResize('right', e)} />
+
startResize('bottom', e)} />
+
startResize('corner', e)} />
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Lightweight markdown renderer
+// ---------------------------------------------------------------------------
+
+function MdText({ text, limit }: { text: string; limit?: number }) {
+ const content = limit && text.length > limit ? text.substring(0, limit) + '...' : text
+
+ // Split into blocks by double newline
+ const blocks = content.split(/\n{2,}/)
+
+ return (
+
+ {blocks.map((block, i) => {
+ const trimmed = block.trim()
+ if (!trimmed) return null
+
+ // H3 heading
+ if (trimmed.startsWith('### ')) {
+ return
{renderInline(trimmed.slice(4))}
+ }
+ // H4 heading
+ if (trimmed.startsWith('#### ')) {
+ return
{renderInline(trimmed.slice(5))}
+ }
+
+ // List block
+ const lines = trimmed.split('\n')
+ const isList = lines.every((l) => /^\s*[-*]\s/.test(l) || !l.trim())
+ if (isList) {
+ return (
+
+ {lines.filter((l) => l.trim()).map((line, j) => (
+
+ •
+ {renderInline(line.replace(/^\s*[-*]\s+/, ''))}
+
+ ))}
+
+ )
+ }
+
+ // Paragraph
+ return
{renderInline(trimmed.replace(/\n/g, ' '))}
+ })}
+
+ )
+}
+
+/** Render inline markdown: **bold**, *italic*, `code`, #HEX color chips */
+function renderInline(text: string): React.ReactNode {
+ // Split by markdown inline patterns
+ const parts: React.ReactNode[] = []
+ let remaining = text
+ let key = 0
+
+ while (remaining) {
+ // Bold: **text**
+ const boldMatch = remaining.match(/\*\*(.+?)\*\*/)
+ // Code: `text`
+ const codeMatch = remaining.match(/`([^`]+)`/)
+ // Hex color: #XXXXXX
+ const colorMatch = remaining.match(/#([0-9A-Fa-f]{6})\b/)
+
+ // Find earliest match
+ const matches = [
+ boldMatch && { type: 'bold' as const, index: boldMatch.index!, length: boldMatch[0].length, content: boldMatch[1] },
+ codeMatch && { type: 'code' as const, index: codeMatch.index!, length: codeMatch[0].length, content: codeMatch[1] },
+ colorMatch && { type: 'color' as const, index: colorMatch.index!, length: colorMatch[0].length, content: `#${colorMatch[1]}` },
+ ].filter(Boolean).sort((a, b) => a!.index - b!.index)
+
+ if (matches.length === 0) {
+ parts.push(remaining)
+ break
+ }
+
+ const m = matches[0]!
+ if (m.index > 0) parts.push(remaining.substring(0, m.index))
+
+ switch (m.type) {
+ case 'bold':
+ parts.push(
{m.content} )
+ break
+ case 'code':
+ parts.push(
{m.content})
+ break
+ case 'color':
+ parts.push(
+
+
+ {m.content}
+ ,
+ )
+ break
+ }
+
+ remaining = remaining.substring(m.index + m.length)
+ }
+
+ return parts.length === 1 && typeof parts[0] === 'string' ? parts[0] : <>{parts}>
+}
+
+// ---------------------------------------------------------------------------
+// Collapsible section
+// ---------------------------------------------------------------------------
+
+function Section({
+ title,
+ expanded,
+ onToggle,
+ action,
+ children,
+}: {
+ title: string
+ expanded: boolean
+ onToggle: () => void
+ action?: React.ReactNode
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+ {expanded ? : }
+ {title}
+
+ {action && e.stopPropagation()}>{action}
}
+
+ {expanded &&
{children}
}
+
+ )
+}
diff --git a/src/components/panels/effects-section.tsx b/apps/web/src/components/panels/effects-section.tsx
similarity index 100%
rename from src/components/panels/effects-section.tsx
rename to apps/web/src/components/panels/effects-section.tsx
diff --git a/src/components/panels/export-section.tsx b/apps/web/src/components/panels/export-section.tsx
similarity index 100%
rename from src/components/panels/export-section.tsx
rename to apps/web/src/components/panels/export-section.tsx
diff --git a/src/components/panels/fill-section.tsx b/apps/web/src/components/panels/fill-section.tsx
similarity index 96%
rename from src/components/panels/fill-section.tsx
rename to apps/web/src/components/panels/fill-section.tsx
index 1177b10d..48378707 100644
--- a/src/components/panels/fill-section.tsx
+++ b/apps/web/src/components/panels/fill-section.tsx
@@ -50,7 +50,15 @@ export default function FillSection({
onUpdate,
}: FillSectionProps) {
const { t } = useTranslation()
- const firstFill = fills?.[0]
+ // Guard: AI-generated nodes may store fill as a plain string (e.g. "#000000")
+ // instead of a PenFill[] array, causing "'opacity' in string" crashes.
+ const safeFills: PenFill[] | undefined =
+ typeof fills === 'string'
+ ? [{ type: 'solid', color: fills }]
+ : Array.isArray(fills)
+ ? fills.map((f) => typeof f === 'string' ? { type: 'solid' as const, color: f } : f)
+ : undefined
+ const firstFill = safeFills?.[0]
const fillType = firstFill?.type ?? 'solid'
const currentColor =
diff --git a/src/components/panels/icon-section.tsx b/apps/web/src/components/panels/icon-section.tsx
similarity index 100%
rename from src/components/panels/icon-section.tsx
rename to apps/web/src/components/panels/icon-section.tsx
diff --git a/src/components/panels/image-fill-popover.tsx b/apps/web/src/components/panels/image-fill-popover.tsx
similarity index 100%
rename from src/components/panels/image-fill-popover.tsx
rename to apps/web/src/components/panels/image-fill-popover.tsx
diff --git a/apps/web/src/components/panels/image-generate-popover.tsx b/apps/web/src/components/panels/image-generate-popover.tsx
new file mode 100644
index 00000000..9e471530
--- /dev/null
+++ b/apps/web/src/components/panels/image-generate-popover.tsx
@@ -0,0 +1,203 @@
+import { useState } from 'react'
+import { Settings, Sparkles, Loader2 } from 'lucide-react'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Button } from '@/components/ui/button'
+import { useAgentSettingsStore } from '@/stores/agent-settings-store'
+
+interface ImageGeneratePopoverProps {
+ initialPrompt: string
+ onGenerated: (url: string) => void
+ children: React.ReactNode
+ /** Node dimensions — passed to the API for aspect-ratio-aware generation */
+ width?: number
+ height?: number
+}
+
+type State = 'idle' | 'loading' | 'preview' | 'error'
+
+export default function ImageGeneratePopover({
+ initialPrompt,
+ onGenerated,
+ children,
+ width,
+ height,
+}: ImageGeneratePopoverProps) {
+ const [open, setOpen] = useState(false)
+ const [prompt, setPrompt] = useState(initialPrompt)
+ const [state, setState] = useState
('idle')
+ const [previewUrl, setPreviewUrl] = useState(null)
+ const [errorMsg, setErrorMsg] = useState('')
+
+ const activeProfile = useAgentSettingsStore((s) => s.getActiveImageGenProfile())
+ const setDialogOpen = useAgentSettingsStore((s) => s.setDialogOpen)
+
+ const isConfigured = !!activeProfile?.apiKey
+
+ const handleOpenChange = (next: boolean) => {
+ setOpen(next)
+ if (next) {
+ // Reset to idle when reopening
+ setPrompt(initialPrompt)
+ setState('idle')
+ setPreviewUrl(null)
+ setErrorMsg('')
+ }
+ }
+
+ const handleGenerate = async () => {
+ if (!activeProfile) return
+ setState('loading')
+ setErrorMsg('')
+ try {
+ const res = await fetch('/api/ai/image-generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ prompt,
+ provider: activeProfile.provider,
+ model: activeProfile.model,
+ apiKey: activeProfile.apiKey,
+ baseUrl: activeProfile.baseUrl,
+ ...(width && height ? { width, height } : {}),
+ }),
+ })
+ if (!res.ok) {
+ let msg = `HTTP ${res.status}`
+ try {
+ const errData = await res.json()
+ const raw = errData.message || errData.statusMessage || errData.error || ''
+ if (raw) msg = String(raw).slice(0, 200)
+ } catch { /* use default msg */ }
+ throw new Error(msg)
+ }
+ const data = (await res.json()) as { url?: string; error?: string }
+ if (data.error) throw new Error(data.error)
+ if (!data.url) throw new Error('No image URL returned')
+ setPreviewUrl(data.url)
+ setState('preview')
+ } catch (err) {
+ setErrorMsg(err instanceof Error ? err.message : String(err))
+ setState('error')
+ }
+ }
+
+ const handleApply = () => {
+ if (previewUrl) {
+ onGenerated(previewUrl)
+ setOpen(false)
+ }
+ }
+
+ return (
+
+ {children}
+
+ {!isConfigured ? (
+ setDialogOpen(true)} />
+ ) : state === 'loading' ? (
+
+ ) : state === 'preview' && previewUrl ? (
+ setState('idle')} />
+ ) : (
+
+ )}
+
+
+ )
+}
+
+function NotConfiguredView({ onOpenSettings }: { onOpenSettings: () => void }) {
+ return (
+
+
+
Image generation not configured
+
+ Open Settings
+
+
+ )
+}
+
+function LoadingView() {
+ return (
+
+
+ Generating...
+
+ )
+}
+
+function PreviewView({
+ url,
+ onApply,
+ onRetry,
+}: {
+ url: string
+ onApply: () => void
+ onRetry: () => void
+}) {
+ return (
+
+
+
+
+ Apply
+
+
+ Retry
+
+
+
+ )
+}
+
+function IdleView({
+ prompt,
+ onPromptChange,
+ onGenerate,
+ provider,
+ model,
+ profileName,
+ error,
+}: {
+ prompt: string
+ onPromptChange: (v: string) => void
+ onGenerate: () => void
+ provider: string
+ model: string
+ profileName: string
+ error?: string
+}) {
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/panels/image-search-popover.tsx b/apps/web/src/components/panels/image-search-popover.tsx
new file mode 100644
index 00000000..f68fc3db
--- /dev/null
+++ b/apps/web/src/components/panels/image-search-popover.tsx
@@ -0,0 +1,172 @@
+import { useState, useCallback, useRef } from 'react'
+import { Search, Loader2, Image as ImageIcon } from 'lucide-react'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { useAgentSettingsStore } from '@/stores/agent-settings-store'
+import type { ImageSearchResult, ImageSearchResponse } from '@/types/image-service'
+
+interface ImageSearchPopoverProps {
+ initialQuery: string
+ onSelect: (url: string) => void
+ children: React.ReactNode
+}
+
+export default function ImageSearchPopover({ initialQuery, onSelect, children }: ImageSearchPopoverProps) {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState(initialQuery)
+ const [loading, setLoading] = useState(false)
+ const [results, setResults] = useState([])
+ const [source, setSource] = useState<'openverse' | 'wikimedia' | null>(null)
+ const [hasSearched, setHasSearched] = useState(false)
+ const inputRef = useRef(null)
+
+ const openverseOAuth = useAgentSettingsStore((s) => s.openverseOAuth)
+
+ const handleSearch = useCallback(async () => {
+ const trimmed = query.trim()
+ if (!trimmed || loading) return
+
+ setLoading(true)
+ setHasSearched(true)
+
+ try {
+ const body: Record = { query: trimmed, count: 5 }
+ if (openverseOAuth) {
+ body.openverseClientId = openverseOAuth.clientId
+ body.openverseClientSecret = openverseOAuth.clientSecret
+ }
+
+ const res = await fetch('/api/ai/image-search', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+
+ if (res.ok) {
+ const data = (await res.json()) as ImageSearchResponse
+ setResults(data.results ?? [])
+ setSource(data.source ?? null)
+ } else {
+ setResults([])
+ setSource(null)
+ }
+ } catch {
+ setResults([])
+ setSource(null)
+ } finally {
+ setLoading(false)
+ }
+ }, [query, loading, openverseOAuth])
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ void handleSearch()
+ }
+ },
+ [handleSearch],
+ )
+
+ const handleSelect = useCallback(
+ (url: string) => {
+ onSelect(url)
+ setOpen(false)
+ },
+ [onSelect],
+ )
+
+ const handleOpenChange = useCallback((next: boolean) => {
+ setOpen(next)
+ if (next) {
+ // Reset search state when re-opening
+ setHasSearched(false)
+ setResults([])
+ setSource(null)
+ }
+ }, [])
+
+ return (
+
+ {children}
+
+
+ {/* Search bar */}
+
+ setQuery(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Search images..."
+ className="flex-1 h-7 px-2 text-xs rounded border border-border bg-background text-foreground placeholder:text-muted-foreground outline-none focus:border-primary transition-colors"
+ />
+ void handleSearch()}
+ disabled={loading || !query.trim()}
+ className="h-7 w-7 flex items-center justify-center rounded border border-border bg-background hover:bg-accent/50 text-foreground disabled:opacity-40 disabled:cursor-not-allowed transition-colors shrink-0"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Results / empty state */}
+ {loading ? (
+
+
+ Searching...
+
+ ) : results.length > 0 ? (
+
+ {results.map((result) => (
+
handleSelect(result.thumbUrl)}
+ className="aspect-square w-full overflow-hidden rounded border border-border hover:border-primary transition-colors cursor-pointer"
+ title={result.attribution ?? result.license}
+ >
+
+
+ ))}
+
+ ) : (
+
+
+
+ {hasSearched ? 'No results found' : 'Search for images'}
+
+
+ )}
+
+ {/* Footer: license + source */}
+ {results.length > 0 && source && (
+
+
+ Images from{' '}
+
+ {source === 'openverse' ? 'Openverse' : 'Wikimedia Commons'}
+
+ . Freely licensed — verify license before use.
+
+
+ )}
+
+
+ )
+}
diff --git a/src/components/panels/image-section.tsx b/apps/web/src/components/panels/image-section.tsx
similarity index 70%
rename from src/components/panels/image-section.tsx
rename to apps/web/src/components/panels/image-section.tsx
index ba16799f..91b02c7d 100644
--- a/src/components/panels/image-section.tsx
+++ b/apps/web/src/components/panels/image-section.tsx
@@ -2,8 +2,11 @@ import { useState, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { ImageNode, ImageFitMode } from '@/types/pen'
import SectionHeader from '@/components/shared/section-header'
-import { Image as ImageIcon } from 'lucide-react'
+import { Image as ImageIcon, Search, Sparkles } from 'lucide-react'
import ImageFillPopover from './image-fill-popover'
+import ImageSearchPopover from './image-search-popover'
+import ImageGeneratePopover from './image-generate-popover'
+import { Button } from '@/components/ui/button'
interface ImageSectionProps {
node: ImageNode
@@ -50,6 +53,30 @@ export default function ImageSection({ node, onUpdate }: ImageSectionProps) {
+
+ onUpdate({ src: url })}
+ >
+
+
+ Search
+
+
+
+ onUpdate({ src: url })}
+ width={typeof node.width === 'number' ? node.width : undefined}
+ height={typeof node.height === 'number' ? node.height : undefined}
+ >
+
+
+ Generate
+
+
+
+
{triggerRect && (
> }
+ { labelKey: string; descriptionKey: string; agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli'; Icon: ComponentType> }
> = {
anthropic: {
labelKey: 'agents.claudeCode',
@@ -44,12 +46,18 @@ const PROVIDER_META: Record<
agent: 'copilot',
Icon: CopilotLogo,
},
+ gemini: {
+ labelKey: 'agents.geminiCli',
+ descriptionKey: 'agents.geminiDesc',
+ agent: 'gemini-cli',
+ Icon: GeminiLogo,
+ },
}
-type SettingsTab = 'agents' | 'mcp' | 'system'
+type SettingsTab = 'agents' | 'mcp' | 'images' | 'system'
async function connectAgent(
- agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot',
+ agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli',
): Promise<{ connected: boolean; models: GroupedModel[]; error?: string; warning?: string; notInstalled?: boolean; connectionInfo?: string; hintPath?: string }> {
try {
const res = await fetch('/api/ai/connect-agent', {
@@ -65,7 +73,7 @@ async function connectAgent(
}
async function installAgent(
- agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot',
+ agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli',
): Promise<{ success: boolean; error?: string; command?: string; docsUrl?: string }> {
try {
const res = await fetch('/api/ai/install-agent', {
@@ -327,6 +335,7 @@ function AgentsPage() {
+
)
@@ -639,6 +648,12 @@ export default function AgentSettingsDialog() {
active={activeTab === 'mcp'}
onClick={() => setActiveTab('mcp')}
/>
+
setActiveTab('images')}
+ />
)}
+ {activeTab === 'images' && }
+
{activeTab === 'system' && (
{t('settings.system')}
diff --git a/apps/web/src/components/shared/agent-settings-images-page.tsx b/apps/web/src/components/shared/agent-settings-images-page.tsx
new file mode 100644
index 00000000..e8a96635
--- /dev/null
+++ b/apps/web/src/components/shared/agent-settings-images-page.tsx
@@ -0,0 +1,454 @@
+import { useState } from 'react'
+import {
+ Check,
+ ChevronDown,
+ ChevronRight,
+ ExternalLink,
+ Loader2,
+ Plus,
+ Trash2,
+} from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { useAgentSettingsStore } from '@/stores/agent-settings-store'
+import type { ImageGenProvider, ImageGenProfile } from '@/types/image-service'
+import { MODEL_PLACEHOLDERS } from '@/types/image-service'
+
+type TestStatus = 'idle' | 'testing' | 'valid' | 'invalid'
+
+/* ---------- Shared UI ---------- */
+
+function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ )
+}
+
+function TextInput({
+ value,
+ onChange,
+ placeholder,
+ type = 'text',
+ className,
+}: {
+ value: string
+ onChange: (v: string) => void
+ placeholder?: string
+ type?: string
+ className?: string
+}) {
+ return (
+
onChange(e.target.value)}
+ placeholder={placeholder}
+ className={cn(
+ 'h-7 w-full rounded border border-input bg-secondary px-2 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-ring transition-colors',
+ className,
+ )}
+ />
+ )
+}
+
+function Collapsible({
+ label,
+ children,
+ defaultOpen = false,
+}: {
+ label: string
+ children: React.ReactNode
+ defaultOpen?: boolean
+}) {
+ const [open, setOpen] = useState(defaultOpen)
+ return (
+
+
setOpen((v) => !v)}
+ className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors mb-2"
+ >
+ {open ? : }
+ {label}
+
+ {open &&
{children}
}
+
+ )
+}
+
+function TestStatusBadge({ status }: { status: TestStatus }) {
+ if (status === 'idle') return null
+ if (status === 'testing') {
+ return
+ }
+ if (status === 'valid') {
+ return
+ }
+ return
Invalid
+}
+
+/* ---------- Image Search section ---------- */
+
+function ImageSearchSection() {
+ const openverseOAuth = useAgentSettingsStore((s) => s.openverseOAuth)
+ const setOpenverseOAuth = useAgentSettingsStore((s) => s.setOpenverseOAuth)
+ const persist = useAgentSettingsStore((s) => s.persist)
+
+ const [clientId, setClientId] = useState(openverseOAuth?.clientId ?? '')
+ const [clientSecret, setClientSecret] = useState(openverseOAuth?.clientSecret ?? '')
+ const [testStatus, setTestStatus] = useState
('idle')
+
+ const handleChange = (field: 'clientId' | 'clientSecret', value: string) => {
+ const updated = {
+ clientId: field === 'clientId' ? value : clientId,
+ clientSecret: field === 'clientSecret' ? value : clientSecret,
+ }
+ if (field === 'clientId') setClientId(value)
+ else setClientSecret(value)
+
+ const hasContent = updated.clientId || updated.clientSecret
+ setOpenverseOAuth(hasContent ? updated : null)
+ persist()
+ }
+
+ const handleTest = async () => {
+ setTestStatus('testing')
+ try {
+ const res = await fetch('/api/ai/image-service-test', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service: 'openverse', clientId, clientSecret }),
+ })
+ setTestStatus(res.ok ? 'valid' : 'invalid')
+ } catch {
+ setTestStatus('invalid')
+ }
+ }
+
+ return (
+
+
+
Image Search
+
+
Ready
+
+
+
+
+ Openverse OAuth (optional, for higher rate limits)
+
+
+
+ handleChange('clientId', v)}
+ placeholder="your-client-id"
+ />
+
+
+
+ handleChange('clientSecret', v)}
+ placeholder="your-client-secret"
+ type="password"
+ />
+
+
+
+
+
+ )
+}
+
+/* ---------- Provider labels ---------- */
+
+const PROVIDER_LABELS: Record = {
+ openai: 'OpenAI',
+ gemini: 'Google Gemini',
+ replicate: 'Replicate',
+ custom: 'Custom',
+}
+
+/* ---------- Single profile editor ---------- */
+
+function ProfileEditor({
+ profile,
+ onUpdate,
+}: {
+ profile: ImageGenProfile
+ onUpdate: (updates: Partial>) => void
+}) {
+ const [testStatus, setTestStatus] = useState('idle')
+
+ const handleTest = async () => {
+ setTestStatus('testing')
+ try {
+ const res = await fetch('/api/ai/image-service-test', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ service: profile.provider,
+ apiKey: profile.apiKey,
+ model: profile.model,
+ baseUrl: profile.baseUrl,
+ }),
+ })
+ setTestStatus(res.ok ? 'valid' : 'invalid')
+ } catch {
+ setTestStatus('invalid')
+ }
+ }
+
+ return (
+
+
+ onUpdate({ name: v })}
+ placeholder="My Config"
+ />
+
+
+
+ onUpdate({ provider: v as ImageGenProvider, model: '' })}
+ >
+
+
+
+
+ {(Object.keys(PROVIDER_LABELS) as ImageGenProvider[]).map((p) => (
+
+ {PROVIDER_LABELS[p]}
+
+ ))}
+
+
+
+
+
+
+
onUpdate({ apiKey: v })}
+ placeholder="sk-..."
+ type="password"
+ className="flex-1"
+ />
+
+
+
+ Test
+
+
+
+
+
+
+ onUpdate({ model: v })}
+ placeholder={MODEL_PLACEHOLDERS[profile.provider]}
+ />
+
+
+
+
+ onUpdate({ baseUrl: v || undefined })}
+ placeholder="https://api.example.com/v1"
+ />
+
+
+
+ )
+}
+
+/* ---------- Image Generation section ---------- */
+
+function ImageGenerationSection() {
+ const profiles = useAgentSettingsStore((s) => s.imageGenProfiles)
+ const activeId = useAgentSettingsStore((s) => s.activeImageGenProfileId)
+ const addProfile = useAgentSettingsStore((s) => s.addImageGenProfile)
+ const updateProfile = useAgentSettingsStore((s) => s.updateImageGenProfile)
+ const removeProfile = useAgentSettingsStore((s) => s.removeImageGenProfile)
+ const setActive = useAgentSettingsStore((s) => s.setActiveImageGenProfile)
+ const persist = useAgentSettingsStore((s) => s.persist)
+
+ const [editingId, setEditingId] = useState(null)
+
+ const handleAdd = () => {
+ const id = addProfile({
+ name: `Config ${profiles.length + 1}`,
+ provider: 'openai',
+ apiKey: '',
+ model: '',
+ })
+ setEditingId(id)
+ persist()
+ }
+
+ const handleUpdate = (id: string, updates: Partial>) => {
+ updateProfile(id, updates)
+ persist()
+ }
+
+ const handleRemove = (id: string) => {
+ removeProfile(id)
+ if (editingId === id) setEditingId(null)
+ persist()
+ }
+
+ const handleActivate = (id: string) => {
+ setActive(id)
+ persist()
+ }
+
+ const effectiveActiveId = activeId ?? profiles[0]?.id
+
+ return (
+
+
+
Image Generation
+
+
+ Add
+
+
+
+ {profiles.length === 0 ? (
+
+ No configurations yet. Click "Add" to create one.
+
+ ) : (
+
+ {profiles.map((profile) => {
+ const isActive = profile.id === effectiveActiveId
+ const isEditing = profile.id === editingId
+
+ return (
+
+ {/* Profile row */}
+
setEditingId(isEditing ? null : profile.id)}
+ >
+ {/* Active indicator */}
+ {
+ e.stopPropagation()
+ handleActivate(profile.id)
+ }}
+ className={cn(
+ 'w-3.5 h-3.5 rounded-full border-2 shrink-0 transition-colors',
+ isActive
+ ? 'border-primary bg-primary'
+ : 'border-muted-foreground/40 hover:border-primary/60',
+ )}
+ title={isActive ? 'Active' : 'Set as active'}
+ >
+ {isActive && }
+
+
+ {/* Name + provider */}
+
+ {profile.name || PROVIDER_LABELS[profile.provider]}
+
+
+ {PROVIDER_LABELS[profile.provider]}
+
+
+ {/* Expand/Collapse */}
+ {isEditing ? : }
+
+ {/* Delete */}
+ {
+ e.stopPropagation()
+ handleRemove(profile.id)
+ }}
+ className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
+ title="Remove"
+ >
+
+
+
+
+ {/* Expanded editor */}
+ {isEditing && (
+
+
handleUpdate(profile.id, updates)}
+ />
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+/* ---------- Main export ---------- */
+
+export function ImagesPage() {
+ return (
+
+
+
+
+ )
+}
diff --git a/src/components/shared/color-picker.tsx b/apps/web/src/components/shared/color-picker.tsx
similarity index 100%
rename from src/components/shared/color-picker.tsx
rename to apps/web/src/components/shared/color-picker.tsx
diff --git a/src/components/shared/export-dialog.tsx b/apps/web/src/components/shared/export-dialog.tsx
similarity index 100%
rename from src/components/shared/export-dialog.tsx
rename to apps/web/src/components/shared/export-dialog.tsx
diff --git a/src/components/shared/figma-import-dialog.tsx b/apps/web/src/components/shared/figma-import-dialog.tsx
similarity index 100%
rename from src/components/shared/figma-import-dialog.tsx
rename to apps/web/src/components/shared/figma-import-dialog.tsx
diff --git a/src/components/shared/font-picker.tsx b/apps/web/src/components/shared/font-picker.tsx
similarity index 100%
rename from src/components/shared/font-picker.tsx
rename to apps/web/src/components/shared/font-picker.tsx
diff --git a/src/components/shared/icon-picker-dialog.tsx b/apps/web/src/components/shared/icon-picker-dialog.tsx
similarity index 100%
rename from src/components/shared/icon-picker-dialog.tsx
rename to apps/web/src/components/shared/icon-picker-dialog.tsx
diff --git a/src/components/shared/language-selector.tsx b/apps/web/src/components/shared/language-selector.tsx
similarity index 100%
rename from src/components/shared/language-selector.tsx
rename to apps/web/src/components/shared/language-selector.tsx
diff --git a/src/components/shared/number-input.tsx b/apps/web/src/components/shared/number-input.tsx
similarity index 100%
rename from src/components/shared/number-input.tsx
rename to apps/web/src/components/shared/number-input.tsx
diff --git a/src/components/shared/save-dialog.tsx b/apps/web/src/components/shared/save-dialog.tsx
similarity index 100%
rename from src/components/shared/save-dialog.tsx
rename to apps/web/src/components/shared/save-dialog.tsx
diff --git a/src/components/shared/section-header.tsx b/apps/web/src/components/shared/section-header.tsx
similarity index 100%
rename from src/components/shared/section-header.tsx
rename to apps/web/src/components/shared/section-header.tsx
diff --git a/src/components/shared/variable-picker.tsx b/apps/web/src/components/shared/variable-picker.tsx
similarity index 100%
rename from src/components/shared/variable-picker.tsx
rename to apps/web/src/components/shared/variable-picker.tsx
diff --git a/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
similarity index 100%
rename from src/components/ui/button.tsx
rename to apps/web/src/components/ui/button.tsx
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
new file mode 100644
index 00000000..98693cb6
--- /dev/null
+++ b/apps/web/src/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react'
+import * as PopoverPrimitive from '@radix-ui/react-popover'
+
+import { cn } from '@/lib/utils'
+
+const Popover = PopoverPrimitive.Root
+
+const PopoverTrigger = PopoverPrimitive.Trigger
+
+const PopoverAnchor = PopoverPrimitive.Anchor
+
+const PopoverContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
diff --git a/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
similarity index 100%
rename from src/components/ui/select.tsx
rename to apps/web/src/components/ui/select.tsx
diff --git a/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx
similarity index 100%
rename from src/components/ui/separator.tsx
rename to apps/web/src/components/ui/separator.tsx
diff --git a/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx
similarity index 100%
rename from src/components/ui/slider.tsx
rename to apps/web/src/components/ui/slider.tsx
diff --git a/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx
similarity index 100%
rename from src/components/ui/switch.tsx
rename to apps/web/src/components/ui/switch.tsx
diff --git a/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx
similarity index 100%
rename from src/components/ui/toggle.tsx
rename to apps/web/src/components/ui/toggle.tsx
diff --git a/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
similarity index 100%
rename from src/components/ui/tooltip.tsx
rename to apps/web/src/components/ui/tooltip.tsx
diff --git a/src/constants/app.ts b/apps/web/src/constants/app.ts
similarity index 100%
rename from src/constants/app.ts
rename to apps/web/src/constants/app.ts
diff --git a/apps/web/src/hooks/use-before-unload.ts b/apps/web/src/hooks/use-before-unload.ts
new file mode 100644
index 00000000..b4616618
--- /dev/null
+++ b/apps/web/src/hooks/use-before-unload.ts
@@ -0,0 +1,25 @@
+import { useEffect } from 'react'
+import { useDocumentStore } from '@/stores/document-store'
+
+/**
+ * Prevents accidental data loss by warning the user before closing the tab/window
+ * when there are unsaved changes. In Electron, close confirmation is handled by
+ * the main process via a native dialog, so this hook is skipped.
+ */
+export function useBeforeUnload() {
+ const isDirty = useDocumentStore((s) => s.isDirty)
+
+ useEffect(() => {
+ // Electron handles close confirmation in the main process
+ if (window.electronAPI) return
+ if (!isDirty) return
+
+ const handler = (e: BeforeUnloadEvent) => {
+ e.preventDefault()
+ e.returnValue = ''
+ }
+
+ window.addEventListener('beforeunload', handler)
+ return () => window.removeEventListener('beforeunload', handler)
+ }, [isDirty])
+}
diff --git a/src/hooks/use-electron-menu.ts b/apps/web/src/hooks/use-electron-menu.ts
similarity index 90%
rename from src/hooks/use-electron-menu.ts
rename to apps/web/src/hooks/use-electron-menu.ts
index d273e8ba..0274ed18 100644
--- a/src/hooks/use-electron-menu.ts
+++ b/apps/web/src/hooks/use-electron-menu.ts
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
+import i18n from '@/i18n'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
@@ -47,11 +48,17 @@ export function useElectronMenu() {
const cleanup = api.onMenuAction((action: string) => {
switch (action) {
case 'new':
+ if (useDocumentStore.getState().isDirty) {
+ if (!window.confirm(i18n.t('topbar.closeConfirmMessage'))) break
+ }
useDocumentStore.getState().newDocument()
requestAnimationFrame(() => zoomToFitContent())
break
case 'open':
+ if (useDocumentStore.getState().isDirty) {
+ if (!window.confirm(i18n.t('topbar.closeConfirmMessage'))) break
+ }
if (api) {
// Electron: use native IPC to get full file path for save-in-place
api.openFile().then((result) => {
@@ -90,7 +97,9 @@ export function useElectronMenu() {
}
break
- case 'save': {
+ case 'save':
+ case 'save-and-close': {
+ const closeAfterSave = action === 'save-and-close'
try { syncCanvasPositionsToStore() } catch { /* continue */ }
const store = useDocumentStore.getState()
const { document: doc, fileName, filePath } = store
@@ -104,6 +113,7 @@ export function useElectronMenu() {
if (filePath && isOpFile) {
await writeToFilePath(filePath, doc)
store.markClean()
+ if (closeAfterSave) api.confirmClose()
return
}
// No in-place target → save as .op via native dialog
@@ -117,7 +127,9 @@ export function useElectronMenu() {
fileHandle: null,
isDirty: false,
})
+ if (closeAfterSave) api.confirmClose()
}
+ // If user cancelled the save dialog, don't close
}
doSave().catch((err) => console.error('[Save] Failed:', err))
break
diff --git a/src/hooks/use-figma-paste.ts b/apps/web/src/hooks/use-figma-paste.ts
similarity index 100%
rename from src/hooks/use-figma-paste.ts
rename to apps/web/src/hooks/use-figma-paste.ts
diff --git a/src/hooks/use-file-drop.ts b/apps/web/src/hooks/use-file-drop.ts
similarity index 100%
rename from src/hooks/use-file-drop.ts
rename to apps/web/src/hooks/use-file-drop.ts
diff --git a/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts
similarity index 98%
rename from src/hooks/use-keyboard-shortcuts.ts
rename to apps/web/src/hooks/use-keyboard-shortcuts.ts
index 68bacd6b..7005f0a9 100644
--- a/src/hooks/use-keyboard-shortcuts.ts
+++ b/apps/web/src/hooks/use-keyboard-shortcuts.ts
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
+import i18n from '@/i18n'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
@@ -118,7 +119,7 @@ export function useKeyboardShortcuts() {
}
}
// Regular paste for non-reusable nodes
- const [cloned] = cloneNodesWithNewIds([original], 10)
+ const [cloned] = cloneNodesWithNewIds([original], { offset: 10 })
useDocumentStore.getState().addNode(null, cloned)
newIds.push(cloned.id)
}
@@ -210,6 +211,9 @@ export function useKeyboardShortcuts() {
// Open: Cmd/Ctrl+O
if (isMod && e.key === 'o' && !e.shiftKey) {
e.preventDefault()
+ if (useDocumentStore.getState().isDirty) {
+ if (!window.confirm(i18n.t('topbar.closeConfirmMessage'))) return
+ }
if (supportsFileSystemAccess()) {
openDocumentFS().then((result) => {
if (result) {
diff --git a/src/hooks/use-mcp-sync.ts b/apps/web/src/hooks/use-mcp-sync.ts
similarity index 100%
rename from src/hooks/use-mcp-sync.ts
rename to apps/web/src/hooks/use-mcp-sync.ts
diff --git a/src/hooks/use-system-fonts.ts b/apps/web/src/hooks/use-system-fonts.ts
similarity index 100%
rename from src/hooks/use-system-fonts.ts
rename to apps/web/src/hooks/use-system-fonts.ts
diff --git a/src/i18n/index.ts b/apps/web/src/i18n/index.ts
similarity index 88%
rename from src/i18n/index.ts
rename to apps/web/src/i18n/index.ts
index e63ba141..4ffce718 100644
--- a/src/i18n/index.ts
+++ b/apps/web/src/i18n/index.ts
@@ -75,4 +75,11 @@ export function detectLanguagePostHydration() {
}
}
+// Expose i18n.t on window so Electron main process can query translated
+// strings via webContents.executeJavaScript (e.g. for close-confirm dialog).
+if (typeof window !== 'undefined') {
+ ;(window as unknown as Record).__i18nT = (key: string) =>
+ i18n.t(key)
+}
+
export default i18n
diff --git a/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts
similarity index 92%
rename from src/i18n/locales/de.ts
rename to apps/web/src/i18n/locales/de.ts
index c55cb58b..c8654264 100644
--- a/src/i18n/locales/de.ts
+++ b/apps/web/src/i18n/locales/de.ts
@@ -51,6 +51,9 @@ const de: TranslationKeys = {
'topbar.fullscreen': 'Vollbild',
'topbar.exitFullscreen': 'Vollbild beenden',
'topbar.edited': '— Bearbeitet',
+ 'topbar.closeConfirmMessage': 'Möchten Sie die Änderungen vor dem Schließen speichern?',
+ 'topbar.closeConfirmDetail': 'Ihre Änderungen gehen verloren, wenn Sie sie nicht speichern.',
+ 'topbar.dontSave': 'Nicht speichern',
'topbar.agentsAndMcp': 'Agenten & MCP',
'topbar.setupAgentsMcp': 'Agenten & MCP einrichten',
'topbar.connected': 'verbunden',
@@ -278,6 +281,29 @@ const de: TranslationKeys = {
'variables.presetName': 'Vorlagenname',
'variables.noPresets': 'Keine gespeicherten Vorlagen',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'Designsystem',
+ 'designMd.import': 'design.md importieren',
+ 'designMd.export': 'design.md exportieren',
+ 'designMd.autoGenerate': 'Automatisch aus Design generieren',
+ 'designMd.empty': 'Kein Designsystem geladen',
+ 'designMd.importCta': 'design.md importieren',
+ 'designMd.autoGenerateCta': 'Automatisch generieren',
+ 'designMd.visualTheme': 'Visuelles Thema',
+ 'designMd.colors': 'Farben',
+ 'designMd.typography': 'Typografie',
+ 'designMd.font': 'Schriftart',
+ 'designMd.headings': 'Überschriften',
+ 'designMd.body': 'Fließtext',
+ 'designMd.componentStyles': 'Komponentenstile',
+ 'designMd.layoutPrinciples': 'Layout-Prinzipien',
+ 'designMd.generationNotes': 'Generierungshinweise',
+ 'designMd.syncAllToVariables': 'Alle mit Variablen synchronisieren',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'Hex-Wert kopieren',
+ 'designMd.remove': 'Designsystem entfernen',
+ 'toolbar.designSystem': 'Designsystem',
+
// ── AI Chat ──
'ai.newChat': 'Neuer Chat',
'ai.collapse': 'Einklappen',
@@ -349,6 +375,8 @@ const de: TranslationKeys = {
'agents.opencodeDesc': '75+ LLM-Anbieter',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'GitHub Copilot-Modelle',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Google Gemini-Modelle',
'agents.mcpServer': 'MCP-Server',
'agents.mcpServerStart': 'Starten',
'agents.mcpServerStop': 'Stoppen',
@@ -368,6 +396,7 @@ const de: TranslationKeys = {
'settings.title': 'Einstellungen',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'System',
'settings.autoUpdateDesc': 'Beim Start automatisch nach neuen Versionen suchen',
'settings.systemDesktopOnly': 'Systemeinstellungen sind in der Desktop-App verfügbar.',
diff --git a/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts
similarity index 92%
rename from src/i18n/locales/en.ts
rename to apps/web/src/i18n/locales/en.ts
index cfc3bf1f..6feb979a 100644
--- a/src/i18n/locales/en.ts
+++ b/apps/web/src/i18n/locales/en.ts
@@ -49,6 +49,9 @@ const en = {
'topbar.fullscreen': 'Fullscreen',
'topbar.exitFullscreen': 'Exit fullscreen',
'topbar.edited': '— Edited',
+ 'topbar.closeConfirmMessage': 'Do you want to save changes before closing?',
+ 'topbar.closeConfirmDetail': 'Your changes will be lost if you don\'t save them.',
+ 'topbar.dontSave': 'Don\'t Save',
'topbar.agentsAndMcp': 'Agents & MCP',
'topbar.setupAgentsMcp': 'Setup Agents & MCP',
'topbar.connected': 'connected',
@@ -274,6 +277,29 @@ const en = {
'variables.presetName': 'Preset name',
'variables.noPresets': 'No saved presets',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'Design System',
+ 'designMd.import': 'Import design.md',
+ 'designMd.export': 'Export design.md',
+ 'designMd.autoGenerate': 'Auto-generate from design',
+ 'designMd.empty': 'No design system loaded',
+ 'designMd.importCta': 'Import design.md',
+ 'designMd.autoGenerateCta': 'Auto-generate',
+ 'designMd.visualTheme': 'Visual Theme',
+ 'designMd.colors': 'Colors',
+ 'designMd.typography': 'Typography',
+ 'designMd.font': 'Font',
+ 'designMd.headings': 'Headings',
+ 'designMd.body': 'Body',
+ 'designMd.componentStyles': 'Component Styles',
+ 'designMd.layoutPrinciples': 'Layout Principles',
+ 'designMd.generationNotes': 'Generation Notes',
+ 'designMd.syncAllToVariables': 'Sync all to variables',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'Copy hex',
+ 'designMd.remove': 'Remove design system',
+ 'toolbar.designSystem': 'Design System',
+
// ── AI Chat ──
'ai.newChat': 'New chat',
'ai.collapse': 'Collapse',
@@ -345,6 +371,8 @@ const en = {
'agents.opencodeDesc': '75+ LLM providers',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'GitHub Copilot models',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Google Gemini models',
'agents.mcpServer': 'MCP Server',
'agents.mcpServerStart': 'Start',
'agents.mcpServerStop': 'Stop',
@@ -364,6 +392,7 @@ const en = {
'settings.title': 'Settings',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'System',
'settings.autoUpdateDesc': 'Automatically check for new versions on startup',
'settings.systemDesktopOnly': 'System settings are available in the desktop app.',
diff --git a/src/i18n/locales/es.ts b/apps/web/src/i18n/locales/es.ts
similarity index 92%
rename from src/i18n/locales/es.ts
rename to apps/web/src/i18n/locales/es.ts
index 419fd428..a1c91cda 100644
--- a/src/i18n/locales/es.ts
+++ b/apps/web/src/i18n/locales/es.ts
@@ -51,6 +51,9 @@ const es: TranslationKeys = {
'topbar.fullscreen': 'Pantalla completa',
'topbar.exitFullscreen': 'Salir de pantalla completa',
'topbar.edited': '— Editado',
+ 'topbar.closeConfirmMessage': '¿Desea guardar los cambios antes de cerrar?',
+ 'topbar.closeConfirmDetail': 'Sus cambios se perderán si no los guarda.',
+ 'topbar.dontSave': 'No guardar',
'topbar.agentsAndMcp': 'Agentes y MCP',
'topbar.setupAgentsMcp': 'Configurar Agentes y MCP',
'topbar.connected': 'conectado',
@@ -279,6 +282,29 @@ const es: TranslationKeys = {
'variables.presetName': 'Nombre del preajuste',
'variables.noPresets': 'No hay preajustes guardados',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'Sistema de diseño',
+ 'designMd.import': 'Importar design.md',
+ 'designMd.export': 'Exportar design.md',
+ 'designMd.autoGenerate': 'Generar automáticamente desde el diseño',
+ 'designMd.empty': 'No hay sistema de diseño cargado',
+ 'designMd.importCta': 'Importar design.md',
+ 'designMd.autoGenerateCta': 'Generar automáticamente',
+ 'designMd.visualTheme': 'Tema visual',
+ 'designMd.colors': 'Colores',
+ 'designMd.typography': 'Tipografía',
+ 'designMd.font': 'Fuente',
+ 'designMd.headings': 'Encabezados',
+ 'designMd.body': 'Cuerpo',
+ 'designMd.componentStyles': 'Estilos de componentes',
+ 'designMd.layoutPrinciples': 'Principios de maquetación',
+ 'designMd.generationNotes': 'Notas de generación',
+ 'designMd.syncAllToVariables': 'Sincronizar todo con variables',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'Copiar hex',
+ 'designMd.remove': 'Eliminar sistema de diseño',
+ 'toolbar.designSystem': 'Sistema de diseño',
+
// ── AI Chat ──
'ai.newChat': 'Nueva conversación',
'ai.collapse': 'Contraer',
@@ -354,6 +380,8 @@ const es: TranslationKeys = {
'agents.opencodeDesc': '75+ proveedores LLM',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'Modelos GitHub Copilot',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Modelos Google Gemini',
'agents.mcpServer': 'Servidor MCP',
'agents.mcpServerStart': 'Iniciar',
'agents.mcpServerStop': 'Detener',
@@ -373,6 +401,7 @@ const es: TranslationKeys = {
'settings.title': 'Configuración',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'Sistema',
'settings.autoUpdateDesc': 'Buscar automáticamente nuevas versiones al iniciar',
'settings.systemDesktopOnly': 'La configuración del sistema está disponible en la aplicación de escritorio.',
diff --git a/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts
similarity index 92%
rename from src/i18n/locales/fr.ts
rename to apps/web/src/i18n/locales/fr.ts
index af8a1583..cbdc538e 100644
--- a/src/i18n/locales/fr.ts
+++ b/apps/web/src/i18n/locales/fr.ts
@@ -51,6 +51,9 @@ const fr: TranslationKeys = {
'topbar.fullscreen': 'Plein écran',
'topbar.exitFullscreen': 'Quitter le plein écran',
'topbar.edited': '— Modifié',
+ 'topbar.closeConfirmMessage': 'Voulez-vous enregistrer les modifications avant de fermer ?',
+ 'topbar.closeConfirmDetail': 'Vos modifications seront perdues si vous ne les enregistrez pas.',
+ 'topbar.dontSave': 'Ne pas enregistrer',
'topbar.agentsAndMcp': 'Agents & MCP',
'topbar.setupAgentsMcp': 'Configurer Agents & MCP',
'topbar.connected': 'connecté',
@@ -279,6 +282,29 @@ const fr: TranslationKeys = {
'variables.presetName': 'Nom du préréglage',
'variables.noPresets': 'Aucun préréglage enregistré',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'Système de design',
+ 'designMd.import': 'Importer design.md',
+ 'designMd.export': 'Exporter design.md',
+ 'designMd.autoGenerate': 'Générer automatiquement depuis le design',
+ 'designMd.empty': 'Aucun système de design chargé',
+ 'designMd.importCta': 'Importer design.md',
+ 'designMd.autoGenerateCta': 'Générer automatiquement',
+ 'designMd.visualTheme': 'Thème visuel',
+ 'designMd.colors': 'Couleurs',
+ 'designMd.typography': 'Typographie',
+ 'designMd.font': 'Police',
+ 'designMd.headings': 'Titres',
+ 'designMd.body': 'Corps',
+ 'designMd.componentStyles': 'Styles des composants',
+ 'designMd.layoutPrinciples': 'Principes de mise en page',
+ 'designMd.generationNotes': 'Notes de génération',
+ 'designMd.syncAllToVariables': 'Tout synchroniser avec les variables',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'Copier le code hex',
+ 'designMd.remove': 'Supprimer le système de design',
+ 'toolbar.designSystem': 'Système de design',
+
// ── AI Chat ──
'ai.newChat': 'Nouvelle conversation',
'ai.collapse': 'Réduire',
@@ -352,6 +378,8 @@ const fr: TranslationKeys = {
'agents.opencodeDesc': '75+ fournisseurs LLM',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'Modèles GitHub Copilot',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Modèles Google Gemini',
'agents.mcpServer': 'Serveur MCP',
'agents.mcpServerStart': 'Démarrer',
'agents.mcpServerStop': 'Arrêter',
@@ -371,6 +399,7 @@ const fr: TranslationKeys = {
'settings.title': 'Paramètres',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'Système',
'settings.autoUpdateDesc': 'Vérifier automatiquement les nouvelles versions au démarrage',
'settings.systemDesktopOnly': 'Les paramètres système sont disponibles dans l\'application de bureau.',
diff --git a/src/i18n/locales/hi.ts b/apps/web/src/i18n/locales/hi.ts
similarity index 92%
rename from src/i18n/locales/hi.ts
rename to apps/web/src/i18n/locales/hi.ts
index e059612d..7bf51052 100644
--- a/src/i18n/locales/hi.ts
+++ b/apps/web/src/i18n/locales/hi.ts
@@ -51,6 +51,9 @@ const hi: TranslationKeys = {
'topbar.fullscreen': 'पूर्ण स्क्रीन',
'topbar.exitFullscreen': 'पूर्ण स्क्रीन से बाहर निकलें',
'topbar.edited': '— संपादित',
+ 'topbar.closeConfirmMessage': 'बंद करने से पहले परिवर्तन सहेजना चाहते हैं?',
+ 'topbar.closeConfirmDetail': 'यदि आप सहेजते नहीं हैं तो आपके परिवर्तन खो जाएंगे।',
+ 'topbar.dontSave': 'सहेजें नहीं',
'topbar.agentsAndMcp': 'एजेंट और MCP',
'topbar.setupAgentsMcp': 'एजेंट और MCP सेटअप करें',
'topbar.connected': 'कनेक्टेड',
@@ -276,6 +279,29 @@ const hi: TranslationKeys = {
'variables.presetName': 'प्रीसेट का नाम',
'variables.noPresets': 'कोई सहेजा गया प्रीसेट नहीं',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'डिज़ाइन सिस्टम',
+ 'designMd.import': 'design.md आयात करें',
+ 'designMd.export': 'design.md निर्यात करें',
+ 'designMd.autoGenerate': 'डिज़ाइन से स्वतः उत्पन्न करें',
+ 'designMd.empty': 'कोई डिज़ाइन सिस्टम लोड नहीं है',
+ 'designMd.importCta': 'design.md आयात करें',
+ 'designMd.autoGenerateCta': 'स्वतः उत्पन्न करें',
+ 'designMd.visualTheme': 'विज़ुअल थीम',
+ 'designMd.colors': 'रंग',
+ 'designMd.typography': 'टाइपोग्राफ़ी',
+ 'designMd.font': 'फ़ॉन्ट',
+ 'designMd.headings': 'शीर्षक',
+ 'designMd.body': 'मुख्य भाग',
+ 'designMd.componentStyles': 'कॉम्पोनेन्ट शैलियाँ',
+ 'designMd.layoutPrinciples': 'लेआउट सिद्धांत',
+ 'designMd.generationNotes': 'उत्पन्न करने संबंधी नोट्स',
+ 'designMd.syncAllToVariables': 'सभी को वेरिएबल्स में सिंक करें',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'hex कॉपी करें',
+ 'designMd.remove': 'डिज़ाइन सिस्टम हटाएँ',
+ 'toolbar.designSystem': 'डिज़ाइन सिस्टम',
+
// ── AI Chat ──
'ai.newChat': 'नई चैट',
'ai.collapse': 'संक्षिप्त करें',
@@ -347,6 +373,8 @@ const hi: TranslationKeys = {
'agents.opencodeDesc': '75+ LLM प्रदाता',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'GitHub Copilot मॉडल',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Google Gemini मॉडल',
'agents.mcpServer': 'MCP सर्वर',
'agents.mcpServerStart': 'शुरू करें',
'agents.mcpServerStop': 'रोकें',
@@ -366,6 +394,7 @@ const hi: TranslationKeys = {
'settings.title': 'सेटिंग्स',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'सिस्टम',
'settings.autoUpdateDesc': 'स्टार्टअप पर स्वचालित रूप से नए संस्करणों की जाँच करें',
'settings.systemDesktopOnly': 'सिस्टम सेटिंग्स डेस्कटॉप ऐप में उपलब्ध हैं।',
diff --git a/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts
similarity index 92%
rename from src/i18n/locales/id.ts
rename to apps/web/src/i18n/locales/id.ts
index 67920f59..6c119b35 100644
--- a/src/i18n/locales/id.ts
+++ b/apps/web/src/i18n/locales/id.ts
@@ -51,6 +51,9 @@ const id: TranslationKeys = {
'topbar.fullscreen': 'Layar penuh',
'topbar.exitFullscreen': 'Keluar layar penuh',
'topbar.edited': '— Diedit',
+ 'topbar.closeConfirmMessage': 'Apakah Anda ingin menyimpan perubahan sebelum menutup?',
+ 'topbar.closeConfirmDetail': 'Perubahan Anda akan hilang jika tidak disimpan.',
+ 'topbar.dontSave': 'Jangan simpan',
'topbar.agentsAndMcp': 'Agent & MCP',
'topbar.setupAgentsMcp': 'Pengaturan Agent & MCP',
'topbar.connected': 'terhubung',
@@ -276,6 +279,29 @@ const id: TranslationKeys = {
'variables.presetName': 'Nama preset',
'variables.noPresets': 'Tidak ada preset tersimpan',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'Sistem Desain',
+ 'designMd.import': 'Impor design.md',
+ 'designMd.export': 'Ekspor design.md',
+ 'designMd.autoGenerate': 'Buat otomatis dari desain',
+ 'designMd.empty': 'Tidak ada sistem desain yang dimuat',
+ 'designMd.importCta': 'Impor design.md',
+ 'designMd.autoGenerateCta': 'Buat otomatis',
+ 'designMd.visualTheme': 'Tema Visual',
+ 'designMd.colors': 'Warna',
+ 'designMd.typography': 'Tipografi',
+ 'designMd.font': 'Font',
+ 'designMd.headings': 'Judul',
+ 'designMd.body': 'Isi',
+ 'designMd.componentStyles': 'Gaya Komponen',
+ 'designMd.layoutPrinciples': 'Prinsip Tata Letak',
+ 'designMd.generationNotes': 'Catatan Pembuatan',
+ 'designMd.syncAllToVariables': 'Sinkronkan semua ke variabel',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'Salin hex',
+ 'designMd.remove': 'Hapus sistem desain',
+ 'toolbar.designSystem': 'Sistem Desain',
+
// ── AI Chat ──
'ai.newChat': 'Chat baru',
'ai.collapse': 'Ciutkan',
@@ -347,6 +373,8 @@ const id: TranslationKeys = {
'agents.opencodeDesc': '75+ penyedia LLM',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'Model GitHub Copilot',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Model Google Gemini',
'agents.mcpServer': 'Server MCP',
'agents.mcpServerStart': 'Mulai',
'agents.mcpServerStop': 'Hentikan',
@@ -366,6 +394,7 @@ const id: TranslationKeys = {
'settings.title': 'Pengaturan',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'Sistem',
'settings.autoUpdateDesc': 'Periksa versi baru secara otomatis saat memulai',
'settings.systemDesktopOnly': 'Pengaturan sistem tersedia di aplikasi desktop.',
diff --git a/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts
similarity index 93%
rename from src/i18n/locales/ja.ts
rename to apps/web/src/i18n/locales/ja.ts
index b6845bdd..858c9419 100644
--- a/src/i18n/locales/ja.ts
+++ b/apps/web/src/i18n/locales/ja.ts
@@ -51,6 +51,9 @@ const ja: TranslationKeys = {
'topbar.fullscreen': 'フルスクリーン',
'topbar.exitFullscreen': 'フルスクリーンを終了',
'topbar.edited': '— 編集済み',
+ 'topbar.closeConfirmMessage': '閉じる前に変更を保存しますか?',
+ 'topbar.closeConfirmDetail': '保存しないと変更内容が失われます。',
+ 'topbar.dontSave': '保存しない',
'topbar.agentsAndMcp': 'Agents & MCP',
'topbar.setupAgentsMcp': 'Agents & MCP を設定',
'topbar.connected': '接続済み',
@@ -280,6 +283,29 @@ const ja: TranslationKeys = {
'variables.presetName': 'プリセット名',
'variables.noPresets': '保存されたプリセットはありません',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'デザインシステム',
+ 'designMd.import': 'design.md をインポート',
+ 'designMd.export': 'design.md をエクスポート',
+ 'designMd.autoGenerate': 'デザインから自動生成',
+ 'designMd.empty': 'デザインシステムが読み込まれていません',
+ 'designMd.importCta': 'design.md をインポート',
+ 'designMd.autoGenerateCta': '自動生成',
+ 'designMd.visualTheme': 'ビジュアルテーマ',
+ 'designMd.colors': 'カラー',
+ 'designMd.typography': 'タイポグラフィ',
+ 'designMd.font': 'フォント',
+ 'designMd.headings': '見出し',
+ 'designMd.body': '本文',
+ 'designMd.componentStyles': 'コンポーネントスタイル',
+ 'designMd.layoutPrinciples': 'レイアウト原則',
+ 'designMd.generationNotes': '生成メモ',
+ 'designMd.syncAllToVariables': 'すべてを変数に同期',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': '16進数をコピー',
+ 'designMd.remove': 'デザインシステムを削除',
+ 'toolbar.designSystem': 'デザインシステム',
+
// ── AI Chat ──
'ai.newChat': '新しいチャット',
'ai.collapse': '折りたたむ',
@@ -350,6 +376,8 @@ const ja: TranslationKeys = {
'agents.opencodeDesc': '75 以上の LLM プロバイダー',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'GitHub Copilot モデル',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Google Gemini モデル',
'agents.mcpServer': 'MCP サーバー',
'agents.mcpServerStart': '開始',
'agents.mcpServerStop': '停止',
@@ -369,6 +397,7 @@ const ja: TranslationKeys = {
'settings.title': '設定',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'システム',
'settings.autoUpdateDesc': '起動時に新しいバージョンを自動的に確認する',
'settings.systemDesktopOnly': 'システム設定はデスクトップアプリで利用できます。',
diff --git a/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts
similarity index 92%
rename from src/i18n/locales/ko.ts
rename to apps/web/src/i18n/locales/ko.ts
index 93e8b1c5..a1cfe92d 100644
--- a/src/i18n/locales/ko.ts
+++ b/apps/web/src/i18n/locales/ko.ts
@@ -51,6 +51,9 @@ const ko: TranslationKeys = {
'topbar.fullscreen': '전체 화면',
'topbar.exitFullscreen': '전체 화면 종료',
'topbar.edited': '— 수정됨',
+ 'topbar.closeConfirmMessage': '닫기 전에 변경 사항을 저장하시겠습니까?',
+ 'topbar.closeConfirmDetail': '저장하지 않으면 변경 사항이 손실됩니다.',
+ 'topbar.dontSave': '저장 안 함',
'topbar.agentsAndMcp': '에이전트 & MCP',
'topbar.setupAgentsMcp': '에이전트 & MCP 설정',
'topbar.connected': '연결됨',
@@ -276,6 +279,29 @@ const ko: TranslationKeys = {
'variables.presetName': '프리셋 이름',
'variables.noPresets': '저장된 프리셋 없음',
+ // ── Design System (design.md) ──
+ 'designMd.title': '디자인 시스템',
+ 'designMd.import': 'design.md 가져오기',
+ 'designMd.export': 'design.md 내보내기',
+ 'designMd.autoGenerate': '디자인에서 자동 생성',
+ 'designMd.empty': '로드된 디자인 시스템 없음',
+ 'designMd.importCta': 'design.md 가져오기',
+ 'designMd.autoGenerateCta': '자동 생성',
+ 'designMd.visualTheme': '비주얼 테마',
+ 'designMd.colors': '색상',
+ 'designMd.typography': '타이포그래피',
+ 'designMd.font': '글꼴',
+ 'designMd.headings': '제목',
+ 'designMd.body': '본문',
+ 'designMd.componentStyles': '컴포넌트 스타일',
+ 'designMd.layoutPrinciples': '레이아웃 원칙',
+ 'designMd.generationNotes': '생성 메모',
+ 'designMd.syncAllToVariables': '모두 변수에 동기화',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'hex 복사',
+ 'designMd.remove': '디자인 시스템 제거',
+ 'toolbar.designSystem': '디자인 시스템',
+
// ── AI Chat ──
'ai.newChat': '새 대화',
'ai.collapse': '접기',
@@ -347,6 +373,8 @@ const ko: TranslationKeys = {
'agents.opencodeDesc': '75개 이상의 LLM 제공자',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'GitHub Copilot 모델',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Google Gemini 모델',
'agents.mcpServer': 'MCP 서버',
'agents.mcpServerStart': '시작',
'agents.mcpServerStop': '정지',
@@ -366,6 +394,7 @@ const ko: TranslationKeys = {
'settings.title': '설정',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': '시스템',
'settings.autoUpdateDesc': '시작 시 자동으로 새 버전 확인',
'settings.systemDesktopOnly': '시스템 설정은 데스크톱 앱에서 사용할 수 있습니다.',
diff --git a/src/i18n/locales/pt.ts b/apps/web/src/i18n/locales/pt.ts
similarity index 92%
rename from src/i18n/locales/pt.ts
rename to apps/web/src/i18n/locales/pt.ts
index 6370644a..a827d278 100644
--- a/src/i18n/locales/pt.ts
+++ b/apps/web/src/i18n/locales/pt.ts
@@ -51,6 +51,9 @@ const pt: TranslationKeys = {
'topbar.fullscreen': 'Tela cheia',
'topbar.exitFullscreen': 'Sair da tela cheia',
'topbar.edited': '— Editado',
+ 'topbar.closeConfirmMessage': 'Deseja salvar as alterações antes de fechar?',
+ 'topbar.closeConfirmDetail': 'Suas alterações serão perdidas se não forem salvas.',
+ 'topbar.dontSave': 'Não salvar',
'topbar.agentsAndMcp': 'Agentes & MCP',
'topbar.setupAgentsMcp': 'Configurar Agentes & MCP',
'topbar.connected': 'conectado',
@@ -278,6 +281,29 @@ const pt: TranslationKeys = {
'variables.presetName': 'Nome da predefinição',
'variables.noPresets': 'Nenhuma predefinição salva',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'Sistema de design',
+ 'designMd.import': 'Importar design.md',
+ 'designMd.export': 'Exportar design.md',
+ 'designMd.autoGenerate': 'Gerar automaticamente a partir do design',
+ 'designMd.empty': 'Nenhum sistema de design carregado',
+ 'designMd.importCta': 'Importar design.md',
+ 'designMd.autoGenerateCta': 'Gerar automaticamente',
+ 'designMd.visualTheme': 'Tema visual',
+ 'designMd.colors': 'Cores',
+ 'designMd.typography': 'Tipografia',
+ 'designMd.font': 'Fonte',
+ 'designMd.headings': 'Títulos',
+ 'designMd.body': 'Corpo',
+ 'designMd.componentStyles': 'Estilos de componentes',
+ 'designMd.layoutPrinciples': 'Princípios de layout',
+ 'designMd.generationNotes': 'Notas de geração',
+ 'designMd.syncAllToVariables': 'Sincronizar tudo com variáveis',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'Copiar hex',
+ 'designMd.remove': 'Remover sistema de design',
+ 'toolbar.designSystem': 'Sistema de design',
+
// ── AI Chat ──
'ai.newChat': 'Novo chat',
'ai.collapse': 'Recolher',
@@ -349,6 +375,8 @@ const pt: TranslationKeys = {
'agents.opencodeDesc': '75+ provedores de LLM',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'Modelos GitHub Copilot',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Modelos Google Gemini',
'agents.mcpServer': 'Servidor MCP',
'agents.mcpServerStart': 'Iniciar',
'agents.mcpServerStop': 'Parar',
@@ -368,6 +396,7 @@ const pt: TranslationKeys = {
'settings.title': 'Configurações',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'Sistema',
'settings.autoUpdateDesc': 'Verificar automaticamente novas versões ao iniciar',
'settings.systemDesktopOnly': 'As configurações do sistema estão disponíveis no aplicativo de desktop.',
diff --git a/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts
similarity index 92%
rename from src/i18n/locales/ru.ts
rename to apps/web/src/i18n/locales/ru.ts
index 409893f4..9156ce62 100644
--- a/src/i18n/locales/ru.ts
+++ b/apps/web/src/i18n/locales/ru.ts
@@ -51,6 +51,9 @@ const ru: TranslationKeys = {
'topbar.fullscreen': 'Полный экран',
'topbar.exitFullscreen': 'Выйти из полноэкранного режима',
'topbar.edited': '— Изменено',
+ 'topbar.closeConfirmMessage': 'Сохранить изменения перед закрытием?',
+ 'topbar.closeConfirmDetail': 'Ваши изменения будут потеряны, если вы их не сохраните.',
+ 'topbar.dontSave': 'Не сохранять',
'topbar.agentsAndMcp': 'Агенты и MCP',
'topbar.setupAgentsMcp': 'Настройка агентов и MCP',
'topbar.connected': 'подключено',
@@ -278,6 +281,29 @@ const ru: TranslationKeys = {
'variables.presetName': 'Название пресета',
'variables.noPresets': 'Нет сохранённых пресетов',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'Дизайн-система',
+ 'designMd.import': 'Импортировать design.md',
+ 'designMd.export': 'Экспортировать design.md',
+ 'designMd.autoGenerate': 'Автоматически сгенерировать из дизайна',
+ 'designMd.empty': 'Дизайн-система не загружена',
+ 'designMd.importCta': 'Импортировать design.md',
+ 'designMd.autoGenerateCta': 'Сгенерировать автоматически',
+ 'designMd.visualTheme': 'Визуальная тема',
+ 'designMd.colors': 'Цвета',
+ 'designMd.typography': 'Типографика',
+ 'designMd.font': 'Шрифт',
+ 'designMd.headings': 'Заголовки',
+ 'designMd.body': 'Основной текст',
+ 'designMd.componentStyles': 'Стили компонентов',
+ 'designMd.layoutPrinciples': 'Принципы компоновки',
+ 'designMd.generationNotes': 'Примечания к генерации',
+ 'designMd.syncAllToVariables': 'Синхронизировать все с переменными',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'Копировать hex',
+ 'designMd.remove': 'Удалить дизайн-систему',
+ 'toolbar.designSystem': 'Дизайн-система',
+
// ── AI Chat ──
'ai.newChat': 'Новый чат',
'ai.collapse': 'Свернуть',
@@ -349,6 +375,8 @@ const ru: TranslationKeys = {
'agents.opencodeDesc': '75+ провайдеров LLM',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'Модели GitHub Copilot',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Модели Google Gemini',
'agents.mcpServer': 'MCP Сервер',
'agents.mcpServerStart': 'Запустить',
'agents.mcpServerStop': 'Остановить',
@@ -368,6 +396,7 @@ const ru: TranslationKeys = {
'settings.title': 'Настройки',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'Система',
'settings.autoUpdateDesc': 'Автоматически проверять наличие новых версий при запуске',
'settings.systemDesktopOnly': 'Системные настройки доступны в настольном приложении.',
diff --git a/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts
similarity index 92%
rename from src/i18n/locales/th.ts
rename to apps/web/src/i18n/locales/th.ts
index faedab40..38ab5b7f 100644
--- a/src/i18n/locales/th.ts
+++ b/apps/web/src/i18n/locales/th.ts
@@ -51,6 +51,9 @@ const th: TranslationKeys = {
'topbar.fullscreen': 'เต็มหน้าจอ',
'topbar.exitFullscreen': 'ออกจากโหมดเต็มหน้าจอ',
'topbar.edited': '— แก้ไขแล้ว',
+ 'topbar.closeConfirmMessage': 'คุณต้องการบันทึกการเปลี่ยนแปลงก่อนปิดหรือไม่?',
+ 'topbar.closeConfirmDetail': 'การเปลี่ยนแปลงของคุณจะสูญหายหากไม่บันทึก',
+ 'topbar.dontSave': 'ไม่บันทึก',
'topbar.agentsAndMcp': 'เอเจนต์และ MCP',
'topbar.setupAgentsMcp': 'ตั้งค่าเอเจนต์และ MCP',
'topbar.connected': 'เชื่อมต่อแล้ว',
@@ -276,6 +279,29 @@ const th: TranslationKeys = {
'variables.presetName': 'ชื่อพรีเซ็ต',
'variables.noPresets': 'ไม่มีพรีเซ็ตที่บันทึกไว้',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'ระบบการออกแบบ',
+ 'designMd.import': 'นำเข้า design.md',
+ 'designMd.export': 'ส่งออก design.md',
+ 'designMd.autoGenerate': 'สร้างอัตโนมัติจากการออกแบบ',
+ 'designMd.empty': 'ไม่มีระบบการออกแบบที่โหลดไว้',
+ 'designMd.importCta': 'นำเข้า design.md',
+ 'designMd.autoGenerateCta': 'สร้างอัตโนมัติ',
+ 'designMd.visualTheme': 'ธีมภาพ',
+ 'designMd.colors': 'สี',
+ 'designMd.typography': 'การจัดตัวอักษร',
+ 'designMd.font': 'แบบอักษร',
+ 'designMd.headings': 'หัวข้อ',
+ 'designMd.body': 'เนื้อหา',
+ 'designMd.componentStyles': 'สไตล์คอมโพเนนต์',
+ 'designMd.layoutPrinciples': 'หลักการจัดวาง',
+ 'designMd.generationNotes': 'บันทึกการสร้าง',
+ 'designMd.syncAllToVariables': 'ซิงค์ทั้งหมดไปยังตัวแปร',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'คัดลอก hex',
+ 'designMd.remove': 'ลบระบบการออกแบบ',
+ 'toolbar.designSystem': 'ระบบการออกแบบ',
+
// ── AI Chat ──
'ai.newChat': 'แชทใหม่',
'ai.collapse': 'ย่อ',
@@ -347,6 +373,8 @@ const th: TranslationKeys = {
'agents.opencodeDesc': '75+ ผู้ให้บริการ LLM',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'โมเดล GitHub Copilot',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'โมเดล Google Gemini',
'agents.mcpServer': 'เซิร์ฟเวอร์ MCP',
'agents.mcpServerStart': 'เริ่ม',
'agents.mcpServerStop': 'หยุด',
@@ -366,6 +394,7 @@ const th: TranslationKeys = {
'settings.title': 'การตั้งค่า',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'ระบบ',
'settings.autoUpdateDesc': 'ตรวจสอบเวอร์ชันใหม่โดยอัตโนมัติเมื่อเริ่มต้น',
'settings.systemDesktopOnly': 'การตั้งค่าระบบใช้ได้ในแอปเดสก์ท็อป',
diff --git a/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts
similarity index 92%
rename from src/i18n/locales/tr.ts
rename to apps/web/src/i18n/locales/tr.ts
index d97711c9..0071ae8a 100644
--- a/src/i18n/locales/tr.ts
+++ b/apps/web/src/i18n/locales/tr.ts
@@ -51,6 +51,9 @@ const tr: TranslationKeys = {
'topbar.fullscreen': 'Tam ekran',
'topbar.exitFullscreen': 'Tam ekrandan çık',
'topbar.edited': '— Düzenlendi',
+ 'topbar.closeConfirmMessage': 'Kapatmadan önce değişiklikleri kaydetmek ister misiniz?',
+ 'topbar.closeConfirmDetail': 'Kaydetmezseniz değişiklikleriniz kaybolacaktır.',
+ 'topbar.dontSave': 'Kaydetme',
'topbar.agentsAndMcp': 'Ajanlar ve MCP',
'topbar.setupAgentsMcp': 'Ajanları ve MCP Kur',
'topbar.connected': 'bağlı',
@@ -276,6 +279,29 @@ const tr: TranslationKeys = {
'variables.presetName': 'Ön ayar adı',
'variables.noPresets': 'Kayıtlı ön ayar yok',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'Tasarım Sistemi',
+ 'designMd.import': 'design.md içe aktar',
+ 'designMd.export': 'design.md dışa aktar',
+ 'designMd.autoGenerate': 'Tasarımdan otomatik oluştur',
+ 'designMd.empty': 'Yüklü tasarım sistemi yok',
+ 'designMd.importCta': 'design.md içe aktar',
+ 'designMd.autoGenerateCta': 'Otomatik oluştur',
+ 'designMd.visualTheme': 'Görsel Tema',
+ 'designMd.colors': 'Renkler',
+ 'designMd.typography': 'Tipografi',
+ 'designMd.font': 'Yazı Tipi',
+ 'designMd.headings': 'Başlıklar',
+ 'designMd.body': 'Gövde',
+ 'designMd.componentStyles': 'Bileşen Stilleri',
+ 'designMd.layoutPrinciples': 'Düzen İlkeleri',
+ 'designMd.generationNotes': 'Oluşturma Notları',
+ 'designMd.syncAllToVariables': 'Tümünü değişkenlerle senkronize et',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'Hex kopyala',
+ 'designMd.remove': 'Tasarım sistemini kaldır',
+ 'toolbar.designSystem': 'Tasarım Sistemi',
+
// ── AI Chat ──
'ai.newChat': 'Yeni sohbet',
'ai.collapse': 'Daralt',
@@ -347,6 +373,8 @@ const tr: TranslationKeys = {
'agents.opencodeDesc': '75+ LLM sağlayıcı',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'GitHub Copilot modelleri',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Google Gemini modelleri',
'agents.mcpServer': 'MCP Sunucu',
'agents.mcpServerStart': 'Başlat',
'agents.mcpServerStop': 'Durdur',
@@ -366,6 +394,7 @@ const tr: TranslationKeys = {
'settings.title': 'Ayarlar',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'Sistem',
'settings.autoUpdateDesc': 'Başlangıçta yeni sürümleri otomatik olarak kontrol et',
'settings.systemDesktopOnly': 'Sistem ayarları masaüstü uygulamasında kullanılabilir.',
diff --git a/src/i18n/locales/vi.ts b/apps/web/src/i18n/locales/vi.ts
similarity index 92%
rename from src/i18n/locales/vi.ts
rename to apps/web/src/i18n/locales/vi.ts
index b78907a4..e65f395c 100644
--- a/src/i18n/locales/vi.ts
+++ b/apps/web/src/i18n/locales/vi.ts
@@ -51,6 +51,9 @@ const vi: TranslationKeys = {
'topbar.fullscreen': 'Toàn màn hình',
'topbar.exitFullscreen': 'Thoát toàn màn hình',
'topbar.edited': '— Đã chỉnh sửa',
+ 'topbar.closeConfirmMessage': 'Bạn có muốn lưu thay đổi trước khi đóng không?',
+ 'topbar.closeConfirmDetail': 'Các thay đổi của bạn sẽ bị mất nếu không lưu.',
+ 'topbar.dontSave': 'Không lưu',
'topbar.agentsAndMcp': 'Agent & MCP',
'topbar.setupAgentsMcp': 'Thiết lập Agent & MCP',
'topbar.connected': 'đã kết nối',
@@ -276,6 +279,29 @@ const vi: TranslationKeys = {
'variables.presetName': 'Tên mẫu',
'variables.noPresets': 'Chưa có mẫu nào được lưu',
+ // ── Design System (design.md) ──
+ 'designMd.title': 'Hệ thống thiết kế',
+ 'designMd.import': 'Nhập design.md',
+ 'designMd.export': 'Xuất design.md',
+ 'designMd.autoGenerate': 'Tự động tạo từ thiết kế',
+ 'designMd.empty': 'Chưa tải hệ thống thiết kế nào',
+ 'designMd.importCta': 'Nhập design.md',
+ 'designMd.autoGenerateCta': 'Tự động tạo',
+ 'designMd.visualTheme': 'Chủ đề trực quan',
+ 'designMd.colors': 'Màu sắc',
+ 'designMd.typography': 'Kiểu chữ',
+ 'designMd.font': 'Phông chữ',
+ 'designMd.headings': 'Tiêu đề',
+ 'designMd.body': 'Nội dung',
+ 'designMd.componentStyles': 'Kiểu thành phần',
+ 'designMd.layoutPrinciples': 'Nguyên tắc bố cục',
+ 'designMd.generationNotes': 'Ghi chú tạo mã',
+ 'designMd.syncAllToVariables': 'Đồng bộ tất cả với biến',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': 'Sao chép hex',
+ 'designMd.remove': 'Xóa hệ thống thiết kế',
+ 'toolbar.designSystem': 'Hệ thống thiết kế',
+
// ── AI Chat ──
'ai.newChat': 'Cuộc trò chuyện mới',
'ai.collapse': 'Thu gọn',
@@ -347,6 +373,8 @@ const vi: TranslationKeys = {
'agents.opencodeDesc': '75+ nhà cung cấp LLM',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'Các mô hình GitHub Copilot',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Các mô hình Google Gemini',
'agents.mcpServer': 'Máy chủ MCP',
'agents.mcpServerStart': 'Khởi động',
'agents.mcpServerStop': 'Dừng',
@@ -366,6 +394,7 @@ const vi: TranslationKeys = {
'settings.title': 'Cài đặt',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': 'Hệ thống',
'settings.autoUpdateDesc': 'Tự động kiểm tra phiên bản mới khi khởi động',
'settings.systemDesktopOnly': 'Cài đặt hệ thống khả dụng trong ứng dụng máy tính.',
diff --git a/src/i18n/locales/zh-tw.ts b/apps/web/src/i18n/locales/zh-tw.ts
similarity index 92%
rename from src/i18n/locales/zh-tw.ts
rename to apps/web/src/i18n/locales/zh-tw.ts
index a353275c..c62fda2f 100644
--- a/src/i18n/locales/zh-tw.ts
+++ b/apps/web/src/i18n/locales/zh-tw.ts
@@ -51,6 +51,9 @@ const zhTW: TranslationKeys = {
'topbar.fullscreen': '全螢幕',
'topbar.exitFullscreen': '退出全螢幕',
'topbar.edited': '— 已編輯',
+ 'topbar.closeConfirmMessage': '關閉前是否要儲存變更?',
+ 'topbar.closeConfirmDetail': '如果不儲存,您的變更將會遺失。',
+ 'topbar.dontSave': '不儲存',
'topbar.agentsAndMcp': 'Agents 與 MCP',
'topbar.setupAgentsMcp': '設定 Agents 與 MCP',
'topbar.connected': '已連線',
@@ -271,6 +274,29 @@ const zhTW: TranslationKeys = {
'variables.presetName': '預設名稱',
'variables.noPresets': '沒有儲存的預設',
+ // ── Design System (design.md) ──
+ 'designMd.title': '設計系統',
+ 'designMd.import': '匯入 design.md',
+ 'designMd.export': '匯出 design.md',
+ 'designMd.autoGenerate': '從設計自動產生',
+ 'designMd.empty': '未載入設計系統',
+ 'designMd.importCta': '匯入 design.md',
+ 'designMd.autoGenerateCta': '自動產生',
+ 'designMd.visualTheme': '視覺主題',
+ 'designMd.colors': '顏色',
+ 'designMd.typography': '排版',
+ 'designMd.font': '字型',
+ 'designMd.headings': '標題',
+ 'designMd.body': '內文',
+ 'designMd.componentStyles': '元件樣式',
+ 'designMd.layoutPrinciples': '版面原則',
+ 'designMd.generationNotes': '產生備註',
+ 'designMd.syncAllToVariables': '全部同步到變數',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': '複製十六進位值',
+ 'designMd.remove': '移除設計系統',
+ 'toolbar.designSystem': '設計系統',
+
// ── AI Chat ──
'ai.newChat': '新對話',
'ai.collapse': '收合',
@@ -339,6 +365,8 @@ const zhTW: TranslationKeys = {
'agents.opencodeDesc': '75+ LLM 供應商',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'GitHub Copilot 模型',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Google Gemini 模型',
'agents.mcpServer': 'MCP 伺服器',
'agents.mcpServerStart': '啟動',
'agents.mcpServerStop': '停止',
@@ -358,6 +386,7 @@ const zhTW: TranslationKeys = {
'settings.title': '設定',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': '系統',
'settings.autoUpdateDesc': '啟動時自動檢查新版本',
'settings.systemDesktopOnly': '系統設定僅在桌面應用程式中可用。',
diff --git a/src/i18n/locales/zh.ts b/apps/web/src/i18n/locales/zh.ts
similarity index 92%
rename from src/i18n/locales/zh.ts
rename to apps/web/src/i18n/locales/zh.ts
index 03fc1271..e1e65e22 100644
--- a/src/i18n/locales/zh.ts
+++ b/apps/web/src/i18n/locales/zh.ts
@@ -51,6 +51,9 @@ const zh: TranslationKeys = {
'topbar.fullscreen': '全屏',
'topbar.exitFullscreen': '退出全屏',
'topbar.edited': '— 已编辑',
+ 'topbar.closeConfirmMessage': '关闭前是否要保存更改?',
+ 'topbar.closeConfirmDetail': '如果不保存,您的更改将会丢失。',
+ 'topbar.dontSave': '不保存',
'topbar.agentsAndMcp': 'Agents 与 MCP',
'topbar.setupAgentsMcp': '设置 Agents 与 MCP',
'topbar.connected': '已连接',
@@ -271,6 +274,29 @@ const zh: TranslationKeys = {
'variables.presetName': '预设名称',
'variables.noPresets': '没有保存的预设',
+ // ── Design System (design.md) ──
+ 'designMd.title': '设计系统',
+ 'designMd.import': '导入 design.md',
+ 'designMd.export': '导出 design.md',
+ 'designMd.autoGenerate': '从设计自动生成',
+ 'designMd.empty': '未加载设计系统',
+ 'designMd.importCta': '导入 design.md',
+ 'designMd.autoGenerateCta': '自动生成',
+ 'designMd.visualTheme': '视觉主题',
+ 'designMd.colors': '颜色',
+ 'designMd.typography': '排版',
+ 'designMd.font': '字体',
+ 'designMd.headings': '标题',
+ 'designMd.body': '正文',
+ 'designMd.componentStyles': '组件样式',
+ 'designMd.layoutPrinciples': '布局原则',
+ 'designMd.generationNotes': '生成备注',
+ 'designMd.syncAllToVariables': '全部同步到变量',
+ 'designMd.addAsVariable': '+Var',
+ 'designMd.copyHex': '复制十六进制值',
+ 'designMd.remove': '移除设计系统',
+ 'toolbar.designSystem': '设计系统',
+
// ── AI Chat ──
'ai.newChat': '新对话',
'ai.collapse': '收起',
@@ -339,6 +365,8 @@ const zh: TranslationKeys = {
'agents.opencodeDesc': '75+ LLM 提供商',
'agents.copilot': 'GitHub Copilot',
'agents.copilotDesc': 'GitHub Copilot 模型',
+ 'agents.geminiCli': 'Gemini CLI',
+ 'agents.geminiDesc': 'Google Gemini 模型',
'agents.mcpServer': 'MCP 服务器',
'agents.mcpServerStart': '启动',
'agents.mcpServerStop': '停止',
@@ -358,6 +386,7 @@ const zh: TranslationKeys = {
'settings.title': '设置',
'settings.agents': 'Agents',
'settings.mcp': 'MCP',
+ 'settings.images': 'Images',
'settings.system': '系统',
'settings.autoUpdateDesc': '启动时自动检查新版本',
'settings.systemDesktopOnly': '系统设置仅在桌面应用中可用。',
diff --git a/src/lib/utils.ts b/apps/web/src/lib/utils.ts
similarity index 100%
rename from src/lib/utils.ts
rename to apps/web/src/lib/utils.ts
diff --git a/src/mcp/__tests__/security.test.ts b/apps/web/src/mcp/__tests__/security.test.ts
similarity index 100%
rename from src/mcp/__tests__/security.test.ts
rename to apps/web/src/mcp/__tests__/security.test.ts
diff --git a/src/mcp/document-manager.ts b/apps/web/src/mcp/document-manager.ts
similarity index 100%
rename from src/mcp/document-manager.ts
rename to apps/web/src/mcp/document-manager.ts
diff --git a/src/mcp/server.ts b/apps/web/src/mcp/server.ts
similarity index 93%
rename from src/mcp/server.ts
rename to apps/web/src/mcp/server.ts
index cc291d2c..918f092a 100644
--- a/src/mcp/server.ts
+++ b/apps/web/src/mcp/server.ts
@@ -22,6 +22,7 @@ import {
handleReplaceNode,
} from './tools/node-crud'
import { handleGetVariables, handleSetVariables, handleSetThemes } from './tools/variables'
+import { handleGetDesignMd, handleSetDesignMd, handleExportDesignMd } from './tools/design-md'
import { handleImportSvg } from './tools/import-svg'
import { handleSnapshotLayout } from './tools/snapshot-layout'
import { handleFindEmptySpace } from './tools/find-empty-space'
@@ -331,6 +332,41 @@ const TOOL_DEFINITIONS = [
required: ['themes'],
},
},
+ {
+ name: 'get_design_md',
+ description: 'Get the design.md (design system specification) from the document. Returns the parsed spec and raw markdown. If no design.md is loaded, returns hasDesignMd: false.',
+ inputSchema: {
+ type: 'object' as const,
+ properties: {
+ filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
+ },
+ required: [],
+ },
+ },
+ {
+ name: 'set_design_md',
+ description: 'Import a design.md (design system specification) into the document. Accepts raw markdown or autoExtract=true to generate from existing document content. The design.md guides AI design generation with consistent colors, typography, and component styles.',
+ inputSchema: {
+ type: 'object' as const,
+ properties: {
+ filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
+ markdown: { type: 'string', description: 'Raw markdown content of design.md file' },
+ autoExtract: { type: 'boolean', description: 'Auto-generate design.md from existing document variables and design content (default false)' },
+ },
+ required: [],
+ },
+ },
+ {
+ name: 'export_design_md',
+ description: 'Export the design.md as markdown text. If no design.md exists, auto-extracts from document content.',
+ inputSchema: {
+ type: 'object' as const,
+ properties: {
+ filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
+ },
+ required: [],
+ },
+ },
{
name: 'snapshot_layout',
description: 'Get the hierarchical bounding box layout tree of an .op file. Useful for understanding spatial arrangement.',
@@ -561,6 +597,12 @@ async function handleToolCall(name: string, args: Record | unde
return JSON.stringify(await handleSetVariables(a), null, 2)
case 'set_themes':
return JSON.stringify(await handleSetThemes(a), null, 2)
+ case 'get_design_md':
+ return JSON.stringify(await handleGetDesignMd(a), null, 2)
+ case 'set_design_md':
+ return JSON.stringify(await handleSetDesignMd(a), null, 2)
+ case 'export_design_md':
+ return JSON.stringify(await handleExportDesignMd(a), null, 2)
case 'snapshot_layout':
return JSON.stringify(await handleSnapshotLayout(a), null, 2)
case 'find_empty_space':
diff --git a/src/mcp/tools/batch-design.ts b/apps/web/src/mcp/tools/batch-design.ts
similarity index 89%
rename from src/mcp/tools/batch-design.ts
rename to apps/web/src/mcp/tools/batch-design.ts
index 87afeff5..909f1c9d 100644
--- a/src/mcp/tools/batch-design.ts
+++ b/apps/web/src/mcp/tools/batch-design.ts
@@ -1,4 +1,4 @@
-import { openDocument, saveDocument, resolveDocPath } from '../document-manager'
+import { openDocument, saveDocument, resolveDocPath, getSyncUrl } from '../document-manager'
import {
findNodeInTree,
insertNodeInTree,
@@ -65,7 +65,7 @@ export async function handleBatchDesign(
for (const line of lines) {
try {
- executeLine(line, doc, bindings, results, pageId)
+ await executeLine(line, doc, bindings, results, pageId)
} catch (err) {
throw new Error(
`Error executing "${line}": ${err instanceof Error ? err.message : String(err)}`,
@@ -123,15 +123,15 @@ export async function handleBatchDesign(
}
}
-function executeLine(
+async function executeLine(
line: string,
doc: PenDocument,
bindings: Map,
results: OpResult[],
pageId?: string,
-): void {
+): Promise {
// Parse: binding=OP(args) or OP(args)
- const assignMatch = line.match(/^(\w+)\s*=\s*([ICRM])\((.+)\)$/)
+ const assignMatch = line.match(/^(\w+)\s*=\s*([ICRMG])\((.+)\)$/)
const callMatch = line.match(/^([UDM])\((.+)\)$/)
if (assignMatch) {
@@ -217,6 +217,48 @@ function executeLine(
results.push({ binding, nodeId })
break
}
+ case 'G': {
+ const gArgs = argsStr.match(/^"([^"]+)"\s*,\s*"(search|generate)"\s*,\s*"([^"]+)"$/)
+ if (!gArgs) throw new Error(`Invalid G() syntax: ${argsStr}`)
+ const [, gParent, gMode, gPrompt] = gArgs
+ const resolvedParent = resolveRef(gParent, bindings)
+
+ const imageNode = {
+ id: generateId(),
+ type: 'image' as const,
+ name: gPrompt.slice(0, 40),
+ imagePrompt: gPrompt,
+ src: '',
+ width: 400,
+ height: 300,
+ }
+
+ // MCP runs in Node.js — must use absolute URL via getSyncUrl()
+ const syncUrl = await getSyncUrl()
+ if (gMode === 'search' && syncUrl) {
+ try {
+ const searchRes = await fetch(`${syncUrl}/api/ai/image-search`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: gPrompt, count: 1 }),
+ })
+ const searchData = (await searchRes.json()) as { results?: Array<{ thumbUrl: string }> }
+ if (searchData.results && searchData.results.length > 0) {
+ imageNode.src = searchData.results[0].thumbUrl
+ }
+ } catch { /* keep empty src */ }
+ }
+ // "generate" mode not supported in MCP (no access to API keys in browser store)
+
+ setDocChildren(
+ doc,
+ insertNodeInTree(getDocChildren(doc, pageId), resolvedParent, imageNode as unknown as PenNode),
+ pageId,
+ )
+ bindings.set(binding, imageNode.id)
+ results.push({ binding, nodeId: imageNode.id })
+ break
+ }
}
} else if (callMatch) {
const [, op, argsStr] = callMatch
diff --git a/src/mcp/tools/batch-get.ts b/apps/web/src/mcp/tools/batch-get.ts
similarity index 100%
rename from src/mcp/tools/batch-get.ts
rename to apps/web/src/mcp/tools/batch-get.ts
diff --git a/src/mcp/tools/design-content.ts b/apps/web/src/mcp/tools/design-content.ts
similarity index 100%
rename from src/mcp/tools/design-content.ts
rename to apps/web/src/mcp/tools/design-content.ts
diff --git a/apps/web/src/mcp/tools/design-md.ts b/apps/web/src/mcp/tools/design-md.ts
new file mode 100644
index 00000000..edc5778c
--- /dev/null
+++ b/apps/web/src/mcp/tools/design-md.ts
@@ -0,0 +1,88 @@
+import { openDocument, resolveDocPath } from '../document-manager'
+import { parseDesignMd, generateDesignMd, extractDesignMdFromDocument } from '../../utils/design-md-parser'
+import type { DesignMdSpec } from '../../types/design-md'
+import { setDesignMdForPrompt } from './design-prompt'
+
+// In MCP context (stdio mode), there's no Zustand store.
+// We keep a module-level cache for the design.md spec.
+let _mcpDesignMd: DesignMdSpec | undefined
+
+export interface GetDesignMdParams {
+ filePath?: string
+}
+
+export interface SetDesignMdParams {
+ filePath?: string
+ /** Raw markdown content of design.md */
+ markdown?: string
+ /** If true, auto-extract from existing document content */
+ autoExtract?: boolean
+}
+
+export interface ExportDesignMdParams {
+ filePath?: string
+}
+
+/** Read the design.md spec. */
+export async function handleGetDesignMd(
+ params: GetDesignMdParams,
+): Promise<{ hasDesignMd: boolean; spec?: DesignMdSpec; markdown?: string }> {
+ // Try module cache first
+ if (_mcpDesignMd) {
+ return {
+ hasDesignMd: true,
+ spec: _mcpDesignMd,
+ markdown: generateDesignMd(_mcpDesignMd),
+ }
+ }
+
+ // Try to auto-extract from document
+ const filePath = resolveDocPath(params.filePath)
+ const doc = await openDocument(filePath)
+ const spec = extractDesignMdFromDocument(doc)
+ const hasContent = !!(spec.colorPalette?.length || spec.typography?.fontFamily || spec.visualTheme)
+
+ if (hasContent) {
+ _mcpDesignMd = spec
+ return { hasDesignMd: true, spec, markdown: generateDesignMd(spec) }
+ }
+
+ return { hasDesignMd: false }
+}
+
+/** Import design.md content. */
+export async function handleSetDesignMd(
+ params: SetDesignMdParams,
+): Promise<{ success: boolean; spec?: DesignMdSpec }> {
+ let spec: DesignMdSpec
+
+ if (params.autoExtract) {
+ const filePath = resolveDocPath(params.filePath)
+ const doc = await openDocument(filePath)
+ spec = extractDesignMdFromDocument(doc)
+ } else if (params.markdown) {
+ spec = parseDesignMd(params.markdown)
+ } else {
+ return { success: false }
+ }
+
+ _mcpDesignMd = spec
+ setDesignMdForPrompt(spec)
+
+ return { success: true, spec }
+}
+
+/** Export design.md as markdown text. */
+export async function handleExportDesignMd(
+ params: ExportDesignMdParams,
+): Promise<{ markdown: string }> {
+ if (_mcpDesignMd) {
+ return { markdown: generateDesignMd(_mcpDesignMd) }
+ }
+
+ // Auto-extract from document
+ const filePath = resolveDocPath(params.filePath)
+ const doc = await openDocument(filePath)
+ const spec = extractDesignMdFromDocument(doc)
+ return { markdown: generateDesignMd(spec) }
+}
diff --git a/src/mcp/tools/design-prompt.ts b/apps/web/src/mcp/tools/design-prompt.ts
similarity index 92%
rename from src/mcp/tools/design-prompt.ts
rename to apps/web/src/mcp/tools/design-prompt.ts
index b3d636b2..f21fc302 100644
--- a/src/mcp/tools/design-prompt.ts
+++ b/apps/web/src/mcp/tools/design-prompt.ts
@@ -3,6 +3,8 @@ import {
ADAPTIVE_STYLE_POLICY,
DESIGN_EXAMPLES,
} from '../../services/ai/ai-prompts'
+import { PROMPT_SECTIONS, buildDesignMdStylePolicy } from '../../services/ai/ai-prompt-sections'
+import type { DesignMdSpec } from '../../types/design-md'
// ---------------------------------------------------------------------------
// Named prompt sections — can be retrieved individually via section parameter
@@ -204,7 +206,13 @@ type PromptSection =
| 'examples'
| 'guidelines'
| 'planning'
+ | 'design-md'
+ | 'copywriting'
+ | 'overflow'
+ | 'cjk'
+ | 'variables'
+// Dynamic section map — some sections use the shared section registry
const SECTION_MAP: Record string> = {
all: () => buildFullPrompt(),
schema: () => PEN_NODE_SCHEMA.trim(),
@@ -216,6 +224,24 @@ const SECTION_MAP: Record string> = {
examples: () => DESIGN_EXAMPLES.trim(),
guidelines: () => DESIGN_GUIDELINES,
planning: () => PLANNING_GUIDE,
+ 'design-md': () => _designMdContent ?? 'No design.md loaded in the current document.',
+ copywriting: () => PROMPT_SECTIONS.copywriting,
+ overflow: () => PROMPT_SECTIONS.overflow,
+ cjk: () => PROMPT_SECTIONS.cjk,
+ variables: () => VARIABLE_RULES,
+}
+
+// Design.md content injected via setDesignMdForPrompt()
+let _designMdContent: string | null = null
+
+/** Set the design.md content to be returned by the 'design-md' section. */
+export function setDesignMdForPrompt(spec: DesignMdSpec | undefined): void {
+ _designMdContent = spec ? buildDesignMdStylePolicy(spec) : null
+}
+
+/** Get the design.md style policy, or null if not loaded. */
+export function getDesignMdForPrompt(): string | null {
+ return _designMdContent
}
// ---------------------------------------------------------------------------
@@ -230,8 +256,14 @@ const SECTION_MAP: Record string> = {
* instead of consuming the full prompt at once.
*/
export function buildDesignPrompt(section?: string): string {
- if (section && section in SECTION_MAP) {
- return SECTION_MAP[section as PromptSection]()
+ if (section) {
+ // When design-md is loaded, 'style' section returns it instead of default
+ if (section === 'style' && _designMdContent) {
+ return `DESIGN SYSTEM (from design.md):\n${_designMdContent}`
+ }
+ if (section in SECTION_MAP) {
+ return SECTION_MAP[section as PromptSection]()
+ }
}
return buildFullPrompt()
}
diff --git a/src/mcp/tools/design-refine.ts b/apps/web/src/mcp/tools/design-refine.ts
similarity index 77%
rename from src/mcp/tools/design-refine.ts
rename to apps/web/src/mcp/tools/design-refine.ts
index 30aa5aae..993fa625 100644
--- a/src/mcp/tools/design-refine.ts
+++ b/apps/web/src/mcp/tools/design-refine.ts
@@ -1,4 +1,4 @@
-import { openDocument, saveDocument, resolveDocPath } from '../document-manager'
+import { openDocument, saveDocument, resolveDocPath, getSyncUrl } from '../document-manager'
import {
findNodeInTree,
flattenNodes,
@@ -96,6 +96,42 @@ export async function handleDesignRefine(
setDocChildren(doc, allChildren, pageId)
await saveDocument(filePath, doc)
+ // Auto-fill image nodes with placeholder/empty src via image-search API
+ const syncUrl = await getSyncUrl()
+ if (syncUrl) {
+ const walkAndFill = async (node: any) => {
+ if (
+ node.type === 'image' &&
+ (!node.src ||
+ node.src.startsWith('data:image/svg+xml;charset=utf-8,%3Csvg'))
+ ) {
+ const query = node.imageSearchQuery ?? node.name ?? 'placeholder'
+ try {
+ const res = await fetch(`${syncUrl}/api/ai/image-search`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, count: 1 }),
+ })
+ const data = (await res.json()) as { results?: { thumbUrl: string }[] }
+ if (data.results && data.results.length > 0) {
+ node.src = data.results[0].thumbUrl
+ }
+ } catch {
+ // non-fatal: image search unavailable
+ }
+ await new Promise((r) => setTimeout(r, 3000))
+ }
+ if (node.children) {
+ for (const child of node.children) await walkAndFill(child)
+ }
+ }
+ const children = getDocChildren(doc, pageId)
+ for (const child of children) await walkAndFill(child)
+ // Persist again with filled image srcs
+ setDocChildren(doc, children, pageId)
+ await saveDocument(filePath, doc)
+ }
+
// Build layout snapshot
const layoutSnapshot = computeLayoutTree([root], allChildren, 3)
diff --git a/src/mcp/tools/design-skeleton.ts b/apps/web/src/mcp/tools/design-skeleton.ts
similarity index 100%
rename from src/mcp/tools/design-skeleton.ts
rename to apps/web/src/mcp/tools/design-skeleton.ts
diff --git a/src/mcp/tools/find-empty-space.ts b/apps/web/src/mcp/tools/find-empty-space.ts
similarity index 100%
rename from src/mcp/tools/find-empty-space.ts
rename to apps/web/src/mcp/tools/find-empty-space.ts
diff --git a/src/mcp/tools/get-selection.ts b/apps/web/src/mcp/tools/get-selection.ts
similarity index 100%
rename from src/mcp/tools/get-selection.ts
rename to apps/web/src/mcp/tools/get-selection.ts
diff --git a/src/mcp/tools/import-svg.ts b/apps/web/src/mcp/tools/import-svg.ts
similarity index 100%
rename from src/mcp/tools/import-svg.ts
rename to apps/web/src/mcp/tools/import-svg.ts
diff --git a/src/mcp/tools/layered-design-defs.ts b/apps/web/src/mcp/tools/layered-design-defs.ts
similarity index 100%
rename from src/mcp/tools/layered-design-defs.ts
rename to apps/web/src/mcp/tools/layered-design-defs.ts
diff --git a/src/mcp/tools/node-crud.ts b/apps/web/src/mcp/tools/node-crud.ts
similarity index 100%
rename from src/mcp/tools/node-crud.ts
rename to apps/web/src/mcp/tools/node-crud.ts
diff --git a/src/mcp/tools/open-document.ts b/apps/web/src/mcp/tools/open-document.ts
similarity index 100%
rename from src/mcp/tools/open-document.ts
rename to apps/web/src/mcp/tools/open-document.ts
diff --git a/src/mcp/tools/pages.ts b/apps/web/src/mcp/tools/pages.ts
similarity index 100%
rename from src/mcp/tools/pages.ts
rename to apps/web/src/mcp/tools/pages.ts
diff --git a/src/mcp/tools/snapshot-layout.ts b/apps/web/src/mcp/tools/snapshot-layout.ts
similarity index 100%
rename from src/mcp/tools/snapshot-layout.ts
rename to apps/web/src/mcp/tools/snapshot-layout.ts
diff --git a/src/mcp/tools/theme-presets.ts b/apps/web/src/mcp/tools/theme-presets.ts
similarity index 100%
rename from src/mcp/tools/theme-presets.ts
rename to apps/web/src/mcp/tools/theme-presets.ts
diff --git a/src/mcp/tools/variables.ts b/apps/web/src/mcp/tools/variables.ts
similarity index 100%
rename from src/mcp/tools/variables.ts
rename to apps/web/src/mcp/tools/variables.ts
diff --git a/apps/web/src/mcp/utils/id.ts b/apps/web/src/mcp/utils/id.ts
new file mode 100644
index 00000000..76055aae
--- /dev/null
+++ b/apps/web/src/mcp/utils/id.ts
@@ -0,0 +1,2 @@
+// Re-export from pen-core (single source of truth)
+export { generateId } from '@/utils/id'
diff --git a/apps/web/src/mcp/utils/node-operations.ts b/apps/web/src/mcp/utils/node-operations.ts
new file mode 100644
index 00000000..75865802
--- /dev/null
+++ b/apps/web/src/mcp/utils/node-operations.ts
@@ -0,0 +1,154 @@
+import type { PenDocument, PenNode } from '@/types/pen'
+import {
+ findNodeInTree,
+ findParentInTree,
+ removeNodeFromTree,
+ updateNodeInTree,
+ insertNodeInTree,
+ flattenNodes,
+ getNodeBounds,
+ cloneNodeWithNewIds,
+} from '@/stores/document-tree-utils'
+
+// Re-export from pen-core (via shim)
+export {
+ findNodeInTree,
+ findParentInTree,
+ removeNodeFromTree,
+ updateNodeInTree,
+ insertNodeInTree,
+ flattenNodes,
+ getNodeBounds,
+ cloneNodeWithNewIds,
+}
+
+// ---------------------------------------------------------------------------
+// MCP-specific utilities
+// ---------------------------------------------------------------------------
+
+/** Get the working children for an MCP operation. */
+export function getDocChildren(doc: PenDocument, pageId?: string): PenNode[] {
+ if (doc.pages && doc.pages.length > 0) {
+ if (pageId) {
+ const page = doc.pages.find((p) => p.id === pageId)
+ if (!page) throw new Error(`Page not found: ${pageId}`)
+ return page.children
+ }
+ return doc.pages[0].children
+ }
+ return doc.children
+}
+
+/** Set the working children for an MCP operation. */
+export function setDocChildren(doc: PenDocument, children: PenNode[], pageId?: string): void {
+ if (doc.pages && doc.pages.length > 0) {
+ if (pageId) {
+ const page = doc.pages.find((p) => p.id === pageId)
+ if (!page) throw new Error(`Page not found: ${pageId}`)
+ page.children = children
+ } else {
+ doc.pages[0].children = children
+ }
+ } else {
+ doc.children = children
+ }
+}
+
+/** Search nodes matching a pattern. */
+export function searchNodes(
+ nodes: PenNode[],
+ pattern: { type?: string; name?: string; reusable?: boolean },
+ maxDepth = Infinity,
+ currentDepth = 0,
+): PenNode[] {
+ if (currentDepth > maxDepth) return []
+ const results: PenNode[] = []
+ for (const node of nodes) {
+ let matches = true
+ if (pattern.type && node.type !== pattern.type) matches = false
+ if (pattern.name) {
+ const regex = new RegExp(pattern.name, 'i')
+ if (!regex.test(node.name ?? '')) matches = false
+ }
+ if (pattern.reusable !== undefined) {
+ const isReusable = 'reusable' in node && (node as unknown as Record).reusable === true
+ if (pattern.reusable !== isReusable) matches = false
+ }
+ if (matches) results.push(node)
+ if ('children' in node && node.children) {
+ results.push(
+ ...searchNodes(node.children, pattern, maxDepth, currentDepth + 1),
+ )
+ }
+ }
+ return results
+}
+
+/** Read a node with depth-limited children. */
+export function readNodeWithDepth(
+ node: PenNode,
+ depth: number,
+): Record {
+ const result: Record = { ...node }
+ if (depth <= 0 && 'children' in node && node.children?.length) {
+ result.children = '...'
+ } else if ('children' in node && node.children) {
+ result.children = node.children.map((c) =>
+ readNodeWithDepth(c, depth - 1),
+ )
+ }
+ return result
+}
+
+export interface LayoutEntry {
+ id: string
+ name?: string
+ type: string
+ x: number
+ y: number
+ width: number
+ height: number
+ children?: LayoutEntry[]
+}
+
+/** Compute bounding box layout tree for snapshot_layout. */
+export function computeLayoutTree(
+ nodes: PenNode[],
+ allNodes: PenNode[],
+ maxDepth: number,
+ currentDepth = 0,
+ parentX = 0,
+ parentY = 0,
+): LayoutEntry[] {
+ const entries: LayoutEntry[] = []
+ for (const node of nodes) {
+ const bounds = getNodeBounds(node, allNodes)
+ const absX = parentX + bounds.x
+ const absY = parentY + bounds.y
+ const entry: LayoutEntry = {
+ id: node.id,
+ name: node.name,
+ type: node.type,
+ x: absX,
+ y: absY,
+ width: bounds.w,
+ height: bounds.h,
+ }
+ if (
+ 'children' in node &&
+ node.children?.length &&
+ currentDepth < maxDepth
+ ) {
+ entry.children = computeLayoutTree(
+ node.children,
+ allNodes,
+ maxDepth,
+ currentDepth + 1,
+ absX,
+ absY,
+ )
+ }
+ entries.push(entry)
+ }
+ return entries
+}
diff --git a/src/mcp/utils/sanitize.ts b/apps/web/src/mcp/utils/sanitize.ts
similarity index 100%
rename from src/mcp/utils/sanitize.ts
rename to apps/web/src/mcp/utils/sanitize.ts
diff --git a/src/mcp/utils/svg-node-parser.ts b/apps/web/src/mcp/utils/svg-node-parser.ts
similarity index 100%
rename from src/mcp/utils/svg-node-parser.ts
rename to apps/web/src/mcp/utils/svg-node-parser.ts
diff --git a/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
similarity index 100%
rename from src/routeTree.gen.ts
rename to apps/web/src/routeTree.gen.ts
diff --git a/src/router.tsx b/apps/web/src/router.tsx
similarity index 100%
rename from src/router.tsx
rename to apps/web/src/router.tsx
diff --git a/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
similarity index 100%
rename from src/routes/__root.tsx
rename to apps/web/src/routes/__root.tsx
diff --git a/src/routes/editor.tsx b/apps/web/src/routes/editor.tsx
similarity index 84%
rename from src/routes/editor.tsx
rename to apps/web/src/routes/editor.tsx
index 8bd64487..1bea604e 100644
--- a/src/routes/editor.tsx
+++ b/apps/web/src/routes/editor.tsx
@@ -1,6 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import EditorLayout from '@/components/editor/editor-layout'
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
+import { useBeforeUnload } from '@/hooks/use-before-unload'
export const Route = createFileRoute('/editor')({
component: EditorPage,
@@ -12,6 +13,7 @@ export const Route = createFileRoute('/editor')({
function EditorPage() {
useKeyboardShortcuts()
+ useBeforeUnload()
return
}
diff --git a/src/routes/index.tsx b/apps/web/src/routes/index.tsx
similarity index 100%
rename from src/routes/index.tsx
rename to apps/web/src/routes/index.tsx
diff --git a/apps/web/src/services/ai/__tests__/image-search-api.test.ts b/apps/web/src/services/ai/__tests__/image-search-api.test.ts
new file mode 100644
index 00000000..182e35ea
--- /dev/null
+++ b/apps/web/src/services/ai/__tests__/image-search-api.test.ts
@@ -0,0 +1,234 @@
+import { describe, it, expect } from 'vitest'
+import { mapOpenverseResult, mapWikimediaPages, simplifySearchQuery } from '../../../../server/api/ai/image-search'
+
+// ---------------------------------------------------------------------------
+// mapOpenverseResult
+// ---------------------------------------------------------------------------
+
+describe('mapOpenverseResult', () => {
+ it('maps an Openverse result to ImageSearchResult correctly', () => {
+ const raw = {
+ id: 'abc-123',
+ url: 'https://openverse.org/images/abc-123/photo.jpg',
+ thumbnail: 'https://openverse.org/thumbs/abc-123/thumb.jpg',
+ width: 1920,
+ height: 1080,
+ license: 'CC BY',
+ license_version: '2.0',
+ attribution: 'Photo by Artist (CC BY 2.0)',
+ }
+
+ const result = mapOpenverseResult(raw)
+
+ expect(result.id).toBe('abc-123')
+ expect(result.url).toBe(raw.url)
+ expect(result.thumbUrl).toBe(raw.thumbnail)
+ expect(result.thumbUrl).toContain('openverse.org')
+ expect(result.width).toBe(1920)
+ expect(result.height).toBe(1080)
+ expect(result.source).toBe('openverse')
+ expect(result.license).toBe('CC BY 2.0')
+ expect(result.attribution).toBe('Photo by Artist (CC BY 2.0)')
+ })
+
+ it('combines license and license_version with a space', () => {
+ const raw = {
+ id: 'xyz',
+ url: 'https://example.com/img.jpg',
+ thumbnail: 'https://example.com/thumb.jpg',
+ width: 800,
+ height: 600,
+ license: 'CC0',
+ license_version: '1.0',
+ attribution: '',
+ }
+
+ const result = mapOpenverseResult(raw)
+ expect(result.license).toBe('CC0 1.0')
+ })
+
+ it('trims license when license_version is empty string', () => {
+ const raw = {
+ id: 'xyz',
+ url: 'https://example.com/img.jpg',
+ thumbnail: 'https://example.com/thumb.jpg',
+ width: 800,
+ height: 600,
+ license: 'PDM',
+ license_version: '',
+ attribution: '',
+ }
+
+ const result = mapOpenverseResult(raw)
+ expect(result.license).toBe('PDM')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// mapWikimediaPages
+// ---------------------------------------------------------------------------
+
+describe('mapWikimediaPages', () => {
+ it('maps Wikimedia pages to ImageSearchResult correctly', () => {
+ const pages = {
+ '12345': {
+ pageid: 12345,
+ title: 'File:Example.jpg',
+ imageinfo: [
+ {
+ url: 'https://upload.wikimedia.org/wikipedia/commons/a/ab/Example.jpg',
+ thumburl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Example.jpg/800px-Example.jpg',
+ width: 1600,
+ height: 1200,
+ mime: 'image/jpeg',
+ extmetadata: {
+ LicenseShortName: { value: 'CC BY-SA 4.0' },
+ },
+ },
+ ],
+ },
+ }
+
+ const results = mapWikimediaPages(pages)
+
+ expect(results).toHaveLength(1)
+ const r = results[0]
+ expect(r.id).toBe('12345')
+ expect(r.url).toContain('wikimedia.org')
+ expect(r.thumbUrl).toContain('800px')
+ expect(r.width).toBe(1600)
+ expect(r.height).toBe(1200)
+ expect(r.source).toBe('wikimedia')
+ expect(r.license).toBe('CC BY-SA 4.0')
+ })
+
+ it('handles pages with no imageinfo gracefully (returns empty)', () => {
+ const pages = {
+ '99999': {
+ pageid: 99999,
+ title: 'File:NoInfo.jpg',
+ // imageinfo intentionally absent
+ },
+ }
+
+ const results = mapWikimediaPages(pages)
+ expect(results).toHaveLength(0)
+ })
+
+ it('handles pages with empty imageinfo array (returns empty)', () => {
+ const pages = {
+ '88888': {
+ pageid: 88888,
+ title: 'File:EmptyInfo.jpg',
+ imageinfo: [],
+ },
+ }
+
+ const results = mapWikimediaPages(pages)
+ expect(results).toHaveLength(0)
+ })
+
+ it('maps multiple pages and skips those with no imageinfo', () => {
+ const pages = {
+ '1': {
+ pageid: 1,
+ title: 'File:A.jpg',
+ imageinfo: [
+ {
+ url: 'https://upload.wikimedia.org/a.jpg',
+ thumburl: 'https://upload.wikimedia.org/thumb/a.jpg',
+ width: 400,
+ height: 300,
+ mime: 'image/jpeg',
+ extmetadata: { LicenseShortName: { value: 'CC0' } },
+ },
+ ],
+ },
+ '2': {
+ pageid: 2,
+ title: 'File:B.jpg',
+ // no imageinfo
+ },
+ '3': {
+ pageid: 3,
+ title: 'File:C.jpg',
+ imageinfo: [
+ {
+ url: 'https://upload.wikimedia.org/c.jpg',
+ thumburl: 'https://upload.wikimedia.org/thumb/c.jpg',
+ width: 600,
+ height: 400,
+ mime: 'image/png',
+ extmetadata: { LicenseShortName: { value: 'CC BY 4.0' } },
+ },
+ ],
+ },
+ }
+
+ const results = mapWikimediaPages(pages)
+ expect(results).toHaveLength(2)
+ expect(results.map(r => r.source)).toEqual(['wikimedia', 'wikimedia'])
+ const licenses = results.map(r => r.license).sort()
+ expect(licenses).toContain('CC0')
+ expect(licenses).toContain('CC BY 4.0')
+ })
+
+})
+
+// ---------------------------------------------------------------------------
+// simplifySearchQuery
+// ---------------------------------------------------------------------------
+
+describe('simplifySearchQuery', () => {
+ it('extracts keywords from verbose AI prompt', () => {
+ const result = simplifySearchQuery('delicious burger with fries and fresh vegetables')
+ expect(result).toBe('delicious burger fries fresh')
+ })
+
+ it('removes stop words', () => {
+ const result = simplifySearchQuery('a beautiful photo of the sunset on the beach')
+ expect(result).toBe('beautiful photo sunset beach')
+ })
+
+ it('limits to 4 keywords', () => {
+ const result = simplifySearchQuery('modern office workspace natural lighting wooden desk plants')
+ const words = result.split(' ')
+ expect(words.length).toBeLessThanOrEqual(4)
+ })
+
+ it('handles short queries unchanged', () => {
+ const result = simplifySearchQuery('burger')
+ expect(result).toBe('burger')
+ })
+
+ it('falls back to truncated input when all words are stop words', () => {
+ const result = simplifySearchQuery('a the an')
+ expect(result.length).toBeGreaterThan(0)
+ })
+})
+
+// (keep original last test)
+describe('mapWikimediaPages (continued)', () => {
+ it('falls back to empty string license when extmetadata is missing (original)', () => {
+ const pages = {
+ '55555': {
+ pageid: 55555,
+ title: 'File:NoLicense.jpg',
+ imageinfo: [
+ {
+ url: 'https://upload.wikimedia.org/nolicense.jpg',
+ thumburl: 'https://upload.wikimedia.org/thumb/nolicense.jpg',
+ width: 200,
+ height: 150,
+ mime: 'image/jpeg',
+ // no extmetadata
+ },
+ ],
+ },
+ }
+
+ const results = mapWikimediaPages(pages)
+ expect(results).toHaveLength(1)
+ expect(results[0].license).toBe('')
+ })
+})
diff --git a/apps/web/src/services/ai/__tests__/image-search-pipeline.test.ts b/apps/web/src/services/ai/__tests__/image-search-pipeline.test.ts
new file mode 100644
index 00000000..adcadf91
--- /dev/null
+++ b/apps/web/src/services/ai/__tests__/image-search-pipeline.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest'
+import { inferAspectRatio } from '../image-search-pipeline'
+import type { PenNode } from '@/types/pen'
+
+function makeImageNode(w: number, h: number): PenNode {
+ return { id: 'test', type: 'image', src: '', width: w, height: h } as PenNode
+}
+
+describe('inferAspectRatio', () => {
+ it('returns wide for landscape images', () => {
+ expect(inferAspectRatio(makeImageNode(1200, 600))).toBe('wide')
+ })
+
+ it('returns tall for portrait images', () => {
+ expect(inferAspectRatio(makeImageNode(400, 800))).toBe('tall')
+ })
+
+ it('returns square for roughly equal dimensions', () => {
+ expect(inferAspectRatio(makeImageNode(500, 500))).toBe('square')
+ expect(inferAspectRatio(makeImageNode(600, 500))).toBe('square')
+ })
+
+ it('returns undefined when dimensions missing', () => {
+ expect(inferAspectRatio({ id: 'x', type: 'image', src: '' } as PenNode)).toBeUndefined()
+ })
+})
diff --git a/src/services/ai/agent-identity.ts b/apps/web/src/services/ai/agent-identity.ts
similarity index 100%
rename from src/services/ai/agent-identity.ts
rename to apps/web/src/services/ai/agent-identity.ts
diff --git a/apps/web/src/services/ai/ai-prompt-sections.ts b/apps/web/src/services/ai/ai-prompt-sections.ts
new file mode 100644
index 00000000..f52070b0
--- /dev/null
+++ b/apps/web/src/services/ai/ai-prompt-sections.ts
@@ -0,0 +1,397 @@
+/**
+ * Prompt Knowledge Sections — modular knowledge blocks for progressive loading.
+ *
+ * Each section is a self-contained knowledge block with:
+ * - `content`: the actual prompt text
+ * - `triggers`: keywords that activate this section (matched against user message)
+ * - `always`: if true, always included in design generation prompts
+ * - `priority`: loading order (lower = loaded first)
+ *
+ * To add a new section: just add an entry to SECTION_REGISTRY.
+ * Detection, assembly, and MCP listing all derive from the registry automatically.
+ *
+ * Usage:
+ * import { detectSections, assembleSections } from './ai-prompt-sections'
+ * const needed = detectSections(userMessage)
+ * const knowledge = assembleSections(needed)
+ */
+
+import { AVAILABLE_FEATHER_ICONS } from './icon-resolver'
+import type { DesignMdSpec } from '@/types/design-md'
+
+const FEATHER_ICON_NAMES = AVAILABLE_FEATHER_ICONS.join(', ')
+
+// ---------------------------------------------------------------------------
+// Section type
+// ---------------------------------------------------------------------------
+
+export type PromptSectionKey =
+ | 'schema'
+ | 'layout'
+ | 'text'
+ | 'style'
+ | 'guidelines'
+ | 'examples'
+ | 'roles'
+ | 'copywriting'
+ | 'overflow'
+ | 'cjk'
+ | 'variables'
+ | 'icons'
+ | 'design-md'
+
+// ---------------------------------------------------------------------------
+// Section registry — single source of truth
+// ---------------------------------------------------------------------------
+
+interface SectionDef {
+ /** The prompt knowledge block content */
+ content: string
+ /** Keywords that trigger this section (matched case-insensitively against user message).
+ * Supports plain strings and regex patterns (prefix with `/`). */
+ triggers?: string[]
+ /** If true, always included in design generation (not modification) prompts */
+ always?: boolean
+ /** Loading priority — lower numbers load first (default 50) */
+ priority?: number
+ /** If true, only loaded when a special flag is set (e.g. hasVariables, hasDesignMd) */
+ flag?: 'hasVariables' | 'hasDesignMd'
+}
+
+export const SECTION_REGISTRY: Record = {
+ schema: {
+ content: `PenNode types (the ONLY format you output for designs):
+- frame: Container. Props: width, height, layout ('none'|'vertical'|'horizontal'), gap, padding, justifyContent ('start'|'center'|'end'|'space_between'|'space_around'), alignItems ('start'|'center'|'end'), clipContent (boolean), children[], cornerRadius, fill, stroke, effects
+- rectangle: Props: width, height, cornerRadius, fill, stroke, effects
+- ellipse: Props: width, height, fill, stroke, effects
+- text: Props: content, fontFamily, fontSize, fontWeight, fontStyle ('normal'|'italic'), fill, width, height, textAlign, textGrowth ('auto'|'fixed-width'|'fixed-width-height'), lineHeight (multiplier), letterSpacing (px), textAlignVertical ('top'|'middle'|'bottom')
+- path: SVG icon. Props: d (SVG path), width, height, fill, stroke, effects
+- image: Props: width, height, cornerRadius, effects, imageSearchQuery (2-3 English keywords)
+
+All nodes share: id, type, name, role, x, y, rotation, opacity
+Fill = [{ type: "solid", color: "#hex" }] or [{ type: "linear_gradient", angle, stops: [{ offset, color }] }]
+Stroke = { thickness, fill: [...] } Effects = [{ type: "shadow", offsetX, offsetY, blur, spread, color }]
+SIZING: width/height accept number (px), "fill_container", or "fit_content".
+PADDING: number (uniform), [v, h], or [top, right, bottom, left].
+cornerRadius is a number. fill is ALWAYS an array. Do NOT set x/y on children inside layout frames.`,
+ always: true,
+ priority: 0,
+ },
+
+ layout: {
+ content: `LAYOUT ENGINE (flexbox-based):
+- Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems.
+- NEVER set x/y on children inside layout containers.
+- CHILD SIZE RULE: child width must be ≤ parent content area. Use "fill_container" when in doubt.
+- In vertical layout: "fill_container" width stretches horizontally. In horizontal: fills remaining space.
+- CLIP CONTENT: clipContent: true clips overflowing children. ALWAYS use on cards with cornerRadius + image.
+- justifyContent: "space_between" (navbars), "center", "start"/"end", "space_around".
+- WIDTH CONSISTENCY: siblings must use same width strategy. Don't mix fixed-px and fill_container.
+- NEVER use "fill_container" on children of "fit_content" parent — circular dependency.
+- Two-column: horizontal frame → two child frames each "fill_container" width.
+- Keep hierarchy shallow: no pointless wrappers. Only use wrappers with visual purpose (fill, padding).
+- Section root: width="fill_container", height="fit_content", layout="vertical".
+- FORMS: ALL inputs AND primary button MUST use width="fill_container". Vertical layout, gap=16-20.`,
+ always: true,
+ priority: 10,
+ },
+
+ text: {
+ content: `TEXT RULES:
+- Body/description in vertical layout: width="fill_container" + textGrowth="fixed-width" (wraps text, auto-sizes height).
+- Short labels in horizontal rows: width="fit_content" + textGrowth="auto". Prevents squeezing siblings.
+- NEVER fixed pixel width on text inside layout frames — causes overflow.
+- Text >15 chars MUST have textGrowth="fixed-width". NEVER set explicit pixel height on text nodes — OMIT height.
+- Typography: Display 40-56px, Heading 28-36px, Subheading 20-24px, Body 16-18px, Caption 13-14px.
+- lineHeight: headings 1.1-1.2, body 1.4-1.6. letterSpacing: -0.5 for headlines, 0.5-2 for uppercase.`,
+ always: true,
+ priority: 15,
+ },
+
+ overflow: {
+ content: `OVERFLOW PREVENTION (CRITICAL):
+- Text in vertical layout: width="fill_container" + textGrowth="fixed-width". In horizontal: width="fit_content".
+- NEVER set fixed pixel width on text inside layout frames (e.g. width:378 in 195px card → overflows!).
+- Fixed-width children must be ≤ parent content area (parent width − padding).
+- Badges: short labels only (CJK ≤8 chars / Latin ≤16 chars).`,
+ always: true,
+ priority: 16,
+ },
+
+ icons: {
+ content: `ICONS:
+- Use "path" nodes, size 16-24px. ONLY use Feather icon names — PascalCase + "Icon" suffix (e.g. "SearchIcon").
+- System auto-resolves names to SVG paths. "d" is replaced automatically.
+- Available: ${FEATHER_ICON_NAMES}
+- NEVER use emoji as icons. Use icon_font nodes for lucide icons.`,
+ always: true,
+ priority: 20,
+ },
+
+ style: {
+ content: `VISUAL STYLE POLICY:
+- Default to clean light marketing style unless user explicitly asks for dark/cyber/terminal.
+DEFAULT LIGHT PALETTE:
+- Page Bg: #F8FAFC, Surface: #FFFFFF, Text: #0F172A, Secondary: #475569
+- Accent: #2563EB, Accent2: #0EA5E9, Border: #E2E8F0
+TYPOGRAPHY SCALE:
+- Display: 40-56px — "Space Grotesk"/"Manrope" (700), lineHeight 1.1
+- Heading: 28-36px — "Space Grotesk"/"Manrope" (600-700), lineHeight 1.2
+- Subheading: 20-24px — "Inter" (600), lineHeight 1.3
+- Body: 16-18px — "Inter" (400-500), lineHeight 1.5
+- Caption: 13-14px — "Inter" (400), lineHeight 1.4
+SHAPES: cornerRadius 8-14. Subtle shadows. Clear hierarchy via spacing and contrast.
+LANDING PAGES: hero 80-120px padding, alternate section backgrounds, cards cornerRadius 12-16, centered content ~1040-1160px.`,
+ // Loaded when no design-md is present (default style fallback)
+ always: true,
+ priority: 5,
+ },
+
+ 'design-md': {
+ content: '', // populated dynamically
+ flag: 'hasDesignMd',
+ priority: 5,
+ },
+
+ guidelines: {
+ content: `DESIGN GUIDELINES:
+- Mobile: 375×812. Web: 1200×800 (single) or 1200×3000-5000 (landing page).
+- "mobile"/"移动端" + screen type = ACTUAL 375×812 screen, NOT desktop with phone mockup.
+- Buttons: height 44-52px, cornerRadius 8-12, padding [12, 24]. Icon+text: layout="horizontal", gap=8.
+- Icon-only buttons: 44×44, justifyContent/alignItems="center", path icon 20-24px.
+- Inputs: height 44px, light bg, subtle border, width="fill_container" in forms.
+- Cards: cornerRadius 12-16, clipContent: true, subtle shadows.
+- CARD ROW ALIGNMENT: sibling cards in horizontal layout ALL use width/height="fill_container".
+- Navigation: justifyContent="space_between", 3 groups (logo | links | CTA), padding=[0,80].
+- Phone mockup: ONE "frame", width 260-300, height 520-580, cornerRadius 32. NEVER ellipse.
+- NEVER use ellipse for decorative shapes. Use frame/rectangle with cornerRadius.
+- NEVER use emoji as icons. Use path nodes with Feather icon names.`,
+ triggers: [
+ 'form', 'input', 'login', 'signup', 'sign up', 'register', 'password', 'email',
+ '搜索', '表单', '登录', '注册',
+ 'mobile', 'phone', '手机', '移动端', 'app screen', 'ios', 'android',
+ 'button', 'card', 'nav', 'navigation', 'mockup',
+ '按钮', '卡片', '导航', '模型',
+ ],
+ priority: 30,
+ },
+
+ roles: {
+ content: `SEMANTIC ROLES (add "role" to nodes — system fills unset props based on role):
+Layout: section, row, column, centered-content, form-group, divider, spacer
+Navigation: navbar, nav-links, nav-link
+Interactive: button, icon-button, badge, tag, pill, input, form-input, search-bar
+Display: card, stat-card, pricing-card, feature-card, image-card
+Media: phone-mockup, screenshot-frame, avatar, icon
+Typography: heading, subheading, body-text, caption, label
+Content: hero, feature-grid, testimonial, cta-section, footer, stats-section
+Table: table, table-row, table-header, table-cell
+Key defaults: section→padding:[60,80], navbar→height:72/layout:horizontal/space_between, hero→padding:[80,80], button→padding:[12,24]/height:44, card→gap:12/cornerRadius:12/clipContent:true.
+Your explicit props ALWAYS override role defaults.`,
+ triggers: [
+ 'landing', 'marketing', 'hero', 'website', '官网', '首页', '产品页',
+ 'table', 'grid', '表格', '表头', 'dashboard', '数据', 'admin',
+ 'testimonial', 'pricing', 'footer', 'stats',
+ '评价', '定价', '页脚', '数据统计',
+ ],
+ priority: 35,
+ },
+
+ copywriting: {
+ content: `COPYWRITING:
+- Headlines: 2-6 words. Subtitles: 1 sentence ≤15 words.
+- Feature titles: 2-4 words. Descriptions: 1 sentence ≤20 words.
+- Buttons: 1-3 words. Card text: ≤2 sentences. Stats: number + 1-3 word label.
+- NEVER 3+ sentence paragraphs. Distill to essence. Power words > vague adjectives.`,
+ triggers: [
+ 'landing', 'marketing', 'hero', 'website', '官网', '首页', '产品页',
+ 'copy', 'text', 'headline', 'content',
+ '文案', '标题', '内容',
+ ],
+ priority: 40,
+ },
+
+ cjk: {
+ content: `CJK TYPOGRAPHY (Chinese/Japanese/Korean):
+- Headings: "Noto Sans SC" (Chinese) / "Noto Sans JP" / "Noto Sans KR". NEVER "Space Grotesk"/"Manrope" for CJK.
+- Body: "Inter" (system CJK fallback) or "Noto Sans SC".
+- CJK lineHeight: headings 1.3-1.4 (NOT 1.1), body 1.6-1.8. letterSpacing: 0, NEVER negative.
+- CJK buttons: each char ≈ fontSize wide. Container width ≥ (charCount × fontSize) + padding.
+- Detect CJK from user request language — use CJK fonts for ALL text nodes.`,
+ // CJK is triggered by Unicode range detection, not keywords
+ triggers: ['/[\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af]/'],
+ priority: 25,
+ },
+
+ variables: {
+ content: `DESIGN VARIABLES:
+- When document has variables, use "$variableName" references instead of hardcoded values.
+- Color: [{ "type": "solid", "color": "$primary" }]. Number: "gap": "$spacing-md".
+- Only reference listed variables — do NOT invent names.`,
+ flag: 'hasVariables',
+ priority: 45,
+ },
+
+ examples: {
+ content: `EXAMPLES:
+Button: { "id":"btn-1","type":"frame","role":"button","width":180,"cornerRadius":8,"fill":[{"type":"solid","color":"#3B82F6"}],"children":[{"id":"btn-icon","type":"path","name":"ArrowRightIcon","role":"icon","d":"M5 12h14m-7-7 7 7-7 7","width":20,"height":20,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#FFF"}]}},{"id":"btn-text","type":"text","role":"label","content":"Continue","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFF"}]}] }
+Card: { "id":"card-1","type":"frame","role":"card","width":320,"height":340,"fill":[{"type":"solid","color":"#FFF"}],"effects":[{"type":"shadow","offsetX":0,"offsetY":4,"blur":12,"spread":0,"color":"rgba(0,0,0,0.1)"}],"children":[{"id":"card-img","type":"image","width":"fill_container","height":180},{"id":"card-body","type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":20,"gap":8,"children":[{"id":"card-title","type":"text","role":"heading","content":"Title","fontSize":20,"fontWeight":700,"fill":[{"type":"solid","color":"#111827"}]},{"id":"card-desc","type":"text","role":"body-text","content":"Description","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}]}] }`,
+ triggers: [
+ 'example', 'sample', 'show me', 'how to',
+ '示例', '样例', '怎么',
+ ],
+ priority: 50,
+ },
+}
+
+// ---------------------------------------------------------------------------
+// Backward-compatible flat content map (used by MCP design-prompt.ts)
+// ---------------------------------------------------------------------------
+
+export const PROMPT_SECTIONS: Record = Object.fromEntries(
+ Object.entries(SECTION_REGISTRY).map(([key, def]) => [key, def.content]),
+) as Record
+
+// ---------------------------------------------------------------------------
+// Intent detection — derive from registry, no hardcoded regexes
+// ---------------------------------------------------------------------------
+
+/** Compile trigger patterns from registry definitions. */
+function buildTriggerMatchers(): Array<{ key: PromptSectionKey; test: (msg: string) => boolean }> {
+ const matchers: Array<{ key: PromptSectionKey; test: (msg: string) => boolean }> = []
+
+ for (const [key, def] of Object.entries(SECTION_REGISTRY) as [PromptSectionKey, SectionDef][]) {
+ if (!def.triggers?.length) continue
+
+ const regexes: RegExp[] = []
+ const keywords: string[] = []
+
+ for (const t of def.triggers) {
+ if (t.startsWith('/') && t.endsWith('/')) {
+ // Regex pattern
+ regexes.push(new RegExp(t.slice(1, -1)))
+ } else if (t.startsWith('/')) {
+ // Regex without trailing slash (e.g. /[unicode]/)
+ regexes.push(new RegExp(t.slice(1)))
+ } else {
+ keywords.push(t.toLowerCase())
+ }
+ }
+
+ // Build a single test function combining keywords and regexes
+ matchers.push({
+ key,
+ test: (msg: string) => {
+ const lower = msg.toLowerCase()
+ if (keywords.some((kw) => lower.includes(kw))) return true
+ if (regexes.some((re) => re.test(msg))) return true
+ return false
+ },
+ })
+ }
+
+ return matchers
+}
+
+const triggerMatchers = buildTriggerMatchers()
+
+/** Detect which sections are needed based on user message content. */
+export function detectSections(
+ userMessage: string,
+ options?: {
+ hasDesignMd?: boolean
+ hasVariables?: boolean
+ isModification?: boolean
+ },
+): PromptSectionKey[] {
+ const selected = new Set()
+
+ // Modification mode: minimal context
+ if (options?.isModification) {
+ selected.add('schema')
+ if (options.hasVariables) selected.add('variables')
+ if (options.hasDesignMd) selected.add('design-md')
+ return [...selected]
+ }
+
+ // Always-on sections
+ for (const [key, def] of Object.entries(SECTION_REGISTRY) as [PromptSectionKey, SectionDef][]) {
+ if (def.always) {
+ // design-md replaces style when present
+ if (key === 'style' && options?.hasDesignMd) continue
+ selected.add(key)
+ }
+ }
+
+ // Flag-based sections
+ if (options?.hasDesignMd) selected.add('design-md')
+ if (options?.hasVariables) selected.add('variables')
+
+ // Trigger-based sections: match keywords/regex against user message
+ for (const matcher of triggerMatchers) {
+ if (matcher.test(userMessage)) {
+ selected.add(matcher.key)
+ }
+ }
+
+ // Sort by priority
+ return [...selected].sort((a, b) =>
+ (SECTION_REGISTRY[a].priority ?? 50) - (SECTION_REGISTRY[b].priority ?? 50),
+ )
+}
+
+/** Assemble selected sections into a single knowledge block string. */
+export function assembleSections(
+ keys: PromptSectionKey[],
+ designMdContent?: string,
+): string {
+ const parts: string[] = []
+ for (const key of keys) {
+ if (key === 'design-md' && designMdContent) {
+ parts.push(`DESIGN SYSTEM (design.md — follow these rules for visual consistency):\n${designMdContent}`)
+ } else {
+ const content = SECTION_REGISTRY[key].content
+ if (content) parts.push(content)
+ }
+ }
+ return parts.join('\n\n')
+}
+
+/** Build a condensed design.md style policy string for AI prompt injection. */
+export function buildDesignMdStylePolicy(spec: DesignMdSpec): string {
+ const parts: string[] = []
+
+ if (spec.visualTheme) {
+ const theme = spec.visualTheme.length > 200
+ ? spec.visualTheme.substring(0, 200) + '...'
+ : spec.visualTheme
+ parts.push(`VISUAL THEME: ${theme}`)
+ }
+
+ if (spec.colorPalette?.length) {
+ const colors = spec.colorPalette
+ .slice(0, 10)
+ .map(c => `${c.name} (${c.hex}) — ${c.role}`)
+ .join('\n- ')
+ parts.push(`COLOR PALETTE:\n- ${colors}`)
+ }
+
+ if (spec.typography?.fontFamily) {
+ parts.push(`FONT: ${spec.typography.fontFamily}`)
+ }
+ if (spec.typography?.headings) {
+ parts.push(`Headings: ${spec.typography.headings}`)
+ }
+ if (spec.typography?.body) {
+ parts.push(`Body: ${spec.typography.body}`)
+ }
+
+ if (spec.componentStyles) {
+ const styles = spec.componentStyles.length > 300
+ ? spec.componentStyles.substring(0, 300) + '...'
+ : spec.componentStyles
+ parts.push(`COMPONENT STYLES:\n${styles}`)
+ }
+
+ return parts.join('\n\n')
+}
diff --git a/src/services/ai/ai-prompts.ts b/apps/web/src/services/ai/ai-prompts.ts
similarity index 86%
rename from src/services/ai/ai-prompts.ts
rename to apps/web/src/services/ai/ai-prompts.ts
index 8088a7c5..bb784785 100644
--- a/src/services/ai/ai-prompts.ts
+++ b/apps/web/src/services/ai/ai-prompts.ts
@@ -1,4 +1,7 @@
import { AVAILABLE_FEATHER_ICONS } from './icon-resolver'
+import type { PromptSectionKey } from './ai-prompt-sections'
+import { assembleSections, buildDesignMdStylePolicy } from './ai-prompt-sections'
+import type { DesignMdSpec } from '@/types/design-md'
// Comma-separated list of all bundled Feather icons — guaranteed to resolve
// instantly from the local icon map without any network request.
@@ -11,7 +14,12 @@ PenNode types (the ONLY format you output for designs):
- ellipse: Props: width, height, fill, stroke, effects
- text: Props: content (string), fontFamily, fontSize, fontWeight, fontStyle ('normal'|'italic'), fill, width, height, textAlign, textGrowth ('auto'|'fixed-width'|'fixed-width-height'), lineHeight (number, multiplier e.g. 1.2), letterSpacing (number, px), textAlignVertical ('top'|'middle'|'bottom')
- path: SVG icon/shape. Props: d (SVG path string), width, height, fill, stroke, effects. IMPORTANT: width and height must match the natural aspect ratio of the SVG path — do NOT force 1:1 for non-square icons/logos
-- image: Raster image. Props: src (URL string), width, height, cornerRadius, effects
+- image: Raster image. Props: width, height, cornerRadius, effects, imageSearchQuery (2-3 English keywords for photo search, e.g. "burger fries", "office workspace"), imagePrompt (optional: longer descriptive phrase for AI image generation). Do NOT include src — images are auto-populated after generation.
+ imagePrompt RULES:
+ - Describe the subject, scene, style, and composition. Example: "a gourmet burger with golden fries on a rustic wooden table, warm natural lighting, top-down view"
+ - NEVER mention background type (transparent, white, plain, isolated, cutout). Images fill a frame area — background removal is unreliable across models.
+ - Match composition to aspect ratio: wide (w>1.3h) → landscape/panoramic, tall (h>1.3w) → portrait/vertical, square → centered subject.
+ - Keep prompts concise (1-2 sentences). Focus on what to show, not technical rendering instructions.
All nodes share: id (string), type, name, role, x, y, rotation, opacity
@@ -365,3 +373,94 @@ RESPONSE FORMAT:
2. ${BLOCK}json [...nodes] ${BLOCK}
3. A very brief 1-sentence confirmation of what was changed.
`
+
+// ---------------------------------------------------------------------------
+// Section-based prompt builders (progressive loading)
+// ---------------------------------------------------------------------------
+
+const CHAT_CORE_PROMPT = `You are a design assistant for OpenPencil, a vector design tool that renders PenNode JSON on a canvas.
+
+ABSOLUTE REQUIREMENT — When a user asks to create/generate/design/make ANY visual element or UI:
+You MUST output a ${BLOCK}json code block containing a valid PenNode JSON array. This is NON-NEGOTIABLE.
+Add a 1-2 sentence description AFTER the JSON block, not before.
+NEVER describe what you "would" create — ALWAYS output the actual JSON immediately.
+NEVER output HTML, CSS, or React code — ONLY PenNode JSON.
+NEVER say "I will create..." — START DIRECTLY WITH .
+
+You may include 1-2 brief tags before the JSON (optional, keep them SHORT).
+When a user asks non-design questions (explain, suggest colors, give advice), respond in text.`
+
+const GENERATOR_CORE_PROMPT = `You are a PenNode JSON streaming engine. Convert design descriptions into flat PenNode JSON, one element at a time.
+
+OUTPUT FORMAT — ELEMENT-BY-ELEMENT STREAMING:
+Each element is rendered to the canvas the INSTANT it finishes generating. Output flat JSON objects inside a single ${BLOCK}json block.
+
+STEP 1 — PLAN: List ALL planned sections as tags BEFORE the json block.
+STEP 2 — BUILD: ${BLOCK}json block with flat JSON objects, ONE PER LINE. Every node MUST have "_parent" field.
+
+CRITICAL:
+- DO NOT use nested "children" arrays — each node is a FLAT JSON object with "_parent".
+- ONE JSON object per line. Output parent before children (depth-first).
+- Root frame: "_parent": null, x:0, y:0.
+- Start with tags, then immediately the json block. NO preamble.
+- After the json block, add a 1-sentence summary.
+Design like a professional: hierarchy, contrast, whitespace, consistent palette.`
+
+/**
+ * Build a chat system prompt with only the sections needed for the user's message.
+ * Replaces the old monolithic CHAT_SYSTEM_PROMPT.
+ */
+export function buildChatSystemPrompt(
+ sections: PromptSectionKey[],
+ designMd?: DesignMdSpec,
+): string {
+ const designMdContent = designMd ? buildDesignMdStylePolicy(designMd) : undefined
+ const knowledge = assembleSections(sections, designMdContent)
+ return `${CHAT_CORE_PROMPT}\n\n${knowledge}`
+}
+
+/**
+ * Build a generator system prompt with only the sections needed.
+ * Replaces the old monolithic DESIGN_GENERATOR_PROMPT.
+ */
+export function buildGeneratorSystemPrompt(
+ sections: PromptSectionKey[],
+ designMd?: DesignMdSpec,
+): string {
+ const designMdContent = designMd ? buildDesignMdStylePolicy(designMd) : undefined
+ const knowledge = assembleSections(sections, designMdContent)
+ return `${GENERATOR_CORE_PROMPT}\n\n${knowledge}`
+}
+
+/**
+ * Build a modifier system prompt with only the sections needed.
+ * Replaces the old monolithic DESIGN_MODIFIER_PROMPT.
+ */
+export function buildModifierSystemPrompt(
+ sections: PromptSectionKey[],
+ designMd?: DesignMdSpec,
+): string {
+ const designMdContent = designMd ? buildDesignMdStylePolicy(designMd) : undefined
+ const knowledge = assembleSections(sections, designMdContent)
+ return `You are a Design Modification Engine. Your job is to UPDATE existing PenNodes based on user instructions.
+
+${knowledge}
+
+INPUT:
+1. "Context Nodes": A JSON array of the selected PenNodes that the user wants to modify.
+2. "Instruction": The user's request.
+
+OUTPUT:
+- A JSON code block containing ONLY the modified PenNodes.
+- You MUST return the nodes with the SAME IDs as the input.
+- You MAY add/remove children if implied.
+
+RULES:
+- PRESERVE IDs. PARTIAL UPDATES OK. DO NOT CHANGE UNRELATED PROPS.
+
+RESPONSE FORMAT:
+1. ...
+2. ...
+3. ${BLOCK}json [...nodes] ${BLOCK}
+4. A very brief 1-sentence confirmation.`
+}
diff --git a/src/services/ai/ai-runtime-config.ts b/apps/web/src/services/ai/ai-runtime-config.ts
similarity index 100%
rename from src/services/ai/ai-runtime-config.ts
rename to apps/web/src/services/ai/ai-runtime-config.ts
diff --git a/src/services/ai/ai-service.ts b/apps/web/src/services/ai/ai-service.ts
similarity index 100%
rename from src/services/ai/ai-service.ts
rename to apps/web/src/services/ai/ai-service.ts
diff --git a/src/services/ai/ai-types.ts b/apps/web/src/services/ai/ai-types.ts
similarity index 98%
rename from src/services/ai/ai-types.ts
rename to apps/web/src/services/ai/ai-types.ts
index e9f7ec5d..02498f86 100644
--- a/src/services/ai/ai-types.ts
+++ b/apps/web/src/services/ai/ai-types.ts
@@ -1,4 +1,5 @@
import type { AIProviderType } from '@/types/agent-settings'
+import type { DesignMdSpec } from '@/types/design-md'
export interface ChatAttachment {
id: string
@@ -28,6 +29,7 @@ export interface AIDesignRequest {
canvasSize?: { width: number; height: number }
variables?: Record
themes?: Record
+ designMd?: DesignMdSpec
}
}
diff --git a/src/services/ai/context-optimizer.ts b/apps/web/src/services/ai/context-optimizer.ts
similarity index 100%
rename from src/services/ai/context-optimizer.ts
rename to apps/web/src/services/ai/context-optimizer.ts
diff --git a/src/services/ai/design-animation.ts b/apps/web/src/services/ai/design-animation.ts
similarity index 100%
rename from src/services/ai/design-animation.ts
rename to apps/web/src/services/ai/design-animation.ts
diff --git a/src/services/ai/design-canvas-ops.ts b/apps/web/src/services/ai/design-canvas-ops.ts
similarity index 96%
rename from src/services/ai/design-canvas-ops.ts
rename to apps/web/src/services/ai/design-canvas-ops.ts
index 684e2024..881d6a4a 100644
--- a/src/services/ai/design-canvas-ops.ts
+++ b/apps/web/src/services/ai/design-canvas-ops.ts
@@ -24,6 +24,7 @@ import type { RoleContext } from './role-resolver'
// Trigger side-effect registration of all role definitions
import './role-definitions'
import { extractJsonFromResponse } from './design-parser'
+import { scanAndFillImages, enqueueImageForSearch, resetImageSearchQueue } from './image-search-pipeline'
import {
deepCloneNode,
mergeNodeForProgressiveUpsert,
@@ -55,6 +56,8 @@ export function resetGenerationRemapping(): void {
generationRootFrameId = DEFAULT_FRAME_ID
// Snapshot all existing node IDs so upsert can avoid collisions
preExistingNodeIds = new Set(useDocumentStore.getState().getFlatNodes().map((n) => n.id))
+ // Reset incremental image search queue for the new generation
+ resetImageSearchQueue()
}
export function setGenerationContextHint(hint?: string): void {
@@ -98,7 +101,22 @@ export function getGenerationRemappedIds(): Map {
*/
function normalizeNodeFills(node: PenNode): void {
const fills = 'fill' in node ? (node as { fill?: unknown }).fill : undefined
+
+ // Convert string shorthand (e.g. "#000000") to PenFill array
+ if (typeof fills === 'string') {
+ ;(node as unknown as Record).fill = [{ type: 'solid', color: fills }]
+ return
+ }
+
if (!Array.isArray(fills)) return
+
+ // Convert any string elements in the array to solid fill objects
+ for (let i = 0; i < fills.length; i++) {
+ if (typeof fills[i] === 'string') {
+ fills[i] = { type: 'solid', color: fills[i] }
+ }
+ }
+
for (const fill of fills) {
if (!fill || typeof fill !== 'object') continue
const f = fill as { type?: string; stops?: unknown[] }
@@ -285,6 +303,11 @@ export function insertStreamingNode(
expandRootFrameHeight()
}
}
+
+ // Immediately enqueue image nodes for background search as they arrive
+ if (node.type === 'image') {
+ enqueueImageForSearch(node)
+ }
}
// ---------------------------------------------------------------------------
@@ -300,6 +323,8 @@ export function applyNodesToCanvas(nodes: PenNode[]): void {
if (isCanvasOnlyEmptyFrame() && preparedNodes.length === 1 && preparedNodes[0].type === 'frame') {
replaceEmptyFrame(preparedNodes[0])
resolveAllPendingIcons().catch(console.warn)
+ const rootId = getGenerationRootFrameId()
+ if (rootId) scanAndFillImages(rootId).catch(() => {})
return
}
@@ -312,6 +337,8 @@ export function applyNodesToCanvas(nodes: PenNode[]): void {
}
adjustRootFrameHeightToContent()
resolveAllPendingIcons().catch(console.warn)
+ const rootId = getGenerationRootFrameId()
+ if (rootId) scanAndFillImages(rootId).catch(() => {})
}
export function upsertNodesToCanvas(nodes: PenNode[]): number {
@@ -342,6 +369,8 @@ export function upsertNodesToCanvas(nodes: PenNode[]): number {
}
adjustRootFrameHeightToContent()
+ const rootId = getGenerationRootFrameId()
+ if (rootId) scanAndFillImages(rootId).catch(() => {})
return count
}
@@ -393,6 +422,8 @@ export function animateNodesToCanvas(nodes: PenNode[]): void {
// Resolve any icons queued for async (brand logos etc.) after nodes are in the store
resolveAllPendingIcons().catch(console.warn)
+ const rootId = getGenerationRootFrameId()
+ if (rootId) scanAndFillImages(rootId).catch(() => {})
}
// ---------------------------------------------------------------------------
diff --git a/src/services/ai/design-code-generator.ts b/apps/web/src/services/ai/design-code-generator.ts
similarity index 100%
rename from src/services/ai/design-code-generator.ts
rename to apps/web/src/services/ai/design-code-generator.ts
diff --git a/src/services/ai/design-code-prompts.ts b/apps/web/src/services/ai/design-code-prompts.ts
similarity index 100%
rename from src/services/ai/design-code-prompts.ts
rename to apps/web/src/services/ai/design-code-prompts.ts
diff --git a/src/services/ai/design-generator.ts b/apps/web/src/services/ai/design-generator.ts
similarity index 90%
rename from src/services/ai/design-generator.ts
rename to apps/web/src/services/ai/design-generator.ts
index 34651b0c..ea21b50c 100644
--- a/src/services/ai/design-generator.ts
+++ b/apps/web/src/services/ai/design-generator.ts
@@ -1,9 +1,11 @@
import type { PenNode } from '@/types/pen'
import type { VariableDefinition, ThemedValue } from '@/types/variables'
import type { AIProviderType } from '@/types/agent-settings'
+import type { DesignMdSpec } from '@/types/design-md'
import type { AIDesignRequest } from './ai-types'
import { streamChat } from './ai-service'
-import { DESIGN_MODIFIER_PROMPT } from './ai-prompts'
+import { buildModifierSystemPrompt } from './ai-prompts'
+import { detectSections } from './ai-prompt-sections'
import { executeOrchestration } from './orchestrator'
import { DESIGN_STREAM_TIMEOUTS } from './ai-runtime-config'
import { extractJsonFromResponse } from './design-parser'
@@ -94,6 +96,7 @@ export async function generateDesignModification(
options?: {
variables?: Record
themes?: Record
+ designMd?: DesignMdSpec
model?: string
provider?: AIProviderType
},
@@ -119,7 +122,15 @@ export async function generateDesignModification(
const profile = resolveModelProfile(options?.model)
const timeouts = applyProfileToTimeouts({ ...DESIGN_STREAM_TIMEOUTS }, profile)
- for await (const chunk of streamChat(DESIGN_MODIFIER_PROMPT, [
+ // Progressive section loading for modification prompts
+ const modSections = detectSections(instruction, {
+ hasDesignMd: !!options?.designMd,
+ hasVariables: !!options?.variables && Object.keys(options.variables).length > 0,
+ isModification: true,
+ })
+ const modifierPrompt = buildModifierSystemPrompt(modSections, options?.designMd)
+
+ for await (const chunk of streamChat(modifierPrompt, [
{ role: 'user', content: userMessage },
], options?.model, timeouts, options?.provider, abortSignal)) {
if (chunk.type === 'thinking') {
diff --git a/src/services/ai/design-node-sanitization.ts b/apps/web/src/services/ai/design-node-sanitization.ts
similarity index 89%
rename from src/services/ai/design-node-sanitization.ts
rename to apps/web/src/services/ai/design-node-sanitization.ts
index 91c87369..e3f403d6 100644
--- a/src/services/ai/design-node-sanitization.ts
+++ b/apps/web/src/services/ai/design-node-sanitization.ts
@@ -1,13 +1,8 @@
import type { PenNode } from '@/types/pen'
import { clamp } from './generation-utils'
-
-// ---------------------------------------------------------------------------
-// Deep clone
-// ---------------------------------------------------------------------------
-
-export function deepCloneNode(node: PenNode): PenNode {
- return JSON.parse(JSON.stringify(node)) as PenNode
-}
+export { isBadgeOverlayNode } from '@/canvas/node-helpers'
+import { isBadgeOverlayNode } from '@/canvas/node-helpers'
+export { deepCloneNode } from '@/stores/document-tree-utils'
// ---------------------------------------------------------------------------
// Children helpers
@@ -69,18 +64,7 @@ export function hasActiveLayout(node: PenNode): boolean {
return node.layout === 'vertical' || node.layout === 'horizontal'
}
-/**
- * Check if a node is a badge/overlay that should use absolute positioning
- * instead of participating in the parent's layout flow.
- */
-export function isBadgeOverlayNode(node: PenNode): boolean {
- if ('role' in node) {
- const role = (node as { role?: string }).role
- if (role === 'badge' || role === 'pill' || role === 'tag') return true
- }
- const name = (node.name ?? '').toLowerCase()
- return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name)
-}
+// isBadgeOverlayNode moved to @/canvas/node-helpers — re-exported above
export function sanitizeLayoutChildPositions(
node: PenNode,
diff --git a/src/services/ai/design-parser.ts b/apps/web/src/services/ai/design-parser.ts
similarity index 100%
rename from src/services/ai/design-parser.ts
rename to apps/web/src/services/ai/design-parser.ts
diff --git a/src/services/ai/design-pre-validation.ts b/apps/web/src/services/ai/design-pre-validation.ts
similarity index 100%
rename from src/services/ai/design-pre-validation.ts
rename to apps/web/src/services/ai/design-pre-validation.ts
diff --git a/src/services/ai/design-principles/color.ts b/apps/web/src/services/ai/design-principles/color.ts
similarity index 100%
rename from src/services/ai/design-principles/color.ts
rename to apps/web/src/services/ai/design-principles/color.ts
diff --git a/src/services/ai/design-principles/components.ts b/apps/web/src/services/ai/design-principles/components.ts
similarity index 100%
rename from src/services/ai/design-principles/components.ts
rename to apps/web/src/services/ai/design-principles/components.ts
diff --git a/src/services/ai/design-principles/composition.ts b/apps/web/src/services/ai/design-principles/composition.ts
similarity index 100%
rename from src/services/ai/design-principles/composition.ts
rename to apps/web/src/services/ai/design-principles/composition.ts
diff --git a/src/services/ai/design-principles/index.ts b/apps/web/src/services/ai/design-principles/index.ts
similarity index 100%
rename from src/services/ai/design-principles/index.ts
rename to apps/web/src/services/ai/design-principles/index.ts
diff --git a/src/services/ai/design-principles/spacing.ts b/apps/web/src/services/ai/design-principles/spacing.ts
similarity index 100%
rename from src/services/ai/design-principles/spacing.ts
rename to apps/web/src/services/ai/design-principles/spacing.ts
diff --git a/src/services/ai/design-principles/typography.ts b/apps/web/src/services/ai/design-principles/typography.ts
similarity index 100%
rename from src/services/ai/design-principles/typography.ts
rename to apps/web/src/services/ai/design-principles/typography.ts
diff --git a/src/services/ai/design-screenshot.ts b/apps/web/src/services/ai/design-screenshot.ts
similarity index 100%
rename from src/services/ai/design-screenshot.ts
rename to apps/web/src/services/ai/design-screenshot.ts
diff --git a/src/services/ai/design-system-generator.ts b/apps/web/src/services/ai/design-system-generator.ts
similarity index 100%
rename from src/services/ai/design-system-generator.ts
rename to apps/web/src/services/ai/design-system-generator.ts
diff --git a/src/services/ai/design-system-prompts.ts b/apps/web/src/services/ai/design-system-prompts.ts
similarity index 100%
rename from src/services/ai/design-system-prompts.ts
rename to apps/web/src/services/ai/design-system-prompts.ts
diff --git a/src/services/ai/design-type-presets.ts b/apps/web/src/services/ai/design-type-presets.ts
similarity index 100%
rename from src/services/ai/design-type-presets.ts
rename to apps/web/src/services/ai/design-type-presets.ts
diff --git a/src/services/ai/design-validation-fixes.ts b/apps/web/src/services/ai/design-validation-fixes.ts
similarity index 100%
rename from src/services/ai/design-validation-fixes.ts
rename to apps/web/src/services/ai/design-validation-fixes.ts
diff --git a/src/services/ai/design-validation.ts b/apps/web/src/services/ai/design-validation.ts
similarity index 100%
rename from src/services/ai/design-validation.ts
rename to apps/web/src/services/ai/design-validation.ts
diff --git a/src/services/ai/generation-utils.ts b/apps/web/src/services/ai/generation-utils.ts
similarity index 100%
rename from src/services/ai/generation-utils.ts
rename to apps/web/src/services/ai/generation-utils.ts
diff --git a/src/services/ai/html-renderer.ts b/apps/web/src/services/ai/html-renderer.ts
similarity index 100%
rename from src/services/ai/html-renderer.ts
rename to apps/web/src/services/ai/html-renderer.ts
diff --git a/src/services/ai/icon-resolver.ts b/apps/web/src/services/ai/icon-resolver.ts
similarity index 100%
rename from src/services/ai/icon-resolver.ts
rename to apps/web/src/services/ai/icon-resolver.ts
diff --git a/apps/web/src/services/ai/image-search-pipeline.ts b/apps/web/src/services/ai/image-search-pipeline.ts
new file mode 100644
index 00000000..04270f90
--- /dev/null
+++ b/apps/web/src/services/ai/image-search-pipeline.ts
@@ -0,0 +1,169 @@
+import { useCanvasStore } from '@/stores/canvas-store'
+import { useDocumentStore } from '@/stores/document-store'
+import { useAgentSettingsStore } from '@/stores/agent-settings-store'
+import { forcePageResync } from '@/canvas/canvas-sync-utils'
+import type { PenNode, ImageNode } from '@/types/pen'
+
+export function inferAspectRatio(
+ node: PenNode,
+): 'wide' | 'tall' | 'square' | undefined {
+ const n = node as unknown as Record
+ const w = typeof n['width'] === 'number' ? (n['width'] as number) : 0
+ const h = typeof n['height'] === 'number' ? (n['height'] as number) : 0
+ if (!w || !h) return undefined
+ const ratio = w / h
+ if (ratio > 1.3) return 'wide'
+ if (ratio < 0.77) return 'tall'
+ return 'square'
+}
+
+export function collectImageNodes(rootId: string): ImageNode[] {
+ const { getNodeById } = useDocumentStore.getState()
+ const root = getNodeById(rootId)
+ if (!root) return []
+
+ const images: ImageNode[] = []
+ const walk = (node: PenNode) => {
+ if (node.type === 'image') images.push(node)
+ if ('children' in node && Array.isArray(node.children)) {
+ for (const child of node.children) walk(child)
+ }
+ }
+ walk(root)
+ return images
+}
+
+// Only match the known phone placeholder prefix, not user-uploaded SVGs
+const PHONE_PLACEHOLDER_PREFIX = 'data:image/svg+xml;charset=utf-8,%3Csvg'
+
+function isPlaceholderSrc(src?: string): boolean {
+ return !src || src.startsWith(PHONE_PLACEHOLDER_PREFIX)
+}
+
+// ---------------------------------------------------------------------------
+// Incremental queue-based image search
+// ---------------------------------------------------------------------------
+
+interface QueuedImage {
+ id: string
+ query: string
+ aspect: 'wide' | 'tall' | 'square' | undefined
+}
+
+const imageSearchQueue: QueuedImage[] = []
+// Track IDs already queued or processed to avoid duplicates
+const queuedNodeIds = new Set()
+let queueProcessing = false
+let queueAbort: AbortController | null = null
+
+/**
+ * Enqueue a single image node for background search.
+ * Called from insertStreamingNode as soon as an image node hits the canvas.
+ */
+export function enqueueImageForSearch(node: PenNode): void {
+ if (node.type !== 'image') return
+ const imgNode = node as ImageNode
+ if (!isPlaceholderSrc(imgNode.src)) return
+ if (queuedNodeIds.has(node.id)) return
+
+ queuedNodeIds.add(node.id)
+ const query = imgNode.imageSearchQuery ?? imgNode.name ?? 'placeholder'
+ const aspect = inferAspectRatio(node)
+
+ imageSearchQueue.push({ id: node.id, query, aspect })
+ useCanvasStore.getState().setImageSearchStatus(node.id, 'pending')
+
+ // Start processing if not already running
+ processQueue()
+}
+
+/**
+ * Reset the queue state. Call when a new generation starts.
+ */
+export function resetImageSearchQueue(): void {
+ queueAbort?.abort()
+ queueAbort = null
+ imageSearchQueue.length = 0
+ queuedNodeIds.clear()
+ queueProcessing = false
+}
+
+async function processQueue(): Promise {
+ if (queueProcessing) return
+ queueProcessing = true
+
+ if (!queueAbort) queueAbort = new AbortController()
+ const abort = queueAbort
+
+ const { updateNode } = useDocumentStore.getState()
+ const { setImageSearchStatus } = useCanvasStore.getState()
+ const { openverseOAuth } = useAgentSettingsStore.getState()
+
+ while (imageSearchQueue.length > 0) {
+ if (abort.signal.aborted) break
+ const item = imageSearchQueue.shift()!
+
+ // Re-check that the node still has a placeholder (may have been filled by user)
+ const currentNode = useDocumentStore.getState().getNodeById(item.id)
+ if (!currentNode || currentNode.type !== 'image' || !isPlaceholderSrc((currentNode as ImageNode).src)) {
+ queuedNodeIds.delete(item.id)
+ continue
+ }
+
+ try {
+ const res = await fetch('/api/ai/image-search', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query: item.query,
+ count: 1,
+ aspectRatio: item.aspect,
+ ...(openverseOAuth && {
+ openverseClientId: openverseOAuth.clientId,
+ openverseClientSecret: openverseOAuth.clientSecret,
+ }),
+ }),
+ signal: abort.signal,
+ })
+ const data = await res.json()
+ if (data.results?.length > 0) {
+ updateNode(item.id, { src: data.results[0].thumbUrl })
+ setImageSearchStatus(item.id, 'found')
+ } else {
+ setImageSearchStatus(item.id, 'failed')
+ }
+ } catch {
+ if (!abort.signal.aborted) {
+ setImageSearchStatus(item.id, 'failed')
+ }
+ }
+
+ // Rate limit: 3s between requests to stay under Openverse 20/min burst
+ if (!abort.signal.aborted && imageSearchQueue.length > 0) {
+ await new Promise((r) => setTimeout(r, 3000))
+ }
+ }
+
+ queueProcessing = false
+ if (!abort.signal.aborted) {
+ forcePageResync()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Batch scan — final sweep to catch any missed placeholder images
+// ---------------------------------------------------------------------------
+
+export async function scanAndFillImages(rootId: string): Promise {
+ const imageNodes = collectImageNodes(rootId)
+ const needsFill = imageNodes.filter(
+ (n) => isPlaceholderSrc(n.src) && !queuedNodeIds.has(n.id),
+ )
+
+ if (needsFill.length === 0) return
+
+ // Enqueue any remaining unfilled nodes — the queue processor handles the rest
+ for (const node of needsFill) {
+ enqueueImageForSearch(node)
+ }
+}
diff --git a/src/services/ai/model-profiles.ts b/apps/web/src/services/ai/model-profiles.ts
similarity index 90%
rename from src/services/ai/model-profiles.ts
rename to apps/web/src/services/ai/model-profiles.ts
index dc331246..5ea83479 100644
--- a/src/services/ai/model-profiles.ts
+++ b/apps/web/src/services/ai/model-profiles.ts
@@ -32,6 +32,11 @@ const MODEL_PROFILES: ModelProfile[] = [
{ match: 'o1', tier: 'standard', thinkingMode: 'disabled', label: 'o1' },
{ match: 'o3', tier: 'standard', thinkingMode: 'disabled', label: 'o3' },
{ match: 'o4', tier: 'standard', thinkingMode: 'disabled', label: 'o4' },
+ { match: 'gemini-3-pro', tier: 'full', thinkingMode: 'disabled', label: 'Gemini 3 Pro' },
+ { match: 'gemini-3-flash', tier: 'standard', thinkingMode: 'disabled', label: 'Gemini 3 Flash' },
+ { match: /^gemini-3/, tier: 'full', thinkingMode: 'disabled', label: 'Gemini 3' },
+ { match: 'gemini-2.5-pro', tier: 'full', thinkingMode: 'disabled', label: 'Gemini 2.5 Pro' },
+ { match: 'gemini-2.5-flash', tier: 'standard', thinkingMode: 'disabled', label: 'Gemini 2.5 Flash' },
{ match: 'gemini-pro', tier: 'standard', thinkingMode: 'disabled', label: 'Gemini Pro' },
{ match: /^gemini-2/, tier: 'standard', thinkingMode: 'disabled', label: 'Gemini 2' },
{ match: 'deepseek', tier: 'standard', thinkingMode: 'disabled', label: 'DeepSeek' },
diff --git a/src/services/ai/orchestrator-progress.ts b/apps/web/src/services/ai/orchestrator-progress.ts
similarity index 100%
rename from src/services/ai/orchestrator-progress.ts
rename to apps/web/src/services/ai/orchestrator-progress.ts
diff --git a/src/services/ai/orchestrator-prompt-optimizer.ts b/apps/web/src/services/ai/orchestrator-prompt-optimizer.ts
similarity index 100%
rename from src/services/ai/orchestrator-prompt-optimizer.ts
rename to apps/web/src/services/ai/orchestrator-prompt-optimizer.ts
diff --git a/src/services/ai/orchestrator-prompts.ts b/apps/web/src/services/ai/orchestrator-prompts.ts
similarity index 98%
rename from src/services/ai/orchestrator-prompts.ts
rename to apps/web/src/services/ai/orchestrator-prompts.ts
index 18a6b1f1..65e16701 100644
--- a/src/services/ai/orchestrator-prompts.ts
+++ b/apps/web/src/services/ai/orchestrator-prompts.ts
@@ -60,7 +60,7 @@ const BLOCK = "```"
export const SUB_AGENT_PROMPT = `PenNode flat JSONL engine. Output a ${BLOCK}json block with ONE node per line.
TYPES:
-frame (width,height,layout,gap,padding,justifyContent,alignItems,clipContent,cornerRadius,fill,stroke,effects), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,textAlign,textGrowth,lineHeight,letterSpacing), icon_font (iconFontName,width,height,fill), path (d,width,height,fill,stroke), image (src,width,height)
+frame (width,height,layout,gap,padding,justifyContent,alignItems,clipContent,cornerRadius,fill,stroke,effects), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,textAlign,textGrowth,lineHeight,letterSpacing), icon_font (iconFontName,width,height,fill), path (d,width,height,fill,stroke), image (width,height,imageSearchQuery,imagePrompt). imagePrompt: describe subject+scene+style, NEVER mention background type (transparent/white/plain). Match composition to aspect ratio.
SHARED: id, type, name, role, x, y, opacity
ROLES: section, row, column, divider | navbar, button, icon-button, badge, input, search-bar | card, stat-card, pricing-card, feature-card | heading, subheading, body-text, caption, label | table, table-row, table-header
width/height: number | "fill_container" | "fit_content". padding: number | [v,h] | [T,R,B,L]. Fill=[{"type":"solid","color":"#hex"}].
diff --git a/src/services/ai/orchestrator-sub-agent.ts b/apps/web/src/services/ai/orchestrator-sub-agent.ts
similarity index 97%
rename from src/services/ai/orchestrator-sub-agent.ts
rename to apps/web/src/services/ai/orchestrator-sub-agent.ts
index fa854388..b15604fa 100644
--- a/src/services/ai/orchestrator-sub-agent.ts
+++ b/apps/web/src/services/ai/orchestrator-sub-agent.ts
@@ -10,6 +10,7 @@
import type { PenNode } from '@/types/pen'
import type { VariableDefinition } from '@/types/variables'
+import type { DesignMdSpec } from '@/types/design-md'
import type {
AIDesignRequest,
OrchestratorPlan,
@@ -240,6 +241,7 @@ async function executeSubAgent(
request.prompt,
request.context?.variables,
request.context?.themes,
+ request.context?.designMd,
)
const basePrompt = SUB_AGENT_PROMPT
@@ -414,6 +416,7 @@ function buildSubAgentUserPrompt(
fullPrompt: string,
variables?: Record,
themes?: Record,
+ designMd?: DesignMdSpec,
): string {
const { region } = subtask
@@ -467,8 +470,17 @@ CRITICAL LAYOUT CONSTRAINTS:
- Only use stacked layout for mobile/narrow viewport sections.`
}
- // Inject style guide so sub-agent uses consistent colors/fonts
- if (plan.styleGuide) {
+ // Inject design.md style OR orchestrator style guide
+ if (designMd?.colorPalette?.length) {
+ const colors = designMd.colorPalette
+ .slice(0, 8)
+ .map(c => `${c.name} (${c.hex}) — ${c.role}`)
+ .join('\n- ')
+ prompt += `\n\nDESIGN SYSTEM (from design.md — use these consistently):\n- ${colors}`
+ if (designMd.typography?.fontFamily) {
+ prompt += `\nFont: ${designMd.typography.fontFamily}`
+ }
+ } else if (plan.styleGuide) {
const sg = plan.styleGuide
const p = sg.palette
prompt += `\n\nSTYLE GUIDE (use these consistently):
diff --git a/src/services/ai/orchestrator.ts b/apps/web/src/services/ai/orchestrator.ts
similarity index 98%
rename from src/services/ai/orchestrator.ts
rename to apps/web/src/services/ai/orchestrator.ts
index 87b94c4c..65c01d16 100644
--- a/src/services/ai/orchestrator.ts
+++ b/apps/web/src/services/ai/orchestrator.ts
@@ -40,6 +40,7 @@ import { zoomToFitContent } from '@/canvas/skia-engine-ref'
import { resetAnimationState } from './design-animation'
import { VALIDATION_ENABLED } from './ai-runtime-config'
import { runPostGenerationValidation } from './design-validation'
+import { scanAndFillImages } from './image-search-pipeline'
import { executeSubAgents } from './orchestrator-sub-agent'
import { emitProgress, buildFinalStepTags } from './orchestrator-progress'
import { assignAgentIdentities } from './agent-identity'
@@ -410,6 +411,10 @@ export async function executeOrchestration(
emitProgress(plan, progress, callbacks)
}
+ // Auto-fill image nodes with search results (fire-and-forget)
+ const rootId = getGenerationRootFrameId()
+ if (rootId) scanAndFillImages(rootId).catch(() => {})
+
// Build final rawResponse that includes step tags so the chat message
// shows the complete pipeline progress after streaming ends
const finalStepTags = buildFinalStepTags(plan, progress)
diff --git a/src/services/ai/role-definitions/content.ts b/apps/web/src/services/ai/role-definitions/content.ts
similarity index 100%
rename from src/services/ai/role-definitions/content.ts
rename to apps/web/src/services/ai/role-definitions/content.ts
diff --git a/src/services/ai/role-definitions/display.ts b/apps/web/src/services/ai/role-definitions/display.ts
similarity index 100%
rename from src/services/ai/role-definitions/display.ts
rename to apps/web/src/services/ai/role-definitions/display.ts
diff --git a/src/services/ai/role-definitions/index.ts b/apps/web/src/services/ai/role-definitions/index.ts
similarity index 100%
rename from src/services/ai/role-definitions/index.ts
rename to apps/web/src/services/ai/role-definitions/index.ts
diff --git a/src/services/ai/role-definitions/interactive.ts b/apps/web/src/services/ai/role-definitions/interactive.ts
similarity index 100%
rename from src/services/ai/role-definitions/interactive.ts
rename to apps/web/src/services/ai/role-definitions/interactive.ts
diff --git a/src/services/ai/role-definitions/layout.ts b/apps/web/src/services/ai/role-definitions/layout.ts
similarity index 100%
rename from src/services/ai/role-definitions/layout.ts
rename to apps/web/src/services/ai/role-definitions/layout.ts
diff --git a/src/services/ai/role-definitions/media.ts b/apps/web/src/services/ai/role-definitions/media.ts
similarity index 100%
rename from src/services/ai/role-definitions/media.ts
rename to apps/web/src/services/ai/role-definitions/media.ts
diff --git a/src/services/ai/role-definitions/navigation.ts b/apps/web/src/services/ai/role-definitions/navigation.ts
similarity index 100%
rename from src/services/ai/role-definitions/navigation.ts
rename to apps/web/src/services/ai/role-definitions/navigation.ts
diff --git a/src/services/ai/role-definitions/table.ts b/apps/web/src/services/ai/role-definitions/table.ts
similarity index 100%
rename from src/services/ai/role-definitions/table.ts
rename to apps/web/src/services/ai/role-definitions/table.ts
diff --git a/src/services/ai/role-definitions/typography.ts b/apps/web/src/services/ai/role-definitions/typography.ts
similarity index 100%
rename from src/services/ai/role-definitions/typography.ts
rename to apps/web/src/services/ai/role-definitions/typography.ts
diff --git a/src/services/ai/role-resolver.ts b/apps/web/src/services/ai/role-resolver.ts
similarity index 100%
rename from src/services/ai/role-resolver.ts
rename to apps/web/src/services/ai/role-resolver.ts
diff --git a/src/services/ai/visual-ref-orchestrator.ts b/apps/web/src/services/ai/visual-ref-orchestrator.ts
similarity index 100%
rename from src/services/ai/visual-ref-orchestrator.ts
rename to apps/web/src/services/ai/visual-ref-orchestrator.ts
diff --git a/apps/web/src/services/codegen/compose-generator.ts b/apps/web/src/services/codegen/compose-generator.ts
new file mode 100644
index 00000000..422f01d4
--- /dev/null
+++ b/apps/web/src/services/codegen/compose-generator.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-codegen — the canonical source
+export { generateComposeCode, generateComposeFromDocument } from '@zseven-w/pen-codegen'
diff --git a/apps/web/src/services/codegen/css-variables-generator.ts b/apps/web/src/services/codegen/css-variables-generator.ts
new file mode 100644
index 00000000..d2c962b8
--- /dev/null
+++ b/apps/web/src/services/codegen/css-variables-generator.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-codegen — the canonical source
+export { variableNameToCSS, generateCSSVariables } from '@zseven-w/pen-codegen'
diff --git a/apps/web/src/services/codegen/flutter-generator.ts b/apps/web/src/services/codegen/flutter-generator.ts
new file mode 100644
index 00000000..7a3980fd
--- /dev/null
+++ b/apps/web/src/services/codegen/flutter-generator.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-codegen — the canonical source
+export { generateFlutterCode, generateFlutterFromDocument } from '@zseven-w/pen-codegen'
diff --git a/apps/web/src/services/codegen/html-generator.ts b/apps/web/src/services/codegen/html-generator.ts
new file mode 100644
index 00000000..ad3c782d
--- /dev/null
+++ b/apps/web/src/services/codegen/html-generator.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-codegen — the canonical source
+export { generateHTMLCode, generateHTMLFromDocument } from '@zseven-w/pen-codegen'
diff --git a/apps/web/src/services/codegen/react-generator.ts b/apps/web/src/services/codegen/react-generator.ts
new file mode 100644
index 00000000..9b97c839
--- /dev/null
+++ b/apps/web/src/services/codegen/react-generator.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-codegen — the canonical source
+export { generateReactCode, generateReactFromDocument } from '@zseven-w/pen-codegen'
diff --git a/apps/web/src/services/codegen/react-native-generator.ts b/apps/web/src/services/codegen/react-native-generator.ts
new file mode 100644
index 00000000..637b66be
--- /dev/null
+++ b/apps/web/src/services/codegen/react-native-generator.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-codegen — the canonical source
+export { generateReactNativeCode, generateReactNativeFromDocument } from '@zseven-w/pen-codegen'
diff --git a/apps/web/src/services/codegen/svelte-generator.ts b/apps/web/src/services/codegen/svelte-generator.ts
new file mode 100644
index 00000000..0676e01f
--- /dev/null
+++ b/apps/web/src/services/codegen/svelte-generator.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-codegen — the canonical source
+export { generateSvelteCode, generateSvelteFromDocument } from '@zseven-w/pen-codegen'
diff --git a/apps/web/src/services/codegen/swiftui-generator.ts b/apps/web/src/services/codegen/swiftui-generator.ts
new file mode 100644
index 00000000..00c8e6c1
--- /dev/null
+++ b/apps/web/src/services/codegen/swiftui-generator.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-codegen — the canonical source
+export { generateSwiftUICode, generateSwiftUIFromDocument } from '@zseven-w/pen-codegen'
diff --git a/apps/web/src/services/codegen/vue-generator.ts b/apps/web/src/services/codegen/vue-generator.ts
new file mode 100644
index 00000000..5ee8868e
--- /dev/null
+++ b/apps/web/src/services/codegen/vue-generator.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-codegen — the canonical source
+export { generateVueCode, generateVueFromDocument } from '@zseven-w/pen-codegen'
diff --git a/apps/web/src/services/figma/fig-parser.ts b/apps/web/src/services/figma/fig-parser.ts
new file mode 100644
index 00000000..afbd4f3e
--- /dev/null
+++ b/apps/web/src/services/figma/fig-parser.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-figma — the canonical source
+export { parseFigFile } from '@zseven-w/pen-figma'
diff --git a/apps/web/src/services/figma/figma-clipboard.ts b/apps/web/src/services/figma/figma-clipboard.ts
new file mode 100644
index 00000000..e75cd742
--- /dev/null
+++ b/apps/web/src/services/figma/figma-clipboard.ts
@@ -0,0 +1,6 @@
+// Re-export from @zseven-w/pen-figma — the canonical source
+export {
+ isFigmaClipboardHtml,
+ extractFigmaClipboardData,
+ figmaClipboardToNodes,
+} from '@zseven-w/pen-figma'
diff --git a/src/services/figma/figma-color-utils.ts b/apps/web/src/services/figma/figma-color-utils.ts
similarity index 100%
rename from src/services/figma/figma-color-utils.ts
rename to apps/web/src/services/figma/figma-color-utils.ts
diff --git a/src/services/figma/figma-effect-mapper.ts b/apps/web/src/services/figma/figma-effect-mapper.ts
similarity index 100%
rename from src/services/figma/figma-effect-mapper.ts
rename to apps/web/src/services/figma/figma-effect-mapper.ts
diff --git a/src/services/figma/figma-fill-mapper.ts b/apps/web/src/services/figma/figma-fill-mapper.ts
similarity index 100%
rename from src/services/figma/figma-fill-mapper.ts
rename to apps/web/src/services/figma/figma-fill-mapper.ts
diff --git a/apps/web/src/services/figma/figma-image-resolver.ts b/apps/web/src/services/figma/figma-image-resolver.ts
new file mode 100644
index 00000000..f04339cb
--- /dev/null
+++ b/apps/web/src/services/figma/figma-image-resolver.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-figma — the canonical source
+export { resolveImageBlobs } from '@zseven-w/pen-figma'
diff --git a/src/services/figma/figma-layout-mapper.ts b/apps/web/src/services/figma/figma-layout-mapper.ts
similarity index 100%
rename from src/services/figma/figma-layout-mapper.ts
rename to apps/web/src/services/figma/figma-layout-mapper.ts
diff --git a/apps/web/src/services/figma/figma-node-mapper.ts b/apps/web/src/services/figma/figma-node-mapper.ts
new file mode 100644
index 00000000..00a2a075
--- /dev/null
+++ b/apps/web/src/services/figma/figma-node-mapper.ts
@@ -0,0 +1,7 @@
+// Re-export from @zseven-w/pen-figma — the canonical source
+export {
+ figmaToPenDocument,
+ figmaAllPagesToPenDocument,
+ getFigmaPages,
+ figmaNodeChangesToPenNodes,
+} from '@zseven-w/pen-figma'
diff --git a/src/services/figma/figma-stroke-mapper.ts b/apps/web/src/services/figma/figma-stroke-mapper.ts
similarity index 100%
rename from src/services/figma/figma-stroke-mapper.ts
rename to apps/web/src/services/figma/figma-stroke-mapper.ts
diff --git a/src/services/figma/figma-text-mapper.ts b/apps/web/src/services/figma/figma-text-mapper.ts
similarity index 100%
rename from src/services/figma/figma-text-mapper.ts
rename to apps/web/src/services/figma/figma-text-mapper.ts
diff --git a/src/services/figma/figma-tree-builder.ts b/apps/web/src/services/figma/figma-tree-builder.ts
similarity index 100%
rename from src/services/figma/figma-tree-builder.ts
rename to apps/web/src/services/figma/figma-tree-builder.ts
diff --git a/src/services/figma/figma-types.ts b/apps/web/src/services/figma/figma-types.ts
similarity index 100%
rename from src/services/figma/figma-types.ts
rename to apps/web/src/services/figma/figma-types.ts
diff --git a/src/services/figma/figma-vector-decoder.ts b/apps/web/src/services/figma/figma-vector-decoder.ts
similarity index 100%
rename from src/services/figma/figma-vector-decoder.ts
rename to apps/web/src/services/figma/figma-vector-decoder.ts
diff --git a/src/stores/agent-settings-store.ts b/apps/web/src/stores/agent-settings-store.ts
similarity index 57%
rename from src/stores/agent-settings-store.ts
rename to apps/web/src/stores/agent-settings-store.ts
index f3a5858d..8ac9c0b8 100644
--- a/src/stores/agent-settings-store.ts
+++ b/apps/web/src/stores/agent-settings-store.ts
@@ -6,6 +6,8 @@ import type {
MCPTransportMode,
GroupedModel,
} from '@/types/agent-settings'
+import type { ImageGenConfig, ImageGenProfile } from '@/types/image-service'
+import { DEFAULT_IMAGE_GEN_CONFIG } from '@/types/image-service'
import { MCP_DEFAULT_PORT } from '@/constants/app'
import { appStorage } from '@/utils/app-storage'
@@ -16,6 +18,10 @@ interface PersistedState {
mcpIntegrations: MCPCliIntegration[]
mcpTransportMode: MCPTransportMode
mcpHttpPort: number
+ imageGenConfig: ImageGenConfig
+ imageGenProfiles: ImageGenProfile[]
+ activeImageGenProfileId: string | null
+ openverseOAuth: { clientId: string; clientSecret: string } | null
}
interface AgentSettingsState extends PersistedState {
@@ -36,6 +42,13 @@ interface AgentSettingsState extends PersistedState {
setMCPTransport: (mode: MCPTransportMode, port?: number) => void
setMcpServerStatus: (running: boolean, localIp?: string | null) => void
setDialogOpen: (open: boolean) => void
+ setImageGenConfig: (config: Partial) => void
+ addImageGenProfile: (profile: Omit) => string
+ updateImageGenProfile: (id: string, updates: Partial>) => void
+ removeImageGenProfile: (id: string) => void
+ setActiveImageGenProfile: (id: string | null) => void
+ getActiveImageGenProfile: () => ImageGenProfile | null
+ setOpenverseOAuth: (oauth: { clientId: string; clientSecret: string } | null) => void
persist: () => void
hydrate: () => void
}
@@ -69,6 +82,13 @@ const DEFAULT_PROVIDERS: Record = {
connectionMethod: null,
models: [],
},
+ gemini: {
+ type: 'gemini',
+ displayName: 'Gemini CLI',
+ isConnected: false,
+ connectionMethod: null,
+ models: [],
+ },
}
const DEFAULT_MCP_INTEGRATIONS: MCPCliIntegration[] = [
@@ -85,6 +105,10 @@ export const useAgentSettingsStore = create((set, get) => ({
mcpIntegrations: [...DEFAULT_MCP_INTEGRATIONS],
mcpTransportMode: 'stdio',
mcpHttpPort: MCP_DEFAULT_PORT,
+ imageGenConfig: DEFAULT_IMAGE_GEN_CONFIG,
+ imageGenProfiles: [],
+ activeImageGenProfileId: null,
+ openverseOAuth: null,
dialogOpen: false,
isHydrated: false,
mcpServerRunning: false,
@@ -133,12 +157,56 @@ export const useAgentSettingsStore = create((set, get) => ({
setDialogOpen: (dialogOpen) => set({ dialogOpen }),
+ setImageGenConfig: (updates) =>
+ set((s) => ({
+ imageGenConfig: { ...s.imageGenConfig, ...updates },
+ })),
+
+ addImageGenProfile: (profile) => {
+ const id = `igp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
+ const newProfile: ImageGenProfile = { ...profile, id }
+ set((s) => {
+ const profiles = [...s.imageGenProfiles, newProfile]
+ // First profile becomes active by default
+ const activeId = s.activeImageGenProfileId ?? id
+ return { imageGenProfiles: profiles, activeImageGenProfileId: activeId }
+ })
+ return id
+ },
+
+ updateImageGenProfile: (id, updates) =>
+ set((s) => ({
+ imageGenProfiles: s.imageGenProfiles.map((p) =>
+ p.id === id ? { ...p, ...updates } : p,
+ ),
+ })),
+
+ removeImageGenProfile: (id) =>
+ set((s) => {
+ const profiles = s.imageGenProfiles.filter((p) => p.id !== id)
+ let activeId = s.activeImageGenProfileId
+ if (activeId === id) {
+ activeId = profiles.length > 0 ? profiles[0].id : null
+ }
+ return { imageGenProfiles: profiles, activeImageGenProfileId: activeId }
+ }),
+
+ setActiveImageGenProfile: (id) => set({ activeImageGenProfileId: id }),
+
+ getActiveImageGenProfile: () => {
+ const { imageGenProfiles, activeImageGenProfileId } = get()
+ if (!activeImageGenProfileId) return imageGenProfiles[0] ?? null
+ return imageGenProfiles.find((p) => p.id === activeImageGenProfileId) ?? imageGenProfiles[0] ?? null
+ },
+
+ setOpenverseOAuth: (oauth) => set({ openverseOAuth: oauth }),
+
persist: () => {
try {
- const { providers, mcpIntegrations, mcpTransportMode, mcpHttpPort } = get()
+ const { providers, mcpIntegrations, mcpTransportMode, mcpHttpPort, imageGenConfig, imageGenProfiles, activeImageGenProfileId, openverseOAuth } = get()
appStorage.setItem(
STORAGE_KEY,
- JSON.stringify({ providers, mcpIntegrations, mcpTransportMode, mcpHttpPort }),
+ JSON.stringify({ providers, mcpIntegrations, mcpTransportMode, mcpHttpPort, imageGenConfig, imageGenProfiles, activeImageGenProfileId, openverseOAuth }),
)
} catch {
// ignore
@@ -171,6 +239,24 @@ export const useAgentSettingsStore = create((set, get) => ({
}
if (data.mcpTransportMode) set({ mcpTransportMode: data.mcpTransportMode })
if (data.mcpHttpPort) set({ mcpHttpPort: data.mcpHttpPort })
+ if (data.imageGenConfig) set({ imageGenConfig: data.imageGenConfig })
+ // Hydrate multi-profile image gen
+ if ((data as Record).imageGenProfiles) {
+ const profiles = (data as Record).imageGenProfiles as ImageGenProfile[]
+ const activeId = (data as Record).activeImageGenProfileId as string | null
+ set({ imageGenProfiles: profiles, activeImageGenProfileId: activeId })
+ } else if (data.imageGenConfig && data.imageGenConfig.apiKey) {
+ // Migrate old single config to profiles
+ const migrated: ImageGenProfile = {
+ id: 'igp-migrated',
+ name: data.imageGenConfig.provider === 'custom'
+ ? 'Custom'
+ : data.imageGenConfig.provider.charAt(0).toUpperCase() + data.imageGenConfig.provider.slice(1),
+ ...data.imageGenConfig,
+ }
+ set({ imageGenProfiles: [migrated], activeImageGenProfileId: migrated.id })
+ }
+ if (data.openverseOAuth !== undefined) set({ openverseOAuth: data.openverseOAuth })
} catch {
// ignore
} finally {
diff --git a/src/stores/ai-store.ts b/apps/web/src/stores/ai-store.ts
similarity index 100%
rename from src/stores/ai-store.ts
rename to apps/web/src/stores/ai-store.ts
diff --git a/src/stores/canvas-store.ts b/apps/web/src/stores/canvas-store.ts
similarity index 74%
rename from src/stores/canvas-store.ts
rename to apps/web/src/stores/canvas-store.ts
index a4170072..106fcd9d 100644
--- a/src/stores/canvas-store.ts
+++ b/apps/web/src/stores/canvas-store.ts
@@ -16,6 +16,7 @@ export type RightPanelTab = 'design' | 'code'
interface CanvasPreferences {
layerPanelOpen: boolean
variablesPanelOpen: boolean
+ designMdPanelOpen: boolean
codePanelOpen: boolean
rightPanelTab?: RightPanelTab
}
@@ -28,6 +29,7 @@ interface CanvasStoreState {
clipboard: PenNode[]
layerPanelOpen: boolean
variablesPanelOpen: boolean
+ designMdPanelOpen: boolean
codePanelOpen: boolean
rightPanelTab: RightPanelTab
figmaImportDialogOpen: boolean
@@ -47,12 +49,16 @@ interface CanvasStoreState {
setClipboard: (nodes: PenNode[]) => void
toggleLayerPanel: () => void
toggleVariablesPanel: () => void
+ toggleDesignMdPanel: () => void
toggleCodePanel: () => void
setCodePanelOpen: (open: boolean) => void
setRightPanelTab: (tab: RightPanelTab) => void
setFigmaImportDialogOpen: (open: boolean) => void
setPendingFigmaFile: (file: File | null) => void
setActivePageId: (pageId: string | null) => void
+ imageSearchStatuses: Map
+ setImageSearchStatus: (nodeId: string, status: 'pending' | 'found' | 'failed') => void
+ clearImageSearchStatuses: () => void
hydrate: () => void
}
@@ -75,11 +81,20 @@ export const useCanvasStore = create((set, get) => ({
clipboard: [],
layerPanelOpen: true,
variablesPanelOpen: false,
+ designMdPanelOpen: false,
codePanelOpen: false,
rightPanelTab: 'design',
figmaImportDialogOpen: false,
pendingFigmaFile: null,
activePageId: DEFAULT_PAGE_ID,
+ imageSearchStatuses: new Map(),
+ setImageSearchStatus: (nodeId, status) =>
+ set((s) => {
+ const next = new Map(s.imageSearchStatuses)
+ next.set(nodeId, status)
+ return { imageSearchStatuses: next }
+ }),
+ clearImageSearchStatuses: () => set({ imageSearchStatuses: new Map() }),
setActiveTool: (tool) => set({ activeTool: tool }),
@@ -145,30 +160,36 @@ export const useCanvasStore = create((set, get) => ({
toggleLayerPanel: () => {
const next = !get().layerPanelOpen
set({ layerPanelOpen: next })
- const { variablesPanelOpen, codePanelOpen } = get()
- persistPrefs({ layerPanelOpen: next, variablesPanelOpen, codePanelOpen })
+ const { variablesPanelOpen, designMdPanelOpen, codePanelOpen } = get()
+ persistPrefs({ layerPanelOpen: next, variablesPanelOpen, designMdPanelOpen, codePanelOpen })
},
toggleVariablesPanel: () => {
const next = !get().variablesPanelOpen
set({ variablesPanelOpen: next })
- const { layerPanelOpen, codePanelOpen } = get()
- persistPrefs({ layerPanelOpen, variablesPanelOpen: next, codePanelOpen })
+ const { layerPanelOpen, designMdPanelOpen, codePanelOpen } = get()
+ persistPrefs({ layerPanelOpen, variablesPanelOpen: next, designMdPanelOpen, codePanelOpen })
+ },
+ toggleDesignMdPanel: () => {
+ const next = !get().designMdPanelOpen
+ set({ designMdPanelOpen: next })
+ const { layerPanelOpen, variablesPanelOpen, codePanelOpen } = get()
+ persistPrefs({ layerPanelOpen, variablesPanelOpen, designMdPanelOpen: next, codePanelOpen })
},
toggleCodePanel: () => {
const next = !get().codePanelOpen
set({ codePanelOpen: next })
- const { layerPanelOpen, variablesPanelOpen } = get()
- persistPrefs({ layerPanelOpen, variablesPanelOpen, codePanelOpen: next })
+ const { layerPanelOpen, variablesPanelOpen, designMdPanelOpen } = get()
+ persistPrefs({ layerPanelOpen, variablesPanelOpen, designMdPanelOpen, codePanelOpen: next })
},
setCodePanelOpen: (open) => {
set({ codePanelOpen: open })
- const { layerPanelOpen, variablesPanelOpen } = get()
- persistPrefs({ layerPanelOpen, variablesPanelOpen, codePanelOpen: open })
+ const { layerPanelOpen, variablesPanelOpen, designMdPanelOpen } = get()
+ persistPrefs({ layerPanelOpen, variablesPanelOpen, designMdPanelOpen, codePanelOpen: open })
},
setRightPanelTab: (tab) => {
set({ rightPanelTab: tab })
- const { layerPanelOpen, variablesPanelOpen, codePanelOpen } = get()
- persistPrefs({ layerPanelOpen, variablesPanelOpen, codePanelOpen, rightPanelTab: tab })
+ const { layerPanelOpen, variablesPanelOpen, designMdPanelOpen, codePanelOpen } = get()
+ persistPrefs({ layerPanelOpen, variablesPanelOpen, designMdPanelOpen, codePanelOpen, rightPanelTab: tab })
},
setFigmaImportDialogOpen: (open) => set({ figmaImportDialogOpen: open, ...(!open && { pendingFigmaFile: null }) }),
setPendingFigmaFile: (file) => set({ pendingFigmaFile: file }),
@@ -181,6 +202,7 @@ export const useCanvasStore = create((set, get) => ({
const data = JSON.parse(raw) as Partial
if (typeof data.layerPanelOpen === 'boolean') set({ layerPanelOpen: data.layerPanelOpen })
if (typeof data.variablesPanelOpen === 'boolean') set({ variablesPanelOpen: data.variablesPanelOpen })
+ if (typeof data.designMdPanelOpen === 'boolean') set({ designMdPanelOpen: data.designMdPanelOpen })
if (typeof data.codePanelOpen === 'boolean') set({ codePanelOpen: data.codePanelOpen })
if (data.rightPanelTab === 'design' || data.rightPanelTab === 'code') set({ rightPanelTab: data.rightPanelTab })
} catch { /* ignore */ }
diff --git a/apps/web/src/stores/design-md-store.ts b/apps/web/src/stores/design-md-store.ts
new file mode 100644
index 00000000..24ae4f82
--- /dev/null
+++ b/apps/web/src/stores/design-md-store.ts
@@ -0,0 +1,100 @@
+import { create } from 'zustand'
+import type { DesignMdSpec } from '@/types/design-md'
+import { appStorage } from '@/utils/app-storage'
+
+const STORAGE_PREFIX = 'openpencil-design-md:'
+const CURRENT_KEY_STORAGE = 'openpencil-design-md-current-key'
+
+/** Derive a storage key from a file identifier. Returns null for untitled documents. */
+function fileKey(fileName: string | null, filePath: string | null): string | null {
+ return filePath ?? fileName ?? null
+}
+
+interface DesignMdStoreState {
+ /** Current design.md spec */
+ designMd: DesignMdSpec | undefined
+ /** Current file key for persistence (null = untitled, skip persistence) */
+ _fileKey: string | null
+
+ setDesignMd: (spec: DesignMdSpec | undefined) => void
+ /** Sync store to a document — restores persisted designMd or clears if none. */
+ syncToDocument: (fileName: string | null, filePath: string | null) => void
+ /** Called on new document — clears designMd. */
+ clearForNewDocument: () => void
+ hydrate: () => void
+}
+
+export const useDesignMdStore = create((set, get) => ({
+ designMd: undefined,
+ _fileKey: null,
+
+ setDesignMd: (spec) => {
+ set({ designMd: spec })
+ const key = get()._fileKey
+ if (!key) return // untitled — skip persistence
+ try {
+ if (spec) {
+ appStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(spec))
+ } else {
+ appStorage.removeItem(STORAGE_PREFIX + key)
+ }
+ } catch { /* ignore */ }
+ },
+
+ syncToDocument: (fileName, filePath) => {
+ const key = fileKey(fileName, filePath)
+ set({ _fileKey: key })
+
+ if (!key) {
+ set({ designMd: undefined })
+ return
+ }
+
+ // Restore persisted designMd for this file
+ try {
+ const raw = appStorage.getItem(STORAGE_PREFIX + key)
+ if (raw) {
+ const data = JSON.parse(raw) as DesignMdSpec
+ if (data && typeof data === 'object' && typeof data.raw === 'string') {
+ set({ designMd: data })
+ return
+ }
+ }
+ } catch { /* ignore */ }
+
+ set({ designMd: undefined })
+ },
+
+ clearForNewDocument: () => {
+ set({ designMd: undefined, _fileKey: null })
+ },
+
+ hydrate: () => {
+ try {
+ const lastKey = appStorage.getItem(CURRENT_KEY_STORAGE)
+ if (!lastKey) return
+ set({ _fileKey: lastKey })
+ const raw = appStorage.getItem(STORAGE_PREFIX + lastKey)
+ if (!raw) return
+ const data = JSON.parse(raw) as DesignMdSpec
+ if (data && typeof data === 'object' && typeof data.raw === 'string') {
+ set({ designMd: data })
+ }
+ } catch { /* ignore */ }
+ },
+}))
+
+// Persist the current file key whenever state changes
+let _prevFileKey: string | null = null
+useDesignMdStore.subscribe((state) => {
+ if (state._fileKey !== _prevFileKey) {
+ _prevFileKey = state._fileKey
+ try {
+ if (state._fileKey) {
+ appStorage.setItem(CURRENT_KEY_STORAGE, state._fileKey)
+ } else {
+ appStorage.removeItem(CURRENT_KEY_STORAGE)
+ }
+ } catch { /* ignore */ }
+ }
+})
diff --git a/src/stores/document-store-pages.ts b/apps/web/src/stores/document-store-pages.ts
similarity index 100%
rename from src/stores/document-store-pages.ts
rename to apps/web/src/stores/document-store-pages.ts
diff --git a/src/stores/document-store.ts b/apps/web/src/stores/document-store.ts
similarity index 96%
rename from src/stores/document-store.ts
rename to apps/web/src/stores/document-store.ts
index e7edc7ed..775b9e5e 100644
--- a/src/stores/document-store.ts
+++ b/apps/web/src/stores/document-store.ts
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import { nanoid } from 'nanoid'
import type { PenDocument, PenNode, GroupNode, RefNode } from '@/types/pen'
import type { VariableDefinition } from '@/types/variables'
+
import { useHistoryStore } from '@/stores/history-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { getDefaultTheme } from '@/variables/resolve-variables'
@@ -19,6 +20,7 @@ import {
findClearX,
scaleChildrenInPlace,
rotateChildrenInPlace,
+ cloneNodeWithNewIds,
getActivePageChildren,
setActivePageChildren,
getAllChildren,
@@ -283,15 +285,7 @@ export const useDocumentStore = create(
}
// Regular duplication for non-reusable nodes
- const cloneWithNewIds = (n: PenNode): PenNode => {
- const cloned = { ...n, id: nanoid() } as PenNode
- if ('children' in cloned && cloned.children) {
- cloned.children = cloned.children.map(cloneWithNewIds)
- }
- return cloned
- }
-
- const clone = cloneWithNewIds(node)
+ const clone = cloneNodeWithNewIds(node)
clone.name = (clone.name ?? clone.type) + ' copy'
const parent = findParentInTree(children, id)
@@ -552,14 +546,7 @@ export const useDocumentStore = create(
}
// Clone with new IDs
- const cloneWithNewIds = (n: PenNode): PenNode => {
- const cloned = { ...n, id: nanoid() } as PenNode
- if ('children' in cloned && cloned.children) {
- cloned.children = cloned.children.map(cloneWithNewIds)
- }
- return cloned
- }
- const detached = cloneWithNewIds(source)
+ const detached = cloneNodeWithNewIds(source)
// Apply all direct instance properties (position, size, meta)
const detachedRecord = detached as unknown as Record
for (const [key, val] of Object.entries(node)) {
@@ -727,6 +714,10 @@ export const useDocumentStore = create(
// Set active page to the first page
const firstPageId = migrated.pages?.[0]?.id ?? null
useCanvasStore.getState().setActivePageId(firstPageId)
+ // Sync design.md to this document (lazy import to avoid circular)
+ import('@/stores/design-md-store').then(({ useDesignMdStore }) => {
+ useDesignMdStore.getState().syncToDocument(fileName ?? null, filePath ?? null)
+ })
},
newDocument: () => {
@@ -740,6 +731,10 @@ export const useDocumentStore = create(
isDirty: false,
})
useCanvasStore.getState().setActivePageId(doc.pages?.[0]?.id ?? DEFAULT_PAGE_ID)
+ // Clear design.md for new document
+ import('@/stores/design-md-store').then(({ useDesignMdStore }) => {
+ useDesignMdStore.getState().clearForNewDocument()
+ })
},
markClean: () => set({ isDirty: false }),
@@ -758,7 +753,15 @@ export {
getAllChildren,
migrateToPages,
} from './document-tree-utils'
-export { nanoid as generateId } from 'nanoid'
+export { generateId } from '@/utils/id'
+
+// Sync isDirty to a global so the Electron main process can query it
+// via webContents.executeJavaScript for close confirmation.
+if (typeof window !== 'undefined') {
+ useDocumentStore.subscribe((state) => {
+ ;(window as unknown as Record).__documentIsDirty = state.isDirty
+ })
+}
// Expose stores on window in dev mode for testing/debugging
if (import.meta.env.DEV && typeof window !== 'undefined') {
diff --git a/apps/web/src/stores/document-tree-utils.ts b/apps/web/src/stores/document-tree-utils.ts
new file mode 100644
index 00000000..04baef1f
--- /dev/null
+++ b/apps/web/src/stores/document-tree-utils.ts
@@ -0,0 +1,26 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export {
+ DEFAULT_FRAME_ID,
+ DEFAULT_PAGE_ID,
+ createEmptyDocument,
+ getActivePage,
+ getActivePageChildren,
+ setActivePageChildren,
+ getAllChildren,
+ migrateToPages,
+ ensureDocumentNodeIds,
+ findNodeInTree,
+ findParentInTree,
+ removeNodeFromTree,
+ updateNodeInTree,
+ flattenNodes,
+ insertNodeInTree,
+ isDescendantOf,
+ getNodeBounds,
+ findClearX,
+ scaleChildrenInPlace,
+ rotateChildrenInPlace,
+ deepCloneNode,
+ cloneNodeWithNewIds,
+ cloneNodesWithNewIds,
+} from '@zseven-w/pen-core'
diff --git a/src/stores/history-store.ts b/apps/web/src/stores/history-store.ts
similarity index 100%
rename from src/stores/history-store.ts
rename to apps/web/src/stores/history-store.ts
diff --git a/src/stores/theme-preset-store.ts b/apps/web/src/stores/theme-preset-store.ts
similarity index 100%
rename from src/stores/theme-preset-store.ts
rename to apps/web/src/stores/theme-preset-store.ts
diff --git a/src/stores/uikit-store.ts b/apps/web/src/stores/uikit-store.ts
similarity index 100%
rename from src/stores/uikit-store.ts
rename to apps/web/src/stores/uikit-store.ts
diff --git a/src/styles.css b/apps/web/src/styles.css
similarity index 100%
rename from src/styles.css
rename to apps/web/src/styles.css
diff --git a/apps/web/src/types/__tests__/image-service.test.ts b/apps/web/src/types/__tests__/image-service.test.ts
new file mode 100644
index 00000000..3ac649eb
--- /dev/null
+++ b/apps/web/src/types/__tests__/image-service.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest'
+import {
+ DEFAULT_IMAGE_GEN_CONFIG,
+ MODEL_PLACEHOLDERS,
+ type ImageSearchResult,
+ type ImageGenProvider,
+} from '../image-service'
+
+describe('image-service types', () => {
+ it('DEFAULT_IMAGE_GEN_CONFIG has expected shape', () => {
+ expect(DEFAULT_IMAGE_GEN_CONFIG.provider).toBe('openai')
+ expect(DEFAULT_IMAGE_GEN_CONFIG.apiKey).toBe('')
+ expect(DEFAULT_IMAGE_GEN_CONFIG.model).toBe('')
+ })
+
+ it('MODEL_PLACEHOLDERS covers all providers', () => {
+ const providers: ImageGenProvider[] = ['openai', 'gemini', 'replicate', 'custom']
+ for (const p of providers) {
+ expect(MODEL_PLACEHOLDERS[p]).toBeTruthy()
+ }
+ })
+
+ it('ImageSearchResult shape is correct', () => {
+ const result: ImageSearchResult = {
+ id: 'test',
+ url: 'https://example.com/img.jpg',
+ thumbUrl: 'https://example.com/thumb.jpg',
+ width: 800,
+ height: 600,
+ source: 'openverse',
+ license: 'CC BY 2.0',
+ }
+ expect(result.source).toBe('openverse')
+ })
+})
diff --git a/src/types/agent-settings.ts b/apps/web/src/types/agent-settings.ts
similarity index 95%
rename from src/types/agent-settings.ts
rename to apps/web/src/types/agent-settings.ts
index 8d2239ed..5539a677 100644
--- a/src/types/agent-settings.ts
+++ b/apps/web/src/types/agent-settings.ts
@@ -1,10 +1,10 @@
-export type AIProviderType = 'anthropic' | 'openai' | 'opencode' | 'copilot'
+export type AIProviderType = 'anthropic' | 'openai' | 'opencode' | 'copilot' | 'gemini'
export interface AIProviderConfig {
type: AIProviderType
displayName: string
isConnected: boolean
- connectionMethod: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | null
+ connectionMethod: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli' | null
/** Models fetched when the user connects this provider */
models: GroupedModel[]
/** Human-readable connection status, e.g. "Connected via API key" */
diff --git a/apps/web/src/types/canvas.ts b/apps/web/src/types/canvas.ts
new file mode 100644
index 00000000..1e4f1b3d
--- /dev/null
+++ b/apps/web/src/types/canvas.ts
@@ -0,0 +1,6 @@
+export type {
+ ToolType,
+ ViewportState,
+ SelectionState,
+ CanvasInteraction,
+} from '@zseven-w/pen-types'
diff --git a/apps/web/src/types/design-md.ts b/apps/web/src/types/design-md.ts
new file mode 100644
index 00000000..ab6cb4d1
--- /dev/null
+++ b/apps/web/src/types/design-md.ts
@@ -0,0 +1,5 @@
+export type {
+ DesignMdSpec,
+ DesignMdColor,
+ DesignMdTypography,
+} from '@zseven-w/pen-types'
diff --git a/src/types/electron.d.ts b/apps/web/src/types/electron.d.ts
similarity index 98%
rename from src/types/electron.d.ts
rename to apps/web/src/types/electron.d.ts
index f8972df7..f0ec8824 100644
--- a/src/types/electron.d.ts
+++ b/apps/web/src/types/electron.d.ts
@@ -29,6 +29,7 @@ interface ElectronAPI {
onOpenFile: (callback: (filePath: string) => void) => () => void
readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null>
getPendingFile: () => Promise
+ confirmClose: () => void
getLogDir: () => Promise
setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void
getPreferences: () => Promise>
diff --git a/apps/web/src/types/image-service.ts b/apps/web/src/types/image-service.ts
new file mode 100644
index 00000000..b3d1f4f9
--- /dev/null
+++ b/apps/web/src/types/image-service.ts
@@ -0,0 +1,43 @@
+export type ImageGenProvider = 'openai' | 'gemini' | 'replicate' | 'custom'
+
+export interface ImageGenConfig {
+ provider: ImageGenProvider
+ apiKey: string
+ model: string
+ baseUrl?: string
+}
+
+export interface ImageGenProfile extends ImageGenConfig {
+ id: string
+ name: string
+}
+
+export interface ImageSearchResult {
+ id: string
+ url: string
+ thumbUrl: string
+ width: number
+ height: number
+ source: 'openverse' | 'wikimedia'
+ license: string
+ attribution?: string
+}
+
+export interface ImageSearchResponse {
+ results: ImageSearchResult[]
+ source: 'openverse' | 'wikimedia'
+}
+
+export const MODEL_PLACEHOLDERS: Record = {
+ openai: 'dall-e-3',
+ gemini: 'gemini-2.0-flash-preview-image-generation',
+ replicate: 'black-forest-labs/flux-1.1-pro',
+ custom: 'model-name',
+}
+
+export const DEFAULT_IMAGE_GEN_CONFIG: ImageGenConfig = {
+ provider: 'openai',
+ apiKey: '',
+ model: '',
+ baseUrl: undefined,
+}
diff --git a/src/types/opencode-sdk.d.ts b/apps/web/src/types/opencode-sdk.d.ts
similarity index 100%
rename from src/types/opencode-sdk.d.ts
rename to apps/web/src/types/opencode-sdk.d.ts
diff --git a/apps/web/src/types/pen.ts b/apps/web/src/types/pen.ts
new file mode 100644
index 00000000..2f285b3f
--- /dev/null
+++ b/apps/web/src/types/pen.ts
@@ -0,0 +1,21 @@
+export type {
+ PenPage,
+ PenDocument,
+ PenNodeType,
+ SizingBehavior,
+ PenNodeBase,
+ ContainerProps,
+ FrameNode,
+ GroupNode,
+ RectangleNode,
+ EllipseNode,
+ LineNode,
+ PolygonNode,
+ PathNode,
+ TextNode,
+ ImageFitMode,
+ ImageNode,
+ IconFontNode,
+ RefNode,
+ PenNode,
+} from '@zseven-w/pen-types'
diff --git a/apps/web/src/types/styles.ts b/apps/web/src/types/styles.ts
new file mode 100644
index 00000000..ceb7ad7b
--- /dev/null
+++ b/apps/web/src/types/styles.ts
@@ -0,0 +1,14 @@
+export type {
+ BlendMode,
+ SolidFill,
+ GradientStop,
+ LinearGradientFill,
+ RadialGradientFill,
+ ImageFill,
+ PenFill,
+ PenStroke,
+ BlurEffect,
+ ShadowEffect,
+ PenEffect,
+ StyledTextSegment,
+} from '@zseven-w/pen-types'
diff --git a/apps/web/src/types/theme-preset.ts b/apps/web/src/types/theme-preset.ts
new file mode 100644
index 00000000..112be73c
--- /dev/null
+++ b/apps/web/src/types/theme-preset.ts
@@ -0,0 +1,4 @@
+export type {
+ ThemePreset,
+ ThemePresetFile,
+} from '@zseven-w/pen-types'
diff --git a/apps/web/src/types/uikit.ts b/apps/web/src/types/uikit.ts
new file mode 100644
index 00000000..f0c1d91d
--- /dev/null
+++ b/apps/web/src/types/uikit.ts
@@ -0,0 +1,5 @@
+export type {
+ ComponentCategory,
+ KitComponent,
+ UIKit,
+} from '@zseven-w/pen-types'
diff --git a/apps/web/src/types/variables.ts b/apps/web/src/types/variables.ts
new file mode 100644
index 00000000..8026e029
--- /dev/null
+++ b/apps/web/src/types/variables.ts
@@ -0,0 +1,5 @@
+export type {
+ VariableDefinition,
+ VariableValue,
+ ThemedValue,
+} from '@zseven-w/pen-types'
diff --git a/src/uikit/built-in-registry.ts b/apps/web/src/uikit/built-in-registry.ts
similarity index 100%
rename from src/uikit/built-in-registry.ts
rename to apps/web/src/uikit/built-in-registry.ts
diff --git a/src/uikit/kit-import-export.ts b/apps/web/src/uikit/kit-import-export.ts
similarity index 100%
rename from src/uikit/kit-import-export.ts
rename to apps/web/src/uikit/kit-import-export.ts
diff --git a/src/uikit/kit-utils.ts b/apps/web/src/uikit/kit-utils.ts
similarity index 96%
rename from src/uikit/kit-utils.ts
rename to apps/web/src/uikit/kit-utils.ts
index db8b5663..c1c3adf0 100644
--- a/src/uikit/kit-utils.ts
+++ b/apps/web/src/uikit/kit-utils.ts
@@ -1,5 +1,6 @@
import type { PenDocument, PenNode } from '@/types/pen'
import { getAllChildren } from '@/stores/document-tree-utils'
+export { deepCloneNode } from '@/stores/document-tree-utils'
import type { ComponentCategory, KitComponent } from '@/types/uikit'
/**
@@ -44,12 +45,7 @@ export function collectVariableRefs(node: PenNode): Set {
return refs
}
-/**
- * Deep-clone a node tree preserving all IDs.
- */
-export function deepCloneNode(node: T): T {
- return structuredClone(node)
-}
+// deepCloneNode re-exported from pen-core via document-tree-utils
// ---------------------------------------------------------------------------
// Internal helpers
diff --git a/src/uikit/kits/shadcn-kit-extra.ts b/apps/web/src/uikit/kits/shadcn-kit-extra.ts
similarity index 100%
rename from src/uikit/kits/shadcn-kit-extra.ts
rename to apps/web/src/uikit/kits/shadcn-kit-extra.ts
diff --git a/src/uikit/kits/shadcn-kit-meta.ts b/apps/web/src/uikit/kits/shadcn-kit-meta.ts
similarity index 100%
rename from src/uikit/kits/shadcn-kit-meta.ts
rename to apps/web/src/uikit/kits/shadcn-kit-meta.ts
diff --git a/src/uikit/kits/shadcn-kit.ts b/apps/web/src/uikit/kits/shadcn-kit.ts
similarity index 100%
rename from src/uikit/kits/shadcn-kit.ts
rename to apps/web/src/uikit/kits/shadcn-kit.ts
diff --git a/src/utils/__tests__/boolean-ops.test.ts b/apps/web/src/utils/__tests__/boolean-ops.test.ts
similarity index 100%
rename from src/utils/__tests__/boolean-ops.test.ts
rename to apps/web/src/utils/__tests__/boolean-ops.test.ts
diff --git a/src/utils/__tests__/security.test.ts b/apps/web/src/utils/__tests__/security.test.ts
similarity index 100%
rename from src/utils/__tests__/security.test.ts
rename to apps/web/src/utils/__tests__/security.test.ts
diff --git a/src/utils/app-storage.ts b/apps/web/src/utils/app-storage.ts
similarity index 100%
rename from src/utils/app-storage.ts
rename to apps/web/src/utils/app-storage.ts
diff --git a/apps/web/src/utils/arc-path.ts b/apps/web/src/utils/arc-path.ts
new file mode 100644
index 00000000..1b7712bc
--- /dev/null
+++ b/apps/web/src/utils/arc-path.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export { buildEllipseArcPath, isArcEllipse } from '@zseven-w/pen-core'
diff --git a/apps/web/src/utils/boolean-ops.ts b/apps/web/src/utils/boolean-ops.ts
new file mode 100644
index 00000000..ce027ddc
--- /dev/null
+++ b/apps/web/src/utils/boolean-ops.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export { type BooleanOpType, canBooleanOp, executeBooleanOp } from '@zseven-w/pen-core'
diff --git a/apps/web/src/utils/design-md-io.ts b/apps/web/src/utils/design-md-io.ts
new file mode 100644
index 00000000..e0a83321
--- /dev/null
+++ b/apps/web/src/utils/design-md-io.ts
@@ -0,0 +1,89 @@
+import type { DesignMdSpec } from '@/types/design-md'
+import { parseDesignMd, generateDesignMd } from './design-md-parser'
+import { supportsFileSystemAccess } from './file-operations'
+
+/** Import a .md design file via file picker. */
+export async function importDesignMd(): Promise {
+ if (supportsFileSystemAccess()) {
+ try {
+ const [handle]: FileSystemFileHandle[] = await (
+ window as unknown as {
+ showOpenFilePicker: (opts: unknown) => Promise
+ }
+ ).showOpenFilePicker({
+ types: [
+ {
+ description: 'Design Markdown',
+ accept: { 'text/markdown': ['.md'] },
+ },
+ ],
+ })
+ const file = await handle.getFile()
+ const text = await file.text()
+ return parseDesignMd(text)
+ } catch {
+ return null
+ }
+ }
+
+ // Fallback:
+ return new Promise((resolve) => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.md'
+ input.onchange = async () => {
+ const file = input.files?.[0]
+ if (!file) { resolve(null); return }
+ try {
+ const text = await file.text()
+ resolve(parseDesignMd(text))
+ } catch {
+ resolve(null)
+ }
+ }
+ input.oncancel = () => resolve(null)
+ input.click()
+ })
+}
+
+/** Export a DesignMdSpec as a .md file. */
+export async function exportDesignMd(
+ spec: DesignMdSpec,
+ fileName?: string,
+): Promise {
+ const markdown = generateDesignMd(spec)
+ const name = fileName ?? `${(spec.projectName ?? 'design').replace(/[^a-zA-Z0-9_-]/g, '_')}.md`
+
+ if (supportsFileSystemAccess()) {
+ try {
+ const handle: FileSystemFileHandle = await (
+ window as unknown as {
+ showSaveFilePicker: (opts: unknown) => Promise
+ }
+ ).showSaveFilePicker({
+ suggestedName: name,
+ types: [
+ {
+ description: 'Design Markdown',
+ accept: { 'text/markdown': ['.md'] },
+ },
+ ],
+ })
+ const writable = await handle.createWritable()
+ await writable.write(markdown)
+ await writable.close()
+ return
+ } catch {
+ return
+ }
+ }
+
+ // Fallback: browser download
+ const blob = new Blob([markdown], { type: 'text/markdown' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = name
+ a.click()
+ URL.revokeObjectURL(url)
+}
diff --git a/apps/web/src/utils/design-md-parser.ts b/apps/web/src/utils/design-md-parser.ts
new file mode 100644
index 00000000..e2c628aa
--- /dev/null
+++ b/apps/web/src/utils/design-md-parser.ts
@@ -0,0 +1,328 @@
+import type { DesignMdSpec, DesignMdColor, DesignMdTypography } from '@/types/design-md'
+import type { PenDocument } from '@/types/pen'
+import type { VariableDefinition } from '@/types/variables'
+
+// ---------------------------------------------------------------------------
+// Section header patterns (fuzzy matching)
+// ---------------------------------------------------------------------------
+
+const SECTION_PATTERNS: Record = {
+ visualTheme: /visual\s*theme|atmosphere|mood/i,
+ colorPalette: /colou?r\s*(palette|roles?|system)?/i,
+ typography: /typography|type\s*rules?|fonts?/i,
+ componentStyles: /component\s*(styl|pattern)|button|card|input/i,
+ layoutPrinciples: /layout\s*(principle|rule|system)?|grid|whitespace/i,
+ generationNotes: /generation\s*notes?|design\s*system\s*notes?|stitch|prompting/i,
+}
+
+interface ParsedSection {
+ key: string
+ title: string
+ content: string
+}
+
+// ---------------------------------------------------------------------------
+// Parse markdown into sections
+// ---------------------------------------------------------------------------
+
+function splitSections(markdown: string): { projectName?: string; sections: ParsedSection[] } {
+ const lines = markdown.split('\n')
+ let projectName: string | undefined
+
+ // Extract project name from H1
+ const h1Match = markdown.match(/^#\s+(?:Design\s+System:\s*)?(.+)$/m)
+ if (h1Match) projectName = h1Match[1].trim()
+
+ const sections: ParsedSection[] = []
+ let currentTitle = ''
+ let currentLines: string[] = []
+ let currentKey = ''
+
+ for (const line of lines) {
+ const h2Match = line.match(/^##\s+(?:\d+\.\s*)?(.+)$/)
+ if (h2Match) {
+ // Flush previous section
+ if (currentKey) {
+ sections.push({ key: currentKey, title: currentTitle, content: currentLines.join('\n').trim() })
+ }
+ currentTitle = h2Match[1].trim()
+ currentLines = []
+ currentKey = matchSectionKey(currentTitle)
+ } else {
+ currentLines.push(line)
+ }
+ }
+
+ // Flush last section
+ if (currentKey) {
+ sections.push({ key: currentKey, title: currentTitle, content: currentLines.join('\n').trim() })
+ }
+
+ return { projectName, sections }
+}
+
+function matchSectionKey(title: string): string {
+ for (const [key, pattern] of Object.entries(SECTION_PATTERNS)) {
+ if (pattern.test(title)) return key
+ }
+ // Fallback: try to match common section names loosely
+ const lower = title.toLowerCase()
+ if (lower.includes('theme') || lower.includes('vibe') || lower.includes('aesthetic')) return 'visualTheme'
+ if (lower.includes('color') || lower.includes('palette') || lower.includes('colour')) return 'colorPalette'
+ if (lower.includes('type') || lower.includes('font') || lower.includes('typo')) return 'typography'
+ if (lower.includes('component') || lower.includes('style') || lower.includes('element')) return 'componentStyles'
+ if (lower.includes('layout') || lower.includes('spacing') || lower.includes('grid')) return 'layoutPrinciples'
+ if (lower.includes('note') || lower.includes('generation') || lower.includes('usage')) return 'generationNotes'
+ return 'unknown'
+}
+
+// ---------------------------------------------------------------------------
+// Color extraction
+// ---------------------------------------------------------------------------
+
+const HEX_COLOR_RE = /#([0-9A-Fa-f]{6})\b/
+
+function parseColors(content: string): DesignMdColor[] {
+ const colors: DesignMdColor[] = []
+ const lines = content.split('\n')
+
+ for (const line of lines) {
+ // Skip JSON/code artifacts
+ if (line.trimStart().startsWith('{') || line.trimStart().startsWith('`')) continue
+
+ const hexMatch = line.match(HEX_COLOR_RE)
+ if (!hexMatch) continue
+
+ const hex = `#${hexMatch[1].toUpperCase()}`
+
+ // Try pattern: **Name** (hex) — role OR - **Name** (#hex) – role
+ const namedMatch = line.match(/\*\*([^*]+)\*\*\s*\(?#?[0-9A-Fa-f]{6}\)?\s*[–—-]\s*(.+)/)
+ if (namedMatch) {
+ colors.push({ name: namedMatch[1].trim(), hex, role: namedMatch[2].trim() })
+ continue
+ }
+
+ // Try pattern: - Name (#hex) — role OR Name (#hex): role
+ const dashMatch = line.match(/[-*]\s*(.+?)\s*\(#?[0-9A-Fa-f]{6}\)\s*[–—:-]\s*(.+)/)
+ if (dashMatch) {
+ colors.push({ name: dashMatch[1].trim(), hex, role: dashMatch[2].trim() })
+ continue
+ }
+
+ // Fallback: just grab the hex and surrounding text
+ const before = line.substring(0, hexMatch.index).replace(/[-*#\s]+$/, '').trim()
+ const after = line.substring((hexMatch.index ?? 0) + hexMatch[0].length).replace(/^[)\s–—:-]+/, '').trim()
+ colors.push({
+ name: before || hex,
+ hex,
+ role: after || '',
+ })
+ }
+
+ return colors
+}
+
+// ---------------------------------------------------------------------------
+// Typography extraction
+// ---------------------------------------------------------------------------
+
+function parseTypography(content: string): DesignMdTypography {
+ const typo: DesignMdTypography = {}
+
+ // Font family
+ const fontMatch = content.match(/(?:font\s*family|primary\s*font)[:\s]*\*?\*?([^*\n]+)/i)
+ if (fontMatch) typo.fontFamily = fontMatch[1].trim()
+
+ // Try to extract heading/body descriptions
+ const headingMatch = content.match(/(?:heading|display|h1)[^:]*:\s*([^\n]+)/i)
+ if (headingMatch) typo.headings = headingMatch[1].trim()
+
+ const bodyMatch = content.match(/(?:body\s*text|paragraph)[^:]*:\s*([^\n]+)/i)
+ if (bodyMatch) typo.body = bodyMatch[1].trim()
+
+ // Keep full content as scale description
+ typo.scale = content
+
+ return typo
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/** Parse a design.md markdown string into a structured DesignMdSpec. */
+export function parseDesignMd(markdown: string): DesignMdSpec {
+ const { projectName, sections } = splitSections(markdown)
+
+ const spec: DesignMdSpec = { raw: markdown, projectName }
+
+ for (const section of sections) {
+ switch (section.key) {
+ case 'visualTheme':
+ spec.visualTheme = section.content
+ break
+ case 'colorPalette':
+ spec.colorPalette = parseColors(section.content)
+ break
+ case 'typography':
+ spec.typography = parseTypography(section.content)
+ break
+ case 'componentStyles':
+ spec.componentStyles = section.content
+ break
+ case 'layoutPrinciples':
+ spec.layoutPrinciples = section.content
+ break
+ case 'generationNotes':
+ spec.generationNotes = section.content
+ break
+ case 'unknown':
+ // Append to componentStyles as catch-all
+ spec.componentStyles = spec.componentStyles
+ ? spec.componentStyles + '\n\n' + section.content
+ : section.content
+ break
+ }
+ }
+
+ // Fallback: if no sections were parsed at all, try to extract colors
+ // and typography from the entire markdown
+ if (!spec.colorPalette || spec.colorPalette.length === 0) {
+ const colors = parseColors(markdown)
+ if (colors.length > 0) spec.colorPalette = colors
+ }
+ if (!spec.typography) {
+ const typo = parseTypography(markdown)
+ if (typo.fontFamily) spec.typography = typo
+ }
+ // If still nothing parsed, store the whole text as visual theme
+ if (!spec.visualTheme && !spec.colorPalette && !spec.componentStyles && sections.length === 0) {
+ spec.visualTheme = markdown.trim()
+ }
+
+ return spec
+}
+
+/** Generate a design.md markdown string from a DesignMdSpec. */
+export function generateDesignMd(spec: DesignMdSpec): string {
+ // If raw was set and nothing was structurally changed, return as-is
+ if (spec.raw) return spec.raw
+
+ const lines: string[] = []
+
+ lines.push(`# Design System: ${spec.projectName ?? 'Untitled'}`)
+ lines.push('')
+
+ if (spec.visualTheme) {
+ lines.push('## 1. Visual Theme & Atmosphere')
+ lines.push(spec.visualTheme)
+ lines.push('')
+ }
+
+ if (spec.colorPalette?.length) {
+ lines.push('## 2. Color Palette & Roles')
+ lines.push('')
+ for (const c of spec.colorPalette) {
+ lines.push(`- **${c.name}** (${c.hex}) — ${c.role}`)
+ }
+ lines.push('')
+ }
+
+ if (spec.typography) {
+ lines.push('## 3. Typography Rules')
+ if (spec.typography.fontFamily) {
+ lines.push(`**Primary Font Family:** ${spec.typography.fontFamily}`)
+ }
+ if (spec.typography.scale) {
+ lines.push(spec.typography.scale)
+ }
+ lines.push('')
+ }
+
+ if (spec.componentStyles) {
+ lines.push('## 4. Component Stylings')
+ lines.push(spec.componentStyles)
+ lines.push('')
+ }
+
+ if (spec.layoutPrinciples) {
+ lines.push('## 5. Layout Principles')
+ lines.push(spec.layoutPrinciples)
+ lines.push('')
+ }
+
+ if (spec.generationNotes) {
+ lines.push('## 6. Design System Notes')
+ lines.push(spec.generationNotes)
+ lines.push('')
+ }
+
+ return lines.join('\n')
+}
+
+/** Auto-extract a DesignMdSpec from an existing PenDocument. */
+export function extractDesignMdFromDocument(doc: PenDocument): DesignMdSpec {
+ const colors: DesignMdColor[] = []
+
+ // Extract colors from document variables
+ if (doc.variables) {
+ for (const [name, def] of Object.entries(doc.variables)) {
+ if (def.type !== 'color') continue
+ const value = typeof def.value === 'string' ? def.value
+ : Array.isArray(def.value) ? String(def.value[0]?.value ?? '')
+ : String(def.value)
+ if (/^#[0-9A-Fa-f]{6,8}$/.test(value)) {
+ colors.push({
+ name: name.replace(/^[$]/, ''),
+ hex: value.substring(0, 7).toUpperCase(),
+ role: `Design variable $${name}`,
+ })
+ }
+ }
+ }
+
+ // Collect font families from text nodes
+ const fonts = new Set()
+ const collectFonts = (nodes: { fontFamily?: string; children?: unknown[] }[]) => {
+ for (const n of nodes) {
+ if ('fontFamily' in n && typeof n.fontFamily === 'string') fonts.add(n.fontFamily)
+ if ('children' in n && Array.isArray(n.children)) collectFonts(n.children as { fontFamily?: string; children?: unknown[] }[])
+ }
+ }
+ collectFonts(doc.children as { fontFamily?: string; children?: unknown[] }[])
+ if (doc.pages) {
+ for (const page of doc.pages) {
+ collectFonts(page.children as { fontFamily?: string; children?: unknown[] }[])
+ }
+ }
+
+ const typography: DesignMdTypography = {}
+ if (fonts.size > 0) typography.fontFamily = [...fonts].join(', ')
+
+ const spec: DesignMdSpec = {
+ raw: '',
+ projectName: doc.name ?? 'Untitled',
+ colorPalette: colors.length > 0 ? colors : undefined,
+ typography: fonts.size > 0 ? typography : undefined,
+ }
+
+ // Generate the markdown and set as raw
+ spec.raw = generateDesignMd(spec)
+
+ return spec
+}
+
+/** Convert design.md colors to document variables. */
+export function designMdColorsToVariables(
+ colors: DesignMdColor[],
+): Record {
+ const vars: Record = {}
+ for (const color of colors) {
+ const key = color.name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+ vars[key] = { type: 'color', value: color.hex }
+ }
+ return vars
+}
diff --git a/src/utils/export.ts b/apps/web/src/utils/export.ts
similarity index 100%
rename from src/utils/export.ts
rename to apps/web/src/utils/export.ts
diff --git a/src/utils/file-operations.ts b/apps/web/src/utils/file-operations.ts
similarity index 100%
rename from src/utils/file-operations.ts
rename to apps/web/src/utils/file-operations.ts
diff --git a/apps/web/src/utils/id.ts b/apps/web/src/utils/id.ts
new file mode 100644
index 00000000..5ed3f3a4
--- /dev/null
+++ b/apps/web/src/utils/id.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export { generateId } from '@zseven-w/pen-core'
diff --git a/apps/web/src/utils/node-clone.ts b/apps/web/src/utils/node-clone.ts
new file mode 100644
index 00000000..0313ab50
--- /dev/null
+++ b/apps/web/src/utils/node-clone.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export { cloneNodesWithNewIds } from '@/stores/document-tree-utils'
diff --git a/apps/web/src/utils/normalize-pen-file.ts b/apps/web/src/utils/normalize-pen-file.ts
new file mode 100644
index 00000000..be74151f
--- /dev/null
+++ b/apps/web/src/utils/normalize-pen-file.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export { normalizePenDocument } from '@zseven-w/pen-core'
diff --git a/src/utils/svg-parser.ts b/apps/web/src/utils/svg-parser.ts
similarity index 100%
rename from src/utils/svg-parser.ts
rename to apps/web/src/utils/svg-parser.ts
diff --git a/src/utils/syntax-highlight.ts b/apps/web/src/utils/syntax-highlight.ts
similarity index 100%
rename from src/utils/syntax-highlight.ts
rename to apps/web/src/utils/syntax-highlight.ts
diff --git a/src/utils/theme-preset-io.ts b/apps/web/src/utils/theme-preset-io.ts
similarity index 100%
rename from src/utils/theme-preset-io.ts
rename to apps/web/src/utils/theme-preset-io.ts
diff --git a/apps/web/src/variables/replace-refs.ts b/apps/web/src/variables/replace-refs.ts
new file mode 100644
index 00000000..f1cec6bc
--- /dev/null
+++ b/apps/web/src/variables/replace-refs.ts
@@ -0,0 +1,2 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export { replaceVariableRefsInTree } from '@zseven-w/pen-core'
diff --git a/apps/web/src/variables/resolve-variables.ts b/apps/web/src/variables/resolve-variables.ts
new file mode 100644
index 00000000..3a0e1963
--- /dev/null
+++ b/apps/web/src/variables/resolve-variables.ts
@@ -0,0 +1,9 @@
+// Re-export from @zseven-w/pen-core — the canonical source
+export {
+ isVariableRef,
+ getDefaultTheme,
+ resolveVariableRef,
+ resolveColorRef,
+ resolveNumericRef,
+ resolveNodeForCanvas,
+} from '@zseven-w/pen-core'
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 00000000..49a09ba3
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "target": "ES2022",
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/vite.config.ts b/apps/web/vite.config.ts
similarity index 88%
rename from vite.config.ts
rename to apps/web/vite.config.ts
index 3a983149..50341b4e 100644
--- a/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -15,7 +15,7 @@ const isElectronBuild = process.env.BUILD_TARGET === 'electron'
function copyCanvasKitWasm() {
const wasmDir = resolve('public/canvaskit')
if (!existsSync(wasmDir)) mkdirSync(wasmDir, { recursive: true })
- const ckDir = resolve('node_modules/canvaskit-wasm/bin')
+ const ckDir = resolve('../../node_modules/canvaskit-wasm/bin')
const files = ['canvaskit.wasm']
for (const file of files) {
const src = resolve(ckDir, file)
@@ -30,6 +30,11 @@ copyCanvasKitWasm()
const config = defineConfig({
test: {
teardownTimeout: 1000,
+ include: [
+ 'src/**/*.test.ts',
+ 'server/**/*.test.ts',
+ '../../packages/*/src/**/*.test.ts',
+ ],
},
resolve: {
alias: {
@@ -42,6 +47,7 @@ const config = defineConfig({
nitro({
rollupConfig: { external: [/^@sentry\//, 'canvas', 'jsdom', 'cssstyle', 'canvaskit-wasm'] },
serverDir: './server',
+ output: { dir: '../../out/web' },
...(isElectronBuild ? { preset: 'node-server' } : {}),
}),
// this is the plugin that enables path aliases
diff --git a/bun.lock b/bun.lock
index 445c5ae7..effcb51d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -25,6 +25,7 @@
"@iconify-json/simple-icons": "^1.2.71",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "1.2.6",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
@@ -81,6 +82,89 @@
"vitest": "^3.0.5",
},
},
+ "apps/desktop": {
+ "name": "@zseven-w/desktop",
+ "version": "0.5.0",
+ },
+ "apps/web": {
+ "name": "@zseven-w/web",
+ "version": "0.5.0",
+ },
+ "packages/pen-codegen": {
+ "name": "@zseven-w/pen-codegen",
+ "version": "0.5.0",
+ "dependencies": {
+ "@zseven-w/pen-core": "workspace:*",
+ "@zseven-w/pen-types": "workspace:*",
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2",
+ },
+ },
+ "packages/pen-core": {
+ "name": "@zseven-w/pen-core",
+ "version": "0.5.0",
+ "dependencies": {
+ "@zseven-w/pen-types": "workspace:*",
+ "nanoid": "^5.1.6",
+ "paper": "^0.12.18",
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2",
+ },
+ },
+ "packages/pen-figma": {
+ "name": "@zseven-w/pen-figma",
+ "version": "0.5.0",
+ "dependencies": {
+ "@zseven-w/pen-types": "workspace:*",
+ "fzstd": "^0.1.1",
+ "kiwi-schema": "^0.5.0",
+ "uzip": "^0.20201231.0",
+ },
+ "devDependencies": {
+ "@types/uzip": "^0.20201231.2",
+ "typescript": "^5.7.2",
+ },
+ },
+ "packages/pen-renderer": {
+ "name": "@zseven-w/pen-renderer",
+ "version": "0.5.0",
+ "dependencies": {
+ "@zseven-w/pen-core": "workspace:*",
+ "@zseven-w/pen-types": "workspace:*",
+ "rbush": "^4.0.1",
+ },
+ "devDependencies": {
+ "@types/rbush": "^4.0.0",
+ "canvaskit-wasm": "^0.40.0",
+ "typescript": "^5.7.2",
+ },
+ "peerDependencies": {
+ "canvaskit-wasm": "^0.40.0",
+ },
+ },
+ "packages/pen-sdk": {
+ "name": "@zseven-w/pen-sdk",
+ "version": "0.5.0",
+ "dependencies": {
+ "@zseven-w/pen-codegen": "workspace:*",
+ "@zseven-w/pen-core": "workspace:*",
+ "@zseven-w/pen-figma": "workspace:*",
+ "@zseven-w/pen-renderer": "workspace:*",
+ "@zseven-w/pen-types": "workspace:*",
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2",
+ },
+ },
+ "packages/pen-types": {
+ "name": "@zseven-w/pen-types",
+ "version": "0.5.0",
+ "devDependencies": {
+ "typescript": "^5.7.2",
+ },
+ },
},
"packages": {
"7zip-bin": ["7zip-bin@5.2.0", "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
@@ -383,6 +467,8 @@
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
+
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@@ -681,6 +767,22 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
+ "@zseven-w/desktop": ["@zseven-w/desktop@workspace:apps/desktop"],
+
+ "@zseven-w/pen-codegen": ["@zseven-w/pen-codegen@workspace:packages/pen-codegen"],
+
+ "@zseven-w/pen-core": ["@zseven-w/pen-core@workspace:packages/pen-core"],
+
+ "@zseven-w/pen-figma": ["@zseven-w/pen-figma@workspace:packages/pen-figma"],
+
+ "@zseven-w/pen-renderer": ["@zseven-w/pen-renderer@workspace:packages/pen-renderer"],
+
+ "@zseven-w/pen-sdk": ["@zseven-w/pen-sdk@workspace:packages/pen-sdk"],
+
+ "@zseven-w/pen-types": ["@zseven-w/pen-types@workspace:packages/pen-types"],
+
+ "@zseven-w/web": ["@zseven-w/web@workspace:apps/web"],
+
"abbrev": ["abbrev@3.0.1", "https://registry.npmmirror.com/abbrev/-/abbrev-3.0.1.tgz", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
diff --git a/package.json b/package.json
index 094de531..a31493d3 100644
--- a/package.json
+++ b/package.json
@@ -1,106 +1,112 @@
{
- "name": "openpencil",
- "version": "0.4.4",
- "description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.",
- "author": {
- "name": "ZSeven-W",
- "email": "xkayshen@gmail.com"
- },
- "private": true,
- "type": "module",
- "main": "electron-dist/main.cjs",
- "bin": {
- "openpencil-mcp": "dist/mcp-server.cjs"
- },
- "scripts": {
- "dev": "bun --bun vite dev --port 3000",
- "build": "bun --bun vite build",
- "preview": "bun --bun vite preview",
- "test": "bun --bun vitest run --passWithNoTests",
- "mcp:compile": "esbuild src/mcp/server.ts --bundle --platform=node --target=node20 --outfile=dist/mcp-server.cjs --format=cjs --sourcemap --alias:@=src --define:import.meta.env={}",
- "mcp:dev": "bun run src/mcp/server.ts",
- "electron:dev": "bun run scripts/electron-dev.ts",
- "electron:compile": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node20 --outdir=electron-dist --external:electron --format=cjs --out-extension:.js=.cjs --sourcemap",
- "electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml",
- "electron:build:mac-arm64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml --mac --arm64 && if [ -f dist-electron/latest-mac.yml ]; then mv dist-electron/latest-mac.yml dist-electron/latest-mac-arm64.yml; fi",
- "electron:build:mac-x64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml --mac --x64",
- "electron:build:mac-universal": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml --mac --arm64 --x64",
- "electron:build:mac-both": "bun run electron:build:mac-arm64 && bun run electron:build:mac-x64"
- },
- "dependencies": {
- "@anthropic-ai/claude-agent-sdk": "^0.2.47",
- "@anthropic-ai/sdk": "^0.77.0",
- "@fontsource-variable/inter": "^5.2.8",
- "@fontsource/dm-sans": "^5.2.8",
- "@fontsource/inter": "^5.2.8",
- "@fontsource/lato": "^5.2.7",
- "@fontsource/montserrat": "^5.2.8",
- "@fontsource/nunito": "^5.2.7",
- "@fontsource/open-sans": "^5.2.7",
- "@fontsource/playfair-display": "^5.2.8",
- "@fontsource/poppins": "^5.2.7",
- "@fontsource/raleway": "^5.2.8",
- "@fontsource/roboto": "^5.2.10",
- "@fontsource/source-sans-3": "^5.2.9",
- "@github/copilot-sdk": "^0.1.32",
- "@iconify-json/feather": "^1.2.1",
- "@iconify-json/lucide": "^1.2.93",
- "@iconify-json/simple-icons": "^1.2.71",
- "@modelcontextprotocol/sdk": "^1.12.1",
- "@opencode-ai/sdk": "1.2.6",
- "@radix-ui/react-select": "^2.2.6",
- "@radix-ui/react-separator": "^1.1.8",
- "@radix-ui/react-slider": "^1.3.6",
- "@radix-ui/react-switch": "^1.2.6",
- "@radix-ui/react-toggle": "^1.1.10",
- "@radix-ui/react-tooltip": "^1.2.8",
- "@tailwindcss/vite": "^4.1.18",
- "@tanstack/react-devtools": "^0.7.0",
- "@tanstack/react-router": "^1.132.0",
- "@tanstack/react-router-devtools": "^1.132.0",
- "@tanstack/react-router-ssr-query": "^1.131.7",
- "@tanstack/react-start": "^1.132.0",
- "@tanstack/router-plugin": "^1.132.0",
- "canvaskit-wasm": "^0.40.0",
- "class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
- "electron-updater": "^6.6.2",
- "fzstd": "^0.1.1",
- "html2canvas": "^1.4.1",
- "i18next": "^25.8.14",
- "i18next-browser-languagedetector": "^8.2.1",
- "kiwi-schema": "^0.5.0",
- "lucide-react": "^0.545.0",
- "nanoid": "^5.1.6",
- "nitro": "npm:nitro-nightly@latest",
- "paper": "^0.12.18",
- "rbush": "^4.0.1",
- "react": "^19.2.0",
- "react-dom": "^19.2.0",
- "react-i18next": "^16.5.4",
- "tailwind-merge": "^3.4.1",
- "tailwindcss": "^4.1.18",
- "uzip": "^0.20201231.0",
- "vite-tsconfig-paths": "^5.1.4",
- "zustand": "^5.0.11"
- },
- "devDependencies": {
- "@tanstack/devtools-vite": "^0.3.11",
- "@testing-library/dom": "^10.4.0",
- "@testing-library/react": "^16.2.0",
- "@types/node": "^22.10.2",
- "@types/rbush": "^4.0.0",
- "@types/react": "^19.2.0",
- "@types/react-dom": "^19.2.0",
- "@types/uzip": "^0.20201231.2",
- "@vitejs/plugin-react": "^5.0.4",
- "electron": "^35.0.0",
- "electron-builder": "^26.0.0",
- "esbuild": "^0.25.0",
- "graceful-fs": "^4.2.11",
- "jsdom": "^27.0.0",
- "typescript": "^5.7.2",
- "vite": "^7.1.7",
- "vitest": "^3.0.5"
- }
-}
\ No newline at end of file
+ "name": "openpencil",
+ "version": "0.5.0",
+ "description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.",
+ "author": {
+ "name": "ZSeven-W",
+ "email": "xkayshen@gmail.com"
+ },
+ "private": true,
+ "type": "module",
+ "workspaces": [
+ "packages/*",
+ "apps/*"
+ ],
+ "main": "out/desktop/main.cjs",
+ "bin": {
+ "openpencil-mcp": "out/mcp-server.cjs"
+ },
+ "scripts": {
+ "dev": "cd apps/web && bun --bun vite dev --port 3000",
+ "build": "cd apps/web && bun --bun vite build",
+ "preview": "cd apps/web && bun --bun vite preview",
+ "test": "cd apps/web && bun --bun vitest run --passWithNoTests",
+ "mcp:compile": "cd apps/web && esbuild src/mcp/server.ts --bundle --platform=node --target=node20 --outfile=../../out/mcp-server.cjs --format=cjs --sourcemap --alias:@=src --define:import.meta.env={} --external:canvas --external:paper",
+ "mcp:dev": "bun run apps/web/src/mcp/server.ts",
+ "electron:dev": "bun run apps/desktop/dev.ts",
+ "electron:compile": "esbuild apps/desktop/main.ts apps/desktop/preload.ts --bundle --platform=node --target=node20 --outdir=out/desktop --external:electron --format=cjs --out-extension:.js=.cjs --sourcemap",
+ "electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config apps/desktop/electron-builder.yml",
+ "electron:build:mac-arm64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config apps/desktop/electron-builder.yml --mac --arm64 && if [ -f out/release/latest-mac.yml ]; then mv out/release/latest-mac.yml out/release/latest-mac-arm64.yml; fi",
+ "electron:build:mac-x64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config apps/desktop/electron-builder.yml --mac --x64",
+ "electron:build:mac-universal": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config apps/desktop/electron-builder.yml --mac --arm64 --x64",
+ "electron:build:mac-both": "bun run electron:build:mac-arm64 && bun run electron:build:mac-x64",
+ "bump": "sh -c 'V=$0; [ -z \"$V\" ] && echo \"Usage: bun run bump \" && exit 1; for f in package.json apps/*/package.json packages/*/package.json; do [ -f \"$f\" ] && jq --arg v \"$V\" \".version=\\$v\" \"$f\" > \"$f.tmp\" && mv \"$f.tmp\" \"$f\" && echo \"$f → $V\"; done'"
+ },
+ "dependencies": {
+ "@anthropic-ai/claude-agent-sdk": "^0.2.47",
+ "@anthropic-ai/sdk": "^0.77.0",
+ "@fontsource-variable/inter": "^5.2.8",
+ "@fontsource/dm-sans": "^5.2.8",
+ "@fontsource/inter": "^5.2.8",
+ "@fontsource/lato": "^5.2.7",
+ "@fontsource/montserrat": "^5.2.8",
+ "@fontsource/nunito": "^5.2.7",
+ "@fontsource/open-sans": "^5.2.7",
+ "@fontsource/playfair-display": "^5.2.8",
+ "@fontsource/poppins": "^5.2.7",
+ "@fontsource/raleway": "^5.2.8",
+ "@fontsource/roboto": "^5.2.10",
+ "@fontsource/source-sans-3": "^5.2.9",
+ "@github/copilot-sdk": "^0.1.32",
+ "@iconify-json/feather": "^1.2.1",
+ "@iconify-json/lucide": "^1.2.93",
+ "@iconify-json/simple-icons": "^1.2.71",
+ "@modelcontextprotocol/sdk": "^1.12.1",
+ "@opencode-ai/sdk": "1.2.6",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slider": "^1.3.6",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-toggle": "^1.1.10",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tailwindcss/vite": "^4.1.18",
+ "@tanstack/react-devtools": "^0.7.0",
+ "@tanstack/react-router": "^1.132.0",
+ "@tanstack/react-router-devtools": "^1.132.0",
+ "@tanstack/react-router-ssr-query": "^1.131.7",
+ "@tanstack/react-start": "^1.132.0",
+ "@tanstack/router-plugin": "^1.132.0",
+ "canvaskit-wasm": "^0.40.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "electron-updater": "^6.6.2",
+ "fzstd": "^0.1.1",
+ "html2canvas": "^1.4.1",
+ "i18next": "^25.8.14",
+ "i18next-browser-languagedetector": "^8.2.1",
+ "kiwi-schema": "^0.5.0",
+ "lucide-react": "^0.545.0",
+ "nanoid": "^5.1.6",
+ "nitro": "npm:nitro-nightly@latest",
+ "paper": "^0.12.18",
+ "rbush": "^4.0.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-i18next": "^16.5.4",
+ "tailwind-merge": "^3.4.1",
+ "tailwindcss": "^4.1.18",
+ "uzip": "^0.20201231.0",
+ "vite-tsconfig-paths": "^5.1.4",
+ "zustand": "^5.0.11"
+ },
+ "devDependencies": {
+ "@tanstack/devtools-vite": "^0.3.11",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/react": "^16.2.0",
+ "@types/node": "^22.10.2",
+ "@types/rbush": "^4.0.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@types/uzip": "^0.20201231.2",
+ "@vitejs/plugin-react": "^5.0.4",
+ "electron": "^35.0.0",
+ "electron-builder": "^26.0.0",
+ "esbuild": "^0.25.0",
+ "graceful-fs": "^4.2.11",
+ "jsdom": "^27.0.0",
+ "typescript": "^5.7.2",
+ "vite": "^7.1.7",
+ "vitest": "^3.0.5"
+ }
+}
diff --git a/packages/CLAUDE.md b/packages/CLAUDE.md
new file mode 100644
index 00000000..1cac7b8f
--- /dev/null
+++ b/packages/CLAUDE.md
@@ -0,0 +1,82 @@
+# Packages
+
+Shared libraries consumed by `apps/web` and `apps/desktop`. All re-exported via `@zseven-w/pen-sdk`.
+
+## pen-types (`pen-types/src/`)
+
+Type definitions (9 files):
+- `pen.ts` — PenDocument/PenNode (frame, group, rectangle, ellipse, line, polygon, path, text, image, ref), ContainerProps, `PenPage`; `PenDocument.variables`, `PenDocument.themes`, `PenDocument.pages`
+- `canvas.ts` — ToolType, ViewportState, SelectionState, CanvasInteraction
+- `styles.ts` — PenFill (solid, linear_gradient, radial_gradient), PenStroke, PenEffect, BlendMode, StyledTextSegment
+- `variables.ts` — `VariableDefinition`, `ThemedValue`, `VariableValue`
+- `uikit.ts` — UIKit, KitComponent, ComponentCategory types
+- `agent-settings.ts` — AI provider config types
+- `electron.d.ts` — Electron IPC bridge types
+- `theme-preset.ts` — Theme preset types
+- `opencode-sdk.d.ts` — Type declarations for @opencode-ai/sdk
+
+## pen-core (`pen-core/src/`)
+
+Core document operations (11 files + `layout/` + `variables/` subdirs):
+- `tree-utils.ts` — Pure tree helpers: `findNodeInTree`, `findParentInTree`, `removeNodeFromTree`, `updateNodeInTree`, `flattenNodes`, `insertNodeInTree`, `isDescendantOf`, `getNodeBounds`, `findClearX`, `scaleChildrenInPlace`, `rotateChildrenInPlace`, `createEmptyDocument`, `DEFAULT_FRAME_ID`; Clone utilities: `deepCloneNode`, `cloneNodeWithNewIds`, `cloneNodesWithNewIds` (canonical source for all node cloning)
+- `normalize.ts` — Pen file normalization (format fixes only, preserves `$variable` refs)
+- `boolean-ops.ts` — Union/subtract/intersect via Paper.js
+- `sync-lock.ts` — Prevents circular sync loops
+- `arc-path.ts` — SVG arc utilities
+- `font-utils.ts` — Font utilities
+- `node-helpers.ts` — Node helper functions
+- `constants.ts` — Core constants
+- `id.ts` — ID generation (`nanoid`)
+- `layout/engine.ts` — Auto-layout computation: `resolvePadding`, `getNodeWidth/Height`, `computeLayoutPositions`
+- `layout/text-measure.ts` — Text width/height estimation, CJK detection, `parseSizing`
+- `variables/resolve.ts` — Core resolution: `resolveVariableRef`, `resolveNodeForCanvas`, `getDefaultTheme`, `isVariableRef`
+- `variables/replace-refs.ts` — `replaceVariableRefsInTree`: recursively walk node tree to replace/resolve `$refs`
+
+## pen-codegen (`pen-codegen/src/`)
+
+Multi-platform code generators (9 files, output `var(--name)` for `$variable` refs):
+- `react-generator.ts` — React + Tailwind CSS
+- `html-generator.ts` — HTML + CSS
+- `css-variables-generator.ts` — CSS Variables from design tokens
+- `vue-generator.ts` — Vue 3 + CSS
+- `svelte-generator.ts` — Svelte + CSS
+- `flutter-generator.ts` — Flutter/Dart
+- `swiftui-generator.ts` — SwiftUI
+- `compose-generator.ts` — Android Jetpack Compose
+- `react-native-generator.ts` — React Native
+
+## pen-figma (`pen-figma/src/`)
+
+Figma `.fig` file import pipeline (17 files):
+- `fig-parser.ts` — Binary `.fig` file parser
+- `figma-node-mapper.ts` — Maps Figma nodes to PenNodes (uses injectable icon lookup via `setIconLookup()`)
+- `figma-node-converters.ts` — Figma node conversion utilities
+- `figma-fill-mapper.ts`, `figma-stroke-mapper.ts`, `figma-effect-mapper.ts` — Style converters
+- `figma-layout-mapper.ts` — Maps Figma auto-layout to PenNode layout props
+- `figma-text-mapper.ts` — Converts Figma text styles
+- `figma-vector-decoder.ts` — Decodes Figma vector geometry
+- `figma-color-utils.ts` — Color space conversion utilities
+- `figma-image-resolver.ts` — Resolves image blob references
+- `figma-clipboard.ts` — Figma clipboard paste handling
+- `figma-tree-builder.ts` — Figma document tree building
+- `figma-types.ts` — Figma internal type definitions
+
+## pen-renderer (`pen-renderer/src/`)
+
+Standalone CanvasKit/Skia renderer (13 files):
+- `renderer.ts` — Core renderer class
+- `document-flattener.ts` — Document tree flattening with layout resolution
+- `node-renderer.ts` — Node-level draw calls
+- `text-renderer.ts` — Text rendering
+- `paint-utils.ts` — Color parsing, gradient creation
+- `path-utils.ts` — SVG path conversion
+- `image-loader.ts` — Async image loading and caching
+- `font-manager.ts` — Font management
+- `spatial-index.ts` — R-tree backed spatial queries
+- `viewport.ts` — Viewport math
+- `init.ts` — CanvasKit WASM loader
+- `types.ts` — Renderer-specific types
+
+## pen-sdk (`pen-sdk/src/`)
+
+Umbrella SDK (1 file): `index.ts` re-exports all packages.
diff --git a/packages/pen-codegen/package.json b/packages/pen-codegen/package.json
new file mode 100644
index 00000000..7266e5fd
--- /dev/null
+++ b/packages/pen-codegen/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@zseven-w/pen-codegen",
+ "version": "0.5.0",
+ "description": "Multi-platform code generators for OpenPencil designs",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
+ "files": [
+ "src"
+ ],
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@zseven-w/pen-types": "workspace:*",
+ "@zseven-w/pen-core": "workspace:*"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/packages/pen-codegen/src/__tests__/codegen.test.ts b/packages/pen-codegen/src/__tests__/codegen.test.ts
new file mode 100644
index 00000000..6814bb28
--- /dev/null
+++ b/packages/pen-codegen/src/__tests__/codegen.test.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect } from 'vitest'
+import type { PenNode, PenDocument } from '@zseven-w/pen-types'
+import { generateReactCode, generateReactFromDocument } from '../react-generator'
+import { generateHTMLCode, generateHTMLFromDocument } from '../html-generator'
+import { generateCSSVariables, variableNameToCSS } from '../css-variables-generator'
+
+const simpleFrame: PenNode = {
+ id: 'f1',
+ type: 'frame',
+ name: 'Card',
+ x: 0, y: 0,
+ width: 300, height: 200,
+ fill: [{ type: 'solid', color: '#ffffff' }],
+ cornerRadius: 8,
+ children: [
+ {
+ id: 't1',
+ type: 'text',
+ content: 'Hello World',
+ fontSize: 16,
+ fontWeight: 600,
+ x: 16, y: 16,
+ },
+ ],
+}
+
+const docWithVars: PenDocument = {
+ version: '1.0.0',
+ variables: {
+ 'primary': { type: 'color', value: '#3b82f6' },
+ 'spacing': { type: 'number', value: 16 },
+ },
+ themes: { 'Theme-1': ['Light', 'Dark'] },
+ children: [simpleFrame],
+}
+
+describe('codegen', () => {
+ describe('variableNameToCSS', () => {
+ it('converts variable name to CSS custom property name', () => {
+ expect(variableNameToCSS('primary-color')).toBe('--primary-color')
+ })
+ })
+
+ describe('generateReactCode', () => {
+ it('generates React/Tailwind code for nodes', () => {
+ const code = generateReactCode([simpleFrame])
+ expect(code).toContain('Card')
+ expect(code).toContain('Hello World')
+ })
+ })
+
+ describe('generateReactFromDocument', () => {
+ it('generates from a document', () => {
+ const code = generateReactFromDocument(docWithVars)
+ expect(code).toContain('Hello World')
+ })
+ })
+
+ describe('generateHTMLCode', () => {
+ it('generates HTML and CSS', () => {
+ const { html, css } = generateHTMLCode([simpleFrame])
+ expect(html).toContain('Hello World')
+ expect(html).toContain('card')
+ expect(css).toBeTruthy()
+ })
+ })
+
+ describe('generateHTMLFromDocument', () => {
+ it('generates from a document with CSS variables', () => {
+ const { html, css } = generateHTMLFromDocument(docWithVars)
+ expect(html).toContain('Hello World')
+ expect(css).toBeTruthy()
+ })
+ })
+
+ describe('generateCSSVariables', () => {
+ it('generates CSS variables from document', () => {
+ const css = generateCSSVariables(docWithVars)
+ expect(css).toContain('--primary')
+ expect(css).toContain('#3b82f6')
+ })
+
+ it('returns empty for document without variables', () => {
+ const doc: PenDocument = { version: '1.0.0', children: [] }
+ const css = generateCSSVariables(doc)
+ expect(css).toContain('No design variables')
+ })
+ })
+})
diff --git a/src/services/codegen/compose-generator.ts b/packages/pen-codegen/src/compose-generator.ts
similarity index 98%
rename from src/services/codegen/compose-generator.ts
rename to packages/pen-codegen/src/compose-generator.ts
index 2c0a0f28..48f0a254 100644
--- a/src/services/codegen/compose-generator.ts
+++ b/packages/pen-codegen/src/compose-generator.ts
@@ -1,8 +1,8 @@
-import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, EllipseNode, LineNode, PathNode, PolygonNode } from '@/types/pen'
-import { getActivePageChildren } from '@/stores/document-tree-utils'
-import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
-import { isVariableRef } from '@/variables/resolve-variables'
-import { variableNameToCSS } from '@/services/codegen/css-variables-generator'
+import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, EllipseNode, LineNode, PathNode, PolygonNode } from '@zseven-w/pen-types'
+import { getActivePageChildren } from '@zseven-w/pen-core'
+import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
+import { isVariableRef } from '@zseven-w/pen-core'
+import { variableNameToCSS } from './css-variables-generator.js'
/**
* Converts PenDocument nodes to Jetpack Compose (Kotlin) code.
diff --git a/src/services/codegen/css-variables-generator.ts b/packages/pen-codegen/src/css-variables-generator.ts
similarity index 97%
rename from src/services/codegen/css-variables-generator.ts
rename to packages/pen-codegen/src/css-variables-generator.ts
index 74a2b1c9..08e25d25 100644
--- a/src/services/codegen/css-variables-generator.ts
+++ b/packages/pen-codegen/src/css-variables-generator.ts
@@ -4,8 +4,8 @@
* Produces `:root { ... }` blocks with one block per theme variant.
*/
-import type { PenDocument } from '@/types/pen'
-import type { VariableDefinition, ThemedValue } from '@/types/variables'
+import type { PenDocument } from '@zseven-w/pen-types'
+import type { VariableDefinition, ThemedValue } from '@zseven-w/pen-types'
/** Sanitise a variable name into a valid CSS custom property name. */
export function variableNameToCSS(name: string): string {
diff --git a/src/services/codegen/flutter-generator.ts b/packages/pen-codegen/src/flutter-generator.ts
similarity index 98%
rename from src/services/codegen/flutter-generator.ts
rename to packages/pen-codegen/src/flutter-generator.ts
index 1cccee1b..d3bd0712 100644
--- a/src/services/codegen/flutter-generator.ts
+++ b/packages/pen-codegen/src/flutter-generator.ts
@@ -1,8 +1,8 @@
-import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, EllipseNode, LineNode, PathNode, PolygonNode } from '@/types/pen'
-import { getActivePageChildren } from '@/stores/document-tree-utils'
-import type { PenFill, PenStroke, PenEffect, ShadowEffect, BlurEffect } from '@/types/styles'
-import { isVariableRef } from '@/variables/resolve-variables'
-import { variableNameToCSS } from '@/services/codegen/css-variables-generator'
+import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, EllipseNode, LineNode, PathNode, PolygonNode } from '@zseven-w/pen-types'
+import { getActivePageChildren } from '@zseven-w/pen-core'
+import type { PenFill, PenStroke, PenEffect, ShadowEffect, BlurEffect } from '@zseven-w/pen-types'
+import { isVariableRef } from '@zseven-w/pen-core'
+import { variableNameToCSS } from './css-variables-generator.js'
/**
* Converts PenDocument nodes to Flutter (Dart) code.
diff --git a/src/services/codegen/html-generator.ts b/packages/pen-codegen/src/html-generator.ts
similarity index 97%
rename from src/services/codegen/html-generator.ts
rename to packages/pen-codegen/src/html-generator.ts
index 8536f40f..237ee41c 100644
--- a/src/services/codegen/html-generator.ts
+++ b/packages/pen-codegen/src/html-generator.ts
@@ -1,9 +1,9 @@
-import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen'
-import { getActivePageChildren } from '@/stores/document-tree-utils'
-import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
-import { isVariableRef } from '@/variables/resolve-variables'
-import { variableNameToCSS, generateCSSVariables } from '@/services/codegen/css-variables-generator'
-import { buildEllipseArcPath, isArcEllipse } from '@/utils/arc-path'
+import type { PenDocument, PenNode, ContainerProps, TextNode } from '@zseven-w/pen-types'
+import { getActivePageChildren } from '@zseven-w/pen-core'
+import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
+import { isVariableRef } from '@zseven-w/pen-core'
+import { variableNameToCSS, generateCSSVariables } from './css-variables-generator.js'
+import { buildEllipseArcPath, isArcEllipse } from '@zseven-w/pen-core'
/**
* Converts PenDocument nodes to HTML + CSS.
diff --git a/packages/pen-codegen/src/index.ts b/packages/pen-codegen/src/index.ts
new file mode 100644
index 00000000..f5d75503
--- /dev/null
+++ b/packages/pen-codegen/src/index.ts
@@ -0,0 +1,26 @@
+// CSS Variables
+export { variableNameToCSS, generateCSSVariables } from './css-variables-generator.js'
+
+// React + Tailwind
+export { generateReactCode, generateReactFromDocument } from './react-generator.js'
+
+// HTML + CSS
+export { generateHTMLCode, generateHTMLFromDocument } from './html-generator.js'
+
+// Vue 3
+export { generateVueCode, generateVueFromDocument } from './vue-generator.js'
+
+// Svelte
+export { generateSvelteCode, generateSvelteFromDocument } from './svelte-generator.js'
+
+// Flutter / Dart
+export { generateFlutterCode, generateFlutterFromDocument } from './flutter-generator.js'
+
+// SwiftUI
+export { generateSwiftUICode, generateSwiftUIFromDocument } from './swiftui-generator.js'
+
+// Android Jetpack Compose
+export { generateComposeCode, generateComposeFromDocument } from './compose-generator.js'
+
+// React Native
+export { generateReactNativeCode, generateReactNativeFromDocument } from './react-native-generator.js'
diff --git a/src/services/codegen/react-generator.ts b/packages/pen-codegen/src/react-generator.ts
similarity index 97%
rename from src/services/codegen/react-generator.ts
rename to packages/pen-codegen/src/react-generator.ts
index a6b910fd..96980a05 100644
--- a/src/services/codegen/react-generator.ts
+++ b/packages/pen-codegen/src/react-generator.ts
@@ -1,9 +1,9 @@
-import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen'
-import { getActivePageChildren } from '@/stores/document-tree-utils'
-import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
-import { isVariableRef } from '@/variables/resolve-variables'
-import { variableNameToCSS } from '@/services/codegen/css-variables-generator'
-import { buildEllipseArcPath, isArcEllipse } from '@/utils/arc-path'
+import type { PenDocument, PenNode, ContainerProps, TextNode } from '@zseven-w/pen-types'
+import { getActivePageChildren } from '@zseven-w/pen-core'
+import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
+import { isVariableRef } from '@zseven-w/pen-core'
+import { variableNameToCSS } from './css-variables-generator.js'
+import { buildEllipseArcPath, isArcEllipse } from '@zseven-w/pen-core'
/**
* Converts PenDocument nodes to React + Tailwind code.
diff --git a/src/services/codegen/react-native-generator.ts b/packages/pen-codegen/src/react-native-generator.ts
similarity index 98%
rename from src/services/codegen/react-native-generator.ts
rename to packages/pen-codegen/src/react-native-generator.ts
index 78da165e..4c92bd73 100644
--- a/src/services/codegen/react-native-generator.ts
+++ b/packages/pen-codegen/src/react-native-generator.ts
@@ -1,8 +1,8 @@
-import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, LineNode, PathNode, PolygonNode } from '@/types/pen'
-import { getActivePageChildren } from '@/stores/document-tree-utils'
-import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
-import { isVariableRef } from '@/variables/resolve-variables'
-import { variableNameToCSS } from '@/services/codegen/css-variables-generator'
+import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, LineNode, PathNode, PolygonNode } from '@zseven-w/pen-types'
+import { getActivePageChildren } from '@zseven-w/pen-core'
+import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
+import { isVariableRef } from '@zseven-w/pen-core'
+import { variableNameToCSS } from './css-variables-generator.js'
/**
* Converts PenDocument nodes to React Native code with inline styles.
diff --git a/src/services/codegen/svelte-generator.ts b/packages/pen-codegen/src/svelte-generator.ts
similarity index 97%
rename from src/services/codegen/svelte-generator.ts
rename to packages/pen-codegen/src/svelte-generator.ts
index 5566bda5..43fdd498 100644
--- a/src/services/codegen/svelte-generator.ts
+++ b/packages/pen-codegen/src/svelte-generator.ts
@@ -1,9 +1,9 @@
-import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen'
-import { getActivePageChildren } from '@/stores/document-tree-utils'
-import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
-import { isVariableRef } from '@/variables/resolve-variables'
-import { variableNameToCSS } from '@/services/codegen/css-variables-generator'
-import { buildEllipseArcPath, isArcEllipse } from '@/utils/arc-path'
+import type { PenDocument, PenNode, ContainerProps, TextNode } from '@zseven-w/pen-types'
+import { getActivePageChildren } from '@zseven-w/pen-core'
+import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
+import { isVariableRef } from '@zseven-w/pen-core'
+import { variableNameToCSS } from './css-variables-generator.js'
+import { buildEllipseArcPath, isArcEllipse } from '@zseven-w/pen-core'
/**
* Converts PenDocument nodes to Svelte component code.
diff --git a/src/services/codegen/swiftui-generator.ts b/packages/pen-codegen/src/swiftui-generator.ts
similarity index 98%
rename from src/services/codegen/swiftui-generator.ts
rename to packages/pen-codegen/src/swiftui-generator.ts
index ba767251..8c06cf47 100644
--- a/src/services/codegen/swiftui-generator.ts
+++ b/packages/pen-codegen/src/swiftui-generator.ts
@@ -1,8 +1,8 @@
-import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, LineNode, PathNode, PolygonNode } from '@/types/pen'
-import { getActivePageChildren } from '@/stores/document-tree-utils'
-import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
-import { isVariableRef } from '@/variables/resolve-variables'
-import { variableNameToCSS } from '@/services/codegen/css-variables-generator'
+import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, LineNode, PathNode, PolygonNode } from '@zseven-w/pen-types'
+import { getActivePageChildren } from '@zseven-w/pen-core'
+import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
+import { isVariableRef } from '@zseven-w/pen-core'
+import { variableNameToCSS } from './css-variables-generator.js'
/**
* Converts PenDocument nodes to SwiftUI code.
diff --git a/src/services/codegen/vue-generator.ts b/packages/pen-codegen/src/vue-generator.ts
similarity index 97%
rename from src/services/codegen/vue-generator.ts
rename to packages/pen-codegen/src/vue-generator.ts
index 2a2439b6..3b22901d 100644
--- a/src/services/codegen/vue-generator.ts
+++ b/packages/pen-codegen/src/vue-generator.ts
@@ -1,9 +1,9 @@
-import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen'
-import { getActivePageChildren } from '@/stores/document-tree-utils'
-import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
-import { isVariableRef } from '@/variables/resolve-variables'
-import { variableNameToCSS } from '@/services/codegen/css-variables-generator'
-import { buildEllipseArcPath, isArcEllipse } from '@/utils/arc-path'
+import type { PenDocument, PenNode, ContainerProps, TextNode } from '@zseven-w/pen-types'
+import { getActivePageChildren } from '@zseven-w/pen-core'
+import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
+import { isVariableRef } from '@zseven-w/pen-core'
+import { variableNameToCSS } from './css-variables-generator.js'
+import { buildEllipseArcPath, isArcEllipse } from '@zseven-w/pen-core'
/**
* Converts PenDocument nodes to Vue 3 Single File Component (SFC) code.
diff --git a/packages/pen-codegen/tsconfig.json b/packages/pen-codegen/tsconfig.json
new file mode 100644
index 00000000..df59da57
--- /dev/null
+++ b/packages/pen-codegen/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/pen-core/package.json b/packages/pen-core/package.json
new file mode 100644
index 00000000..57e1d9f8
--- /dev/null
+++ b/packages/pen-core/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@zseven-w/pen-core",
+ "version": "0.5.0",
+ "description": "Core document operations, tree utils, variables, layout engine for OpenPencil",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
+ "files": [
+ "src"
+ ],
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@zseven-w/pen-types": "workspace:*",
+ "nanoid": "^5.1.6",
+ "paper": "^0.12.18"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/packages/pen-core/src/__tests__/arc-path.test.ts b/packages/pen-core/src/__tests__/arc-path.test.ts
new file mode 100644
index 00000000..0571831f
--- /dev/null
+++ b/packages/pen-core/src/__tests__/arc-path.test.ts
@@ -0,0 +1,39 @@
+import { describe, it, expect } from 'vitest'
+import { buildEllipseArcPath, isArcEllipse } from '../arc-path'
+
+describe('arc-path', () => {
+ describe('isArcEllipse', () => {
+ it('returns false for full circle with no inner radius', () => {
+ expect(isArcEllipse(0, 360, 0)).toBe(false)
+ })
+
+ it('returns true for partial sweep', () => {
+ expect(isArcEllipse(0, 180, 0)).toBe(true)
+ })
+
+ it('returns true for inner radius (donut)', () => {
+ expect(isArcEllipse(0, 360, 0.5)).toBe(true)
+ })
+ })
+
+ describe('buildEllipseArcPath', () => {
+ it('builds a full circle path', () => {
+ const path = buildEllipseArcPath(100, 100, 0, 360, 0)
+ expect(path).toContain('M')
+ expect(path).toContain('A')
+ expect(path).toContain('Z')
+ })
+
+ it('builds a pie slice path', () => {
+ const path = buildEllipseArcPath(100, 100, 0, 90, 0)
+ expect(path).toContain('M50 50') // center point for pie
+ expect(path).toContain('Z')
+ })
+
+ it('builds a donut path with inner radius', () => {
+ const path = buildEllipseArcPath(100, 100, 0, 360, 0.5)
+ // Should have inner arc commands
+ expect(path.split('A').length).toBeGreaterThanOrEqual(3) // outer + inner arcs
+ })
+ })
+})
diff --git a/packages/pen-core/src/__tests__/font-utils.test.ts b/packages/pen-core/src/__tests__/font-utils.test.ts
new file mode 100644
index 00000000..feafe37c
--- /dev/null
+++ b/packages/pen-core/src/__tests__/font-utils.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest'
+import { cssFontFamily } from '../font-utils'
+
+describe('cssFontFamily', () => {
+ it('quotes multi-word font names', () => {
+ expect(cssFontFamily('Noto Sans SC')).toBe('"Noto Sans SC"')
+ })
+
+ it('does not quote generic families', () => {
+ expect(cssFontFamily('sans-serif')).toBe('sans-serif')
+ expect(cssFontFamily('monospace')).toBe('monospace')
+ expect(cssFontFamily('system-ui')).toBe('system-ui')
+ })
+
+ it('handles comma-separated lists', () => {
+ expect(cssFontFamily('Inter, sans-serif')).toBe('"Inter", sans-serif')
+ })
+
+ it('preserves already-quoted names', () => {
+ expect(cssFontFamily('"Noto Sans SC"')).toBe('"Noto Sans SC"')
+ })
+
+ it('handles -apple-system', () => {
+ expect(cssFontFamily('-apple-system')).toBe('-apple-system')
+ })
+})
diff --git a/packages/pen-core/src/__tests__/layout-engine.test.ts b/packages/pen-core/src/__tests__/layout-engine.test.ts
new file mode 100644
index 00000000..1b3c3e91
--- /dev/null
+++ b/packages/pen-core/src/__tests__/layout-engine.test.ts
@@ -0,0 +1,153 @@
+import { describe, it, expect } from 'vitest'
+import type { PenNode } from '@zseven-w/pen-types'
+import {
+ resolvePadding,
+ isNodeVisible,
+ inferLayout,
+ getNodeWidth,
+ getNodeHeight,
+ computeLayoutPositions,
+} from '../layout/engine'
+
+const frame = (props: Partial & { children?: PenNode[] }): PenNode => ({
+ id: 'f1', type: 'frame', x: 0, y: 0, ...props,
+} as PenNode)
+
+const rect = (id: string, w = 50, h = 30): PenNode => ({
+ id, type: 'rectangle', x: 0, y: 0, width: w, height: h,
+})
+
+describe('layout-engine', () => {
+ describe('resolvePadding', () => {
+ it('returns zero for undefined', () => {
+ expect(resolvePadding(undefined)).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
+ })
+
+ it('resolves uniform padding', () => {
+ expect(resolvePadding(10)).toEqual({ top: 10, right: 10, bottom: 10, left: 10 })
+ })
+
+ it('resolves [vertical, horizontal]', () => {
+ expect(resolvePadding([10, 20])).toEqual({ top: 10, right: 20, bottom: 10, left: 20 })
+ })
+
+ it('resolves [top, right, bottom, left]', () => {
+ expect(resolvePadding([1, 2, 3, 4])).toEqual({ top: 1, right: 2, bottom: 3, left: 4 })
+ })
+
+ it('returns zero for string (variable ref)', () => {
+ expect(resolvePadding('$spacing')).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
+ })
+ })
+
+ describe('isNodeVisible', () => {
+ it('returns true by default', () => {
+ expect(isNodeVisible(rect('a'))).toBe(true)
+ })
+
+ it('returns false when visible is false', () => {
+ expect(isNodeVisible({ ...rect('a'), visible: false })).toBe(false)
+ })
+ })
+
+ describe('inferLayout', () => {
+ it('returns undefined for non-frame nodes', () => {
+ expect(inferLayout(rect('a'))).toBeUndefined()
+ })
+
+ it('infers horizontal when gap is set', () => {
+ expect(inferLayout(frame({ gap: 10, children: [] }))).toBe('horizontal')
+ })
+
+ it('infers horizontal when padding is set', () => {
+ expect(inferLayout(frame({ padding: 10, children: [] }))).toBe('horizontal')
+ })
+
+ it('returns undefined when no layout hints', () => {
+ expect(inferLayout(frame({ children: [rect('a')] }))).toBeUndefined()
+ })
+ })
+
+ describe('getNodeWidth / getNodeHeight', () => {
+ it('returns explicit width', () => {
+ expect(getNodeWidth(rect('a', 200))).toBe(200)
+ })
+
+ it('returns explicit height', () => {
+ expect(getNodeHeight(rect('a', 50, 100))).toBe(100)
+ })
+
+ it('estimates text width', () => {
+ const text: PenNode = { id: 't', type: 'text', content: 'Hello World', fontSize: 16 }
+ expect(getNodeWidth(text)).toBeGreaterThan(0)
+ })
+ })
+
+ describe('computeLayoutPositions', () => {
+ it('positions children horizontally', () => {
+ const parent = frame({
+ width: 300, height: 100,
+ layout: 'horizontal', gap: 10,
+ children: [rect('a', 50, 30), rect('b', 50, 30)],
+ })
+ const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
+ expect(result[0].x).toBe(0)
+ expect(result[0].y).toBe(0)
+ expect(result[1].x).toBe(60) // 50 + 10 gap
+ })
+
+ it('positions children vertically', () => {
+ const parent = frame({
+ width: 100, height: 300,
+ layout: 'vertical', gap: 10,
+ children: [rect('a', 50, 30), rect('b', 50, 30)],
+ })
+ const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
+ expect(result[0].x).toBe(0)
+ expect(result[0].y).toBe(0)
+ expect(result[1].y).toBe(40) // 30 + 10 gap
+ })
+
+ it('applies padding', () => {
+ const parent = frame({
+ width: 300, height: 100,
+ layout: 'horizontal', padding: 20,
+ children: [rect('a', 50, 30)],
+ })
+ const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
+ expect(result[0].x).toBe(20)
+ expect(result[0].y).toBe(20)
+ })
+
+ it('centers children on cross axis', () => {
+ const parent = frame({
+ width: 300, height: 100,
+ layout: 'horizontal', alignItems: 'center',
+ children: [rect('a', 50, 30)],
+ })
+ const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
+ expect(result[0].y).toBe(35) // (100 - 30) / 2
+ })
+
+ it('filters invisible children', () => {
+ const parent = frame({
+ width: 300, height: 100,
+ layout: 'horizontal',
+ children: [rect('a', 50, 30), { ...rect('b', 50, 30), visible: false }],
+ })
+ const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
+ expect(result).toHaveLength(1)
+ })
+
+ it('returns visible children as-is when layout is none', () => {
+ const parent = frame({
+ width: 300, height: 100,
+ layout: 'none',
+ children: [rect('a', 50, 30)],
+ })
+ const result = computeLayoutPositions(parent, (parent as PenNode & { children: PenNode[] }).children)
+ expect(result).toHaveLength(1)
+ expect(result[0].id).toBe('a')
+ })
+ })
+})
diff --git a/packages/pen-core/src/__tests__/node-helpers.test.ts b/packages/pen-core/src/__tests__/node-helpers.test.ts
new file mode 100644
index 00000000..4c57a076
--- /dev/null
+++ b/packages/pen-core/src/__tests__/node-helpers.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest'
+import type { PenNode } from '@zseven-w/pen-types'
+import { isBadgeOverlayNode } from '../node-helpers'
+
+describe('isBadgeOverlayNode', () => {
+ it('returns true for badge role', () => {
+ const node: PenNode = { id: '1', type: 'rectangle', role: 'badge' }
+ expect(isBadgeOverlayNode(node)).toBe(true)
+ })
+
+ it('returns true for pill role', () => {
+ const node: PenNode = { id: '1', type: 'rectangle', role: 'pill' }
+ expect(isBadgeOverlayNode(node)).toBe(true)
+ })
+
+ it('returns true for name containing "badge"', () => {
+ const node: PenNode = { id: '1', type: 'rectangle', name: 'Notification Badge' }
+ expect(isBadgeOverlayNode(node)).toBe(true)
+ })
+
+ it('returns true for name containing "overlay"', () => {
+ const node: PenNode = { id: '1', type: 'rectangle', name: 'Image Overlay' }
+ expect(isBadgeOverlayNode(node)).toBe(true)
+ })
+
+ it('returns false for regular nodes', () => {
+ const node: PenNode = { id: '1', type: 'rectangle', name: 'Button' }
+ expect(isBadgeOverlayNode(node)).toBe(false)
+ })
+})
diff --git a/packages/pen-core/src/__tests__/normalize.test.ts b/packages/pen-core/src/__tests__/normalize.test.ts
new file mode 100644
index 00000000..6469cf7d
--- /dev/null
+++ b/packages/pen-core/src/__tests__/normalize.test.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect } from 'vitest'
+import type { PenDocument } from '@zseven-w/pen-types'
+import { normalizePenDocument } from '../normalize'
+
+describe('normalizePenDocument', () => {
+ it('normalizes "color" fill type to "solid"', () => {
+ const doc: PenDocument = {
+ version: '1.0.0',
+ children: [{
+ id: '1', type: 'rectangle', x: 0, y: 0,
+ fill: [{ type: 'color' as 'solid', color: '#ff0000' }],
+ }],
+ }
+ const result = normalizePenDocument(doc)
+ const fill = (result.children[0] as { fill: Array<{ type: string }> }).fill
+ expect(fill[0].type).toBe('solid')
+ })
+
+ it('normalizes string fill shorthand to solid fill array', () => {
+ const doc: PenDocument = {
+ version: '1.0.0',
+ children: [{
+ id: '1', type: 'rectangle', x: 0, y: 0,
+ fill: '#ff0000' as unknown as Array<{ type: 'solid'; color: string }>,
+ }],
+ }
+ const result = normalizePenDocument(doc)
+ const fill = (result.children[0] as { fill: Array<{ type: string; color: string }> }).fill
+ expect(fill).toHaveLength(1)
+ expect(fill[0]).toEqual({ type: 'solid', color: '#ff0000' })
+ })
+
+ it('normalizes fill_container sizing', () => {
+ const doc: PenDocument = {
+ version: '1.0.0',
+ children: [{
+ id: '1', type: 'frame', x: 0, y: 0,
+ width: 'fill_container(300)' as unknown as number,
+ height: 'fill_container' as unknown as number,
+ children: [],
+ }],
+ }
+ const result = normalizePenDocument(doc)
+ const node = result.children[0] as { width: unknown; height: unknown }
+ expect(node.width).toBe('fill_container')
+ expect(node.height).toBe('fill_container')
+ })
+
+ it('normalizes fit_content with hint to number', () => {
+ const doc: PenDocument = {
+ version: '1.0.0',
+ children: [{
+ id: '1', type: 'frame', x: 0, y: 0,
+ width: 'fit_content(250)' as unknown as number,
+ height: 'fit_content' as unknown as number,
+ children: [],
+ }],
+ }
+ const result = normalizePenDocument(doc)
+ const node = result.children[0] as { width: unknown; height: unknown }
+ expect(node.width).toBe(250)
+ expect(node.height).toBe('fit_content')
+ })
+
+ it('normalizes pages children too', () => {
+ const doc: PenDocument = {
+ version: '1.0.0',
+ pages: [{
+ id: 'p1', name: 'Page 1',
+ children: [{
+ id: '1', type: 'rectangle', x: 0, y: 0,
+ fill: [{ type: 'color' as 'solid', color: '#00ff00' }],
+ }],
+ }],
+ children: [],
+ }
+ const result = normalizePenDocument(doc)
+ const fill = (result.pages![0].children[0] as { fill: Array<{ type: string }> }).fill
+ expect(fill[0].type).toBe('solid')
+ })
+
+ it('normalizes string elements inside fill array', () => {
+ const doc: PenDocument = {
+ version: '1.0.0',
+ children: [{
+ id: '1', type: 'path', d: 'M0 0', x: 0, y: 0,
+ fill: ['#ff0000'] as unknown as Array<{ type: 'solid'; color: string }>,
+ }],
+ }
+ const result = normalizePenDocument(doc)
+ const fill = (result.children[0] as { fill: Array<{ type: string; color: string }> }).fill
+ expect(fill).toHaveLength(1)
+ expect(fill[0]).toEqual({ type: 'solid', color: '#ff0000' })
+ })
+
+ it('preserves $variable references', () => {
+ const doc: PenDocument = {
+ version: '1.0.0',
+ children: [{
+ id: '1', type: 'rectangle', x: 0, y: 0,
+ fill: [{ type: 'solid', color: '$primary' }],
+ opacity: '$opacity' as unknown as number,
+ }],
+ }
+ const result = normalizePenDocument(doc)
+ const node = result.children[0] as { fill: Array<{ color: string }>; opacity: unknown }
+ expect(node.fill[0].color).toBe('$primary')
+ expect(node.opacity).toBe('$opacity')
+ })
+})
diff --git a/packages/pen-core/src/__tests__/text-measure.test.ts b/packages/pen-core/src/__tests__/text-measure.test.ts
new file mode 100644
index 00000000..af0c63c9
--- /dev/null
+++ b/packages/pen-core/src/__tests__/text-measure.test.ts
@@ -0,0 +1,147 @@
+import { describe, it, expect } from 'vitest'
+import type { PenNode } from '@zseven-w/pen-types'
+import {
+ parseSizing,
+ defaultLineHeight,
+ isCjkCodePoint,
+ hasCjkText,
+ estimateGlyphWidth,
+ estimateLineWidth,
+ estimateTextWidth,
+ resolveTextContent,
+ countExplicitTextLines,
+ estimateTextHeight,
+} from '../layout/text-measure'
+
+describe('text-measure', () => {
+ describe('parseSizing', () => {
+ it('returns number for number input', () => {
+ expect(parseSizing(100)).toBe(100)
+ })
+
+ it('returns "fill" for fill_container', () => {
+ expect(parseSizing('fill_container')).toBe('fill')
+ })
+
+ it('returns "fit" for fit_content', () => {
+ expect(parseSizing('fit_content')).toBe('fit')
+ })
+
+ it('parses numeric strings', () => {
+ expect(parseSizing('200')).toBe(200)
+ })
+
+ it('returns 0 for non-parseable', () => {
+ expect(parseSizing(undefined)).toBe(0)
+ expect(parseSizing('abc')).toBe(0)
+ })
+ })
+
+ describe('defaultLineHeight', () => {
+ it('returns tight leading for display text', () => {
+ expect(defaultLineHeight(48)).toBe(1.0)
+ })
+
+ it('returns comfortable leading for body text', () => {
+ expect(defaultLineHeight(14)).toBe(1.5)
+ })
+ })
+
+ describe('CJK detection', () => {
+ it('detects CJK code points', () => {
+ expect(isCjkCodePoint('中'.codePointAt(0)!)).toBe(true)
+ expect(isCjkCodePoint('あ'.codePointAt(0)!)).toBe(true)
+ expect(isCjkCodePoint('A'.codePointAt(0)!)).toBe(false)
+ })
+
+ it('hasCjkText detects CJK in strings', () => {
+ expect(hasCjkText('Hello 世界')).toBe(true)
+ expect(hasCjkText('Hello World')).toBe(false)
+ })
+ })
+
+ describe('estimateGlyphWidth', () => {
+ it('returns 0 for newline', () => {
+ expect(estimateGlyphWidth('\n', 16)).toBe(0)
+ })
+
+ it('estimates CJK wider than Latin', () => {
+ const cjk = estimateGlyphWidth('中', 16)
+ const latin = estimateGlyphWidth('a', 16)
+ expect(cjk).toBeGreaterThan(latin)
+ })
+
+ it('estimates uppercase wider than lowercase', () => {
+ const upper = estimateGlyphWidth('A', 16)
+ const lower = estimateGlyphWidth('a', 16)
+ expect(upper).toBeGreaterThan(lower)
+ })
+ })
+
+ describe('estimateLineWidth', () => {
+ it('estimates width of a line', () => {
+ const width = estimateLineWidth('Hello', 16)
+ expect(width).toBeGreaterThan(0)
+ })
+
+ it('adds letter spacing', () => {
+ const base = estimateLineWidth('AB', 16, 0)
+ const spaced = estimateLineWidth('AB', 16, 5)
+ expect(spaced).toBeGreaterThan(base)
+ })
+ })
+
+ describe('estimateTextWidth', () => {
+ it('returns the widest line', () => {
+ const width = estimateTextWidth('short\nmuch longer line', 16)
+ const singleWidth = estimateTextWidth('much longer line', 16)
+ // Multi-line should return width of longest line
+ expect(width).toBeCloseTo(singleWidth, 0)
+ })
+ })
+
+ describe('resolveTextContent', () => {
+ it('resolves string content', () => {
+ const node: PenNode = { id: '1', type: 'text', content: 'Hello' }
+ expect(resolveTextContent(node)).toBe('Hello')
+ })
+
+ it('resolves styled segment content', () => {
+ const node: PenNode = {
+ id: '1', type: 'text',
+ content: [{ text: 'Hello ' }, { text: 'World' }],
+ }
+ expect(resolveTextContent(node)).toBe('Hello World')
+ })
+
+ it('returns empty for non-text nodes', () => {
+ const node: PenNode = { id: '1', type: 'rectangle' }
+ expect(resolveTextContent(node)).toBe('')
+ })
+ })
+
+ describe('countExplicitTextLines', () => {
+ it('counts newlines', () => {
+ expect(countExplicitTextLines('a\nb\nc')).toBe(3)
+ })
+
+ it('returns 1 for empty string', () => {
+ expect(countExplicitTextLines('')).toBe(1)
+ })
+ })
+
+ describe('estimateTextHeight', () => {
+ it('estimates height for single-line text', () => {
+ const node: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 }
+ const height = estimateTextHeight(node)
+ expect(height).toBeGreaterThan(0)
+ expect(height).toBeLessThan(50)
+ })
+
+ it('estimates taller for multi-line text', () => {
+ const single: PenNode = { id: '1', type: 'text', content: 'Hello', fontSize: 16 }
+ const multi: PenNode = { id: '2', type: 'text', content: 'Hello\nWorld', fontSize: 16 }
+ expect(estimateTextHeight(multi)).toBeGreaterThan(estimateTextHeight(single))
+ })
+ })
+})
diff --git a/packages/pen-core/src/__tests__/tree-utils.test.ts b/packages/pen-core/src/__tests__/tree-utils.test.ts
new file mode 100644
index 00000000..7244cc4f
--- /dev/null
+++ b/packages/pen-core/src/__tests__/tree-utils.test.ts
@@ -0,0 +1,170 @@
+import { describe, it, expect } from 'vitest'
+import type { PenNode, PenDocument } from '@zseven-w/pen-types'
+import {
+ createEmptyDocument,
+ findNodeInTree,
+ findParentInTree,
+ removeNodeFromTree,
+ updateNodeInTree,
+ flattenNodes,
+ insertNodeInTree,
+ isDescendantOf,
+ getActivePageChildren,
+ setActivePageChildren,
+ migrateToPages,
+ DEFAULT_FRAME_ID,
+ DEFAULT_PAGE_ID,
+} from '../tree-utils'
+
+const frame = (id: string, children: PenNode[] = []): PenNode => ({
+ id, type: 'frame', name: id, x: 0, y: 0, width: 100, height: 100,
+ fill: [{ type: 'solid', color: '#fff' }], children,
+})
+
+const rect = (id: string): PenNode => ({
+ id, type: 'rectangle', x: 0, y: 0, width: 50, height: 50,
+})
+
+describe('tree-utils', () => {
+ describe('createEmptyDocument', () => {
+ it('creates a document with a default page and root frame', () => {
+ const doc = createEmptyDocument()
+ expect(doc.version).toBe('1.0.0')
+ expect(doc.pages).toHaveLength(1)
+ expect(doc.pages![0].id).toBe(DEFAULT_PAGE_ID)
+ expect(doc.pages![0].children).toHaveLength(1)
+ expect(doc.pages![0].children[0].id).toBe(DEFAULT_FRAME_ID)
+ })
+ })
+
+ describe('findNodeInTree', () => {
+ it('finds a node by id at root level', () => {
+ const nodes = [rect('a'), rect('b')]
+ expect(findNodeInTree(nodes, 'b')?.id).toBe('b')
+ })
+
+ it('finds a nested node', () => {
+ const nodes = [frame('parent', [rect('child')])]
+ expect(findNodeInTree(nodes, 'child')?.id).toBe('child')
+ })
+
+ it('returns undefined for missing node', () => {
+ expect(findNodeInTree([rect('a')], 'missing')).toBeUndefined()
+ })
+ })
+
+ describe('findParentInTree', () => {
+ it('finds the parent of a child node', () => {
+ const nodes = [frame('parent', [rect('child')])]
+ expect(findParentInTree(nodes, 'child')?.id).toBe('parent')
+ })
+
+ it('returns undefined for root nodes', () => {
+ expect(findParentInTree([rect('root')], 'root')).toBeUndefined()
+ })
+ })
+
+ describe('removeNodeFromTree', () => {
+ it('removes a root node', () => {
+ const result = removeNodeFromTree([rect('a'), rect('b')], 'a')
+ expect(result).toHaveLength(1)
+ expect(result[0].id).toBe('b')
+ })
+
+ it('removes a nested node', () => {
+ const nodes = [frame('parent', [rect('child1'), rect('child2')])]
+ const result = removeNodeFromTree(nodes, 'child1')
+ const parent = result[0] as PenNode & { children: PenNode[] }
+ expect(parent.children).toHaveLength(1)
+ expect(parent.children[0].id).toBe('child2')
+ })
+ })
+
+ describe('updateNodeInTree', () => {
+ it('updates a node by id', () => {
+ const nodes = [rect('a')]
+ const result = updateNodeInTree(nodes, 'a', { name: 'updated' })
+ expect(result[0].name).toBe('updated')
+ })
+
+ it('updates a nested node', () => {
+ const nodes = [frame('parent', [rect('child')])]
+ const result = updateNodeInTree(nodes, 'child', { name: 'updated' })
+ const parent = result[0] as PenNode & { children: PenNode[] }
+ expect(parent.children[0].name).toBe('updated')
+ })
+ })
+
+ describe('flattenNodes', () => {
+ it('flattens a nested tree', () => {
+ const nodes = [frame('a', [rect('b'), frame('c', [rect('d')])])]
+ const flat = flattenNodes(nodes)
+ expect(flat.map(n => n.id)).toEqual(['a', 'b', 'c', 'd'])
+ })
+ })
+
+ describe('insertNodeInTree', () => {
+ it('inserts at root level', () => {
+ const result = insertNodeInTree([rect('a')], null, rect('b'))
+ expect(result).toHaveLength(2)
+ expect(result[1].id).toBe('b')
+ })
+
+ it('inserts into a parent', () => {
+ const nodes = [frame('parent', [rect('existing')])]
+ const result = insertNodeInTree(nodes, 'parent', rect('new'))
+ const parent = result[0] as PenNode & { children: PenNode[] }
+ expect(parent.children).toHaveLength(2)
+ expect(parent.children[1].id).toBe('new')
+ })
+
+ it('inserts at a specific index', () => {
+ const nodes = [frame('parent', [rect('a'), rect('c')])]
+ const result = insertNodeInTree(nodes, 'parent', rect('b'), 1)
+ const parent = result[0] as PenNode & { children: PenNode[] }
+ expect(parent.children.map(n => n.id)).toEqual(['a', 'b', 'c'])
+ })
+ })
+
+ describe('isDescendantOf', () => {
+ it('returns true for a descendant', () => {
+ const nodes = [frame('a', [frame('b', [rect('c')])])]
+ expect(isDescendantOf(nodes, 'c', 'a')).toBe(true)
+ })
+
+ it('returns false for non-descendant', () => {
+ const nodes = [frame('a', [rect('b')]), rect('c')]
+ expect(isDescendantOf(nodes, 'c', 'a')).toBe(false)
+ })
+ })
+
+ describe('page helpers', () => {
+ it('getActivePageChildren returns page children', () => {
+ const doc = createEmptyDocument()
+ const children = getActivePageChildren(doc, DEFAULT_PAGE_ID)
+ expect(children).toHaveLength(1)
+ expect(children[0].id).toBe(DEFAULT_FRAME_ID)
+ })
+
+ it('setActivePageChildren replaces page children', () => {
+ const doc = createEmptyDocument()
+ const newChildren = [rect('new')]
+ const updated = setActivePageChildren(doc, DEFAULT_PAGE_ID, newChildren)
+ expect(updated.pages![0].children).toHaveLength(1)
+ expect(updated.pages![0].children[0].id).toBe('new')
+ })
+
+ it('migrateToPages wraps legacy doc', () => {
+ const legacy: PenDocument = { version: '1.0.0', children: [rect('a')] }
+ const migrated = migrateToPages(legacy)
+ expect(migrated.pages).toHaveLength(1)
+ expect(migrated.pages![0].children[0].id).toBe('a')
+ expect(migrated.children).toEqual([])
+ })
+
+ it('migrateToPages preserves existing pages', () => {
+ const doc = createEmptyDocument()
+ expect(migrateToPages(doc)).toBe(doc)
+ })
+ })
+})
diff --git a/packages/pen-core/src/__tests__/variables.test.ts b/packages/pen-core/src/__tests__/variables.test.ts
new file mode 100644
index 00000000..26d4c31c
--- /dev/null
+++ b/packages/pen-core/src/__tests__/variables.test.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect } from 'vitest'
+import type { PenNode } from '@zseven-w/pen-types'
+import type { VariableDefinition } from '@zseven-w/pen-types'
+import {
+ isVariableRef,
+ getDefaultTheme,
+ resolveVariableRef,
+ resolveColorRef,
+ resolveNumericRef,
+ resolveNodeForCanvas,
+} from '../variables/resolve'
+
+const vars: Record = {
+ 'primary': { type: 'color', value: '#3b82f6' },
+ 'spacing': { type: 'number', value: 16 },
+ 'themed-color': {
+ type: 'color',
+ value: [
+ { value: '#ffffff', theme: { 'Theme-1': 'Light' } },
+ { value: '#1a1a1a', theme: { 'Theme-1': 'Dark' } },
+ ],
+ },
+}
+
+describe('variables/resolve', () => {
+ describe('isVariableRef', () => {
+ it('returns true for $ prefixed strings', () => {
+ expect(isVariableRef('$primary')).toBe(true)
+ })
+
+ it('returns false for regular strings', () => {
+ expect(isVariableRef('#ff0000')).toBe(false)
+ })
+
+ it('returns false for non-strings', () => {
+ expect(isVariableRef(42)).toBe(false)
+ expect(isVariableRef(undefined)).toBe(false)
+ })
+ })
+
+ describe('getDefaultTheme', () => {
+ it('returns first value of each theme axis', () => {
+ const themes = { 'Theme-1': ['Light', 'Dark'], 'Size': ['Compact', 'Regular'] }
+ expect(getDefaultTheme(themes)).toEqual({
+ 'Theme-1': 'Light',
+ 'Size': 'Compact',
+ })
+ })
+
+ it('returns empty for undefined themes', () => {
+ expect(getDefaultTheme(undefined)).toEqual({})
+ })
+ })
+
+ describe('resolveVariableRef', () => {
+ it('resolves a simple color variable', () => {
+ expect(resolveVariableRef('$primary', vars)).toBe('#3b82f6')
+ })
+
+ it('resolves a number variable', () => {
+ expect(resolveVariableRef('$spacing', vars)).toBe(16)
+ })
+
+ it('returns undefined for missing variable', () => {
+ expect(resolveVariableRef('$missing', vars)).toBeUndefined()
+ })
+
+ it('resolves themed values with matching theme', () => {
+ expect(resolveVariableRef('$themed-color', vars, { 'Theme-1': 'Dark' })).toBe('#1a1a1a')
+ })
+
+ it('falls back to first value when no theme match', () => {
+ expect(resolveVariableRef('$themed-color', vars)).toBe('#ffffff')
+ })
+
+ it('returns undefined for non-ref strings', () => {
+ expect(resolveVariableRef('#ff0000', vars)).toBeUndefined()
+ })
+ })
+
+ describe('resolveColorRef', () => {
+ it('returns the color when not a ref', () => {
+ expect(resolveColorRef('#ff0000', vars)).toBe('#ff0000')
+ })
+
+ it('resolves a color ref', () => {
+ expect(resolveColorRef('$primary', vars)).toBe('#3b82f6')
+ })
+
+ it('returns undefined for undefined input', () => {
+ expect(resolveColorRef(undefined, vars)).toBeUndefined()
+ })
+ })
+
+ describe('resolveNumericRef', () => {
+ it('returns the number when not a ref', () => {
+ expect(resolveNumericRef(42, vars)).toBe(42)
+ })
+
+ it('resolves a numeric ref', () => {
+ expect(resolveNumericRef('$spacing', vars)).toBe(16)
+ })
+
+ it('returns undefined for non-numeric results', () => {
+ expect(resolveNumericRef('$primary', vars)).toBeUndefined()
+ })
+ })
+
+ describe('resolveNodeForCanvas', () => {
+ it('returns same node when no variables', () => {
+ const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0 }
+ expect(resolveNodeForCanvas(node, {})).toBe(node)
+ })
+
+ it('resolves $variable opacity', () => {
+ const node: PenNode = { id: '1', type: 'rectangle', x: 0, y: 0, opacity: '$spacing' }
+ const resolved = resolveNodeForCanvas(node, vars)
+ expect(resolved.opacity).toBe(16)
+ expect(resolved).not.toBe(node)
+ })
+
+ it('resolves fill colors', () => {
+ const node: PenNode = {
+ id: '1', type: 'rectangle', x: 0, y: 0,
+ fill: [{ type: 'solid', color: '$primary' }],
+ }
+ const resolved = resolveNodeForCanvas(node, vars)
+ const fill = (resolved as { fill: Array<{ color: string }> }).fill
+ expect(fill[0].color).toBe('#3b82f6')
+ })
+ })
+})
diff --git a/src/utils/arc-path.ts b/packages/pen-core/src/arc-path.ts
similarity index 100%
rename from src/utils/arc-path.ts
rename to packages/pen-core/src/arc-path.ts
diff --git a/src/utils/boolean-ops.ts b/packages/pen-core/src/boolean-ops.ts
similarity index 99%
rename from src/utils/boolean-ops.ts
rename to packages/pen-core/src/boolean-ops.ts
index 9014a718..537198ac 100644
--- a/src/utils/boolean-ops.ts
+++ b/packages/pen-core/src/boolean-ops.ts
@@ -1,6 +1,6 @@
import paper from 'paper'
import { nanoid } from 'nanoid'
-import type { PenNode, PathNode } from '@/types/pen'
+import type { PenNode, PathNode } from '@zseven-w/pen-types'
export type BooleanOpType = 'union' | 'subtract' | 'intersect'
diff --git a/src/canvas/canvas-constants.ts b/packages/pen-core/src/constants.ts
similarity index 86%
rename from src/canvas/canvas-constants.ts
rename to packages/pen-core/src/constants.ts
index 42b607be..bc6253db 100644
--- a/src/canvas/canvas-constants.ts
+++ b/packages/pen-core/src/constants.ts
@@ -8,12 +8,6 @@ export const DEFAULT_STROKE_WIDTH = 1
export const CANVAS_BACKGROUND_LIGHT = '#e5e5e5'
export const CANVAS_BACKGROUND_DARK = '#1a1a1a'
-export function getCanvasBackground(): string {
- if (typeof document === 'undefined') return CANVAS_BACKGROUND_DARK
- return document.documentElement.classList.contains('light')
- ? CANVAS_BACKGROUND_LIGHT
- : CANVAS_BACKGROUND_DARK
-}
export const SELECTION_BLUE = '#0d99ff'
export const COMPONENT_COLOR = '#a855f7'
export const INSTANCE_COLOR = '#9281f7'
diff --git a/packages/pen-core/src/font-utils.ts b/packages/pen-core/src/font-utils.ts
new file mode 100644
index 00000000..1d5703ef
--- /dev/null
+++ b/packages/pen-core/src/font-utils.ts
@@ -0,0 +1,23 @@
+// ---------------------------------------------------------------------------
+// CSS font family quoting — extracted for portability (no CanvasKit deps)
+// ---------------------------------------------------------------------------
+
+const GENERIC_FAMILIES = new Set([
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
+ 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
+ '-apple-system', 'blinkmacsystemfont',
+])
+
+export function cssFontFamily(family: string): string {
+ return family.split(',').map(f => {
+ const trimmed = f.trim()
+ if (!trimmed) return trimmed
+ // Already quoted
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed
+ // Generic families must not be quoted
+ if (GENERIC_FAMILIES.has(trimmed.toLowerCase())) return trimmed
+ // Quote everything else (safe even for single-word names)
+ return `"${trimmed}"`
+ }).join(', ')
+}
diff --git a/packages/pen-core/src/id.ts b/packages/pen-core/src/id.ts
new file mode 100644
index 00000000..64e43152
--- /dev/null
+++ b/packages/pen-core/src/id.ts
@@ -0,0 +1 @@
+export { nanoid as generateId } from 'nanoid'
diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts
new file mode 100644
index 00000000..6d5e7466
--- /dev/null
+++ b/packages/pen-core/src/index.ts
@@ -0,0 +1,133 @@
+// ID generation
+export { generateId } from './id.js'
+
+// Tree utilities
+export {
+ DEFAULT_FRAME_ID,
+ DEFAULT_PAGE_ID,
+ createEmptyDocument,
+ getActivePage,
+ getActivePageChildren,
+ setActivePageChildren,
+ getAllChildren,
+ migrateToPages,
+ ensureDocumentNodeIds,
+ findNodeInTree,
+ findParentInTree,
+ removeNodeFromTree,
+ updateNodeInTree,
+ flattenNodes,
+ insertNodeInTree,
+ isDescendantOf,
+ getNodeBounds,
+ findClearX,
+ scaleChildrenInPlace,
+ rotateChildrenInPlace,
+ deepCloneNode,
+ cloneNodeWithNewIds,
+ cloneNodesWithNewIds,
+} from './tree-utils.js'
+
+// Variables
+export {
+ isVariableRef,
+ getDefaultTheme,
+ resolveVariableRef,
+ resolveColorRef,
+ resolveNumericRef,
+ resolveNodeForCanvas,
+} from './variables/resolve.js'
+export { replaceVariableRefsInTree } from './variables/replace-refs.js'
+
+// Normalization
+export { normalizePenDocument } from './normalize.js'
+
+// Layout
+export {
+ type Padding,
+ resolvePadding,
+ isNodeVisible,
+ setRootChildrenProvider,
+ getRootFillWidthFallback,
+ inferLayout,
+ fitContentWidth,
+ fitContentHeight,
+ getNodeWidth,
+ getNodeHeight,
+ computeLayoutPositions,
+} from './layout/engine.js'
+
+// Text measurement
+export {
+ parseSizing,
+ defaultLineHeight,
+ isCjkCodePoint,
+ hasCjkText,
+ estimateGlyphWidth,
+ estimateLineWidth,
+ widthSafetyFactor,
+ estimateTextWidth,
+ estimateTextWidthPrecise,
+ resolveTextContent,
+ countExplicitTextLines,
+ getTextOpticalCenterYOffset,
+ countWrappedLinesFallback,
+ type WrappedLineCounter,
+ setWrappedLineCounter,
+ estimateTextHeight,
+} from './layout/text-measure.js'
+
+// Constants
+export {
+ MIN_ZOOM,
+ MAX_ZOOM,
+ ZOOM_STEP,
+ SNAP_THRESHOLD,
+ DEFAULT_FILL,
+ DEFAULT_STROKE,
+ DEFAULT_STROKE_WIDTH,
+ CANVAS_BACKGROUND_LIGHT,
+ CANVAS_BACKGROUND_DARK,
+ SELECTION_BLUE,
+ COMPONENT_COLOR,
+ INSTANCE_COLOR,
+ HOVER_BLUE,
+ HOVER_LINE_WIDTH,
+ HOVER_DASH,
+ INDICATOR_BLUE,
+ INDICATOR_LINE_WIDTH,
+ INDICATOR_DASH,
+ INDICATOR_ENDPOINT_RADIUS,
+ FRAME_LABEL_FONT_SIZE,
+ FRAME_LABEL_OFFSET_Y,
+ FRAME_LABEL_COLOR,
+ PEN_ANCHOR_FILL,
+ PEN_ANCHOR_RADIUS,
+ PEN_ANCHOR_FIRST_RADIUS,
+ PEN_HANDLE_DOT_RADIUS,
+ PEN_HANDLE_LINE_STROKE,
+ PEN_RUBBER_BAND_STROKE,
+ PEN_RUBBER_BAND_DASH,
+ PEN_CLOSE_HIT_THRESHOLD,
+ DIMENSION_LABEL_OFFSET_Y,
+ DEFAULT_FRAME_FILL,
+ DEFAULT_TEXT_FILL,
+ GUIDE_COLOR,
+ GUIDE_LINE_WIDTH,
+ GUIDE_DASH,
+} from './constants.js'
+
+// Sync lock
+export { isFabricSyncLocked, setFabricSyncLock } from './sync-lock.js'
+
+// Arc path
+export { buildEllipseArcPath, isArcEllipse } from './arc-path.js'
+
+// Boolean operations
+export { type BooleanOpType, canBooleanOp, executeBooleanOp } from './boolean-ops.js'
+
+// Font utilities
+export { cssFontFamily } from './font-utils.js'
+
+// Node helpers
+export { isBadgeOverlayNode } from './node-helpers.js'
diff --git a/src/canvas/canvas-layout-engine.ts b/packages/pen-core/src/layout/engine.ts
similarity index 76%
rename from src/canvas/canvas-layout-engine.ts
rename to packages/pen-core/src/layout/engine.ts
index 6dfa83d6..7e8d1b80 100644
--- a/src/canvas/canvas-layout-engine.ts
+++ b/packages/pen-core/src/layout/engine.ts
@@ -1,6 +1,5 @@
-import type { PenNode, ContainerProps, SizingBehavior } from '@/types/pen'
-import { isBadgeOverlayNode } from '@/services/ai/design-node-sanitization'
-import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
+import type { PenNode, ContainerProps, SizingBehavior } from '@zseven-w/pen-types'
+import { isBadgeOverlayNode } from '../node-helpers.js'
import {
parseSizing,
estimateTextWidth,
@@ -10,7 +9,7 @@ import {
resolveTextContent,
countExplicitTextLines,
defaultLineHeight,
-} from './canvas-text-measure'
+} from './text-measure.js'
// ---------------------------------------------------------------------------
@@ -63,8 +62,18 @@ export function isNodeVisible(node: PenNode): boolean {
// Root fill-width fallback
// ---------------------------------------------------------------------------
+const DEFAULT_FRAME_ID = 'root-frame'
+
+/** Resolve root fill-width fallback. Pass root children to avoid store coupling. */
+let _rootChildrenProvider: (() => PenNode[]) | null = null
+
+/** Set a provider function for root children (called once from app initialization). */
+export function setRootChildrenProvider(provider: () => PenNode[]): void {
+ _rootChildrenProvider = provider
+}
+
export function getRootFillWidthFallback(): number {
- const roots = useDocumentStore.getState().document.children
+ const roots = _rootChildrenProvider?.() ?? []
const rootFrame = roots.find(
(n) => n.type === 'frame'
&& n.id === DEFAULT_FRAME_ID
@@ -88,21 +97,11 @@ export function getRootFillWidthFallback(): number {
// Layout inference — shared logic for detecting implicit layout
// ---------------------------------------------------------------------------
-/**
- * Infer layout direction for a frame that has no explicit `layout` property.
- * Pencil treats frames as horizontal layout (CSS flexbox default = row) when:
- * - gap, justifyContent, or alignItems are set, OR
- * - padding is set (CSS flexbox respects padding for child positioning), OR
- * - any child uses `fill_container` sizing (only meaningful in a layout context)
- */
export function inferLayout(node: PenNode): 'horizontal' | undefined {
if (node.type !== 'frame') return undefined
const c = node as PenNode & ContainerProps
if (c.gap != null || c.justifyContent || c.alignItems) return 'horizontal'
- // Padding implies layout context: in Pencil (CSS flexbox), padding offsets
- // child content. Without layout inference, children sit at (0,0) ignoring padding.
if (c.padding != null) return 'horizontal'
- // Check if any child uses fill_container, implying layout context
if ('children' in node && node.children?.length) {
for (const child of node.children) {
if ('width' in child && child.width === 'fill_container') return 'horizontal'
@@ -116,11 +115,8 @@ export function inferLayout(node: PenNode): 'horizontal' | undefined {
// Fit-content size computation
// ---------------------------------------------------------------------------
-/** Compute fit-content width from children. */
export function fitContentWidth(node: PenNode, parentAvail?: number): number {
if (!('children' in node) || !node.children?.length) return 0
- // Exclude badge/overlay nodes — they use absolute positioning and
- // should not inflate the container's fit-content dimensions.
const visibleChildren = node.children.filter(
(child) => isNodeVisible(child) && !isBadgeOverlayNode(child),
)
@@ -144,11 +140,8 @@ export function fitContentWidth(node: PenNode, parentAvail?: number): number {
return maxChildW + pad.left + pad.right
}
-/** Compute fit-content height from children. */
export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
if (!('children' in node) || !node.children?.length) return 0
- // Exclude badge/overlay nodes — they use absolute positioning and
- // should not inflate the container's fit-content dimensions.
const visibleChildren = node.children.filter(
(child) => isNodeVisible(child) && !isBadgeOverlayNode(child),
)
@@ -157,7 +150,6 @@ export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
const layout = c.layout || inferLayout(node)
const pad = resolvePadding(c.padding)
const nodeGap = typeof c.gap === 'number' ? c.gap : 0
- // Compute available width for children (used by text height estimation)
const nodeW = getNodeWidth(node, parentAvailW)
const childAvailW = nodeW > 0 ? Math.max(0, nodeW - pad.left - pad.right) : parentAvailW
if (layout === 'vertical') {
@@ -179,14 +171,10 @@ export function getNodeWidth(node: PenNode, parentAvail?: number): number {
if (typeof s === 'number' && s > 0) return s
if (s === 'fill') {
if (parentAvail && parentAvail > 0) return parentAvail
- // Unresolved fill width (no parent available): use root viewport width
- // to avoid collapsing frames to content width and causing squeeze.
if (node.type !== 'text') {
const fallbackFillW = getRootFillWidthFallback()
if (fallbackFillW > 0) return fallbackFillW
}
- // If fill width cannot be resolved yet, prefer intrinsic content width
- // over collapsing to 0. This prevents accidental narrowing cascades.
if ('children' in node && node.children?.length) {
const intrinsic = fitContentWidth(node)
if (intrinsic > 0) return intrinsic
@@ -207,7 +195,6 @@ export function getNodeWidth(node: PenNode, parentAvail?: number): number {
if (fit > 0) return fit
}
}
- // Containers without explicit width: compute from children
if ('children' in node && node.children?.length) {
const fit = fitContentWidth(node, parentAvail)
if (fit > 0) return fit
@@ -220,10 +207,6 @@ export function getNodeWidth(node: PenNode, parentAvail?: number): number {
typeof node.content === 'string'
? node.content
: node.content.map((s) => s.text).join('')
- // Use precise estimation (no safety factor) for fit-content / natural-width
- // text. Fabric IText auto-computes its own width, so overestimating only
- // inflates the layout allocation and creates visible gaps. The space_between
- // overflow issue is handled by correct layout inference in inferLayout().
return Math.max(Math.ceil(estimateTextWidthPrecise(content, fontSize, letterSpacing, fontWeight)), 1)
}
return 0
@@ -239,7 +222,6 @@ export function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?
if (fit > 0) return fit
}
}
- // Containers without explicit height: compute from children
if ('children' in node && node.children?.length) {
const fit = fitContentHeight(node, parentAvailW)
if (fit > 0) return fit
@@ -254,7 +236,6 @@ export function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?
// Auto-layout position computation
// ---------------------------------------------------------------------------
-/** Compute child positions according to the parent's layout rules. */
export function computeLayoutPositions(
parent: PenNode,
children: PenNode[],
@@ -263,22 +244,15 @@ export function computeLayoutPositions(
const visibleChildren = children.filter((child) => isNodeVisible(child))
if (visibleChildren.length === 0) return []
const c = parent as PenNode & ContainerProps
- // Infer layout when gap/justifyContent/alignItems are set but layout is not.
- // Pencil treats these frames as horizontal layout (CSS flexbox default = row).
const layout = c.layout || inferLayout(parent)
if (!layout || layout === 'none') return visibleChildren
- // Separate badge/overlay nodes from layout children — badges use absolute
- // positioning and should not participate in the layout flow.
const badgeNodes = visibleChildren.filter(isBadgeOverlayNode)
const layoutChildren = visibleChildren.filter((ch) => !isBadgeOverlayNode(ch))
if (layoutChildren.length === 0) return visibleChildren
const pW = parseSizing(c.width)
const pH = parseSizing(c.height)
- // When parent has no explicit dimensions (fit_content), resolve actual size
- // from children. parseSizing(undefined) returns 0 which would make available
- // space negative after subtracting padding, breaking all child positioning.
const parentW = (typeof pW === 'number' && pW > 0) ? pW : (getNodeWidth(parent) || 100)
const parentH = (typeof pH === 'number' && pH > 0) ? pH : (getNodeHeight(parent) || 100)
const pad = resolvePadding(c.padding)
@@ -292,7 +266,6 @@ export function computeLayoutPositions(
const availMain = isVertical ? availH : availW
const totalGapSpace = gap * Math.max(0, layoutChildren.length - 1)
- // Two-pass sizing: first compute fixed sizes, then allocate remaining space for fill children
const mainSizing = layoutChildren.map((ch) => {
const prop = isVertical ? 'height' : 'width'
if (prop in ch) {
@@ -311,8 +284,6 @@ export function computeLayoutPositions(
const sizes = layoutChildren.map((ch, i) => {
let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
- // For single-line text in vertical layouts, use the actual CanvasKit
- // Paragraph height (fontSize * lineHeight) for correct main-axis positioning.
if (isVertical && ch.type === 'text' && mainSizing[i] !== 'fill') {
const content = resolveTextContent(ch)
if (countExplicitTextLines(content) <= 1) {
@@ -327,9 +298,6 @@ export function computeLayoutPositions(
}
return {
w: isVertical ? getNodeWidth(ch, availW) : mainSize,
- // For horizontal layouts, use the child's resolved width (mainSize) for
- // height estimation. This ensures text wrapping is calculated at the
- // actual width the child will occupy, not the parent's full available width.
h: isVertical ? mainSize : getNodeHeight(ch, availH, isVertical ? availW : mainSize),
}
})
@@ -366,7 +334,6 @@ export function computeLayoutPositions(
break
}
default:
- // 'start' — mainPos stays 0
break
}
@@ -376,10 +343,6 @@ export function computeLayoutPositions(
const childCross = isVertical ? size.w : size.h
let crossPos = 0
- // For single-line text centered in horizontal layouts, use the actual
- // CanvasKit Paragraph height (fontSize * lineHeight) for centering.
- // CanvasKit renders with halfLeading:true, distributing leading evenly
- // above and below, so no optical correction is needed.
let effectiveChildCross = childCross
if (align === 'center' && !isVertical && child.type === 'text') {
const fontSize = child.fontSize ?? 16
@@ -402,7 +365,6 @@ export function computeLayoutPositions(
break
}
- // Keep child within cross-axis bounds.
const clampCrossSize =
(!isVertical && align === 'center' && child.type === 'text')
? effectiveChildCross
@@ -416,9 +378,6 @@ export function computeLayoutPositions(
mainPos += (isVertical ? size.h : size.w) + effectiveGap
- // Always use computed positions for layout children — this function
- // is only called when layout !== 'none', so all children here are
- // layout-managed and should not retain manual x/y values.
const out: Record = {
...child,
x: computedX,
@@ -427,11 +386,6 @@ export function computeLayoutPositions(
height: size.h,
}
- // For text nodes centered in a vertical layout, expand to full available
- // width and set textAlign:'center'. This avoids width estimation inaccuracy:
- // IText ignores our width and computes its own, so textAlign has no effect.
- // By using full width (which triggers Textbox in the factory) + center align,
- // the text is precisely centered regardless of glyph estimation error.
if (isVertical && align === 'center' && child.type === 'text') {
const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left'
if (!hasExplicitAlign) {
@@ -441,13 +395,9 @@ export function computeLayoutPositions(
}
}
-
return out as unknown as PenNode
})
- // Prepend badge/overlay nodes (they keep original x/y for absolute positioning).
- // flattenNodes iterates in REVERSE, so index 0 = frontmost z-order.
- // Badges at the beginning render on top of layout children.
if (badgeNodes.length > 0) {
return [...badgeNodes, ...positioned]
}
@@ -506,5 +456,5 @@ function normalizeAlignItems(value: unknown): 'start' | 'center' | 'end' {
}
}
-// Re-export estimateLineWidth for convenience (used by drag-into-layout etc.)
+// Re-export estimateLineWidth for convenience
export { estimateLineWidth }
diff --git a/src/canvas/canvas-text-measure.ts b/packages/pen-core/src/layout/text-measure.ts
similarity index 76%
rename from src/canvas/canvas-text-measure.ts
rename to packages/pen-core/src/layout/text-measure.ts
index 6a6058d4..09217b68 100644
--- a/src/canvas/canvas-text-measure.ts
+++ b/packages/pen-core/src/layout/text-measure.ts
@@ -1,5 +1,4 @@
-import type { PenNode } from '@/types/pen'
-import { cssFontFamily } from './skia/skia-paint-utils'
+import type { PenNode } from '@zseven-w/pen-types'
// ---------------------------------------------------------------------------
// Sizing parser (shared by layout engine and text height estimation)
@@ -102,7 +101,7 @@ export function estimateLineWidth(
return Math.max(0, width)
}
-function widthSafetyFactor(text: string): number {
+export function widthSafetyFactor(text: string): number {
// Latin fonts vary a lot by weight/family; use a larger safety margin to
// avoid underestimating width and causing accidental wraps.
return hasCjkText(text) ? 1.06 : 1.14
@@ -173,92 +172,44 @@ export function getTextOpticalCenterYOffset(node: PenNode): number {
}
// ---------------------------------------------------------------------------
-// Canvas 2D measurement context (lazy singleton, browser-only)
+// Wrapped line count — injectable for browser/non-browser environments
// ---------------------------------------------------------------------------
-let _textMeasureCtx: CanvasRenderingContext2D | null = null
-function getTextMeasureCtx(): CanvasRenderingContext2D | null {
- if (typeof document === 'undefined') return null
- if (!_textMeasureCtx) {
- const c = document.createElement('canvas')
- _textMeasureCtx = c.getContext('2d')
- }
- return _textMeasureCtx
+/**
+ * Count wrapped lines using character-width estimation fallback.
+ * This is the pure (non-browser) implementation.
+ */
+export function countWrappedLinesFallback(
+ rawLines: string[],
+ wrapWidth: number,
+ fontSize: number,
+ letterSpacing: number,
+ fontWeight: string | number | undefined,
+): number {
+ return rawLines.reduce((sum, line) => {
+ const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line)
+ return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth))
+ }, 0)
}
/**
- * Count wrapped lines using Canvas 2D measureText for accurate word-wrap
- * prediction. Falls back to character-width estimation if Canvas 2D is
- * unavailable (e.g. SSR).
+ * Injectable wrapped line counter. Browser environments can replace this
+ * with a Canvas 2D-based implementation for accurate word-wrap prediction.
*/
-function countWrappedLinesCanvas2D(
+export type WrappedLineCounter = (
rawLines: string[],
wrapWidth: number,
fontSize: number,
fontWeight: string | number | undefined,
fontFamily: string,
letterSpacing: number,
-): number {
- const ctx = getTextMeasureCtx()
- if (!ctx) {
- // Fallback: character-width estimation
- return rawLines.reduce((sum, line) => {
- const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line)
- return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth))
- }, 0)
- }
+) => number
- const fw = typeof fontWeight === 'number' ? String(fontWeight) : (fontWeight ?? '400')
- ctx.font = `${fw} ${fontSize}px ${cssFontFamily(fontFamily)}`
+let _wrappedLineCounter: WrappedLineCounter | null = null
- let total = 0
- for (const rawLine of rawLines) {
- if (!rawLine) { total += 1; continue }
- // Word-wrap using Canvas 2D measureText — same logic as the renderer's wrapLine
- if (ctx.measureText(rawLine).width <= wrapWidth) { total += 1; continue }
- let lineCount = 0
- let current = ''
- let i = 0
- while (i < rawLine.length) {
- const ch = rawLine[i]
- if (isCjkCodePoint(ch.codePointAt(0) ?? 0)) {
- const test = current + ch
- if (ctx.measureText(test).width > wrapWidth && current) {
- lineCount++
- current = ch
- } else {
- current = test
- }
- i++
- } else if (ch === ' ') {
- const test = current + ch
- if (ctx.measureText(test).width > wrapWidth && current) {
- lineCount++
- current = ''
- } else {
- current = test
- }
- i++
- } else {
- // Collect word
- let word = ''
- while (i < rawLine.length && rawLine[i] !== ' ' && !isCjkCodePoint(rawLine[i].codePointAt(0) ?? 0)) {
- word += rawLine[i]
- i++
- }
- const test = current + word
- if (ctx.measureText(test).width > wrapWidth && current) {
- lineCount++
- current = word
- } else {
- current = test
- }
- }
- }
- if (current) lineCount++
- total += Math.max(1, lineCount)
- }
- return total
+/** Set a custom wrapped line counter (e.g. Canvas 2D-based). */
+export function setWrappedLineCounter(counter: WrappedLineCounter): void {
+ _wrappedLineCounter = counter
}
// ---------------------------------------------------------------------------
@@ -301,17 +252,17 @@ export function estimateTextHeight(node: PenNode, availableWidth?: number): numb
return Math.round(n2 <= 1 ? glyphH : (n2 - 1) * lineStep + glyphH)
}
- // Use Canvas 2D measureText for accurate wrapping prediction (matches renderer).
- // Falls back to character-width estimation in non-browser environments.
+ // Use custom wrapped line counter if set (e.g. Canvas 2D), else fallback
const fontWeight = n.fontWeight as string | number | undefined
const fontFamily = (typeof n.fontFamily === 'string' ? n.fontFamily : '') || 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
const letterSpacing = (typeof n.letterSpacing === 'number' ? n.letterSpacing : 0)
const rawLines = content.split(/\r?\n/)
// Add tolerance matching the renderer's wrapLine (w + fontSize * 0.2)
const wrapWidth = textWidth + fontSize * 0.2
- const wrappedLineCount = countWrappedLinesCanvas2D(
- rawLines, wrapWidth, fontSize, fontWeight, fontFamily, letterSpacing,
- )
+
+ const wrappedLineCount = _wrappedLineCounter
+ ? _wrappedLineCounter(rawLines, wrapWidth, fontSize, fontWeight, fontFamily, letterSpacing)
+ : countWrappedLinesFallback(rawLines, wrapWidth, fontSize, letterSpacing, fontWeight)
const totalLines = Math.max(1, wrappedLineCount)
return Math.round(totalLines <= 1 ? glyphH : (totalLines - 1) * lineStep + glyphH)
diff --git a/packages/pen-core/src/node-helpers.ts b/packages/pen-core/src/node-helpers.ts
new file mode 100644
index 00000000..69677d62
--- /dev/null
+++ b/packages/pen-core/src/node-helpers.ts
@@ -0,0 +1,14 @@
+import type { PenNode } from '@zseven-w/pen-types'
+
+/**
+ * Check if a node is a badge/overlay that uses absolute positioning
+ * and should not participate in layout flow.
+ */
+export function isBadgeOverlayNode(node: PenNode): boolean {
+ if ('role' in node) {
+ const role = (node as { role?: string }).role
+ if (role === 'badge' || role === 'pill' || role === 'tag') return true
+ }
+ const name = (node.name ?? '').toLowerCase()
+ return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name)
+}
diff --git a/src/utils/normalize-pen-file.ts b/packages/pen-core/src/normalize.ts
similarity index 96%
rename from src/utils/normalize-pen-file.ts
rename to packages/pen-core/src/normalize.ts
index 5d76afdf..a8b7730c 100644
--- a/src/utils/normalize-pen-file.ts
+++ b/packages/pen-core/src/normalize.ts
@@ -13,8 +13,8 @@
* canvas render time, preserving $variable bindings in the document.
*/
-import type { PenDocument, PenNode } from '@/types/pen'
-import type { PenFill, PenStroke, GradientStop } from '@/types/styles'
+import type { PenDocument, PenNode } from '@zseven-w/pen-types'
+import type { PenFill, PenStroke, GradientStop } from '@zseven-w/pen-types'
// ---------------------------------------------------------------------------
// Public API
@@ -105,8 +105,12 @@ function normalizeFills(raw: unknown): PenFill[] {
}
function normalizeSingleFill(
- raw: Record,
+ raw: Record | string,
): PenFill | null {
+ // String shorthand inside array: "#hex" or "$variable" → solid fill
+ if (typeof raw === 'string') {
+ return raw ? { type: 'solid', color: raw } : null
+ }
if (!raw || typeof raw !== 'object') return null
const t = raw.type as string | undefined
diff --git a/src/canvas/canvas-sync-lock.ts b/packages/pen-core/src/sync-lock.ts
similarity index 100%
rename from src/canvas/canvas-sync-lock.ts
rename to packages/pen-core/src/sync-lock.ts
diff --git a/src/stores/document-tree-utils.ts b/packages/pen-core/src/tree-utils.ts
similarity index 86%
rename from src/stores/document-tree-utils.ts
rename to packages/pen-core/src/tree-utils.ts
index 9d6b0a6b..52cbec84 100644
--- a/src/stores/document-tree-utils.ts
+++ b/packages/pen-core/src/tree-utils.ts
@@ -1,5 +1,5 @@
import { nanoid } from 'nanoid'
-import type { PenDocument, PenNode, PenNodeBase, PenPage, RefNode } from '@/types/pen'
+import type { PenDocument, PenNode, PenNodeBase, PenPage, RefNode } from '@zseven-w/pen-types'
export const DEFAULT_FRAME_ID = 'root-frame'
export const DEFAULT_PAGE_ID = 'page-1'
@@ -325,6 +325,48 @@ export function scaleChildrenInPlace(
})
}
+// ---------------------------------------------------------------------------
+// Clone utilities
+// ---------------------------------------------------------------------------
+
+/** Deep-clone a node tree preserving all IDs. */
+export function deepCloneNode(node: T): T {
+ return structuredClone(node)
+}
+
+/** Clone a single node tree, assigning new IDs to every node. */
+export function cloneNodeWithNewIds(
+ node: PenNode,
+ idGenerator: () => string = nanoid,
+): PenNode {
+ const cloned = { ...node, id: idGenerator() } as PenNode
+ if ('children' in cloned && cloned.children) {
+ cloned.children = cloned.children.map((c) =>
+ cloneNodeWithNewIds(c, idGenerator),
+ )
+ }
+ return cloned
+}
+
+/** Clone multiple nodes with new IDs. Optionally strip `reusable` flag and apply position offset. */
+export function cloneNodesWithNewIds(
+ nodes: PenNode[],
+ options: { offset?: number; stripReusable?: boolean; idGenerator?: () => string } = {},
+): PenNode[] {
+ const { offset = 0, stripReusable = true, idGenerator = nanoid } = options
+ return structuredClone(nodes).map((node) => {
+ const withNewId = cloneNodeWithNewIds(node, idGenerator)
+ if (stripReusable && 'reusable' in withNewId) {
+ delete (withNewId as unknown as Record).reusable
+ }
+ if (offset !== 0) {
+ withNewId.x = (withNewId.x ?? 0) + offset
+ withNewId.y = (withNewId.y ?? 0) + offset
+ }
+ return withNewId
+ })
+}
+
/** Recursively rotate all children's relative positions and angles. */
export function rotateChildrenInPlace(
children: PenNode[],
diff --git a/src/variables/replace-refs.ts b/packages/pen-core/src/variables/replace-refs.ts
similarity index 95%
rename from src/variables/replace-refs.ts
rename to packages/pen-core/src/variables/replace-refs.ts
index 9c9f06fe..7dd9bb87 100644
--- a/src/variables/replace-refs.ts
+++ b/packages/pen-core/src/variables/replace-refs.ts
@@ -4,10 +4,10 @@
* Used when renaming or deleting a variable to keep the tree consistent.
*/
-import type { PenNode } from '@/types/pen'
-import type { PenFill } from '@/types/styles'
-import type { VariableDefinition } from '@/types/variables'
-import { resolveVariableRef } from './resolve-variables'
+import type { PenNode } from '@zseven-w/pen-types'
+import type { PenFill } from '@zseven-w/pen-types'
+import type { VariableDefinition } from '@zseven-w/pen-types'
+import { resolveVariableRef } from './resolve.js'
/**
* Replace all occurrences of `$oldRef` with `$newRef` in the node tree.
diff --git a/src/variables/resolve-variables.ts b/packages/pen-core/src/variables/resolve.ts
similarity index 97%
rename from src/variables/resolve-variables.ts
rename to packages/pen-core/src/variables/resolve.ts
index 34a83163..a4b77a2c 100644
--- a/src/variables/resolve-variables.ts
+++ b/packages/pen-core/src/variables/resolve.ts
@@ -5,9 +5,9 @@
* optionally matching themed values to an active theme context.
*/
-import type { PenNode } from '@/types/pen'
-import type { PenFill, PenStroke, PenEffect } from '@/types/styles'
-import type { VariableDefinition, ThemedValue } from '@/types/variables'
+import type { PenNode } from '@zseven-w/pen-types'
+import type { PenFill, PenStroke, PenEffect } from '@zseven-w/pen-types'
+import type { VariableDefinition, ThemedValue } from '@zseven-w/pen-types'
type Vars = Record
type Theme = Record
diff --git a/packages/pen-core/tsconfig.json b/packages/pen-core/tsconfig.json
new file mode 100644
index 00000000..df59da57
--- /dev/null
+++ b/packages/pen-core/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/pen-figma/package.json b/packages/pen-figma/package.json
new file mode 100644
index 00000000..48894a57
--- /dev/null
+++ b/packages/pen-figma/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@zseven-w/pen-figma",
+ "version": "0.5.0",
+ "description": "Figma .fig file parser and converter for OpenPencil",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
+ "files": [
+ "src"
+ ],
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@zseven-w/pen-types": "workspace:*",
+ "kiwi-schema": "^0.5.0",
+ "uzip": "^0.20201231.0",
+ "fzstd": "^0.1.1"
+ },
+ "devDependencies": {
+ "@types/uzip": "^0.20201231.2",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/src/services/figma/fig-parser.ts b/packages/pen-figma/src/fig-parser.ts
similarity index 100%
rename from src/services/figma/fig-parser.ts
rename to packages/pen-figma/src/fig-parser.ts
diff --git a/src/services/figma/figma-clipboard.ts b/packages/pen-figma/src/figma-clipboard.ts
similarity index 99%
rename from src/services/figma/figma-clipboard.ts
rename to packages/pen-figma/src/figma-clipboard.ts
index 928f1333..f49c5b5e 100644
--- a/src/services/figma/figma-clipboard.ts
+++ b/packages/pen-figma/src/figma-clipboard.ts
@@ -1,4 +1,4 @@
-import type { PenNode } from '@/types/pen'
+import type { PenNode } from '@zseven-w/pen-types'
import { parseFigFile } from './fig-parser'
import { figmaNodeChangesToPenNodes } from './figma-node-mapper'
import { resolveImageBlobs } from './figma-image-resolver'
diff --git a/packages/pen-figma/src/figma-color-utils.ts b/packages/pen-figma/src/figma-color-utils.ts
new file mode 100644
index 00000000..52f3cd6f
--- /dev/null
+++ b/packages/pen-figma/src/figma-color-utils.ts
@@ -0,0 +1,28 @@
+import type { FigmaColor } from './figma-types'
+
+/**
+ * Convert Figma {r, g, b, a} (0-1 floats) to #RRGGBB or #RRGGBBAA hex string.
+ */
+export function figmaColorToHex(color: FigmaColor): string {
+ const r = Math.round(color.r * 255)
+ const g = Math.round(color.g * 255)
+ const b = Math.round(color.b * 255)
+ const hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`
+
+ if (color.a !== undefined && color.a < 1) {
+ const a = Math.round(color.a * 255)
+ return `${hex}${toHex(a)}`
+ }
+ return hex
+}
+
+function toHex(n: number): string {
+ return n.toString(16).padStart(2, '0')
+}
+
+/**
+ * Extract opacity from a Figma color's alpha channel (0-1).
+ */
+export function figmaColorOpacity(color: FigmaColor): number {
+ return color.a ?? 1
+}
diff --git a/packages/pen-figma/src/figma-effect-mapper.ts b/packages/pen-figma/src/figma-effect-mapper.ts
new file mode 100644
index 00000000..571450a0
--- /dev/null
+++ b/packages/pen-figma/src/figma-effect-mapper.ts
@@ -0,0 +1,57 @@
+import type { FigmaEffect } from './figma-types'
+import type { PenEffect } from '@zseven-w/pen-types'
+import { figmaColorToHex } from './figma-color-utils'
+
+/**
+ * Convert Figma effects[] (internal format) to PenEffect[].
+ */
+export function mapFigmaEffects(
+ effects: FigmaEffect[] | undefined
+): PenEffect[] | undefined {
+ if (!effects || effects.length === 0) return undefined
+ const mapped: PenEffect[] = []
+
+ for (const effect of effects) {
+ if (effect.visible === false) continue
+ const pen = mapSingleEffect(effect)
+ if (pen) mapped.push(pen)
+ }
+
+ return mapped.length > 0 ? mapped : undefined
+}
+
+function mapSingleEffect(effect: FigmaEffect): PenEffect | null {
+ switch (effect.type) {
+ case 'DROP_SHADOW':
+ case 'INNER_SHADOW': {
+ return {
+ type: 'shadow',
+ inner: effect.type === 'INNER_SHADOW',
+ offsetX: effect.offset?.x ?? 0,
+ offsetY: effect.offset?.y ?? 0,
+ blur: effect.radius ?? 0,
+ spread: effect.spread ?? 0,
+ color: effect.color
+ ? figmaColorToHex(effect.color)
+ : '#00000040',
+ }
+ }
+
+ case 'FOREGROUND_BLUR': {
+ return {
+ type: 'blur',
+ radius: effect.radius ?? 0,
+ }
+ }
+
+ case 'BACKGROUND_BLUR': {
+ return {
+ type: 'background_blur',
+ radius: effect.radius ?? 0,
+ }
+ }
+
+ default:
+ return null
+ }
+}
diff --git a/packages/pen-figma/src/figma-fill-mapper.ts b/packages/pen-figma/src/figma-fill-mapper.ts
new file mode 100644
index 00000000..2ee150d9
--- /dev/null
+++ b/packages/pen-figma/src/figma-fill-mapper.ts
@@ -0,0 +1,100 @@
+import type { FigmaPaint, FigmaMatrix } from './figma-types'
+import type { PenFill } from '@zseven-w/pen-types'
+import { figmaColorToHex } from './figma-color-utils'
+
+/**
+ * Convert Figma fillPaints (internal format) to PenFill[].
+ */
+export function mapFigmaFills(paints: FigmaPaint[] | undefined): PenFill[] | undefined {
+ if (!paints || paints.length === 0) return undefined
+ const fills: PenFill[] = []
+
+ for (const paint of paints) {
+ if (paint.visible === false) continue
+ const mapped = mapSingleFill(paint)
+ if (mapped) fills.push(mapped)
+ }
+
+ return fills.length > 0 ? fills : undefined
+}
+
+function mapSingleFill(paint: FigmaPaint): PenFill | null {
+ switch (paint.type) {
+ case 'SOLID': {
+ if (!paint.color) return null
+ return {
+ type: 'solid',
+ color: figmaColorToHex(paint.color),
+ opacity: paint.opacity,
+ }
+ }
+
+ case 'GRADIENT_LINEAR': {
+ if (!paint.stops) return null
+ const angle = paint.transform
+ ? gradientAngleFromTransform(paint.transform)
+ : 0
+ return {
+ type: 'linear_gradient',
+ angle,
+ stops: paint.stops.map((s) => ({
+ offset: s.position,
+ color: figmaColorToHex(s.color),
+ })),
+ opacity: paint.opacity,
+ }
+ }
+
+ case 'GRADIENT_RADIAL':
+ case 'GRADIENT_ANGULAR':
+ case 'GRADIENT_DIAMOND': {
+ if (!paint.stops) return null
+ return {
+ type: 'radial_gradient',
+ cx: 0.5,
+ cy: 0.5,
+ radius: 0.5,
+ stops: paint.stops.map((s) => ({
+ offset: s.position,
+ color: figmaColorToHex(s.color),
+ })),
+ opacity: paint.opacity,
+ }
+ }
+
+ case 'IMAGE': {
+ // Image fills reference blobs or ZIP image files; we'll resolve them later
+ let url = ''
+ if (paint.image?.hash && paint.image.hash.length > 0) {
+ url = `__hash:${Array.from(paint.image.hash).map(b => b.toString(16).padStart(2, '0')).join('')}`
+ } else if (paint.image?.dataBlob !== undefined) {
+ url = `__blob:${paint.image.dataBlob}`
+ }
+ return {
+ type: 'image',
+ url,
+ mode: mapScaleMode(paint.imageScaleMode),
+ opacity: paint.opacity,
+ }
+ }
+
+ default:
+ return null
+ }
+}
+
+function gradientAngleFromTransform(m: FigmaMatrix): number {
+ // Figma gradient direction is (m00, m10) in object space (default = horizontal).
+ // atan2 gives the math-convention angle (0° = right, CCW).
+ // Convert to CSS gradient convention (0° = bottom-to-top, 90° = left-to-right).
+ const mathAngle = Math.atan2(m.m10, m.m00) * (180 / Math.PI)
+ return Math.round(90 - mathAngle)
+}
+
+function mapScaleMode(mode?: string): 'stretch' | 'fill' | 'fit' {
+ switch (mode) {
+ case 'FIT': return 'fit'
+ case 'STRETCH': return 'stretch'
+ default: return 'fill'
+ }
+}
diff --git a/src/services/figma/figma-image-resolver.ts b/packages/pen-figma/src/figma-image-resolver.ts
similarity index 96%
rename from src/services/figma/figma-image-resolver.ts
rename to packages/pen-figma/src/figma-image-resolver.ts
index 61a10b44..b6a7b23e 100644
--- a/src/services/figma/figma-image-resolver.ts
+++ b/packages/pen-figma/src/figma-image-resolver.ts
@@ -1,5 +1,5 @@
-import type { PenNode } from '@/types/pen'
-import type { ImageFill } from '@/types/styles'
+import type { PenNode } from '@zseven-w/pen-types'
+import type { ImageFill } from '@zseven-w/pen-types'
/**
* Resolve __blob:N and __hash: references in the PenNode tree to data URLs
diff --git a/packages/pen-figma/src/figma-layout-mapper.ts b/packages/pen-figma/src/figma-layout-mapper.ts
new file mode 100644
index 00000000..f2dfa954
--- /dev/null
+++ b/packages/pen-figma/src/figma-layout-mapper.ts
@@ -0,0 +1,145 @@
+import type { FigmaNodeChange } from './figma-types'
+import type { ContainerProps, SizingBehavior } from '@zseven-w/pen-types'
+
+/**
+ * Map Figma stack (auto-layout) properties to PenNode ContainerProps.
+ */
+export function mapFigmaLayout(
+ node: FigmaNodeChange
+): Pick<
+ ContainerProps,
+ 'layout' | 'gap' | 'padding' | 'justifyContent' | 'alignItems' | 'clipContent'
+> {
+ const result: Pick<
+ ContainerProps,
+ 'layout' | 'gap' | 'padding' | 'justifyContent' | 'alignItems' | 'clipContent'
+ > = {}
+
+ if (node.stackMode && node.stackMode !== 'NONE') {
+ result.layout = node.stackMode === 'HORIZONTAL' ? 'horizontal' : 'vertical'
+ }
+
+ if (node.stackPrimaryAlignItems) {
+ result.justifyContent = mapJustifyContent(node.stackPrimaryAlignItems)
+ }
+
+ // Set gap from stackSpacing, but skip when justifyContent is space_between.
+ // Figma stores the COMPUTED inter-item spacing in stackSpacing for
+ // SPACE_EVENLY mode — using it as an explicit gap would conflict with
+ // the dynamic spacing that space_between already provides.
+ if (node.stackSpacing !== undefined && node.stackSpacing !== 0 && result.justifyContent !== 'space_between') {
+ result.gap = node.stackSpacing
+ }
+
+ const padding = mapPadding(node)
+ if (padding !== undefined) {
+ result.padding = padding
+ }
+
+ if (node.stackCounterAlignItems) {
+ result.alignItems = mapAlignItems(node.stackCounterAlignItems)
+ }
+
+ // Frames clip by default in Figma (frameMaskDisabled defaults to false).
+ // Only skip clipContent when explicitly disabled.
+ if (node.frameMaskDisabled !== true) {
+ result.clipContent = true
+ }
+
+ return result
+}
+
+function mapPadding(
+ node: FigmaNodeChange
+): number | [number, number] | [number, number, number, number] | undefined {
+ // Check individual padding values first
+ const hasHorizontal = node.stackHorizontalPadding !== undefined
+ const hasVertical = node.stackVerticalPadding !== undefined
+ const hasRight = node.stackPaddingRight !== undefined
+ const hasBottom = node.stackPaddingBottom !== undefined
+
+ if (!hasHorizontal && !hasVertical && !hasRight && !hasBottom) {
+ // Uniform padding
+ if (node.stackPadding && node.stackPadding > 0) return node.stackPadding
+ return undefined
+ }
+
+ const vPad = node.stackVerticalPadding ?? node.stackPadding ?? 0
+ const hPad = node.stackHorizontalPadding ?? node.stackPadding ?? 0
+ const top = vPad
+ const bottom = node.stackPaddingBottom ?? vPad
+ const left = hPad
+ const right = node.stackPaddingRight ?? hPad
+
+ if (top === 0 && right === 0 && bottom === 0 && left === 0) return undefined
+ if (top === right && right === bottom && bottom === left) return top
+ if (top === bottom && left === right) return [top, right]
+ return [top, right, bottom, left]
+}
+
+function mapJustifyContent(
+ align: string
+): ContainerProps['justifyContent'] {
+ switch (align) {
+ case 'MIN': return 'start'
+ case 'CENTER': return 'center'
+ case 'MAX': return 'end'
+ case 'SPACE_EVENLY': return 'space_between'
+ default: return undefined
+ }
+}
+
+function mapAlignItems(
+ align: string
+): ContainerProps['alignItems'] {
+ switch (align) {
+ case 'MIN': return 'start'
+ case 'CENTER': return 'center'
+ case 'MAX': return 'end'
+ default: return undefined
+ }
+}
+
+/**
+ * Determine width sizing behavior from Figma internal format.
+ */
+export function mapWidthSizing(node: FigmaNodeChange, parentStackMode?: string): SizingBehavior {
+ // Check stack sizing for containers
+ if (node.stackPrimarySizing === 'RESIZE_TO_FIT' && node.stackMode === 'HORIZONTAL') {
+ return 'fit_content'
+ }
+ if (node.stackCounterSizing === 'RESIZE_TO_FIT' && node.stackMode === 'VERTICAL') {
+ return 'fit_content'
+ }
+
+ // Check child sizing within parent
+ if (node.stackChildPrimaryGrow === 1 && parentStackMode === 'HORIZONTAL') {
+ return 'fill_container'
+ }
+ if (node.stackChildAlignSelf === 'STRETCH' && parentStackMode === 'VERTICAL') {
+ return 'fill_container'
+ }
+
+ return node.size?.x ?? 100
+}
+
+/**
+ * Determine height sizing behavior from Figma internal format.
+ */
+export function mapHeightSizing(node: FigmaNodeChange, parentStackMode?: string): SizingBehavior {
+ if (node.stackPrimarySizing === 'RESIZE_TO_FIT' && node.stackMode === 'VERTICAL') {
+ return 'fit_content'
+ }
+ if (node.stackCounterSizing === 'RESIZE_TO_FIT' && node.stackMode === 'HORIZONTAL') {
+ return 'fit_content'
+ }
+
+ if (node.stackChildPrimaryGrow === 1 && parentStackMode === 'VERTICAL') {
+ return 'fill_container'
+ }
+ if (node.stackChildAlignSelf === 'STRETCH' && parentStackMode === 'HORIZONTAL') {
+ return 'fill_container'
+ }
+
+ return node.size?.y ?? 100
+}
diff --git a/src/services/figma/figma-node-converters.ts b/packages/pen-figma/src/figma-node-converters.ts
similarity index 98%
rename from src/services/figma/figma-node-converters.ts
rename to packages/pen-figma/src/figma-node-converters.ts
index 0d3be78b..2c0cc190 100644
--- a/src/services/figma/figma-node-converters.ts
+++ b/packages/pen-figma/src/figma-node-converters.ts
@@ -2,14 +2,30 @@ import type {
FigmaNodeChange, FigmaMatrix, FigmaImportLayoutMode,
FigmaSymbolOverride, FigmaDerivedSymbolDataEntry, FigmaGUID,
} from './figma-types'
-import type { PenNode, SizingBehavior } from '@/types/pen'
+import type { PenNode, SizingBehavior } from '@zseven-w/pen-types'
import { mapFigmaFills } from './figma-fill-mapper'
import { mapFigmaStroke } from './figma-stroke-mapper'
import { mapFigmaEffects } from './figma-effect-mapper'
import { mapFigmaLayout, mapWidthSizing, mapHeightSizing } from './figma-layout-mapper'
import { mapFigmaTextProps } from './figma-text-mapper'
import { decodeFigmaVectorPath, computeSvgPathBounds } from './figma-vector-decoder'
-import { lookupIconByName } from '@/services/ai/icon-resolver'
+// Icon lookup is injectable — set via setIconLookup() from the host app
+export interface IconLookupResult {
+ d: string
+ iconId?: string
+ style?: 'fill' | 'stroke'
+}
+
+let _lookupIconByName: ((name: string) => IconLookupResult | null) | null = null
+
+/** Set the icon lookup function (provided by host app's icon-resolver). */
+export function setIconLookup(fn: (name: string) => IconLookupResult | null): void {
+ _lookupIconByName = fn
+}
+
+function lookupIconByName(name: string): IconLookupResult | null {
+ return _lookupIconByName?.(name) ?? null
+}
import type { TreeNode } from './figma-tree-builder'
import { guidToString } from './figma-tree-builder'
diff --git a/src/services/figma/figma-node-mapper.ts b/packages/pen-figma/src/figma-node-mapper.ts
similarity index 99%
rename from src/services/figma/figma-node-mapper.ts
rename to packages/pen-figma/src/figma-node-mapper.ts
index 985d5f1a..e761b3f0 100644
--- a/src/services/figma/figma-node-mapper.ts
+++ b/packages/pen-figma/src/figma-node-mapper.ts
@@ -1,5 +1,5 @@
import type { FigmaDecodedFile, FigmaImportLayoutMode, FigmaNodeChange } from './figma-types'
-import type { PenNode, PenPage, PenDocument } from '@/types/pen'
+import type { PenNode, PenPage, PenDocument } from '@zseven-w/pen-types'
import {
type TreeNode,
guidToString,
diff --git a/packages/pen-figma/src/figma-stroke-mapper.ts b/packages/pen-figma/src/figma-stroke-mapper.ts
new file mode 100644
index 00000000..a6ad118d
--- /dev/null
+++ b/packages/pen-figma/src/figma-stroke-mapper.ts
@@ -0,0 +1,65 @@
+import type { FigmaNodeChange } from './figma-types'
+import type { PenStroke } from '@zseven-w/pen-types'
+import { mapFigmaFills } from './figma-fill-mapper'
+
+/**
+ * Convert Figma strokePaints + strokeWeight to PenStroke.
+ */
+export function mapFigmaStroke(node: FigmaNodeChange): PenStroke | undefined {
+ if (!node.strokePaints || node.strokePaints.length === 0) return undefined
+ const visibleStrokes = node.strokePaints.filter((s) => s.visible !== false)
+ if (visibleStrokes.length === 0) return undefined
+
+ const thickness = node.borderStrokeWeightsIndependent
+ ? [
+ node.borderTopWeight ?? 0,
+ node.borderRightWeight ?? 0,
+ node.borderBottomWeight ?? 0,
+ node.borderLeftWeight ?? 0,
+ ] as [number, number, number, number]
+ : (node.strokeWeight ?? 1)
+
+ const fill = mapFigmaFills(visibleStrokes)
+
+ return {
+ thickness,
+ align: mapStrokeAlign(node.strokeAlign),
+ join: mapStrokeJoin(node.strokeJoin),
+ cap: mapStrokeCap(node.strokeCap),
+ dashPattern: node.dashPattern?.length ? node.dashPattern : undefined,
+ fill,
+ }
+}
+
+function mapStrokeAlign(
+ align?: string
+): 'inside' | 'center' | 'outside' | undefined {
+ switch (align) {
+ case 'INSIDE': return 'inside'
+ case 'OUTSIDE': return 'outside'
+ case 'CENTER': return 'center'
+ default: return undefined
+ }
+}
+
+function mapStrokeJoin(
+ join?: string
+): 'miter' | 'bevel' | 'round' | undefined {
+ switch (join) {
+ case 'MITER': return 'miter'
+ case 'BEVEL': return 'bevel'
+ case 'ROUND': return 'round'
+ default: return undefined
+ }
+}
+
+function mapStrokeCap(
+ cap?: string
+): 'none' | 'round' | 'square' | undefined {
+ switch (cap) {
+ case 'NONE': return 'none'
+ case 'ROUND': return 'round'
+ case 'SQUARE': return 'square'
+ default: return undefined
+ }
+}
diff --git a/packages/pen-figma/src/figma-text-mapper.ts b/packages/pen-figma/src/figma-text-mapper.ts
new file mode 100644
index 00000000..71fd9b95
--- /dev/null
+++ b/packages/pen-figma/src/figma-text-mapper.ts
@@ -0,0 +1,217 @@
+import type { FigmaNodeChange } from './figma-types'
+import type { TextNode } from '@zseven-w/pen-types'
+import type { StyledTextSegment } from '@zseven-w/pen-types'
+import { figmaColorToHex } from './figma-color-utils'
+
+/**
+ * Map Figma .fig internal text properties to PenNode TextNode partial.
+ */
+export function mapFigmaTextProps(
+ node: FigmaNodeChange
+): Pick<
+ TextNode,
+ | 'content'
+ | 'fontFamily'
+ | 'fontSize'
+ | 'fontWeight'
+ | 'fontStyle'
+ | 'letterSpacing'
+ | 'lineHeight'
+ | 'textAlign'
+ | 'textAlignVertical'
+ | 'textGrowth'
+ | 'underline'
+ | 'strikethrough'
+> {
+ const result: ReturnType = {
+ content: applyTextCase(buildContent(node), node.textCase),
+ fontFamily: node.fontName?.family,
+ fontSize: node.fontSize,
+ fontWeight: parseFontWeight(node.fontName?.style),
+ fontStyle: node.fontName?.style?.toLowerCase().includes('italic') ? 'italic' : undefined,
+ letterSpacing: mapLetterSpacing(node),
+ lineHeight: mapLineHeight(node),
+ textAlign: mapTextAlign(node.textAlignHorizontal),
+ textAlignVertical: mapTextAlignVertical(node.textAlignVertical),
+ textGrowth: mapTextGrowth(node.textAutoResize),
+ }
+
+ if (node.textDecoration === 'UNDERLINE') result.underline = true
+ if (node.textDecoration === 'STRIKETHROUGH') result.strikethrough = true
+
+ return result
+}
+
+function applyTextCase(
+ content: string | StyledTextSegment[],
+ textCase?: string,
+): string | StyledTextSegment[] {
+ if (!textCase || textCase === 'ORIGINAL') return content
+
+ const transform = (text: string): string => {
+ switch (textCase) {
+ case 'UPPER': return text.toUpperCase()
+ case 'LOWER': return text.toLowerCase()
+ case 'TITLE': return text.replace(/\b\w/g, c => c.toUpperCase())
+ default: return text
+ }
+ }
+
+ if (typeof content === 'string') {
+ return transform(content)
+ }
+
+ return content.map(seg => ({ ...seg, text: transform(seg.text) }))
+}
+
+function buildContent(node: FigmaNodeChange): string | StyledTextSegment[] {
+ const textData = node.textData
+ if (!textData?.characters) return ''
+
+ const text = textData.characters
+ const styleIds = textData.characterStyleIDs
+ const table = textData.styleOverrideTable
+
+ if (!styleIds || !table || styleIds.length === 0 || table.length === 0) {
+ return text
+ }
+
+ // Build segments from character style IDs
+ const segments: StyledTextSegment[] = []
+ let currentStyleId = styleIds[0] ?? 0
+ let segStart = 0
+
+ for (let i = 1; i <= text.length; i++) {
+ const styleId = i < styleIds.length ? styleIds[i] : -1
+ if (styleId !== currentStyleId || i === text.length) {
+ const endIdx = i === text.length ? text.length : i
+ const segText = text.slice(segStart, endIdx)
+ if (segText) {
+ const segment = buildSegment(segText, currentStyleId, table)
+ segments.push(segment)
+ }
+ currentStyleId = styleId
+ segStart = i
+ }
+ }
+
+ // If all segments have no style overrides, return plain string
+ if (segments.every((s) => !s.fontFamily && !s.fontSize && !s.fontWeight && !s.fill)) {
+ return text
+ }
+
+ return segments
+}
+
+function buildSegment(
+ text: string,
+ styleId: number,
+ table: FigmaNodeChange[]
+): StyledTextSegment {
+ if (styleId === 0) return { text }
+
+ // styleOverrideTable is 0-indexed but style IDs start from 1 in some cases
+ const override = table[styleId] ?? table[styleId - 1]
+ if (!override) return { text }
+
+ const segment: StyledTextSegment = { text }
+ if (override.fontName?.family) segment.fontFamily = override.fontName.family
+ if (override.fontSize) segment.fontSize = override.fontSize
+ const weight = parseFontWeight(override.fontName?.style)
+ if (weight) segment.fontWeight = weight
+ if (override.fontName?.style?.toLowerCase().includes('italic')) {
+ segment.fontStyle = 'italic'
+ }
+ if (override.textDecoration === 'UNDERLINE') segment.underline = true
+ if (override.textDecoration === 'STRIKETHROUGH') segment.strikethrough = true
+
+ // Text fill color
+ if (override.fillPaints?.[0]?.color) {
+ segment.fill = figmaColorToHex(override.fillPaints[0].color)
+ }
+
+ return segment
+}
+
+function parseFontWeight(style?: string): number | undefined {
+ if (!style) return undefined
+ const lower = style.toLowerCase()
+ if (lower.includes('thin') || lower.includes('hairline')) return 100
+ if (lower.includes('extralight') || lower.includes('ultralight')) return 200
+ if (lower.includes('light')) return 300
+ if (lower.includes('regular') || lower.includes('normal')) return 400
+ if (lower.includes('medium')) return 500
+ if (lower.includes('semibold') || lower.includes('demibold')) return 600
+ if (lower.includes('extrabold') || lower.includes('ultrabold')) return 800
+ if (lower.includes('bold')) return 700
+ if (lower.includes('black') || lower.includes('heavy')) return 900
+ return undefined
+}
+
+function mapLineHeight(node: FigmaNodeChange): number | undefined {
+ if (!node.lineHeight) return undefined
+ const fontSize = node.fontSize ?? 14
+ // PenNode lineHeight is a MULTIPLIER (e.g. 1.5), not absolute pixels.
+ // drawText computes final px as: lineHeight * fontSize.
+ if (node.lineHeight.units === 'PIXELS' && node.lineHeight.value) {
+ // Convert absolute pixels to multiplier (e.g. 24px / 16px = 1.5)
+ const mul = node.lineHeight.value / fontSize
+ return Math.round(mul * 1000) / 1000
+ }
+ if (node.lineHeight.units === 'PERCENT' && node.lineHeight.value) {
+ // Convert percentage to multiplier (e.g. 150% = 1.5)
+ return Math.round(node.lineHeight.value / 100 * 1000) / 1000
+ }
+ if (node.lineHeight.units === 'RAW' && node.lineHeight.value) {
+ // RAW is already a multiplier
+ return Math.round(node.lineHeight.value * 1000) / 1000
+ }
+ return undefined
+}
+
+function mapLetterSpacing(node: FigmaNodeChange): number | undefined {
+ if (!node.letterSpacing) return undefined
+ if (node.letterSpacing.units === 'PIXELS' && node.letterSpacing.value) {
+ return node.letterSpacing.value
+ }
+ // Percentage letter spacing: relative to font size
+ if (node.letterSpacing.units === 'PERCENT' && node.letterSpacing.value) {
+ const fontSize = node.fontSize ?? 14
+ return Math.round(fontSize * node.letterSpacing.value / 100 * 100) / 100
+ }
+ return undefined
+}
+
+function mapTextAlign(
+ align?: string
+): TextNode['textAlign'] {
+ switch (align) {
+ case 'LEFT': return 'left'
+ case 'CENTER': return 'center'
+ case 'RIGHT': return 'right'
+ case 'JUSTIFIED': return 'justify'
+ default: return undefined
+ }
+}
+
+function mapTextAlignVertical(
+ align?: string
+): TextNode['textAlignVertical'] {
+ switch (align) {
+ case 'TOP': return 'top'
+ case 'CENTER': return 'middle'
+ case 'BOTTOM': return 'bottom'
+ default: return undefined
+ }
+}
+
+function mapTextGrowth(
+ resize?: string
+): TextNode['textGrowth'] {
+ switch (resize) {
+ case 'WIDTH_AND_HEIGHT': return 'auto'
+ case 'HEIGHT': return 'fixed-width'
+ case 'NONE': return 'fixed-width-height'
+ default: return undefined
+ }
+}
diff --git a/packages/pen-figma/src/figma-tree-builder.ts b/packages/pen-figma/src/figma-tree-builder.ts
new file mode 100644
index 00000000..a43c402f
--- /dev/null
+++ b/packages/pen-figma/src/figma-tree-builder.ts
@@ -0,0 +1,137 @@
+import type { FigmaNodeChange, FigmaGUID } from './figma-types'
+
+export interface TreeNode {
+ figma: FigmaNodeChange
+ children: TreeNode[]
+}
+
+export function guidToString(guid: FigmaGUID): string {
+ return `${guid.sessionID}:${guid.localID}`
+}
+
+/** Filter out Figma's internal-only canvas (stores metadata, not user content). */
+export function isUserPage(node: TreeNode): boolean {
+ return node.figma.type === 'CANVAS' &&
+ !/^Internal\s+Only/i.test(node.figma.name ?? '')
+}
+
+export function buildTree(nodeChanges: FigmaNodeChange[]): TreeNode | null {
+ const nodeMap = new Map()
+ let root: TreeNode | null = null
+
+ for (const nc of nodeChanges) {
+ if (!nc.guid) continue
+ if (nc.phase === 'REMOVED') continue
+ const key = guidToString(nc.guid)
+ nodeMap.set(key, { figma: nc, children: [] })
+ }
+
+ for (const nc of nodeChanges) {
+ if (!nc.guid || nc.phase === 'REMOVED') continue
+ const key = guidToString(nc.guid)
+ const treeNode = nodeMap.get(key)
+ if (!treeNode) continue
+
+ if (nc.type === 'DOCUMENT') {
+ root = treeNode
+ continue
+ }
+
+ if (nc.parentIndex?.guid) {
+ const parentKey = guidToString(nc.parentIndex.guid)
+ const parent = nodeMap.get(parentKey)
+ if (parent) {
+ parent.children.push(treeNode)
+ }
+ }
+ }
+
+ if (root) {
+ sortChildrenRecursive(root)
+ }
+
+ return root
+}
+
+/**
+ * Build a tree from clipboard nodeChanges that may lack a DOCUMENT wrapper.
+ * Collects orphan nodes (whose parent is not in the data) as roots.
+ */
+export function buildTreeForClipboard(nodeChanges: FigmaNodeChange[]): TreeNode[] {
+ const nodeMap = new Map()
+ const childKeys = new Set()
+
+ for (const nc of nodeChanges) {
+ if (!nc.guid) continue
+ if (nc.phase === 'REMOVED') continue
+ const key = guidToString(nc.guid)
+ nodeMap.set(key, { figma: nc, children: [] })
+ }
+
+ for (const nc of nodeChanges) {
+ if (!nc.guid || nc.phase === 'REMOVED') continue
+ const key = guidToString(nc.guid)
+ const treeNode = nodeMap.get(key)
+ if (!treeNode) continue
+
+ if (nc.parentIndex?.guid) {
+ const parentKey = guidToString(nc.parentIndex.guid)
+ const parent = nodeMap.get(parentKey)
+ if (parent) {
+ parent.children.push(treeNode)
+ childKeys.add(key)
+ }
+ }
+ }
+
+ const roots: TreeNode[] = []
+ for (const [key, node] of nodeMap) {
+ if (!childKeys.has(key) && node.figma.type !== 'DOCUMENT') {
+ roots.push(node)
+ }
+ }
+
+ for (const root of roots) {
+ sortChildrenRecursive(root)
+ }
+
+ return roots
+}
+
+function sortChildrenRecursive(node: TreeNode): void {
+ node.children.sort((a, b) => {
+ const posA = a.figma.parentIndex?.position ?? ''
+ const posB = b.figma.parentIndex?.position ?? ''
+ return posA < posB ? 1 : posA > posB ? -1 : 0
+ })
+ for (const child of node.children) {
+ sortChildrenRecursive(child)
+ }
+}
+
+export function collectComponents(
+ node: TreeNode,
+ map: Map,
+ genId: () => string,
+): void {
+ if (node.figma.type === 'SYMBOL' && node.figma.guid) {
+ const figmaId = guidToString(node.figma.guid)
+ map.set(figmaId, genId())
+ }
+ for (const child of node.children) {
+ collectComponents(child, map, genId)
+ }
+}
+
+/** Collect SYMBOL TreeNodes keyed by figma GUID from all canvases (including internal). */
+export function collectSymbolTree(
+ root: TreeNode,
+ map: Map,
+): void {
+ if (root.figma.type === 'SYMBOL' && root.figma.guid) {
+ map.set(guidToString(root.figma.guid), root)
+ }
+ for (const child of root.children) {
+ collectSymbolTree(child, map)
+ }
+}
diff --git a/packages/pen-figma/src/figma-types.ts b/packages/pen-figma/src/figma-types.ts
new file mode 100644
index 00000000..7b642911
--- /dev/null
+++ b/packages/pen-figma/src/figma-types.ts
@@ -0,0 +1,275 @@
+// Figma .fig binary file internal format type definitions
+// Decoded via kiwi-schema from the binary format
+
+export interface FigmaGUID {
+ sessionID: number
+ localID: number
+}
+
+export interface FigmaParentIndex {
+ guid: FigmaGUID
+ position: string
+}
+
+export interface FigmaVector {
+ x: number
+ y: number
+}
+
+export interface FigmaMatrix {
+ m00: number
+ m01: number
+ m02: number // translateX
+ m10: number
+ m11: number
+ m12: number // translateY
+}
+
+export interface FigmaColor {
+ r: number // 0.0-1.0
+ g: number
+ b: number
+ a: number
+}
+
+export interface FigmaColorStop {
+ color: FigmaColor
+ position: number
+}
+
+export type FigmaPaintType =
+ | 'SOLID'
+ | 'GRADIENT_LINEAR'
+ | 'GRADIENT_RADIAL'
+ | 'GRADIENT_ANGULAR'
+ | 'GRADIENT_DIAMOND'
+ | 'IMAGE'
+ | 'EMOJI'
+
+export interface FigmaImage {
+ hash?: Uint8Array
+ name?: string
+ dataBlob?: number
+}
+
+export interface FigmaPaint {
+ type?: FigmaPaintType
+ color?: FigmaColor
+ opacity?: number
+ visible?: boolean
+ blendMode?: string
+ stops?: FigmaColorStop[]
+ transform?: FigmaMatrix
+ image?: FigmaImage
+ imageScaleMode?: 'STRETCH' | 'FIT' | 'FILL' | 'TILE'
+}
+
+export type FigmaEffectType =
+ | 'INNER_SHADOW'
+ | 'DROP_SHADOW'
+ | 'FOREGROUND_BLUR'
+ | 'BACKGROUND_BLUR'
+
+export interface FigmaEffect {
+ type?: FigmaEffectType
+ color?: FigmaColor
+ offset?: FigmaVector
+ radius?: number
+ spread?: number
+ visible?: boolean
+ blendMode?: string
+}
+
+export interface FigmaFontName {
+ family?: string
+ style?: string
+ postscript?: string
+}
+
+export interface FigmaNumber {
+ value?: number
+ units?: 'RAW' | 'PIXELS' | 'PERCENT'
+}
+
+export interface FigmaTextData {
+ characters?: string
+ characterStyleIDs?: number[]
+ styleOverrideTable?: FigmaNodeChange[]
+}
+
+export interface FigmaPath {
+ windingRule?: 'NONZERO' | 'ODD'
+ commandsBlob?: number
+ styleID?: number
+}
+
+export interface FigmaVectorData {
+ vectorNetworkBlob?: number
+ normalizedSize?: FigmaVector
+}
+
+export interface FigmaArcData {
+ startingAngle?: number
+ endingAngle?: number
+ innerRadius?: number
+}
+
+export interface FigmaGuidPath {
+ guids: FigmaGUID[]
+}
+
+/** Per-child override stored on an INSTANCE's symbolData.
+ * Extends FigmaNodeChange to support all overridable node properties
+ * (size, opacity, visible, strokes, layout, corner radii, etc.). */
+export interface FigmaSymbolOverride extends Omit {
+ guidPath?: FigmaGuidPath
+}
+
+/** Pre-computed size/transform for each node inside an INSTANCE. */
+export interface FigmaDerivedSymbolDataEntry {
+ guidPath?: FigmaGuidPath
+ size?: FigmaVector
+ transform?: FigmaMatrix
+ fontSize?: number
+ derivedTextData?: FigmaTextData
+}
+
+export type FigmaNodeType =
+ | 'NONE'
+ | 'DOCUMENT'
+ | 'CANVAS'
+ | 'GROUP'
+ | 'FRAME'
+ | 'BOOLEAN_OPERATION'
+ | 'VECTOR'
+ | 'STAR'
+ | 'LINE'
+ | 'ELLIPSE'
+ | 'RECTANGLE'
+ | 'ROUNDED_RECTANGLE'
+ | 'REGULAR_POLYGON'
+ | 'TEXT'
+ | 'SLICE'
+ | 'SYMBOL' // = COMPONENT in REST API
+ | 'INSTANCE'
+ | 'STICKY'
+ | 'SHAPE_WITH_TEXT'
+ | 'CONNECTOR'
+ | 'CODE_BLOCK'
+ | 'WIDGET'
+ | 'STAMP'
+ | 'MEDIA'
+ | 'HIGHLIGHT'
+ | 'SECTION'
+ | 'SECTION_OVERLAY'
+ | 'WASHI_TAPE'
+
+export interface FigmaNodeChange {
+ guid?: FigmaGUID
+ parentIndex?: FigmaParentIndex
+ type?: FigmaNodeType
+ phase?: string
+ name?: string
+ visible?: boolean
+ locked?: boolean
+
+ // Geometry
+ size?: FigmaVector
+ transform?: FigmaMatrix
+
+ // Appearance
+ opacity?: number
+ blendMode?: string
+
+ // Fills & Strokes
+ fillPaints?: FigmaPaint[]
+ backgroundPaints?: FigmaPaint[]
+ strokePaints?: FigmaPaint[]
+ strokeWeight?: number
+ strokeAlign?: 'CENTER' | 'INSIDE' | 'OUTSIDE'
+ strokeCap?: string
+ strokeJoin?: 'MITER' | 'BEVEL' | 'ROUND'
+ dashPattern?: number[]
+
+ // Individual border weights
+ borderStrokeWeightsIndependent?: boolean
+ borderTopWeight?: number
+ borderBottomWeight?: number
+ borderLeftWeight?: number
+ borderRightWeight?: number
+
+ // Effects
+ effects?: FigmaEffect[]
+
+ // Corner radius
+ cornerRadius?: number
+ rectangleCornerRadiiIndependent?: boolean
+ rectangleTopLeftCornerRadius?: number
+ rectangleTopRightCornerRadius?: number
+ rectangleBottomLeftCornerRadius?: number
+ rectangleBottomRightCornerRadius?: number
+
+ // Text
+ fontSize?: number
+ fontName?: FigmaFontName
+ textAlignHorizontal?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
+ textAlignVertical?: 'TOP' | 'CENTER' | 'BOTTOM'
+ lineHeight?: FigmaNumber
+ letterSpacing?: FigmaNumber
+ textAutoResize?: 'NONE' | 'WIDTH_AND_HEIGHT' | 'HEIGHT'
+ textDecoration?: 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH'
+ textCase?: 'ORIGINAL' | 'UPPER' | 'LOWER' | 'TITLE'
+ textData?: FigmaTextData
+
+ // Auto-layout (stack)
+ stackMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL'
+ stackSpacing?: number
+ stackPadding?: number
+ stackHorizontalPadding?: number
+ stackVerticalPadding?: number
+ stackPaddingRight?: number
+ stackPaddingBottom?: number
+ stackPrimarySizing?: string
+ stackCounterSizing?: string
+ stackPrimaryAlignItems?: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_EVENLY'
+ stackCounterAlignItems?: 'MIN' | 'CENTER' | 'MAX' | 'BASELINE'
+ stackChildPrimaryGrow?: number
+ stackChildAlignSelf?: string
+ stackPositioning?: 'AUTO' | 'ABSOLUTE'
+
+ // Masking / clipping
+ frameMaskDisabled?: boolean
+
+ // Vector/Path
+ vectorData?: FigmaVectorData
+ fillGeometry?: FigmaPath[]
+ strokeGeometry?: FigmaPath[]
+
+ // Ellipse arc
+ arcData?: FigmaArcData
+
+ // Component/Instance
+ symbolData?: {
+ symbolID?: FigmaGUID
+ symbolOverrides?: FigmaSymbolOverride[]
+ }
+ overriddenSymbolID?: FigmaGUID
+ componentKey?: string
+ derivedSymbolData?: FigmaDerivedSymbolDataEntry[]
+}
+
+export interface FigmaDecodedFile {
+ nodeChanges: FigmaNodeChange[]
+ blobs: (Uint8Array | string)[]
+ imageFiles: Map
+}
+
+export interface FigmaPage {
+ id: string
+ name: string
+ childCount: number
+}
+
+export type FigmaImportLayoutMode = 'preserve' | 'openpencil'
diff --git a/packages/pen-figma/src/figma-vector-decoder.ts b/packages/pen-figma/src/figma-vector-decoder.ts
new file mode 100644
index 00000000..580262ba
--- /dev/null
+++ b/packages/pen-figma/src/figma-vector-decoder.ts
@@ -0,0 +1,321 @@
+import type { FigmaNodeChange } from './figma-types'
+
+/**
+ * Decode Figma binary path blob to SVG path `d` string.
+ * Binary format: sequence of commands, each starting with a command byte:
+ * 0x00 = closePath (Z) — 0 floats
+ * 0x01 = moveTo (M) — 2 float32 LE (x, y)
+ * 0x02 = lineTo (L) — 2 float32 LE (x, y)
+ * 0x04 = cubicTo (C) — 6 float32 LE (cp1x, cp1y, cp2x, cp2y, x, y)
+ * 0x03 = quadTo (Q) — 4 float32 LE (cpx, cpy, x, y)
+ */
+function decodeFigmaPathBlob(blob: Uint8Array): string | null {
+ if (blob.length < 9) return null // minimum: 1 cmd byte + 2 float32
+
+ const buf = new ArrayBuffer(blob.byteLength)
+ new Uint8Array(buf).set(blob)
+ const view = new DataView(buf)
+
+ const parts: string[] = []
+ let offset = 0
+
+ while (offset < blob.length) {
+ const cmd = blob[offset]
+ offset += 1
+
+ switch (cmd) {
+ case 0x00: // close
+ parts.push('Z')
+ break
+ case 0x01: { // moveTo
+ if (offset + 8 > blob.length) return joinParts(parts)
+ const x = view.getFloat32(offset, true); offset += 4
+ const y = view.getFloat32(offset, true); offset += 4
+ if (!hasNaN(x, y)) parts.push(`M${r(x)} ${r(y)}`)
+ break
+ }
+ case 0x02: { // lineTo
+ if (offset + 8 > blob.length) return joinParts(parts)
+ const x = view.getFloat32(offset, true); offset += 4
+ const y = view.getFloat32(offset, true); offset += 4
+ if (!hasNaN(x, y)) parts.push(`L${r(x)} ${r(y)}`)
+ break
+ }
+ case 0x03: { // quadTo
+ if (offset + 16 > blob.length) return joinParts(parts)
+ const cpx = view.getFloat32(offset, true); offset += 4
+ const cpy = view.getFloat32(offset, true); offset += 4
+ const x = view.getFloat32(offset, true); offset += 4
+ const y = view.getFloat32(offset, true); offset += 4
+ if (!hasNaN(cpx, cpy, x, y)) parts.push(`Q${r(cpx)} ${r(cpy)} ${r(x)} ${r(y)}`)
+ break
+ }
+ case 0x04: { // cubicTo
+ if (offset + 24 > blob.length) return joinParts(parts)
+ const cp1x = view.getFloat32(offset, true); offset += 4
+ const cp1y = view.getFloat32(offset, true); offset += 4
+ const cp2x = view.getFloat32(offset, true); offset += 4
+ const cp2y = view.getFloat32(offset, true); offset += 4
+ const x = view.getFloat32(offset, true); offset += 4
+ const y = view.getFloat32(offset, true); offset += 4
+ if (!hasNaN(cp1x, cp1y, cp2x, cp2y, x, y)) parts.push(`C${r(cp1x)} ${r(cp1y)} ${r(cp2x)} ${r(cp2y)} ${r(x)} ${r(y)}`)
+ break
+ }
+ default:
+ // Unknown command — stop decoding
+ return joinParts(parts)
+ }
+ }
+
+ return joinParts(parts)
+}
+
+/** Round to 4 decimal places for accurate SVG path data. */
+function r(n: number): string {
+ return Math.abs(n) < 0.00005 ? '0' : parseFloat(n.toFixed(4)).toString()
+}
+
+/** Check if any float is NaN/Infinity. */
+function hasNaN(...vals: number[]): boolean {
+ for (const v of vals) { if (!Number.isFinite(v)) return true }
+ return false
+}
+
+function joinParts(parts: string[]): string | null {
+ return parts.length > 0 ? parts.join(' ') : null
+}
+
+export interface PathBounds {
+ minX: number; minY: number; maxX: number; maxY: number
+}
+
+/**
+ * Compute approximate bounding box of an SVG path string from its coordinates.
+ * Uses control points (not curve extrema), which is sufficient for layout purposes.
+ */
+export function computeSvgPathBounds(d: string): PathBounds | null {
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
+ const cmds = d.match(/[MLCQZ][^MLCQZ]*/gi)
+ if (!cmds) return null
+ for (const cmd of cmds) {
+ const letter = cmd[0].toUpperCase()
+ if (letter === 'Z') continue
+ const coords = cmd.slice(1).trim().match(/-?\d+\.?\d*/g)
+ if (!coords) continue
+ const vals = coords.map(Number)
+ for (let i = 0; i < vals.length - 1; i += 2) {
+ const x = vals[i], y = vals[i + 1]
+ if (Number.isFinite(x) && Number.isFinite(y)) {
+ minX = Math.min(minX, x)
+ minY = Math.min(minY, y)
+ maxX = Math.max(maxX, x)
+ maxY = Math.max(maxY, y)
+ }
+ }
+ }
+ if (!Number.isFinite(minX)) return null
+ return { minX, minY, maxX, maxY }
+}
+
+/**
+ * Try to decode vector path data from a Figma node's fill/stroke geometry blobs.
+ * Scales coordinates from normalizedSize to actual node size if needed.
+ */
+export function decodeFigmaVectorPath(
+ figma: FigmaNodeChange,
+ blobs: (Uint8Array | string)[],
+): string | null {
+ // For stroke-only vectors (e.g. Lucide icons), prefer strokeGeometry which
+ // contains the original centerline path. fillGeometry for stroke-only vectors
+ // is the expanded stroke outline — stroking it again produces double thickness.
+ const hasVisibleFills = figma.fillPaints?.some((p) => p.visible !== false)
+ const hasVisibleStrokes = figma.strokePaints?.some((p) => p.visible !== false)
+ const geometries = (!hasVisibleFills && hasVisibleStrokes)
+ ? (figma.strokeGeometry ?? figma.fillGeometry)
+ : (figma.fillGeometry ?? figma.strokeGeometry)
+
+ if (!geometries || geometries.length === 0) {
+ // Try to decode from vectorData.vectorNetworkBlob as fallback
+ return decodeVectorNetworkBlob(figma, blobs)
+ }
+
+ const pathParts: string[] = []
+
+ for (const geom of geometries) {
+ if (geom.commandsBlob == null) continue
+ const blob = blobs[geom.commandsBlob]
+ if (!blob || typeof blob === 'string') continue
+ const decoded = decodeFigmaPathBlob(blob)
+ if (decoded) pathParts.push(decoded)
+ }
+
+ if (pathParts.length === 0) {
+ // Try vectorNetworkBlob fallback
+ const vnPath = decodeVectorNetworkBlob(figma, blobs)
+ if (vnPath) return vnPath
+ return null
+ }
+
+ // fillGeometry/strokeGeometry coordinates are already in the node's local
+ // coordinate space (0..size.x, 0..size.y). Do NOT scale by normalizedSize —
+ // that applies only to vectorNetworkBlob, which is not used here.
+ return pathParts.join(' ')
+}
+
+/**
+ * Decode Figma's vectorNetworkBlob (VectorNetwork) as a fallback when
+ * fill/stroke geometry blobs are not available.
+ *
+ * The vectorNetwork blob format:
+ * [4 bytes LE] vertex count
+ * For each vertex: [4 bytes float32 LE x] [4 bytes float32 LE y]
+ * [4 bytes LE] segment count
+ * For each segment:
+ * [4 bytes LE] start vertex index
+ * [4 bytes LE] end vertex index
+ * [4 bytes float32 LE] tangentStart.x
+ * [4 bytes float32 LE] tangentStart.y
+ * [4 bytes float32 LE] tangentEnd.x
+ * [4 bytes float32 LE] tangentEnd.y
+ */
+function decodeVectorNetworkBlob(
+ figma: FigmaNodeChange,
+ blobs: (Uint8Array | string)[],
+): string | null {
+ const blobIdx = figma.vectorData?.vectorNetworkBlob
+ if (blobIdx == null) return null
+ const blob = blobs[blobIdx]
+ if (!blob || typeof blob === 'string' || blob.length < 8) return null
+
+ const buf = new ArrayBuffer(blob.byteLength)
+ new Uint8Array(buf).set(blob)
+ const view = new DataView(buf)
+ let offset = 0
+
+ try {
+ // Read vertices
+ const vertexCount = view.getUint32(offset, true); offset += 4
+ if (vertexCount > 100000 || offset + vertexCount * 8 > blob.length) return null
+
+ const vertices: { x: number; y: number }[] = []
+ for (let i = 0; i < vertexCount; i++) {
+ const x = view.getFloat32(offset, true); offset += 4
+ const y = view.getFloat32(offset, true); offset += 4
+ vertices.push({ x, y })
+ }
+
+ if (offset + 4 > blob.length) return null
+ // Read segments
+ const segmentCount = view.getUint32(offset, true); offset += 4
+ if (segmentCount > 100000) return null
+
+ // Build adjacency list: for each vertex, which segments start from it
+ const segments: {
+ start: number; end: number
+ ts: { x: number; y: number }; te: { x: number; y: number }
+ }[] = []
+
+ for (let i = 0; i < segmentCount; i++) {
+ if (offset + 24 > blob.length) break
+ const startIdx = view.getUint32(offset, true); offset += 4
+ const endIdx = view.getUint32(offset, true); offset += 4
+ const tsx = view.getFloat32(offset, true); offset += 4
+ const tsy = view.getFloat32(offset, true); offset += 4
+ const tex = view.getFloat32(offset, true); offset += 4
+ const tey = view.getFloat32(offset, true); offset += 4
+
+ if (startIdx < vertexCount && endIdx < vertexCount) {
+ segments.push({
+ start: startIdx, end: endIdx,
+ ts: { x: tsx, y: tsy }, te: { x: tex, y: tey },
+ })
+ }
+ }
+
+ if (segments.length === 0 || vertices.length === 0) return null
+
+ // Scale from normalizedSize to actual node size
+ const normW = figma.vectorData?.normalizedSize?.x ?? 1
+ const normH = figma.vectorData?.normalizedSize?.y ?? 1
+ const nodeW = figma.size?.x ?? normW
+ const nodeH = figma.size?.y ?? normH
+ const sx = normW > 0.001 ? nodeW / normW : 1
+ const sy = normH > 0.001 ? nodeH / normH : 1
+
+ // Convert segments to SVG path commands
+ // Simple approach: each segment becomes an independent moveTo + curveTo/lineTo
+ const parts: string[] = []
+ const used = new Set()
+
+ // Build adjacency for chain walking
+ const adj = new Map()
+ for (let i = 0; i < segments.length; i++) {
+ const s = segments[i]
+ if (!adj.has(s.start)) adj.set(s.start, [])
+ adj.get(s.start)!.push(i)
+ }
+
+ // Walk chains starting from each unused segment
+ for (let i = 0; i < segments.length; i++) {
+ if (used.has(i)) continue
+
+ const seg = segments[i]
+ const sv = vertices[seg.start]
+ parts.push(`M${r(sv.x * sx)} ${r(sv.y * sy)}`)
+ used.add(i)
+
+ // Emit this segment
+ emitSegment(seg, vertices, sx, sy, parts)
+
+ // Follow chain
+ let current = seg.end
+ let found = true
+ while (found) {
+ found = false
+ const nexts = adj.get(current)
+ if (nexts) {
+ for (const ni of nexts) {
+ if (used.has(ni)) continue
+ used.add(ni)
+ emitSegment(segments[ni], vertices, sx, sy, parts)
+ current = segments[ni].end
+ found = true
+ break
+ }
+ }
+ }
+
+ // Check if path is closed
+ if (current === seg.start) parts.push('Z')
+ }
+
+ const result = parts.join(' ')
+ return result || null
+ } catch {
+ return null
+ }
+}
+
+function emitSegment(
+ seg: { start: number; end: number; ts: { x: number; y: number }; te: { x: number; y: number } },
+ vertices: { x: number; y: number }[],
+ sx: number, sy: number,
+ parts: string[],
+): void {
+ const sv = vertices[seg.start]
+ const ev = vertices[seg.end]
+ const isStraight =
+ Math.abs(seg.ts.x) < 0.0001 && Math.abs(seg.ts.y) < 0.0001 &&
+ Math.abs(seg.te.x) < 0.0001 && Math.abs(seg.te.y) < 0.0001
+
+ if (isStraight) {
+ parts.push(`L${r(ev.x * sx)} ${r(ev.y * sy)}`)
+ } else {
+ // Tangents are relative offsets from start/end vertices
+ const cp1x = (sv.x + seg.ts.x) * sx
+ const cp1y = (sv.y + seg.ts.y) * sy
+ const cp2x = (ev.x + seg.te.x) * sx
+ const cp2y = (ev.y + seg.te.y) * sy
+ parts.push(`C${r(cp1x)} ${r(cp1y)} ${r(cp2x)} ${r(cp2y)} ${r(ev.x * sx)} ${r(ev.y * sy)}`)
+ }
+}
diff --git a/packages/pen-figma/src/index.ts b/packages/pen-figma/src/index.ts
new file mode 100644
index 00000000..6a10d052
--- /dev/null
+++ b/packages/pen-figma/src/index.ts
@@ -0,0 +1,26 @@
+// Parser
+export { parseFigFile } from './fig-parser.js'
+
+// Document conversion
+export {
+ figmaToPenDocument,
+ figmaAllPagesToPenDocument,
+ getFigmaPages,
+ figmaNodeChangesToPenNodes,
+} from './figma-node-mapper.js'
+
+// Clipboard
+export {
+ isFigmaClipboardHtml,
+ extractFigmaClipboardData,
+ figmaClipboardToNodes,
+} from './figma-clipboard.js'
+
+// Image resolution
+export { resolveImageBlobs } from './figma-image-resolver.js'
+
+// Icon lookup injection
+export { setIconLookup } from './figma-node-converters.js'
+
+// Types
+export type { FigmaDecodedFile, FigmaImportLayoutMode } from './figma-types.js'
diff --git a/packages/pen-figma/tsconfig.json b/packages/pen-figma/tsconfig.json
new file mode 100644
index 00000000..df59da57
--- /dev/null
+++ b/packages/pen-figma/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/pen-renderer/package.json b/packages/pen-renderer/package.json
new file mode 100644
index 00000000..343cd772
--- /dev/null
+++ b/packages/pen-renderer/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@zseven-w/pen-renderer",
+ "version": "0.5.0",
+ "description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
+ "files": [
+ "src"
+ ],
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@zseven-w/pen-types": "workspace:*",
+ "@zseven-w/pen-core": "workspace:*",
+ "rbush": "^4.0.1"
+ },
+ "peerDependencies": {
+ "canvaskit-wasm": "^0.40.0"
+ },
+ "devDependencies": {
+ "@types/rbush": "^4.0.0",
+ "canvaskit-wasm": "^0.40.0",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/packages/pen-renderer/src/document-flattener.ts b/packages/pen-renderer/src/document-flattener.ts
new file mode 100644
index 00000000..4736e336
--- /dev/null
+++ b/packages/pen-renderer/src/document-flattener.ts
@@ -0,0 +1,340 @@
+import type { PenNode, ContainerProps, RefNode } from '@zseven-w/pen-types'
+import {
+ resolvePadding,
+ isNodeVisible,
+ getNodeWidth,
+ getNodeHeight,
+ computeLayoutPositions,
+ inferLayout,
+ parseSizing,
+ defaultLineHeight,
+ findNodeInTree,
+ cssFontFamily,
+} from '@zseven-w/pen-core'
+import { wrapLine } from './paint-utils.js'
+import type { RenderNode } from './types.js'
+
+// ---------------------------------------------------------------------------
+// Pre-measure text widths using Canvas 2D (browser fonts)
+// ---------------------------------------------------------------------------
+
+let _measureCtx: CanvasRenderingContext2D | null = null
+function getMeasureCtx(): CanvasRenderingContext2D {
+ if (!_measureCtx) {
+ const c = document.createElement('canvas')
+ _measureCtx = c.getContext('2d')!
+ }
+ return _measureCtx
+}
+
+/**
+ * Walk the node tree and fix text HEIGHTS using actual Canvas 2D wrapping.
+ *
+ * Only targets fixed-width text with auto height — these are the cases where
+ * estimateTextHeight may underestimate because its width estimation differs
+ * from Canvas 2D's actual text measurement, leading to incorrect wrap counts.
+ *
+ * IMPORTANT: This function never touches WIDTH or container-relative sizing
+ * strings (fill_container / fit_content). Changing widths breaks layout
+ * resolution in computeLayoutPositions.
+ */
+export function premeasureTextHeights(nodes: PenNode[]): PenNode[] {
+ return nodes.map((node) => {
+ let result = node
+
+ if (node.type === 'text') {
+ const tNode = node as PenNode & { width?: number | string; height?: number | string; fontSize?: number; fontWeight?: string; fontFamily?: string; lineHeight?: number; textAlign?: string; textGrowth?: string; content?: string | { text?: string }[] }
+ const hasFixedWidth = typeof tNode.width === 'number' && tNode.width > 0
+ const isContainerHeight = typeof tNode.height === 'string'
+ && (tNode.height === 'fill_container' || tNode.height === 'fit_content')
+ const textGrowth = tNode.textGrowth
+ const content = typeof tNode.content === 'string'
+ ? tNode.content
+ : Array.isArray(tNode.content)
+ ? tNode.content.map((s) => s.text ?? '').join('')
+ : ''
+
+ const textAlign = tNode.textAlign
+ const isFixedWidthText = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
+ || (textGrowth !== 'auto' && textAlign != null && textAlign !== 'left')
+ if (content && hasFixedWidth && isFixedWidthText && !isContainerHeight) {
+ const fontSize = tNode.fontSize ?? 16
+ const fontWeight = tNode.fontWeight ?? '400'
+ const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
+ const ctx = getMeasureCtx()
+ ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
+
+ const wrapWidth = (tNode.width as number) + fontSize * 0.2
+ const rawLines = content.split('\n')
+ const wrappedLines: string[] = []
+ for (const raw of rawLines) {
+ if (!raw) { wrappedLines.push(''); continue }
+ wrapLine(ctx, raw, wrapWidth, wrappedLines)
+ }
+ const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
+ const lineHeight = lineHeightMul * fontSize
+ const glyphH = fontSize * 1.13
+ const measuredHeight = Math.ceil(
+ wrappedLines.length <= 1
+ ? glyphH + 2
+ : (wrappedLines.length - 1) * lineHeight + glyphH + 2,
+ )
+ const currentHeight = typeof tNode.height === 'number' ? tNode.height : 0
+ const explicitLineCount = rawLines.length
+ const needsHeight = currentHeight <= 0 || wrappedLines.length > explicitLineCount
+ if (needsHeight && measuredHeight > currentHeight) {
+ result = { ...node, height: measuredHeight } as unknown as PenNode
+ }
+ }
+ }
+
+ // Recurse into children
+ if ('children' in result && result.children) {
+ const children = result.children
+ const measured = premeasureTextHeights(children)
+ if (measured !== children) {
+ result = { ...result, children: measured } as unknown as PenNode
+ }
+ }
+
+ return result
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Flatten document tree -> absolute-positioned RenderNode list
+// ---------------------------------------------------------------------------
+
+interface ClipInfo {
+ x: number; y: number; w: number; h: number; rx: number
+}
+
+function sizeToNumber(val: number | string | undefined, fallback: number): number {
+ if (typeof val === 'number') return val
+ if (typeof val === 'string') {
+ const m = val.match(/\((\d+(?:\.\d+)?)\)/)
+ if (m) return parseFloat(m[1])
+ const n = parseFloat(val)
+ if (!isNaN(n)) return n
+ }
+ return fallback
+}
+
+function cornerRadiusVal(cr: number | [number, number, number, number] | undefined): number {
+ if (cr === undefined) return 0
+ if (typeof cr === 'number') return cr
+ return cr[0]
+}
+
+export function flattenToRenderNodes(
+ nodes: PenNode[],
+ offsetX = 0,
+ offsetY = 0,
+ parentAvailW?: number,
+ parentAvailH?: number,
+ clipCtx?: ClipInfo,
+ depth = 0,
+): RenderNode[] {
+ const result: RenderNode[] = []
+
+ // Reverse order: children[0] = top layer = rendered last (frontmost)
+ for (let i = nodes.length - 1; i >= 0; i--) {
+ const node = nodes[i]
+ if (!isNodeVisible(node)) continue
+
+ // Resolve fill_container / fit_content
+ let resolved = node
+ if (parentAvailW !== undefined || parentAvailH !== undefined) {
+ let changed = false
+ const r: Record = { ...node }
+ if ('width' in node && typeof node.width !== 'number') {
+ const s = parseSizing(node.width)
+ if (s === 'fill' && parentAvailW) { r.width = parentAvailW; changed = true }
+ else if (s === 'fit') { r.width = getNodeWidth(node, parentAvailW); changed = true }
+ }
+ if ('height' in node && typeof node.height !== 'number') {
+ const s = parseSizing(node.height)
+ if (s === 'fill' && parentAvailH) { r.height = parentAvailH; changed = true }
+ else if (s === 'fit') { r.height = getNodeHeight(node, parentAvailH, parentAvailW); changed = true }
+ }
+ if (changed) resolved = r as unknown as PenNode
+ }
+
+ // Compute height for frames without explicit numeric height
+ if (
+ node.type === 'frame'
+ && 'children' in node && node.children?.length
+ && (!('height' in resolved) || typeof resolved.height !== 'number')
+ ) {
+ const computedH = getNodeHeight(resolved, parentAvailH, parentAvailW)
+ if (computedH > 0) resolved = { ...resolved, height: computedH } as unknown as PenNode
+ }
+
+ const absX = (resolved.x ?? 0) + offsetX
+ const absY = (resolved.y ?? 0) + offsetY
+ const absW = 'width' in resolved ? sizeToNumber(resolved.width, 100) : 100
+ const absH = 'height' in resolved ? sizeToNumber(resolved.height, 100) : 100
+
+ result.push({
+ node: { ...resolved, x: absX, y: absY } as PenNode,
+ absX, absY, absW, absH,
+ clipRect: clipCtx,
+ })
+
+ // Recurse into children
+ const children = 'children' in node ? node.children : undefined
+ if (children && children.length > 0) {
+ const nodeW = getNodeWidth(resolved, parentAvailW)
+ const nodeH = getNodeHeight(resolved, parentAvailH, parentAvailW)
+ const pad = resolvePadding('padding' in resolved ? (resolved as PenNode & ContainerProps).padding : undefined)
+ const childAvailW = Math.max(0, nodeW - pad.left - pad.right)
+ const childAvailH = Math.max(0, nodeH - pad.top - pad.bottom)
+
+ const layout = ('layout' in node ? (node as ContainerProps).layout : undefined) || inferLayout(node)
+ const positioned = layout && layout !== 'none'
+ ? computeLayoutPositions(resolved, children)
+ : children
+
+ // Clipping — only clip for root frames (artboard behavior).
+ let childClip = clipCtx
+ const isRootFrame = node.type === 'frame' && depth === 0
+ if (isRootFrame) {
+ const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0
+ const cr = Math.min(crRaw, nodeH / 2)
+ childClip = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr }
+ }
+
+ const childRNs = flattenToRenderNodes(positioned, absX, absY, childAvailW, childAvailH, childClip, depth + 1)
+
+ // Propagate parent flip to children
+ const parentFlipX = node.flipX === true
+ const parentFlipY = node.flipY === true
+ if (parentFlipX || parentFlipY) {
+ const pcx = absX + nodeW / 2
+ const pcy = absY + nodeH / 2
+ for (const crn of childRNs) {
+ const updates: Record = {}
+ if (parentFlipX) {
+ const ccx = crn.absX + crn.absW / 2
+ crn.absX = 2 * pcx - ccx - crn.absW / 2
+ const childFlip = crn.node.flipX === true
+ updates.flipX = !childFlip || undefined
+ }
+ if (parentFlipY) {
+ const ccy = crn.absY + crn.absH / 2
+ crn.absY = 2 * pcy - ccy - crn.absH / 2
+ const childFlip = crn.node.flipY === true
+ updates.flipY = !childFlip || undefined
+ }
+ crn.node = { ...crn.node, x: crn.absX, y: crn.absY, ...updates } as PenNode
+ }
+ }
+
+ // Propagate parent rotation to children
+ const parentRot = node.rotation ?? 0
+ if (parentRot !== 0) {
+ const cx = absX + nodeW / 2
+ const cy = absY + nodeH / 2
+ const rad = parentRot * Math.PI / 180
+ const cosA = Math.cos(rad)
+ const sinA = Math.sin(rad)
+
+ for (const crn of childRNs) {
+ const ccx = crn.absX + crn.absW / 2
+ const ccy = crn.absY + crn.absH / 2
+ const dx = ccx - cx
+ const dy = ccy - cy
+ const newCx = cx + dx * cosA - dy * sinA
+ const newCy = cy + dx * sinA + dy * cosA
+ crn.absX = newCx - crn.absW / 2
+ crn.absY = newCy - crn.absH / 2
+ const childRot = crn.node.rotation ?? 0
+ crn.node = { ...crn.node, x: crn.absX, y: crn.absY, rotation: childRot + parentRot } as PenNode
+ }
+ }
+
+ result.push(...childRNs)
+ }
+ }
+
+ return result
+}
+
+// ---------------------------------------------------------------------------
+// Ref resolution — resolve RefNodes to their target components
+// ---------------------------------------------------------------------------
+
+/** Resolve RefNodes inline (same logic as use-canvas-sync.ts). */
+export function resolveRefs(
+ nodes: PenNode[],
+ rootNodes: PenNode[],
+ findInTree?: (nodes: PenNode[], id: string) => PenNode | null,
+ visited = new Set(),
+): PenNode[] {
+ const finder = findInTree ?? ((ns: PenNode[], id: string) => findNodeInTree(ns, id) ?? null)
+ return nodes.flatMap((node) => {
+ if (node.type !== 'ref') {
+ if ('children' in node && node.children) {
+ return [{ ...node, children: resolveRefs(node.children, rootNodes, finder, visited) } as PenNode]
+ }
+ return [node]
+ }
+ if (visited.has(node.ref)) return []
+ const component = finder(rootNodes, node.ref)
+ if (!component) return []
+ visited.add(node.ref)
+ const resolved: Record = { ...component }
+ for (const [key, val] of Object.entries(node)) {
+ if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
+ if (val !== undefined) resolved[key] = val
+ }
+ resolved.type = component.type
+ if (!resolved.name) resolved.name = component.name
+ delete resolved.reusable
+ const resolvedNode = resolved as unknown as PenNode
+ if ('children' in component && component.children) {
+ const refNode = node as RefNode
+ ;(resolvedNode as PenNode & ContainerProps).children = remapIds(component.children, node.id, refNode.descendants)
+ }
+ visited.delete(node.ref)
+ return [resolvedNode]
+ })
+}
+
+export function remapIds(children: PenNode[], refId: string, overrides?: Record>): PenNode[] {
+ return children.map((child) => {
+ const virtualId = `${refId}__${child.id}`
+ const ov = overrides?.[child.id] ?? {}
+ const mapped = { ...child, ...ov, id: virtualId } as PenNode
+ if ('children' in mapped && mapped.children) {
+ (mapped as PenNode & ContainerProps).children = remapIds(mapped.children, refId, overrides)
+ }
+ return mapped
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Component / instance ID collection (from raw tree, before ref resolution)
+// ---------------------------------------------------------------------------
+
+export function collectReusableIds(nodes: PenNode[], result: Set) {
+ for (const node of nodes) {
+ if (node.type === 'frame' && node.reusable === true) {
+ result.add(node.id)
+ }
+ if ('children' in node && node.children) {
+ collectReusableIds(node.children, result)
+ }
+ }
+}
+
+export function collectInstanceIds(nodes: PenNode[], result: Set) {
+ for (const node of nodes) {
+ if (node.type === 'ref') {
+ result.add(node.id)
+ }
+ if ('children' in node && node.children) {
+ collectInstanceIds(node.children, result)
+ }
+ }
+}
diff --git a/src/canvas/skia/skia-font-manager.ts b/packages/pen-renderer/src/font-manager.ts
similarity index 68%
rename from src/canvas/skia/skia-font-manager.ts
rename to packages/pen-renderer/src/font-manager.ts
index e4a580a1..ac3b2f75 100644
--- a/src/canvas/skia/skia-font-manager.ts
+++ b/packages/pen-renderer/src/font-manager.ts
@@ -1,81 +1,88 @@
import type { TypefaceFontProvider, CanvasKit } from 'canvaskit-wasm'
+export interface FontManagerOptions {
+ /** Base URL for bundled font files. Default: '/fonts/' */
+ fontBasePath?: string
+ /** Custom Google Fonts CSS endpoint. Default: 'https://fonts.googleapis.com/css2' */
+ googleFontsCssUrl?: string
+}
+
/**
- * Bundled font files served from /fonts/ (no external CDN dependency).
- * Key = lowercase family name, values = local URLs.
+ * Bundled font files (relative paths, prepended with fontBasePath at load time).
+ * Key = lowercase family name, values = relative file names.
*/
const BUNDLED_FONTS: Record = {
inter: [
- '/fonts/inter-400.woff2',
- '/fonts/inter-500.woff2',
- '/fonts/inter-600.woff2',
- '/fonts/inter-700.woff2',
- '/fonts/inter-ext-400.woff2',
- '/fonts/inter-ext-500.woff2',
- '/fonts/inter-ext-600.woff2',
- '/fonts/inter-ext-700.woff2',
+ 'inter-400.woff2',
+ 'inter-500.woff2',
+ 'inter-600.woff2',
+ 'inter-700.woff2',
+ 'inter-ext-400.woff2',
+ 'inter-ext-500.woff2',
+ 'inter-ext-600.woff2',
+ 'inter-ext-700.woff2',
],
poppins: [
- '/fonts/poppins-400.woff2',
- '/fonts/poppins-500.woff2',
- '/fonts/poppins-600.woff2',
- '/fonts/poppins-700.woff2',
+ 'poppins-400.woff2',
+ 'poppins-500.woff2',
+ 'poppins-600.woff2',
+ 'poppins-700.woff2',
],
roboto: [
- '/fonts/roboto-400.woff2',
- '/fonts/roboto-500.woff2',
- '/fonts/roboto-700.woff2',
+ 'roboto-400.woff2',
+ 'roboto-500.woff2',
+ 'roboto-700.woff2',
],
montserrat: [
- '/fonts/montserrat-400.woff2',
- '/fonts/montserrat-500.woff2',
- '/fonts/montserrat-600.woff2',
- '/fonts/montserrat-700.woff2',
+ 'montserrat-400.woff2',
+ 'montserrat-500.woff2',
+ 'montserrat-600.woff2',
+ 'montserrat-700.woff2',
],
'open sans': [
- '/fonts/open-sans-400.woff2',
- '/fonts/open-sans-600.woff2',
- '/fonts/open-sans-700.woff2',
+ 'open-sans-400.woff2',
+ 'open-sans-600.woff2',
+ 'open-sans-700.woff2',
],
lato: [
- '/fonts/lato-400.woff2',
- '/fonts/lato-700.woff2',
+ 'lato-400.woff2',
+ 'lato-700.woff2',
],
raleway: [
- '/fonts/raleway-400.woff2',
- '/fonts/raleway-500.woff2',
- '/fonts/raleway-600.woff2',
- '/fonts/raleway-700.woff2',
+ 'raleway-400.woff2',
+ 'raleway-500.woff2',
+ 'raleway-600.woff2',
+ 'raleway-700.woff2',
],
'dm sans': [
- '/fonts/dm-sans-400.woff2',
- '/fonts/dm-sans-500.woff2',
- '/fonts/dm-sans-700.woff2',
+ 'dm-sans-400.woff2',
+ 'dm-sans-500.woff2',
+ 'dm-sans-700.woff2',
],
'playfair display': [
- '/fonts/playfair-display-400.woff2',
- '/fonts/playfair-display-700.woff2',
+ 'playfair-display-400.woff2',
+ 'playfair-display-700.woff2',
],
nunito: [
- '/fonts/nunito-400.woff2',
- '/fonts/nunito-600.woff2',
- '/fonts/nunito-700.woff2',
+ 'nunito-400.woff2',
+ 'nunito-600.woff2',
+ 'nunito-700.woff2',
],
'source sans 3': [
- '/fonts/source-sans-3-400.woff2',
- '/fonts/source-sans-3-600.woff2',
- '/fonts/source-sans-3-700.woff2',
+ 'source-sans-3-400.woff2',
+ 'source-sans-3-600.woff2',
+ 'source-sans-3-700.woff2',
],
'source sans pro': [
- '/fonts/source-sans-3-400.woff2',
- '/fonts/source-sans-3-600.woff2',
- '/fonts/source-sans-3-700.woff2',
+ 'source-sans-3-400.woff2',
+ 'source-sans-3-600.woff2',
+ 'source-sans-3-700.woff2',
],
'noto sans sc': [
- '/fonts/noto-sans-sc-400.woff2',
- '/fonts/noto-sans-sc-700.woff2',
- '/fonts/noto-sans-sc-latin-400.woff2',
- '/fonts/noto-sans-sc-latin-700.woff2',
+ 'noto-sans-sc-400.woff2',
+ 'noto-sans-sc-700.woff2',
+ 'noto-sans-sc-latin-400.woff2',
+ 'noto-sans-sc-latin-700.woff2',
],
}
@@ -98,22 +105,28 @@ export const BUNDLED_FONT_FAMILIES = [
/**
* Manages font loading for CanvasKit's Paragraph API (vector text rendering).
*
- * Fonts are loaded from bundled /fonts/ directory first, falling back to
+ * Fonts are loaded from a configurable base path first, falling back to
* Google Fonts CDN. Once loaded, text is rendered as true vector glyphs.
*/
export class SkiaFontManager {
private provider: TypefaceFontProvider
- /** Registered family names (lowercase) → true once loaded */
+ private fontBasePath: string
+ private googleFontsCssUrl: string
+ /** Registered family names (lowercase) -> true once loaded */
private loadedFamilies = new Set()
- /** Font families that failed to load — prevents repeated fetch attempts */
+ /** Font families that failed to load */
private failedFamilies = new Set()
- /** System fonts that render via bitmap — not a failure, just not loadable into CanvasKit */
+ /** System fonts that render via bitmap */
private systemFontFamilies = new Set()
/** In-flight font fetch promises to avoid duplicate requests */
private pendingFetches = new Map>()
- constructor(ck: CanvasKit) {
+ constructor(ck: CanvasKit, options?: FontManagerOptions) {
this.provider = ck.TypefaceFontProvider.Make()
+ this.fontBasePath = options?.fontBasePath ?? '/fonts/'
+ // Ensure trailing slash
+ if (!this.fontBasePath.endsWith('/')) this.fontBasePath += '/'
+ this.googleFontsCssUrl = options?.googleFontsCssUrl ?? 'https://fonts.googleapis.com/css2'
}
getProvider(): TypefaceFontProvider {
@@ -138,37 +151,29 @@ export class SkiaFontManager {
/**
* Build a font fallback chain for the Paragraph API.
* Only includes fonts actually registered in the TypefaceFontProvider.
- * Extended subsets (e.g. "Inter Ext") are added for per-glyph fallback
- * so characters like ₦ (U+20A6) render correctly.
*/
getFallbackChain(primaryFamily: string): string[] {
const chain: string[] = []
const lower = primaryFamily.toLowerCase()
- // Only add primary if it's actually registered
if (this.loadedFamilies.has(lower)) {
chain.push(primaryFamily)
}
- // Add ext subset of primary family if available
if (this.loadedFamilies.has(lower + ' ext')) {
chain.push(primaryFamily + ' Ext')
}
- // Add Noto Sans SC for CJK glyph fallback (bundled, works offline)
if (lower !== 'noto sans sc' && this.loadedFamilies.has('noto sans sc')) {
chain.push('Noto Sans SC')
}
- // Add Inter + Inter Ext as final fallback for Latin glyphs
if (lower !== 'inter') {
if (this.loadedFamilies.has('inter')) chain.push('Inter')
if (this.loadedFamilies.has('inter ext')) chain.push('Inter Ext')
}
- // Must have at least one font
if (chain.length === 0) chain.push('Inter')
return chain
}
/**
* Check if there's at least one loaded fallback font for the given primary family.
- * Used to decide whether vector rendering can proceed when the primary font is unavailable.
*/
hasAnyFallback(primaryFamily: string): boolean {
const key = primaryFamily.toLowerCase()
@@ -206,8 +211,6 @@ export class SkiaFontManager {
this.pendingFetches.delete(key)
if (!result) {
if (isSystemFont(family)) {
- // System font — not a failure, just can't be loaded into CanvasKit.
- // Renderer will use bitmap (Canvas 2D) which supports all system fonts.
this.systemFontFamilies.add(key)
} else {
this.failedFamilies.add(key)
@@ -235,11 +238,12 @@ export class SkiaFontManager {
// 1. Try bundled fonts first (no network dependency)
const bundled = BUNDLED_FONTS[family.toLowerCase()]
if (bundled) {
- const ok = await this._fetchLocalFonts(family, bundled)
+ const urls = bundled.map(f => `${this.fontBasePath}${f}`)
+ const ok = await this._fetchLocalFonts(family, urls, bundled)
if (ok) return true
}
- // 2. Skip Google Fonts for system/proprietary fonts that won't exist there
+ // 2. Skip Google Fonts for system/proprietary fonts
if (isSystemFont(family)) {
return false
}
@@ -248,14 +252,12 @@ export class SkiaFontManager {
return this._fetchGoogleFont(family, weights)
}
- private async _fetchLocalFonts(family: string, urls: string[]): Promise {
+ private async _fetchLocalFonts(family: string, urls: string[], relPaths: string[]): Promise {
try {
const buffers = await Promise.all(
urls.map(async (url) => {
const resp = await fetch(url)
- if (!resp.ok) {
- return null
- }
+ if (!resp.ok) return null
return resp.arrayBuffer()
})
)
@@ -263,33 +265,27 @@ export class SkiaFontManager {
for (let i = 0; i < buffers.length; i++) {
const buf = buffers[i]
if (!buf) continue
- // Register extended subset files (e.g. inter-ext-400.woff2) under a separate
- // family name so CanvasKit's Paragraph API can do per-glyph fallback.
- // Base Inter doesn't have ₦ (U+20A6) but latin-ext does.
- const regName = urls[i].includes('-ext-') ? family + ' Ext' : family
+ const regName = relPaths[i].includes('-ext-') ? family + ' Ext' : family
if (this.registerFont(buf, regName)) registered++
}
return registered > 0
- } catch (e) {
+ } catch {
return false
}
}
/**
* Fetch a font from Google Fonts CDN with China mirror fallback.
- * Tries Google Fonts first (3s timeout), then falls back to loli.net mirror
- * which is accessible in China where Google services are blocked.
*/
private async _fetchGoogleFont(family: string, weights: number[]): Promise {
const weightStr = weights.join(';')
const encodedFamily = encodeURIComponent(family)
const query = `family=${encodedFamily}:wght@${weightStr}&display=swap`
- // Try Google Fonts first, then China-accessible mirrors
const cdnConfigs = [
{
- cssBase: 'https://fonts.googleapis.com/css2',
- fontUrlPattern: /url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/g,
+ cssBase: this.googleFontsCssUrl,
+ fontUrlPattern: /url\((https?:\/\/[^)]+\.woff2)\)/g,
},
{
cssBase: 'https://fonts.font.im/css2',
@@ -341,11 +337,10 @@ export class SkiaFontManager {
}
}
-/**
- * Detect whether a font is available locally on the user's OS using Canvas 2D
- * text measurement. If the measured width differs from a known fallback font,
- * the font is installed. Results are cached to avoid repeated measurements.
- */
+// ---------------------------------------------------------------------------
+// System font detection (browser-only)
+// ---------------------------------------------------------------------------
+
const localFontCache = new Map()
function isFontLocallyAvailable(family: string): boolean {
@@ -358,7 +353,6 @@ function isFontLocallyAvailable(family: string): boolean {
const ctx = canvas.getContext('2d')
if (!ctx) return false
- // Measure with two different generic fallbacks to avoid false positives
const testStr = 'mmmmmmmmmmlli1|'
ctx.font = '72px monospace'
const monoWidth = ctx.measureText(testStr).width
@@ -369,35 +363,23 @@ function isFontLocallyAvailable(family: string): boolean {
ctx.font = `72px "${family}", serif`
const testSerifWidth = ctx.measureText(testStr).width
- // If the font changes the measurement against BOTH fallbacks, it's installed
const available = testMonoWidth !== monoWidth && testSerifWidth !== serifWidth
localFontCache.set(key, available)
return available
}
-/**
- * Known font family prefixes/patterns that are NOT on Google Fonts.
- * These are system fonts, proprietary fonts, or vendor-specific fonts
- * that should never be fetched from Google Fonts CDN.
- */
const NON_GOOGLE_FONT_PATTERNS = [
- // Microsoft
/^microsoft/i, /^ms /i, /^segoe/i, /^simhei/i, /^simsun/i,
/^kaiti/i, /^fangsong/i, /^youyuan/i, /^lishu/i, /^dengxian/i,
- // Apple / macOS
/^sf /i, /^sf-/i, /^apple/i, /^pingfang/i, /^hiragino/i,
/^helvetica/i, /^menlo/i, /^monaco/i, /^lucida grande/i,
/^avenir/i, /^\.apple/i,
- // Proprietary / DIN variants
/^d-din/i, /^din[ -]/i, /^din$/i, /^proxima/i, /^gotham/i,
/^futura/i, /^akzidenz/i, /^univers/i, /^frutiger/i,
- // Chinese custom fonts
/^youshebiaotihei/i, /^youshebiaoti/i,
/^fz/i, /^alibaba/i, /^huawen/i, /^stk/i, /^st[hf]/i,
/^source han /i, /^noto sans cjk/i, /^noto serif cjk/i,
- // Japanese
/^yu gothic/i, /^yu mincho/i, /^meiryo/i, /^ms gothic/i, /^ms mincho/i,
- // System generics
/^system-ui/i, /^-apple-system/i, /^blinkmacsystemfont/i,
/^arial/i, /^times new roman/i, /^courier new/i, /^georgia/i,
/^verdana/i, /^tahoma/i, /^trebuchet/i, /^impact/i,
@@ -412,7 +394,6 @@ function isSystemFont(family: string): boolean {
return isFontLocallyAvailable(family) || isKnownNonGoogleFont(family)
}
-/** Fetch with timeout — rejects if response doesn't arrive within `ms`. */
function fetchWithTimeout(url: string, ms: number): Promise {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), ms)
diff --git a/src/canvas/skia/skia-image-loader.ts b/packages/pen-renderer/src/image-loader.ts
similarity index 100%
rename from src/canvas/skia/skia-image-loader.ts
rename to packages/pen-renderer/src/image-loader.ts
diff --git a/packages/pen-renderer/src/index.ts b/packages/pen-renderer/src/index.ts
new file mode 100644
index 00000000..a240feb5
--- /dev/null
+++ b/packages/pen-renderer/src/index.ts
@@ -0,0 +1,60 @@
+/**
+ * @zseven-w/pen-renderer — Standalone CanvasKit/Skia renderer for OpenPencil (.op) files
+ *
+ * @example
+ * ```ts
+ * import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer'
+ *
+ * const ck = await loadCanvasKit('/canvaskit/')
+ * const renderer = new PenRenderer(ck, { fontBasePath: '/fonts/' })
+ * renderer.init(canvas)
+ * renderer.setDocument(doc)
+ * renderer.zoomToFit()
+ * ```
+ */
+
+// ---- Primary API ----
+export { loadCanvasKit, getCanvasKit } from './init.js'
+export { PenRenderer } from './renderer.js'
+
+// ---- Types ----
+export type { RenderNode, ViewportState, PenRendererOptions, IconLookupFn } from './types.js'
+
+// ---- Low-level utilities (for apps/web editor re-use) ----
+export { SkiaNodeRenderer } from './node-renderer.js'
+export { SkiaTextRenderer } from './text-renderer.js'
+export { SkiaFontManager, BUNDLED_FONT_FAMILIES } from './font-manager.js'
+export type { FontManagerOptions } from './font-manager.js'
+export { SkiaImageLoader } from './image-loader.js'
+export { SpatialIndex } from './spatial-index.js'
+export {
+ flattenToRenderNodes,
+ resolveRefs,
+ remapIds,
+ premeasureTextHeights,
+ collectReusableIds,
+ collectInstanceIds,
+} from './document-flattener.js'
+export {
+ viewportMatrix,
+ screenToScene,
+ sceneToScreen,
+ zoomToPoint,
+ getViewportBounds,
+ isRectInViewport,
+} from './viewport.js'
+export {
+ parseColor,
+ cornerRadiusValue,
+ cornerRadii,
+ resolveFillColor,
+ resolveStrokeColor,
+ resolveStrokeWidth,
+ wrapLine,
+ cssFontFamily,
+} from './paint-utils.js'
+export {
+ sanitizeSvgPath,
+ hasInvalidNumbers,
+ tryManualPathParse,
+} from './path-utils.js'
diff --git a/src/canvas/skia/skia-init.ts b/packages/pen-renderer/src/init.ts
similarity index 67%
rename from src/canvas/skia/skia-init.ts
rename to packages/pen-renderer/src/init.ts
index 3a1c0fb6..557e5a36 100644
--- a/src/canvas/skia/skia-init.ts
+++ b/packages/pen-renderer/src/init.ts
@@ -5,11 +5,20 @@ let ckPromise: Promise | null = null
/**
* Load CanvasKit WASM singleton. Returns the same instance on subsequent calls.
+ *
+ * @param locateFile - Base path string (e.g. '/canvaskit/') or a function
+ * `(file: string) => string` that resolves WASM file URLs. Defaults to '/canvaskit/'.
*/
-export async function loadCanvasKit(): Promise {
+export async function loadCanvasKit(
+ locateFile?: string | ((file: string) => string),
+): Promise {
if (ckInstance) return ckInstance
if (ckPromise) return ckPromise
+ const resolver: (file: string) => string = typeof locateFile === 'function'
+ ? locateFile
+ : (file: string) => `${locateFile ?? '/canvaskit/'}${file}`
+
ckPromise = (async () => {
// canvaskit-wasm is a CJS module (module.exports = CanvasKitInit).
// Depending on bundler interop, the init function may be on .default or the module itself.
@@ -18,7 +27,7 @@ export async function loadCanvasKit(): Promise {
? mod.default
: (mod as unknown as (opts?: { locateFile?: (file: string) => string }) => Promise)
const ck = await CanvasKitInit({
- locateFile: (file: string) => `/canvaskit/${file}`,
+ locateFile: resolver,
})
ckInstance = ck
return ck
diff --git a/packages/pen-renderer/src/node-renderer.ts b/packages/pen-renderer/src/node-renderer.ts
new file mode 100644
index 00000000..3ab5762e
--- /dev/null
+++ b/packages/pen-renderer/src/node-renderer.ts
@@ -0,0 +1,599 @@
+import type { CanvasKit, Canvas, Paint, Font, Typeface } from 'canvaskit-wasm'
+import type { PenNode, ContainerProps, EllipseNode, LineNode, PolygonNode, PathNode, ImageNode, IconFontNode } from '@zseven-w/pen-types'
+import type { PenFill, PenStroke, PenEffect, ShadowEffect, ImageFill } from '@zseven-w/pen-types'
+import { DEFAULT_FILL, DEFAULT_STROKE, DEFAULT_STROKE_WIDTH, buildEllipseArcPath, isArcEllipse } from '@zseven-w/pen-core'
+import { parseColor, cornerRadiusValue, cornerRadii, resolveFillColor, resolveStrokeColor, resolveStrokeWidth } from './paint-utils.js'
+import { sanitizeSvgPath, hasInvalidNumbers, tryManualPathParse } from './path-utils.js'
+import { SkiaImageLoader } from './image-loader.js'
+import { SkiaTextRenderer } from './text-renderer.js'
+import type { SkiaFontManager, FontManagerOptions } from './font-manager.js'
+import type { RenderNode, IconLookupFn } from './types.js'
+
+const FALLBACK_ICON_D = 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0'
+
+/**
+ * Core node renderer for CanvasKit/Skia. Draws PenNode shapes, fills,
+ * strokes, effects, text, and images. No editor overlays or store dependencies.
+ */
+export class SkiaNodeRenderer {
+ protected ck: CanvasKit
+ private defaultTypeface: Typeface | null = null
+ private defaultFont: Font | null = null
+
+ // Current viewport zoom (set by engine before each render frame)
+ zoom = 1
+
+ // Device pixel ratio
+ devicePixelRatio: number | undefined
+
+ // Sub-renderers
+ private textRenderer: SkiaTextRenderer
+ imageLoader: SkiaImageLoader
+
+ // Injectable icon lookup
+ private iconLookup: IconLookupFn | null = null
+
+ /** Font manager — delegates to text renderer */
+ get fontManager(): SkiaFontManager {
+ return this.textRenderer.fontManager
+ }
+
+ constructor(ck: CanvasKit, fontOptions?: FontManagerOptions) {
+ this.ck = ck
+ this.imageLoader = new SkiaImageLoader(ck)
+ this.textRenderer = new SkiaTextRenderer(ck, fontOptions)
+ }
+
+ init() {
+ this.defaultFont = new this.ck.Font(null, 16)
+ }
+
+ /** Set callback to trigger re-render when async images finish loading. */
+ setRedrawCallback(cb: () => void) {
+ this.imageLoader.setOnLoaded(cb)
+ }
+
+ /** Set injectable icon lookup function. */
+ setIconLookup(fn: IconLookupFn) {
+ this.iconLookup = fn
+ }
+
+ dispose() {
+ this.defaultFont?.delete()
+ this.defaultFont = null
+ this.defaultTypeface?.delete()
+ this.defaultTypeface = null
+ this.textRenderer.dispose()
+ this.imageLoader.dispose()
+ }
+
+ clearTextCache() { this.textRenderer.clearTextCache() }
+ clearParaCache() { this.textRenderer.clearParaCache() }
+
+ // ---------------------------------------------------------------------------
+ // Fill paint
+ // ---------------------------------------------------------------------------
+
+ private makeFillPaint(
+ fills: PenFill[] | string | undefined,
+ w: number, h: number, opacity: number, absX: number, absY: number,
+ ): { paint: Paint; imageFillDraw?: { fill: ImageFill; w: number; h: number; absX: number; absY: number; opacity: number } } {
+ const ck = this.ck
+ const paint = new ck.Paint()
+ paint.setStyle(ck.PaintStyle.Fill)
+ paint.setAntiAlias(true)
+
+ if (typeof fills === 'string') {
+ const c = parseColor(ck, fills); c[3] *= opacity; paint.setColor(c)
+ return { paint }
+ }
+ if (!fills || fills.length === 0) {
+ const c = parseColor(ck, DEFAULT_FILL); c[3] *= opacity; paint.setColor(c)
+ return { paint }
+ }
+
+ const first = fills[0]
+ if (first.type === 'solid') {
+ const c = parseColor(ck, first.color); c[3] *= (first.opacity ?? 1) * opacity; paint.setColor(c)
+ } else if (first.type === 'linear_gradient') {
+ const stops = first.stops ?? []
+ const fillOpacity = (first.opacity ?? 1) * opacity
+ if (stops.length >= 2) {
+ const rad = ((first.angle ?? 0) - 90) * Math.PI / 180
+ const cos = Math.cos(rad), sin = Math.sin(rad)
+ const x1 = absX + w / 2 - (cos * w) / 2, y1 = absY + h / 2 - (sin * h) / 2
+ const x2 = absX + w / 2 + (cos * w) / 2, y2 = absY + h / 2 + (sin * h) / 2
+ const colors = stops.map((s) => { const c = parseColor(ck, s.color); c[3] *= fillOpacity; return c })
+ const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)))
+ const shader = ck.Shader.MakeLinearGradient([x1, y1], [x2, y2], colors, positions, ck.TileMode.Clamp)
+ if (shader) paint.setShader(shader)
+ } else {
+ const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL); c[3] *= fillOpacity; paint.setColor(c)
+ }
+ } else if (first.type === 'radial_gradient') {
+ const stops = first.stops ?? []
+ const fillOpacity = (first.opacity ?? 1) * opacity
+ if (stops.length >= 2) {
+ const cx = absX + (first.cx ?? 0.5) * w, cy = absY + (first.cy ?? 0.5) * h
+ const r = (first.radius ?? 0.5) * Math.max(w, h)
+ const colors = stops.map((s) => { const c = parseColor(ck, s.color); c[3] *= fillOpacity; return c })
+ const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)))
+ const shader = ck.Shader.MakeRadialGradient([cx, cy], r, colors, positions, ck.TileMode.Clamp)
+ if (shader) paint.setShader(shader)
+ } else {
+ const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL); c[3] *= fillOpacity; paint.setColor(c)
+ }
+ } else if (first.type === 'image') {
+ const result = this.applyImageFillToPaint(paint, first, w, h, opacity, absX, absY)
+ if (result.needsDrawImageRect && result.fill) {
+ return { paint, imageFillDraw: { fill: result.fill, w: result.w!, h: result.h!, absX: result.absX!, absY: result.absY!, opacity: result.opacity! } }
+ }
+ }
+
+ return { paint }
+ }
+
+ private applyImageFillToPaint(
+ paint: Paint, fill: ImageFill, w: number, h: number,
+ opacity: number, absX: number, absY: number,
+ ): { needsDrawImageRect: boolean; fill?: ImageFill; w?: number; h?: number; absX?: number; absY?: number; opacity?: number } {
+ const ck = this.ck
+ const fillOpacity = (fill.opacity ?? 1) * opacity
+ const url = fill.url
+ if (!url) { const c = parseColor(ck, '#e5e7eb'); c[3] *= fillOpacity; paint.setColor(c); return { needsDrawImageRect: false } }
+
+ const cached = this.imageLoader.get(url)
+ if (cached === undefined) this.imageLoader.request(url)
+ if (!cached) { const c = parseColor(ck, '#e5e7eb'); c[3] *= fillOpacity; paint.setColor(c); return { needsDrawImageRect: false } }
+
+ const imgW = cached.width(), imgH = cached.height()
+ if (imgW <= 0 || imgH <= 0) return { needsDrawImageRect: false }
+
+ const mode = fill.mode ?? 'fill'
+ if (mode === 'tile') {
+ const dispX = absX + (w - imgW) / 2, dispY = absY + (h - imgH) / 2
+ const localMatrix = Float32Array.of(1, 0, -dispX, 0, 1, -dispY, 0, 0, 1)
+ const shader = cached.makeShaderOptions(ck.TileMode.Repeat, ck.TileMode.Repeat, ck.FilterMode.Linear, ck.MipmapMode.None, localMatrix)
+ if (shader) { paint.setShader(shader); if (fillOpacity < 1) paint.setAlphaf(fillOpacity); const cf = this.buildImageAdjustmentFilter(fill); if (cf) paint.setColorFilter(cf) }
+ return { needsDrawImageRect: false }
+ }
+
+ paint.setColor(Float32Array.of(0, 0, 0, 0))
+ return { needsDrawImageRect: true, fill, w, h, absX, absY, opacity: fillOpacity }
+ }
+
+ private drawImageFillRect(canvas: Canvas, fill: ImageFill, w: number, h: number, absX: number, absY: number, fillOpacity: number) {
+ const ck = this.ck
+ const url = fill.url
+ if (!url) return
+ const cached = this.imageLoader.get(url)
+ if (!cached) return
+ const imgW = cached.width(), imgH = cached.height()
+ if (imgW <= 0 || imgH <= 0) return
+
+ const mode = fill.mode ?? 'fill'
+ const paint = new ck.Paint()
+ paint.setAntiAlias(true)
+ if (fillOpacity < 1) paint.setAlphaf(fillOpacity)
+ const adjFilter = this.buildImageAdjustmentFilter(fill)
+ if (adjFilter) paint.setColorFilter(adjFilter)
+
+ if (mode === 'fit') {
+ const scale = Math.min(w / imgW, h / imgH)
+ const dw = imgW * scale, dh = imgH * scale
+ const dx = absX + (w - dw) / 2, dy = absY + (h - dh) / 2
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(dx, dy, dx + dw, dy + dh), paint)
+ } else if (mode === 'stretch') {
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(absX, absY, absX + w, absY + h), paint)
+ } else {
+ const scale = Math.max(w / imgW, h / imgH)
+ const dw = imgW * scale, dh = imgH * scale
+ const dx = absX + (w - dw) / 2, dy = absY + (h - dh) / 2
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(dx, dy, dx + dw, dy + dh), paint)
+ }
+ paint.delete()
+ }
+
+ private buildImageAdjustmentFilter(adj: { exposure?: number; contrast?: number; saturation?: number; temperature?: number; tint?: number; highlights?: number; shadows?: number }) {
+ const ck = this.ck
+ const exp = (adj.exposure ?? 0) / 100, con = (adj.contrast ?? 0) / 100, sat = (adj.saturation ?? 0) / 100
+ const temp = (adj.temperature ?? 0) / 100, tintVal = (adj.tint ?? 0) / 100
+ const hi = (adj.highlights ?? 0) / 100, sh = (adj.shadows ?? 0) / 100
+ if (exp === 0 && con === 0 && sat === 0 && temp === 0 && tintVal === 0 && hi === 0 && sh === 0) return null
+
+ const e = 1 + exp * 1.5, c = 1 + con, cOff = 0.5 * (1 - c)
+ const s = 1 + sat
+ const lr = 0.2126, lg = 0.7152, lb = 0.0722
+ const sr = (1 - s) * lr, sg = (1 - s) * lg, sb = (1 - s) * lb
+ const f = c * e
+ const offR = cOff + temp * 0.15 + (hi + sh * 0.5) * 0.1
+ const offG = cOff + tintVal * 0.15 + (hi + sh * 0.5) * 0.1
+ const offB = cOff - temp * 0.15 + (hi + sh * 0.5) * 0.1
+
+ return ck.ColorFilter.MakeMatrix([
+ f * (sr + s), f * sg, f * sb, 0, offR,
+ f * sr, f * (sg + s), f * sb, 0, offG,
+ f * sr, f * sg, f * (sb + s), 0, offB,
+ 0, 0, 0, 1, 0,
+ ])
+ }
+
+ // ---------------------------------------------------------------------------
+ // Stroke paint
+ // ---------------------------------------------------------------------------
+
+ private makeStrokePaint(stroke: PenStroke | undefined, opacity: number): Paint | null {
+ if (!stroke) return null
+ const strokeColor = resolveStrokeColor(stroke)
+ const strokeWidth = resolveStrokeWidth(stroke)
+ if (!strokeColor || strokeWidth <= 0) return null
+
+ const ck = this.ck
+ const paint = new ck.Paint()
+ paint.setStyle(ck.PaintStyle.Stroke)
+ paint.setAntiAlias(true)
+ paint.setStrokeWidth(strokeWidth)
+ const c = parseColor(ck, strokeColor); c[3] *= opacity; paint.setColor(c)
+
+ if (stroke.join === 'round') paint.setStrokeJoin(ck.StrokeJoin.Round)
+ else if (stroke.join === 'bevel') paint.setStrokeJoin(ck.StrokeJoin.Bevel)
+ if (stroke.cap === 'round') paint.setStrokeCap(ck.StrokeCap.Round)
+ else if (stroke.cap === 'square') paint.setStrokeCap(ck.StrokeCap.Square)
+ if (stroke.dashPattern && stroke.dashPattern.length >= 2) {
+ const effect = ck.PathEffect.MakeDash(stroke.dashPattern, 0)
+ if (effect) paint.setPathEffect(effect)
+ }
+
+ return paint
+ }
+
+ // ---------------------------------------------------------------------------
+ // Shadow
+ // ---------------------------------------------------------------------------
+
+ private applyShadowDirect(canvas: Canvas, effects: PenEffect[] | undefined, x: number, y: number, w: number, h: number): boolean {
+ if (!effects) return false
+ const shadow = effects.find((e): e is ShadowEffect => e.type === 'shadow')
+ if (!shadow) return false
+
+ const ck = this.ck
+ const paint = new ck.Paint()
+ paint.setStyle(ck.PaintStyle.Fill)
+ paint.setAntiAlias(true)
+ paint.setColor(parseColor(ck, shadow.color))
+ paint.setMaskFilter(ck.MaskFilter.MakeBlur(ck.BlurStyle.Normal, shadow.blur / 2, true))
+ canvas.drawRect(ck.LTRBRect(
+ x + shadow.offsetX - shadow.spread, y + shadow.offsetY - shadow.spread,
+ x + w + shadow.offsetX + shadow.spread, y + h + shadow.offsetY + shadow.spread,
+ ), paint)
+ paint.delete()
+ return true
+ }
+
+ // ---------------------------------------------------------------------------
+ // Draw a single render node (no selection/overlay logic)
+ // ---------------------------------------------------------------------------
+
+ drawNode(canvas: Canvas, rn: RenderNode) {
+ const { node, absX, absY, absW, absH, clipRect } = rn
+ const ck = this.ck
+ const opacity = typeof node.opacity === 'number' ? node.opacity : 1
+
+ if (('visible' in node ? node.visible : undefined) === false) return
+
+ // Pass zoom to text renderer
+ this.textRenderer.zoom = this.zoom
+ this.textRenderer.devicePixelRatio = this.devicePixelRatio
+
+ // Apply clipping from parent frame
+ let clipped = false
+ if (clipRect) {
+ canvas.save(); clipped = true
+ if (clipRect.rx > 0) {
+ canvas.clipRRect(ck.RRectXY(ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h), clipRect.rx, clipRect.rx), ck.ClipOp.Intersect, true)
+ } else {
+ canvas.clipRect(ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h), ck.ClipOp.Intersect, true)
+ }
+ }
+
+ // Apply flip
+ const flipX = node.flipX === true, flipY = node.flipY === true
+ if (flipX || flipY) {
+ canvas.save()
+ canvas.translate(absX + absW / 2, absY + absH / 2)
+ canvas.scale(flipX ? -1 : 1, flipY ? -1 : 1)
+ canvas.translate(-(absX + absW / 2), -(absY + absH / 2))
+ }
+
+ // Apply rotation
+ const rotation = node.rotation ?? 0
+ if (rotation !== 0) {
+ canvas.save()
+ canvas.rotate(rotation, absX + absW / 2, absY + absH / 2)
+ }
+
+ // Apply shadow (text uses glyph-shaped shadow, not rectangle)
+ const effects = 'effects' in node ? (node as PenNode & { effects?: PenEffect[] }).effects : undefined
+ if (node.type !== 'text') {
+ this.applyShadowDirect(canvas, effects, absX, absY, absW, absH)
+ }
+
+ switch (node.type) {
+ case 'frame': case 'rectangle': case 'group':
+ this.drawRect(canvas, node, absX, absY, absW, absH, opacity); break
+ case 'ellipse':
+ this.drawEllipse(canvas, node, absX, absY, absW, absH, opacity); break
+ case 'line':
+ this.drawLine(canvas, node, absX, absY, opacity); break
+ case 'polygon':
+ this.drawPolygon(canvas, node, absX, absY, absW, absH, opacity); break
+ case 'path':
+ this.drawPath(canvas, node, absX, absY, absW, absH, opacity); break
+ case 'icon_font':
+ this.drawIconFont(canvas, node, absX, absY, absW, absH, opacity); break
+ case 'text':
+ this.textRenderer.drawText(canvas, node, absX, absY, absW, absH, opacity, effects); break
+ case 'image':
+ this.drawImage(canvas, node, absX, absY, absW, absH, opacity); break
+ }
+
+ if (rotation !== 0) canvas.restore()
+ if (flipX || flipY) canvas.restore()
+ if (clipped) canvas.restore()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Shape drawing
+ // ---------------------------------------------------------------------------
+
+ private drawRect(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
+ const ck = this.ck
+ const container = node as PenNode & ContainerProps
+ const cr = cornerRadii(container.cornerRadius)
+ const fills = container.fill
+ const stroke = container.stroke
+ const hasFill = fills && fills.length > 0
+ const isContainer = node.type === 'frame' || node.type === 'group'
+
+ const { paint: fillPaint, imageFillDraw } = this.makeFillPaint(hasFill ? fills : (isContainer ? 'transparent' : undefined), w, h, opacity, x, y)
+ const hasRoundedCorners = cr.some((r) => r > 0)
+ if (hasRoundedCorners) {
+ const maxR = Math.min(w / 2, h / 2)
+ canvas.drawRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)), fillPaint)
+ } else {
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
+ }
+ fillPaint.delete()
+
+ if (imageFillDraw) {
+ canvas.save()
+ if (hasRoundedCorners) {
+ const maxR = Math.min(w / 2, h / 2)
+ canvas.clipRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)), ck.ClipOp.Intersect, true)
+ } else {
+ canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true)
+ }
+ this.drawImageFillRect(canvas, imageFillDraw.fill, imageFillDraw.w, imageFillDraw.h, imageFillDraw.absX, imageFillDraw.absY, imageFillDraw.opacity)
+ canvas.restore()
+ }
+
+ const strokePaint = this.makeStrokePaint(stroke, opacity)
+ if (strokePaint) {
+ if (hasRoundedCorners) {
+ const maxR = Math.min(w / 2, h / 2)
+ canvas.drawRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)), strokePaint)
+ } else {
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), strokePaint)
+ }
+ strokePaint.delete()
+ }
+ }
+
+ private drawEllipse(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
+ const ck = this.ck
+ const eNode = node as EllipseNode
+ const fills = eNode.fill, stroke = eNode.stroke
+ const cr = cornerRadiusValue(eNode.cornerRadius)
+
+ if (isArcEllipse(eNode.startAngle, eNode.sweepAngle, eNode.innerRadius)) {
+ const arcD = buildEllipseArcPath(w, h, eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0)
+ const path = ck.Path.MakeFromSVGString(arcD)
+ if (path) {
+ path.offset(x, y)
+ const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
+ if (cr > 0) { const effect = ck.PathEffect.MakeCorner(cr); if (effect) fillPaint.setPathEffect(effect) }
+ canvas.drawPath(path, fillPaint); fillPaint.delete()
+ const strokePaint = this.makeStrokePaint(stroke, opacity)
+ if (strokePaint) { if (cr > 0) { const effect = ck.PathEffect.MakeCorner(cr); if (effect) strokePaint.setPathEffect(effect) }; canvas.drawPath(path, strokePaint); strokePaint.delete() }
+ path.delete()
+ }
+ return
+ }
+
+ const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
+ canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint); fillPaint.delete()
+ const strokePaint = this.makeStrokePaint(stroke, opacity)
+ if (strokePaint) { canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), strokePaint); strokePaint.delete() }
+ }
+
+ private drawLine(canvas: Canvas, node: PenNode, x: number, y: number, opacity: number) {
+ const ck = this.ck
+ const lNode = node as LineNode
+ const x2 = lNode.x2 ?? x + 100, y2 = lNode.y2 ?? y
+ const strokeColor = resolveStrokeColor(lNode.stroke) ?? DEFAULT_STROKE
+ const strokeWidth = resolveStrokeWidth(lNode.stroke) || DEFAULT_STROKE_WIDTH
+ const paint = new ck.Paint()
+ paint.setStyle(ck.PaintStyle.Stroke); paint.setAntiAlias(true); paint.setStrokeWidth(strokeWidth)
+ const c = parseColor(ck, strokeColor); c[3] *= opacity; paint.setColor(c)
+ canvas.drawLine(x, y, x2, y2, paint); paint.delete()
+ }
+
+ private drawPolygon(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
+ const ck = this.ck
+ const pNode = node as PolygonNode
+ const count = pNode.polygonCount || 6
+ const fills = pNode.fill, stroke = pNode.stroke
+ const cr = cornerRadiusValue(pNode.cornerRadius)
+
+ const raw: [number, number][] = []
+ for (let i = 0; i < count; i++) {
+ const angle = (i * 2 * Math.PI) / count - Math.PI / 2
+ raw.push([Math.cos(angle), Math.sin(angle)])
+ }
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
+ for (const [rx, ry] of raw) { if (rx < minX) minX = rx; if (rx > maxX) maxX = rx; if (ry < minY) minY = ry; if (ry > maxY) maxY = ry }
+ const rawW = maxX - minX, rawH = maxY - minY
+
+ const path = new ck.Path()
+ for (let i = 0; i < count; i++) {
+ const px = x + ((raw[i][0] - minX) / rawW) * w, py = y + ((raw[i][1] - minY) / rawH) * h
+ if (i === 0) path.moveTo(px, py); else path.lineTo(px, py)
+ }
+ path.close()
+
+ const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
+ if (cr > 0) { const effect = ck.PathEffect.MakeCorner(cr); if (effect) fillPaint.setPathEffect(effect) }
+ canvas.drawPath(path, fillPaint); fillPaint.delete()
+ const strokePaint = this.makeStrokePaint(stroke, opacity)
+ if (strokePaint) { if (cr > 0) { const effect = ck.PathEffect.MakeCorner(cr); if (effect) strokePaint.setPathEffect(effect) }; canvas.drawPath(path, strokePaint); strokePaint.delete() }
+ path.delete()
+ }
+
+ private drawPath(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
+ const ck = this.ck
+ const pNode = node as PathNode
+ const rawD = typeof pNode.d === 'string' && pNode.d.trim().length > 0 ? pNode.d : 'M0 0 L0 0'
+ const fills = pNode.fill, stroke = pNode.stroke
+
+ let path: ReturnType = null
+ if (hasInvalidNumbers(rawD)) { path = tryManualPathParse(ck, rawD) }
+ else {
+ const d = sanitizeSvgPath(rawD)
+ path = ck.Path.MakeFromSVGString(d)
+ if (!path && d !== rawD) path = ck.Path.MakeFromSVGString(rawD)
+ if (!path) path = tryManualPathParse(ck, rawD)
+ }
+ if (!path) {
+ if (w > 0 && h > 0) { const { paint: fp } = this.makeFillPaint(fills, w, h, opacity, x, y); canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fp); fp.delete() }
+ return
+ }
+
+ const bounds = path.getBounds()
+ const nativeW = bounds[2] - bounds[0], nativeH = bounds[3] - bounds[1]
+ if (w > 0 && h > 0 && nativeW > 0.01 && nativeH > 0.01) {
+ const isIcon = !!pNode.iconId
+ const sx = isIcon ? Math.min(w / nativeW, h / nativeH) : w / nativeW
+ const sy = isIcon ? sx : h / nativeH
+ path.transform(ck.Matrix.multiply(ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy), ck.Matrix.scaled(sx, sy)))
+ } else if (nativeW > 0.01 || nativeH > 0.01) {
+ const sx = nativeW > 0.01 && w > 0 ? w / nativeW : 1, sy = nativeH > 0.01 && h > 0 ? h / nativeH : 1
+ path.transform(ck.Matrix.multiply(ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy), ck.Matrix.scaled(sx, sy)))
+ } else { path.offset(x, y) }
+
+ const hasExplicitFill = fills && fills.length > 0
+ const strokeColor = resolveStrokeColor(stroke), strokeWidth = resolveStrokeWidth(stroke)
+ const hasVisibleStroke = strokeWidth > 0 && !!strokeColor
+
+ if (hasExplicitFill || !hasVisibleStroke) {
+ const { paint: fillPaint } = this.makeFillPaint(hasExplicitFill ? fills : undefined, w, h, opacity, x, y)
+ const closeCount = (rawD.match(/Z/gi) || []).length
+ path.setFillType(closeCount > 1 ? ck.FillType.EvenOdd : ck.FillType.Winding)
+ canvas.drawPath(path, fillPaint); fillPaint.delete()
+ }
+ if (hasVisibleStroke) { const sp = this.makeStrokePaint(stroke, opacity); if (sp) { canvas.drawPath(path, sp); sp.delete() } }
+ path.delete()
+ }
+
+ private drawIconFont(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
+ const ck = this.ck
+ const iNode = node as IconFontNode
+ const iconName = iNode.iconFontName ?? iNode.name ?? ''
+ const iconMatch = this.iconLookup?.(iconName) ?? null
+ const iconD = iconMatch?.d ?? FALLBACK_ICON_D
+ const iconStyle = iconMatch?.style ?? 'stroke'
+
+ const rawFill = iNode.fill
+ const iconFillColor = typeof rawFill === 'string' ? rawFill
+ : Array.isArray(iNode.fill) && iNode.fill.length > 0 ? resolveFillColor(iNode.fill) : '#64748B'
+
+ const sanitizedIconD = sanitizeSvgPath(iconD)
+ let path = ck.Path.MakeFromSVGString(sanitizedIconD)
+ if (!path && sanitizedIconD !== iconD) path = ck.Path.MakeFromSVGString(iconD)
+ if (!path) path = tryManualPathParse(ck, iconD)
+ if (!path) return
+
+ const bounds = path.getBounds()
+ const nativeW = bounds[2] - bounds[0], nativeH = bounds[3] - bounds[1]
+ if (w > 0 && h > 0 && nativeW > 0 && nativeH > 0) {
+ const s = Math.min(w / nativeW, h / nativeH)
+ path.transform(ck.Matrix.multiply(ck.Matrix.translated(x - bounds[0] * s, y - bounds[1] * s), ck.Matrix.scaled(s, s)))
+ } else { path.offset(x, y) }
+
+ const paint = new ck.Paint()
+ paint.setAntiAlias(true)
+ const c = parseColor(ck, iconFillColor); c[3] *= opacity; paint.setColor(c)
+ if (iconStyle === 'stroke') {
+ paint.setStyle(ck.PaintStyle.Stroke); paint.setStrokeWidth(2)
+ paint.setStrokeCap(ck.StrokeCap.Round); paint.setStrokeJoin(ck.StrokeJoin.Round)
+ } else {
+ paint.setStyle(ck.PaintStyle.Fill); path.setFillType(ck.FillType.EvenOdd)
+ }
+ canvas.drawPath(path, paint); paint.delete(); path.delete()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Image drawing
+ // ---------------------------------------------------------------------------
+
+ private drawImage(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
+ const ck = this.ck
+ const iNode = node as ImageNode
+ const src: string | undefined = iNode.src
+ const cr = cornerRadiusValue(iNode.cornerRadius)
+
+ if (!src) { this.drawImageFallback(canvas, x, y, w, h, cr, opacity); return }
+
+ const cached = this.imageLoader.get(src)
+ if (cached === undefined) { this.imageLoader.request(src); this.drawImageFallback(canvas, x, y, w, h, cr, opacity); return }
+ if (!cached) { this.drawImageFallback(canvas, x, y, w, h, cr, opacity); return }
+
+ const imgW = cached.width(), imgH = cached.height()
+
+ if (cr > 0) { canvas.save(); const maxR = Math.min(cr, w / 2, h / 2); canvas.clipRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR), ck.ClipOp.Intersect, true) }
+ else { canvas.save(); canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true) }
+
+ const paint = new ck.Paint()
+ paint.setAntiAlias(true)
+ if (opacity < 1) paint.setAlphaf(opacity)
+ const adjFilter = this.buildImageAdjustmentFilter(iNode)
+ if (adjFilter) paint.setColorFilter(adjFilter)
+
+ const fit = iNode.objectFit ?? 'fill'
+ if (fit === 'tile') {
+ const tileMatrix = Float32Array.of(1, 0, -x, 0, 1, -y, 0, 0, 1)
+ const shader = cached.makeShaderOptions(ck.TileMode.Repeat, ck.TileMode.Repeat, ck.FilterMode.Linear, ck.MipmapMode.None, tileMatrix)
+ if (shader) { paint.setShader(shader); canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint) }
+ } else if (fit === 'fit') {
+ const bgPaint = new ck.Paint(); bgPaint.setStyle(ck.PaintStyle.Fill); bgPaint.setColor(parseColor(ck, '#f3f4f6'))
+ if (opacity < 1) bgPaint.setAlphaf(opacity * 0.3); else bgPaint.setAlphaf(0.3)
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), bgPaint); bgPaint.delete()
+ const scale = Math.min(w / imgW, h / imgH), dw = imgW * scale, dh = imgH * scale
+ const dx = x + (w - dw) / 2, dy = y + (h - dh) / 2
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(dx, dy, dx + dw, dy + dh), paint)
+ } else {
+ const scale = Math.max(w / imgW, h / imgH), dw = imgW * scale, dh = imgH * scale
+ const dx = x + (w - dw) / 2, dy = y + (h - dh) / 2
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(dx, dy, dx + dw, dy + dh), paint)
+ }
+ paint.delete(); canvas.restore()
+ }
+
+ private drawImageFallback(canvas: Canvas, x: number, y: number, w: number, h: number, cr: number, opacity: number) {
+ const ck = this.ck
+ const paint = new ck.Paint(); paint.setStyle(ck.PaintStyle.Fill); paint.setAntiAlias(true)
+ const c = parseColor(ck, '#e5e7eb'); c[3] *= opacity; paint.setColor(c)
+ if (cr > 0) { const maxR = Math.min(cr, w / 2, h / 2); canvas.drawRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR), paint) }
+ else { canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint) }
+ paint.delete()
+ }
+}
diff --git a/src/canvas/skia/skia-paint-utils.ts b/packages/pen-renderer/src/paint-utils.ts
similarity index 81%
rename from src/canvas/skia/skia-paint-utils.ts
rename to packages/pen-renderer/src/paint-utils.ts
index 5c31c164..eafba9c9 100644
--- a/src/canvas/skia/skia-paint-utils.ts
+++ b/packages/pen-renderer/src/paint-utils.ts
@@ -1,6 +1,8 @@
import type { CanvasKit } from 'canvaskit-wasm'
-import type { PenFill, PenStroke } from '@/types/styles'
-import { DEFAULT_FILL, DEFAULT_STROKE_WIDTH } from '../canvas-constants'
+import type { PenFill, PenStroke } from '@zseven-w/pen-types'
+import { DEFAULT_FILL, DEFAULT_STROKE_WIDTH } from '@zseven-w/pen-core'
+
+export { cssFontFamily } from '@zseven-w/pen-core'
// ---------------------------------------------------------------------------
// Color parsing — ck.Color4f takes 0-1 floats for all channels (r, g, b, a)
@@ -144,29 +146,3 @@ export function wrapLine(ctx: CanvasRenderingContext2D, text: string, maxW: numb
}
if (current) out.push(current)
}
-
-/** CSS generic font families that must NOT be quoted */
-const GENERIC_FAMILIES = new Set([
- 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
- 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
- '-apple-system', 'blinkmacsystemfont',
-])
-
-/**
- * Quote font family names for CSS `font` shorthand.
- * Names with spaces MUST be quoted, otherwise Canvas 2D parses each word
- * as a separate family (e.g. "Bodoni 72 Smallcaps" → "Bodoni", "72", "Smallcaps").
- */
-export function cssFontFamily(family: string): string {
- return family.split(',').map(f => {
- const trimmed = f.trim()
- if (!trimmed) return trimmed
- // Already quoted
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
- (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed
- // Generic families must not be quoted
- if (GENERIC_FAMILIES.has(trimmed.toLowerCase())) return trimmed
- // Quote everything else (safe even for single-word names)
- return `"${trimmed}"`
- }).join(', ')
-}
diff --git a/src/canvas/skia/skia-path-utils.ts b/packages/pen-renderer/src/path-utils.ts
similarity index 94%
rename from src/canvas/skia/skia-path-utils.ts
rename to packages/pen-renderer/src/path-utils.ts
index d35ad885..a498a638 100644
--- a/src/canvas/skia/skia-path-utils.ts
+++ b/packages/pen-renderer/src/path-utils.ts
@@ -3,9 +3,9 @@ import type { CanvasKit, Path } from 'canvaskit-wasm'
/**
* Normalize SVG path data for CanvasKit's parser:
* - Add spaces between command letters and numbers
- * - Handle negative-sign number separators (e.g. "10-5" → "10 -5")
+ * - Handle negative-sign number separators (e.g. "10-5" -> "10 -5")
* - Normalize comma separators to spaces
- * - Separate concatenated arc flags (e.g. "a2 2 0 012 2" → "a2 2 0 0 1 2 2")
+ * - Separate concatenated arc flags (e.g. "a2 2 0 012 2" -> "a2 2 0 0 1 2 2")
*/
export function sanitizeSvgPath(d: string): string {
let result = d
@@ -21,7 +21,7 @@ export function sanitizeSvgPath(d: string): string {
// Separate concatenated arc flags: in SVG arc commands, the large-arc and
// sweep flags are single digits (0 or 1) that may be concatenated with each
- // other and with the following number. e.g. "a2 2 0 012 2" → "a2 2 0 0 1 2 2"
+ // other and with the following number. e.g. "a2 2 0 012 2" -> "a2 2 0 0 1 2 2"
result = result.replace(
/([aA])\s*([\d.e+-]+)\s+([\d.e+-]+)\s+([\d.e+-]+)\s+([01])([01])([\d.+-])/g,
'$1 $2 $3 $4 $5 $6 $7',
@@ -142,8 +142,6 @@ export function tryManualPathParse(ck: CanvasKit, d: string): Path | null {
try {
const path = new ck.Path()
// Replace NaN/Infinity with 0 so commands keep their parameter count.
- // This produces degenerate but valid paths (e.g. a horizontal line at y=0)
- // which is better than dropping the entire command.
const cleaned = d.replace(/-?NaN/g, '0').replace(/-?Infinity/g, '0')
// Tokenize: split on commands and extract numbers
const tokens = cleaned.match(/[MLCQZAHVSmlcqzahvs]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g)
@@ -195,7 +193,6 @@ export function tryManualPathParse(ck: CanvasKit, d: string): Path | null {
case 'Z': case 'z': path.close(); break
case 'A': case 'a': {
// Arc: rx, ry, rotation, largeArc, sweep, x, y
- // Convert SVG arc to cubic bezier approximation via CanvasKit's conic API
const p = nums(7)
if (p.length === 7) {
const [rx, ry, , largeArc, sweep, ex, ey] = p
diff --git a/packages/pen-renderer/src/renderer.ts b/packages/pen-renderer/src/renderer.ts
new file mode 100644
index 00000000..5eca3b34
--- /dev/null
+++ b/packages/pen-renderer/src/renderer.ts
@@ -0,0 +1,374 @@
+import type { CanvasKit, Surface } from 'canvaskit-wasm'
+import type { PenDocument, PenNode } from '@zseven-w/pen-types'
+import {
+ getActivePageChildren,
+ getAllChildren,
+ getDefaultTheme,
+ resolveNodeForCanvas,
+ MIN_ZOOM,
+ MAX_ZOOM,
+ CANVAS_BACKGROUND_DARK,
+ FRAME_LABEL_FONT_SIZE,
+ FRAME_LABEL_OFFSET_Y,
+ FRAME_LABEL_COLOR,
+ setRootChildrenProvider,
+} from '@zseven-w/pen-core'
+import { SkiaNodeRenderer } from './node-renderer.js'
+import { SpatialIndex } from './spatial-index.js'
+import {
+ flattenToRenderNodes,
+ resolveRefs,
+ premeasureTextHeights,
+ collectReusableIds,
+ collectInstanceIds,
+} from './document-flattener.js'
+import { parseColor } from './paint-utils.js'
+import {
+ viewportMatrix,
+ screenToScene,
+ zoomToPoint as vpZoomToPoint,
+} from './viewport.js'
+import type { RenderNode, PenRendererOptions, ViewportState } from './types.js'
+
+/**
+ * Standalone read-only renderer for OpenPencil (.op) design files.
+ * No React, no Zustand, no TanStack — just pure TypeScript + CanvasKit.
+ *
+ * @example
+ * ```ts
+ * import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer'
+ *
+ * const ck = await loadCanvasKit('/canvaskit/')
+ * const renderer = new PenRenderer(ck, { fontBasePath: '/fonts/' })
+ * renderer.init(document.getElementById('canvas') as HTMLCanvasElement)
+ * renderer.setDocument(myDocument)
+ * renderer.zoomToFit()
+ * ```
+ */
+export class PenRenderer {
+ private ck: CanvasKit
+ private surface: Surface | null = null
+ private canvasEl: HTMLCanvasElement | null = null
+ private nodeRenderer: SkiaNodeRenderer
+ private spatialIndex = new SpatialIndex()
+ private renderNodes: RenderNode[] = []
+ private options: PenRendererOptions
+
+ // Component/instance IDs for colored frame labels
+ private reusableIds = new Set()
+ private instanceIds = new Set()
+
+ // Viewport
+ private _zoom = 1
+ private _panX = 0
+ private _panY = 0
+ private dirty = true
+ private animFrameId = 0
+
+ // Document
+ private document: PenDocument | null = null
+ private activePageId: string | null = null
+
+ constructor(ck: CanvasKit, options?: PenRendererOptions) {
+ this.ck = ck
+ this.options = options ?? {}
+ this.nodeRenderer = new SkiaNodeRenderer(ck, {
+ fontBasePath: this.options.fontBasePath,
+ googleFontsCssUrl: this.options.googleFontsCssUrl,
+ })
+ if (this.options.iconLookup) {
+ this.nodeRenderer.setIconLookup(this.options.iconLookup)
+ }
+ if (this.options.devicePixelRatio !== undefined) {
+ this.nodeRenderer.devicePixelRatio = this.options.devicePixelRatio
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Lifecycle
+ // ---------------------------------------------------------------------------
+
+ init(canvas: HTMLCanvasElement) {
+ this.canvasEl = canvas
+ const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1
+ canvas.width = canvas.clientWidth * dpr
+ canvas.height = canvas.clientHeight * dpr
+
+ this.surface = this.ck.MakeWebGLCanvasSurface(canvas)
+ if (!this.surface) this.surface = this.ck.MakeSWCanvasSurface(canvas)
+ if (!this.surface) { console.error('PenRenderer: Failed to create surface'); return }
+
+ this.nodeRenderer.init()
+ this.nodeRenderer.setRedrawCallback(() => this.markDirty())
+ ;(this.nodeRenderer as any).textRenderer._onFontLoaded = () => this.markDirty()
+
+ // Pre-load default fonts
+ const defaultFonts = this.options.defaultFonts ?? ['Inter', 'Noto Sans SC']
+ for (const font of defaultFonts) {
+ this.nodeRenderer.fontManager.ensureFont(font).then(() => this.markDirty())
+ }
+
+ // Wire up root children provider for layout engine fill-width fallback
+ setRootChildrenProvider(() => this.document?.children ?? [])
+
+ this.startRenderLoop()
+ }
+
+ dispose() {
+ if (this.animFrameId) cancelAnimationFrame(this.animFrameId)
+ this.nodeRenderer.dispose()
+ this.surface?.delete()
+ this.surface = null
+ }
+
+ resize(width: number, height: number) {
+ if (!this.canvasEl) return
+ const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1
+ this.canvasEl.width = width * dpr
+ this.canvasEl.height = height * dpr
+ this.surface?.delete()
+ this.surface = this.ck.MakeWebGLCanvasSurface(this.canvasEl)
+ if (!this.surface) this.surface = this.ck.MakeSWCanvasSurface(this.canvasEl)
+ this.markDirty()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Document
+ // ---------------------------------------------------------------------------
+
+ setDocument(doc: PenDocument) {
+ this.document = doc
+ this.activePageId = doc.pages?.[0]?.id ?? null
+ this.syncFromDocument()
+ }
+
+ getDocument(): PenDocument | null {
+ return this.document
+ }
+
+ // ---------------------------------------------------------------------------
+ // Pages
+ // ---------------------------------------------------------------------------
+
+ setPage(pageId: string) {
+ this.activePageId = pageId
+ this.syncFromDocument()
+ }
+
+ getPageIds(): string[] {
+ return this.document?.pages?.map(p => p.id) ?? []
+ }
+
+ getActivePageId(): string | null {
+ return this.activePageId
+ }
+
+ // ---------------------------------------------------------------------------
+ // Viewport
+ // ---------------------------------------------------------------------------
+
+ setViewport(zoom: number, panX: number, panY: number) {
+ this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
+ this._panX = panX
+ this._panY = panY
+ this.markDirty()
+ }
+
+ getViewport(): ViewportState {
+ return { zoom: this._zoom, panX: this._panX, panY: this._panY }
+ }
+
+ zoomToFit(padding = 64) {
+ if (!this.canvasEl || this.renderNodes.length === 0) return
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
+ for (const rn of this.renderNodes) {
+ if (rn.clipRect) continue
+ minX = Math.min(minX, rn.absX)
+ minY = Math.min(minY, rn.absY)
+ maxX = Math.max(maxX, rn.absX + rn.absW)
+ maxY = Math.max(maxY, rn.absY + rn.absH)
+ }
+ if (!isFinite(minX)) return
+
+ const contentW = maxX - minX
+ const contentH = maxY - minY
+ const canvasW = this.canvasEl.clientWidth
+ const canvasH = this.canvasEl.clientHeight
+ const zoom = Math.min(
+ (canvasW - padding * 2) / contentW,
+ (canvasH - padding * 2) / contentH,
+ 2,
+ )
+ const panX = (canvasW - contentW * zoom) / 2 - minX * zoom
+ const panY = (canvasH - contentH * zoom) / 2 - minY * zoom
+ this.setViewport(zoom, panX, panY)
+ }
+
+ zoomToPoint(screenX: number, screenY: number, newZoom: number) {
+ if (!this.canvasEl) return
+ const rect = this.canvasEl.getBoundingClientRect()
+ const vp = vpZoomToPoint(
+ { zoom: this._zoom, panX: this._panX, panY: this._panY },
+ screenX, screenY, rect, newZoom,
+ )
+ this.setViewport(vp.zoom, vp.panX, vp.panY)
+ }
+
+ pan(dx: number, dy: number) {
+ this.setViewport(this._zoom, this._panX + dx, this._panY + dy)
+ }
+
+ // ---------------------------------------------------------------------------
+ // Theme
+ // ---------------------------------------------------------------------------
+
+ setThemeVariant(variant: Record) {
+ this.options.themeVariant = variant
+ this.syncFromDocument()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Hit testing
+ // ---------------------------------------------------------------------------
+
+ hitTest(screenX: number, screenY: number): PenNode | null {
+ if (!this.canvasEl) return null
+ const rect = this.canvasEl.getBoundingClientRect()
+ const scene = screenToScene(screenX, screenY, rect, { zoom: this._zoom, panX: this._panX, panY: this._panY })
+ const hits = this.spatialIndex.hitTest(scene.x, scene.y)
+ return hits.length > 0 ? hits[0].node : null
+ }
+
+ getNodeBounds(nodeId: string): { x: number; y: number; w: number; h: number } | null {
+ const rn = this.spatialIndex.get(nodeId)
+ if (!rn) return null
+ return { x: rn.absX, y: rn.absY, w: rn.absW, h: rn.absH }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Internal: Document sync
+ // ---------------------------------------------------------------------------
+
+ private syncFromDocument() {
+ if (!this.document) return
+ const pageChildren = getActivePageChildren(this.document, this.activePageId)
+ const allNodes = getAllChildren(this.document)
+
+ // Collect reusable/instance IDs
+ this.reusableIds.clear()
+ this.instanceIds.clear()
+ collectReusableIds(pageChildren, this.reusableIds)
+ collectInstanceIds(pageChildren, this.instanceIds)
+
+ // Resolve refs
+ const resolved = resolveRefs(pageChildren, allNodes)
+
+ // Resolve design variables
+ const variables = this.document.variables ?? {}
+ const themes = this.document.themes
+ const activeTheme = this.options.themeVariant ?? getDefaultTheme(themes)
+ const variableResolved = resolved.map((n) => resolveNodeForCanvas(n, variables, activeTheme))
+
+ // Pre-measure text heights
+ const measured = premeasureTextHeights(variableResolved)
+
+ this.renderNodes = flattenToRenderNodes(measured)
+ this.spatialIndex.rebuild(this.renderNodes)
+ this.markDirty()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Render loop
+ // ---------------------------------------------------------------------------
+
+ private markDirty() {
+ this.dirty = true
+ }
+
+ private startRenderLoop() {
+ const loop = () => {
+ this.animFrameId = requestAnimationFrame(loop)
+ if (!this.dirty || !this.surface) return
+ this.dirty = false
+ this.render()
+ }
+ this.animFrameId = requestAnimationFrame(loop)
+ }
+
+ render() {
+ if (!this.surface || !this.canvasEl) return
+ const canvas = this.surface.getCanvas()
+ const ck = this.ck
+ const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1
+
+ // Clear
+ const bgColor = this.options.backgroundColor ?? CANVAS_BACKGROUND_DARK
+ canvas.clear(parseColor(ck, bgColor))
+
+ // Apply viewport transform
+ canvas.save()
+ canvas.scale(dpr, dpr)
+ canvas.concat(viewportMatrix({ zoom: this._zoom, panX: this._panX, panY: this._panY }))
+
+ // Pass current zoom to renderer
+ this.nodeRenderer.zoom = this._zoom
+
+ // Draw all render nodes
+ for (const rn of this.renderNodes) {
+ this.nodeRenderer.drawNode(canvas, rn)
+ }
+
+ // Draw frame labels for root frames + reusable + instances
+ for (const rn of this.renderNodes) {
+ if (!rn.node.name) continue
+ const isRootFrame = rn.node.type === 'frame' && !rn.clipRect
+ const isReusable = this.reusableIds.has(rn.node.id)
+ const isInstance = this.instanceIds.has(rn.node.id)
+ if (!isRootFrame && !isReusable && !isInstance) continue
+ this.drawFrameLabel(canvas, rn.node.name, rn.absX, rn.absY)
+ }
+
+ canvas.restore()
+ this.surface.flush()
+ }
+
+ /** Simple frame label drawing for read-only renderer. */
+ private drawFrameLabel(canvas: ReturnType, name: string, x: number, y: number) {
+ const ck = this.ck
+ const fontSize = FRAME_LABEL_FONT_SIZE / this._zoom
+ const offsetY = FRAME_LABEL_OFFSET_Y / this._zoom
+
+ // Use Canvas 2D to rasterize the label text
+ const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1
+ const scale = Math.min(this._zoom * dpr, 4)
+ const tmp = document.createElement('canvas')
+ const textW = Math.ceil(name.length * fontSize * 0.7 * scale) + 4
+ const textH = Math.ceil(fontSize * 1.4 * scale) + 4
+ tmp.width = textW
+ tmp.height = textH
+ const ctx = tmp.getContext('2d')!
+ ctx.scale(scale, scale)
+ ctx.font = `500 ${fontSize}px Inter, system-ui, sans-serif`
+ ctx.fillStyle = FRAME_LABEL_COLOR
+ ctx.textBaseline = 'top'
+ ctx.fillText(name, 0, 0)
+
+ const imageData = ctx.getImageData(0, 0, textW, textH)
+ const img = ck.MakeImage(
+ { width: textW, height: textH, alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB },
+ imageData.data, textW * 4,
+ )
+ if (img) {
+ const paint = new ck.Paint()
+ paint.setAntiAlias(true)
+ canvas.drawImageRect(
+ img,
+ ck.LTRBRect(0, 0, textW, textH),
+ ck.LTRBRect(x, y - offsetY - fontSize * 1.2, x + textW / scale, y - offsetY),
+ paint,
+ )
+ paint.delete()
+ img.delete()
+ }
+ }
+}
diff --git a/src/canvas/skia/skia-hit-test.ts b/packages/pen-renderer/src/spatial-index.ts
similarity index 97%
rename from src/canvas/skia/skia-hit-test.ts
rename to packages/pen-renderer/src/spatial-index.ts
index 400c3d03..98bb6635 100644
--- a/src/canvas/skia/skia-hit-test.ts
+++ b/packages/pen-renderer/src/spatial-index.ts
@@ -1,5 +1,5 @@
import RBush from 'rbush'
-import type { RenderNode } from './skia-renderer'
+import type { RenderNode } from './types.js'
interface RTreeItem {
minX: number
diff --git a/packages/pen-renderer/src/text-renderer.ts b/packages/pen-renderer/src/text-renderer.ts
new file mode 100644
index 00000000..814432e0
--- /dev/null
+++ b/packages/pen-renderer/src/text-renderer.ts
@@ -0,0 +1,455 @@
+import type { CanvasKit, Canvas, Image as SkImage, Paragraph } from 'canvaskit-wasm'
+import type { PenNode, TextNode } from '@zseven-w/pen-types'
+import type { PenEffect, ShadowEffect } from '@zseven-w/pen-types'
+import { defaultLineHeight, cssFontFamily } from '@zseven-w/pen-core'
+import { parseColor, resolveFillColor, wrapLine } from './paint-utils.js'
+import { SkiaFontManager, type FontManagerOptions } from './font-manager.js'
+
+/**
+ * Text rendering sub-system for SkiaNodeRenderer.
+ * Handles both vector (Paragraph API) and bitmap (Canvas 2D) text rendering
+ * with caching for performance.
+ */
+export class SkiaTextRenderer {
+ private ck: CanvasKit
+
+ // Text rasterization cache (Canvas 2D -> CanvasKit Image)
+ // FIFO eviction via Map insertion order; bytes tracked separately against TEXT_CACHE_BYTE_LIMIT.
+ private textCache = new Map()
+ private textCacheBytes = 0
+ // 256 MB — each bitmap entry is ~cw*ch*4 bytes (RGBA pixels)
+ private static TEXT_CACHE_BYTE_LIMIT = 256 * 1024 * 1024
+
+ // Paragraph cache for vector text (keyed by content+style)
+ // FIFO eviction via Map insertion order; bytes estimated from content length against PARA_CACHE_BYTE_LIMIT.
+ private paraCache = new Map()
+ private paraCacheBytes = 0
+ // 64 MB — each entry is estimated as content.length*64+4096 bytes (WASM heap approximation)
+ private static PARA_CACHE_BYTE_LIMIT = 64 * 1024 * 1024
+
+ private static estimateParaBytes(content: string): number {
+ return content.length * 64 + 4096
+ }
+
+ // Current viewport zoom (set by engine before each render frame)
+ zoom = 1
+
+ // Device pixel ratio override
+ devicePixelRatio: number | undefined
+
+ // Font manager for vector text rendering
+ fontManager: SkiaFontManager
+
+ constructor(ck: CanvasKit, fontOptions?: FontManagerOptions) {
+ this.ck = ck
+ this.fontManager = new SkiaFontManager(ck, fontOptions)
+ }
+
+ clearTextCache() {
+ for (const img of this.textCache.values()) {
+ img?.delete()
+ }
+ this.textCache.clear()
+ this.textCacheBytes = 0
+ }
+
+ clearParaCache() {
+ for (const p of this.paraCache.values()) {
+ p?.delete()
+ }
+ this.paraCache.clear()
+ this.paraCacheBytes = 0
+ }
+
+ // Evict oldest entries (Map head = first inserted) until there is room for `incoming` bytes.
+ private evictParaCache(incoming: number) {
+ while (this.paraCacheBytes + incoming > SkiaTextRenderer.PARA_CACHE_BYTE_LIMIT && this.paraCache.size > 0) {
+ const [key, para] = this.paraCache.entries().next().value!
+ para?.delete()
+ this.paraCache.delete(key)
+ this.paraCacheBytes -= SkiaTextRenderer.estimateParaBytes(key.split('|')[1] ?? '')
+ }
+ }
+
+ private evictTextCache(incoming: number) {
+ while (this.textCacheBytes + incoming > SkiaTextRenderer.TEXT_CACHE_BYTE_LIMIT && this.textCache.size > 0) {
+ const [key, img] = this.textCache.entries().next().value!
+ if (img) {
+ this.textCacheBytes -= img.width() * img.height() * 4
+ img.delete()
+ }
+ this.textCache.delete(key)
+ }
+ }
+
+ dispose() {
+ this.clearTextCache()
+ this.clearParaCache()
+ this.fontManager.dispose()
+ }
+
+ /**
+ * Main text drawing entry — tries vector, falls back to bitmap.
+ */
+ drawText(
+ canvas: Canvas, node: PenNode,
+ x: number, y: number, w: number, h: number,
+ opacity: number,
+ effects?: PenEffect[],
+ ) {
+ // Draw text shadow as blurred copy of the text glyphs (not a rectangle)
+ const shadow = effects?.find((e): e is ShadowEffect => e.type === 'shadow')
+ if (shadow) {
+ this.drawTextShadow(canvas, node, x, y, w, h, opacity, shadow)
+ }
+
+ // Try vector text first (true Skia Paragraph API)
+ const vectorOk = this.drawTextVector(canvas, node, x, y, w, h, opacity)
+ if (vectorOk) return
+
+ // Fallback to bitmap text rendering
+ this.drawTextBitmap(canvas, node, x, y, w, h, opacity)
+ }
+
+ /**
+ * Render text as true vector glyphs using CanvasKit's Paragraph API.
+ * Returns true if rendered, false if font not available (caller should fallback).
+ */
+ drawTextVector(
+ canvas: Canvas, node: PenNode,
+ x: number, y: number, w: number, _h: number,
+ opacity: number,
+ ): boolean {
+ const ck = this.ck
+ const tNode = node as TextNode
+ const content = typeof tNode.content === 'string'
+ ? tNode.content
+ : Array.isArray(tNode.content)
+ ? tNode.content.map((s) => s.text ?? '').join('')
+ : ''
+ if (!content) return true
+
+ const fontSize = tNode.fontSize ?? 16
+ const fillColor = resolveFillColor(tNode.fill)
+ const fontWeight = tNode.fontWeight ?? '400'
+ const fontFamily = tNode.fontFamily ?? 'Inter'
+ const textAlign: string = tNode.textAlign ?? 'left'
+ const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
+ const textGrowth = tNode.textGrowth
+ const letterSpacing = tNode.letterSpacing ?? 0
+
+ const primaryFamily = fontFamily.split(',')[0].trim().replace(/['"]/g, '')
+ if (!this.fontManager.isFontReady(primaryFamily)) {
+ if (this.fontManager.isSystemFont(primaryFamily)) {
+ return false
+ }
+ this.fontManager.ensureFont(primaryFamily).then((ok) => {
+ if (ok) {
+ this.clearParaCache()
+ ;(this as any)._onFontLoaded?.()
+ }
+ })
+ if (!this.fontManager.hasAnyFallback(primaryFamily)) {
+ return false
+ }
+ }
+
+ const isFixedWidth = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
+ const fwTolerance = isFixedWidth ? Math.min(Math.ceil(w * 0.05), Math.ceil(fontSize * 0.5)) : 0
+ const layoutWidth = isFixedWidth && w > 0 ? w + fwTolerance : 1e6
+ const effectiveAlign = isFixedWidth ? textAlign : 'left'
+
+ const cacheKey = `p|${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${effectiveAlign}|${Math.round(layoutWidth)}|${letterSpacing}|${lineHeightMul}`
+
+ let para = this.paraCache.get(cacheKey)
+ if (para === undefined) {
+ const color = parseColor(ck, fillColor)
+
+ let ckAlign = ck.TextAlign.Left
+ if (effectiveAlign === 'center') ckAlign = ck.TextAlign.Center
+ else if (effectiveAlign === 'right') ckAlign = ck.TextAlign.Right
+ else if (effectiveAlign === 'justify') ckAlign = ck.TextAlign.Justify
+
+ const weightNum = typeof fontWeight === 'number' ? fontWeight : parseInt(fontWeight as string, 10) || 400
+ let ckWeight = ck.FontWeight.Normal
+ if (weightNum <= 100) ckWeight = ck.FontWeight.Thin
+ else if (weightNum <= 200) ckWeight = ck.FontWeight.ExtraLight
+ else if (weightNum <= 300) ckWeight = ck.FontWeight.Light
+ else if (weightNum <= 400) ckWeight = ck.FontWeight.Normal
+ else if (weightNum <= 500) ckWeight = ck.FontWeight.Medium
+ else if (weightNum <= 600) ckWeight = ck.FontWeight.SemiBold
+ else if (weightNum <= 700) ckWeight = ck.FontWeight.Bold
+ else if (weightNum <= 800) ckWeight = ck.FontWeight.ExtraBold
+ else ckWeight = ck.FontWeight.Black
+
+ const fallbackFamilies = this.fontManager.getFallbackChain(primaryFamily)
+
+ const paraStyle = new ck.ParagraphStyle({
+ textAlign: ckAlign,
+ textStyle: {
+ color,
+ fontSize,
+ fontFamilies: fallbackFamilies,
+ fontStyle: { weight: ckWeight },
+ letterSpacing,
+ heightMultiplier: lineHeightMul,
+ halfLeading: true,
+ },
+ })
+
+ try {
+ const builder = ck.ParagraphBuilder.MakeFromFontProvider(
+ paraStyle,
+ this.fontManager.getProvider(),
+ )
+
+ // Handle styled segments
+ if (Array.isArray(tNode.content) && tNode.content.some(s => s.fontFamily || s.fontSize || s.fontWeight || s.fill)) {
+ for (const seg of tNode.content) {
+ if (seg.fontFamily || seg.fontSize || seg.fontWeight || seg.fill) {
+ const segColor = seg.fill ? parseColor(ck, seg.fill) : color
+ const segWeight = seg.fontWeight
+ ? (typeof seg.fontWeight === 'number' ? seg.fontWeight : parseInt(seg.fontWeight as string, 10) || weightNum)
+ : weightNum
+ const segPrimary = seg.fontFamily?.split(',')[0].trim().replace(/['"]/g, '') ?? primaryFamily
+ builder.pushStyle(new ck.TextStyle({
+ color: segColor,
+ fontSize: seg.fontSize ?? fontSize,
+ fontFamilies: this.fontManager.getFallbackChain(segPrimary),
+ fontStyle: { weight: segWeight as any },
+ letterSpacing,
+ heightMultiplier: lineHeightMul,
+ halfLeading: true,
+ }))
+ builder.addText(seg.text ?? '')
+ builder.pop()
+ } else {
+ builder.addText(seg.text ?? '')
+ }
+ }
+ } else {
+ builder.addText(content)
+ }
+
+ para = builder.build()
+ para.layout(layoutWidth)
+ builder.delete()
+ const entryBytes = SkiaTextRenderer.estimateParaBytes(content)
+ this.evictParaCache(entryBytes)
+ this.paraCacheBytes += entryBytes
+ } catch {
+ para = null
+ }
+
+ this.paraCache.set(cacheKey, para ?? null)
+ }
+
+ if (!para) return false
+
+ let drawX = x
+ if (!isFixedWidth && w > 0 && textAlign !== 'left') {
+ const longestLine = para.getLongestLine()
+ if (textAlign === 'center') drawX = x + Math.max(0, (w - longestLine) / 2)
+ else if (textAlign === 'right') drawX = x + Math.max(0, w - longestLine)
+ }
+
+ if (opacity < 1) {
+ const paint = new ck.Paint()
+ paint.setAlphaf(opacity)
+ canvas.saveLayer(paint)
+ paint.delete()
+ canvas.drawParagraph(para, drawX, y)
+ canvas.restore()
+ } else {
+ canvas.drawParagraph(para, drawX, y)
+ }
+
+ return true
+ }
+
+ /**
+ * Draw text shadow as a blurred copy of the actual text glyphs.
+ */
+ private drawTextShadow(
+ canvas: Canvas, node: PenNode,
+ x: number, y: number, w: number, h: number,
+ opacity: number,
+ shadow: ShadowEffect,
+ ) {
+ const ck = this.ck
+ const tNode = node as TextNode
+ const shadowFillColor = shadow.color ?? '#00000066'
+ const shadowNode = {
+ ...tNode,
+ fill: [{ type: 'solid' as const, color: shadowFillColor }],
+ } as PenNode
+
+ const sx = x + shadow.offsetX
+ const sy = y + shadow.offsetY
+
+ if (shadow.blur > 0) {
+ const paint = new ck.Paint()
+ if (opacity < 1) paint.setAlphaf(opacity)
+ const sigma = shadow.blur / 2
+ const filter = ck.ImageFilter.MakeBlur(sigma, sigma, ck.TileMode.Decal, null)
+ paint.setImageFilter(filter)
+ canvas.saveLayer(paint)
+ paint.delete()
+
+ const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, 1)
+ if (!vectorOk) {
+ this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, 1)
+ }
+
+ canvas.restore()
+ } else {
+ const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, opacity)
+ if (!vectorOk) {
+ this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, opacity)
+ }
+ }
+ }
+
+ /** Bitmap text rendering fallback — supports all system fonts via Canvas 2D API. */
+ drawTextBitmap(
+ canvas: Canvas, node: PenNode,
+ x: number, y: number, w: number, h: number,
+ opacity: number,
+ ) {
+ const ck = this.ck
+ const tNode = node as TextNode
+ const content = typeof tNode.content === 'string'
+ ? tNode.content
+ : Array.isArray(tNode.content)
+ ? tNode.content.map((s) => s.text ?? '').join('')
+ : ''
+
+ if (!content) return
+
+ const fontSize = tNode.fontSize ?? 16
+ const fillColor = resolveFillColor(tNode.fill)
+ const fontWeight = tNode.fontWeight ?? '400'
+ const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
+ const textAlign: string = tNode.textAlign ?? 'left'
+ const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
+ const lineHeight = lineHeightMul * fontSize
+ const textGrowth = tNode.textGrowth
+
+ const isFixedWidth = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
+ || (textGrowth !== 'auto' && textAlign !== 'left' && textAlign !== undefined)
+ const shouldWrap = isFixedWidth && w > 0
+
+ const measureCanvas = document.createElement('canvas')
+ const mCtx = measureCanvas.getContext('2d')!
+ mCtx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
+
+ const rawLines = content.split('\n')
+ let wrappedLines: string[]
+ let renderW: number
+
+ if (shouldWrap) {
+ renderW = Math.max(w + fontSize * 0.2, 10)
+ wrappedLines = []
+ for (const raw of rawLines) {
+ if (!raw) { wrappedLines.push(''); continue }
+ wrapLine(mCtx, raw, renderW, wrappedLines)
+ }
+ } else {
+ wrappedLines = rawLines.length > 0 ? rawLines : ['']
+ let maxLineWidth = 0
+ for (const line of wrappedLines) {
+ if (line) maxLineWidth = Math.max(maxLineWidth, mCtx.measureText(line).width)
+ }
+ renderW = Math.max(maxLineWidth + 2, w, 10)
+ }
+
+ const FABRIC_FONT_MULT = 1.13
+ const glyphH = fontSize * FABRIC_FONT_MULT
+ const textH = Math.max(h,
+ wrappedLines.length <= 1
+ ? glyphH + 2
+ : (wrappedLines.length - 1) * lineHeight + glyphH + 2,
+ )
+
+ const dpr = this.devicePixelRatio ?? ((typeof window !== 'undefined' ? window.devicePixelRatio : 1) || 1)
+ const rawScale = this.zoom * dpr
+ const scale = rawScale <= 2 ? 2 : rawScale <= 4 ? 4 : 8
+
+ const cacheKey = `${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${textAlign}|${Math.round(renderW)}|${Math.round(textH)}|${scale}`
+
+ let img = this.textCache.get(cacheKey)
+ if (img === undefined) {
+ let effectiveScale = scale
+ let cw = Math.ceil(renderW * effectiveScale)
+ let ch = Math.ceil(textH * effectiveScale)
+ if (cw <= 0 || ch <= 0) { this.textCache.set(cacheKey, null); return }
+ const MAX_TEX = 4096
+ if (cw > MAX_TEX || ch > MAX_TEX) {
+ effectiveScale = Math.min(MAX_TEX / renderW, MAX_TEX / textH, effectiveScale)
+ cw = Math.ceil(renderW * effectiveScale)
+ ch = Math.ceil(textH * effectiveScale)
+ }
+
+ const tmp = document.createElement('canvas')
+ tmp.width = cw
+ tmp.height = ch
+ const ctx = tmp.getContext('2d')!
+ ctx.scale(effectiveScale, effectiveScale)
+ ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
+ ctx.fillStyle = fillColor
+ ctx.textBaseline = 'top'
+ ctx.textAlign = (textAlign || 'left') as CanvasTextAlign
+
+ let cy = 0
+ for (const line of wrappedLines) {
+ if (!line) { cy += lineHeight; continue }
+ let tx = 0
+ if (textAlign === 'center') tx = renderW / 2
+ else if (textAlign === 'right') tx = renderW
+ ctx.fillText(line, tx, cy)
+ cy += lineHeight
+ }
+
+ const imageData = ctx.getImageData(0, 0, cw, ch)
+ // Premultiply alpha for correct CanvasKit texture blending
+ const premul = new Uint8Array(imageData.data.length)
+ for (let p = 0; p < premul.length; p += 4) {
+ const a = imageData.data[p + 3]
+ if (a === 255) {
+ premul[p] = imageData.data[p]
+ premul[p + 1] = imageData.data[p + 1]
+ premul[p + 2] = imageData.data[p + 2]
+ premul[p + 3] = 255
+ } else if (a > 0) {
+ const f = a / 255
+ premul[p] = Math.round(imageData.data[p] * f)
+ premul[p + 1] = Math.round(imageData.data[p + 1] * f)
+ premul[p + 2] = Math.round(imageData.data[p + 2] * f)
+ premul[p + 3] = a
+ }
+ }
+ img = ck.MakeImage(
+ { width: cw, height: ch, alphaType: ck.AlphaType.Premul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB },
+ premul, cw * 4,
+ ) ?? null
+
+ const imgBytes = img ? cw * ch * 4 : 0
+ this.evictTextCache(imgBytes)
+ this.textCache.set(cacheKey, img)
+ this.textCacheBytes += imgBytes
+ }
+
+ if (!img) return
+
+ const paint = new ck.Paint()
+ paint.setAntiAlias(true)
+ if (opacity < 1) paint.setAlphaf(opacity)
+ canvas.drawImageRect(
+ img,
+ ck.LTRBRect(0, 0, img.width(), img.height()),
+ ck.LTRBRect(x, y, x + renderW, y + textH),
+ paint,
+ )
+ paint.delete()
+ }
+}
diff --git a/packages/pen-renderer/src/types.ts b/packages/pen-renderer/src/types.ts
new file mode 100644
index 00000000..d4b165ac
--- /dev/null
+++ b/packages/pen-renderer/src/types.ts
@@ -0,0 +1,40 @@
+import type { PenNode } from '@zseven-w/pen-types'
+
+export interface RenderNode {
+ node: PenNode
+ absX: number
+ absY: number
+ absW: number
+ absH: number
+ clipRect?: { x: number; y: number; w: number; h: number; rx: number }
+}
+
+export interface ViewportState {
+ zoom: number
+ panX: number
+ panY: number
+}
+
+/** Injectable icon lookup function for resolving icon names to SVG path data. */
+export interface IconLookupFn {
+ (name: string): { d: string; iconId: string; style: 'stroke' | 'fill' } | null
+}
+
+export interface PenRendererOptions {
+ /** URL pattern for CanvasKit WASM files. Default: '/canvaskit/' */
+ canvasKitPath?: string | ((file: string) => string)
+ /** Base URL for bundled font files. Default: '/fonts/' */
+ fontBasePath?: string
+ /** Custom Google Fonts CSS endpoint. Default: 'https://fonts.googleapis.com/css2' */
+ googleFontsCssUrl?: string
+ /** Icon lookup function. Default: null (icons render as fallback circle) */
+ iconLookup?: IconLookupFn
+ /** Theme variant to use for variable resolution. Default: first variant per axis */
+ themeVariant?: Record
+ /** Background color. Default: '#1a1a1a' */
+ backgroundColor?: string
+ /** Device pixel ratio override. Default: window.devicePixelRatio */
+ devicePixelRatio?: number
+ /** Default fonts to preload. Default: ['Inter', 'Noto Sans SC'] */
+ defaultFonts?: string[]
+}
diff --git a/src/canvas/skia/skia-viewport.ts b/packages/pen-renderer/src/viewport.ts
similarity index 93%
rename from src/canvas/skia/skia-viewport.ts
rename to packages/pen-renderer/src/viewport.ts
index 2ecc0a39..edf93e6d 100644
--- a/src/canvas/skia/skia-viewport.ts
+++ b/packages/pen-renderer/src/viewport.ts
@@ -1,10 +1,7 @@
-import { MIN_ZOOM, MAX_ZOOM } from '../canvas-constants'
+import { MIN_ZOOM, MAX_ZOOM } from '@zseven-w/pen-core'
+import type { ViewportState } from './types.js'
-export interface ViewportState {
- zoom: number
- panX: number
- panY: number
-}
+export type { ViewportState } from './types.js'
/**
* Compute the 3x3 transform matrix for CanvasKit from viewport state.
diff --git a/packages/pen-renderer/tsconfig.json b/packages/pen-renderer/tsconfig.json
new file mode 100644
index 00000000..5ab07c8b
--- /dev/null
+++ b/packages/pen-renderer/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "lib": ["ES2022", "DOM"]
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/pen-sdk/package.json b/packages/pen-sdk/package.json
new file mode 100644
index 00000000..359f69f5
--- /dev/null
+++ b/packages/pen-sdk/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@zseven-w/pen-sdk",
+ "version": "0.5.0",
+ "description": "OpenPencil SDK — parse, manipulate, and generate code from .op design files",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
+ "files": [
+ "src"
+ ],
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@zseven-w/pen-types": "workspace:*",
+ "@zseven-w/pen-core": "workspace:*",
+ "@zseven-w/pen-codegen": "workspace:*",
+ "@zseven-w/pen-figma": "workspace:*",
+ "@zseven-w/pen-renderer": "workspace:*"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/packages/pen-sdk/src/index.ts b/packages/pen-sdk/src/index.ts
new file mode 100644
index 00000000..ad9630b8
--- /dev/null
+++ b/packages/pen-sdk/src/index.ts
@@ -0,0 +1,195 @@
+/**
+ * @zseven-w/pen-sdk — OpenPencil SDK
+ *
+ * High-level API for working with OpenPencil (.op) design files.
+ * Combines types, document operations, code generation, and Figma import.
+ *
+ * @example
+ * ```ts
+ * import {
+ * type PenDocument,
+ * createEmptyDocument,
+ * normalizePenDocument,
+ * generateReactFromDocument,
+ * parseFigFile,
+ * } from '@zseven-w/pen-sdk'
+ * ```
+ */
+
+// ── Types ──────────────────────────────────────────────────────────────
+export type {
+ // Document model
+ PenDocument,
+ PenNode,
+ PenNodeType,
+ PenPage,
+ PenNodeBase,
+ ContainerProps,
+ SizingBehavior,
+ FrameNode,
+ GroupNode,
+ RectangleNode,
+ EllipseNode,
+ LineNode,
+ PolygonNode,
+ PathNode,
+ TextNode,
+ ImageNode,
+ ImageFitMode,
+ IconFontNode,
+ RefNode,
+ // Styles
+ PenFill,
+ PenStroke,
+ PenEffect,
+ SolidFill,
+ LinearGradientFill,
+ RadialGradientFill,
+ ImageFill,
+ GradientStop,
+ BlendMode,
+ BlurEffect,
+ ShadowEffect,
+ StyledTextSegment,
+ // Variables
+ VariableDefinition,
+ VariableValue,
+ ThemedValue,
+ // Canvas
+ ToolType,
+ ViewportState,
+ // UIKit
+ UIKit,
+ KitComponent,
+ ComponentCategory,
+ // Theme presets
+ ThemePreset,
+ ThemePresetFile,
+} from '@zseven-w/pen-types'
+
+// ── Core: Document operations ──────────────────────────────────────────
+export {
+ // ID generation
+ generateId,
+ // Document creation & tree operations
+ createEmptyDocument,
+ DEFAULT_FRAME_ID,
+ DEFAULT_PAGE_ID,
+ findNodeInTree,
+ findParentInTree,
+ removeNodeFromTree,
+ updateNodeInTree,
+ flattenNodes,
+ insertNodeInTree,
+ isDescendantOf,
+ getNodeBounds,
+ // Page operations
+ getActivePage,
+ getActivePageChildren,
+ setActivePageChildren,
+ getAllChildren,
+ migrateToPages,
+ ensureDocumentNodeIds,
+ // Variables
+ isVariableRef,
+ getDefaultTheme,
+ resolveVariableRef,
+ resolveColorRef,
+ resolveNumericRef,
+ resolveNodeForCanvas,
+ replaceVariableRefsInTree,
+ // Normalization
+ normalizePenDocument,
+ // Layout
+ type Padding,
+ resolvePadding,
+ computeLayoutPositions,
+ getNodeWidth,
+ getNodeHeight,
+ inferLayout,
+ // Text measurement
+ parseSizing,
+ defaultLineHeight,
+ estimateTextWidth,
+ estimateTextHeight,
+ resolveTextContent,
+ hasCjkText,
+ // Arc path
+ buildEllipseArcPath,
+ isArcEllipse,
+ // Boolean operations
+ type BooleanOpType,
+ canBooleanOp,
+ executeBooleanOp,
+} from '@zseven-w/pen-core'
+
+// ── Codegen: Multi-platform code generation ────────────────────────────
+export {
+ // CSS Variables
+ variableNameToCSS,
+ generateCSSVariables,
+ // React + Tailwind
+ generateReactCode,
+ generateReactFromDocument,
+ // HTML + CSS
+ generateHTMLCode,
+ generateHTMLFromDocument,
+ // Vue 3
+ generateVueCode,
+ generateVueFromDocument,
+ // Svelte
+ generateSvelteCode,
+ generateSvelteFromDocument,
+ // Flutter
+ generateFlutterCode,
+ generateFlutterFromDocument,
+ // SwiftUI
+ generateSwiftUICode,
+ generateSwiftUIFromDocument,
+ // Android Compose
+ generateComposeCode,
+ generateComposeFromDocument,
+ // React Native
+ generateReactNativeCode,
+ generateReactNativeFromDocument,
+} from '@zseven-w/pen-codegen'
+
+// ── Figma: .fig file import ────────────────────────────────────────────
+export {
+ parseFigFile,
+ figmaToPenDocument,
+ figmaAllPagesToPenDocument,
+ getFigmaPages,
+ figmaNodeChangesToPenNodes,
+ isFigmaClipboardHtml,
+ extractFigmaClipboardData,
+ figmaClipboardToNodes,
+ resolveImageBlobs,
+ setIconLookup,
+ type FigmaDecodedFile,
+ type FigmaImportLayoutMode,
+} from '@zseven-w/pen-figma'
+
+// ── Renderer: CanvasKit/Skia rendering engine ────────────────────────
+export {
+ // Primary API
+ loadCanvasKit,
+ PenRenderer,
+ // Low-level
+ SkiaNodeRenderer,
+ SkiaFontManager,
+ SkiaImageLoader,
+ SpatialIndex,
+ flattenToRenderNodes,
+ resolveRefs,
+ premeasureTextHeights,
+ // Viewport
+ viewportMatrix,
+ screenToScene,
+ sceneToScreen,
+ zoomToPoint,
+ // Types
+ type RenderNode,
+ type PenRendererOptions,
+ type IconLookupFn,
+} from '@zseven-w/pen-renderer'
diff --git a/packages/pen-sdk/tsconfig.json b/packages/pen-sdk/tsconfig.json
new file mode 100644
index 00000000..5ab07c8b
--- /dev/null
+++ b/packages/pen-sdk/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "lib": ["ES2022", "DOM"]
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/pen-types/package.json b/packages/pen-types/package.json
new file mode 100644
index 00000000..5aaec637
--- /dev/null
+++ b/packages/pen-types/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@zseven-w/pen-types",
+ "version": "0.5.0",
+ "description": "Type definitions for OpenPencil document model",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
+ "files": [
+ "src"
+ ],
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/src/types/canvas.ts b/packages/pen-types/src/canvas.ts
similarity index 100%
rename from src/types/canvas.ts
rename to packages/pen-types/src/canvas.ts
diff --git a/packages/pen-types/src/design-md.ts b/packages/pen-types/src/design-md.ts
new file mode 100644
index 00000000..60199327
--- /dev/null
+++ b/packages/pen-types/src/design-md.ts
@@ -0,0 +1,24 @@
+export interface DesignMdSpec {
+ /** Original markdown source (for round-trip fidelity) */
+ raw: string
+ projectName?: string
+ visualTheme?: string
+ colorPalette?: DesignMdColor[]
+ typography?: DesignMdTypography
+ componentStyles?: string
+ layoutPrinciples?: string
+ generationNotes?: string
+}
+
+export interface DesignMdColor {
+ name: string
+ hex: string
+ role: string
+}
+
+export interface DesignMdTypography {
+ fontFamily?: string
+ headings?: string
+ body?: string
+ scale?: string
+}
diff --git a/packages/pen-types/src/index.ts b/packages/pen-types/src/index.ts
new file mode 100644
index 00000000..3d63363d
--- /dev/null
+++ b/packages/pen-types/src/index.ts
@@ -0,0 +1,73 @@
+// Styles
+export type {
+ BlendMode,
+ SolidFill,
+ GradientStop,
+ LinearGradientFill,
+ RadialGradientFill,
+ ImageFill,
+ PenFill,
+ PenStroke,
+ BlurEffect,
+ ShadowEffect,
+ PenEffect,
+ StyledTextSegment,
+} from './styles.js'
+
+// Variables
+export type {
+ VariableDefinition,
+ VariableValue,
+ ThemedValue,
+} from './variables.js'
+
+// Canvas
+export type {
+ ToolType,
+ ViewportState,
+ SelectionState,
+ CanvasInteraction,
+} from './canvas.js'
+
+// Document model
+export type {
+ PenPage,
+ PenDocument,
+ PenNodeType,
+ SizingBehavior,
+ PenNodeBase,
+ ContainerProps,
+ FrameNode,
+ GroupNode,
+ RectangleNode,
+ EllipseNode,
+ LineNode,
+ PolygonNode,
+ PathNode,
+ TextNode,
+ ImageFitMode,
+ ImageNode,
+ IconFontNode,
+ RefNode,
+ PenNode,
+} from './pen.js'
+
+// UIKit
+export type {
+ ComponentCategory,
+ KitComponent,
+ UIKit,
+} from './uikit.js'
+
+// Theme presets
+export type {
+ ThemePreset,
+ ThemePresetFile,
+} from './theme-preset.js'
+
+// Design.md
+export type {
+ DesignMdSpec,
+ DesignMdColor,
+ DesignMdTypography,
+} from './design-md.js'
diff --git a/src/types/pen.ts b/packages/pen-types/src/pen.ts
similarity index 94%
rename from src/types/pen.ts
rename to packages/pen-types/src/pen.ts
index 227838dc..1377cace 100644
--- a/src/types/pen.ts
+++ b/packages/pen-types/src/pen.ts
@@ -3,8 +3,8 @@ import type {
PenStroke,
PenEffect,
StyledTextSegment,
-} from './styles'
-import type { VariableDefinition } from './variables'
+} from './styles.js'
+import type { VariableDefinition } from './variables.js'
// --- Page ---
@@ -184,6 +184,8 @@ export interface ImageNode extends PenNodeBase {
tint?: number // -100 to 100
highlights?: number // -100 to 100
shadows?: number // -100 to 100
+ imagePrompt?: string // Descriptive prompt for AI image generation (long)
+ imageSearchQuery?: string // Short keywords for image search (e.g. "burger fries")
}
export interface IconFontNode extends PenNodeBase {
diff --git a/src/types/styles.ts b/packages/pen-types/src/styles.ts
similarity index 100%
rename from src/types/styles.ts
rename to packages/pen-types/src/styles.ts
diff --git a/src/types/theme-preset.ts b/packages/pen-types/src/theme-preset.ts
similarity index 86%
rename from src/types/theme-preset.ts
rename to packages/pen-types/src/theme-preset.ts
index 6ecadfa8..6ae4c47d 100644
--- a/src/types/theme-preset.ts
+++ b/packages/pen-types/src/theme-preset.ts
@@ -1,4 +1,4 @@
-import type { VariableDefinition } from './variables'
+import type { VariableDefinition } from './variables.js'
export interface ThemePreset {
id: string
diff --git a/src/types/uikit.ts b/packages/pen-types/src/uikit.ts
similarity index 95%
rename from src/types/uikit.ts
rename to packages/pen-types/src/uikit.ts
index 4c27497a..bb51a3e2 100644
--- a/src/types/uikit.ts
+++ b/packages/pen-types/src/uikit.ts
@@ -1,4 +1,4 @@
-import type { PenDocument } from './pen'
+import type { PenDocument } from './pen.js'
export type ComponentCategory =
| 'buttons'
diff --git a/src/types/variables.ts b/packages/pen-types/src/variables.ts
similarity index 100%
rename from src/types/variables.ts
rename to packages/pen-types/src/variables.ts
diff --git a/packages/pen-types/tsconfig.json b/packages/pen-types/tsconfig.json
new file mode 100644
index 00000000..df59da57
--- /dev/null
+++ b/packages/pen-types/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/public/favicon.ico b/public/favicon.ico
deleted file mode 100644
index a11777cc..00000000
Binary files a/public/favicon.ico and /dev/null differ
diff --git a/public/logo192.png b/public/logo192.png
deleted file mode 100644
index fc44b0a3..00000000
Binary files a/public/logo192.png and /dev/null differ
diff --git a/public/logo512.png b/public/logo512.png
deleted file mode 100644
index a4e47a65..00000000
Binary files a/public/logo512.png and /dev/null differ
diff --git a/public/manifest.json b/public/manifest.json
deleted file mode 100644
index 078ef501..00000000
--- a/public/manifest.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "short_name": "TanStack App",
- "name": "Create TanStack App Sample",
- "icons": [
- {
- "src": "favicon.ico",
- "sizes": "64x64 32x32 24x24 16x16",
- "type": "image/x-icon"
- },
- {
- "src": "logo192.png",
- "type": "image/png",
- "sizes": "192x192"
- },
- {
- "src": "logo512.png",
- "type": "image/png",
- "sizes": "512x512"
- }
- ],
- "start_url": ".",
- "display": "standalone",
- "theme_color": "#000000",
- "background_color": "#ffffff"
-}
diff --git a/public/tanstack-circle-logo.png b/public/tanstack-circle-logo.png
deleted file mode 100644
index 9db3e67b..00000000
Binary files a/public/tanstack-circle-logo.png and /dev/null differ
diff --git a/public/tanstack-word-logo-white.svg b/public/tanstack-word-logo-white.svg
deleted file mode 100644
index b6ec5086..00000000
--- a/public/tanstack-word-logo-white.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/canvas/skia/skia-canvas.tsx b/src/canvas/skia/skia-canvas.tsx
deleted file mode 100644
index 1fbe44f4..00000000
--- a/src/canvas/skia/skia-canvas.tsx
+++ /dev/null
@@ -1,1037 +0,0 @@
-import { useRef, useEffect, useState } from 'react'
-import { loadCanvasKit } from './skia-init'
-import { SkiaEngine, screenToScene } from './skia-engine'
-import { useCanvasStore } from '@/stores/canvas-store'
-import { useDocumentStore } from '@/stores/document-store'
-import { createNodeForTool, isDrawingTool } from '../canvas-node-creator'
-import { inferLayout } from '../canvas-layout-engine'
-import { SkiaPenTool } from './skia-pen-tool'
-import { setSkiaEngineRef } from '../skia-engine-ref'
-import type { ToolType } from '@/types/canvas'
-import type { PenNode, ContainerProps, TextNode, EllipseNode } from '@/types/pen'
-import { computeArcHandles } from './skia-overlays'
-
-interface TextEditState {
- nodeId: string
- x: number; y: number; w: number; h: number
- content: string
- fontSize: number
- fontFamily: string
- fontWeight: string
- textAlign: string
- color: string
- lineHeight: number
-}
-
-function toolToCursor(tool: ToolType): string {
- switch (tool) {
- case 'hand': return 'grab'
- case 'text': return 'text'
- case 'select': return 'default'
- default: return 'crosshair'
- }
-}
-
-export default function SkiaCanvas() {
- const canvasRef = useRef(null)
- const containerRef = useRef(null)
- const engineRef = useRef(null)
- const [error, setError] = useState(null)
- const [editingText, setEditingText] = useState(null)
-
- // Initialize CanvasKit + engine
- useEffect(() => {
- let disposed = false
-
- async function init() {
- try {
- const ck = await loadCanvasKit()
- if (disposed) return
-
- const canvasEl = canvasRef.current
- if (!canvasEl) return
-
- const engine = new SkiaEngine(ck)
- engine.init(canvasEl)
- engineRef.current = engine
- setSkiaEngineRef(engine)
-
- // Initial sync
- engine.syncFromDocument()
- requestAnimationFrame(() => engine.zoomToFitContent())
-
- } catch (err) {
- console.error('SkiaCanvas init failed:', err)
- setError(String(err))
- }
- }
-
- init()
-
- return () => {
- disposed = true
- setSkiaEngineRef(null)
- engineRef.current?.dispose()
- engineRef.current = null
- }
- }, [])
-
- // Resize observer
- useEffect(() => {
- const container = containerRef.current
- if (!container) return
- const observer = new ResizeObserver((entries) => {
- const engine = engineRef.current
- if (!engine) return
- for (const entry of entries) {
- const { width, height } = entry.contentRect
- engine.resize(width, height)
- }
- })
- observer.observe(container)
- return () => observer.disconnect()
- }, [])
-
- // Document sync: re-render when document changes
- useEffect(() => {
- const unsub = useDocumentStore.subscribe(() => {
- engineRef.current?.syncFromDocument()
- })
- return unsub
- }, [])
-
- // Page sync: re-render when active page changes
- useEffect(() => {
- let prevPageId = useCanvasStore.getState().activePageId
- const unsub = useCanvasStore.subscribe((state) => {
- if (state.activePageId !== prevPageId) {
- prevPageId = state.activePageId
- engineRef.current?.syncFromDocument()
- }
- })
- return unsub
- }, [])
-
- // Selection sync: re-render when selection changes
- useEffect(() => {
- let prevIds = useCanvasStore.getState().selection.selectedIds
- const unsub = useCanvasStore.subscribe((state) => {
- if (state.selection.selectedIds !== prevIds) {
- prevIds = state.selection.selectedIds
- engineRef.current?.markDirty()
- }
- })
- return unsub
- }, [])
-
- // ---- Event handlers ----
-
- // Wheel: zoom + pan
- useEffect(() => {
- const canvasEl = canvasRef.current
- if (!canvasEl) return
-
- const handleWheel = (e: WheelEvent) => {
- e.preventDefault()
- e.stopPropagation()
- const engine = engineRef.current
- if (!engine) return
-
- if (e.ctrlKey || e.metaKey) {
- let delta = -e.deltaY
- if (e.deltaMode === 1) delta *= 40
- const factor = Math.pow(1.005, delta)
- const newZoom = engine.zoom * factor
- engine.zoomToPoint(e.clientX, e.clientY, newZoom)
- } else {
- let dx = -e.deltaX
- let dy = -e.deltaY
- if (e.deltaMode === 1) { dx *= 40; dy *= 40 }
- engine.pan(dx, dy)
- }
- }
-
- canvasEl.addEventListener('wheel', handleWheel, { passive: false })
- return () => canvasEl.removeEventListener('wheel', handleWheel)
- }, [])
-
- // Mouse interactions: select, move, resize, draw, hover
- useEffect(() => {
- const canvasEl = canvasRef.current
- if (!canvasEl) return
-
- // --- Shared state ---
- let isPanning = false
- let spacePressed = false
- let lastX = 0
- let lastY = 0
-
- // --- Select tool state ---
- let isDragging = false
- let dragMoved = false
- let isMarquee = false
- let dragNodeIds: string[] = []
- let dragStartSceneX = 0
- let dragStartSceneY = 0
- let dragOrigPositions: { id: string; x: number; y: number }[] = []
- let dragPrevDx = 0
- let dragPrevDy = 0
- /** Set of node IDs being dragged (including descendants). */
- let dragAllIds: Set | null = null
- const DRAG_THRESHOLD = 3
-
- // --- Resize handle state ---
- type HandleDir = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
- let isResizing = false
- let resizeHandle: HandleDir | null = null
- let resizeNodeId: string | null = null
- let resizeOrigX = 0
- let resizeOrigY = 0
- let resizeOrigW = 0
- let resizeOrigH = 0
- let resizeStartSceneX = 0
- let resizeStartSceneY = 0
-
- const HANDLE_HIT_RADIUS = 8 // larger hit area than visual
- const ROTATE_OUTER_RADIUS = 16 // rotation zone is outside corner handles
-
- const handleCursors: Record = {
- nw: 'nwse-resize', n: 'ns-resize', ne: 'nesw-resize', e: 'ew-resize',
- se: 'nwse-resize', s: 'ns-resize', sw: 'nesw-resize', w: 'ew-resize',
- }
-
- // --- Rotation state ---
- let isRotating = false
- let rotateNodeId: string | null = null
- let rotateOrigAngle = 0
- let rotateCenterX = 0
- let rotateCenterY = 0
- let rotateStartAngle = 0
-
- // --- Arc handle state ---
- type ArcHandleType = 'start' | 'end' | 'inner'
- let isDraggingArc = false
- let arcHandleType: ArcHandleType | null = null
- let arcNodeId: string | null = null
-
- const ARC_HANDLE_HIT_RADIUS = 8
-
- /** Check if a scene point hits an arc handle of the selected ellipse. */
- const hitTestArcHandle = (sceneX: number, sceneY: number): { type: ArcHandleType; nodeId: string } | null => {
- const engine = getEngine()
- if (!engine) return null
- const { selectedIds } = useCanvasStore.getState().selection
- if (selectedIds.length !== 1) return null
- const rn = engine.spatialIndex.get(selectedIds[0])
- if (!rn || rn.node.type !== 'ellipse') return null
- const eNode = rn.node as EllipseNode
- const handles = computeArcHandles(
- rn.absX, rn.absY, rn.absW, rn.absH,
- eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0,
- )
- const hitR = ARC_HANDLE_HIT_RADIUS / engine.zoom
- for (const key of ['start', 'end', 'inner'] as ArcHandleType[]) {
- const h = handles[key]
- if (Math.hypot(sceneX - h.x, sceneY - h.y) <= hitR) {
- return { type: key, nodeId: rn.node.id }
- }
- }
- return null
- }
-
- // --- Drawing tool state ---
- let isDrawing = false
- let drawTool: ToolType = 'select'
- let drawStartX = 0
- let drawStartY = 0
-
- // --- Pen tool ---
- const penTool = new SkiaPenTool(() => engineRef.current)
-
- const getEngine = () => engineRef.current
- const getTool = () => useCanvasStore.getState().activeTool
-
- const getScene = (e: MouseEvent) => {
- const engine = getEngine()
- if (!engine) return null
- const rect = engine.getCanvasRect()
- if (!rect) return null
- return screenToScene(e.clientX, e.clientY, rect, {
- zoom: engine.zoom, panX: engine.panX, panY: engine.panY,
- })
- }
-
- /** Get the selected node's render info (single selection only). */
- const getSelectedRN = () => {
- const engine = getEngine()
- if (!engine) return null
- const { selectedIds } = useCanvasStore.getState().selection
- if (selectedIds.length !== 1) return null
- return engine.spatialIndex.get(selectedIds[0]) ?? null
- }
-
- /** Check if a scene point hits a resize handle of the selected node. */
- const hitTestHandle = (sceneX: number, sceneY: number): { dir: HandleDir; nodeId: string } | null => {
- const engine = getEngine()
- if (!engine) return null
- const rn = getSelectedRN()
- if (!rn) return null
-
- const hitR = HANDLE_HIT_RADIUS / engine.zoom
- const { absX: x, absY: y, absW: w, absH: h } = rn
- const handles: [HandleDir, number, number][] = [
- ['nw', x, y], ['n', x + w / 2, y], ['ne', x + w, y],
- ['w', x, y + h / 2], ['e', x + w, y + h / 2],
- ['sw', x, y + h], ['s', x + w / 2, y + h], ['se', x + w, y + h],
- ]
- for (const [dir, hx, hy] of handles) {
- if (Math.abs(sceneX - hx) <= hitR && Math.abs(sceneY - hy) <= hitR) {
- return { dir, nodeId: rn.node.id }
- }
- }
- return null
- }
-
- /** Check if a scene point is in the rotation zone (just outside corner handles). */
- const hitTestRotation = (sceneX: number, sceneY: number): { nodeId: string } | null => {
- const engine = getEngine()
- if (!engine) return null
- const rn = getSelectedRN()
- if (!rn) return null
-
- const innerR = HANDLE_HIT_RADIUS / engine.zoom
- const outerR = ROTATE_OUTER_RADIUS / engine.zoom
- const { absX: x, absY: y, absW: w, absH: h } = rn
- const corners = [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]
- for (const [cx, cy] of corners) {
- const dist = Math.hypot(sceneX - cx, sceneY - cy)
- if (dist > innerR && dist <= outerR) {
- return { nodeId: rn.node.id }
- }
- }
- return null
- }
-
- // --- Keyboard: space for panning ---
- const onKeyDown = (e: KeyboardEvent) => {
- // Pen tool keyboard shortcuts
- if (penTool.onKeyDown(e.key)) {
- e.preventDefault()
- return
- }
-
- if (e.code === 'Space' && !e.repeat) {
- spacePressed = true
- canvasEl.style.cursor = 'grab'
- }
- }
- const onKeyUp = (e: KeyboardEvent) => {
- if (e.code === 'Space') {
- spacePressed = false
- isPanning = false
- canvasEl.style.cursor = toolToCursor(getTool())
- }
- }
- document.addEventListener('keydown', onKeyDown)
- document.addEventListener('keyup', onKeyUp)
-
- // Tool change → cursor + cancel pen if switching away
- const unsubTool = useCanvasStore.subscribe((state) => {
- if (!spacePressed && !isResizing) canvasEl.style.cursor = toolToCursor(state.activeTool)
- penTool.onToolChange(state.activeTool)
- })
-
- // =====================================================================
- // MOUSE DOWN
- // =====================================================================
- const onMouseDown = (e: MouseEvent) => {
- const engine = getEngine()
- if (!engine) return
-
- // Ignore right-click — only handle left (0) and middle (1)
- if (e.button === 2) return
-
- // --- Pan: space+click, hand tool, or middle mouse ---
- if (spacePressed || getTool() === 'hand' || e.button === 1) {
- isPanning = true
- lastX = e.clientX
- lastY = e.clientY
- canvasEl.style.cursor = 'grabbing'
- return
- }
-
- const tool = getTool()
- const scene = getScene(e)
- if (!scene) return
-
- // --- Text tool: click to create immediately ---
- if (tool === 'text') {
- const node = createNodeForTool('text', scene.x, scene.y, 0, 0)
- if (node) {
- useDocumentStore.getState().addNode(null, node)
- useCanvasStore.getState().setSelection([node.id], node.id)
- }
- useCanvasStore.getState().setActiveTool('select')
- return
- }
-
- // --- Pen tool ---
- if (tool === 'path') {
- penTool.onMouseDown(scene, engine.zoom || 1)
- return
- }
-
- // --- Drawing tools: start rubber-band ---
- if (isDrawingTool(tool)) {
- isDrawing = true
- drawTool = tool
- drawStartX = scene.x
- drawStartY = scene.y
- engine.previewShape = {
- type: tool as 'rectangle' | 'ellipse' | 'frame' | 'line' | 'polygon',
- x: scene.x, y: scene.y, w: 0, h: 0,
- }
- engine.markDirty()
- return
- }
-
- // --- Select tool ---
- if (tool === 'select') {
- // Check arc handles first (ellipse arc editing)
- const arcHit = hitTestArcHandle(scene.x, scene.y)
- if (arcHit) {
- isDraggingArc = true
- arcHandleType = arcHit.type
- arcNodeId = arcHit.nodeId
- canvasEl.style.cursor = 'pointer'
- return
- }
-
- // Check resize handle first (only for single selection)
- const handleHit = hitTestHandle(scene.x, scene.y)
- if (handleHit) {
- isResizing = true
- resizeHandle = handleHit.dir
- resizeNodeId = handleHit.nodeId
- resizeStartSceneX = scene.x
- resizeStartSceneY = scene.y
- const docNode = useDocumentStore.getState().getNodeById(handleHit.nodeId)
- resizeOrigX = docNode?.x ?? 0
- resizeOrigY = docNode?.y ?? 0
- // Use resolved dimensions from spatial index — document store may have
- // string values like 'fill_container' that break arithmetic.
- const resizeRN = engine.spatialIndex.get(handleHit.nodeId)
- const docNodeAny = docNode as (PenNode & ContainerProps) | undefined
- resizeOrigW = resizeRN?.absW ?? (typeof docNodeAny?.width === 'number' ? docNodeAny.width : 100)
- resizeOrigH = resizeRN?.absH ?? (typeof docNodeAny?.height === 'number' ? docNodeAny.height : 100)
- canvasEl.style.cursor = handleCursors[handleHit.dir]
- return
- }
-
- // Check rotation zone (just outside corner handles)
- const rotHit = hitTestRotation(scene.x, scene.y)
- if (rotHit) {
- isRotating = true
- rotateNodeId = rotHit.nodeId
- const docNode = useDocumentStore.getState().getNodeById(rotHit.nodeId)
- rotateOrigAngle = docNode?.rotation ?? 0
- const rn = getSelectedRN()!
- rotateCenterX = rn.absX + rn.absW / 2
- rotateCenterY = rn.absY + rn.absH / 2
- rotateStartAngle = Math.atan2(scene.y - rotateCenterY, scene.x - rotateCenterX) * 180 / Math.PI
- canvasEl.style.cursor = 'grabbing'
- return
- }
-
- const hits = engine.spatialIndex.hitTest(scene.x, scene.y)
-
- if (hits.length > 0) {
- const topHit = hits[0]
- let nodeId = topHit.node.id
- const currentSelection = useCanvasStore.getState().selection.selectedIds
- const docStore = useDocumentStore.getState()
-
- // If the hit node is a descendant of an already-selected node,
- // keep the current selection so the whole group moves together.
- const isChildOfSelected = currentSelection.some(
- (selId) => selId !== nodeId && docStore.isDescendantOf(nodeId, selId),
- )
- if (isChildOfSelected) {
- // Don't change selection — proceed to drag the selected parent
- } else if (!currentSelection.includes(nodeId)) {
- // Walk up the tree to find the top-level parent (group behavior):
- // clicking a child inside a group selects the group, not the child.
- const parent = docStore.getParentOf(nodeId)
- if (parent && (parent.type === 'frame' || parent.type === 'group')) {
- // Check if parent is a top-level node (its parent is the page root)
- const grandparent = docStore.getParentOf(parent.id)
- if (!grandparent || grandparent.type === 'frame') {
- nodeId = parent.id
- }
- }
-
- if (e.shiftKey) {
- if (currentSelection.includes(nodeId)) {
- const next = currentSelection.filter((id) => id !== nodeId)
- useCanvasStore.getState().setSelection(next, next[0] ?? null)
- } else {
- useCanvasStore.getState().setSelection([...currentSelection, nodeId], nodeId)
- }
- } else {
- useCanvasStore.getState().setSelection([nodeId], nodeId)
- }
- }
-
- // Start drag — move all selected nodes
- const selectedIds = useCanvasStore.getState().selection.selectedIds
- isDragging = true
- dragMoved = false
- dragNodeIds = selectedIds
- dragStartSceneX = scene.x
- dragStartSceneY = scene.y
- dragOrigPositions = selectedIds.map((id) => {
- const n = useDocumentStore.getState().getNodeById(id)
- return { id, x: n?.x ?? 0, y: n?.y ?? 0 }
- })
- } else {
- // Empty space → start marquee or clear selection
- if (!e.shiftKey) {
- useCanvasStore.getState().clearSelection()
- }
- isMarquee = true
- lastX = scene.x
- lastY = scene.y
- engine.marquee = { x1: scene.x, y1: scene.y, x2: scene.x, y2: scene.y }
- }
- }
- }
-
- // =====================================================================
- // MOUSE MOVE
- // =====================================================================
- const onMouseMove = (e: MouseEvent) => {
- const engine = getEngine()
- if (!engine) return
-
- if (isPanning) {
- const dx = e.clientX - lastX
- const dy = e.clientY - lastY
- lastX = e.clientX
- lastY = e.clientY
- engine.pan(dx, dy)
- return
- }
-
- const scene = getScene(e)
- if (!scene) return
-
- // --- Pen tool move ---
- if (penTool.onMouseMove(scene)) return
-
- // --- Resize handle drag ---
- if (isResizing && resizeHandle && resizeNodeId) {
- const dx = scene.x - resizeStartSceneX
- const dy = scene.y - resizeStartSceneY
- let newX = resizeOrigX
- let newY = resizeOrigY
- let newW = resizeOrigW
- let newH = resizeOrigH
-
- // Compute new bounds based on which handle is dragged
- const dir = resizeHandle
- if (dir.includes('w')) { newX = resizeOrigX + dx; newW = resizeOrigW - dx }
- if (dir.includes('e')) { newW = resizeOrigW + dx }
- if (dir.includes('n')) { newY = resizeOrigY + dy; newH = resizeOrigH - dy }
- if (dir.includes('s')) { newH = resizeOrigH + dy }
-
- // Enforce minimum size
- const MIN = 2
- if (newW < MIN) { if (dir.includes('w')) newX = resizeOrigX + resizeOrigW - MIN; newW = MIN }
- if (newH < MIN) { if (dir.includes('n')) newY = resizeOrigY + resizeOrigH - MIN; newH = MIN }
-
- // For text nodes, switching to fixed-width enables auto-wrap
- const resizedNode = useDocumentStore.getState().getNodeById(resizeNodeId)
- const updates: Record = { x: newX, y: newY, width: newW, height: newH }
- if (resizedNode?.type === 'text' && !(resizedNode as TextNode).textGrowth) {
- updates.textGrowth = 'fixed-width'
- }
- useDocumentStore.getState().updateNode(resizeNodeId, updates as Partial)
-
- // Scale children proportionally when resizing a container with children
- if (
- resizedNode
- && 'children' in resizedNode
- && resizedNode.children?.length
- ) {
- // Use resolved dimensions — document store may have string values
- const resizeRN2 = engine.spatialIndex.get(resizeNodeId)
- const resizedContainer = resizedNode as PenNode & ContainerProps
- const oldW = resizeRN2?.absW ?? (typeof resizedContainer.width === 'number' ? resizedContainer.width : 0)
- const oldH = resizeRN2?.absH ?? (typeof resizedContainer.height === 'number' ? resizedContainer.height : 0)
- if (oldW > 0 && oldH > 0) {
- const scaleX = newW / oldW
- const scaleY = newH / oldH
- useDocumentStore.getState().scaleDescendantsInStore(resizeNodeId, scaleX, scaleY)
- }
- }
- return
- }
-
- // --- Rotation drag ---
- if (isRotating && rotateNodeId) {
- const currentAngle = Math.atan2(scene.y - rotateCenterY, scene.x - rotateCenterX) * 180 / Math.PI
- let newAngle = rotateOrigAngle + (currentAngle - rotateStartAngle)
- // Snap to 15° increments when holding shift
- if (e.shiftKey) {
- newAngle = Math.round(newAngle / 15) * 15
- }
- useDocumentStore.getState().updateNode(rotateNodeId, { rotation: newAngle } as Partial)
- return
- }
-
- // --- Arc handle drag ---
- if (isDraggingArc && arcNodeId && arcHandleType) {
- const rn = engine.spatialIndex.get(arcNodeId)
- if (rn) {
- const cx = rn.absX + rn.absW / 2
- const cy = rn.absY + rn.absH / 2
- // Compute angle from center, accounting for ellipse aspect ratio
- const angle = Math.atan2(scene.y - cy, scene.x - cx) * 180 / Math.PI
- const normalizedAngle = ((angle % 360) + 360) % 360
- const eNode = rn.node as EllipseNode
-
- if (arcHandleType === 'start') {
- const oldStart = eNode.startAngle ?? 0
- const oldEnd = oldStart + (eNode.sweepAngle ?? 360)
- // Keep end angle fixed, adjust sweep
- const newSweep = ((oldEnd - normalizedAngle) % 360 + 360) % 360
- useDocumentStore.getState().updateNode(arcNodeId, {
- startAngle: normalizedAngle,
- sweepAngle: newSweep || 360,
- } as Partial)
- } else if (arcHandleType === 'end') {
- const startA = eNode.startAngle ?? 0
- const newSweep = ((normalizedAngle - startA) % 360 + 360) % 360
- useDocumentStore.getState().updateNode(arcNodeId, {
- sweepAngle: newSweep || 360,
- } as Partial)
- } else if (arcHandleType === 'inner') {
- // Inner radius: distance from center as ratio of outer radius
- const rx = rn.absW / 2
- const ry = rn.absH / 2
- const dist = Math.hypot((scene.x - cx) / rx, (scene.y - cy) / ry)
- const newInner = Math.max(0, Math.min(0.99, dist))
- useDocumentStore.getState().updateNode(arcNodeId, {
- innerRadius: newInner,
- } as Partial)
- }
- }
- return
- }
-
- // --- Drawing tool preview ---
- if (isDrawing && engine.previewShape) {
- const dx = scene.x - drawStartX
- const dy = scene.y - drawStartY
-
- if (drawTool === 'line') {
- engine.previewShape = {
- type: 'line',
- x: drawStartX, y: drawStartY,
- w: dx, h: dy,
- }
- } else {
- // Rectangle / ellipse / frame: handle negative drag direction
- engine.previewShape = {
- type: drawTool as 'rectangle' | 'ellipse' | 'frame' | 'line' | 'polygon',
- x: dx < 0 ? scene.x : drawStartX,
- y: dy < 0 ? scene.y : drawStartY,
- w: Math.abs(dx),
- h: Math.abs(dy),
- }
- }
- engine.markDirty()
- return
- }
-
- // --- Select tool: drag move (all selected nodes) ---
- if (isDragging && dragNodeIds.length > 0) {
- const dx = scene.x - dragStartSceneX
- const dy = scene.y - dragStartSceneY
-
- if (!dragMoved) {
- const screenDist = Math.hypot(dx * engine.zoom, dy * engine.zoom)
- if (screenDist < DRAG_THRESHOLD) return
- dragMoved = true
- // Suppress store→sync loop so layout engine doesn't override positions
- engine.dragSyncSuppressed = true
- dragPrevDx = 0
- dragPrevDy = 0
- // Collect all node IDs being moved (selected + their descendants)
- dragAllIds = new Set(dragNodeIds)
- for (const id of dragNodeIds) {
- const collectDescs = (nodeId: string) => {
- const n = useDocumentStore.getState().getNodeById(nodeId)
- if (n && 'children' in n && n.children) {
- for (const child of n.children) {
- dragAllIds!.add(child.id)
- collectDescs(child.id)
- }
- }
- }
- collectDescs(id)
- }
- }
-
- // Apply incremental delta directly to render nodes for immediate feedback
- const incrDx = dx - dragPrevDx
- const incrDy = dy - dragPrevDy
- dragPrevDx = dx
- dragPrevDy = dy
-
- for (const rn of engine.renderNodes) {
- if (dragAllIds!.has(rn.node.id)) {
- rn.absX += incrDx
- rn.absY += incrDy
- rn.node = { ...rn.node, x: rn.absX, y: rn.absY }
- }
- }
- engine.spatialIndex.rebuild(engine.renderNodes)
- engine.markDirty()
- return
- }
-
- // --- Marquee ---
- if (isMarquee && engine.marquee) {
- engine.marquee.x2 = scene.x
- engine.marquee.y2 = scene.y
- engine.markDirty()
-
- const marqueeHits = engine.spatialIndex.searchRect(
- engine.marquee.x1, engine.marquee.y1,
- engine.marquee.x2, engine.marquee.y2,
- )
- const ids = marqueeHits.map((rn) => rn.node.id)
- useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
- return
- }
-
- // --- Hover + handle cursor (select tool only) ---
- if (getTool() === 'select' && !spacePressed) {
- // Check arc handle hover
- const arcHoverHit = hitTestArcHandle(scene.x, scene.y)
- if (arcHoverHit) {
- canvasEl.style.cursor = 'pointer'
- return
- }
- // Check handle hover for cursor
- const handleHit = hitTestHandle(scene.x, scene.y)
- if (handleHit) {
- canvasEl.style.cursor = handleCursors[handleHit.dir]
- } else if (hitTestRotation(scene.x, scene.y)) {
- // Rotation cursor — rotate icon via CSS
- canvasEl.style.cursor = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'24\' height=\'24\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'black\' stroke-width=\'2\'%3E%3Cpath d=\'M21 2v6h-6\'/%3E%3Cpath d=\'M21 13a9 9 0 1 1-3-7.7L21 8\'/%3E%3C/svg%3E") 12 12, crosshair'
- } else {
- const hoverHits = engine.spatialIndex.hitTest(scene.x, scene.y)
- const newHoveredId = hoverHits.length > 0 ? hoverHits[0].node.id : null
- canvasEl.style.cursor = newHoveredId ? 'move' : 'default'
- if (newHoveredId !== engine.hoveredNodeId) {
- engine.hoveredNodeId = newHoveredId
- useCanvasStore.getState().setHoveredId(newHoveredId)
- engine.markDirty()
- }
- }
- }
- }
-
- // =====================================================================
- // MOUSE UP
- // =====================================================================
- const onMouseUp = () => {
- const engine = getEngine()
-
- // --- Pen tool: end handle drag ---
- if (penTool.onMouseUp()) return
-
- // --- Pan end ---
- if (isPanning) {
- isPanning = false
- canvasEl.style.cursor = spacePressed ? 'grab' : toolToCursor(getTool())
- }
-
- // --- Resize end ---
- if (isResizing) {
- isResizing = false
- resizeHandle = null
- resizeNodeId = null
- canvasEl.style.cursor = toolToCursor(getTool())
- }
-
- // --- Arc handle end ---
- if (isDraggingArc) {
- isDraggingArc = false
- arcHandleType = null
- arcNodeId = null
- canvasEl.style.cursor = toolToCursor(getTool())
- }
-
- // --- Rotation end ---
- if (isRotating) {
- isRotating = false
- rotateNodeId = null
- canvasEl.style.cursor = toolToCursor(getTool())
- }
-
- // --- Drawing tool: create node ---
- if (isDrawing && engine?.previewShape) {
- const { type, x, y, w, h } = engine.previewShape
- engine.previewShape = null
- engine.markDirty()
- isDrawing = false
-
- // Ignore tiny accidental clicks (< 2px)
- const minSize = type === 'line'
- ? Math.hypot(w, h) >= 2
- : w >= 2 && h >= 2
- if (minSize) {
- const node = createNodeForTool(drawTool, x, y, w, h)
- if (node) {
- useDocumentStore.getState().addNode(null, node)
- useCanvasStore.getState().setSelection([node.id], node.id)
- }
- }
- useCanvasStore.getState().setActiveTool('select')
- return
- }
- isDrawing = false
-
- // --- Select tool: end drag / marquee ---
- if (isDragging && dragMoved && dragOrigPositions.length > 0 && engine) {
- const dx = dragPrevDx
- const dy = dragPrevDy
- const docStore = useDocumentStore.getState()
-
- for (const orig of dragOrigPositions) {
- const parent = docStore.getParentOf(orig.id)
- const draggedRN = engine.renderNodes.find((rn) => rn.node.id === orig.id)
- const objBounds = draggedRN
- ? { x: draggedRN.absX, y: draggedRN.absY, w: draggedRN.absW, h: draggedRN.absH }
- : { x: orig.x + dx, y: orig.y + dy, w: 100, h: 100 }
-
- // Check if dragged completely outside parent → reparent
- if (parent) {
- const parentRN = engine.renderNodes.find((rn) => rn.node.id === parent.id)
- if (parentRN) {
- const pBounds = { x: parentRN.absX, y: parentRN.absY, w: parentRN.absW, h: parentRN.absH }
- const outside =
- objBounds.x + objBounds.w <= pBounds.x ||
- objBounds.x >= pBounds.x + pBounds.w ||
- objBounds.y + objBounds.h <= pBounds.y ||
- objBounds.y >= pBounds.y + pBounds.h
-
- if (outside) {
- // Reparent to root level with absolute position
- docStore.updateNode(orig.id, { x: objBounds.x, y: objBounds.y } as Partial)
- docStore.moveNode(orig.id, null, 0)
- continue
- }
- }
- }
-
- // Check if node is inside a layout container
- const parentLayout = parent
- ? ((parent as PenNode & ContainerProps).layout || inferLayout(parent))
- : undefined
-
- if (parentLayout && parentLayout !== 'none' && parent) {
- // Layout child: reorder based on drop position
- const siblings = ('children' in parent ? parent.children ?? [] : [])
- .filter((c) => c.id !== orig.id)
- const isVertical = parentLayout === 'vertical'
-
- let newIndex = siblings.length
- for (let i = 0; i < siblings.length; i++) {
- const sibRN = engine.renderNodes.find((rn) => rn.node.id === siblings[i].id)
- const sibMid = sibRN
- ? (isVertical ? sibRN.absY + sibRN.absH / 2 : sibRN.absX + sibRN.absW / 2)
- : 0
- const dragMid = isVertical
- ? objBounds.y + objBounds.h / 2
- : objBounds.x + objBounds.w / 2
- if (dragMid < sibMid) {
- newIndex = i
- break
- }
- }
- docStore.moveNode(orig.id, parent.id, newIndex)
- } else {
- // Non-layout node: freely set position
- docStore.updateNode(orig.id, {
- x: orig.x + dx,
- y: orig.y + dy,
- } as Partial)
- }
- }
-
- // Re-enable sync and do a full rebuild
- engine.dragSyncSuppressed = false
- engine.syncFromDocument()
- } else if (engine) {
- engine.dragSyncSuppressed = false
- }
- isDragging = false
- dragNodeIds = []
- dragOrigPositions = []
- dragAllIds = null
- if (isMarquee && engine) {
- engine.marquee = null
- engine.markDirty()
- }
- isMarquee = false
- }
-
- // =====================================================================
- // DOUBLE CLICK — text editing
- // =====================================================================
- const onDblClick = (e: MouseEvent) => {
- const engine = getEngine()
- if (!engine) return
-
- // Pen tool: double-click finalizes the path (open)
- if (penTool.onDblClick()) return
-
- if (getTool() !== 'select') return
-
- const scene = getScene(e)
- if (!scene) return
-
- const hits = engine.spatialIndex.hitTest(scene.x, scene.y)
- if (hits.length === 0) return
-
- const topHit = hits[0]
- const currentSelection = useCanvasStore.getState().selection.selectedIds
-
- // Double-click on a selected group/frame → enter it and select the child
- if (currentSelection.length === 1) {
- const selectedNode = useDocumentStore.getState().getNodeById(currentSelection[0])
- if (
- selectedNode
- && (selectedNode.type === 'frame' || selectedNode.type === 'group')
- && 'children' in selectedNode && selectedNode.children?.length
- ) {
- // Find the direct child under the cursor
- const childId = topHit.node.id
- if (childId !== currentSelection[0]) {
- useCanvasStore.getState().setSelection([childId], childId)
- return
- }
- }
- }
-
- if (topHit.node.type !== 'text') return
-
- const tNode = topHit.node as TextNode
- const fills = tNode.fill
- const firstFill = Array.isArray(fills) ? fills[0] : undefined
- const color = firstFill?.type === 'solid' ? firstFill.color : '#000000'
-
- setEditingText({
- nodeId: topHit.node.id,
- x: topHit.absX * engine.zoom + engine.panX,
- y: topHit.absY * engine.zoom + engine.panY,
- w: topHit.absW * engine.zoom,
- h: topHit.absH * engine.zoom,
- content: typeof tNode.content === 'string'
- ? tNode.content
- : Array.isArray(tNode.content)
- ? tNode.content.map((s) => s.text ?? '').join('')
- : '',
- fontSize: (tNode.fontSize ?? 16) * engine.zoom,
- fontFamily: tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif',
- fontWeight: String(tNode.fontWeight ?? '400'),
- textAlign: tNode.textAlign ?? 'left',
- color,
- lineHeight: tNode.lineHeight ?? 1.4,
- })
- }
-
- const onContextMenu = (e: MouseEvent) => e.preventDefault()
-
- canvasEl.addEventListener('mousedown', onMouseDown)
- canvasEl.addEventListener('dblclick', onDblClick)
- canvasEl.addEventListener('contextmenu', onContextMenu)
- window.addEventListener('mousemove', onMouseMove)
- window.addEventListener('mouseup', onMouseUp)
-
- return () => {
- document.removeEventListener('keydown', onKeyDown)
- document.removeEventListener('keyup', onKeyUp)
- canvasEl.removeEventListener('mousedown', onMouseDown)
- canvasEl.removeEventListener('dblclick', onDblClick)
- canvasEl.removeEventListener('contextmenu', onContextMenu)
- window.removeEventListener('mousemove', onMouseMove)
- window.removeEventListener('mouseup', onMouseUp)
- unsubTool()
- }
- }, [])
-
- return (
-
- )
-}
diff --git a/src/canvas/skia/skia-engine.ts b/src/canvas/skia/skia-engine.ts
deleted file mode 100644
index 229aa8cd..00000000
--- a/src/canvas/skia/skia-engine.ts
+++ /dev/null
@@ -1,757 +0,0 @@
-import type { CanvasKit, Surface } from 'canvaskit-wasm'
-import type { PenNode, ContainerProps, EllipseNode } from '@/types/pen'
-import { useCanvasStore } from '@/stores/canvas-store'
-import { useDocumentStore, getActivePageChildren, getAllChildren } from '@/stores/document-store'
-import { resolveNodeForCanvas, getDefaultTheme } from '@/variables/resolve-variables'
-import { getCanvasBackground, MIN_ZOOM, MAX_ZOOM } from '../canvas-constants'
-import {
- resolvePadding,
- isNodeVisible,
- getNodeWidth,
- getNodeHeight,
- computeLayoutPositions,
- inferLayout,
-} from '../canvas-layout-engine'
-import { parseSizing, defaultLineHeight } from '../canvas-text-measure'
-import { SkiaRenderer, type RenderNode } from './skia-renderer'
-import { SpatialIndex } from './skia-hit-test'
-import { parseColor, wrapLine, cssFontFamily } from './skia-paint-utils'
-import {
- viewportMatrix,
- zoomToPoint as vpZoomToPoint,
-} from './skia-viewport'
-import {
- getActiveAgentIndicators,
- getActiveAgentFrames,
- isPreviewNode,
-} from '../agent-indicator'
-import { isNodeBorderReady, getNodeRevealTime } from '@/services/ai/design-animation'
-
-// Re-export for use by canvas component
-export { screenToScene } from './skia-viewport'
-export { SpatialIndex } from './skia-hit-test'
-
-// ---------------------------------------------------------------------------
-// Pre-measure text widths using Canvas 2D (browser fonts)
-// ---------------------------------------------------------------------------
-
-let _measureCtx: CanvasRenderingContext2D | null = null
-function getMeasureCtx(): CanvasRenderingContext2D {
- if (!_measureCtx) {
- const c = document.createElement('canvas')
- _measureCtx = c.getContext('2d')!
- }
- return _measureCtx
-}
-
-/**
- * Walk the node tree and fix text HEIGHTS using actual Canvas 2D wrapping.
- *
- * Only targets fixed-width text with auto height — these are the cases where
- * estimateTextHeight may underestimate because its width estimation differs
- * from Canvas 2D's actual text measurement, leading to incorrect wrap counts.
- *
- * IMPORTANT: This function never touches WIDTH or container-relative sizing
- * strings (fill_container / fit_content). Changing widths breaks layout
- * resolution in computeLayoutPositions.
- */
-function premeasureTextHeights(nodes: PenNode[]): PenNode[] {
- return nodes.map((node) => {
- let result = node
-
- if (node.type === 'text') {
- const tNode = node as import('@/types/pen').TextNode
- const hasFixedWidth = typeof tNode.width === 'number' && tNode.width > 0
- const isContainerHeight = typeof tNode.height === 'string'
- && (tNode.height === 'fill_container' || tNode.height === 'fit_content')
- const textGrowth = tNode.textGrowth
- const content = typeof tNode.content === 'string'
- ? tNode.content
- : Array.isArray(tNode.content)
- ? tNode.content.map((s) => s.text ?? '').join('')
- : ''
-
- // Match Fabric.js wrapping: only premeasure when text actually wraps.
- // textGrowth='auto' means auto-width (no wrapping) regardless of textAlign.
- // textGrowth=undefined with non-left textAlign uses fixed-width for alignment.
- const textAlign = tNode.textAlign
- const isFixedWidthText = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
- || (textGrowth !== 'auto' && textAlign != null && textAlign !== 'left')
- if (content && hasFixedWidth && isFixedWidthText && !isContainerHeight) {
- const fontSize = tNode.fontSize ?? 16
- const fontWeight = tNode.fontWeight ?? '400'
- const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
- const ctx = getMeasureCtx()
- ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
-
- // Fixed-width text with auto height: wrap and measure actual height
- const wrapWidth = (tNode.width as number) + fontSize * 0.2
- const rawLines = content.split('\n')
- const wrappedLines: string[] = []
- for (const raw of rawLines) {
- if (!raw) { wrappedLines.push(''); continue }
- wrapLine(ctx, raw, wrapWidth, wrappedLines)
- }
- const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
- const lineHeight = lineHeightMul * fontSize
- const glyphH = fontSize * 1.13
- const measuredHeight = Math.ceil(
- wrappedLines.length <= 1
- ? glyphH + 2
- : (wrappedLines.length - 1) * lineHeight + glyphH + 2,
- )
- const currentHeight = typeof tNode.height === 'number' ? tNode.height : 0
- const explicitLineCount = rawLines.length
- const needsHeight = currentHeight <= 0 || wrappedLines.length > explicitLineCount
- if (needsHeight && measuredHeight > currentHeight) {
- result = { ...node, height: measuredHeight } as unknown as PenNode
- }
- }
- }
-
- // Recurse into children
- if ('children' in result && result.children) {
- const children = result.children
- const measured = premeasureTextHeights(children)
- if (measured !== children) {
- result = { ...result, children: measured } as unknown as PenNode
- }
- }
-
- return result
- })
-}
-
-// ---------------------------------------------------------------------------
-// Flatten document tree → absolute-positioned RenderNode list
-// ---------------------------------------------------------------------------
-
-interface ClipInfo {
- x: number; y: number; w: number; h: number; rx: number
-}
-
-function sizeToNumber(val: number | string | undefined, fallback: number): number {
- if (typeof val === 'number') return val
- if (typeof val === 'string') {
- const m = val.match(/\((\d+(?:\.\d+)?)\)/)
- if (m) return parseFloat(m[1])
- const n = parseFloat(val)
- if (!isNaN(n)) return n
- }
- return fallback
-}
-
-function cornerRadiusVal(cr: number | [number, number, number, number] | undefined): number {
- if (cr === undefined) return 0
- if (typeof cr === 'number') return cr
- return cr[0]
-}
-
-/** Resolve RefNodes inline (same logic as use-canvas-sync.ts). */
-function resolveRefs(
- nodes: PenNode[],
- rootNodes: PenNode[],
- findInTree: (nodes: PenNode[], id: string) => PenNode | null,
- visited = new Set(),
-): PenNode[] {
- return nodes.flatMap((node) => {
- if (node.type !== 'ref') {
- if ('children' in node && node.children) {
- return [{ ...node, children: resolveRefs(node.children, rootNodes, findInTree, visited) } as PenNode]
- }
- return [node]
- }
- if (visited.has(node.ref)) return []
- const component = findInTree(rootNodes, node.ref)
- if (!component) return []
- visited.add(node.ref)
- const resolved: Record = { ...component }
- for (const [key, val] of Object.entries(node)) {
- if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
- if (val !== undefined) resolved[key] = val
- }
- resolved.type = component.type
- if (!resolved.name) resolved.name = component.name
- delete resolved.reusable
- const resolvedNode = resolved as unknown as PenNode
- if ('children' in component && component.children) {
- const refNode = node as import('@/types/pen').RefNode
- ;(resolvedNode as PenNode & ContainerProps).children = remapIds(component.children, node.id, refNode.descendants)
- }
- visited.delete(node.ref)
- return [resolvedNode]
- })
-}
-
-function remapIds(children: PenNode[], refId: string, overrides?: Record>): PenNode[] {
- return children.map((child) => {
- const virtualId = `${refId}__${child.id}`
- const ov = overrides?.[child.id] ?? {}
- const mapped = { ...child, ...ov, id: virtualId } as PenNode
- if ('children' in mapped && mapped.children) {
- (mapped as PenNode & ContainerProps).children = remapIds(mapped.children, refId, overrides)
- }
- return mapped
- })
-}
-
-export function flattenToRenderNodes(
- nodes: PenNode[],
- offsetX = 0,
- offsetY = 0,
- parentAvailW?: number,
- parentAvailH?: number,
- clipCtx?: ClipInfo,
- depth = 0,
-): RenderNode[] {
- const result: RenderNode[] = []
-
- // Reverse order: children[0] = top layer = rendered last (frontmost)
- for (let i = nodes.length - 1; i >= 0; i--) {
- const node = nodes[i]
- if (!isNodeVisible(node)) continue
-
- // Resolve fill_container / fit_content
- let resolved = node
- if (parentAvailW !== undefined || parentAvailH !== undefined) {
- let changed = false
- const r: Record = { ...node }
- if ('width' in node && typeof node.width !== 'number') {
- const s = parseSizing(node.width)
- if (s === 'fill' && parentAvailW) { r.width = parentAvailW; changed = true }
- else if (s === 'fit') { r.width = getNodeWidth(node, parentAvailW); changed = true }
- }
- if ('height' in node && typeof node.height !== 'number') {
- const s = parseSizing(node.height)
- if (s === 'fill' && parentAvailH) { r.height = parentAvailH; changed = true }
- else if (s === 'fit') { r.height = getNodeHeight(node, parentAvailH, parentAvailW); changed = true }
- }
- if (changed) resolved = r as unknown as PenNode
- }
-
- // Compute height for frames without explicit numeric height
- if (
- node.type === 'frame'
- && 'children' in node && node.children?.length
- && (!('height' in resolved) || typeof resolved.height !== 'number')
- ) {
- const computedH = getNodeHeight(resolved, parentAvailH, parentAvailW)
- if (computedH > 0) resolved = { ...resolved, height: computedH } as unknown as PenNode
- }
-
- const absX = (resolved.x ?? 0) + offsetX
- const absY = (resolved.y ?? 0) + offsetY
- const absW = 'width' in resolved ? sizeToNumber(resolved.width, 100) : 100
- const absH = 'height' in resolved ? sizeToNumber(resolved.height, 100) : 100
-
- result.push({
- node: { ...resolved, x: absX, y: absY } as PenNode,
- absX, absY, absW, absH,
- clipRect: clipCtx,
- })
-
- // Recurse into children
- const children = 'children' in node ? node.children : undefined
- if (children && children.length > 0) {
- const nodeW = getNodeWidth(resolved, parentAvailW)
- const nodeH = getNodeHeight(resolved, parentAvailH, parentAvailW)
- const pad = resolvePadding('padding' in resolved ? (resolved as PenNode & ContainerProps).padding : undefined)
- const childAvailW = Math.max(0, nodeW - pad.left - pad.right)
- const childAvailH = Math.max(0, nodeH - pad.top - pad.bottom)
-
- const layout = ('layout' in node ? (node as ContainerProps).layout : undefined) || inferLayout(node)
- const positioned = layout && layout !== 'none'
- ? computeLayoutPositions(resolved, children)
- : children
-
- // Clipping — only clip for root frames (artboard behavior).
- // Nested frames do NOT clip children, matching Fabric.js behavior.
- // Fabric.js doesn't implement frame-level clipping, so children always overflow.
- // TODO: add proper clipContent support once Fabric.js is fully replaced.
- let childClip = clipCtx
- const isRootFrame = node.type === 'frame' && depth === 0
- if (isRootFrame) {
- const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0
- const cr = Math.min(crRaw, nodeH / 2)
- childClip = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr }
- }
-
- const childRNs = flattenToRenderNodes(positioned, absX, absY, childAvailW, childAvailH, childClip, depth + 1)
-
- // Propagate parent flip to children: mirror positions within parent bounds
- // and toggle child flipX/flipY. Must run BEFORE rotation propagation.
- const parentFlipX = node.flipX === true
- const parentFlipY = node.flipY === true
- if (parentFlipX || parentFlipY) {
- const pcx = absX + nodeW / 2
- const pcy = absY + nodeH / 2
- for (const crn of childRNs) {
- const updates: Record = {}
- if (parentFlipX) {
- const ccx = crn.absX + crn.absW / 2
- crn.absX = 2 * pcx - ccx - crn.absW / 2
- const childFlip = crn.node.flipX === true
- updates.flipX = !childFlip || undefined
- }
- if (parentFlipY) {
- const ccy = crn.absY + crn.absH / 2
- crn.absY = 2 * pcy - ccy - crn.absH / 2
- const childFlip = crn.node.flipY === true
- updates.flipY = !childFlip || undefined
- }
- crn.node = { ...crn.node, x: crn.absX, y: crn.absY, ...updates } as PenNode
- }
- }
-
- // Propagate parent rotation to children: rotate their positions around
- // the parent's center and accumulate the rotation angle.
- // Children are in the parent's LOCAL (unrotated) coordinate space, so we
- // need to apply the parent's rotation to get correct absolute positions.
- const parentRot = node.rotation ?? 0
- if (parentRot !== 0) {
- const cx = absX + nodeW / 2
- const cy = absY + nodeH / 2
- const rad = parentRot * Math.PI / 180
- const cosA = Math.cos(rad)
- const sinA = Math.sin(rad)
-
- for (const crn of childRNs) {
- // Rotate child CENTER around parent center
- const ccx = crn.absX + crn.absW / 2
- const ccy = crn.absY + crn.absH / 2
- const dx = ccx - cx
- const dy = ccy - cy
- const newCx = cx + dx * cosA - dy * sinA
- const newCy = cy + dx * sinA + dy * cosA
- crn.absX = newCx - crn.absW / 2
- crn.absY = newCy - crn.absH / 2
- // Accumulate rotation and update node position
- const childRot = crn.node.rotation ?? 0
- crn.node = { ...crn.node, x: crn.absX, y: crn.absY, rotation: childRot + parentRot } as PenNode
- }
- }
-
- result.push(...childRNs)
- }
- }
-
- return result
-}
-
-// ---------------------------------------------------------------------------
-// Component / instance ID collection (from raw tree, before ref resolution)
-// ---------------------------------------------------------------------------
-
-function collectReusableIds(nodes: PenNode[], result: Set) {
- for (const node of nodes) {
- if (node.type === 'frame' && node.reusable === true) {
- result.add(node.id)
- }
- if ('children' in node && node.children) {
- collectReusableIds(node.children, result)
- }
- }
-}
-
-function collectInstanceIds(nodes: PenNode[], result: Set) {
- for (const node of nodes) {
- if (node.type === 'ref') {
- result.add(node.id)
- }
- if ('children' in node && node.children) {
- collectInstanceIds(node.children, result)
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// SkiaEngine — ties rendering, viewport, hit testing together
-// ---------------------------------------------------------------------------
-
-export class SkiaEngine {
- ck: CanvasKit
- surface: Surface | null = null
- renderer: SkiaRenderer
- spatialIndex = new SpatialIndex()
- renderNodes: RenderNode[] = []
-
- // Component/instance IDs for colored frame labels
- private reusableIds = new Set()
- private instanceIds = new Set()
-
- // Agent animation: track start time so glow only pulses ~2 times
- private agentAnimStart = 0
-
- private canvasEl: HTMLCanvasElement | null = null
- private animFrameId = 0
- private dirty = true
-
- // Viewport
- zoom = 1
- panX = 0
- panY = 0
-
- // Drag suppression — prevents syncFromDocument during drag
- // so the layout engine doesn't override visual positions
- dragSyncSuppressed = false
-
- // Interaction state
- hoveredNodeId: string | null = null
- marquee: { x1: number; y1: number; x2: number; y2: number } | null = null
- previewShape: {
- type: 'rectangle' | 'ellipse' | 'frame' | 'line' | 'polygon'
- x: number; y: number; w: number; h: number
- } | null = null
- penPreview: import('./skia-overlays').PenPreviewData | null = null
-
- constructor(ck: CanvasKit) {
- this.ck = ck
- this.renderer = new SkiaRenderer(ck)
- }
-
- // ---------------------------------------------------------------------------
- // Lifecycle
- // ---------------------------------------------------------------------------
-
- init(canvasEl: HTMLCanvasElement) {
- this.canvasEl = canvasEl
- const dpr = window.devicePixelRatio || 1
- canvasEl.width = canvasEl.clientWidth * dpr
- canvasEl.height = canvasEl.clientHeight * dpr
-
- this.surface = this.ck.MakeWebGLCanvasSurface(canvasEl)
- if (!this.surface) {
- // Fallback to software
- this.surface = this.ck.MakeSWCanvasSurface(canvasEl)
- }
- if (!this.surface) {
- console.error('SkiaEngine: Failed to create surface')
- return
- }
-
- this.renderer.init()
- this.renderer.setRedrawCallback(() => this.markDirty())
- // Re-render when async font loading completes
- ;(this.renderer as any)._onFontLoaded = () => this.markDirty()
- // Pre-load default fonts for vector text rendering.
- // Noto Sans SC is loaded alongside Inter so CJK glyphs are always available
- // in the fallback chain — system CJK fonts (PingFang SC, Microsoft YaHei, etc.)
- // are skipped from Google Fonts, and without Noto Sans SC the fallback chain
- // would only contain Inter which has no CJK coverage, causing tofu.
- this.renderer.fontManager.ensureFont('Inter').then(() => this.markDirty())
- this.renderer.fontManager.ensureFont('Noto Sans SC').then(() => this.markDirty())
- this.startRenderLoop()
- }
-
- dispose() {
- if (this.animFrameId) cancelAnimationFrame(this.animFrameId)
- this.renderer.dispose()
- this.surface?.delete()
- this.surface = null
- }
-
- resize(width: number, height: number) {
- if (!this.canvasEl) return
- const dpr = window.devicePixelRatio || 1
- this.canvasEl.width = width * dpr
- this.canvasEl.height = height * dpr
-
- // Recreate surface
- this.surface?.delete()
- this.surface = this.ck.MakeWebGLCanvasSurface(this.canvasEl)
- if (!this.surface) {
- this.surface = this.ck.MakeSWCanvasSurface(this.canvasEl)
- }
- this.markDirty()
- }
-
- // ---------------------------------------------------------------------------
- // Document sync
- // ---------------------------------------------------------------------------
-
- syncFromDocument() {
- if (this.dragSyncSuppressed) return
- const docState = useDocumentStore.getState()
- const activePageId = useCanvasStore.getState().activePageId
- const pageChildren = getActivePageChildren(docState.document, activePageId)
- const allNodes = getAllChildren(docState.document)
-
- // Simple findNodeInTree
- const findInTree = (nodes: PenNode[], id: string): PenNode | null => {
- for (const n of nodes) {
- if (n.id === id) return n
- if ('children' in n && n.children) {
- const found = findInTree(n.children, id)
- if (found) return found
- }
- }
- return null
- }
-
- // Collect reusable/instance IDs from raw tree (before ref resolution strips them)
- this.reusableIds.clear()
- this.instanceIds.clear()
- collectReusableIds(pageChildren, this.reusableIds)
- collectInstanceIds(pageChildren, this.instanceIds)
-
- // Resolve refs, variables, then flatten
- const resolved = resolveRefs(pageChildren, allNodes, findInTree)
-
- // Resolve design variables
- const variables = docState.document.variables ?? {}
- const themes = docState.document.themes
- const defaultTheme = getDefaultTheme(themes)
- const variableResolved = resolved.map((n) =>
- resolveNodeForCanvas(n, variables, defaultTheme),
- )
-
- // Only premeasure text HEIGHTS for fixed-width text (where wrapping
- // estimation may differ from Canvas 2D). Never touch widths or
- // container-relative sizing to maintain layout consistency with Fabric.js.
- const measured = premeasureTextHeights(variableResolved)
-
- this.renderNodes = flattenToRenderNodes(measured)
-
- this.spatialIndex.rebuild(this.renderNodes)
- this.markDirty()
- }
-
- // ---------------------------------------------------------------------------
- // Render loop
- // ---------------------------------------------------------------------------
-
- markDirty() {
- this.dirty = true
- }
-
- private startRenderLoop() {
- const loop = () => {
- this.animFrameId = requestAnimationFrame(loop)
- if (!this.dirty || !this.surface) return
- this.dirty = false
- this.render()
- }
- this.animFrameId = requestAnimationFrame(loop)
- }
-
- private render() {
- if (!this.surface || !this.canvasEl) return
- const canvas = this.surface.getCanvas()
- const ck = this.ck
-
- const dpr = window.devicePixelRatio || 1
- const selectedIds = new Set(useCanvasStore.getState().selection.selectedIds)
-
- // Clear
- const bgColor = getCanvasBackground()
- canvas.clear(parseColor(ck, bgColor))
-
- // Apply viewport transform
- canvas.save()
- canvas.scale(dpr, dpr)
- canvas.concat(viewportMatrix({ zoom: this.zoom, panX: this.panX, panY: this.panY }))
-
- // Pass current zoom to renderer for zoom-aware text rasterization
- this.renderer.zoom = this.zoom
-
- // Draw all render nodes
- for (const rn of this.renderNodes) {
- this.renderer.drawNode(canvas, rn, selectedIds)
- }
-
- // Draw frame labels (root frames + reusable components + instances at any depth)
- for (const rn of this.renderNodes) {
- if (!rn.node.name) continue
- const isRootFrame = rn.node.type === 'frame' && !rn.clipRect
- const isReusable = this.reusableIds.has(rn.node.id)
- const isInstance = this.instanceIds.has(rn.node.id)
- if (!isRootFrame && !isReusable && !isInstance) continue
- this.renderer.drawFrameLabelColored(
- canvas, rn.node.name, rn.absX, rn.absY,
- isReusable, isInstance, this.zoom,
- )
- }
-
- // Draw agent indicators (glow, badges, node borders, preview fills)
- const agentIndicators = getActiveAgentIndicators()
- const agentFrames = getActiveAgentFrames()
- const hasAgentOverlays = agentIndicators.size > 0 || agentFrames.size > 0
-
- if (!hasAgentOverlays) {
- this.agentAnimStart = 0
- }
-
- if (hasAgentOverlays) {
- const now = Date.now()
- if (this.agentAnimStart === 0) this.agentAnimStart = now
- const elapsed = now - this.agentAnimStart
- // Frame glow: smooth fade-in → fade-out (single bell, ~1.2s)
- const GLOW_DURATION = 1200
- const glowT = Math.min(1, elapsed / GLOW_DURATION)
- const breath = Math.sin(glowT * Math.PI) // 0 → 1 → 0
-
- // Agent node borders and preview fills (per-element fade-in → fade-out)
- const NODE_FADE_DURATION = 1000
- for (const rn of this.renderNodes) {
- const indicator = agentIndicators.get(rn.node.id)
- if (!indicator) continue
- if (!isNodeBorderReady(rn.node.id)) continue
-
- const revealAt = getNodeRevealTime(rn.node.id)
- if (revealAt === undefined) continue
- const nodeElapsed = now - revealAt
- if (nodeElapsed > NODE_FADE_DURATION) continue
-
- // Smooth bell curve: fade in then fade out
- const nodeT = Math.min(1, nodeElapsed / NODE_FADE_DURATION)
- const nodeBreath = Math.sin(nodeT * Math.PI)
-
- if (isPreviewNode(rn.node.id)) {
- this.renderer.drawAgentPreviewFill(
- canvas, rn.absX, rn.absY, rn.absW, rn.absH,
- indicator.color, now,
- )
- }
-
- this.renderer.drawAgentNodeBorder(
- canvas, rn.absX, rn.absY, rn.absW, rn.absH,
- indicator.color, nodeBreath, this.zoom,
- )
- }
-
- // Agent frame glow and badges
- for (const rn of this.renderNodes) {
- const frame = agentFrames.get(rn.node.id)
- if (!frame) continue
-
- this.renderer.drawAgentGlow(
- canvas, rn.absX, rn.absY, rn.absW, rn.absH,
- frame.color, breath, this.zoom,
- )
- this.renderer.drawAgentBadge(
- canvas, frame.name,
- rn.absX, rn.absY, rn.absW,
- frame.color, this.zoom, now,
- )
- }
- }
-
- // Hover outline
- if (this.hoveredNodeId && !selectedIds.has(this.hoveredNodeId)) {
- const hovered = this.spatialIndex.get(this.hoveredNodeId)
- if (hovered) {
- this.renderer.drawHoverOutline(canvas, hovered.absX, hovered.absY, hovered.absW, hovered.absH)
- }
- }
-
- // Arc handles for selected ellipse
- if (selectedIds.size === 1) {
- const selId = selectedIds.values().next().value as string
- const selRN = this.spatialIndex.get(selId)
- if (selRN && selRN.node.type === 'ellipse') {
- const eNode = selRN.node as EllipseNode
- this.renderer.drawArcHandles(
- canvas,
- selRN.absX, selRN.absY, selRN.absW, selRN.absH,
- eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0,
- this.zoom,
- )
- }
- }
-
- // Drawing preview shape
- if (this.previewShape) {
- this.renderer.drawPreview(canvas, this.previewShape)
- }
-
- // Pen tool preview
- if (this.penPreview) {
- this.renderer.drawPenPreview(canvas, this.penPreview, this.zoom)
- }
-
- // Selection marquee
- if (this.marquee) {
- this.renderer.drawSelectionMarquee(
- canvas,
- this.marquee.x1, this.marquee.y1,
- this.marquee.x2, this.marquee.y2,
- )
- }
-
- canvas.restore()
- this.surface.flush()
-
- // Keep animating while agent overlays are active (spinning dot + node flashes)
- if (hasAgentOverlays) {
- this.markDirty()
- }
- }
-
- // ---------------------------------------------------------------------------
- // Viewport control
- // ---------------------------------------------------------------------------
-
- setViewport(zoom: number, panX: number, panY: number) {
- this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
- this.panX = panX
- this.panY = panY
- useCanvasStore.getState().setZoom(this.zoom)
- useCanvasStore.getState().setPan(this.panX, this.panY)
- this.markDirty()
- }
-
- zoomToPoint(screenX: number, screenY: number, newZoom: number) {
- if (!this.canvasEl) return
- const rect = this.canvasEl.getBoundingClientRect()
- const vp = vpZoomToPoint(
- { zoom: this.zoom, panX: this.panX, panY: this.panY },
- screenX, screenY, rect, newZoom,
- )
- this.setViewport(vp.zoom, vp.panX, vp.panY)
- }
-
- pan(dx: number, dy: number) {
- this.setViewport(this.zoom, this.panX + dx, this.panY + dy)
- }
-
- getCanvasRect(): DOMRect | null {
- return this.canvasEl?.getBoundingClientRect() ?? null
- }
-
- getCanvasSize(): { width: number; height: number } {
- return {
- width: this.canvasEl?.clientWidth ?? 800,
- height: this.canvasEl?.clientHeight ?? 600,
- }
- }
-
- zoomToFitContent() {
- if (!this.canvasEl || this.renderNodes.length === 0) return
- const FIT_PADDING = 64
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
- for (const rn of this.renderNodes) {
- if (rn.clipRect) continue // skip children, only root bounds
- minX = Math.min(minX, rn.absX)
- minY = Math.min(minY, rn.absY)
- maxX = Math.max(maxX, rn.absX + rn.absW)
- maxY = Math.max(maxY, rn.absY + rn.absH)
- }
- if (!isFinite(minX)) return
- const contentW = maxX - minX
- const contentH = maxY - minY
- const cw = this.canvasEl.clientWidth
- const ch = this.canvasEl.clientHeight
- const scaleX = (cw - FIT_PADDING * 2) / contentW
- const scaleY = (ch - FIT_PADDING * 2) / contentH
- let zoom = Math.min(scaleX, scaleY, 1)
- zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
- const centerX = (minX + maxX) / 2
- const centerY = (minY + maxY) / 2
- this.setViewport(
- zoom,
- cw / 2 - centerX * zoom,
- ch / 2 - centerY * zoom,
- )
- }
-}
-
diff --git a/src/canvas/skia/skia-renderer.ts b/src/canvas/skia/skia-renderer.ts
deleted file mode 100644
index ccc18090..00000000
--- a/src/canvas/skia/skia-renderer.ts
+++ /dev/null
@@ -1,1631 +0,0 @@
-import type { CanvasKit, Canvas, Paint, Font, Typeface, Image as SkImage, Paragraph } from 'canvaskit-wasm'
-import type { PenNode, ContainerProps, TextNode, EllipseNode, LineNode, PolygonNode, PathNode, ImageNode, IconFontNode } from '@/types/pen'
-import type { PenFill, PenStroke, PenEffect, ShadowEffect, ImageFill } from '@/types/styles'
-import { DEFAULT_FILL, DEFAULT_STROKE, DEFAULT_STROKE_WIDTH } from '../canvas-constants'
-import { defaultLineHeight } from '../canvas-text-measure'
-import { lookupIconByName } from '@/services/ai/icon-resolver'
-import { buildEllipseArcPath, isArcEllipse } from '@/utils/arc-path'
-import { SkiaImageLoader } from './skia-image-loader'
-import { SkiaFontManager } from './skia-font-manager'
-import {
- parseColor,
- cornerRadiusValue,
- cornerRadii,
- resolveFillColor,
- resolveStrokeColor,
- resolveStrokeWidth,
- wrapLine,
- cssFontFamily,
-} from './skia-paint-utils'
-import { sanitizeSvgPath, hasInvalidNumbers, tryManualPathParse } from './skia-path-utils'
-import {
- drawSelectionBorder as _drawSelectionBorder,
- drawFrameLabel as _drawFrameLabel,
- drawFrameLabelColored as _drawFrameLabelColored,
- drawHoverOutline as _drawHoverOutline,
- drawSelectionMarquee as _drawSelectionMarquee,
- drawGuide as _drawGuide,
- drawPenPreview as _drawPenPreview,
- drawAgentGlow as _drawAgentGlow,
- drawAgentBadge as _drawAgentBadge,
- drawAgentNodeBorder as _drawAgentNodeBorder,
- drawAgentPreviewFill as _drawAgentPreviewFill,
- drawArcHandles as _drawArcHandles,
- type PenPreviewData,
-} from './skia-overlays'
-
-export interface RenderNode {
- node: PenNode
- absX: number
- absY: number
- absW: number
- absH: number
- clipRect?: { x: number; y: number; w: number; h: number; rx: number }
-}
-
-export class SkiaRenderer {
- private ck: CanvasKit
- private defaultTypeface: Typeface | null = null
- private defaultFont: Font | null = null
-
- // Text rasterization cache (Canvas 2D → CanvasKit Image)
- private textCache = new Map()
- private textCacheOrder: string[] = []
- private static TEXT_CACHE_MAX = 300
-
- // Paragraph cache for vector text (keyed by content+style, caches Paragraph objects)
- private paraCache = new Map()
- private paraCacheOrder: string[] = []
- private static PARA_CACHE_MAX = 200
-
- // Current viewport zoom (set by engine before each render frame)
- zoom = 1
-
- // Font manager for vector text rendering
- fontManager: SkiaFontManager
-
- // Image loader
- imageLoader: SkiaImageLoader
-
- constructor(ck: CanvasKit) {
- this.ck = ck
- this.imageLoader = new SkiaImageLoader(ck)
- this.fontManager = new SkiaFontManager(ck)
- }
-
- init() {
- this.defaultFont = new this.ck.Font(null, 16)
- }
-
- /** Set callback to trigger re-render when async images finish loading. */
- setRedrawCallback(cb: () => void) {
- this.imageLoader.setOnLoaded(cb)
- }
-
- dispose() {
- this.defaultFont?.delete()
- this.defaultFont = null
- this.defaultTypeface?.delete()
- this.defaultTypeface = null
- this.clearTextCache()
- this.clearParaCache()
- this.fontManager.dispose()
- this.imageLoader.dispose()
- }
-
- clearTextCache() {
- for (const img of this.textCache.values()) {
- img?.delete()
- }
- this.textCache.clear()
- this.textCacheOrder = []
- }
-
- clearParaCache() {
- for (const p of this.paraCache.values()) {
- p?.delete()
- }
- this.paraCache.clear()
- this.paraCacheOrder = []
- }
-
- private evictParaCache() {
- while (this.paraCacheOrder.length > SkiaRenderer.PARA_CACHE_MAX) {
- const key = this.paraCacheOrder.shift()!
- const p = this.paraCache.get(key)
- p?.delete()
- this.paraCache.delete(key)
- }
- }
-
- private evictTextCache() {
- while (this.textCacheOrder.length > SkiaRenderer.TEXT_CACHE_MAX) {
- const key = this.textCacheOrder.shift()!
- const img = this.textCache.get(key)
- img?.delete()
- this.textCache.delete(key)
- }
- }
-
- // Fill paint
-
- private makeFillPaint(
- fills: PenFill[] | string | undefined,
- w: number,
- h: number,
- opacity: number,
- absX: number,
- absY: number,
- ): { paint: Paint; imageFillDraw?: { fill: ImageFill; w: number; h: number; absX: number; absY: number; opacity: number } } {
- const ck = this.ck
- const paint = new ck.Paint()
- paint.setStyle(ck.PaintStyle.Fill)
- paint.setAntiAlias(true)
-
- if (typeof fills === 'string') {
- const c = parseColor(ck, fills)
- c[3] *= opacity
- paint.setColor(c)
- return { paint }
- }
- if (!fills || fills.length === 0) {
- const c = parseColor(ck, DEFAULT_FILL)
- c[3] *= opacity
- paint.setColor(c)
- return { paint }
- }
-
- const first = fills[0]
- if (first.type === 'solid') {
- const c = parseColor(ck, first.color)
- c[3] *= (first.opacity ?? 1) * opacity
- paint.setColor(c)
- } else if (first.type === 'linear_gradient') {
- const stops = first.stops ?? []
- const fillOpacity = (first.opacity ?? 1) * opacity
- if (stops.length >= 2) {
- const angleDeg = first.angle ?? 0
- const rad = ((angleDeg - 90) * Math.PI) / 180
- const cos = Math.cos(rad)
- const sin = Math.sin(rad)
- const x1 = absX + w / 2 - (cos * w) / 2
- const y1 = absY + h / 2 - (sin * h) / 2
- const x2 = absX + w / 2 + (cos * w) / 2
- const y2 = absY + h / 2 + (sin * h) / 2
- const colors = stops.map((s) => {
- const c = parseColor(ck, s.color)
- c[3] *= fillOpacity
- return c
- })
- const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)))
- const shader = ck.Shader.MakeLinearGradient(
- [x1, y1], [x2, y2],
- colors, positions,
- ck.TileMode.Clamp,
- )
- if (shader) paint.setShader(shader)
- } else {
- const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL)
- c[3] *= fillOpacity
- paint.setColor(c)
- }
- } else if (first.type === 'radial_gradient') {
- const stops = first.stops ?? []
- const fillOpacity = (first.opacity ?? 1) * opacity
- if (stops.length >= 2) {
- const cx = absX + (first.cx ?? 0.5) * w
- const cy = absY + (first.cy ?? 0.5) * h
- const r = (first.radius ?? 0.5) * Math.max(w, h)
- const colors = stops.map((s) => {
- const c = parseColor(ck, s.color)
- c[3] *= fillOpacity
- return c
- })
- const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)))
- const shader = ck.Shader.MakeRadialGradient(
- [cx, cy], r,
- colors, positions,
- ck.TileMode.Clamp,
- )
- if (shader) paint.setShader(shader)
- } else {
- const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL)
- c[3] *= fillOpacity
- paint.setColor(c)
- }
- } else if (first.type === 'image') {
- const result = this.applyImageFillToPaint(paint, first, w, h, opacity, absX, absY)
- if (result.needsDrawImageRect && result.fill) {
- return { paint, imageFillDraw: { fill: result.fill, w: result.w!, h: result.h!, absX: result.absX!, absY: result.absY!, opacity: result.opacity! } }
- }
- }
-
- return { paint }
- }
-
- /**
- * Apply an image fill to a Paint object using an image shader.
- * If the image is not yet loaded, a placeholder color is used.
- */
- /**
- * Apply an image fill to a Paint object.
- * For tile mode: uses a shader with TileMode.Repeat.
- * For fill/fit/crop/stretch: sets a placeholder paint and returns
- * draw info so the caller can use drawImageRect (shader scaling
- * is unreliable in CanvasKit for Clamp/Decal tile modes).
- */
- private applyImageFillToPaint(
- paint: Paint,
- fill: ImageFill,
- w: number, h: number,
- opacity: number,
- absX: number, absY: number,
- ): { needsDrawImageRect: boolean; fill?: ImageFill; w?: number; h?: number; absX?: number; absY?: number; opacity?: number } {
- const ck = this.ck
- const fillOpacity = (fill.opacity ?? 1) * opacity
- const url = fill.url
- if (!url) {
- const c = parseColor(ck, '#e5e7eb')
- c[3] *= fillOpacity
- paint.setColor(c)
- return { needsDrawImageRect: false }
- }
-
- const cached = this.imageLoader.get(url)
- if (cached === undefined) {
- this.imageLoader.request(url)
- }
- if (!cached) {
- const c = parseColor(ck, '#e5e7eb')
- c[3] *= fillOpacity
- paint.setColor(c)
- return { needsDrawImageRect: false }
- }
-
- const imgW = cached.width()
- const imgH = cached.height()
- if (imgW <= 0 || imgH <= 0) return { needsDrawImageRect: false }
-
- const mode = fill.mode ?? 'fill'
-
- // Tile mode: use shader (works reliably with Repeat + translation matrix)
- if (mode === 'tile') {
- const dispX = absX + (w - imgW) / 2
- const dispY = absY + (h - imgH) / 2
- const localMatrix = Float32Array.of(
- 1, 0, -dispX,
- 0, 1, -dispY,
- 0, 0, 1,
- )
- const shader = cached.makeShaderOptions(
- ck.TileMode.Repeat, ck.TileMode.Repeat,
- ck.FilterMode.Linear, ck.MipmapMode.None,
- localMatrix,
- )
- if (shader) {
- paint.setShader(shader)
- if (fillOpacity < 1) paint.setAlphaf(fillOpacity)
- const cf = this.buildImageAdjustmentFilter(fill)
- if (cf) paint.setColorFilter(cf)
- }
- return { needsDrawImageRect: false }
- }
-
- // For fill/fit/crop/stretch: use transparent paint, caller draws image via drawImageRect
- paint.setColor(Float32Array.of(0, 0, 0, 0))
- return { needsDrawImageRect: true, fill, w, h, absX, absY, opacity: fillOpacity }
- }
-
- /**
- * Draw an image fill using drawImageRect (for fill/fit/crop/stretch modes).
- * Must be called after clipping to the shape bounds.
- */
- private drawImageFillRect(
- canvas: Canvas,
- fill: ImageFill,
- w: number, h: number,
- absX: number, absY: number,
- fillOpacity: number,
- ) {
- const ck = this.ck
- const url = fill.url
- if (!url) return
-
- const cached = this.imageLoader.get(url)
- if (!cached) return
-
- const imgW = cached.width()
- const imgH = cached.height()
- if (imgW <= 0 || imgH <= 0) return
-
- const mode = fill.mode ?? 'fill'
- const paint = new ck.Paint()
- paint.setAntiAlias(true)
- if (fillOpacity < 1) paint.setAlphaf(fillOpacity)
-
- const adjFilter = this.buildImageAdjustmentFilter(fill)
- if (adjFilter) paint.setColorFilter(adjFilter)
-
- if (mode === 'fit') {
- // Contain: entire image visible, centered, with letterbox
- const scale = Math.min(w / imgW, h / imgH)
- const dw = imgW * scale
- const dh = imgH * scale
- const dx = absX + (w - dw) / 2
- const dy = absY + (h - dh) / 2
- canvas.drawImageRect(
- cached,
- ck.LTRBRect(0, 0, imgW, imgH),
- ck.LTRBRect(dx, dy, dx + dw, dy + dh),
- paint,
- )
- } else if (mode === 'stretch') {
- // Stretch: distort to fill entire area
- canvas.drawImageRect(
- cached,
- ck.LTRBRect(0, 0, imgW, imgH),
- ck.LTRBRect(absX, absY, absX + w, absY + h),
- paint,
- )
- } else {
- // 'fill', 'crop': cover, centered, excess clipped by parent clip
- const scale = Math.max(w / imgW, h / imgH)
- const dw = imgW * scale
- const dh = imgH * scale
- const dx = absX + (w - dw) / 2
- const dy = absY + (h - dh) / 2
- canvas.drawImageRect(
- cached,
- ck.LTRBRect(0, 0, imgW, imgH),
- ck.LTRBRect(dx, dy, dx + dw, dy + dh),
- paint,
- )
- }
-
- paint.delete()
- }
-
- /**
- * Build a CanvasKit ColorFilter from image adjustment values.
- * Builds a single 4x5 color matrix combining all adjustments.
- *
- * Matrix layout (row-major 4×5):
- * R' = m[0]*r + m[1]*g + m[2]*b + m[3]*a + m[4]
- * G' = m[5]*r + m[6]*g + m[7]*b + m[8]*a + m[9]
- * B' = m[10]*r+ m[11]*g+ m[12]*b + m[13]*a+ m[14]
- * A' = m[15]*r+ m[16]*g+ m[17]*b + m[18]*a+ m[19]
- */
- private buildImageAdjustmentFilter(adj: {
- exposure?: number; contrast?: number; saturation?: number
- temperature?: number; tint?: number; highlights?: number; shadows?: number
- }) {
- const ck = this.ck
- const exp = (adj.exposure ?? 0) / 100
- const con = (adj.contrast ?? 0) / 100
- const sat = (adj.saturation ?? 0) / 100
- const temp = (adj.temperature ?? 0) / 100
- const tintVal = (adj.tint ?? 0) / 100
- const hi = (adj.highlights ?? 0) / 100
- const sh = (adj.shadows ?? 0) / 100
-
- if (exp === 0 && con === 0 && sat === 0 && temp === 0 && tintVal === 0 && hi === 0 && sh === 0) {
- return null
- }
-
- // Exposure: brightness multiplier
- const e = 1 + exp * 1.5
-
- // Contrast: scale around 0.5 midpoint
- const c = 1 + con
- const cOff = 0.5 * (1 - c)
-
- // Saturation: luminance-preserving mix
- const s = 1 + sat
- const lr = 0.2126, lg = 0.7152, lb = 0.0722
- const sr = (1 - s) * lr, sg = (1 - s) * lg, sb = (1 - s) * lb
-
- // Combined scale factor for each matrix cell: contrast * exposure * saturation
- // Order: saturate → exposure → contrast
- // saturated_R = (sr+s)*r + sg*g + sb*b
- // exposed_R = e * saturated_R
- // final_R = c * exposed_R + cOff + offsets
- const f = c * e
-
- // Offsets: temperature (warm/cool), tint, highlights, shadows
- const offR = cOff + temp * 0.15 + (hi + sh * 0.5) * 0.1
- const offG = cOff + tintVal * 0.15 + (hi + sh * 0.5) * 0.1
- const offB = cOff - temp * 0.15 + (hi + sh * 0.5) * 0.1
-
- const m = [
- f * (sr + s), f * sg, f * sb, 0, offR,
- f * sr, f * (sg + s), f * sb, 0, offG,
- f * sr, f * sg, f * (sb + s), 0, offB,
- 0, 0, 0, 1, 0,
- ]
-
- return ck.ColorFilter.MakeMatrix(m)
- }
-
- // Stroke paint
-
- private makeStrokePaint(
- stroke: PenStroke | undefined,
- opacity: number,
- ): Paint | null {
- if (!stroke) return null
- const strokeColor = resolveStrokeColor(stroke)
- const strokeWidth = resolveStrokeWidth(stroke)
- if (!strokeColor || strokeWidth <= 0) return null
-
- const ck = this.ck
- const paint = new ck.Paint()
- paint.setStyle(ck.PaintStyle.Stroke)
- paint.setAntiAlias(true)
- paint.setStrokeWidth(strokeWidth)
-
- const c = parseColor(ck, strokeColor)
- c[3] *= opacity
- paint.setColor(c)
-
- if (stroke.join === 'round') paint.setStrokeJoin(ck.StrokeJoin.Round)
- else if (stroke.join === 'bevel') paint.setStrokeJoin(ck.StrokeJoin.Bevel)
-
- if (stroke.cap === 'round') paint.setStrokeCap(ck.StrokeCap.Round)
- else if (stroke.cap === 'square') paint.setStrokeCap(ck.StrokeCap.Square)
-
- if (stroke.dashPattern && stroke.dashPattern.length >= 2) {
- const effect = ck.PathEffect.MakeDash(stroke.dashPattern, 0)
- if (effect) paint.setPathEffect(effect)
- }
-
- return paint
- }
-
- // Shadow / blur
-
- // applyShadowDirect is used instead of saveLayer approach
-
- // Draw a single render node
-
- drawNode(canvas: Canvas, rn: RenderNode, selectedIds: Set) {
- const { node, absX, absY, absW, absH, clipRect } = rn
- const ck = this.ck
- const opacity = typeof node.opacity === 'number' ? node.opacity : 1
-
- if (('visible' in node ? node.visible : undefined) === false) return
-
- // Apply clipping from parent frame
- let clipped = false
- if (clipRect) {
- canvas.save()
- clipped = true
- const radii = clipRect.rx
- if (radii > 0) {
- const rrect = ck.RRectXY(
- ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h),
- radii, radii,
- )
- canvas.clipRRect(rrect, ck.ClipOp.Intersect, true)
- } else {
- canvas.clipRect(
- ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h),
- ck.ClipOp.Intersect,
- true,
- )
- }
- }
-
- // Apply flip (flipX / flipY from Figma import)
- const flipX = node.flipX === true
- const flipY = node.flipY === true
- if (flipX || flipY) {
- canvas.save()
- canvas.translate(absX + absW / 2, absY + absH / 2)
- canvas.scale(flipX ? -1 : 1, flipY ? -1 : 1)
- canvas.translate(-(absX + absW / 2), -(absY + absH / 2))
- }
-
- // Apply rotation
- const rotation = node.rotation ?? 0
- if (rotation !== 0) {
- canvas.save()
- canvas.rotate(rotation, absX + absW / 2, absY + absH / 2)
- }
-
- // Apply shadow (text uses glyph-shaped shadow, not rectangle)
- const effects = 'effects' in node ? (node as PenNode & { effects?: PenEffect[] }).effects : undefined
- if (node.type !== 'text') {
- this.applyShadowDirect(canvas, effects, absX, absY, absW, absH)
- }
-
- switch (node.type) {
- case 'frame':
- case 'rectangle':
- case 'group':
- this.drawRect(canvas, node, absX, absY, absW, absH, opacity)
- break
- case 'ellipse':
- this.drawEllipse(canvas, node, absX, absY, absW, absH, opacity)
- break
- case 'line':
- this.drawLine(canvas, node, absX, absY, opacity)
- break
- case 'polygon':
- this.drawPolygon(canvas, node, absX, absY, absW, absH, opacity)
- break
- case 'path':
- this.drawPath(canvas, node, absX, absY, absW, absH, opacity, clipRect)
- break
- case 'icon_font':
- this.drawIconFont(canvas, node, absX, absY, absW, absH, opacity)
- break
- case 'text':
- this.drawText(canvas, node, absX, absY, absW, absH, opacity, effects)
- break
- case 'image':
- this.drawImage(canvas, node, absX, absY, absW, absH, opacity)
- break
- }
-
- // Selection highlight
- if (selectedIds.has(node.id)) {
- this.drawSelectionBorder(canvas, absX, absY, absW, absH)
- }
-
- if (rotation !== 0) canvas.restore()
- if (flipX || flipY) canvas.restore()
- if (clipped) canvas.restore()
- }
-
- // Shadow (direct, not saveLayer)
-
- private applyShadowDirect(
- canvas: Canvas,
- effects: PenEffect[] | undefined,
- x: number, y: number, w: number, h: number,
- ): boolean {
- if (!effects) return false
- const shadow = effects.find((e): e is ShadowEffect => e.type === 'shadow')
- if (!shadow) return false
-
- const ck = this.ck
- const paint = new ck.Paint()
- paint.setStyle(ck.PaintStyle.Fill)
- paint.setAntiAlias(true)
- const c = parseColor(ck, shadow.color)
- paint.setColor(c)
- const filter = ck.MaskFilter.MakeBlur(ck.BlurStyle.Normal, shadow.blur / 2, true)
- paint.setMaskFilter(filter)
- canvas.drawRect(
- ck.LTRBRect(
- x + shadow.offsetX - shadow.spread,
- y + shadow.offsetY - shadow.spread,
- x + w + shadow.offsetX + shadow.spread,
- y + h + shadow.offsetY + shadow.spread,
- ),
- paint,
- )
- paint.delete()
- return true
- }
-
- // Shape drawing
-
- private drawRect(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, h: number,
- opacity: number,
- ) {
- const ck = this.ck
- const container = node as PenNode & ContainerProps
- const cr = cornerRadii(container.cornerRadius)
- const fills = container.fill
- const stroke = container.stroke
-
- // For frames/groups without explicit fill, use transparent (no visible background)
- const hasFill = fills && fills.length > 0
- const isContainer = node.type === 'frame' || node.type === 'group'
-
- // Fill
- const { paint: fillPaint, imageFillDraw } = this.makeFillPaint(
- hasFill ? fills : (isContainer ? 'transparent' : undefined),
- w, h, opacity, x, y,
- )
-
- const hasRoundedCorners = cr.some((r) => r > 0)
- if (hasRoundedCorners) {
- const maxR = Math.min(w / 2, h / 2)
- const rrect = ck.RRectXY(
- ck.LTRBRect(x, y, x + w, y + h),
- Math.min(cr[0], maxR), Math.min(cr[0], maxR),
- )
- canvas.drawRRect(rrect, fillPaint)
- } else {
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
- }
- fillPaint.delete()
-
- // Image fill (fill/fit/crop/stretch): draw via drawImageRect with clipping
- if (imageFillDraw) {
- canvas.save()
- if (hasRoundedCorners) {
- const maxR = Math.min(w / 2, h / 2)
- canvas.clipRRect(
- ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
- ck.ClipOp.Intersect, true,
- )
- } else {
- canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true)
- }
- this.drawImageFillRect(canvas, imageFillDraw.fill, imageFillDraw.w, imageFillDraw.h, imageFillDraw.absX, imageFillDraw.absY, imageFillDraw.opacity)
- canvas.restore()
- }
-
- // Stroke
- const strokePaint = this.makeStrokePaint(stroke, opacity)
- if (strokePaint) {
- if (hasRoundedCorners) {
- const maxR = Math.min(w / 2, h / 2)
- const rrect = ck.RRectXY(
- ck.LTRBRect(x, y, x + w, y + h),
- Math.min(cr[0], maxR), Math.min(cr[0], maxR),
- )
- canvas.drawRRect(rrect, strokePaint)
- } else {
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), strokePaint)
- }
- strokePaint.delete()
- }
- }
-
- private drawEllipse(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, h: number,
- opacity: number,
- ) {
- const ck = this.ck
- const eNode = node as EllipseNode
- const fills = eNode.fill
- const stroke = eNode.stroke
- const cr = cornerRadiusValue(eNode.cornerRadius)
-
- if (isArcEllipse(eNode.startAngle, eNode.sweepAngle, eNode.innerRadius)) {
- const arcD = buildEllipseArcPath(w, h, eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0)
- const path = ck.Path.MakeFromSVGString(arcD)
- if (path) {
- path.offset(x, y)
- const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
- fillPaint.setAntiAlias(true)
- if (cr > 0) {
- const effect = ck.PathEffect.MakeCorner(cr)
- if (effect) fillPaint.setPathEffect(effect)
- }
- canvas.drawPath(path, fillPaint)
- fillPaint.delete()
-
- const strokePaint = this.makeStrokePaint(stroke, opacity)
- if (strokePaint) {
- if (cr > 0) {
- const effect = ck.PathEffect.MakeCorner(cr)
- if (effect) strokePaint.setPathEffect(effect)
- }
- canvas.drawPath(path, strokePaint)
- strokePaint.delete()
- }
- path.delete()
- }
- return
- }
-
- const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
- canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
- fillPaint.delete()
-
- const strokePaint = this.makeStrokePaint(stroke, opacity)
- if (strokePaint) {
- canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), strokePaint)
- strokePaint.delete()
- }
- }
-
- private drawLine(
- canvas: Canvas, node: PenNode,
- x: number, y: number,
- opacity: number,
- ) {
- const ck = this.ck
- const lNode = node as LineNode
- const x2 = lNode.x2 ?? x + 100
- const y2 = lNode.y2 ?? y
- const strokeColor = resolveStrokeColor(lNode.stroke) ?? DEFAULT_STROKE
- const strokeWidth = resolveStrokeWidth(lNode.stroke) || DEFAULT_STROKE_WIDTH
-
- const paint = new ck.Paint()
- paint.setStyle(ck.PaintStyle.Stroke)
- paint.setAntiAlias(true)
- paint.setStrokeWidth(strokeWidth)
- const c = parseColor(ck, strokeColor)
- c[3] *= opacity
- paint.setColor(c)
-
- canvas.drawLine(x, y, x2, y2, paint)
- paint.delete()
- }
-
- private drawPolygon(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, h: number,
- opacity: number,
- ) {
- const ck = this.ck
- const pNode = node as PolygonNode
- const count = pNode.polygonCount || 6
- const fills = pNode.fill
- const stroke = pNode.stroke
- const cr = cornerRadiusValue(pNode.cornerRadius)
-
- // Compute unit polygon vertices, then scale to fill bounding box
- const raw: [number, number][] = []
- for (let i = 0; i < count; i++) {
- const angle = (i * 2 * Math.PI) / count - Math.PI / 2
- raw.push([Math.cos(angle), Math.sin(angle)])
- }
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
- for (const [rx, ry] of raw) {
- if (rx < minX) minX = rx
- if (rx > maxX) maxX = rx
- if (ry < minY) minY = ry
- if (ry > maxY) maxY = ry
- }
- const rawW = maxX - minX
- const rawH = maxY - minY
-
- const path = new ck.Path()
- for (let i = 0; i < count; i++) {
- const px = x + ((raw[i][0] - minX) / rawW) * w
- const py = y + ((raw[i][1] - minY) / rawH) * h
- if (i === 0) path.moveTo(px, py)
- else path.lineTo(px, py)
- }
- path.close()
-
- const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
- if (cr > 0) {
- const effect = ck.PathEffect.MakeCorner(cr)
- if (effect) fillPaint.setPathEffect(effect)
- }
- canvas.drawPath(path, fillPaint)
- fillPaint.delete()
-
- const strokePaint = this.makeStrokePaint(stroke, opacity)
- if (strokePaint) {
- if (cr > 0) {
- const effect = ck.PathEffect.MakeCorner(cr)
- if (effect) strokePaint.setPathEffect(effect)
- }
- canvas.drawPath(path, strokePaint)
- strokePaint.delete()
- }
- path.delete()
- }
-
- private drawPath(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, h: number,
- opacity: number,
- _clipRect?: { x: number; y: number; w: number; h: number; rx: number },
- ) {
- const ck = this.ck
- const pNode = node as PathNode
- const rawD = typeof pNode.d === 'string' && pNode.d.trim().length > 0 ? pNode.d : 'M0 0 L0 0'
- const fills = pNode.fill
- const stroke = pNode.stroke
-
- // If path contains NaN/Infinity (e.g. corrupted Figma binary data),
- // go straight to manual parser which skips invalid commands (like Canvas 2D does).
- let path: ReturnType = null
- if (hasInvalidNumbers(rawD)) {
- path = tryManualPathParse(ck, rawD)
- } else {
- // Sanitize and try CanvasKit's native parser first
- const d = sanitizeSvgPath(rawD)
- path = ck.Path.MakeFromSVGString(d)
- if (!path && d !== rawD) {
- path = ck.Path.MakeFromSVGString(rawD)
- }
- if (!path) {
- path = tryManualPathParse(ck, rawD)
- }
- }
- if (!path) {
- // Render fallback with the node's fill color (not debug red)
- if (w > 0 && h > 0) {
- const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
- fillPaint.delete()
- }
- return
- }
-
- // Get native bounds and scale to target size
- const bounds = path.getBounds()
- const nativeW = bounds[2] - bounds[0]
- const nativeH = bounds[3] - bounds[1]
-
- if (w > 0 && h > 0 && nativeW > 0.01 && nativeH > 0.01) {
- // Icons (with iconId): uniform scaling to preserve aspect ratio.
- // All other paths: non-uniform scaling to fill target bounding box exactly.
- // Figma vector paths may have unscaled normalized coordinates that need
- // different X/Y scaling to match the node's target width/height.
- const isIcon = !!pNode.iconId
- const sx = isIcon ? Math.min(w / nativeW, h / nativeH) : w / nativeW
- const sy = isIcon ? sx : h / nativeH
- const matrix = ck.Matrix.multiply(
- ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy),
- ck.Matrix.scaled(sx, sy),
- )
- path.transform(matrix)
- } else if (nativeW > 0.01 || nativeH > 0.01) {
- // Degenerate path (one dimension is ~0, e.g. horizontal/vertical line)
- const sx = nativeW > 0.01 && w > 0 ? w / nativeW : 1
- const sy = nativeH > 0.01 && h > 0 ? h / nativeH : 1
- const matrix = ck.Matrix.multiply(
- ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy),
- ck.Matrix.scaled(sx, sy),
- )
- path.transform(matrix)
- } else {
- path.offset(x, y)
- }
-
- const hasExplicitFill = fills && fills.length > 0
- const strokeColor = resolveStrokeColor(stroke)
- const strokeWidth = resolveStrokeWidth(stroke)
- const hasVisibleStroke = strokeWidth > 0 && !!strokeColor
-
- // Fill — use EvenOdd for compound paths (multiple sub-paths), Winding for simple paths
- if (hasExplicitFill || !hasVisibleStroke) {
- const { paint: fillPaint } = this.makeFillPaint(
- hasExplicitFill ? fills : undefined,
- w, h, opacity, x, y,
- )
- // Detect compound paths: more than one close command indicates multiple sub-paths
- const closeCount = (rawD.match(/Z/gi) || []).length
- const isCompound = closeCount > 1
- path.setFillType(isCompound ? ck.FillType.EvenOdd : ck.FillType.Winding)
- canvas.drawPath(path, fillPaint)
- fillPaint.delete()
- }
-
- // Stroke
- if (hasVisibleStroke) {
- const strokePaint = this.makeStrokePaint(stroke, opacity)
- if (strokePaint) {
- canvas.drawPath(path, strokePaint)
- strokePaint.delete()
- }
- }
-
- path.delete()
- }
-
- private drawIconFont(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, h: number,
- opacity: number,
- ) {
- const ck = this.ck
- const iNode = node as IconFontNode
- const iconName = iNode.iconFontName ?? iNode.name ?? ''
- const iconMatch = lookupIconByName(iconName)
- const iconD = iconMatch?.d ?? 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0'
- const iconStyle = iconMatch?.style ?? 'stroke'
-
- const rawFill = iNode.fill
- const iconFillColor = typeof rawFill === 'string'
- ? rawFill
- : Array.isArray(iNode.fill) && iNode.fill.length > 0
- ? resolveFillColor(iNode.fill)
- : '#64748B'
-
- // Sanitize path data and try multiple parse strategies (same as drawPath)
- const sanitizedIconD = sanitizeSvgPath(iconD)
- let path = ck.Path.MakeFromSVGString(sanitizedIconD)
- if (!path && sanitizedIconD !== iconD) {
- path = ck.Path.MakeFromSVGString(iconD)
- }
- if (!path) {
- path = tryManualPathParse(ck, iconD)
- }
- if (!path) return
-
- const bounds = path.getBounds()
- const nativeW = bounds[2] - bounds[0]
- const nativeH = bounds[3] - bounds[1]
- if (w > 0 && h > 0 && nativeW > 0 && nativeH > 0) {
- const uniformScale = Math.min(w / nativeW, h / nativeH)
- const matrix = ck.Matrix.multiply(
- ck.Matrix.translated(x - bounds[0] * uniformScale, y - bounds[1] * uniformScale),
- ck.Matrix.scaled(uniformScale, uniformScale),
- )
- path.transform(matrix)
- } else {
- path.offset(x, y)
- }
-
- if (iconStyle === 'stroke') {
- const paint = new ck.Paint()
- paint.setStyle(ck.PaintStyle.Stroke)
- paint.setAntiAlias(true)
- paint.setStrokeWidth(2)
- paint.setStrokeCap(ck.StrokeCap.Round)
- paint.setStrokeJoin(ck.StrokeJoin.Round)
- const c = parseColor(ck, iconFillColor)
- c[3] *= opacity
- paint.setColor(c)
- canvas.drawPath(path, paint)
- paint.delete()
- } else {
- const paint = new ck.Paint()
- paint.setStyle(ck.PaintStyle.Fill)
- paint.setAntiAlias(true)
- const c = parseColor(ck, iconFillColor)
- c[3] *= opacity
- paint.setColor(c)
- path.setFillType(ck.FillType.EvenOdd)
- canvas.drawPath(path, paint)
- paint.delete()
- }
-
- path.delete()
- }
-
- /**
- * Render text as true vector glyphs using CanvasKit's Paragraph API.
- * Returns true if rendered, false if font not available (caller should fallback).
- */
- private drawTextVector(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, _h: number,
- opacity: number,
- ): boolean {
- const ck = this.ck
- const tNode = node as TextNode
- const content = typeof tNode.content === 'string'
- ? tNode.content
- : Array.isArray(tNode.content)
- ? tNode.content.map((s) => s.text ?? '').join('')
- : ''
- if (!content) return true
-
- const fontSize = tNode.fontSize ?? 16
- const fillColor = resolveFillColor(tNode.fill)
- const fontWeight = tNode.fontWeight ?? '400'
- const fontFamily = tNode.fontFamily ?? 'Inter'
- const textAlign: string = tNode.textAlign ?? 'left'
- const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
- const textGrowth = tNode.textGrowth
- const letterSpacing = tNode.letterSpacing ?? 0
-
- // Check if primary font family is loaded; if not, try async load
- const primaryFamily = fontFamily.split(',')[0].trim().replace(/['"]/g, '')
- if (!this.fontManager.isFontReady(primaryFamily)) {
- // System fonts can't be loaded into CanvasKit — use bitmap rendering
- // which supports all OS-installed fonts via Canvas 2D API
- if (this.fontManager.isSystemFont(primaryFamily)) {
- return false
- }
- this.fontManager.ensureFont(primaryFamily).then((ok) => {
- if (ok) {
- this.clearParaCache()
- ;(this as any)._onFontLoaded?.()
- }
- })
- // If no fallback font is available, fall back to bitmap rendering
- if (!this.fontManager.hasAnyFallback(primaryFamily)) {
- return false
- }
- }
-
- // Fixed-width text uses node width for wrapping; paragraph handles alignment.
- // Auto-width text uses unbounded layout (no wrapping); alignment is handled
- // by manually offsetting the draw position after layout.
- const isFixedWidth = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
- // Add tolerance for fixed-width text to prevent unwanted wrapping from
- // font metric differences between design tools (Figma) and CanvasKit/Skia.
- // Use 5% of width capped at half fontSize to avoid affecting intentional wrapping.
- const fwTolerance = isFixedWidth ? Math.min(Math.ceil(w * 0.05), Math.ceil(fontSize * 0.5)) : 0
- const layoutWidth = isFixedWidth && w > 0 ? w + fwTolerance : 1e6
- // For auto-width text, force LEFT alignment in the paragraph to prevent
- // centering within the 1e6 layout width. We manually offset x when drawing.
- const effectiveAlign = isFixedWidth ? textAlign : 'left'
-
- // Cache key for paragraph object
- const cacheKey = `p|${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${effectiveAlign}|${Math.round(layoutWidth)}|${letterSpacing}|${lineHeightMul}`
-
- let para = this.paraCache.get(cacheKey)
- if (para === undefined) {
- const color = parseColor(ck, fillColor)
-
- // Map text alignment
- let ckAlign = ck.TextAlign.Left
- if (effectiveAlign === 'center') ckAlign = ck.TextAlign.Center
- else if (effectiveAlign === 'right') ckAlign = ck.TextAlign.Right
- else if (effectiveAlign === 'justify') ckAlign = ck.TextAlign.Justify
-
- // Map font weight
- const weightNum = typeof fontWeight === 'number' ? fontWeight : parseInt(fontWeight as string, 10) || 400
- let ckWeight = ck.FontWeight.Normal
- if (weightNum <= 100) ckWeight = ck.FontWeight.Thin
- else if (weightNum <= 200) ckWeight = ck.FontWeight.ExtraLight
- else if (weightNum <= 300) ckWeight = ck.FontWeight.Light
- else if (weightNum <= 400) ckWeight = ck.FontWeight.Normal
- else if (weightNum <= 500) ckWeight = ck.FontWeight.Medium
- else if (weightNum <= 600) ckWeight = ck.FontWeight.SemiBold
- else if (weightNum <= 700) ckWeight = ck.FontWeight.Bold
- else if (weightNum <= 800) ckWeight = ck.FontWeight.ExtraBold
- else ckWeight = ck.FontWeight.Black
-
- // Build font fallback chain: primary font → Inter (has latin-ext for ₦ etc.)
- const fallbackFamilies = this.fontManager.getFallbackChain(primaryFamily)
-
- const paraStyle = new ck.ParagraphStyle({
- textAlign: ckAlign,
- textStyle: {
- color,
- fontSize,
- fontFamilies: fallbackFamilies,
- fontStyle: { weight: ckWeight },
- letterSpacing,
- heightMultiplier: lineHeightMul,
- halfLeading: true,
- },
- })
-
- try {
- const builder = ck.ParagraphBuilder.MakeFromFontProvider(
- paraStyle,
- this.fontManager.getProvider(),
- )
-
- // Handle styled segments
- if (Array.isArray(tNode.content) && tNode.content.some(s => s.fontFamily || s.fontSize || s.fontWeight || s.fill)) {
- for (const seg of tNode.content) {
- if (seg.fontFamily || seg.fontSize || seg.fontWeight || seg.fill) {
- const segColor = seg.fill ? parseColor(ck, seg.fill) : color
- const segWeight = seg.fontWeight
- ? (typeof seg.fontWeight === 'number' ? seg.fontWeight : parseInt(seg.fontWeight as string, 10) || weightNum)
- : weightNum
- const segPrimary = seg.fontFamily?.split(',')[0].trim().replace(/['"]/g, '') ?? primaryFamily
- builder.pushStyle(new ck.TextStyle({
- color: segColor,
- fontSize: seg.fontSize ?? fontSize,
- fontFamilies: this.fontManager.getFallbackChain(segPrimary),
- fontStyle: { weight: segWeight as any },
- letterSpacing,
- heightMultiplier: lineHeightMul,
- halfLeading: true,
- }))
- builder.addText(seg.text ?? '')
- builder.pop()
- } else {
- builder.addText(seg.text ?? '')
- }
- }
- } else {
- builder.addText(content)
- }
-
- para = builder.build()
- para.layout(layoutWidth)
- builder.delete()
- } catch {
- para = null
- }
-
- this.paraCache.set(cacheKey, para ?? null)
- this.paraCacheOrder.push(cacheKey)
- this.evictParaCache()
- }
-
- if (!para) return false
-
- // For auto-width text with non-left alignment, manually offset draw position
- // (the paragraph uses LEFT alignment to avoid centering in infinite space)
- let drawX = x
- if (!isFixedWidth && w > 0 && textAlign !== 'left') {
- const longestLine = para.getLongestLine()
- if (textAlign === 'center') drawX = x + Math.max(0, (w - longestLine) / 2)
- else if (textAlign === 'right') drawX = x + Math.max(0, w - longestLine)
- }
-
- if (opacity < 1) {
- const paint = new ck.Paint()
- paint.setAlphaf(opacity)
- canvas.saveLayer(paint)
- paint.delete()
- canvas.drawParagraph(para, drawX, y)
- canvas.restore()
- } else {
- canvas.drawParagraph(para, drawX, y)
- }
-
- return true
- }
-
- /**
- * Draw text shadow as a blurred copy of the actual text glyphs,
- * matching Figma's drop-shadow behavior (shadow follows glyph outlines).
- */
- private drawTextShadow(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, h: number,
- opacity: number,
- shadow: ShadowEffect,
- ) {
- const ck = this.ck
- const tNode = node as TextNode
-
- // Create a shadow-colored version of the text node
- const shadowFillColor = shadow.color ?? '#00000066'
- const shadowNode = {
- ...tNode,
- fill: [{ type: 'solid' as const, color: shadowFillColor }],
- } as PenNode
-
- const sx = x + shadow.offsetX
- const sy = y + shadow.offsetY
-
- if (shadow.blur > 0) {
- // Use saveLayer with blur ImageFilter to blur the text glyphs
- const paint = new ck.Paint()
- if (opacity < 1) paint.setAlphaf(opacity)
- const sigma = shadow.blur / 2
- const filter = ck.ImageFilter.MakeBlur(sigma, sigma, ck.TileMode.Decal, null)
- paint.setImageFilter(filter)
- canvas.saveLayer(paint)
- paint.delete()
-
- // Draw shadow text (vector path first, then bitmap fallback)
- const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, 1)
- if (!vectorOk) {
- this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, 1)
- }
-
- canvas.restore()
- } else {
- // No blur — just draw offset text with shadow color
- const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, opacity)
- if (!vectorOk) {
- this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, opacity)
- }
- }
- }
-
- /**
- * Render text using browser Canvas 2D API (supports all system fonts including CJK),
- * then draw the rasterized result as a CanvasKit image. Results are cached.
- */
- private drawText(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, h: number,
- opacity: number,
- effects?: PenEffect[],
- ) {
- // Draw text shadow as blurred copy of the text glyphs (not a rectangle)
- const shadow = effects?.find((e): e is ShadowEffect => e.type === 'shadow')
- if (shadow) {
- this.drawTextShadow(canvas, node, x, y, w, h, opacity, shadow)
- }
-
- // Try vector text first (true Skia Paragraph API — no pixelation at any zoom)
- const vectorOk = this.drawTextVector(canvas, node, x, y, w, h, opacity)
- if (vectorOk) return
-
- // Fallback to bitmap text rendering
- this.drawTextBitmap(canvas, node, x, y, w, h, opacity)
- }
-
- /** Bitmap text rendering fallback — supports all system fonts via Canvas 2D API. */
- private drawTextBitmap(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, h: number,
- opacity: number,
- ) {
- const ck = this.ck
- const tNode = node as TextNode
- const content = typeof tNode.content === 'string'
- ? tNode.content
- : Array.isArray(tNode.content)
- ? tNode.content.map((s) => s.text ?? '').join('')
- : ''
-
- if (!content) return
-
- const fontSize = tNode.fontSize ?? 16
- const fillColor = resolveFillColor(tNode.fill)
- const fontWeight = tNode.fontWeight ?? '400'
- const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
- const textAlign: string = tNode.textAlign ?? 'left'
- const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
- const lineHeight = lineHeightMul * fontSize
- const textGrowth = tNode.textGrowth
-
- // Match Fabric.js wrapping logic (isFixedWidthText in canvas-object-factory):
- // Only wrap when textGrowth is explicitly 'fixed-width'/'fixed-width-height',
- // or textAlign is non-left AND textGrowth isn't explicitly 'auto'.
- // textGrowth='auto' means auto-width (no wrapping) regardless of textAlign,
- // since for auto-sized text centering is a no-op anyway.
- const isFixedWidth = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
- || (textGrowth !== 'auto' && textAlign !== 'left' && textAlign !== undefined)
- const shouldWrap = isFixedWidth && w > 0
-
- // Set up measurement context
- const measureCanvas = document.createElement('canvas')
- const mCtx = measureCanvas.getContext('2d')!
- mCtx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
-
- const rawLines = content.split('\n')
- let wrappedLines: string[]
- let renderW: number
-
- if (shouldWrap) {
- // Fixed-width text: wrap with tolerance for font metric differences
- renderW = Math.max(w + fontSize * 0.2, 10)
- wrappedLines = []
- for (const raw of rawLines) {
- if (!raw) { wrappedLines.push(''); continue }
- wrapLine(mCtx, raw, renderW, wrappedLines)
- }
- } else {
- // Auto-sized text: don't wrap, measure actual width from Canvas 2D
- wrappedLines = rawLines.length > 0 ? rawLines : ['']
- let maxLineWidth = 0
- for (const line of wrappedLines) {
- if (line) maxLineWidth = Math.max(maxLineWidth, mCtx.measureText(line).width)
- }
- renderW = Math.max(maxLineWidth + 2, w, 10)
- }
-
- // Match Fabric.js: _fontSizeMult = 1.13 for the base glyph height.
- // lineHeight only adds spacing BETWEEN lines, not below the last line.
- const FABRIC_FONT_MULT = 1.13
- const glyphH = fontSize * FABRIC_FONT_MULT
- const textH = Math.max(h,
- wrappedLines.length <= 1
- ? glyphH + 2
- : (wrappedLines.length - 1) * lineHeight + glyphH + 2,
- )
-
- // Zoom-aware rasterization scale: quantized to 2/4/8 for cache efficiency.
- // At zoom ≤ 1 with 2× DPR → scale 2 (1:1 pixel mapping on Retina).
- // Higher zoom → 4 or 8 so text remains sharp when zoomed in.
- const dpr = window.devicePixelRatio || 1
- const rawScale = this.zoom * dpr
- const scale = rawScale <= 2 ? 2 : rawScale <= 4 ? 4 : 8
-
- // Cache key — includes rasterization scale so zoom changes use fresh textures
- const cacheKey = `${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${textAlign}|${Math.round(renderW)}|${Math.round(textH)}|${scale}`
-
- let img = this.textCache.get(cacheKey)
- if (img === undefined) {
- let effectiveScale = scale
- let cw = Math.ceil(renderW * effectiveScale)
- let ch = Math.ceil(textH * effectiveScale)
- if (cw <= 0 || ch <= 0) { this.textCache.set(cacheKey, null); return }
- // Cap texture dimensions to avoid exceeding browser canvas limits
- const MAX_TEX = 4096
- if (cw > MAX_TEX || ch > MAX_TEX) {
- effectiveScale = Math.min(MAX_TEX / renderW, MAX_TEX / textH, effectiveScale)
- cw = Math.ceil(renderW * effectiveScale)
- ch = Math.ceil(textH * effectiveScale)
- }
-
- const tmp = document.createElement('canvas')
- tmp.width = cw
- tmp.height = ch
- const ctx = tmp.getContext('2d')!
- ctx.scale(effectiveScale, effectiveScale)
- ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
- ctx.fillStyle = fillColor
- ctx.textBaseline = 'top'
- ctx.textAlign = (textAlign || 'left') as CanvasTextAlign
-
- let cy = 0
- for (const line of wrappedLines) {
- if (!line) { cy += lineHeight; continue }
- let tx = 0
- if (textAlign === 'center') tx = renderW / 2
- else if (textAlign === 'right') tx = renderW
- ctx.fillText(line, tx, cy)
- cy += lineHeight
- }
-
- const imageData = ctx.getImageData(0, 0, cw, ch)
- // Premultiply alpha for correct CanvasKit texture blending.
- // Canvas 2D getImageData returns unpremultiplied RGBA, but CanvasKit's
- // WebGL backend handles Premul textures more reliably than Unpremul,
- // avoiding gray-background artifacts on transparent text images.
- const premul = new Uint8Array(imageData.data.length)
- for (let p = 0; p < premul.length; p += 4) {
- const a = imageData.data[p + 3]
- if (a === 255) {
- premul[p] = imageData.data[p]
- premul[p + 1] = imageData.data[p + 1]
- premul[p + 2] = imageData.data[p + 2]
- premul[p + 3] = 255
- } else if (a > 0) {
- const f = a / 255
- premul[p] = Math.round(imageData.data[p] * f)
- premul[p + 1] = Math.round(imageData.data[p + 1] * f)
- premul[p + 2] = Math.round(imageData.data[p + 2] * f)
- premul[p + 3] = a
- }
- // a === 0 → all zeros (already initialized)
- }
- img = ck.MakeImage(
- { width: cw, height: ch, alphaType: ck.AlphaType.Premul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB },
- premul, cw * 4,
- ) ?? null
-
- this.textCache.set(cacheKey, img)
- this.textCacheOrder.push(cacheKey)
- this.evictTextCache()
- }
-
- if (!img) return
-
- const paint = new ck.Paint()
- paint.setAntiAlias(true)
- if (opacity < 1) paint.setAlphaf(opacity)
- canvas.drawImageRect(
- img,
- ck.LTRBRect(0, 0, img.width(), img.height()),
- ck.LTRBRect(x, y, x + renderW, y + textH),
- paint,
- )
- paint.delete()
- }
-
- private drawImage(
- canvas: Canvas, node: PenNode,
- x: number, y: number, w: number, h: number,
- opacity: number,
- ) {
- const ck = this.ck
- const iNode = node as ImageNode
- const src: string | undefined = iNode.src
- const cr = cornerRadiusValue(iNode.cornerRadius)
-
- if (!src) {
- this.drawImageFallback(canvas, x, y, w, h, cr, opacity)
- return
- }
-
- // Check cache / start loading
- const cached = this.imageLoader.get(src)
- if (cached === undefined) {
- // Not yet requested — start loading
- this.imageLoader.request(src)
- this.drawImageFallback(canvas, x, y, w, h, cr, opacity)
- return
- }
- if (!cached) {
- // Still loading or failed
- this.drawImageFallback(canvas, x, y, w, h, cr, opacity)
- return
- }
-
- // Draw loaded image with objectFit and optional corner radius clipping
- const imgW = cached.width()
- const imgH = cached.height()
-
- // Clip for corner radius
- if (cr > 0) {
- canvas.save()
- const maxR = Math.min(cr, w / 2, h / 2)
- canvas.clipRRect(
- ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR),
- ck.ClipOp.Intersect, true,
- )
- } else {
- canvas.save()
- canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true)
- }
-
- const paint = new ck.Paint()
- paint.setAntiAlias(true)
- if (opacity < 1) paint.setAlphaf(opacity)
-
- // Apply image adjustments if any
- const adjFilter = this.buildImageAdjustmentFilter(iNode)
- if (adjFilter) paint.setColorFilter(adjFilter)
-
- const fit = iNode.objectFit ?? 'fill'
-
- if (fit === 'tile') {
- // Tile: repeat image at its original pixel size
- const tileMatrix = Float32Array.of(1, 0, -x, 0, 1, -y, 0, 0, 1)
- const shader = cached.makeShaderOptions(
- ck.TileMode.Repeat, ck.TileMode.Repeat,
- ck.FilterMode.Linear, ck.MipmapMode.None,
- tileMatrix,
- )
- if (shader) {
- paint.setShader(shader)
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint)
- }
- } else if (fit === 'fit') {
- // Fit (contain): scale uniformly so entire image is visible, centered
- // Draw a subtle background so letterbox areas are visible
- const bgPaint = new ck.Paint()
- bgPaint.setStyle(ck.PaintStyle.Fill)
- bgPaint.setColor(parseColor(ck, '#f3f4f6'))
- if (opacity < 1) bgPaint.setAlphaf(opacity * 0.3)
- else bgPaint.setAlphaf(0.3)
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), bgPaint)
- bgPaint.delete()
-
- const scale = Math.min(w / imgW, h / imgH)
- const dw = imgW * scale
- const dh = imgH * scale
- const dx = x + (w - dw) / 2
- const dy = y + (h - dh) / 2
- canvas.drawImageRect(
- cached,
- ck.LTRBRect(0, 0, imgW, imgH),
- ck.LTRBRect(dx, dy, dx + dw, dy + dh),
- paint,
- )
- } else {
- // 'fill' and 'crop' (cover): scale uniformly to fill entire area, centered, excess clipped
- const scale = Math.max(w / imgW, h / imgH)
- const dw = imgW * scale
- const dh = imgH * scale
- const dx = x + (w - dw) / 2
- const dy = y + (h - dh) / 2
- canvas.drawImageRect(
- cached,
- ck.LTRBRect(0, 0, imgW, imgH),
- ck.LTRBRect(dx, dy, dx + dw, dy + dh),
- paint,
- )
- }
-
- paint.delete()
- canvas.restore()
- }
-
- private drawImageFallback(
- canvas: Canvas,
- x: number, y: number, w: number, h: number,
- cr: number, opacity: number,
- ) {
- const ck = this.ck
- const paint = new ck.Paint()
- paint.setStyle(ck.PaintStyle.Fill)
- paint.setAntiAlias(true)
- const c = parseColor(ck, '#e5e7eb')
- c[3] *= opacity
- paint.setColor(c)
-
- if (cr > 0) {
- const maxR = Math.min(cr, w / 2, h / 2)
- canvas.drawRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR), paint)
- } else {
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint)
- }
- paint.delete()
- }
-
- // Drawing preview (semi-transparent shape while user drags to create)
- drawPreview(
- canvas: Canvas,
- shape: { type: string; x: number; y: number; w: number; h: number },
- ) {
- const ck = this.ck
- const fillPaint = new ck.Paint()
- fillPaint.setStyle(ck.PaintStyle.Fill)
- fillPaint.setAntiAlias(true)
- fillPaint.setColor(parseColor(ck, 'rgba(59, 130, 246, 0.1)'))
-
- const strokePaint = new ck.Paint()
- strokePaint.setStyle(ck.PaintStyle.Stroke)
- strokePaint.setAntiAlias(true)
- strokePaint.setStrokeWidth(1)
- strokePaint.setColor(parseColor(ck, '#3b82f6'))
-
- const { x, y, w, h } = shape
- if (shape.type === 'line') {
- canvas.drawLine(x, y, x + w, y + h, strokePaint)
- } else if (shape.type === 'ellipse') {
- canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
- canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), strokePaint)
- } else if (shape.type === 'polygon') {
- const count = 3
- const raw: [number, number][] = []
- for (let i = 0; i < count; i++) {
- const angle = (i * 2 * Math.PI) / count - Math.PI / 2
- raw.push([Math.cos(angle), Math.sin(angle)])
- }
- let pMinX = Infinity, pMaxX = -Infinity, pMinY = Infinity, pMaxY = -Infinity
- for (const [rx, ry] of raw) {
- if (rx < pMinX) pMinX = rx
- if (rx > pMaxX) pMaxX = rx
- if (ry < pMinY) pMinY = ry
- if (ry > pMaxY) pMaxY = ry
- }
- const rw = pMaxX - pMinX
- const rh = pMaxY - pMinY
- const path = new ck.Path()
- for (let i = 0; i < count; i++) {
- const px = x + ((raw[i][0] - pMinX) / rw) * w
- const py = y + ((raw[i][1] - pMinY) / rh) * h
- if (i === 0) path.moveTo(px, py)
- else path.lineTo(px, py)
- }
- path.close()
- canvas.drawPath(path, fillPaint)
- canvas.drawPath(path, strokePaint)
- path.delete()
- } else {
- // rectangle / frame
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), strokePaint)
- }
-
- fillPaint.delete()
- strokePaint.delete()
- }
-
- // Overlay drawing (delegated to skia-overlays.ts)
-
- drawSelectionBorder(canvas: Canvas, x: number, y: number, w: number, h: number) {
- _drawSelectionBorder(this.ck, canvas, x, y, w, h)
- }
-
- drawFrameLabel(canvas: Canvas, name: string, x: number, y: number) {
- _drawFrameLabel(this.ck, canvas, name, x, y)
- }
-
- drawHoverOutline(canvas: Canvas, x: number, y: number, w: number, h: number) {
- _drawHoverOutline(this.ck, canvas, x, y, w, h)
- }
-
- drawSelectionMarquee(canvas: Canvas, x1: number, y1: number, x2: number, y2: number) {
- _drawSelectionMarquee(this.ck, canvas, x1, y1, x2, y2)
- }
-
- drawGuide(canvas: Canvas, x1: number, y1: number, x2: number, y2: number, zoom: number) {
- _drawGuide(this.ck, canvas, x1, y1, x2, y2, zoom)
- }
-
- drawPenPreview(canvas: Canvas, data: PenPreviewData, zoom: number) {
- _drawPenPreview(this.ck, canvas, data, zoom)
- }
-
- drawFrameLabelColored(
- canvas: Canvas, name: string, x: number, y: number,
- isReusable: boolean, isInstance: boolean, zoom = 1,
- ) {
- _drawFrameLabelColored(this.ck, canvas, name, x, y, isReusable, isInstance, zoom)
- }
-
- drawAgentGlow(
- canvas: Canvas, x: number, y: number, w: number, h: number,
- color: string, breath: number, zoom: number,
- ) {
- _drawAgentGlow(this.ck, canvas, x, y, w, h, color, breath, zoom)
- }
-
- drawAgentBadge(
- canvas: Canvas, name: string,
- frameX: number, frameY: number, frameW: number,
- color: string, zoom: number, time: number,
- ) {
- _drawAgentBadge(this.ck, canvas, name, frameX, frameY, frameW, color, zoom, time)
- }
-
- drawAgentNodeBorder(
- canvas: Canvas, x: number, y: number, w: number, h: number,
- color: string, breath: number, zoom: number,
- ) {
- _drawAgentNodeBorder(this.ck, canvas, x, y, w, h, color, breath, zoom)
- }
-
- drawAgentPreviewFill(
- canvas: Canvas, x: number, y: number, w: number, h: number,
- color: string, time: number,
- ) {
- _drawAgentPreviewFill(this.ck, canvas, x, y, w, h, color, time)
- }
-
- drawArcHandles(
- canvas: Canvas,
- x: number, y: number, w: number, h: number,
- startAngle: number, sweepAngle: number, innerRadius: number,
- zoom: number,
- ) {
- _drawArcHandles(this.ck, canvas, x, y, w, h, startAngle, sweepAngle, innerRadius, zoom)
- }
-}
diff --git a/src/mcp/utils/id.ts b/src/mcp/utils/id.ts
deleted file mode 100644
index a5dc55d8..00000000
--- a/src/mcp/utils/id.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { nanoid } from 'nanoid'
-
-export function generateId(): string {
- return nanoid()
-}
diff --git a/src/mcp/utils/node-operations.ts b/src/mcp/utils/node-operations.ts
deleted file mode 100644
index b02820f8..00000000
--- a/src/mcp/utils/node-operations.ts
+++ /dev/null
@@ -1,278 +0,0 @@
-import type { PenDocument, PenNode, RefNode } from '../../types/pen'
-
-/** Get the working children for an MCP operation. When pageId is given, targets that page; otherwise targets the first page (or doc.children). */
-export function getDocChildren(doc: PenDocument, pageId?: string): PenNode[] {
- if (doc.pages && doc.pages.length > 0) {
- if (pageId) {
- const page = doc.pages.find((p) => p.id === pageId)
- if (!page) throw new Error(`Page not found: ${pageId}`)
- return page.children
- }
- return doc.pages[0].children
- }
- return doc.children
-}
-
-/** Set the working children for an MCP operation. When pageId is given, targets that page; otherwise targets the first page (or doc.children). */
-export function setDocChildren(doc: PenDocument, children: PenNode[], pageId?: string): void {
- if (doc.pages && doc.pages.length > 0) {
- if (pageId) {
- const page = doc.pages.find((p) => p.id === pageId)
- if (!page) throw new Error(`Page not found: ${pageId}`)
- page.children = children
- } else {
- doc.pages[0].children = children
- }
- } else {
- doc.children = children
- }
-}
-
-export function findNodeInTree(
- nodes: PenNode[],
- id: string,
-): PenNode | undefined {
- for (const node of nodes) {
- if (node.id === id) return node
- if ('children' in node && node.children) {
- const found = findNodeInTree(node.children, id)
- if (found) return found
- }
- }
- return undefined
-}
-
-export function findParentInTree(
- nodes: PenNode[],
- id: string,
-): PenNode | undefined {
- for (const node of nodes) {
- if ('children' in node && node.children) {
- for (const child of node.children) {
- if (child.id === id) return node
- }
- const found = findParentInTree(node.children, id)
- if (found) return found
- }
- }
- return undefined
-}
-
-export function removeNodeFromTree(nodes: PenNode[], id: string): PenNode[] {
- return nodes
- .filter((n) => n.id !== id)
- .map((n) => {
- if ('children' in n && n.children) {
- return { ...n, children: removeNodeFromTree(n.children, id) }
- }
- return n
- })
-}
-
-export function updateNodeInTree(
- nodes: PenNode[],
- id: string,
- updates: Partial,
-): PenNode[] {
- return nodes.map((n) => {
- if (n.id === id) {
- return { ...n, ...updates } as PenNode
- }
- if ('children' in n && n.children) {
- return {
- ...n,
- children: updateNodeInTree(n.children, id, updates),
- } as PenNode
- }
- return n
- })
-}
-
-export function insertNodeInTree(
- nodes: PenNode[],
- parentId: string | null,
- node: PenNode,
- index?: number,
-): PenNode[] {
- if (parentId === null) {
- const arr = [...nodes]
- if (index !== undefined) {
- arr.splice(index, 0, node)
- } else {
- arr.push(node)
- }
- return arr
- }
-
- return nodes.map((n) => {
- if (n.id === parentId) {
- const children = 'children' in n && n.children ? [...n.children] : []
- if (index !== undefined) {
- children.splice(index, 0, node)
- } else {
- children.push(node)
- }
- return { ...n, children } as PenNode
- }
- if ('children' in n && n.children) {
- return {
- ...n,
- children: insertNodeInTree(n.children, parentId, node, index),
- } as PenNode
- }
- return n
- })
-}
-
-export function flattenNodes(nodes: PenNode[]): PenNode[] {
- const result: PenNode[] = []
- for (const node of nodes) {
- result.push(node)
- if ('children' in node && node.children) {
- result.push(...flattenNodes(node.children))
- }
- }
- return result
-}
-
-export function cloneNodeWithNewIds(
- node: PenNode,
- generateId: () => string,
-): PenNode {
- const cloned = { ...node, id: generateId() } as PenNode
- if ('children' in cloned && cloned.children) {
- cloned.children = cloned.children.map((c) =>
- cloneNodeWithNewIds(c, generateId),
- )
- }
- return cloned
-}
-
-/** Get the bounding box of a node, resolving RefNode dimensions from component. */
-export function getNodeBounds(
- node: PenNode,
- allNodes: PenNode[],
-): { x: number; y: number; w: number; h: number } {
- const x = node.x ?? 0
- const y = node.y ?? 0
- let w = 'width' in node && typeof node.width === 'number' ? node.width : 0
- let h = 'height' in node && typeof node.height === 'number' ? node.height : 0
- if (node.type === 'ref' && !w) {
- const refComp = findNodeInTree(allNodes, (node as RefNode).ref)
- if (refComp) {
- w =
- 'width' in refComp && typeof refComp.width === 'number'
- ? refComp.width
- : 100
- h =
- 'height' in refComp && typeof refComp.height === 'number'
- ? refComp.height
- : 100
- }
- }
- return { x, y, w: w || 100, h: h || 100 }
-}
-
-/**
- * Search nodes matching a pattern. Supports:
- * - type: exact match on node type
- * - name: regex match on node name
- * - reusable: match reusable flag
- */
-export function searchNodes(
- nodes: PenNode[],
- pattern: { type?: string; name?: string; reusable?: boolean },
- maxDepth = Infinity,
- currentDepth = 0,
-): PenNode[] {
- if (currentDepth > maxDepth) return []
- const results: PenNode[] = []
- for (const node of nodes) {
- let matches = true
- if (pattern.type && node.type !== pattern.type) matches = false
- if (pattern.name) {
- const regex = new RegExp(pattern.name, 'i')
- if (!regex.test(node.name ?? '')) matches = false
- }
- if (pattern.reusable !== undefined) {
- const isReusable = 'reusable' in node && (node as any).reusable === true
- if (pattern.reusable !== isReusable) matches = false
- }
- if (matches) results.push(node)
- if ('children' in node && node.children) {
- results.push(
- ...searchNodes(node.children, pattern, maxDepth, currentDepth + 1),
- )
- }
- }
- return results
-}
-
-/** Read a node with depth-limited children. */
-export function readNodeWithDepth(
- node: PenNode,
- depth: number,
-): Record {
- const result: Record = { ...node }
- if (depth <= 0 && 'children' in node && node.children?.length) {
- result.children = '...'
- } else if ('children' in node && node.children) {
- result.children = node.children.map((c) =>
- readNodeWithDepth(c, depth - 1),
- )
- }
- return result
-}
-
-/** Compute bounding box layout tree for snapshot_layout. */
-export function computeLayoutTree(
- nodes: PenNode[],
- allNodes: PenNode[],
- maxDepth: number,
- currentDepth = 0,
- parentX = 0,
- parentY = 0,
-): LayoutEntry[] {
- const entries: LayoutEntry[] = []
- for (const node of nodes) {
- const bounds = getNodeBounds(node, allNodes)
- const absX = parentX + bounds.x
- const absY = parentY + bounds.y
- const entry: LayoutEntry = {
- id: node.id,
- name: node.name,
- type: node.type,
- x: absX,
- y: absY,
- width: bounds.w,
- height: bounds.h,
- }
- if (
- 'children' in node &&
- node.children?.length &&
- currentDepth < maxDepth
- ) {
- entry.children = computeLayoutTree(
- node.children,
- allNodes,
- maxDepth,
- currentDepth + 1,
- absX,
- absY,
- )
- }
- entries.push(entry)
- }
- return entries
-}
-
-export interface LayoutEntry {
- id: string
- name?: string
- type: string
- x: number
- y: number
- width: number
- height: number
- children?: LayoutEntry[]
-}
diff --git a/src/utils/node-clone.ts b/src/utils/node-clone.ts
deleted file mode 100644
index b5792d16..00000000
--- a/src/utils/node-clone.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { PenNode } from '@/types/pen'
-
-function reassignIds(node: PenNode): PenNode {
- const cloned = { ...node, id: crypto.randomUUID() }
- if ('children' in cloned && cloned.children) {
- cloned.children = cloned.children.map(reassignIds)
- }
- return cloned as PenNode
-}
-
-export function cloneNodesWithNewIds(
- nodes: PenNode[],
- offset = 0,
-): PenNode[] {
- return structuredClone(nodes).map((node) => {
- const withNewId = reassignIds(node)
- // Cloned nodes should not retain reusable component status
- if ('reusable' in withNewId) {
- delete (withNewId as unknown as Record).reusable
- }
- if (offset !== 0) {
- withNewId.x = (withNewId.x ?? 0) + offset
- withNewId.y = (withNewId.y ?? 0) + offset
- }
- return withNewId
- })
-}
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 00000000..676e276c
--- /dev/null
+++ b/tsconfig.base.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "lib": ["ES2022"],
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index fbb99a24..4ec8e8f0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,28 +1,4 @@
{
- "include": ["**/*.ts", "**/*.tsx"],
- "compilerOptions": {
- "target": "ES2022",
- "jsx": "react-jsx",
- "module": "ESNext",
- "lib": ["ES2022", "DOM", "DOM.Iterable"],
- "types": ["vite/client"],
-
- /* Bundler mode */
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "verbatimModuleSyntax": true,
- "noEmit": true,
-
- /* Linting */
- "skipLibCheck": true,
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedSideEffectImports": true,
- "baseUrl": ".",
- "paths": {
- "@/*": ["./src/*"]
- }
- }
+ "extends": "./apps/web/tsconfig.json",
+ "include": ["apps/web/**/*.ts", "apps/web/**/*.tsx", "packages/*/src/**/*.ts"]
}