* feat(boolean-operations): implement boolean operations in the editor

- Added a new BooleanToolbar component for union, subtract, and intersect operations.
- Integrated boolean operations into the layer context menu and keyboard shortcuts.
- Enhanced the editor layout to include the boolean toolbar for improved user interaction.
- Updated internationalization support with new translation keys for boolean operations.
- Bumped version to 0.3.0 to reflect the addition of these features.

* refactor(editor): update editor layout and panels for improved functionality

- Replaced the PropertyPanel with a new RightPanel that includes both Property and Code panels.
- Removed the CodePanel from the main editor layout and integrated it into the RightPanel.
- Updated keyboard shortcuts to switch the right panel to the code tab.
- Enhanced the LayerPanel with a resizable width feature for better user experience.
- Added internationalization support for new right panel labels and code panel features.
- Introduced new code generation capabilities for various frameworks in the CodePanel.
- Improved overall layout structure for better responsiveness and usability.

* feat(electron): implement .op file association and enhance file handling

- Added support for .op file association in electron-builder, allowing OpenPencil documents to be opened directly from the file system.
- Implemented IPC handlers for opening and reading .op files, ensuring proper loading of document content.
- Enhanced the main process to handle file opening events on macOS and single-instance locking on Windows/Linux.
- Updated the renderer to listen for file open events and load documents accordingly.
- Improved README to reflect new file association feature.

* fix(canvas): improve layout accuracy for AI-generated designs

- Unify lineHeight default via canonical defaultLineHeight() function
- Unify text measurement by removing duplicate estimators in generation-utils
- Fix optical centering formula to scale proportionally with fontSize
- Round layout positions to whole pixels to prevent sub-pixel artifacts
- Recursively sanitize nested x/y in streaming layout containers
- Fix input trailing icon alignment using fill_container instead of space_between

* feat(canvas): right-align agent badge and add breathing glow border

- Agent badge now right-aligned to frame's right edge instead of after label
- Added breathing glow border around agent-owned frames during generation
- Glow border uses same color and lifecycle as the agent badge
- Removed unused BADGE_GAP constant and useDocumentStore import

* feat(code-panel): enhance tab scrolling functionality and add scrollbar utility

- Introduced left and right scroll buttons for tab navigation in the CodePanel, improving user experience for navigating long tab lists.
- Added a custom utility to hide scrollbars for a cleaner interface.
- Updated styles for better responsiveness and usability in the CodePanel layout.

* fix(docs): update Discord invite links in multiple README files

- Replaced outdated Discord invite links with the new link across all language-specific README files.
- Ensured consistency in the documentation for community engagement.

* feat(code-panel): enhance system prompt for responsive design

- Updated the ENHANCE_SYSTEM_PROMPT to emphasize the importance of responsive design in code rewriting.
- Added detailed guidelines for converting fixed pixel widths to relative units and using responsive Tailwind breakpoints.
- Ensured that the output remains visually faithful on desktop while adapting gracefully across screen sizes.

* feat(docs): add WeChat group information to README.zh.md and include group image

- Introduced a new section in the Chinese README to provide details about the WeChat group for community engagement.
- Added an image representing the WeChat group for better visibility and user interaction.

* feat(electron): enhance theme management and title bar overlay for Windows/Linux

- Updated the `setTheme` method in the Electron API to accept custom colors for the title bar overlay, improving theme synchronization across platforms.
- Adjusted title bar overlay colors for Windows and Linux to ensure proper visibility and aesthetics.
- Enhanced the top bar component to read computed CSS colors and apply them dynamically, ensuring a consistent user interface.
- Improved handling of theme changes in the application to support background and foreground color customization.

* fix(screenshot): update screenshot image for improved clarity and quality

* fix(docs): update WeChat group image path in README.zh.md for consistency

* fix(ai): fix post-generation validation pipeline and text centering

- Fix Agent SDK validation: save temp screenshots inside project dir
  (.openpencil-tmp/) so Claude Code plan mode can read them, instead
  of /tmp/ which is outside the project sandbox
- Enrich validation tree dump with fill colors, stroke, fontSize,
  fontWeight, textAlign, cornerRadius, opacity for comprehensive
  visual analysis
- Add multi-round validation with quality scoring (threshold 8/10),
  500ms stabilization delay between rounds
- Add detailed debug logging to applyValidationFixes showing which
  nodes were found/skipped and property changes
- Fix canvas sync needsTextbox check to also account for textAlign
  (matching isFixedWidthText in factory), preventing IText↔Textbox
  thrashing on every sync tick
- Auto-center text in vertical+center layouts by expanding to full
  container width and injecting textAlign:'center'
- Force Textbox for non-left-aligned text so textAlign is respected
  (IText ignores width and computes its own)

* fix(canvas): use precise text width estimation for fit-content layout

Remove the 14% safety factor from text width estimation when computing
fit-content/natural-width text dimensions. IText auto-computes its own
width and ignores our setting, so the safety margin only inflated the
layout allocation, making text appear left-shifted within its container.

* fix(canvas): center fit-content text in horizontal layouts

For text nodes with fit-content width in horizontal layouts, set
textAlign:'center' to compensate for width estimation inaccuracy.
The estimated box is typically wider than the actual rendered text,
causing left-aligned text to appear visually shifted. Centering
distributes the estimation error evenly on both sides.

* feat(ai): show validation details in checklist panel

- Accumulate validation log (screenshot, analysis, fixes) instead of
  overwriting status messages, so the full process is visible
- Preserve step thinking content in buildFinalStepTags (was discarded)
- Add details field to pipeline items and render in checklist UI
- Each validation step now shows: screenshot captured, issues found,
  quality score, fixes applied

* feat(ai): add visual reference pipeline types and integration hooks

- Add DesignSystem and VisualReference types to ai-types
- Add 'visual-ref' mode to AIDesignRequest and SubTask.htmlReference
- Detect visual-ref candidates in chat handlers (landing pages, websites)
- Wire visual-ref mode in design-generator and orchestrator
- Inject HTML reference snippets into sub-agent prompts

* feat(ai): add modular design principles for sub-agent context

- Add design-principles module with topic files: color, typography,
  spacing, composition, components
- Selectively load relevant principles based on prompt content
- Inject design principles into sub-agent system prompts

* feat(ai): implement visual reference pipeline

- Add design-system-generator: generates color/typography/spacing tokens
- Add design-code-generator: generates HTML/CSS from design system
- Add html-renderer: renders HTML to screenshot via html2canvas
- Add visual-ref-orchestrator: coordinates the full pipeline
  (design system → HTML code → screenshot → enrich subtasks)
- Add html2canvas dependency for client-side HTML rendering

* feat(mcp): default filePath to live canvas and fix cross-platform issues

- Default all MCP tool filePath to live://canvas when omitted, so tools
  operate on the real-time canvas instead of stale files
- Remove filePath from required params in all tool schemas (21 interfaces)
- Fix mcp-server-manager.ts using process.cwd() which fails in Electron
  production on Linux — now checks ELECTRON_RESOURCES_PATH first
- Fix stopMcpHttpServer using SIGTERM on Windows — use taskkill instead
- Force new children reference in applyExternalDocument to ensure canvas
  sync subscriber always detects MCP-pushed document updates

* feat(mcp): enhance design prompt with semantic roles, CJK typography, and layout rules

Add comprehensive design knowledge to MCP design prompt for better
AI-generated designs: design type detection (mobile vs desktop), full
semantic role reference with context-aware defaults, CJK typography
rules, expanded text/layout/form guidelines, and detailed post-processing
documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(ai): implement intent classification for chat handlers

- Replace hardcoded keyword matching with a lightweight LLM call to classify user intent in chat messages.
- Introduce a new function `classifyIntent` to determine if the request is for design generation or conversation.
- Update design request handling in `useChatHandlers` to utilize the new classification method.
- Enhance design prompt documentation to reflect changes in design type detection based on intent rather than keywords.

* fix(ai): handle string qualityScore in validation response parsing

The LLM sometimes returns qualityScore as a string (e.g. "8" instead of 8),
causing it to fall through to 0. Also hide misleading "quality: 0/10" display
when the score couldn't be determined, and log raw response for debugging.

* fix(ai): increase validation timeout to 90s and fix quality score parsing

Agent SDK validation requires spawning a process, reading the image, and
analyzing it — 30s was consistently timing out. Also handle string
qualityScore values from LLM responses and hide misleading 0/10 display.

* fix(ai): fix validation timeout and response parsing

- Increase validation timeout from 30s to 180s (Agent SDK needs time
  for subprocess spawn + OAuth auth + multi-turn image reading)
- Strip <tool_use> XML blocks from Agent SDK response before extracting
  JSON — the tool call XML was confusing the regex, causing qualityScore
  to parse as 0 despite valid JSON being present
- Handle string qualityScore values and hide misleading "quality: 0/10"
- Revert unnecessary direct API key approach for validation

* fix(ai): prevent node ID collisions between generations

When generating new content on a canvas with existing nodes, AI-generated
IDs (e.g. brand-spacer) would collide with previous generations. Now
captures pre-existing node IDs at generation start and checks against
them during upsert sanitization. Remapped IDs are tracked in
generationRemappedIds so progressive streaming updates can still find
their nodes.

* fix(ai): require styleGuide in orchestrator plan and fix validation detail icons

- Add fallback default styleGuide when orchestrator LLM omits it
- Strengthen prompt to mark styleGuide as REQUIRED
- Replace emoji icons in validation details with [done]/[pending]/[error]
  markers for consistent styling with the checklist design system

* feat(server): add port file plugin for server instance discovery

- Introduce a new Nitro plugin that writes a port file on server startup to allow the MCP server to discover the running instance, whether it's a development server or Electron.
- Implement error handling in the Electron main process for writing the port file, logging any failures.
- Update Vite configuration to include additional external dependencies in the rollup configuration.

* feat(electron): implement IPC for retrieving pending file paths

- Added a new IPC handler `file:getPending` to retrieve and clear the pending file path when the React app mounts.
- Updated the Electron API to include `getPendingFile` for renderer access.
- Enhanced the `useElectronMenu` hook to load any pending file on application startup.
- Updated UI components to reflect changes in file handling and improved user experience.

* fix(panels): replace emoji icons with styled icons in validation checklist

- Parse [done]/[pending]/[error] prefixes in detail lines and render as
  styled circle icons matching the parent checklist design system
- Replace remaining emoji markers in design-validation.ts with text prefixes
- Fix isApplied detection to recognize new [done] Applied marker

* refactor(electron): update settings path to use platform-standard app data directory

- Changed the settings file path to utilize Electron's user data directory for better cross-platform compatibility.
- Updated the settings writing function to ensure the user data directory is created if it doesn't exist.
- Added comments to clarify the storage location for different operating systems.
- Implemented a fixed partition for localStorage/cookies to maintain data across server port changes.

* feat(ai): enhance validation with pre-checks, structural fixes, and border detection

- Add design-pre-validation.ts: pure code checks before LLM validation
  - Invisible container detection (same fill as parent → auto-add border)
  - Sibling consistency (majority-rule for height/cornerRadius)
- Add structural fixes to validation: addChild/removeNode operations
  - Icon injection via lookupIconByName with server fallback
  - autoFixParentLayout with child count guard to prevent layout breakage
- Add strokeColor/strokeWidth to safe fix properties for border fixes
- Simplify intent classification: all design requests use visual-ref pipeline
- Fix checklist: "Found N issues" now shows [done] instead of [pending]
- Fix qualityScore: only update when > 0 to preserve valid round scores

* fix(ai): cherry-pick safe validation improvements, drop aggressive pre-checks

Keep: stroke tree dump bug fix (object not array), qualityScore=0 false
positive detection, fit_content→fixed safety guard, empty path removal,
type-specific sibling consistency, repeated fix filtering, screenshot
extraction to design-screenshot.ts.

Drop: detectForcedFixedHeight (destroyed input/button heights),
MAX_VALIDATION_ROUNDS 5 (too many rounds), removal of quality threshold
early stop, section regeneration phase.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kayshen Xu 2026-03-08 11:55:35 +08:00 committed by GitHub
parent 5e13f48be1
commit ca1b5370ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 7680 additions and 617 deletions

View file

@ -19,7 +19,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Built as a **TanStack Start** full-stack React application with Bun runtime. Server API powered by **Nitro**. Also ships as an **Electron** desktop app for macOS, Windows, and Linux.
**Key technologies:** React 19, Fabric.js v7 (canvas engine), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), TypeScript (strict mode).
**Key technologies:** React 19, Fabric.js v7 (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), TypeScript (strict mode).
### Data Flow
@ -133,7 +133,7 @@ PenDocument (source of truth)
- `agent-settings.ts` — AI provider config types (`AIProviderType`: anthropic/openai/opencode/copilot, `AIProviderConfig`, `MCPCliIntegration`, `GroupedModel`)
- `electron.d.ts` — Electron IPC bridge types (file dialogs, save operations, updater: `UpdaterState`/`UpdaterStatus`, `getState`/`checkForUpdates`/`quitAndInstall`/`onStateChange`)
- `opencode-sdk.d.ts` — Type declarations for @opencode-ai/sdk
- **`src/components/editor/`** — Editor UI (8 files): editor-layout, toolbar (with variables panel toggle), tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar (with `AgentStatusButton`), status-bar, page-tabs (multi-page navigation with context menu), update-ready-banner (Electron auto-updater notification)
- **`src/components/editor/`** — Editor UI (9 files): editor-layout, toolbar (with variables panel toggle), boolean-toolbar (contextual floating toolbar for union/subtract/intersect, shown when 2+ compatible shapes selected), tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar (with `AgentStatusButton`), status-bar, page-tabs (multi-page navigation with context menu), update-ready-banner (Electron auto-updater notification)
- **`src/components/panels/`** — Panels (26 files):
- `layer-panel.tsx` / `layer-item.tsx` / `layer-context-menu.tsx` — Tree view with drag-and-drop reordering and drop-into-children (above/below/inside), visibility/lock toggles, context menu, rename
- `property-panel.tsx` — Unified property panel
@ -194,8 +194,8 @@ PenDocument (source of truth)
- `figma-image-resolver.ts` — Resolves image blob references
- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators (output `var(--name)` for `$variable` refs), CSS variables generator
- **`src/hooks/`** — Hooks (2 files):
- `use-keyboard-shortcuts.ts` — Global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order
- `use-electron-menu.ts` — Electron native menu IPC listener: dispatches menu actions (new, open, save, save-as, undo, redo, etc.) to Zustand stores
- `use-keyboard-shortcuts.ts` — Global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order, boolean operations (Cmd+Alt+U/S/I)
- `use-electron-menu.ts` — Electron native menu IPC listener: dispatches menu actions (new, open, save, save-as, undo, redo, etc.) to Zustand stores; also handles `onOpenFile` for `.op` file association
- **`src/lib/`** — Utility functions (`utils.ts` with `cn()` for class merging)
- **`src/uikit/`** — UI kit system (3 files + `kits/` subdir):
- `built-in-registry.ts` — Default built-in UIKit with standard UI components
@ -207,7 +207,7 @@ PenDocument (source of truth)
- `document-manager.ts` — MCP utility for reading, writing, and caching PenDocuments from disk
- `tools/` — Individual MCP tool implementations: `batch-design.ts`, `batch-get.ts`, `find-empty-space.ts`, `open-document.ts`, `snapshot-layout.ts`, `variables.ts`, `pages.ts` (page CRUD: add/remove/rename/reorder/duplicate)
- `utils/` — Shared utilities: `id.ts`, `node-operations.ts` (page-aware `getDocChildren`/`setDocChildren`)
- **`src/utils/`** — File operations (save/open .pen), export (PNG/SVG), node clone, pen file normalization (format fixes only, preserves `$variable` refs), SVG parser (import SVG to editable PenNodes), syntax highlight
- **`src/utils/`** — File operations (save/open .pen), export (PNG/SVG), node clone, pen file normalization (format fixes only, preserves `$variable` refs), SVG parser (import SVG to editable PenNodes), syntax highlight, boolean operations (union/subtract/intersect via Paper.js)
- **`server/api/ai/`** — Nitro server API (7 files): `chat.ts` (streaming SSE with thinking state, multimodal image attachments per provider), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI/OpenCode/Copilot connection), `models.ts` (model definitions), `validate.ts` (vision-based post-generation validation), `mcp-install.ts` (MCP server install/uninstall into CLI tool configs), `icon.ts` (icon name → SVG path resolution via local Iconify sets). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers
- **`server/utils/`** — Server utilities (5 files):
- `resolve-claude-cli.ts` — Resolves standalone `claude` binary path (handles Nitro bundling issues with SDK's `import.meta.url`)
@ -250,13 +250,14 @@ Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`sr
### Electron Desktop App
- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, native application menu, auto-updater, macOS traffic-light padding (auto-hidden in fullscreen)
- **`electron/preload.ts`** — Context bridge for renderer ↔ main IPC (file dialogs, menu actions, updater state)
- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb)
- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, native application menu, auto-updater, macOS traffic-light padding (auto-hidden in fullscreen), `.op` file association handling (`open-file` event on macOS, CLI args + single-instance lock on Windows/Linux)
- **`electron/preload.ts`** — Context bridge for renderer ↔ main IPC (file dialogs, menu actions, updater state, `onOpenFile`/`readFile` for file association)
- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb), `.op` file association (`fileAssociations`)
- **`scripts/electron-dev.ts`** — Dev workflow: starts Vite → waits for port 3000 → compiles electron/ with esbuild → launches Electron
- Build flow: `BUILD_TARGET=electron bun run build``bun run electron:compile``npx electron-builder`
- In production, Nitro server is forked as a child process on a random port; Electron loads `http://127.0.0.1:{port}/editor`
- Auto-updater checks GitHub Releases on startup and every hour; `update-ready-banner.tsx` shows download progress and "Restart & Install" prompt
- **File association:** `.op` files are registered as OpenPencil documents via `fileAssociations` in `electron-builder.yml`. On macOS the `open-file` app event handles double-click/drag; on Windows/Linux `requestSingleInstanceLock` + `second-instance` event forwards CLI args to the existing window. Pending file paths are queued until the renderer is ready, then sent via `file:open` IPC channel. The renderer (`use-electron-menu.ts`) listens via `onOpenFile`, reads the file through `file:read` IPC, and calls `loadDocument`.
### CI / CD

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#schnellstart">Schnellstart</a> ·
<a href="#ki-natives-design">KI</a> ·
<a href="#funktionen">Funktionen</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#mitwirken">Mitwirken</a>
</p>
@ -89,6 +89,7 @@ OpenPencil wurde von Grund auf mit KI im Kern aufgebaut — nicht als Plugin, so
**Canvas und Zeichnen**
- Unendliche Canvas mit Pan, Zoom, intelligenten Ausrichtungshilfslinien und Einrasten
- Rechteck, Ellipse, Linie, Polygon, Stift (Bezier), Frame, Text
- Boolesche Operationen — Vereinigung, Subtraktion, Schnittmenge mit kontextbezogener Werkzeugleiste
- Icon-Auswahl (Iconify) und Bildimport (PNG/JPEG/SVG/WebP/GIF)
- Auto-Layout — vertikal/horizontal mit Gap, Padding, Justify, Align
- Mehrseitige Dokumente mit Tab-Navigation
@ -156,6 +157,8 @@ electron/
| `Del` | Löschen | | `Cmd+Shift+V` | Variablen-Panel |
| `[ / ]` | Reihenfolge ändern | | `Cmd+J` | KI-Chat |
| Pfeiltasten | 1px verschieben | | `Cmd+,` | Agenteneinstellungen |
| `Cmd+Alt+U` | Boolesche Vereinigung | | `Cmd+Alt+S` | Boolesche Subtraktion |
| `Cmd+Alt+I` | Boolesche Schnittmenge | | | |
## Skripte
@ -186,7 +189,7 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail
- [x] MCP-Server-Integration
- [x] Mehrseitige Unterstützung
- [x] Figma-`.fig`-Import
- [ ] Boolesche Operationen (Vereinigung, Subtraktion, Schnittmenge)
- [x] Boolesche Operationen (Vereinigung, Subtraktion, Schnittmenge)
- [ ] Kollaboratives Bearbeiten
- [ ] Plugin-System
@ -198,7 +201,7 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail
## Community
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Unserem Discord beitreten</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#inicio-rápido">Inicio Rápido</a> ·
<a href="#diseño-nativo-de-ia">IA</a> ·
<a href="#características">Características</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contribuir">Contribuir</a>
</p>
@ -89,6 +89,7 @@ OpenPencil está construido desde cero con IA en su núcleo — no como un plugi
**Lienzo y Dibujo**
- Lienzo infinito con panorámica, zoom, guías de alineación inteligentes y ajuste
- Rectángulo, Elipse, Línea, Polígono, Pluma (Bezier), Frame, Texto
- Operaciones booleanas — unión, resta, intersección con barra de herramientas contextual
- Selector de iconos (Iconify) e importación de imágenes (PNG/JPEG/SVG/WebP/GIF)
- Diseño automático — vertical/horizontal con gap, padding, justify, align
- Documentos multipágina con navegación por pestañas
@ -156,6 +157,8 @@ electron/
| `Del` | Eliminar | | `Cmd+Shift+V` | Panel de variables |
| `[ / ]` | Reordenar | | `Cmd+J` | Chat de IA |
| Flechas | Mover 1px | | `Cmd+,` | Configuración de agente |
| `Cmd+Alt+U` | Unión booleana | | `Cmd+Alt+S` | Resta booleana |
| `Cmd+Alt+I` | Intersección booleana | | | |
## Scripts
@ -186,7 +189,7 @@ bun run electron:build # Empaquetado de Electron
- [x] Integración con servidor MCP
- [x] Soporte multipágina
- [x] Importación de Figma `.fig`
- [ ] Operaciones booleanas (unión, sustracción, intersección)
- [x] Operaciones booleanas (unión, sustracción, intersección)
- [ ] Edición colaborativa
- [ ] Sistema de plugins
@ -198,7 +201,7 @@ bun run electron:build # Empaquetado de Electron
## Comunidad
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Únete a nuestro Discord</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">Démarrage rapide</a> ·
<a href="#ai-native-design">IA</a> ·
<a href="#features">Fonctionnalités</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">Contribuer</a>
</p>
@ -89,6 +89,7 @@ OpenPencil est conçu autour de l'IA dès le départ — non pas comme un plugin
**Canevas et dessin**
- Canevas infini avec panoramique, zoom, guides d'alignement intelligents et magnétisme
- Rectangle, Ellipse, Ligne, Polygone, Plume (Bézier), Frame, Texte
- Opérations booléennes — union, soustraction, intersection avec barre d'outils contextuelle
- Sélecteur d'icônes (Iconify) et import d'images (PNG/JPEG/SVG/WebP/GIF)
- Auto-layout — vertical/horizontal avec gap, padding, justify, align
- Documents multi-pages avec navigation par onglets
@ -156,6 +157,8 @@ electron/
| `Del` | Supprimer | | `Cmd+Shift+V` | Panneau des variables |
| `[ / ]` | Réordonner | | `Cmd+J` | Chat IA |
| Flèches | Déplacer de 1px | | `Cmd+,` | Paramètres de l'agent |
| `Cmd+Alt+U` | Union booléenne | | `Cmd+Alt+S` | Soustraction booléenne |
| `Cmd+Alt+I` | Intersection booléenne | | | |
## Scripts
@ -186,7 +189,7 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour
- [x] Intégration du serveur MCP
- [x] Support multi-pages
- [x] Import Figma `.fig`
- [ ] Opérations booléennes (union, soustraction, intersection)
- [x] Opérations booléennes (union, soustraction, intersection)
- [ ] Édition collaborative
- [ ] Système de plugins
@ -198,7 +201,7 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour
## Communauté
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Rejoindre notre Discord</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">त्वरित शुरुआत</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">विशेषताएँ</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">योगदान</a>
</p>
@ -89,6 +89,7 @@ OpenPencil को AI के इर्द-गिर्द शुरू से ब
**कैनवास और ड्रॉइंग**
- पैन, ज़ूम, स्मार्ट अलाइनमेंट गाइड और स्नैपिंग के साथ अनंत कैनवास
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
- बूलियन ऑपरेशन — संयोजन, घटाना, प्रतिच्छेदन संदर्भ टूलबार के साथ
- आइकन पिकर (Iconify) और इमेज इम्पोर्ट (PNG/JPEG/SVG/WebP/GIF)
- ऑटो-लेआउट — gap, padding, justify, align के साथ वर्टिकल/हॉरिज़ॉन्टल
- टैब नेवीगेशन के साथ मल्टी-पेज दस्तावेज़
@ -156,6 +157,8 @@ electron/
| `Del` | हटाएँ | | `Cmd+Shift+V` | वेरिएबल पैनल |
| `[ / ]` | क्रम बदलें | | `Cmd+J` | AI चैट |
| Arrows | 1px नज | | `Cmd+,` | एजेंट सेटिंग्स |
| `Cmd+Alt+U` | बूलियन संयोजन | | `Cmd+Alt+S` | बूलियन घटाना |
| `Cmd+Alt+I` | बूलियन प्रतिच्छेदन | | | |
## स्क्रिप्ट
@ -186,7 +189,7 @@ bun run electron:build # Electron पैकेज
- [x] MCP सर्वर इंटीग्रेशन
- [x] मल्टी-पेज सपोर्ट
- [x] Figma `.fig` इम्पोर्ट
- [ ] बूलियन ऑपरेशन (यूनियन, सबट्रैक्ट, इंटरसेक्ट)
- [x] बूलियन ऑपरेशन (यूनियन, सबट्रैक्ट, इंटरसेक्ट)
- [ ] सहयोगी संपादन
- [ ] प्लगइन सिस्टम
@ -198,7 +201,7 @@ bun run electron:build # Electron पैकेज
## समुदाय
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> हमारे Discord में शामिल हों</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#mulai-cepat">Mulai Cepat</a> ·
<a href="#desain-berbasis-ai">AI</a> ·
<a href="#fitur">Fitur</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#berkontribusi">Berkontribusi</a>
</p>
@ -89,6 +89,7 @@ OpenPencil dibangun dengan AI sebagai inti — bukan sebagai plugin, melainkan s
**Kanvas & Menggambar**
- Kanvas tak terbatas dengan pan, zoom, panduan perataan cerdas, dan snapping
- Persegi panjang, Elips, Garis, Poligon, Pen (Bezier), Frame, Teks
- Operasi Boolean — gabungan, kurangi, irisan dengan toolbar kontekstual
- Pemilih ikon (Iconify) dan impor gambar (PNG/JPEG/SVG/WebP/GIF)
- Auto-layout — vertikal/horizontal dengan gap, padding, justify, align
- Dokumen multi-halaman dengan navigasi tab
@ -156,6 +157,8 @@ electron/
| `Del` | Hapus | | `Cmd+Shift+V` | Panel variabel |
| `[ / ]` | Ubah urutan | | `Cmd+J` | Chat AI |
| Panah | Geser 1px | | `Cmd+,` | Pengaturan agen |
| `Cmd+Alt+U` | Union Boolean | | `Cmd+Alt+S` | Subtract Boolean |
| `Cmd+Alt+I` | Intersect Boolean | | | |
## Skrip
@ -186,7 +189,7 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt
- [x] Integrasi server MCP
- [x] Dukungan multi-halaman
- [x] Impor Figma `.fig`
- [ ] Operasi boolean (gabung, kurangi, potong)
- [x] Operasi boolean (gabung, kurangi, potong)
- [ ] Pengeditan kolaboratif
- [ ] Sistem plugin
@ -198,7 +201,7 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt
## Komunitas
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Bergabung dengan Discord kami</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">クイックスタート</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">機能</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">コントリビュート</a>
</p>
@ -89,6 +89,7 @@ OpenPencil はプラグインとしてではなく、コアワークフローと
**キャンバスと描画**
- パン、ズーム、スマートアライメントガイド、スナッピング対応の無限キャンバス
- 矩形、楕円、直線、多角形、ペンベジェ、Frame、テキスト
- ブーリアン演算 — 合体、型抜き、交差(コンテキストツールバー付き)
- アイコンピッカーIconifyと画像インポートPNG/JPEG/SVG/WebP/GIF
- オートレイアウト — 垂直/水平方向、ギャップ・パディング・justify・align 対応
- タブナビゲーション付きマルチページドキュメント
@ -156,6 +157,8 @@ electron/
| `Del` | 削除 | | `Cmd+Shift+V` | 変数パネル |
| `[ / ]` | 重ね順の変更 | | `Cmd+J` | AI チャット |
| 矢印キー | 1px 微調整 | | `Cmd+,` | エージェント設定 |
| `Cmd+Alt+U` | ブーリアン合体 | | `Cmd+Alt+S` | ブーリアン型抜き |
| `Cmd+Alt+I` | ブーリアン交差 | | | |
## スクリプト
@ -186,7 +189,7 @@ bun run electron:build # Electron パッケージング
- [x] MCP サーバー統合
- [x] マルチページサポート
- [x] Figma `.fig` インポート
- [ ] ブール演算(結合、減算、交差)
- [x] ブール演算(結合、減算、交差)
- [ ] 共同編集
- [ ] プラグインシステム
@ -198,7 +201,7 @@ bun run electron:build # Electron パッケージング
## コミュニティ
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Discord に参加する</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">빠른 시작</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">기능</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">기여하기</a>
</p>
@ -89,6 +89,7 @@ OpenPencil은 AI를 플러그인이 아닌 핵심 워크플로로서 처음부
**캔버스 & 드로잉**
- 팬, 줌, 스마트 정렬 가이드, 스냅 지원의 무한 캔버스
- 사각형, 타원, 직선, 다각형, 펜(베지어), Frame, 텍스트
- 불리언 연산 — 합치기, 빼기, 교차 (컨텍스트 도구 모음)
- 아이콘 피커(Iconify)와 이미지 가져오기(PNG/JPEG/SVG/WebP/GIF)
- 오토 레이아웃 — 수직/수평 방향, 갭·패딩·justify·align 지원
- 탭 내비게이션이 있는 멀티 페이지 문서
@ -156,6 +157,8 @@ electron/
| `Del` | 삭제 | | `Cmd+Shift+V` | 변수 패널 |
| `[ / ]` | 순서 변경 | | `Cmd+J` | AI 채팅 |
| 화살표 키 | 1px 이동 | | `Cmd+,` | 에이전트 설정 |
| `Cmd+Alt+U` | 불리언 합치기 | | `Cmd+Alt+S` | 불리언 빼기 |
| `Cmd+Alt+I` | 불리언 교차 | | | |
## 스크립트
@ -186,7 +189,7 @@ bun run electron:build # Electron 패키징
- [x] MCP 서버 통합
- [x] 멀티 페이지 지원
- [x] Figma `.fig` 가져오기
- [ ] 불리언 연산(결합, 빼기, 교차)
- [x] 불리언 연산(결합, 빼기, 교차)
- [ ] 공동 편집
- [ ] 플러그인 시스템
@ -198,7 +201,7 @@ bun run electron:build # Electron 패키징
## 커뮤니티
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Discord에 참여하기</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">Features</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">Contributing</a>
</p>
@ -91,6 +91,7 @@ OpenPencil is built around AI from the ground up — not as a plugin, but as a c
**Canvas & Drawing**
- Infinite canvas with pan, zoom, smart alignment guides, and snapping
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
- Boolean operations — union, subtract, intersect with contextual toolbar
- Icon picker (Iconify) and image import (PNG/JPEG/SVG/WebP/GIF)
- Auto-layout — vertical/horizontal with gap, padding, justify, align
- Multi-page documents with tab navigation
@ -106,6 +107,7 @@ OpenPencil is built around AI from the ground up — not as a plugin, but as a c
**Desktop App**
- Native macOS, Windows, and Linux via Electron
- `.op` file association — double-click to open, single-instance lock
- Auto-update from GitHub Releases
- Native application menu and file dialogs
@ -158,6 +160,8 @@ electron/
| `Del` | Delete | | `Cmd+Shift+V` | Variables panel |
| `[ / ]` | Reorder | | `Cmd+J` | AI chat |
| Arrows | Nudge 1px | | `Cmd+,` | Agent settings |
| `Cmd+Alt+U` | Boolean union | | `Cmd+Alt+S` | Boolean subtract |
| `Cmd+Alt+I` | Boolean intersect | | | |
## Scripts
@ -188,7 +192,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details
- [x] MCP server integration
- [x] Multi-page support
- [x] Figma `.fig` import
- [ ] Boolean operations (union, subtract, intersect)
- [x] Boolean operations (union, subtract, intersect)
- [ ] Collaborative editing
- [ ] Plugin system
@ -200,7 +204,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details
## Community
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Join our Discord</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#início-rápido">Início Rápido</a> ·
<a href="#design-nativo-com-ia">IA</a> ·
<a href="#funcionalidades">Funcionalidades</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contribuindo">Contribuindo</a>
</p>
@ -89,6 +89,7 @@ O OpenPencil é construído com IA desde o início — não como um plugin, mas
**Canvas e Desenho**
- Canvas infinito com pan, zoom, guias de alinhamento inteligentes e snapping
- Retângulo, Elipse, Linha, Polígono, Caneta (Bezier), Frame, Texto
- Operações booleanas — união, subtração, interseção com barra de ferramentas contextual
- Seletor de ícones (Iconify) e importação de imagens (PNG/JPEG/SVG/WebP/GIF)
- Auto-layout — vertical/horizontal com gap, padding, justify, align
- Documentos com múltiplas páginas e navegação por abas
@ -156,6 +157,8 @@ electron/
| `Del` | Excluir | | `Cmd+Shift+V` | Painel de variáveis |
| `[ / ]` | Reordenar | | `Cmd+J` | Chat IA |
| Setas | Mover 1px | | `Cmd+,` | Configurações do agente |
| `Cmd+Alt+U` | União booleana | | `Cmd+Alt+S` | Subtração booleana |
| `Cmd+Alt+I` | Interseção booleana | | | |
## Scripts
@ -186,7 +189,7 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh
- [x] Integração com servidor MCP
- [x] Suporte a múltiplas páginas
- [x] Importação do Figma `.fig`
- [ ] Operações booleanas (união, subtração, interseção)
- [x] Operações booleanas (união, subtração, interseção)
- [ ] Edição colaborativa
- [ ] Sistema de plugins
@ -198,7 +201,7 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh
## Comunidade
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Entre no nosso Discord</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">Быстрый старт</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">Возможности</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">Участие в разработке</a>
</p>
@ -89,6 +89,7 @@ OpenPencil построен вокруг AI с самого начала — н
**Холст и рисование**
- Бесконечный холст с панорамированием, масштабированием, умными направляющими и привязкой
- Прямоугольник, Эллипс, Линия, Многоугольник, Перо (Безье), Frame, Текст
- Булевы операции — объединение, вычитание, пересечение с контекстной панелью инструментов
- Выбор иконок (Iconify) и импорт изображений (PNG/JPEG/SVG/WebP/GIF)
- Авто-раскладка — вертикальная/горизонтальная с gap, padding, justify, align
- Многостраничные документы с навигацией по вкладкам
@ -156,6 +157,8 @@ electron/
| `Del` | Удалить | | `Cmd+Shift+V` | Панель переменных |
| `[ / ]` | Изменить порядок | | `Cmd+J` | AI-чат |
| Стрелки | Сдвиг на 1px | | `Cmd+,` | Настройки агента |
| `Cmd+Alt+U` | Булево объединение | | `Cmd+Alt+S` | Булево вычитание |
| `Cmd+Alt+I` | Булево пересечение | | | |
## Скрипты
@ -186,7 +189,7 @@ bun run electron:build # Упаковка Electron
- [x] Интеграция с MCP-сервером
- [x] Поддержка нескольких страниц
- [x] Импорт Figma `.fig`
- [ ] Булевы операции (объединение, вычитание, пересечение)
- [x] Булевы операции (объединение, вычитание, пересечение)
- [ ] Совместное редактирование
- [ ] Система плагинов
@ -198,7 +201,7 @@ bun run electron:build # Упаковка Electron
## Сообщество
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Присоединяйтесь к нашему Discord</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">เริ่มต้นอย่างรวดเร็ว</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">ฟีเจอร์</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">มีส่วนร่วม</a>
</p>
@ -89,6 +89,7 @@ OpenPencil ถูกสร้างขึ้นโดยมี AI เป็น
**Canvas และการวาด**
- Canvas ไม่จำกัดขนาดพร้อม pan, zoom, smart alignment guides และ snapping
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
- การดำเนินการบูลีน — รวม ลบ ตัดกัน พร้อมแถบเครื่องมือตามบริบท
- ตัวเลือก Icon (Iconify) และนำเข้ารูปภาพ (PNG/JPEG/SVG/WebP/GIF)
- Auto-layout — แนวตั้ง/แนวนอนพร้อม gap, padding, justify, align
- เอกสารหลายหน้าพร้อมการนำทางด้วย tab
@ -156,6 +157,8 @@ electron/
| `Del` | ลบ | | `Cmd+Shift+V` | Variables panel |
| `[ / ]` | เรียงลำดับ | | `Cmd+J` | AI chat |
| ลูกศร | เลื่อน 1px | | `Cmd+,` | Agent settings |
| `Cmd+Alt+U` | รวมบูลีน | | `Cmd+Alt+S` | ลบบูลีน |
| `Cmd+Alt+I` | ตัดกันบูลีน | | | |
## Scripts
@ -186,7 +189,7 @@ bun run electron:build # Electron package
- [x] การเชื่อมต่อ MCP server
- [x] รองรับหลายหน้า
- [x] นำเข้า Figma `.fig`
- [ ] Boolean operations (union, subtract, intersect)
- [x] Boolean operations (union, subtract, intersect)
- [ ] การแก้ไขร่วมกัน
- [ ] ระบบปลั๊กอิน
@ -198,7 +201,7 @@ bun run electron:build # Electron package
## ชุมชน
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> เข้าร่วม Discord ของเรา</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#hızlı-başlangıç">Hızlı Başlangıç</a> ·
<a href="#ai-destekli-tasarım">AI</a> ·
<a href="#özellikler">Özellikler</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#katkıda-bulunma">Katkıda Bulunma</a>
</p>
@ -89,6 +89,7 @@ OpenPencil, AI'yi bir eklenti olarak değil, temel iş akışı olarak sıfırda
**Kanvas ve Çizim**
- Kaydırma, yakınlaştırma, akıllı hizalama kılavuzları ve yakalamayı destekleyen sonsuz kanvas
- Dikdörtgen, Elips, Çizgi, Çokgen, Kalem (Bezier), Frame, Metin
- Boolean işlemler — birleştir, çıkar, kesiştir bağlamsal araç çubuğuyla
- Simge seçici (Iconify) ve görsel içe aktarma (PNG/JPEG/SVG/WebP/GIF)
- Otomatik düzen — boşluk, dolgu, justify, align ile dikey/yatay
- Sekme navigasyonlu çok sayfalı belgeler
@ -156,6 +157,8 @@ electron/
| `Del` | Sil | | `Cmd+Shift+V` | Değişkenler paneli |
| `[ / ]` | Yeniden sırala | | `Cmd+J` | AI sohbet |
| Oklar | 1px kaydır | | `Cmd+,` | Ajan ayarları |
| `Cmd+Alt+U` | Boolean birleştir | | `Cmd+Alt+S` | Boolean çıkar |
| `Cmd+Alt+I` | Boolean kesiştir | | | |
## Betikler
@ -186,7 +189,7 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md]
- [x] MCP sunucu entegrasyonu
- [x] Çok sayfa desteği
- [x] Figma `.fig` içe aktarma
- [ ] Boolean işlemler (birleştirme, çıkarma, kesişim)
- [x] Boolean işlemler (birleştirme, çıkarma, kesişim)
- [ ] Ortak düzenleme
- [ ] Eklenti sistemi
@ -198,7 +201,7 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md]
## Topluluk
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Discord'umuza katılın</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">Bắt đầu nhanh</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">Tính năng</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">Đóng góp</a>
</p>
@ -89,6 +89,7 @@ OpenPencil được xây dựng xung quanh AI từ nền tảng — không phả
**Canvas và Vẽ**
- Canvas vô hạn với pan, zoom, hướng dẫn căn chỉnh thông minh và snapping
- Hình chữ nhật, Hình ellipse, Đường thẳng, Đa giác, Bút (Bezier), Frame, Văn bản
- Phép toán Boolean — hợp nhất, trừ, giao nhau với thanh công cụ ngữ cảnh
- Trình chọn icon (Iconify) và nhập hình ảnh (PNG/JPEG/SVG/WebP/GIF)
- Auto-layout — dọc/ngang với gap, padding, justify, align
- Tài liệu nhiều trang với điều hướng bằng tab
@ -156,6 +157,8 @@ electron/
| `Del` | Xóa | | `Cmd+Shift+V` | Bảng biến |
| `[ / ]` | Sắp xếp lại | | `Cmd+J` | AI chat |
| Mũi tên | Dịch chuyển 1px | | `Cmd+,` | Cài đặt tác nhân |
| `Cmd+Alt+U` | Hợp nhất Boolean | | `Cmd+Alt+S` | Trừ Boolean |
| `Cmd+Alt+I` | Giao nhau Boolean | | | |
## Scripts
@ -186,7 +189,7 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v
- [x] Tích hợp máy chủ MCP
- [x] Hỗ trợ nhiều trang
- [x] Nhập Figma `.fig`
- [ ] Phép toán Boolean (hợp nhất, trừ, giao)
- [x] Phép toán Boolean (hợp nhất, trừ, giao)
- [ ] Chỉnh sửa cộng tác
- [ ] Hệ thống plugin
@ -198,7 +201,7 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v
## Cộng đồng
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> Tham gia Discord của chúng tôi</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">快速開始</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">功能特色</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">參與貢獻</a>
</p>
@ -78,6 +78,7 @@ OpenPencil 從底層就圍繞 AI 構建——不是作為外掛程式,而是
**畫布與繪圖**
- 無限畫布,支援平移、縮放、智慧對齊參考線和吸附
- 矩形、橢圓、直線、多邊形、鋼筆貝茲曲線、Frame、文字
- 布林運算 — 聯合、減去、交集,搭配上下文工具列
- 圖示選擇器Iconify和圖片匯入PNG/JPEG/SVG/WebP/GIF
- 自動版面配置 — 垂直/水平方向,支援間距、內邊距、主軸對齊、交叉軸對齊
- 多頁面文件,支援分頁導覽
@ -145,6 +146,8 @@ electron/
| `Del` | 刪除 | | `Cmd+Shift+V` | 變數面板 |
| `[ / ]` | 調整圖層順序 | | `Cmd+J` | AI 聊天 |
| 方向鍵 | 微移 1px | | `Cmd+,` | 智能體設定 |
| `Cmd+Alt+U` | 布林聯合 | | `Cmd+Alt+S` | 布林減去 |
| `Cmd+Alt+I` | 布林交集 | | | |
## 指令碼命令
@ -175,7 +178,7 @@ bun run electron:build # Electron 封裝
- [x] MCP 伺服器整合
- [x] 多頁面支援
- [x] Figma `.fig` 匯入
- [ ] 布林運算(聯集、減去、交集)
- [x] 布林運算(聯集、減去、交集)
- [ ] 協同編輯
- [ ] 外掛程式系統
@ -187,7 +190,7 @@ bun run electron:build # Electron 封裝
## 社群
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> 加入我們的 Discord</strong>
</a>

