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.
This commit is contained in:
Kayshen-X 2026-02-21 15:22:23 +08:00
parent 523f96a2f7
commit 80dcda0bf1
9 changed files with 611 additions and 229 deletions

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

@ -0,0 +1,88 @@
name: Build Electron
on:
push:
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

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"]})
@ -155,6 +158,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
- **`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 行。超出时应拆分为更小的模块。

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
@ -132,12 +134,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)
- **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)
- **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,6 +151,16 @@ 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:
@ -156,40 +172,50 @@ The AI assistant works in two modes:
| 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
canvas/ # Fabric.js canvas engine
variables/ # Design variables/tokens system
resolve-variables Core $variable resolution for canvas rendering
replace-refs Replace/resolve $refs on rename/delete
components/
editor/ # Editor layout, toolbar, tool buttons, status bar
panels/ # Layer panel, property panel (17 files), AI chat, code panel,
# variables panel, variable row
panels/ # Layer panel, property panel, AI chat, code panel, variables panel
shared/ # ColorPicker, NumberInput, VariablePicker, ExportDialog, etc.
icons/ # Provider logos (Claude, OpenAI)
ui/ # shadcn/ui primitives (Button, Select, Slider, Switch, etc.)
@ -202,8 +228,15 @@ src/
types/ # PenDocument/PenNode types, style types, variables, agent settings
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)
preload.ts # Context bridge for renderer ↔ main IPC
server/
api/ai/ # Nitro API: streaming chat, generation, agent connection, models
.github/
workflows/
ci.yml # CI: type check, test, web build
build-electron.yml # Electron build for macOS/Windows/Linux + GitHub Release
```
## Roadmap

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'
@ -24,7 +24,11 @@ import type { AIProviderType } 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'
import ChatMessage from './chat-message'
import ChatMessage, {
parseStepBlocks,
countDesignJsonBlocks,
buildPipelineProgress,
} from './chat-message'
const PROVIDER_ICON: Record<AIProviderType, typeof ClaudeLogo> = {
anthropic: ClaudeLogo,
@ -274,6 +278,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="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 flex-col gap-1">
{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.
@ -678,6 +764,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

View file

@ -46,23 +46,12 @@ function stripToolCallXml(text: string): string {
return cleaned.trim()
}
interface ParsedStep {
export interface ParsedStep {
title: string
content: string
}
const DESIGN_PIPELINE_TASKS = [
'Create sidebar navigation with mission control sections',
'Add system status panel with telemetry data',
'Build main header with launch controls',
'Create mission metrics cards row',
'Add rocket visualization with futuristic image',
'Build launch sequence panel',
'Add mission timeline/countdown section',
'Final spacing and visual consistency pass',
]
function parseStepBlocks(text: string, isStreaming?: boolean): ParsedStep[] {
export function parseStepBlocks(text: string, isStreaming?: boolean): ParsedStep[] {
const stepRegex = /<step(?:[^>]*title="([^"]+)")?[^>]*>([\s\S]*?)<\/step>/gi
const parsed: ParsedStep[] = []
let match: RegExpExecArray | null
@ -102,58 +91,87 @@ function stripStepBlocks(text: string): string {
.trim()
}
function countDesignJsonBlocks(text: string): number {
const blockRegex = /```(?:json)?\s*([\s\S]*?)```/gi
/** 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) {
if (isDesignJson(match[1])) count += 1
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
}
function buildPipelineProgress(
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 DESIGN_PIPELINE_TASKS.map((label) => ({ label, done: true, active: false }))
return steps.map((s) => ({ label: s.title, done: true, active: false }))
}
const lowerTitles = new Set(steps.map((s) => s.title.toLowerCase()))
const hasGuidelines = [...lowerTitles].some((t) => t.includes('guidelines'))
const hasEditorState = [...lowerTitles].some((t) => t.includes('editor state') || t.includes('state'))
const hasStyleGuide = [...lowerTitles].some((t) => t.includes('styleguide') || t.includes('style guide'))
const doneCount = Math.min(
DESIGN_PIPELINE_TASKS.length,
(hasGuidelines ? 1 : 0) +
(hasEditorState ? 1 : 0) +
(hasStyleGuide ? 1 : 0) +
Math.min(4, jsonBlockCount) +
(isApplied ? 1 : 0),
)
return DESIGN_PIPELINE_TASKS.map((label, index) => {
const done = index < doneCount
const active = isStreaming && !done && index === doneCount
return { label, done, active }
// 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 */
/** 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 }) {
if (steps.length === 0) return null
// 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 isDone = !isStreaming || i < steps.length - 1
const isActive = !!isStreaming && i === steps.length - 1
{stepsWithContent.map((step, i) => {
const isDone = !isStreaming || i < stepsWithContent.length - 1
const isActive = !!isStreaming && i === stepsWithContent.length - 1
return (
<ActionStepItem
key={`${step.title}-${i}`}
@ -234,41 +252,6 @@ function ActionStepItem({
)
}
function PipelineChecklist({
items,
}: {
items: Array<{ label: string; done: boolean; active: boolean }>
}) {
const completed = items.filter((item) => item.done).length
return (
<div className="mt-2 border-t border-border/40 pt-2">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">Pencil it out</span>
<span className="text-[10px] text-muted-foreground">{completed}/{items.length}</span>
</div>
<div className="flex flex-col gap-1">
{items.map((item, index) => (
<div key={`${item.label}-${index}`} className="flex items-center gap-1.5 text-[10px] text-muted-foreground/90">
<span
className={cn(
'w-3 h-3 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>
)
}
/** Check if a JSON string looks like PenNode data */
function isDesignJson(code: string): boolean {
return /^\s*[\[{]/.test(code) && /"type"\s*:/.test(code) && /"id"\s*:/.test(code)
@ -502,6 +485,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])
@ -605,21 +592,6 @@ export default function ChatMessage({
() => (isUser ? displayContent : stripStepBlocks(displayContent)),
[isUser, displayContent],
)
const jsonBlockCount = useMemo(
() => (isUser ? 0 : countDesignJsonBlocks(displayContent)),
[isUser, displayContent],
)
const checklistItems = useMemo(
() =>
buildPipelineProgress(
steps,
jsonBlockCount,
!!isStreaming,
isApplied,
/\*\*Error:\*\*/i.test(content),
),
[steps, jsonBlockCount, isStreaming, isApplied, content],
)
const isEmpty = !contentWithoutSteps.trim() && !hasFlow
// Don't render an empty non-streaming assistant message
@ -662,7 +634,6 @@ export default function ChatMessage({
{hasFlow && (
<div className="mb-2">
<ActionSteps steps={steps} isStreaming={isStreaming} />
<PipelineChecklist items={checklistItems} />
</div>
)}
{contentWithoutSteps.trim() ? (

View file

@ -116,64 +116,61 @@ 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. You may include 1-2 brief <step> tags (optional, keep them SHORT one line each).
2. Output a SINGLE ${BLOCK}json code block containing the COMPLETE design as a PenNode JSON array.
3. Add a 1-sentence summary after the 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":"#16171B"}]}
{"_parent":"page","id":"nav","type":"frame","name":"Nav","width":"fill_container","height":56,"layout":"horizontal","padding":16,"alignItems":"center","fill":[{"type":"solid","color":"#1E2026"}]}
{"_parent":"nav","id":"logo","type":"text","name":"Logo","content":"App","fontSize":18,"fontWeight":700,"width":60,"height":22,"fill":[{"type":"solid","color":"#F4F4F5"}]}
{"_parent":"nav","id":"menu-icon","type":"path","name":"MenuIcon","d":"M3 12h18M3 6h18M3 18h18","width":24,"height":24,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#F4F4F5"}]}}
{"_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,"width":300,"height":36,"fill":[{"type":"solid","color":"#F4F4F5"}]}
${BLOCK}
CRITICAL RULES:
- Output ONE complete JSON block with ALL nodes do NOT split into multiple phases.
- Use a single root frame containing ALL elements as children.
- Keep IDs unique and descriptive.
- DO NOT WRITE ANY INTRODUCTORY TEXT.
- Start generating JSON as quickly as possible minimize preamble.
- 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.
- Children of layout frames: NO x/y. Use width/height (or "fill_container").
- 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.
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.
${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
- You can use icons from any popular Iconify collection: Lucide, Material Design Icons (mdi), Phosphor, Tabler Icons, Heroicons, Carbon, etc. Use the SVG path data you know from these icon sets
- 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.
ICONS: "path" nodes with SVG d. Size 16-24px. Use Lucide/MDI/Heroicons paths.
IMAGES: "image" nodes with src "https://picsum.photos/{w}/{h}".
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

@ -6,6 +6,7 @@ import { DESIGN_GENERATOR_PROMPT, DESIGN_MODIFIER_PROMPT } from './ai-prompts'
import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
import { useHistoryStore } from '@/stores/history-store'
import {
pendingAnimationNodes,
markNodesForAnimation,
startNewAnimationBatch,
resetAnimationState,
@ -38,6 +39,10 @@ function extractJsonFromResponse(text: string): PenNode[] | null {
return selectBestNodeSet(parsedBlocks)
}
// Try JSONL format (flat nodes with _parent field)
const jsonlTree = parseJsonlToTree(text)
if (jsonlTree) return jsonlTree
// Fallback: try to find a single JSON array if no blocks found
const arrayMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/)
if (arrayMatch) {
@ -51,7 +56,7 @@ function extractJsonFromResponse(text: string): PenNode[] | null {
if (directNodes) {
return directNodes
}
return null
}
@ -139,6 +144,153 @@ function extractAllJsonBlocks(text: string): string[] {
return blocks
}
// ---------------------------------------------------------------------------
// Streaming JSONL parser — extracts completed JSON objects from within
// a ```json block as they stream in, enabling element-by-element rendering.
// ---------------------------------------------------------------------------
interface StreamingNodeResult {
node: PenNode
parentId: string | null
}
/**
* Extract completed JSON objects from streaming text (within a ```json block).
* Uses brace-counting to detect complete objects before the block closes.
* Each object is expected to have a `_parent` field for tree insertion.
*/
function extractStreamingNodes(
text: string,
processedOffset: number,
): { results: StreamingNodeResult[]; newOffset: number } {
// Find the start of the json block
const jsonBlockStart = text.indexOf('```json')
if (jsonBlockStart === -1) return { results: [], newOffset: processedOffset }
const contentStart = text.indexOf('\n', jsonBlockStart)
if (contentStart === -1) return { results: [], newOffset: processedOffset }
const startPos = Math.max(processedOffset, contentStart + 1)
// Check if the block has ended (stop before closing ```)
const blockEnd = text.indexOf('\n```', contentStart + 1)
const searchEnd = blockEnd > 0 ? blockEnd : text.length
const results: StreamingNodeResult[] = []
let i = startPos
while (i < searchEnd) {
// Skip to next '{' character
while (i < searchEnd && text[i] !== '{') i++
if (i >= searchEnd) break
// Brace-counting to find matching '}'
const objStart = i
let depth = 0
let inString = false
let escaped = false
let j = i
while (j < searchEnd) {
const ch = text[j]
if (escaped) { escaped = false; j++; continue }
if (ch === '\\' && inString) { escaped = true; j++; continue }
if (ch === '"') { inString = !inString; j++; continue }
if (inString) { j++; continue }
if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth === 0) {
// Complete object found
const objStr = text.slice(objStart, j + 1)
try {
const obj = JSON.parse(objStr) as Record<string, unknown>
if (obj.id && obj.type) {
const parentId = (obj._parent as string | null) ?? null
delete obj._parent
results.push({ node: obj as unknown as PenNode, parentId })
}
} catch { /* malformed JSON, skip */ }
i = j + 1
break
}
}
j++
}
if (depth > 0) break // Incomplete object, wait for more data
}
return { results, newOffset: i }
}
/**
* Parse JSONL-format response (flat nodes with _parent field) into a tree.
* Used by extractAndApplyDesign for batch apply of JSONL content.
*/
function parseJsonlToTree(text: string): PenNode[] | null {
const { results } = extractStreamingNodes(text, 0)
if (results.length === 0) return null
const nodeMap = new Map<string, PenNode>()
const roots: PenNode[] = []
for (const { node, parentId } of results) {
nodeMap.set(node.id, node)
if (parentId === null) {
roots.push(node)
} else {
const parent = nodeMap.get(parentId)
if (parent) {
if (!('children' in parent) || !Array.isArray((parent as PenNode & { children?: PenNode[] }).children)) {
;(parent as PenNode & { children?: PenNode[] }).children = []
}
;(parent as PenNode & { children: PenNode[] }).children.push(node)
} else {
roots.push(node) // Parent not found, treat as root
}
}
}
return roots.length > 0 ? roots : null
}
/**
* Insert a single streaming node into the canvas with animation.
* Handles root frame replacement and parent ID remapping.
*/
function insertStreamingNode(
node: PenNode,
parentId: string | null,
): void {
const { addNode, getNodeById } = useDocumentStore.getState()
// Ensure container nodes have children array for later child insertions
if ((node.type === 'frame' || node.type === 'group') && !('children' in node)) {
;(node as PenNode & { children: PenNode[] }).children = []
}
// Resolve remapped parent IDs (e.g., root frame → DEFAULT_FRAME_ID)
const resolvedParent = parentId
? (generationRemappedIds.get(parentId) ?? parentId)
: null
// Mark node for fade-in animation
pendingAnimationNodes.add(node.id)
startNewAnimationBatch()
if (resolvedParent === null && isCanvasOnlyEmptyFrame() && node.type === 'frame') {
// Root frame replaces the default empty frame
replaceEmptyFrame(node)
} else {
const effectiveParent = resolvedParent ?? DEFAULT_FRAME_ID
// Verify parent exists, fall back to root frame
const parent = getNodeById(effectiveParent)
addNode(parent ? effectiveParent : DEFAULT_FRAME_ID, node)
}
}
function selectBestNodeSet(candidates: PenNode[][]): PenNode[] {
let best = candidates[candidates.length - 1]
let bestScore = scoreNodeSet(best)
@ -191,69 +343,47 @@ export async function generateDesign(
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
const userMessage = buildContextMessage(request)
let fullResponse = ''
let processedBlockCount = 0
let streamingOffset = 0 // Tracks how far we've parsed in the streaming text
let appliedCount = 0
let streamError: string | null = null
const animated = callbacks?.animated ?? false
// Reset cross-phase ID remapping so that replaceEmptyFrame mappings
// from a previous generation don't leak into this one.
resetGenerationRemapping()
// Animation setup: single history batch + stagger state.
// Nodes are inserted immediately (sync) via upsertNodesToCanvas.
// Canvas-sync creates Fabric objects at opacity 0 and schedules
// staggered fade-in via fire-and-forget setTimeout — no stream blocking.
if (animated) {
resetAnimationState()
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
}
let isThinking = false
let thinkingContent = ''
try {
for await (const chunk of streamChat(DESIGN_GENERATOR_PROMPT, [
{ role: 'user', content: userMessage },
], undefined, DESIGN_STREAM_TIMEOUTS)) {
if (chunk.type === 'thinking') {
// Show a "Thinking" step so the UI isn't stuck on the empty indicator
if (!isThinking && !fullResponse) {
isThinking = true
callbacks?.onTextUpdate?.('<step title="Thinking">Analyzing your design request...</step>')
}
thinkingContent += chunk.content
// Stream actual thinking content to UI in real-time
callbacks?.onTextUpdate?.(`<step title="Thinking">${thinkingContent}</step>`)
} else if (chunk.type === 'text') {
isThinking = false
fullResponse += chunk.content
if (callbacks?.onTextUpdate) {
callbacks.onTextUpdate(fullResponse)
}
// Prepend thinking step so it stays visible after text starts
const thinkingPrefix = thinkingContent
? `<step title="Thinking">${thinkingContent}</step>\n`
: ''
callbacks?.onTextUpdate?.(thinkingPrefix + fullResponse)
if (callbacks?.onApplyPartial) {
const allBlocks = extractAllJsonBlocks(fullResponse)
if (allBlocks.length > processedBlockCount) {
const newBlocks = allBlocks.slice(processedBlockCount)
let applied = 0
for (const blockJson of newBlocks) {
const blockNodes = tryParseNodes(blockJson)
if (!blockNodes || blockNodes.length === 0) continue
if (animated) {
// Mark sanitized IDs for animation, then upsert (sync).
// Canvas-sync will create objects at opacity 0 and schedule
// staggered fade-in via setTimeout — does NOT block the stream.
const prepared = sanitizeNodesForUpsert(blockNodes)
startNewAnimationBatch()
markNodesForAnimation(prepared)
applied += upsertPreparedNodes(prepared)
} else {
applied += upsertNodesToCanvas(blockNodes)
}
if (animated) {
// Element-by-element streaming: extract completed JSON objects
// from the JSONL block as they finish generating.
const { results, newOffset } = extractStreamingNodes(fullResponse, streamingOffset)
if (results.length > 0) {
streamingOffset = newOffset
for (const { node, parentId } of results) {
insertStreamingNode(node, parentId)
appliedCount++
}
if (applied > 0) {
callbacks.onApplyPartial(applied)
}
processedBlockCount = allBlocks.length
callbacks?.onApplyPartial?.(appliedCount)
}
}
} else if (chunk.type === 'error') {
@ -267,8 +397,13 @@ export async function generateDesign(
}
}
// Build final tree from response for return value
const streamedNodes = extractJsonFromResponse(fullResponse)
if (streamedNodes && streamedNodes.length > 0) {
// If nothing was applied during streaming, apply now as fallback
if (appliedCount === 0) {
return { nodes: streamedNodes, rawResponse: fullResponse }
}
return { nodes: streamedNodes, rawResponse: fullResponse }
}
@ -276,10 +411,6 @@ export async function generateDesign(
throw new Error(streamError)
}
// If no JSON found, return empty nodes but valid response.
// This allows the "chatty" response ("I'll create a plan...") to be shown to the user
// instead of an ugly "Failed to parse" error.
// The UI will just show the text and appliedCount will be 0.
return { nodes: [], rawResponse: fullResponse }
}
@ -525,17 +656,18 @@ function mergeNodeForProgressiveUpsert(
}
const existingById = new Map(existingChildren.map((c) => [c.id, c] as const))
const incomingIds = new Set(incomingChildren.map((c) => c.id))
const incomingById = new Map(incomingChildren.map((c) => [c.id, c] as const))
const mergedChildren: PenNode[] = []
for (const child of incomingChildren) {
const ex = existingById.get(child.id)
mergedChildren.push(ex ? mergeNodeForProgressiveUpsert(ex, child) : child)
// 1. Existing children first (preserves already-built order)
for (const ex of existingChildren) {
const inc = incomingById.get(ex.id)
mergedChildren.push(inc ? mergeNodeForProgressiveUpsert(ex, inc) : ex)
}
// Keep existing children that are not mentioned in this phase.
for (const ex of existingChildren) {
if (!incomingIds.has(ex.id)) mergedChildren.push(ex)
// 2. Append new incoming children (progressive sections added at end)
for (const child of incomingChildren) {
if (!existingById.has(child.id)) mergedChildren.push(child)
}
setNodeChildren(merged, mergedChildren)

View file

@ -194,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 {