* feat(canvas,panels): multi-selection drag, shift-key selection, and chat pipeline UI

- Preserve multi-selection when clicking a selected object without Shift,
  enabling drag-move of the whole set
- Set selectionKey to shiftKey so only Shift+click toggles multi-select
- Refactor chat-message step rendering: extract step blocks with regex,
  add pipeline checklist progress UI, separate step display from markdown
- Adjust AI chat panel selection count display and layout

* feat(opencode): integrate OpenCode SDK for enhanced AI interactions

- Add support for OpenCode as a new AI provider, allowing users to connect to a local OpenCode server.
- Implement new API endpoints for generating and streaming chat responses via OpenCode.
- Update UI components to include OpenCode in provider selection and display.
- Introduce OpenCode logo and enhance the agent settings dialog to manage OpenCode connections.
- Refactor existing chat and generation logic to accommodate provider-specific routing.
- Include type definitions for OpenCode SDK to ensure type safety and improve developer experience.

* feat(electron): integrate Electron framework for desktop application support

- Add Electron configuration and main process setup for building a desktop application.
- Implement IPC communication for file operations (open, save) between the renderer and main processes.
- Create a preload script to expose Electron APIs to the renderer.
- Update package.json to include Electron and related dependencies.
- Enhance the build process with electron-builder for packaging the application.
- Introduce a new electron-builder.yml configuration file for build settings.
- Modify Vite configuration to support Electron-specific builds.
- Update UI components to accommodate Electron's window management and drag regions.

* fix(canvas): multi-selection drag reparenting and infinite recursion guard

- Add re-entry guard to object:modified handler to prevent infinite
  recursion caused by discardActiveObject() firing object:modified
  via _finalizeCurrentTransform
- Extend drag-into detection to non-layout root frames (rootFrameBounds)
  in addition to layout containers (layoutContainerBounds)
- Add checkReparentIntoFrame() fallback for root-level objects dragged
  into frames, handling both layout and non-layout targets
- Redirect _currentTransform to ActiveSelection in mouse:down so the
  whole group moves/scales/rotates together
- Support same-container reorder in commitDragIntoMulti
- Fix guide-utils getEdges() for center-origin ActiveSelection objects
- Track selection descendants for visual drag-follow during multi-drag
- Show position coordinates during multi-selection drag in dimension label

* feat(store,canvas): add reusable component and instance system

add makeReusable/detachComponent store methods, RefNode resolution in
canvas sync with component/instance selection borders, virtual child ID
handling in selection context, purple/instance-colored frame labels,
and collision avoidance when duplicating nodes

* feat(panels): add component/instance UI to layer and property panels

layer panel shows diamond icon and purple/#9281f7 styling for
components/instances with context menu actions; property panel resolves
RefNode display, routes instance overrides, adds header with go-to and
detach buttons, and supports inline name editing on click

* feat(canvas): match hover outline to selection style for components and instances

hover outlines now use purple solid for reusable components and #9281f7
dashed for instances, consistent with their selection border styling

* fix(editor): save directly without dialog for previously opened files

prioritize fileName check over File System Access API so opened files
save via download without showing a file picker; also add Cmd+Alt+K
shortcut for creating reusable components

* refactor(canvas): remove dimension label overlay

remove the blue dimension/position label that appeared on selection,
drag, and scale interactions

* feat(export): add export section for raster image export functionality

- Introduce a new ExportSection component to facilitate exporting layers as raster images in various formats (PNG, JPEG, WEBP) with adjustable scale options.
- Integrate the ExportSection into the PropertyPanel for easy access during design editing.
- Enhance export utility functions to support exporting layers and managing descendant nodes.

* feat(assets): add new icon files for application branding

- Introduce new icon files in various formats (ICNS, ICO, PNG) for application branding.
- Add logo image for the Electron interface to enhance visual identity.
- Update canvas selection logic to allow programmatic selection from the layer panel.

* feat(electron): enhance Electron app integration and CI/CD workflows

- Add new commands for Electron development, compilation, and building processes in CLAUDE.md.
- Update README.md to reflect the availability of the application as both a web and desktop app.
- Introduce a FixedChecklist component in the AI chat panel for better user interaction with generated tasks.
- Implement CI/CD workflows for automated testing and Electron builds in GitHub Actions.
- Refactor design generator prompts to support element-by-element streaming for improved performance.

* feat(ai): enhance chat functionality and orchestration capabilities

- Introduce context optimization to manage chat history size and prevent unbounded growth.
- Implement complexity assessment for design prompts to determine orchestration needs.
- Add orchestrator functionality for parallel design generation, improving performance and responsiveness.
- Update chat message parsing to include explicit status handling for orchestrator steps.
- Refactor design generator to support orchestration and streamline context building.

* refactor(ai): streamline orchestration and remove complexity classifier

- Remove the complexity classifier as its functionality is no longer needed.
- Update design generator to always route through the orchestrator, simplifying the logic.
- Enhance error handling during orchestration to ensure fallback to direct generation is clear.
- Introduce a new sub-agent prompt for improved output formatting and clarity in design generation.

* feat(ai): refine orchestrator prompt and timeout settings

- Update the ORCHESTRATOR_PROMPT to allow for more granular task division, specifying 4-15 atomic sections.
- Enhance the JSON output structure by adding new subtasks for the hero section.
- Adjust ORCHESTRATOR_TIMEOUTS to extend hard and no-text timeout values for improved orchestration performance.

* feat(docs): update CLAUDE.md and README.md for new features and improvements

- Expand the Fabric.js integration section in CLAUDE.md to include new files and functionalities.
- Highlight new features such as double-click frame entry, advanced drag-and-drop capabilities, and per-layer export options in README.md.
- Add context optimization and multi-provider support for AI in README.md.
- Update the project structure descriptions for clarity and completeness.

* refactor(config): update Vite configuration import for Vitest compatibility

* chore(ci): update GitHub Actions workflow to trigger on main branch pushes

* feat(uikit): introduce UIKit browser and component management features

- Add a new ComponentBrowserPanel for browsing and managing UI components.
- Implement ComponentBrowserGrid and ComponentBrowserCard for displaying components.
- Integrate UIKit store for managing component kits, search queries, and active categories.
- Enhance editor layout to support toggling the UIKit browser with keyboard shortcuts.
- Include import/export functionality for UIKit components.

* feat(types): add clipContent to ContainerProps

Allow frames to explicitly clip overflowing children, essential for
cards with cornerRadius + image children.

* feat(canvas): wire letterSpacing, textGrowth and clipContent to Fabric.js

- Convert letterSpacing (px) to Fabric charSpacing (1/1000 em) in
  factory and sync
- Select IText vs Textbox based on textGrowth mode, restore canvas
  selection after text object recreation
- Add multi-line text height estimation with CJK character support
- Honor clipContent flag in clipping logic

* feat(panels): split text Layout and Typography into separate sections

- Add readOnly prop to NumberInput for non-editable dimension display
- Extract text Layout section (dimensions, fill/hug, resizing toggles)
  into text-layout-section.tsx
- Enhance Typography section with line height, letter spacing, vertical
  alignment controls
- Reorder property panel: Size → Layout → Appearance → Fill → Stroke →
  Typography → Effects
- Add hideWH prop to SizeSection to avoid duplicate W/H inputs

* feat(panels): rewrite layout-section with full Flex Layout panel

- 3x3 alignment grid with context-aware behavior per layout direction
- Gap section with numeric/space-between/space-around radio modes
- Multi-mode padding: single, 2-axis (V/H), 4-individual (T/R/B/L)
  with gear popover
- Dimensions (W/H) and sizing checkboxes (fill/hug/clip) integrated
  into layout panel

* feat(panels): improve layer panel with auto-collapse and scroll-to-selection

- Collapse all layers by default, auto-collapse newly added nodes
- Auto-expand ancestor layers when a child is selected on canvas
- Scroll selected layer item into view after expansion

* fix(panels): improve AI chat panel overflow and color picker styling

- Fix chat panel overflow with proper flex layout and min-h-0
- Cap checklist height with scrollable overflow
- Adjust color picker input sizing

* feat(codegen): add textGrowth, textAlignVertical and clipContent to code generation

- textGrowth: auto → whitespace-nowrap, fixed-width-height → overflow-hidden
- textAlignVertical: middle/bottom → vertical-align CSS
- clipContent: true → overflow-hidden on containers

* feat(ai): integrate text typography properties into design prompts

- Add textGrowth, lineHeight, letterSpacing, textAlignVertical, fontStyle
  to PEN_NODE_SCHEMA and sub-agent prompt
- Add typography scale guidelines with lineHeight/letterSpacing defaults
- Update examples with lineHeight, clipContent, fill_container usage
- Add phone placeholder and clipContent rules

* feat(ai): enhance streaming service with runtime config and thinking modes

- Extract timeout constants to ai-runtime-config.ts
- Add thinking mode control (adaptive/disabled/enabled) to stream options
- Add StyleGuide interface for orchestrator visual consistency
- Add ping timeout and first-text timeout options

* feat(ai): improve design generator with typography defaults and phone placeholder

- Set default lineHeight on text nodes (1.2 for headings, 1.5 for body)
- Expand phone/mockup placeholder detection with shape-based fallback
- Flatten nested phone-shaped frames in post-streaming tree fixes
- Increase CJK character width estimation and button padding buffer

* feat(ai): enhance orchestrator with style guide and prompt optimizer

- Pass style guide from orchestrator plan to sub-agent prompts
- Add orchestrator-prompt-optimizer for context-aware prompt tuning
- Improve parallel sub-task generation and error recovery

* feat(ai): add thinking mode and Codex provider support to server API

- Support thinkingMode/effort params in chat and generate endpoints
- Add Codex (OpenAI) provider streaming via codex-client utility
- Forward thinking config to both Anthropic SDK and Agent SDK paths

* feat(mcp): implement OpenPencil file format and MCP server integration

- Introduce support for .op file format alongside .pen
- Add MCP server functionality for document management and tool operations
- Implement batch processing tools for design and variable management
- Enhance save and open dialogs to accommodate new file format
- Update dependencies and scripts for MCP server compilation and execution

* feat(build): update electron build configuration and resource handling

- Add mcp-server.cjs to extraResources in electron-builder.yml for production packaging
- Modify package.json build script to include MCP server compilation
- Enhance main.ts to set ELECTRON_RESOURCES_PATH for resource access
- Update mcp-install.ts to resolve MCP server path from resources in production

---------

Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
Kayshen Xu 2026-02-22 12:09:12 +08:00 committed by GitHub
parent e4670ce2e1
commit 3b00d5564d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 13656 additions and 876 deletions

90
.github/workflows/build-electron.yml vendored Normal file
View file

@ -0,0 +1,90 @@
name: Build Electron
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: mac
- os: windows-latest
platform: win
- os: ubuntu-latest
platform: linux
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build web (electron target)
run: bun --bun run build
env:
BUILD_TARGET: electron
- name: Compile electron
run: bun run electron:compile
- name: Build Electron app
run: npx electron-builder --config electron-builder.yml --${{ matrix.platform }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: electron-${{ matrix.platform }}
path: |
dist-electron/*.dmg
dist-electron/*.zip
dist-electron/*.exe
dist-electron/*.AppImage
dist-electron/*.deb
retention-days: 30
release:
name: Create Release
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
merge-multiple: true
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: true
files: artifacts/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

55
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,55 @@
name: CI
on:
push:
branches: [main, v0.0.1]
pull_request:
branches: [main, v0.0.1]
jobs:
lint-and-test:
name: Lint & Test
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check
run: npx tsc --noEmit
- name: Run tests
run: bun --bun run test
build-web:
name: Build Web
runs-on: ubuntu-latest
timeout-minutes: 10
needs: lint-and-test
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun --bun run build
- name: Upload web build artifact
uses: actions/upload-artifact@v4
with:
name: web-build
path: .output/
retention-days: 7

2
.gitignore vendored
View file

@ -12,3 +12,5 @@ count.txt
.vinxi
__unconfig*
todos.json
electron-dist/
dist-electron/

View file

@ -11,16 +11,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Run a single test:** `bun --bun vitest run path/to/test.ts`
- **Type check:** `npx tsc --noEmit`
- **Install dependencies:** `bun install`
- **Electron dev:** `bun run electron:dev` (starts Vite + Electron together)
- **Electron compile:** `bun run electron:compile` (esbuild electron/ to electron-dist/)
- **Electron build:** `bun run electron:build` (full web build + compile + electron-builder package)
## Architecture
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**.
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), TypeScript (strict mode).
**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).
### Data Flow
```
```text
React Components (Toolbar, LayerPanel, PropertyPanel)
│ Zustand hooks
@ -43,7 +46,7 @@ React Components (Toolbar, LayerPanel, PropertyPanel)
### Design Variables Architecture
```
```text
PenDocument (source of truth)
├── variables: Record<string, VariableDefinition> ($color-1, $spacing-md, ...)
├── themes: Record<string, string[]> ({Theme-1: ["Default","Dark"]})
@ -65,8 +68,9 @@ PenDocument (source of truth)
### Key Modules
- **`src/canvas/`** — Fabric.js integration (16 files):
- **`src/canvas/`** — Fabric.js integration (25 files):
- `fabric-canvas.tsx` — Canvas component initialization
- `use-fabric-canvas.ts` — Canvas initialization hook
- `canvas-object-factory.ts` — Creates Fabric objects from PenNodes (rect, ellipse, line, polygon, path, text, image, frame, group)
- `canvas-object-sync.ts` — Syncs individual object properties between Fabric and store
- `canvas-sync-lock.ts` — Prevents circular sync loops
@ -76,12 +80,20 @@ PenDocument (source of truth)
- `use-canvas-sync.ts` — Bidirectional PenDocument ↔ Fabric.js sync, node flattening with parent offsets, variable resolution via `resolveNodeForCanvas()`
- `use-canvas-viewport.ts` — Wheel zoom, space+drag panning, tool cursor switching, selection toggling per tool
- `use-canvas-selection.ts` — Selection sync between Fabric objects and canvas-store
- `use-canvas-hover.ts` — Hover state management for objects
- `use-canvas-guides.ts` — Smart alignment guides with snapping
- `guide-utils.ts` — Guide calculation and rendering
- `pen-tool.ts` — Bezier pen tool: anchor points, control handles, path closure, preview rendering
- `parent-child-transform.ts` — Propagates parent transforms (move/scale/rotate) to children proportionally
- `use-dimension-label.ts` — Shows size/position labels during object manipulation
- `use-frame-labels.ts` — Renders frame names and boundaries on canvas
- `use-entered-frame-overlay.ts` — Visual overlay when entering a frame for editing
- `use-layout-indicator.ts` — Layout indicator rendering during drag operations
- `insertion-indicator.ts` — Insertion point indicator for layout drop targets
- `drag-into-layout.ts` — Drag-and-drop into auto-layout frames with insertion detection
- `drag-reparent.ts` — Reparenting nodes during drag operations
- `layout-reorder.ts` — Reorder children within layout frames during drag
- `selection-context.ts` — Selection context management for multi-select operations
- **`src/variables/`** — Design variables system (2 files):
- `resolve-variables.ts` — Core resolution utilities: `resolveVariableRef`, `resolveNodeForCanvas`, `getDefaultTheme`, `isVariableRef`; resolves `$variable` references to concrete values for canvas rendering with circular reference guards
- `replace-refs.ts``replaceVariableRefsInTree`: recursively walk node tree to replace/resolve `$refs` when renaming or deleting variables (covers opacity, gap, padding, fills, strokes, effects, text)
@ -91,14 +103,16 @@ PenDocument (source of truth)
- `history-store.ts` — Undo/redo (max 300 states), batch mode for grouped operations
- `ai-store.ts` — Chat messages, streaming state, generated code, model selection
- `agent-settings-store.ts` — AI provider config (Anthropic/OpenAI), MCP CLI integrations, localStorage persistence
- **`src/types/`** — Type system:
- **`src/types/`** — Type system (7 files):
- `pen.ts` — PenDocument/PenNode (frame, group, rectangle, ellipse, line, polygon, path, text, image, ref), ContainerProps; `PenDocument.variables` and `PenDocument.themes`
- `canvas.ts` — ToolType (select, frame, rectangle, ellipse, line, polygon, path, text, hand), ViewportState, SelectionState
- `styles.ts` — PenFill (solid, linear_gradient, radial_gradient), PenStroke, PenEffect (shadow, blur)
- `canvas.ts` — ToolType (select, frame, rectangle, ellipse, line, polygon, path, text, hand), ViewportState, SelectionState, CanvasInteraction
- `styles.ts` — PenFill (solid, linear_gradient, radial_gradient), PenStroke, PenEffect (shadow, blur), BlendMode, StyledTextSegment
- `variables.ts``VariableDefinition` (type + value), `ThemedValue` (value per theme), `VariableValue`
- `agent-settings.ts` — AI provider config types
- `agent-settings.ts` — AI provider config types (`AIProviderType`, `AIProviderConfig`, `MCPCliIntegration`, `GroupedModel`)
- `electron.d.ts` — Electron IPC bridge types (file dialogs, save operations)
- `opencode-sdk.d.ts` — Type declarations for @opencode-ai/sdk
- **`src/components/editor/`** — Editor UI (6 files): editor-layout, toolbar (with variables panel toggle), tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar, status-bar
- **`src/components/panels/`** — Panels (17 files):
- **`src/components/panels/`** — Panels (18 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
- `fill-section.tsx` — Solid + gradient fill, variable picker integration for color binding
@ -107,6 +121,7 @@ PenDocument (source of truth)
- `size-section.tsx` — Position, size, rotation
- `text-section.tsx` — Font, size, weight, spacing, alignment
- `effects-section.tsx` — Shadow and blur
- `export-section.tsx` — Per-layer export to PNG/SVG with scale options (1x/2x/3x)
- `layout-section.tsx` — Auto-layout (none/vertical/horizontal), gap, padding, justify, align; variable picker for gap/padding binding
- `appearance-section.tsx` — Opacity, visibility, lock, flip; variable picker for opacity binding
- `ai-chat-panel.tsx` / `chat-message.tsx` — AI chat with markdown, design block collapse, apply design
@ -114,14 +129,25 @@ PenDocument (source of truth)
- `variables-panel.tsx` — Design variables management: theme axes as tabs, variant columns, resizable floating panel, add/rename/delete themes and variants
- `variable-row.tsx` — Individual variable row: type icon, editable name, per-theme-variant value cells (color picker, number input, text input), context menu
- **`src/components/shared/`** — Reusable UI (9 files): ColorPicker, NumberInput, DropdownSelect, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker
- **`src/components/icons/`** — Provider logos: ClaudeLogo, OpenAILogo
- **`src/components/icons/`** — Provider logos: ClaudeLogo, OpenAILogo, OpenCodeLogo
- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Switch, Toggle, Tooltip
- **`src/services/ai/`** — AI chat service, design prompts, design-to-node generation, AI types
- **`src/services/ai/`** — AI services (8 files):
- `ai-service.ts` — Main AI chat API wrapper, model negotiation, provider selection
- `ai-prompts.ts` — System prompts for design generation, context building
- `ai-types.ts``ChatMessage`, `AIDesignRequest`, streaming response types
- `design-generator.ts` — Converts AI JSONL to PenNodes, applies variables, animation coordination
- `design-animation.ts` — Fade-in animation coordination for generated design nodes
- `orchestrator.ts` — Parallel design generation: decomposes requests into spatial sub-tasks, JSONL streaming, fallback to single-call
- `orchestrator-prompts.ts` — Ultra-lightweight orchestrator prompt for spatial decomposition
- `context-optimizer.ts` — Chat history trimming, sliding window to prevent unbounded context growth
- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators (output `var(--name)` for `$variable` refs), CSS variables generator
- **`src/hooks/`** — `use-keyboard-shortcuts` (global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order)
- **`src/lib/`** — Utility functions (`utils.ts` with `cn()` for class merging)
- **`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
- **`server/api/ai/`** — Nitro server API: `chat.ts` (streaming SSE with thinking state), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI connection), `models.ts` (model definitions). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers
- **`server/api/ai/`** — Nitro server API: `chat.ts` (streaming SSE with thinking state), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI/OpenCode connection), `models.ts` (model definitions). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers
- **`server/utils/`** — Server utilities:
- `resolve-claude-cli.ts` — Resolves standalone `claude` binary path (handles Nitro bundling issues with SDK's `import.meta.url`)
- `opencode-client.ts` — Shared OpenCode client manager, reuses server on port 4096 with random port fallback
### Fabric.js v7 Gotchas
@ -155,6 +181,20 @@ File-based routing via TanStack Router. Routes in `src/routes/`, auto-generated
Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`src/components/ui/`). Icons from `lucide-react`. shadcn/ui config in `components.json`.
### Electron Desktop App
- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, macOS traffic-light padding (auto-hidden in fullscreen)
- **`electron/preload.ts`** — Context bridge for renderer ↔ main IPC
- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb)
- **`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`
### CI / CD
- **`.github/workflows/ci.yml`** — Push/PR: type check (`tsc --noEmit`), tests (`vitest`), web build
- **`.github/workflows/build-electron.yml`** — Tag push (`v*`) or manual: builds Electron for macOS, Windows, Linux in parallel, creates draft GitHub Release with all artifacts
## Code Style
- 单个文件不要超过 800 行。超出时应拆分为更小的模块。

106
README.md
View file

@ -2,6 +2,8 @@
Open-source vector design tool with a Design-as-Code philosophy. An alternative to [Pencil.dev](https://pencil.dev).
Available as a **web app** and **desktop app** (macOS / Windows / Linux via Electron).
## Features
### Canvas
@ -10,6 +12,8 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
- Smart alignment guides with edge, center, and distance snapping
- Dimension labels during object manipulation
- Frame labels and boundary visualization
- Double-click to enter frames with visual overlay
- Advanced drag-and-drop: drag into auto-layout frames with insertion indicators, reparenting, reorder within layout
### Drawing Tools
@ -30,6 +34,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
- Effects: shadow and blur
- Auto-layout: direction, gap, padding, justify-content, align-items
- Variable binding: bind any property to a design variable via variable picker
- Per-layer export: export individual layers to PNG/SVG with scale options (1x/2x/3x)
### Design Variables & Tokens
@ -83,10 +88,12 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
- Built-in AI chat panel (Cmd+J)
- AI-powered design generation from text prompts
- Orchestrator-based parallel design generation: decomposes requests into spatial sub-tasks for faster output
- Design block preview with "Apply Design" action
- Streaming responses with thinking state
- Streaming responses with thinking state and JSONL real-time canvas insertion
- Context optimizer: sliding window history trimming to prevent unbounded context growth
- Dual provider: Anthropic API or local Claude Code (OAuth)
- Multi-provider settings: Claude Code, Codex CLI
- Multi-provider settings: Claude Code, Codex CLI, OpenCode
### Editor UI
@ -132,12 +139,16 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
- **Styling:** [Tailwind CSS](https://tailwindcss.com/) v4
- **Icons:** [Lucide React](https://lucide.dev/)
- **Server:** [Nitro](https://nitro.build/) (API routes)
- **AI:** [Anthropic SDK](https://docs.anthropic.com/) + [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk)
- **Desktop:** [Electron](https://www.electronjs.org/) 35 + [electron-builder](https://www.electron.build/)
- **AI:** [Anthropic SDK](https://docs.anthropic.com/) + [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk) + [OpenCode SDK](https://github.com/opencode-ai/sdk)
- **Runtime:** [Bun](https://bun.sh/)
- **Build:** [Vite](https://vite.dev/) 7
- **CI/CD:** GitHub Actions
## Getting Started
### Web (Development)
```bash
bun install
bun --bun run dev
@ -145,65 +156,94 @@ bun --bun run dev
Open http://localhost:3000 and click "New Design" to enter the editor.
### Electron (Desktop)
```bash
# Development: starts Vite dev server + Electron
bun run electron:dev
# Production build (current platform)
bun run electron:build
```
### AI Configuration
The AI assistant works in two modes:
The AI assistant works in multiple modes:
- **Anthropic API**: Set `ANTHROPIC_API_KEY` in `.env`
- **Local Claude Code**: No config needed — uses Claude Agent SDK with OAuth login as fallback
- **OpenCode**: Connect via OpenCode SDK for additional model support
## Scripts
| Command | Description |
|---|---|
| `bun --bun run dev` | Start dev server on port 3000 |
| `bun --bun run build` | Production build |
| `bun --bun run dev` | Start web dev server on port 3000 |
| `bun --bun run build` | Production web build |
| `bun --bun run preview` | Preview production build |
| `bun --bun run test` | Run tests (Vitest) |
| `npx tsc --noEmit` | Type check |
| `bun run electron:dev` | Start Vite + Electron for desktop dev |
| `bun run electron:compile` | Compile electron/ with esbuild |
| `bun run electron:build` | Full Electron package (web build + compile + electron-builder) |
## CI / CD
### CI (`ci.yml`)
Runs on every push and PR to `main` / `v0.0.1`:
1. **Lint & Test** — type check (`tsc --noEmit`) + unit tests (`vitest`)
2. **Build Web** — production web build, uploads `.output/` as artifact
### Build Electron (`build-electron.yml`)
Triggered by version tags (`v*`) or manual dispatch:
1. **Build** — parallel matrix across macOS, Windows, Linux
- macOS: `.dmg` + `.zip`
- Windows: `.exe` (NSIS installer + portable)
- Linux: `.AppImage` + `.deb`
2. **Release** — creates a draft GitHub Release with all platform artifacts
To create a release:
```bash
git tag v0.1.0
git push origin v0.1.0
```
## Project Structure
```
```text
src/
canvas/ # Fabric.js canvas engine (16 files)
fabric-canvas.tsx Canvas component initialization
canvas-object-factory Creates Fabric objects from PenNodes
canvas-object-sync Syncs object properties Fabric ↔ store
canvas-sync-lock Prevents circular sync loops
canvas-controls Custom rotation controls and cursors
canvas-constants Default colors, zoom limits
use-canvas-events Drawing events, tool management
use-canvas-sync Bidirectional PenDocument ↔ Fabric sync + variable resolution
use-canvas-viewport Zoom, pan, tool cursor switching
use-canvas-selection Selection sync Fabric ↔ store
use-canvas-guides Smart alignment guides
guide-utils Guide calculation and rendering
pen-tool Bezier pen tool with anchors/handles
parent-child-transform Parent transform propagation to children
use-dimension-label Size/position labels during manipulation
use-frame-labels Frame name/boundary rendering
variables/ # Design variables/tokens system
resolve-variables Core $variable resolution for canvas rendering
replace-refs Replace/resolve $refs on rename/delete
canvas/ # Fabric.js canvas engine (25 files: sync, events, guides, drag-drop, pen tool, etc.)
variables/ # Design variables/tokens system (resolve, replace refs)
components/
editor/ # Editor layout, toolbar, tool buttons, status bar
panels/ # Layer panel, property panel (17 files), AI chat, code panel,
# variables panel, variable row
editor/ # Editor layout, toolbar, tool buttons, top bar, status bar
panels/ # Layer panel, property panel, AI chat, code panel, variables panel, export section
shared/ # ColorPicker, NumberInput, VariablePicker, ExportDialog, etc.
icons/ # Provider logos (Claude, OpenAI)
icons/ # Provider logos (Claude, OpenAI, OpenCode)
ui/ # shadcn/ui primitives (Button, Select, Slider, Switch, etc.)
hooks/ # Keyboard shortcuts
lib/ # Utility functions (cn class merging)
services/
ai/ # AI chat service, prompts, design generation
ai/ # AI chat, orchestrator, context optimizer, design generation, prompts
codegen/ # React+Tailwind, HTML+CSS, and CSS variables generators
stores/ # Zustand stores (canvas, document, history, AI, agent-settings)
types/ # PenDocument/PenNode types, style types, variables, agent settings
types/ # PenDocument/PenNode types, style types, variables, agent settings, Electron IPC
utils/ # File operations, export, node clone, SVG parser, syntax highlight
routes/ # TanStack Router pages (/, /editor)
electron/
main.ts # Electron main process (window, Nitro server, IPC, fullscreen handling)
preload.ts # Context bridge for renderer ↔ main IPC
server/
api/ai/ # Nitro API: streaming chat, generation, agent connection, models
utils/ # Server utilities: Claude CLI resolver, OpenCode client manager
.github/
workflows/
ci.yml # CI: type check, test, web build
build-electron.yml # Electron build for macOS/Windows/Linux + GitHub Release
```
## Roadmap

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

975
bun.lock

File diff suppressed because it is too large Load diff

53
electron-builder.yml Normal file
View file

@ -0,0 +1,53 @@
appId: dev.openpencil.app
productName: OpenPencil
copyright: Copyright (c) 2024-2026 OpenPencil contributors
directories:
output: dist-electron
buildResources: build
files:
- electron-dist/**/*
- "!node_modules"
extraResources:
- from: .output/server
to: server
- from: .output/public
to: public
- from: dist/mcp-server.cjs
to: mcp-server.cjs
mac:
category: public.app-category.graphics-design
icon: build/icon.icns
target:
- dmg
- zip
hardenedRuntime: true
gatekeeperAssess: false
dmg:
title: "${productName} ${version}"
win:
icon: build/icon.ico
target:
- nsis
- portable
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
linux:
icon: build/icon.png
category: Graphics
target:
- AppImage
- deb
asar: true
publish: null

BIN
electron/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

272
electron/main.ts Normal file
View file

@ -0,0 +1,272 @@
import {
app,
BrowserWindow,
ipcMain,
dialog,
type BrowserWindowConstructorOptions,
} from 'electron'
import { execSync } from 'node:child_process'
import { fork, type ChildProcess } from 'node:child_process'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { readFile, writeFile } from 'node:fs/promises'
let mainWindow: BrowserWindow | null = null
let nitroProcess: ChildProcess | null = null
let serverPort = 0
const isDev = !app.isPackaged
// ---------------------------------------------------------------------------
// Fix PATH for macOS GUI apps (Finder doesn't inherit shell PATH)
// ---------------------------------------------------------------------------
function fixPath(): void {
if (process.platform !== 'darwin' && process.platform !== 'linux') return
try {
const shell = process.env.SHELL || '/bin/zsh'
const shellPath = execSync(`${shell} -ilc 'echo -n "$PATH"'`, {
encoding: 'utf-8',
timeout: 5000,
}).trim()
if (shellPath) {
const current = process.env.PATH || ''
process.env.PATH = [...new Set([...shellPath.split(':'), ...current.split(':')])]
.filter(Boolean)
.join(':')
}
} catch {
// Packaged app may not have a login shell — ignore
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getFreePorts(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr && typeof addr === 'object') {
const { port } = addr
server.close(() => resolve(port))
} else {
reject(new Error('Failed to get free port'))
}
})
server.on('error', reject)
})
}
function getServerEntry(): string {
if (isDev) {
// In dev, the Nitro output lives at .output/server/index.mjs
return join(app.getAppPath(), '.output', 'server', 'index.mjs')
}
// In production, extraResources copies .output into the resources folder
return join(process.resourcesPath, 'server', 'index.mjs')
}
// ---------------------------------------------------------------------------
// Nitro server
// ---------------------------------------------------------------------------
async function startNitroServer(): Promise<number> {
const port = await getFreePorts()
const entry = getServerEntry()
return new Promise((resolve, reject) => {
const child = fork(entry, [], {
env: {
...process.env,
HOST: '127.0.0.1',
PORT: String(port),
NITRO_HOST: '127.0.0.1',
NITRO_PORT: String(port),
ELECTRON_RESOURCES_PATH: process.resourcesPath,
},
stdio: 'pipe',
})
nitroProcess = child
child.stdout?.on('data', (data: Buffer) => {
const msg = data.toString()
console.log('[nitro]', msg)
// Resolve once Nitro reports it's listening
if (msg.includes('Listening') || msg.includes('ready')) {
resolve(port)
}
})
child.stderr?.on('data', (data: Buffer) => {
console.error('[nitro:err]', data.toString())
})
child.on('error', reject)
child.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`Nitro exited with code ${code}`)
}
nitroProcess = null
})
// Fallback: if no stdout "ready" message comes, wait then resolve anyway
setTimeout(() => resolve(port), 3000)
})
}
// ---------------------------------------------------------------------------
// Window
// ---------------------------------------------------------------------------
function createWindow(): void {
const windowOptions: BrowserWindowConstructorOptions = {
width: 1440,
height: 900,
minWidth: 1024,
minHeight: 600,
title: 'OpenPencil',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
preload: join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
},
}
if (process.platform === 'darwin') {
windowOptions.trafficLightPosition = { x: 16, y: 11 }
}
// Start hidden to avoid visual flash before CSS injection
windowOptions.show = false
mainWindow = new BrowserWindow(windowOptions)
const url = isDev
? 'http://localhost:3000/editor'
: `http://127.0.0.1:${serverPort}/editor`
// Inject traffic-light padding CSS then show window (no flash)
mainWindow.webContents.on('did-finish-load', async () => {
if (!mainWindow) return
if (process.platform === 'darwin') {
await mainWindow.webContents.insertCSS(
'.electron-traffic-light-pad { margin-left: 74px; }' +
'.electron-fullscreen .electron-traffic-light-pad { margin-left: 0; }',
)
}
mainWindow.show()
})
// Toggle fullscreen class to remove traffic-light padding in fullscreen
if (process.platform === 'darwin') {
mainWindow.on('enter-full-screen', () => {
mainWindow?.webContents.executeJavaScript(
'document.body.classList.add("electron-fullscreen")',
)
})
mainWindow.on('leave-full-screen', () => {
mainWindow?.webContents.executeJavaScript(
'document.body.classList.remove("electron-fullscreen")',
)
})
}
mainWindow.loadURL(url)
if (isDev) {
mainWindow.webContents.openDevTools({ mode: 'detach' })
}
mainWindow.on('closed', () => {
mainWindow = null
})
}
// ---------------------------------------------------------------------------
// IPC: native file dialogs
// ---------------------------------------------------------------------------
function setupIPC(): void {
ipcMain.handle('dialog:openFile', async () => {
if (!mainWindow) return null
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Open .op file',
filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }],
properties: ['openFile'],
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
const content = await readFile(filePath, 'utf-8')
return { filePath, content }
})
ipcMain.handle(
'dialog:saveFile',
async (_event, payload: { content: string; defaultPath?: string }) => {
if (!mainWindow) return null
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Save .op file',
defaultPath: payload.defaultPath,
filters: [{ name: 'OpenPencil Files', extensions: ['op'] }],
})
if (result.canceled || !result.filePath) return null
await writeFile(result.filePath, payload.content, 'utf-8')
return result.filePath
},
)
ipcMain.handle(
'dialog:saveToPath',
async (_event, payload: { filePath: string; content: string }) => {
await writeFile(payload.filePath, payload.content, 'utf-8')
return payload.filePath
},
)
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
app.on('ready', async () => {
fixPath()
setupIPC()
if (!isDev) {
try {
serverPort = await startNitroServer()
console.log(`Nitro server started on port ${serverPort}`)
} catch (err) {
console.error('Failed to start Nitro server:', err)
app.quit()
return
}
}
createWindow()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
app.on('before-quit', () => {
if (nitroProcess) {
nitroProcess.kill()
nitroProcess = null
}
})

25
electron/preload.ts Normal file
View file

@ -0,0 +1,25 @@
import { contextBridge, ipcRenderer } from 'electron'
export interface ElectronAPI {
isElectron: true
openFile: () => Promise<{ filePath: string; content: string } | null>
saveFile: (
content: string,
defaultPath?: string,
) => Promise<string | null>
saveToPath: (filePath: string, content: string) => Promise<string>
}
const api: ElectronAPI = {
isElectron: true,
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveFile: (content: string, defaultPath?: string) =>
ipcRenderer.invoke('dialog:saveFile', { content, defaultPath }),
saveToPath: (filePath: string, content: string) =>
ipcRenderer.invoke('dialog:saveToPath', { filePath, content }),
}
contextBridge.exposeInMainWorld('electronAPI', api)

15
electron/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "../electron-dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["./**/*.ts"]
}

View file

@ -1,16 +1,30 @@
{
"name": "openpencil",
"version": "0.0.1",
"description": "Open-source vector design tool with Design-as-Code philosophy",
"author": "ZSeven-W",
"private": true,
"type": "module",
"main": "electron-dist/main.cjs",
"bin": {
"openpencil-mcp": "dist/mcp-server.cjs"
},
"scripts": {
"dev": "bun --bun vite dev --port 3000",
"build": "bun --bun vite build",
"preview": "bun --bun vite preview",
"test": "bun --bun vitest run"
"test": "bun --bun vitest run --passWithNoTests",
"mcp:compile": "esbuild src/mcp/server.ts --bundle --platform=node --target=node20 --outfile=dist/mcp-server.cjs --format=cjs --sourcemap --alias:@=src",
"mcp:dev": "bun run src/mcp/server.ts",
"electron:dev": "bun run scripts/electron-dev.ts",
"electron:compile": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node20 --outdir=electron-dist --external:electron --format=cjs --out-extension:.js=.cjs --sourcemap",
"electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
"@anthropic-ai/sdk": "^0.77.0",
"@opencode-ai/sdk": "1.2.6",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
@ -45,6 +59,9 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"electron": "^35.0.0",
"electron-builder": "^26.0.0",
"esbuild": "^0.25.0",
"jsdom": "^27.0.0",
"typescript": "^5.7.2",
"vite": "^7.1.7",

110
scripts/electron-dev.ts Normal file
View file

@ -0,0 +1,110 @@
/**
* Electron development workflow orchestrator.
*
* 1. Start Vite dev server (bun run dev)
* 2. Wait for it to be ready on port 3000
* 3. Compile electron/ with esbuild
* 4. Launch Electron pointing at the dev server
*/
import { spawn, type ChildProcess } from 'node:child_process'
import { build } from 'esbuild'
import { join } from 'node:path'
const ROOT = join(import.meta.dirname, '..')
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function waitForServer(
url: string,
timeoutMs = 30_000,
): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url)
if (res.ok || res.status < 500) return
} catch {
// server not ready yet
}
await new Promise((r) => setTimeout(r, 500))
}
throw new Error(`Timeout waiting for ${url}`)
}
async function compileElectron(): Promise<void> {
const common: Parameters<typeof build>[0] = {
platform: 'node',
bundle: true,
sourcemap: true,
external: ['electron'],
target: 'node20',
outdir: join(ROOT, 'electron-dist'),
outExtension: { '.js': '.cjs' },
format: 'cjs' as const,
}
await Promise.all([
build({
...common,
entryPoints: [join(ROOT, 'electron', 'main.ts')],
}),
build({
...common,
entryPoints: [join(ROOT, 'electron', 'preload.ts')],
}),
])
console.log('[electron-dev] Electron files compiled')
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
// 1. Start Vite dev server
console.log('[electron-dev] Starting Vite dev server...')
const vite = spawn('bun', ['--bun', 'run', 'dev'], {
cwd: ROOT,
stdio: 'inherit',
env: { ...process.env },
})
// Ensure cleanup on exit
const cleanup = () => {
vite.kill()
process.exit()
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
// 2. Wait for Vite to be ready
console.log('[electron-dev] Waiting for Vite on port 3000...')
await waitForServer('http://localhost:3000')
console.log('[electron-dev] Vite is ready')
// 3. Compile Electron files
await compileElectron()
// 4. Launch Electron
console.log('[electron-dev] Starting Electron...')
const electronBin = join(ROOT, 'node_modules', '.bin', 'electron')
const electron = spawn(electronBin, [join(ROOT, 'electron-dist', 'main.cjs')], {
cwd: ROOT,
stdio: 'inherit',
env: { ...process.env },
}) as ChildProcess
electron.on('exit', () => {
vite.kill()
process.exit()
})
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View file

@ -1,9 +1,15 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { runCodexExec } from '../../utils/codex-client'
interface ChatBody {
system: string
messages: Array<{ role: 'user' | 'assistant'; content: string }>
model?: string
provider?: string
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
thinkingBudgetTokens?: number
effort?: 'low' | 'medium' | 'high' | 'max'
}
/**
@ -25,6 +31,15 @@ export default defineEventHandler(async (event) => {
Connection: 'keep-alive',
})
// Explicit provider routing
if (body.provider === 'opencode') {
return streamViaOpenCode(body, body.model)
}
if (body.provider === 'openai') {
return streamViaCodex(body, body.model)
}
// Default: existing behavior (backward-compatible)
const apiKey = process.env.ANTHROPIC_API_KEY
if (apiKey) {
try {
@ -39,6 +54,29 @@ export default defineEventHandler(async (event) => {
// Keep-alive ping interval (ms) — prevents client timeout while waiting for API TTFT
const KEEPALIVE_INTERVAL_MS = 15_000
function getAnthropicThinkingConfig(body: ChatBody):
| { type: 'adaptive' | 'disabled' }
| { type: 'enabled'; budget_tokens: number }
| undefined {
if (!body.thinkingMode) return undefined
if (body.thinkingMode === 'enabled') {
const budget = Math.max(1024, body.thinkingBudgetTokens ?? 1024)
return { type: 'enabled', budget_tokens: budget }
}
return { type: body.thinkingMode }
}
function getAgentThinkingConfig(body: ChatBody):
| { type: 'adaptive' | 'disabled' }
| { type: 'enabled'; budgetTokens?: number }
| undefined {
if (!body.thinkingMode) return undefined
if (body.thinkingMode === 'enabled') {
return { type: 'enabled', budgetTokens: body.thinkingBudgetTokens }
}
return { type: body.thinkingMode }
}
/** Stream via Anthropic SDK (when API key is available) */
async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: string) {
const { default: Anthropic } = await import('@anthropic-ai/sdk')
@ -54,11 +92,14 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str
} catch { /* stream already closed */ }
}, KEEPALIVE_INTERVAL_MS)
try {
const thinking = getAnthropicThinkingConfig(body)
const messageStream = client.messages.stream({
model: model || 'claude-sonnet-4-5-20250929',
max_tokens: 16384,
system: body.system,
messages: body.messages,
...(body.effort ? { effort: body.effort } : {}),
...(thinking ? { thinking } : {}),
})
for await (const ev of messageStream) {
@ -68,7 +109,7 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
} else if (ev.delta.type === 'thinking_delta') {
clearInterval(pingTimer)
// Keep pings alive during thinking — only stop on text output
const data = JSON.stringify({ type: 'thinking', content: ev.delta.thinking })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
}
@ -110,12 +151,15 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
// Build prompt from the last user message
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
const prompt = lastUserMsg?.content ?? ''
let prompt = lastUserMsg?.content ?? ''
// Remove CLAUDECODE env to allow running from within a CC terminal
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const claudePath = resolveClaudeCli()
const thinking = getAgentThinkingConfig(body)
const q = query({
prompt,
options: {
@ -124,9 +168,13 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
maxTurns: 1,
includePartialMessages: true,
tools: [],
plugins: [],
permissionMode: 'plan',
persistSession: false,
...(body.effort ? { effort: body.effort } : {}),
...(thinking ? { thinking } : {}),
env,
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
@ -139,7 +187,7 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
} else if (ev.delta.type === 'thinking_delta') {
clearInterval(pingTimer)
// Keep pings alive during thinking — only stop on text output
const data = JSON.stringify({ type: 'thinking', content: (ev.delta as any).thinking })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
}
@ -172,3 +220,197 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
return new Response(stream)
}
/** Parse an OpenCode model string ("providerID/modelID") into its parts */
function parseOpenCodeModel(model?: string): { providerID: string; modelID: string } | undefined {
if (!model || !model.includes('/')) return undefined
const idx = model.indexOf('/')
return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }
}
function mapOpenCodeEffort(
effort?: 'low' | 'medium' | 'high' | 'max',
): 'low' | 'medium' | 'high' | undefined {
if (!effort) return undefined
if (effort === 'max') return 'high'
return effort
}
function buildOpenCodeReasoning(
body: ChatBody,
): Record<string, unknown> | undefined {
const reasoning: Record<string, unknown> = {}
const effort = mapOpenCodeEffort(body.effort)
if (effort) {
reasoning.effort = effort
}
if (body.thinkingMode === 'enabled') {
reasoning.enabled = true
} else if (body.thinkingMode === 'disabled') {
reasoning.enabled = false
}
if (typeof body.thinkingBudgetTokens === 'number' && body.thinkingBudgetTokens > 0) {
reasoning.budgetTokens = body.thinkingBudgetTokens
}
return Object.keys(reasoning).length > 0 ? reasoning : undefined
}
async function promptOpenCodeWithThinking(
ocClient: any,
basePayload: Record<string, unknown>,
body: ChatBody,
): Promise<{ data: any; error: any }> {
const reasoning = buildOpenCodeReasoning(body)
if (!reasoning) {
return await ocClient.session.prompt(basePayload)
}
const enhanced = { ...basePayload, reasoning }
const firstTry = await ocClient.session.prompt(enhanced)
if (!firstTry.error) {
return firstTry
}
console.warn('[AI] OpenCode reasoning options rejected, retrying without reasoning.')
return await ocClient.session.prompt(basePayload)
}
function streamViaCodex(body: ChatBody, model?: string) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
const pingTimer = setInterval(() => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`))
} catch { /* stream already closed */ }
}, KEEPALIVE_INTERVAL_MS)
try {
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
const prompt = lastUserMsg?.content ?? ''
const result = await runCodexExec(prompt, {
model,
systemPrompt: body.system,
thinkingMode: body.thinkingMode,
thinkingBudgetTokens: body.thinkingBudgetTokens,
effort: body.effort,
})
clearInterval(pingTimer)
if (result.error) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content: result.error })}\n\n`),
)
return
}
if (result.text) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'text', content: result.text })}\n\n`),
)
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
)
} catch (error) {
const content = error instanceof Error ? error.message : 'Unknown error'
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
)
} finally {
clearInterval(pingTimer)
controller.close()
}
},
})
return new Response(stream)
}
/** Stream via OpenCode SDK (connects to a running OpenCode server) */
function streamViaOpenCode(body: ChatBody, model?: string) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
const pingTimer = setInterval(() => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`))
} catch { /* stream already closed */ }
}, KEEPALIVE_INTERVAL_MS)
let ocServer: { close(): void } | undefined
try {
const { getOpencodeClient } = await import('../../utils/opencode-client')
const oc = await getOpencodeClient()
const ocClient = oc.client
ocServer = oc.server
// Create a session for this conversation
const { data: session, error: sessionError } = await ocClient.session.create({
title: 'OpenPencil Chat',
})
if (sessionError || !session) {
throw new Error('Failed to create OpenCode session')
}
// Inject system prompt as context (no AI reply)
await ocClient.session.prompt({
sessionID: session.id,
noReply: true,
parts: [{ type: 'text', text: body.system }],
})
// Build prompt from the last user message
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
const prompt = lastUserMsg?.content ?? ''
const parsed = parseOpenCodeModel(model)
// Send prompt and await full response
const promptPayload: Record<string, unknown> = {
sessionID: session.id,
...(parsed ? { model: parsed } : {}),
parts: [{ type: 'text', text: prompt }],
}
const { data: result, error: promptError } = await promptOpenCodeWithThinking(
ocClient,
promptPayload,
body,
)
if (promptError) {
throw new Error('OpenCode prompt failed')
}
// Extract text from response parts
clearInterval(pingTimer)
if (result?.parts) {
for (const part of result.parts) {
if (part.type === 'text' && 'text' in part) {
const data = JSON.stringify({ type: 'text', content: part.text })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
}
}
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
)
} catch (error) {
const content = error instanceof Error ? error.message : 'Unknown error'
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
)
} finally {
const { releaseOpencodeServer } = await import('../../utils/opencode-client')
releaseOpencodeServer(ocServer)
clearInterval(pingTimer)
controller.close()
}
},
})
return new Response(stream)
}

View file

@ -1,8 +1,9 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import type { GroupedModel } from '../../../src/types/agent-settings'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
interface ConnectBody {
agent: 'claude-code' | 'codex-cli'
agent: 'claude-code' | 'codex-cli' | 'opencode'
}
interface ConnectResult {
@ -31,6 +32,10 @@ export default defineEventHandler(async (event) => {
return connectCodexCli()
}
if (body.agent === 'opencode') {
return connectOpenCode()
}
return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult
})
@ -42,6 +47,8 @@ async function connectClaudeCode(): Promise<ConnectResult> {
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const claudePath = resolveClaudeCli()
const q = query({
prompt: '',
options: {
@ -51,6 +58,7 @@ async function connectClaudeCode(): Promise<ConnectResult> {
permissionMode: 'plan',
persistSession: false,
env,
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
@ -151,3 +159,54 @@ async function connectCodexCli(): Promise<ConnectResult> {
return { connected: false, models: [], error: msg }
}
}
/** Connect to OpenCode and fetch its configured providers/models. */
async function connectOpenCode(): Promise<ConnectResult> {
try {
const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client')
const { client, server } = await getOpencodeClient()
const { data, error } = await client.config.providers()
releaseOpencodeServer(server)
if (error) {
return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.' }
}
const models: GroupedModel[] = []
for (const provider of data?.providers ?? []) {
if (!provider.models) continue
for (const [, model] of Object.entries(provider.models)) {
models.push({
value: `${provider.id}/${model.id}`,
displayName: model.name || model.id,
description: `via ${provider.name || provider.id}`,
provider: 'opencode' as const,
})
}
}
if (models.length === 0) {
return { connected: false, models: [], error: 'No models configured in OpenCode. Run "opencode" to set up providers.' }
}
return { connected: true, models }
} catch (error) {
const raw = error instanceof Error ? error.message : 'Failed to connect'
return { connected: false, models: [], error: friendlyOpenCodeError(raw) }
}
}
/** Map OpenCode connection errors to user-friendly messages */
function friendlyOpenCodeError(raw: string): string {
if (/ECONNREFUSED/i.test(raw)) {
return 'OpenCode server not running. Start it with "opencode" in your terminal first.'
}
if (/not found|ENOENT/i.test(raw)) {
return 'OpenCode CLI not found. Please install it first.'
}
if (/timed?\s*out/i.test(raw)) {
return 'Connection timed out. Please try again.'
}
return raw
}

View file

@ -1,9 +1,15 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { runCodexExec } from '../../utils/codex-client'
interface GenerateBody {
system: string
message: string
model?: string
provider?: string
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
thinkingBudgetTokens?: number
effort?: 'low' | 'medium' | 'high' | 'max'
}
/**
@ -19,6 +25,15 @@ export default defineEventHandler(async (event) => {
return { error: 'Missing required fields: system, message' }
}
// Explicit provider routing
if (body.provider === 'opencode') {
return generateViaOpenCode(body, body.model)
}
if (body.provider === 'openai') {
return generateViaCodex(body, body.model)
}
// Default: existing behavior (backward-compatible)
const apiKey = process.env.ANTHROPIC_API_KEY
if (apiKey) {
try {
@ -59,6 +74,8 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const claudePath = resolveClaudeCli()
const q = query({
prompt: body.message,
options: {
@ -66,9 +83,11 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
model: model || 'claude-sonnet-4-6',
maxTurns: 1,
tools: [],
plugins: [],
permissionMode: 'plan',
persistSession: false,
env,
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
@ -88,3 +107,128 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
return { error: message }
}
}
async function generateViaCodex(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> {
const result = await runCodexExec(body.message, {
model,
systemPrompt: body.system,
thinkingMode: body.thinkingMode,
thinkingBudgetTokens: body.thinkingBudgetTokens,
effort: body.effort,
})
return result.error ? { error: result.error } : { text: result.text ?? '' }
}
function mapOpenCodeEffort(
effort?: 'low' | 'medium' | 'high' | 'max',
): 'low' | 'medium' | 'high' | undefined {
if (!effort) return undefined
if (effort === 'max') return 'high'
return effort
}
function buildOpenCodeReasoning(
body: GenerateBody,
): Record<string, unknown> | undefined {
const reasoning: Record<string, unknown> = {}
const effort = mapOpenCodeEffort(body.effort)
if (effort) {
reasoning.effort = effort
}
if (body.thinkingMode === 'enabled') {
reasoning.enabled = true
} else if (body.thinkingMode === 'disabled') {
reasoning.enabled = false
}
if (typeof body.thinkingBudgetTokens === 'number' && body.thinkingBudgetTokens > 0) {
reasoning.budgetTokens = body.thinkingBudgetTokens
}
return Object.keys(reasoning).length > 0 ? reasoning : undefined
}
async function promptOpenCodeWithThinking(
ocClient: any,
basePayload: Record<string, unknown>,
body: GenerateBody,
): Promise<{ data: any; error: any }> {
const reasoning = buildOpenCodeReasoning(body)
if (!reasoning) {
return await ocClient.session.prompt(basePayload)
}
const enhanced = { ...basePayload, reasoning }
const firstTry = await ocClient.session.prompt(enhanced)
if (!firstTry.error) {
return firstTry
}
console.warn('[AI] OpenCode reasoning options rejected, retrying without reasoning.')
return await ocClient.session.prompt(basePayload)
}
/** Generate via OpenCode SDK (connects to a running OpenCode server) */
async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> {
let ocServer: { close(): void } | undefined
try {
const { getOpencodeClient } = await import('../../utils/opencode-client')
const oc = await getOpencodeClient()
const ocClient = oc.client
ocServer = oc.server
const { data: session, error: sessionError } = await ocClient.session.create({
title: 'OpenPencil Generate',
})
if (sessionError || !session) {
return { error: 'Failed to create OpenCode session' }
}
// Inject system prompt as context (no AI reply)
await ocClient.session.prompt({
sessionID: session.id,
noReply: true,
parts: [{ type: 'text', text: body.system }],
})
// Parse model string ("providerID/modelID")
let modelOption: { providerID: string; modelID: string } | undefined
if (model && model.includes('/')) {
const idx = model.indexOf('/')
modelOption = { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }
}
// Send main prompt and await full response
const promptPayload: Record<string, unknown> = {
sessionID: session.id,
...(modelOption ? { model: modelOption } : {}),
parts: [{ type: 'text', text: body.message }],
}
const { data: result, error: promptError } = await promptOpenCodeWithThinking(
ocClient,
promptPayload,
body,
)
if (promptError) {
return { error: 'OpenCode generation failed' }
}
// Extract text from response parts
const texts: string[] = []
if (result?.parts) {
for (const part of result.parts) {
if (part.type === 'text' && part.text) {
texts.push(part.text)
}
}
}
return { text: texts.join('') }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { error: message }
} finally {
const { releaseOpencodeServer } = await import('../../utils/opencode-client')
releaseOpencodeServer(ocServer)
}
}

View file

@ -0,0 +1,201 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { existsSync } from 'node:fs'
interface InstallBody {
tool: string
action: 'install' | 'uninstall'
}
interface InstallResult {
success: boolean
error?: string
configPath?: string
}
const MCP_SERVER_NAME = 'openpencil'
/**
* Resolve the absolute path to the compiled MCP server.
* In dev: <project>/dist/mcp-server.cjs
* In production (Electron): <resources>/mcp-server.cjs
*/
function resolveMcpServerPath(): string {
// Electron production: extraResources places it in resourcesPath
const electronResources = process.env.ELECTRON_RESOURCES_PATH
if (electronResources) {
const electronPath = join(electronResources, 'mcp-server.cjs')
if (existsSync(electronPath)) return electronPath
}
// Try dist/ in the project root (dev + web build)
const projectDist = resolve(process.cwd(), 'dist', 'mcp-server.cjs')
if (existsSync(projectDist)) return projectDist
// Fallback: try relative to this file
const serverDist = resolve(__dirname, '..', '..', '..', 'dist', 'mcp-server.cjs')
if (existsSync(serverDist)) return serverDist
return projectDist // Return expected path even if not yet compiled
}
/** Config file locations and formats for each CLI tool. */
const CLI_CONFIGS: Record<
string,
{
configPath: () => string
read: (filePath: string) => Promise<Record<string, any>>
write: (filePath: string, config: Record<string, any>) => Promise<void>
install: (config: Record<string, any>, serverPath: string) => Record<string, any>
uninstall: (config: Record<string, any>) => Record<string, any>
}
> = {
'claude-code': {
configPath: () => join(homedir(), '.claude.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return {
...config,
mcpServers: Object.keys(servers).length > 0 ? servers : undefined,
}
},
},
'codex-cli': {
configPath: () => join(homedir(), '.codex', 'config.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
'gemini-cli': {
configPath: () => join(homedir(), '.gemini', 'settings.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: {
command: 'node',
args: [serverPath],
},
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
'opencode-cli': {
configPath: () => join(homedir(), '.opencode', 'config.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
'kiro-cli': {
configPath: () => join(homedir(), '.kiro', 'settings.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
}
async function readJsonConfig(filePath: string): Promise<Record<string, any>> {
try {
const text = await readFile(filePath, 'utf-8')
return JSON.parse(text)
} catch {
return {}
}
}
async function writeJsonConfig(
filePath: string,
config: Record<string, any>,
): Promise<void> {
const dir = join(filePath, '..')
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true })
}
await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
}
/**
* POST /api/ai/mcp-install
* Install or uninstall the openpencil MCP server into a CLI tool's config.
*/
export default defineEventHandler(async (event) => {
const body = await readBody<InstallBody>(event)
setResponseHeaders(event, { 'Content-Type': 'application/json' })
if (!body?.tool || !body?.action) {
return { success: false, error: 'Missing tool or action field' } satisfies InstallResult
}
const cliConfig = CLI_CONFIGS[body.tool]
if (!cliConfig) {
return { success: false, error: `Unknown CLI tool: ${body.tool}` } satisfies InstallResult
}
try {
const configPath = cliConfig.configPath()
const config = await cliConfig.read(configPath)
const serverPath = resolveMcpServerPath()
const updated =
body.action === 'install'
? cliConfig.install(config, serverPath)
: cliConfig.uninstall(config)
await cliConfig.write(configPath, updated)
return { success: true, configPath } satisfies InstallResult
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
} satisfies InstallResult
}
})

View file

@ -0,0 +1,245 @@
import { spawn } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
type ThinkingMode = 'adaptive' | 'disabled' | 'enabled'
type ThinkingEffort = 'low' | 'medium' | 'high' | 'max'
interface CodexExecOptions {
model?: string
systemPrompt?: string
thinkingMode?: ThinkingMode
thinkingBudgetTokens?: number
effort?: ThinkingEffort
timeoutMs?: number
}
interface CodexCliResult {
text?: string
error?: string
}
const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000
export async function runCodexExec(
userPrompt: string,
options: CodexExecOptions = {},
): Promise<CodexCliResult> {
const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-codex-'))
const outputPath = join(tempDir, 'last-message.txt')
const prompt = buildPrompt(options.systemPrompt, userPrompt)
const codexEffort = resolveCodexEffort(options.thinkingMode, options.effort)
const args = [
'exec',
'--json',
'--skip-git-repo-check',
'--sandbox',
'read-only',
'--output-last-message',
outputPath,
]
if (options.model) {
args.push('--model', options.model)
}
if (codexEffort) {
args.push('--config', `model_reasoning_effort="${codexEffort}"`)
}
args.push(prompt)
try {
const runResult = await executeCodexCommand(
args,
options.timeoutMs ?? DEFAULT_CODEX_TIMEOUT_MS,
)
const finalText = await readFile(outputPath, 'utf-8').catch(() => '')
const normalizedText = finalText.trim() || runResult.text.trim()
if (normalizedText) {
return { text: normalizedText }
}
if (runResult.errors.length > 0) {
return { error: runResult.errors.join('; ') }
}
return { error: 'Codex returned no output.' }
} catch (error) {
return { error: error instanceof Error ? error.message : 'Codex execution failed' }
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
}
function buildPrompt(systemPrompt: string | undefined, userPrompt: string): string {
const userText = userPrompt.trim()
if (!systemPrompt?.trim()) {
return userText
}
return [
'SYSTEM INSTRUCTIONS:',
systemPrompt.trim(),
'',
'USER REQUEST:',
userText,
].join('\n')
}
function resolveCodexEffort(
thinkingMode: ThinkingMode | undefined,
effort: ThinkingEffort | undefined,
): 'low' | 'medium' | 'high' | undefined {
if (thinkingMode === 'disabled') {
return 'low'
}
if (effort === 'max') {
return 'high'
}
if (effort === 'low' || effort === 'medium' || effort === 'high') {
return effort
}
if (thinkingMode === 'enabled') {
return 'medium'
}
return undefined
}
async function executeCodexCommand(
args: string[],
timeoutMs: number,
): Promise<{ text: string; errors: string[] }> {
return await new Promise((resolve, reject) => {
const child = spawn('codex', args, {
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdoutBuffer = ''
let stderrBuffer = ''
let textAccumulator = ''
const errors: string[] = []
const flushStdoutLine = (line: string) => {
const event = parseCodexJsonLine(line)
if (!event) return
if (event.text) {
textAccumulator += event.text
}
if (event.error) {
errors.push(event.error)
}
}
const timer = setTimeout(() => {
child.kill('SIGTERM')
reject(new Error(`Codex request timed out after ${Math.round(timeoutMs / 1000)}s.`))
}, timeoutMs)
child.stdout.on('data', (chunk: Buffer) => {
stdoutBuffer += chunk.toString('utf-8')
let idx = stdoutBuffer.indexOf('\n')
while (idx >= 0) {
const line = stdoutBuffer.slice(0, idx).trim()
stdoutBuffer = stdoutBuffer.slice(idx + 1)
if (line) flushStdoutLine(line)
idx = stdoutBuffer.indexOf('\n')
}
})
child.stderr.on('data', (chunk: Buffer) => {
stderrBuffer += chunk.toString('utf-8')
})
child.on('error', (err) => {
clearTimeout(timer)
reject(err)
})
child.on('close', (code) => {
clearTimeout(timer)
const tail = stdoutBuffer.trim()
if (tail) {
flushStdoutLine(tail)
}
if (code === 0) {
resolve({ text: textAccumulator, errors })
return
}
const stderrError = extractCodexCliError(stderrBuffer)
const fallback = errors[errors.length - 1]
reject(
new Error(
stderrError
|| fallback
|| `Codex exited with code ${code ?? 'unknown'}.`,
),
)
})
})
}
function parseCodexJsonLine(
line: string,
): { text?: string; error?: string } | null {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(line) as Record<string, unknown>
} catch {
return null
}
const type = typeof parsed.type === 'string' ? parsed.type : ''
if (type === 'error') {
const message = getStringField(parsed, ['message'])
return { error: message || 'Codex returned an unknown error.' }
}
// Common Codex JSONL stream events include deltas in "delta" or "text".
const text =
getStringField(parsed, ['delta'])
|| getStringField(parsed, ['text'])
|| getStringField(parsed, ['content'])
if (!text) return null
return { text }
}
function getStringField(
obj: Record<string, unknown>,
keys: string[],
): string | null {
for (const key of keys) {
const val = obj[key]
if (typeof val === 'string' && val.length > 0) {
return val
}
}
return null
}
function extractCodexCliError(stderr: string): string | null {
const trimmed = stderr.trim()
if (!trimmed) return null
const lines = trimmed.split('\n').map((line) => line.trim()).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]
if (line.toLowerCase().startsWith('error:')) {
return line.replace(/^error:\s*/i, '').trim()
}
}
return lines[lines.length - 1] ?? null
}

View file

@ -0,0 +1,41 @@
/**
* Shared OpenCode client manager.
* Reuses an existing server on port 4096; starts one on a random port as fallback.
* Tracks spawned servers so they can be cleaned up on process exit.
*/
const activeServers = new Set<{ close(): void }>()
// Clean up spawned OpenCode servers on process exit
function cleanup() {
for (const server of activeServers) {
try { server.close() } catch { /* ignore */ }
}
activeServers.clear()
}
process.on('beforeExit', cleanup)
process.on('SIGTERM', cleanup)
process.on('SIGINT', cleanup)
export async function getOpencodeClient() {
const { createOpencodeClient, createOpencode } = await import('@opencode-ai/sdk/v2')
// Try connecting to an existing server first
try {
const client = createOpencodeClient()
await client.config.providers() // probe
return { client, server: undefined }
} catch {
// No running server — start a temporary one on a random port
const oc = await createOpencode({ port: 0 })
activeServers.add(oc.server)
return { client: oc.client, server: oc.server }
}
}
export function releaseOpencodeServer(server: { close(): void } | undefined) {
if (!server) return
try { server.close() } catch { /* ignore */ }
activeServers.delete(server)
}

View file

@ -0,0 +1,36 @@
import { execSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
/**
* Resolve the absolute path to the standalone `claude` binary.
*
* When Nitro bundles @anthropic-ai/claude-agent-sdk, the SDK's internal
* `import.meta.url`-based resolution to find its own `cli.js` breaks.
* Instead we locate the standalone native binary and pass it via
* `pathToClaudeCodeExecutable` the SDK detects non-.js paths as native
* binaries and spawns them directly (no `node` wrapper needed).
*/
export function resolveClaudeCli(): string | undefined {
// 1. Try `which claude` (works when PATH is correctly set)
try {
const p = execSync('which claude 2>/dev/null', {
encoding: 'utf-8',
timeout: 3000,
}).trim()
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',
]
for (const c of candidates) {
if (existsSync(c)) return c
}
return undefined
}

View file

@ -15,6 +15,8 @@ export function getCanvasBackground(): string {
: CANVAS_BACKGROUND_DARK
}
export const SELECTION_BLUE = '#0d99ff'
export const COMPONENT_COLOR = '#a855f7'
export const INSTANCE_COLOR = '#9281f7'
// Smart guides
export const GUIDE_COLOR = '#FF6B35'

View file

@ -316,12 +316,18 @@ export function createFabricObject(
underline: node.underline ?? false,
linethrough: node.strikethrough ?? false,
lineHeight: node.lineHeight ?? 1.2,
charSpacing: node.letterSpacing
? (node.letterSpacing / (node.fontSize || 16)) * 1000
: 0,
}
// Use Textbox for text with explicit width so textAlign works correctly
if (w > 0) {
// Use Textbox for fixed-width / fixed-size modes (word wrapping).
// Use IText for auto-width mode (no wrapping, expands horizontally).
const growth = node.textGrowth
const useTextbox = growth === 'fixed-width' || growth === 'fixed-width-height' || w > 0
if (useTextbox) {
obj = new fabric.Textbox(textContent, {
...textProps,
width: w,
width: w > 0 ? w : 200,
}) as FabricObjectWithPenId
} else {
obj = new fabric.IText(textContent, textProps) as FabricObjectWithPenId

View file

@ -115,15 +115,19 @@ export function syncFabricObject(
? node.content
: node.content.map((s) => s.text).join('')
const w = sizeToNumber(node.width, 0)
const fontSize = node.fontSize ?? 16
obj.set({
text: content,
fontFamily: node.fontFamily ?? 'Inter, sans-serif',
fontSize: node.fontSize ?? 16,
fontSize,
fontWeight: (node.fontWeight as string) ?? 'normal',
fontStyle: node.fontStyle ?? 'normal',
fill: resolveFillColor(node.fill),
textAlign: node.textAlign ?? 'left',
lineHeight: node.lineHeight ?? 1.2,
charSpacing: node.letterSpacing
? (node.letterSpacing / fontSize) * 1000
: 0,
})
if (w > 0) obj.set({ width: w })
break

View file

@ -2,7 +2,7 @@ import type * as fabric from 'fabric'
import { useDocumentStore } from '@/stores/document-store'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { setFabricSyncLock } from './canvas-sync-lock'
import { layoutContainerBounds } from './use-canvas-sync'
import { layoutContainerBounds, rootFrameBounds } from './use-canvas-sync'
import type { LayoutContainerInfo } from './use-canvas-sync'
import { setInsertionIndicator, setContainerHighlight } from './insertion-indicator'
@ -12,8 +12,11 @@ import { setInsertionIndicator, setContainerHighlight } from './insertion-indica
interface DragIntoSession {
nodeId: string
nodeIds?: string[]
targetContainerId: string
insertionIndex: number
/** Whether the target is a layout container (vertical/horizontal) vs a plain frame */
isLayoutTarget: boolean
}
let activeSession: DragIntoSession | null = null
@ -162,21 +165,23 @@ export function checkDragIntoTarget(
const objCenterY =
(obj.top ?? 0) + ((obj.height ?? 0) * (obj.scaleY ?? 1)) / 2
// Find the best (innermost) layout container containing the center.
// Innermost = smallest area among matching containers.
// Find the best (innermost) container containing the center.
// Check layout containers first (they support insertion indicators),
// then non-layout root frames (just container highlight).
const parent = store.getParentOf(nodeId)
let bestContainerId: string | null = null
let bestInfo: LayoutContainerInfo | null = null
let bestFrameBounds: { x: number; y: number; w: number; h: number } | null = null
let bestArea = Infinity
let bestIsLayout = false
// 1. Check layout containers (preferred — support insertion indicators)
for (const [containerId, info] of layoutContainerBounds) {
// Skip if the dragged node is already a direct child of this container
const parent = store.getParentOf(nodeId)
if (parent?.id === containerId) continue
// Skip if the container is a descendant of the dragged node (prevent cycles)
if (store.isDescendantOf(containerId, nodeId)) continue
if (containerId === nodeId) continue
// Hit test: is the center inside the container bounds?
if (
objCenterX >= info.x &&
objCenterX <= info.x + info.w &&
@ -188,11 +193,37 @@ export function checkDragIntoTarget(
bestArea = area
bestContainerId = containerId
bestInfo = info
bestIsLayout = true
}
}
}
if (bestContainerId && bestInfo) {
// 2. Check non-layout root frames (just container highlight, no indicator)
for (const [frameId, bounds] of rootFrameBounds) {
if (layoutContainerBounds.has(frameId)) continue // already checked above
if (parent?.id === frameId) continue
if (store.isDescendantOf(frameId, nodeId)) continue
if (frameId === nodeId) continue
if (
objCenterX >= bounds.x &&
objCenterX <= bounds.x + bounds.w &&
objCenterY >= bounds.y &&
objCenterY <= bounds.y + bounds.h
) {
const area = bounds.w * bounds.h
if (area < bestArea) {
bestArea = area
bestContainerId = frameId
bestInfo = null
bestFrameBounds = bounds
bestIsLayout = false
}
}
}
if (bestContainerId && bestIsLayout && bestInfo) {
// Layout container: show insertion indicator + container highlight
const fabObjectMap = buildFabObjectMap(canvas)
const container = store.getNodeById(bestContainerId)
const childIds =
@ -213,6 +244,7 @@ export function checkDragIntoTarget(
nodeId,
targetContainerId: bestContainerId,
insertionIndex: insertIndex,
isLayoutTarget: true,
}
computeIndicator(bestInfo, childIds, insertIndex, fabObjectMap)
@ -223,9 +255,34 @@ export function checkDragIntoTarget(
h: bestInfo.h,
})
canvas.requestRenderAll()
} else if (bestContainerId && !bestIsLayout) {
// Non-layout frame: show container highlight only, append at end
const bounds = bestFrameBounds ?? rootFrameBounds.get(bestContainerId)!
const container = store.getNodeById(bestContainerId)
const childCount =
container && 'children' in container && container.children
? container.children.length
: 0
activeSession = {
nodeId,
targetContainerId: bestContainerId,
insertionIndex: childCount,
isLayoutTarget: false,
}
setInsertionIndicator(null)
setContainerHighlight({
x: bounds.x,
y: bounds.y,
w: bounds.w,
h: bounds.h,
})
canvas.requestRenderAll()
} else {
// Not over any layout container — clear
// Not over any container — clear
if (activeSession) {
activeSession = null
setInsertionIndicator(null)
@ -240,20 +297,31 @@ export function checkDragIntoTarget(
* Returns true if a reparent was performed.
*/
export function commitDragInto(
_obj: FabricObjectWithPenId,
obj: FabricObjectWithPenId,
canvas: fabric.Canvas,
): boolean {
if (!activeSession) return false
const { nodeId, targetContainerId, insertionIndex } = activeSession
const { nodeId, targetContainerId, insertionIndex, isLayoutTarget } = activeSession
setFabricSyncLock(true)
// Clear manual position so layout engine takes over
useDocumentStore.getState().updateNode(nodeId, {
x: undefined,
y: undefined,
} as Partial<import('@/types/pen').PenNode>)
if (isLayoutTarget) {
// Clear manual position so layout engine takes over
useDocumentStore.getState().updateNode(nodeId, {
x: undefined,
y: undefined,
} as Partial<import('@/types/pen').PenNode>)
} else {
// Non-layout frame: convert absolute position to relative
const targetBounds = rootFrameBounds.get(targetContainerId)
if (targetBounds) {
useDocumentStore.getState().updateNode(nodeId, {
x: (obj.left ?? 0) - targetBounds.x,
y: (obj.top ?? 0) - targetBounds.y,
})
}
}
// Reparent into the target container
useDocumentStore.getState().moveNode(nodeId, targetContainerId, insertionIndex)
@ -276,6 +344,235 @@ export function commitDragInto(
return true
}
/**
* Multi-selection version of checkDragIntoTarget.
* Uses a given center point and list of node IDs.
*/
export function checkDragIntoTargetMulti(
centerX: number,
centerY: number,
nodeIds: string[],
canvas: fabric.Canvas,
): void {
if (nodeIds.length === 0) return
const store = useDocumentStore.getState()
const nodeIdSet = new Set(nodeIds)
let bestContainerId: string | null = null
let bestInfo: LayoutContainerInfo | null = null
let bestFrameBounds: { x: number; y: number; w: number; h: number } | null = null
let bestArea = Infinity
let bestIsLayout = false
// 1. Check layout containers (preferred — support insertion indicators)
for (const [containerId, info] of layoutContainerBounds) {
if (nodeIdSet.has(containerId)) continue
if (nodeIds.some((nid) => store.isDescendantOf(containerId, nid))) continue
if (
centerX >= info.x &&
centerX <= info.x + info.w &&
centerY >= info.y &&
centerY <= info.y + info.h
) {
const area = info.w * info.h
if (area < bestArea) {
bestArea = area
bestContainerId = containerId
bestInfo = info
bestIsLayout = true
}
}
}
// 2. Check non-layout root frames
for (const [frameId, bounds] of rootFrameBounds) {
if (layoutContainerBounds.has(frameId)) continue
if (nodeIdSet.has(frameId)) continue
if (nodeIds.some((nid) => store.isDescendantOf(frameId, nid))) continue
if (
centerX >= bounds.x &&
centerX <= bounds.x + bounds.w &&
centerY >= bounds.y &&
centerY <= bounds.y + bounds.h
) {
const area = bounds.w * bounds.h
if (area < bestArea) {
bestArea = area
bestContainerId = frameId
bestInfo = null
bestFrameBounds = bounds
bestIsLayout = false
}
}
}
if (bestContainerId && bestIsLayout && bestInfo) {
// Layout container: show insertion indicator + container highlight
const fabObjectMap = buildFabObjectMap(canvas)
const container = store.getNodeById(bestContainerId)
const childIds =
container && 'children' in container && container.children
? container.children.map((c) => c.id).filter((id) => !nodeIdSet.has(id))
: []
const isVertical = bestInfo.layout === 'vertical'
const mainCenter = isVertical ? centerY : centerX
const insertIndex = calcInsertionIndex(
mainCenter,
bestInfo,
childIds,
fabObjectMap,
)
activeSession = {
nodeId: nodeIds[0],
nodeIds,
targetContainerId: bestContainerId,
insertionIndex: insertIndex,
isLayoutTarget: true,
}
computeIndicator(bestInfo, childIds, insertIndex, fabObjectMap)
setContainerHighlight({
x: bestInfo.x,
y: bestInfo.y,
w: bestInfo.w,
h: bestInfo.h,
})
canvas.requestRenderAll()
} else if (bestContainerId && !bestIsLayout) {
// Non-layout frame: show container highlight only, append at end
const bounds = bestFrameBounds ?? rootFrameBounds.get(bestContainerId)!
const container = store.getNodeById(bestContainerId)
const childIds =
container && 'children' in container && container.children
? container.children.map((c) => c.id).filter((id) => !nodeIdSet.has(id))
: []
activeSession = {
nodeId: nodeIds[0],
nodeIds,
targetContainerId: bestContainerId,
insertionIndex: childIds.length,
isLayoutTarget: false,
}
setInsertionIndicator(null)
setContainerHighlight({
x: bounds.x,
y: bounds.y,
w: bounds.w,
h: bounds.h,
})
canvas.requestRenderAll()
} else {
if (activeSession) {
activeSession = null
setInsertionIndicator(null)
setContainerHighlight(null)
canvas.requestRenderAll()
}
}
}
/**
* Commit drag-into for multi-selection: reparent or reorder nodes.
* Handles both cross-container reparent and same-container reorder.
* Returns true if a reparent/reorder was performed.
*/
export function commitDragIntoMulti(
canvas: fabric.Canvas,
): boolean {
if (!activeSession || !activeSession.nodeIds) return false
const { nodeIds, targetContainerId, insertionIndex, isLayoutTarget } = activeSession
const store = useDocumentStore.getState()
// Check if this is a same-container reorder
const firstParent = store.getParentOf(nodeIds[0])
const isSameContainer = firstParent?.id === targetContainerId
setFabricSyncLock(true)
if (isSameContainer) {
// Same container: reorder by manipulating children array directly.
// This avoids shifting-index issues that arise when calling moveNode
// in a loop (each removal shifts subsequent indices).
const container = store.getNodeById(targetContainerId)
if (container && 'children' in container && container.children) {
const nodeIdSet = new Set(nodeIds)
const dragged: typeof container.children = []
const remaining: typeof container.children = []
for (const child of container.children) {
if (nodeIdSet.has(child.id)) {
dragged.push(child)
} else {
remaining.push(child)
}
}
// insertionIndex was computed for the filtered (remaining) list
const newChildren = [
...remaining.slice(0, insertionIndex),
...dragged,
...remaining.slice(insertionIndex),
]
store.updateNode(targetContainerId, {
children: newChildren,
} as Partial<import('@/types/pen').PenNode>)
}
} else {
// Cross container: reparent
const fabObjectMap = buildFabObjectMap(canvas)
const targetBounds = rootFrameBounds.get(targetContainerId)
for (let i = 0; i < nodeIds.length; i++) {
if (isLayoutTarget) {
// Clear manual position so layout engine takes over
useDocumentStore.getState().updateNode(nodeIds[i], {
x: undefined,
y: undefined,
} as Partial<import('@/types/pen').PenNode>)
} else if (targetBounds) {
// Non-layout frame: convert absolute position to relative
const fabObj = fabObjectMap.get(nodeIds[i])
if (fabObj) {
useDocumentStore.getState().updateNode(nodeIds[i], {
x: (fabObj.left ?? 0) - targetBounds.x,
y: (fabObj.top ?? 0) - targetBounds.y,
})
}
}
// Reparent into the target container at successive indices
useDocumentStore.getState().moveNode(nodeIds[i], targetContainerId, insertionIndex + i)
}
}
setFabricSyncLock(false)
// Force re-sync
const doc = useDocumentStore.getState().document
useDocumentStore.setState({
document: { ...doc, children: [...doc.children] },
})
// Clean up
activeSession = null
setInsertionIndicator(null)
setContainerHighlight(null)
canvas.requestRenderAll()
return true
}
/** Cancel the drag-into session (safety cleanup). */
export function cancelDragInto(): void {
if (!activeSession) return

View file

@ -68,6 +68,169 @@ function getParentBounds(parentId: string, parentNode: unknown): Bounds | null {
return { x, y, w, h }
}
/**
* Check if a node (identified by absolute bounds) was dragged outside its
* parent container. If so, reparent it to the best overlapping root frame
* or to root level.
*
* Unlike `checkDragReparent` (which reads Fabric coords), this version
* accepts pre-computed absolute bounds needed for objects inside an
* ActiveSelection whose Fabric left/top are group-relative.
*
* The caller is responsible for holding the fabric sync lock.
* Returns true if reparenting occurred.
*/
export function checkDragReparentByBounds(
nodeId: string,
objBounds: Bounds,
): boolean {
const store = useDocumentStore.getState()
const parent = store.getParentOf(nodeId)
if (!parent) return false // Already root-level
const parentBounds = getParentBounds(parent.id, parent)
if (!parentBounds) return false
if (!isCompletelyOutside(objBounds, parentBounds)) return false
// Find the root frame with the most overlap
let bestFrameId: string | null = null
let bestOverlap = 0
for (const [frameId, frameBounds] of rootFrameBounds) {
// Don't reparent into the frame we just left
if (frameId === parent.id) continue
const area = overlapArea(objBounds, frameBounds)
if (area > bestOverlap) {
bestOverlap = area
bestFrameId = frameId
}
}
if (bestFrameId) {
// Reparent into the overlapping frame — convert absolute to relative position
const targetBounds = rootFrameBounds.get(bestFrameId)!
store.updateNode(nodeId, {
x: objBounds.x - targetBounds.x,
y: objBounds.y - targetBounds.y,
})
const targetChildren = store.getNodeById(bestFrameId)
const childCount =
targetChildren && 'children' in targetChildren && targetChildren.children
? targetChildren.children.length
: 0
store.moveNode(nodeId, bestFrameId, childCount)
} else {
// No overlapping frame — make it a root-level node
store.updateNode(nodeId, {
x: objBounds.x,
y: objBounds.y,
})
const rootCount = store.document.children.length
store.moveNode(nodeId, null, rootCount)
}
return true
}
/**
* Check if a root-level node (identified by absolute bounds) should be
* reparented INTO a root frame based on center-point containment.
*
* This handles the case where objects are dragged from root level into
* a frame the reverse of checkDragReparentByBounds (which handles
* objects being dragged OUT of a frame).
*
* The caller is responsible for holding the fabric sync lock.
* Returns true if reparenting occurred.
*/
export function checkReparentIntoFrame(
nodeId: string,
objBounds: Bounds,
skipFrameIds?: Set<string>,
): boolean {
const store = useDocumentStore.getState()
const parent = store.getParentOf(nodeId)
if (parent) return false // Not root-level — use checkDragReparentByBounds instead
const centerX = objBounds.x + objBounds.w / 2
const centerY = objBounds.y + objBounds.h / 2
// Find the smallest root frame containing the object's center
let bestFrameId: string | null = null
let bestArea = Infinity
for (const [frameId, frameBounds] of rootFrameBounds) {
if (frameId === nodeId) continue
if (skipFrameIds?.has(frameId)) continue
if (store.isDescendantOf(frameId, nodeId)) continue
if (
centerX >= frameBounds.x &&
centerX <= frameBounds.x + frameBounds.w &&
centerY >= frameBounds.y &&
centerY <= frameBounds.y + frameBounds.h
) {
const area = frameBounds.w * frameBounds.h
if (area < bestArea) {
bestArea = area
bestFrameId = frameId
}
}
}
// Also check layout containers (nested layout frames that aren't root frames)
for (const [containerId, info] of layoutContainerBounds) {
if (containerId === nodeId) continue
if (skipFrameIds?.has(containerId)) continue
if (rootFrameBounds.has(containerId)) continue // already checked above
if (store.isDescendantOf(containerId, nodeId)) continue
if (
centerX >= info.x &&
centerX <= info.x + info.w &&
centerY >= info.y &&
centerY <= info.y + info.h
) {
const area = info.w * info.h
if (area < bestArea) {
bestArea = area
bestFrameId = containerId
}
}
}
if (!bestFrameId) return false
// Determine if target is a layout container (clear x/y) or plain frame (relative position)
const layoutInfo = layoutContainerBounds.get(bestFrameId)
if (layoutInfo) {
// Layout container: clear position, layout engine will place it
store.updateNode(nodeId, {
x: undefined,
y: undefined,
} as any)
} else {
// Non-layout frame: convert absolute to relative position
const targetBounds = rootFrameBounds.get(bestFrameId)
if (targetBounds) {
store.updateNode(nodeId, {
x: objBounds.x - targetBounds.x,
y: objBounds.y - targetBounds.y,
})
}
}
const targetChildren = store.getNodeById(bestFrameId)
const childCount =
targetChildren && 'children' in targetChildren && targetChildren.children
? targetChildren.children.length
: 0
store.moveNode(nodeId, bestFrameId, childCount)
return true
}
/**
* Check if a Fabric object was dragged outside its current parent container.
* If so, reparent it to the overlapping root frame (if any), or to root level.

View file

@ -5,7 +5,6 @@ import { useCanvasEvents } from './use-canvas-events'
import { useCanvasViewport } from './use-canvas-viewport'
import { useCanvasSelection } from './use-canvas-selection'
import { useCanvasSync } from './use-canvas-sync'
import { useDimensionLabel } from './use-dimension-label'
import { useFrameLabels } from './use-frame-labels'
import { useLayoutIndicator } from './use-layout-indicator'
import { useCanvasHover } from './use-canvas-hover'
@ -23,7 +22,7 @@ export default function FabricCanvas() {
useCanvasSync()
useCanvasHover()
useEnteredFrameOverlay()
useDimensionLabel(containerRef)
useFrameLabels()
useLayoutIndicator()

View file

@ -45,10 +45,15 @@ export function clearGuides() {
// ---------------------------------------------------------------------------
function getEdges(obj: fabric.FabricObject): Edges {
const left = obj.left ?? 0
const top = obj.top ?? 0
let left = obj.left ?? 0
let top = obj.top ?? 0
const w = (obj.width ?? 0) * (obj.scaleX ?? 1)
const h = (obj.height ?? 0) * (obj.scaleY ?? 1)
// ActiveSelection / Group uses center origin — adjust to top-left
if (obj.originX === 'center') left -= w / 2
if (obj.originY === 'center') top -= h / 2
return {
left,
right: left + w,

View file

@ -33,7 +33,7 @@ let activeDragSession: DragSession | null = null
// ---------------------------------------------------------------------------
/** Recursively collect all descendant node IDs from the document tree. */
function collectDescendantIds(nodeId: string): Set<string> {
export function collectDescendantIds(nodeId: string): Set<string> {
const result = new Set<string>()
const node = useDocumentStore.getState().getNodeById(nodeId)
if (!node) return result

View file

@ -38,6 +38,20 @@ export function resolveTargetAtDepth(nodeId: string): string | null {
// Direct match
if (selectableIds.has(nodeId)) return nodeId
// Handle virtual instance child IDs (refId__childId)
if (nodeId.includes('__')) {
const refId = nodeId.substring(0, nodeId.indexOf('__'))
if (selectableIds.has(refId)) return refId
// Walk up from the RefNode
let cur: string | undefined = refId
while (cur) {
const parent = useDocumentStore.getState().getParentOf(cur)
if (!parent) break
if (selectableIds.has(parent.id)) return parent.id
cur = parent.id
}
}
// Walk up parent chain
let currentId: string | undefined = nodeId
while (currentId) {

View file

@ -24,6 +24,7 @@ import {
rotateDescendants,
finalizeParentTransform,
finalizeParentRotation,
collectDescendantIds,
} from './parent-child-transform'
import {
isPenToolActive,
@ -41,12 +42,14 @@ import {
isLayoutDragActive,
} from './layout-reorder'
import { isEnterableContainer, resolveTargetAtDepth } from './selection-context'
import { checkDragReparent } from './drag-reparent'
import { checkDragReparent, checkDragReparentByBounds, checkReparentIntoFrame } from './drag-reparent'
import {
checkDragIntoTarget,
commitDragInto,
cancelDragInto,
isDragIntoActive,
checkDragIntoTargetMulti,
commitDragIntoMulti,
} from './drag-into-layout'
function createNodeForTool(
@ -452,6 +455,17 @@ export function useCanvasEvents() {
// modification never creates a no-op undo entry.
let preModificationDoc: PenDocument | null = null
// --- ActiveSelection descendant tracking ---
// When dragging an ActiveSelection, children of selected objects are
// separate Fabric objects (due to tree flattening) and are NOT part of
// the selection group. We track them here so they follow the group drag.
interface SelectionDescInfo {
initGroupLeft: number
initGroupTop: number
descendants: Map<string, { obj: FabricObjectWithPenId; initLeft: number; initTop: number }>
}
let selectionDragInfo: SelectionDescInfo | null = null
// --- History batching for drag/resize/rotate ---
let transformBatchActive = false
let pendingBatchCloseRaf: number | null = null
@ -475,8 +489,39 @@ export function useCanvasEvents() {
preModificationDoc = null
const tool = useCanvasStore.getState().activeTool
if (tool !== 'select') return
const target = opt.target as FabricObjectWithPenId | null
if (!target?.penNodeId) return
const e = opt.e as MouseEvent | undefined
// Keep multi-selection active when clicking one of its selected objects
// so users can drag the whole set without needing Shift on drag start.
if (!e?.shiftKey) {
const { selectedIds } = useCanvasStore.getState().selection
const clicked = opt.target as FabricObjectWithPenId | null
const clickedResolved = clicked?.penNodeId
? resolveTargetAtDepth(clicked.penNodeId)
: null
const activeObj = canvas.getActiveObject()
const isActiveSelection = !!activeObj?.isType?.('activeSelection')
if (
!isActiveSelection &&
clickedResolved &&
selectedIds.length > 1 &&
selectedIds.includes(clickedResolved)
) {
const objects = canvas.getObjects() as FabricObjectWithPenId[]
const selectedSet = new Set(selectedIds)
const selectedObjs = objects.filter(
(o) => o.penNodeId && selectedSet.has(o.penNodeId),
)
if (selectedObjs.length > 1) {
const sel = new fabric.ActiveSelection(selectedObjs, { canvas })
canvas.setActiveObject(sel)
canvas.requestRenderAll()
}
}
}
const activeTarget = canvas.getActiveObject() ?? opt.target
if (!activeTarget) return
// Snapshot the document BEFORE any drag/resize/rotate begins.
@ -487,6 +532,91 @@ export function useCanvasEvents() {
.startBatch(useDocumentStore.getState().document)
transformBatchActive = true
// ActiveSelection move/scale/rotate: batch + final sync in object:modified.
// Layout/parent-child single-node logic does not apply here.
// However, we must track descendants of selected objects so they
// visually follow the group during drag.
if ('getObjects' in activeTarget) {
// Fix: if Fabric's _currentTransform targets a single object
// inside the selection (happens when handleSelection creates
// the ActiveSelection during selection:updated, after Fabric
// already set up the transform for the clicked single object),
// redirect the transform to the ActiveSelection so the whole
// group moves/scales/rotates together.
const ct = (canvas as unknown as { _currentTransform?: {
target: fabric.FabricObject
offsetX: number
offsetY: number
original?: Record<string, unknown>
} })._currentTransform
if (ct && ct.target !== activeTarget) {
const pointerEvt = opt.e as PointerEvent | undefined
if (pointerEvt) {
canvas.calcOffset()
const pointer = canvas.getScenePoint(pointerEvt)
ct.target = activeTarget
ct.offsetX = pointer.x - (activeTarget.left ?? 0)
ct.offsetY = pointer.y - (activeTarget.top ?? 0)
if (ct.original) {
ct.original = {
...ct.original,
left: activeTarget.left,
top: activeTarget.top,
scaleX: activeTarget.scaleX,
scaleY: activeTarget.scaleY,
}
}
}
}
cancelLayoutDrag()
const group = activeTarget as fabric.ActiveSelection
const selObjs = group.getObjects() as FabricObjectWithPenId[]
const selIds = new Set(
selObjs.map((o) => o.penNodeId).filter(Boolean) as string[],
)
// Collect descendants of all selected objects that are NOT in the selection
const allCanvasObjs = canvas.getObjects() as FabricObjectWithPenId[]
const canvasObjMap = new Map(
allCanvasObjs.filter((o) => o.penNodeId).map((o) => [o.penNodeId!, o]),
)
const descendants = new Map<
string,
{ obj: FabricObjectWithPenId; initLeft: number; initTop: number }
>()
for (const selObj of selObjs) {
if (!selObj.penNodeId) continue
for (const descId of collectDescendantIds(selObj.penNodeId)) {
if (selIds.has(descId) || descendants.has(descId)) continue
const descObj = canvasObjMap.get(descId)
if (descObj) {
descendants.set(descId, {
obj: descObj,
initLeft: descObj.left ?? 0,
initTop: descObj.top ?? 0,
})
}
}
}
selectionDragInfo =
descendants.size > 0
? {
initGroupLeft: group.left ?? 0,
initGroupTop: group.top ?? 0,
descendants,
}
: null
return
}
const target = activeTarget as FabricObjectWithPenId
if (!target.penNodeId) return
// Only start layout reorder for actual move drags.
// Scale/rotate handles on layout children should follow normal transform sync.
const transform = (opt as unknown as {
@ -514,6 +644,7 @@ export function useCanvasEvents() {
// commit and cleanup. In Fabric.js v7 mouse:up can fire before
// object:modified, which would clear the session prematurely.
endParentDrag()
selectionDragInfo = null
// Defer batch close one frame so object:modified can run first.
if (transformBatchActive) {
@ -558,14 +689,25 @@ export function useCanvasEvents() {
setFabricSyncLock(false)
}
/** Absolute bounds for each child computed during syncSelectionToStore. */
interface ChildAbsBounds {
nodeId: string
x: number
y: number
w: number
h: number
}
/**
* Resolve the absolute position of each child inside an ActiveSelection
* and sync every child to the document store.
* Returns an array of absolute bounds (for subsequent reparent checks).
*/
const syncSelectionToStore = (
target: fabric.FabricObject,
) => {
if (!('getObjects' in target)) return
): ChildAbsBounds[] => {
const boundsOut: ChildAbsBounds[] = []
if (!('getObjects' in target)) return boundsOut
const group = target as fabric.ActiveSelection
const groupMatrix = group.calcTransformMatrix()
@ -600,23 +742,32 @@ export function useCanvasEvents() {
const scaleX = child.scaleX ?? 1
const scaleY = child.scaleY ?? 1
const absW = (child.width ?? 0) * scaleX
const absH = (child.height ?? 0) * scaleY
const updates: Partial<PenNode> = {
x: absLeft - offsetX,
y: absTop - offsetY,
rotation: child.angle ?? 0,
}
if (child.width !== undefined) {
;(updates as Record<string, unknown>).width =
child.width * scaleX
;(updates as Record<string, unknown>).width = absW
}
if (child.height !== undefined) {
;(updates as Record<string, unknown>).height =
child.height * scaleY
;(updates as Record<string, unknown>).height = absH
}
useDocumentStore.getState().updateNode(obj.penNodeId, updates)
boundsOut.push({
nodeId: obj.penNodeId,
x: absLeft,
y: absTop,
w: absW,
h: absH,
})
}
setFabricSyncLock(false)
return boundsOut
}
// Real-time sync during drag / resize / rotate (locked to prevent circular sync)
@ -629,6 +780,12 @@ export function useCanvasEvents() {
clipPathsCleared = true
const movingObj = opt.target as FabricObjectWithPenId
if (movingObj.clipPath) movingObj.clipPath = undefined
// Clear clip paths on objects INSIDE the ActiveSelection
if ('getObjects' in opt.target) {
for (const child of (opt.target as fabric.ActiveSelection).getObjects()) {
if (child.clipPath) child.clipPath = undefined
}
}
// Also clear descendants' clip paths
const session = getActiveDragSession()
if (session) {
@ -636,6 +793,46 @@ export function useCanvasEvents() {
if (descObj.clipPath) descObj.clipPath = undefined
}
}
// Clear clip paths on ActiveSelection descendants too
if (selectionDragInfo) {
for (const [, { obj }] of selectionDragInfo.descendants) {
if (obj.clipPath) obj.clipPath = undefined
}
}
}
// ActiveSelection drag: snap + move descendants + drag-into detection
if ('getObjects' in opt.target) {
const group = opt.target as fabric.ActiveSelection
// Smart guides + snapping for the whole selection bounding box
calculateAndSnap(opt.target, canvas)
// Move descendants based on the (possibly snapped) group position
if (selectionDragInfo) {
const deltaX = (group.left ?? 0) - selectionDragInfo.initGroupLeft
const deltaY = (group.top ?? 0) - selectionDragInfo.initGroupTop
for (const [, { obj, initLeft, initTop }] of selectionDragInfo.descendants) {
obj.set({ left: initLeft + deltaX, top: initTop + deltaY })
obj.setCoords()
}
}
// Drag-into layout container detection (using selection center)
const selObjs = group.getObjects() as FabricObjectWithPenId[]
const selNodeIds = selObjs
.map((o) => o.penNodeId)
.filter(Boolean) as string[]
// ActiveSelection uses center origin — left/top IS the center
const cx = group.originX === 'center'
? (group.left ?? 0)
: (group.left ?? 0) + ((group.width ?? 0) * (group.scaleX ?? 1)) / 2
const cy = group.originY === 'center'
? (group.top ?? 0)
: (group.top ?? 0) + ((group.height ?? 0) * (group.scaleY ?? 1)) / 2
checkDragIntoTargetMulti(cx, cy, selNodeIds, canvas)
return
}
if (isLayoutDragActive()) {
@ -676,7 +873,14 @@ export function useCanvasEvents() {
// click-to-select without modification never creates a no-op undo
// entry. We use the pre-modification snapshot captured in mouse:down
// as the batch base to guarantee a correct undo point.
let inModifiedHandler = false
canvas.on('object:modified', (opt) => {
// Guard against re-entry: discardActiveObject() fires
// _finalizeCurrentTransform → object:modified recursively.
if (inModifiedHandler) return
inModifiedHandler = true
try {
if (pendingBatchCloseRaf !== null) {
cancelAnimationFrame(pendingBatchCloseRaf)
pendingBatchCloseRaf = null
@ -697,6 +901,9 @@ export function useCanvasEvents() {
useHistoryStore.getState().startBatch(baseDoc)
}
let isSelectionModification = false
let selectionReparented = false
try {
// Single object -- bake scale and sync
const asPen = target as FabricObjectWithPenId
@ -757,7 +964,22 @@ export function useCanvasEvents() {
}
// Check if the node was dragged out of / into a root frame
if (checkDragReparent(asPen)) {
const didReparentOut = checkDragReparent(asPen)
// Fallback: check if a root-level node should be reparented INTO a frame
const didReparentIn = !didReparentOut && asPen.penNodeId
? (() => {
setFabricSyncLock(true)
const result = checkReparentIntoFrame(asPen.penNodeId!, {
x: asPen.left ?? 0,
y: asPen.top ?? 0,
w: (asPen.width ?? 0) * (asPen.scaleX ?? 1),
h: (asPen.height ?? 0) * (asPen.scaleY ?? 1),
})
setFabricSyncLock(false)
return result
})()
: false
if (didReparentOut || didReparentIn) {
// Force re-sync since tree structure changed
rebuildNodeRenderInfo()
const doc = useDocumentStore.getState().document
@ -766,6 +988,7 @@ export function useCanvasEvents() {
})
}
} else if ('getObjects' in target) {
isSelectionModification = true
// ActiveSelection -- bake scale per child, then sync all
const group = target as fabric.ActiveSelection
for (const child of group.getObjects()) {
@ -784,7 +1007,42 @@ export function useCanvasEvents() {
}
child.setCoords()
}
syncSelectionToStore(target)
// Drag-into container: reparent all selected objects
if (isDragIntoActive()) {
canvas.discardActiveObject()
commitDragIntoMulti(canvas)
rebuildNodeRenderInfo()
closeTransformBatch()
return
}
const childBounds = syncSelectionToStore(target)
// Check reparenting for each child based on final positions:
// 1. Objects with a parent that are dragged completely outside → detach
// 2. Root-level objects whose center is inside a frame → reparent into it
let anyReparented = false
const selNodeIdSet = new Set(childBounds.map((b) => b.nodeId))
setFabricSyncLock(true)
for (const b of childBounds) {
const bounds = { x: b.x, y: b.y, w: b.w, h: b.h }
if (checkDragReparentByBounds(b.nodeId, bounds)) {
anyReparented = true
} else if (
checkReparentIntoFrame(b.nodeId, bounds, selNodeIdSet)
) {
anyReparented = true
}
}
setFabricSyncLock(false)
if (anyReparented) {
// Tree structure changed — force full re-sync after the
// ActiveSelection is disbanded so clip paths and positions
// are recomputed correctly.
isSelectionModification = false
selectionReparented = true
}
}
// Safety cleanup: clear any leftover drag-into session that wasn't
@ -800,13 +1058,39 @@ export function useCanvasEvents() {
// Force re-sync so clip paths (which use absolute coordinates) are
// recomputed from the new node positions. Without this, children of
// a dragged frame stay clipped to the old parent frame bounds.
// Skip the forced setState for ActiveSelection modifications — the
// positions were already written by syncSelectionToStore(), and
// re-syncing absolute coords onto group-relative objects would undo
// the move.
rebuildNodeRenderInfo()
const currentDoc = useDocumentStore.getState().document
useDocumentStore.setState({
document: { ...currentDoc, children: [...currentDoc.children] },
})
if (selectionReparented) {
// Disband the ActiveSelection so objects become individual canvas
// items. Then defer the full re-sync to the next frame — Fabric
// needs a render cycle to fully restore the objects' transforms
// and properties from the disbanded group. Without this delay,
// the subscriber re-syncs while objects are in a transitional
// state and visual properties (fill, clip paths) get lost.
canvas.discardActiveObject()
canvas.requestRenderAll()
requestAnimationFrame(() => {
rebuildNodeRenderInfo()
const doc = useDocumentStore.getState().document
useDocumentStore.setState({
document: { ...doc, children: [...doc.children] },
})
})
} else if (!isSelectionModification) {
const currentDoc = useDocumentStore.getState().document
useDocumentStore.setState({
document: { ...currentDoc, children: [...currentDoc.children] },
})
}
closeTransformBatch()
} finally {
inModifiedHandler = false
}
})
// --- Text editing: sync edited content back to document store ---

View file

@ -1,12 +1,29 @@
import { useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { resolveTargetAtDepth, getChildIds } from './selection-context'
import { COMPONENT_COLOR, INSTANCE_COLOR } from './canvas-constants'
import type { PenNode } from '@/types/pen'
const HOVER_COLOR = '#3b82f6'
const HOVER_LINE_WIDTH = 1.5
const CHILD_DASH = [4, 4]
function collectReusableIds(nodes: PenNode[], result: Set<string>) {
for (const node of nodes) {
if ('reusable' in node && node.reusable === true) result.add(node.id)
if ('children' in node && node.children) collectReusableIds(node.children, result)
}
}
function collectInstanceIds(nodes: PenNode[], result: Set<string>) {
for (const node of nodes) {
if (node.type === 'ref') result.add(node.id)
if ('children' in node && node.children) collectInstanceIds(node.children, result)
}
}
export function useCanvasHover() {
useEffect(() => {
const interval = setInterval(() => {
@ -66,6 +83,12 @@ export function useCanvasHover() {
const zoom = vpt[0]
const dpr = el.width / el.offsetWidth
const docChildren = useDocumentStore.getState().document.children
const reusableIds = new Set<string>()
const instanceIds = new Set<string>()
collectReusableIds(docChildren, reusableIds)
collectInstanceIds(docChildren, instanceIds)
ctx.save()
ctx.setTransform(
vpt[0] * dpr, vpt[1] * dpr,
@ -76,13 +99,13 @@ export function useCanvasHover() {
// Solid outline on hovered target — skip if already selected
// (Fabric draws its own selection handles)
if (!isSelected) {
drawNodeOutline(ctx, hoveredId, false, zoom)
drawNodeOutline(ctx, hoveredId, false, zoom, reusableIds, instanceIds)
}
// Dashed outlines on direct children — always draw on hover
const childIds = getChildIds(hoveredId)
for (const childId of childIds) {
drawNodeOutline(ctx, childId, true, zoom)
drawNodeOutline(ctx, childId, true, zoom, reusableIds, instanceIds)
}
ctx.restore()
@ -93,6 +116,8 @@ export function useCanvasHover() {
nodeId: string,
dashed: boolean,
zoom: number,
reusableIds: Set<string>,
instanceIds: Set<string>,
) {
const objects = canvas!.getObjects() as FabricObjectWithPenId[]
const obj = objects.find((o) => o.penNodeId === nodeId)
@ -104,6 +129,9 @@ export function useCanvasHover() {
const h = (obj.height ?? 0) * (obj.scaleY ?? 1)
const angle = obj.angle ?? 0
const isReusable = reusableIds.has(nodeId)
const isInstance = instanceIds.has(nodeId)
ctx.save()
if (angle !== 0) {
@ -114,9 +142,15 @@ export function useCanvasHover() {
ctx.translate(-cx, -cy)
}
ctx.strokeStyle = HOVER_COLOR
ctx.strokeStyle = isReusable
? COMPONENT_COLOR
: isInstance
? INSTANCE_COLOR
: HOVER_COLOR
ctx.lineWidth = HOVER_LINE_WIDTH / zoom
if (dashed) {
if (isInstance) {
ctx.setLineDash(CHILD_DASH.map((d) => d / zoom))
} else if (dashed) {
ctx.setLineDash(CHILD_DASH.map((d) => d / zoom))
} else {
ctx.setLineDash([])

View file

@ -5,6 +5,16 @@ import { useCanvasStore } from '@/stores/canvas-store'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import { resolveTargetAtDepth } from './selection-context'
/**
* When true, the next selection event will skip depth-resolution and
* pass through as-is. Used by the layer panel to programmatically select
* children without the handler resolving them back to their parent.
*/
let skipNextDepthResolve = false
export function setSkipNextDepthResolve() {
skipNextDepthResolve = true
}
/**
* Resolve a list of Fabric selected objects to node IDs at the current
* entered-frame depth. If any target falls outside the current context,
@ -44,9 +54,59 @@ export function useCanvasSelection() {
if (!canvas) return
clearInterval(interval)
const handleSelection = (e: { selected?: FabricObject[] }) => {
const selected = e.selected ?? []
const ids = resolveIds(selected)
// Guard against re-entry: setActiveObject fires selection events
// synchronously, which would call handleSelection again and cause
// infinite recursion.
let updatingSelection = false
const handleSelection = (e: { selected?: FabricObject[]; e?: unknown }) => {
if (updatingSelection) return
// Programmatic selection from layer panel — skip depth resolution
if (skipNextDepthResolve) {
skipNextDepthResolve = false
return
}
// `selection:updated` payload `selected` may contain only delta objects.
// Always read the full active selection from canvas for accurate multi-select.
const selected = canvas.getActiveObjects()
const fallbackSelected = e.selected ?? []
const effectiveSelected = selected.length > 0 ? selected : fallbackSelected
const prevIds = useCanvasStore.getState().selection.selectedIds
const mouseEvent = e.e as MouseEvent | undefined
// If user already has a multi-selection and clicks one selected object
// (without Shift), keep the whole selection so dragging moves all.
if (!mouseEvent?.shiftKey && prevIds.length > 1 && effectiveSelected.length === 1) {
const clicked = effectiveSelected[0] as FabricObjectWithPenId
const resolvedClicked = clicked?.penNodeId
? resolveTargetAtDepth(clicked.penNodeId)
: null
if (resolvedClicked && prevIds.includes(resolvedClicked)) {
const objects = canvas.getObjects() as FabricObjectWithPenId[]
const prevSet = new Set(prevIds)
const restoredObjects = objects.filter(
(o) => o.penNodeId && prevSet.has(o.penNodeId),
)
if (restoredObjects.length > 1) {
updatingSelection = true
try {
const restored = new ActiveSelection(restoredObjects, { canvas })
canvas.setActiveObject(restored)
canvas.requestRenderAll()
useCanvasStore.getState().setSelection(prevIds, prevIds[0] ?? null)
} finally {
updatingSelection = false
}
return
}
}
}
const ids = resolveIds(effectiveSelected)
useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
// Correct Fabric's active object to match the depth-resolved target.
@ -56,29 +116,34 @@ export function useCanvasSelection() {
const objects = canvas.getObjects() as FabricObjectWithPenId[]
if (ids.length === 1) {
const currentActive = selected[0] as FabricObjectWithPenId
if (currentActive?.penNodeId !== ids[0]) {
const correctObj = objects.find((o) => o.penNodeId === ids[0])
if (correctObj) {
canvas.setActiveObject(correctObj)
updatingSelection = true
try {
if (ids.length === 1) {
const currentActive = effectiveSelected[0] as FabricObjectWithPenId
if (currentActive?.penNodeId !== ids[0]) {
const correctObj = objects.find((o) => o.penNodeId === ids[0])
if (correctObj) {
canvas.setActiveObject(correctObj)
canvas.requestRenderAll()
}
}
} else {
// Multi-select: build an ActiveSelection from the resolved objects
const resolvedSet = new Set(ids)
const resolvedObjs = objects.filter(
(o) => o.penNodeId && resolvedSet.has(o.penNodeId),
)
if (resolvedObjs.length > 1) {
const sel = new ActiveSelection(resolvedObjs, { canvas })
canvas.setActiveObject(sel)
canvas.requestRenderAll()
} else if (resolvedObjs.length === 1) {
canvas.setActiveObject(resolvedObjs[0])
canvas.requestRenderAll()
}
}
} else {
// Multi-select: build an ActiveSelection from the resolved objects
const resolvedSet = new Set(ids)
const resolvedObjs = objects.filter(
(o) => o.penNodeId && resolvedSet.has(o.penNodeId),
)
if (resolvedObjs.length > 1) {
const sel = new ActiveSelection(resolvedObjs, { canvas })
canvas.setActiveObject(sel)
canvas.requestRenderAll()
} else if (resolvedObjs.length === 1) {
canvas.setActiveObject(resolvedObjs[0])
canvas.requestRenderAll()
}
} finally {
updatingSelection = false
}
}

View file

@ -11,6 +11,8 @@ import { syncFabricObject } from './canvas-object-sync'
import { isFabricSyncLocked, setFabricSyncLock } from './canvas-sync-lock'
import { pendingAnimationNodes, getNextStaggerDelay } from '@/services/ai/design-animation'
import { resolveNodeForCanvas, getDefaultTheme } from '@/variables/resolve-variables'
import { findNodeInTree } from '@/stores/document-store'
import { COMPONENT_COLOR, INSTANCE_COLOR, SELECTION_BLUE } from './canvas-constants'
// ---------------------------------------------------------------------------
// Clip info — tracks parent frame bounds for child clipping
@ -116,17 +118,20 @@ function fitContentWidth(node: PenNode): number {
}
/** Compute fit-content height from children. */
function fitContentHeight(node: PenNode): number {
function fitContentHeight(node: PenNode, parentAvailW?: number): number {
if (!('children' in node) || !node.children?.length) return 0
const layout = 'layout' in node ? (node as ContainerProps).layout : undefined
const pad = resolvePadding('padding' in node ? (node as any).padding : undefined)
const gap = 'gap' in node && typeof (node as any).gap === 'number' ? (node as any).gap : 0
// Compute available width for children (used by text height estimation)
const nodeW = getNodeWidth(node, parentAvailW)
const childAvailW = nodeW > 0 ? Math.max(0, nodeW - pad.left - pad.right) : parentAvailW
if (layout === 'vertical') {
const childTotal = node.children.reduce((sum, c) => sum + getNodeHeight(c), 0)
const childTotal = node.children.reduce((sum, c) => sum + getNodeHeight(c, undefined, childAvailW), 0)
const gapTotal = gap * Math.max(0, node.children.length - 1)
return childTotal + gapTotal + pad.top + pad.bottom
}
const maxChildH = node.children.reduce((max, c) => Math.max(max, getNodeHeight(c)), 0)
const maxChildH = node.children.reduce((max, c) => Math.max(max, getNodeHeight(c, undefined, childAvailW)), 0)
return maxChildH + pad.top + pad.bottom
}
@ -156,29 +161,71 @@ function getNodeWidth(node: PenNode, parentAvail?: number): number {
return 0
}
function getNodeHeight(node: PenNode, parentAvail?: number): number {
function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?: number): number {
if ('height' in node) {
const s = parseSizing(node.height)
if (typeof s === 'number' && s > 0) return s
if (s === 'fill' && parentAvail) return parentAvail
if (s === 'fit') {
const fit = fitContentHeight(node)
const fit = fitContentHeight(node, parentAvailW)
if (fit > 0) return fit
}
}
// Containers without explicit height: compute from children
if ('children' in node && node.children?.length) {
const fit = fitContentHeight(node)
const fit = fitContentHeight(node, parentAvailW)
if (fit > 0) return fit
}
if (node.type === 'text') {
const fontSize = node.fontSize ?? 16
const lineHeight = ('lineHeight' in node ? node.lineHeight : undefined) ?? 1.2
return fontSize * lineHeight
return estimateTextHeight(node, parentAvailW)
}
return 0
}
/** Estimate text height including multi-line wrapping when available width is known. */
function estimateTextHeight(node: PenNode, availableWidth?: number): number {
// 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 singleLineH = fontSize * lineHeight
// Get text content
const rawContent = n.content
const content = typeof rawContent === 'string'
? rawContent
: Array.isArray(rawContent)
? rawContent.map((s: { text: string }) => s.text).join('')
: ''
if (!content) return singleLineH
// Determine the effective text width for wrapping estimation
let textWidth = 0
if ('width' in node) {
const w = parseSizing(node.width)
if (typeof w === 'number' && w > 0) textWidth = w
else if (w === 'fill' && availableWidth && availableWidth > 0) textWidth = availableWidth
}
// If no width constraint is known, return single-line height
if (textWidth <= 0) return singleLineH
// Estimate wrapped lines using per-character width estimation
// CJK characters are roughly 1em wide; Latin characters ~0.55em
let totalTextWidth = 0
for (const ch of content) {
const code = ch.codePointAt(0) ?? 0
const isCjk = (code >= 0x4E00 && code <= 0x9FFF)
|| (code >= 0x3000 && code <= 0x30FF)
|| (code >= 0xFF00 && code <= 0xFFEF)
|| (code >= 0xAC00 && code <= 0xD7AF)
totalTextWidth += isCjk ? fontSize : fontSize * 0.55
}
const lines = Math.max(1, Math.ceil(totalTextWidth / textWidth))
return Math.round(lines * singleLineH)
}
/** Compute child positions according to the parent's layout rules. */
function computeLayoutPositions(
parent: PenNode,
@ -211,7 +258,7 @@ function computeLayoutPositions(
const s = parseSizing((ch as any)[prop])
if (s === 'fill') return 'fill' as const
}
return isVertical ? getNodeHeight(ch, availH) : getNodeWidth(ch, availW)
return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW)
})
const fixedTotal = mainSizing.reduce<number>(
(sum, s) => sum + (typeof s === 'number' ? s : 0),
@ -225,7 +272,7 @@ function computeLayoutPositions(
const mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
return {
w: isVertical ? getNodeWidth(ch, availW) : mainSize,
h: isVertical ? mainSize : getNodeHeight(ch, availH),
h: isVertical ? mainSize : getNodeHeight(ch, availH, availW),
}
})
@ -317,6 +364,92 @@ function computeLayoutPositions(
})
}
// ---------------------------------------------------------------------------
// Resolve RefNodes — expand instances by looking up their referenced component
// ---------------------------------------------------------------------------
/** Give children unique IDs scoped to the instance, apply overrides from descendants. */
function remapInstanceChildIds(
children: PenNode[],
refId: string,
overrides?: Record<string, Partial<PenNode>>,
): PenNode[] {
return children.map((child) => {
const virtualId = `${refId}__${child.id}`
const ov = overrides?.[child.id] ?? {}
const mapped = { ...child, ...ov, id: virtualId } as PenNode
if ('children' in mapped && mapped.children) {
;(mapped as PenNode & { children: PenNode[] }).children =
remapInstanceChildIds(mapped.children, refId, overrides)
}
return mapped
})
}
/**
* Recursively resolve all RefNodes in the tree by expanding them
* with their referenced component's structure.
*/
function resolveRefs(
nodes: PenNode[],
rootNodes: PenNode[],
visited = new Set<string>(),
): PenNode[] {
return nodes.flatMap((node) => {
if (node.type !== 'ref') {
if ('children' in node && node.children) {
return [
{
...node,
children: resolveRefs(node.children, rootNodes, visited),
} as PenNode,
]
}
return [node]
}
// Resolve RefNode
if (visited.has(node.ref)) return [] // circular reference guard
const component = findNodeInTree(rootNodes, node.ref)
if (!component) return []
visited.add(node.ref)
const refNode = node as PenNode & { descendants?: Record<string, Partial<PenNode>> }
// Apply top-level visual overrides from descendants[componentId]
const topOverrides = refNode.descendants?.[node.ref] ?? {}
// Build resolved node: component base → overrides → RefNode's own properties
const resolved: Record<string, unknown> = { ...component, ...topOverrides }
// Apply all explicitly-defined RefNode properties (position, size, opacity, etc.)
for (const [key, val] of Object.entries(node)) {
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
if (val !== undefined) {
resolved[key] = val
}
}
// Use component's type (not 'ref') and ensure name fallback
resolved.type = component.type
if (!resolved.name) resolved.name = component.name
// Clear the reusable flag — this is an instance, not the component
delete resolved.reusable
const resolvedNode = resolved as unknown as PenNode
// Remap children IDs to avoid clashes with the original component
if ('children' in resolvedNode && resolvedNode.children) {
;(resolvedNode as PenNode & { children: PenNode[] }).children =
remapInstanceChildIds(
resolvedNode.children,
node.id,
refNode.descendants,
)
}
visited.delete(node.ref)
return [resolvedNode]
})
}
// ---------------------------------------------------------------------------
// Flatten document tree → absolute-positioned list for Fabric.js
// ---------------------------------------------------------------------------
@ -370,7 +503,7 @@ function flattenNodes(
r.height = parentAvailH
changed = true
} else if (s === 'fit') {
r.height = getNodeHeight(node, parentAvailH)
r.height = getNodeHeight(node, parentAvailH, parentAvailW)
changed = true
}
}
@ -421,7 +554,8 @@ function flattenNodes(
let childClip = clipCtx
const cr = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0
const isRootFrame = node.type === 'frame' && depth === 0
if (isRootFrame || cr > 0) {
const hasClipContent = 'clipContent' in node && (node as ContainerProps).clipContent === true
if (isRootFrame || cr > 0 || hasClipContent) {
childClip = { x: parentAbsX, y: parentAbsY, w: nodeW, h: nodeH, rx: cr }
}
@ -461,7 +595,8 @@ export function rebuildNodeRenderInfo() {
nodeRenderInfo.clear()
rootFrameBounds.clear()
layoutContainerBounds.clear()
flattenNodes(state.document.children, 0, 0, undefined, undefined, undefined, new Map())
const resolvedTree = resolveRefs(state.document.children, state.document.children)
flattenNodes(resolvedTree, 0, 0, undefined, undefined, undefined, new Map())
}
/**
@ -554,8 +689,10 @@ export function useCanvasSync() {
nodeRenderInfo.clear()
rootFrameBounds.clear()
layoutContainerBounds.clear()
// Resolve RefNodes before flattening so instances render as their component
const resolvedTree = resolveRefs(state.document.children, state.document.children)
const flatNodes = flattenNodes(
state.document.children, 0, 0, undefined, undefined, undefined, clipMap,
resolvedTree, 0, 0, undefined, undefined, undefined, clipMap,
).map((node) => resolveNodeForCanvas(node, variables, activeTheme))
const nodeMap = new Map(flatNodes.map((n) => [n.id, n]))
const objects = canvas.getObjects() as FabricObjectWithPenId[]
@ -565,6 +702,17 @@ export function useCanvasSync() {
.map((o) => [o.penNodeId!, o]),
)
// Collect component and instance IDs for selection styling
const reusableIds = new Set<string>()
const instanceIds = new Set<string>()
;(function collectComponentIds(nodes: PenNode[]) {
for (const n of nodes) {
if ('reusable' in n && n.reusable === true) reusableIds.add(n.id)
if (n.type === 'ref') instanceIds.add(n.id)
if ('children' in n && n.children) collectComponentIds(n.children)
}
})(state.document.children)
// Remove objects that no longer exist in the document
for (const obj of objects) {
if (obj.penNodeId && !nodeMap.has(obj.penNodeId)) {
@ -577,8 +725,30 @@ export function useCanvasSync() {
if (node.type === 'ref') continue // Skip unresolved refs
let obj: FabricObjectWithPenId | undefined
const existingObj = objMap.get(node.id)
let existingObj = objMap.get(node.id)
// Text nodes may need recreation when textGrowth mode changes
// (IText ↔ Textbox are different Fabric classes).
let textRecreated = false
if (existingObj && node.type === 'text') {
const growth = node.textGrowth
const w = typeof node.width === 'number' ? node.width : 0
const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height' || w > 0
const isTextbox = existingObj instanceof fabric.Textbox
if (needsTextbox !== isTextbox) {
canvas.remove(existingObj)
existingObj = undefined
textRecreated = true
}
}
if (existingObj) {
// Skip objects inside an ActiveSelection — their left/top are
// group-relative, not absolute. Setting absolute values from
// the store would move them to wrong positions (snap-back bug).
if (existingObj.group instanceof fabric.ActiveSelection) {
continue
}
syncFabricObject(existingObj, node)
obj = existingObj
} else {
@ -603,12 +773,34 @@ export function useCanvasSync() {
} else {
canvas.add(newObj)
}
// Restore Fabric selection when text object was recreated
if (textRecreated) {
const { activeId } = useCanvasStore.getState().selection
if (activeId === node.id) {
canvas.setActiveObject(newObj)
}
}
obj = newObj
}
}
// Apply clip path from parent frame with cornerRadius
if (obj) {
// Component/instance selection border styling
if (reusableIds.has(node.id)) {
obj.borderColor = COMPONENT_COLOR
obj.cornerColor = COMPONENT_COLOR
obj.borderDashArray = []
} else if (instanceIds.has(node.id)) {
obj.borderColor = INSTANCE_COLOR
obj.cornerColor = INSTANCE_COLOR
obj.borderDashArray = [4, 4]
} else if (obj.borderColor === COMPONENT_COLOR || obj.borderColor === INSTANCE_COLOR) {
obj.borderColor = SELECTION_BLUE
obj.cornerColor = SELECTION_BLUE
obj.borderDashArray = []
}
// Apply clip path from parent frame with cornerRadius
const clip = clipMap.get(node.id)
if (clip) {
obj.clipPath = new fabric.Rect({

View file

@ -1,5 +1,8 @@
import { useEffect, useRef, type RefObject } from 'react'
import * as fabric from 'fabric'
import { useCanvasStore } from '@/stores/canvas-store'
import { nodeRenderInfo } from './use-canvas-sync'
import type { FabricObjectWithPenId } from './canvas-object-factory'
export function useDimensionLabel(
containerRef: RefObject<HTMLDivElement | null>,
@ -40,10 +43,45 @@ export function useDimensionLabel(
}
const bound = active.getBoundingRect()
const w = Math.round(active.getScaledWidth())
const h = Math.round(active.getScaledHeight())
label.textContent = `${w} \u00d7 ${h}`
// Detect active interaction (dragging or scaling)
const transform = (canvas as unknown as { _currentTransform?: { action?: string } })
._currentTransform
const action = transform?.action
if (!action) {
// No active interaction — hide the label
label.style.display = 'none'
return
}
if (action === 'drag') {
// Show position in scene coordinates during drag
const vpt = canvas.viewportTransform
const zoom = vpt[0]
const panX = vpt[4]
const panY = vpt[5]
if (active instanceof fabric.ActiveSelection) {
const sceneX = Math.round(((bound.left - panX) / zoom))
const sceneY = Math.round(((bound.top - panY) / zoom))
label.textContent = `X: ${sceneX} Y: ${sceneY}`
} else {
const obj = active as FabricObjectWithPenId
const info = obj.penNodeId ? nodeRenderInfo.get(obj.penNodeId) : null
const offsetX = info?.parentOffsetX ?? 0
const offsetY = info?.parentOffsetY ?? 0
const relX = Math.round((active.left ?? 0) - offsetX)
const relY = Math.round((active.top ?? 0) - offsetY)
label.textContent = `X: ${relX} Y: ${relY}`
}
} else {
// Show dimensions during scale/resize
const w = Math.round(active.getScaledWidth())
const h = Math.round(active.getScaledHeight())
label.textContent = `${w} \u00d7 ${h}`
}
label.style.display = 'block'
label.style.left = `${bound.left + bound.width / 2}px`
label.style.top = `${bound.top + bound.height + 8}px`

View file

@ -110,6 +110,7 @@ export function useFabricCanvas(
height: container.clientHeight,
backgroundColor: getCanvasBackground(),
selection: true,
selectionKey: 'shiftKey',
preserveObjectStacking: true,
stopContextMenu: true,
fireRightClick: true,

View file

@ -1,12 +1,38 @@
import { useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { COMPONENT_COLOR, INSTANCE_COLOR } from './canvas-constants'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import type { PenNode } from '@/types/pen'
const LABEL_FONT_SIZE = 12
const LABEL_OFFSET_Y = 6
const LABEL_COLOR = '#999999'
/** Collect IDs of all nodes with reusable: true in the tree. */
function collectReusableIds(nodes: PenNode[], result: Set<string>) {
for (const node of nodes) {
if ('reusable' in node && node.reusable === true) {
result.add(node.id)
}
if ('children' in node && node.children) {
collectReusableIds(node.children, result)
}
}
}
/** Collect IDs of all RefNodes (instances) in the tree. */
function collectInstanceIds(nodes: PenNode[], result: Set<string>) {
for (const node of nodes) {
if (node.type === 'ref') {
result.add(node.id)
}
if ('children' in node && node.children) {
collectInstanceIds(node.children, result)
}
}
}
export function useFrameLabels() {
useEffect(() => {
const interval = setInterval(() => {
@ -26,22 +52,33 @@ export function useFrameLabels() {
const dpr = el.width / el.offsetWidth
const store = useDocumentStore.getState()
// Only top-level frame nodes show labels
// Top-level frame nodes show labels
const topFrameIds = new Set(
store.document.children
.filter((c) => c.type === 'frame')
.map((c) => c.id),
)
// Reusable component IDs (at any depth)
const reusableIds = new Set<string>()
collectReusableIds(store.document.children, reusableIds)
// Instance (RefNode) IDs
const instanceIds = new Set<string>()
collectInstanceIds(store.document.children, instanceIds)
const objects = canvas.getObjects() as FabricObjectWithPenId[]
ctx.save()
const fontSize = LABEL_FONT_SIZE * dpr
ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
ctx.fillStyle = LABEL_COLOR
for (const obj of objects) {
if (!obj.penNodeId) continue
if (!topFrameIds.has(obj.penNodeId)) continue
const isTopFrame = topFrameIds.has(obj.penNodeId)
const isReusable = reusableIds.has(obj.penNodeId)
const isInstance = instanceIds.has(obj.penNodeId)
if (!isTopFrame && !isReusable && !isInstance) continue
const node = store.getNodeById(obj.penNodeId)
if (!node) continue
@ -50,6 +87,11 @@ export function useFrameLabels() {
const x = ((obj.left ?? 0) * zoom + vpt[4]) * dpr
const y = ((obj.top ?? 0) * zoom + vpt[5]) * dpr
ctx.fillStyle = isReusable
? COMPONENT_COLOR
: isInstance
? INSTANCE_COLOR
: LABEL_COLOR
ctx.fillText(name, x, y - LABEL_OFFSET_Y * dpr)
}

View file

@ -8,6 +8,7 @@ import PropertyPanel from '@/components/panels/property-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'
import SaveDialog from '@/components/shared/save-dialog'
import AgentSettingsDialog from '@/components/shared/agent-settings-dialog'
@ -15,6 +16,7 @@ import { useAIStore } from '@/stores/ai-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import { useUIKitStore } from '@/stores/uikit-store'
const FabricCanvas = lazy(() => import('@/canvas/fabric-canvas'))
@ -23,6 +25,7 @@ export default function EditorLayout() {
const hasSelection = useCanvasStore((s) => s.selection.activeId !== null)
const layerPanelOpen = useCanvasStore((s) => s.layerPanelOpen)
const variablesPanelOpen = useCanvasStore((s) => s.variablesPanelOpen)
const browserOpen = useUIKitStore((s) => s.browserOpen)
const saveDialogOpen = useDocumentStore((s) => s.saveDialogOpen)
const closeSaveDialog = useCallback(() => {
useDocumentStore.getState().setSaveDialogOpen(false)
@ -70,6 +73,13 @@ export default function EditorLayout() {
return
}
// Cmd+Shift+K: toggle UIKit browser
if (isMod && e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault()
useUIKitStore.getState().toggleBrowser()
return
}
// Cmd+,: open agent settings
if (isMod && e.key === ',') {
e.preventDefault()
@ -81,9 +91,10 @@ export default function EditorLayout() {
return () => window.removeEventListener('keydown', handler)
}, [toggleMinimize, toggleCodePanel])
// Hydrate persisted agent settings
// Hydrate persisted settings
useEffect(() => {
useAgentSettingsStore.getState().hydrate()
useUIKitStore.getState().hydrate()
}, [])
return (
@ -108,6 +119,9 @@ export default function EditorLayout() {
{/* Floating variables panel — anchored to the right of the toolbar */}
{variablesPanelOpen && <VariablesPanel />}
{/* Floating UIKit browser panel */}
{browserOpen && <ComponentBrowserPanel />}
{/* Bottom bar: minimized AI (left) + zoom controls (right) */}
<div className="absolute bottom-2 left-2 right-2 z-10 flex items-center justify-between pointer-events-none">
<div className="pointer-events-auto">

View file

@ -6,7 +6,8 @@ import {
Hand,
Undo2,
Redo2,
SlidersHorizontal,
Braces,
LayoutGrid,
} from 'lucide-react'
import ToolButton from './tool-button'
import ShapeToolDropdown from './shape-tool-dropdown'
@ -14,6 +15,7 @@ import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore, generateId } from '@/stores/document-store'
import { parseSvgToNodes } from '@/utils/svg-parser'
import { useHistoryStore } from '@/stores/history-store'
import { useUIKitStore } from '@/stores/uikit-store'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
@ -28,6 +30,8 @@ export default function Toolbar() {
const canRedo = useHistoryStore((s) => s.redoStack.length > 0)
const variablesPanelOpen = useCanvasStore((s) => s.variablesPanelOpen)
const toggleVariablesPanel = useCanvasStore((s) => s.toggleVariablesPanel)
const browserOpen = useUIKitStore((s) => s.browserOpen)
const toggleBrowser = useUIKitStore((s) => s.toggleBrowser)
const fileInputRef = useRef<HTMLInputElement>(null)
const [iconPickerOpen, setIconPickerOpen] = useState(false)
@ -245,7 +249,7 @@ export default function Toolbar() {
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<SlidersHorizontal size={20} />
<Braces size={20} />
</button>
</TooltipTrigger>
<TooltipContent side="right">
@ -256,6 +260,31 @@ export default function Toolbar() {
</TooltipContent>
</Tooltip>
{/* UIKit Browser */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleBrowser}
aria-label="UIKit Browser"
aria-pressed={browserOpen}
className={`inline-flex items-center justify-center h-8 min-w-8 px-1.5 rounded-lg transition-colors [&_svg]:size-5 [&_svg]:shrink-0 ${
browserOpen
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<LayoutGrid size={20} />
</button>
</TooltipTrigger>
<TooltipContent side="right">
UIKit Browser
<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">
{'\u2318\u21e7'}K
</kbd>
</TooltipContent>
</Tooltip>
{/* Hidden file input + icon picker dialog */}
<input
ref={fileInputRef}

View file

@ -11,6 +11,7 @@ import {
} from 'lucide-react'
import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
import OpenCodeLogo from '@/components/icons/opencode-logo'
import { Button } from '@/components/ui/button'
import {
Tooltip,
@ -95,8 +96,11 @@ export default function TopBar() {
if (fileHandle) {
writeToFileHandle(fileHandle, doc).then(() => store.markClean())
} else if (fn) {
downloadDocument(doc, fn)
store.markClean()
} else if (supportsFileSystemAccess()) {
saveDocumentAs(doc, fn ?? 'untitled.pen').then((result) => {
saveDocumentAs(doc, 'untitled.op').then((result) => {
if (result) {
useDocumentStore.setState({
fileName: result.fileName,
@ -105,9 +109,6 @@ export default function TopBar() {
})
}
})
} else if (fn) {
downloadDocument(doc, fn)
store.markClean()
} else {
store.setSaveDialogOpen(true)
}
@ -136,9 +137,9 @@ export default function TopBar() {
const displayName = fileName ?? 'Untitled'
return (
<div className="h-10 bg-card border-b border-border flex items-center px-2 shrink-0 select-none">
<div className="h-10 bg-card border-b border-border flex items-center px-2 shrink-0 select-none app-region-drag">
{/* Left section */}
<div className="flex items-center gap-0.5">
<div className="flex items-center gap-0.5 app-region-no-drag electron-traffic-light-pad">
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -198,7 +199,7 @@ export default function TopBar() {
</div>
{/* Right section */}
<div className="flex items-center gap-0.5">
<div className="flex items-center gap-0.5 app-region-no-drag">
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -209,6 +210,7 @@ export default function TopBar() {
>
<ClaudeLogo className="w-4 h-4" />
<OpenAILogo className="w-4 h-4 -ml-1" />
<OpenCodeLogo className="w-4 h-4 -ml-1" />
<span className="hidden sm:inline">Agents & MCP</span>
</Button>
</TooltipTrigger>

View file

@ -0,0 +1,63 @@
import type { SVGProps } from 'react'
export default function OpenCodeLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#oc-clip)">
{/* Terminal window frame */}
<rect
x="12"
y="14"
width="40"
height="36"
rx="4"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
/>
{/* Title bar line */}
<line
x1="12"
y1="22"
x2="52"
y2="22"
stroke="currentColor"
strokeWidth="2"
/>
{/* Title bar dots */}
<circle cx="18" cy="18" r="1.5" fill="currentColor" />
<circle cx="23" cy="18" r="1.5" fill="currentColor" />
<circle cx="28" cy="18" r="1.5" fill="currentColor" />
{/* Terminal prompt chevron > */}
<path
d="M18 29L26 35L18 41"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
{/* Cursor line */}
<line
x1="30"
y1="40"
x2="44"
y2="40"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</g>
<defs>
<clipPath id="oc-clip">
<rect width="40" height="40" fill="white" transform="translate(12 12)" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare, Loader2 } from 'lucide-react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare, Loader2, Pencil } from 'lucide-react'
import { nanoid } from 'nanoid'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@ -19,15 +19,23 @@ import {
extractAndApplyDesign,
extractAndApplyDesignModification
} from '@/services/ai/design-generator'
import { trimChatHistory } from '@/services/ai/context-optimizer'
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
import type { AIProviderType } from '@/types/agent-settings'
import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config'
import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
import ChatMessage from './chat-message'
import OpenCodeLogo from '@/components/icons/opencode-logo'
import ChatMessage, {
parseStepBlocks,
countDesignJsonBlocks,
buildPipelineProgress,
} from './chat-message'
const PROVIDER_ICON: Record<AIProviderType, typeof ClaudeLogo> = {
anthropic: ClaudeLogo,
openai: OpenAILogo,
opencode: OpenCodeLogo,
}
const QUICK_ACTIONS = [
@ -117,7 +125,6 @@ function useChatHandlers() {
const addMessage = useAIStore((s) => s.addMessage)
const updateLastMessage = useAIStore((s) => s.updateLastMessage)
const setStreaming = useAIStore((s) => s.setStreaming)
const handleSend = useCallback(
async (text?: string) => {
const messageText = text ?? input.trim()
@ -221,8 +228,20 @@ function useChatHandlers() {
} else {
// --- CHAT MODE ---
chatHistory.push({ role: 'user', content: fullUserMessage })
// Trim history to prevent unbounded context growth
const trimmedHistory = trimChatHistory(chatHistory)
// Resolve which provider the currently selected model belongs to
const currentProvider = useAIStore.getState().modelGroups.find((g) =>
g.models.some((m) => m.value === model),
)?.provider
let chatThinking = false
for await (const chunk of streamChat(CHAT_SYSTEM_PROMPT, chatHistory, model)) {
for await (const chunk of streamChat(
CHAT_SYSTEM_PROMPT,
trimmedHistory,
model,
CHAT_STREAM_THINKING_CONFIG,
currentProvider,
)) {
if (chunk.type === 'thinking') {
// Show a brief thinking indicator so the UI isn't stuck on empty
if (!chatThinking && !accumulated) {
@ -268,6 +287,88 @@ function useChatHandlers() {
return { input, setInput, handleSend, isStreaming }
}
/** Fixed collapsible checklist pinned between messages and input */
function FixedChecklist({ messages, isStreaming }: { messages: ChatMessageType[]; isStreaming: boolean }) {
const [collapsed, setCollapsed] = useState(false)
// Find the last assistant message to extract checklist data
const lastAssistant = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return messages[i]
}
return null
}, [messages])
const items = useMemo(() => {
if (!lastAssistant) return []
const content = lastAssistant.content
const steps = parseStepBlocks(content, isStreaming)
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 hasError = /\*\*Error:\*\*/i.test(content)
return buildPipelineProgress(planSteps, jsonCount, isStreaming, isApplied, hasError)
}, [lastAssistant, isStreaming])
if (items.length === 0) return null
const completed = items.filter((item) => item.done).length
return (
<div className="shrink-0 border-t border-border bg-card/95">
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="flex items-center justify-between w-full px-3 py-2 hover:bg-secondary/30 transition-colors"
>
<div className="flex items-center gap-2">
<Pencil size={13} className="text-muted-foreground shrink-0" />
<span className="text-xs font-medium text-foreground">Pencil it out</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{completed}/{items.length}</span>
<ChevronDown
size={12}
className={cn(
'text-muted-foreground transition-transform duration-200',
collapsed ? '' : 'rotate-180',
)}
/>
</div>
</button>
{!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>
))}
</div>
)}
</div>
)
}
/**
* Minimized AI bar a compact clickable pill.
* Parent is responsible for placing it in the layout.
@ -343,6 +444,7 @@ export default function AIChatPanel() {
const providerNames: Record<AIProviderType, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode',
}
const groups = connectedProviders.map((p) => ({
provider: p,
@ -587,7 +689,7 @@ export default function AIChatPanel() {
<div
ref={panelRef}
className={cn(
'absolute z-50 w-[320px] rounded-xl shadow-2xl border border-border bg-card/95 backdrop-blur-sm flex flex-col',
'absolute z-50 flex w-[320px] flex-col overflow-hidden rounded-xl border border-border bg-card/95 shadow-2xl backdrop-blur-sm',
!dragStyle && CORNER_CLASSES[panelCorner],
)}
style={{ ...dragStyle, height: panelHeight }}
@ -635,7 +737,7 @@ export default function AIChatPanel() {
</div>
{/* --- Messages --- */}
<div className="flex-1 overflow-y-auto px-3.5 py-3 bg-background/80 rounded-b-xl">
<div className="min-h-0 flex-1 overflow-y-auto rounded-b-xl bg-background/80 px-3.5 py-3">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-4">
<p className="text-xs text-muted-foreground mb-4">
@ -671,6 +773,9 @@ export default function AIChatPanel() {
<div ref={messagesEndRef} />
</div>
{/* --- Fixed Checklist --- */}
<FixedChecklist messages={messages} isStreaming={isStreaming} />
{/* --- Input area --- */}
<div className="relative border-t border-border bg-card rounded-b-xl">
<textarea
@ -711,15 +816,18 @@ export default function AIChatPanel() {
<ChevronUp size={10} className="shrink-0" />
</button>
<div className="flex items-center gap-1 justify-between w-full">
{selectedIds.length > 0 && (
<span className="flex text-[10px] text-muted-foreground ml-2 select-none overflow-hidden text-ellipsis ">
{selectedIds.length} object{selectedIds.length > 1 ? 's' : ''}
</span>
)}
<div className="flex items-center gap-1 w-full">
<span
className={cn(
'ml-1 shrink-0 whitespace-nowrap text-[10px] select-none',
selectedIds.length > 0 ? 'text-muted-foreground/80' : 'text-muted-foreground/40',
)}
>
{selectedIds.length} selected
</span>
{/* Action icons */}
<div className="flex items-center gap-0.5 w-full justify-end">
<div className="ml-auto flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"

View file

@ -1,5 +1,5 @@
import React, { useState, useMemo, type ReactNode } from 'react'
import { Copy, Check, Wand2, ChevronDown, ScanSearch, FileJson, ListOrdered, Palette, LayoutTemplate } from 'lucide-react'
import { Copy, Check, Wand2, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@ -46,46 +46,158 @@ function stripToolCallXml(text: string): string {
return cleaned.trim()
}
/** Check if a line is a step action */
function isActionStep(line: string): boolean {
return /<step.*<\/step>/.test(line) || line.trim().startsWith('<step')
export interface ParsedStep {
title: string
content: string
/** Explicit status from orchestrator steps (undefined for normal steps) */
status?: 'pending' | 'streaming' | 'done' | 'error'
}
function parseStepTitle(step: string): string {
const match = step.match(/title="([^"]+)"/)
return match ? match[1] : 'Processing'
export function parseStepBlocks(text: string, isStreaming?: boolean): ParsedStep[] {
const stepRegex = /<step([^>]*)>([\s\S]*?)<\/step>/gi
const parsed: ParsedStep[] = []
let match: RegExpExecArray | null
while ((match = stepRegex.exec(text)) !== null) {
const attrs = match[1]
const titleMatch = attrs.match(/title="([^"]+)"/)
const statusMatch = attrs.match(/status="([^"]+)"/)
parsed.push({
title: (titleMatch?.[1] ?? 'Processing').trim() || 'Processing',
status: (statusMatch?.[1] as ParsedStep['status']) ?? undefined,
content: (match[2] ?? '').trim(),
})
}
const lastOpen = text.lastIndexOf('<step')
const lastClose = text.lastIndexOf('</step>')
if (isStreaming && lastOpen > lastClose) {
const partial = text.slice(lastOpen)
const titleMatch = partial.match(/title="([^"]+)"/i)
const statusMatch = partial.match(/status="([^"]+)"/i)
const contentStart = partial.indexOf('>')
parsed.push({
title: (titleMatch?.[1] ?? 'Design').trim() || 'Design',
status: (statusMatch?.[1] as ParsedStep['status']) ?? undefined,
content:
contentStart >= 0
? partial
.slice(contentStart + 1)
.replace(/<\/step>$/i, '')
.trim()
: '',
})
}
return parsed
}
function parseStepContent(step: string): string {
return step.replace(/<step[^>]*>/, '').replace(/<\/step>/, '').trim()
function stripStepBlocks(text: string): string {
return text
.replace(/<step(?:[^>]*title="[^"]*")?[^>]*>[\s\S]*?<\/step>/gi, '')
.replace(/<step(?:[^>]*title="[^"]*")?[^>]*>[\s\S]*$/gi, '')
.trim()
}
/** Component for rendering a list of action steps as accordions */
function ActionSteps({ steps }: { steps: string[] }) {
if (steps.length === 0) return null
/** Count completed sections in JSONL content (direct children of root frame). */
function countJsonlSections(content: string): number {
const lines = content.split('\n')
let rootId: string | null = null
let sectionCount = 0
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('{')) continue
const parentMatch = trimmed.match(/"_parent"\s*:\s*(null|"([^"]*)")/)
if (!parentMatch) continue
if (parentMatch[1] === 'null') {
const idMatch = trimmed.match(/"id"\s*:\s*"([^"]*)"/)
if (idMatch) rootId = idMatch[1]
} else if (rootId && parentMatch[2] === rootId) {
sectionCount++
}
}
return sectionCount
}
export function countDesignJsonBlocks(text: string): number {
const blockRegex = /```(?:json)?\s*\n?([\s\S]*?)(?:\n?```|$)/gi
let count = 0
let match: RegExpExecArray | null
while ((match = blockRegex.exec(text)) !== null) {
const content = match[1].trim()
if (!isDesignJson(content)) continue
// JSONL format: count direct children of root as sections
if (/"_parent"\s*:/.test(content)) {
count += countJsonlSections(content)
} else {
count += 1
}
}
return count
}
export function buildPipelineProgress(
steps: ParsedStep[],
jsonBlockCount: number,
isStreaming: boolean,
isApplied: boolean,
hasError: boolean,
): Array<{ label: string; done: boolean; active: boolean }> {
// No steps = no checklist
if (steps.length === 0) return []
// 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 }))
}
// If steps have explicit status (orchestrator mode), use that directly
const hasExplicitStatus = steps.some((s) => s.status !== undefined)
if (hasExplicitStatus) {
return steps.map((s) => ({
label: s.title,
done: s.status === 'done',
active: s.status === 'streaming',
}))
}
// Fallback: Map each step to done/active/pending based on completed JSON blocks.
// Step[i] is done when jsonBlockCount > i.
// The step at jsonBlockCount is active (currently being generated).
return steps.map((s, index) => {
const done = index < jsonBlockCount
const active = isStreaming && !done && index === jsonBlockCount
return { label: s.title, done, active }
})
}
/** Component for rendering a list of action steps as accordions.
* Only shows steps with non-empty content (e.g. thinking, analysis).
* Empty plan steps are shown in PipelineChecklist instead. */
function ActionSteps({ steps, isStreaming }: { steps: ParsedStep[]; isStreaming?: boolean }) {
// Filter to only show steps with actual content (not empty plan steps)
const stepsWithContent = steps.filter((s) => s.content.trim())
if (stepsWithContent.length === 0) return null
return (
<div className="flex flex-col gap-1 w-full">
{steps.map((step, i) => {
const title = parseStepTitle(step)
const content = parseStepContent(step)
const isDesign = title.toLowerCase() === 'design'
// Icon mapping based on step title
let Icon = ScanSearch
if (title.toLowerCase().includes('guidelines')) Icon = FileJson
if (title.toLowerCase().includes('state')) Icon = ListOrdered
if (title.toLowerCase().includes('styleguide')) Icon = Palette
if (isDesign) Icon = LayoutTemplate
{stepsWithContent.map((step, i) => {
const isDone = !isStreaming || i < stepsWithContent.length - 1
const isActive = !!isStreaming && i === stepsWithContent.length - 1
return (
<ActionStepItem
key={i}
title={title}
content={content}
icon={Icon}
defaultOpen={isDesign}
isLast={i === steps.length - 1}
<ActionStepItem
key={`${step.title}-${i}`}
title={step.title}
content={step.content}
defaultOpen={isActive}
isDone={isDone}
isActive={isActive}
/>
)
})}
@ -93,67 +205,65 @@ function ActionSteps({ steps }: { steps: string[] }) {
)
}
function ActionStepItem({
title,
content,
icon: _Icon,
function ActionStepItem({
title,
content,
defaultOpen = false,
isLast
}: {
isDone,
isActive,
}: {
title: string
content: string
icon: any
defaultOpen?: boolean
isLast?: boolean
defaultOpen?: boolean
isDone: boolean
isActive: boolean
}) {
const [isOpen, setIsOpen] = useState(defaultOpen)
// Pencil-like style: Rounded pill/card with subtle border
return (
<div className="group">
<button
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-left transition-all rounded-md border",
isOpen
? "bg-secondary/40 border-border/60"
: "bg-background/40 hover:bg-secondary/20 border-border/30 hover:border-border/50"
'flex items-center justify-between w-full px-3 py-2 text-left transition-all rounded-md border',
isOpen
? 'bg-secondary/40 border-border/60'
: 'bg-background/40 hover:bg-secondary/20 border-border/30 hover:border-border/50',
)}
>
<div className="flex items-center gap-2.5 overflow-hidden">
{/* Status Icon - Pencil puts it on the right usually, but we can keep left for flow or move right */}
{/* Actually looking at Pencil screenshot: Text is left, checkmark is INLINE or right. */}
{/* Let's keep consistent icon on left for now, but style it like a status indicator */}
<div className={cn(
"w-4 h-4 rounded-full flex items-center justify-center shrink-0 transition-colors",
isLast
? "text-primary scale-110"
: "text-emerald-500/80" // Green for completed steps like Pencil
)}>
{isLast ? (
<div className="w-2 h-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
<div
className={cn(
'w-4 h-4 rounded-full flex items-center justify-center shrink-0 transition-colors',
isDone ? 'text-emerald-500/80' : isActive ? 'text-primary' : 'text-muted-foreground/50',
)}
>
{isDone ? (
<Check size={12} strokeWidth={2.5} />
) : (
<Check size={12} strokeWidth={2.5} />
<div className={cn('w-2 h-2 rounded-full', isActive ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60')} />
)}
</div>
<span title={title} className={cn(
"text-[11px] font-medium transition-colors truncate select-none",
isLast ? "text-foreground" : "text-muted-foreground/90"
)}>
<span
title={title}
className={cn(
'text-[11px] font-medium transition-colors truncate select-none',
isDone ? 'text-muted-foreground/90' : isActive ? 'text-foreground' : 'text-muted-foreground/70',
)}
>
{title}
</span>
</div>
<div className="flex items-center text-muted-foreground/30">
<ChevronDown size={12} className={cn("transition-transform duration-200", isOpen ? "rotate-180" : "")} />
<ChevronDown size={12} className={cn('transition-transform duration-200', isOpen ? 'rotate-180' : '')} />
</div>
</button>
{isOpen && (
{isOpen && content && (
<div className="px-3 py-2 mx-1 mt-0.5 border-l border-border/30 text-[10px] text-muted-foreground/80 leading-relaxed font-mono animate-in slide-in-from-top-0.5 duration-200 whitespace-pre-wrap break-words">
{content}
{content}
</div>
)}
</div>
@ -178,56 +288,7 @@ function parseMarkdown(
let codeLang = ''
let blockKey = 0
// Pre-process: extract sequential steps at the start or throughout?
// Our prompt puts them at the start. Let's process line by line.
// If we encounter steps, we collect them. If we encounter non-step content, we flush steps.
// Actually, simpler: Treat <step> lines as special blocks.
let currentSteps: string[] = []
const flushSteps = () => {
if (currentSteps.length > 0) {
parts.push(<ActionSteps key={`steps-${blockKey++}`} steps={[...currentSteps]} />)
currentSteps = []
}
}
for (const line of lines) {
if (line.includes('</step>') && !line.includes('<step')) {
const withoutCloseStep = line.replace(/<\/step>/g, '').trim()
if (!withoutCloseStep) continue
}
if (/^\s*<\/step>\s*$/.test(line)) {
continue
}
if (isActionStep(line)) {
// Check if it's a complete step or partial (streaming)
// For now assume complete lines or handle partials if needed
// If valid step line, add to current buffer
currentSteps.push(line)
continue
}
// specific hack for streaming partially completed step
if (line.trim().startsWith('<step') && !line.trim().includes('</step>')) {
// It's a streaming step, possibly unfinished.
// We can show it as "Working..." or just text.
// Let's just treat it as a step for now?
currentSteps.push(line + '</step>') // Auto-close for display
continue
}
if (/^\s*<step[^>]*>\s*$/.test(line)) {
currentSteps.push(line + '</step>')
continue
}
// Not a step -> flush any pending steps
flushSteps()
if (line.startsWith('```') && !inCodeBlock) {
inCodeBlock = true
codeLang = line.slice(3).trim()
@ -267,12 +328,8 @@ function parseMarkdown(
// Empty lines
if (!line) {
// If we're inside a sequence of steps, just ignore the blank line to group them together
if (currentSteps.length > 0) continue
// Otherwise render as newline
parts.push('\n')
continue
parts.push('\n')
continue
}
parts.push(
@ -283,9 +340,6 @@ function parseMarkdown(
)
}
// Flush remaining steps at the end
flushSteps()
// Handle unclosed code block (streaming)
if (inCodeBlock && codeContent) {
const code = codeContent.trimEnd()
@ -449,6 +503,10 @@ function DesignJsonBlock({
if (Array.isArray(parsed)) return parsed.length
return 1
} catch {
// JSONL format: count lines that look like JSON objects
if (/"_parent"\s*:/.test(code)) {
return code.split('\n').filter(line => line.trim().startsWith('{')).length
}
return 0
}
}, [code])
@ -543,7 +601,16 @@ export default function ChatMessage({
const isUser = role === 'user'
// Strip raw tool-call XML that the model may emit (should never be visible)
const displayContent = isUser ? content : stripToolCallXml(content)
const isEmpty = !displayContent.trim()
const steps = useMemo(
() => (isUser ? [] : parseStepBlocks(displayContent, isStreaming)),
[isUser, displayContent, isStreaming],
)
const hasFlow = !isUser && steps.length > 0
const contentWithoutSteps = useMemo(
() => (isUser ? displayContent : stripStepBlocks(displayContent)),
[isUser, displayContent],
)
const isEmpty = !contentWithoutSteps.trim() && !hasFlow
// Don't render an empty non-streaming assistant message
// UNLESS we stripped something out (meaning the AI did something, but we hid it).
@ -581,9 +648,23 @@ export default function ChatMessage({
</span>
</div>
) : (
<div className="whitespace-pre-wrap">
{parseMarkdown(displayContent, onApplyDesign, isApplied, isStreaming && !isEmpty)}
</div>
<>
{hasFlow && (
<div className="mb-2">
<ActionSteps steps={steps} isStreaming={isStreaming} />
</div>
)}
{contentWithoutSteps.trim() ? (
<div className="whitespace-pre-wrap">
{parseMarkdown(
contentWithoutSteps,
onApplyDesign,
isApplied,
isStreaming && !!contentWithoutSteps.trim(),
)}
</div>
) : null}
</>
)}
</div>
)}

View file

@ -0,0 +1,102 @@
import { useCallback } from 'react'
import { nanoid } from 'nanoid'
import type { KitComponent, UIKit } from '@/types/uikit'
import type { PenNode } from '@/types/pen'
import { useDocumentStore } from '@/stores/document-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { findReusableNode, deepCloneNode, collectVariableRefs } from '@/uikit/kit-utils'
interface ComponentBrowserCardProps {
component: KitComponent
kit: UIKit
}
/** Recursively assign new IDs to a node tree so each insert is independent. */
function reassignIds(node: PenNode): PenNode {
const clone = { ...node, id: nanoid() }
if ('children' in clone && Array.isArray(clone.children)) {
clone.children = clone.children.map(reassignIds)
}
return clone as PenNode
}
export default function ComponentBrowserCard({ component, kit }: ComponentBrowserCardProps) {
const handleInsert = useCallback(() => {
const { addNode, document } = useDocumentStore.getState()
const { viewport, fabricCanvas } = useCanvasStore.getState()
const kitNode = findReusableNode(kit.document, component.id)
if (!kitNode) return
// Copy referenced variables from the kit document
if (kit.document.variables) {
const refs = collectVariableRefs(kitNode)
const { setVariable } = useDocumentStore.getState()
for (const ref of refs) {
const name = ref.startsWith('$') ? ref.slice(1) : ref
const varDef = kit.document.variables[name]
if (varDef && !document.variables?.[name]) {
setVariable(name, varDef)
}
}
}
// Deep clone with new IDs, remove reusable flag so it's standalone
const cloned = reassignIds(deepCloneNode(kitNode))
if ('reusable' in cloned) {
delete (cloned as unknown as Record<string, unknown>).reusable
}
// Place at viewport center
const canvasEl = fabricCanvas?.getElement()
const canvasW = canvasEl?.clientWidth ?? 800
const canvasH = canvasEl?.clientHeight ?? 600
const centerX = (-viewport.panX + canvasW / 2) / viewport.zoom
const centerY = (-viewport.panY + canvasH / 2) / viewport.zoom
cloned.x = centerX - component.width / 2
cloned.y = centerY - component.height / 2
cloned.name = component.name
addNode(null, cloned)
useCanvasStore.getState().setSelection([cloned.id], cloned.id)
}, [component, kit])
// Compute preview aspect ratio
const maxPreviewW = 120
const maxPreviewH = 64
const scale = Math.min(maxPreviewW / component.width, maxPreviewH / component.height, 1)
const previewW = Math.round(component.width * scale)
const previewH = Math.round(component.height * scale)
// Extract primary fill color from the kit node for preview
const kitNode = findReusableNode(kit.document, component.id)
let fillColor = '#E5E7EB'
if (kitNode && 'fill' in kitNode && Array.isArray(kitNode.fill) && kitNode.fill.length > 0) {
const f = kitNode.fill[0]
if (f.type === 'solid' && !f.color.startsWith('$')) {
fillColor = f.color
}
}
return (
<button
type="button"
onClick={handleInsert}
className="flex flex-col items-center gap-2 p-3 rounded-lg border border-border bg-card hover:bg-muted transition-colors cursor-pointer group"
>
<div className="flex items-center justify-center w-full h-16">
<div
className="rounded transition-transform group-hover:scale-105"
style={{
width: previewW,
height: previewH,
backgroundColor: fillColor,
}}
/>
</div>
<span className="text-xs text-foreground truncate w-full text-center">
{component.name}
</span>
</button>
)
}

View file

@ -0,0 +1,57 @@
import { useMemo } from 'react'
import type { UIKit, KitComponent, ComponentCategory } from '@/types/uikit'
import ComponentBrowserCard from './component-browser-card'
interface ComponentBrowserGridProps {
kits: UIKit[]
searchQuery: string
activeCategory: ComponentCategory | null
activeKitId: string | null
}
export default function ComponentBrowserGrid({
kits,
searchQuery,
activeCategory,
activeKitId,
}: ComponentBrowserGridProps) {
const filteredItems = useMemo(() => {
const items: { component: KitComponent; kit: UIKit }[] = []
const query = searchQuery.toLowerCase().trim()
for (const kit of kits) {
if (activeKitId && kit.id !== activeKitId) continue
for (const comp of kit.components) {
if (activeCategory && comp.category !== activeCategory) continue
if (query) {
const nameMatch = comp.name.toLowerCase().includes(query)
const tagMatch = comp.tags.some((t) => t.includes(query))
if (!nameMatch && !tagMatch) continue
}
items.push({ component: comp, kit })
}
}
return items
}, [kits, searchQuery, activeCategory, activeKitId])
if (filteredItems.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm py-8">
No components found
</div>
)
}
return (
<div className="grid grid-cols-3 gap-2 p-2">
{filteredItems.map(({ component, kit }) => (
<ComponentBrowserCard
key={`${kit.id}-${component.id}`}
component={component}
kit={kit}
/>
))}
</div>
)
}

View file

@ -0,0 +1,290 @@
import { useState, useRef, useCallback, useMemo } from 'react'
import { X, Search, Upload, Download, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUIKitStore } from '@/stores/uikit-store'
import { useDocumentStore } from '@/stores/document-store'
import { importKitFromFile, exportKit } from '@/uikit/kit-import-export'
import ComponentBrowserGrid from './component-browser-grid'
import type { ComponentCategory } from '@/types/uikit'
const MIN_WIDTH = 420
const MIN_HEIGHT = 300
const DEFAULT_WIDTH = 520
const DEFAULT_HEIGHT = 460
const CATEGORIES: { value: ComponentCategory | null; label: string }[] = [
{ value: null, label: 'All' },
{ value: 'buttons', label: 'Buttons' },
{ value: 'inputs', label: 'Inputs' },
{ value: 'cards', label: 'Cards' },
{ value: 'navigation', label: 'Nav' },
{ value: 'layout', label: 'Layout' },
{ value: 'feedback', label: 'Feedback' },
{ value: 'data-display', label: 'Data' },
{ value: 'other', label: 'Other' },
]
export default function ComponentBrowserPanel() {
const kits = useUIKitStore((s) => s.kits)
const searchQuery = useUIKitStore((s) => s.searchQuery)
const setSearchQuery = useUIKitStore((s) => s.setSearchQuery)
const activeCategory = useUIKitStore((s) => s.activeCategory)
const setActiveCategory = useUIKitStore((s) => s.setActiveCategory)
const activeKitId = useUIKitStore((s) => s.activeKitId)
const setActiveKitId = useUIKitStore((s) => s.setActiveKitId)
const toggleBrowser = useUIKitStore((s) => s.toggleBrowser)
const importKitAction = useUIKitStore((s) => s.importKit)
const removeKit = useUIKitStore((s) => s.removeKit)
const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH)
const [panelHeight, setPanelHeight] = useState(DEFAULT_HEIGHT)
const [confirmDeleteKitId, setConfirmDeleteKitId] = useState<string | null>(null)
const panelRef = useRef<HTMLDivElement>(null)
const resizeRef = useRef<{
edge: 'right' | 'bottom' | 'corner'
startX: number; startY: number; startW: number; startH: number
} | null>(null)
/* --- Resize --- */
const handleResizeStart = useCallback(
(edge: 'right' | 'bottom' | 'corner', e: React.PointerEvent) => {
e.preventDefault()
e.stopPropagation()
resizeRef.current = {
edge,
startX: e.clientX,
startY: e.clientY,
startW: panelWidth,
startH: panelHeight,
}
e.currentTarget.setPointerCapture(e.pointerId)
},
[panelWidth, panelHeight],
)
const handleResizeMove = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return
e.preventDefault()
const { edge, startX, startY, startW, startH } = resizeRef.current
const container = panelRef.current?.parentElement
const maxW = container ? container.clientWidth - 72 : 1400
const maxH = container ? container.clientHeight - 16 : 900
if (edge === 'right' || edge === 'corner')
setPanelWidth(Math.max(MIN_WIDTH, Math.min(maxW, startW + e.clientX - startX)))
if (edge === 'bottom' || edge === 'corner')
setPanelHeight(Math.max(MIN_HEIGHT, Math.min(maxH, startH + e.clientY - startY)))
}, [])
const handleResizeEnd = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return
resizeRef.current = null
e.currentTarget.releasePointerCapture(e.pointerId)
}, [])
/* --- Import --- */
const handleImport = useCallback(async () => {
const kit = await importKitFromFile()
if (kit) {
importKitAction(kit)
}
}, [importKitAction])
/* --- Export --- */
const handleExport = useCallback(async () => {
const doc = useDocumentStore.getState().document
await exportKit(doc, [], doc.name ?? 'My Kit')
}, [])
/* --- Delete imported kit --- */
const handleDeleteKit = useCallback(
(kitId: string) => {
removeKit(kitId)
setConfirmDeleteKitId(null)
},
[removeKit],
)
/* --- Imported kits for delete list --- */
const importedKits = useMemo(() => kits.filter((k) => !k.builtIn), [kits])
/* --- Visible categories (only show tabs that have components) --- */
const visibleCategories = useMemo(() => {
const categorySet = new Set<ComponentCategory>()
const targetKits = activeKitId ? kits.filter((k) => k.id === activeKitId) : kits
for (const kit of targetKits) {
for (const comp of kit.components) {
categorySet.add(comp.category)
}
}
return CATEGORIES.filter((c) => c.value === null || categorySet.has(c.value))
}, [kits, activeKitId])
return (
<div
ref={panelRef}
className="absolute left-14 top-2 z-20 flex flex-col select-none"
style={{ width: panelWidth, height: panelHeight }}
>
{/* Background */}
<div className="absolute inset-0 bg-card/95 backdrop-blur-sm border border-border rounded-2xl shadow-2xl" />
{/* Header */}
<div className="relative h-10 flex items-center justify-between px-3 border-b border-border shrink-0">
<span className="text-sm font-medium text-foreground">UIKit Browser</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleExport}
className="inline-flex items-center justify-center h-6 w-6 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Export kit"
>
<Download size={14} />
</button>
<button
type="button"
onClick={handleImport}
className="inline-flex items-center justify-center h-6 w-6 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Import kit"
>
<Upload size={14} />
</button>
<button
type="button"
onClick={toggleBrowser}
className="inline-flex items-center justify-center h-6 w-6 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Kit filter + Category tabs */}
<div className="relative shrink-0">
{/* Kit selector row */}
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border">
<span className="text-xs text-muted-foreground shrink-0">Kit:</span>
<select
value={activeKitId ?? ''}
onChange={(e) => setActiveKitId(e.target.value || null)}
className="text-xs bg-transparent border border-border rounded px-1.5 py-0.5 text-foreground outline-none min-w-0"
>
<option value="">All</option>
{kits.map((k) => (
<option key={k.id} value={k.id}>
{k.name}{k.builtIn ? '' : ' (imported)'}
</option>
))}
</select>
</div>
{/* Imported kits list — each with individual delete */}
{importedKits.length > 0 && (
<div className="flex flex-col gap-0 border-b border-border">
{importedKits.map((kit) => (
<div
key={kit.id}
className="flex items-center gap-2 px-3 py-1 hover:bg-muted/50 transition-colors"
>
<span className="text-xs text-foreground truncate flex-1">{kit.name}</span>
<span className="text-[10px] text-muted-foreground shrink-0">
{kit.components.length} components
</span>
{confirmDeleteKitId === kit.id ? (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => handleDeleteKit(kit.id)}
className="text-[10px] px-1.5 py-0.5 rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
>
Delete
</button>
<button
type="button"
onClick={() => setConfirmDeleteKitId(null)}
className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDeleteKitId(kit.id)}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors shrink-0"
title={`Delete ${kit.name}`}
>
<Trash2 size={12} />
</button>
)}
</div>
))}
</div>
)}
{/* Category pills */}
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border overflow-x-auto">
{visibleCategories.map((cat) => (
<button
key={cat.label}
type="button"
onClick={() => setActiveCategory(cat.value)}
className={cn(
'px-2.5 py-1 text-xs rounded-full whitespace-nowrap transition-colors',
activeCategory === cat.value
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground',
)}
>
{cat.label}
</button>
))}
</div>
</div>
{/* Search */}
<div className="relative shrink-0 px-3 py-2 border-b border-border">
<div className="flex items-center gap-2 bg-muted rounded-lg px-2.5 py-1.5">
<Search size={14} className="text-muted-foreground shrink-0" />
<input
type="text"
placeholder="Search components..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
</div>
</div>
{/* Grid */}
<div className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0">
<ComponentBrowserGrid
kits={kits}
searchQuery={searchQuery}
activeCategory={activeCategory}
activeKitId={activeKitId}
/>
</div>
{/* Resize handles */}
<div
className="absolute top-0 -right-1 w-2 h-full cursor-ew-resize"
onPointerDown={(e) => handleResizeStart('right', e)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
/>
<div
className="absolute -bottom-1 left-0 w-full h-2 cursor-ns-resize"
onPointerDown={(e) => handleResizeStart('bottom', e)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
/>
<div
className="absolute -bottom-1 -right-1 w-4 h-4 cursor-nwse-resize"
onPointerDown={(e) => handleResizeStart('corner', e)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
/>
</div>
)
}

View file

@ -0,0 +1,79 @@
import { useState } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { exportLayerToRaster, type RasterFormat } from '@/utils/export'
import SectionHeader from '@/components/shared/section-header'
import DropdownSelect from '@/components/shared/dropdown-select'
import { Button } from '@/components/ui/button'
const SCALE_OPTIONS = [
{ value: '1', label: '1x' },
{ value: '2', label: '2x' },
{ value: '3', label: '3x' },
]
const FORMAT_OPTIONS = [
{ value: 'png', label: 'PNG' },
{ value: 'jpeg', label: 'JPEG' },
{ value: 'webp', label: 'WEBP' },
]
interface ExportSectionProps {
nodeId: string
nodeName: string
}
export default function ExportSection({ nodeId, nodeName }: ExportSectionProps) {
const [scale, setScale] = useState('1')
const [format, setFormat] = useState('png')
const fabricCanvas = useCanvasStore((s) => s.fabricCanvas)
const handleExport = () => {
if (!fabricCanvas) return
// Collect all descendant IDs for this node
const { getFlatNodes, isDescendantOf } = useDocumentStore.getState()
const allNodes = getFlatNodes()
const descendantIds = new Set<string>()
for (const n of allNodes) {
if (n.id !== nodeId && isDescendantOf(n.id, nodeId)) {
descendantIds.add(n.id)
}
}
const ext = format === 'jpeg' ? 'jpg' : format
exportLayerToRaster(fabricCanvas, nodeId, descendantIds, {
format: format as RasterFormat,
multiplier: Number(scale),
filename: `${nodeName}.${ext}`,
})
}
return (
<div className="space-y-1.5">
<SectionHeader title="Export" />
<div className="flex gap-1.5">
<DropdownSelect
value={scale}
options={SCALE_OPTIONS}
onChange={setScale}
className="flex-1"
/>
<DropdownSelect
value={format}
options={FORMAT_OPTIONS}
onChange={setFormat}
className="flex-1"
/>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={handleExport}
>
Export layer
</Button>
</div>
)
}

View file

@ -5,6 +5,8 @@ import {
Group,
Lock,
EyeOff,
Component,
Unlink,
} from 'lucide-react'
interface LayerContextMenuProps {
@ -12,6 +14,9 @@ interface LayerContextMenuProps {
y: number
nodeId: string
canGroup: boolean
canCreateComponent: boolean
isReusable: boolean
isInstance: boolean
onAction: (action: string) => void
onClose: () => void
}
@ -20,6 +25,9 @@ const MENU_ITEMS = [
{ action: 'duplicate', label: 'Duplicate', icon: Copy },
{ action: 'delete', label: 'Delete', icon: Trash2 },
{ action: 'group', label: 'Group Selection', icon: Group, requireGroup: true },
{ action: 'make-component', label: 'Create Component', icon: Component, requireCreateComponent: true },
{ action: 'detach-component', label: 'Detach Component', icon: Unlink, requireReusable: true },
{ action: 'detach-component', label: 'Detach Instance', icon: Unlink, requireInstance: true },
{ action: 'lock', label: 'Toggle Lock', icon: Lock },
{ action: 'hide', label: 'Toggle Visibility', icon: EyeOff },
]
@ -28,6 +36,9 @@ export default function LayerContextMenu({
x,
y,
canGroup,
canCreateComponent,
isReusable,
isInstance,
onAction,
onClose,
}: LayerContextMenuProps) {
@ -57,7 +68,11 @@ export default function LayerContextMenu({
style={{ left: x, top: y }}
>
{MENU_ITEMS.filter(
(item) => !item.requireGroup || canGroup,
(item) =>
(!item.requireGroup || canGroup) &&
(!('requireCreateComponent' in item) || canCreateComponent) &&
(!('requireReusable' in item) || isReusable) &&
(!('requireInstance' in item) || isInstance),
).map((item) => (
<button
key={item.action}

View file

@ -16,6 +16,7 @@ import {
ImageIcon,
ChevronDown,
ChevronRight,
Diamond,
} from 'lucide-react'
import type { PenNodeType } from '@/types/pen'
@ -44,6 +45,8 @@ interface LayerItemProps {
locked: boolean
hasChildren: boolean
expanded: boolean
isReusable: boolean
isInstance: boolean
dropPosition: DropPosition
onSelect: (id: string) => void
onRename: (id: string, name: string) => void
@ -66,6 +69,8 @@ export default function LayerItem({
locked,
hasChildren,
expanded,
isReusable,
isInstance,
dropPosition,
onSelect,
onRename,
@ -80,7 +85,7 @@ export default function LayerItem({
const [isEditing, setIsEditing] = useState(false)
const [editName, setEditName] = useState(name)
const Icon = TYPE_ICONS[type] ?? Square
const Icon = isReusable || isInstance ? Diamond : (TYPE_ICONS[type] ?? Square)
const handleDoubleClick = () => {
setEditName(name)
@ -113,15 +118,23 @@ export default function LayerItem({
dropPosition === 'inside' ? 'ring-2 ring-inset ring-blue-500 bg-blue-500/10' : ''
return (
<div className="relative">
<div className="relative" data-layer-id={id}>
{dropPosition === 'above' && (
<div className="absolute top-0 left-2 right-2 h-0.5 bg-blue-500 rounded-full z-10" />
)}
<div
className={`group/layer flex items-center h-7 px-1 gap-1 cursor-pointer rounded text-xs transition-colors ${
selected
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:bg-accent/50'
? isReusable
? 'bg-purple-500/15 text-purple-400'
: isInstance
? 'bg-[#9281f7]/10 text-[#9281f7]'
: 'bg-primary/15 text-primary'
: isReusable
? 'text-purple-400 hover:bg-purple-500/10'
: isInstance
? 'text-[#9281f7] hover:bg-[#9281f7]/10'
: 'text-muted-foreground hover:bg-accent/50'
} ${!visible ? 'opacity-40' : ''} ${dropInsideHighlight}`}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
onClick={() => onSelect(id)}
@ -146,7 +159,7 @@ export default function LayerItem({
<span className="shrink-0 w-3" />
)}
<Icon size={12} className="shrink-0 opacity-60" />
<Icon size={12} className={`shrink-0 ${isReusable ? 'text-purple-400' : isInstance ? 'text-[#9281f7]' : 'opacity-60'}`} />
{isEditing ? (
<input

View file

@ -1,6 +1,7 @@
import { useState, useRef, useCallback } from 'react'
import { useDocumentStore } from '@/stores/document-store'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useDocumentStore, findNodeInTree } from '@/stores/document-store'
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 LayerItem from './layer-item'
@ -15,6 +16,28 @@ interface DragState {
dropPosition: DropPosition
}
function isNodeReusable(node: PenNode, parentReusable: boolean): boolean {
if (parentReusable) return true
return 'reusable' in node && node.reusable === true
}
/** Get effective children for a node, resolving RefNode instances. */
function getEffectiveChildren(
node: PenNode,
allChildren: PenNode[],
): PenNode[] | null {
if (node.type === 'ref') {
const component = findNodeInTree(allChildren, node.ref)
if (component && 'children' in component && component.children?.length) {
return component.children
}
return null
}
return 'children' in node && node.children && node.children.length > 0
? node.children
: null
}
function renderLayerTree(
nodes: PenNode[],
depth: number,
@ -33,14 +56,16 @@ function renderLayerTree(
dragOverId: string | null,
dropPosition: DropPosition,
collapsedIds: Set<string>,
allChildren: PenNode[],
parentReusable = false,
parentIsInstance = false,
) {
return [...nodes].reverse().map((node) => {
const nodeChildren =
'children' in node && node.children && node.children.length > 0
? node.children
: null
const nodeChildren = getEffectiveChildren(node, allChildren)
const isExpanded = !collapsedIds.has(node.id)
const isDropTarget = dragOverId === node.id
const isInstance = node.type === 'ref' || parentIsInstance
const reusable = isNodeReusable(node, parentReusable)
return (
<div key={node.id}>
@ -54,6 +79,8 @@ function renderLayerTree(
locked={node.locked === true}
hasChildren={nodeChildren !== null}
expanded={isExpanded}
isReusable={reusable}
isInstance={isInstance}
dropPosition={isDropTarget ? dropPosition : null}
{...handlers}
/>
@ -67,12 +94,29 @@ function renderLayerTree(
dragOverId,
dropPosition,
collapsedIds,
allChildren,
reusable,
isInstance,
)}
</div>
)
})
}
function collectCollapsibleNodeIds(
nodes: PenNode[],
allChildren: PenNode[],
result: Set<string> = new Set(),
): Set<string> {
for (const node of nodes) {
const nodeChildren = getEffectiveChildren(node, allChildren)
if (!nodeChildren) continue
result.add(node.id)
collectCollapsibleNodeIds(nodeChildren, allChildren, result)
}
return result
}
export default function LayerPanel() {
const children = useDocumentStore((s) => s.document.children)
const updateNode = useDocumentStore((s) => s.updateNode)
@ -102,7 +146,65 @@ export default function LayerPanel() {
})
const [dragOverId, setDragOverId] = useState<string | null>(null)
const [dropPosition, setDropPosition] = useState<DropPosition>(null)
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set())
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(
() => collectCollapsibleNodeIds(children, children),
)
const knownCollapsibleIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
const currentCollapsibleIds = collectCollapsibleNodeIds(children, children)
const known = knownCollapsibleIdsRef.current
setCollapsedIds((prev) => {
const next = new Set<string>()
for (const id of currentCollapsibleIds) {
const isNewNode = !known.has(id)
if (isNewNode || prev.has(id)) {
next.add(id)
}
}
return next
})
knownCollapsibleIdsRef.current = currentCollapsibleIds
}, [children])
const scrollContainerRef = useRef<HTMLDivElement>(null)
// Auto-expand ancestors when selection changes (e.g. child selected on canvas)
useEffect(() => {
if (selectedIds.length === 0) return
const ancestorIds = new Set<string>()
for (const id of selectedIds) {
let current = getParentOf(id)
while (current) {
ancestorIds.add(current.id)
current = getParentOf(current.id)
}
}
if (ancestorIds.size === 0) return
setCollapsedIds((prev) => {
let changed = false
for (const aid of ancestorIds) {
if (prev.has(aid)) { changed = true; break }
}
if (!changed) return prev
const next = new Set(prev)
for (const aid of ancestorIds) next.delete(aid)
return next
})
// Scroll the selected item into view after DOM updates
requestAnimationFrame(() => {
const container = scrollContainerRef.current
if (!container) return
const el = container.querySelector(`[data-layer-id="${selectedIds[0]}"]`)
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}, [selectedIds, getParentOf])
const handleToggleExpand = useCallback((id: string) => {
setCollapsedIds((prev) => {
@ -125,6 +227,7 @@ export default function LayerPanel() {
(o) => (o as FabricObjectWithPenId).penNodeId === id,
)
if (target) {
setSkipNextDepthResolve()
fabricCanvas.setActiveObject(target)
fabricCanvas.requestRenderAll()
}
@ -214,6 +317,9 @@ export default function LayerPanel() {
setDropPosition(null)
}, [children, getParentOf, moveNode])
const makeReusable = useDocumentStore((s) => s.makeReusable)
const detachComponent = useDocumentStore((s) => s.detachComponent)
const handleContextAction = useCallback(
(action: string) => {
if (!contextMenu) return
@ -239,6 +345,12 @@ export default function LayerPanel() {
case 'hide':
toggleVisibility(nodeId)
break
case 'make-component':
makeReusable(nodeId)
break
case 'detach-component':
detachComponent(nodeId)
break
}
setContextMenu(null)
},
@ -251,6 +363,8 @@ export default function LayerPanel() {
toggleLock,
toggleVisibility,
setSelection,
makeReusable,
detachComponent,
],
)
@ -273,7 +387,7 @@ export default function LayerPanel() {
Layers
</span>
</div>
<div className="flex-1 overflow-y-auto py-1 px-1">
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto py-1 px-1">
{children.length === 0 ? (
<p className="text-xs text-muted-foreground text-center mt-4 px-2">
No layers yet. Use the toolbar to draw shapes.
@ -287,20 +401,34 @@ export default function LayerPanel() {
dragOverId,
dropPosition,
collapsedIds,
children,
)
)}
</div>
{contextMenu && (
<LayerContextMenu
x={contextMenu.x}
y={contextMenu.y}
nodeId={contextMenu.nodeId}
canGroup={selectedIds.length >= 2}
onAction={handleContextAction}
onClose={() => setContextMenu(null)}
/>
)}
{contextMenu && (() => {
const contextNode = getNodeById(contextMenu.nodeId)
const isContainer = contextNode
? contextNode.type === 'frame' || contextNode.type === 'group' || contextNode.type === 'rectangle'
: false
const nodeIsReusable = contextNode
? 'reusable' in contextNode && contextNode.reusable === true
: false
const nodeIsInstance = contextNode?.type === 'ref'
return (
<LayerContextMenu
x={contextMenu.x}
y={contextMenu.y}
nodeId={contextMenu.nodeId}
canGroup={selectedIds.length >= 2}
canCreateComponent={isContainer && !nodeIsReusable}
isReusable={nodeIsReusable}
isInstance={nodeIsInstance}
onAction={handleContextAction}
onClose={() => setContextMenu(null)}
/>
)
})()}
</div>
)
}

View file

@ -1,19 +1,13 @@
import { useState, useRef, useEffect } from 'react'
import NumberInput from '@/components/shared/number-input'
import DropdownSelect from '@/components/shared/dropdown-select'
import VariablePicker from '@/components/shared/variable-picker'
import { isVariableRef } from '@/variables/resolve-variables'
import type { PenNode, ContainerProps } from '@/types/pen'
import type { PenNode, ContainerProps, SizingBehavior } from '@/types/pen'
import { cn } from '@/lib/utils'
import {
Rows3,
Columns3,
Rows3,
LayoutGrid,
AlignStartHorizontal,
AlignCenterHorizontal,
AlignEndHorizontal,
AlignStartVertical,
AlignCenterVertical,
AlignEndVertical,
Settings,
Check,
} from 'lucide-react'
interface LayoutSectionProps {
@ -21,19 +15,34 @@ interface LayoutSectionProps {
onUpdate: (updates: Partial<PenNode>) => void
}
const JUSTIFY_OPTIONS = [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
{ value: 'space_between', label: 'Between' },
{ value: 'space_around', label: 'Around' },
]
const POSITIONS = ['start', 'center', 'end'] as const
const ALIGN_OPTIONS = [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
]
type GapMode = 'numeric' | 'space_between' | 'space_around'
type PaddingMode = 'single' | 'axis' | 'individual'
// ---------------------------------------------------------------------------
// Padding Icons (small SVG indicators for V/H padding)
// ---------------------------------------------------------------------------
const PadVIcon = (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor">
<rect x="2.5" y="3.5" width="7" height="5" strokeWidth="1.2" rx="0.5" />
<line x1="4" y1="1" x2="8" y2="1" strokeWidth="1.4" strokeLinecap="round" />
<line x1="4" y1="11" x2="8" y2="11" strokeWidth="1.4" strokeLinecap="round" />
</svg>
)
const PadHIcon = (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor">
<rect x="2.5" y="2.5" width="7" height="7" strokeWidth="1.2" rx="0.5" />
<line x1="1" y1="4" x2="1" y2="8" strokeWidth="1.4" strokeLinecap="round" />
<line x1="11" y1="4" x2="11" y2="8" strokeWidth="1.4" strokeLinecap="round" />
</svg>
)
// ---------------------------------------------------------------------------
// ToggleButton
// ---------------------------------------------------------------------------
function ToggleButton({
active,
@ -52,7 +61,7 @@ function ToggleButton({
title={title}
onClick={onClick}
className={cn(
'h-6 w-6 flex items-center justify-center rounded transition-colors',
'h-7 w-7 flex items-center justify-center rounded transition-colors',
active
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-accent',
@ -63,140 +72,701 @@ function ToggleButton({
)
}
export default function LayoutSection({ node, onUpdate }: LayoutSectionProps) {
const layout = node.layout ?? 'none'
const rawGap = node.gap
const rawPadding = node.padding
const gapIsBound = typeof rawGap === 'string' && isVariableRef(rawGap)
const paddingIsBound = typeof rawPadding === 'string' && isVariableRef(rawPadding)
const gap = typeof rawGap === 'number' ? rawGap : 0
const padding = typeof rawPadding === 'number'
? rawPadding
: Array.isArray(rawPadding)
? rawPadding[0]
: 0
const justifyContent = node.justifyContent ?? 'start'
const alignItems = node.alignItems ?? 'start'
const hasLayout = layout !== 'none'
// ---------------------------------------------------------------------------
// RadioCircle
// ---------------------------------------------------------------------------
function RadioCircle({
selected,
onClick,
}: {
selected: boolean
onClick?: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'w-[14px] h-[14px] rounded-full border-[1.5px] flex items-center justify-center shrink-0 transition-colors',
selected ? 'border-primary' : 'border-muted-foreground/40',
)}
>
{selected && <div className="w-2 h-2 rounded-full bg-primary" />}
</button>
)
}
// ---------------------------------------------------------------------------
// AlignmentGrid — 3×3 interactive alignment picker
// ---------------------------------------------------------------------------
function AlignmentGrid({
layout,
justifyContent,
alignItems,
isSpaceMode,
onUpdate,
}: {
layout: 'none' | 'vertical' | 'horizontal'
justifyContent: string
alignItems: string
isSpaceMode: boolean
onUpdate: (updates: Partial<PenNode>) => void
}) {
const isFreedom = layout === 'none'
const isVertical = layout === 'vertical'
return (
<div className="space-y-1.5">
<span className="text-[10px] font-medium text-muted-foreground tracking-wider">
Layout
</span>
<div className="grid grid-cols-3 gap-[3px] p-2 bg-secondary rounded">
{[0, 1, 2].map((row) =>
[0, 1, 2].map((col) => {
const rowPos = POSITIONS[row]
const colPos = POSITIONS[col]
const cellJustify = isVertical ? rowPos : colPos
const cellAlign = isVertical ? colPos : rowPos
const isActive =
!isFreedom &&
!isSpaceMode &&
justifyContent === cellJustify &&
alignItems === cellAlign
const cellCrossPos = isVertical ? colPos : rowPos
const isOnActiveCross =
isSpaceMode && cellCrossPos === alignItems
{/* Layout direction */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground w-8 shrink-0">Dir</span>
<div className="flex gap-0.5">
<ToggleButton
active={layout === 'none'}
onClick={() => onUpdate({ layout: 'none' } as Partial<PenNode>)}
title="No layout"
>
<LayoutGrid className="w-3 h-3" />
</ToggleButton>
<ToggleButton
active={layout === 'vertical'}
onClick={() => onUpdate({ layout: 'vertical' } as Partial<PenNode>)}
title="Vertical layout"
>
<Rows3 className="w-3 h-3" />
</ToggleButton>
<ToggleButton
active={layout === 'horizontal'}
onClick={() => onUpdate({ layout: 'horizontal' } as Partial<PenNode>)}
title="Horizontal layout"
>
<Columns3 className="w-3 h-3" />
</ToggleButton>
</div>
</div>
{hasLayout && (
<>
{/* Gap & Padding */}
<div className="space-y-1">
<div className="flex items-center gap-1">
<div className="flex-1">
{gapIsBound ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{String(rawGap)}
</div>
) : (
<NumberInput
label="Gap"
value={gap}
onChange={(v) => onUpdate({ gap: v } as Partial<PenNode>)}
min={0}
/>
)}
</div>
<VariablePicker
type="number"
currentValue={gapIsBound ? String(rawGap) : undefined}
onBind={(ref) => onUpdate({ gap: ref as unknown as number } as Partial<PenNode>)}
onUnbind={(val) => onUpdate({ gap: Number(val) } as Partial<PenNode>)}
/>
</div>
<div className="flex items-center gap-1">
<div className="flex-1">
{paddingIsBound ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{String(rawPadding)}
</div>
) : (
<NumberInput
label="Pad"
value={padding}
onChange={(v) => onUpdate({ padding: v } as Partial<PenNode>)}
min={0}
/>
)}
</div>
<VariablePicker
type="number"
currentValue={paddingIsBound ? String(rawPadding) : undefined}
onBind={(ref) => onUpdate({ padding: ref as unknown as number } as Partial<PenNode>)}
onUnbind={(val) => onUpdate({ padding: Number(val) } as Partial<PenNode>)}
/>
</div>
</div>
{/* Justify Content */}
<DropdownSelect
label="Justify"
value={justifyContent}
options={JUSTIFY_OPTIONS}
onChange={(v) =>
onUpdate({ justifyContent: v } as Partial<PenNode>)
}
/>
{/* Align Items */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground w-8 shrink-0">Align</span>
<div className="flex gap-0.5">
{ALIGN_OPTIONS.map((opt) => {
const icons = layout === 'horizontal'
? { start: AlignStartVertical, center: AlignCenterVertical, end: AlignEndVertical }
: { start: AlignStartHorizontal, center: AlignCenterHorizontal, end: AlignEndHorizontal }
const Icon = icons[opt.value as keyof typeof icons]
return (
<ToggleButton
key={opt.value}
active={alignItems === opt.value}
onClick={() => onUpdate({ alignItems: opt.value } as Partial<PenNode>)}
title={`Align ${opt.label}`}
>
<Icon className="w-3 h-3" />
</ToggleButton>
)
})}
</div>
</div>
</>
return (
<button
key={`${row}-${col}`}
type="button"
disabled={isFreedom}
className={cn(
'w-7 h-5 rounded-[2px] flex items-center justify-center transition-colors',
isFreedom && 'cursor-default',
!isFreedom && 'cursor-pointer hover:bg-accent/50',
)}
onClick={() => {
if (isFreedom) return
if (isSpaceMode) {
onUpdate({
alignItems: cellAlign,
} as Partial<PenNode>)
} else {
onUpdate({
justifyContent: cellJustify,
alignItems: cellAlign,
} as Partial<PenNode>)
}
}}
>
{isFreedom ? (
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground/30" />
) : isSpaceMode && isOnActiveCross ? (
<div
className={cn(
'rounded-[1px] bg-primary',
isVertical
? 'w-[10px] h-[2px]'
: 'w-[2px] h-[10px]',
)}
/>
) : isActive ? (
<div className="w-2.5 h-2.5 rounded-[2px] bg-primary" />
) : (
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground/40" />
)}
</button>
)
}),
)}
</div>
)
}
// ---------------------------------------------------------------------------
// GapSection — Radio: Numeric / Space Between / Space Around
// ---------------------------------------------------------------------------
function GapSection({
gap,
gapMode,
onGapModeChange,
onUpdate,
}: {
gap: number
gapMode: GapMode
onGapModeChange: (mode: GapMode) => void
onUpdate: (updates: Partial<PenNode>) => void
}) {
return (
<div className="space-y-1.5">
<div
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => onGapModeChange('numeric')}
>
<RadioCircle selected={gapMode === 'numeric'} />
<div
className="flex-1"
onClick={(e) => e.stopPropagation()}
>
<NumberInput
value={gap}
onChange={(v) =>
onUpdate({ gap: v } as Partial<PenNode>)
}
min={0}
/>
</div>
</div>
<div
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => onGapModeChange('space_between')}
>
<RadioCircle selected={gapMode === 'space_between'} />
<span className="text-[10px] text-muted-foreground select-none">
Space Between
</span>
</div>
<div
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => onGapModeChange('space_around')}
>
<RadioCircle selected={gapMode === 'space_around'} />
<span className="text-[10px] text-muted-foreground select-none">
Space Around
</span>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// PaddingSection — Uniform / V-H / T-R-B-L with gear popover
// ---------------------------------------------------------------------------
function parsePaddingValues(
padding:
| number
| [number, number]
| [number, number, number, number]
| string
| undefined,
): { mode: PaddingMode; values: [number, number, number, number] } {
if (typeof padding === 'string' || padding === undefined) {
return { mode: 'single', values: [0, 0, 0, 0] }
}
if (typeof padding === 'number') {
return {
mode: 'single',
values: [padding, padding, padding, padding],
}
}
if (padding.length === 2) {
return {
mode: 'axis',
values: [padding[0], padding[1], padding[0], padding[1]],
}
}
if (padding[0] === padding[2] && padding[1] === padding[3]) {
return {
mode: 'axis',
values: [padding[0], padding[1], padding[2], padding[3]],
}
}
return {
mode: 'individual',
values: [padding[0], padding[1], padding[2], padding[3]],
}
}
function PaddingSection({
padding,
onUpdate,
}: {
padding:
| number
| [number, number]
| [number, number, number, number]
| string
| undefined
onUpdate: (updates: Partial<PenNode>) => void
}) {
const parsed = parsePaddingValues(padding)
const [mode, setMode] = useState<PaddingMode>(parsed.mode)
const [popoverOpen, setPopoverOpen] = useState(false)
const popoverRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setMode(parsePaddingValues(padding).mode)
}, [padding])
useEffect(() => {
if (!popoverOpen) return
const handler = (e: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node)
) {
setPopoverOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [popoverOpen])
const handleModeChange = (newMode: PaddingMode) => {
setMode(newMode)
setPopoverOpen(false)
const vals = parsed.values
switch (newMode) {
case 'single':
onUpdate({ padding: vals[0] } as Partial<PenNode>)
break
case 'axis':
onUpdate({
padding: [vals[0], vals[1]],
} as Partial<PenNode>)
break
case 'individual':
onUpdate({
padding: [vals[0], vals[1], vals[2], vals[3]],
} as Partial<PenNode>)
break
}
}
const MODES = [
{ value: 'single' as const, label: 'One value for all sides' },
{ value: 'axis' as const, label: 'Horizontal/Vertical' },
{ value: 'individual' as const, label: 'Top/Right/Bottom/Left' },
]
return (
<div className="space-y-1.5">
{/* Label row: "Padding" left, gear right */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
Padding
</span>
<div ref={popoverRef} className="relative">
<button
type="button"
title="Padding mode"
onClick={() => setPopoverOpen(!popoverOpen)}
className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Settings className="w-3.5 h-3.5" />
</button>
{popoverOpen && (
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-md p-3 min-w-[190px]">
<div className="text-[12px] font-medium mb-3 text-foreground">Padding Values</div>
<div className="space-y-2.5">
{MODES.map((opt) => (
<div
key={opt.value}
className="flex items-center gap-2 cursor-pointer"
onClick={() => handleModeChange(opt.value)}
>
<RadioCircle selected={mode === opt.value} />
<span className="text-[12px] text-foreground leading-none">{opt.label}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Padding inputs */}
{mode === 'single' && (
<NumberInput
icon={PadVIcon}
value={parsed.values[0]}
onChange={(v) =>
onUpdate({ padding: v } as Partial<PenNode>)
}
min={0}
/>
)}
{mode === 'axis' && (
<div className="grid grid-cols-2 gap-1">
<NumberInput
icon={PadHIcon}
value={parsed.values[1]}
onChange={(v) =>
onUpdate({
padding: [parsed.values[0], v],
} as Partial<PenNode>)
}
min={0}
/>
<NumberInput
icon={PadVIcon}
value={parsed.values[0]}
onChange={(v) =>
onUpdate({
padding: [v, parsed.values[1]],
} as Partial<PenNode>)
}
min={0}
/>
</div>
)}
{mode === 'individual' && (
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="T"
value={parsed.values[0]}
onChange={(v) =>
onUpdate({
padding: [
v,
parsed.values[1],
parsed.values[2],
parsed.values[3],
],
} as Partial<PenNode>)
}
min={0}
/>
<NumberInput
label="R"
value={parsed.values[1]}
onChange={(v) =>
onUpdate({
padding: [
parsed.values[0],
v,
parsed.values[2],
parsed.values[3],
],
} as Partial<PenNode>)
}
min={0}
/>
<NumberInput
label="B"
value={parsed.values[2]}
onChange={(v) =>
onUpdate({
padding: [
parsed.values[0],
parsed.values[1],
v,
parsed.values[3],
],
} as Partial<PenNode>)
}
min={0}
/>
<NumberInput
label="L"
value={parsed.values[3]}
onChange={(v) =>
onUpdate({
padding: [
parsed.values[0],
parsed.values[1],
parsed.values[2],
v,
],
} as Partial<PenNode>)
}
min={0}
/>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// SizingCheckbox
// ---------------------------------------------------------------------------
function SizingCheckbox({
label,
checked,
onChange,
}: {
label: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<label className="flex items-center gap-1.5 cursor-pointer group">
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
'w-4 h-4 rounded-[3px] border-[1.5px] flex items-center justify-center transition-colors shrink-0',
checked
? 'bg-primary border-primary'
: 'border-muted-foreground/40 group-hover:border-muted-foreground',
)}
>
{checked && (
<Check className="w-3 h-3 text-primary-foreground" strokeWidth={3} />
)}
</button>
<span className="text-[11px] text-muted-foreground select-none">
{label}
</span>
</label>
)
}
// ---------------------------------------------------------------------------
// SizingCheckboxes — Fill / Hug per axis + Clip Content
// ---------------------------------------------------------------------------
function extractNumericSize(
value: SizingBehavior | undefined,
): number {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const match = value.match(/\((\d+)\)/)
if (match) return parseInt(match[1], 10)
}
return 100
}
function SizingCheckboxes({
node,
onUpdate,
}: {
node: PenNode & ContainerProps
onUpdate: (updates: Partial<PenNode>) => void
}) {
const widthStr =
typeof node.width === 'string' ? node.width : ''
const heightStr =
typeof node.height === 'string' ? node.height : ''
const fillWidth = widthStr.startsWith('fill_container')
const fillHeight = heightStr.startsWith('fill_container')
const hugWidth = widthStr.startsWith('fit_content')
const hugHeight = heightStr.startsWith('fit_content')
const clipContent = node.clipContent === true
const fallbackW = extractNumericSize(node.width)
const fallbackH = extractNumericSize(node.height)
return (
<div className="space-y-1.5">
<div className="grid grid-cols-2 gap-y-1.5">
<SizingCheckbox
label="Fill Width"
checked={fillWidth}
onChange={(v) =>
onUpdate({
width: v ? 'fill_container' : fallbackW,
} as Partial<PenNode>)
}
/>
<SizingCheckbox
label="Fill Height"
checked={fillHeight}
onChange={(v) =>
onUpdate({
height: v ? 'fill_container' : fallbackH,
} as Partial<PenNode>)
}
/>
<SizingCheckbox
label="Hug Width"
checked={hugWidth}
onChange={(v) =>
onUpdate({
width: v ? 'fit_content' : fallbackW,
} as Partial<PenNode>)
}
/>
<SizingCheckbox
label="Hug Height"
checked={hugHeight}
onChange={(v) =>
onUpdate({
height: v ? 'fit_content' : fallbackH,
} as Partial<PenNode>)
}
/>
</div>
<SizingCheckbox
label="Clip Content"
checked={clipContent}
onChange={(v) =>
onUpdate({ clipContent: v } as Partial<PenNode>)
}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Main LayoutSection
// ---------------------------------------------------------------------------
export default function LayoutSection({
node,
onUpdate,
}: LayoutSectionProps) {
const layout = node.layout ?? 'none'
const hasLayout = layout !== 'none'
const justifyContent = node.justifyContent ?? 'start'
const alignItems = node.alignItems ?? 'start'
const rawGap = node.gap
const gap = typeof rawGap === 'number' ? rawGap : 0
const gapMode: GapMode =
justifyContent === 'space_between'
? 'space_between'
: justifyContent === 'space_around'
? 'space_around'
: 'numeric'
const isSpaceMode =
gapMode === 'space_between' || gapMode === 'space_around'
const handleGapModeChange = (mode: GapMode) => {
switch (mode) {
case 'numeric':
onUpdate({
justifyContent: 'start',
} as Partial<PenNode>)
break
case 'space_between':
onUpdate({
justifyContent: 'space_between',
} as Partial<PenNode>)
break
case 'space_around':
onUpdate({
justifyContent: 'space_around',
} as Partial<PenNode>)
break
}
}
const width =
typeof node.width === 'number' ? node.width : undefined
const height =
typeof node.height === 'number' ? node.height : undefined
return (
<div className="space-y-3">
{/* Header */}
<span className="text-[11px] font-medium text-foreground">
Flex Layout
</span>
{/* Direction row — no label, just buttons */}
<div className="flex jusfity-between gap-0.5">
<ToggleButton
active={layout === 'none'}
onClick={() =>
onUpdate({ layout: 'none' } as Partial<PenNode>)
}
title="Freedom (no layout)"
>
<LayoutGrid className="w-3.5 h-3.5" />
</ToggleButton>
<ToggleButton
active={layout === 'vertical'}
onClick={() =>
onUpdate({ layout: 'vertical' } as Partial<PenNode>)
}
title="Vertical layout"
>
<Rows3 className="w-3.5 h-3.5" />
</ToggleButton>
<ToggleButton
active={layout === 'horizontal'}
onClick={() =>
onUpdate({
layout: 'horizontal',
} as Partial<PenNode>)
}
title="Horizontal layout"
>
<Columns3 className="w-3.5 h-3.5" />
</ToggleButton>
</div>
{/* Alignment + Gap side by side */}
{hasLayout && (
<>
<div className="flex gap-2">
{/* Left: Alignment */}
<div className="w-[160px]">
<span className="text-[10px] w-full text-muted-foreground mb-1.5 block">
Alignment
</span>
<AlignmentGrid
layout={layout}
justifyContent={justifyContent}
alignItems={alignItems}
isSpaceMode={isSpaceMode}
onUpdate={onUpdate}
/>
</div>
{/* Right: Gap */}
<div>
<span className="text-[10px] text-muted-foreground mb-1.5 block">
Gap
</span>
<GapSection
gap={gap}
gapMode={gapMode}
onGapModeChange={handleGapModeChange}
onUpdate={onUpdate}
/>
</div>
</div>
{/* Padding */}
<PaddingSection
padding={node.padding}
onUpdate={onUpdate}
/>
</>
)}
{/* Dimensions */}
{(width !== undefined || height !== undefined) && (
<div>
<span className="text-[10px] text-muted-foreground mb-1.5 block">
Dimensions
</span>
<div className="grid grid-cols-2 gap-1">
{width !== undefined && (
<NumberInput
label="W"
value={Math.round(width)}
onChange={(v) =>
onUpdate({ width: v } as Partial<PenNode>)
}
min={1}
/>
)}
{height !== undefined && (
<NumberInput
label="H"
value={Math.round(height)}
onChange={(v) =>
onUpdate({ height: v } as Partial<PenNode>)
}
min={1}
/>
)}
</div>
</div>
)}
{/* Sizing checkboxes */}
<SizingCheckboxes node={node} onUpdate={onUpdate} />
</div>
)
}

View file

@ -1,61 +1,262 @@
import { useState, useEffect } from 'react'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { Separator } from '@/components/ui/separator'
import type { PenNode, ContainerProps } from '@/types/pen'
import type { PenNode, ContainerProps, RefNode } from '@/types/pen'
import { Component, Diamond, ArrowUpRight, Unlink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
import SizeSection from './size-section'
import LayoutSection from './layout-section'
import FillSection from './fill-section'
import StrokeSection from './stroke-section'
import AppearanceSection from './appearance-section'
import TextSection from './text-section'
import TextLayoutSection from './text-layout-section'
import EffectsSection from './effects-section'
import ExportSection from './export-section'
/** Properties stored directly on the RefNode (instance-level), not as overrides. */
const INSTANCE_DIRECT_PROPS = new Set([
'x', 'y', 'width', 'height', 'name', 'visible', 'locked', 'rotation', 'opacity', 'flipX', 'flipY', 'enabled', 'theme',
])
export default function PropertyPanel() {
const activeId = useCanvasStore((s) => s.selection.activeId)
const setSelection = useCanvasStore((s) => s.setSelection)
const fabricCanvas = useCanvasStore((s) => s.fabricCanvas)
const children = useDocumentStore((s) => s.document.children)
const getNodeById = useDocumentStore((s) => s.getNodeById)
const updateNode = useDocumentStore((s) => s.updateNode)
const makeReusable = useDocumentStore((s) => s.makeReusable)
const detachComponent = useDocumentStore((s) => s.detachComponent)
// Subscribe to `children` so we re-render when nodes change
void children
const node = activeId ? getNodeById(activeId) : undefined
const handleUpdate = (updates: Partial<PenNode>) => {
if (activeId) {
updateNode(activeId, updates)
}
}
if (!node) {
return null
}
const hasLayout =
node.type === 'frame' || node.type === 'group' || node.type === 'rectangle'
const hasFill =
node.type !== 'line' && node.type !== 'ref'
const hasStroke = node.type !== 'ref'
const nodeIsReusable = 'reusable' in node && node.reusable === true
const nodeIsInstance = node.type === 'ref'
// For RefNodes, resolve the referenced component to get visual properties.
// The display node merges: component base → instance overrides → RefNode position/meta.
let displayNode = node
if (nodeIsInstance) {
const refNode = node as RefNode
const component = getNodeById(refNode.ref)
if (component) {
const topOverrides = refNode.descendants?.[refNode.ref] ?? {}
const merged: Record<string, unknown> = { ...component, ...topOverrides }
// Apply RefNode's own explicitly defined properties
for (const [key, val] of Object.entries(node)) {
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
if (val !== undefined) {
merged[key] = val
}
}
// Use component's type (frame/rect/etc.) not 'ref'
merged.type = component.type
if (!merged.name) merged.name = component.name
displayNode = merged as unknown as PenNode
}
}
const handleUpdate = (updates: Partial<PenNode>) => {
if (!activeId) return
if (nodeIsInstance && node.type === 'ref') {
const refNode = node as RefNode
const refNodeUpdate: Record<string, unknown> = {}
const overrideProps: Record<string, unknown> = {}
for (const [key, value] of Object.entries(updates)) {
if (INSTANCE_DIRECT_PROPS.has(key)) {
refNodeUpdate[key] = value
} else {
overrideProps[key] = value
}
}
// Store visual properties as overrides in descendants[ref]
if (Object.keys(overrideProps).length > 0) {
const currentDescendants = refNode.descendants ?? {}
const existing = currentDescendants[refNode.ref] ?? {}
refNodeUpdate.descendants = {
...currentDescendants,
[refNode.ref]: { ...existing, ...overrideProps },
}
}
if (Object.keys(refNodeUpdate).length > 0) {
updateNode(activeId, refNodeUpdate as Partial<PenNode>)
}
} else {
updateNode(activeId, updates)
}
}
const handleGoToComponent = () => {
if (!nodeIsInstance || node.type !== 'ref') return
const refId = (node as RefNode).ref
setSelection([refId], refId)
if (fabricCanvas) {
const objects = fabricCanvas.getObjects() as FabricObjectWithPenId[]
const target = objects.find((o) => (o as FabricObjectWithPenId).penNodeId === refId)
if (target) {
fabricCanvas.setActiveObject(target)
fabricCanvas.requestRenderAll()
}
}
}
const isContainer =
displayNode.type === 'frame' || displayNode.type === 'group' || displayNode.type === 'rectangle'
const hasLayout = isContainer
const hasFill = displayNode.type !== 'line'
const hasStroke = true
const hasCornerRadius =
node.type === 'rectangle' || node.type === 'frame'
const hasEffects = node.type !== 'ref'
const isText = node.type === 'text'
displayNode.type === 'rectangle' || displayNode.type === 'frame'
const hasEffects = true
const isText = displayNode.type === 'text'
const [isEditingName, setIsEditingName] = useState(false)
const [editName, setEditName] = useState('')
// Reset editing state when selection changes
useEffect(() => {
setIsEditingName(false)
}, [activeId])
const handleNameClick = () => {
setEditName(node.name ?? node.type)
setIsEditingName(true)
}
const handleNameBlur = () => {
setIsEditingName(false)
const trimmed = editName.trim()
if (trimmed && trimmed !== (node.name ?? node.type)) {
updateNode(activeId!, { name: trimmed } as Partial<PenNode>)
}
}
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
if (e.key === 'Escape') {
setIsEditingName(false)
setEditName(node.name ?? node.type)
}
}
return (
<div className="w-64 bg-card border-l border-border flex flex-col shrink-0">
<div className="h-8 flex items-center px-3 border-b border-border">
<span className="text-[11px] font-medium text-foreground">
{node.name ?? node.type}
</span>
{/* Header */}
<div className="h-8 flex items-center px-2 border-b border-border gap-1">
{(nodeIsReusable || nodeIsInstance) && (
<Diamond size={12} className={`shrink-0 ${nodeIsReusable ? 'text-purple-400' : 'text-[#9281f7]'}`} />
)}
{isEditingName ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
className={`text-[11px] font-medium flex-1 min-w-0 bg-secondary rounded px-1.5 py-0.5 border border-ring focus:outline-none ${
nodeIsReusable
? 'text-purple-400'
: nodeIsInstance
? 'text-[#9281f7]'
: 'text-foreground'
}`}
autoFocus
/>
) : (
<span
className={`text-[11px] font-medium flex-1 truncate cursor-text ${
nodeIsReusable
? 'text-purple-400 border border-purple-400/50 rounded px-1.5 py-0.5'
: nodeIsInstance
? 'text-[#9281f7] border border-dashed border-[#9281f7]/50 rounded px-1.5 py-0.5'
: 'text-foreground px-1'
}`}
onClick={handleNameClick}
>
{node.name ?? node.type}
</span>
)}
{nodeIsInstance && (
<>
<button
type="button"
title="Go to component"
className="p-1 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground shrink-0"
onClick={handleGoToComponent}
>
<ArrowUpRight size={12} />
</button>
<button
type="button"
title="Detach instance"
className="p-1 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground shrink-0"
onClick={() => activeId && detachComponent(activeId)}
>
<Unlink size={12} />
</button>
</>
)}
</div>
<div className="flex-1 overflow-y-auto">
{(isContainer || nodeIsInstance) && (
<div className="px-3 py-2">
{nodeIsReusable ? (
<Button
variant="outline"
size="sm"
className="w-full h-7 text-xs gap-1.5 text-purple-400 border-purple-500/30 hover:bg-purple-500/10"
onClick={() => activeId && detachComponent(activeId)}
>
<Unlink size={12} />
Detach Component
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full h-7 text-xs gap-1.5"
onClick={() => {
if (!activeId) return
if (nodeIsInstance) {
const newId = detachComponent(activeId)
if (newId) {
makeReusable(newId)
setSelection([newId], newId)
}
return
}
makeReusable(activeId)
}}
>
<Component size={12} />
Create Component
</Button>
)}
</div>
)}
<div className="px-3 py-2">
<SizeSection
node={node}
node={displayNode}
onUpdate={handleUpdate}
hasCornerRadius={hasCornerRadius}
cornerRadius={
'cornerRadius' in node ? node.cornerRadius : undefined
'cornerRadius' in displayNode ? displayNode.cornerRadius : undefined
}
hideWH={hasLayout || isText}
/>
</div>
@ -63,18 +264,33 @@ export default function PropertyPanel() {
<>
<Separator />
<div className="px-3 py-2">
<LayoutSection node={node as PenNode & ContainerProps} onUpdate={handleUpdate} />
<LayoutSection node={displayNode as PenNode & ContainerProps} onUpdate={handleUpdate} />
</div>
</>
)}
{isText && displayNode.type === 'text' && (
<>
<Separator />
<div className="px-3 py-2">
<TextLayoutSection node={displayNode} onUpdate={handleUpdate} />
</div>
</>
)}
<Separator />
<div className="px-3 py-2">
<AppearanceSection node={displayNode} onUpdate={handleUpdate} />
</div>
<Separator />
{hasFill && (
<>
<div className="px-3 py-2">
<FillSection
fills={'fill' in node ? node.fill : undefined}
fills={'fill' in displayNode ? displayNode.fill : undefined}
onUpdate={handleUpdate}
/>
</div>
@ -86,7 +302,7 @@ export default function PropertyPanel() {
<>
<div className="px-3 py-2">
<StrokeSection
stroke={'stroke' in node ? node.stroke : undefined}
stroke={'stroke' in displayNode ? displayNode.stroke : undefined}
onUpdate={handleUpdate}
/>
</div>
@ -94,30 +310,28 @@ export default function PropertyPanel() {
</>
)}
<div className="px-3 py-2">
<AppearanceSection node={node} onUpdate={handleUpdate} />
</div>
{isText && displayNode.type === 'text' && (
<div className="px-3 py-2">
<TextSection node={displayNode} onUpdate={handleUpdate} />
</div>
)}
{hasEffects && (
<>
<Separator />
<div className="px-3 py-2">
<EffectsSection
effects={'effects' in node ? node.effects : undefined}
effects={'effects' in displayNode ? displayNode.effects : undefined}
onUpdate={handleUpdate}
/>
</div>
</>
)}
{isText && node.type === 'text' && (
<>
<Separator />
<div className="px-3 py-2">
<TextSection node={node} onUpdate={handleUpdate} />
</div>
</>
)}
<Separator />
<div className="px-3 py-2">
<ExportSection nodeId={node.id} nodeName={node.name ?? node.type} />
</div>
</div>
</div>
)

View file

@ -8,6 +8,7 @@ interface SizeSectionProps {
onUpdate: (updates: Partial<PenNode>) => void
hasCornerRadius?: boolean
cornerRadius?: number | [number, number, number, number]
hideWH?: boolean
}
export default function SizeSection({
@ -15,6 +16,7 @@ export default function SizeSection({
onUpdate,
hasCornerRadius,
cornerRadius,
hideWH,
}: SizeSectionProps) {
const info = nodeRenderInfo.get(node.id)
const offsetX = info?.parentOffsetX ?? 0
@ -40,7 +42,12 @@ export default function SizeSection({
: 0
return (
<div className="space-y-3">
<span className=" text-[11px] font-medium text-foreground ">
Position
</span>
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="X"
value={Math.round(x)}
@ -51,7 +58,7 @@ export default function SizeSection({
value={Math.round(y)}
onChange={(v) => onUpdate({ y: v - offsetY })}
/>
{width !== undefined && (
{!hideWH && width !== undefined && (
<NumberInput
label="W"
value={Math.round(width)}
@ -61,7 +68,7 @@ export default function SizeSection({
min={1}
/>
)}
{height !== undefined && (
{!hideWH && height !== undefined && (
<NumberInput
label="H"
value={Math.round(height)}
@ -88,5 +95,6 @@ export default function SizeSection({
/>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,235 @@
import NumberInput from '@/components/shared/number-input'
import SectionHeader from '@/components/shared/section-header'
import {
MoveHorizontal,
WrapText,
Maximize2,
Check,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { PenNode, TextNode, SizingBehavior } from '@/types/pen'
interface TextLayoutSectionProps {
node: TextNode
onUpdate: (updates: Partial<PenNode>) => void
}
type TextResizing = 'auto' | 'fixed-width' | 'fixed-width-height'
function resolveTextGrowth(node: TextNode): TextResizing {
if (node.textGrowth) return node.textGrowth
const w = node.width
if (typeof w === 'number' && w > 0) return 'fixed-width'
if (typeof w === 'string' && w.startsWith('fill_container')) return 'fixed-width'
return 'auto'
}
function extractNumericSize(value: SizingBehavior | undefined): number {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const match = value.match(/\((\d+)\)/)
if (match) return parseInt(match[1], 10)
}
return 100
}
function ResizingToggle({
active,
onClick,
children,
title,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
title: string
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className={cn(
'h-7 flex-1 flex items-center justify-center gap-1 rounded text-[10px] transition-colors',
active
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-accent',
)}
>
{children}
</button>
)
}
function SizingCheckbox({
label,
checked,
onChange,
}: {
label: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<label className="flex items-center gap-1.5 cursor-pointer group">
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
'w-4 h-4 rounded-[3px] border-[1.5px] flex items-center justify-center transition-colors shrink-0',
checked
? 'bg-primary border-primary'
: 'border-muted-foreground/40 group-hover:border-muted-foreground',
)}
>
{checked && (
<Check className="w-3 h-3 text-primary-foreground" strokeWidth={3} />
)}
</button>
<span className="text-[11px] text-muted-foreground select-none">
{label}
</span>
</label>
)
}
export default function TextLayoutSection({
node,
onUpdate,
}: TextLayoutSectionProps) {
const resizing = resolveTextGrowth(node)
const widthStr = typeof node.width === 'string' ? node.width : ''
const heightStr = typeof node.height === 'string' ? node.height : ''
const fillWidth = widthStr.startsWith('fill_container')
const fillHeight = heightStr.startsWith('fill_container')
const numericWidth = typeof node.width === 'number' && node.width > 0 ? node.width : undefined
const numericHeight = typeof node.height === 'number' && node.height > 0 ? node.height : undefined
const fallbackW = extractNumericSize(node.width)
const fallbackH = extractNumericSize(node.height)
const handleResizingChange = (mode: TextResizing) => {
const updates: Record<string, unknown> = { textGrowth: mode }
switch (mode) {
case 'auto':
updates.width = 0
updates.height = 0
break
case 'fixed-width':
if (!fillWidth && (typeof node.width !== 'number' || node.width <= 0)) {
updates.width = fallbackW > 0 ? fallbackW : 200
}
if (!fillHeight && (typeof node.height !== 'number' || node.height <= 0)) {
updates.height = fallbackH > 0 ? fallbackH : 44
}
break
case 'fixed-width-height':
if (!fillWidth && (typeof node.width !== 'number' || node.width <= 0)) {
updates.width = fallbackW > 0 ? fallbackW : 200
}
if (!fillHeight && (typeof node.height !== 'number' || node.height <= 0)) {
updates.height = fallbackH > 0 ? fallbackH : 100
}
break
}
onUpdate(updates as Partial<PenNode>)
}
// Always show dimensions — read-only when not directly editable
const canEditWidth = resizing !== 'auto' && !fillWidth && numericWidth !== undefined
const canEditHeight = resizing === 'fixed-width-height' && !fillHeight && numericHeight !== undefined
// Display value: prefer numeric, fallback to extracted size from fill_container(N)
const displayW = numericWidth ?? fallbackW
const displayH = numericHeight ?? fallbackH
return (
<div className="space-y-1.5">
<SectionHeader title="Layout" />
{/* Dimensions — always visible, read-only when auto/fill */}
<div>
<span className="text-[10px] text-muted-foreground mb-1 block">
Dimensions
</span>
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="W"
value={Math.round(displayW)}
onChange={(v) =>
onUpdate({ width: v } as Partial<PenNode>)
}
min={1}
readOnly={!canEditWidth}
/>
<NumberInput
label="H"
value={Math.round(displayH)}
onChange={(v) =>
onUpdate({ height: v } as Partial<PenNode>)
}
min={1}
readOnly={!canEditHeight}
/>
</div>
</div>
{/* Fill Width / Fill Height */}
{resizing !== 'auto' && (
<div className="grid grid-cols-2 gap-y-1.5">
<SizingCheckbox
label="Fill Width"
checked={fillWidth}
onChange={(v) =>
onUpdate({
width: v ? 'fill_container' : (fallbackW > 0 ? fallbackW : 200),
} as Partial<PenNode>)
}
/>
<SizingCheckbox
label="Fill Height"
checked={fillHeight}
onChange={(v) =>
onUpdate({
height: v ? 'fill_container' : (fallbackH > 0 ? fallbackH : 100),
} as Partial<PenNode>)
}
/>
</div>
)}
{/* Resizing mode */}
<div>
<span className="text-[10px] text-muted-foreground mb-1 block">
Resizing
</span>
<div className="flex gap-0.5">
<ResizingToggle
active={resizing === 'auto'}
onClick={() => handleResizingChange('auto')}
title="Auto Width — text expands horizontally"
>
<MoveHorizontal className="w-3 h-3" />
<span>Auto W</span>
</ResizingToggle>
<ResizingToggle
active={resizing === 'fixed-width'}
onClick={() => handleResizingChange('fixed-width')}
title="Auto Height — fixed width, height auto-sizes"
>
<WrapText className="w-3 h-3" />
<span>Auto H</span>
</ResizingToggle>
<ResizingToggle
active={resizing === 'fixed-width-height'}
onClick={() => handleResizingChange('fixed-width-height')}
title="Fixed Size — both width and height are fixed"
>
<Maximize2 className="w-3 h-3" />
<span>Fixed</span>
</ResizingToggle>
</div>
</div>
</div>
)
}

View file

@ -7,7 +7,15 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { AlignLeft, AlignCenter, AlignRight, AlignJustify } from 'lucide-react'
import {
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { PenNode, TextNode } from '@/types/pen'
@ -35,13 +43,58 @@ const WEIGHT_OPTIONS = [
{ value: '900', label: 'Black' },
]
const ALIGN_OPTIONS = [
const H_ALIGN_OPTIONS = [
{ value: 'left', icon: AlignLeft, label: 'Align left' },
{ value: 'center', icon: AlignCenter, label: 'Align center' },
{ value: 'right', icon: AlignRight, label: 'Align right' },
{ value: 'justify', icon: AlignJustify, label: 'Justify' },
]
const V_ALIGN_OPTIONS = [
{ value: 'top', icon: AlignVerticalJustifyStart, label: 'Top' },
{ value: 'middle', icon: AlignVerticalJustifyCenter, label: 'Middle' },
{ value: 'bottom', icon: AlignVerticalJustifyEnd, label: 'Bottom' },
]
const LineHeightIcon = (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<line x1="1" y1="2" x2="1" y2="10" />
<polyline points="3,4 1,2 -1,4" transform="translate(0,0)" />
<polyline points="3,8 1,10 -1,8" transform="translate(0,0)" />
<line x1="5" y1="6" x2="11" y2="6" />
<line x1="5" y1="3" x2="9" y2="3" />
<line x1="5" y1="9" x2="9" y2="9" />
</svg>
)
function AlignButton({
active,
onClick,
icon: Icon,
label,
}: {
active: boolean
onClick: () => void
icon: React.ComponentType<{ className?: string }>
label: string
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={cn(
'h-6 w-6 flex items-center justify-center rounded transition-colors',
active
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
)}
>
<Icon className="w-3.5 h-3.5" />
</button>
)
}
export default function TextSection({
node,
onUpdate,
@ -49,12 +102,16 @@ export default function TextSection({
const fontFamily = node.fontFamily ?? 'Inter, sans-serif'
const fontSize = node.fontSize ?? 16
const fontWeight = String(node.fontWeight ?? '400')
const lineHeight = node.lineHeight ?? 1.2
const letterSpacing = node.letterSpacing ?? 0
const textAlign = node.textAlign ?? 'left'
const textAlignVertical = node.textAlignVertical ?? 'top'
return (
<div className="space-y-1.5">
<SectionHeader title="Text" />
<SectionHeader title="Typography" />
{/* Font family */}
<Select
value={fontFamily}
onValueChange={(v) =>
@ -73,15 +130,8 @@ export default function TextSection({
</SelectContent>
</Select>
{/* Weight + Size */}
<div className="grid grid-cols-2 gap-1">
<NumberInput
value={fontSize}
onChange={(v) =>
onUpdate({ fontSize: v } as Partial<PenNode>)
}
min={1}
max={999}
/>
<Select
value={fontWeight}
onValueChange={(v) =>
@ -99,29 +149,80 @@ export default function TextSection({
))}
</SelectContent>
</Select>
<NumberInput
label="S"
value={fontSize}
onChange={(v) =>
onUpdate({ fontSize: v } as Partial<PenNode>)
}
min={1}
max={999}
/>
</div>
<div className="flex items-center gap-0.5">
{ALIGN_OPTIONS.map(({ value, icon: Icon, label }) => (
<button
key={value}
type="button"
aria-label={label}
onClick={() =>
onUpdate({
textAlign: value as TextNode['textAlign'],
} as Partial<PenNode>)
}
className={cn(
'h-6 w-6 flex items-center justify-center rounded transition-colors',
textAlign === value
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
)}
>
<Icon className="w-3.5 h-3.5" />
</button>
))}
{/* Line height + Letter spacing */}
<div className="flex items-center justify-between text-[9px] text-muted-foreground px-0.5">
<span>Line height</span>
<span>Letter spacing</span>
</div>
<div className="grid grid-cols-2 gap-1">
<NumberInput
icon={LineHeightIcon}
value={Math.round(lineHeight * 100)}
onChange={(v) =>
onUpdate({ lineHeight: v / 100 } as Partial<PenNode>)
}
min={50}
max={400}
suffix="%"
/>
<NumberInput
label="|A|"
value={letterSpacing}
onChange={(v) =>
onUpdate({ letterSpacing: v } as Partial<PenNode>)
}
/>
</div>
{/* Horizontal alignment */}
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground">Horizontal</span>
<div className="flex items-center gap-0.5">
{H_ALIGN_OPTIONS.map(({ value, icon, label }) => (
<AlignButton
key={value}
active={textAlign === value}
onClick={() =>
onUpdate({
textAlign: value as TextNode['textAlign'],
} as Partial<PenNode>)
}
icon={icon}
label={label}
/>
))}
</div>
</div>
{/* Vertical alignment */}
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground">Vertical</span>
<div className="flex items-center gap-0.5">
{V_ALIGN_OPTIONS.map(({ value, icon, label }) => (
<AlignButton
key={value}
active={textAlignVertical === value}
onClick={() =>
onUpdate({
textAlignVertical: value as TextNode['textAlignVertical'],
} as Partial<PenNode>)
}
icon={icon}
label={label}
/>
))}
</div>
</div>
</div>
)

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type { ComponentType, SVGProps } from 'react'
import { X, Check, Loader2, Unplug } from 'lucide-react'
import { X, Check, Loader2, Unplug, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
@ -8,11 +8,12 @@ import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import type { AIProviderType, GroupedModel } from '@/types/agent-settings'
import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
import OpenCodeLogo from '@/components/icons/opencode-logo'
/** Provider display metadata */
const PROVIDER_META: Record<
AIProviderType,
{ label: string; description: string; agent: 'claude-code' | 'codex-cli'; Icon: ComponentType<SVGProps<SVGSVGElement>> }
{ label: string; description: string; agent: 'claude-code' | 'codex-cli' | 'opencode'; Icon: ComponentType<SVGProps<SVGSVGElement>> }
> = {
anthropic: {
label: 'Claude Code',
@ -26,10 +27,16 @@ const PROVIDER_META: Record<
agent: 'codex-cli',
Icon: OpenAILogo,
},
opencode: {
label: 'OpenCode',
description: 'Connect to local OpenCode server to use 75+ LLM providers',
agent: 'opencode',
Icon: OpenCodeLogo,
},
}
async function connectAgent(
agent: 'claude-code' | 'codex-cli',
agent: 'claude-code' | 'codex-cli' | 'opencode',
): Promise<{ connected: boolean; models: GroupedModel[]; error?: string }> {
try {
const res = await fetch('/api/ai/connect-agent', {
@ -147,12 +154,37 @@ export default function AgentSettingsDialog() {
return () => document.removeEventListener('keydown', handler)
}, [open, setDialogOpen])
const [mcpInstalling, setMcpInstalling] = useState<string | null>(null)
const [mcpError, setMcpError] = useState<string | null>(null)
const handleToggleMCP = useCallback(
(tool: string) => {
toggleMCP(tool)
persist()
async (tool: string) => {
const current = mcpIntegrations.find((m) => m.tool === tool)
if (!current) return
const action = current.enabled ? 'uninstall' : 'install'
setMcpInstalling(tool)
setMcpError(null)
try {
const res = await fetch('/api/ai/mcp-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool, action }),
})
const result = await res.json()
if (result.success) {
toggleMCP(tool)
persist()
} else {
setMcpError(result.error ?? `Failed to ${action}`)
}
} catch {
setMcpError(`Failed to ${action} MCP server`)
} finally {
setMcpInstalling(null)
}
},
[toggleMCP, persist],
[mcpIntegrations, toggleMCP, persist],
)
if (!open) return null
@ -194,6 +226,7 @@ export default function AgentSettingsDialog() {
<div className="space-y-2">
<ProviderCard type="anthropic" />
<ProviderCard type="openai" />
<ProviderCard type="opencode" />
</div>
</div>
@ -208,21 +241,33 @@ export default function AgentSettingsDialog() {
key={m.tool}
className="flex items-center justify-between py-2 px-1"
>
<span
className={cn(
'text-xs',
m.enabled ? 'text-foreground' : 'text-muted-foreground',
<div className="flex items-center gap-1.5">
<span
className={cn(
'text-xs',
m.enabled ? 'text-foreground' : 'text-muted-foreground',
)}
>
{m.displayName}
</span>
{mcpInstalling === m.tool && (
<Loader2 size={11} className="animate-spin text-muted-foreground" />
)}
>
{m.displayName}
</span>
</div>
<Switch
checked={m.enabled}
disabled={mcpInstalling !== null}
onCheckedChange={() => handleToggleMCP(m.tool)}
/>
</div>
))}
</div>
{mcpError && (
<div className="flex items-center gap-1.5 mt-1.5 px-1">
<AlertCircle size={11} className="text-destructive shrink-0" />
<p className="text-[10px] text-destructive">{mcpError}</p>
</div>
)}
<p className="text-[10px] text-muted-foreground mt-2">
MCP integrations will take effect after restarting the terminal.
</p>

View file

@ -55,7 +55,7 @@ export default function ColorPicker({
type="color"
value={value.slice(0, 7)}
onChange={handleNativeChange}
className="w-4 h-4 rounded border border-input/50 cursor-pointer bg-transparent p-0"
className="w-4 h-5 rounded cursor-pointer bg-transparent p-0"
/>
</div>
<input
@ -64,7 +64,7 @@ export default function ColorPicker({
value={hexInput}
onChange={handleHexChange}
onBlur={handleBlur}
className="flex-1 bg-transparent text-foreground text-[11px] px-1.5 py-0.5 focus:outline-none font-mono tabular-nums min-w-0"
className="flex-1 bg-transparent text-foreground text-[11px] px-1.5 h-5 focus:outline-none font-mono tabular-nums min-w-0"
placeholder="#000000"
/>
</div>

View file

@ -13,6 +13,7 @@ interface NumberInputProps {
icon?: React.ReactNode
suffix?: string
className?: string
readOnly?: boolean
}
export default function NumberInput({
@ -25,6 +26,7 @@ export default function NumberInput({
icon,
suffix,
className = '',
readOnly = false,
}: NumberInputProps) {
const [localValue, setLocalValue] = useState(String(value))
const [isDragging, setIsDragging] = useState(false)
@ -69,6 +71,7 @@ export default function NumberInput({
}
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return
if (e.target instanceof HTMLInputElement) return
setIsDragging(true)
dragStartY.current = e.clientY
@ -116,10 +119,14 @@ export default function NumberInput({
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-full bg-transparent text-foreground text-[11px] px-1 py-0.5 focus:outline-none tabular-nums"
onChange={(e) => !readOnly && setLocalValue(e.target.value)}
onBlur={readOnly ? undefined : handleBlur}
onKeyDown={readOnly ? undefined : handleKeyDown}
readOnly={readOnly}
className={cn(
'w-full bg-transparent text-[11px] px-1 py-0.5 focus:outline-none tabular-nums',
readOnly ? 'text-muted-foreground cursor-default' : 'text-foreground',
)}
/>
{suffix && (
<span className="text-[10px] text-muted-foreground pr-1.5 shrink-0">

View file

@ -19,7 +19,7 @@ export default function SaveDialog({ open, onClose }: SaveDialogProps) {
// Pre-fill with existing name (without extension)
const fn = useDocumentStore.getState().fileName
if (fn) {
setName(fn.replace(/\.pen$|\.json$/, ''))
setName(fn.replace(/\.op$|\.pen$|\.json$/, ''))
} else {
setName('untitled')
}
@ -43,7 +43,7 @@ export default function SaveDialog({ open, onClose }: SaveDialogProps) {
if (!trimmed) return
// Force-sync all Fabric object positions to the store before serializing
syncCanvasPositionsToStore()
const fileName = trimmed.endsWith('.pen') ? trimmed : `${trimmed}.pen`
const fileName = trimmed.endsWith('.op') ? trimmed : `${trimmed}.op`
const doc = useDocumentStore.getState().document
downloadDocument(doc, fileName)
useDocumentStore.setState({ fileName, isDirty: false })
@ -74,7 +74,7 @@ export default function SaveDialog({ open, onClose }: SaveDialogProps) {
className="flex-1 bg-secondary border border-input rounded px-2 py-1.5 text-sm text-foreground focus:outline-none focus:border-ring"
autoFocus
/>
<span className="text-xs text-muted-foreground">.pen</span>
<span className="text-xs text-muted-foreground">.op</span>
</div>
<div className="flex gap-2">

View file

@ -128,11 +128,23 @@ export function useKeyboardShortcuts() {
const { clipboard } = useCanvasStore.getState()
if (clipboard.length > 0) {
e.preventDefault()
const cloned = cloneNodesWithNewIds(clipboard, 10)
const newIds: string[] = []
for (const node of cloned) {
useDocumentStore.getState().addNode(null, node)
newIds.push(node.id)
for (const original of clipboard) {
// Pasting a reusable component creates an instance (RefNode)
if ('reusable' in original && original.reusable) {
const component = useDocumentStore.getState().getNodeById(original.id)
if (component && 'reusable' in component && component.reusable) {
const newId = useDocumentStore.getState().duplicateNode(original.id)
if (newId) {
newIds.push(newId)
continue
}
}
}
// Regular paste for non-reusable nodes
const [cloned] = cloneNodesWithNewIds([original], 10)
useDocumentStore.getState().addNode(null, cloned)
newIds.push(cloned.id)
}
useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null)
}
@ -144,16 +156,14 @@ export function useKeyboardShortcuts() {
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length > 0) {
e.preventDefault()
const nodes = selectedIds
.map((id) => useDocumentStore.getState().getNodeById(id))
.filter((n): n is NonNullable<typeof n> => n != null)
const cloned = cloneNodesWithNewIds(nodes, 10)
const newIds: string[] = []
for (const node of cloned) {
useDocumentStore.getState().addNode(null, node)
newIds.push(node.id)
for (const id of selectedIds) {
const newId = useDocumentStore.getState().duplicateNode(id)
if (newId) newIds.push(newId)
}
if (newIds.length > 0) {
useCanvasStore.getState().setSelection(newIds, newIds[0])
}
useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null)
}
return
}
@ -168,8 +178,11 @@ export function useKeyboardShortcuts() {
if (fileHandle) {
writeToFileHandle(fileHandle, doc).then(() => store.markClean())
} else if (fileName) {
downloadDocument(doc, fileName)
store.markClean()
} else if (supportsFileSystemAccess()) {
saveDocumentAs(doc, fileName ?? 'untitled.pen').then((result) => {
saveDocumentAs(doc, 'untitled.op').then((result) => {
if (result) {
useDocumentStore.setState({
fileName: result.fileName,
@ -178,9 +191,6 @@ export function useKeyboardShortcuts() {
})
}
})
} else if (fileName) {
downloadDocument(doc, fileName)
store.markClean()
} else {
store.setSaveDialogOpen(true)
}
@ -225,6 +235,16 @@ export function useKeyboardShortcuts() {
return
}
// Create Component: Cmd/Ctrl+Alt+K
if (isMod && e.altKey && e.key.toLowerCase() === 'k') {
const { selectedIds } = useCanvasStore.getState().selection
if (selectedIds.length === 1) {
e.preventDefault()
useDocumentStore.getState().makeReusable(selectedIds[0])
}
return
}
// Ungroup: Cmd/Ctrl+Shift+G
if (isMod && e.shiftKey && e.key.toLowerCase() === 'g') {
const { selectedIds } = useCanvasStore.getState().selection

View file

@ -0,0 +1,75 @@
import { readFile, writeFile, access } from 'node:fs/promises'
import { constants } from 'node:fs'
import type { PenDocument } from '../types/pen'
const cache = new Map<string, { doc: PenDocument; mtime: number }>()
/** Validate that a parsed object looks like a PenDocument. */
function validate(doc: unknown): doc is PenDocument {
if (!doc || typeof doc !== 'object') return false
const d = doc as Record<string, unknown>
return typeof d.version === 'string' && Array.isArray(d.children)
}
/** Read and parse a .op / .pen file, returning a PenDocument. Uses cache. */
export async function openDocument(filePath: string): Promise<PenDocument> {
const cached = cache.get(filePath)
if (cached) return cached.doc
await access(filePath, constants.R_OK)
const text = await readFile(filePath, 'utf-8')
const raw = JSON.parse(text)
if (!validate(raw)) {
throw new Error(`Invalid document format: ${filePath}`)
}
cache.set(filePath, { doc: raw, mtime: Date.now() })
return raw
}
/** Create a new empty document (not saved to disk yet). */
export function createEmptyDocument(): PenDocument {
return {
version: '1.0.0',
children: [],
}
}
/** Write a PenDocument to disk and update cache. */
export async function saveDocument(
filePath: string,
doc: PenDocument,
): Promise<void> {
const json = JSON.stringify(doc, null, 2)
await writeFile(filePath, json, 'utf-8')
cache.set(filePath, { doc, mtime: Date.now() })
}
/** Get document from cache (for tools that operate on the active doc). */
export function getCachedDocument(
filePath: string,
): PenDocument | undefined {
return cache.get(filePath)?.doc
}
/** Update the cached document in-memory (call saveDocument to persist). */
export function setCachedDocument(
filePath: string,
doc: PenDocument,
): void {
cache.set(filePath, { doc, mtime: Date.now() })
}
/** Check if a file exists. */
export async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK)
return true
} catch {
return false
}
}
/** Invalidate cache for a file. */
export function invalidateCache(filePath: string): void {
cache.delete(filePath)
}

274
src/mcp/server.ts Normal file
View file

@ -0,0 +1,274 @@
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { handleOpenDocument } from './tools/open-document'
import { handleBatchGet } from './tools/batch-get'
import { handleBatchDesign } from './tools/batch-design'
import { handleGetVariables, handleSetVariables } from './tools/variables'
import { handleSnapshotLayout } from './tools/snapshot-layout'
import { handleFindEmptySpace } from './tools/find-empty-space'
const server = new Server(
{ name: 'openpencil', version: '0.1.0' },
{ capabilities: { tools: {} } },
)
// --- Tool definitions ---
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'open_document',
description:
'Open an existing .op file or create a new empty document. Returns document metadata.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file to open or create',
},
},
required: ['filePath'],
},
},
{
name: 'batch_get',
description:
'Search and read nodes from an .op file. Search by patterns (type, name regex, reusable flag) or read specific node IDs. Control depth with readDepth and searchDepth.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
patterns: {
type: 'array',
description: 'Search patterns to match nodes',
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Node type (frame, text, rectangle, etc.)' },
name: { type: 'string', description: 'Regex pattern to match node name' },
reusable: { type: 'boolean', description: 'Match reusable components' },
},
},
},
nodeIds: {
type: 'array',
items: { type: 'string' },
description: 'Specific node IDs to read',
},
parentId: {
type: 'string',
description: 'Limit search to children of this parent node',
},
readDepth: {
type: 'number',
description: 'How deep to include children in results (default 1)',
},
searchDepth: {
type: 'number',
description: 'How deep to search for matching nodes (default unlimited)',
},
},
required: ['filePath'],
},
},
{
name: 'batch_design',
description: `Execute design operations on an .op file using a DSL. Each line is one operation:
- Insert: binding=I(parent, { type: "frame", ... })
- Copy: binding=C(sourceId, parent, { ...overrides })
- Update: U(nodeId, { fill: [...] })
- Replace: binding=R(nodeId, { type: "text", ... })
- Move: M(nodeId, newParent, index)
- Delete: D(nodeId)
Bindings can reference earlier results: U(myFrame+"/childId", { ... })`,
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
operations: {
type: 'string',
description: 'Operations DSL (one operation per line)',
},
},
required: ['filePath', 'operations'],
},
},
{
name: 'get_variables',
description: 'Get all design variables and themes defined in an .op file.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
},
required: ['filePath'],
},
},
{
name: 'set_variables',
description:
'Add or update design variables in an .op file. By default merges with existing variables.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
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'],
},
},
{
name: 'snapshot_layout',
description:
'Get the hierarchical bounding box layout tree of an .op file. Useful for understanding spatial arrangement.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
parentId: {
type: 'string',
description: 'Only return layout under this parent node',
},
maxDepth: {
type: 'number',
description: 'Max depth to traverse (default 1)',
},
},
required: ['filePath'],
},
},
{
name: 'find_empty_space',
description:
'Find empty canvas space in a given direction for placing new content.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
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)',
},
direction: {
type: 'string',
enum: ['top', 'right', 'bottom', 'left'],
description: 'Direction to search for empty space',
},
nodeId: {
type: 'string',
description: 'Search relative to this node (default: entire canvas)',
},
},
required: ['filePath', 'width', 'height', 'direction'],
},
},
],
}))
// --- Tool execution ---
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
switch (name) {
case 'open_document': {
const result = await handleOpenDocument(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'batch_get': {
const result = await handleBatchGet(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'batch_design': {
const result = await handleBatchDesign(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'get_variables': {
const result = await handleGetVariables(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'set_variables': {
const result = await handleSetVariables(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'snapshot_layout': {
const result = await handleSnapshotLayout(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'find_empty_space': {
const result = await handleFindEmptySpace(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
default:
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true,
}
}
} catch (err) {
return {
content: [
{
type: 'text',
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
}
}
})
// --- Start ---
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
}
main().catch((err) => {
console.error('MCP server failed to start:', err)
process.exit(1)
})

View file

@ -0,0 +1,441 @@
import { resolve } from 'node:path'
import { openDocument, saveDocument } from '../document-manager'
import {
findNodeInTree,
insertNodeInTree,
updateNodeInTree,
removeNodeFromTree,
cloneNodeWithNewIds,
} from '../utils/node-operations'
import { generateId } from '../utils/id'
import type { PenDocument, PenNode } from '../../types/pen'
export interface BatchDesignParams {
filePath: string
operations: string
}
interface OpResult {
binding: string
nodeId: string
}
/**
* Parse and execute the batch_design operations DSL.
*
* Supported operations (one per line):
* binding=I(parent, { ...nodeData }) Insert
* binding=C(nodeId, parent, { ...data }) Copy
* U(path, { ...updates }) Update
* binding=R(path, { ...nodeData }) Replace
* M(nodeId, parent, index?) Move
* D(nodeId) Delete
*/
export async function handleBatchDesign(
params: BatchDesignParams,
): Promise<{ results: OpResult[]; nodeCount: number }> {
const filePath = resolve(params.filePath)
let doc = await openDocument(filePath)
doc = structuredClone(doc)
const bindings = new Map<string, string>()
const results: OpResult[] = []
const lines = params.operations
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('//'))
for (const line of lines) {
try {
executeLine(line, doc, bindings, results)
} catch (err) {
throw new Error(
`Error executing "${line}": ${err instanceof Error ? err.message : String(err)}`,
)
}
}
await saveDocument(filePath, doc)
return {
results,
nodeCount: countNodes(doc.children),
}
}
function executeLine(
line: string,
doc: PenDocument,
bindings: Map<string, string>,
results: OpResult[],
): void {
// Parse: binding=OP(args) or OP(args)
const assignMatch = line.match(/^(\w+)\s*=\s*([ICRM])\((.+)\)$/)
const callMatch = line.match(/^([UDM])\((.+)\)$/)
if (assignMatch) {
const [, binding, op, argsStr] = assignMatch
switch (op) {
case 'I': {
const { parent, data } = parseInsertArgs(argsStr, bindings)
const node = { ...data, id: generateId() } as PenNode
doc.children = insertNodeInTree(doc.children, parent, node)
bindings.set(binding, node.id)
results.push({ binding, nodeId: node.id })
break
}
case 'C': {
const { sourceId, parent, data } = parseCopyArgs(argsStr, bindings)
const source = findNodeInTree(doc.children, sourceId)
if (!source) throw new Error(`Copy source not found: ${sourceId}`)
const cloned = cloneNodeWithNewIds(source, generateId)
// Apply override properties
if (data) {
Object.assign(cloned, data)
// Don't override the cloned id
if (data.id) delete (cloned as any).id
}
// Apply descendant overrides
if (data?.descendants) {
applyDescendantOverrides(cloned, data.descendants)
}
doc.children = insertNodeInTree(doc.children, parent, cloned)
bindings.set(binding, cloned.id)
results.push({ binding, nodeId: cloned.id })
break
}
case 'R': {
const { path, data } = parseReplaceArgs(argsStr, bindings)
const resolvedPath = resolveSlashPath(path, doc, bindings)
const newNode = { ...data, id: generateId() } as PenNode
// Find and replace the node
const oldNode = findNodeByPath(resolvedPath, doc)
if (!oldNode) throw new Error(`Replace target not found: ${path}`)
// Remove old, insert new at same position
const parent = findParentByPath(resolvedPath, doc)
const parentId = parent ? parent.id : null
const siblings = parent
? ('children' in parent ? parent.children ?? [] : [])
: doc.children
const idx = siblings.findIndex((n) => n.id === oldNode.id)
doc.children = removeNodeFromTree(doc.children, oldNode.id)
doc.children = insertNodeInTree(doc.children, parentId, newNode, idx)
bindings.set(binding, newNode.id)
results.push({ binding, nodeId: newNode.id })
break
}
case 'M': {
const { nodeId, parent, index } = parseMoveArgs(argsStr, bindings)
const node = findNodeInTree(doc.children, nodeId)
if (!node) throw new Error(`Move target not found: ${nodeId}`)
doc.children = removeNodeFromTree(doc.children, nodeId)
doc.children = insertNodeInTree(doc.children, parent, node, index)
bindings.set(binding, nodeId)
results.push({ binding, nodeId })
break
}
}
} else if (callMatch) {
const [, op, argsStr] = callMatch
switch (op) {
case 'U': {
const { path, data } = parseUpdateArgs(argsStr, bindings)
const resolvedPath = resolveSlashPath(path, doc, bindings)
const targetNode = findNodeByPath(resolvedPath, doc)
if (!targetNode)
throw new Error(`Update target not found: ${path}`)
// Update the node in-place
doc.children = updateNodeInTree(doc.children, targetNode.id, data)
break
}
case 'D': {
const nodeId = resolveRef(argsStr.trim().replace(/^"|"$/g, ''), bindings)
doc.children = removeNodeFromTree(doc.children, nodeId)
break
}
case 'M': {
const { nodeId, parent, index } = parseMoveArgs(argsStr, bindings)
const node = findNodeInTree(doc.children, nodeId)
if (!node) throw new Error(`Move target not found: ${nodeId}`)
doc.children = removeNodeFromTree(doc.children, nodeId)
doc.children = insertNodeInTree(doc.children, parent, node, index)
break
}
}
} else {
throw new Error(`Cannot parse operation: ${line}`)
}
}
// --- Argument parsers ---
function parseInsertArgs(
argsStr: string,
bindings: Map<string, string>,
): { parent: string | null; data: Record<string, any> } {
const firstComma = findTopLevelComma(argsStr)
if (firstComma === -1) throw new Error('Insert requires parent and node data')
const parentRaw = argsStr.slice(0, firstComma).trim()
const dataStr = argsStr.slice(firstComma + 1).trim()
const parent = parentRaw === 'null' ? null : resolveRef(parentRaw, bindings)
const data = parseJsonArg(dataStr)
return { parent, data }
}
function parseCopyArgs(
argsStr: string,
bindings: Map<string, string>,
): { sourceId: string; parent: string | null; data: Record<string, any> } {
const first = findTopLevelComma(argsStr)
if (first === -1) throw new Error('Copy requires sourceId, parent, and data')
const sourceRaw = argsStr.slice(0, first).trim()
const rest = argsStr.slice(first + 1).trim()
const second = findTopLevelComma(rest)
let parentRaw: string
let dataStr: string
if (second === -1) {
parentRaw = rest
dataStr = '{}'
} else {
parentRaw = rest.slice(0, second).trim()
dataStr = rest.slice(second + 1).trim()
}
return {
sourceId: resolveRef(sourceRaw, bindings),
parent: parentRaw === 'null' ? null : resolveRef(parentRaw, bindings),
data: parseJsonArg(dataStr),
}
}
function parseUpdateArgs(
argsStr: string,
bindings: Map<string, string>,
): { path: string; data: Record<string, any> } {
const firstComma = findTopLevelComma(argsStr)
if (firstComma === -1)
throw new Error('Update requires path and update data')
const pathRaw = argsStr.slice(0, firstComma).trim()
const dataStr = argsStr.slice(firstComma + 1).trim()
const path = resolvePathExpr(pathRaw, bindings)
return { path, data: parseJsonArg(dataStr) }
}
function parseReplaceArgs(
argsStr: string,
bindings: Map<string, string>,
): { path: string; data: Record<string, any> } {
const firstComma = findTopLevelComma(argsStr)
if (firstComma === -1) throw new Error('Replace requires path and node data')
const pathRaw = argsStr.slice(0, firstComma).trim()
const dataStr = argsStr.slice(firstComma + 1).trim()
const path = resolvePathExpr(pathRaw, bindings)
return { path, data: parseJsonArg(dataStr) }
}
function parseMoveArgs(
argsStr: string,
bindings: Map<string, string>,
): { nodeId: string; parent: string | null; index?: number } {
const parts = splitTopLevel(argsStr)
if (parts.length < 2) throw new Error('Move requires nodeId and parent')
return {
nodeId: resolveRef(parts[0].trim(), bindings),
parent:
parts[1].trim() === 'null' || parts[1].trim() === 'undefined'
? null
: resolveRef(parts[1].trim(), bindings),
index: parts[2] ? parseInt(parts[2].trim(), 10) : undefined,
}
}
// --- Helpers ---
function resolveRef(raw: string, bindings: Map<string, string>): string {
const cleaned = raw.replace(/^"|"$/g, '')
return bindings.get(cleaned) ?? cleaned
}
/** Resolve path expressions like `binding+"/child"` or `"id"` */
function resolvePathExpr(
raw: string,
bindings: Map<string, string>,
): string {
if (raw.includes('+')) {
return raw
.split('+')
.map((p) => {
const t = p.trim()
if (t.startsWith('"') || t.startsWith("'")) {
return t.slice(1, -1)
}
return bindings.get(t) ?? t
})
.join('')
}
const cleaned = raw.replace(/^"|"$/g, '')
return bindings.get(cleaned) ?? cleaned
}
/** Resolve slash-separated path (e.g. "instanceId/childId") to find the actual node. */
function resolveSlashPath(
path: string,
_doc: PenDocument,
_bindings: Map<string, string>,
): string {
// The path may be "parentId/childId" — we return as-is for findNodeByPath
return path
}
function findNodeByPath(path: string, doc: PenDocument): PenNode | undefined {
const parts = path.split('/')
if (parts.length === 1) {
return findNodeInTree(doc.children, parts[0])
}
// For paths like "instanceId/childId", resolve through the tree
// First find the instance, then look for child
let current = findNodeInTree(doc.children, parts[0])
for (let i = 1; i < parts.length && current; i++) {
if ('children' in current && current.children) {
current = current.children.find((c) => c.id === parts[i])
} else {
// For ref nodes, the child might be in the referenced component
return undefined
}
}
return current
}
function findParentByPath(
path: string,
doc: PenDocument,
): PenNode | undefined {
const parts = path.split('/')
if (parts.length <= 1) {
// Top-level or simple ID — find parent in tree
return findParentInTree(doc.children, parts[0])
}
// Parent is the second-to-last part
const parentPath = parts.slice(0, -1).join('/')
return findNodeByPath(parentPath, doc)
}
function findParentInTree(
nodes: PenNode[],
id: string,
): PenNode | undefined {
for (const node of nodes) {
if ('children' in node && node.children) {
for (const child of node.children) {
if (child.id === id) return node
}
const found = findParentInTree(node.children, id)
if (found) return found
}
}
return undefined
}
function applyDescendantOverrides(
node: PenNode,
descendants: Record<string, any>,
): void {
if (!('children' in node) || !node.children) return
for (const child of node.children) {
const override = descendants[child.id]
if (override) {
Object.assign(child, override)
}
applyDescendantOverrides(child, descendants)
}
}
/** Parse a JSON-like argument, handling unquoted keys. */
function parseJsonArg(str: string): Record<string, any> {
let normalized = str.trim()
// Convert JavaScript-style object to JSON: unquoted keys → quoted
normalized = normalized.replace(
/(?<=\{|,)\s*(\w+)\s*:/g,
' "$1":',
)
// Handle single-quoted strings → double-quoted
normalized = normalized.replace(/'/g, '"')
try {
return JSON.parse(normalized)
} catch {
throw new Error(`Failed to parse JSON: ${str}`)
}
}
/** Find the index of the first comma not inside braces/brackets/quotes. */
function findTopLevelComma(str: string): number {
let depth = 0
let inString = false
let quote = ''
for (let i = 0; i < str.length; i++) {
const ch = str[i]
if (inString) {
if (ch === '\\') {
i++
continue
}
if (ch === quote) inString = false
continue
}
if (ch === '"' || ch === "'") {
inString = true
quote = ch
continue
}
if (ch === '{' || ch === '[' || ch === '(') depth++
if (ch === '}' || ch === ']' || ch === ')') depth--
if (ch === ',' && depth === 0) return i
}
return -1
}
function splitTopLevel(str: string): string[] {
const result: string[] = []
let start = 0
let depth = 0
let inString = false
let quote = ''
for (let i = 0; i < str.length; i++) {
const ch = str[i]
if (inString) {
if (ch === '\\') {
i++
continue
}
if (ch === quote) inString = false
continue
}
if (ch === '"' || ch === "'") {
inString = true
quote = ch
continue
}
if (ch === '{' || ch === '[' || ch === '(') depth++
if (ch === '}' || ch === ']' || ch === ')') depth--
if (ch === ',' && depth === 0) {
result.push(str.slice(start, i))
start = i + 1
}
}
result.push(str.slice(start))
return result
}
function countNodes(nodes: PenNode[]): number {
let count = 0
for (const node of nodes) {
count++
if ('children' in node && node.children) {
count += countNodes(node.children)
}
}
return count
}

View file

@ -0,0 +1,89 @@
import { resolve } from 'node:path'
import { openDocument } from '../document-manager'
import {
findNodeInTree,
searchNodes,
readNodeWithDepth,
} from '../utils/node-operations'
import type { PenNode } from '../../types/pen'
export interface SearchPattern {
type?: string
name?: string
reusable?: boolean
}
export interface BatchGetParams {
filePath: string
patterns?: SearchPattern[]
nodeIds?: string[]
parentId?: string
readDepth?: number
searchDepth?: number
}
export async function handleBatchGet(
params: BatchGetParams,
): Promise<{ nodes: Record<string, unknown>[] }> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
const readDepth = params.readDepth ?? 1
const searchDepth = params.searchDepth ?? Infinity
// If no patterns or nodeIds, return top-level children
if (!params.patterns?.length && !params.nodeIds?.length) {
const rootNodes = params.parentId
? (() => {
const parent = findNodeInTree(doc.children, params.parentId)
return parent && 'children' in parent && parent.children
? parent.children
: []
})()
: doc.children
return {
nodes: rootNodes.map((n) => readNodeWithDepth(n, readDepth)),
}
}
const results: PenNode[] = []
const seen = new Set<string>()
// Search by patterns
if (params.patterns?.length) {
const searchRoot = params.parentId
? (() => {
const parent = findNodeInTree(doc.children, params.parentId)
return parent && 'children' in parent && parent.children
? parent.children
: []
})()
: doc.children
for (const pattern of params.patterns) {
const found = searchNodes(searchRoot, pattern, searchDepth)
for (const node of found) {
if (!seen.has(node.id)) {
seen.add(node.id)
results.push(node)
}
}
}
}
// Read by IDs
if (params.nodeIds?.length) {
for (const id of params.nodeIds) {
if (seen.has(id)) continue
const node = findNodeInTree(doc.children, id)
if (node) {
seen.add(id)
results.push(node)
}
}
}
return {
nodes: results.map((n) => readNodeWithDepth(n, readDepth)),
}
}

View file

@ -0,0 +1,64 @@
import { resolve } from 'node:path'
import { openDocument } from '../document-manager'
import { getNodeBounds, findNodeInTree } from '../utils/node-operations'
import type { PenNode } from '../../types/pen'
export interface FindEmptySpaceParams {
filePath: string
width: number
height: number
padding?: number
direction: 'top' | 'right' | 'bottom' | 'left'
nodeId?: string
}
export interface FindEmptySpaceResult {
x: number
y: number
}
export async function handleFindEmptySpace(
params: FindEmptySpaceParams,
): Promise<FindEmptySpaceResult> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
const padding = params.padding ?? 50
// Compute bounding box of reference content
let nodes: PenNode[]
if (params.nodeId) {
const node = findNodeInTree(doc.children, params.nodeId)
if (!node) throw new Error(`Node not found: ${params.nodeId}`)
nodes = [node]
} else {
nodes = doc.children
}
if (nodes.length === 0) {
return { x: 0, y: 0 }
}
// Compute combined bounding box
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of nodes) {
const b = getNodeBounds(node, doc.children)
minX = Math.min(minX, b.x)
minY = Math.min(minY, b.y)
maxX = Math.max(maxX, b.x + b.w)
maxY = Math.max(maxY, b.y + b.h)
}
switch (params.direction) {
case 'right':
return { x: maxX + padding, y: minY }
case 'left':
return { x: minX - padding - params.width, y: minY }
case 'bottom':
return { x: minX, y: maxY + padding }
case 'top':
return { x: minX, y: minY - padding - params.height }
}
}

View file

@ -0,0 +1,57 @@
import { resolve } from 'node:path'
import {
openDocument,
createEmptyDocument,
saveDocument,
fileExists,
} from '../document-manager'
import type { PenDocument } from '../../types/pen'
export interface OpenDocumentParams {
filePath?: string
}
export interface OpenDocumentResult {
filePath: string
document: {
version: string
name?: string
childCount: number
hasVariables: boolean
hasThemes: boolean
}
}
export async function handleOpenDocument(
params: OpenDocumentParams,
): Promise<OpenDocumentResult> {
let filePath: string
let doc: PenDocument
if (params.filePath) {
filePath = resolve(params.filePath)
const exists = await fileExists(filePath)
if (exists) {
doc = await openDocument(filePath)
} else {
// Create new file at specified path
doc = createEmptyDocument()
await saveDocument(filePath, doc)
}
} else {
throw new Error(
'filePath is required. Provide a path to an existing .op file or a new file to create.',
)
}
return {
filePath,
document: {
version: doc.version,
name: doc.name,
childCount: doc.children.length,
hasVariables: !!doc.variables && Object.keys(doc.variables).length > 0,
hasThemes: !!doc.themes && Object.keys(doc.themes).length > 0,
},
}
}

View file

@ -0,0 +1,44 @@
import { resolve } from 'node:path'
import { openDocument } from '../document-manager'
import { computeLayoutTree, type LayoutEntry } from '../utils/node-operations'
export interface SnapshotLayoutParams {
filePath: string
parentId?: string
maxDepth?: number
}
export async function handleSnapshotLayout(
params: SnapshotLayoutParams,
): Promise<{ layout: LayoutEntry[] }> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
const maxDepth = params.maxDepth ?? 1
let nodes = doc.children
if (params.parentId) {
const findNode = (
list: typeof nodes,
id: string,
): (typeof nodes)[0] | undefined => {
for (const n of list) {
if (n.id === id) return n
if ('children' in n && n.children) {
const found = findNode(n.children, id)
if (found) return found
}
}
return undefined
}
const parent = findNode(doc.children, params.parentId)
if (!parent) {
throw new Error(`Node not found: ${params.parentId}`)
}
nodes =
'children' in parent && parent.children ? parent.children : []
}
const layout = computeLayoutTree(nodes, doc.children, maxDepth)
return { layout }
}

View file

@ -0,0 +1,40 @@
import { resolve } from 'node:path'
import { openDocument, saveDocument } from '../document-manager'
import type { VariableDefinition } from '../../types/variables'
export interface GetVariablesParams {
filePath: string
}
export interface SetVariablesParams {
filePath: string
variables: Record<string, VariableDefinition>
replace?: boolean
}
export async function handleGetVariables(
params: GetVariablesParams,
): Promise<{ variables: Record<string, VariableDefinition>; themes: Record<string, string[]> }> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
return {
variables: doc.variables ?? {},
themes: doc.themes ?? {},
}
}
export async function handleSetVariables(
params: SetVariablesParams,
): Promise<{ variables: Record<string, VariableDefinition> }> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
if (params.replace) {
doc.variables = params.variables
} else {
doc.variables = { ...(doc.variables ?? {}), ...params.variables }
}
await saveDocument(filePath, doc)
return { variables: doc.variables }
}

5
src/mcp/utils/id.ts Normal file
View file

@ -0,0 +1,5 @@
import { nanoid } from 'nanoid'
export function generateId(): string {
return nanoid()
}

View file

@ -0,0 +1,250 @@
import type { PenNode, RefNode } from '../../types/pen'
export function findNodeInTree(
nodes: PenNode[],
id: string,
): PenNode | undefined {
for (const node of nodes) {
if (node.id === id) return node
if ('children' in node && node.children) {
const found = findNodeInTree(node.children, id)
if (found) return found
}
}
return undefined
}
export function findParentInTree(
nodes: PenNode[],
id: string,
): PenNode | undefined {
for (const node of nodes) {
if ('children' in node && node.children) {
for (const child of node.children) {
if (child.id === id) return node
}
const found = findParentInTree(node.children, id)
if (found) return found
}
}
return undefined
}
export function removeNodeFromTree(nodes: PenNode[], id: string): PenNode[] {
return nodes
.filter((n) => n.id !== id)
.map((n) => {
if ('children' in n && n.children) {
return { ...n, children: removeNodeFromTree(n.children, id) }
}
return n
})
}
export function updateNodeInTree(
nodes: PenNode[],
id: string,
updates: Partial<PenNode>,
): PenNode[] {
return nodes.map((n) => {
if (n.id === id) {
return { ...n, ...updates } as PenNode
}
if ('children' in n && n.children) {
return {
...n,
children: updateNodeInTree(n.children, id, updates),
} as PenNode
}
return n
})
}
export function insertNodeInTree(
nodes: PenNode[],
parentId: string | null,
node: PenNode,
index?: number,
): PenNode[] {
if (parentId === null) {
const arr = [...nodes]
if (index !== undefined) {
arr.splice(index, 0, node)
} else {
arr.push(node)
}
return arr
}
return nodes.map((n) => {
if (n.id === parentId) {
const children = 'children' in n && n.children ? [...n.children] : []
if (index !== undefined) {
children.splice(index, 0, node)
} else {
children.push(node)
}
return { ...n, children } as PenNode
}
if ('children' in n && n.children) {
return {
...n,
children: insertNodeInTree(n.children, parentId, node, index),
} as PenNode
}
return n
})
}
export function flattenNodes(nodes: PenNode[]): PenNode[] {
const result: PenNode[] = []
for (const node of nodes) {
result.push(node)
if ('children' in node && node.children) {
result.push(...flattenNodes(node.children))
}
}
return result
}
export function cloneNodeWithNewIds(
node: PenNode,
generateId: () => string,
): PenNode {
const cloned = { ...node, id: generateId() } as PenNode
if ('children' in cloned && cloned.children) {
cloned.children = cloned.children.map((c) =>
cloneNodeWithNewIds(c, generateId),
)
}
return cloned
}
/** Get the bounding box of a node, resolving RefNode dimensions from component. */
export function getNodeBounds(
node: PenNode,
allNodes: PenNode[],
): { x: number; y: number; w: number; h: number } {
const x = node.x ?? 0
const y = node.y ?? 0
let w = 'width' in node && typeof node.width === 'number' ? node.width : 0
let h = 'height' in node && typeof node.height === 'number' ? node.height : 0
if (node.type === 'ref' && !w) {
const refComp = findNodeInTree(allNodes, (node as RefNode).ref)
if (refComp) {
w =
'width' in refComp && typeof refComp.width === 'number'
? refComp.width
: 100
h =
'height' in refComp && typeof refComp.height === 'number'
? refComp.height
: 100
}
}
return { x, y, w: w || 100, h: h || 100 }
}
/**
* Search nodes matching a pattern. Supports:
* - type: exact match on node type
* - name: regex match on node name
* - reusable: match reusable flag
*/
export function searchNodes(
nodes: PenNode[],
pattern: { type?: string; name?: string; reusable?: boolean },
maxDepth = Infinity,
currentDepth = 0,
): PenNode[] {
if (currentDepth > maxDepth) return []
const results: PenNode[] = []
for (const node of nodes) {
let matches = true
if (pattern.type && node.type !== pattern.type) matches = false
if (pattern.name) {
const regex = new RegExp(pattern.name, 'i')
if (!regex.test(node.name ?? '')) matches = false
}
if (pattern.reusable !== undefined) {
const isReusable = 'reusable' in node && (node as any).reusable === true
if (pattern.reusable !== isReusable) matches = false
}
if (matches) results.push(node)
if ('children' in node && node.children) {
results.push(
...searchNodes(node.children, pattern, maxDepth, currentDepth + 1),
)
}
}
return results
}
/** Read a node with depth-limited children. */
export function readNodeWithDepth(
node: PenNode,
depth: number,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...node }
if (depth <= 0 && 'children' in node && node.children?.length) {
result.children = '...'
} else if ('children' in node && node.children) {
result.children = node.children.map((c) =>
readNodeWithDepth(c, depth - 1),
)
}
return result
}
/** Compute bounding box layout tree for snapshot_layout. */
export function computeLayoutTree(
nodes: PenNode[],
allNodes: PenNode[],
maxDepth: number,
currentDepth = 0,
parentX = 0,
parentY = 0,
): LayoutEntry[] {
const entries: LayoutEntry[] = []
for (const node of nodes) {
const bounds = getNodeBounds(node, allNodes)
const absX = parentX + bounds.x
const absY = parentY + bounds.y
const entry: LayoutEntry = {
id: node.id,
name: node.name,
type: node.type,
x: absX,
y: absY,
width: bounds.w,
height: bounds.h,
}
if (
'children' in node &&
node.children?.length &&
currentDepth < maxDepth
) {
entry.children = computeLayoutTree(
node.children,
allNodes,
maxDepth,
currentDepth + 1,
absX,
absY,
)
}
entries.push(entry)
}
return entries
}
export interface LayoutEntry {
id: string
name?: string
type: string
x: number
y: number
width: number
height: number
children?: LayoutEntry[]
}

View file

@ -1,70 +1,117 @@
const PEN_NODE_SCHEMA = `
PenNode types (the ONLY format you output for designs):
- frame: Container. Props: width, height, layout ('none'|'vertical'|'horizontal'), gap, padding, justifyContent ('start'|'center'|'end'|'space_between'|'space_around'), alignItems ('start'|'center'|'end'), children[], cornerRadius, fill, stroke, effects
- frame: Container. Props: width, height, layout ('none'|'vertical'|'horizontal'), gap, padding, justifyContent ('start'|'center'|'end'|'space_between'|'space_around'), alignItems ('start'|'center'|'end'), clipContent (boolean, clips overflowing children), children[], cornerRadius, fill, stroke, effects
- rectangle: Props: width, height, cornerRadius, fill, stroke, effects
- ellipse: Props: width, height, fill, stroke, effects
- text: Props: content (string), fontFamily, fontSize, fontWeight, fill, width, height, textAlign
- text: Props: content (string), fontFamily, fontSize, fontWeight, fontStyle ('normal'|'italic'), fill, width, height, textAlign, textGrowth ('auto'|'fixed-width'|'fixed-width-height'), lineHeight (number, multiplier e.g. 1.2), letterSpacing (number, px), textAlignVertical ('top'|'middle'|'bottom')
- path: SVG icon/shape. Props: d (SVG path string), width, height, fill, stroke, effects. IMPORTANT: width and height must match the natural aspect ratio of the SVG path do NOT force 1:1 for non-square icons/logos
- image: Raster image. Props: src (URL string), width, height, cornerRadius, effects
All nodes share: id (string), type, name, x, y, rotation, opacity
SIZING: width/height accept number (px), "fill_container" (stretch to fill parent), or "fit_content" (shrink to content).
- In vertical layout: "fill_container" width = stretch horizontally, "fill_container" height = grow to fill remaining vertical space.
- In horizontal layout: "fill_container" width = grow to fill remaining horizontal space, "fill_container" height = stretch vertically.
- "fit_content" = shrink-wrap to the size of children content.
PADDING: number (uniform), [vertical, horizontal] (e.g. [0, 80] for side padding), or [top, right, bottom, left].
CLIP CONTENT: set clipContent: true on frames to clip children that overflow. Use with cornerRadius to prevent children from poking out of rounded corners. Essential for cards with images + cornerRadius.
Fill = [{ type: "solid", color: "#hex" }] or [{ type: "linear_gradient", angle: number, stops: [{ offset: 0-1, color: "#hex" }] }]
Stroke = { thickness: number, fill: [{ type: "solid", color: "#hex" }] }
Effects = [{ type: "shadow", offsetX, offsetY, blur, spread, color }]
TEXT RESIZING (textGrowth):
- "auto" = Auto Width: text expands horizontally, no word wrapping. Best for short labels, buttons, single-line text.
- "fixed-width" = Auto Height: width is fixed (or "fill_container"), height auto-sizes to wrapped content. Best for paragraphs, descriptions, multi-line text.
- "fixed-width-height" = Fixed Size: both width and height are fixed. Content clips if too long.
- DEFAULT RULE: text inside layout frames should use textGrowth="fixed-width" + width="fill_container". This ensures text wraps within the parent and height auto-sizes.
- Short labels/buttons can omit textGrowth (defaults to "auto").
TEXT TYPOGRAPHY:
- lineHeight: multiplier (e.g. 1.2 = 120%). Defaults: display/heading 1.1-1.2, body 1.4-1.6, captions 1.3. Always set lineHeight on text nodes.
- letterSpacing: px value. Defaults: 0 for body, -0.5 to -1 for large headlines (tighter), 0.5-2 for uppercase labels/captions (looser). Set when it improves readability.
- textAlignVertical: 'top' (default), 'middle', 'bottom'. Use 'middle' for text centered in fixed-height containers like buttons or badges.
RULES:
- cornerRadius is a number, NOT an object
- fill is ALWAYS an array
- Children inside layout frames MUST have explicit numeric width and height
- Do NOT set x/y on children inside layout frames the engine positions them
- Only set x/y on the ROOT frame
- Use "fill_container" as width/height string for children that should stretch to fill their parent
- Use "fill_container" to stretch, "fit_content" to shrink-wrap
- Use clipContent: true on cards/containers with cornerRadius + image children to prevent overflow
- Use justifyContent="space_between" to spread items across full width (great for navbars, footers)
OVERFLOW PREVENTION (CRITICAL violations cause visual glitches):
- TEXT WIDTH: text nodes inside layout frames MUST use width="fill_container" + textGrowth="fixed-width". NEVER set a fixed pixel width on text inside a layout it WILL overflow. The layout engine auto-constrains fill_container text to the parent's available content area.
BAD: {"type":"text","width":378,"textGrowth":"fixed-width"} inside a 195px card with 80px padding overflows!
GOOD: {"type":"text","width":"fill_container","textGrowth":"fixed-width"} auto-fits to 115px available space.
- CHILD SIZE: any child with a fixed pixel width must be parent's content area (parent width total horizontal padding). If unsure, use "fill_container".
- CJK TEXT (Chinese/Japanese/Korean): each character renders at ~1.0× fontSize width. For buttons/badges containing CJK text, ensure: container width (charCount × fontSize) + total horizontal padding. Example: "免费下载" (4 chars) at fontSize 15 needs ~60px content + padding button width 104px with padding [8,22].
- BADGES: when badge text is long or CJK, use width="fit_content" on the badge frame so it auto-sizes to its text content.
`
const DESIGN_EXAMPLES = `
EXAMPLES:
Button with icon:
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 180, "height": 44, "cornerRadius": 8, "layout": "horizontal", "gap": 8, "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowIcon", "d": "M5 12h14M12 5l7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "width": 80, "height": 22, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 180, "height": 48, "cornerRadius": 8, "layout": "horizontal", "gap": 8, "padding": [12, 24], "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowRightIcon", "d": "M5 12h14m-7-7 7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
Card with image:
{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 340, "cornerRadius": 12, "layout": "vertical", "gap": 0, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-img", "type": "image", "name": "Cover", "src": "https://picsum.photos/320/180", "width": 320, "height": 180 }, { "id": "card-body", "type": "frame", "name": "Body", "width": 320, "height": 140, "layout": "vertical", "padding": 20, "gap": 8, "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "width": 280, "height": 28, "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "width": 280, "height": 20, "fill": [{ "type": "solid", "color": "#6B7280" }] }] }] }
Card with image (clipContent prevents image from poking out of rounded corners):
{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 340, "cornerRadius": 12, "clipContent": true, "layout": "vertical", "gap": 0, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-img", "type": "image", "name": "Cover", "src": "https://picsum.photos/320/180", "width": "fill_container", "height": 180 }, { "id": "card-body", "type": "frame", "name": "Body", "width": "fill_container", "height": "fit_content", "layout": "vertical", "padding": 20, "gap": 8, "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "lineHeight": 1.2, "textGrowth": "fixed-width", "width": "fill_container", "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "lineHeight": 1.5, "textGrowth": "fixed-width", "width": "fill_container", "fill": [{ "type": "solid", "color": "#6B7280" }] }] }] }
ICONS & IMAGES:
- Icons: Use "path" nodes with SVG d attribute. Use stroke for line icons, fill for solid icons. Size 16-24px for UI icons. IMPORTANT: width and height must match the SVG path's natural aspect ratio symmetric icons like arrows are square, but brand logos (Apple, Meta, etc.) are often taller than wide or vice versa. Never force all icons to 1:1.
- Images: Use "image" nodes. src = "https://picsum.photos/{width}/{height}" for placeholders. Set explicit width/height.
- You know many Lucide icon SVG paths use them freely. Always give icon nodes descriptive names.
- Icons: Use "path" nodes with SVG d attribute. Size 16-24px for UI icons. Use stroke for line icons (Lucide-style), fill for solid icons.
IMPORTANT: Give icon nodes a descriptive name matching standard icon names (e.g. "SearchIcon", "MenuIcon", "ArrowRightIcon", "CheckIcon", "StarIcon", "DownloadIcon", "PlayIcon", "ShieldIcon", "ZapIcon", "HeartIcon", "UserIcon", "HomeIcon", "MailIcon", "BellIcon", "SettingsIcon", "PlusIcon", "EyeIcon", "LockIcon", "PhoneIcon", "ChevronRightIcon", "ChevronDownIcon", "XIcon").
The system will auto-resolve icon names to verified SVG paths, so the name is more important than the d data.
- Never use emoji characters as icons (e.g. 🧠📱). Always use path nodes for icons.
- For app screenshot/mockup areas, use a phone placeholder frame with solid fill matching the page theme + 1px subtle stroke. cornerRadius ~32. No text inside just a clean phone shape.
- Do NOT use random real-world app screenshots or dense mini-app simulations for showcase sections.
`
const INDUSTRIAL_DESIGN_SYSTEM = `
INDUSTRIAL DESIGN SYSTEM (Dark / Technical / Terminal-inspired)
const ADAPTIVE_STYLE_POLICY = `
VISUAL STYLE POLICY:
- Do NOT force a dark black+green palette unless the user explicitly asks for it.
- Infer style from user intent and content:
- If user requests dark/cyber/terminal, use dark themes.
- Otherwise default to a clean light marketing style.
COLORS:
- Page Bg: #16171B (Near-black)
- Card Bg: #1E2026 (Dark charcoal)
- Text Primary: #F4F4F5
- Text Secondary: #52525B
- Text Tertiary: #71717A
- Accent Primary: #22C55E (Terminal Green - Active/Success)
- Accent Warning: #F59E0B (Amber)
- Borders: #2A2B30 (Standard), #22C55E (Active)
DEFAULT LIGHT PALETTE (when no explicit style is requested):
- Page Bg: #F8FAFC
- Surface/Card: #FFFFFF
- Text Primary: #0F172A
- Text Secondary: #475569
- Accent Primary: #2563EB
- Accent Secondary: #0EA5E9
- Border: #E2E8F0
TYPOGRAPHY:
- Headlines: "Space Grotesk" (Bold 700)
- Data/Labels: "Roboto Mono" (Systematic)
- Body: "Inter"
TYPOGRAPHY SCALE (always set lineHeight on text nodes):
- Display: 40-56px (hero headlines) "Space Grotesk" or "Manrope" (700), lineHeight: 1.1, letterSpacing: -0.5
- Heading: 28-36px (section titles) "Space Grotesk" or "Manrope" (600-700), lineHeight: 1.2
- Subheading: 20-24px "Inter" (600), lineHeight: 1.3
- Body: 16-18px "Inter" (400-500), lineHeight: 1.5
- Caption: 13-14px "Inter" (400), lineHeight: 1.4
- Labels/Numbers: "Inter" or "Roboto Mono" as needed
- Uppercase labels: letterSpacing: 1-2
SHAPES:
- Corner Radius: 4px (Sharp, industrial)
- Tab Bar: 100px (Pill shape)
- Shadows: NONE (Use 1px borders)
CJK TYPOGRAPHY (Chinese/Japanese/Korean content):
- When the design content is in Chinese/Japanese/Korean, use CJK-compatible fonts:
- Headings: "Noto Sans SC" (Chinese), "Noto Sans JP" (Japanese), "Noto Sans KR" (Korean)
- Body/UI: "Inter" (has system CJK fallback) or "Noto Sans SC"
- DO NOT use "Space Grotesk" or "Manrope" for CJK text these fonts have NO CJK glyphs and will render inconsistently.
- CJK lineHeight: use 1.3-1.4 for headings (not 1.1), 1.6-1.8 for body. CJK characters are taller and need more line spacing.
- CJK letterSpacing: use 0 for body, 0.5-1 for headings. Do NOT use negative letterSpacing on CJK it causes characters to overlap.
- Detect language from the user's request content: if the prompt or product description is in Chinese/Japanese/Korean, use CJK fonts for ALL text nodes.
COMPONENTS:
- Section Headers: "// SECTION_NAME" (Code comment style, Roboto Mono)
- Cards: Flat, 1px border, #1E2026 bg, 4px radius
- Status Indicators: Terminal green dots/dashes
- Navigation: Pill-shaped bottom bar
SHAPES & EFFECTS:
- Corner Radius: 8-14 for modern product UI
- Use subtle shadows when appropriate; avoid heavy glow by default
- Keep hierarchy clear with spacing and contrast
LANDING PAGE DESIGN TIPS:
- Hero sections: gradient or bold color backgrounds, large headline, generous whitespace (80-120px padding)
- Section rhythm: alternate backgrounds for visual separation, 80-120px vertical padding per section
- Cards: consistent corner radius (12-16px), clipContent: true, subtle shadows, grouped content
- CTAs: bold accent color, generous padding (16-20px v, 32-48px h), clear action text
- Centered content width ~1040-1160px across sections for alignment stability
`
// Safe code block delimiter
@ -82,50 +129,66 @@ NEVER output HTML, CSS, or React code — ONLY PenNode JSON.
NEVER use tools, functions, or external calls. Design everything URSELF in the response.
NEVER say "I will create..." or "Here is the design..." START DIRECTLY WITH <step>.
PROCESS VISUALIZATION (Deep Agent Simulation):
You MUST output your thought process as structured XML steps BEFORE the final JSON.
Follow this EXACT sequence of steps:
1. <step title="Checking guidelines">
[Analyze the request against the Industrial Design System. Quote specific rules you will follow.]
</step>
2. <step title="Getting editor state">
[Simulate checking the context. Mention "pencil-new.pen" and "No reusable components found".]
</step>
3. <step title="Picked a styleguide">
[Briefly summarize the chosen style: "Industrial × Technical Mobile Dashboard". Mention "Space Grotesk", "Roboto Mono", and "Terminal Green".]
</step>
4. <step title="Design">
[The final generation step. Describe the high-level layout structure you are building: "Building layout with sidebar, header, and KPI section..."]
</step>
You may include 1-2 brief <step> tags before the JSON (optional, keep them SHORT one line each).
Start generating JSON as quickly as possible minimize preamble.
When a user asks non-design questions (explain, suggest colors, give advice), respond in text.
${INDUSTRIAL_DESIGN_SYSTEM}
${ADAPTIVE_STYLE_POLICY}
${DESIGN_EXAMPLES}
LAYOUT ENGINE:
LAYOUT ENGINE (flexbox-based):
- Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems
- Do NOT set x/y on children inside layout containers
- Every child in a layout frame MUST have explicit numeric width and height
- NEVER set x/y on children inside layout containers the engine positions them automatically
- CHILD SIZE RULE: child width must be parent content area. Use "fill_container" when in doubt.
- SIZING: width/height accept: number (px), "fill_container" (stretch to fill parent), "fit_content" (shrink-wrap to content size).
In vertical layout: "fill_container" width stretches horizontally; "fill_container" height fills remaining vertical space.
In horizontal layout: "fill_container" width fills remaining horizontal space; "fill_container" height stretches vertically.
- PADDING: number (uniform), [vertical, horizontal] (e.g. [0, 80]), or [top, right, bottom, left].
- CLIP CONTENT: set clipContent: true to clip children that overflow the frame. ALWAYS use on cards with cornerRadius + image children.
- FLEX DISTRIBUTION via justifyContent:
"space_between" = push items to edges with equal gaps between (ideal for navbars: logo | links | CTA)
"space_around" = equal space around each item
"center" = center-pack items
"start"/"end" = pack to start/end
- 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 input/button uses "fill_container", ALL sibling inputs/buttons must also use "fill_container". Mixing fixed-px and fill_container causes misalignment.
- NEVER use "fill_container" on children of a "fit_content" parent circular dependency breaks layout.
- For two-column layouts: root (vertical) content row (horizontal) left column + right column. Each column uses "fill_container" width.
- TEXT IN LAYOUTS (MOST COMMON BUG read carefully): text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER use a fixed pixel width (e.g. width:378) on text inside a layout the text WILL overflow its parent. "fill_container" auto-constrains to available space.
- SHORT TEXT: buttons, labels, single-line text can use textGrowth="auto" (or omit it) text expands horizontally to fit content.
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes (e.g. height:22, height:44). OMIT the height property entirely the layout engine auto-calculates height from textGrowth + content. Setting a small height causes text clipping and overlap with siblings below.
- CJK BUTTONS/BADGES: Chinese/Japanese/Korean characters are wider. For a button with CJK text, ensure container width (charCount × fontSize) + horizontal padding. Example: "免费下载" (4 chars) at 15px min content width ~60px button width 60 + left padding + right padding.
- Use nested frames for complex layouts
DESIGN GUIDELINES:
- Mobile screens: root frame 375x812 at x:0,y:0. Web: 1200x800
- Mobile screens: 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 no floating elements
- For web pages, use a consistent centered content container (~1040-1160px) across sections to keep alignment stable
- Max 3-4 levels of nesting
- Text: titles 22-28px bold, body 14-16px, captions 12px
- Buttons: height 44-48px, cornerRadius 8-12
- Inputs: height 44px, light bg, subtle border
- Buttons: height 44-52px, cornerRadius 8-12, padding [12, 24] (vertical, horizontal). With icon+text: layout="horizontal", gap=8, alignItems="center". Width: "fill_container" (stretch), "fit_content" (hug), or fixed px choose per context.
- Icon-only buttons (heart, bookmark, share, etc.): square frame 44x44px, justifyContent="center", alignItems="center", path icon 20-24px inside.
- Badges/tags ("NEW", "SALE", "PRO"): frame with padding [4, 12], cornerRadius 4-6, height="fit_content". Small text (11-13px), no textGrowth. Never clip badge text.
- Button + icon-button row: horizontal, gap=8-12. Primary button width="fill_container"; icon-only button fixed square 44-48px.
- Inputs: height 44px, light bg, subtle border. Use width="fill_container" in form contexts.
- Fixed-width children must NOT exceed their parent's content area (parent width minus padding).
- Consistent color palette
- Use path nodes for icons (SVG d path data). Size icons 16-24px. Preserve the natural aspect ratio of the SVG path do NOT force all icons to square
- Use image nodes for photos/illustrations with picsum.photos placeholder URLs
- Default to light neutral styling unless user explicitly asks for dark/neon/terminal
- Avoid repeating the exact same palette across unrelated designs
- Navigation bars (when designing landing pages/websites): use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button), padding=[0,80], alignItems="center". This auto-distributes them perfectly across the full width.
- Icons: use "path" nodes with descriptive names matching standard icon names (e.g. "SearchIcon", "MenuIcon", "ArrowRightIcon"). The system auto-resolves names to verified SVG paths. Size 16-24px.
- Never use emoji glyphs as icon substitutes. If an icon is needed, use a path node with a descriptive icon name.
- Use image nodes for generic photos/illustrations only; for app preview areas prefer phone mockup placeholders
- Phone mockup/screenshot placeholder: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill matching theme + 1px subtle stroke. NEVER use ellipse or circle for mockups. NEVER add any children inside (no text, no frames, no images). All mockups must look identical.
- NEVER use ellipse nodes for decorative/placeholder shapes. Use frame or rectangle with cornerRadius instead.
- Avoid adding an extra full-width CTA strip directly under navigation unless the prompt explicitly asks for that section.
- Buttons, nav items, and list items should include icons when appropriate for better UX
- Long subtitles/body copy should use fixed-width text blocks so lines wrap naturally instead of becoming a single very long line.
- CARD ROW ALIGNMENT: when cards are siblings in a horizontal layout, ALL cards MUST use height="fill_container". This makes all cards match the tallest card's height, creating a visually aligned row. Never use different fixed heights on sibling cards.
- TEXT WRAPPING: any text content longer than ~15 characters MUST have textGrowth="fixed-width". Without it, text expands horizontally in a single line and overflows. Only omit textGrowth for very short labels (1-3 words) like button text or nav links.
DESIGN VARIABLES:
- When the user message includes a DOCUMENT VARIABLES section, use "$variableName" references instead of hardcoded values wherever a matching variable exists.
@ -133,70 +196,84 @@ DESIGN VARIABLES:
- Number variables: use for gap, padding, opacity. Example: "gap": "$spacing-md"
- Only reference variables that are listed do NOT invent new variable names.`
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON generation engine. Your ONLY job is to convert design descriptions into PenNode JSON.
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON streaming engine. Convert design descriptions into flat PenNode JSON, one element at a time.
${PEN_NODE_SCHEMA}
OUTPUT FORMAT:
1. Start immediately with <step title="Checking guidelines">...</step>.
2. You may include additional <step ...>...</step> lines.
3. Output progressive JSON blocks in sequence:
- Phase 1 (Structure): one ${BLOCK}json block with root frame + main layout skeleton.
- Phase 2 (Content): one ${BLOCK}json block that uses the SAME IDs and fills content/details.
- Phase 3 (Refine, optional): one ${BLOCK}json block for minor style/spacing fixes.
4. Add a 1-2 sentence summary after the final JSON block.
OUTPUT FORMAT ELEMENT-BY-ELEMENT STREAMING:
Each element is rendered to the canvas the INSTANT it finishes generating. Output flat JSON objects inside a single ${BLOCK}json block.
STEP 1 PLAN (required):
List ALL planned sections as <step> tags BEFORE the json block:
<step title="Navigation bar"></step>
<step title="Hero section"></step>
<step title="Feature cards"></step>
STEP 2 BUILD:
Output a ${BLOCK}json block containing flat JSON objects, ONE PER LINE.
Every node MUST have a "_parent" field:
- Root frame: "_parent": null
- All others: "_parent": "<parent-id>"
Output parent nodes BEFORE their children (depth-first order).
Each line = one complete JSON object. NO multi-line formatting. NO nested "children" arrays.
EXAMPLE:
<step title="Page structure"></step>
<step title="Navigation"></step>
<step title="Hero"></step>
${BLOCK}json
{"_parent":null,"id":"page","type":"frame","name":"Page","x":0,"y":0,"width":375,"height":812,"layout":"vertical","gap":0,"fill":[{"type":"solid","color":"#F8FAFC"}]}
{"_parent":"page","id":"nav","type":"frame","name":"Nav","width":"fill_container","height":56,"layout":"horizontal","padding":16,"alignItems":"center","fill":[{"type":"solid","color":"#FFFFFF"}]}
{"_parent":"nav","id":"logo","type":"text","name":"Logo","content":"App","fontSize":18,"fontWeight":700,"fill":[{"type":"solid","color":"#0F172A"}]}
{"_parent":"nav","id":"menu-icon","type":"path","name":"MenuIcon","d":"M4 6h16M4 12h16M4 18h16","width":24,"height":24,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#0F172A"}]}}
{"_parent":"page","id":"hero","type":"frame","name":"Hero","width":"fill_container","height":300,"layout":"vertical","padding":24,"gap":16,"alignItems":"center","justifyContent":"center"}
{"_parent":"hero","id":"title","type":"text","name":"Title","content":"Welcome","fontSize":28,"fontWeight":700,"textGrowth":"fixed-width","width":"fill_container","fill":[{"type":"solid","color":"#0F172A"}]}
${BLOCK}
CRITICAL RULES:
- Progressive generation is required when possible (at least 2 JSON blocks for UI screens).
- Reuse the same node IDs across phases so partial upsert can update existing nodes.
- Each later phase should be more complete than the previous one.
- After Phase 1, do NOT move already placed sections unless explicitly fixing overlap.
- Keep root frame x/y/width/height stable across phases.
- Use a single root frame containing ALL elements as children.
- Keep IDs unique and stable across all phases.
- DO NOT WRITE ANY INTRODUCTORY TEXT.
- DO NOT use nested "children" arrays each node is a FLAT JSON object with "_parent".
- ONE JSON object per line never split a node across lines.
- Output parent before children (depth-first).
- Root frame: "_parent": null, x:0, y:0.
- NEVER set x/y on children inside layout frames the layout engine positions them automatically.
- ALL nodes must be descendants of the root frame no floating/orphan elements.
- Section frames must use width="fill_container" to span full page width.
- WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one input uses "fill_container", ALL sibling inputs/buttons in that container must also use "fill_container". Never mix fixed-px and fill_container in form layouts.
- NEVER use "fill_container" on children of "fit_content" parent circular dependency breaks layout.
- For two-column content: use a horizontal frame parent with two child frames.
- Use clipContent: true on cards/containers with cornerRadius + image/overflow content. Essential for clean rounded corners.
- Use width/height (or "fill_container") on all children. Unique descriptive IDs. All colors as fill arrays.
- Start with <step> tags, then immediately the json block. NO preamble text.
- After the json block, add a 1-sentence summary.
- Phone mockup: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. NEVER use ellipse. NEVER add any children inside (no text, no frames, no images). All mockups identical.
- NEVER use ellipse for decorative/placeholder shapes use frame or rectangle with cornerRadius.
- Navigation bars (when applicable): justifyContent="space_between", 3 groups (logo | links | CTA), padding=[0,80], alignItems="center".
- Never use emoji as icons; use path nodes with descriptive icon names (system auto-resolves to verified SVG paths).
- TEXT IN LAYOUTS: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER use fixed pixel widths on text. Short labels/buttons can omit textGrowth.
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes (e.g. height:22). OMIT the height property the engine auto-calculates from textGrowth + content. A small explicit height causes text clipping and overlap.
- Cards with images: ALWAYS set clipContent: true + cornerRadius. Use "fill_container" width on image/body/text children inside the card.
- CARD ROW ALIGNMENT: cards in a horizontal row MUST ALL use width="fill_container" + height="fill_container" for even distribution and equal height. Never use different fixed heights on sibling cards it creates an ugly uneven row.
- TEXT WRAPPING: any text content longer than ~15 characters MUST have textGrowth="fixed-width" + width="fill_container". Without textGrowth, long text renders as ONE single line and overflows. Only omit textGrowth for very short labels (1-3 words).
- Keep section rhythm consistent (80-120px vertical padding) and preserve alignment between sections.
DO NOT output bullet points, design descriptions, or explanations BEFORE the JSON (except <step> tags).
DO NOT describe what you plan to create just CREATE IT as JSON.
DO NOT output HTML, CSS, or any code other than PenNode JSON.
OVERFLOW PREVENTION (CRITICAL #1 source of visual bugs):
- ALL text nodes inside layout frames width="fill_container" + textGrowth="fixed-width". No exceptions. NEVER width:378 or width:224 on text inside a layout frame.
- Fixed-width children must be parent content area (parent width horizontal padding). Example: a card width=195 with padding=[24,40,24,40] has 115px available a child with width=378 causes severe overflow.
- CJK (Chinese/Japanese/Korean) text in buttons: each CJK char fontSize wide. "免费下载" (4 chars) at fontSize 15 = ~60px minimum content width. Button must be 60 + horizontal padding.
- Badges with dynamic text: use width="fit_content" so the badge auto-expands to fit its text.
${DESIGN_EXAMPLES}
STRUCTURE:
- Single root frame containing ALL elements as children
- Root uses layout: "vertical" with gap and padding
- Sections are horizontal/vertical frames nested inside
- Max 4 levels of nesting
- Use gap and padding for spacing never manual x/y inside layout containers
- If you need absolute decorative blobs, place them in a non-layout wrapper frame ("layout: \"none\"")
SIZING:
- Mobile screens: root frame 375x812
- Web layouts: root frame 1200x800
- Every child MUST have explicit numeric width and height
- Use unique descriptive IDs
- All colors as fill arrays: [{ "type": "solid", "color": "#hex" }]
ICONS & IMAGES:
- Use "path" nodes for icons: provide SVG d attribute, set width/height (16-24px for UI icons), use stroke for line icons or fill for solid icons. Width and height MUST match the natural aspect ratio of the SVG path data do not squeeze non-square logos into square dimensions
- Use "image" nodes for photos/illustrations: set src to "https://picsum.photos/{width}/{height}" as placeholder, set explicit width/height
- Include icons in buttons, nav items, list items, cards for professional polish
- Reference the icon patterns in the examples section for common icons
VISUAL QUALITY GUARDRAILS:
- Keep all interactive content in a safe area (at least 20px left/right padding on mobile)
- Decorative blobs should be subtle and must not hide inputs/buttons/text
- Avoid oversized decorations outside the root frame (max ~10% bleed allowed)
- Do not use emoji in headings or body copy unless the user explicitly asks for it
SIZING: Mobile root 375x812. Web root 1200x800 (single screen) or 1200x3000-5000 (landing page).
ICONS: "path" nodes with descriptive names (e.g. "SearchIcon", "MenuIcon"). System auto-resolves to verified SVG paths. Size 16-24px.
IMAGES: for app showcase sections, prefer phone mockup placeholders over real screenshots.
STYLE: Default to light neutral palette unless user explicitly asks for dark/terminal/cyber. Avoid always reusing black+green.
DESIGN VARIABLES:
- When the user message includes a DOCUMENT VARIABLES section, use "$variableName" references instead of hardcoded values wherever a matching variable exists.
- Color variables: use in fill color, stroke color, shadow color. Example: [{ "type": "solid", "color": "$primary" }]
- Number variables: use for gap, padding, opacity. Example: "gap": "$spacing-md"
- Only reference variables that are listed do NOT invent new variable names.
- If no variables are provided, use hardcoded values as usual.
- If DOCUMENT VARIABLES are provided, use "$name" refs instead of hardcoded values.
- Only reference listed variables.
Design like a professional: visual hierarchy, contrast, whitespace, consistent palette, purposeful iconography.`
Design like a professional: hierarchy, contrast, whitespace, consistent palette.`
export const CODE_GENERATOR_PROMPT = `You are a code generation engine for OpenPencil. Convert PenNode design descriptions into clean, production-ready code.

View file

@ -0,0 +1,113 @@
export type ThinkingMode = 'adaptive' | 'disabled' | 'enabled'
export type ThinkingEffort = 'low' | 'medium' | 'high' | 'max'
export const DEFAULT_THINKING_MODE: ThinkingMode = 'adaptive'
export const DEFAULT_THINKING_EFFORT: ThinkingEffort = 'low'
export const DEFAULT_THINKING_CONFIG = {
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
} as const
export const CHAT_STREAM_THINKING_CONFIG = {
...DEFAULT_THINKING_CONFIG,
} as const
export const STREAM_TIMEOUT_MIN_MS = 10_000
export const DEFAULT_STREAM_HARD_TIMEOUT_MS = 600_000
export const DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS = 300_000
export const DEFAULT_GENERATE_TIMEOUT_MS = 180_000
export const PROMPT_OPTIMIZER_LIMITS = {
longPromptCharThreshold: 2200,
maxPromptCharsForOrchestrator: 2400,
maxPromptCharsForSubAgent: 3600,
maxFeatureLines: 12,
maxSectionLines: 14,
maxFallbackSections: 8,
} as const
export const SUB_AGENT_TIMEOUT_BASE = {
hardTimeoutMs: 420_000,
noTextTimeoutMs: 210_000,
thinkingResetsTimeout: true,
} as const
export const PROMPT_TIMEOUT_BUCKETS = {
mediumPromptMaxChars: 4200,
} as const
export const SUB_AGENT_TIMEOUT_PROFILES = {
short: {
...SUB_AGENT_TIMEOUT_BASE,
pingResetsTimeout: false,
firstTextTimeoutMs: 420_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
},
medium: {
...SUB_AGENT_TIMEOUT_BASE,
hardTimeoutMs: 600_000,
noTextTimeoutMs: 300_000,
pingResetsTimeout: false,
firstTextTimeoutMs: 600_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
},
long: {
...SUB_AGENT_TIMEOUT_BASE,
hardTimeoutMs: 900_000,
noTextTimeoutMs: 480_000,
pingResetsTimeout: false,
firstTextTimeoutMs: 900_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
},
} as const
export const ORCHESTRATOR_TIMEOUT_PROFILES = {
short: {
hardTimeoutMs: 300_000,
noTextTimeoutMs: 150_000,
thinkingResetsTimeout: true,
pingResetsTimeout: false,
firstTextTimeoutMs: 300_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
},
medium: {
hardTimeoutMs: 420_000,
noTextTimeoutMs: 210_000,
thinkingResetsTimeout: true,
pingResetsTimeout: false,
firstTextTimeoutMs: 420_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
},
long: {
hardTimeoutMs: 600_000,
noTextTimeoutMs: 300_000,
thinkingResetsTimeout: true,
pingResetsTimeout: false,
firstTextTimeoutMs: 600_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
},
} as const
export const DESIGN_STREAM_TIMEOUTS = {
hardTimeoutMs: 900_000,
noTextTimeoutMs: 480_000,
thinkingResetsTimeout: true,
pingResetsTimeout: false,
firstTextTimeoutMs: 900_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
} as const
export const RETRY_TIMEOUT_CONFIG = {
multiplier: 2,
hardTimeoutMaxMs: 1_200_000,
noTextTimeoutMaxMs: 480_000,
firstTextTimeoutMaxMs: 1_200_000,
} as const

View file

@ -1,12 +1,43 @@
import type { AIStreamChunk } from './ai-types'
import type { AIModelInfo } from '@/stores/ai-store'
const DEFAULT_STREAM_HARD_TIMEOUT_MS = 180_000
const DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS = 75_000
import {
DEFAULT_GENERATE_TIMEOUT_MS,
DEFAULT_STREAM_HARD_TIMEOUT_MS,
DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS,
STREAM_TIMEOUT_MIN_MS,
} from './ai-runtime-config'
interface StreamChatOptions {
hardTimeoutMs?: number
noTextTimeoutMs?: number
/**
* Whether thinking events should reset the no-text timeout.
* Default: true (backward compatible). Set to false for fast calls
* where thinking should NOT prevent the no-text timeout from firing.
*/
thinkingResetsTimeout?: boolean
/**
* Whether keep-alive ping events reset the no-text timeout.
* Default: true (backward compatible). Set to false to avoid endless
* waiting when the server only emits pings.
*/
pingResetsTimeout?: boolean
/**
* Max time to wait for the first non-empty text token.
* This timeout is independent from keep-alive pings/thinking chunks.
*/
firstTextTimeoutMs?: number
/**
* Controls provider thinking mode.
* - adaptive: model decides thinking depth
* - disabled: disable extended thinking for faster first text
* - enabled: explicitly enable extended thinking
*/
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
/** Thinking budget (used when thinkingMode === 'enabled'). */
thinkingBudgetTokens?: number
/** Model effort level (low is usually faster). */
effort?: 'low' | 'medium' | 'high' | 'max'
}
/**
@ -18,14 +49,21 @@ export async function* streamChat(
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
model?: string,
options?: StreamChatOptions,
provider?: string,
): AsyncGenerator<AIStreamChunk> {
const hardTimeoutMs = Math.max(10_000, options?.hardTimeoutMs ?? DEFAULT_STREAM_HARD_TIMEOUT_MS)
const noTextTimeoutMs = Math.max(10_000, options?.noTextTimeoutMs ?? DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS)
const hardTimeoutMs = Math.max(STREAM_TIMEOUT_MIN_MS, options?.hardTimeoutMs ?? DEFAULT_STREAM_HARD_TIMEOUT_MS)
const noTextTimeoutMs = Math.max(STREAM_TIMEOUT_MIN_MS, options?.noTextTimeoutMs ?? DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS)
const thinkingResetsTimeout = options?.thinkingResetsTimeout ?? true
const pingResetsTimeout = options?.pingResetsTimeout ?? true
const firstTextTimeoutMs = options?.firstTextTimeoutMs
? Math.max(STREAM_TIMEOUT_MIN_MS, options.firstTextTimeoutMs)
: null
const controller = new AbortController()
let abortReason: 'hard_timeout' | 'no_text_timeout' | null = null
let hasNonEmptyText = false
let abortReason: 'hard_timeout' | 'no_text_timeout' | 'first_text_timeout' | null = null
let noTextTimeout: ReturnType<typeof setTimeout> | null = null
let firstTextTimeout: ReturnType<typeof setTimeout> | null = null
let sawText = false
const clearNoTextTimeout = () => {
if (noTextTimeout) {
@ -34,23 +72,49 @@ export async function* streamChat(
}
}
const clearFirstTextTimeout = () => {
if (firstTextTimeout) {
clearTimeout(firstTextTimeout)
firstTextTimeout = null
}
}
const resetActivityTimeout = () => {
clearNoTextTimeout()
noTextTimeout = setTimeout(() => {
abortReason = 'no_text_timeout'
controller.abort()
}, noTextTimeoutMs)
}
const hardTimeout = setTimeout(() => {
abortReason = 'hard_timeout'
controller.abort()
}, hardTimeoutMs)
noTextTimeout = setTimeout(() => {
if (!hasNonEmptyText) {
abortReason = 'no_text_timeout'
if (firstTextTimeoutMs) {
firstTextTimeout = setTimeout(() => {
if (sawText) return
abortReason = 'first_text_timeout'
controller.abort()
}
}, noTextTimeoutMs)
}, firstTextTimeoutMs)
}
resetActivityTimeout()
try {
const response = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system: systemPrompt, messages, model }),
body: JSON.stringify({
system: systemPrompt,
messages,
model,
provider,
thinkingMode: options?.thinkingMode,
thinkingBudgetTokens: options?.thinkingBudgetTokens,
effort: options?.effort,
}),
signal: controller.signal,
})
@ -59,6 +123,7 @@ export async function* streamChat(
yield { type: 'error', content: `Server error: ${response.status} ${errBody}` }
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
return
}
@ -67,6 +132,7 @@ export async function* streamChat(
yield { type: 'error', content: 'No response stream available' }
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
return
}
@ -92,6 +158,7 @@ export async function* streamChat(
if (chunk.type === 'done') {
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
try {
await reader.cancel()
} catch {
@ -100,15 +167,11 @@ export async function* streamChat(
return
}
// Keep-alive pings from server — reset timeout but don't yield
// Keep-alive pings from server — reset activity timeout but don't yield
if (chunk.type === 'ping') {
clearNoTextTimeout()
noTextTimeout = setTimeout(() => {
if (!hasNonEmptyText) {
abortReason = 'no_text_timeout'
controller.abort()
}
}, noTextTimeoutMs)
if (pingResetsTimeout) {
resetActivityTimeout()
}
continue
}
@ -116,16 +179,21 @@ export async function* streamChat(
continue
}
// Any non-empty content (text or thinking) counts as activity
if ((chunk.type === 'text' || chunk.type === 'thinking') && chunk.content.trim().length > 0) {
hasNonEmptyText = true
clearNoTextTimeout()
// Any non-empty text counts as activity; thinking only resets
// the timeout when thinkingResetsTimeout is true (default).
if (chunk.type === 'text' && chunk.content.trim().length > 0) {
sawText = true
clearFirstTextTimeout()
resetActivityTimeout()
} else if (chunk.type === 'thinking' && chunk.content.trim().length > 0 && thinkingResetsTimeout) {
resetActivityTimeout()
}
yield chunk
if (chunk.type === 'error') {
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
try {
await reader.cancel()
} catch {
@ -149,21 +217,24 @@ export async function* streamChat(
if (chunk.type === 'done') {
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
return
}
if (chunk.type === 'thinking' && !chunk.content) {
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
return
}
if ((chunk.type === 'text' || chunk.type === 'thinking') && chunk.content.trim().length > 0) {
hasNonEmptyText = true
clearNoTextTimeout()
if (chunk.type === 'text' && chunk.content.trim().length > 0) {
sawText = true
clearFirstTextTimeout()
}
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
yield chunk
if (chunk.type === 'error') {
clearTimeout(hardTimeout)
clearNoTextTimeout()
return
}
} catch {
@ -183,6 +254,11 @@ export async function* streamChat(
type: 'error',
content: 'AI request timed out. Please retry.',
}
} else if (abortReason === 'first_text_timeout') {
yield {
type: 'error',
content: 'AI spent too long thinking without producing output. Request stopped, please retry.',
}
} else {
yield {
type: 'error',
@ -191,6 +267,7 @@ export async function* streamChat(
}
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
return
}
@ -200,6 +277,7 @@ export async function* streamChat(
} finally {
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
}
}
@ -207,12 +285,11 @@ export async function* streamChat(
* Non-streaming completion for design/code generation.
* Calls the server-side endpoint which reads ANTHROPIC_API_KEY from env.
*/
const DEFAULT_GENERATE_TIMEOUT_MS = 180_000
export async function generateCompletion(
systemPrompt: string,
userMessage: string,
model?: string,
provider?: string,
): Promise<string> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), DEFAULT_GENERATE_TIMEOUT_MS)
@ -222,7 +299,7 @@ export async function generateCompletion(
response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system: systemPrompt, message: userMessage, model }),
body: JSON.stringify({ system: systemPrompt, message: userMessage, model, provider }),
signal: controller.signal,
})
} catch (error) {

View file

@ -27,3 +27,74 @@ export interface AIStreamChunk {
type: 'text' | 'thinking' | 'done' | 'error' | 'ping'
content: string
}
// ---------------------------------------------------------------------------
// Orchestrator types — used for parallel sub-agent design generation
// ---------------------------------------------------------------------------
/** A sub-task produced by the orchestrator (lightweight — only spatial info) */
export interface SubTask {
/** Unique ID for this sub-task, e.g. "sidebar", "header" */
id: string
/** Human-readable label for progress UI */
label: string
/** Spatial region allocated on the canvas */
region: { width: number; height: number }
/** ID prefix for all nodes generated by this sub-agent (assigned at runtime) */
idPrefix: string
/** Parent frame ID to insert into (assigned at runtime) */
parentFrameId: string | null
}
/** Style guide produced by the orchestrator for visual consistency */
export interface StyleGuide {
palette: {
background: string
surface: string
text: string
secondary: string
accent: string
accent2: string
border: string
}
fonts: {
heading: string
body: string
}
aesthetic: string
}
/** Plan produced by the orchestrator (lightweight — only structure) */
export interface OrchestratorPlan {
rootFrame: {
id: string
name: string
width: number
height: number
layout?: 'none' | 'vertical' | 'horizontal'
gap?: number
fill?: Array<{ type: string; color: string }>
}
styleGuide?: StyleGuide
subtasks: SubTask[]
}
/** Progress state for the orchestrated generation */
export interface OrchestrationProgress {
phase: 'planning' | 'generating' | 'merging' | 'done' | 'error'
subtasks: Array<{
id: string
label: string
status: 'pending' | 'streaming' | 'done' | 'error'
nodeCount: number
}>
totalNodes: number
}
/** Result from a single sub-agent */
export interface SubAgentResult {
subtaskId: string
nodes: import('@/types/pen').PenNode[]
rawResponse: string
error?: string
}

View file

@ -0,0 +1,56 @@
/**
* Context optimization utilities for AI chat.
* Prevents unbounded growth of chat history and context size.
*/
const DEFAULT_MAX_MESSAGES = 10
const DEFAULT_MAX_CHARS = 32_000
/**
* Sliding window for chat history.
* Keeps the most recent messages while respecting character limits.
* Always preserves the first user message for context continuity.
*/
export function trimChatHistory<T extends { role: string; content: string }>(
messages: T[],
maxMessages: number = DEFAULT_MAX_MESSAGES,
maxChars: number = DEFAULT_MAX_CHARS,
): T[] {
if (messages.length <= maxMessages) {
const totalChars = messages.reduce((sum, m) => sum + m.content.length, 0)
if (totalChars <= maxChars) return messages
}
// Always keep the first user message for context continuity
const firstUser = messages.find((m) => m.role === 'user')
const recentMessages = messages.slice(-maxMessages)
const window: T[] = []
let charCount = 0
// Add first user message if it's not already in the recent window
if (firstUser && !recentMessages.includes(firstUser)) {
window.push(firstUser)
charCount += firstUser.content.length
}
// Add recent messages, respecting char limit
for (const msg of recentMessages) {
const msgChars = msg.content.length
if (charCount + msgChars > maxChars) {
// Truncate this message to fit
const remaining = maxChars - charCount
if (remaining > 200) {
window.push({
...msg,
content: msg.content.slice(0, remaining) + '\n[...truncated...]',
} as T)
}
break
}
window.push(msg)
charCount += msgChars
}
return window
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,409 @@
import type { OrchestratorPlan } from './ai-types'
import {
ORCHESTRATOR_TIMEOUT_PROFILES,
PROMPT_TIMEOUT_BUCKETS,
PROMPT_OPTIMIZER_LIMITS,
SUB_AGENT_TIMEOUT_PROFILES,
} from './ai-runtime-config'
export interface PreparedDesignPrompt {
original: string
orchestratorPrompt: string
subAgentPrompt: string
wasCompressed: boolean
originalLength: number
}
export function getSubAgentTimeouts(promptLength: number): {
hardTimeoutMs: number
noTextTimeoutMs: number
thinkingResetsTimeout: boolean
pingResetsTimeout: boolean
firstTextTimeoutMs: number
thinkingMode: 'adaptive' | 'disabled' | 'enabled'
effort: 'low' | 'medium' | 'high' | 'max'
} {
if (promptLength < PROMPT_OPTIMIZER_LIMITS.longPromptCharThreshold) {
return { ...SUB_AGENT_TIMEOUT_PROFILES.short }
}
if (promptLength < PROMPT_TIMEOUT_BUCKETS.mediumPromptMaxChars) {
return { ...SUB_AGENT_TIMEOUT_PROFILES.medium }
}
return { ...SUB_AGENT_TIMEOUT_PROFILES.long }
}
export function getOrchestratorTimeouts(promptLength: number): {
hardTimeoutMs: number
noTextTimeoutMs: number
thinkingResetsTimeout: boolean
pingResetsTimeout: boolean
firstTextTimeoutMs: number
thinkingMode: 'adaptive' | 'disabled' | 'enabled'
effort: 'low' | 'medium' | 'high' | 'max'
} {
if (promptLength < PROMPT_OPTIMIZER_LIMITS.longPromptCharThreshold) {
return { ...ORCHESTRATOR_TIMEOUT_PROFILES.short }
}
if (promptLength < PROMPT_TIMEOUT_BUCKETS.mediumPromptMaxChars) {
return { ...ORCHESTRATOR_TIMEOUT_PROFILES.medium }
}
return { ...ORCHESTRATOR_TIMEOUT_PROFILES.long }
}
export function prepareDesignPrompt(prompt: string): PreparedDesignPrompt {
const normalized = normalizePromptText(prompt)
if (normalized.length <= PROMPT_OPTIMIZER_LIMITS.longPromptCharThreshold) {
return {
original: prompt,
orchestratorPrompt: normalized,
subAgentPrompt: normalized,
wasCompressed: false,
originalLength: normalized.length,
}
}
const sections = parseTopLevelSections(prompt)
const overview = extractOverviewLine(normalized)
const featureLines = extractFeatureHeadings(prompt).slice(0, PROMPT_OPTIMIZER_LIMITS.maxFeatureLines)
const sectionLines = extractWebsiteSectionLines(sections).slice(0, PROMPT_OPTIMIZER_LIMITS.maxSectionLines)
const highlightLines = extractCoreHighlightLines(sections).slice(0, 6)
const needsScreenshotPlaceholder = hasScreenshotRequirements(normalized)
const sloganLines = extractSloganCandidates(sections).slice(0, 4)
const personalityHint = extractProductPersonality(normalized)
const conciseParts: string[] = [
'Design brief (auto-condensed from a long product document):',
]
if (overview) {
conciseParts.push(`Product: ${overview}`)
}
if (personalityHint) {
conciseParts.push(`Product personality: ${personalityHint}`)
}
if (sloganLines.length > 0) {
conciseParts.push(`Slogan candidates:\n${sloganLines.join('\n')}`)
}
if (sectionLines.length > 0) {
conciseParts.push(`Preferred landing page sections:\n${sectionLines.join('\n')}`)
}
if (featureLines.length > 0) {
conciseParts.push(`Core capabilities to reflect visually:\n${featureLines.join('\n')}`)
}
if (highlightLines.length > 0) {
conciseParts.push(`Key product highlights:\n${highlightLines.join('\n')}`)
}
if (needsScreenshotPlaceholder) {
conciseParts.push(
'Screenshot rule: for sections like "App截图"/"XX截图"/"screenshot", use a minimal phone placeholder (outline + short label), avoid random real screenshots, and do not recreate detailed mini-app internals.',
)
}
conciseParts.push(
'Icon rule: never use emoji glyphs as icons; always use path nodes with descriptive icon names (e.g. "SearchIcon", "MenuIcon"). System auto-resolves to verified SVG paths.',
)
conciseParts.push(
'Scope guardrails: clear hierarchy, avoid over-detailed micro-content.',
)
conciseParts.push(
'Layout guardrails: keep a stable centered content width.',
)
// Only add landing-page-specific guardrails if the prompt looks like a landing page
const isLandingPage = /(?:landing\s*page|website|官网|首页|marketing)/i.test(normalized)
if (isLandingPage) {
conciseParts.push(
'CTA guardrails: avoid inserting extra full-width CTA stripes unless explicitly requested.',
)
conciseParts.push(
'Navbar guardrails: keep logo/links/CTA horizontally aligned; links should be evenly distributed in the center group.',
)
}
conciseParts.push(
'Typography guardrails: long subtitle/body text should use constrained width so lines wrap naturally.',
)
conciseParts.push(
'Overflow prevention: ALL text inside layout frames must use width="fill_container" (never fixed pixel widths). Buttons/badges with CJK text must be wide enough for character count × fontSize + padding.',
)
const concise = conciseParts.join('\n\n')
return {
original: prompt,
orchestratorPrompt: truncateByCharCount(concise, PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForOrchestrator),
subAgentPrompt: truncateByCharCount(concise, PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForSubAgent),
wasCompressed: true,
originalLength: normalized.length,
}
}
export function buildFallbackPlanFromPrompt(prompt: string): OrchestratorPlan {
const labels = extractFallbackSectionLabels(prompt)
const sectionCount = Math.max(1, labels.length)
const isMobile = /(?:mobile|手机|phone|app\s*screen|登录|注册|login|signup)/i.test(prompt)
const isAppScreen = /(?:login|signup|register|登录|注册|settings|设置|profile|个人|form|表单|dashboard|modal|dialog)/i.test(prompt)
const width = isMobile ? 375 : 1200
// Mobile app screens: fixed 812px viewport. Desktop landing pages: auto-expand. Desktop app screens: fixed height.
const totalHeight = isMobile
? 812
: isAppScreen
? 800
: sectionCount >= 4 ? 4000 : 800
const heights = allocateSectionHeights(totalHeight, sectionCount)
return {
rootFrame: {
id: 'page',
name: 'Page',
width,
height: isMobile ? 812 : (isAppScreen ? totalHeight : 0),
layout: 'vertical',
fill: [{ type: 'solid', color: '#F8FAFC' }],
},
subtasks: labels.map((label, index) => ({
id: makeSafeSectionId(label, index),
label,
region: { width, height: heights[index] ?? 120 },
idPrefix: '',
parentFrameId: null,
})),
}
}
function normalizePromptText(text: string): string {
return text
.replace(/\r/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function truncateByCharCount(text: string, maxChars: number): string {
if (text.length <= maxChars) return text
const truncated = text.slice(0, maxChars)
const lastBoundary = Math.max(
truncated.lastIndexOf('\n'),
truncated.lastIndexOf('。'),
truncated.lastIndexOf('.'),
)
if (lastBoundary > Math.floor(maxChars * 0.7)) {
return `${truncated.slice(0, lastBoundary).trim()}\n\n[truncated]`
}
return `${truncated.trim()}\n\n[truncated]`
}
function parseTopLevelSections(markdown: string): Map<string, string> {
const lines = markdown.replace(/\r/g, '').split('\n')
const sections = new Map<string, string>()
let currentTitle: string | null = null
let currentLines: string[] = []
const flush = () => {
if (!currentTitle) return
const content = currentLines.join('\n').trim()
if (content) sections.set(currentTitle, content)
}
for (const line of lines) {
const headingMatch = line.match(/^##\s+(.+)$/)
if (headingMatch) {
flush()
currentTitle = headingMatch[1].trim()
currentLines = []
continue
}
if (currentTitle) {
currentLines.push(line)
}
}
flush()
return sections
}
function extractOverviewLine(text: string): string | null {
const lines = text.split('\n')
for (const raw of lines) {
const line = raw.trim()
if (!line) continue
if (line.startsWith('#')) continue
if (line.startsWith('|')) continue
if (line === '---') continue
return line.replace(/\*\*/g, '')
}
return null
}
function extractFeatureHeadings(text: string): string[] {
return text
.split('\n')
.map((line) => line.match(/^###\s+\d+\.\s+(.+)$/)?.[1]?.trim())
.filter((line): line is string => Boolean(line))
.map((line) => `- ${line}`)
}
function extractWebsiteSectionLines(sections: Map<string, string>): string[] {
const content = sections.get('推荐的官网结构')
if (!content) return []
return content
.split('\n')
.map((line) => line.trim())
.filter((line) => /^\d+\./.test(line))
.map((line) => `- ${line.replace(/^\d+\.\s*/, '')}`)
}
function extractCoreHighlightLines(sections: Map<string, string>): string[] {
const content = sections.get('核心亮点')
if (!content) return []
const lines = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('|') && !line.includes('|------|'))
const result: string[] = []
for (const line of lines) {
const columns = line.split('|').map((cell) => cell.trim()).filter(Boolean)
if (columns.length >= 2) {
result.push(`- ${columns[0]}: ${columns[1]}`)
}
}
return result
}
function hasScreenshotRequirements(text: string): boolean {
return /(截图|screenshot|mockup)/i.test(text)
}
function extractFallbackSectionLabels(prompt: string): string[] {
const lines = prompt.replace(/\r/g, '').split('\n')
const labels: string[] = []
const seen = new Set<string>()
for (const raw of lines) {
const line = raw.trim()
const bulletMatch = line.match(/^- (.+)$/)
if (!bulletMatch) continue
const cleaned = sanitizePlanSectionLabel(bulletMatch[1])
if (!cleaned) continue
const key = cleaned.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
labels.push(cleaned)
if (labels.length >= PROMPT_OPTIMIZER_LIMITS.maxFallbackSections) break
}
if (labels.length > 0) return labels
// Detect design type to provide appropriate fallback labels
const isAppScreen = /(?:login|signup|register|登录|注册|settings|设置|profile|个人|form|表单|dashboard|modal|dialog)/i.test(prompt)
if (isAppScreen) {
return [
'Header',
'Main Content',
'Actions',
]
}
return [
'Navigation',
'Hero',
'Core Highlights',
'Feature Showcase',
'CTA',
'Footer',
]
}
function sanitizePlanSectionLabel(label: string): string {
const cleaned = label
.replace(/^["'`]+|["'`]+$/g, '')
.replace(/\s*\([^)]*\)\s*/g, ' ')
.replace(/[_*#]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!cleaned) return ''
return cleaned.slice(0, 48)
}
function makeSafeSectionId(label: string, index: number): string {
const ascii = label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
if (ascii.length > 0) return ascii
return `section-${index + 1}`
}
function allocateSectionHeights(totalHeight: number, count: number): number[] {
if (count <= 0) return []
const minHeight = 80
const base = Math.max(minHeight, Math.floor(totalHeight / count))
const heights = Array.from({ length: count }, () => base)
let allocated = base * count
let idx = 0
while (allocated < totalHeight) {
heights[idx] += 1
allocated += 1
idx = (idx + 1) % count
}
idx = count - 1
while (allocated > totalHeight) {
if (heights[idx] > minHeight) {
heights[idx] -= 1
allocated -= 1
}
idx = idx - 1
if (idx < 0) idx = count - 1
}
return heights
}
function extractSloganCandidates(sections: Map<string, string>): string[] {
const content = sections.get('Slogan 候选') ?? sections.get('slogan') ?? sections.get('标语')
if (!content) return []
return content
.split('\n')
.map((line) => line.trim())
.filter((line) => /^\d+\./.test(line) || line.startsWith('-') || line.startsWith('"'))
.map((line) => `- ${line.replace(/^\d+\.\s*/, '').replace(/^-\s*/, '')}`)
}
function extractProductPersonality(text: string): string | null {
// Look for personality/tone hints in the document
const patterns = [
/(?:风格|tone|style|personality)[:]\s*(.+)/i,
/(?:品牌调性|brand\s*tone)[:]\s*(.+)/i,
/(?:设计风格|design\s*style)[:]\s*(.+)/i,
]
for (const pattern of patterns) {
const match = text.match(pattern)
if (match) return match[1].trim().slice(0, 100)
}
// Infer from keywords in the text
const hasKids = /(?:kids|children|儿童|少儿|幼儿)/i.test(text)
const hasTech = /(?:AI|developer|API|tech|code|engineering)/i.test(text)
const hasLuxury = /(?:premium|luxury|奢华|高端)/i.test(text)
const hasFun = /(?:game|fun|play|趣味|游戏|娱乐)/i.test(text)
const hasEducation = /(?:learn|study|education|学习|教育|vocabulary)/i.test(text)
const traits: string[] = []
if (hasKids) traits.push('playful, colorful')
if (hasTech) traits.push('modern, technical')
if (hasLuxury) traits.push('premium, elegant')
if (hasFun) traits.push('energetic, vibrant')
if (hasEducation) traits.push('trustworthy, encouraging')
return traits.length > 0 ? traits.join(', ') : null
}

View file

@ -0,0 +1,153 @@
/**
* Orchestrator prompt ultra-lightweight, only splits into sections.
* No design details, no prompt rewriting. Just structure.
*/
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)
FORMAT:
{"rootFrame":{"id":"page","name":"Page","width":1200,"height":0,"layout":"vertical","fill":[{"type":"solid","color":"#0B1120"}]},"styleGuide":{"palette":{"background":"#0B1120","surface":"#141D33","text":"#F1F5F9","secondary":"#94A3B8","accent":"#3B82F6","accent2":"#06B6D4","border":"#1E293B"},"fonts":{"heading":"Space Grotesk","body":"Inter"},"aesthetic":"dark navy with blue gradient accents"},"subtasks":[{"id":"nav","label":"Navigation Bar","region":{"width":1200,"height":72}},{"id":"hero","label":"Hero Section","region":{"width":1200,"height":560}},{"id":"features","label":"Feature Cards","region":{"width":1200,"height":480}}]}
RULES:
- 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).
- 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".
- 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).
- Landing page height hints: nav 64-80px, hero 500-600px, feature sections 400-600px, testimonials 300-400px, CTA 200-300px, footer 200-300px.
- App screen height hints: status bar 44px, header 56-64px, form fields 48-56px each, buttons 48px, spacing 16-24px.
- If a section is about "App截图"/"XX截图"/"screenshot"/"mockup", plan it as a phone mockup placeholder block, not a detailed mini-app reconstruction.
- 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).
- NO explanation. NO markdown. JUST the JSON object.`
// Safe code block delimiter
const BLOCK = "```"
/**
* Sub-agent prompt lean version of DESIGN_GENERATOR_PROMPT.
* Only essential schema + JSONL output format. Includes one example for format clarity.
*/
export const SUB_AGENT_PROMPT = `PenNode flat JSONL engine. Output a ${BLOCK}json block with ONE node per line.
TYPES: frame (width,height,layout,gap,padding,justifyContent,alignItems,clipContent,cornerRadius,fill,stroke,effects,children), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,height,textAlign,textGrowth,lineHeight,letterSpacing,textAlignVertical), path (d,width,height,fill,stroke), image (src,width,height)
textGrowth: "auto" (expand horizontally, no wrap), "fixed-width" (fixed width, height auto-sizes to wrapped content), "fixed-width-height" (both fixed). Default for text in layouts: "fixed-width" + width="fill_container".
lineHeight: multiplier (1.1-1.2 for headings, 1.4-1.6 for body). letterSpacing: px (-0.5 for headlines, 0.5-2 for uppercase labels). textAlignVertical: "top"|"middle"|"bottom".
SHARED: id, type, name, x, y, opacity
width/height: number (px), "fill_container" (stretch), "fit_content" (shrink-wrap)
In vertical layout: "fill_container" width = stretch horizontally; height = fill remaining vertical space.
In horizontal layout: "fill_container" width = fill remaining horizontal space; height = stretch vertically.
padding: number or [vertical,horizontal] (e.g. [0,80]) or [top,right,bottom,left]
clipContent: boolean clips overflowing children. ALWAYS use on frames with cornerRadius + image children.
justifyContent: "start"|"center"|"end"|"space_between"|"space_around". Use "space_between" for navbars/footers.
Fill=[{"type":"solid","color":"#hex"}] or [{"type":"linear_gradient","angle":N,"stops":[{"offset":0,"color":"#hex"},{"offset":1,"color":"#hex"}]}]
Stroke={"thickness":N,"fill":[...]}
cornerRadius=number. fill=array.
CRITICAL LAYOUT RULES (violations cause rendering bugs):
- Section root frame: width="fill_container", height="fit_content", layout="vertical". NEVER use fixed pixel height on section root it causes blank gaps. Let content determine height.
- NEVER set x or y on ANY child inside a layout frame. The layout engine positions them automatically.
- ALL nodes must be descendants of the section root. No orphan/floating nodes.
- CHILD SIZE RULE: every child's width must be ≤ parent's content area. Use "fill_container" when in doubt.
- WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one input/button uses "fill_container", ALL inputs/buttons in that container must also use "fill_container". Mixing fixed-px and fill_container causes misalignment.
- NEVER use "fill_container" on children of a "fit_content" parent this creates a circular dependency and breaks layout.
- CLIP CONTENT: set clipContent: true on cards with cornerRadius + image children. Prevents overflow past rounded corners.
- FLEX LAYOUT: use justifyContent to distribute children:
"space_between" = push first/last to edges, even space between (BEST for navbars: logo | links | CTA).
"space_around" = equal space around each child.
"center" = center-pack children.
- SIZING: width/height accept: number (px), "fill_container" (stretch to parent), "fit_content" (shrink to content).
- PADDING: number (uniform), [vertical, horizontal] (e.g. [0, 80] for side padding), or [top, right, bottom, left].
- For two-column layouts: horizontal frame with two child frames, each "fill_container" width.
- For centered content: frame with alignItems="center", then content frame with fixed width (e.g. 1080).
- SHORT TEXT: buttons/labels can omit textGrowth (defaults to "auto" = expands horizontally).
TEXT RULES (the #1 most common bug source MUST follow):
TEXT WIDTH:
- ALL text nodes inside a layout frame width="fill_container" + textGrowth="fixed-width". NO EXCEPTIONS.
- NEVER output a text node with a fixed pixel width (width:224, width:378, width:784 etc.) inside a layout frame. This causes the text to overflow horizontally and break the design.
- The ONLY time text can have a fixed pixel width is when it is NOT inside a layout frame (layout="none" parent).
- BAD example (causes overflow): parent card width=195, padding=[24,40,24,40] (available=115px), child text width=378 text overflows by 263px!
- GOOD example: same parent card, child text width="fill_container" auto-constrained to 115px, wraps correctly.
TEXT WRAPPING (textGrowth):
- Any text content longer than ~15 characters MUST have textGrowth="fixed-width". Without it, text expands horizontally in a single line and overflows.
- textGrowth="fixed-width" makes the text WRAP within its width and auto-size its height. This is required for descriptions, paragraphs, subtitles, and any multi-word text.
- ONLY omit textGrowth for very short labels (1-3 words) like button text "Submit", nav links, or badge labels.
- BAD: {"type":"text","content":"PolarWords 的 AI 助记系统为每个单词生成专属记忆方案...","fontSize":16,"width":"fill_container"} text renders as ONE long line, overflows!
- GOOD: {"type":"text","content":"PolarWords 的 AI 助记系统为每个单词生成专属记忆方案...","fontSize":16,"width":"fill_container","textGrowth":"fixed-width","lineHeight":1.6} text wraps within parent, height auto-sizes.
TEXT HEIGHT (the #2 most common bug causes overlap):
- NEVER set explicit pixel height on text nodes (e.g. height:22, height:44). OMIT the height property entirely on text.
- The layout engine auto-calculates text height from textGrowth + content. An explicit small height clips the text and causes it to overlap with siblings below.
- BAD: {"type":"text","content":"50,000+","fontSize":36,"height":22} 22px is way too small for 36px font, text overlaps next element!
- GOOD: {"type":"text","content":"50,000+","fontSize":36,"width":"fill_container"} engine auto-sizes height to ~43px, no overlap.
- This applies to ALL text nodes: headings, body, labels, numbers, captions. Never set height on text.
CARD ROW ALIGNMENT:
- When cards are siblings in a horizontal layout, ALL cards MUST use height="fill_container". This makes all cards match the tallest card's height, creating a visually aligned row.
- BAD: 5 cards in horizontal row, each with different fixed heights uneven, ugly row.
- GOOD: 5 cards in horizontal row, each with height="fill_container" all same height, clean alignment.
- Card content (icon + title + description) should use width="fill_container" on text nodes so text wraps within the card.
FORMAT: Each line has "_parent" (null=root, else parent-id). Parent before children.
${BLOCK}json
{"_parent":null,"id":"root","type":"frame","name":"Hero","width":"fill_container","height":"fit_content","layout":"vertical","padding":80,"gap":24,"alignItems":"center","fill":[{"type":"solid","color":"#0B1120"}]}
{"_parent":"root","id":"content","type":"frame","name":"Content","width":1080,"height":400,"layout":"horizontal","gap":48,"alignItems":"center"}
{"_parent":"content","id":"left","type":"frame","name":"Text Column","width":520,"height":360,"layout":"vertical","gap":20}
{"_parent":"left","id":"title","type":"text","name":"Headline","content":"Learn Smarter","fontSize":48,"fontWeight":700,"fontFamily":"Space Grotesk","lineHeight":1.1,"letterSpacing":-0.5,"textGrowth":"fixed-width","width":"fill_container","fill":[{"type":"solid","color":"#F1F5F9"}]}
{"_parent":"left","id":"desc","type":"text","name":"Description","content":"AI-powered vocabulary learning","fontSize":18,"lineHeight":1.5,"textGrowth":"fixed-width","width":"fill_container","fill":[{"type":"solid","color":"#94A3B8"}]}
{"_parent":"left","id":"cta","type":"frame","name":"CTA Button","width":180,"height":48,"cornerRadius":10,"layout":"horizontal","gap":8,"justifyContent":"center","alignItems":"center","fill":[{"type":"solid","color":"#3B82F6"}]}
{"_parent":"cta","id":"cta-text","type":"text","name":"CTA Label","content":"Get Started","fontSize":16,"fontWeight":600,"width":"fill_container","fill":[{"type":"solid","color":"#FFFFFF"}]}
{"_parent":"content","id":"phone","type":"frame","name":"Phone Mockup","width":280,"height":560,"cornerRadius":32,"fill":[{"type":"solid","color":"#141D33"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#1E293B"}]}}
${BLOCK}
DESIGN RULES:
- Typography: Display 40-56px Heading 28-36px Subheading 20-24px Body 16-18px Caption 13-14px. Always set lineHeight: headings 1.1-1.2, body 1.4-1.6, captions 1.3. Use letterSpacing: -0.5 for large headlines, 0.5-2 for uppercase labels.
- CJK FONTS: When content is in Chinese/Japanese/Korean, use CJK-compatible fonts "Noto Sans SC" for headings, "Inter" or "Noto Sans SC" for body. NEVER use "Space Grotesk" or "Manrope" for CJK text (they have no CJK glyphs). CJK lineHeight: 1.3-1.4 for headings, 1.6-1.8 for body. CJK letterSpacing: 0 for body, never negative.
- Cards in horizontal rows: ALL cards MUST use width="fill_container" + height="fill_container" for even distribution and equal height. Never use fixed pixel width/height on cards in a row. A card with icon+title+description needs at least 160-200px content height the row will auto-size. ALWAYS set clipContent: true on cards with cornerRadius + image children.
- Icons: "path" nodes with descriptive names (e.g. "SearchIcon", "MenuIcon", "ArrowRightIcon", "StarIcon", "ShieldIcon", "ZapIcon"). System auto-resolves to verified SVG paths. Size 16-24px. NEVER use emoji.
- PHONE MOCKUP: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. NEVER use ellipse or circle for mockups. NEVER add any children inside (no text, no frames, no images). Every phone mockup must look identical.
- NEVER use ellipse nodes for decorative shapes. Use frame or rectangle with cornerRadius instead.
- Use STYLE GUIDE colors/fonts from user prompt consistently. Do not introduce random colors.
- TEXT: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER set fixed pixel width on text. NEVER set height on text omit it entirely, the engine auto-sizes. Short labels/buttons can omit textGrowth.
- BUTTONS: height 44-52px, padding [12, 24] minimum. With icon+text: layout="horizontal", gap=8, alignItems="center". Sizing: "fill_container" (stretch), "fit_content" (hug content), or fixed px choose per context.
- CJK BUTTONS (Chinese/Japanese/Korean text): each CJK character renders ~1.0× fontSize wide. For "免费下载" (4 chars) at fontSize 15: content needs ~60px width button needs 60 + horizontal padding (e.g. padding [8,22] = 44px total 104px minimum). ALWAYS calculate: button width charCount × fontSize + totalHorizontalPadding.
- ICON-ONLY BUTTONS (heart, bookmark, share, etc.): square frame, minimum 44x44px, justifyContent="center", alignItems="center". Path icon inside 20-24px.
- BADGES/TAGS (e.g. "NEW", "SALE", "PRO"): frame with padding [4, 12] minimum, cornerRadius 4-6, height="fit_content". For badges with CJK or long text, use width="fit_content" so badge auto-sizes. Text inside must NOT be clipped use short fontSize (11-13px), no textGrowth needed.
- BUTTON + ICON-BUTTON ROW: horizontal frame, gap=8-12. Primary button width="fill_container" to take remaining space; icon-only button fixed square (44-48px).
- LANDING PAGE SECTIONS (only when designing landing pages/websites):
- Hero: large headline (40-56px), gradient or bold backgrounds, clear CTA, generous whitespace
- Visual rhythm: alternate section backgrounds for separation
- CTAs: bold accent color, padding [16, 32] minimum
- Nav bar: use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button). padding=[0,80]. This auto-distributes them perfectly.
- APP SCREENS (login, settings, forms, dashboards, etc.):
- Focus on the screen's core functionality, avoid unnecessary decorative sections.
- Form inputs: consistent height (48-56px), clear labels, proper spacing (16-24px gap). Use width="fill_container" for inputs so they align with parent width.
- Primary/submit buttons: use width="fill_container" to match the form width.
- Button rows (social login etc.): wrap in horizontal frame with width="fill_container", gap=12. Each button uses width="fit_content" (hug content) so they stay compact. Use justifyContent="center" or "space_between" to distribute.
- Fixed-width children must NOT exceed their parent's content area (parent width minus padding).
SELF-CHECK before finishing (mentally verify these):
1. Every text node inside a layout frame has width="fill_container" + textGrowth="fixed-width"? (not a fixed pixel width, not missing textGrowth)
2. Every text with content > 15 chars has textGrowth="fixed-width"? (without it, text won't wrap and will overflow)
3. NO text node has an explicit pixel height (height:22, height:44 etc.)? Text height must be OMITTED engine auto-sizes it.
4. Cards in horizontal rows all use width="fill_container" + height="fill_container"? (ensures equal distribution and height)
5. Every button/badge with CJK text has enough width for its characters + padding?
6. No child has a fixed pixel width exceeding its parent's available content area?
7. If content is CJK: using "Noto Sans SC" (not "Space Grotesk") for headings, and lineHeight 1.3 for headings, 1.6 for body?
Start with ${BLOCK}json immediately. No preamble, no <step> tags.`

View file

@ -0,0 +1,888 @@
/**
* Orchestrator for parallel design generation.
*
* Flow:
* 1. Fast "architect" API call decomposes the prompt into spatial sub-tasks
* 2. Root frame is created on canvas
* 3. Multiple sub-agents execute in parallel, each streaming JSONL
* 4. Nodes are inserted to canvas in real-time with animation
*
* Falls back to single-call generation on any orchestrator failure.
*/
import type { PenNode, FrameNode } from '@/types/pen'
import type { VariableDefinition } from '@/types/variables'
import type {
AIDesignRequest,
OrchestratorPlan,
OrchestrationProgress,
SubTask,
SubAgentResult,
} from './ai-types'
import { streamChat } from './ai-service'
import { ORCHESTRATOR_PROMPT, SUB_AGENT_PROMPT } from './orchestrator-prompts'
import {
type PreparedDesignPrompt,
buildFallbackPlanFromPrompt,
getOrchestratorTimeouts,
getSubAgentTimeouts,
prepareDesignPrompt,
} from './orchestrator-prompt-optimizer'
import {
adjustRootFrameHeightToContent,
expandRootFrameHeight,
extractStreamingNodes,
extractJsonFromResponse,
insertStreamingNode,
buildVariableContext,
resetGenerationRemapping,
setGenerationContextHint,
setGenerationCanvasWidth,
applyPostStreamingTreeHeuristics,
} from './design-generator'
import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import { zoomToFitContent } from '@/canvas/use-fabric-canvas'
import {
resetAnimationState,
startNewAnimationBatch,
} from './design-animation'
import { RETRY_TIMEOUT_CONFIG } from './ai-runtime-config'
interface StreamTimeoutConfig {
hardTimeoutMs: number
noTextTimeoutMs: number
thinkingResetsTimeout: boolean
pingResetsTimeout?: boolean
firstTextTimeoutMs?: number
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
thinkingBudgetTokens?: number
effort?: 'low' | 'medium' | 'high' | 'max'
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function executeOrchestration(
request: AIDesignRequest,
callbacks?: {
onApplyPartial?: (count: number) => void
onTextUpdate?: (text: string) => void
animated?: boolean
},
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
setGenerationContextHint(request.prompt)
const animated = callbacks?.animated ?? false
const preparedPrompt = prepareDesignPrompt(request.prompt)
const renderPlanningStatus = (message: string) => {
callbacks?.onTextUpdate?.(
`<step title="Planning layout" status="streaming">${message}</step>`,
)
}
if (preparedPrompt.wasCompressed) {
console.log(
'[Orchestrator] Compressed long prompt:',
`${preparedPrompt.originalLength} -> ${preparedPrompt.subAgentPrompt.length} chars`,
)
}
try {
// -- Phase 1: Planning (streaming) --
renderPlanningStatus('Analyzing design structure...')
let plan: OrchestratorPlan
try {
plan = await callOrchestrator(
preparedPrompt.orchestratorPrompt,
preparedPrompt.originalLength,
(thinking) => {
const truncated = thinking.length > 200
? thinking.slice(-200) + '...'
: thinking
renderPlanningStatus(truncated)
},
)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown orchestrator error'
console.warn('[Orchestrator] Planner failed, using fallback plan:', message)
renderPlanningStatus('Planner timeout, switching to fallback layout plan...')
plan = buildFallbackPlanFromPrompt(preparedPrompt.orchestratorPrompt)
}
// Assign ID prefixes
for (const st of plan.subtasks) {
st.idPrefix = st.id
st.parentFrameId = plan.rootFrame.id
}
// Set canvas width hint for accurate text height estimation
setGenerationCanvasWidth(plan.rootFrame.width)
// Show planning done + all subtask steps as pending
emitProgress(plan, {
phase: 'generating',
subtasks: plan.subtasks.map((st) => ({
id: st.id, label: st.label, status: 'pending' as const, nodeCount: 0,
})),
totalNodes: 0,
}, callbacks)
// -- Phase 2: Setup canvas --
resetGenerationRemapping()
if (animated) {
resetAnimationState()
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
}
// Mobile screens have fixed dimensions (375x812), use plan height directly.
// Desktop: pre-allocate total planned height so the root frame's background
// covers the entire page from the start. Without this, children streaming into
// later sections appear outside the root's visible background area.
const isMobile = plan.rootFrame.width <= 480
const totalPlannedHeight = plan.subtasks.reduce((sum, st) => sum + st.region.height, 0)
const initialHeight = isMobile
? (plan.rootFrame.height || 812)
: Math.max(320, totalPlannedHeight)
const rootNode: FrameNode = {
id: plan.rootFrame.id,
type: 'frame',
name: plan.rootFrame.name,
x: 0,
y: 0,
width: plan.rootFrame.width,
height: initialHeight,
layout: plan.rootFrame.layout ?? 'vertical',
gap: plan.rootFrame.gap ?? 0,
fill: (plan.rootFrame.fill as FrameNode['fill']) ?? [
{ type: 'solid', color: plan.styleGuide?.palette?.background ?? '#FFFFFF' },
],
children: [],
}
insertStreamingNode(rootNode, null)
if (typeof window !== 'undefined') {
// Wait for store -> canvas sync, then fit/center around generated root frame.
requestAnimationFrame(() => {
requestAnimationFrame(() => zoomToFitContent())
})
}
// -- Phase 3: Parallel sub-agent execution --
const progress: OrchestrationProgress = {
phase: 'generating',
subtasks: plan.subtasks.map((st) => ({
id: st.id,
label: st.label,
status: 'pending' as const,
nodeCount: 0,
})),
totalNodes: 0,
}
let results: SubAgentResult[]
try {
results = await executeSubAgentsSequentially(
plan,
request,
preparedPrompt,
progress,
callbacks,
)
if (animated) {
adjustRootFrameHeightToContent()
}
} finally {
if (animated) {
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
}
}
// -- Phase 4: Collect results --
// Mark all completed subtasks as done in final progress
for (const entry of progress.subtasks) {
if (entry.status !== 'error') {
entry.status = 'done'
}
}
progress.phase = 'done'
emitProgress(plan, progress, callbacks)
const allNodes: PenNode[] = [rootNode]
for (const r of results) {
allNodes.push(...r.nodes)
}
const generatedNodeCount = allNodes.length - 1
if (generatedNodeCount === 0) {
throw new Error('Orchestration produced no nodes beyond root frame')
}
if (!animated) {
adjustRootFrameHeightToContent()
}
const adjustedRoot = useDocumentStore.getState().getNodeById(DEFAULT_FRAME_ID)
if (adjustedRoot && adjustedRoot.type === 'frame') {
rootNode.height = adjustedRoot.height
}
// Build final rawResponse that includes step tags so the chat message
// shows the complete pipeline progress after streaming ends
const finalStepTags = buildFinalStepTags(plan, progress)
return { nodes: allNodes, rawResponse: finalStepTags }
} finally {
setGenerationContextHint('')
setGenerationCanvasWidth(1200) // Reset to default
}
}
// ---------------------------------------------------------------------------
// Orchestrator call — fast decomposition
// ---------------------------------------------------------------------------
async function callOrchestrator(
prompt: string,
timeoutHintLength: number,
onThinking?: (thinking: string) => void,
): Promise<OrchestratorPlan> {
console.log('[Orchestrator] Calling streamChat...')
let rawResponse = ''
let thinkingContent = ''
for await (const chunk of streamChat(
ORCHESTRATOR_PROMPT,
[{ role: 'user', content: prompt }],
undefined,
getOrchestratorTimeouts(timeoutHintLength),
)) {
if (chunk.type === 'text') {
rawResponse += chunk.content
} else if (chunk.type === 'thinking') {
thinkingContent += chunk.content
onThinking?.(thinkingContent)
} else if (chunk.type === 'error') {
throw new Error(`Orchestrator failed: ${chunk.content}`)
}
}
console.log('[Orchestrator] Raw response:', rawResponse.slice(0, 500))
const plan = parseOrchestratorResponse(rawResponse)
if (!plan) {
console.error('[Orchestrator] Failed to parse plan from:', rawResponse.slice(0, 500))
throw new Error('Failed to parse orchestrator plan')
}
console.log('[Orchestrator] Plan:', plan.subtasks.length, 'subtasks')
return plan
}
function parseOrchestratorResponse(raw: string): OrchestratorPlan | null {
const trimmed = raw.trim()
// Try direct parse
const plan = tryParsePlan(trimmed)
if (plan) return plan
// Try extracting from code fences
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (fenceMatch) {
const fenced = tryParsePlan(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 = tryParsePlan(trimmed.slice(firstBrace, lastBrace + 1))
if (braced) return braced
}
return null
}
function tryParsePlan(text: string): OrchestratorPlan | null {
try {
const obj = JSON.parse(text) as Record<string, unknown>
if (!obj.rootFrame || typeof obj.rootFrame !== 'object') return null
if (!Array.isArray(obj.subtasks) || obj.subtasks.length === 0) return null
const rf = obj.rootFrame as Record<string, unknown>
if (!rf.id || !rf.width || (rf.height == null)) return null
for (const st of obj.subtasks as Record<string, unknown>[]) {
if (!st.id || !st.region) return null
}
const plan = obj as unknown as OrchestratorPlan
// Extract optional styleGuide if present and well-formed
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') {
plan.styleGuide = sg as unknown as import('./ai-types').StyleGuide
}
}
return plan
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Sequential sub-agent execution
// ---------------------------------------------------------------------------
async function executeSubAgentsSequentially(
plan: OrchestratorPlan,
request: AIDesignRequest,
preparedPrompt: PreparedDesignPrompt,
progress: OrchestrationProgress,
callbacks?: {
onApplyPartial?: (count: number) => void
onTextUpdate?: (text: string) => void
animated?: boolean
},
): Promise<SubAgentResult[]> {
const results: SubAgentResult[] = []
const timeoutOptions = getSubAgentTimeouts(preparedPrompt.originalLength)
const retryTimeoutOptions = getRetrySubAgentTimeouts(timeoutOptions)
for (let i = 0; i < plan.subtasks.length; i++) {
let result = await executeSubAgent(
plan.subtasks[i], plan, request, preparedPrompt, timeoutOptions, progress, i, callbacks,
)
if (shouldRetrySubtask(result)) {
console.warn(
`[Orchestrator] Retrying subtask "${plan.subtasks[i].label}" with extended timeout after: ${result.error}`,
)
result = await executeSubAgent(
plan.subtasks[i], plan, request, preparedPrompt, retryTimeoutOptions, progress, i, callbacks,
)
}
if (result.nodes.length === 0) {
const minimalPrompt = buildMinimalFallbackPrompt(preparedPrompt.subAgentPrompt, plan.subtasks[i].label)
console.warn(
`[Orchestrator] Retrying subtask "${plan.subtasks[i].label}" in minimal mode because no nodes were produced.`,
)
result = await executeSubAgent(
plan.subtasks[i],
plan,
request,
preparedPrompt,
retryTimeoutOptions,
progress,
i,
callbacks,
minimalPrompt,
)
}
if (result.nodes.length === 0) {
const placeholderNodes = insertLocalSubtaskPlaceholder(
plan.subtasks[i],
plan,
progress.subtasks[i],
progress,
)
if (placeholderNodes.length > 0) {
callbacks?.onApplyPartial?.(progress.totalNodes)
emitProgress(plan, progress, callbacks)
result = {
...result,
nodes: placeholderNodes,
error: result.error
? `${result.error}; local placeholder inserted`
: 'Local placeholder inserted after repeated empty output',
}
}
}
results.push(result)
// Progressively expand root frame after each subtask is done (desktop only).
// Mobile has a fixed viewport height so no progressive expansion is needed.
if (result.nodes.length > 0 && plan.rootFrame.width > 480) {
expandRootFrameHeight()
}
}
return results
}
async function executeSubAgent(
subtask: SubTask,
plan: OrchestratorPlan,
request: AIDesignRequest,
preparedPrompt: PreparedDesignPrompt,
timeoutOptions: StreamTimeoutConfig,
progress: OrchestrationProgress,
index: number,
callbacks?: {
onApplyPartial?: (count: number) => void
onTextUpdate?: (text: string) => void
animated?: boolean
},
promptOverride?: string,
): Promise<SubAgentResult> {
const animated = callbacks?.animated ?? false
const progressEntry = progress.subtasks[index]
progressEntry.status = 'streaming'
emitProgress(plan, progress, callbacks)
// Update context hint with subtask label so heuristics can detect
// screenshot/mockup context even when the user prompt doesn't mention it
setGenerationContextHint(`${request.prompt} ${subtask.label}`)
const userPrompt = buildSubAgentUserPrompt(
subtask,
plan,
promptOverride ?? preparedPrompt.subAgentPrompt,
request.context?.variables,
request.context?.themes,
)
let rawResponse = ''
const nodes: PenNode[] = []
let streamOffset = 0
let subtaskRootId: string | null = null
try {
for await (const chunk of streamChat(
SUB_AGENT_PROMPT,
[{ role: 'user', content: userPrompt }],
undefined,
timeoutOptions,
)) {
if (chunk.type === 'text') {
rawResponse += chunk.content
// Forward streaming text to panel
emitProgress(plan, progress, callbacks, rawResponse)
if (animated) {
const { results, newOffset } = extractStreamingNodes(
rawResponse,
streamOffset,
)
if (results.length > 0) {
streamOffset = newOffset
startNewAnimationBatch()
for (const { node, parentId } of results) {
// Enforce ID prefix
ensureIdPrefix(node, subtask.idPrefix)
if (parentId !== null) {
// Prefix the parent reference too
const prefixedParent = ensurePrefixStr(
parentId,
subtask.idPrefix,
)
insertStreamingNode(node, prefixedParent)
} else {
// Sub-agent root → insert under the orchestrator root frame
insertStreamingNode(node, plan.rootFrame.id)
if (!subtaskRootId) subtaskRootId = node.id
}
nodes.push(node)
progressEntry.nodeCount++
progress.totalNodes++
}
callbacks?.onApplyPartial?.(progress.totalNodes)
// Expand root frame as content grows — children inside sections
// don't trigger expandRootFrameHeight from insertStreamingNode
// because they insert under the section root, not DEFAULT_FRAME_ID.
if (plan.rootFrame.width > 480) {
expandRootFrameHeight()
}
emitProgress(plan, progress, callbacks, rawResponse)
}
}
} else if (chunk.type === 'thinking') {
// Forward thinking progress so UI doesn't look stuck
emitProgress(plan, progress, callbacks)
} else if (chunk.type === 'error') {
if (rawResponse.trim().length > 0) {
const { recovered } = recoverNodesFromRawResponse(rawResponse, subtask, plan, nodes, progressEntry, progress)
if (recovered > 0) {
progressEntry.status = 'done'
emitProgress(plan, progress, callbacks)
return {
subtaskId: subtask.id,
nodes,
rawResponse,
error: `partial recovery after error: ${chunk.content}`,
}
}
}
progressEntry.status = 'error'
emitProgress(plan, progress, callbacks)
return { subtaskId: subtask.id, nodes, rawResponse, error: chunk.content }
}
}
// Fallback: if streaming extraction found nothing, try batch extraction
if (nodes.length === 0 && rawResponse.trim().length > 0) {
const fallbackNodes = extractJsonFromResponse(rawResponse)
if (fallbackNodes && fallbackNodes.length > 0) {
startNewAnimationBatch()
for (const node of fallbackNodes) {
ensureIdPrefix(node, subtask.idPrefix)
insertStreamingNode(node, plan.rootFrame.id)
if (!subtaskRootId) subtaskRootId = node.id
nodes.push(node)
progressEntry.nodeCount++
progress.totalNodes++
}
callbacks?.onApplyPartial?.(progress.totalNodes)
}
}
if (nodes.length === 0) {
progressEntry.status = 'error'
emitProgress(plan, progress, callbacks)
console.warn(
`[Orchestrator] Subtask "${subtask.label}" produced no parseable nodes. Raw length=${rawResponse.length}`,
)
return {
subtaskId: subtask.id,
nodes,
rawResponse,
error: 'Subtask completed but returned no parseable PenNode output.',
}
}
// Apply tree-aware heuristics now that the full subtree is in the store.
// During streaming, nodes were inserted individually without children, so
// tree-aware heuristics (button width, frame height, clipContent) couldn't run.
if (subtaskRootId) {
applyPostStreamingTreeHeuristics(subtaskRootId)
}
progressEntry.status = 'done'
emitProgress(plan, progress, callbacks)
return { subtaskId: subtask.id, nodes, rawResponse }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
if (rawResponse.trim().length > 0) {
const { recovered } = recoverNodesFromRawResponse(rawResponse, subtask, plan, nodes, progressEntry, progress)
if (recovered > 0) {
progressEntry.status = 'done'
emitProgress(plan, progress, callbacks)
return {
subtaskId: subtask.id,
nodes,
rawResponse,
error: `partial recovery after exception: ${msg}`,
}
}
}
progressEntry.status = 'error'
emitProgress(plan, progress, callbacks)
return { subtaskId: subtask.id, nodes, rawResponse, error: msg }
}
}
function recoverNodesFromRawResponse(
rawResponse: string,
subtask: SubTask,
plan: OrchestratorPlan,
targetNodes: PenNode[],
progressEntry: OrchestrationProgress['subtasks'][number],
progress: OrchestrationProgress,
): { recovered: number; rootId: string | null } {
const fallbackNodes = extractJsonFromResponse(rawResponse)
if (!fallbackNodes || fallbackNodes.length === 0) return { recovered: 0, rootId: null }
let recovered = 0
let rootId: string | null = null
startNewAnimationBatch()
for (const node of fallbackNodes) {
ensureIdPrefix(node, subtask.idPrefix)
insertStreamingNode(node, plan.rootFrame.id)
if (!rootId) rootId = node.id
targetNodes.push(node)
progressEntry.nodeCount++
progress.totalNodes++
recovered++
}
if (rootId) {
applyPostStreamingTreeHeuristics(rootId)
}
return { recovered, rootId }
}
// ---------------------------------------------------------------------------
// Sub-agent prompt builder
// ---------------------------------------------------------------------------
function buildSubAgentUserPrompt(
subtask: SubTask,
plan: OrchestratorPlan,
compactPrompt: string,
variables?: Record<string, VariableDefinition>,
themes?: Record<string, string[]>,
): string {
const { region } = subtask
// Show all sections so the model knows scope — only generate THIS one
const sectionList = plan.subtasks
.map((st) => `- ${st.label} (${st.region.width}x${st.region.height})${st.id === subtask.id ? ' ← YOU' : ''}`)
.join('\n')
let prompt = `Page sections:\n${sectionList}\n\nGenerate ONLY "${subtask.label}" (~${region.height}px of content).\n${compactPrompt}
CRITICAL LAYOUT CONSTRAINTS:
- Root frame: id="${subtask.idPrefix}-root", width="fill_container", height="fit_content", layout="vertical". NEVER use fixed pixel height on root let content determine height.
- Target content amount: ~${region.height}px tall. Generate enough elements to fill this area.
- ALL nodes must be descendants of the root frame. No floating/orphan nodes.
- NEVER set x or y on children inside layout frames.
- Use "fill_container" for children that stretch, "fit_content" for shrink-wrap sizing.
- Use justifyContent="space_between" to distribute items (e.g. navbar: logo | links | CTA). Use padding=[0,80] for horizontal page margins.
- For side-by-side layouts, nest a horizontal frame with child frames using "fill_container" width.
- Phone mockup = ONE frame node, cornerRadius 32, NO children inside. NEVER use ellipse.
- Text height must fit content: estimate lines × fontSize × 1.4.
- IDs prefix="${subtask.idPrefix}-". No <step> tags. Output \`\`\`json immediately.`
// Inject style guide so sub-agent uses consistent colors/fonts
if (plan.styleGuide) {
const sg = plan.styleGuide
const p = sg.palette
prompt += `\n\nSTYLE GUIDE (use these consistently):
- Background: ${p.background} Surface: ${p.surface}
- Text: ${p.text} Secondary: ${p.secondary}
- Accent: ${p.accent} Accent2: ${p.accent2} Border: ${p.border}
- Heading font: ${sg.fonts.heading} Body font: ${sg.fonts.body}
- Aesthetic: ${sg.aesthetic}`
}
const varContext = buildVariableContext(variables, themes)
if (varContext) {
prompt += '\n\n' + varContext
}
return prompt
}
// ---------------------------------------------------------------------------
// ID namespace isolation
// ---------------------------------------------------------------------------
function ensureIdPrefix(node: PenNode, prefix: string): void {
if (!node.id.startsWith(`${prefix}-`)) {
node.id = `${prefix}-${node.id}`
}
// Recursively prefix children (for fallback tree extraction)
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) {
ensureIdPrefix(child, prefix)
}
}
}
function ensurePrefixStr(id: string, prefix: string): string {
if (id.startsWith(`${prefix}-`)) return id
return `${prefix}-${id}`
}
function getRetrySubAgentTimeouts(base: StreamTimeoutConfig): StreamTimeoutConfig {
return {
...base,
hardTimeoutMs: Math.min(
base.hardTimeoutMs * RETRY_TIMEOUT_CONFIG.multiplier,
RETRY_TIMEOUT_CONFIG.hardTimeoutMaxMs,
),
noTextTimeoutMs: Math.min(
base.noTextTimeoutMs * RETRY_TIMEOUT_CONFIG.multiplier,
RETRY_TIMEOUT_CONFIG.noTextTimeoutMaxMs,
),
firstTextTimeoutMs: base.firstTextTimeoutMs
? Math.min(
base.firstTextTimeoutMs * RETRY_TIMEOUT_CONFIG.multiplier,
RETRY_TIMEOUT_CONFIG.firstTextTimeoutMaxMs,
)
: undefined,
}
}
function shouldRetrySubtask(result: SubAgentResult): boolean {
if (result.nodes.length > 0) return false
if (!result.error) return false
const error = result.error.toLowerCase()
return error.includes('timed out') || error.includes('thinking too long')
}
function buildMinimalFallbackPrompt(basePrompt: string, label: string): string {
return `${basePrompt}
Fallback mode for section "${label}":
- Prioritize completion over detail.
- Output a minimal skeleton only (4-8 nodes).
- Include: section container, heading, short description, and 1-2 placeholder blocks.
- Avoid complex SVG/icon/path details.`
}
function insertLocalSubtaskPlaceholder(
subtask: SubTask,
plan: OrchestratorPlan,
progressEntry: OrchestrationProgress['subtasks'][number],
progress: OrchestrationProgress,
): PenNode[] {
const sectionId = `${subtask.idPrefix}-fallback-section`
const titleId = `${subtask.idPrefix}-fallback-title`
const descId = `${subtask.idPrefix}-fallback-desc`
const rowId = `${subtask.idPrefix}-fallback-row`
const cardAId = `${subtask.idPrefix}-fallback-card-a`
const cardBId = `${subtask.idPrefix}-fallback-card-b`
const sectionNode: PenNode = {
id: sectionId,
type: 'frame',
name: `${subtask.label} (Fallback)`,
width: 'fill_container',
height: Math.max(120, subtask.region.height),
layout: 'vertical',
padding: 24,
gap: 12,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E2E8F0' }] },
children: [],
}
const titleNode: PenNode = {
id: titleId,
type: 'text',
name: 'Fallback Title',
content: subtask.label,
fontSize: 24,
fontWeight: 700,
width: 'fill_container',
height: 32,
fill: [{ type: 'solid', color: '#0F172A' }],
}
const descNode: PenNode = {
id: descId,
type: 'text',
name: 'Fallback Description',
content: 'Content is loading from a complex generation task. This placeholder keeps layout continuity.',
fontSize: 14,
fontWeight: 400,
width: 'fill_container',
height: 40,
fill: [{ type: 'solid', color: '#64748B' }],
}
const rowNode: PenNode = {
id: rowId,
type: 'frame',
name: 'Fallback Row',
width: 'fill_container',
height: 120,
layout: 'horizontal',
gap: 12,
children: [],
}
const cardANode: PenNode = {
id: cardAId,
type: 'rectangle',
name: 'Fallback Card A',
width: 'fill_container',
height: 120,
cornerRadius: 8,
fill: [{ type: 'solid', color: '#F1F5F9' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#CBD5E1' }] },
}
const cardBNode: PenNode = {
id: cardBId,
type: 'rectangle',
name: 'Fallback Card B',
width: 'fill_container',
height: 120,
cornerRadius: 8,
fill: [{ type: 'solid', color: '#F1F5F9' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#CBD5E1' }] },
}
const insertedNodes = [sectionNode, titleNode, descNode, rowNode, cardANode, cardBNode]
startNewAnimationBatch()
insertStreamingNode(sectionNode, plan.rootFrame.id)
insertStreamingNode(titleNode, sectionId)
insertStreamingNode(descNode, sectionId)
insertStreamingNode(rowNode, sectionId)
insertStreamingNode(cardANode, rowId)
insertStreamingNode(cardBNode, rowId)
progressEntry.nodeCount += insertedNodes.length
progress.totalNodes += insertedNodes.length
progressEntry.status = 'done'
console.warn(
`[Orchestrator] Inserted local placeholder for subtask "${subtask.label}" after repeated empty output.`,
)
return insertedNodes
}
// ---------------------------------------------------------------------------
// Progress emission — updates UI via <step> tags
// ---------------------------------------------------------------------------
function emitProgress(
plan: OrchestratorPlan,
progress: OrchestrationProgress,
callbacks?: {
onTextUpdate?: (text: string) => void
},
streamingText?: string,
): void {
if (!callbacks?.onTextUpdate) return
// Always show "Planning layout" as done first
const planningStep = '<step title="Planning layout" status="done">Analyzing design structure...</step>'
const subtaskSteps = plan.subtasks
.map((st, i) => {
const entry = progress.subtasks[i]
const status = entry.status === 'streaming' ? 'streaming'
: entry.status === 'done' ? 'done'
: entry.status === 'error' ? 'error'
: 'pending'
const nodeInfo = entry.nodeCount > 0 ? ` (${entry.nodeCount} elements)` : ''
return `<step title="${st.label}${nodeInfo}" status="${status}"></step>`
})
.join('\n')
let output = `${planningStep}\n${subtaskSteps}`
if (streamingText) {
output += '\n\n' + streamingText
}
callbacks.onTextUpdate(output)
}
/** Build step tags for the final rawResponse (shown in message after streaming ends) */
function buildFinalStepTags(
plan: OrchestratorPlan,
progress: OrchestrationProgress,
): string {
const planningStep = '<step title="Planning layout" status="done">Analyzing design structure...</step>'
const subtaskSteps = plan.subtasks
.map((st, i) => {
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>`
})
.join('\n')
return `${planningStep}\n${subtaskSteps}`
}

View file

@ -138,6 +138,9 @@ function layoutToCSS(node: ContainerProps): Record<string, string> {
}
css['align-items'] = map[node.alignItems] ?? node.alignItems
}
if (node.clipContent) {
css.overflow = 'hidden'
}
return css
}
@ -240,6 +243,10 @@ function generateNodeHTML(
if (node.fontFamily) css['font-family'] = `'${node.fontFamily}', sans-serif`
if (node.lineHeight) css['line-height'] = String(node.lineHeight)
if (node.letterSpacing) css['letter-spacing'] = `${node.letterSpacing}px`
if (node.textAlignVertical === 'middle') css['vertical-align'] = 'middle'
else if (node.textAlignVertical === 'bottom') css['vertical-align'] = 'bottom'
if (node.textGrowth === 'auto') css['white-space'] = 'nowrap'
else if (node.textGrowth === 'fixed-width-height') css.overflow = 'hidden'
if (node.underline) css['text-decoration'] = 'underline'
if (node.strikethrough) css['text-decoration'] = 'line-through'
Object.assign(css, effectsToCSS(node.effects))

View file

@ -135,6 +135,9 @@ function layoutToTailwind(node: ContainerProps): string[] {
}
if (aiMap[node.alignItems]) classes.push(aiMap[node.alignItems])
}
if (node.clipContent) {
classes.push('overflow-hidden')
}
return classes
}
@ -193,6 +196,10 @@ function textToTailwind(node: TextNode): string[] {
if (node.fontFamily) classes.push(`font-['${node.fontFamily.replace(/\s/g, '_')}']`)
if (node.lineHeight) classes.push(`leading-[${node.lineHeight}]`)
if (node.letterSpacing) classes.push(`tracking-[${node.letterSpacing}px]`)
if (node.textAlignVertical === 'middle') classes.push('align-middle')
else if (node.textAlignVertical === 'bottom') classes.push('align-bottom')
if (node.textGrowth === 'auto') classes.push('whitespace-nowrap')
else if (node.textGrowth === 'fixed-width-height') classes.push('overflow-hidden')
if (node.underline) classes.push('underline')
if (node.strikethrough) classes.push('line-through')
return classes

View file

@ -43,6 +43,13 @@ const DEFAULT_PROVIDERS: Record<AIProviderType, AIProviderConfig> = {
connectionMethod: null,
models: [],
},
opencode: {
type: 'opencode',
displayName: 'OpenCode',
isConnected: false,
connectionMethod: null,
models: [],
},
}
const DEFAULT_MCP_INTEGRATIONS: MCPCliIntegration[] = [

View file

@ -1,6 +1,6 @@
import { create } from 'zustand'
import { nanoid } from 'nanoid'
import type { PenDocument, PenNode, GroupNode } from '@/types/pen'
import type { PenDocument, PenNode, GroupNode, RefNode } from '@/types/pen'
import type { VariableDefinition } from '@/types/variables'
import { useHistoryStore } from '@/stores/history-store'
import { getDefaultTheme } from '@/variables/resolve-variables'
@ -98,6 +98,71 @@ function flattenNodes(nodes: PenNode[]): PenNode[] {
return result
}
/** Resolve the bounding box of a node, falling back to its referenced component for RefNodes. */
function getNodeBounds(
node: PenNode,
allNodes: PenNode[],
): { x: number; y: number; w: number; h: number } {
const x = node.x ?? 0
const y = node.y ?? 0
let w = ('width' in node && typeof node.width === 'number') ? node.width : 0
let h = ('height' in node && typeof node.height === 'number') ? node.height : 0
if (node.type === 'ref' && !w) {
const refComp = findNodeInTree(allNodes, (node as RefNode).ref)
if (refComp) {
w = ('width' in refComp && typeof refComp.width === 'number') ? refComp.width : 100
h = ('height' in refComp && typeof refComp.height === 'number') ? refComp.height : 100
}
}
return { x, y, w: w || 100, h: h || 100 }
}
/**
* Find a clear X position to the right of `sourceX + sourceW` that doesn't
* overlap any sibling (excluding `excludeId`) on the same vertical band.
*/
function findClearX(
sourceX: number,
sourceW: number,
proposedY: number,
proposedH: number,
siblings: PenNode[],
excludeId: string,
allNodes: PenNode[],
gap = 20,
): number {
const proposedW = sourceW
let proposedX = sourceX + sourceW + gap
const siblingBounds: { x: number; y: number; w: number; h: number }[] = []
for (const sib of siblings) {
if (sib.id === excludeId) continue
const b = getNodeBounds(sib, allNodes)
if (b.w > 0 && b.h > 0) siblingBounds.push(b)
}
let maxAttempts = 100
while (maxAttempts-- > 0) {
const hasOverlap = siblingBounds.some((b) => {
const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x
const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y
return overlapX && overlapY
})
if (!hasOverlap) break
let maxRight = proposedX
for (const b of siblingBounds) {
const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x
const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y
if (overlapX && overlapY && b.x + b.w > maxRight) {
maxRight = b.x + b.w
}
}
proposedX = maxRight + gap
}
return proposedX
}
function isDescendantOf(
nodes: PenNode[],
nodeId: string,
@ -129,8 +194,8 @@ function insertNodeInTree(
}
return nodes.map((n) => {
if (n.id === parentId && 'children' in n) {
const children = [...(n.children ?? [])]
if (n.id === parentId) {
const children = 'children' in n && n.children ? [...n.children] : []
if (index !== undefined) {
children.splice(index, 0, node)
} else {
@ -234,6 +299,10 @@ interface DocumentStoreState {
getFlatNodes: () => PenNode[]
isDescendantOf: (nodeId: string, ancestorId: string) => boolean
// Component management
makeReusable: (nodeId: string) => void
detachComponent: (nodeId: string) => string | undefined
// Variable management
setVariable: (name: string, definition: VariableDefinition) => void
removeVariable: (name: string) => void
@ -398,6 +467,47 @@ export const useDocumentStore = create<DocumentStoreState>(
const node = findNodeInTree(state.document.children, id)
if (!node) return null
// Duplicating a reusable component creates an instance (RefNode)
if ('reusable' in node && node.reusable === true) {
const bounds = getNodeBounds(node, state.document.children)
const parent = findParentInTree(state.document.children, id)
const parentId = parent ? parent.id : null
const siblings = parent
? ('children' in parent ? parent.children ?? [] : [])
: state.document.children
const idx = siblings.findIndex((n) => n.id === id)
const clearX = findClearX(
bounds.x, bounds.w, bounds.y, bounds.h,
siblings, id, state.document.children,
)
const refNode: RefNode = {
id: nanoid(),
type: 'ref',
ref: node.id,
name: node.name ?? node.type,
x: clearX,
y: bounds.y,
}
useHistoryStore.getState().pushState(state.document)
set((s) => ({
document: {
...s.document,
children: insertNodeInTree(
s.document.children,
parentId,
refNode as PenNode,
idx + 1,
),
},
isDirty: true,
}))
return refNode.id
}
// Regular duplication for non-reusable nodes
const cloneWithNewIds = (n: PenNode): PenNode => {
const cloned = { ...n, id: nanoid() } as PenNode
if ('children' in cloned && cloned.children) {
@ -408,8 +518,6 @@ export const useDocumentStore = create<DocumentStoreState>(
const clone = cloneWithNewIds(node)
clone.name = (clone.name ?? clone.type) + ' copy'
if (clone.x !== undefined) clone.x += 10
if (clone.y !== undefined) clone.y += 10
const parent = findParentInTree(state.document.children, id)
const parentId = parent ? parent.id : null
@ -418,6 +526,13 @@ export const useDocumentStore = create<DocumentStoreState>(
: state.document.children
const idx = siblings.findIndex((n) => n.id === id)
const bounds = getNodeBounds(node, state.document.children)
clone.x = findClearX(
bounds.x, bounds.w, bounds.y, bounds.h,
siblings, id, state.document.children,
)
clone.y = bounds.y
useHistoryStore.getState().pushState(state.document)
set((s) => ({
document: {
@ -596,6 +711,113 @@ export const useDocumentStore = create<DocumentStoreState>(
isDescendantOf: (nodeId, ancestorId) =>
isDescendantOf(get().document.children, nodeId, ancestorId),
// --- Component management ---
makeReusable: (nodeId) => {
const state = get()
const node = findNodeInTree(state.document.children, nodeId)
if (!node) return
// Only container types (frame, group, rectangle) can be made reusable
if (node.type !== 'frame' && node.type !== 'group' && node.type !== 'rectangle') return
if ('reusable' in node && node.reusable) return
useHistoryStore.getState().pushState(state.document)
set((s) => ({
document: {
...s.document,
children: updateNodeInTree(s.document.children, nodeId, {
reusable: true,
} as Partial<PenNode>),
},
isDirty: true,
}))
},
detachComponent: (nodeId) => {
const state = get()
const node = findNodeInTree(state.document.children, nodeId)
if (!node) return
// Case 1: Detach a reusable component (remove reusable flag)
if ('reusable' in node && node.reusable) {
useHistoryStore.getState().pushState(state.document)
set((s) => ({
document: {
...s.document,
children: updateNodeInTree(s.document.children, nodeId, {
reusable: undefined,
} as Partial<PenNode>),
},
isDirty: true,
}))
return nodeId
}
// Case 2: Detach an instance (RefNode → independent node tree)
if (node.type === 'ref') {
const component = findNodeInTree(state.document.children, node.ref)
if (!component) return
useHistoryStore.getState().pushState(state.document)
// Apply overrides to a copy of the component before cloning IDs
const source = structuredClone(component)
// Apply top-level visual overrides (fill, stroke, etc.)
const topOverrides = node.descendants?.[node.ref]
if (topOverrides) {
Object.assign(source, topOverrides)
}
// Apply child-level overrides
if (node.descendants && 'children' in source && source.children) {
source.children = source.children.map((child: PenNode) => {
const override = node.descendants?.[child.id]
return override ? ({ ...child, ...override } as PenNode) : child
})
}
// Clone with new IDs
const cloneWithNewIds = (n: PenNode): PenNode => {
const cloned = { ...n, id: nanoid() } as PenNode
if ('children' in cloned && cloned.children) {
cloned.children = cloned.children.map(cloneWithNewIds)
}
return cloned
}
const detached = cloneWithNewIds(source)
// Apply all direct instance properties (position, size, meta)
const detachedRecord = detached as unknown as Record<string, unknown>
for (const [key, val] of Object.entries(node)) {
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children' || key === 'id') continue
if (val !== undefined) {
detachedRecord[key] = val
}
}
if (!detached.name) detached.name = source.name
delete detachedRecord.reusable
// Replace the RefNode with the detached tree
const parent = findParentInTree(state.document.children, nodeId)
const parentId = parent ? parent.id : null
const siblings = parent
? ('children' in parent ? parent.children ?? [] : [])
: state.document.children
const idx = siblings.findIndex((n) => n.id === nodeId)
let newChildren = removeNodeFromTree(state.document.children, nodeId)
newChildren = insertNodeInTree(
newChildren,
parentId,
detached,
idx >= 0 ? idx : undefined,
)
set({
document: { ...state.document, children: newChildren },
isDirty: true,
})
return detached.id
}
},
// --- Variable management ---
setVariable: (name, definition) => {

87
src/stores/uikit-store.ts Normal file
View file

@ -0,0 +1,87 @@
import { create } from 'zustand'
import type { UIKit, ComponentCategory } from '@/types/uikit'
import { getBuiltInKits } from '@/uikit/built-in-registry'
const STORAGE_KEY = 'openpencil-uikits'
interface PersistedState {
importedKits: UIKit[]
}
interface UIKitStoreState {
/** All loaded kits (built-in + imported) */
kits: UIKit[]
/** Whether the browser panel is open */
browserOpen: boolean
/** Current search query */
searchQuery: string
/** Active category filter (null = all) */
activeCategory: ComponentCategory | null
/** Active kit filter (null = all) */
activeKitId: string | null
toggleBrowser: () => void
setBrowserOpen: (open: boolean) => void
setSearchQuery: (query: string) => void
setActiveCategory: (category: ComponentCategory | null) => void
setActiveKitId: (kitId: string | null) => void
importKit: (kit: UIKit) => void
removeKit: (kitId: string) => void
persist: () => void
hydrate: () => void
}
export const useUIKitStore = create<UIKitStoreState>((set, get) => ({
kits: getBuiltInKits(),
browserOpen: false,
searchQuery: '',
activeCategory: null,
activeKitId: null,
toggleBrowser: () => set((s) => ({ browserOpen: !s.browserOpen })),
setBrowserOpen: (open) => set({ browserOpen: open }),
setSearchQuery: (searchQuery) => set({ searchQuery }),
setActiveCategory: (activeCategory) => set({ activeCategory }),
setActiveKitId: (activeKitId) => set({ activeKitId }),
importKit: (kit) => {
set((s) => ({ kits: [...s.kits, kit] }))
get().persist()
},
removeKit: (kitId) => {
const { activeKitId } = get()
set((s) => ({
kits: s.kits.filter((k) => k.id !== kitId || k.builtIn),
// Reset filter if the deleted kit was selected
activeKitId: activeKitId === kitId ? null : activeKitId,
}))
get().persist()
},
persist: () => {
try {
const imported = get().kits.filter((k) => !k.builtIn)
localStorage.setItem(STORAGE_KEY, JSON.stringify({ importedKits: imported }))
} catch {
// ignore — localStorage may be full
}
},
hydrate: () => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const data = JSON.parse(raw) as Partial<PersistedState>
if (data.importedKits && Array.isArray(data.importedKits)) {
const builtIn = getBuiltInKits()
const builtInIds = new Set(builtIn.map((k) => k.id))
// Filter out any imported kits that clash with built-in IDs
const imported = data.importedKits.filter((k) => !builtInIds.has(k.id))
set({ kits: [...builtIn, ...imported] })
}
} catch {
// ignore
}
},
}))

View file

@ -108,3 +108,8 @@ code {
.syn-comment { color: #546e7a; font-style: italic; }
.syn-number { color: #f78c6c; }
.syn-bracket { color: #89ddff; }
/* Electron window drag regions */
.app-region-drag { -webkit-app-region: drag; }
.app-region-no-drag { -webkit-app-region: no-drag; }

View file

@ -1,10 +1,10 @@
export type AIProviderType = 'anthropic' | 'openai'
export type AIProviderType = 'anthropic' | 'openai' | 'opencode'
export interface AIProviderConfig {
type: AIProviderType
displayName: string
isConnected: boolean
connectionMethod: 'claude-code' | 'codex-cli' | null
connectionMethod: 'claude-code' | 'codex-cli' | 'opencode' | null
/** Models fetched when the user connects this provider */
models: GroupedModel[]
}

13
src/types/electron.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
interface ElectronAPI {
isElectron: true
openFile: () => Promise<{ filePath: string; content: string } | null>
saveFile: (
content: string,
defaultPath?: string,
) => Promise<string | null>
saveToPath: (filePath: string, content: string) => Promise<string>
}
interface Window {
electronAPI?: ElectronAPI
}

82
src/types/opencode-sdk.d.ts vendored Normal file
View file

@ -0,0 +1,82 @@
/**
* Minimal type declarations for @opencode-ai/sdk.
* The package's export map points to non-existent paths (dist/ vs dist/src/).
* A postinstall script patches this, but we keep declarations as fallback.
*/
interface OpencodeClient {
config: {
providers(options?: unknown): Promise<{
data: {
providers: OpencodeProvider[]
default: Record<string, string>
}
error: unknown
}>
}
session: {
create(options?: {
body?: { parentID?: string; title?: string }
}): Promise<{
data: OpencodeSession | undefined
error: unknown
}>
prompt(options: {
path: { id: string }
body: {
model?: { providerID: string; modelID: string }
noReply?: boolean
parts: Array<{ type: string; text: string }>
}
}): Promise<{
data:
| {
info: Record<string, unknown>
parts: Array<{ type: string; text?: string } & Record<string, unknown>>
}
| undefined
error: unknown
}>
}
}
interface OpencodeProvider {
id: string
name: string
models: Record<string, OpencodeModel>
}
interface OpencodeModel {
id: string
name: string
providerID: string
}
interface OpencodeSession {
id: string
title: string
}
declare module '@opencode-ai/sdk' {
export function createOpencode(options?: {
hostname?: string
port?: number
signal?: AbortSignal
timeout?: number
}): Promise<{
client: OpencodeClient
server: { url: string; close(): void }
}>
export function createOpencodeClient(config?: {
baseUrl?: string
directory?: string
}): OpencodeClient
}
declare module '@opencode-ai/sdk/client' {
export function createOpencodeClient(config?: {
baseUrl?: string
directory?: string
}): OpencodeClient
}

View file

@ -69,6 +69,7 @@ export interface ContainerProps {
| 'space_between'
| 'space_around'
alignItems?: 'start' | 'center' | 'end'
clipContent?: boolean
children?: PenNode[]
cornerRadius?: number | [number, number, number, number]
fill?: PenFill[]

42
src/types/uikit.ts Normal file
View file

@ -0,0 +1,42 @@
import type { PenDocument } from './pen'
export type ComponentCategory =
| 'buttons'
| 'inputs'
| 'cards'
| 'navigation'
| 'layout'
| 'feedback'
| 'data-display'
| 'other'
export interface KitComponent {
/** Node ID of the reusable FrameNode in the kit document */
id: string
/** Display name */
name: string
/** Category for organization in the browser */
category: ComponentCategory
/** Tags for search */
tags: string[]
/** Component dimensions for preview sizing */
width: number
height: number
}
export interface UIKit {
/** Unique identifier */
id: string
/** Display name */
name: string
/** Optional description */
description?: string
/** Version string */
version: string
/** Whether this is a built-in kit that ships with the app */
builtIn: boolean
/** Backing PenDocument containing the reusable nodes */
document: PenDocument
/** Extracted component metadata for browsing */
components: KitComponent[]
}

View file

@ -0,0 +1,18 @@
import type { UIKit } from '@/types/uikit'
import { DEFAULT_KIT_DOCUMENT } from './kits/default-kit'
import { DEFAULT_KIT_META } from './kits/default-kit-meta'
import { extractComponentsFromDocument } from './kit-utils'
const defaultKit: UIKit = {
id: 'default-uikit',
name: 'Default UIKit',
description: 'Built-in UI components: buttons, inputs, cards, navigation, feedback, and layout primitives.',
version: '1.0.0',
builtIn: true,
document: DEFAULT_KIT_DOCUMENT,
components: extractComponentsFromDocument(DEFAULT_KIT_DOCUMENT, DEFAULT_KIT_META),
}
export function getBuiltInKits(): UIKit[] {
return [defaultKit]
}

View file

@ -0,0 +1,195 @@
import { nanoid } from 'nanoid'
import type { PenDocument, PenNode } from '@/types/pen'
import type { UIKit } from '@/types/uikit'
import { normalizePenDocument } from '@/utils/normalize-pen-file'
import {
supportsFileSystemAccess,
saveDocumentAs,
downloadDocument,
} from '@/utils/file-operations'
import { extractComponentsFromDocument } from './kit-utils'
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
/**
* Import a .pen file as a UIKit via the native file picker.
* Returns null if the user cancels or the file has no reusable components.
*/
export async function importKitFromFile(): Promise<UIKit | null> {
const doc = await pickAndParsePenFile()
if (!doc) return null
const components = extractComponentsFromDocument(doc)
if (components.length === 0) return null
return {
id: nanoid(),
name: doc.name ?? 'Imported Kit',
version: doc.version,
builtIn: false,
document: doc,
components,
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
/**
* Export reusable components from the current document as a .pen kit file.
* If componentIds is empty, exports all reusable components.
*/
export async function exportKit(
sourceDoc: PenDocument,
componentIds: string[],
kitName: string,
): Promise<boolean> {
const reusableNodes = collectReusableNodes(sourceDoc.children)
const selected =
componentIds.length > 0
? reusableNodes.filter((n) => componentIds.includes(n.id))
: reusableNodes
if (selected.length === 0) return false
const kitDoc: PenDocument = {
version: '1.0.0',
name: kitName,
children: selected.map((n) => structuredClone(n)),
}
// Copy referenced variables
if (sourceDoc.variables) {
const refs = collectAllVariableRefs(selected)
const vars: Record<string, unknown> = {}
for (const ref of refs) {
const name = ref.startsWith('$') ? ref.slice(1) : ref
if (sourceDoc.variables[name]) {
vars[name] = structuredClone(sourceDoc.variables[name])
}
}
if (Object.keys(vars).length > 0) {
kitDoc.variables = vars as PenDocument['variables']
}
}
// Copy themes if variables were included
if (kitDoc.variables && sourceDoc.themes) {
kitDoc.themes = structuredClone(sourceDoc.themes)
}
const fileName = `${kitName.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'kit'}.op`
if (supportsFileSystemAccess()) {
const result = await saveDocumentAs(kitDoc, fileName)
return result !== null
}
downloadDocument(kitDoc, fileName)
return true
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
async function pickAndParsePenFile(): Promise<PenDocument | null> {
if (supportsFileSystemAccess()) {
return pickFileSystemAccess()
}
return pickFallback()
}
async function pickFileSystemAccess(): Promise<PenDocument | null> {
try {
const [handle] = await (
window as unknown as {
showOpenFilePicker: (opts: unknown) => Promise<FileSystemFileHandle[]>
}
).showOpenFilePicker({
types: [
{
description: 'OpenPencil File',
accept: { 'application/json': ['.op', '.pen', '.json'] },
},
],
})
const file = await handle.getFile()
const text = await file.text()
return parsePenJson(text)
} catch {
return null
}
}
function pickFallback(): Promise<PenDocument | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.op,.pen,.json'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) { resolve(null); return }
try {
const text = await file.text()
resolve(parsePenJson(text))
} catch {
resolve(null)
}
}
input.oncancel = () => resolve(null)
input.click()
})
}
function parsePenJson(text: string): PenDocument | null {
try {
const raw = JSON.parse(text) as PenDocument
if (!raw.version || !Array.isArray(raw.children)) return null
return normalizePenDocument(raw)
} catch {
return null
}
}
function collectReusableNodes(nodes: PenNode[]): PenNode[] {
const result: PenNode[] = []
for (const node of nodes) {
if ('reusable' in node && node.reusable) {
result.push(node)
}
if ('children' in node && node.children) {
result.push(...collectReusableNodes(node.children))
}
}
return result
}
function isVarRef(v: unknown): v is string {
return typeof v === 'string' && v.startsWith('$')
}
function collectAllVariableRefs(nodes: PenNode[]): Set<string> {
const refs = new Set<string>()
for (const node of nodes) {
collectNodeRefs(node, refs)
}
return refs
}
function collectNodeRefs(node: PenNode, refs: Set<string>): void {
if (isVarRef(node.opacity)) refs.add(node.opacity)
if (isVarRef(node.enabled)) refs.add(node.enabled as string)
if ('gap' in node && isVarRef(node.gap)) refs.add(node.gap as string)
if ('padding' in node && isVarRef(node.padding)) refs.add(node.padding as string)
if ('fill' in node && Array.isArray(node.fill)) {
for (const f of node.fill) {
if (f.type === 'solid' && isVarRef(f.color)) refs.add(f.color)
}
}
if ('children' in node && node.children) {
for (const child of node.children) collectNodeRefs(child, refs)
}
}

139
src/uikit/kit-utils.ts Normal file
View file

@ -0,0 +1,139 @@
import type { PenDocument, PenNode } from '@/types/pen'
import type { ComponentCategory, KitComponent } from '@/types/uikit'
/**
* Walk the document tree and extract all reusable nodes as KitComponent metadata.
*/
export function extractComponentsFromDocument(
doc: PenDocument,
metaOverrides?: Record<string, { category: ComponentCategory; tags: string[] }>,
): KitComponent[] {
const components: KitComponent[] = []
walkTree(doc.children, (node) => {
if ('reusable' in node && node.reusable) {
const w = 'width' in node && typeof node.width === 'number' ? node.width : 100
const h = 'height' in node && typeof node.height === 'number' ? node.height : 100
const meta = metaOverrides?.[node.id]
components.push({
id: node.id,
name: node.name ?? node.id,
category: meta?.category ?? inferCategory(node.name ?? ''),
tags: meta?.tags ?? inferTags(node.name ?? ''),
width: w,
height: h,
})
}
})
return components
}
/**
* Find a specific node in a document tree by ID.
*/
export function findReusableNode(doc: PenDocument, nodeId: string): PenNode | undefined {
return findInTree(doc.children, nodeId)
}
/**
* Recursively collect all $variable references used by a node tree.
*/
export function collectVariableRefs(node: PenNode): Set<string> {
const refs = new Set<string>()
collectRefs(node, refs)
return refs
}
/**
* Deep-clone a node tree preserving all IDs.
*/
export function deepCloneNode<T extends PenNode>(node: T): T {
return structuredClone(node)
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function walkTree(nodes: PenNode[], visitor: (node: PenNode) => void): void {
for (const node of nodes) {
visitor(node)
if ('children' in node && node.children) {
walkTree(node.children, visitor)
}
}
}
function findInTree(nodes: PenNode[], id: string): PenNode | undefined {
for (const node of nodes) {
if (node.id === id) return node
if ('children' in node && node.children) {
const found = findInTree(node.children, id)
if (found) return found
}
}
return undefined
}
function isVariableRef(value: unknown): value is string {
return typeof value === 'string' && value.startsWith('$')
}
function collectRefs(node: PenNode, refs: Set<string>): void {
if (isVariableRef(node.opacity)) refs.add(node.opacity)
if (isVariableRef(node.enabled)) refs.add(node.enabled)
if ('gap' in node && isVariableRef(node.gap)) refs.add(node.gap as string)
if ('padding' in node && isVariableRef(node.padding)) refs.add(node.padding as string)
if ('fill' in node && Array.isArray(node.fill)) {
for (const f of node.fill) {
if (f.type === 'solid' && isVariableRef(f.color)) refs.add(f.color)
}
}
if ('stroke' in node && node.stroke) {
const s = node.stroke
if (Array.isArray(s.fill)) {
for (const f of s.fill) {
if (f.type === 'solid' && isVariableRef(f.color)) refs.add(f.color)
}
}
if (isVariableRef(s.thickness)) refs.add(s.thickness as unknown as string)
}
if ('children' in node && node.children) {
for (const child of node.children) {
collectRefs(child, refs)
}
}
}
const CATEGORY_KEYWORDS: Record<ComponentCategory, string[]> = {
buttons: ['button', 'btn', 'cta'],
inputs: ['input', 'text field', 'textarea', 'checkbox', 'radio', 'toggle', 'switch', 'select', 'form'],
cards: ['card', 'tile'],
navigation: ['nav', 'navbar', 'tab', 'breadcrumb', 'menu', 'sidebar'],
layout: ['divider', 'separator', 'spacer', 'container', 'grid'],
feedback: ['alert', 'banner', 'toast', 'badge', 'tag', 'chip', 'avatar', 'tooltip'],
'data-display': ['table', 'list', 'stat', 'chart', 'progress'],
other: [],
}
function inferCategory(name: string): ComponentCategory {
const lower = name.toLowerCase()
for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
if (category === 'other') continue
for (const kw of keywords) {
if (lower.includes(kw)) return category as ComponentCategory
}
}
return 'other'
}
function inferTags(name: string): string[] {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.split(/[\s-]+/)
.filter(Boolean)
}

View file

@ -0,0 +1,26 @@
import type { ComponentCategory } from '@/types/uikit'
export const DEFAULT_KIT_META: Record<
string,
{ category: ComponentCategory; tags: string[] }
> = {
'uikit-btn-primary': { category: 'buttons', tags: ['button', 'primary', 'cta', 'action'] },
'uikit-btn-secondary': { category: 'buttons', tags: ['button', 'secondary', 'outline'] },
'uikit-btn-ghost': { category: 'buttons', tags: ['button', 'ghost', 'text', 'link'] },
'uikit-btn-destructive': { category: 'buttons', tags: ['button', 'destructive', 'danger', 'delete'] },
'uikit-input-text': { category: 'inputs', tags: ['input', 'text', 'field', 'form'] },
'uikit-input-textarea': { category: 'inputs', tags: ['textarea', 'multiline', 'text', 'form'] },
'uikit-input-checkbox': { category: 'inputs', tags: ['checkbox', 'check', 'toggle', 'form'] },
'uikit-input-toggle': { category: 'inputs', tags: ['toggle', 'switch', 'on', 'off'] },
'uikit-input-radio': { category: 'inputs', tags: ['radio', 'option', 'select', 'form'] },
'uikit-card-basic': { category: 'cards', tags: ['card', 'container', 'surface', 'basic'] },
'uikit-card-stats': { category: 'cards', tags: ['card', 'stats', 'metric', 'dashboard', 'kpi'] },
'uikit-card-image': { category: 'cards', tags: ['card', 'image', 'media', 'thumbnail'] },
'uikit-nav-bar': { category: 'navigation', tags: ['navbar', 'header', 'navigation', 'top'] },
'uikit-tab-bar': { category: 'navigation', tags: ['tab', 'tabs', 'navigation', 'segment'] },
'uikit-breadcrumb': { category: 'navigation', tags: ['breadcrumb', 'navigation', 'path'] },
'uikit-alert-banner': { category: 'feedback', tags: ['alert', 'banner', 'notification', 'info'] },
'uikit-badge': { category: 'feedback', tags: ['badge', 'tag', 'chip', 'label', 'status'] },
'uikit-avatar': { category: 'feedback', tags: ['avatar', 'user', 'profile', 'icon'] },
'uikit-divider': { category: 'layout', tags: ['divider', 'separator', 'line', 'hr'] },
}

View file

@ -0,0 +1,763 @@
import type { PenDocument, PenNode } from '@/types/pen'
// ---------------------------------------------------------------------------
// Buttons
// ---------------------------------------------------------------------------
const btnPrimary: PenNode = {
id: 'uikit-btn-primary',
type: 'frame',
name: 'Primary Button',
reusable: true,
x: 0,
y: 0,
width: 120,
height: 40,
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
padding: [0, 20, 0, 20],
cornerRadius: 8,
fill: [{ type: 'solid', color: '#2563EB' }],
children: [
{
id: 'uikit-btn-primary-label',
type: 'text',
name: 'Label',
content: 'Button',
fontSize: 14,
fontWeight: 600,
fill: [{ type: 'solid', color: '#FFFFFF' }],
},
],
}
const btnSecondary: PenNode = {
id: 'uikit-btn-secondary',
type: 'frame',
name: 'Secondary Button',
reusable: true,
x: 140,
y: 0,
width: 120,
height: 40,
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
padding: [0, 20, 0, 20],
cornerRadius: 8,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#D1D5DB' }] },
children: [
{
id: 'uikit-btn-secondary-label',
type: 'text',
name: 'Label',
content: 'Button',
fontSize: 14,
fontWeight: 600,
fill: [{ type: 'solid', color: '#374151' }],
},
],
}
const btnGhost: PenNode = {
id: 'uikit-btn-ghost',
type: 'frame',
name: 'Ghost Button',
reusable: true,
x: 280,
y: 0,
width: 120,
height: 40,
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
padding: [0, 20, 0, 20],
cornerRadius: 8,
children: [
{
id: 'uikit-btn-ghost-label',
type: 'text',
name: 'Label',
content: 'Button',
fontSize: 14,
fontWeight: 600,
fill: [{ type: 'solid', color: '#2563EB' }],
},
],
}
const btnDestructive: PenNode = {
id: 'uikit-btn-destructive',
type: 'frame',
name: 'Destructive Button',
reusable: true,
x: 420,
y: 0,
width: 120,
height: 40,
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
padding: [0, 20, 0, 20],
cornerRadius: 8,
fill: [{ type: 'solid', color: '#DC2626' }],
children: [
{
id: 'uikit-btn-destructive-label',
type: 'text',
name: 'Label',
content: 'Delete',
fontSize: 14,
fontWeight: 600,
fill: [{ type: 'solid', color: '#FFFFFF' }],
},
],
}
// ---------------------------------------------------------------------------
// Inputs
// ---------------------------------------------------------------------------
const inputText: PenNode = {
id: 'uikit-input-text',
type: 'frame',
name: 'Text Input',
reusable: true,
x: 0,
y: 60,
width: 240,
height: 40,
layout: 'horizontal',
alignItems: 'center',
padding: [0, 12, 0, 12],
cornerRadius: 8,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#D1D5DB' }] },
children: [
{
id: 'uikit-input-text-placeholder',
type: 'text',
name: 'Placeholder',
content: 'Enter text...',
fontSize: 14,
fill: [{ type: 'solid', color: '#9CA3AF' }],
},
],
}
const inputTextarea: PenNode = {
id: 'uikit-input-textarea',
type: 'frame',
name: 'Textarea',
reusable: true,
x: 260,
y: 60,
width: 240,
height: 96,
layout: 'vertical',
padding: 12,
cornerRadius: 8,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#D1D5DB' }] },
children: [
{
id: 'uikit-input-textarea-placeholder',
type: 'text',
name: 'Placeholder',
content: 'Enter description...',
fontSize: 14,
fill: [{ type: 'solid', color: '#9CA3AF' }],
width: 'fill_container',
},
],
}
const inputCheckbox: PenNode = {
id: 'uikit-input-checkbox',
type: 'frame',
name: 'Checkbox',
reusable: true,
x: 0,
y: 176,
width: 160,
height: 24,
layout: 'horizontal',
gap: 8,
alignItems: 'center',
children: [
{
id: 'uikit-input-checkbox-box',
type: 'rectangle',
name: 'Box',
width: 18,
height: 18,
cornerRadius: 4,
fill: [{ type: 'solid', color: '#2563EB' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#2563EB' }] },
},
{
id: 'uikit-input-checkbox-label',
type: 'text',
name: 'Label',
content: 'Checkbox label',
fontSize: 14,
fill: [{ type: 'solid', color: '#374151' }],
},
],
}
const inputToggle: PenNode = {
id: 'uikit-input-toggle',
type: 'frame',
name: 'Toggle Switch',
reusable: true,
x: 180,
y: 176,
width: 44,
height: 24,
cornerRadius: 12,
fill: [{ type: 'solid', color: '#2563EB' }],
children: [
{
id: 'uikit-input-toggle-thumb',
type: 'ellipse',
name: 'Thumb',
x: 22,
y: 2,
width: 20,
height: 20,
fill: [{ type: 'solid', color: '#FFFFFF' }],
},
],
}
const inputRadio: PenNode = {
id: 'uikit-input-radio',
type: 'frame',
name: 'Radio Button',
reusable: true,
x: 240,
y: 176,
width: 160,
height: 24,
layout: 'horizontal',
gap: 8,
alignItems: 'center',
children: [
{
id: 'uikit-input-radio-circle',
type: 'ellipse',
name: 'Circle',
width: 18,
height: 18,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 2, fill: [{ type: 'solid', color: '#2563EB' }] },
},
{
id: 'uikit-input-radio-label',
type: 'text',
name: 'Label',
content: 'Radio label',
fontSize: 14,
fill: [{ type: 'solid', color: '#374151' }],
},
],
}
// ---------------------------------------------------------------------------
// Cards
// ---------------------------------------------------------------------------
const cardBasic: PenNode = {
id: 'uikit-card-basic',
type: 'frame',
name: 'Basic Card',
reusable: true,
x: 0,
y: 220,
width: 280,
height: 160,
layout: 'vertical',
gap: 8,
padding: 20,
cornerRadius: 12,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
effects: [{ type: 'shadow', offsetX: 0, offsetY: 1, blur: 3, spread: 0, color: 'rgba(0,0,0,0.1)' }],
children: [
{
id: 'uikit-card-basic-title',
type: 'text',
name: 'Title',
content: 'Card Title',
fontSize: 18,
fontWeight: 600,
fill: [{ type: 'solid', color: '#111827' }],
},
{
id: 'uikit-card-basic-desc',
type: 'text',
name: 'Description',
content: 'Card description goes here with some supporting text.',
fontSize: 14,
lineHeight: 1.5,
fill: [{ type: 'solid', color: '#6B7280' }],
width: 'fill_container',
},
],
}
const cardStats: PenNode = {
id: 'uikit-card-stats',
type: 'frame',
name: 'Stats Card',
reusable: true,
x: 300,
y: 220,
width: 200,
height: 120,
layout: 'vertical',
gap: 4,
padding: 20,
cornerRadius: 12,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
effects: [{ type: 'shadow', offsetX: 0, offsetY: 1, blur: 3, spread: 0, color: 'rgba(0,0,0,0.1)' }],
children: [
{
id: 'uikit-card-stats-label',
type: 'text',
name: 'Label',
content: 'Total Revenue',
fontSize: 12,
fontWeight: 500,
fill: [{ type: 'solid', color: '#6B7280' }],
},
{
id: 'uikit-card-stats-value',
type: 'text',
name: 'Value',
content: '$45,231',
fontSize: 28,
fontWeight: 700,
fill: [{ type: 'solid', color: '#111827' }],
},
{
id: 'uikit-card-stats-change',
type: 'text',
name: 'Change',
content: '+20.1% from last month',
fontSize: 12,
fill: [{ type: 'solid', color: '#16A34A' }],
},
],
}
const cardImage: PenNode = {
id: 'uikit-card-image',
type: 'frame',
name: 'Image Card',
reusable: true,
x: 520,
y: 220,
width: 280,
height: 240,
layout: 'vertical',
cornerRadius: 12,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
effects: [{ type: 'shadow', offsetX: 0, offsetY: 1, blur: 3, spread: 0, color: 'rgba(0,0,0,0.1)' }],
children: [
{
id: 'uikit-card-image-placeholder',
type: 'rectangle',
name: 'Image',
width: 'fill_container',
height: 140,
fill: [{ type: 'solid', color: '#F3F4F6' }],
cornerRadius: [12, 12, 0, 0],
},
{
id: 'uikit-card-image-body',
type: 'frame',
name: 'Body',
width: 'fill_container',
layout: 'vertical',
gap: 4,
padding: [12, 16, 16, 16],
children: [
{
id: 'uikit-card-image-title',
type: 'text',
name: 'Title',
content: 'Card Title',
fontSize: 16,
fontWeight: 600,
fill: [{ type: 'solid', color: '#111827' }],
},
{
id: 'uikit-card-image-desc',
type: 'text',
name: 'Description',
content: 'Brief description text.',
fontSize: 13,
fill: [{ type: 'solid', color: '#6B7280' }],
},
],
},
],
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
const navbar: PenNode = {
id: 'uikit-nav-bar',
type: 'frame',
name: 'Navbar',
reusable: true,
x: 0,
y: 480,
width: 800,
height: 56,
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'space_between',
padding: [0, 24, 0, 24],
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
children: [
{
id: 'uikit-nav-bar-brand',
type: 'text',
name: 'Brand',
content: 'Brand',
fontSize: 18,
fontWeight: 700,
fill: [{ type: 'solid', color: '#111827' }],
},
{
id: 'uikit-nav-bar-links',
type: 'frame',
name: 'Links',
layout: 'horizontal',
gap: 24,
alignItems: 'center',
children: [
{
id: 'uikit-nav-bar-link-1',
type: 'text',
name: 'Link',
content: 'Home',
fontSize: 14,
fontWeight: 500,
fill: [{ type: 'solid', color: '#374151' }],
},
{
id: 'uikit-nav-bar-link-2',
type: 'text',
name: 'Link',
content: 'Products',
fontSize: 14,
fontWeight: 500,
fill: [{ type: 'solid', color: '#6B7280' }],
},
{
id: 'uikit-nav-bar-link-3',
type: 'text',
name: 'Link',
content: 'About',
fontSize: 14,
fontWeight: 500,
fill: [{ type: 'solid', color: '#6B7280' }],
},
],
},
],
}
const tabBar: PenNode = {
id: 'uikit-tab-bar',
type: 'frame',
name: 'Tab Bar',
reusable: true,
x: 0,
y: 556,
width: 400,
height: 40,
layout: 'horizontal',
gap: 0,
alignItems: 'center',
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
cornerRadius: 8,
children: [
{
id: 'uikit-tab-bar-tab-1',
type: 'frame',
name: 'Tab Active',
height: 'fill_container',
width: 'fill_container',
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
fill: [{ type: 'solid', color: '#EFF6FF' }],
cornerRadius: [8, 0, 0, 8],
children: [
{
id: 'uikit-tab-bar-tab-1-label',
type: 'text',
name: 'Label',
content: 'Tab 1',
fontSize: 13,
fontWeight: 600,
fill: [{ type: 'solid', color: '#2563EB' }],
},
],
},
{
id: 'uikit-tab-bar-tab-2',
type: 'frame',
name: 'Tab',
height: 'fill_container',
width: 'fill_container',
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
children: [
{
id: 'uikit-tab-bar-tab-2-label',
type: 'text',
name: 'Label',
content: 'Tab 2',
fontSize: 13,
fontWeight: 500,
fill: [{ type: 'solid', color: '#6B7280' }],
},
],
},
{
id: 'uikit-tab-bar-tab-3',
type: 'frame',
name: 'Tab',
height: 'fill_container',
width: 'fill_container',
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
cornerRadius: [0, 8, 8, 0],
children: [
{
id: 'uikit-tab-bar-tab-3-label',
type: 'text',
name: 'Label',
content: 'Tab 3',
fontSize: 13,
fontWeight: 500,
fill: [{ type: 'solid', color: '#6B7280' }],
},
],
},
],
}
const breadcrumb: PenNode = {
id: 'uikit-breadcrumb',
type: 'frame',
name: 'Breadcrumb',
reusable: true,
x: 420,
y: 556,
width: 280,
height: 32,
layout: 'horizontal',
gap: 8,
alignItems: 'center',
children: [
{
id: 'uikit-breadcrumb-home',
type: 'text',
name: 'Home',
content: 'Home',
fontSize: 13,
fill: [{ type: 'solid', color: '#6B7280' }],
},
{
id: 'uikit-breadcrumb-sep-1',
type: 'text',
name: 'Separator',
content: '/',
fontSize: 13,
fill: [{ type: 'solid', color: '#D1D5DB' }],
},
{
id: 'uikit-breadcrumb-section',
type: 'text',
name: 'Section',
content: 'Section',
fontSize: 13,
fill: [{ type: 'solid', color: '#6B7280' }],
},
{
id: 'uikit-breadcrumb-sep-2',
type: 'text',
name: 'Separator',
content: '/',
fontSize: 13,
fill: [{ type: 'solid', color: '#D1D5DB' }],
},
{
id: 'uikit-breadcrumb-current',
type: 'text',
name: 'Current',
content: 'Current Page',
fontSize: 13,
fontWeight: 500,
fill: [{ type: 'solid', color: '#111827' }],
},
],
}
// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------
const alertBanner: PenNode = {
id: 'uikit-alert-banner',
type: 'frame',
name: 'Alert Banner',
reusable: true,
x: 0,
y: 616,
width: 400,
height: 56,
layout: 'horizontal',
gap: 12,
alignItems: 'center',
padding: [0, 16, 0, 16],
cornerRadius: 8,
fill: [{ type: 'solid', color: '#EFF6FF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#BFDBFE' }] },
children: [
{
id: 'uikit-alert-banner-icon',
type: 'ellipse',
name: 'Icon',
width: 8,
height: 8,
fill: [{ type: 'solid', color: '#2563EB' }],
},
{
id: 'uikit-alert-banner-text',
type: 'text',
name: 'Message',
content: 'This is an informational alert message.',
fontSize: 14,
fill: [{ type: 'solid', color: '#1E40AF' }],
},
],
}
const badge: PenNode = {
id: 'uikit-badge',
type: 'frame',
name: 'Badge',
reusable: true,
x: 420,
y: 616,
width: 64,
height: 24,
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
padding: [0, 10, 0, 10],
cornerRadius: 12,
fill: [{ type: 'solid', color: '#DBEAFE' }],
children: [
{
id: 'uikit-badge-label',
type: 'text',
name: 'Label',
content: 'Badge',
fontSize: 12,
fontWeight: 500,
fill: [{ type: 'solid', color: '#1D4ED8' }],
},
],
}
const avatar: PenNode = {
id: 'uikit-avatar',
type: 'frame',
name: 'Avatar',
reusable: true,
x: 504,
y: 616,
width: 40,
height: 40,
cornerRadius: 20,
fill: [{ type: 'solid', color: '#DBEAFE' }],
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
children: [
{
id: 'uikit-avatar-initials',
type: 'text',
name: 'Initials',
content: 'JD',
fontSize: 14,
fontWeight: 600,
fill: [{ type: 'solid', color: '#2563EB' }],
},
],
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
const divider: PenNode = {
id: 'uikit-divider',
type: 'frame',
name: 'Divider',
reusable: true,
x: 0,
y: 692,
width: 400,
height: 1,
fill: [{ type: 'solid', color: '#E5E7EB' }],
}
// ---------------------------------------------------------------------------
// Document
// ---------------------------------------------------------------------------
export const DEFAULT_KIT_DOCUMENT: PenDocument = {
version: '1.0.0',
name: 'Default UIKit',
children: [
btnPrimary,
btnSecondary,
btnGhost,
btnDestructive,
inputText,
inputTextarea,
inputCheckbox,
inputToggle,
inputRadio,
cardBasic,
cardStats,
cardImage,
navbar,
tabBar,
breadcrumb,
alertBanner,
badge,
avatar,
divider,
],
}

View file

@ -1,4 +1,5 @@
import type { Canvas } from 'fabric'
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
function downloadFile(url: string, filename: string) {
const link = document.createElement('a')
@ -43,6 +44,88 @@ export function exportToPNG(canvas: Canvas, options?: PNGExportOptions) {
downloadFile(dataURL, filename)
}
export type RasterFormat = 'png' | 'jpeg' | 'webp'
export interface RasterExportOptions {
format?: RasterFormat
multiplier?: number
filename?: string
selectedOnly?: boolean
}
export function exportToRaster(canvas: Canvas, options?: RasterExportOptions) {
const format = options?.format ?? 'png'
const multiplier = options?.multiplier ?? 2
const filename = options?.filename ?? `design.${format === 'jpeg' ? 'jpg' : format}`
const quality = format === 'png' ? 1 : 0.92
const exportOpts = { format, multiplier, quality } as Parameters<Canvas['toDataURL']>[0]
if (options?.selectedOnly) {
const active = canvas.getActiveObject()
if (active) {
const dataURL = active.toDataURL(exportOpts)
downloadFile(dataURL, filename)
return
}
}
const dataURL = canvas.toDataURL(exportOpts)
downloadFile(dataURL, filename)
}
/**
* Export a layer (node + all descendants) as a raster image.
* Nodes are flattened on canvas as individual Fabric objects. We render them
* onto a fresh offscreen canvas to avoid viewport transform issues.
*/
export function exportLayerToRaster(
canvas: Canvas,
nodeId: string,
descendantIds: Set<string>,
options?: Omit<RasterExportOptions, 'selectedOnly'>,
) {
const format = options?.format ?? 'png'
const multiplier = options?.multiplier ?? 1
const filename = options?.filename ?? `design.${format === 'jpeg' ? 'jpg' : format}`
const quality = format === 'png' ? 1 : 0.92
const allIds = new Set(descendantIds)
allIds.add(nodeId)
const allObjects = canvas.getObjects() as FabricObjectWithPenId[]
// Find the root node's Fabric object to determine crop bounds
const rootObj = allObjects.find((obj) => obj.penNodeId === nodeId)
if (!rootObj) return
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)
// Collect layer objects in render order
const layerObjects = allObjects.filter(
(obj) => obj.penNodeId && allIds.has(obj.penNodeId),
)
// Render onto a fresh offscreen canvas — no viewport transform interference
const offscreen = document.createElement('canvas')
offscreen.width = Math.ceil(w * multiplier)
offscreen.height = Math.ceil(h * multiplier)
const ctx = offscreen.getContext('2d')!
ctx.scale(multiplier, multiplier)
ctx.translate(-originX, -originY)
for (const obj of layerObjects) {
obj.render(ctx)
}
const mimeType = format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png'
const dataURL = offscreen.toDataURL(mimeType, quality)
downloadFile(dataURL, filename)
}
export function exportToSVG(canvas: Canvas, options?: SVGExportOptions) {
const filename = options?.filename ?? 'design.svg'

View file

@ -34,11 +34,11 @@ export async function saveDocumentAs(
showSaveFilePicker: (opts: unknown) => Promise<FileSystemFileHandle>
}
).showSaveFilePicker({
suggestedName: suggestedName || 'untitled.pen',
suggestedName: suggestedName || 'untitled.op',
types: [
{
description: 'Pen Design File',
accept: { 'application/json': ['.pen'] },
description: 'OpenPencil File',
accept: { 'application/json': ['.op'] },
},
],
})
@ -66,8 +66,8 @@ export async function openDocumentFS(): Promise<{
).showOpenFilePicker({
types: [
{
description: 'Pen Design File',
accept: { 'application/json': ['.pen', '.json'] },
description: 'OpenPencil File',
accept: { 'application/json': ['.op', '.pen', '.json'] },
},
],
})
@ -108,7 +108,7 @@ export function openDocument(): Promise<{
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.pen,.json'
input.accept = '.op,.pen,.json'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) {

View file

@ -14,6 +14,10 @@ export function cloneNodesWithNewIds(
): PenNode[] {
return structuredClone(nodes).map((node) => {
const withNewId = reassignIds(node)
// Cloned nodes should not retain reusable component status
if ('reusable' in withNewId) {
delete (withNewId as unknown as Record<string, unknown>).reusable
}
if (offset !== 0) {
withNewId.x = (withNewId.x ?? 0) + offset
withNewId.y = (withNewId.y ?? 0) + offset

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