View file

@ -17,14 +17,14 @@
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">
<a href="#quick-start">快速开始</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">功能特性</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
<a href="#contributing">参与贡献</a>
</p>
@ -78,6 +78,7 @@ OpenPencil 从底层就围绕 AI 构建——不是作为插件,而是作为
**画布与绘图**
- 无限画布,支持平移、缩放、智能对齐参考线和吸附
- 矩形、椭圆、直线、多边形、钢笔贝塞尔、Frame、文本
- 布尔运算 — 联合、减去、交集,配合上下文工具栏
- 图标选择器Iconify和图片导入PNG/JPEG/SVG/WebP/GIF
- 自动布局 — 垂直/水平方向,支持间距、内边距、主轴对齐、交叉轴对齐
- 多页面文档,支持标签页导航
@ -145,6 +146,8 @@ electron/
| `Del` | 删除 | | `Cmd+Shift+V` | 变量面板 |
| `[ / ]` | 调整层级顺序 | | `Cmd+J` | AI 聊天 |
| 方向键 | 微移 1px | | `Cmd+,` | 智能体设置 |
| `Cmd+Alt+U` | 布尔联合 | | `Cmd+Alt+S` | 布尔减去 |
| `Cmd+Alt+I` | 布尔交集 | | | |
## 脚本命令
@ -175,7 +178,7 @@ bun run electron:build # Electron 打包
- [x] MCP 服务器集成
- [x] 多页面支持
- [x] Figma `.fig` 导入
- [ ] 布尔运算(合并、减去、相交)
- [x] 布尔运算(合并、减去、相交)
- [ ] 协同编辑
- [ ] 插件系统
@ -187,12 +190,16 @@ bun run electron:build # Electron 打包
## 社区
<a href="https://discord.gg/fE9STbMG">
<a href="https://discord.gg/KwXp6BJD">
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
<strong> 加入我们的 Discord</strong>
</a>
— 提问、分享设计、提出功能建议。
**飞书交流群**
<img src="./screenshot/557517811-62010928-d91a-4223-bc10-9ee7a4fbf043.jpg" alt="飞书交流群" width="240" />
## 许可证
[MIT](./LICENSE) — Copyright (c) 2026 ZSeven-W

View file

@ -31,12 +31,14 @@
"electron-updater": "^6.6.2",
"fabric": "^7.1.0",
"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",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.4",
@ -688,6 +690,8 @@
"balanced-match": ["balanced-match@4.0.3", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.3.tgz", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
@ -810,6 +814,8 @@
"crossws": ["crossws@0.4.4", "https://registry.npmmirror.com/crossws/-/crossws-0.4.4.tgz", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg=="],
"css-line-break": ["css-line-break@2.1.0", "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
"css-select": ["css-select@5.2.2", "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-tree": ["css-tree@3.1.0", "https://registry.npmmirror.com/css-tree/-/css-tree-3.1.0.tgz", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
@ -1060,6 +1066,8 @@
"html-parse-stringify": ["html-parse-stringify@3.0.1", "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html2canvas": ["html2canvas@1.4.1", "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"htmlparser2": ["htmlparser2@10.1.0", "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"http-cache-semantics": ["http-cache-semantics@4.2.0", "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
@ -1302,6 +1310,8 @@
"package-json-from-dist": ["package-json-from-dist@1.0.1", "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"paper": ["paper@0.12.18", "https://registry.npmmirror.com/paper/-/paper-0.12.18.tgz", {}, "sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw=="],
"parse5": ["parse5@8.0.0", "https://registry.npmmirror.com/parse5/-/parse5-8.0.0.tgz", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
@ -1540,6 +1550,8 @@
"temp-file": ["temp-file@3.4.0", "https://registry.npmmirror.com/temp-file/-/temp-file-3.4.0.tgz", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="],
"text-segmentation": ["text-segmentation@1.0.3", "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
"tiny-async-pool": ["tiny-async-pool@1.3.0", "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@ -1628,6 +1640,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"utrie": ["utrie@1.0.2", "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
"uzip": ["uzip@0.20201231.0", "https://registry.npmmirror.com/uzip/-/uzip-0.20201231.0.tgz", {}, "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng=="],
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],

View file

@ -50,6 +50,14 @@ linux:
- AppImage
- deb
fileAssociations:
- ext: op
name: OpenPencil Document
description: OpenPencil Design File
mimeType: application/x-openpencil
role: Editor
icon: build/icon
asar: true
publish:

View file

@ -20,9 +20,14 @@ let nitroProcess: ChildProcess | null = null
let serverPort = 0
let autoUpdateEnabled = true
let updateCheckTimer: ReturnType<typeof setInterval> | null = null
let pendingFilePath: string | null = null
const isDev = !app.isPackaged
const SETTINGS_PATH = join(homedir(), '.openpencil', 'settings.json')
// Settings stored in platform-standard app data dir (Electron-managed):
// macOS: ~/Library/Application Support/OpenPencil/
// Windows: %APPDATA%\OpenPencil\
// Linux: ~/.config/OpenPencil/
const SETTINGS_PATH = join(app.getPath('userData'), 'settings.json')
type UpdaterStatus =
| 'disabled'
@ -218,7 +223,7 @@ async function readAppSettings(): Promise<AppSettings> {
async function writeAppSettings(patch: Partial<AppSettings>): Promise<void> {
const current = await readAppSettings()
const merged = { ...current, ...patch }
await mkdir(PORT_FILE_DIR, { recursive: true })
await mkdir(app.getPath('userData'), { recursive: true })
await writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2), 'utf-8')
}
@ -237,8 +242,8 @@ async function writePortFile(port: number): Promise<void> {
JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }),
'utf-8',
)
} catch {
// Non-critical — MCP sync will fall back to file I/O
} catch (err) {
console.error('[port-file] Failed to write port file:', err)
}
}
@ -379,8 +384,9 @@ function createWindow(): void {
...(isWinOrLinux
? {
titleBarOverlay: {
color: 'rgba(0,0,0,0)',
symbolColor: '#a1a1aa',
// Windows supports transparent overlay; Linux needs solid color
color: process.platform === 'win32' ? 'rgba(0,0,0,0)' : '#111',
symbolColor: '#d4d4d8',
height: 36,
},
}
@ -389,6 +395,9 @@ function createWindow(): void {
preload: join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
// Persist localStorage/cookies in a fixed partition so data survives
// across random Nitro server port changes (origin-independent storage).
partition: 'persist:openpencil',
},
}
@ -523,17 +532,45 @@ function setupIPC(): void {
},
)
// Theme sync for Windows/Linux title bar overlay
ipcMain.handle('theme:set', (_event, theme: 'dark' | 'light') => {
if (!mainWindow || mainWindow.isDestroyed()) return
const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'
if (!isWinOrLinux) return
mainWindow.setTitleBarOverlay({
color: 'rgba(0,0,0,0)',
symbolColor: theme === 'dark' ? '#a1a1aa' : '#71717a',
})
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,
})
},
)
ipcMain.handle('updater:getState', () => updaterState)
ipcMain.handle('updater:checkForUpdates', async () => {
@ -699,6 +736,58 @@ function buildAppMenu(): void {
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
// ---------------------------------------------------------------------------
// File association: open .op files
// ---------------------------------------------------------------------------
/** Extract .op file path from command-line arguments. */
function getFilePathFromArgs(args: string[]): string | null {
for (const arg of args) {
if (arg.endsWith('.op') || arg.endsWith('.pen')) {
return arg
}
}
return null
}
/** Send a file path to the renderer for loading. */
function sendOpenFile(filePath: string): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('file:open', filePath)
} else {
pendingFilePath = filePath
}
}
// macOS: open-file fires when user double-clicks a .op file
app.on('open-file', (event, filePath) => {
event.preventDefault()
if (app.isReady()) {
sendOpenFile(filePath)
} else {
pendingFilePath = filePath
}
})
// Single instance lock (Windows/Linux: second instance passes file path as arg)
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', (_event, argv) => {
const filePath = getFilePathFromArgs(argv)
if (filePath) {
sendOpenFile(filePath)
}
// Focus existing window
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
@ -725,6 +814,13 @@ app.on('ready', async () => {
createWindow()
// Check for file to open: pending open-file event or CLI args (Windows/Linux).
// The file path is stored in pendingFilePath and pulled by the renderer
// via file:getPending IPC when the React app mounts (useElectronMenu hook).
if (!pendingFilePath) {
pendingFilePath = getFilePathFromArgs(process.argv)
}
if (!isDev) {
const settings = await readAppSettings()
autoUpdateEnabled = settings.autoUpdate !== false

View file

@ -28,7 +28,10 @@ export interface ElectronAPI {
) => Promise<string | null>
saveToPath: (filePath: string, content: string) => Promise<string>
onMenuAction: (callback: (action: string) => void) => () => void
setTheme: (theme: 'dark' | 'light') => void
onOpenFile: (callback: (filePath: string) => void) => () => void
readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null>
getPendingFile: () => Promise<string | null>
setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void
updater: {
getState: () => Promise<UpdaterState>
checkForUpdates: () => Promise<UpdaterState>
@ -50,7 +53,8 @@ const api: ElectronAPI = {
saveToPath: (filePath: string, content: string) =>
ipcRenderer.invoke('dialog:saveToPath', { filePath, content }),
setTheme: (theme: 'dark' | 'light') => ipcRenderer.invoke('theme:set', theme),
setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) =>
ipcRenderer.invoke('theme:set', theme, colors),
onMenuAction: (callback: (action: string) => void) => {
const listener = (_event: IpcRendererEvent, action: string) => {
@ -62,6 +66,20 @@ const api: ElectronAPI = {
}
},
onOpenFile: (callback: (filePath: string) => void) => {
const listener = (_event: IpcRendererEvent, filePath: string) => {
callback(filePath)
}
ipcRenderer.on('file:open', listener)
return () => {
ipcRenderer.removeListener('file:open', listener)
}
},
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
getPendingFile: () => ipcRenderer.invoke('file:getPending'),
updater: {
getState: () => ipcRenderer.invoke('updater:getState'),
checkForUpdates: () => ipcRenderer.invoke('updater:checkForUpdates'),

View file

@ -1,6 +1,6 @@
{
"name": "openpencil",
"version": "0.2.1",
"version": "0.3.0",
"description": "Open-source vector design tool with Design-as-Code philosophy",
"author": {
"name": "ZSeven-W",
@ -54,12 +54,14 @@
"electron-updater": "^6.6.2",
"fabric": "^7.1.0",
"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",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View file

@ -117,10 +117,11 @@ async function connectCodexCli(): Promise<ConnectResult> {
const { join } = await import('node:path')
// Check if codex binary exists
const which = execSync('which codex 2>/dev/null || echo ""', {
const whichCmd = process.platform === 'win32' ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""'
const which = execSync(whichCmd, {
encoding: 'utf-8',
timeout: 5000,
}).trim()
}).trim().split(/\r?\n/)[0]?.trim() ?? ''
if (!which) {
return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' }

View file

@ -63,8 +63,20 @@ function toImageBase64(data: string): string {
async function withTempImageFile<T>(
imageBase64: string,
run: (tempPath: string) => Promise<T>,
insideProject = false,
): Promise<T> {
const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-'))
let tempDir: string
if (insideProject) {
// Save inside the project directory so Claude Code Agent SDK (plan mode)
// can read the file — it restricts reads to the project directory.
const { mkdirSync, chmodSync } = await import('node:fs')
const baseDir = join(process.cwd(), '.openpencil-tmp')
mkdirSync(baseDir, { recursive: true })
chmodSync(baseDir, 0o700)
tempDir = await mkdtemp(join(baseDir, 'validate-'))
} else {
tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-'))
}
const tempPath = join(tempDir, 'screenshot.png')
try {
await writeFile(tempPath, Buffer.from(toImageBase64(imageBase64), 'base64'))
@ -75,8 +87,10 @@ async function withTempImageFile<T>(
}
/**
* Agent SDK fallback: save screenshot to a temp PNG file, then ask Claude
* Code to read it (Claude Code's Read tool supports images natively).
* Agent SDK: save screenshot to a temp PNG file inside the project directory,
* then ask Claude Code to read it (Claude Code's Read tool supports images
* natively). Must use insideProject=true because plan mode restricts reads
* to the project directory.
*/
async function validateViaAgentSDK(
body: ValidateBody,
@ -87,23 +101,23 @@ async function validateViaAgentSDK(
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
const claudePath = resolveClaudeCli()
// Prompt Claude Code to read the temp image and analyze it
const prompt = `Read the image file at "${tempPath}" and analyze it as a UI design screenshot.
const prompt = `IMPORTANT: First, use the Read tool to read the image file at "${tempPath}". This is a PNG screenshot of a UI design.
${body.message}
After viewing the image, analyze it according to these instructions:
${body.system}
Output ONLY the JSON object, no markdown fences, no explanation.`
${body.message}
CRITICAL: Your ENTIRE response must be a single JSON object. No markdown, no explanation, no tool calls after reading the image. Just the JSON.`
const q = query({
prompt,
options: {
...(model ? { model } : {}),
maxTurns: 2,
maxTurns: 3,
tools: [],
plugins: [],
permissionMode: 'plan',
@ -131,7 +145,7 @@ Output ONLY the JSON object, no markdown fences, no explanation.`
}
return { text: '', skipped: true }
})
}, true)
}
async function validateViaCodex(

View file

@ -0,0 +1,48 @@
/**
* Nitro plugin writes ~/.openpencil/.port on server startup so the MCP
* server can discover the running instance (dev server or Electron).
*
* In Electron production mode the main process also writes this file,
* but this plugin ensures the dev server (`bun --bun run dev`) is
* discoverable too.
*/
import { writeFile, mkdir, unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { homedir } from 'node:os'
const PORT_FILE_DIR = join(homedir(), '.openpencil')
const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port')
async function writePortFile(port: number): Promise<void> {
try {
await mkdir(PORT_FILE_DIR, { recursive: true })
await writeFile(
PORT_FILE_PATH,
JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }),
'utf-8',
)
} catch {
// Non-critical — MCP sync will fall back to file I/O
}
}
async function cleanupPortFile(): Promise<void> {
try {
await unlink(PORT_FILE_PATH)
} catch {
// Ignore if already removed
}
}
export default () => {
const port = parseInt(process.env.PORT || '3000', 10)
writePortFile(port)
const cleanup = () => {
cleanupPortFile()
}
process.on('beforeExit', cleanup)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
}

View file

@ -29,7 +29,12 @@ const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000
* Only passes through safe system vars and provider-specific prefixes.
* Prevents leaking secrets like ANTHROPIC_API_KEY, AWS_SECRET_KEY, GITHUB_TOKEN, etc.
*/
const CODEX_ENV_ALLOWLIST = new Set(['PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR'])
const CODEX_ENV_ALLOWLIST = new Set([
'PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR',
// Windows-essential vars
'SYSTEMROOT', 'COMSPEC', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
'PATHEXT', 'SYSTEMDRIVE', 'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH',
])
export function filterCodexEnv(
env: Record<string, string | undefined>,
@ -146,6 +151,8 @@ async function executeCodexCommand(
const child = spawn('codex', args, {
env: filterCodexEnv(process.env as Record<string, string | undefined>),
stdio: ['ignore', 'pipe', 'pipe'],
// On Windows, npm-installed CLIs are .cmd scripts — need shell to resolve them
...(process.platform === 'win32' && { shell: true }),
})
let stdoutBuffer = ''

View file

@ -1,9 +1,14 @@
import { execSync } from 'node:child_process'
const isWindows = process.platform === 'win32'
/** Resolve the standalone copilot CLI binary path to avoid Bun's node:sqlite issue */
export function resolveCopilotCli(): string | undefined {
try {
const path = execSync('which copilot 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim()
const cmd = isWindows ? 'where copilot 2>nul' : 'which copilot 2>/dev/null'
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()
return path || undefined
} catch {
return undefined

View file

@ -1,10 +1,28 @@
import { fork, type ChildProcess } from 'node:child_process'
import { fork, execSync, type ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { networkInterfaces } from 'node:os'
import { resolve } from 'node:path'
import { join, resolve } from 'node:path'
let mcpProcess: ChildProcess | null = null
let mcpPort: number | null = null
/** Resolve the MCP server script path across dev, web build, and Electron production. */
function resolveMcpServerScript(): string {
// Electron production: extraResources
const electronResources = process.env.ELECTRON_RESOURCES_PATH
if (electronResources) {
const p = join(electronResources, 'mcp-server.cjs')
if (existsSync(p)) return p
}
// dev + web build
const fromCwd = resolve(process.cwd(), 'dist', 'mcp-server.cjs')
if (existsSync(fromCwd)) return fromCwd
// Fallback: relative to this file (Nitro bundled output)
const fromFile = resolve(__dirname, '..', '..', '..', 'dist', 'mcp-server.cjs')
if (existsSync(fromFile)) return fromFile
return fromCwd
}
/** Get the first non-internal IPv4 address (LAN IP). */
export function getLocalIp(): string | null {
const nets = networkInterfaces()
@ -32,7 +50,7 @@ export function startMcpHttpServer(port: number): { running: boolean; port: numb
return { running: true, port: mcpPort!, localIp: getLocalIp() }
}
const serverScript = resolve(process.cwd(), 'dist/mcp-server.cjs')
const serverScript = resolveMcpServerScript()
try {
mcpProcess = fork(serverScript, ['--http', '--port', String(port)], {
@ -60,7 +78,17 @@ export function startMcpHttpServer(port: number): { running: boolean; port: numb
export function stopMcpHttpServer(): { running: false } {
if (mcpProcess) {
mcpProcess.kill('SIGTERM')
if (process.platform === 'win32') {
// SIGTERM is unreliable on Windows; use taskkill for proper cleanup
try {
const pid = mcpProcess.pid
if (pid) {
execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' })
}
} catch { /* ignore */ }
} else {
mcpProcess.kill('SIGTERM')
}
mcpProcess = null
mcpPort = null
}

View file

@ -1,8 +1,10 @@
import { execSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { homedir, platform } from 'node:os'
import { join } from 'node:path'
const isWindows = platform() === 'win32'
/**
* Resolve the absolute path to the standalone `claude` binary.
*
@ -13,23 +15,31 @@ import { join } from 'node:path'
* binaries and spawns them directly (no `node` wrapper needed).
*/
export function resolveClaudeCli(): string | undefined {
// 1. Try `which claude` (works when PATH is correctly set)
// 1. Try PATH lookup
try {
const p = execSync('which claude 2>/dev/null', {
const cmd = isWindows ? 'where claude' : 'which claude 2>/dev/null'
const p = execSync(cmd, {
encoding: 'utf-8',
timeout: 3000,
}).trim()
}).trim().split(/\r?\n/)[0] // `where` on Windows may return multiple lines
if (p && existsSync(p)) return p
} catch { /* not in PATH */ }
// 2. Common install locations
const candidates = [
join(homedir(), '.local', 'bin', 'claude'),
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
]
const candidates = isWindows
? [
join(process.env.LOCALAPPDATA || '', 'Programs', 'claude-code', 'claude.exe'),
join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'claude.exe'),
join(homedir(), '.claude', 'local', 'claude.exe'),
join(homedir(), 'AppData', 'Local', 'Programs', 'claude-code', 'claude.exe'),
]
: [
join(homedir(), '.local', 'bin', 'claude'),
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
]
for (const c of candidates) {
if (existsSync(c)) return c
if (c && existsSync(c)) return c
}
return undefined

View file

@ -3,9 +3,11 @@ import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
import {
parseSizing,
estimateTextWidth,
estimateTextWidthPrecise,
estimateTextHeight,
estimateLineWidth,
getTextOpticalCenterYOffset,
defaultLineHeight,
} from './canvas-text-measure'
// ---------------------------------------------------------------------------
@ -175,7 +177,11 @@ export function getNodeWidth(node: PenNode, parentAvail?: number): number {
typeof node.content === 'string'
? node.content
: node.content.map((s) => s.text).join('')
return Math.max(Math.ceil(estimateTextWidth(content, fontSize, letterSpacing)), 20)
// Use precise estimation (no safety factor) for fit-content / natural-width
// text. IText auto-computes its own width and ignores ours, so the safety
// margin only inflates the layout allocation, making the text appear
// left-shifted within its overwide box.
return Math.max(Math.ceil(estimateTextWidthPrecise(content, fontSize, letterSpacing)), 20)
}
return 0
}
@ -299,19 +305,16 @@ export function computeLayoutPositions(
const childCross = isVertical ? size.w : size.h
let crossPos = 0
// For text nodes, use the actual Fabric-rendered height for cross-axis
// centering instead of the declared height. Fabric.js text height =
// fontSize * lineHeight, which is typically smaller than the AI-declared
// height, causing text to appear shifted upward when centered.
// For text nodes in horizontal layout with center alignment, use the actual
// Fabric-rendered height (fontSize * lineHeight) instead of the declared
// height, since Fabric text is shorter than AI-declared height.
let effectiveChildCross = childCross
if (align === 'center' && child.type === 'text') {
const fontSize = child.fontSize ?? 16
const lineHeight = ('lineHeight' in child ? child.lineHeight : undefined) ?? 1.2
const lineHeight = ('lineHeight' in child ? child.lineHeight : undefined) ?? defaultLineHeight(fontSize)
const visualH = fontSize * lineHeight
if (!isVertical && visualH < childCross) {
effectiveChildCross = visualH
} else if (isVertical && visualH < childCross) {
// vertical layout: cross axis is width, not applicable
}
}
@ -340,8 +343,8 @@ export function computeLayoutPositions(
crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
}
const computedX = isVertical ? pad.left + crossPos : pad.left + mainPos
const computedY = isVertical ? pad.top + mainPos : pad.top + crossPos
const computedX = Math.round(isVertical ? pad.left + crossPos : pad.left + mainPos)
const computedY = Math.round(isVertical ? pad.top + mainPos : pad.top + crossPos)
mainPos += (isVertical ? size.h : size.w) + effectiveGap
@ -355,6 +358,35 @@ export function computeLayoutPositions(
width: size.w,
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) {
out.width = availW
out.x = Math.round(pad.left)
out.textAlign = 'center'
}
}
// For fit-content text nodes in horizontal layouts, set textAlign:'center'
// to compensate for width estimation inaccuracy. The estimated box is
// typically slightly wider than the actual rendered text, so left-aligned
// text appears visually shifted left. Centering the text within its box
// distributes the error evenly on both sides.
if (!isVertical && child.type === 'text') {
const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left'
const widthMode = 'width' in child ? parseSizing(child.width) : 0
const isFitContent = widthMode === 'fit' || widthMode === 0
if (!hasExplicitAlign && isFitContent) {
out.textAlign = 'center'
}
}
return out as unknown as PenNode
})
}

View file

@ -14,6 +14,7 @@ import {
DEFAULT_STROKE_WIDTH,
SELECTION_BLUE,
} from './canvas-constants'
import { defaultLineHeight } from './canvas-text-measure'
import { applyRotationControls } from './canvas-controls'
function angleToCoords(
@ -150,7 +151,13 @@ function shouldSplitByGrapheme(text: string): boolean {
function isFixedWidthText(node: PenNode): boolean {
if (node.type !== 'text') return false
return node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height'
if (node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height') return true
// When textAlign is not 'left', the layout engine injected centering.
// IText ignores width and computes its own, making textAlign ineffective
// for single-line text. Use Textbox so the width is respected and
// textAlign:'center' actually centers the text within the box.
if (node.textAlign && node.textAlign !== 'left') return true
return false
}
function sizeToNumber(
@ -425,7 +432,7 @@ export function createFabricObject(
textAlign: node.textAlign ?? 'left',
underline: node.underline ?? false,
linethrough: node.strikethrough ?? false,
lineHeight: node.lineHeight ?? 1.2,
lineHeight: node.lineHeight ?? defaultLineHeight(node.fontSize ?? 16),
charSpacing: node.letterSpacing
? (node.letterSpacing / (node.fontSize || 16)) * 1000
: 0,

View file

@ -14,6 +14,20 @@ export function parseSizing(value: unknown): number | 'fit' | 'fill' {
return isNaN(n) ? 0 : n
}
// ---------------------------------------------------------------------------
// Default line height — single source of truth for all modules
// ---------------------------------------------------------------------------
/**
* Canonical default lineHeight when a text node has no explicit value.
* Display/heading text (>=28px) gets tighter spacing; body text gets looser.
* All modules (factory, layout engine, text estimation, AI generation)
* MUST use this function instead of hardcoded fallbacks.
*/
export function defaultLineHeight(fontSize: number): number {
return fontSize >= 28 ? 1.2 : 1.5
}
// ---------------------------------------------------------------------------
// CJK detection
// ---------------------------------------------------------------------------
@ -85,6 +99,19 @@ export function estimateTextWidth(text: string, fontSize: number, letterSpacing
return maxLine
}
/**
* Estimate text width WITHOUT safety factor.
* Used for layout centering where the safety margin causes text to appear
* off-center (the overestimated width shifts the text box left when centered).
* For wrapping/sizing decisions, use estimateTextWidth() which includes the safety factor.
*/
export function estimateTextWidthPrecise(text: string, fontSize: number, letterSpacing = 0): number {
const lines = text.split(/\r?\n/)
return lines.reduce((max, line) => {
return Math.max(max, estimateLineWidth(line, fontSize, letterSpacing))
}, 0)
}
// ---------------------------------------------------------------------------
// Text content helpers
// ---------------------------------------------------------------------------
@ -108,7 +135,8 @@ export function countExplicitTextLines(text: string): number {
/**
* Optical vertical correction for centered single-line text.
* Font line boxes are mathematically centered but glyph ink tends to look
* slightly top-heavy, especially for CJK, so we nudge down by 1-3px.
* slightly top-heavy, especially for CJK, so we nudge down proportionally.
* The offset scales with fontSize (no fixed cap) so large text stays centered.
*/
export function getTextOpticalCenterYOffset(node: PenNode): number {
if (node.type !== 'text') return 0
@ -117,13 +145,16 @@ export function getTextOpticalCenterYOffset(node: PenNode): number {
if (countExplicitTextLines(text) > 1) return 0
const fontSize = node.fontSize ?? 16
const lineHeight = node.lineHeight ?? 1.2
const lineHeight = node.lineHeight ?? defaultLineHeight(fontSize)
const hasCjk = hasCjkText(text)
const ratio = hasCjk ? 0.16 : 0.1
const compactLineBoost = lineHeight <= 1.35 ? 1 : 0.7
// Base ratio: CJK glyphs sit higher in the em box than Latin
const ratio = hasCjk ? 0.12 : 0.07
// When lineHeight is compact (≤1.35), the visual offset is more pronounced
const compactLineBoost = lineHeight <= 1.35 ? 1 : 0.65
const offset = fontSize * ratio * compactLineBoost
return Math.max(1, Math.min(5, Math.round(offset)))
// Proportional cap: never exceed 8% of fontSize, minimum 1px
return Math.max(1, Math.min(Math.round(fontSize * 0.08), Math.round(offset)))
}
// ---------------------------------------------------------------------------
@ -135,7 +166,7 @@ export function estimateTextHeight(node: PenNode, availableWidth?: number): numb
// Access text-specific properties via Record to avoid union type issues
const n = node as unknown as Record<string, unknown>
const fontSize = (typeof n.fontSize === 'number' ? n.fontSize : 16)
const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : 1.2)
const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : defaultLineHeight(fontSize))
const singleLineH = fontSize * lineHeight
// Get text content

View file

@ -3,13 +3,13 @@
*
* Visual effects:
* 1. Soft colored border on nodes being generated (breathing opacity)
* 2. Agent badge next to the frame name with a spinning dot animation
* 3. Preview fill tint on nodes that haven't materialized yet
* 2. Breathing glow border around agent-owned frames (same color as badge)
* 3. Agent badge right-aligned above the frame with a spinning dot animation
* 4. Preview fill tint on nodes that haven't materialized yet
*/
import { useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import {
getActiveAgentIndicators,
getActiveAgentFrames,
@ -23,7 +23,6 @@ const BADGE_FONT_SIZE = 11
const BADGE_PAD_X = 6
const BADGE_PAD_Y = 3
const BADGE_RADIUS = 4
const BADGE_GAP = 6
const DOT_RADIUS = 3
export function useAgentIndicators() {
@ -106,25 +105,39 @@ export function useAgentIndicators() {
}
}
// ---- Draw agent badges next to frame names ----
// ---- Draw agent frame glow borders + badges ----
for (const frame of agentFrames.values()) {
const obj = objMap.get(frame.frameId)
if (!obj) continue
const corners = obj.getCoords()
const x = Math.min(...corners.map((p) => p.x))
const y = Math.min(...corners.map((p) => p.y))
const xs = corners.map((p) => p.x)
const ys = corners.map((p) => p.y)
const fx = Math.min(...xs)
const fy = Math.min(...ys)
const fw = Math.max(...xs) - fx
const fh = Math.max(...ys) - fy
if (fw < 1 || fh < 1) continue
// Measure the existing frame label width to position badge after it
// --- Breathing glow border around the frame ---
const glowWidth = 1.5 / zoom
ctx.save()
ctx.strokeStyle = frame.color
ctx.lineWidth = glowWidth
ctx.globalAlpha = breath * 0.8
// Outer glow (wider, more transparent)
ctx.shadowColor = frame.color
ctx.shadowBlur = 8 / zoom
ctx.strokeRect(fx, fy, fw, fh)
// Inner crisp border (no shadow)
ctx.shadowColor = 'transparent'
ctx.shadowBlur = 0
ctx.globalAlpha = breath
ctx.strokeRect(fx, fy, fw, fh)
ctx.restore()
// --- Badge: right-aligned above the frame ---
const fontSize = BADGE_FONT_SIZE / zoom
const labelFontSize = 12 / zoom
ctx.font = `500 ${labelFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
const docStore = useDocumentStore.getState()
const frameNode = docStore.getNodeById(frame.frameId)
const frameName = frameNode?.name ?? frameNode?.type ?? ''
const labelWidth = frameName ? ctx.measureText(frameName).width + BADGE_GAP / zoom : 0
// Badge position: right of frame label, above frame
const padX = BADGE_PAD_X / zoom
const padY = BADGE_PAD_Y / zoom
const radius = BADGE_RADIUS / zoom
@ -133,12 +146,12 @@ export function useAgentIndicators() {
ctx.font = `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
const textWidth = ctx.measureText(frame.name).width
// Space for spinning dot + text
const dotSpace = dotR * 2 + 4 / zoom
const badgeW = dotSpace + textWidth + padX * 2
const badgeH = fontSize + padY * 2
const badgeX = x + labelWidth
const badgeY = y - labelOffsetY - badgeH
// Right-align badge to frame's right edge
const badgeX = fx + fw - badgeW
const badgeY = fy - labelOffsetY - badgeH
// Badge background (pill shape)
ctx.globalAlpha = 0.9

View file

@ -467,11 +467,13 @@ export function useCanvasSync() {
// class/shape data changes (e.g. IText↔Textbox).
// Path `d` changes are handled in-place by syncFabricObject.
let objectRecreated = false
// Text nodes may need recreation when textGrowth mode changes
// Text nodes may need recreation when textGrowth mode or textAlign changes
// (IText ↔ Textbox are different Fabric classes).
// Must match isFixedWidthText() in canvas-object-factory.ts.
if (existingObj && node.type === 'text') {
const growth = node.textGrowth
const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height'
|| (node.textAlign != null && node.textAlign !== 'left')
const isTextbox = existingObj instanceof fabric.Textbox
if (needsTextbox !== isTextbox) {
canvas.remove(existingObj)

View file

@ -0,0 +1,88 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
SquaresUnite,
SquaresSubtract,
SquaresIntersect,
} from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import type { PenNode } from '@/types/pen'
import {
canBooleanOp,
executeBooleanOp,
type BooleanOpType,
} from '@/utils/boolean-ops'
const OPS = [
{ type: 'union' as BooleanOpType, icon: SquaresUnite, labelKey: 'layerMenu.booleanUnion', shortcut: '\u2318\u2325U' },
{ type: 'subtract' as BooleanOpType, icon: SquaresSubtract, labelKey: 'layerMenu.booleanSubtract', shortcut: '\u2318\u2325S' },
{ type: 'intersect' as BooleanOpType, icon: SquaresIntersect, labelKey: 'layerMenu.booleanIntersect', shortcut: '\u2318\u2325I' },
] as const
export default function BooleanToolbar() {
const { t } = useTranslation()
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
const nodes = selectedIds
.map((id) => useDocumentStore.getState().getNodeById(id))
.filter((n): n is PenNode => n != null)
const show = canBooleanOp(nodes)
const handleOp = useCallback((opType: BooleanOpType) => {
const { selectedIds } = useCanvasStore.getState().selection
const currentNodes = selectedIds
.map((id) => useDocumentStore.getState().getNodeById(id))
.filter((n): n is PenNode => n != null)
if (!canBooleanOp(currentNodes)) return
const result = executeBooleanOp(currentNodes, opType)
if (!result) return
useHistoryStore.getState().pushState(useDocumentStore.getState().document)
for (const id of selectedIds) {
useDocumentStore.getState().removeNode(id)
}
useDocumentStore.getState().addNode(null, result)
useCanvasStore.getState().setSelection([result.id], result.id)
const canvas = useCanvasStore.getState().fabricCanvas
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
}, [])
if (!show) return null
return (
<div className="absolute top-2 left-14 z-10 bg-card border border-border rounded-xl flex items-center py-1 px-1 gap-0.5 shadow-lg">
{OPS.map((op) => (
<Tooltip key={op.type}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleOp(op.type)}
aria-label={t(op.labelKey)}
className="inline-flex items-center justify-center h-7 w-7 rounded-lg transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
>
<op.icon size={16} strokeWidth={1.5} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t(op.labelKey)}
<kbd className="ml-1.5 inline-flex h-4 items-center rounded border border-border/50 bg-muted px-1 font-mono text-[10px] text-muted-foreground">
{op.shortcut}
</kbd>
</TooltipContent>
</Tooltip>
))}
</div>
)
}

View file

@ -2,11 +2,11 @@ import { lazy, Suspense, useState, useCallback, useEffect } from 'react'
import { TooltipProvider } from '@/components/ui/tooltip'
import TopBar from './top-bar'
import Toolbar from './toolbar'
import BooleanToolbar from './boolean-toolbar'
import StatusBar from './status-bar'
import LayerPanel from '@/components/panels/layer-panel'
import PropertyPanel from '@/components/panels/property-panel'
import RightPanel from '@/components/panels/right-panel'
import AIChatPanel, { AIChatMinimizedBar } from '@/components/panels/ai-chat-panel'
import CodePanel from '@/components/panels/code-panel'
import VariablesPanel from '@/components/panels/variables-panel'
import ComponentBrowserPanel from '@/components/panels/component-browser-panel'
import ExportDialog from '@/components/shared/export-dialog'
@ -36,17 +36,12 @@ export default function EditorLayout() {
useCanvasStore.getState().setFigmaImportDialogOpen(false)
}, [])
const browserOpen = useUIKitStore((s) => s.browserOpen)
const codePanelOpen = useCanvasStore((s) => s.codePanelOpen)
const saveDialogOpen = useDocumentStore((s) => s.saveDialogOpen)
const closeSaveDialog = useCallback(() => {
useDocumentStore.getState().setSaveDialogOpen(false)
}, [])
const [exportOpen, setExportOpen] = useState(false)
const toggleCodePanel = useCallback(() => {
useCanvasStore.getState().toggleCodePanel()
}, [])
const closeExport = useCallback(() => {
setExportOpen(false)
}, [])
@ -62,10 +57,10 @@ export default function EditorLayout() {
return
}
// Cmd+Shift+C: toggle code panel
// Cmd+Shift+C: switch right panel to code tab
if (isMod && e.shiftKey && e.key.toLowerCase() === 'c') {
e.preventDefault()
toggleCodePanel()
useCanvasStore.getState().setRightPanelTab('code')
return
}
@ -106,7 +101,7 @@ export default function EditorLayout() {
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [toggleMinimize, toggleCodePanel])
}, [toggleMinimize])
// Handle Electron native menu actions
useElectronMenu()
@ -144,6 +139,7 @@ export default function EditorLayout() {
<FabricCanvas />
</Suspense>
<Toolbar />
<BooleanToolbar />
{/* Floating variables panel — anchored to the right of the toolbar */}
{variablesPanelOpen && <VariablesPanel />}
@ -164,9 +160,8 @@ export default function EditorLayout() {
{/* Expanded AI panel (floating, draggable) */}
<AIChatPanel />
</div>
{hasSelection && <PropertyPanel />}
{hasSelection && <RightPanel />}
</div>
{codePanelOpen && <CodePanel onClose={() => useCanvasStore.getState().setCodePanelOpen(false)} />}
</div>
<ExportDialog open={exportOpen} onClose={closeExport} />
<SaveDialog open={saveDialogOpen} onClose={closeSaveDialog} />

View file

@ -40,6 +40,21 @@ import { zoomToFitContent } from '@/canvas/use-fabric-canvas'
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import type { AIProviderType } from '@/types/agent-settings'
/** Convert a computed CSS color value (oklch/rgb/etc.) to #rrggbb via an offscreen canvas. */
function cssToHex(raw: string): string | null {
const v = raw.trim()
if (!v) return null
try {
const ctx = document.createElement('canvas').getContext('2d')
if (!ctx) return null
ctx.fillStyle = v
const hex = ctx.fillStyle // browser normalises to #rrggbb
return hex.startsWith('#') ? hex : null
} catch {
return null
}
}
const PROVIDER_ICONS: Record<AIProviderType, ComponentType<SVGProps<SVGSVGElement>>> = {
anthropic: ClaudeLogo,
openai: OpenAILogo,
@ -123,6 +138,18 @@ export default function TopBar() {
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
const [isFullscreen, setIsFullscreen] = useState(false)
// Read computed CSS --card and --card-foreground as hex for Electron overlay
const syncOverlayColors = useCallback((t: 'dark' | 'light') => {
if (!window.electronAPI?.setTheme) return
// Allow a frame for CSS to apply after class toggle
requestAnimationFrame(() => {
const s = getComputedStyle(document.documentElement)
const bg = cssToHex(s.getPropertyValue('--card'))
const fg = cssToHex(s.getPropertyValue('--card-foreground'))
window.electronAPI!.setTheme(t, bg && fg ? { bg, fg } : undefined)
})
}, [])
// Restore saved theme after hydration
useEffect(() => {
try {
@ -130,14 +157,14 @@ export default function TopBar() {
if (saved === 'light') {
document.documentElement.classList.add('light')
setTheme('light')
window.electronAPI?.setTheme?.('light')
syncOverlayColors('light')
} else {
window.electronAPI?.setTheme?.('dark')
syncOverlayColors('dark')
}
} catch {
// ignore
}
}, [])
}, [syncOverlayColors])
// Listen to fullscreen changes
useEffect(() => {
@ -154,13 +181,13 @@ export default function TopBar() {
document.documentElement.classList.remove('light')
}
setTheme(next)
window.electronAPI?.setTheme?.(next)
syncOverlayColors(next)
try {
localStorage.setItem('openpencil-theme', next)
} catch {
// ignore
}
}, [theme])
}, [theme, syncOverlayColors])
const toggleFullscreen = useCallback(() => {
if (document.fullscreenElement) {

View file

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'
import { Pencil, ChevronDown, Check } from 'lucide-react'
import { Pencil, ChevronDown, Check, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
import {
@ -8,6 +8,13 @@ import {
buildPipelineProgress,
} from './chat-message'
/** Parse [done]/[pending]/[error] prefix from a detail line */
function parseDetailStatus(line: string): { status: 'done' | 'pending' | 'error' | null; text: string } {
const match = line.match(/^\[(done|pending|error)\]\s*(.*)$/)
if (match) return { status: match[1] as 'done' | 'pending' | 'error', text: match[2] }
return { status: null, text: line }
}
/** Fixed collapsible checklist pinned between messages and input */
export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessageType[]; isStreaming: boolean }) {
const [collapsed, setCollapsed] = useState(false)
@ -27,7 +34,7 @@ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessag
const planSteps = steps.filter((s) => s.title !== 'Thinking')
if (planSteps.length === 0) return []
const jsonCount = countDesignJsonBlocks(content)
const isApplied = content.includes('\u2705') || content.includes('<!-- APPLIED -->')
const isApplied = content.includes('\u2705') || content.includes('<!-- APPLIED -->') || content.includes('[done] Applied')
const hasError = /\*\*Error:\*\*/i.test(content)
return buildPipelineProgress(planSteps, jsonCount, isStreaming, isApplied, hasError)
}, [lastAssistant, isStreaming])
@ -64,27 +71,54 @@ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessag
{!collapsed && (
<div className="px-3 pb-2.5 flex max-h-44 flex-col gap-1 overflow-y-auto">
{items.map((item, index) => (
<div key={`${item.label}-${index}`} className="flex items-center gap-2 text-[11px] text-muted-foreground/90">
<span
className={cn(
'w-3.5 h-3.5 rounded-full border flex items-center justify-center shrink-0',
item.done
? 'border-emerald-500/70 text-emerald-500/80'
: item.active
? 'border-primary/70 text-primary'
: 'border-border/70 text-muted-foreground/50',
)}
>
{item.done ? (
<Check size={9} strokeWidth={2.5} />
) : (
<span className={cn(
'w-1.5 h-1.5 rounded-full',
item.active ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60',
)} />
)}
</span>
<span className={cn(item.active ? 'text-foreground' : '')}>{item.label}</span>
<div key={`${item.label}-${index}`} className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/90">
<span
className={cn(
'w-3.5 h-3.5 rounded-full border flex items-center justify-center shrink-0',
item.done
? 'border-emerald-500/70 text-emerald-500/80'
: item.active
? 'border-primary/70 text-primary'
: 'border-border/70 text-muted-foreground/50',
)}
>
{item.done ? (
<Check size={9} strokeWidth={2.5} />
) : (
<span className={cn(
'w-1.5 h-1.5 rounded-full',
item.active ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60',
)} />
)}
</span>
<span className={cn(item.active ? 'text-foreground' : '')}>{item.label}</span>
</div>
{item.details && item.details.length > 0 && (
<div className="ml-[22px] flex flex-col gap-px">
{item.details.map((line, di) => {
const { status, text } = parseDetailStatus(line)
return (
<span key={di} className="flex items-center gap-1.5 text-[10px] text-muted-foreground/70">
{status === 'done' && (
<span className="w-2.5 h-2.5 rounded-full border border-emerald-500/70 text-emerald-500/80 flex items-center justify-center shrink-0">
<Check size={7} strokeWidth={2.5} />
</span>
)}
{status === 'pending' && (
<span className="w-2.5 h-2.5 rounded-full border border-primary/70 flex items-center justify-center shrink-0">
<span className="w-1 h-1 rounded-full bg-primary animate-pulse" />
</span>
)}
{status === 'error' && (
<AlertTriangle size={10} className="text-amber-500/80 shrink-0" />
)}
<span>{text}</span>
</span>
)
})}
</div>
)}
</div>
))}
</div>

View file

@ -15,20 +15,44 @@ import { trimChatHistory } from '@/services/ai/context-optimizer'
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config'
/** Detect if a message is a design generation request */
export function isDesignRequest(text: string): boolean {
const lower = text.toLowerCase()
const designKeywords = [
'生成', '设计', '创建', '画', '做一个', '来一个', '弄一个',
'generate', 'create', 'design', 'make', 'build', 'draw',
'add a', 'add an', 'place a', 'insert',
'界面', '页面', 'screen', 'page', 'layout', 'component',
'按钮', '卡片', '导航', '表单', '输入框', '列表',
'button', 'card', 'nav', 'form', 'input', 'list',
'header', 'footer', 'sidebar', 'modal', 'dialog',
'login', 'signup', 'dashboard', 'profile',
]
return designKeywords.some((kw) => lower.includes(kw))
/** Intent classification prompt — lightweight LLM call to determine message routing */
const CLASSIFY_PROMPT = `You are a UI design tool assistant. Classify the user's message intent.
Reply with EXACTLY one of these tags, nothing else:
- DESIGN user wants to create, generate, or modify any UI element, component, screen, or page
- CHAT user is asking a question, seeking help, or having a conversation`
/** Classify user intent via a lightweight LLM call instead of hardcoded keyword matching */
async function classifyIntent(
text: string,
model: string,
provider?: string,
): Promise<{ isDesign: boolean }> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8_000)
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system: CLASSIFY_PROMPT,
message: text,
model,
provider,
}),
signal: controller.signal,
})
clearTimeout(timeout)
if (!response.ok) throw new Error('classify failed')
const data = await response.json()
const upper = (data.text ?? '').trim().toUpperCase()
return { isDesign: upper.includes('DESIGN') }
} catch {
// Fallback: in a design tool, default to design mode
return { isDesign: true }
}
}
export function buildContextString(): string {
@ -96,8 +120,6 @@ export function useChatHandlers() {
// Determine context and mode
const selectedIds = useCanvasStore.getState().selection.selectedIds
const hasSelection = selectedIds.length > 0
const isDesign = isDesignRequest(messageText)
const isModification = isDesign && hasSelection
const context = buildContextString()
const fullUserMessage = messageText + context
@ -141,11 +163,19 @@ export function useChatHandlers() {
let accumulated = ''
let appliedCount = 0
let isDesign = false
const abortController = new AbortController()
useAIStore.getState().setAbortController(abortController)
try {
// Classify intent via lightweight LLM call
const classified = await classifyIntent(
messageText, model, currentProvider,
)
isDesign = classified.isDesign
const isModification = isDesign && hasSelection
if (isDesign) {
if (isModification) {
// --- MODIFICATION MODE ---
@ -177,6 +207,7 @@ export function useChatHandlers() {
model,
provider: currentProvider,
concurrency,
mode: 'visual-ref',
context: {
canvasSize: { width: 1200, height: 800 },
documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`,

View file

@ -143,16 +143,31 @@ export function countDesignJsonBlocks(text: string): number {
return count
}
export interface PipelineItem {
label: string
done: boolean
active: boolean
/** Optional detail lines (e.g. validation log) */
details?: string[]
}
export function buildPipelineProgress(
steps: ParsedStep[],
jsonBlockCount: number,
isStreaming: boolean,
isApplied: boolean,
hasError: boolean,
): Array<{ label: string; done: boolean; active: boolean }> {
): PipelineItem[] {
// No steps = no checklist
if (steps.length === 0) return []
// Parse detail lines from step content (one line per entry)
function extractDetails(content: string): string[] | undefined {
if (!content) return undefined
const lines = content.split('\n').map((l) => l.trim()).filter(Boolean)
return lines.length > 0 ? lines : undefined
}
// If steps have explicit status (orchestrator mode), use that directly.
// Check this BEFORE terminal result logic so that user-stopped generations
// preserve the actual per-step status instead of marking everything done.
@ -162,13 +177,14 @@ export function buildPipelineProgress(
label: s.title,
done: s.status === 'done',
active: isStreaming && s.status === 'streaming',
details: extractDetails(s.content),
}))
}
// If generation is complete and applied, mark all steps done
const hasTerminalResult = !isStreaming && !hasError && (isApplied || jsonBlockCount > 0)
if (hasTerminalResult) {
return steps.map((s) => ({ label: s.title, done: true, active: false }))
return steps.map((s) => ({ label: s.title, done: true, active: false, details: extractDetails(s.content) }))
}
// Fallback: Map each step to done/active/pending based on completed JSON blocks.
@ -177,7 +193,7 @@ export function buildPipelineProgress(
return steps.map((s, index) => {
const done = index < jsonBlockCount
const active = isStreaming && !done && index === jsonBlockCount
return { label: s.title, done, active }
return { label: s.title, done, active, details: extractDetails(s.content) }
})
}

View file

@ -1,22 +1,95 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { Copy, Check, X } from 'lucide-react'
import { Copy, Check, Sparkles, Loader2, RotateCcw, Download, ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
import { useAIStore } from '@/stores/ai-store'
import { streamChat } from '@/services/ai/ai-service'
import { generateReactCode } from '@/services/codegen/react-generator'
import { generateHTMLCode } from '@/services/codegen/html-generator'
import { generateVueCode } from '@/services/codegen/vue-generator'
import { generateSvelteCode } from '@/services/codegen/svelte-generator'
import { generateSwiftUICode } from '@/services/codegen/swiftui-generator'
import { generateComposeCode } from '@/services/codegen/compose-generator'
import { generateFlutterCode } from '@/services/codegen/flutter-generator'
import { generateReactNativeCode } from '@/services/codegen/react-native-generator'
import { generateCSSVariables } from '@/services/codegen/css-variables-generator'
import { highlightCode } from '@/utils/syntax-highlight'
import type { PenNode } from '@/types/pen'
type CodeTab = 'react' | 'html' | 'css-vars'
type CodeTab = 'react' | 'vue' | 'svelte' | 'html' | 'swiftui' | 'compose' | 'flutter' | 'react-native' | 'css-vars'
export default function CodePanel({ onClose }: { onClose: () => void }) {
const ENHANCE_SYSTEM_PROMPT = `You are a code rewriter. You receive auto-generated UI code and rewrite it to be idiomatic, production-ready, and RESPONSIVE.
CRITICAL: Your ENTIRE response must be ONLY the improved source code. Nothing else.
- Do NOT include explanations, commentary, reasoning, or thinking.
- Do NOT include markdown fences (\`\`\`), XML tags, or tool calls.
- Do NOT prefix with "Here is" or any preamble.
- Start your response with the first line of code and end with the last line.
Rewriting rules:
- Preserve visual fidelity the output must look identical to the input design on desktop.
- Use semantic HTML where appropriate (nav, header, main, section, article, etc.).
- Replace absolute pixel positioning with proper layout (flexbox/grid) where possible.
- Use meaningful class/variable names derived from the node names.
- Keep the same framework and language as the input.
- For CSS-in-JS or scoped styles, keep styles co-located.
- For SwiftUI/Compose/Flutter, use idiomatic patterns (SwiftUI: adaptive stacks, Compose: adaptive layouts, Flutter: LayoutBuilder/MediaQuery).
- Do not add functionality, interactivity, or state beyond what exists.
- If design variables are present as var(--name), preserve them.
RESPONSIVE DESIGN (CRITICAL make the output adapt gracefully across screen sizes):
- Convert fixed pixel widths to relative units (%, max-w-*, flex-1, w-full) where appropriate. Keep max-width constraints for readability.
- Use responsive Tailwind breakpoints (sm:, md:, lg:) for layout changes:
- Multi-column rows (horizontal layouts) should stack vertically on small screens (flex-col sm:flex-row or md:flex-row).
- Grid layouts: use responsive column counts (grid-cols-1 sm:grid-cols-2 lg:grid-cols-3).
- Adjust padding/gap with responsive variants (p-4 md:p-8 lg:p-12).
- Font sizes: scale down on mobile (text-2xl md:text-4xl lg:text-5xl).
- Images: use w-full max-w-* with aspect ratio preservation, never fixed px width.
- Container: use max-w-7xl mx-auto px-4 (or similar) for centered content with side padding.
- Navigation: consider collapsible patterns on small screens.
- For HTML+CSS: use @media queries with mobile-first breakpoints (min-width: 640px, 768px, 1024px).
- For Vue/Svelte: apply the same responsive CSS/class strategies.
- Do NOT break the design on desktop while making it responsive desktop must remain visually faithful.`
/** Strip markdown fences, reasoning preamble, and tool-call XML from AI output. */
function cleanEnhancedResult(raw: string): string {
let result = raw
// Strip everything before the first code-like line if the AI prepended reasoning
// Detect patterns like "I'll start...", "<tool_call>", "Let me..."
const reasoningPatterns = /^(I['']ll |Let me |Here['']s |Sure|Okay|<tool_call>|<tool_name>)/im
if (reasoningPatterns.test(result)) {
// Try to find the start of actual code after the reasoning
// Look for common code markers: import, export, <, struct, @, class, fun, <!DOCTYPE
const codeStart = result.search(/^(import |export |<[!a-zA-Z]|struct |@Composable|@override|class |fun |<!DOCTYPE|package )/m)
if (codeStart > 0) {
result = result.slice(codeStart)
}
}
// Strip markdown fences
if (result.startsWith('```')) {
const firstNewline = result.indexOf('\n')
const lastFence = result.lastIndexOf('```')
if (lastFence > firstNewline) {
result = result.slice(firstNewline + 1, lastFence).trimEnd()
}
}
return result
}
export default function CodePanel() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<CodeTab>('react')
const [copied, setCopied] = useState(false)
const [enhancedCode, setEnhancedCode] = useState<Record<string, string>>({})
const [isEnhancing, setIsEnhancing] = useState(false)
const enhanceAbortRef = useRef<AbortController | null>(null)
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
const activePageId = useCanvasStore((s) => s.activePageId)
@ -38,107 +111,351 @@ export default function CodePanel({ onClose }: { onClose: () => void }) {
const document = useDocumentStore((s) => s.document)
const generatedCode = useMemo(() => {
if (activeTab === 'css-vars') {
return generateCSSVariables(document)
switch (activeTab) {
case 'css-vars': return generateCSSVariables(document)
case 'react': return generateReactCode(targetNodes)
case 'vue': return generateVueCode(targetNodes)
case 'svelte': return generateSvelteCode(targetNodes)
case 'swiftui': return generateSwiftUICode(targetNodes)
case 'compose': return generateComposeCode(targetNodes)
case 'flutter': return generateFlutterCode(targetNodes)
case 'react-native': return generateReactNativeCode(targetNodes)
case 'html': {
const { html, css } = generateHTMLCode(targetNodes)
return `<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>Design</title>\n <style>\n${css.split('\n').map((l) => ` ${l}`).join('\n')}\n </style>\n</head>\n<body>\n${html.split('\n').map((l) => ` ${l}`).join('\n')}\n</body>\n</html>`
}
}
if (activeTab === 'react') {
return generateReactCode(targetNodes)
}
const { html, css } = generateHTMLCode(targetNodes)
return `<!-- HTML -->\n${html}\n\n/* CSS */\n${css}`
}, [activeTab, targetNodes, document])
// Use enhanced code if available for this tab, otherwise the generated code
const displayCode = enhancedCode[activeTab] ?? generatedCode
const highlightedHTML = useMemo(() => {
if (activeTab === 'css-vars') {
return highlightCode(generatedCode, 'css')
const langMap: Record<CodeTab, Parameters<typeof highlightCode>[1]> = {
react: 'jsx',
vue: 'html',
svelte: 'html',
swiftui: 'swift',
compose: 'kotlin',
flutter: 'dart',
'react-native': 'jsx',
'css-vars': 'css',
html: 'html',
}
if (activeTab === 'react') {
return highlightCode(generatedCode, 'jsx')
// HTML / Vue / Svelte: split at <style to highlight CSS portion separately
if (activeTab === 'html' || activeTab === 'vue' || activeTab === 'svelte') {
const styleIdx = displayCode.indexOf('<style')
if (styleIdx !== -1) {
const templatePart = displayCode.slice(0, styleIdx)
const stylePart = displayCode.slice(styleIdx)
// Highlight the style tag line as HTML, then contents as CSS
const styleTagEnd = stylePart.indexOf('>\n')
if (styleTagEnd !== -1) {
const styleTag = stylePart.slice(0, styleTagEnd + 1)
const styleBody = stylePart.slice(styleTagEnd + 1)
const closingIdx = styleBody.lastIndexOf('</style>')
if (closingIdx !== -1) {
const cssContent = styleBody.slice(0, closingIdx)
const closingTag = styleBody.slice(closingIdx)
return (
highlightCode(templatePart, 'html') +
highlightCode(styleTag, 'html') + '\n' +
highlightCode(cssContent, 'css') +
highlightCode(closingTag, 'html')
)
}
}
return highlightCode(templatePart, 'html') + highlightCode(stylePart, 'css')
}
}
// Split HTML and CSS sections for mixed highlighting
const htmlEnd = generatedCode.indexOf('\n\n/* CSS */')
if (htmlEnd === -1) return highlightCode(generatedCode, 'html')
const htmlPart = generatedCode.slice(0, htmlEnd)
const cssPart = generatedCode.slice(htmlEnd + 2)
return highlightCode(htmlPart, 'html') + '\n\n' + highlightCode(cssPart, 'css')
}, [activeTab, generatedCode])
return highlightCode(displayCode, langMap[activeTab])
}, [activeTab, displayCode])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(generatedCode).then(() => {
navigator.clipboard.writeText(displayCode).then(() => {
setCopied(true)
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
})
}, [generatedCode])
}, [displayCode])
const handleDownload = useCallback(() => {
const extMap: Record<CodeTab, string> = {
react: 'tsx',
vue: 'vue',
svelte: 'svelte',
html: 'html',
swiftui: 'swift',
compose: 'kt',
flutter: 'dart',
'react-native': 'tsx',
'css-vars': 'css',
}
const ext = extMap[activeTab]
const blob = new Blob([displayCode], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = globalThis.document.createElement('a')
a.href = url
a.download = `design.${ext}`
a.click()
URL.revokeObjectURL(url)
}, [activeTab, displayCode])
const hasAI = useAIStore((s) => s.availableModels.length > 0)
const handleEnhance = useCallback(async () => {
if (isEnhancing || activeTab === 'css-vars') 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
setIsEnhancing(true)
const abortController = new AbortController()
enhanceAbortRef.current = abortController
// Build a compact node summary for context
const nodesSummary = JSON.stringify(
targetNodes.map((n) => {
const base: Record<string, unknown> = { type: n.type, name: n.name }
if ('width' in n) base.width = n.width
if ('height' in n) base.height = n.height
if ('children' in n && Array.isArray(n.children)) base.childCount = n.children.length
if ('layout' in n) base.layout = n.layout
return base
}),
)
const frameworkNames: Record<CodeTab, string> = {
react: 'React with Tailwind CSS',
vue: 'Vue 3 SFC',
svelte: 'Svelte',
html: 'HTML + CSS',
swiftui: 'SwiftUI',
compose: 'Jetpack Compose (Kotlin)',
flutter: 'Flutter (Dart)',
'react-native': 'React Native',
'css-vars': '',
}
const userMessage = `Framework: ${frameworkNames[activeTab]}
Design nodes (JSON summary):
${nodesSummary}
Auto-generated code to improve:
${generatedCode}`
try {
let result = ''
for await (const chunk of streamChat(
ENHANCE_SYSTEM_PROMPT,
[{ role: 'user', content: userMessage }],
model,
{ thinkingMode: 'disabled', effort: 'high' },
provider,
abortController.signal,
)) {
if (chunk.type === 'text') {
result += chunk.content
setEnhancedCode((prev) => ({ ...prev, [activeTab]: result }))
}
if (chunk.type === 'error') break
}
// Clean up artifacts the AI may have included despite instructions
result = cleanEnhancedResult(result)
setEnhancedCode((prev) => ({ ...prev, [activeTab]: result }))
} finally {
setIsEnhancing(false)
enhanceAbortRef.current = null
}
}, [isEnhancing, activeTab, generatedCode, targetNodes])
const handleCancelEnhance = useCallback(() => {
enhanceAbortRef.current?.abort()
setIsEnhancing(false)
}, [])
const handleResetEnhance = useCallback(() => {
setEnhancedCode((prev) => {
const next = { ...prev }
delete next[activeTab]
return next
})
}, [activeTab])
// Clear enhanced code when nodes change
useEffect(() => {
setEnhancedCode({})
}, [targetNodes])
useEffect(() => {
return () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
enhanceAbortRef.current?.abort()
}
}, [])
const tabs: { key: CodeTab; labelKey: string }[] = [
{ key: 'react', labelKey: 'code.reactTailwind' },
{ key: 'html', labelKey: 'code.htmlCss' },
{ key: 'css-vars', labelKey: 'code.cssVariables' },
const tabs: { key: CodeTab; label: string }[] = [
{ key: 'react', label: 'React' },
{ key: 'vue', label: 'Vue' },
{ key: 'svelte', label: 'Svelte' },
{ key: 'html', label: 'HTML' },
{ key: 'swiftui', label: 'SwiftUI' },
{ key: 'compose', label: 'Compose' },
{ key: 'flutter', label: 'Flutter' },
{ key: 'react-native', label: 'RN' },
{ key: 'css-vars', label: t('code.cssVariables') },
]
const tabsScrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
const updateScrollState = useCallback(() => {
const el = tabsScrollRef.current
if (!el) return
setCanScrollLeft(el.scrollLeft > 1)
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
}, [])
useEffect(() => {
const el = tabsScrollRef.current
if (!el) return
updateScrollState()
el.addEventListener('scroll', updateScrollState, { passive: true })
const ro = new ResizeObserver(updateScrollState)
ro.observe(el)
return () => {
el.removeEventListener('scroll', updateScrollState)
ro.disconnect()
}
}, [updateScrollState])
const scrollTabs = useCallback((dir: 'left' | 'right') => {
tabsScrollRef.current?.scrollBy({ left: dir === 'left' ? -80 : 80, behavior: 'smooth' })
}, [])
return (
<div className="bg-card border-t border-border flex flex-col" style={{ height: 280 }}>
{/* Header */}
<div className="h-8 flex items-center px-3 border-b border-border shrink-0">
<div className="flex items-center gap-2 flex-1">
<div className="flex flex-col flex-1 min-h-0">
{/* Framework tabs + action buttons */}
<div className="flex items-center pl-1 pr-2 py-1 border-b border-border shrink-0 gap-0.5">
{canScrollLeft && (
<button type="button" onClick={() => scrollTabs('left')} className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground">
<ChevronLeft size={10} />
</button>
)}
<div ref={tabsScrollRef} className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-none px-1">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={cn(
'text-xs px-2 py-1 rounded transition-colors',
'text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 whitespace-nowrap',
activeTab === tab.key
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t(tab.labelKey)}
{tab.label}
</button>
))}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
title={t('code.copyClipboard')}
>
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
title={t('code.closeCodePanel')}
>
<X size={14} />
</Button>
{canScrollRight && (
<button type="button" onClick={() => scrollTabs('right')} className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground">
<ChevronRight size={10} />
</button>
)}
<div className="flex items-center gap-0.5 shrink-0">
{hasAI && activeTab !== 'css-vars' && (
<>
{enhancedCode[activeTab] && !isEnhancing && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleResetEnhance}
className="text-muted-foreground h-5 w-5"
>
<RotateCcw size={11} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('code.resetEnhance')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={isEnhancing ? handleCancelEnhance : handleEnhance}
className={cn(
'h-5 w-5',
isEnhancing && 'text-primary',
enhancedCode[activeTab] && !isEnhancing && 'text-primary',
)}
>
{isEnhancing ? <Loader2 size={12} className="animate-spin" /> : <Sparkles size={12} />}
</Button>
</TooltipTrigger>
<TooltipContent>{isEnhancing ? t('code.cancelEnhance') : t('code.aiEnhance')}</TooltipContent>
</Tooltip>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleDownload}
className="h-5 w-5"
>
<Download size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('code.download')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
className="h-5 w-5"
>
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t('code.copied') : t('code.copyClipboard')}</TooltipContent>
</Tooltip>
</div>
</div>
{/* Code content */}
<div className="flex-1 overflow-auto p-3">
<pre className="text-xs leading-relaxed font-mono text-foreground/80 whitespace-pre">
<div className="flex-1 overflow-auto p-2">
<pre className="text-[10px] leading-relaxed font-mono text-foreground/80 whitespace-pre-wrap break-all">
<code dangerouslySetInnerHTML={{ __html: highlightedHTML }} />
</pre>
</div>
{/* Footer info */}
<div className="h-6 flex items-center px-3 border-t border-border shrink-0">
<span className="text-[10px] text-muted-foreground">
{activeTab === 'css-vars'
? t('code.genCssVars')
: selectedIds.length > 0
? t('code.genSelected', { count: selectedIds.length })
: t('code.genDocument')}
<div className="h-5 flex items-center px-2 border-t border-border shrink-0">
<span className="text-[9px] text-muted-foreground">
{isEnhancing
? t('code.enhancing')
: enhancedCode[activeTab]
? t('code.enhanced')
: activeTab === 'css-vars'
? t('code.genCssVars')
: selectedIds.length > 0
? t('code.genSelected', { count: selectedIds.length })
: t('code.genDocument')}
</span>
</div>
</div>

View file

@ -8,6 +8,9 @@ import {
EyeOff,
Component,
Unlink,
SquaresUnite,
SquaresSubtract,
SquaresIntersect,
} from 'lucide-react'
interface LayerContextMenuProps {
@ -15,6 +18,7 @@ interface LayerContextMenuProps {
y: number
nodeId: string
canGroup: boolean
canBoolean: boolean
canCreateComponent: boolean
isReusable: boolean
isInstance: boolean
@ -26,6 +30,9 @@ const MENU_ITEMS = [
{ action: 'duplicate', labelKey: 'common.duplicate', icon: Copy },
{ action: 'delete', labelKey: 'common.delete', icon: Trash2 },
{ action: 'group', labelKey: 'layerMenu.groupSelection', icon: Group, requireGroup: true },
{ action: 'boolean-union', labelKey: 'layerMenu.booleanUnion', icon: SquaresUnite, requireBoolean: true },
{ action: 'boolean-subtract', labelKey: 'layerMenu.booleanSubtract', icon: SquaresSubtract, requireBoolean: true },
{ action: 'boolean-intersect', labelKey: 'layerMenu.booleanIntersect', icon: SquaresIntersect, requireBoolean: true },
{ action: 'make-component', labelKey: 'layerMenu.createComponent', icon: Component, requireCreateComponent: true },
{ action: 'detach-component', labelKey: 'layerMenu.detachComponent', icon: Unlink, requireReusable: true },
{ action: 'detach-component', labelKey: 'layerMenu.detachInstance', icon: Unlink, requireInstance: true },
@ -37,6 +44,7 @@ export default function LayerContextMenu({
x,
y,
canGroup,
canBoolean,
canCreateComponent,
isReusable,
isInstance,
@ -72,6 +80,7 @@ export default function LayerContextMenu({
{MENU_ITEMS.filter(
(item) =>
(!item.requireGroup || canGroup) &&
(!('requireBoolean' in item) || canBoolean) &&
(!('requireCreateComponent' in item) || canCreateComponent) &&
(!('requireReusable' in item) || isReusable) &&
(!('requireInstance' in item) || isInstance),

View file

@ -5,6 +5,8 @@ import { useCanvasStore } from '@/stores/canvas-store'
import { setSkipNextDepthResolve } from '@/canvas/use-canvas-selection'
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
import type { PenNode } from '@/types/pen'
import { useHistoryStore } from '@/stores/history-store'
import { canBooleanOp, executeBooleanOp, type BooleanOpType } from '@/utils/boolean-ops'
import LayerItem from './layer-item'
import type { DropPosition } from './layer-item'
import LayerContextMenu from './layer-context-menu'
@ -12,6 +14,10 @@ import PageTabs from '@/components/editor/page-tabs'
const CONTAINER_TYPES = new Set(['frame', 'group', 'ref'])
const LAYER_MIN_WIDTH = 180
const LAYER_MAX_WIDTH = 480
const LAYER_DEFAULT_WIDTH = 224 // w-56
interface DragState {
dragId: string | null
overId: string | null
@ -121,6 +127,38 @@ function collectCollapsibleNodeIds(
export default function LayerPanel() {
const { t } = useTranslation()
const [panelWidth, setPanelWidth] = useState(LAYER_DEFAULT_WIDTH)
const isDraggingResize = useRef(false)
const resizeStartX = useRef(0)
const resizeStartWidth = useRef(0)
const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
isDraggingResize.current = true
resizeStartX.current = e.clientX
resizeStartWidth.current = panelWidth
const handleMouseMove = (ev: MouseEvent) => {
if (!isDraggingResize.current) return
const delta = ev.clientX - resizeStartX.current
const newWidth = Math.max(LAYER_MIN_WIDTH, Math.min(LAYER_MAX_WIDTH, resizeStartWidth.current + delta))
setPanelWidth(newWidth)
}
const handleMouseUp = () => {
isDraggingResize.current = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [panelWidth])
const activePageId = useCanvasStore((s) => s.activePageId)
const children = useDocumentStore((s) => getActivePageChildren(s.document, activePageId))
const updateNode = useDocumentStore((s) => s.updateNode)
@ -355,6 +393,24 @@ export default function LayerPanel() {
case 'detach-component':
detachComponent(nodeId)
break
case 'boolean-union':
case 'boolean-subtract':
case 'boolean-intersect': {
const opType = action.replace('boolean-', '') as BooleanOpType
const nodes = selectedIds
.map((id) => getNodeById(id))
.filter((n): n is PenNode => n != null)
if (canBooleanOp(nodes)) {
const result = executeBooleanOp(nodes, opType)
if (result) {
useHistoryStore.getState().pushState(useDocumentStore.getState().document)
for (const id of selectedIds) removeNode(id)
useDocumentStore.getState().addNode(null, result)
setSelection([result.id], result.id)
}
}
break
}
}
setContextMenu(null)
},
@ -369,6 +425,7 @@ export default function LayerPanel() {
setSelection,
makeReusable,
detachComponent,
getNodeById,
],
)
@ -385,7 +442,12 @@ export default function LayerPanel() {
}
return (
<div className="w-56 bg-card border-r border-border flex flex-col shrink-0">
<div className="bg-card border-r border-border flex flex-col shrink-0 relative" style={{ width: panelWidth }}>
{/* Resize handle */}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 z-10"
onMouseDown={handleResizeMouseDown}
/>
<PageTabs />
<div className="h-8 flex items-center px-3 border-b border-border">
<span className="text-xs font-medium text-muted-foreground tracking-wider">
@ -420,12 +482,16 @@ export default function LayerPanel() {
? 'reusable' in contextNode && contextNode.reusable === true
: false
const nodeIsInstance = contextNode?.type === 'ref'
const booleanNodes = selectedIds
.map((id) => getNodeById(id))
.filter((n): n is PenNode => n != null)
return (
<LayerContextMenu
x={contextMenu.x}
y={contextMenu.y}
nodeId={contextMenu.nodeId}
canGroup={selectedIds.length >= 2}
canBoolean={canBooleanOp(booleanNodes)}
canCreateComponent={isContainer && !nodeIsReusable}
isReusable={nodeIsReusable}
isInstance={nodeIsInstance}

View file

@ -24,7 +24,7 @@ const INSTANCE_DIRECT_PROPS = new Set([
'x', 'y', 'width', 'height', 'name', 'visible', 'locked', 'rotation', 'opacity', 'flipX', 'flipY', 'enabled', 'theme',
])
export default function PropertyPanel() {
export default function PropertyPanel({ embedded }: { embedded?: boolean } = {}) {
const { t } = useTranslation()
const activeId = useCanvasStore((s) => s.selection.activeId)
const setSelection = useCanvasStore((s) => s.setSelection)
@ -158,10 +158,10 @@ export default function PropertyPanel() {
}
}
return (
<div className="w-64 bg-card border-l border-border flex flex-col shrink-0">
const content = (
<>
{/* Header */}
<div className="h-8 flex items-center px-2 border-b border-border gap-1">
<div className="h-8 flex items-center px-2 border-b border-border gap-1 shrink-0">
{(nodeIsReusable || nodeIsInstance) && (
<Diamond size={12} className={`shrink-0 ${nodeIsReusable ? 'text-purple-400' : 'text-[#9281f7]'}`} />
)}
@ -364,6 +364,14 @@ export default function PropertyPanel() {
<ExportSection nodeId={node.id} nodeName={node.name ?? node.type} />
</div>
</div>
</>
)
if (embedded) return content
return (
<div className="w-64 bg-card border-l border-border flex flex-col shrink-0">
{content}
</div>
)
}

View file

@ -0,0 +1,90 @@
import { useState, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useCanvasStore } from '@/stores/canvas-store'
import type { RightPanelTab } from '@/stores/canvas-store'
import PropertyPanel from './property-panel'
import CodePanel from './code-panel'
const MIN_WIDTH = 256 // 16rem (w-64)
const MAX_WIDTH = 640 // 40rem
const DEFAULT_WIDTH = 256
export default function RightPanel() {
const { t } = useTranslation()
const activeTab = useCanvasStore((s) => s.rightPanelTab)
const setTab = useCanvasStore((s) => s.setRightPanelTab)
const [width, setWidth] = useState(DEFAULT_WIDTH)
const isDragging = useRef(false)
const startX = useRef(0)
const startWidth = useRef(0)
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
isDragging.current = true
startX.current = e.clientX
startWidth.current = width
const handleMouseMove = (ev: MouseEvent) => {
if (!isDragging.current) return
// Dragging left border: moving mouse left => wider
const delta = startX.current - ev.clientX
const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startWidth.current + delta))
setWidth(newWidth)
}
const handleMouseUp = () => {
isDragging.current = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [width])
const tabs: { key: RightPanelTab; label: string }[] = [
{ key: 'design', label: t('rightPanel.design') },
{ key: 'code', label: t('rightPanel.code') },
]
return (
<div className="bg-card border-l border-border flex flex-col shrink-0 relative" style={{ width }}>
{/* Resize handle */}
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 z-10"
onMouseDown={handleMouseDown}
/>
{/* Tab bar */}
<div className="h-8 flex items-center px-2 border-b border-border shrink-0 gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setTab(tab.key)}
className={cn(
'text-[11px] font-medium px-2 py-0.5 rounded transition-colors',
activeTab === tab.key
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
{activeTab === 'design' ? (
<PropertyPanel embedded />
) : (
<CodePanel />
)}
</div>
)
}

View file

@ -211,7 +211,7 @@ function ProviderRow({ type }: { type: AIProviderType }) {
<div className="group">
<div
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
'flex items-center gap-2.5 px-3 py-1.5 rounded-lg transition-colors',
provider.isConnected
? 'bg-secondary/40'
: 'hover:bg-secondary/30',
@ -220,11 +220,11 @@ function ProviderRow({ type }: { type: AIProviderType }) {
{/* Icon */}
<div
className={cn(
'w-7 h-7 rounded-md flex items-center justify-center shrink-0 transition-colors',
'w-6 h-6 rounded-md flex items-center justify-center shrink-0 transition-colors',
provider.isConnected ? 'bg-foreground/10 text-foreground' : 'bg-secondary text-muted-foreground',
)}
>
<Icon className="w-4 h-4" />
<Icon className="w-3.5 h-3.5" />
</div>
{/* Name + description */}
@ -305,7 +305,7 @@ export default function AgentSettingsDialog() {
const [mcpError, setMcpError] = useState<string | null>(null)
const [mcpServerLoading, setMcpServerLoading] = useState(false)
const [mcpServerError, setMcpServerError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [configCopied, setConfigCopied] = useState(false)
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true)
const [isElectron, setIsElectron] = useState(false)
@ -364,11 +364,16 @@ export default function AgentSettingsDialog() {
}
}, [mcpServerRunning, mcpHttpPort, setMcpServerStatus, t])
const handleCopyLanUrl = useCallback(() => {
const handleCopyConfig = useCallback(() => {
if (!mcpServerLocalIp) return
navigator.clipboard.writeText(`http://${mcpServerLocalIp}:${mcpHttpPort}/mcp`)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
const config = JSON.stringify(
{ type: 'http', url: `http://${mcpServerLocalIp}:${mcpHttpPort}/mcp` },
null,
2,
)
navigator.clipboard.writeText(config)
setConfigCopied(true)
setTimeout(() => setConfigCopied(false), 2000)
}, [mcpServerLocalIp, mcpHttpPort])
const handleToggleMCP = useCallback(
@ -421,7 +426,7 @@ export default function AgentSettingsDialog() {
className="relative bg-card rounded-xl border border-border w-[480px] max-h-[80vh] overflow-hidden shadow-xl flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-4 pb-3">
<div className="flex items-center justify-between px-5 pt-3 pb-2">
<h3 className="text-sm font-semibold text-foreground">
{t('agents.title')}
</h3>
@ -435,10 +440,10 @@ export default function AgentSettingsDialog() {
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-5 pb-5">
<div className="flex-1 overflow-y-auto px-5 pb-4">
{/* Agents section */}
<div className="mb-5">
<div className="flex items-center gap-2 mb-2 px-1">
<div className="mb-3">
<div className="flex items-center gap-2 mb-1 px-1">
<Zap size={12} className="text-muted-foreground" />
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{t('agents.agentsOnCanvas')}
@ -453,17 +458,17 @@ export default function AgentSettingsDialog() {
</div>
{/* Divider */}
<div className="h-px bg-border mb-5" />
<div className="h-px bg-border mb-3" />
{/* MCP Server section */}
<div className="mb-5">
<div className="flex items-center gap-2 mb-3 px-1">
<div className="mb-3">
<div className="flex items-center gap-2 mb-1.5 px-1">
<Globe size={12} className="text-muted-foreground" />
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{t('agents.mcpServer')}
</h4>
</div>
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-secondary/30">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-secondary/30">
{/* Status indicator */}
<div
className={cn(
@ -507,19 +512,19 @@ export default function AgentSettingsDialog() {
</Button>
</div>
{mcpServerRunning && mcpServerLocalIp && (
<div className="flex items-center gap-2 mt-2 px-3 py-2 rounded-lg bg-secondary/20">
<span className="text-[10px] text-muted-foreground shrink-0">{t('agents.mcpLanAccess')}</span>
<code className="text-[11px] text-foreground font-mono flex-1 truncate">
http://{mcpServerLocalIp}:{mcpHttpPort}/mcp
</code>
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopyLanUrl}
className="shrink-0 h-6 w-6"
>
{copied ? <Check size={10} className="text-green-500" /> : <Copy size={10} />}
</Button>
<div className="mt-1.5 px-3 py-1.5 rounded-lg bg-secondary/20">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{t('agents.mcpClientConfig')}</span>
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopyConfig}
className="shrink-0 h-5 w-5"
>
{configCopied ? <Check size={9} className="text-green-500" /> : <Copy size={9} />}
</Button>
</div>
<code className="text-[10px] text-muted-foreground font-mono select-all leading-none">{`{ "type": "http", "url": "http://${mcpServerLocalIp}:${mcpHttpPort}/mcp" }`}</code>
</div>
)}
{mcpServerError && (
@ -531,23 +536,23 @@ export default function AgentSettingsDialog() {
</div>
{/* Divider */}
<div className="h-px bg-border mb-5" />
<div className="h-px bg-border mb-3" />
{/* MCP integrations section */}
<div>
<div className="flex items-center gap-2 mb-3 px-1">
<div className="flex items-center gap-2 mb-1.5 px-1">
<Terminal size={12} className="text-muted-foreground" />
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{t('agents.mcpIntegrations')}
</h4>
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5">
<div className="grid grid-cols-2 gap-x-2 gap-y-0">
{mcpIntegrations.map((m) => (
<div
key={m.tool}
className={cn(
'flex items-center justify-between py-2 px-3 rounded-lg transition-colors',
'flex items-center justify-between py-1.5 px-3 rounded-lg transition-colors',
m.enabled ? 'bg-secondary/40' : 'hover:bg-secondary/20',
)}
>
@ -579,7 +584,7 @@ export default function AgentSettingsDialog() {
<p className="text-[10px] text-destructive">{mcpError}</p>
</div>
)}
<p className="text-[10px] text-muted-foreground/60 mt-3 px-1">
<p className="text-[10px] text-muted-foreground/60 mt-2 px-1">
{t('agents.mcpRestart')}
</p>
</div>
@ -587,7 +592,7 @@ export default function AgentSettingsDialog() {
{/* Auto-update toggle (Electron only) */}
{isElectron && (
<>
<div className="h-px bg-border my-5" />
<div className="h-px bg-border my-3" />
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<RefreshCw size={12} className="text-muted-foreground" />

View file

@ -4,6 +4,7 @@ import { useDocumentStore } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import { zoomToFitContent } from '@/canvas/use-fabric-canvas'
import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync'
import { normalizePenDocument } from '@/utils/normalize-pen-file'
import {
supportsFileSystemAccess,
writeToFileHandle,
@ -22,6 +23,29 @@ export function useElectronMenu() {
const api = window.electronAPI
if (!api?.onMenuAction) return
const loadFileFromPath = (filePath: string) => {
api.readFile?.(filePath).then((result) => {
if (!result) return
try {
const raw = JSON.parse(result.content)
if (!raw.version || !Array.isArray(raw.children)) return
const doc = normalizePenDocument(raw)
const name = filePath.split(/[/\\]/).pop() || 'untitled.op'
useDocumentStore.getState().loadDocument(doc, name)
requestAnimationFrame(() => zoomToFitContent())
} catch {
// Invalid file — ignore
}
})
}
const cleanupOpenFile = api.onOpenFile?.(loadFileFromPath)
// Pull any pending file from cold start (double-click .op to launch app)
api.getPendingFile?.().then((filePath) => {
if (filePath) loadFileFromPath(filePath)
})
const cleanup = api.onMenuAction((action: string) => {
switch (action) {
case 'new':
@ -113,6 +137,9 @@ export function useElectronMenu() {
}
})
return cleanup
return () => {
cleanup()
cleanupOpenFile?.()
}
}, [])
}

View file

@ -4,6 +4,7 @@ import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import { cloneNodesWithNewIds } from '@/utils/node-clone'
import { canBooleanOp, executeBooleanOp, type BooleanOpType } from '@/utils/boolean-ops'
import { tryPasteFigmaFromClipboard } from '@/hooks/use-figma-paste'
import {
supportsFileSystemAccess,
@ -267,6 +268,40 @@ export function useKeyboardShortcuts() {
return
}
// Boolean operations: Cmd/Ctrl+Alt+U (union), Cmd/Ctrl+Alt+S (subtract), Cmd/Ctrl+Alt+I (intersect)
if (isMod && e.altKey && !e.shiftKey) {
const booleanOps: Record<string, BooleanOpType> = {
u: 'union',
s: 'subtract',
i: 'intersect',
}
const opType = booleanOps[e.key.toLowerCase()]
if (opType) {
const { selectedIds } = useCanvasStore.getState().selection
const nodes = selectedIds
.map((id) => useDocumentStore.getState().getNodeById(id))
.filter((n): n is NonNullable<typeof n> => n != null)
if (canBooleanOp(nodes)) {
e.preventDefault()
const result = executeBooleanOp(nodes, opType)
if (result) {
useHistoryStore.getState().pushState(useDocumentStore.getState().document)
for (const id of selectedIds) {
useDocumentStore.getState().removeNode(id)
}
useDocumentStore.getState().addNode(null, result)
useCanvasStore.getState().setSelection([result.id], result.id)
const canvas = useCanvasStore.getState().fabricCanvas
if (canvas) {
canvas.discardActiveObject()
canvas.requestRenderAll()
}
}
}
return
}
}
// Tool shortcuts (single key, no modifier)
if (!isMod && !e.shiftKey && !e.altKey) {
const tool = TOOL_KEYS[e.key.toLowerCase()]

View file

@ -44,6 +44,7 @@ const de: TranslationKeys = {
'topbar.open': 'Öffnen',
'topbar.save': 'Speichern',
'topbar.importFigma': 'Figma importieren',
'topbar.codePanel': 'Code',
'topbar.lightMode': 'Heller Modus',
'topbar.darkMode': 'Dunkler Modus',
'topbar.fullscreen': 'Vollbild',
@ -54,6 +55,11 @@ const de: TranslationKeys = {
'topbar.connected': 'verbunden',
'topbar.agentStatus': '{{agents}} Agent{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'Design',
'rightPanel.code': 'Code',
'rightPanel.noSelection': 'Element auswählen',
// ── Pages ──
'pages.title': 'Seiten',
'pages.addPage': 'Seite hinzufügen',
@ -107,6 +113,9 @@ const de: TranslationKeys = {
'layerMenu.createComponent': 'Komponente erstellen',
'layerMenu.detachComponent': 'Komponente lösen',
'layerMenu.detachInstance': 'Instanz lösen',
'layerMenu.booleanUnion': 'Vereinigung',
'layerMenu.booleanSubtract': 'Subtraktion',
'layerMenu.booleanIntersect': 'Schnittmenge',
'layerMenu.toggleLock': 'Sperren umschalten',
'layerMenu.toggleVisibility': 'Sichtbarkeit umschalten',
@ -266,11 +275,18 @@ const de: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'In Zwischenablage kopieren',
'code.copied': 'Kopiert!',
'code.download': 'Code-Datei herunterladen',
'code.closeCodePanel': 'Code-Panel schließen',
'code.genCssVars': 'CSS-Variablen für das gesamte Dokument generieren',
'code.genSelected':
'Code für {{count}} ausgewählte(s) Element(e) generieren',
'code.genDocument': 'Code für das gesamte Dokument generieren',
'code.aiEnhance': 'KI-Verbesserung',
'code.cancelEnhance': 'Verbesserung abbrechen',
'code.resetEnhance': 'Zurücksetzen',
'code.enhancing': 'KI verbessert den Code...',
'code.enhanced': 'Von KI verbessert',
// ── Save Dialog ──
'save.saveAs': 'Speichern unter',
@ -305,6 +321,7 @@ const de: TranslationKeys = {
'agents.mcpServerRunning': 'Läuft',
'agents.mcpServerStopped': 'Gestoppt',
'agents.mcpLanAccess': 'LAN-Zugriff',
'agents.mcpClientConfig': 'Client-Konfiguration',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -42,6 +42,7 @@ const en = {
'topbar.open': 'Open',
'topbar.save': 'Save',
'topbar.importFigma': 'Import Figma',
'topbar.codePanel': 'Code',
'topbar.lightMode': 'Light mode',
'topbar.darkMode': 'Dark mode',
'topbar.fullscreen': 'Fullscreen',
@ -52,6 +53,11 @@ const en = {
'topbar.connected': 'connected',
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'Design',
'rightPanel.code': 'Code',
'rightPanel.noSelection': 'Select an element',
// ── Pages ──
'pages.title': 'Pages',
'pages.addPage': 'Add page',
@ -103,6 +109,9 @@ const en = {
'layerMenu.createComponent': 'Create Component',
'layerMenu.detachComponent': 'Detach Component',
'layerMenu.detachInstance': 'Detach Instance',
'layerMenu.booleanUnion': 'Union',
'layerMenu.booleanSubtract': 'Subtract',
'layerMenu.booleanIntersect': 'Intersect',
'layerMenu.toggleLock': 'Toggle Lock',
'layerMenu.toggleVisibility': 'Toggle Visibility',
@ -262,11 +271,18 @@ const en = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'Copy to clipboard',
'code.copied': 'Copied!',
'code.download': 'Download code file',
'code.closeCodePanel': 'Close code panel',
'code.genCssVars': 'Generating CSS variables for entire document',
'code.genSelected':
'Generating code for {{count}} selected element(s)',
'code.genDocument': 'Generating code for entire document',
'code.aiEnhance': 'AI Enhance',
'code.cancelEnhance': 'Cancel enhancement',
'code.resetEnhance': 'Reset to original',
'code.enhancing': 'AI is enhancing code...',
'code.enhanced': 'Enhanced by AI',
// ── Save Dialog ──
'save.saveAs': 'Save As',
@ -301,6 +317,7 @@ const en = {
'agents.mcpServerRunning': 'Running',
'agents.mcpServerStopped': 'Stopped',
'agents.mcpLanAccess': 'LAN Access',
'agents.mcpClientConfig': 'Client Config',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const es: TranslationKeys = {
'topbar.open': 'Abrir',
'topbar.save': 'Guardar',
'topbar.importFigma': 'Importar Figma',
'topbar.codePanel': 'Código',
'topbar.lightMode': 'Modo claro',
'topbar.darkMode': 'Modo oscuro',
'topbar.fullscreen': 'Pantalla completa',
@ -54,6 +55,11 @@ const es: TranslationKeys = {
'topbar.connected': 'conectado',
'topbar.agentStatus': '{{agents}} agente{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'Diseño',
'rightPanel.code': 'Código',
'rightPanel.noSelection': 'Selecciona un elemento',
// ── Pages ──
'pages.title': 'Páginas',
'pages.addPage': 'Agregar página',
@ -107,6 +113,9 @@ const es: TranslationKeys = {
'layerMenu.createComponent': 'Crear componente',
'layerMenu.detachComponent': 'Separar componente',
'layerMenu.detachInstance': 'Separar instancia',
'layerMenu.booleanUnion': 'Unión',
'layerMenu.booleanSubtract': 'Restar',
'layerMenu.booleanIntersect': 'Intersección',
'layerMenu.toggleLock': 'Alternar bloqueo',
'layerMenu.toggleVisibility': 'Alternar visibilidad',
@ -270,12 +279,19 @@ const es: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'Copiar al portapapeles',
'code.copied': '¡Copiado!',
'code.download': 'Descargar archivo de código',
'code.closeCodePanel': 'Cerrar panel de código',
'code.genCssVars':
'Generando variables CSS para todo el documento',
'code.genSelected':
'Generando código para {{count}} elemento(s) seleccionado(s)',
'code.genDocument': 'Generando código para todo el documento',
'code.aiEnhance': 'Mejorar con IA',
'code.cancelEnhance': 'Cancelar mejora',
'code.resetEnhance': 'Restablecer original',
'code.enhancing': 'La IA está mejorando el código...',
'code.enhanced': 'Mejorado por IA',
// ── Save Dialog ──
'save.saveAs': 'Guardar como',
@ -310,6 +326,7 @@ const es: TranslationKeys = {
'agents.mcpServerRunning': 'En ejecución',
'agents.mcpServerStopped': 'Detenido',
'agents.mcpLanAccess': 'Acceso LAN',
'agents.mcpClientConfig': 'Config. del cliente',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const fr: TranslationKeys = {
'topbar.open': 'Ouvrir',
'topbar.save': 'Enregistrer',
'topbar.importFigma': 'Importer Figma',
'topbar.codePanel': 'Code',
'topbar.lightMode': 'Mode clair',
'topbar.darkMode': 'Mode sombre',
'topbar.fullscreen': 'Plein écran',
@ -54,6 +55,11 @@ const fr: TranslationKeys = {
'topbar.connected': 'connecté',
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'Design',
'rightPanel.code': 'Code',
'rightPanel.noSelection': 'Sélectionnez un élément',
// ── Pages ──
'pages.title': 'Pages',
'pages.addPage': 'Ajouter une page',
@ -107,6 +113,9 @@ const fr: TranslationKeys = {
'layerMenu.createComponent': 'Créer un composant',
'layerMenu.detachComponent': 'Détacher le composant',
'layerMenu.detachInstance': 'Détacher l\u2019instance',
'layerMenu.booleanUnion': 'Union',
'layerMenu.booleanSubtract': 'Soustraire',
'layerMenu.booleanIntersect': 'Intersection',
'layerMenu.toggleLock': 'Verrouiller / Déverrouiller',
'layerMenu.toggleVisibility': 'Afficher / Masquer',
@ -268,12 +277,19 @@ const fr: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'Copier dans le presse-papiers',
'code.copied': 'Copié !',
'code.download': 'Télécharger le fichier de code',
'code.closeCodePanel': 'Fermer le panneau de code',
'code.genCssVars':
'Génération des variables CSS pour l\u2019ensemble du document',
'code.genSelected':
'Génération du code pour {{count}} élément(s) sélectionné(s)',
'code.genDocument': 'Génération du code pour l\u2019ensemble du document',
'code.aiEnhance': 'Améliorer par IA',
'code.cancelEnhance': 'Annuler l\u2019amélioration',
'code.resetEnhance': 'Réinitialiser',
'code.enhancing': 'L\u2019IA améliore le code...',
'code.enhanced': 'Amélioré par IA',
// ── Save Dialog ──
'save.saveAs': 'Enregistrer sous',
@ -308,6 +324,7 @@ const fr: TranslationKeys = {
'agents.mcpServerRunning': 'En cours',
'agents.mcpServerStopped': 'Arrêté',
'agents.mcpLanAccess': 'Accès LAN',
'agents.mcpClientConfig': 'Config. client',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const hi: TranslationKeys = {
'topbar.open': 'खोलें',
'topbar.save': 'सहेजें',
'topbar.importFigma': 'Figma आयात करें',
'topbar.codePanel': 'कोड',
'topbar.lightMode': 'लाइट मोड',
'topbar.darkMode': 'डार्क मोड',
'topbar.fullscreen': 'पूर्ण स्क्रीन',
@ -54,6 +55,11 @@ const hi: TranslationKeys = {
'topbar.connected': 'कनेक्टेड',
'topbar.agentStatus': '{{agents}} एजेंट{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'डिज़ाइन',
'rightPanel.code': 'कोड',
'rightPanel.noSelection': 'एक तत्व चुनें',
// ── Pages ──
'pages.title': 'पेज',
'pages.addPage': 'पेज जोड़ें',
@ -105,6 +111,9 @@ const hi: TranslationKeys = {
'layerMenu.createComponent': 'कंपोनेंट बनाएँ',
'layerMenu.detachComponent': 'कंपोनेंट अलग करें',
'layerMenu.detachInstance': 'इंस्टेंस अलग करें',
'layerMenu.booleanUnion': 'संयोजन',
'layerMenu.booleanSubtract': 'घटाना',
'layerMenu.booleanIntersect': 'प्रतिच्छेदन',
'layerMenu.toggleLock': 'लॉक टॉगल करें',
'layerMenu.toggleVisibility': 'दृश्यता टॉगल करें',
@ -264,11 +273,18 @@ const hi: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'क्लिपबोर्ड पर कॉपी करें',
'code.copied': 'कॉपी हो गया!',
'code.download': 'कोड फ़ाइल डाउनलोड करें',
'code.closeCodePanel': 'कोड पैनल बंद करें',
'code.genCssVars': 'संपूर्ण डॉक्यूमेंट के लिए CSS वेरिएबल जनरेट हो रहे हैं',
'code.genSelected':
'{{count}} चयनित तत्व(ओं) के लिए कोड जनरेट हो रहा है',
'code.genDocument': 'संपूर्ण डॉक्यूमेंट के लिए कोड जनरेट हो रहा है',
'code.aiEnhance': 'AI से सुधारें',
'code.cancelEnhance': 'सुधार रद्द करें',
'code.resetEnhance': 'मूल पर वापस जाएं',
'code.enhancing': 'AI कोड सुधार रहा है...',
'code.enhanced': 'AI द्वारा सुधारा गया',
// ── Save Dialog ──
'save.saveAs': 'इस रूप में सहेजें',
@ -303,6 +319,7 @@ const hi: TranslationKeys = {
'agents.mcpServerRunning': 'चल रहा है',
'agents.mcpServerStopped': 'रुका हुआ',
'agents.mcpLanAccess': 'LAN एक्सेस',
'agents.mcpClientConfig': 'क्लाइंट कॉन्फ़िग',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const id: TranslationKeys = {
'topbar.open': 'Buka',
'topbar.save': 'Simpan',
'topbar.importFigma': 'Impor Figma',
'topbar.codePanel': 'Kode',
'topbar.lightMode': 'Mode terang',
'topbar.darkMode': 'Mode gelap',
'topbar.fullscreen': 'Layar penuh',
@ -54,6 +55,11 @@ const id: TranslationKeys = {
'topbar.connected': 'terhubung',
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'Desain',
'rightPanel.code': 'Kode',
'rightPanel.noSelection': 'Pilih sebuah elemen',
// ── Pages ──
'pages.title': 'Halaman',
'pages.addPage': 'Tambah halaman',
@ -105,6 +111,9 @@ const id: TranslationKeys = {
'layerMenu.createComponent': 'Buat Komponen',
'layerMenu.detachComponent': 'Lepaskan Komponen',
'layerMenu.detachInstance': 'Lepaskan Instance',
'layerMenu.booleanUnion': 'Gabungan',
'layerMenu.booleanSubtract': 'Kurangi',
'layerMenu.booleanIntersect': 'Irisan',
'layerMenu.toggleLock': 'Alihkan Kunci',
'layerMenu.toggleVisibility': 'Alihkan Visibilitas',
@ -264,11 +273,18 @@ const id: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'Salin ke papan klip',
'code.copied': 'Tersalin!',
'code.download': 'Unduh file kode',
'code.closeCodePanel': 'Tutup panel kode',
'code.genCssVars': 'Membuat CSS variables untuk seluruh dokumen',
'code.genSelected':
'Membuat kode untuk {{count}} elemen yang dipilih',
'code.genDocument': 'Membuat kode untuk seluruh dokumen',
'code.aiEnhance': 'Tingkatkan dengan AI',
'code.cancelEnhance': 'Batalkan peningkatan',
'code.resetEnhance': 'Kembalikan ke asli',
'code.enhancing': 'AI sedang meningkatkan kode...',
'code.enhanced': 'Ditingkatkan oleh AI',
// ── Save Dialog ──
'save.saveAs': 'Simpan Sebagai',
@ -303,6 +319,7 @@ const id: TranslationKeys = {
'agents.mcpServerRunning': 'Berjalan',
'agents.mcpServerStopped': 'Berhenti',
'agents.mcpLanAccess': 'Akses LAN',
'agents.mcpClientConfig': 'Konfigurasi klien',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const ja: TranslationKeys = {
'topbar.open': '開く',
'topbar.save': '保存',
'topbar.importFigma': 'Figma をインポート',
'topbar.codePanel': 'コード',
'topbar.lightMode': 'ライトモード',
'topbar.darkMode': 'ダークモード',
'topbar.fullscreen': 'フルスクリーン',
@ -54,6 +55,11 @@ const ja: TranslationKeys = {
'topbar.connected': '接続済み',
'topbar.agentStatus': '{{agents}} Agent{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'デザイン',
'rightPanel.code': 'コード',
'rightPanel.noSelection': '要素を選択してください',
// ── Pages ──
'pages.title': 'ページ',
'pages.addPage': 'ページを追加',
@ -109,6 +115,9 @@ const ja: TranslationKeys = {
'layerMenu.createComponent': 'コンポーネントを作成',
'layerMenu.detachComponent': 'コンポーネントを解除',
'layerMenu.detachInstance': 'インスタンスを解除',
'layerMenu.booleanUnion': '合体',
'layerMenu.booleanSubtract': '前面で型抜き',
'layerMenu.booleanIntersect': '交差',
'layerMenu.toggleLock': 'ロックの切り替え',
'layerMenu.toggleVisibility': '表示の切り替え',
@ -268,10 +277,17 @@ const ja: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'クリップボードにコピー',
'code.copied': 'コピーしました!',
'code.download': 'コードファイルをダウンロード',
'code.closeCodePanel': 'コードパネルを閉じる',
'code.genCssVars': 'ドキュメント全体の CSS 変数を生成中',
'code.genSelected': '{{count}} 個の選択要素のコードを生成中',
'code.genDocument': 'ドキュメント全体のコードを生成中',
'code.aiEnhance': 'AI で改善',
'code.cancelEnhance': '改善をキャンセル',
'code.resetEnhance': '元に戻す',
'code.enhancing': 'AI がコードを改善中...',
'code.enhanced': 'AI により改善済み',
// ── Save Dialog ──
'save.saveAs': '名前を付けて保存',
@ -306,6 +322,7 @@ const ja: TranslationKeys = {
'agents.mcpServerRunning': '実行中',
'agents.mcpServerStopped': '停止中',
'agents.mcpLanAccess': 'LAN アクセス',
'agents.mcpClientConfig': 'クライアント設定',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const ko: TranslationKeys = {
'topbar.open': '열기',
'topbar.save': '저장',
'topbar.importFigma': 'Figma 가져오기',
'topbar.codePanel': '코드',
'topbar.lightMode': '라이트 모드',
'topbar.darkMode': '다크 모드',
'topbar.fullscreen': '전체 화면',
@ -54,6 +55,11 @@ const ko: TranslationKeys = {
'topbar.connected': '연결됨',
'topbar.agentStatus': '에이전트 {{agents}}개{{agentSuffix}} · MCP {{mcp}}개',
// ── Right Panel ──
'rightPanel.design': '디자인',
'rightPanel.code': '코드',
'rightPanel.noSelection': '요소를 선택하세요',
// ── Pages ──
'pages.title': '페이지',
'pages.addPage': '페이지 추가',
@ -105,6 +111,9 @@ const ko: TranslationKeys = {
'layerMenu.createComponent': '컴포넌트 만들기',
'layerMenu.detachComponent': '컴포넌트 분리',
'layerMenu.detachInstance': '인스턴스 분리',
'layerMenu.booleanUnion': '합치기',
'layerMenu.booleanSubtract': '빼기',
'layerMenu.booleanIntersect': '교차',
'layerMenu.toggleLock': '잠금 전환',
'layerMenu.toggleVisibility': '표시 전환',
@ -264,11 +273,18 @@ const ko: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': '클립보드에 복사',
'code.copied': '복사됨!',
'code.download': '코드 파일 다운로드',
'code.closeCodePanel': '코드 패널 닫기',
'code.genCssVars': '전체 문서의 CSS 변수를 생성 중',
'code.genSelected':
'선택한 요소 {{count}}개의 코드를 생성 중',
'code.genDocument': '전체 문서의 코드를 생성 중',
'code.aiEnhance': 'AI 개선',
'code.cancelEnhance': '개선 취소',
'code.resetEnhance': '원본으로 복원',
'code.enhancing': 'AI가 코드를 개선 중...',
'code.enhanced': 'AI로 개선됨',
// ── Save Dialog ──
'save.saveAs': '다른 이름으로 저장',
@ -303,6 +319,7 @@ const ko: TranslationKeys = {
'agents.mcpServerRunning': '실행 중',
'agents.mcpServerStopped': '정지됨',
'agents.mcpLanAccess': 'LAN 접근',
'agents.mcpClientConfig': '클라이언트 설정',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const pt: TranslationKeys = {
'topbar.open': 'Abrir',
'topbar.save': 'Salvar',
'topbar.importFigma': 'Importar Figma',
'topbar.codePanel': 'Código',
'topbar.lightMode': 'Modo claro',
'topbar.darkMode': 'Modo escuro',
'topbar.fullscreen': 'Tela cheia',
@ -54,6 +55,11 @@ const pt: TranslationKeys = {
'topbar.connected': 'conectado',
'topbar.agentStatus': '{{agents}} agente{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'Design',
'rightPanel.code': 'Código',
'rightPanel.noSelection': 'Selecione um elemento',
// ── Pages ──
'pages.title': 'Páginas',
'pages.addPage': 'Adicionar página',
@ -107,6 +113,9 @@ const pt: TranslationKeys = {
'layerMenu.createComponent': 'Criar componente',
'layerMenu.detachComponent': 'Desanexar componente',
'layerMenu.detachInstance': 'Desanexar instância',
'layerMenu.booleanUnion': 'União',
'layerMenu.booleanSubtract': 'Subtrair',
'layerMenu.booleanIntersect': 'Interseção',
'layerMenu.toggleLock': 'Alternar bloqueio',
'layerMenu.toggleVisibility': 'Alternar visibilidade',
@ -266,11 +275,18 @@ const pt: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'Copiar para a área de transferência',
'code.copied': 'Copiado!',
'code.download': 'Baixar arquivo de código',
'code.closeCodePanel': 'Fechar painel de código',
'code.genCssVars': 'Gerando variáveis CSS para o documento inteiro',
'code.genSelected':
'Gerando código para {{count}} elemento(s) selecionado(s)',
'code.genDocument': 'Gerando código para o documento inteiro',
'code.aiEnhance': 'Melhorar com IA',
'code.cancelEnhance': 'Cancelar melhoria',
'code.resetEnhance': 'Restaurar original',
'code.enhancing': 'A IA está melhorando o código...',
'code.enhanced': 'Melhorado por IA',
// ── Save Dialog ──
'save.saveAs': 'Salvar como',
@ -305,6 +321,7 @@ const pt: TranslationKeys = {
'agents.mcpServerRunning': 'Em execução',
'agents.mcpServerStopped': 'Parado',
'agents.mcpLanAccess': 'Acesso LAN',
'agents.mcpClientConfig': 'Config. do cliente',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const ru: TranslationKeys = {
'topbar.open': 'Открыть',
'topbar.save': 'Сохранить',
'topbar.importFigma': 'Импорт из Figma',
'topbar.codePanel': 'Код',
'topbar.lightMode': 'Светлая тема',
'topbar.darkMode': 'Тёмная тема',
'topbar.fullscreen': 'Полный экран',
@ -54,6 +55,11 @@ const ru: TranslationKeys = {
'topbar.connected': 'подключено',
'topbar.agentStatus': '{{agents}} агент{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'Дизайн',
'rightPanel.code': 'Код',
'rightPanel.noSelection': 'Выберите элемент',
// ── Pages ──
'pages.title': 'Страницы',
'pages.addPage': 'Добавить страницу',
@ -107,6 +113,9 @@ const ru: TranslationKeys = {
'layerMenu.createComponent': 'Создать компонент',
'layerMenu.detachComponent': 'Отсоединить компонент',
'layerMenu.detachInstance': 'Отсоединить экземпляр',
'layerMenu.booleanUnion': 'Объединение',
'layerMenu.booleanSubtract': 'Вычитание',
'layerMenu.booleanIntersect': 'Пересечение',
'layerMenu.toggleLock': 'Переключить блокировку',
'layerMenu.toggleVisibility': 'Переключить видимость',
@ -266,11 +275,18 @@ const ru: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'Копировать в буфер обмена',
'code.copied': 'Скопировано!',
'code.download': 'Скачать файл с кодом',
'code.closeCodePanel': 'Закрыть панель кода',
'code.genCssVars': 'Генерация CSS-переменных для всего документа',
'code.genSelected':
'Генерация кода для {{count}} выделенных элементов',
'code.genDocument': 'Генерация кода для всего документа',
'code.aiEnhance': 'Улучшить с ИИ',
'code.cancelEnhance': 'Отменить улучшение',
'code.resetEnhance': 'Сбросить',
'code.enhancing': 'ИИ улучшает код...',
'code.enhanced': 'Улучшено ИИ',
// ── Save Dialog ──
'save.saveAs': 'Сохранить как',
@ -305,6 +321,7 @@ const ru: TranslationKeys = {
'agents.mcpServerRunning': 'Работает',
'agents.mcpServerStopped': 'Остановлен',
'agents.mcpLanAccess': 'Доступ по LAN',
'agents.mcpClientConfig': 'Конфиг клиента',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const th: TranslationKeys = {
'topbar.open': 'เปิด',
'topbar.save': 'บันทึก',
'topbar.importFigma': 'นำเข้า Figma',
'topbar.codePanel': 'โค้ด',
'topbar.lightMode': 'โหมดสว่าง',
'topbar.darkMode': 'โหมดมืด',
'topbar.fullscreen': 'เต็มหน้าจอ',
@ -54,6 +55,11 @@ const th: TranslationKeys = {
'topbar.connected': 'เชื่อมต่อแล้ว',
'topbar.agentStatus': '{{agents}} เอเจนต์{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'ออกแบบ',
'rightPanel.code': 'โค้ด',
'rightPanel.noSelection': 'เลือกองค์ประกอบ',
// ── Pages ──
'pages.title': 'หน้า',
'pages.addPage': 'เพิ่มหน้า',
@ -105,6 +111,9 @@ const th: TranslationKeys = {
'layerMenu.createComponent': 'สร้างคอมโพเนนต์',
'layerMenu.detachComponent': 'แยกคอมโพเนนต์',
'layerMenu.detachInstance': 'แยกอินสแตนซ์',
'layerMenu.booleanUnion': 'รวม',
'layerMenu.booleanSubtract': 'ลบ',
'layerMenu.booleanIntersect': 'ตัดกัน',
'layerMenu.toggleLock': 'สลับล็อก',
'layerMenu.toggleVisibility': 'สลับการมองเห็น',
@ -264,11 +273,18 @@ const th: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'คัดลอกไปยังคลิปบอร์ด',
'code.copied': 'คัดลอกแล้ว!',
'code.download': 'ดาวน์โหลดไฟล์โค้ด',
'code.closeCodePanel': 'ปิดแผงโค้ด',
'code.genCssVars': 'กำลังสร้าง CSS Variables สำหรับเอกสารทั้งหมด',
'code.genSelected':
'กำลังสร้างโค้ดสำหรับ {{count}} องค์ประกอบที่เลือก',
'code.genDocument': 'กำลังสร้างโค้ดสำหรับเอกสารทั้งหมด',
'code.aiEnhance': 'ปรับปรุงด้วย AI',
'code.cancelEnhance': 'ยกเลิกการปรับปรุง',
'code.resetEnhance': 'กลับเป็นต้นฉบับ',
'code.enhancing': 'AI กำลังปรับปรุงโค้ด...',
'code.enhanced': 'ปรับปรุงแล้วโดย AI',
// ── Save Dialog ──
'save.saveAs': 'บันทึกเป็น',
@ -303,6 +319,7 @@ const th: TranslationKeys = {
'agents.mcpServerRunning': 'กำลังทำงาน',
'agents.mcpServerStopped': 'หยุดแล้ว',
'agents.mcpLanAccess': 'เข้าถึง LAN',
'agents.mcpClientConfig': 'การตั้งค่าไคลเอนต์',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const tr: TranslationKeys = {
'topbar.open': 'Aç',
'topbar.save': 'Kaydet',
'topbar.importFigma': 'Figma İçe Aktar',
'topbar.codePanel': 'Kod',
'topbar.lightMode': 'Açık mod',
'topbar.darkMode': 'Koyu mod',
'topbar.fullscreen': 'Tam ekran',
@ -54,6 +55,11 @@ const tr: TranslationKeys = {
'topbar.connected': 'bağlı',
'topbar.agentStatus': '{{agents}} ajan{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'Tasarım',
'rightPanel.code': 'Kod',
'rightPanel.noSelection': 'Bir öğe seçin',
// ── Pages ──
'pages.title': 'Sayfalar',
'pages.addPage': 'Sayfa ekle',
@ -105,6 +111,9 @@ const tr: TranslationKeys = {
'layerMenu.createComponent': 'Bileşen Oluştur',
'layerMenu.detachComponent': 'Bileşeni Ayır',
'layerMenu.detachInstance': 'Örneği Ayır',
'layerMenu.booleanUnion': 'Birleştir',
'layerMenu.booleanSubtract': ıkar',
'layerMenu.booleanIntersect': 'Kesiştir',
'layerMenu.toggleLock': 'Kilidi Aç/Kapat',
'layerMenu.toggleVisibility': 'Görünürlüğü Aç/Kapat',
@ -264,11 +273,18 @@ const tr: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'Panoya kopyala',
'code.copied': 'Kopyalandı!',
'code.download': 'Kod dosyasını indir',
'code.closeCodePanel': 'Kod panelini kapat',
'code.genCssVars': 'Tüm belge için CSS değişkenleri oluşturuluyor',
'code.genSelected':
'{{count}} seçili öge için kod oluşturuluyor',
'code.genDocument': 'Tüm belge için kod oluşturuluyor',
'code.aiEnhance': 'AI ile geliştir',
'code.cancelEnhance': 'Geliştirmeyi iptal et',
'code.resetEnhance': 'Orijinale sıfırla',
'code.enhancing': 'AI kodu geliştiriyor...',
'code.enhanced': 'AI tarafından geliştirildi',
// ── Save Dialog ──
'save.saveAs': 'Farklı Kaydet',
@ -303,6 +319,7 @@ const tr: TranslationKeys = {
'agents.mcpServerRunning': 'Çalışıyor',
'agents.mcpServerStopped': 'Durduruldu',
'agents.mcpLanAccess': 'LAN Erişimi',
'agents.mcpClientConfig': 'İstemci yapılandırması',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const vi: TranslationKeys = {
'topbar.open': 'Mở',
'topbar.save': 'Lưu',
'topbar.importFigma': 'Nhập từ Figma',
'topbar.codePanel': 'Mã',
'topbar.lightMode': 'Chế độ sáng',
'topbar.darkMode': 'Chế độ tối',
'topbar.fullscreen': 'Toàn màn hình',
@ -54,6 +55,11 @@ const vi: TranslationKeys = {
'topbar.connected': 'đã kết nối',
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
// ── Right Panel ──
'rightPanel.design': 'Thiết kế',
'rightPanel.code': 'Mã',
'rightPanel.noSelection': 'Chọn một phần tử',
// ── Pages ──
'pages.title': 'Trang',
'pages.addPage': 'Thêm trang',
@ -105,6 +111,9 @@ const vi: TranslationKeys = {
'layerMenu.createComponent': 'Tạo thành phần',
'layerMenu.detachComponent': 'Tách thành phần',
'layerMenu.detachInstance': 'Tách bản thể',
'layerMenu.booleanUnion': 'Hợp nhất',
'layerMenu.booleanSubtract': 'Trừ',
'layerMenu.booleanIntersect': 'Giao nhau',
'layerMenu.toggleLock': 'Bật/Tắt khoá',
'layerMenu.toggleVisibility': 'Bật/Tắt hiển thị',
@ -264,11 +273,18 @@ const vi: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': 'Sao chép vào bộ nhớ tạm',
'code.copied': 'Đã sao chép!',
'code.download': 'Tải xuống tệp mã',
'code.closeCodePanel': 'Đóng bảng mã',
'code.genCssVars': 'Đang tạo CSS variables cho toàn bộ tài liệu',
'code.genSelected':
'Đang tạo mã cho {{count}} phần tử đã chọn',
'code.genDocument': 'Đang tạo mã cho toàn bộ tài liệu',
'code.aiEnhance': 'Cải thiện bằng AI',
'code.cancelEnhance': 'Hủy cải thiện',
'code.resetEnhance': 'Khôi phục gốc',
'code.enhancing': 'AI đang cải thiện mã...',
'code.enhanced': 'Đã cải thiện bởi AI',
// ── Save Dialog ──
'save.saveAs': 'Lưu thành',
@ -303,6 +319,7 @@ const vi: TranslationKeys = {
'agents.mcpServerRunning': 'Đang chạy',
'agents.mcpServerStopped': 'Đã dừng',
'agents.mcpLanAccess': 'Truy cập LAN',
'agents.mcpClientConfig': 'Cấu hình client',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const zhTW: TranslationKeys = {
'topbar.open': '開啟',
'topbar.save': '儲存',
'topbar.importFigma': '匯入 Figma',
'topbar.codePanel': '程式碼',
'topbar.lightMode': '淺色模式',
'topbar.darkMode': '深色模式',
'topbar.fullscreen': '全螢幕',
@ -54,6 +55,11 @@ const zhTW: TranslationKeys = {
'topbar.connected': '已連線',
'topbar.agentStatus': '{{agents}} 個 Agent{{agentSuffix}} · {{mcp}} 個 MCP',
// ── Right Panel ──
'rightPanel.design': '設計',
'rightPanel.code': '程式碼',
'rightPanel.noSelection': '選擇一個元素',
// ── Pages ──
'pages.title': '頁面',
'pages.addPage': '新增頁面',
@ -102,6 +108,9 @@ const zhTW: TranslationKeys = {
'layerMenu.createComponent': '建立元件',
'layerMenu.detachComponent': '分離元件',
'layerMenu.detachInstance': '分離實例',
'layerMenu.booleanUnion': '聯合',
'layerMenu.booleanSubtract': '減去',
'layerMenu.booleanIntersect': '交集',
'layerMenu.toggleLock': '切換鎖定',
'layerMenu.toggleVisibility': '切換可見性',
@ -258,10 +267,17 @@ const zhTW: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': '複製到剪貼簿',
'code.copied': '已複製!',
'code.download': '下載程式碼檔案',
'code.closeCodePanel': '關閉程式碼面板',
'code.genCssVars': '正在為整份文件產生 CSS 變數',
'code.genSelected': '正在為 {{count}} 個選取元素產生程式碼',
'code.genDocument': '正在為整份文件產生程式碼',
'code.aiEnhance': 'AI 優化',
'code.cancelEnhance': '取消優化',
'code.resetEnhance': '恢復原始程式碼',
'code.enhancing': 'AI 正在優化程式碼...',
'code.enhanced': '已由 AI 優化',
// ── Save Dialog ──
'save.saveAs': '另存新檔',
@ -295,6 +311,7 @@ const zhTW: TranslationKeys = {
'agents.mcpServerRunning': '執行中',
'agents.mcpServerStopped': '已停止',
'agents.mcpLanAccess': '區域網路存取',
'agents.mcpClientConfig': '客戶端配置',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -44,6 +44,7 @@ const zh: TranslationKeys = {
'topbar.open': '打开',
'topbar.save': '保存',
'topbar.importFigma': '导入 Figma',
'topbar.codePanel': '代码',
'topbar.lightMode': '浅色模式',
'topbar.darkMode': '深色模式',
'topbar.fullscreen': '全屏',
@ -54,6 +55,11 @@ const zh: TranslationKeys = {
'topbar.connected': '已连接',
'topbar.agentStatus': '{{agents}} 个 Agent{{agentSuffix}} · {{mcp}} 个 MCP',
// ── Right Panel ──
'rightPanel.design': '设计',
'rightPanel.code': '代码',
'rightPanel.noSelection': '选择一个元素',
// ── Pages ──
'pages.title': '页面',
'pages.addPage': '添加页面',
@ -102,6 +108,9 @@ const zh: TranslationKeys = {
'layerMenu.createComponent': '创建组件',
'layerMenu.detachComponent': '分离组件',
'layerMenu.detachInstance': '分离实例',
'layerMenu.booleanUnion': '联合',
'layerMenu.booleanSubtract': '减去',
'layerMenu.booleanIntersect': '交集',
'layerMenu.toggleLock': '切换锁定',
'layerMenu.toggleVisibility': '切换可见性',
@ -258,10 +267,17 @@ const zh: TranslationKeys = {
'code.htmlCss': 'HTML + CSS',
'code.cssVariables': 'CSS Variables',
'code.copyClipboard': '复制到剪贴板',
'code.copied': '已复制!',
'code.download': '下载代码文件',
'code.closeCodePanel': '关闭代码面板',
'code.genCssVars': '正在为整个文档生成 CSS 变量',
'code.genSelected': '正在为 {{count}} 个选中元素生成代码',
'code.genDocument': '正在为整个文档生成代码',
'code.aiEnhance': 'AI 优化',
'code.cancelEnhance': '取消优化',
'code.resetEnhance': '恢复原始代码',
'code.enhancing': 'AI 正在优化代码...',
'code.enhanced': '已由 AI 优化',
// ── Save Dialog ──
'save.saveAs': '另存为',
@ -295,6 +311,7 @@ const zh: TranslationKeys = {
'agents.mcpServerRunning': '运行中',
'agents.mcpServerStopped': '已停止',
'agents.mcpLanAccess': '局域网访问',
'agents.mcpClientConfig': '客户端配置',
'agents.stdio': 'stdio',
'agents.http': 'http',
'agents.stdioHttp': 'stdio + http',

View file

@ -10,9 +10,9 @@ const cache = new Map<string, { doc: PenDocument; mtime: number }>()
/** Special path indicating the MCP should operate on the live Electron canvas. */
export const LIVE_CANVAS_PATH = 'live://canvas'
/** Resolve filePath for MCP tools — passes through live://canvas, resolves file paths normally. */
export function resolveDocPath(filePath: string): string {
if (filePath === LIVE_CANVAS_PATH) return LIVE_CANVAS_PATH
/** Resolve filePath for MCP tools — defaults to live canvas when omitted. */
export function resolveDocPath(filePath?: string): string {
if (!filePath || filePath === LIVE_CANVAS_PATH) return LIVE_CANVAS_PATH
return resolve(filePath)
}

View file

@ -60,7 +60,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
patterns: {
type: 'array',
description: 'Search patterns to match nodes',
@ -79,7 +79,7 @@ const TOOL_DEFINITIONS = [
searchDepth: { type: 'number', description: 'How deep to search for matching nodes (default unlimited)' },
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath'],
required: [],
},
},
{
@ -89,7 +89,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
parent: {
type: ['string', 'null'] as any,
description: 'Parent node ID, or null for root level',
@ -110,7 +110,7 @@ const TOOL_DEFINITIONS = [
},
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath', 'parent', 'data'],
required: ['parent', 'data'],
},
},
{
@ -120,7 +120,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
nodeId: { type: 'string', description: 'ID of the node to update' },
data: {
type: 'object',
@ -136,7 +136,7 @@ const TOOL_DEFINITIONS = [
},
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath', 'nodeId', 'data'],
required: ['nodeId', 'data'],
},
},
{
@ -145,11 +145,11 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
nodeId: { type: 'string', description: 'ID of the node to delete' },
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath', 'nodeId'],
required: ['nodeId'],
},
},
{
@ -159,7 +159,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
nodeId: { type: 'string', description: 'ID of the node to move' },
parent: {
type: ['string', 'null'] as any,
@ -171,7 +171,7 @@ const TOOL_DEFINITIONS = [
},
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath', 'nodeId', 'parent'],
required: ['nodeId', 'parent'],
},
},
{
@ -181,7 +181,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
sourceId: { type: 'string', description: 'ID of the node to copy' },
parent: {
type: ['string', 'null'] as any,
@ -193,7 +193,7 @@ const TOOL_DEFINITIONS = [
},
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath', 'sourceId', 'parent'],
required: ['sourceId', 'parent'],
},
},
{
@ -203,7 +203,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
nodeId: { type: 'string', description: 'ID of the node to replace' },
data: {
type: 'object',
@ -219,7 +219,7 @@ const TOOL_DEFINITIONS = [
},
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath', 'nodeId', 'data'],
required: ['nodeId', 'data'],
},
},
{
@ -229,7 +229,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
svgPath: { type: 'string', description: 'Absolute path to a local .svg file' },
parent: {
type: ['string', 'null'] as any,
@ -249,7 +249,7 @@ const TOOL_DEFINITIONS = [
},
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath', 'svgPath'],
required: ['svgPath'],
},
},
{
@ -258,9 +258,9 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
},
required: ['filePath'],
required: [],
},
},
{
@ -269,11 +269,11 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
variables: { type: 'object', description: 'Variables to set (name → { type, value })' },
replace: { type: 'boolean', description: 'Replace all variables instead of merging (default false)' },
},
required: ['filePath', 'variables'],
required: ['variables'],
},
},
{
@ -283,7 +283,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
themes: {
type: 'object',
description:
@ -294,7 +294,7 @@ const TOOL_DEFINITIONS = [
description: 'Replace all themes instead of merging (default false)',
},
},
required: ['filePath', 'themes'],
required: ['themes'],
},
},
{
@ -303,12 +303,12 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
parentId: { type: 'string', description: 'Only return layout under this parent node' },
maxDepth: { type: 'number', description: 'Max depth to traverse (default 1)' },
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath'],
required: [],
},
},
{
@ -317,7 +317,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
width: { type: 'number', description: 'Required width of empty space' },
height: { type: 'number', description: 'Required height of empty space' },
padding: { type: 'number', description: 'Minimum padding from other elements (default 50)' },
@ -325,7 +325,7 @@ const TOOL_DEFINITIONS = [
nodeId: { type: 'string', description: 'Search relative to this node (default: entire canvas)' },
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['filePath', 'width', 'height', 'direction'],
required: ['width', 'height', 'direction'],
},
},
{
@ -334,11 +334,11 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file to extract themes from' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
presetPath: { type: 'string', description: 'Absolute path for the output .optheme file' },
name: { type: 'string', description: 'Display name for the preset (defaults to file name)' },
},
required: ['filePath', 'presetPath'],
required: ['presetPath'],
},
},
{
@ -347,10 +347,10 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file to update' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
presetPath: { type: 'string', description: 'Absolute path to the .optheme file to load' },
},
required: ['filePath', 'presetPath'],
required: ['presetPath'],
},
},
{
@ -371,7 +371,7 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
name: { type: 'string', description: 'Page name (default: "Page N")' },
children: {
type: 'array',
@ -380,7 +380,7 @@ const TOOL_DEFINITIONS = [
items: { type: 'object' },
},
},
required: ['filePath'],
required: [],
},
},
{
@ -389,10 +389,10 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
pageId: { type: 'string', description: 'ID of the page to remove' },
},
required: ['filePath', 'pageId'],
required: ['pageId'],
},
},
{
@ -401,11 +401,11 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
pageId: { type: 'string', description: 'ID of the page to rename' },
name: { type: 'string', description: 'New page name' },
},
required: ['filePath', 'pageId', 'name'],
required: ['pageId', 'name'],
},
},
{
@ -414,11 +414,11 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
pageId: { type: 'string', description: 'ID of the page to move' },
index: { type: 'number', description: 'New zero-based index for the page' },
},
required: ['filePath', 'pageId', 'index'],
required: ['pageId', 'index'],
},
},
{
@ -428,11 +428,11 @@ const TOOL_DEFINITIONS = [
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' },
pageId: { type: 'string', description: 'ID of the page to duplicate' },
name: { type: 'string', description: 'Name for the duplicated page (default: "original copy")' },
},
required: ['filePath', 'pageId'],
required: ['pageId'],
},
},
]
@ -512,12 +512,9 @@ function registerTools(server: Server): void {
// --- HTTP server helper ---
function startHttpServer(server: Server, port: number): void {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
})
server.connect(transport)
function startHttpServer(port: number): void {
// Per-session transport map: each client gets its own Server + Transport
const sessions = new Map<string, { transport: StreamableHTTPServerTransport; server: Server }>()
const httpServer = createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
@ -531,19 +528,62 @@ function startHttpServer(server: Server, port: number): void {
return
}
if (req.url === '/mcp') {
if (req.url !== '/mcp') {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found. Use /mcp endpoint.' }))
return
}
const sessionId = req.headers['mcp-session-id'] as string | undefined
// Route to existing session
if (sessionId && sessions.has(sessionId)) {
const session = sessions.get(sessionId)!
if (req.method === 'POST') {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
const body = JSON.parse(Buffer.concat(chunks).toString())
await transport.handleRequest(req, res, body)
await session.transport.handleRequest(req, res, body)
} else {
await transport.handleRequest(req, res)
await session.transport.handleRequest(req, res)
}
} else {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found. Use /mcp endpoint.' }))
return
}
// New session — only POST (initialize) is valid without session ID
if (req.method === 'POST') {
const mcpServer = new Server(
{ name: pkg.name, version: pkg.version },
{ capabilities: { tools: {} } },
)
registerTools(mcpServer)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid: string) => {
sessions.set(sid, { transport, server: mcpServer })
},
onsessionclosed: (sid: string) => {
sessions.delete(sid)
},
})
transport.onclose = () => {
if (transport.sessionId) sessions.delete(transport.sessionId)
}
await mcpServer.connect(transport)
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
const body = JSON.parse(Buffer.concat(chunks).toString())
await transport.handleRequest(req, res, body)
return
}
// Invalid: GET/DELETE without valid session ID
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Invalid or missing session ID' }, id: null }))
})
httpServer.listen(port, '0.0.0.0', () => {
@ -569,7 +609,7 @@ async function main() {
const { stdio, http, port } = parseArgs()
if (stdio && http) {
// Both: two Server instances sharing the same tool handlers
// Both: stdio server + HTTP server (per-session)
const stdioServer = new Server(
{ name: pkg.name, version: pkg.version },
{ capabilities: { tools: {} } },
@ -577,19 +617,9 @@ async function main() {
registerTools(stdioServer)
await stdioServer.connect(new StdioServerTransport())
const httpServer = new Server(
{ name: pkg.name, version: pkg.version },
{ capabilities: { tools: {} } },
)
registerTools(httpServer)
startHttpServer(httpServer, port)
startHttpServer(port)
} else if (http) {
const server = new Server(
{ name: pkg.name, version: pkg.version },
{ capabilities: { tools: {} } },
)
registerTools(server)
startHttpServer(server, port)
startHttpServer(port)
} else {
const server = new Server(
{ name: pkg.name, version: pkg.version },

View file

@ -25,7 +25,7 @@ import {
import type { PenDocument, PenNode } from '../../types/pen'
export interface BatchDesignParams {
filePath: string
filePath?: string
operations: string
postProcess?: boolean
canvasWidth?: number

View file

@ -14,7 +14,7 @@ export interface SearchPattern {
}
export interface BatchGetParams {
filePath: string
filePath?: string
patterns?: SearchPattern[]
nodeIds?: string[]
parentId?: string

View file

@ -22,6 +22,55 @@ ${ADAPTIVE_STYLE_POLICY}
${DESIGN_EXAMPLES}
DESIGN TYPE DETECTION:
Classify by the design's PURPOSE to choose the correct root frame size reason about intent, do not keyword-match:
- Multi-section page (marketing, promotional, informational content designed to be scrolled) Desktop: width=1200, height=0 (auto-expands), layout="vertical"
- Single-task screen (functional UI focused on one user task: authentication, forms, settings, profiles, modals, onboarding, etc.) Mobile: width=375, height=812 (FIXED)
- Data-rich workspace (dashboards, admin panels, analytics) Desktop: width=1200, height=0
- CRITICAL: Single-task screens MUST be 375×812. NEVER use 1200 width for focused app screens.
- Multi-section page height hints: nav 64-80px, hero 500-600px, feature sections 400-600px, CTA 200-300px, footer 200-300px.
- Single-task screen height hints: status bar 44px, header 56-64px, form fields 48-56px each, buttons 48px, spacing 16-24px.
SEMANTIC ROLES (context-aware defaults):
Add "role" to nodes for automatic smart defaults. System fills unset props based on role. Your explicit props always override.
Available roles:
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 role defaults:
section width:fill_container, height:fit_content, gap:24, padding:[60,80] (desktop)/[40,16] (mobile)
navbar height:72 (desktop)/56 (mobile), layout:horizontal, justifyContent:space_between, alignItems:center
hero layout:vertical, padding:[80,80] (desktop)/[40,16] (mobile), gap:24, alignItems:center
button padding:[12,24], height:44, cornerRadius:8, layout:horizontal, alignItems:center
button (in navbar) padding:[8,16], height:36
button (in form-group) width:fill_container, height:48, padding:[12,24]
icon-button 44×44, layout:horizontal, justifyContent:center, alignItems:center, cornerRadius:8
badge/pill layout:horizontal, padding:[6,12], cornerRadius:999
input height:48, layout:horizontal, padding:[12,16]
form-input same as input + width:fill_container
search-bar height:44, cornerRadius:22
card gap:12, cornerRadius:12, clipContent:true
card (in horizontal layout) width:fill_container, height:fill_container
feature-card (in horizontal) width:fill_container, height:fill_container
phone-mockup width:280, height:560, cornerRadius:32, layout:none
avatar circular (cornerRadius=width/2), clipContent:true
heading lineHeight:1.2, letterSpacing:-0.5
body-text lineHeight:1.5, textGrowth:fixed-width, width:fill_container
caption lineHeight:1.3, textGrowth:auto
label lineHeight:1.2, textGrowth:auto, textAlignVertical:middle
divider width:fill_container, height:1 (or width:1 for vertical)
spacer width:fill_container, height:40
feature-grid layout:horizontal, gap:24, alignItems:start
table layout:vertical, gap:0, clipContent:true
table-row layout:horizontal, padding:[12,16], alignItems:center
table-cell width:fill_container
Any string is valid as a role unknown roles pass through unchanged.
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 the engine positions them automatically
@ -39,42 +88,71 @@ LAYOUT ENGINE (flexbox-based):
- ALL nodes must be descendants of the root frame no floating/orphan elements
- WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one uses "fill_container", ALL siblings must too.
- NEVER use "fill_container" on children of a "fit_content" parent circular dependency.
- TEXT IN LAYOUTS: in vertical layouts, body text textGrowth="fixed-width" + width="fill_container". In horizontal rows, labels textGrowth="auto" + width="fit_content". NEVER use fixed pixel width on text inside a layout.
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes. OMIT height the engine auto-calculates.
- CJK BUTTONS/BADGES: each CJK char fontSize wide. Ensure container width (charCount × fontSize) + padding.
- Section root: width="fill_container", height="fit_content", layout="vertical". Never fixed pixel height on section root.
- Two-column: horizontal frame, two child frames each "fill_container" width.
- Centered content: frame alignItems="center", content frame with fixed width (e.g. 1080).
- FORMS: ALL inputs AND primary button MUST use width="fill_container". Vertical layout, gap=16-20. ONE primary action button only.
Social login buttons: horizontal frame width="fill_container", each button width="fit_content".
- Keep hierarchy shallow: no pointless "Inner" wrappers. Only use wrappers with a visual purpose (fill, padding, border).
TEXT RULES:
- Body/description text in vertical layout: width="fill_container" + textGrowth="fixed-width". This wraps text and auto-sizes height.
- Short labels in horizontal rows: width="fit_content" (or omit) + textGrowth="auto" (or omit). Prevents squeezing siblings.
- NEVER fixed pixel width on text inside layout frames causes overflow. Only allowed in layout="none" parent.
- Text >15 chars MUST have textGrowth="fixed-width" without it text won't wrap.
- NEVER set explicit pixel height on text nodes. OMIT height the engine auto-calculates.
- Typography scale: 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.
CJK TYPOGRAPHY:
- CJK font selection: heading="Noto Sans SC" (Chinese) / "Noto Sans JP" (Japanese) / "Noto Sans KR" (Korean), body="Inter".
NEVER use "Space Grotesk" or "Manrope" for CJK content they have no CJK glyphs.
- CJK lineHeight: headings 1.3-1.4 (NOT 1.1-1.2 like Latin), body 1.6-1.8 (NOT 1.4-1.6 like Latin).
- CJK letterSpacing: 0, NEVER negative. Negative letterSpacing causes CJK character overlap.
- CJK buttons/badges: each CJK char fontSize wide. Ensure container width (charCount × fontSize) + padding.
COPYWRITING:
- Headlines: 2-6 words. Subtitles: 1 sentence 15 words. Buttons: 1-3 words. Card text: 2 sentences.
- NEVER generate placeholder paragraphs with 3+ sentences. Distill to essence.
DESIGN GUIDELINES:
- Mobile: root frame 375x812 at x:0,y:0. Web: 1200x800 (single screen) or 1200x3000-5000 (landing page).
- Use unique descriptive IDs. All elements INSIDE root frame as children.
- Max 3-4 levels of nesting. Consistent centered content container (~1040-1160px) for web.
- Buttons: height 44-52px, cornerRadius 8-12, padding [12, 24]. Icon+text: layout="horizontal", gap=8, alignItems="center".
- Inputs: height 44px, light bg, subtle border, width="fill_container" in forms.
- Cards: cornerRadius 12-16, clipContent: true, subtle shadows. Cards in a horizontal row: ALL use height="fill_container".
- Icons: "path" nodes with Feather icon names (PascalCase + "Icon" suffix). Size 16-24px. System auto-resolves names to SVG paths.
- Never use emoji as icons. Never use ellipse for decorative shapes.
- Phone mockup: ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke.
- Icon-only buttons: square 44×44, justifyContent/alignItems="center", path icon 20-24px.
- Inputs: height 48px, light bg, subtle border, width="fill_container" in forms.
Semantic affordance icons: searchleading SearchIcon, passwordtrailing EyeIcon, emailleading MailIcon.
- Cards: cornerRadius 12-16, clipContent: true, subtle shadows. Cards in a horizontal row: ALL use width="fill_container" + height="fill_container".
- Icons: "path" nodes with Feather icon names (PascalCase + "Icon" suffix, e.g. "SearchIcon", "MenuIcon"). Size 16-24px. System auto-resolves names to SVG paths.
- Never use emoji as icons. Never use ellipse for decorative shapes use frame/rectangle with cornerRadius.
- Phone mockup: ONE "frame" node with role="phone-mockup". No ellipse for mockups. At most ONE centered text child inside.
- Hero + phone (desktop): two-column horizontal layout (left text, right phone). Not stacked unless mobile.
- Landing pages: hero 40-56px headline, alternating section backgrounds, nav with space_between.
- App screens: focus on core function, inputs width="fill_container", consistent 48-56px height, 16-24px gap.
- Default to light neutral styling unless user asks for dark.
Dark theme only when user explicitly mentions: dark/cyber/terminal/neon///gaming/noir.
DESIGN VARIABLES:
- When document has variables, use "$variableName" references instead of hardcoded values.
- Color variables: [{ "type": "solid", "color": "$primary" }]
- Number variables: "gap": "$spacing-md"
- Variables can have per-theme values. Use $name syntax the engine resolves to concrete values for rendering.
EMPTY FRAME AUTO-REPLACEMENT:
- When inserting a root-level frame via I(null, {...}), if an empty root frame (no children) already exists on the canvas, it is automatically replaced no need to delete or move into it manually.
- The new frame inherits the position (x/y) of the replaced empty frame, so find_empty_space is unnecessary when an empty root frame exists.
- Always use I(null, {...}) for root-level designs the tool handles reuse of empty frames automatically.
POST-PROCESSING (automatic):
- batch_design with postProcess=true automatically applies after insertion:
- Semantic role defaults (button padding, card corners, input styling, etc.)
- Icon name SVG path resolution
- Emoji removal
- Layout child position sanitization
- Unique ID enforcement
POST-PROCESSING (automatic with postProcess=true):
- Semantic role defaults: fills unset props based on role (see SEMANTIC ROLES above). Context-aware e.g. button defaults differ in navbar vs form.
- Icon name SVG path auto-resolution: set icon "name" field, system resolves to SVG "d" path.
- Card row equalization: horizontal layout with 2+ cards auto-equalizes to fill_container width+height.
- Horizontal overflow fix: auto-reduces gap or expands parent when children exceed width.
- Form input consistency: if any input uses fill_container, all sibling inputs get normalized.
- Text height estimation: auto-calculates optimal height based on fontSize, lineHeight, and content width.
- Frame height expansion: auto-expands frames when content exceeds fixed height.
- clipContent auto-addition: frames with cornerRadius + image children get clipContent:true.
- Emoji removal and layout child position sanitization.
- Unique ID enforcement.
Always set postProcess=true when generating designs for best visual quality.`
}

View file

@ -3,7 +3,7 @@ import { getNodeBounds, findNodeInTree, getDocChildren } from '../utils/node-ope
import type { PenNode } from '../../types/pen'
export interface FindEmptySpaceParams {
filePath: string
filePath?: string
width: number
height: number
padding?: number

View file

@ -11,7 +11,7 @@ import { parseSvgToNodesServer } from '../utils/svg-node-parser'
import { postProcessNode } from './node-crud'
export interface ImportSvgParams {
filePath: string
filePath?: string
svgPath: string
parent?: string | null
maxDim?: number

View file

@ -73,7 +73,7 @@ function isEmptyFrame(node: PenNode): boolean {
// ---------------------------------------------------------------------------
export interface InsertNodeParams {
filePath: string
filePath?: string
parent: string | null
data: Record<string, any>
postProcess?: boolean
@ -132,7 +132,7 @@ export async function handleInsertNode(
// ---------------------------------------------------------------------------
export interface UpdateNodeParams {
filePath: string
filePath?: string
nodeId: string
data: Record<string, any>
postProcess?: boolean
@ -164,7 +164,7 @@ export async function handleUpdateNode(
// ---------------------------------------------------------------------------
export interface DeleteNodeParams {
filePath: string
filePath?: string
nodeId: string
pageId?: string
}
@ -190,7 +190,7 @@ export async function handleDeleteNode(
// ---------------------------------------------------------------------------
export interface MoveNodeParams {
filePath: string
filePath?: string
nodeId: string
parent: string | null
index?: number
@ -221,7 +221,7 @@ export async function handleMoveNode(
// ---------------------------------------------------------------------------
export interface CopyNodeParams {
filePath: string
filePath?: string
sourceId: string
parent: string | null
overrides?: Record<string, any>
@ -260,7 +260,7 @@ export async function handleCopyNode(
// ---------------------------------------------------------------------------
export interface ReplaceNodeParams {
filePath: string
filePath?: string
nodeId: string
data: Record<string, any>
postProcess?: boolean

View file

@ -8,7 +8,7 @@ import type { PenPage, PenNode } from '../../types/pen'
// ---------------------------------------------------------------------------
export interface AddPageParams {
filePath: string
filePath?: string
name?: string
children?: Record<string, any>[]
}
@ -62,7 +62,7 @@ export async function handleAddPage(
// ---------------------------------------------------------------------------
export interface RemovePageParams {
filePath: string
filePath?: string
pageId: string
}
@ -91,7 +91,7 @@ export async function handleRemovePage(
// ---------------------------------------------------------------------------
export interface RenamePageParams {
filePath: string
filePath?: string
pageId: string
name: string
}
@ -118,7 +118,7 @@ export async function handleRenamePage(
// ---------------------------------------------------------------------------
export interface ReorderPageParams {
filePath: string
filePath?: string
pageId: string
index: number
}
@ -147,7 +147,7 @@ export async function handleReorderPage(
// ---------------------------------------------------------------------------
export interface DuplicatePageParams {
filePath: string
filePath?: string
pageId: string
name?: string
}

View file

@ -2,7 +2,7 @@ import { openDocument, resolveDocPath } from '../document-manager'
import { computeLayoutTree, getDocChildren, type LayoutEntry } from '../utils/node-operations'
export interface SnapshotLayoutParams {
filePath: string
filePath?: string
parentId?: string
maxDepth?: number
pageId?: string

View file

@ -9,7 +9,7 @@ import type { VariableDefinition } from '../../types/variables'
// ---------------------------------------------------------------------------
export interface SaveThemePresetParams {
filePath: string
filePath?: string
presetPath: string
name?: string
}
@ -38,7 +38,7 @@ export async function handleSaveThemePreset(
// ---------------------------------------------------------------------------
export interface LoadThemePresetParams {
filePath: string
filePath?: string
presetPath: string
}

View file

@ -2,11 +2,11 @@ import { openDocument, saveDocument, resolveDocPath } from '../document-manager'
import type { VariableDefinition } from '../../types/variables'
export interface GetVariablesParams {
filePath: string
filePath?: string
}
export interface SetVariablesParams {
filePath: string
filePath?: string
variables: Record<string, VariableDefinition>
replace?: boolean
}
@ -43,7 +43,7 @@ export async function handleSetVariables(
// ---------------------------------------------------------------------------
export interface SetThemesParams {
filePath: string
filePath?: string
themes: Record<string, string[]>
replace?: boolean
}

View file

@ -105,7 +105,9 @@ export const DESIGN_STREAM_TIMEOUTS = {
effort: DEFAULT_THINKING_EFFORT,
} as const
export const VALIDATION_TIMEOUT_MS = 30_000
export const VALIDATION_TIMEOUT_MS = 180_000
export const MAX_VALIDATION_ROUNDS = 3
export const VALIDATION_QUALITY_THRESHOLD = 8
export const RETRY_TIMEOUT_CONFIG = {
multiplier: 2,

View file

@ -22,6 +22,8 @@ export interface AIDesignRequest {
model?: string
provider?: AIProviderType
concurrency?: number
/** Generation mode: 'direct' uses current pipeline, 'visual-ref' uses visual reference pipeline */
mode?: 'direct' | 'visual-ref'
context?: {
selectedNodes?: string[]
documentSummary?: string
@ -31,6 +33,49 @@ export interface AIDesignRequest {
}
}
// ---------------------------------------------------------------------------
// Design System types — generated design tokens for consistency
// ---------------------------------------------------------------------------
export interface DesignSystem {
palette: {
background: string
surface: string
text: string
textSecondary: string
primary: string
primaryLight: string
accent: string
border: string
}
typography: {
headingFont: string
bodyFont: string
scale: number[]
}
spacing: {
unit: number
scale: number[]
}
radius: number[]
aesthetic: string
}
// ---------------------------------------------------------------------------
// Visual Reference types — for the visual reference pipeline
// ---------------------------------------------------------------------------
export interface VisualReference {
/** Generated HTML/CSS code */
html: string
/** Screenshot of the rendered HTML (base64 PNG, no data: prefix) */
screenshot: string
/** Design system tokens used */
designSystem: DesignSystem
/** Structural summary extracted from the HTML */
structureSummary: string
}
export interface AICodeRequest {
prompt?: string
format: 'react-tailwind' | 'html-css' | 'react-inline'
@ -62,6 +107,8 @@ export interface SubTask {
parentFrameId: string | null
/** Screen/page grouping — subtasks with the same screen share one root frame */
screen?: string
/** HTML reference snippet for this section (from visual reference pipeline) */
htmlReference?: string
}
/** Style guide produced by the orchestrator for visual consistency */

View file

@ -13,6 +13,7 @@ import {
createPhonePlaceholderDataUri,
estimateNodeIntrinsicHeight,
} from './generation-utils'
import { defaultLineHeight } from '@/canvas/canvas-text-measure'
import { applyIconPathResolution, applyNoEmojiIconHeuristic, resolveAsyncIcons, resolveAllPendingIcons } from './icon-resolver'
import {
resolveNodeRole,
@ -44,10 +45,15 @@ let generationCanvasWidth = 1200
/** Root frame ID for the current generation may differ from DEFAULT_FRAME_ID
* when canvas already has content and new content is placed beside it. */
let generationRootFrameId: string = DEFAULT_FRAME_ID
/** Node IDs that existed on canvas before the current generation started.
* Used by upsert sanitization to avoid ID collisions with pre-existing content. */
let preExistingNodeIds = new Set<string>()
export function resetGenerationRemapping(): void {
generationRemappedIds.clear()
generationRootFrameId = DEFAULT_FRAME_ID
// Snapshot all existing node IDs so upsert can avoid collisions
preExistingNodeIds = new Set(useDocumentStore.getState().getFlatNodes().map((n) => n.id))
}
export function setGenerationContextHint(hint?: string): void {
@ -158,8 +164,7 @@ export function insertStreamingNode(
}
// Default lineHeight based on text role (heading vs body)
if (!node.lineHeight) {
const fs = node.fontSize ?? 16
node.lineHeight = fs >= 28 ? 1.2 : 1.5
node.lineHeight = defaultLineHeight(node.fontSize ?? 16)
}
}
}
@ -174,6 +179,11 @@ export function insertStreamingNode(
applyGenerationHeuristics(node)
// Recursively remove x/y from children inside layout containers so the
// layout engine can position them correctly during canvas sync.
const parentHasLayout = parentNode ? hasActiveLayout(parentNode) : false
sanitizeLayoutChildPositions(node, parentHasLayout)
// Skip AI-streamed children under phone placeholders. Placeholder internals are
// normalized post-streaming (at most one centered label text is allowed).
// Also skip if the parent node doesn't exist on canvas (was itself blocked).
@ -624,10 +634,20 @@ function sanitizeNodesForUpsert(nodes: PenNode[]): PenNode[] {
sanitizeScreenFrameBounds(node)
}
// Start with pre-existing node IDs to avoid collisions with content
// that was on canvas before this generation started. IDs generated
// within the current batch are also tracked so siblings stay unique.
// Record remappings so progressive upsert can resolve renamed IDs.
const counters = new Map<string, number>()
const used = new Set<string>()
const used = new Set(preExistingNodeIds)
const newRemaps = new Map<string, string>()
for (const node of cloned) {
ensureUniqueNodeIds(node, used, counters)
ensureUniqueNodeIds(node, used, counters, newRemaps)
}
// Merge new remappings into the generation-wide remap table
for (const [from, to] of newRemaps) {
generationRemappedIds.set(from, to)
}
return cloned

View file

@ -0,0 +1,170 @@
/**
* Design Code Generator (Stage 1 of visual reference pipeline).
*
* Generates self-contained HTML/CSS code using the model's strongest design
* capability. The output is a visual reference that guides PenNode generation.
* Design principles are included to ensure consistent visual quality.
*/
import type { DesignSystem } from './ai-types'
import type { AIProviderType } from '@/types/agent-settings'
import { generateCompletion } from './ai-service'
import {
DESIGN_CODE_SYSTEM_PROMPT,
buildCodeGenUserPrompt,
} from './design-code-prompts'
import { getAllPrinciples } from './design-principles'
import { designSystemToPromptContext } from './design-system-generator'
interface CodeGenOptions {
width: number
height: number
model?: string
provider?: AIProviderType
}
/**
* Generate self-contained HTML/CSS code for a design request.
* The code is production-grade and serves as a visual blueprint.
*/
export async function generateDesignCode(
prompt: string,
designSystem: DesignSystem,
options: CodeGenOptions,
): Promise<string> {
const principles = getAllPrinciples()
// Build the system prompt with principles injected
const systemPrompt = principles
? `${DESIGN_CODE_SYSTEM_PROMPT}\n\n${principles}`
: DESIGN_CODE_SYSTEM_PROMPT
// Build the user prompt with design system context
const dsContext = designSystemToPromptContext(designSystem)
const userPrompt = buildCodeGenUserPrompt(
prompt,
dsContext,
options.width,
options.height,
)
const response = await generateCompletion(
systemPrompt,
userPrompt,
options.model,
options.provider,
)
return extractHtmlFromResponse(response)
}
/**
* Extract the HTML content from an AI response.
* Handles responses with code fences, markdown, or bare HTML.
*/
function extractHtmlFromResponse(response: string): string {
const trimmed = response.trim()
// Check for code fence wrapped HTML
const fenceMatch = trimmed.match(/```(?:html)?\s*\n?([\s\S]*?)\n?```/)
if (fenceMatch) {
const content = fenceMatch[1].trim()
if (content.includes('<!DOCTYPE') || content.includes('<html')) {
return content
}
}
// Check if the response itself starts with HTML
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
return trimmed
}
// Try to find HTML document in the response
const htmlMatch = trimmed.match(/(<!DOCTYPE[\s\S]*<\/html>)/i)
if (htmlMatch) {
return htmlMatch[1]
}
// Last resort: wrap bare content
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Design</title></head>
<body>${trimmed}</body>
</html>`
}
/**
* Extract a structural summary from HTML for use as sub-agent reference.
* Produces a concise text description of the HTML structure.
*/
export function extractStructureSummary(html: string): string {
const lines: string[] = ['DESIGN REFERENCE STRUCTURE:']
// Extract section-level elements
const sectionPattern = /<(?:section|header|footer|nav|main|div)\s+[^>]*(?:class|id)="([^"]*)"[^>]*>/gi
let match: RegExpExecArray | null
while ((match = sectionPattern.exec(html)) !== null) {
const classOrId = match[1]
if (classOrId && !classOrId.includes('__')) {
lines.push(`- Section: ${classOrId}`)
}
}
// Extract heading content for structure hints
const headingPattern = /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi
while ((match = headingPattern.exec(html)) !== null) {
const level = match[1]
const content = match[2].replace(/<[^>]+>/g, '').trim().slice(0, 60)
if (content) {
lines.push(`- H${level}: "${content}"`)
}
}
// Extract button/CTA text
const buttonPattern = /<(?:button|a)\s+[^>]*class="[^"]*(?:btn|button|cta)[^"]*"[^>]*>([\s\S]*?)<\/(?:button|a)>/gi
while ((match = buttonPattern.exec(html)) !== null) {
const text = match[1].replace(/<[^>]+>/g, '').trim().slice(0, 30)
if (text) {
lines.push(`- CTA: "${text}"`)
}
}
// If we couldn't extract structure, provide a generic summary
if (lines.length <= 1) {
lines.push('(HTML structure extracted — use as visual layout reference)')
}
return lines.join('\n')
}
/**
* Extract the HTML section relevant to a specific subtask label.
* Uses heuristic matching on section/div IDs, classes, and heading content.
*/
export function extractHtmlSection(html: string, subtaskLabel: string): string | null {
const labelLower = subtaskLabel.toLowerCase()
// Try to find a matching section by common keywords
const keywords = labelLower
.replace(/[(].+[)]/g, '')
.split(/[\s,/]+/)
.filter((w) => w.length > 2)
if (keywords.length === 0) return null
// Build a regex to match section containers
const keywordPattern = keywords.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
const sectionRegex = new RegExp(
`<(?:section|div|header|footer|nav)[^>]*(?:class|id)="[^"]*(?:${keywordPattern})[^"]*"[^>]*>[\\s\\S]*?(?=<(?:section|div|header|footer|nav)[^>]*(?:class|id)="|$)`,
'i',
)
const match = sectionRegex.exec(html)
if (match) {
// Truncate to reasonable length for context
const section = match[0].slice(0, 1500)
return `HTML reference for "${subtaskLabel}":\n${section}`
}
return null
}

View file

@ -0,0 +1,67 @@
/**
* Prompts for HTML/CSS design code generation (Stage 1 of visual reference pipeline).
*
* These prompts leverage the model's strongest design capability HTML/CSS generation
* to produce a high-fidelity visual reference. The generated HTML serves as a blueprint
* that is later converted to PenNode format.
*/
export const DESIGN_CODE_SYSTEM_PROMPT = `You are a world-class frontend designer. Generate a SINGLE self-contained HTML file that looks production-grade.
OUTPUT RULES:
- Output ONLY the complete HTML file, starting with <!DOCTYPE html>. No explanation.
- ALL CSS must be inline in a <style> block. No external stylesheets except Google Fonts.
- Use modern CSS: flexbox, gap, custom properties, clamp().
- The page must render correctly at the specified viewport dimensions.
- All images use colored placeholder rectangles with labels (no external images).
- Icons use simple inline SVG shapes (geometric, not complex).
- Include Google Fonts via <link> in the <head> if non-system fonts are specified.
DESIGN QUALITY:
- This is a visual reference for a design tool every pixel matters.
- Create clear visual hierarchy: one dominant element per section, everything else subordinate.
- Use whitespace generously premium designs breathe.
- Avoid template-ish layouts: don't put everything in the center, explore asymmetry.
- Color should guide the eye: accent color on CTAs and key elements, neutral everywhere else.
- Typography should create rhythm: vary size, weight, and color across the type scale.
- Shadows should be subtle (0 2px 8px rgba(0,0,0,0.08)) never heavy drop shadows.
- Corner radius should be consistent across the design (8-12px for modern, 16px+ for friendly).
- Sections should flow naturally: alternate background tints, use generous vertical padding (80-120px).
ANTI-PATTERNS TO AVOID:
- Every card looking identical with blue icon + black title + gray text (the "AI template" look).
- Centered everything real designs use left-alignment and asymmetric layouts.
- Too many things competing for attention ruthlessly prioritize.
- Decorative elements that serve no purpose every element must earn its place.
- Generic stock-photo-style image placeholders use branded colored rectangles.
- All buttons the same size and color create a button hierarchy.
TEXT CONTENT:
- Headlines: 2-6 words, punchy and specific to the product.
- Subtitles: 1 sentence, max 15 words.
- Feature descriptions: 1 sentence, max 20 words.
- Button text: 1-3 words.
- Never use lorem ipsum or generic "Your text here" placeholders.`
/**
* Build the user prompt for HTML/CSS code generation.
* Includes the design system tokens and viewport constraints.
*/
export function buildCodeGenUserPrompt(
userPrompt: string,
designSystemContext: string,
width: number,
height: number,
): string {
const heightInstruction = height > 0
? `Height: ${height}px (fixed viewport).`
: `Height: auto (content determines height, estimate based on sections).`
return `Design request: ${userPrompt}
Viewport: Width ${width}px. ${heightInstruction}
${designSystemContext}
Generate the complete HTML file now.`
}

View file

@ -5,6 +5,7 @@ import type { AIDesignRequest } from './ai-types'
import { streamChat } from './ai-service'
import { DESIGN_MODIFIER_PROMPT } from './ai-prompts'
import { executeOrchestration } from './orchestrator'
import { executeVisualRefOrchestration } from './visual-ref-orchestrator'
import { DESIGN_STREAM_TIMEOUTS } from './ai-runtime-config'
import { extractJsonFromResponse } from './design-parser'
@ -80,6 +81,9 @@ export async function generateDesign(
},
abortSignal?: AbortSignal,
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
if (request.mode === 'visual-ref') {
return executeVisualRefOrchestration(request, callbacks, abortSignal)
}
return executeOrchestration(request, callbacks, abortSignal)
}

View file

@ -153,7 +153,9 @@ export function ensureUniqueNodeIds(
node: PenNode,
used: Set<string>,
counters: Map<string, number>,
remapping?: Map<string, string>,
): void {
const originalId = node.id
const base = normalizeIdBase(node.id, node.type)
let finalId = base
@ -165,11 +167,16 @@ export function ensureUniqueNodeIds(
node.id = finalId
}
// Track original→new mapping so progressive upsert can resolve IDs
if (remapping && originalId && finalId !== originalId) {
remapping.set(originalId, finalId)
}
used.add(finalId)
if (!('children' in node) || !Array.isArray(node.children)) return
for (const child of node.children) {
ensureUniqueNodeIds(child, used, counters)
ensureUniqueNodeIds(child, used, counters, remapping)
}
}

View file

@ -0,0 +1,251 @@
/**
* Pre-validation: pure code checks that don't require LLM.
*
* Inspired by Pencil's search_all_unique_properties + replace_all_matching_properties.
* Runs before vision API validation to fix obvious inconsistencies:
* 1. Invisible containers frame with same fill as parent add border
* 2. Empty icons path nodes without geometry data
* 3. Sibling consistency majority-rule for height, cornerRadius, fontSize
*/
import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'
import type { PenNode } from '@/types/pen'
interface PreValidationFix {
nodeId: string
property: string
value: unknown
reason: string
}
/**
* Run pre-validation checks on the generated design.
* Returns the number of fixes applied.
*/
export function runPreValidationFixes(): number {
const store = useDocumentStore.getState()
const root = store.getNodeById(DEFAULT_FRAME_ID)
if (!root) return 0
const fixes: PreValidationFix[] = []
// Pass 1: Find invisible containers (same fill as parent → needs border)
detectInvisibleContainers(root, null, fixes, store)
// Pass 2: Find empty path/icon nodes (missing geometry)
detectEmptyPaths(root, fixes)
// Pass 3: Find sibling property inconsistencies
detectSiblingInconsistencies(root, fixes)
// Deduplicate (a node might get multiple fixes for same property)
const seen = new Set<string>()
const uniqueFixes = fixes.filter((f) => {
const key = `${f.nodeId}:${f.property}`
if (seen.has(key)) return false
seen.add(key)
return true
})
// Apply
for (const fix of uniqueFixes) {
if (fix.property === '__remove') {
store.removeNode(fix.nodeId)
console.log(`[Pre-validation] ${fix.nodeId}: removed (${fix.reason})`)
} else {
store.updateNode(fix.nodeId, { [fix.property]: fix.value })
console.log(
`[Pre-validation] ${fix.nodeId}: ${fix.property}${JSON.stringify(fix.value)} (${fix.reason})`,
)
}
}
return uniqueFixes.length
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Extract the first fill color from a node (raw, including variable refs) */
function getFirstFillColor(node: PenNode): string | null {
if (!('fill' in node) || !Array.isArray(node.fill) || node.fill.length === 0) return null
const first = node.fill[0]
if (first && 'color' in first && first.color) return first.color
return null
}
/** Check if a node already has a visible stroke */
function hasStroke(node: PenNode): boolean {
if (!('stroke' in node)) return false
const s = node.stroke as { thickness?: number } | undefined
return s != null && (s.thickness ?? 0) > 0
}
/** Resolve border color: prefer $color-border variable if it exists, else neutral gray */
function getBorderStroke(store: ReturnType<typeof useDocumentStore.getState>): {
thickness: number
fill: Array<{ type: 'solid'; color: string }>
} {
const doc = store.document
const hasBorderVar = doc.variables && 'color-border' in doc.variables
const color = hasBorderVar ? '$color-border' : '#E2E8F0'
return { thickness: 1, fill: [{ type: 'solid', color }] }
}
// ---------------------------------------------------------------------------
// Pass 1: Invisible container detection
// ---------------------------------------------------------------------------
/**
* Detect containers with same fill as parent.
* These blend visually and need a border to be distinguishable.
*/
function detectInvisibleContainers(
node: PenNode,
parentFillColor: string | null,
fixes: PreValidationFix[],
store: ReturnType<typeof useDocumentStore.getState>,
): void {
const nodeFill = getFirstFillColor(node)
if (
parentFillColor &&
nodeFill &&
nodeFill === parentFillColor &&
!hasStroke(node) &&
node.type === 'frame' &&
'layout' in node &&
node.layout && // only layout frames (inputs, buttons, cards)
'children' in node &&
node.children &&
node.children.length > 0 // has content inside
) {
fixes.push({
nodeId: node.id,
property: 'stroke',
value: getBorderStroke(store),
reason: `same fill as parent (${nodeFill})`,
})
}
// Recurse
if ('children' in node && node.children) {
for (const child of node.children) {
detectInvisibleContainers(child, nodeFill ?? parentFillColor, fixes, store)
}
}
}
// ---------------------------------------------------------------------------
// Pass 2: Empty path/icon detection
// ---------------------------------------------------------------------------
/**
* Detect path nodes without geometry data.
* These render as invisible empty rectangles on canvas.
*/
function detectEmptyPaths(node: PenNode, fixes: PreValidationFix[]): void {
if (node.type === 'path') {
const hasD = 'd' in node && (node as unknown as Record<string, unknown>).d
if (!hasD) {
fixes.push({
nodeId: node.id,
property: '__remove',
value: true,
reason: 'path node without geometry (renders invisible)',
})
}
}
if ('children' in node && node.children) {
for (const child of node.children) {
detectEmptyPaths(child, fixes)
}
}
}
// ---------------------------------------------------------------------------
// Pass 3: Sibling property consistency
// ---------------------------------------------------------------------------
/** Properties to check for consistency among same-type siblings */
const FRAME_CONSISTENCY_PROPS = ['height', 'cornerRadius'] as const
const TEXT_CONSISTENCY_PROPS = ['fontSize'] as const
/**
* Detect property inconsistencies among siblings.
* Uses majority rule: if >= 2/3 of siblings agree on a value, fix the outlier.
*/
function detectSiblingInconsistencies(node: PenNode, fixes: PreValidationFix[]): void {
if (!('children' in node) || !node.children) {
return
}
// Need at least 3 siblings for meaningful majority
if (node.children.length >= 3) {
// Group children by type
const groups = new Map<string, PenNode[]>()
for (const child of node.children) {
if (!groups.has(child.type)) groups.set(child.type, [])
groups.get(child.type)!.push(child)
}
for (const [type, siblings] of groups) {
if (siblings.length < 3) continue
const props = type === 'text' ? TEXT_CONSISTENCY_PROPS : FRAME_CONSISTENCY_PROPS
for (const prop of props) {
checkConsistency(siblings, prop, fixes)
}
}
}
// Recurse
for (const child of node.children) {
detectSiblingInconsistencies(child, fixes)
}
}
function checkConsistency(
siblings: PenNode[],
property: string,
fixes: PreValidationFix[],
): void {
const values = new Map<string, { value: unknown; nodes: PenNode[] }>()
for (const node of siblings) {
const raw = (node as unknown as Record<string, unknown>)[property]
if (raw == null) continue
const key = JSON.stringify(raw)
if (!values.has(key)) values.set(key, { value: raw, nodes: [] })
values.get(key)!.nodes.push(node)
}
if (values.size < 2) return // all same, no inconsistency
// Find majority
let majority: { value: unknown; nodes: PenNode[] } | null = null
for (const entry of values.values()) {
if (!majority || entry.nodes.length > majority.nodes.length) {
majority = entry
}
}
if (!majority) return
// Only fix if clear majority (>= 2/3)
const totalWithProp = Array.from(values.values()).reduce((s, e) => s + e.nodes.length, 0)
if (majority.nodes.length < (totalWithProp * 2) / 3) return
// Fix outliers
for (const entry of values.values()) {
if (entry === majority) continue
for (const node of entry.nodes) {
fixes.push({
nodeId: node.id,
property,
value: majority.value,
reason: `inconsistent with ${majority.nodes.length} siblings`,
})
}
}
}

View file

@ -0,0 +1,13 @@
/**
* Color theory design principles selective loading based on request context.
*/
export const COLOR_PRINCIPLES = `COLOR THEORY & PALETTE:
- A strong palette has 1 primary action color, 1 accent for secondary actions, and a neutral scale for text/backgrounds. Max 2 saturated colors more creates visual noise.
- Create depth with the neutral scale: page bg slightly tinted (not pure #FFFFFF use #F8FAFC, #FAFAF9, or #F5F3FF for subtle warmth/coolness), card surface pure white, text dark but not black (#0F172A reads softer than #000000).
- Contrast is law: text on backgrounds must pass WCAG AA (4.5:1 for body, 3:1 for large text). Test your accent color on both light and dark surfaces.
- Use color temperature to set mood: blue/slate = trust/tech, warm gray/amber = friendly/creative, emerald/teal = growth/health, violet/indigo = premium/creative.
- Gradient use: subtle gradients on hero backgrounds (30-degree angle, 2 related hues) add polish. Avoid rainbow gradients or harsh color jumps.
- Borders should be barely visible (#E2E8F0 on white, rgba(255,255,255,0.1) on dark) they separate, not decorate.
- Dark themes: background #0F172A or #18181B (never pure black), surface #1E293B, text #F8FAFC. Accent colors should be slightly lighter/more saturated than in light themes.
- Avoid the "AI palette" trap: not every design needs blue primary + white cards. Match colors to the product domain fintech can use deep navy, health apps can use sage green, creative tools can use coral/amber.`

View file

@ -0,0 +1,14 @@
/**
* Component design patterns selective loading based on request context.
*/
export const COMPONENT_PRINCIPLES = `COMPONENT DESIGN PATTERNS:
- Buttons have weight hierarchy: primary (filled, accent color) for THE action, secondary (outlined or ghost) for alternatives, tertiary (text-only) for less important. Never two primary buttons side by side.
- Cards: consistent corner radius (12-16px), consistent padding (24-32px), consistent shadow depth. Content inside follows vertical rhythm: image title description action. Separate concerns into body and footer.
- Navigation: keep it minimal logo, 3-5 links, one CTA. The nav bar is wayfinding, not a menu explosion. Use space_between for distribution.
- Forms: all inputs same width (fill_container), consistent height (44-48px), clear labels above inputs, submit button at bottom with same width. Group related fields. Don't forget affordance icons (search, email, password).
- Hero sections: one clear headline, one supporting sentence, one or two CTAs, optional visual (image/mockup). Resist adding more every extra element dilutes the focus.
- Feature sections: icon + title + description per card. Icons should be uniform size and style. Descriptions should be 1 sentence. Three or four cards max per row.
- Pricing cards: highlight the recommended plan with accent background or border. Keep plan names short. Use checkmark lists for features, not paragraphs.
- Testimonials: real-feeling names, short quotes (1-2 sentences), optional avatar. Three cards or a slider, not a wall of text.
- Footer: organized in 3-4 column groups (product, company, resources, social). Muted colors, smaller text. Don't cram everything from the nav here.`

View file

@ -0,0 +1,13 @@
/**
* Visual composition and hierarchy principles selective loading based on request context.
*/
export const COMPOSITION_PRINCIPLES = `VISUAL HIERARCHY & COMPOSITION:
- Every screen has ONE primary focal point. In a landing page hero, it's the headline. In a dashboard, it's the key metric. Make it unmistakably dominant through size, contrast, and space.
- Create visual weight through the hierarchy chain: focal headline supporting subtitle action CTA secondary content. Each level should be clearly subordinate to the one above.
- Use contrast pairs to create interest: large text next to small, dark block next to light, dense section next to spacious. Monotone layouts feel flat.
- Section backgrounds: alternate between white and tinted (#F8FAFC / #F1F5F9) to create natural separation. This is more elegant than borders or heavy shadows.
- The Z-pattern for landing pages: eye scans top-left (logo) top-right (CTA) middle-left (value prop) bottom-right (action). Place elements along this path.
- Visual grouping: items that belong together should share a container (card, subtle background). Items that don't should have clear separation. Proximity = relationship.
- Shadows create depth layers: no shadow = background, subtle shadow = floating card, medium shadow = modal/dropdown. Don't put heavy shadows on everything.
- Balance asymmetric layouts: a two-column hero with 60% text / 40% image is more dynamic than 50/50. The text side carries visual weight through content density.`

View file

@ -0,0 +1,35 @@
/**
* Design principles domain-specific design knowledge extracted from
* professional design practices.
*
* All principles are always included since the total content is small (~60 lines).
* Selective loading via keyword matching was removed to avoid hardcoded keyword lists.
*/
import { TYPOGRAPHY_PRINCIPLES } from './typography'
import { COLOR_PRINCIPLES } from './color'
import { SPACING_PRINCIPLES } from './spacing'
import { COMPOSITION_PRINCIPLES } from './composition'
import { COMPONENT_PRINCIPLES } from './components'
const ALL_PRINCIPLES = [
COMPOSITION_PRINCIPLES,
TYPOGRAPHY_PRINCIPLES,
COLOR_PRINCIPLES,
SPACING_PRINCIPLES,
COMPONENT_PRINCIPLES,
].join('\n\n')
/**
* Get all design principles combined.
*/
export function getAllPrinciples(): string {
return ALL_PRINCIPLES
}
// Re-export individual principles for direct access
export { TYPOGRAPHY_PRINCIPLES } from './typography'
export { COLOR_PRINCIPLES } from './color'
export { SPACING_PRINCIPLES } from './spacing'
export { COMPOSITION_PRINCIPLES } from './composition'
export { COMPONENT_PRINCIPLES } from './components'

View file

@ -0,0 +1,13 @@
/**
* Spacing and layout design principles selective loading based on request context.
*/
export const SPACING_PRINCIPLES = `SPACING & LAYOUT RHYTHM:
- Use an 8px grid: all spacing values should be multiples of 8 (8, 16, 24, 32, 48, 64, 80, 96). This creates visual consistency that humans perceive subconsciously.
- Section padding: hero/major sections need generous breathing room (80-120px vertical, 80px horizontal). Cramped sections feel cheap.
- Gap hierarchy mirrors content hierarchy: related items 8-16px apart, groups 24-32px, sections 48-80px. The gap between a title and its paragraph should be smaller than the gap between two cards.
- Padding inside containers should scale with the container size: small cards 16-20px, medium cards 24-32px, large sections 48-80px. Uniform 16px on everything looks template-ish.
- Consistent content width: keep the main content column at 1040-1160px across sections. Sections can have full-bleed backgrounds but content must align. Inconsistent content widths look amateurish.
- White space is a design element, not wasted space. A hero section with generous padding looks premium; a hero crammed with elements looks like a flyer.
- Cards in a row: equal width via fill_container, equal height via fill_container, consistent padding, consistent gap. Uneven cards break visual rhythm.
- Responsive readability: body text line length should be 50-75 characters. In a 1200px layout, this means text columns of ~600-700px, not full-width.`

View file

@ -0,0 +1,13 @@
/**
* Typography design principles selective loading based on request context.
*/
export const TYPOGRAPHY_PRINCIPLES = `TYPOGRAPHY CRAFT:
- Build a clear type scale with real contrast: display 48-64px, heading 28-36px, body 16px. Jumps must be noticeable 16 to 18 is not a "heading".
- Pair fonts with purpose: a geometric sans (Space Grotesk, Outfit) for headlines creates personality; a neutral sans (Inter, DM Sans) for body ensures readability. Never use the same font weight everywhere.
- Weight creates hierarchy: 700 for titles, 500 for subtitles, 400 for body. Using 600 for everything makes nothing stand out.
- Line height is tighter at large sizes (1.05-1.15 for 40px+) and looser at small sizes (1.5-1.6 for 16px). This is how print typography works.
- Letter spacing: pull headlines tighter (-0.5 to -1px) for density, push uppercase labels wider (+1-2px) for legibility. Body stays at 0.
- Use font weight shifts for emphasis within paragraphs (500 for inline emphasis), not font-size changes. Size hierarchy is structural, weight hierarchy is inline.
- Headlines are short and punchy (2-6 words). If your headline needs two lines, the font size is too large or the text is too verbose.
- Color reinforces hierarchy: primary text (#0F172A-level) for headings, muted (#475569-level) for body, lighter still for captions. Never same color at same opacity for all text.`

View file

@ -0,0 +1,67 @@
/**
* Screenshot capture utilities for design validation.
* Supports both full root frame and per-node screenshots.
*/
import { useCanvasStore } from '@/stores/canvas-store'
import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
/**
* Capture a screenshot of a specific node and all its descendants.
* Returns a base64 PNG data URL, or null if the node can't be rendered.
*/
export function captureNodeScreenshot(nodeId: string): string | null {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return null
const store = useDocumentStore.getState()
if (!store.getNodeById(nodeId)) return null
const allFlat = store.getFlatNodes()
const descendantIds = new Set<string>()
for (const node of allFlat) {
if (node.id !== nodeId && store.isDescendantOf(node.id, nodeId)) {
descendantIds.add(node.id)
}
}
const allObjects = canvas.getObjects() as FabricObjectWithPenId[]
const rootObj = allObjects.find((obj) => obj.penNodeId === nodeId)
if (!rootObj) return null
const originX = rootObj.left ?? 0
const originY = rootObj.top ?? 0
const w = (rootObj.width ?? 0) * (rootObj.scaleX ?? 1)
const h = (rootObj.height ?? 0) * (rootObj.scaleY ?? 1)
if (w <= 0 || h <= 0) return null
const allIds = new Set(descendantIds)
allIds.add(nodeId)
const layerObjects = allObjects.filter(
(obj) => obj.penNodeId && allIds.has(obj.penNodeId),
)
const offscreen = document.createElement('canvas')
offscreen.width = Math.ceil(w)
offscreen.height = Math.ceil(h)
const ctx = offscreen.getContext('2d')
if (!ctx) return null
ctx.translate(-originX, -originY)
for (const obj of layerObjects) {
obj.render(ctx)
}
return offscreen.toDataURL('image/png')
}
/**
* Capture a screenshot of the entire root frame.
*/
export function captureRootFrameScreenshot(): string | null {
return captureNodeScreenshot(DEFAULT_FRAME_ID)
}

View file

@ -0,0 +1,175 @@
/**
* Design System Generator (Stage 0 of visual reference pipeline).
*
* Generates structured design tokens (colors, typography, spacing) from
* a user's design request. The tokens serve dual purpose:
* 1. Guide HTML code generation for consistent design
* 2. Map to PenDocument.variables for design system integration
*/
import type { DesignSystem } from './ai-types'
import type { AIProviderType } from '@/types/agent-settings'
import type { VariableDefinition } from '@/types/variables'
import { generateCompletion } from './ai-service'
import { DESIGN_SYSTEM_PROMPT } from './design-system-prompts'
/**
* Generate a design system from a user's prompt.
* Uses a fast model (Haiku-class) for speed since output is small JSON.
*/
export async function generateDesignSystem(
prompt: string,
model?: string,
provider?: AIProviderType,
): Promise<DesignSystem> {
const response = await generateCompletion(
DESIGN_SYSTEM_PROMPT,
prompt,
model,
provider,
)
return parseDesignSystem(response)
}
/**
* Parse a design system from AI response text.
* Tolerant of code fences and surrounding text.
*/
function parseDesignSystem(text: string): DesignSystem {
const trimmed = text.trim()
// Try direct parse
const direct = tryParseDS(trimmed)
if (direct) return direct
// Try extracting from code fences
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (fenceMatch) {
const fenced = tryParseDS(fenceMatch[1].trim())
if (fenced) return fenced
}
// Try extracting first { ... } block
const firstBrace = trimmed.indexOf('{')
const lastBrace = trimmed.lastIndexOf('}')
if (firstBrace >= 0 && lastBrace > firstBrace) {
const braced = tryParseDS(trimmed.slice(firstBrace, lastBrace + 1))
if (braced) return braced
}
// Fallback: return default design system
return DEFAULT_DESIGN_SYSTEM
}
function tryParseDS(json: string): DesignSystem | null {
try {
const obj = JSON.parse(json) as Record<string, unknown>
if (!obj.palette || typeof obj.palette !== 'object') return null
if (!obj.typography || typeof obj.typography !== 'object') return null
const p = obj.palette as Record<string, string>
const t = obj.typography as Record<string, unknown>
const s = (obj.spacing as Record<string, unknown>) ?? { unit: 8, scale: [8, 16, 24, 32, 48, 64] }
return {
palette: {
background: p.background ?? '#F8FAFC',
surface: p.surface ?? '#FFFFFF',
text: p.text ?? '#0F172A',
textSecondary: p.textSecondary ?? '#475569',
primary: p.primary ?? '#2563EB',
primaryLight: p.primaryLight ?? '#DBEAFE',
accent: p.accent ?? '#0EA5E9',
border: p.border ?? '#E2E8F0',
},
typography: {
headingFont: (t.headingFont as string) ?? 'Space Grotesk',
bodyFont: (t.bodyFont as string) ?? 'Inter',
scale: Array.isArray(t.scale) ? (t.scale as number[]) : [14, 16, 20, 28, 40, 56],
},
spacing: {
unit: (s.unit as number) ?? 8,
scale: Array.isArray(s.scale) ? (s.scale as number[]) : [8, 16, 24, 32, 48, 64],
},
radius: Array.isArray(obj.radius) ? (obj.radius as number[]) : [8, 12, 16],
aesthetic: (obj.aesthetic as string) ?? 'clean modern',
}
} catch {
return null
}
}
const DEFAULT_DESIGN_SYSTEM: DesignSystem = {
palette: {
background: '#F8FAFC',
surface: '#FFFFFF',
text: '#0F172A',
textSecondary: '#475569',
primary: '#2563EB',
primaryLight: '#DBEAFE',
accent: '#0EA5E9',
border: '#E2E8F0',
},
typography: {
headingFont: 'Space Grotesk',
bodyFont: 'Inter',
scale: [14, 16, 20, 28, 40, 56],
},
spacing: {
unit: 8,
scale: [8, 16, 24, 32, 48, 64],
},
radius: [8, 12, 16],
aesthetic: 'clean modern blue',
}
// ---------------------------------------------------------------------------
// Map design system → PenDocument.variables
// ---------------------------------------------------------------------------
/**
* Convert a DesignSystem into PenDocument variable definitions.
* These are stored in the document and referenced as $variable-name in nodes.
*/
export function designSystemToVariables(ds: DesignSystem): Record<string, VariableDefinition> {
const vars: Record<string, VariableDefinition> = {}
// Colors
for (const [key, value] of Object.entries(ds.palette)) {
const name = `color-${kebab(key)}`
vars[name] = { type: 'color', value }
}
// Spacing
const spacingNames = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl']
for (let i = 0; i < ds.spacing.scale.length && i < spacingNames.length; i++) {
vars[`spacing-${spacingNames[i]}`] = { type: 'number', value: ds.spacing.scale[i] }
}
// Radius
const radiusNames = ['sm', 'md', 'lg', 'xl']
for (let i = 0; i < ds.radius.length && i < radiusNames.length; i++) {
vars[`radius-${radiusNames[i]}`] = { type: 'number', value: ds.radius[i] }
}
return vars
}
/**
* Build a concise design system context string for AI prompts.
*/
export function designSystemToPromptContext(ds: DesignSystem): string {
const p = ds.palette
return `DESIGN SYSTEM (use these values consistently):
Colors: bg ${p.background}, surface ${p.surface}, text ${p.text}, muted ${p.textSecondary}, primary ${p.primary}, primaryLight ${p.primaryLight}, accent ${p.accent}, border ${p.border}
Fonts: heading "${ds.typography.headingFont}", body "${ds.typography.bodyFont}"
Type scale: ${ds.typography.scale.join(', ')}px
Spacing: ${ds.spacing.scale.join(', ')}px (${ds.spacing.unit}px grid)
Radius: ${ds.radius.join(', ')}px
Style: ${ds.aesthetic}`
}
function kebab(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
}

View file

@ -0,0 +1,42 @@
/**
* Prompts for the design system generator (Stage 0 of visual reference pipeline).
* Produces structured design tokens from a user's design request.
*/
export const DESIGN_SYSTEM_PROMPT = `You are a design system architect. Given a product description, create a cohesive design token system.
Output ONLY a JSON object, no explanation.
{
"palette": {
"background": "#hex (page bg, slightly tinted — never pure white)",
"surface": "#hex (card/container bg)",
"text": "#hex (primary text, dark but not black)",
"textSecondary": "#hex (body/secondary text, muted)",
"primary": "#hex (main action color)",
"primaryLight": "#hex (lighter tint for hover/subtle backgrounds)",
"accent": "#hex (secondary accent, complementary to primary)",
"border": "#hex (subtle dividers)"
},
"typography": {
"headingFont": "font name (display/personality font)",
"bodyFont": "font name (readable/neutral font)",
"scale": [14, 16, 20, 28, 40, 56]
},
"spacing": {
"unit": 8,
"scale": [4, 8, 12, 16, 24, 32, 48, 64, 80, 96]
},
"radius": [4, 8, 12, 16],
"aesthetic": "2-5 word style description"
}
RULES:
- Match colors to the product personality: tech/SaaS cool blue/indigo, creative warm amber/coral, finance deep navy/emerald, health sage/teal, education violet/sky.
- Ensure WCAG AA contrast (4.5:1) between text and background, primary and surface.
- Font pairing: heading should be distinctive (Space Grotesk, Outfit, Sora, Plus Jakarta Sans, Clash Display), body should be readable (Inter, DM Sans, Satoshi). Max 2 families.
- CJK content: if the request is in Chinese/Japanese/Korean, use "Noto Sans SC"/"Noto Sans JP"/"Noto Sans KR" for heading, "Inter" for body. Never use display fonts without CJK glyphs.
- Dark theme: when request mentions dark/cyber/terminal/neon//, use dark background (#0F172A or #18181B), light text, brighter accents.
- Default to light theme unless explicitly asked for dark.
- Radius: 0-4 for sharp/professional, 8-12 for modern, 16+ for playful/friendly.
- Scale should have clear size jumps: [14, 16, 20, 28, 40, 56] not [14, 15, 16, 17, 18].
- Aesthetic description guides the overall feel: "clean minimal blue tech", "warm editorial amber", "bold dark neon gaming".`

View file

@ -6,12 +6,15 @@
* The LLM correlates visual issues with actual node IDs and returns fixes.
*/
import { useCanvasStore } from '@/stores/canvas-store'
import { nanoid } from 'nanoid'
import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'
import { VALIDATION_TIMEOUT_MS } from './ai-runtime-config'
import { VALIDATION_TIMEOUT_MS, MAX_VALIDATION_ROUNDS, VALIDATION_QUALITY_THRESHOLD } from './ai-runtime-config'
import type { PenNode } from '@/types/pen'
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
import type { AIProviderType } from '@/types/agent-settings'
import { getCurrentVisualReference, clearVisualReference } from './visual-ref-orchestrator'
import { lookupIconByName } from './icon-resolver'
import { runPreValidationFixes } from './design-pre-validation'
import { captureRootFrameScreenshot } from './design-screenshot'
// ---------------------------------------------------------------------------
// System prompt for the vision validator
@ -26,37 +29,79 @@ Check for these issues:
3. SPACING: Uneven padding, elements too close to edges, inconsistent gaps between siblings.
4. OVERFLOW: Text or elements visually clipped or extending beyond their container.
5. ALIGNMENT: Elements that should be aligned but aren't (e.g. form fields not left-aligned).
6. MISSING ICONS: Path nodes that rendered as empty/invisible rectangles.
6. TEXT CENTERING: Text that should be horizontally centered in its container but appears shifted left or right. Common in headings, buttons, divider text ("or continue with"), and footer text. Fix: ensure the parent container has alignItems="center" or the text node has width="fill_container".
7. MISSING ICONS: Path nodes that rendered as empty/invisible rectangles.
8. COLOR ISSUES: Text with poor contrast against its background, wrong background colors, inconsistent color usage across similar elements.
9. TYPOGRAPHY: Inconsistent font sizes between similar elements, wrong font weights for headings vs body text.
10. MISSING BORDERS: Input fields, cards, or containers that lack a visible border and blend into their parent background. Fix with strokeColor and strokeWidth.
11. STRUCTURAL INCONSISTENCY: Sibling elements that should follow the same pattern but have different child structures. For example, if one input field has a leading icon but a sibling input field does not, or a list item is missing an expected child element. Fix by adding the missing child node.
12. MISSING ELEMENTS: When a reference design is provided, check if important UI elements visible in the reference are missing or absent in the current design. Fix by adding the missing element as a child of the appropriate parent.
Output ONLY a JSON object. No explanation, no markdown fences.
{"issues":["description1","description2"],"fixes":[{"nodeId":"actual-node-id","property":"width","value":"fill_container"}]}
{"qualityScore":8,"issues":["description1","description2"],"fixes":[{"nodeId":"actual-node-id","property":"width","value":"fill_container"}],"structuralFixes":[]}
Allowed properties and value types:
qualityScore: Rate the overall design quality from 1-10.
- 9-10: Production-ready, polished design
- 7-8: Good design with minor issues
- 5-6: Acceptable but needs improvement
- 1-4: Significant problems
Allowed property fixes (update existing node):
- width: number | "fill_container" | "fit_content"
- height: number | "fill_container" | "fit_content"
- padding: number | [top,right,bottom,left]
- gap: number
- fontSize: number
- fontWeight: number (300-900)
- letterSpacing: number
- lineHeight: number
- cornerRadius: number
- opacity: number
- fillColor: "#hex" (background/fill color of the node)
- strokeColor: "#hex" (border/stroke color)
- strokeWidth: number (border/stroke width)
- textAlign: "left" | "center" | "right" (text horizontal alignment within its box)
- alignItems: "start" | "center" | "end"
- justifyContent: "start" | "center" | "end" | "space_between"
Structural fixes (add or remove nodes use sparingly, only for clear structural issues):
- Add child: {"action":"addChild","parentId":"real-parent-id","index":0,"node":{"type":"path","name":"KeyIcon","width":18,"height":18}}
- Add child: {"action":"addChild","parentId":"real-parent-id","node":{"type":"text","name":"Label","content":"text","fontSize":14,"fillColor":"#hex"}}
- Add child: {"action":"addChild","parentId":"real-parent-id","node":{"type":"frame","name":"Divider","width":"fill_container","height":1,"fillColor":"#hex"}}
- Remove node: {"action":"removeNode","nodeId":"real-node-id"}
For addChild nodes:
- type: "frame" | "text" | "path" | "rectangle" | "ellipse"
- For path/icon nodes: set name to the icon name (e.g. "KeyIcon", "LockIcon", "EyeIcon"). The system resolves icon paths automatically.
- index is optional (defaults to 0 = first child). Use it to control insertion position among siblings.
- Specify width, height, fillColor as needed. Other properties are optional.
IMPORTANT:
- Use REAL node IDs from the provided tree never guess or fabricate IDs.
- For form consistency issues, fix ALL inconsistent siblings, not just one.
- If the design looks correct, return: {"issues":[],"fixes":[]}
- Keep fixes minimal only fix clear visual bugs, not stylistic preferences.`
- If the design looks correct, return: {"qualityScore":9,"issues":[],"fixes":[],"structuralFixes":[]}
- Keep fixes minimal only fix clear visual bugs, not stylistic preferences.
- Focus on the most impactful issues first.
- For structuralFixes, only add elements that are clearly needed for consistency or completeness. Do not add decorative elements unless they are present in the reference.
- CRITICAL: When using addChild, ALWAYS include companion property fixes for the parent node to maintain correct layout. For example, if the parent has justifyContent="space_between" and adding a child would break the spacing, also add a property fix to change justifyContent and/or add a gap value. Look at sibling elements with the same pattern and match the parent's layout properties to theirs.
- CRITICAL: NEVER change height or width from "fit_content" to a fixed pixel value on a frame that has layout (auto-layout). This creates empty whitespace. If a container appears invisible, fix its opacity, fill color, or border instead not its height.`
// Properties that are safe to auto-fix, with allowed value types
const SAFE_FIX_PROPERTIES: Record<string, 'number' | 'sizing' | 'number_or_array' | 'enum_align' | 'enum_justify'> = {
const SAFE_FIX_PROPERTIES: Record<string, 'number' | 'sizing' | 'number_or_array' | 'enum_align' | 'enum_justify' | 'enum_text_align' | 'color' | 'font_weight'> = {
width: 'sizing',
height: 'sizing',
padding: 'number_or_array',
gap: 'number',
fontSize: 'number',
fontWeight: 'font_weight',
letterSpacing: 'number',
lineHeight: 'number',
cornerRadius: 'number',
opacity: 'number',
fillColor: 'color',
strokeColor: 'color',
strokeWidth: 'number',
textAlign: 'enum_text_align',
alignItems: 'enum_align',
justifyContent: 'enum_justify',
}
@ -85,9 +130,26 @@ function buildNodeTreeDump(rootId: string): string {
if ('padding' in node && node.padding != null) props.push(`pad=${JSON.stringify(node.padding)}`)
if ('justifyContent' in node && node.justifyContent) props.push(`justify=${node.justifyContent}`)
if ('alignItems' in node && node.alignItems) props.push(`align=${node.alignItems}`)
if (node.type === 'text' && 'content' in node) {
const content = (node as { content?: string }).content ?? ''
props.push(`text="${content.slice(0, 30)}"`)
if ('cornerRadius' in node && node.cornerRadius != null) props.push(`cr=${node.cornerRadius}`)
if ('opacity' in node && node.opacity != null && node.opacity !== 1) props.push(`opacity=${node.opacity}`)
if ('fill' in node && Array.isArray(node.fill) && node.fill.length > 0) {
const firstFill = node.fill[0]
if (firstFill && 'color' in firstFill && firstFill.color) props.push(`fill="${firstFill.color}"`)
}
if ('stroke' in node && node.stroke) {
const s = node.stroke as { thickness?: number | number[]; fill?: Array<{ color?: string }> }
const strokeColor = s.fill?.[0]?.color
const strokeW = typeof s.thickness === 'number' ? s.thickness : (Array.isArray(s.thickness) ? s.thickness[0] : 0)
if (strokeColor) props.push(`stroke="${strokeColor}" strokeW=${strokeW ?? 0}`)
}
if (node.type === 'text') {
if ('fontSize' in node && node.fontSize) props.push(`fontSize=${node.fontSize}`)
if ('fontWeight' in node && node.fontWeight) props.push(`fontWeight=${node.fontWeight}`)
if ('textAlign' in node && node.textAlign) props.push(`textAlign=${node.textAlign}`)
if ('content' in node) {
const content = (node as { content?: string }).content ?? ''
props.push(`text="${content.slice(0, 30)}"`)
}
}
lines.push(`${indent}${props.join(' ')}`)
@ -104,59 +166,6 @@ function buildNodeTreeDump(rootId: string): string {
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Screenshot capture
// ---------------------------------------------------------------------------
export function captureRootFrameScreenshot(): string | null {
const canvas = useCanvasStore.getState().fabricCanvas
if (!canvas) return null
const store = useDocumentStore.getState()
const rootNode = store.getNodeById(DEFAULT_FRAME_ID)
if (!rootNode) return null
const allFlat = store.getFlatNodes()
const descendantIds = new Set<string>()
for (const node of allFlat) {
if (node.id !== DEFAULT_FRAME_ID && store.isDescendantOf(node.id, DEFAULT_FRAME_ID)) {
descendantIds.add(node.id)
}
}
const allObjects = canvas.getObjects() as FabricObjectWithPenId[]
const rootObj = allObjects.find((obj) => obj.penNodeId === DEFAULT_FRAME_ID)
if (!rootObj) return null
const originX = rootObj.left ?? 0
const originY = rootObj.top ?? 0
const w = (rootObj.width ?? 0) * (rootObj.scaleX ?? 1)
const h = (rootObj.height ?? 0) * (rootObj.scaleY ?? 1)
if (w <= 0 || h <= 0) return null
const allIds = new Set(descendantIds)
allIds.add(DEFAULT_FRAME_ID)
const layerObjects = allObjects.filter(
(obj) => obj.penNodeId && allIds.has(obj.penNodeId),
)
const offscreen = document.createElement('canvas')
offscreen.width = Math.ceil(w)
offscreen.height = Math.ceil(h)
const ctx = offscreen.getContext('2d')
if (!ctx) return null
ctx.translate(-originX, -originY)
for (const obj of layerObjects) {
obj.render(ctx)
}
return offscreen.toDataURL('image/png')
}
// ---------------------------------------------------------------------------
// Validation API call
// ---------------------------------------------------------------------------
@ -167,9 +176,40 @@ interface ValidationFix {
value: number | string | number[]
}
interface StructuralAddChildFix {
action: 'addChild'
parentId: string
index?: number
node: {
type: 'frame' | 'text' | 'path' | 'rectangle' | 'ellipse'
name?: string
width?: number | string
height?: number | string
fillColor?: string
content?: string
fontSize?: number
fontWeight?: number
layout?: string
gap?: number
padding?: number | number[]
cornerRadius?: number
alignItems?: string
justifyContent?: string
}
}
interface StructuralRemoveNodeFix {
action: 'removeNode'
nodeId: string
}
type StructuralFix = StructuralAddChildFix | StructuralRemoveNodeFix
interface ValidationResult {
issues: string[]
fixes: ValidationFix[]
structuralFixes: StructuralFix[]
qualityScore: number
skipped?: boolean
}
@ -178,9 +218,19 @@ async function validateDesignScreenshot(
nodeTreeDump: string,
model?: string,
provider?: AIProviderType,
referenceScreenshot?: string,
round: number = 1,
): Promise<ValidationResult> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS)
const timeout = setTimeout(() => controller.abort(), referenceScreenshot ? VALIDATION_TIMEOUT_MS * 2 : VALIDATION_TIMEOUT_MS)
const referenceInstruction = referenceScreenshot
? `\n\nA REFERENCE DESIGN screenshot was also provided. Compare the current design against the reference and fix any significant deviations in layout, spacing, proportions, or missing elements. The reference shows the intended design — the current screenshot should match its structure, visual balance, and element completeness. If elements visible in the reference are missing in the current design, use structuralFixes with addChild to add them.`
: ''
const roundInstruction = round > 1
? `\n\nThis is validation round ${round}. Previous fixes have already been applied. Focus on remaining issues only — do NOT re-report issues that have already been fixed.`
: ''
const message = `Analyze this UI design screenshot. Here is the node tree structure:
@ -188,7 +238,7 @@ async function validateDesignScreenshot(
${nodeTreeDump}
\`\`\`
Cross-reference visual issues with the node IDs above. Return JSON fixes using real node IDs from the tree.`
Cross-reference visual issues with the node IDs above. Return JSON fixes using real node IDs from the tree.${referenceInstruction}${roundInstruction}`
try {
const response = await fetch('/api/ai/validate', {
@ -205,23 +255,37 @@ Cross-reference visual issues with the node IDs above. Return JSON fixes using r
})
if (!response.ok) {
return { issues: [], fixes: [], skipped: true }
console.warn(`[Validation] HTTP ${response.status}: ${response.statusText}`)
return { issues: [], fixes: [], structuralFixes: [], qualityScore: 0, skipped: true }
}
const data = await response.json() as { text?: string; skipped?: boolean; error?: string }
if (data.skipped || data.error || !data.text) {
return { issues: [], fixes: [], skipped: true }
console.warn(`[Validation] Server response:`, {
skipped: data.skipped, error: data.error, hasText: !!data.text,
provider, model,
})
return { issues: [], fixes: [], structuralFixes: [], qualityScore: 0, skipped: true }
}
return parseValidationResponse(data.text)
} catch {
return { issues: [], fixes: [], skipped: true }
const parsed = parseValidationResponse(data.text)
if (parsed.qualityScore === 0) {
console.warn(`[Validation] qualityScore=0, raw response (first 500 chars):`, data.text.slice(0, 500))
}
return parsed
} catch (err) {
console.warn(`[Validation] Fetch error:`, err)
return { issues: [], fixes: [], structuralFixes: [], qualityScore: 0, skipped: true }
} finally {
clearTimeout(timeout)
}
}
const VALID_HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
const VALID_FONT_WEIGHTS = new Set([100, 200, 300, 400, 500, 600, 700, 800, 900])
const VALID_TEXT_ALIGN = new Set(['left', 'center', 'right'])
function isValidFixValue(property: string, value: unknown): boolean {
const type = SAFE_FIX_PROPERTIES[property]
if (!type) return false
@ -237,11 +301,33 @@ function isValidFixValue(property: string, value: unknown): boolean {
return typeof value === 'string' && VALID_ALIGN.has(value)
case 'enum_justify':
return typeof value === 'string' && VALID_JUSTIFY.has(value)
case 'color':
return typeof value === 'string' && VALID_HEX_COLOR.test(value)
case 'font_weight':
return typeof value === 'number' && VALID_FONT_WEIGHTS.has(value)
case 'enum_text_align':
return typeof value === 'string' && VALID_TEXT_ALIGN.has(value)
default:
return false
}
}
function isValidStructuralFix(fix: unknown): fix is StructuralFix {
if (!fix || typeof fix !== 'object') return false
const f = fix as Record<string, unknown>
if (f.action === 'addChild') {
if (typeof f.parentId !== 'string' || !f.parentId) return false
if (!f.node || typeof f.node !== 'object') return false
const node = f.node as Record<string, unknown>
const validTypes = new Set(['frame', 'text', 'path', 'rectangle', 'ellipse'])
return typeof node.type === 'string' && validTypes.has(node.type)
}
if (f.action === 'removeNode') {
return typeof f.nodeId === 'string' && !!f.nodeId
}
return false
}
function parseValidationResponse(text: string): ValidationResult {
const tryParse = (json: string): ValidationResult | null => {
try {
@ -250,49 +336,319 @@ function parseValidationResponse(text: string): ValidationResult {
parsed.fixes = parsed.fixes.filter(
(f) => f.nodeId && f.property in SAFE_FIX_PROPERTIES && isValidFixValue(f.property, f.value),
)
// Parse and validate structural fixes
parsed.structuralFixes = Array.isArray(parsed.structuralFixes)
? parsed.structuralFixes.filter(isValidStructuralFix)
: []
const rawScore = parsed.qualityScore
const numScore = typeof rawScore === 'number' ? rawScore
: typeof rawScore === 'string' ? Number(rawScore)
: 0
parsed.qualityScore = numScore > 0
? Math.max(1, Math.min(10, Math.round(numScore)))
: 0
return parsed
} catch {
return null
}
}
// Strip Agent SDK tool_use XML blocks that may precede the JSON response.
// The Agent SDK sometimes includes raw tool call XML (e.g. <tool_use>...<input>{...}</input></tool_use>)
// which confuses the JSON extraction regex.
const cleaned = text.replace(/<tool_use>[\s\S]*?<\/tool_use>/g, '').trim()
// Try direct parse
const direct = tryParse(text.trim())
const direct = tryParse(cleaned)
if (direct) return direct
// Try extracting JSON from text
const match = text.match(/\{[\s\S]*\}/)
const match = cleaned.match(/\{[\s\S]*\}/)
if (match) {
const extracted = tryParse(match[0])
if (extracted) return extracted
}
return { issues: [], fixes: [] }
return { issues: [], fixes: [], structuralFixes: [], qualityScore: 0 }
}
// ---------------------------------------------------------------------------
// Apply fixes
// ---------------------------------------------------------------------------
function applyValidationFixes(result: ValidationResult): number {
if (result.fixes.length === 0) return 0
async function applyValidationFixes(result: ValidationResult): Promise<number> {
const hasFixes = result.fixes.length > 0
const hasStructural = result.structuralFixes.length > 0
if (!hasFixes && !hasStructural) return 0
const store = useDocumentStore.getState()
let applied = 0
const skipped: string[] = []
// --- Property fixes ---
for (const fix of result.fixes) {
const node = store.getNodeById(fix.nodeId)
if (!node) continue
if (!(fix.property in SAFE_FIX_PROPERTIES)) continue
if (!isValidFixValue(fix.property, fix.value)) continue
if (!node) {
skipped.push(`${fix.nodeId} (not found)`)
continue
}
if (!(fix.property in SAFE_FIX_PROPERTIES)) {
skipped.push(`${fix.nodeId}.${fix.property} (unsupported property)`)
continue
}
if (!isValidFixValue(fix.property, fix.value)) {
skipped.push(`${fix.nodeId}.${fix.property}=${JSON.stringify(fix.value)} (invalid value)`)
continue
}
const oldValue = (node as unknown as Record<string, unknown>)[fix.property]
// Safety guard: never change fit_content → fixed pixel on layout containers.
if (
(fix.property === 'height' || fix.property === 'width') &&
typeof fix.value === 'number' &&
oldValue === 'fit_content' &&
'layout' in node && node.layout &&
'children' in node && Array.isArray(node.children) && node.children.length > 1
) {
skipped.push(`${fix.nodeId}.${fix.property} (refusing fit_content→${fix.value}px on layout container)`)
console.warn(`[Validation] Blocked: ${fix.nodeId}.${fix.property} fit_content→${fix.value}px (layout container with ${node.children.length} children)`)
continue
}
// fillColor is a virtual property — translate to PenFill array
if (fix.property === 'fillColor' && typeof fix.value === 'string') {
store.updateNode(fix.nodeId, { fill: [{ type: 'solid', color: fix.value }] })
console.log(`[Validation Fix] ${fix.nodeId}: fill → ${fix.value}`)
applied++
continue
}
// strokeColor — translate to PenStroke
if (fix.property === 'strokeColor' && typeof fix.value === 'string') {
const existingNode = store.getNodeById(fix.nodeId)
const existingStroke = existingNode && 'stroke' in existingNode ? existingNode.stroke : undefined
const thickness = existingStroke && 'thickness' in existingStroke ? (existingStroke as { thickness?: number }).thickness ?? 1 : 1
store.updateNode(fix.nodeId, {
stroke: { thickness, fill: [{ type: 'solid', color: fix.value }] },
})
console.log(`[Validation Fix] ${fix.nodeId}: strokeColor → ${fix.value}`)
applied++
continue
}
// strokeWidth — update thickness in existing stroke or create new stroke
if (fix.property === 'strokeWidth' && typeof fix.value === 'number') {
const existingNode = store.getNodeById(fix.nodeId)
const existingStroke = existingNode && 'stroke' in existingNode ? existingNode.stroke : undefined
const color = existingStroke && 'fill' in (existingStroke as object)
? ((existingStroke as { fill?: Array<{ color?: string }> }).fill?.[0]?.color ?? '#CBD5E1')
: '#CBD5E1'
store.updateNode(fix.nodeId, {
stroke: { thickness: fix.value, fill: [{ type: 'solid', color }] },
})
console.log(`[Validation Fix] ${fix.nodeId}: strokeWidth → ${fix.value}`)
applied++
continue
}
store.updateNode(fix.nodeId, { [fix.property]: fix.value })
console.log(`[Validation Fix] ${fix.nodeId}: ${fix.property} ${JSON.stringify(oldValue)}${JSON.stringify(fix.value)}`)
applied++
}
// --- Structural fixes ---
for (const sf of result.structuralFixes) {
if (sf.action === 'addChild') {
const parent = store.getNodeById(sf.parentId)
if (!parent) {
skipped.push(`addChild: parent ${sf.parentId} not found`)
continue
}
const newNode = await buildNodeFromSpec(sf.node)
if (!newNode) {
skipped.push(`addChild: could not build node for ${sf.node.type}:${sf.node.name ?? '?'}`)
continue
}
store.addNode(sf.parentId, newNode, sf.index)
console.log(`[Validation Fix] addChild: ${newNode.type}:${newNode.name ?? newNode.id} → parent ${sf.parentId} at index ${sf.index ?? 0}`)
applied++
// Auto-fix parent layout if addChild might break it.
// When adding a child to a space_between parent, look for a sibling
// with the same pattern and copy its layout properties.
autoFixParentLayoutAfterAddChild(store, sf.parentId, parent)
} else if (sf.action === 'removeNode') {
const node = store.getNodeById(sf.nodeId)
if (!node) {
skipped.push(`removeNode: ${sf.nodeId} not found`)
continue
}
store.removeNode(sf.nodeId)
console.log(`[Validation Fix] removeNode: ${sf.nodeId}`)
applied++
}
}
if (skipped.length > 0) {
console.warn(`[Validation] Skipped fixes:`, skipped)
}
return applied
}
/**
* Build a PenNode from a structural fix spec.
* For path nodes with icon-like names, resolves via the local icon registry.
*/
async function buildNodeFromSpec(
spec: StructuralAddChildFix['node'],
): Promise<PenNode | null> {
const id = nanoid(8)
const node: Record<string, unknown> = {
id,
type: spec.type,
name: spec.name,
}
// Dimensions
if (spec.width != null) node.width = spec.width
if (spec.height != null) node.height = spec.height
if (spec.cornerRadius != null) node.cornerRadius = spec.cornerRadius
if (spec.layout) node.layout = spec.layout
if (spec.gap != null) node.gap = spec.gap
if (spec.padding != null) node.padding = spec.padding
if (spec.alignItems) node.alignItems = spec.alignItems
if (spec.justifyContent) node.justifyContent = spec.justifyContent
// Fill color
if (spec.fillColor && VALID_HEX_COLOR.test(spec.fillColor)) {
node.fill = [{ type: 'solid', color: spec.fillColor }]
}
// Text-specific
if (spec.type === 'text') {
if (spec.content) node.content = spec.content
if (spec.fontSize) node.fontSize = spec.fontSize
if (spec.fontWeight) node.fontWeight = spec.fontWeight
}
// Path/icon — resolve icon path data
if (spec.type === 'path' && spec.name) {
const color = spec.fillColor && VALID_HEX_COLOR.test(spec.fillColor) ? spec.fillColor : '#64748B'
const icon = lookupIconByName(spec.name)
if (icon) {
node.d = icon.d
node.iconId = icon.iconId
if (icon.style === 'stroke') {
node.stroke = { thickness: 2, fill: [{ type: 'solid', color }] }
node.fill = []
} else {
node.fill = [{ type: 'solid', color }]
}
} else {
// Try server-side resolution
try {
const res = await fetch(`/api/ai/icon?name=${encodeURIComponent(spec.name)}`)
if (res.ok) {
const data = await res.json()
if (data.icon) {
node.d = data.icon.d
node.iconId = data.icon.iconId
if (data.icon.style === 'stroke') {
node.stroke = { thickness: 2, fill: [{ type: 'solid', color }] }
node.fill = []
} else {
node.fill = [{ type: 'solid', color }]
}
}
}
} catch {
console.warn(`[Validation] Icon resolution failed for ${spec.name}`)
}
}
// Default dimensions for icons if not specified
if (!spec.width) node.width = 18
if (!spec.height) node.height = 18
}
return node as unknown as PenNode
}
/**
* After adding a child, auto-fix parent layout if needed.
* Searches the tree for structurally equivalent nodes (same type, layout,
* similar name pattern) and copies their layout properties.
*/
function autoFixParentLayoutAfterAddChild(
store: ReturnType<typeof useDocumentStore.getState>,
parentId: string,
parentBeforeAdd: PenNode,
): void {
const parentNode = parentBeforeAdd as unknown as Record<string, unknown>
const justify = parentNode.justifyContent as string | undefined
if (!justify || justify === 'start') return // already fine
// Search the full flat node list for a structural equivalent:
// same type, same layout direction, similar name pattern, but different justify
const flatNodes = store.getFlatNodes()
const parentNameBase = extractNameBase(parentBeforeAdd.name ?? '')
// Get current parent (after child was added) to compare child counts
const currentParent = store.getNodeById(parentId)
const currentChildCount = currentParent && 'children' in currentParent
? (currentParent.children?.length ?? 0)
: 0
for (const candidate of flatNodes) {
if (candidate.id === parentId) continue
if (candidate.type !== parentBeforeAdd.type) continue
const cand = candidate as unknown as Record<string, unknown>
if (cand.layout !== parentNode.layout) continue
// Check name similarity (e.g. "Email Input" vs "Password Input" share "Input")
const candNameBase = extractNameBase(candidate.name ?? '')
if (!parentNameBase || !candNameBase || parentNameBase !== candNameBase) continue
// Skip if the target parent now has more children than the candidate.
// The candidate's layout was designed for fewer children and may not
// be appropriate (e.g. copying "start" from a 2-child sibling would
// break a 3-child node that needs "space_between" for a trailing icon).
const candChildCount = 'children' in candidate
? ((candidate as { children?: unknown[] }).children?.length ?? 0)
: 0
if (currentChildCount > candChildCount) {
console.log(`[Validation Fix] autoFixParentLayout: skipped ${parentId} — has ${currentChildCount} children vs candidate ${candidate.id} with ${candChildCount}`)
return
}
// Found a structural equivalent with same or more children — copy its justify and gap
const candJustify = cand.justifyContent as string | undefined
const candGap = cand.gap as number | undefined
const updates: Record<string, unknown> = {}
if ((candJustify ?? 'start') !== justify) {
updates.justifyContent = candJustify ?? 'start'
}
if (candGap != null && candGap !== parentNode.gap) {
updates.gap = candGap
}
if (Object.keys(updates).length > 0) {
store.updateNode(parentId, updates)
console.log(`[Validation Fix] autoFixParentLayout: ${parentId} matched ${candidate.id}`, updates)
}
return
}
}
/** Extract the last word of a node name as a structural key (e.g. "Email Input" → "input") */
function extractNameBase(name: string): string {
const words = name.trim().toLowerCase().split(/\s+/)
return words.length > 0 ? words[words.length - 1] : ''
}
// ---------------------------------------------------------------------------
// Public orchestration
// ---------------------------------------------------------------------------
@ -304,52 +660,175 @@ export async function runPostGenerationValidation(
provider?: AIProviderType
},
): Promise<{ applied: number; skipped: boolean }> {
options?.onStatusUpdate?.('streaming', 'Capturing screenshot...')
let totalApplied = 0
let lastQualityScore = 0
const fixHistory = new Map<string, number>() // "nodeId:property" → round count
// Wait for canvas render to stabilize
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
// Accumulate a log so the final status retains all validation steps
const log: string[] = []
function emit(status: 'pending' | 'streaming' | 'done' | 'error', line?: string) {
if (line) log.push(line)
options?.onStatusUpdate?.(status, log.join('\n'))
}
// Pre-validation: pure code checks (no LLM needed)
emit('streaming', '[pending] Running pre-checks...')
const preFixCount = runPreValidationFixes()
if (preFixCount > 0) {
totalApplied += preFixCount
log[log.length - 1] = `[done] Pre-checks: fixed ${preFixCount} issue${preFixCount > 1 ? 's' : ''}`
} else {
log[log.length - 1] = '[done] Pre-checks: OK'
}
emit('streaming')
for (let round = 1; round <= MAX_VALIDATION_ROUNDS; round++) {
const isFirstRound = round === 1
emit('streaming',
isFirstRound ? '[pending] Capturing screenshot...' : `[pending] Re-capturing screenshot (round ${round})...`,
)
// Wait for canvas render to stabilize.
// After applying fixes (round 2+), the Zustand → canvas sync pipeline needs
// more time: subscribe fires → flattenNodes → computeLayout → Fabric render.
// Use a longer delay for subsequent rounds to ensure fixes are rendered.
if (round > 1) {
await new Promise<void>((resolve) => setTimeout(resolve, 500))
}
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
})
const imageBase64 = captureRootFrameScreenshot()
if (!imageBase64) {
console.warn('[Validation] Could not capture screenshot — skipping')
options?.onStatusUpdate?.('done', 'Skipped (no screenshot)')
return { applied: 0, skipped: true }
const imageBase64 = captureRootFrameScreenshot()
if (!imageBase64) {
console.warn(`[Validation] Round ${round}: could not capture screenshot — stopping`)
if (isFirstRound) {
emit('done', '[error] Screenshot failed')
clearVisualReference()
return { applied: 0, skipped: true }
}
break
}
// Replace the "Capturing..." line with success
log[log.length - 1] = isFirstRound ? '[done] Screenshot captured' : `[done] Screenshot captured (round ${round})`
emit('streaming')
const nodeTreeDump = buildNodeTreeDump(DEFAULT_FRAME_ID)
if (isFirstRound) {
console.log(`[Validation] Node tree dump:\n${nodeTreeDump}`)
}
// Reference comparison only on first round
const visualRef = isFirstRound ? getCurrentVisualReference() : null
const hasReference = visualRef?.screenshot && visualRef.screenshot.length > 0
emit('streaming',
hasReference && isFirstRound
? '[pending] Comparing with design reference...'
: isFirstRound ? '[pending] Analyzing design...' : `[pending] Analyzing (round ${round})...`,
)
const result = await validateDesignScreenshot(
imageBase64,
nodeTreeDump,
options?.model,
options?.provider,
hasReference ? visualRef!.screenshot : undefined,
round,
)
if (result.skipped) {
console.log(`[Validation] Round ${round}: skipped (see warnings above for details; provider=${options?.provider}, model=${options?.model})`)
// Replace "Analyzing..." with skipped reason
log[log.length - 1] = '[error] Analysis skipped (timeout or provider error)'
if (isFirstRound) {
clearVisualReference()
emit('done')
return { applied: 0, skipped: true }
}
emit('streaming')
break
}
if (result.qualityScore > 0) {
lastQualityScore = result.qualityScore
}
// Replace "Analyzing..." with result
const scoreLabel = result.qualityScore > 0 ? ` (quality: ${result.qualityScore}/10)` : ''
if (result.qualityScore === 0 && result.issues.length === 0) {
// Parsing failed — not a clean pass
log[log.length - 1] = `[error] Analysis incomplete (round ${round})`
console.warn(`[Validation] Round ${round}: qualityScore=0 with no issues — likely parse failure`)
break
} else if (result.issues.length > 0) {
log[log.length - 1] = `[done] Found ${result.issues.length} issue${result.issues.length > 1 ? 's' : ''}${scoreLabel}`
console.log(`[Validation] Round ${round}: issues found:`, result.issues)
} else {
log[log.length - 1] = `[done] No issues found${scoreLabel}`
}
emit('streaming')
// Quality threshold reached — design is good enough
if (result.qualityScore >= VALIDATION_QUALITY_THRESHOLD) {
console.log(`[Validation] Round ${round}: quality ${result.qualityScore}/10 >= threshold, stopping`)
break
}
// Track fixes for repeated-fix detection
for (const f of result.fixes) {
const key = `${f.nodeId}:${f.property}`
fixHistory.set(key, (fixHistory.get(key) ?? 0) + 1)
}
// Filter out repeated fixes that already failed in previous rounds
if (round > 1) {
const preFilterLen = result.fixes.length
result.fixes = result.fixes.filter(f => (fixHistory.get(`${f.nodeId}:${f.property}`) ?? 0) <= 1)
if (result.fixes.length < preFilterLen) {
console.log(`[Validation] Round ${round}: filtered ${preFilterLen - result.fixes.length} repeated fix(es)`)
}
}
if (result.fixes.length === 0 && result.structuralFixes.length === 0) {
console.log(`[Validation] Round ${round}: no fixes needed`)
break
}
const totalFixCount = result.fixes.length + result.structuralFixes.length
emit('streaming', `[pending] Applying ${totalFixCount} fix${totalFixCount > 1 ? 'es' : ''}...`)
const applied = await applyValidationFixes(result)
totalApplied += applied
console.log(`[Validation] Round ${round}: applied ${applied} fixes (quality: ${result.qualityScore}/10):`, result.fixes, result.structuralFixes)
// Replace "Applying..." with result
if (applied > 0) {
log[log.length - 1] = `[done] Applied ${applied} fix${applied > 1 ? 'es' : ''}`
} else {
log[log.length - 1] = '[error] No fixes could be applied'
console.log(`[Validation] Round ${round}: no fixes could be applied, stopping`)
break
}
emit('streaming')
}
// Build simplified node tree for LLM context
const nodeTreeDump = buildNodeTreeDump(DEFAULT_FRAME_ID)
// Cleanup visual reference after all rounds
clearVisualReference()
options?.onStatusUpdate?.('streaming', 'Analyzing design...')
const result = await validateDesignScreenshot(
imageBase64,
nodeTreeDump,
options?.model,
options?.provider,
)
if (result.skipped) {
console.log('[Validation] Skipped (provider unsupported)')
options?.onStatusUpdate?.('done', 'Skipped')
return { applied: 0, skipped: true }
// Final summary line
const qualityInfo = lastQualityScore > 0 ? ` — quality: ${lastQualityScore}/10` : ''
if (totalApplied > 0) {
emit('done', `[done] Done: ${totalApplied} fix${totalApplied > 1 ? 'es' : ''} applied${qualityInfo}`)
} else if (lastQualityScore > 0) {
emit('done', `[done] Done: no fixes needed${qualityInfo}`)
} else {
emit('done')
}
if (result.issues.length > 0) {
console.log('[Validation] Issues found:', result.issues)
}
if (result.fixes.length === 0) {
console.log('[Validation] No fixes needed')
options?.onStatusUpdate?.('done', 'No issues found')
return { applied: 0, skipped: false }
}
options?.onStatusUpdate?.('streaming', `Applying ${result.fixes.length} fixes...`)
const applied = applyValidationFixes(result)
console.log(`[Validation] Applied ${applied} fixes:`, result.fixes)
options?.onStatusUpdate?.('done', `Applied ${applied} fixes`)
return { applied, skipped: false }
return { applied: totalApplied, skipped: false }
}

View file

@ -1,4 +1,9 @@
import type { PenNode } from '@/types/pen'
import {
defaultLineHeight,
estimateLineWidth,
hasCjkText as _hasCjkText,
} from '@/canvas/canvas-text-measure'
// ---------------------------------------------------------------------------
// Pure utility functions extracted from design-generator.ts
@ -93,50 +98,6 @@ export function parsePaddingValues(
// Text measurement utilities
// ---------------------------------------------------------------------------
export function estimateWrappingLineWidth(
text: string,
fontSize: number,
): number {
let width = 0
for (const char of text) {
if (
/[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF\uFE30-\uFE4F]/.test(
char,
)
) {
width += fontSize * 1.12
} else if (char === ' ') {
width += fontSize * 0.3
} else if (/[A-Z]/.test(char)) {
width += fontSize * 0.72
} else {
width += fontSize * 0.58
}
}
return width
}
export function estimateSingleLineTextWidth(
text: string,
fontSize: number,
): number {
let width = 0
for (const char of text) {
if (
/[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF\uFE30-\uFE4F]/.test(
char,
)
) {
width += fontSize * 1.35
} else if (char === ' ') {
width += fontSize * 0.3
} else {
width += fontSize * 0.6
}
}
return width
}
/**
* Estimate auto-height for text with wrapping.
* `canvasWidth` is used as fallback when parentContentWidth is unavailable.
@ -148,7 +109,7 @@ export function estimateAutoHeight(
parentContentWidth?: number,
canvasWidth = 1200,
): number {
const hasCjk = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F]/.test(text)
const hasCjk = _hasCjkText(text)
const minLh = hasCjk
? fontSize >= 28
? 1.28
@ -167,7 +128,10 @@ export function estimateAutoHeight(
const logicalLines = text.split(/\r?\n/)
const wrappedLineCount = logicalLines.reduce((sum, line) => {
const lineWidth = estimateWrappingLineWidth(line, fontSize)
// Use the canonical estimateLineWidth from canvas-text-measure with
// a safety factor to avoid underestimating wrapping.
const safetyW = _hasCjkText(line) ? 1.06 : 1.14
const lineWidth = estimateLineWidth(line, fontSize) * safetyW
return sum + Math.max(1, Math.ceil(lineWidth / availW))
}, 0)
const safetyMargin = fontSize >= 28 ? 1.15 : 1.1
@ -192,7 +156,7 @@ export function estimateNodeIntrinsicHeight(
let textHeight = 0
if (node.type === 'text') {
const fs = node.fontSize ?? 16
const lh = node.lineHeight ?? (fs >= 28 ? 1.2 : 1.5)
const lh = node.lineHeight ?? defaultLineHeight(fs)
if (
typeof node.content === 'string' &&
node.content.trim() &&
@ -353,9 +317,9 @@ export function getTextContentForNode(node: PenNode): string {
: ''
}
export function hasCjkText(text: string): boolean {
return /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(text)
}
// Re-export canonical hasCjkText from canvas-text-measure so existing
// importers (role-resolver, typography roles) continue to work.
export const hasCjkText = _hasCjkText
// ---------------------------------------------------------------------------
// Phone placeholder SVG

View file

@ -0,0 +1,127 @@
/**
* HTML Renderer (Stage 2 of visual reference pipeline).
*
* Renders generated HTML/CSS to a screenshot using a hidden iframe + html2canvas.
* Runs entirely client-side no external browser process needed.
*/
import html2canvas from 'html2canvas'
/**
* Render an HTML string to a base64 PNG screenshot.
* Creates a hidden iframe, writes the HTML, and captures with html2canvas.
*
* @param html - Complete HTML document string
* @param width - Viewport width in pixels
* @param height - Viewport height in pixels (0 = auto based on content)
* @returns Base64 PNG string (without data: URL prefix)
*/
export async function renderHtmlToScreenshot(
html: string,
width: number,
height: number,
): Promise<string> {
// Safety check — only runs in browser
if (typeof document === 'undefined') {
throw new Error('renderHtmlToScreenshot requires a browser environment')
}
const iframe = document.createElement('iframe')
try {
// Position off-screen
iframe.style.cssText = `
position: fixed;
left: -9999px;
top: 0;
width: ${width}px;
height: ${height > 0 ? `${height}px` : '4000px'};
border: none;
opacity: 0;
pointer-events: none;
`
document.body.appendChild(iframe)
const iframeDoc = iframe.contentDocument
if (!iframeDoc) {
throw new Error('Could not access iframe document')
}
// Write the HTML into the iframe (same-origin blob)
iframeDoc.open()
iframeDoc.write(html)
iframeDoc.close()
// Wait for fonts and rendering to settle
await waitForRender(iframeDoc)
// Determine actual content height if height was auto
const captureHeight = height > 0
? height
: Math.min(iframeDoc.body.scrollHeight || 4000, 6000)
// Resize iframe to actual content height
if (height <= 0) {
iframe.style.height = `${captureHeight}px`
// Wait one more frame for resize to apply
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
}
// Capture with html2canvas
const canvas = await html2canvas(iframeDoc.body, {
width,
height: captureHeight,
windowWidth: width,
windowHeight: captureHeight,
useCORS: true,
allowTaint: true,
scale: 1, // 1x is sufficient for reference (saves memory/bandwidth)
logging: false,
backgroundColor: null, // Preserve transparency
})
// Convert to base64 PNG (strip the data:image/png;base64, prefix)
const dataUrl = canvas.toDataURL('image/png')
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
return base64
} finally {
// Cleanup
if (iframe.parentNode) {
document.body.removeChild(iframe)
}
}
}
/**
* Wait for the iframe document to finish rendering.
* Waits for fonts, images, and layout to stabilize.
*/
async function waitForRender(doc: Document): Promise<void> {
// Wait for fonts to load (if the document's fonts API is available)
try {
if (doc.fonts && typeof doc.fonts.ready === 'object') {
await Promise.race([
doc.fonts.ready,
new Promise<void>((r) => setTimeout(r, 3000)), // Max 3s for fonts
])
}
} catch {
// Fonts API not available in iframe — continue anyway
}
// Wait for general rendering to stabilize (2 animation frames + small delay)
await new Promise<void>((resolve) => {
setTimeout(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve()
})
})
}, 300) // 300ms for CSS transitions and layout
})
}

View file

@ -55,7 +55,9 @@ export function buildFinalStepTags(
const entry = progress.subtasks[i]
const status = entry.status
const nodeInfo = entry.nodeCount > 0 ? ` (${entry.nodeCount} elements)` : ''
return `<step title="${st.label}${nodeInfo}" status="${status}"></step>`
// Preserve thinking content so validation details remain visible after streaming
const thinkingContent = entry.thinking ?? ''
return `<step title="${st.label}${nodeInfo}" status="${status}">${thinkingContent}</step>`
})
.join('\n')
return `${planningStep}\n${subtaskSteps}`

View file

@ -5,6 +5,7 @@ import {
PROMPT_OPTIMIZER_LIMITS,
SUB_AGENT_TIMEOUT_PROFILES,
} from './ai-runtime-config'
import { getAllPrinciples } from './design-principles'
export interface PreparedDesignPrompt {
original: string
@ -12,6 +13,8 @@ export interface PreparedDesignPrompt {
subAgentPrompt: string
wasCompressed: boolean
originalLength: number
/** Selectively loaded design principles for sub-agent context */
designPrinciples: string
}
export function getSubAgentTimeouts(promptLength: number): {
@ -64,6 +67,7 @@ export function prepareDesignPrompt(prompt: string): PreparedDesignPrompt {
subAgentPrompt: truncateByCharCount(normalized, PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForSubAgent),
wasCompressed: normalized.length > PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForOrchestrator,
originalLength: normalized.length,
designPrinciples: getAllPrinciples(),
}
}

View file

@ -6,12 +6,19 @@
export const ORCHESTRATOR_PROMPT = `Split a UI request into cohesive subtasks. Each subtask = a meaningful UI section or component group. Output ONLY JSON, start with {.
DESIGN TYPE DETECTION:
First determine the design type from the user's request:
- "landing page" / "website" / "官网" / "首页" Landing Page (multi-section scrollable page, 6-10 subtasks)
- "login" / "signup" / "register" / "登录" / "注册" App Screen (single screen, 1-4 subtasks)
- "dashboard" / "settings" / "profile" / "设置" / "个人中心" App Screen (single screen, 2-5 subtasks)
- "form" / "表单" / "checkout" / "结算" App Screen (single screen, 1-4 subtasks)
- Other app screens (modal, dialog, onboarding, etc.) App Screen (1-5 subtasks)
Classify by the design's PURPOSE reason about intent, do not keyword-match:
1. Multi-section page marketing, promotional, or informational content designed to be scrolled (e.g. product sites, portfolios, company pages):
Desktop: width=1200, height=0 (scrollable), 6-10 subtasks
Structure: navigation hero content sections CTA footer
2. Single-task screen functional UI focused on one user task (e.g. authentication, forms, settings, profiles, modals, onboarding):
Mobile: width=375, height=812 (fixed viewport), 1-5 subtasks
Structure: header + focused content area only, no navigation/hero/footer
3. Data-rich workspace overview screens with metrics, tables, or management panels (e.g. dashboards, admin consoles, analytics):
Desktop: width=1200, height=0, 2-5 subtasks
Structure: sidebar or topbar + content panels
FORMAT:
{"rootFrame":{"id":"page","name":"Page","width":1200,"height":0,"layout":"vertical","fill":[{"type":"solid","color":"#F8FAFC"}]},"styleGuide":{"palette":{"background":"#F8FAFC","surface":"#FFFFFF","text":"#0F172A","secondary":"#64748B","accent":"#2563EB","accent2":"#0EA5E9","border":"#E2E8F0"},"fonts":{"heading":"Space Grotesk","body":"Inter"},"aesthetic":"clean modern with blue accents"},"subtasks":[{"id":"nav","label":"Navigation Bar","elements":"logo, nav links (Home, Features, Pricing, Blog), sign-in button, get-started CTA button","region":{"width":1200,"height":72}},{"id":"hero","label":"Hero Section","elements":"headline, subtitle, CTA button, hero illustration or phone mockup","region":{"width":1200,"height":560}},{"id":"features","label":"Feature Cards","elements":"section title, 3 feature cards each with icon + title + description","region":{"width":1200,"height":480}}]}
@ -20,12 +27,12 @@ RULES:
- ELEMENT BOUNDARIES: Each subtask MUST have an "elements" field listing the specific UI elements it contains. Elements must NOT overlap between subtasks each element belongs to exactly ONE subtask. Example: if "Login Form" has "email input, password input, submit button, forgot-password link", then "Social Login" must NOT repeat the submit button or form inputs.
- STYLE SELECTION: Choose light or dark theme based on user intent. Dark: user mentions dark/cyber/terminal/neon///deep/gaming/noir. Light (default): all other cases SaaS, marketing, education, e-commerce, productivity, social. Never default to dark unless the content clearly calls for it.
- Detect the design type FIRST, then choose the appropriate structure and subtask count.
- Landing pages: include Navigation Bar as the FIRST subtask, followed by Hero, feature sections, CTA, footer, etc. (6-10 subtasks)
- App screens (login, settings, forms, etc.): do NOT include Navigation Bar, Hero, CTA, or footer. Only include the actual UI elements needed (1-5 subtasks).
- Multi-section pages (type 1): include Navigation Bar as the FIRST subtask, followed by Hero, feature sections, CTA, footer, etc. (6-10 subtasks)
- Single-task screens (type 2): do NOT include Navigation Bar, Hero, CTA, or footer. Only include the actual UI elements needed (1-5 subtasks).
- FORM INTEGRITY: Keep a form's core elements (inputs + submit button) in the same subtask. Splitting inputs into one subtask and the button into another causes duplicate buttons.
- Combine related elements: "Hero with title + image + CTA" = ONE subtask, not three.
- Each subtask generates a meaningful section (~10-30 nodes). Only split if it would exceed 40 nodes.
- Choose a visual direction (palette, fonts, aesthetic) that matches the product personality and target audience. Output it in "styleGuide".
- REQUIRED: "styleGuide" must ALWAYS be included. Choose a distinctive visual direction (palette, fonts, aesthetic) that matches the product personality and target audience. Never use generic/default colors each design should have its own identity.
- CJK FONT RULE: If the user's request is in Chinese/Japanese/Korean or the product targets CJK audiences, the styleGuide fonts MUST use CJK-compatible fonts: heading="Noto Sans SC" (Chinese) / "Noto Sans JP" (Japanese) / "Noto Sans KR" (Korean), body="Inter". NEVER use "Space Grotesk" or "Manrope" as heading font for CJK content they have no CJK character support.
- Root frame fill must use the styleGuide palette background color.
- Root frame height: Mobile (width=375) set height=812 (fixed viewport). Desktop (width=1200) set height=0 (auto-expands as sections are generated).
@ -35,7 +42,7 @@ RULES:
- For landing pages: navigation sections should preserve good horizontal balance, links evenly distributed in the center group.
- Regions tile to fill rootFrame. vertical = top-to-bottom.
- Mobile: 375x812 (both width AND height are fixed). Desktop: 1200x0 (width fixed, height auto-expands).
- WIDTH SELECTION: App screens (login, signup, register, settings, profile, forms, modals, dialogs, onboarding) ALWAYS use width=375, height=812 (mobile). Landing pages, websites, dashboards use width=1200, height=0 (desktop). This is mandatory.
- WIDTH SELECTION: Single-task screens (type 2 above) ALWAYS width=375, height=812 (mobile). Multi-section pages and data-rich workspaces (types 1 & 3) width=1200, height=0 (desktop). This is mandatory.
- MULTI-SCREEN APPS: When the request involves multiple distinct screens/pages (e.g. "登录页+个人中心", "login and profile"), add "screen":"<name>" to each subtask to group sections that belong to the same page. Use a concise page name (e.g. "登录", "Profile"). Subtasks sharing the same "screen" are placed in one root frame. Single-screen requests don't need "screen". Example: [{"id":"brand","label":"Brand Area","screen":"Login","region":{...}},{"id":"form","label":"Login Form","screen":"Login","region":{...}},{"id":"card","label":"User Card","screen":"Profile","region":{...}}]
- NO explanation. NO markdown. JUST the JSON object.`

View file

@ -241,6 +241,11 @@ async function executeSubAgent(
request.context?.themes,
)
// Inject design principles into the system prompt (selective, not all at once)
const systemPrompt = preparedPrompt.designPrinciples
? `${SUB_AGENT_PROMPT}\n\n${preparedPrompt.designPrinciples}`
: SUB_AGENT_PROMPT
let rawResponse = ''
const nodes: PenNode[] = []
let streamOffset = 0
@ -248,7 +253,7 @@ async function executeSubAgent(
try {
for await (const chunk of streamChat(
SUB_AGENT_PROMPT,
systemPrompt,
[{ role: 'user', content: userPrompt }],
request.model,
timeoutOptions,
@ -465,6 +470,11 @@ CRITICAL LAYOUT CONSTRAINTS:
prompt += '\n\n' + varContext
}
// Inject HTML reference from visual reference pipeline (if available)
if (subtask.htmlReference) {
prompt += `\n\n${subtask.htmlReference}\nUse this HTML structure as guidance for layout and content placement. Match the visual hierarchy and component structure.`
}
return prompt
}

View file

@ -42,6 +42,7 @@ import { executeSubAgents } from './orchestrator-sub-agent'
import { emitProgress, buildFinalStepTags } from './orchestrator-progress'
import { assignAgentIdentities } from './agent-identity'
import { addAgentFrame, clearAgentIndicators } from '@/canvas/agent-indicator'
import { enrichSubtasksWithHtmlReference } from './visual-ref-orchestrator'
// ---------------------------------------------------------------------------
// Public API
@ -87,6 +88,9 @@ export async function executeOrchestration(
st.parentFrameId = plan.rootFrame.id
}
// Enrich subtasks with HTML references from visual reference pipeline (if active)
enrichSubtasksWithHtmlReference(plan.subtasks)
// Set canvas width hint for accurate text height estimation
setGenerationCanvasWidth(plan.rootFrame.width)
@ -499,7 +503,7 @@ function tryParsePlan(text: string): OrchestratorPlan | null {
const plan = obj as unknown as OrchestratorPlan
// Extract optional styleGuide if present and well-formed
// Extract styleGuide — required for consistent visual output
if (obj.styleGuide && typeof obj.styleGuide === 'object') {
const sg = obj.styleGuide as Record<string, unknown>
if (sg.palette && typeof sg.palette === 'object' && sg.fonts && typeof sg.fonts === 'object') {
@ -507,6 +511,24 @@ function tryParsePlan(text: string): OrchestratorPlan | null {
}
}
// Fallback: always provide a style guide so sub-agents have consistent styling
if (!plan.styleGuide) {
const bg = (plan.rootFrame.fill as Array<{ color?: string }> | undefined)?.[0]?.color ?? '#F8FAFC'
plan.styleGuide = {
palette: {
background: bg,
surface: '#FFFFFF',
text: '#0F172A',
secondary: '#64748B',
accent: '#6366F1',
accent2: '#8B5CF6',
border: '#E2E8F0',
},
fonts: { heading: 'Space Grotesk', body: 'Inter' },
aesthetic: 'clean modern',
}
}
return plan
} catch {
return null

Some files were not shown because too many files have changed in this diff Show more