* feat(ai): scaffold pen-ai-skills package

* feat(ai): add pen-ai-skills core types

* feat(ai): add Vite plugin for skill file compilation

* feat(ai): add budget module with token estimation and category-priority trimming

* feat(ai): add skill loader with phase filtering and test injection

* feat(ai): add skill resolver with phase filter, intent match, and dynamic injection

* feat(ai): migrate all prompt content to skill files

Extract prompt content from 7 TypeScript source files into 27 Markdown
skill files with proper frontmatter. Each file contains phase, trigger,
priority, budget, and category metadata for the skill resolution engine.

Planning: decomposition, design-type
Generation: jsonl-format, jsonl-format-simplified, schema, layout,
  text-rules, overflow, style-defaults, variables, design-system,
  design-code, design-md
Validation: vision-feedback
Maintenance: local-edit, incremental-add, style-consistency
Domains: landing-page, dashboard, mobile-app, form-ui, cjk-typography
Knowledge: role-definitions, design-principles, icon-catalog,
  copywriting, examples

* feat(ai): add document context memory module

* feat(ai): add generation history memory module

* feat(ai): wire memory loading into skill resolver

Add memory field to ResolveOptions and load documentContext/generationHistory
into AgentContext with per-phase limits (planning:5, maintenance:3, others:0).
Export memory utility functions from package index.

* refactor(ai): use resolveSkills('planning') in orchestrator

Replace ORCHESTRATOR_PROMPT import with resolveSkills() from pen-ai-skills.
The planning phase system prompt is now resolved dynamically from skill
files instead of a static constant.

* refactor(ai): use resolveSkills('generation') in sub-agent

Replace SUB_AGENT_PROMPT + designPrinciples concatenation with
resolveSkills() from pen-ai-skills. The generation phase system prompt
is now resolved dynamically with flag-based skill filtering for
variables and design.md context.

* refactor(ai): use resolveSkills('validation') in design validation

Replace the 70-line inline VALIDATION_SYSTEM_PROMPT constant with a
lazy resolver function that loads the validation prompt from pen-ai-skills
skill files at call time.

* refactor(mcp): use skill registry for design prompt sections

* refactor(ai): remove old prompt files replaced by pen-ai-skills

Delete prompt files whose content has been migrated to the
pen-ai-skills package:
- ai-prompt-sections.ts (section registry, triggers, builders)
- orchestrator-prompts.ts (orchestrator + sub-agent prompts)
- design-system-prompts.ts (design token generation prompt)
- design-code-prompts.ts (HTML/CSS code-gen prompt)
- design-principles/ directory (5 principle files + index)

Consolidate role-definitions/ 8 sub-files into index.ts
(registerRole calls are runtime behavior, must be preserved).

Move buildDesignMdStylePolicy into ai-prompts.ts. Strip migrated
constants (PEN_NODE_SCHEMA, DESIGN_EXAMPLES, ADAPTIVE_STYLE_POLICY,
CHAT_SYSTEM_PROMPT, DESIGN_GENERATOR_PROMPT, etc.) from ai-prompts.ts.

Add pen-ai-skills alias to mcp:compile esbuild script.

* docs: add pen-ai-skills to architecture documentation

* fix(mcp): use h3 v2 createEventStream API for SSE endpoint

event.node.res was removed in h3 v2. Migrate to createEventStream
which handles SSE headers, streaming, and cleanup automatically.

* chore: update package versions and add pen-ai-skills to Dockerfile

- Bump version for multiple packages to 0.5.2, including pen-ai-skills, pen-codegen, pen-core, pen-figma, pen-renderer, pen-sdk, and pen-types.
- Add pen-ai-skills package to Dockerfile for inclusion in the build process.
- Update TypeScript configuration to ensure proper file inclusion.
- Modify GitHub Actions workflow to trigger on version tags.
- Enhance AI-related functionality by integrating resolveAgentModel for dynamic model resolution in chat and generation APIs.

* refactor: clean up unused imports and improve dynamic content handling

- Removed unused imports from various files, including SkillMeta and ResolvedSkill from types.test.ts, and estimateTokens from resolve-skills.ts.
- Updated the dynamic content injection function to use a more concise parameter in the regex replacement callback.

* chore: enhance build and CI workflows with skill generation

- Added a new script to generate the skill registry during post-installation in package.json.
- Updated the GitHub Actions workflows to include steps for compiling the CLI and generating the skill registry, ensuring all necessary components are built and ready for deployment.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
Kayshen Xu 2026-03-24 21:16:28 +08:00 committed by GitHub
parent 2592bf116f
commit 3caaa495bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 2847 additions and 1742 deletions

View file

@ -57,6 +57,9 @@ jobs:
- name: Compile MCP server
run: bun run mcp:compile
- name: Compile CLI
run: bun run cli:compile
- name: Build Electron app
run: npx electron-builder --config apps/desktop/electron-builder.yml ${{ matrix.build_args }} --publish never
env:

View file

@ -22,6 +22,9 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate skill registry
run: bun run generate
- name: Type check
run: npx tsc --noEmit

View file

@ -1,6 +1,9 @@
name: Publish npm
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:

View file

@ -36,7 +36,8 @@ openpencil/
│ ├── pen-codegen/ Multi-platform code generators
│ ├── pen-figma/ Figma .fig file parser and converter
│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer
│ └── pen-sdk/ Umbrella SDK (re-exports all packages)
│ ├── pen-sdk/ Umbrella SDK (re-exports all packages)
│ └── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading + design memory)
├── scripts/ Build and publish scripts
└── .githooks/ Pre-commit version sync from branch name
```

View file

@ -9,6 +9,7 @@ COPY packages/pen-codegen/package.json packages/pen-codegen/
COPY packages/pen-figma/package.json packages/pen-figma/
COPY packages/pen-renderer/package.json packages/pen-renderer/
COPY packages/pen-sdk/package.json packages/pen-sdk/
COPY packages/pen-ai-skills/package.json packages/pen-ai-skills/
COPY apps/web/package.json apps/web/
COPY apps/desktop/package.json apps/desktop/
COPY apps/cli/package.json apps/cli/

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/openpencil",
"version": "0.5.1",
"version": "0.5.2",
"description": "CLI for OpenPencil — control the design tool from your terminal",
"author": {
"name": "ZSeven-W",

View file

@ -10,6 +10,7 @@
import { spawn, execSync, type ChildProcess } from 'node:child_process'
import { build } from 'esbuild'
import { join } from 'node:path'
import { compileSkills } from '../../packages/pen-ai-skills/vite-plugin-skills'
const DESKTOP_DIR = import.meta.dirname
const ROOT = join(DESKTOP_DIR, '..', '..')
@ -95,6 +96,7 @@ async function main(): Promise<void> {
console.log('[electron-dev] Vite is ready')
// 3. Compile MCP server + Electron files
compileSkills(join(ROOT, 'packages', 'pen-ai-skills'))
console.log('[electron-dev] Compiling MCP server...')
await build({
platform: 'node',

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/desktop",
"version": "0.5.1",
"version": "0.5.2",
"private": true,
"type": "module"
}

View file

@ -110,6 +110,18 @@ TanStack Start full-stack React app (Vite + Nitro). Routes in `src/routes/`, aut
File operations: save/open .pen, export PNG/SVG, node clone (re-exports `cloneNodesWithNewIds` from pen-core), pen file normalization, SVG parser, syntax highlight, boolean operations, `app-storage.ts`, `arc-path.ts`, `theme-preset-io.ts`, `id.ts`
### AI Prompt Skill System
Prompts for AI design generation live in `packages/pen-ai-skills/skills/` as Markdown files with YAML frontmatter. The skill engine loads prompts by phase and intent:
- **Phases:** `planning`, `generation`, `validation`, `maintenance` — each phase loads different base skills
- **Intent matching:** Domain skills (landing-page, dashboard, etc.) are loaded when keywords match the user message
- **Budget control:** Token budget per phase prevents context overflow
**Adding a new skill:** Create a `.md` file in the appropriate `skills/` subdirectory with frontmatter (name, phase, trigger, priority, budget, category). The Vite plugin auto-compiles on save.
**Usage:** `import { resolveSkills } from '@zseven-w/pen-ai-skills'``resolveSkills('generation', userMessage, { flags, dynamicContent })`
## Server API (`server/`)
- **`api/ai/`** — Nitro API (11 files): streaming chat, generation, agent connection, validation, MCP install, icon resolution, image generation/search. Supports Anthropic API key or Claude Agent SDK (local OAuth)

View file

@ -1,6 +1,6 @@
{
"name": "@zseven-w/web",
"version": "0.5.1",
"version": "0.5.2",
"private": true,
"type": "module",
"dependencies": {
@ -8,6 +8,7 @@
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-codegen": "workspace:*",
"@zseven-w/pen-figma": "workspace:*",
"@zseven-w/pen-renderer": "workspace:*"
"@zseven-w/pen-renderer": "workspace:*",
"@zseven-w/pen-ai-skills": "workspace:*"
}
}

View file

@ -8,6 +8,7 @@ import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
resolveAgentModel,
} from '../../utils/resolve-claude-agent-env'
/** Pattern for detecting sensitive data in debug log output */
export const SENSITIVE_LOG_PATTERN = /ANTHROPIC_API_KEY=|Authorization:\s*Bearer|api[_-]?key\s*[:=]/i
@ -213,7 +214,7 @@ function stripNoToolsRestriction(systemPrompt: string): string {
}
/** Stream via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */
function streamViaAgentSDK(body: ChatBody, model?: string) {
function streamViaAgentSDK(body: ChatBody, requestedModel?: string) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
@ -250,6 +251,11 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
const env = buildClaudeAgentEnv()
debugFile = getClaudeAgentDebugFilePath()
// When using a custom proxy (ANTHROPIC_BASE_URL), skip explicit model
// so Claude Code uses ANTHROPIC_MODEL from env — the proxy may not
// recognize standard Claude model IDs.
const model = resolveAgentModel(requestedModel, env)
const claudePath = resolveClaudeCli()
const spawnProcess = buildSpawnClaudeCodeProcess()
const thinking = getAgentThinkingConfig(body)

View file

@ -5,6 +5,7 @@ import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
resolveAgentModel,
} from '../../utils/resolve-claude-agent-env'
import { formatOpenCodeError } from './chat'
@ -55,13 +56,14 @@ export default defineEventHandler(async (event) => {
})
/** Generate via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */
async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> {
async function generateViaAgentSDK(body: GenerateBody, requestedModel?: string): Promise<{ text?: string; error?: string }> {
const runQuery = async (): Promise<{ text?: string; error?: string }> => {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
// Remove CLAUDECODE env to allow running from within a CC terminal
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
const model = resolveAgentModel(requestedModel, env)
const claudePath = resolveClaudeCli()

View file

@ -4,6 +4,7 @@ import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
resolveAgentModel,
} from '../../utils/resolve-claude-agent-env'
import { writeFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@ -98,7 +99,7 @@ async function withTempImageFile<T>(
*/
async function validateViaAgentSDK(
body: ValidateBody,
model?: string,
requestedModel?: string,
): Promise<{ text: string; skipped?: boolean; error?: string }> {
return await withTempImageFile(body.imageBase64, async (tempPath) => {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
@ -106,6 +107,7 @@ async function validateViaAgentSDK(
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
const claudePath = resolveClaudeCli()
const model = resolveAgentModel(requestedModel, env)
const prompt = `IMPORTANT: First, use the Read tool to read the image file at "${tempPath}". This is a PNG screenshot of a UI design.

View file

@ -1,15 +1,11 @@
import { defineEventHandler, setResponseHeaders } from 'h3'
import { defineEventHandler, createError } from 'h3'
import { getSyncDocument } from '../../utils/mcp-sync-state'
/** GET /api/mcp/document — Returns the current canvas document for MCP to read. */
export default defineEventHandler((event) => {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
export default defineEventHandler(() => {
const { doc, version } = getSyncDocument()
if (!doc) {
return new Response(JSON.stringify({ error: 'No document loaded in editor' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
throw createError({ statusCode: 404, statusMessage: 'No document loaded in editor' })
}
return { version, document: doc }
})

View file

@ -1,4 +1,4 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { defineEventHandler, readBody, createError } from 'h3'
import { setSyncDocument } from '../../utils/mcp-sync-state'
import type { PenDocument } from '../../../src/types/pen'
@ -9,20 +9,13 @@ interface PostBody {
/** POST /api/mcp/document — Receives document update from MCP or renderer, triggers SSE broadcast. */
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
const body = await readBody<PostBody>(event)
if (!body?.document) {
return new Response(JSON.stringify({ error: 'Missing document in request body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
throw createError({ statusCode: 400, statusMessage: 'Missing document in request body' })
}
const doc = body.document
if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) {
return new Response(JSON.stringify({ error: 'Invalid document format' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
throw createError({ statusCode: 400, statusMessage: 'Invalid document format' })
}
const version = setSyncDocument(doc, body.sourceClientId)
return { ok: true, version }

View file

@ -1,44 +1,49 @@
import { defineEventHandler } from 'h3'
import { defineEventHandler, createEventStream } from 'h3'
import { randomUUID } from 'node:crypto'
import type { ServerResponse } from 'node:http'
import { registerSSEClient, unregisterSSEClient, getSyncDocument } from '../../utils/mcp-sync-state'
// Bun.serve has a default idleTimeout of 10s. Heartbeat must be shorter
// to prevent the SSE connection from being killed.
const HEARTBEAT_MS = 8_000
/** GET /api/mcp/events — SSE stream for renderer to subscribe to live document changes. */
export default defineEventHandler((event) => {
const clientId = randomUUID()
const stream = createEventStream(event)
// Write headers directly on the raw Node.js response for h3 v2 compatibility.
// h3 v2 no longer supports `_handled = true` to keep connections open.
const res = event.node!.res! as ServerResponse
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
let closed = false
const cleanup = () => {
if (closed) return
closed = true
clearInterval(heartbeat)
unregisterSSEClient(clientId)
stream.close()
}
const write = (data: string) => {
if (closed) return
stream.push(data).catch(cleanup)
}
// Send client ID so renderer can use it as sourceClientId when pushing back
res.write(`data: ${JSON.stringify({ type: 'client:id', clientId })}\n\n`)
write(JSON.stringify({ type: 'client:id', clientId }))
// Send current document as initial state (if any)
const { doc, version } = getSyncDocument()
if (doc) {
res.write(`data: ${JSON.stringify({ type: 'document:init', version, document: doc })}\n\n`)
write(JSON.stringify({ type: 'document:init', version, document: doc }))
}
registerSSEClient(clientId, res)
registerSSEClient(clientId, { push: write })
// Keep-alive heartbeat
// Keep-alive heartbeat — must be shorter than Bun's idle timeout (10s)
const heartbeat = setInterval(() => {
if (!res.closed) res.write(': heartbeat\n\n')
}, 30_000)
if (closed) return
stream.push(':heartbeat').catch(cleanup)
}, HEARTBEAT_MS)
res.on('close', () => {
clearInterval(heartbeat)
unregisterSSEClient(clientId)
})
// Clean up when client disconnects
stream.onClosed(cleanup)
// Return a promise that resolves on close to prevent h3 from ending the response
return new Promise<void>((resolve) => {
res.on('close', resolve)
})
return stream.send()
})

View file

@ -1,4 +1,4 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { defineEventHandler, readBody, createError } from 'h3'
import { setSyncSelection } from '../../utils/mcp-sync-state'
interface PostBody {
@ -8,13 +8,9 @@ interface PostBody {
/** POST /api/mcp/selection — Receives selection update from renderer. */
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
const body = await readBody<PostBody>(event)
if (!body || !Array.isArray(body.selectedIds)) {
return new Response(JSON.stringify({ error: 'Missing selectedIds array' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
throw createError({ statusCode: 400, statusMessage: 'Missing selectedIds array' })
}
setSyncSelection(body.selectedIds, body.activePageId)
return { ok: true }

View file

@ -21,13 +21,19 @@ function resolveMcpServerScript(): string {
const p = join(electronResources, 'mcp-server.cjs')
if (existsSync(p)) return p
}
// dev + web build (from cwd)
const fromCwd = resolve(process.cwd(), 'dist', 'mcp-server.cjs')
if (existsSync(fromCwd)) return fromCwd
// dev + web build (from cwd) — monorepo outputs to out/
// In dev mode, CWD is apps/web/ (due to "cd apps/web && ..." in dev script),
// so also check ../../out/ to reach the monorepo root.
for (const base of [
resolve(process.cwd(), 'out', 'mcp-server.cjs'),
resolve(process.cwd(), '..', '..', 'out', 'mcp-server.cjs'),
]) {
if (existsSync(base)) return base
}
// Fallback: relative to this file (Nitro bundled output)
const fromFile = resolve(__dirname, '..', '..', '..', 'dist', 'mcp-server.cjs')
const fromFile = resolve(__dirname, '..', '..', '..', 'out', 'mcp-server.cjs')
if (existsSync(fromFile)) return fromFile
return fromCwd
return resolve(process.cwd(), 'out', 'mcp-server.cjs')
}
/** Get the first non-internal IPv4 address (LAN IP). */
@ -104,7 +110,12 @@ export function startMcpHttpServer(port: number): { running: boolean; port: numb
// for later tracking and graceful shutdown.
const child = spawn(process.execPath, [serverScript, '--http', '--port', String(port)], {
stdio: ['ignore', 'ignore', 'ignore'],
env: { ...process.env },
env: {
...process.env,
// In Electron, process.execPath is the Electron binary.
// ELECTRON_RUN_AS_NODE makes it behave as plain Node.js.
ELECTRON_RUN_AS_NODE: '1',
},
detached: true,
windowsHide: true,
})

View file

@ -1,19 +1,22 @@
/**
* In-memory sync state for MCP Renderer real-time communication.
* In-memory sync state for MCP <-> Renderer real-time communication.
* Shared across Nitro API endpoints: GET/POST /api/mcp/document, GET /api/mcp/events.
*/
import type { PenDocument } from '../../src/types/pen'
import type { ServerResponse } from 'node:http'
let currentDocument: PenDocument | null = null
let documentVersion = 0
let currentSelection: string[] = []
let currentActivePageId: string | null = null
interface SSEWriter {
push(data: string): void
}
interface SSEClient {
id: string
res: ServerResponse
writer: SSEWriter
}
const clients = new Map<string, SSEClient>()
@ -38,8 +41,8 @@ export function setSyncSelection(selectedIds: string[], activePageId?: string |
if (activePageId !== undefined) currentActivePageId = activePageId
}
export function registerSSEClient(id: string, res: ServerResponse): void {
clients.set(id, { id, res })
export function registerSSEClient(id: string, writer: SSEWriter): void {
clients.set(id, { id, writer })
}
export function unregisterSSEClient(id: string): void {
@ -47,11 +50,11 @@ export function unregisterSSEClient(id: string): void {
}
function broadcast(payload: Record<string, unknown>, excludeClientId?: string): void {
const data = `data: ${JSON.stringify(payload)}\n\n`
const data = JSON.stringify(payload)
for (const [id, client] of clients) {
if (id === excludeClientId) continue
try {
if (!client.res.closed) client.res.write(data)
client.writer.push(data)
} catch {
clients.delete(id)
}

View file

@ -181,6 +181,31 @@ export function buildClaudeAgentEnv(): EnvLike {
return merged
}
/**
* Resolve the model to pass to Claude Code Agent SDK.
*
* When a custom ANTHROPIC_BASE_URL is set (proxy mode), the proxy may not
* recognize standard Claude model IDs like "claude-sonnet-4-6". Map the
* requested model tier to the proxy's real model via ANTHROPIC_DEFAULT_*_MODEL
* env vars (read from ~/.claude/settings.json).
*
* Example: user selects "Claude Sonnet 4.6" detected as sonnet tier
* mapped to ANTHROPIC_DEFAULT_SONNET_MODEL (e.g. "gpt-5.3-codex")
*/
export function resolveAgentModel(requestedModel: string | undefined, env: Record<string, string | undefined>): string | undefined {
if (!requestedModel) return undefined
if (!env.ANTHROPIC_BASE_URL) return requestedModel
// Proxy mode: map model tier to the proxy's model via env vars
const lower = requestedModel.toLowerCase()
if (lower.includes('opus')) return env.ANTHROPIC_DEFAULT_OPUS_MODEL || env.ANTHROPIC_MODEL || undefined
if (lower.includes('haiku')) return env.ANTHROPIC_DEFAULT_HAIKU_MODEL || env.ANTHROPIC_MODEL || undefined
if (lower.includes('sonnet')) return env.ANTHROPIC_DEFAULT_SONNET_MODEL || env.ANTHROPIC_MODEL || undefined
// Unknown tier: use the general default
return env.ANTHROPIC_MODEL || undefined
}
/**
* Force Claude CLI debug output into a writable temp location.
* This avoids crashes in restricted environments where ~/.claude/debug is not writable.
@ -235,8 +260,21 @@ export function buildSpawnClaudeCodeProcess() {
windowsHide: true,
})
} else {
// For .cmd or extensionless binaries, use shell
child = spawn(cmd, options.args, {
// For .cmd or extensionless binaries, use shell.
// When shell: true on Windows, empty string args get swallowed.
// Filter out --setting-sources with empty value to prevent the next
// flag (e.g. --permission-mode) from being consumed as its value.
const safeArgs: string[] = []
for (let i = 0; i < options.args.length; i++) {
const arg = options.args[i]
// Skip --setting-sources followed by an empty string
if (arg === '--setting-sources' && i + 1 < options.args.length && options.args[i + 1] === '') {
i++ // skip the empty value too
continue
}
safeArgs.push(arg)
}
child = spawn(cmd, safeArgs, {
cwd: options.cwd,
env: options.env as NodeJS.ProcessEnv,
signal: options.signal,

View file

@ -7,7 +7,6 @@ import { useDesignMdStore } from '@/stores/design-md-store'
import { getActivePageChildren } from '@/stores/document-tree-utils'
import { streamChat } from '@/services/ai/ai-service'
import { buildChatSystemPrompt } from '@/services/ai/ai-prompts'
import { detectSections } from '@/services/ai/ai-prompt-sections'
import {
generateDesign,
generateDesignModification,
@ -272,14 +271,14 @@ export function useChatHandlers() {
})
// Trim history to prevent unbounded context growth
const trimmedHistory = trimChatHistory(chatHistory)
// Progressive section loading: detect needed sections from user message
// Progressive skill loading: resolve needed skills from user message
const chatDoc = useDocumentStore.getState().document
const chatDesignMd = useDesignMdStore.getState().designMd
const chatSections = detectSections(fullUserMessage, {
const chatSystemPrompt = buildChatSystemPrompt(fullUserMessage, {
hasDesignMd: !!chatDesignMd,
hasVariables: !!chatDoc.variables && Object.keys(chatDoc.variables).length > 0,
designMd: chatDesignMd,
})
const chatSystemPrompt = buildChatSystemPrompt(chatSections, chatDesignMd)
let chatThinking = ''
for await (const chunk of streamChat(
chatSystemPrompt,

View file

@ -6,6 +6,7 @@ import type { PenDocument } from '@/types/pen'
const PUSH_DEBOUNCE_MS = 2000
const SELECTION_DEBOUNCE_MS = 300
const RECONNECT_DELAY_MS = 3000
const MAX_RECONNECT_ATTEMPTS = 3
function getBaseUrl(): string {
return window.location.origin
@ -38,12 +39,14 @@ export function useMcpSync() {
let eventSource: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let disposed = false
let reconnectAttempts = 0
function connect() {
if (disposed) return
eventSource = new EventSource(`${baseUrl}/api/mcp/events`)
eventSource.onmessage = (event) => {
reconnectAttempts = 0 // Reset on successful message
try {
const data = JSON.parse(event.data)
@ -68,9 +71,12 @@ export function useMcpSync() {
eventSource.onerror = () => {
eventSource?.close()
eventSource = null
if (!disposed) {
reconnectAttempts++
if (!disposed && reconnectAttempts <= MAX_RECONNECT_ATTEMPTS) {
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS)
}
// Stop retrying after MAX_RECONNECT_ATTEMPTS to avoid console spam.
// MCP sync is optional — the editor works fine without it.
}
}

View file

@ -1,11 +1,31 @@
import {
PEN_NODE_SCHEMA,
ADAPTIVE_STYLE_POLICY,
DESIGN_EXAMPLES,
} from '../../services/ai/ai-prompts'
import { PROMPT_SECTIONS, buildDesignMdStylePolicy } from '../../services/ai/ai-prompt-sections'
import { getSkillByName } from '@zseven-w/pen-ai-skills'
import { buildDesignMdStylePolicy } from '../../services/ai/ai-prompts'
import type { DesignMdSpec } from '../../types/design-md'
// ---------------------------------------------------------------------------
// Skill name mapping — maps legacy section keys to skill registry names
// ---------------------------------------------------------------------------
const SECTION_NAME_MAP: Record<string, string> = {
schema: 'schema',
layout: 'layout',
text: 'text-rules',
overflow: 'overflow',
style: 'style-defaults',
icons: 'icon-catalog',
guidelines: 'form-ui',
roles: 'role-definitions',
copywriting: 'copywriting',
cjk: 'cjk-typography',
examples: 'examples',
}
/** Look up a skill by legacy section key or skill name. */
function getSkillContent(key: string): string {
const skillName = SECTION_NAME_MAP[key] ?? key
return getSkillByName(skillName)?.content ?? ''
}
// ---------------------------------------------------------------------------
// Named prompt sections — can be retrieved individually via section parameter
// ---------------------------------------------------------------------------
@ -212,22 +232,22 @@ type PromptSection =
| 'cjk'
| 'variables'
// Dynamic section map — some sections use the shared section registry
// Dynamic section map — skills from registry, local sections for planning/variables/design-md
const SECTION_MAP: Record<PromptSection, () => string> = {
all: () => buildFullPrompt(),
schema: () => PEN_NODE_SCHEMA.trim(),
schema: () => getSkillContent('schema'),
layout: () => LAYOUT_RULES,
roles: () => ROLE_GUIDE,
text: () => TEXT_RULES,
style: () => ADAPTIVE_STYLE_POLICY.trim(),
icons: () => DESIGN_EXAMPLES.trim(),
examples: () => DESIGN_EXAMPLES.trim(),
style: () => getSkillContent('style'),
icons: () => getSkillContent('icons'),
examples: () => getSkillContent('examples'),
guidelines: () => DESIGN_GUIDELINES,
planning: () => PLANNING_GUIDE,
'design-md': () => _designMdContent ?? 'No design.md loaded in the current document.',
copywriting: () => PROMPT_SECTIONS.copywriting,
overflow: () => PROMPT_SECTIONS.overflow,
cjk: () => PROMPT_SECTIONS.cjk,
copywriting: () => getSkillContent('copywriting'),
overflow: () => getSkillContent('overflow'),
cjk: () => getSkillContent('cjk'),
variables: () => VARIABLE_RULES,
}
@ -280,11 +300,11 @@ export function listPromptSections(): string[] {
function buildFullPrompt(): string {
return `${INTRO}
${PEN_NODE_SCHEMA}
${getSkillContent('schema')}
${ADAPTIVE_STYLE_POLICY}
${getSkillContent('style')}
${DESIGN_EXAMPLES}
${getSkillContent('examples')}
${DESIGN_TYPE_DETECTION}

View file

@ -1,397 +0,0 @@
/**
* Prompt Knowledge Sections modular knowledge blocks for progressive loading.
*
* Each section is a self-contained knowledge block with:
* - `content`: the actual prompt text
* - `triggers`: keywords that activate this section (matched against user message)
* - `always`: if true, always included in design generation prompts
* - `priority`: loading order (lower = loaded first)
*
* To add a new section: just add an entry to SECTION_REGISTRY.
* Detection, assembly, and MCP listing all derive from the registry automatically.
*
* Usage:
* import { detectSections, assembleSections } from './ai-prompt-sections'
* const needed = detectSections(userMessage)
* const knowledge = assembleSections(needed)
*/
import { AVAILABLE_FEATHER_ICONS } from './icon-resolver'
import type { DesignMdSpec } from '@/types/design-md'
const FEATHER_ICON_NAMES = AVAILABLE_FEATHER_ICONS.join(', ')
// ---------------------------------------------------------------------------
// Section type
// ---------------------------------------------------------------------------
export type PromptSectionKey =
| 'schema'
| 'layout'
| 'text'
| 'style'
| 'guidelines'
| 'examples'
| 'roles'
| 'copywriting'
| 'overflow'
| 'cjk'
| 'variables'
| 'icons'
| 'design-md'
// ---------------------------------------------------------------------------
// Section registry — single source of truth
// ---------------------------------------------------------------------------
interface SectionDef {
/** The prompt knowledge block content */
content: string
/** Keywords that trigger this section (matched case-insensitively against user message).
* Supports plain strings and regex patterns (prefix with `/`). */
triggers?: string[]
/** If true, always included in design generation (not modification) prompts */
always?: boolean
/** Loading priority — lower numbers load first (default 50) */
priority?: number
/** If true, only loaded when a special flag is set (e.g. hasVariables, hasDesignMd) */
flag?: 'hasVariables' | 'hasDesignMd'
}
export const SECTION_REGISTRY: Record<PromptSectionKey, SectionDef> = {
schema: {
content: `PenNode types (the ONLY format you output for designs):
- frame: Container. Props: width, height, layout ('none'|'vertical'|'horizontal'), gap, padding, justifyContent ('start'|'center'|'end'|'space_between'|'space_around'), alignItems ('start'|'center'|'end'), clipContent (boolean), children[], cornerRadius, fill, stroke, effects
- rectangle: Props: width, height, cornerRadius, fill, stroke, effects
- ellipse: Props: width, height, fill, stroke, effects
- text: Props: content, fontFamily, fontSize, fontWeight, fontStyle ('normal'|'italic'), fill, width, height, textAlign, textGrowth ('auto'|'fixed-width'|'fixed-width-height'), lineHeight (multiplier), letterSpacing (px), textAlignVertical ('top'|'middle'|'bottom')
- path: SVG icon. Props: d (SVG path), width, height, fill, stroke, effects
- image: Props: width, height, cornerRadius, effects, imageSearchQuery (2-3 English keywords)
All nodes share: id, type, name, role, x, y, rotation, opacity
Fill = [{ type: "solid", color: "#hex" }] or [{ type: "linear_gradient", angle, stops: [{ offset, color }] }]
Stroke = { thickness, fill: [...] } Effects = [{ type: "shadow", offsetX, offsetY, blur, spread, color }]
SIZING: width/height accept number (px), "fill_container", or "fit_content".
PADDING: number (uniform), [v, h], or [top, right, bottom, left].
cornerRadius is a number. fill is ALWAYS an array. Do NOT set x/y on children inside layout frames.`,
always: true,
priority: 0,
},
layout: {
content: `LAYOUT ENGINE (flexbox-based):
- Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems.
- NEVER set x/y on children inside layout containers.
- CHILD SIZE RULE: child width must be parent content area. Use "fill_container" when in doubt.
- In vertical layout: "fill_container" width stretches horizontally. In horizontal: fills remaining space.
- CLIP CONTENT: clipContent: true clips overflowing children. ALWAYS use on cards with cornerRadius + image.
- justifyContent: "space_between" (navbars), "center", "start"/"end", "space_around".
- WIDTH CONSISTENCY: siblings must use same width strategy. Don't mix fixed-px and fill_container.
- NEVER use "fill_container" on children of "fit_content" parent circular dependency.
- Two-column: horizontal frame two child frames each "fill_container" width.
- Keep hierarchy shallow: no pointless wrappers. Only use wrappers with visual purpose (fill, padding).
- Section root: width="fill_container", height="fit_content", layout="vertical".
- FORMS: ALL inputs AND primary button MUST use width="fill_container". Vertical layout, gap=16-20.`,
always: true,
priority: 10,
},
text: {
content: `TEXT RULES:
- Body/description in vertical layout: width="fill_container" + textGrowth="fixed-width" (wraps text, auto-sizes height).
- Short labels in horizontal rows: width="fit_content" + textGrowth="auto". Prevents squeezing siblings.
- NEVER fixed pixel width on text inside layout frames causes overflow.
- Text >15 chars MUST have textGrowth="fixed-width". NEVER set explicit pixel height on text nodes OMIT height.
- Typography: Display 40-56px, Heading 28-36px, Subheading 20-24px, Body 16-18px, Caption 13-14px.
- lineHeight: headings 1.1-1.2, body 1.4-1.6. letterSpacing: -0.5 for headlines, 0.5-2 for uppercase.`,
always: true,
priority: 15,
},
overflow: {
content: `OVERFLOW PREVENTION (CRITICAL):
- Text in vertical layout: width="fill_container" + textGrowth="fixed-width". In horizontal: width="fit_content".
- NEVER set fixed pixel width on text inside layout frames (e.g. width:378 in 195px card overflows!).
- Fixed-width children must be parent content area (parent width padding).
- Badges: short labels only (CJK 8 chars / Latin 16 chars).`,
always: true,
priority: 16,
},
icons: {
content: `ICONS:
- Use "path" nodes, size 16-24px. ONLY use Feather icon names PascalCase + "Icon" suffix (e.g. "SearchIcon").
- System auto-resolves names to SVG paths. "d" is replaced automatically.
- Available: ${FEATHER_ICON_NAMES}
- NEVER use emoji as icons. Use icon_font nodes for lucide icons.`,
always: true,
priority: 20,
},
style: {
content: `VISUAL STYLE POLICY:
- Default to clean light marketing style unless user explicitly asks for dark/cyber/terminal.
DEFAULT LIGHT PALETTE:
- Page Bg: #F8FAFC, Surface: #FFFFFF, Text: #0F172A, Secondary: #475569
- Accent: #2563EB, Accent2: #0EA5E9, Border: #E2E8F0
TYPOGRAPHY SCALE:
- Display: 40-56px "Space Grotesk"/"Manrope" (700), lineHeight 1.1
- Heading: 28-36px "Space Grotesk"/"Manrope" (600-700), lineHeight 1.2
- Subheading: 20-24px "Inter" (600), lineHeight 1.3
- Body: 16-18px "Inter" (400-500), lineHeight 1.5
- Caption: 13-14px "Inter" (400), lineHeight 1.4
SHAPES: cornerRadius 8-14. Subtle shadows. Clear hierarchy via spacing and contrast.
LANDING PAGES: hero 80-120px padding, alternate section backgrounds, cards cornerRadius 12-16, centered content ~1040-1160px.`,
// Loaded when no design-md is present (default style fallback)
always: true,
priority: 5,
},
'design-md': {
content: '', // populated dynamically
flag: 'hasDesignMd',
priority: 5,
},
guidelines: {
content: `DESIGN GUIDELINES:
- Mobile: 375×812. Web: 1200×800 (single) or 1200×3000-5000 (landing page).
- "mobile"/"移动端" + screen type = ACTUAL 375×812 screen, NOT desktop with phone mockup.
- Buttons: height 44-52px, cornerRadius 8-12, padding [12, 24]. Icon+text: layout="horizontal", gap=8.
- Icon-only buttons: 44×44, justifyContent/alignItems="center", path icon 20-24px.
- Inputs: height 44px, light bg, subtle border, width="fill_container" in forms.
- Cards: cornerRadius 12-16, clipContent: true, subtle shadows.
- CARD ROW ALIGNMENT: sibling cards in horizontal layout ALL use width/height="fill_container".
- Navigation: justifyContent="space_between", 3 groups (logo | links | CTA), padding=[0,80].
- Phone mockup: ONE "frame", width 260-300, height 520-580, cornerRadius 32. NEVER ellipse.
- NEVER use ellipse for decorative shapes. Use frame/rectangle with cornerRadius.
- NEVER use emoji as icons. Use path nodes with Feather icon names.`,
triggers: [
'form', 'input', 'login', 'signup', 'sign up', 'register', 'password', 'email',
'搜索', '表单', '登录', '注册',
'mobile', 'phone', '手机', '移动端', 'app screen', 'ios', 'android',
'button', 'card', 'nav', 'navigation', 'mockup',
'按钮', '卡片', '导航', '模型',
],
priority: 30,
},
roles: {
content: `SEMANTIC ROLES (add "role" to nodes — system fills unset props based on role):
Layout: section, row, column, centered-content, form-group, divider, spacer
Navigation: navbar, nav-links, nav-link
Interactive: button, icon-button, badge, tag, pill, input, form-input, search-bar
Display: card, stat-card, pricing-card, feature-card, image-card
Media: phone-mockup, screenshot-frame, avatar, icon
Typography: heading, subheading, body-text, caption, label
Content: hero, feature-grid, testimonial, cta-section, footer, stats-section
Table: table, table-row, table-header, table-cell
Key defaults: sectionpadding:[60,80], navbarheight:72/layout:horizontal/space_between, heropadding:[80,80], buttonpadding:[12,24]/height:44, cardgap:12/cornerRadius:12/clipContent:true.
Your explicit props ALWAYS override role defaults.`,
triggers: [
'landing', 'marketing', 'hero', 'website', '官网', '首页', '产品页',
'table', 'grid', '表格', '表头', 'dashboard', '数据', 'admin',
'testimonial', 'pricing', 'footer', 'stats',
'评价', '定价', '页脚', '数据统计',
],
priority: 35,
},
copywriting: {
content: `COPYWRITING:
- Headlines: 2-6 words. Subtitles: 1 sentence 15 words.
- Feature titles: 2-4 words. Descriptions: 1 sentence 20 words.
- Buttons: 1-3 words. Card text: 2 sentences. Stats: number + 1-3 word label.
- NEVER 3+ sentence paragraphs. Distill to essence. Power words > vague adjectives.`,
triggers: [
'landing', 'marketing', 'hero', 'website', '官网', '首页', '产品页',
'copy', 'text', 'headline', 'content',
'文案', '标题', '内容',
],
priority: 40,
},
cjk: {
content: `CJK TYPOGRAPHY (Chinese/Japanese/Korean):
- Headings: "Noto Sans SC" (Chinese) / "Noto Sans JP" / "Noto Sans KR". NEVER "Space Grotesk"/"Manrope" for CJK.
- Body: "Inter" (system CJK fallback) or "Noto Sans SC".
- CJK lineHeight: headings 1.3-1.4 (NOT 1.1), body 1.6-1.8. letterSpacing: 0, NEVER negative.
- CJK buttons: each char fontSize wide. Container width (charCount × fontSize) + padding.
- Detect CJK from user request language use CJK fonts for ALL text nodes.`,
// CJK is triggered by Unicode range detection, not keywords
triggers: ['/[\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af]/'],
priority: 25,
},
variables: {
content: `DESIGN VARIABLES:
- When document has variables, use "$variableName" references instead of hardcoded values.
- Color: [{ "type": "solid", "color": "$primary" }]. Number: "gap": "$spacing-md".
- Only reference listed variables do NOT invent names.`,
flag: 'hasVariables',
priority: 45,
},
examples: {
content: `EXAMPLES:
Button: { "id":"btn-1","type":"frame","role":"button","width":180,"cornerRadius":8,"fill":[{"type":"solid","color":"#3B82F6"}],"children":[{"id":"btn-icon","type":"path","name":"ArrowRightIcon","role":"icon","d":"M5 12h14m-7-7 7 7-7 7","width":20,"height":20,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#FFF"}]}},{"id":"btn-text","type":"text","role":"label","content":"Continue","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFF"}]}] }
Card: { "id":"card-1","type":"frame","role":"card","width":320,"height":340,"fill":[{"type":"solid","color":"#FFF"}],"effects":[{"type":"shadow","offsetX":0,"offsetY":4,"blur":12,"spread":0,"color":"rgba(0,0,0,0.1)"}],"children":[{"id":"card-img","type":"image","width":"fill_container","height":180},{"id":"card-body","type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":20,"gap":8,"children":[{"id":"card-title","type":"text","role":"heading","content":"Title","fontSize":20,"fontWeight":700,"fill":[{"type":"solid","color":"#111827"}]},{"id":"card-desc","type":"text","role":"body-text","content":"Description","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}]}] }`,
triggers: [
'example', 'sample', 'show me', 'how to',
'示例', '样例', '怎么',
],
priority: 50,
},
}
// ---------------------------------------------------------------------------
// Backward-compatible flat content map (used by MCP design-prompt.ts)
// ---------------------------------------------------------------------------
export const PROMPT_SECTIONS: Record<PromptSectionKey, string> = Object.fromEntries(
Object.entries(SECTION_REGISTRY).map(([key, def]) => [key, def.content]),
) as Record<PromptSectionKey, string>
// ---------------------------------------------------------------------------
// Intent detection — derive from registry, no hardcoded regexes
// ---------------------------------------------------------------------------
/** Compile trigger patterns from registry definitions. */
function buildTriggerMatchers(): Array<{ key: PromptSectionKey; test: (msg: string) => boolean }> {
const matchers: Array<{ key: PromptSectionKey; test: (msg: string) => boolean }> = []
for (const [key, def] of Object.entries(SECTION_REGISTRY) as [PromptSectionKey, SectionDef][]) {
if (!def.triggers?.length) continue
const regexes: RegExp[] = []
const keywords: string[] = []
for (const t of def.triggers) {
if (t.startsWith('/') && t.endsWith('/')) {
// Regex pattern
regexes.push(new RegExp(t.slice(1, -1)))
} else if (t.startsWith('/')) {
// Regex without trailing slash (e.g. /[unicode]/)
regexes.push(new RegExp(t.slice(1)))
} else {
keywords.push(t.toLowerCase())
}
}
// Build a single test function combining keywords and regexes
matchers.push({
key,
test: (msg: string) => {
const lower = msg.toLowerCase()
if (keywords.some((kw) => lower.includes(kw))) return true
if (regexes.some((re) => re.test(msg))) return true
return false
},
})
}
return matchers
}
const triggerMatchers = buildTriggerMatchers()
/** Detect which sections are needed based on user message content. */
export function detectSections(
userMessage: string,
options?: {
hasDesignMd?: boolean
hasVariables?: boolean
isModification?: boolean
},
): PromptSectionKey[] {
const selected = new Set<PromptSectionKey>()
// Modification mode: minimal context
if (options?.isModification) {
selected.add('schema')
if (options.hasVariables) selected.add('variables')
if (options.hasDesignMd) selected.add('design-md')
return [...selected]
}
// Always-on sections
for (const [key, def] of Object.entries(SECTION_REGISTRY) as [PromptSectionKey, SectionDef][]) {
if (def.always) {
// design-md replaces style when present
if (key === 'style' && options?.hasDesignMd) continue
selected.add(key)
}
}
// Flag-based sections
if (options?.hasDesignMd) selected.add('design-md')
if (options?.hasVariables) selected.add('variables')
// Trigger-based sections: match keywords/regex against user message
for (const matcher of triggerMatchers) {
if (matcher.test(userMessage)) {
selected.add(matcher.key)
}
}
// Sort by priority
return [...selected].sort((a, b) =>
(SECTION_REGISTRY[a].priority ?? 50) - (SECTION_REGISTRY[b].priority ?? 50),
)
}
/** Assemble selected sections into a single knowledge block string. */
export function assembleSections(
keys: PromptSectionKey[],
designMdContent?: string,
): string {
const parts: string[] = []
for (const key of keys) {
if (key === 'design-md' && designMdContent) {
parts.push(`DESIGN SYSTEM (design.md — follow these rules for visual consistency):\n${designMdContent}`)
} else {
const content = SECTION_REGISTRY[key].content
if (content) parts.push(content)
}
}
return parts.join('\n\n')
}
/** Build a condensed design.md style policy string for AI prompt injection. */
export function buildDesignMdStylePolicy(spec: DesignMdSpec): string {
const parts: string[] = []
if (spec.visualTheme) {
const theme = spec.visualTheme.length > 200
? spec.visualTheme.substring(0, 200) + '...'
: spec.visualTheme
parts.push(`VISUAL THEME: ${theme}`)
}
if (spec.colorPalette?.length) {
const colors = spec.colorPalette
.slice(0, 10)
.map(c => `${c.name} (${c.hex}) — ${c.role}`)
.join('\n- ')
parts.push(`COLOR PALETTE:\n- ${colors}`)
}
if (spec.typography?.fontFamily) {
parts.push(`FONT: ${spec.typography.fontFamily}`)
}
if (spec.typography?.headings) {
parts.push(`Headings: ${spec.typography.headings}`)
}
if (spec.typography?.body) {
parts.push(`Body: ${spec.typography.body}`)
}
if (spec.componentStyles) {
const styles = spec.componentStyles.length > 300
? spec.componentStyles.substring(0, 300) + '...'
: spec.componentStyles
parts.push(`COMPONENT STYLES:\n${styles}`)
}
return parts.join('\n\n')
}

View file

@ -1,381 +1,54 @@
import { AVAILABLE_FEATHER_ICONS } from './icon-resolver'
import type { PromptSectionKey } from './ai-prompt-sections'
import { assembleSections, buildDesignMdStylePolicy } from './ai-prompt-sections'
import { resolveSkills } from '@zseven-w/pen-ai-skills'
import type { DesignMdSpec } from '@/types/design-md'
// Comma-separated list of all bundled Feather icons — guaranteed to resolve
// instantly from the local icon map without any network request.
const FEATHER_ICON_NAMES = AVAILABLE_FEATHER_ICONS.join(', ')
export 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'), 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, 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: width, height, cornerRadius, effects, imageSearchQuery (2-3 English keywords for photo search, e.g. "burger fries", "office workspace"), imagePrompt (optional: longer descriptive phrase for AI image generation). Do NOT include src images are auto-populated after generation.
imagePrompt RULES:
- Describe the subject, scene, style, and composition. Example: "a gourmet burger with golden fries on a rustic wooden table, warm natural lighting, top-down view"
- NEVER mention background type (transparent, white, plain, isolated, cutout). Images fill a frame area background removal is unreliable across models.
- Match composition to aspect ratio: wide (w>1.3h) landscape/panoramic, tall (h>1.3w) portrait/vertical, square centered subject.
- Keep prompts concise (1-2 sentences). Focus on what to show, not technical rendering instructions.
All nodes share: id (string), type, name, role, x, y, rotation, opacity
SEMANTIC ROLES (add "role" field to declare intent the system applies smart defaults for unset properties):
Layout: "section", "row", "column", "centered-content", "form-group", "divider", "spacer"
Navigation: "navbar", "nav-links", "nav-link"
Interactive: "button", "icon-button", "badge", "tag", "pill", "input", "form-input", "search-bar"
Display: "card", "stat-card", "pricing-card", "image-card"
Media: "phone-mockup", "screenshot-frame", "avatar", "icon"
Typography: "heading", "subheading", "body-text", "caption", "label"
Content: "hero", "feature-grid", "feature-card", "testimonial", "cta-section", "footer", "stats-section"
Table: "table", "table-row", "table-header", "table-cell"
You can also invent new roles unknown roles pass through unchanged.
Your explicit properties ALWAYS override role defaults. Only unset properties get filled in.
Example: {"type":"frame","role":"button","fill":[...]} system adds padding, height, layout, alignItems IF you didn't set them.
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: in vertical layout frames, body/description text should use textGrowth="fixed-width" + width="fill_container". In horizontal rows, short labels should use width="fit_content" (or omit width) + textGrowth="auto" to avoid squeezing siblings.
- 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
- 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" 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)
- INPUT ICON AFFORDANCE: for semantic inputs (search/password/email/login), include one path icon when appropriate.
For trailing icons (e.g. password visibility), use horizontal input layout with justifyContent="space_between".
For leading icons (e.g. search/email), use justifyContent="start" with gap 8-12.
OVERFLOW PREVENTION (CRITICAL violations cause visual glitches):
- TEXT WIDTH: for text inside vertical layout frames, use width="fill_container" + textGrowth="fixed-width". For text inside horizontal rows (nav/footer/button rows), default to width="fit_content" (or omit width) + textGrowth="auto". NEVER set fixed pixel width on text inside a layout.
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: use badge/chip style only for short labels (CJK <=8 chars / Latin <=16 chars). If text is longer, do NOT use badge style; use a normal text row or small card.
`
export const DESIGN_EXAMPLES = `
EXAMPLES:
Button with icon (role="button" auto-adds padding, height, layout, alignItems if not set):
{ "id": "btn-1", "type": "frame", "name": "Button", "role": "button", "x": 100, "y": 100, "width": 180, "cornerRadius": 8, "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowRightIcon", "role": "icon", "d": "M5 12h14m-7-7 7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "role": "label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
Card with image (role="card" auto-adds layout, cornerRadius, clipContent):
{ "id": "card-1", "type": "frame", "name": "Card", "role": "card", "x": 50, "y": 50, "width": 320, "height": 340, "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", "role": "heading", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "role": "body-text", "content": "Some description text here", "fontSize": 14, "fill": [{ "type": "solid", "color": "#6B7280" }] }] }] }
ICONS & IMAGES:
- Icons: Use "path" nodes. Size 16-24px. CRITICAL: ONLY use names from the Feather icon library below these are bundled locally and render instantly. Convert the icon name to PascalCase + "Icon" suffix (e.g. "search" "SearchIcon", "arrow-right" "ArrowRightIcon"). Do NOT invent names outside this list.
The system auto-resolves icon names to verified SVG paths the "name" field is what matters; "d" is replaced automatically.
Available Feather icons: ${FEATHER_ICON_NAMES}
- NEVER use emoji characters as icons (e.g. 🍕🍔🔔). Always use icon_font nodes emoji cannot render on canvas.
- For app screenshot/mockup areas, use a phone placeholder frame with solid fill matching the page theme + 1px subtle stroke. cornerRadius ~32. Prefer no inner content; if a placeholder copy is needed (e.g. "APP截图占位"), keep exactly one centered text node INSIDE the phone frame (never as a sibling below it).
- Do NOT use random real-world app screenshots or dense mini-app simulations for showcase sections.
`
export 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.
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 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
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.
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
const BLOCK = "```"
export const CHAT_SYSTEM_PROMPT = `You are a design assistant for OpenPencil, a vector design tool that renders PenNode JSON on a canvas.
// ---------------------------------------------------------------------------
// buildDesignMdStylePolicy — condensed design.md style policy for AI prompts
// ---------------------------------------------------------------------------
${PEN_NODE_SCHEMA}
/** Build a condensed design.md style policy string for AI prompt injection. */
export function buildDesignMdStylePolicy(spec: DesignMdSpec): string {
const parts: string[] = []
ABSOLUTE REQUIREMENT When a user asks to create/generate/design/make ANY visual element or UI:
You MUST output a ${BLOCK}json code block containing a valid PenNode JSON array. This is NON-NEGOTIABLE.
Add a 1-2 sentence description AFTER the JSON block, not before.
NEVER describe what you "would" create ALWAYS output the actual JSON immediately.
NEVER output HTML, CSS, or React code ONLY PenNode JSON.
NEVER 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>.
if (spec.visualTheme) {
const theme = spec.visualTheme.length > 200
? spec.visualTheme.substring(0, 200) + '...'
: spec.visualTheme
parts.push(`VISUAL THEME: ${theme}`)
}
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.
if (spec.colorPalette?.length) {
const colors = spec.colorPalette
.slice(0, 10)
.map(c => `${c.name} (${c.hex}) — ${c.role}`)
.join('\n- ')
parts.push(`COLOR PALETTE:\n- ${colors}`)
}
When a user asks non-design questions (explain, suggest colors, give advice), respond in text.
if (spec.typography?.fontFamily) {
parts.push(`FONT: ${spec.typography.fontFamily}`)
}
if (spec.typography?.headings) {
parts.push(`Headings: ${spec.typography.headings}`)
}
if (spec.typography?.body) {
parts.push(`Body: ${spec.typography.body}`)
}
${ADAPTIVE_STYLE_POLICY}
if (spec.componentStyles) {
const styles = spec.componentStyles.length > 300
? spec.componentStyles.substring(0, 300) + '...'
: spec.componentStyles
parts.push(`COMPONENT STYLES:\n${styles}`)
}
${DESIGN_EXAMPLES}
LAYOUT ENGINE (flexbox-based):
- Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems
- NEVER set x/y on children inside layout containers the engine positions them automatically
- 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): in vertical layouts, body text should use textGrowth="fixed-width" + width="fill_container". In horizontal rows, labels should use textGrowth="auto" + width="fit_content" (or omit width). NEVER use fixed pixel width (e.g. width:378) on text inside a layout.
- 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
- Keep hierarchy shallow: DO NOT add a single redundant wrapper named "Inner" under a section. Place real content frames directly under the section unless the wrapper has a clear visual role.
COPYWRITING (text content in designs must be concise and polished):
- Headlines: 2-6 words max. Punchy and direct. e.g. "Ship Faster" not "Build and ship your products faster than ever before".
- Subtitles: 1 short sentence, 15 words. e.g. "AI-powered design tool for modern teams" not a full paragraph.
- Feature titles: 2-4 words. e.g. "Smart Layout" not "Intelligent Automatic Layout System".
- Feature descriptions: 1 sentence, 20 words. Convey the core value only.
- Button/CTA text: 1-3 words. e.g. "Get Started", "Try Free", "Learn More".
- Card body text: 2 short sentences. Cut filler words ruthlessly.
- Stats/numbers: use the number + a 1-3 word label. e.g. "10K+ Users" not "More than ten thousand users worldwide".
- Navigation links: 1-2 words each.
- NEVER generate placeholder paragraphs with 3+ sentences. If a section needs body text, keep it to 1-2 crisp sentences.
- Prefer power words and concrete nouns over vague adjectives. Show value, not verbosity.
- When the user provides long copy, distill it to its essence for the design. Design mockups are not documents.
DESIGN GUIDELINES:
- Mobile screens: root frame 375x812 at x:0,y:0. Web: 1200x800 (single screen) or 1200x3000-5000 (landing page).
IMPORTANT: When the user requests a "mobile login page" / "移动端登录页" / "手机注册页" etc., generate the ACTUAL mobile UI (375x812 root frame) do NOT create a desktop page containing a phone mockup. Phone mockups are only for marketing/showcase pages that preview an app.
- 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-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"): only for short labels (CJK <=8 chars / Latin <=16 chars). For longer copy, use a normal text row/card instead of badge/chip style.
- 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.
- Semantic inputs should include affordance icons when appropriate:
- search bars: leading SearchIcon
- password fields: trailing EyeIcon or EyeOffIcon
- email/account fields: leading MailIcon or UserIcon
- Fixed-width children must NOT exceed their parent's content area (parent width minus padding).
- Consistent color palette
- 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 Feather icon names only (full list in the ICONS & IMAGES section above). Size 16-24px.
- NEVER use emoji glyphs as icon substitutes (🍕🍔 etc). If an icon is needed, use an icon_font node with iconFontName (lucide name). Emoji cannot render on canvas.
- 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. If a placeholder label is used, keep exactly ONE centered text child inside the phone frame; otherwise no children. Never put the label as a sibling below the phone.
- Hero with phone mockup (desktop): prefer a two-column horizontal layout (left text/cta, right phone). Do NOT stack the phone below headline unless mobile.
- CRITICAL: "mobile"/"移动端"/"手机" + screen type (login, profile, settings, etc.) = design the ACTUAL mobile screen at 375x812. Do NOT wrap it in a desktop landing page with a phone mockup frame. Phone mockups are ONLY for app showcase/preview sections on marketing pages.
- 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.
- DENSE CARD ROWS: if a horizontal row has 5+ cards, aggressively compact each card: title must be very short (CJK 6 chars / Latin 12 chars), keep at most 2 text blocks (title + one short metric), and remove non-essential decorative elements. Rewrite copy into concise keyword phrases; NEVER output truncated text with "..." or "…".
- 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.
- 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.`
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 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","role":"navbar","width":"fill_container","fill":[{"type":"solid","color":"#FFFFFF"}]}
{"_parent":"nav","id":"logo","type":"text","name":"Logo","role":"label","content":"App","fontSize":18,"fontWeight":700,"fill":[{"type":"solid","color":"#0F172A"}]}
{"_parent":"nav","id":"menu-icon","type":"path","name":"MenuIcon","role":"icon","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","role":"hero","width":"fill_container","padding":24,"gap":16,"justifyContent":"center"}
{"_parent":"hero","id":"title","type":"text","name":"Title","role":"heading","content":"Welcome","fontSize":28,"fontWeight":700,"fill":[{"type":"solid","color":"#0F172A"}]}
${BLOCK}
CRITICAL RULES:
- 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. If a placeholder label is needed, allow exactly ONE centered text child inside the phone; otherwise no children. Never put placeholder text below the phone as a sibling. ONLY use phone mockups for app showcase/marketing sections. When the user says "mobile screen" / "移动端" / "手机页面", build the ACTUAL mobile UI at 375x812 NOT a desktop page with a phone mockup.
- 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 (🍕🍔🔔 etc); use icon_font nodes with iconFontName. Emoji cannot render on canvas.
- TEXT IN LAYOUTS: vertical layout body text should use textGrowth="fixed-width" + width="fill_container". Horizontal layout labels/buttons should use textGrowth="auto" + width="fit_content" (or omit width). NEVER use fixed pixel widths on text.
- 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.
OVERFLOW PREVENTION (CRITICAL #1 source of visual bugs):
- Do NOT force fill width for all text. Use width="fill_container" + textGrowth="fixed-width" for vertical body text; use width="fit_content"/auto for short text in horizontal rows. 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 must stay short-label only (CJK <=8 chars / Latin <=16 chars). For longer text, avoid badge/chip style.
COPYWRITING (keep all text content concise verbose copy breaks layout and hurts aesthetics):
- Headlines: 2-6 words, punchy. Subtitles: 1 sentence 15 words.
- Feature titles: 2-4 words. Feature descriptions: 1 sentence 20 words.
- Buttons: 1-3 words. Card text: 2 short sentences.
- Stats: number + 1-3 word label (e.g. "10K+ Users").
- NEVER output paragraphs with 3+ sentences in a design. Distill user-provided long copy to its essence.
SIZING: Mobile root 375x812. Web root 1200x800 (single screen) or 1200x3000-5000 (landing page). "Mobile login/signup/settings" = 375x812 actual screen, NOT a desktop page with phone mockup.
ICONS: "path" nodes, size 16-24px. ONLY use Feather icon names PascalCase + "Icon" suffix (e.g. "SearchIcon", "ArrowRightIcon", "CheckIcon"). System auto-resolves name to verified SVG path; "d" is replaced automatically. Available Feather icons: ${FEATHER_ICON_NAMES}
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:
- If DOCUMENT VARIABLES are provided, use "$name" refs instead of hardcoded values.
- Only reference listed variables.
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.
${PEN_NODE_SCHEMA}
Given a design structure (PenNode tree), generate the requested code format:
For react-tailwind: React functional component with Tailwind CSS classes and semantic HTML.
For html-css: Clean HTML with embedded <style> block using CSS custom properties and flexbox.
Output code in a single code block with the appropriate language tag.`
export const DESIGN_MODIFIER_PROMPT = `You are a Design Modification Engine. Your job is to UPDATE existing PenNodes based on user instructions.
${PEN_NODE_SCHEMA}
INPUT:
1. "Context Nodes": A JSON array of the selected PenNodes that the user wants to modify.
2. "Instruction": The user's request (e.g., "make them red", "align left", "change text to Hello").
OUTPUT:
- A JSON code block (marked with "JSON") containing ONLY the modified PenNodes.
- You MUST return the nodes with the SAME IDs as the input if you are modifying them.
- You MAY add new children to frames (new IDs) if the instruction implies it.
- You MAY remove children if implied.
RULES:
- PRESERVE IDs: The most important rule. If you return a node with a new ID, it will be treated as a new object. To update, you MUST match the input ID.
- PARTIAL UPDATES: You can return the full node object with updated fields.
- DO NOT CHANGE UNRELATED PROPS: If the user says "change color", do not change the x/y position unless necessary.
- DESIGN VARIABLES: When the user message includes a DOCUMENT VARIABLES section, prefer "$variableName" references over hardcoded values for matching properties. Only reference listed variables.
RESPONSE FORMAT:
1. <step title="Checking guidelines">...</step>
2. <step title="Getting editor state">...</step>
3. <step title="Picked a styleguide">...</step>
4. <step title="Design">...</step>
2. ${BLOCK}json [...nodes] ${BLOCK}
3. A very brief 1-sentence confirmation of what was changed.
`
return parts.join('\n\n')
}
// ---------------------------------------------------------------------------
// Section-based prompt builders (progressive loading)
// Core prompt templates (compact — design knowledge lives in pen-ai-skills)
// ---------------------------------------------------------------------------
const CHAT_CORE_PROMPT = `You are a design assistant for OpenPencil, a vector design tool that renders PenNode JSON on a canvas.
@ -406,61 +79,82 @@ CRITICAL:
- After the json block, add a 1-sentence summary.
Design like a professional: hierarchy, contrast, whitespace, consistent palette.`
// ---------------------------------------------------------------------------
// Prompt builders (progressive skill loading via pen-ai-skills)
// ---------------------------------------------------------------------------
/**
* Build a chat system prompt with only the sections needed for the user's message.
* Replaces the old monolithic CHAT_SYSTEM_PROMPT.
* Build a chat system prompt with only the skills needed for the user's message.
* Uses pen-ai-skills resolver for progressive skill loading.
*/
export function buildChatSystemPrompt(
sections: PromptSectionKey[],
designMd?: DesignMdSpec,
userMessage: string,
options?: {
hasDesignMd?: boolean
hasVariables?: boolean
designMd?: DesignMdSpec
},
): string {
const designMdContent = designMd ? buildDesignMdStylePolicy(designMd) : undefined
const knowledge = assembleSections(sections, designMdContent)
const genCtx = resolveSkills('generation', userMessage, {
flags: {
hasDesignMd: !!options?.hasDesignMd,
hasVariables: !!options?.hasVariables,
},
dynamicContent: options?.designMd
? { designMdContent: buildDesignMdStylePolicy(options.designMd) }
: undefined,
})
const knowledge = genCtx.skills.map(s => s.content).join('\n\n')
return `${CHAT_CORE_PROMPT}\n\n${knowledge}`
}
/**
* Build a generator system prompt with only the sections needed.
* Replaces the old monolithic DESIGN_GENERATOR_PROMPT.
* Build a generator system prompt with only the skills needed.
* Uses pen-ai-skills resolver for progressive skill loading.
*/
export function buildGeneratorSystemPrompt(
sections: PromptSectionKey[],
designMd?: DesignMdSpec,
userMessage: string,
options?: {
hasDesignMd?: boolean
hasVariables?: boolean
designMd?: DesignMdSpec
},
): string {
const designMdContent = designMd ? buildDesignMdStylePolicy(designMd) : undefined
const knowledge = assembleSections(sections, designMdContent)
const genCtx = resolveSkills('generation', userMessage, {
flags: {
hasDesignMd: !!options?.hasDesignMd,
hasVariables: !!options?.hasVariables,
},
dynamicContent: options?.designMd
? { designMdContent: buildDesignMdStylePolicy(options.designMd) }
: undefined,
})
const knowledge = genCtx.skills.map(s => s.content).join('\n\n')
return `${GENERATOR_CORE_PROMPT}\n\n${knowledge}`
}
/**
* Build a modifier system prompt with only the sections needed.
* Replaces the old monolithic DESIGN_MODIFIER_PROMPT.
* Build a modifier system prompt using maintenance-phase skills.
* Uses pen-ai-skills resolver for progressive skill loading.
*/
export function buildModifierSystemPrompt(
sections: PromptSectionKey[],
designMd?: DesignMdSpec,
userMessage: string,
options?: {
hasDesignMd?: boolean
hasVariables?: boolean
designMd?: DesignMdSpec
},
): string {
const designMdContent = designMd ? buildDesignMdStylePolicy(designMd) : undefined
const knowledge = assembleSections(sections, designMdContent)
return `You are a Design Modification Engine. Your job is to UPDATE existing PenNodes based on user instructions.
${knowledge}
INPUT:
1. "Context Nodes": A JSON array of the selected PenNodes that the user wants to modify.
2. "Instruction": The user's request.
OUTPUT:
- A JSON code block containing ONLY the modified PenNodes.
- You MUST return the nodes with the SAME IDs as the input.
- You MAY add/remove children if implied.
RULES:
- PRESERVE IDs. PARTIAL UPDATES OK. DO NOT CHANGE UNRELATED PROPS.
RESPONSE FORMAT:
1. <step title="Checking guidelines">...</step>
2. <step title="Design">...</step>
3. ${BLOCK}json [...nodes] ${BLOCK}
4. A very brief 1-sentence confirmation.`
const maintenanceCtx = resolveSkills('maintenance', userMessage, {
flags: {
hasVariables: !!options?.hasVariables,
hasDesignMd: !!options?.hasDesignMd,
},
})
let prompt = maintenanceCtx.skills.map(s => s.content).join('\n\n')
// Append design-md context if present (design-md skill is generation-phase only)
if (options?.designMd) {
prompt += '\n\n' + buildDesignMdStylePolicy(options.designMd)
}
return prompt
}

View file

@ -40,7 +40,7 @@ export const PROMPT_TIMEOUT_BUCKETS = {
export const SUB_AGENT_TIMEOUT_PROFILES = {
short: {
...SUB_AGENT_TIMEOUT_BASE,
pingResetsTimeout: false,
pingResetsTimeout: true,
firstTextTimeoutMs: 420_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
@ -49,7 +49,7 @@ export const SUB_AGENT_TIMEOUT_PROFILES = {
...SUB_AGENT_TIMEOUT_BASE,
hardTimeoutMs: 600_000,
noTextTimeoutMs: 300_000,
pingResetsTimeout: false,
pingResetsTimeout: true,
firstTextTimeoutMs: 600_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
@ -58,7 +58,7 @@ export const SUB_AGENT_TIMEOUT_PROFILES = {
...SUB_AGENT_TIMEOUT_BASE,
hardTimeoutMs: 900_000,
noTextTimeoutMs: 480_000,
pingResetsTimeout: false,
pingResetsTimeout: true,
firstTextTimeoutMs: 900_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
@ -70,7 +70,7 @@ export const ORCHESTRATOR_TIMEOUT_PROFILES = {
hardTimeoutMs: 300_000,
noTextTimeoutMs: 150_000,
thinkingResetsTimeout: true,
pingResetsTimeout: false,
pingResetsTimeout: true,
firstTextTimeoutMs: 300_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
@ -79,7 +79,7 @@ export const ORCHESTRATOR_TIMEOUT_PROFILES = {
hardTimeoutMs: 420_000,
noTextTimeoutMs: 210_000,
thinkingResetsTimeout: true,
pingResetsTimeout: false,
pingResetsTimeout: true,
firstTextTimeoutMs: 420_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
@ -88,7 +88,7 @@ export const ORCHESTRATOR_TIMEOUT_PROFILES = {
hardTimeoutMs: 600_000,
noTextTimeoutMs: 300_000,
thinkingResetsTimeout: true,
pingResetsTimeout: false,
pingResetsTimeout: true,
firstTextTimeoutMs: 600_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,
@ -99,7 +99,7 @@ export const DESIGN_STREAM_TIMEOUTS = {
hardTimeoutMs: 900_000,
noTextTimeoutMs: 480_000,
thinkingResetsTimeout: true,
pingResetsTimeout: false,
pingResetsTimeout: true,
firstTextTimeoutMs: 900_000,
thinkingMode: DEFAULT_THINKING_MODE,
effort: DEFAULT_THINKING_EFFORT,

View file

@ -9,11 +9,7 @@
import type { DesignSystem } from './ai-types'
import type { AIProviderType } from '@/types/agent-settings'
import { generateCompletion } from './ai-service'
import {
DESIGN_CODE_SYSTEM_PROMPT,
buildCodeGenUserPrompt,
} from './design-code-prompts'
import { getAllPrinciples } from './design-principles'
import { getSkillByName } from '@zseven-w/pen-ai-skills'
import { designSystemToPromptContext } from './design-system-generator'
interface CodeGenOptions {
@ -32,12 +28,13 @@ export async function generateDesignCode(
designSystem: DesignSystem,
options: CodeGenOptions,
): Promise<string> {
const principles = getAllPrinciples()
const designCodeSkill = getSkillByName('design-code')?.content ?? ''
const principles = getSkillByName('design-principles')?.content ?? ''
// Build the system prompt with principles injected
const systemPrompt = principles
? `${DESIGN_CODE_SYSTEM_PROMPT}\n\n${principles}`
: DESIGN_CODE_SYSTEM_PROMPT
? `${designCodeSkill}\n\n${principles}`
: designCodeSkill
// Build the user prompt with design system context
const dsContext = designSystemToPromptContext(designSystem)
@ -168,3 +165,26 @@ export function extractHtmlSection(html: string, subtaskLabel: string): string |
return null
}
/**
* Build the user prompt for HTML/CSS code generation.
* Includes the design system tokens and viewport constraints.
*/
function buildCodeGenUserPrompt(
userPrompt: string,
designSystemContext: string,
width: number,
height: number,
): string {
const heightInstruction = height > 0
? `Height: ${height}px (fixed viewport).`
: `Height: auto (content determines height, estimate based on sections).`
return `Design request: ${userPrompt}
Viewport: Width ${width}px. ${heightInstruction}
${designSystemContext}
Generate the complete HTML file now.`
}

View file

@ -4,8 +4,8 @@ import type { AIProviderType } from '@/types/agent-settings'
import type { DesignMdSpec } from '@/types/design-md'
import type { AIDesignRequest } from './ai-types'
import { streamChat } from './ai-service'
import { buildModifierSystemPrompt } from './ai-prompts'
import { detectSections } from './ai-prompt-sections'
import { resolveSkills } from '@zseven-w/pen-ai-skills'
import { buildDesignMdStylePolicy } from './ai-prompts'
import { executeOrchestration } from './orchestrator'
import { DESIGN_STREAM_TIMEOUTS } from './ai-runtime-config'
import { extractJsonFromResponse } from './design-parser'
@ -122,13 +122,18 @@ export async function generateDesignModification(
const profile = resolveModelProfile(options?.model)
const timeouts = applyProfileToTimeouts({ ...DESIGN_STREAM_TIMEOUTS }, profile)
// Progressive section loading for modification prompts
const modSections = detectSections(instruction, {
hasDesignMd: !!options?.designMd,
hasVariables: !!options?.variables && Object.keys(options.variables).length > 0,
isModification: true,
// Resolve maintenance skills for modification prompts
const maintenanceCtx = resolveSkills('maintenance', instruction, {
flags: {
hasVariables: !!options?.variables && Object.keys(options.variables).length > 0,
hasDesignMd: !!options?.designMd,
},
})
const modifierPrompt = buildModifierSystemPrompt(modSections, options?.designMd)
let modifierPrompt = maintenanceCtx.skills.map(s => s.content).join('\n\n')
// Append design-md context if present (design-md skill is generation-phase only)
if (options?.designMd) {
modifierPrompt += '\n\n' + buildDesignMdStylePolicy(options.designMd)
}
for await (const chunk of streamChat(modifierPrompt, [
{ role: 'user', content: userMessage },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@ import type { DesignSystem } from './ai-types'
import type { AIProviderType } from '@/types/agent-settings'
import type { VariableDefinition } from '@/types/variables'
import { generateCompletion } from './ai-service'
import { DESIGN_SYSTEM_PROMPT } from './design-system-prompts'
import { getSkillByName } from '@zseven-w/pen-ai-skills'
/**
* Generate a design system from a user's prompt.
@ -22,8 +22,9 @@ export async function generateDesignSystem(
model?: string,
provider?: AIProviderType,
): Promise<DesignSystem> {
const designSystemPrompt = getSkillByName('design-system')?.content ?? ''
const response = await generateCompletion(
DESIGN_SYSTEM_PROMPT,
designSystemPrompt,
prompt,
model,
provider,

View file

@ -11,6 +11,7 @@ import { VALIDATION_ENABLED, VALIDATION_TIMEOUT_MS, MAX_VALIDATION_ROUNDS, VALID
import type { PenNode } from '@/types/pen'
import type { AIProviderType } from '@/types/agent-settings'
import { getCurrentVisualReference, clearVisualReference } from './visual-ref-orchestrator'
import { resolveSkills } from '@zseven-w/pen-ai-skills'
import { runPreValidationFixes } from './design-pre-validation'
import { captureRootFrameScreenshot } from './design-screenshot'
import {
@ -22,80 +23,13 @@ import {
} from './design-validation-fixes'
// ---------------------------------------------------------------------------
// System prompt for the vision validator
// System prompt for the vision validator (resolved from pen-ai-skills)
// ---------------------------------------------------------------------------
const VALIDATION_SYSTEM_PROMPT = `You are a design QA validator. You receive a screenshot of a UI design AND its node tree structure.
Cross-reference the visual issues you see in the screenshot with the node IDs in the tree.
Check for these issues:
1. WIDTH INCONSISTENCY: Form inputs, buttons, cards that are siblings but have different widths. They should all use "fill_container" width to match their parent.
2. ELEMENT TOO NARROW: Buttons or inputs that are much narrower than their parent container. Fix: width="fill_container".
3. SPACING: Uneven padding, elements too close to edges, inconsistent gaps between siblings.
4. OVERFLOW: Text or elements visually clipped or extending beyond their container.
5. ALIGNMENT: Elements that should be aligned but aren't (e.g. form fields not left-aligned).
6. TEXT CENTERING: Text that should be horizontally centered in its container but appears shifted left or right. Common in headings, buttons, divider text ("or continue with"), and footer text. Fix: ensure the parent container has alignItems="center" or the text node has width="fill_container".
7. MISSING ICONS: Path nodes that rendered as empty/invisible rectangles.
8. COLOR ISSUES: Text with poor contrast against its background, wrong background colors, inconsistent color usage across similar elements.
9. TYPOGRAPHY: Inconsistent font sizes between similar elements, wrong font weights for headings vs body text.
10. MISSING BORDERS: Input fields, cards, or containers that lack a visible border and blend into their parent background. Fix with strokeColor and strokeWidth.
11. STRUCTURAL INCONSISTENCY: Sibling elements that should follow the same pattern but have different child structures. For example, if one input field has a leading icon but a sibling input field does not, or a list item is missing an expected child element. Fix by adding the missing child node.
12. MISSING ELEMENTS: When a reference design is provided, check if important UI elements visible in the reference are missing or absent in the current design. Fix by adding the missing element as a child of the appropriate parent.
Output ONLY a JSON object. No explanation, no markdown fences.
{"qualityScore":8,"issues":["description1","description2"],"fixes":[{"nodeId":"actual-node-id","property":"width","value":"fill_container"}],"structuralFixes":[]}
qualityScore: Rate the overall design quality from 1-10.
- 9-10: Production-ready, polished design
- 7-8: Good design with minor issues
- 5-6: Acceptable but needs improvement
- 1-4: Significant problems
Allowed property fixes (update existing node):
- width: number | "fill_container" | "fit_content"
- height: number | "fill_container" | "fit_content"
- padding: number | [top,right,bottom,left]
- gap: number
- fontSize: number
- fontWeight: number (300-900)
- letterSpacing: number
- lineHeight: number
- cornerRadius: number
- opacity: number
- fillColor: "#hex" (background/fill color of the node)
- strokeColor: "#hex" (border/stroke color)
- strokeWidth: number (border/stroke width)
- textAlign: "left" | "center" | "right" (text horizontal alignment within its box)
- textGrowth: "auto" | "fixed-width" | "fixed-width-height" (text wrapping mode "fixed-width" = wrap text and auto-size height)
- alignItems: "start" | "center" | "end"
- justifyContent: "start" | "center" | "end" | "space_between"
TEXT CLIPPING DETECTION:
- If a text node has an explicit pixel height (h=22, h=30 etc.) AND its content appears visually clipped or overlapping siblings, the fix is: set textGrowth="fixed-width" and height="fit_content". This lets the engine auto-calculate the correct height.
- Text nodes should almost NEVER have explicit pixel heights. The node tree shows textGrowth and lineHeight values use these to diagnose text issues.
- Button text clipped at bottom: check if the parent frame's padding leaves enough space for the text height (fontSize × lineHeight). Fix the parent's padding or height, not the text's fontSize.
Structural fixes (add or remove nodes use sparingly, only for clear structural issues):
- Add child: {"action":"addChild","parentId":"real-parent-id","index":0,"node":{"type":"path","name":"KeyIcon","width":18,"height":18}}
- Add child: {"action":"addChild","parentId":"real-parent-id","node":{"type":"text","name":"Label","content":"text","fontSize":14,"fillColor":"#hex"}}
- Add child: {"action":"addChild","parentId":"real-parent-id","node":{"type":"frame","name":"Divider","width":"fill_container","height":1,"fillColor":"#hex"}}
- Remove node: {"action":"removeNode","nodeId":"real-node-id"}
For addChild nodes:
- type: "frame" | "text" | "path" | "rectangle" | "ellipse"
- For path/icon nodes: set name to the icon name (e.g. "KeyIcon", "LockIcon", "EyeIcon"). The system resolves icon paths automatically.
- index is optional (defaults to 0 = first child). Use it to control insertion position among siblings.
- Specify width, height, fillColor as needed. Other properties are optional.
IMPORTANT:
- Use REAL node IDs from the provided tree never guess or fabricate IDs.
- For form consistency issues, fix ALL inconsistent siblings, not just one.
- If the design looks correct, return: {"qualityScore":9,"issues":[],"fixes":[],"structuralFixes":[]}
- Keep fixes minimal only fix clear visual bugs, not stylistic preferences.
- Focus on the most impactful issues first.
- For structuralFixes, only add elements that are clearly needed for consistency or completeness. Do not add decorative elements unless they are present in the reference.
- CRITICAL: When using addChild, ALWAYS include companion property fixes for the parent node to maintain correct layout. For example, if the parent has justifyContent="space_between" and adding a child would break the spacing, also add a property fix to change justifyContent and/or add a gap value. Look at sibling elements with the same pattern and match the parent's layout properties to theirs.
- CRITICAL: NEVER change height or width from "fit_content" to a fixed pixel value on a frame that has layout (auto-layout). This creates empty whitespace. If a container appears invisible, fix its opacity, fill color, or border instead not its height.`
function getValidationSystemPrompt(): string {
const validationCtx = resolveSkills('validation', '')
return validationCtx.skills.map(s => s.content).join('\n\n')
}
// ---------------------------------------------------------------------------
@ -193,7 +127,7 @@ Cross-reference visual issues with the node IDs above. Return JSON fixes using r
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system: VALIDATION_SYSTEM_PROMPT,
system: getValidationSystemPrompt(),
message,
imageBase64,
model,

View file

@ -6,7 +6,7 @@ import {
SUB_AGENT_TIMEOUT_PROFILES,
} from './ai-runtime-config'
import { detectDesignType } from './design-type-presets'
import { getAllPrinciples } from './design-principles'
import { getSkillByName } from '@zseven-w/pen-ai-skills'
import { resolveModelProfile, applyProfileToTimeouts } from './model-profiles'
export interface PreparedDesignPrompt {
@ -73,7 +73,7 @@ export function prepareDesignPrompt(prompt: string): PreparedDesignPrompt {
subAgentPrompt: truncateByCharCount(normalized, PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForSubAgent),
wasCompressed: normalized.length > PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForOrchestrator,
originalLength: normalized.length,
designPrinciples: getAllPrinciples(),
designPrinciples: getSkillByName('design-principles')?.content ?? '',
}
}

View file

@ -1,143 +0,0 @@
/**
* 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:
Classify by the design's PURPOSE reason about intent, do not keyword-match:
1. Multi-section page marketing, promotional, or informational content designed to be scrolled (e.g. product sites, portfolios, company pages):
Desktop: width=1200, height=0 (scrollable), 6-10 subtasks
Structure: navigation hero content sections CTA footer
2. Single-task screen functional UI focused on one user task (e.g. authentication, forms, settings, profiles, modals, onboarding):
Mobile: width=375, height=812 (fixed viewport), 1-5 subtasks
Structure: header + focused content area only, no navigation/hero/footer
3. Data-rich workspace overview screens with metrics, tables, or management panels (e.g. dashboards, admin consoles, analytics):
Desktop: width=1200, height=0, 2-5 subtasks
Structure: sidebar or topbar + content panels
CRITICAL "MOBILE" MEANS MOBILE-SIZED SCREEN, NOT A PHONE MOCKUP:
When the user says "mobile"/"移动端"/"手机" + a screen type (login, profile, settings, etc.), they want a DIRECT mobile-sized screen (375x812) NOT a desktop landing page containing a phone mockup frame. A "mobile login page" = type 2 (375x812 login screen). Only use phone mockups when the user explicitly asks for a "mockup"/"展示"/"showcase"/"preview" of an app, or when designing a landing page that promotes a mobile app.
FORMAT:
{"rootFrame":{"id":"page","name":"Page","width":1200,"height":0,"layout":"vertical","gap":0,"fill":[{"type":"solid","color":"#F8FAFC"}]},"styleGuide":{"palette":{"background":"#F8FAFC","surface":"#FFFFFF","text":"#0F172A","secondary":"#64748B","accent":"#2563EB","accent2":"#0EA5E9","border":"#E2E8F0"},"fonts":{"heading":"Space Grotesk","body":"Inter"},"aesthetic":"clean modern with blue accents"},"subtasks":[{"id":"nav","label":"Navigation Bar","elements":"logo, nav links (Home, Features, Pricing, Blog), sign-in button, get-started CTA button","region":{"width":1200,"height":72}},{"id":"hero","label":"Hero Section","elements":"headline, subtitle, CTA button, hero illustration or phone mockup","region":{"width":1200,"height":560}},{"id":"features","label":"Feature Cards","elements":"section title, 3 feature cards each with icon + title + description","region":{"width":1200,"height":480}}]}
RULES:
- ELEMENT BOUNDARIES: Each subtask MUST have an "elements" field listing the specific UI elements it contains. Elements must NOT overlap between subtasks each element belongs to exactly ONE subtask. Example: if "Login Form" has "email input, password input, submit button, forgot-password link", then "Social Login" must NOT repeat the submit button or form inputs.
- STYLE SELECTION: Choose light or dark theme based on user intent. Dark: user mentions dark/cyber/terminal/neon///deep/gaming/noir. Light (default): all other cases SaaS, marketing, education, e-commerce, productivity, social. Never default to dark unless the content clearly calls for it.
- Detect the design type FIRST, then choose the appropriate structure and subtask count.
- Multi-section pages (type 1): include Navigation Bar as the FIRST subtask, followed by Hero, feature sections, CTA, footer, etc. (6-10 subtasks)
- Single-task screens (type 2): do NOT include Navigation Bar, Hero, CTA, or footer. Only include the actual UI elements needed (1-5 subtasks).
- FORM INTEGRITY: Keep a form's core elements (inputs + submit button) in the same subtask. Splitting inputs into one subtask and the button into another causes duplicate buttons.
- Combine related elements: "Hero with title + image + CTA" = ONE subtask, not three.
- Each subtask generates a meaningful section (~10-30 nodes). Only split if it would exceed 40 nodes.
- REQUIRED: "styleGuide" must ALWAYS be included. Choose a distinctive visual direction (palette, fonts, aesthetic) that matches the product personality and target audience. Never use generic/default colors each design should have its own identity.
- CJK FONT RULE: If the user's request is in Chinese/Japanese/Korean or the product targets CJK audiences, the styleGuide fonts MUST use CJK-compatible fonts: heading="Noto Sans SC" (Chinese) / "Noto Sans JP" (Japanese) / "Noto Sans KR" (Korean), body="Inter". NEVER use "Space Grotesk" or "Manrope" as heading font for CJK content they have no CJK character support.
- Root frame fill must use the styleGuide palette background color.
- Root frame gap: Landing pages with distinct section backgrounds gap=0 (sections flush). Mobile screens and dashboards gap=16-24 (breathing room between sections). Always include "gap" in rootFrame.
- 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).
- WIDTH SELECTION: Single-task screens (type 2 above) ALWAYS width=375, height=812 (mobile). Multi-section pages and data-rich workspaces (types 1 & 3) width=1200, height=0 (desktop). This is mandatory.
- MULTI-SCREEN APPS: When the request involves multiple distinct screens/pages (e.g. "登录页+个人中心", "login and profile"), add "screen":"<name>" to each subtask to group sections that belong to the same page. Use a concise page name (e.g. "登录", "Profile"). Subtasks sharing the same "screen" are placed in one root frame. Single-screen requests don't need "screen". Example: [{"id":"brand","label":"Brand Area","screen":"Login","region":{...}},{"id":"form","label":"Login Form","screen":"Login","region":{...}},{"id":"card","label":"User Card","screen":"Profile","region":{...}}]
- NO explanation. NO markdown. NO tool calls. NO function calls. NO [TOOL_CALL]. JUST the JSON object. Start with {.`
// 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), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,textAlign,textGrowth,lineHeight,letterSpacing), icon_font (iconFontName,width,height,fill), path (d,width,height,fill,stroke), image (width,height,imageSearchQuery,imagePrompt). imagePrompt: describe subject+scene+style, NEVER mention background type (transparent/white/plain). Match composition to aspect ratio.
SHARED: id, type, name, role, x, y, opacity
ROLES: section, row, column, divider | navbar, button, icon-button, badge, input, search-bar | card, stat-card, pricing-card, feature-card | heading, subheading, body-text, caption, label | table, table-row, table-header
width/height: number | "fill_container" | "fit_content". padding: number | [v,h] | [T,R,B,L]. Fill=[{"type":"solid","color":"#hex"}].
Stroke: {"thickness":N,"fill":[{"type":"solid","color":"#hex"}]}. Directional: {"thickness":{"bottom":1},"fill":[...]}.
RULES:
- Section root: width="fill_container", height="fit_content", layout="vertical".
- No x/y on children in layout frames. All nodes descend from section root.
- Width consistency: siblings in vertical layout use the SAME width strategy.
- Never "fill_container" inside "fit_content" parent.
- clipContent: true on cards with cornerRadius + image children.
- Text: NEVER set height. Short text (titles, labels, buttons) omit textGrowth. Long text (>15 chars wrapping) textGrowth="fixed-width", width="fill_container", lineHeight=1.4-1.6.
- lineHeight: Display 40-56px 0.9-1.0. Heading 20-36px 1.0-1.2. Body 1.4-1.6. letterSpacing: -0.5 to -1 for headlines, 1-3 for uppercase.
- Icons: ALWAYS use icon_font nodes with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings, etc). Sizes: 14/20/24px. NEVER use emoji characters (🍕🍔🔔 etc) as icon substitutes they cannot render on canvas.
- CJK fonts: "Noto Sans SC"/"Noto Sans JP"/"Noto Sans KR" for headings. CJK lineHeight: 1.3-1.4 headings, 1.6-1.8 body.
- Buttons: frame(padding=[12,24], justifyContent="center") > text. Icon+text: frame(layout="horizontal", gap=8, alignItems="center", padding=[8,16]).
- Card rows: ALL cards width="fill_container" + height="fill_container".
- FORMS: ALL inputs AND button use width="fill_container". gap=16-20.
- Phone mockup: ONE frame, w=260-300, h=520-580, cornerRadius=32, solid fill + 1px stroke.
- Z-order: Earlier siblings render on top. Overlay elements (badges, indicators, floating buttons) MUST come BEFORE the content they overlap.
FORMAT: _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","gap":24,"padding":[48,24],"fill":[{"type":"solid","color":"#F8FAFC"}]}
{"_parent":"root","id":"header","type":"frame","name":"Header","justifyContent":"space_between","alignItems":"center","width":"fill_container"}
{"_parent":"header","id":"logo","type":"text","name":"Logo","content":"ACME","fontSize":18,"fontWeight":600,"fontFamily":"Space Grotesk","fill":[{"type":"solid","color":"#0D0D0D"}]}
{"_parent":"header","id":"notifBtn","type":"frame","name":"Notification","width":44,"height":44}
{"_parent":"notifBtn","id":"notifIcon","type":"icon_font","name":"Bell","iconFontName":"bell","width":20,"height":20,"fill":"#0D0D0D","x":12,"y":12}
{"_parent":"root","id":"title","type":"text","name":"Headline","content":"Learn Smarter","fontSize":48,"fontWeight":700,"fontFamily":"Space Grotesk","lineHeight":0.95,"fill":[{"type":"solid","color":"#0F172A"}]}
{"_parent":"root","id":"desc","type":"text","name":"Description","content":"AI-powered vocabulary learning that adapts to your pace","fontSize":16,"textGrowth":"fixed-width","width":"fill_container","lineHeight":1.5,"fill":[{"type":"solid","color":"#64748B"}]}
{"_parent":"root","id":"cta","type":"frame","name":"CTA Button","padding":[14,28],"cornerRadius":10,"justifyContent":"center","fill":[{"type":"solid","color":"#2563EB"}]}
{"_parent":"cta","id":"cta-text","type":"text","name":"CTA Label","content":"Get Started","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}
${BLOCK}
CRITICAL: Output ONLY the ${BLOCK}json block. Do NOT write any text, explanation, plan, tool calls, or function calls. Do NOT use [TOOL_CALL] or {tool => ...} syntax. Start your response with ${BLOCK}json immediately.`
/**
* Simplified sub-agent prompt for weaker models (basic tier).
* Uses nested JSON with children arrays (not flat JSONL with _parent).
* Keeps only essential node types and rules.
*/
export const SUB_AGENT_PROMPT_SIMPLIFIED = `Generate a UI section as a nested JSON tree. Output a ${BLOCK}json block with a single root object containing nested "children" arrays.
TYPES:
frame (width,height,layout,gap,padding,justifyContent,alignItems,cornerRadius,fill,children), rectangle (width,height,cornerRadius,fill), text (content,fontFamily,fontSize,fontWeight,fill,width,textAlign), icon_font (iconFontName,width,height,fill)
SHARED: id, type, name
RULES:
- Root: type="frame", width="fill_container", height="fit_content", layout="vertical".
- Children go in "children" arrays. No x/y on layout children.
- width/height: number | "fill_container" | "fit_content".
- fill: [{"type":"solid","color":"#hex"}].
- Text: never set height. Use width="fill_container" for wrapping text.
- Icons: use icon_font with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings). Sizes: 16/20/24px.
- Buttons: frame with padding=[12,24] containing a text child.
- No emoji characters. No markdown. No explanation. No tool calls.
EXAMPLE:
${BLOCK}json
{
"id": "root",
"type": "frame",
"name": "Hero",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 24,
"padding": [48, 24],
"fill": [{"type": "solid", "color": "#F8FAFC"}],
"children": [
{"id": "title", "type": "text", "name": "Headline", "content": "Learn Smarter", "fontSize": 48, "fontWeight": 700, "fontFamily": "Space Grotesk", "fill": [{"type": "solid", "color": "#0F172A"}]},
{"id": "desc", "type": "text", "name": "Description", "content": "AI-powered learning", "fontSize": 16, "width": "fill_container", "fill": [{"type": "solid", "color": "#64748B"}]},
{"id": "cta", "type": "frame", "name": "CTA", "padding": [14, 28], "cornerRadius": 10, "justifyContent": "center", "fill": [{"type": "solid", "color": "#2563EB"}], "children": [
{"id": "cta-text", "type": "text", "content": "Get Started", "fontSize": 16, "fontWeight": 600, "fill": [{"type": "solid", "color": "#FFFFFF"}]}
]}
]
}
${BLOCK}
CRITICAL: You are a JSON generator, NOT a code assistant. Output ONLY the ${BLOCK}json block. Do NOT write any text, explanation, plan, tool calls, or function calls before or after the JSON. Do NOT use [TOOL_CALL], {tool => ...}, or any tool/function invocation syntax. Start your response with ${BLOCK}json immediately.`

View file

@ -19,7 +19,7 @@ import type {
SubAgentResult,
} from './ai-types'
import { streamChat } from './ai-service'
import { SUB_AGENT_PROMPT } from './orchestrator-prompts'
import { resolveSkills } from '@zseven-w/pen-ai-skills'
import {
type PreparedDesignPrompt,
getSubAgentTimeouts,
@ -244,10 +244,16 @@ async function executeSubAgent(
request.context?.designMd,
)
const basePrompt = SUB_AGENT_PROMPT
const systemPrompt = preparedPrompt.designPrinciples
? `${basePrompt}\n\n${preparedPrompt.designPrinciples}`
: basePrompt
const designMd = request.context?.designMd
const variables = request.context?.variables
const genCtx = resolveSkills('generation', request.prompt, {
flags: {
hasVariables: !!variables && Object.keys(variables).length > 0,
hasDesignMd: !!designMd,
},
dynamicContent: designMd ? { designMdContent: JSON.stringify(designMd) } : undefined,
})
const systemPrompt = genCtx.skills.map(s => s.content).join('\n\n')
let rawResponse = ''
const nodes: PenNode[] = []

View file

@ -19,7 +19,7 @@ import type {
SubAgentResult,
} from './ai-types'
import { streamChat } from './ai-service'
import { ORCHESTRATOR_PROMPT } from './orchestrator-prompts'
import { resolveSkills } from '@zseven-w/pen-ai-skills'
import {
getOrchestratorTimeouts,
prepareDesignPrompt,
@ -442,8 +442,11 @@ async function callOrchestrator(
let rawResponse = ''
let thinkingContent = ''
const planningCtx = resolveSkills('planning', prompt)
const planningSystemPrompt = planningCtx.skills.map(s => s.content).join('\n\n')
for await (const chunk of streamChat(
ORCHESTRATOR_PROMPT,
planningSystemPrompt,
[{ role: 'user', content: prompt }],
model,
getOrchestratorTimeouts(timeoutHintLength, model),

View file

@ -1,82 +0,0 @@
import { registerRole } from '../role-resolver'
registerRole('hero', (_node, ctx) => ({
layout: 'vertical' as const,
width: 'fill_container' as const,
height: 'fit_content' as const,
padding:
ctx.canvasWidth <= 480
? ([40, 16] as [number, number])
: ([80, 80] as [number, number]),
gap: 24,
alignItems: 'center',
}))
registerRole('feature-grid', (_node, _ctx) => ({
layout: 'horizontal' as const,
width: 'fill_container' as const,
gap: 24,
alignItems: 'start' as const,
}))
registerRole('feature-card', (_node, ctx) => {
if (ctx.parentLayout === 'horizontal') {
return {
width: 'fill_container' as const,
height: 'fill_container' as const,
layout: 'vertical' as const,
gap: 12,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}
}
return {
layout: 'vertical' as const,
gap: 12,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}
})
registerRole('testimonial', (_node, _ctx) => ({
layout: 'vertical' as const,
gap: 16,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}))
registerRole('cta-section', (_node, ctx) => ({
layout: 'vertical' as const,
width: 'fill_container' as const,
height: 'fit_content' as const,
padding:
ctx.canvasWidth <= 480
? ([40, 16] as [number, number])
: ([60, 80] as [number, number]),
gap: 20,
alignItems: 'center',
}))
registerRole('footer', (_node, ctx) => ({
layout: 'vertical' as const,
width: 'fill_container' as const,
height: 'fit_content' as const,
padding:
ctx.canvasWidth <= 480
? ([32, 16] as [number, number])
: ([48, 80] as [number, number]),
gap: 24,
}))
registerRole('stats-section', (_node, ctx) => ({
layout: 'horizontal' as const,
width: 'fill_container' as const,
height: 'fit_content' as const,
padding:
ctx.canvasWidth <= 480
? ([32, 16] as [number, number])
: ([48, 80] as [number, number]),
gap: 32,
justifyContent: 'center' as const,
alignItems: 'center' as const,
}))

View file

@ -1,67 +0,0 @@
import { registerRole } from '../role-resolver'
registerRole('card', (_node, ctx) => {
if (ctx.parentLayout === 'horizontal') {
return {
width: 'fill_container' as const,
height: 'fill_container' as const,
layout: 'vertical' as const,
gap: 12,
cornerRadius: 12,
clipContent: true,
}
}
return {
layout: 'vertical' as const,
gap: 12,
cornerRadius: 12,
clipContent: true,
}
})
registerRole('stat-card', (_node, ctx) => {
if (ctx.parentLayout === 'horizontal') {
return {
width: 'fill_container' as const,
height: 'fill_container' as const,
layout: 'vertical' as const,
gap: 8,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}
}
return {
layout: 'vertical' as const,
gap: 8,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}
})
registerRole('pricing-card', (_node, ctx) => {
if (ctx.parentLayout === 'horizontal') {
return {
width: 'fill_container' as const,
height: 'fill_container' as const,
layout: 'vertical' as const,
gap: 16,
padding: [32, 24] as [number, number],
cornerRadius: 16,
clipContent: true,
}
}
return {
layout: 'vertical' as const,
gap: 16,
padding: [32, 24] as [number, number],
cornerRadius: 16,
clipContent: true,
}
})
registerRole('image-card', (_node, _ctx) => ({
layout: 'vertical' as const,
gap: 0,
cornerRadius: 12,
clipContent: true,
}))

View file

@ -1,14 +1,479 @@
/**
* Role definitions for the AI design generation system.
* Importing this module registers all role rules with the role resolver.
* All registerRole() calls are consolidated here for runtime registration.
*/
// Each import triggers side-effect registration via registerRole()
import './layout'
import './navigation'
import './interactive'
import './display'
import './content'
import './media'
import './typography'
import './table'
import { registerRole } from '../role-resolver'
import { hasCjkText, getTextContentForNode } from '../generation-utils'
// ---------------------------------------------------------------------------
// Layout roles
// ---------------------------------------------------------------------------
registerRole('section', (_node, ctx) => ({
layout: 'vertical',
width: 'fill_container' as const,
height: 'fit_content' as const,
gap: 24,
padding:
ctx.canvasWidth <= 480
? ([40, 16] as [number, number])
: ([60, 80] as [number, number]),
alignItems: 'center',
}))
registerRole('row', (_node, _ctx) => ({
layout: 'horizontal',
width: 'fill_container' as const,
gap: 16,
alignItems: 'center',
}))
registerRole('column', (_node, _ctx) => ({
layout: 'vertical',
width: 'fill_container' as const,
gap: 16,
}))
registerRole('centered-content', (_node, ctx) => ({
layout: 'vertical',
width: ctx.canvasWidth <= 480 ? ('fill_container' as const) : 1080,
gap: 24,
alignItems: 'center',
}))
registerRole('form-group', (_node, _ctx) => ({
layout: 'vertical',
width: 'fill_container' as const,
gap: 16,
}))
registerRole('spacer', (_node, _ctx) => ({
width: 'fill_container' as const,
height: 40,
}))
registerRole('divider', (node, _ctx) => {
const isVertical = node.name?.toLowerCase().includes('vertical')
if (isVertical) {
return { width: 1, height: 'fill_container' as const, layout: 'none' as const }
}
return {
width: 'fill_container' as const,
height: 1,
layout: 'none' as const,
}
})
// ---------------------------------------------------------------------------
// Navigation roles
// ---------------------------------------------------------------------------
registerRole('navbar', (_node, ctx) => ({
layout: 'horizontal',
width: 'fill_container' as const,
height: ctx.canvasWidth <= 480 ? 56 : 72,
padding:
ctx.canvasWidth <= 480
? ([0, 16] as [number, number])
: ([0, 80] as [number, number]),
alignItems: 'center',
justifyContent: 'space_between' as const,
}))
registerRole('nav-links', (_node, _ctx) => ({
layout: 'horizontal',
gap: 24,
alignItems: 'center',
}))
registerRole('nav-link', (_node, _ctx) => ({
textGrowth: 'auto' as const,
lineHeight: 1.2,
}))
// ---------------------------------------------------------------------------
// Interactive roles
// ---------------------------------------------------------------------------
registerRole('button', (_node, ctx) => {
if (ctx.parentRole === 'navbar') {
return {
padding: [8, 16] as [number, number],
height: 36,
layout: 'horizontal' as const,
gap: 8,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 8,
}
}
if (ctx.parentRole === 'form-group') {
return {
width: 'fill_container' as const,
height: 48,
layout: 'horizontal' as const,
gap: 8,
padding: [12, 24] as [number, number],
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 10,
}
}
return {
padding: [12, 24] as [number, number],
height: 44,
layout: 'horizontal' as const,
gap: 8,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 8,
}
})
registerRole('icon-button', (_node, _ctx) => ({
width: 44,
height: 44,
layout: 'horizontal' as const,
justifyContent: 'center' as const,
alignItems: 'center' as const,
cornerRadius: 8,
}))
registerRole('badge', (_node, _ctx) => ({
layout: 'horizontal' as const,
padding: [6, 12] as [number, number],
gap: 4,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 999,
}))
registerRole('tag', (_node, _ctx) => ({
layout: 'horizontal' as const,
padding: [4, 10] as [number, number],
gap: 4,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 6,
}))
registerRole('pill', (_node, _ctx) => ({
layout: 'horizontal' as const,
padding: [6, 14] as [number, number],
gap: 6,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 999,
}))
registerRole('input', (_node, ctx) => {
if (ctx.parentLayout === 'vertical') {
return {
width: 'fill_container' as const,
height: 48,
layout: 'horizontal' as const,
padding: [12, 16] as [number, number],
alignItems: 'center' as const,
cornerRadius: 8,
}
}
return {
height: 48,
layout: 'horizontal' as const,
padding: [12, 16] as [number, number],
alignItems: 'center' as const,
cornerRadius: 8,
}
})
registerRole('form-input', (_node, _ctx) => ({
width: 'fill_container' as const,
height: 48,
layout: 'horizontal' as const,
padding: [12, 16] as [number, number],
alignItems: 'center' as const,
cornerRadius: 8,
}))
registerRole('search-bar', (_node, _ctx) => ({
layout: 'horizontal' as const,
height: 44,
padding: [10, 16] as [number, number],
gap: 8,
alignItems: 'center' as const,
cornerRadius: 22,
}))
// ---------------------------------------------------------------------------
// Display roles
// ---------------------------------------------------------------------------
registerRole('card', (_node, ctx) => {
if (ctx.parentLayout === 'horizontal') {
return {
width: 'fill_container' as const,
height: 'fill_container' as const,
layout: 'vertical' as const,
gap: 12,
cornerRadius: 12,
clipContent: true,
}
}
return {
layout: 'vertical' as const,
gap: 12,
cornerRadius: 12,
clipContent: true,
}
})
registerRole('stat-card', (_node, ctx) => {
if (ctx.parentLayout === 'horizontal') {
return {
width: 'fill_container' as const,
height: 'fill_container' as const,
layout: 'vertical' as const,
gap: 8,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}
}
return {
layout: 'vertical' as const,
gap: 8,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}
})
registerRole('pricing-card', (_node, ctx) => {
if (ctx.parentLayout === 'horizontal') {
return {
width: 'fill_container' as const,
height: 'fill_container' as const,
layout: 'vertical' as const,
gap: 16,
padding: [32, 24] as [number, number],
cornerRadius: 16,
clipContent: true,
}
}
return {
layout: 'vertical' as const,
gap: 16,
padding: [32, 24] as [number, number],
cornerRadius: 16,
clipContent: true,
}
})
registerRole('image-card', (_node, _ctx) => ({
layout: 'vertical' as const,
gap: 0,
cornerRadius: 12,
clipContent: true,
}))
// ---------------------------------------------------------------------------
// Content roles
// ---------------------------------------------------------------------------
registerRole('hero', (_node, ctx) => ({
layout: 'vertical' as const,
width: 'fill_container' as const,
height: 'fit_content' as const,
padding:
ctx.canvasWidth <= 480
? ([40, 16] as [number, number])
: ([80, 80] as [number, number]),
gap: 24,
alignItems: 'center',
}))
registerRole('feature-grid', (_node, _ctx) => ({
layout: 'horizontal' as const,
width: 'fill_container' as const,
gap: 24,
alignItems: 'start' as const,
}))
registerRole('feature-card', (_node, ctx) => {
if (ctx.parentLayout === 'horizontal') {
return {
width: 'fill_container' as const,
height: 'fill_container' as const,
layout: 'vertical' as const,
gap: 12,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}
}
return {
layout: 'vertical' as const,
gap: 12,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}
})
registerRole('testimonial', (_node, _ctx) => ({
layout: 'vertical' as const,
gap: 16,
padding: [24, 24] as [number, number],
cornerRadius: 12,
}))
registerRole('cta-section', (_node, ctx) => ({
layout: 'vertical' as const,
width: 'fill_container' as const,
height: 'fit_content' as const,
padding:
ctx.canvasWidth <= 480
? ([40, 16] as [number, number])
: ([60, 80] as [number, number]),
gap: 20,
alignItems: 'center',
}))
registerRole('footer', (_node, ctx) => ({
layout: 'vertical' as const,
width: 'fill_container' as const,
height: 'fit_content' as const,
padding:
ctx.canvasWidth <= 480
? ([32, 16] as [number, number])
: ([48, 80] as [number, number]),
gap: 24,
}))
registerRole('stats-section', (_node, ctx) => ({
layout: 'horizontal' as const,
width: 'fill_container' as const,
height: 'fit_content' as const,
padding:
ctx.canvasWidth <= 480
? ([32, 16] as [number, number])
: ([48, 80] as [number, number]),
gap: 32,
justifyContent: 'center' as const,
alignItems: 'center' as const,
}))
// ---------------------------------------------------------------------------
// Media roles
// ---------------------------------------------------------------------------
registerRole('phone-mockup', (_node, _ctx) => ({
width: 280,
height: 560,
cornerRadius: 32,
layout: 'none' as const,
}))
registerRole('screenshot-frame', (_node, _ctx) => ({
cornerRadius: 12,
clipContent: true,
}))
registerRole('avatar', (node, _ctx) => {
const rawWidth = 'width' in node ? node.width : undefined
const size =
typeof rawWidth === 'number' && rawWidth > 0 ? rawWidth : 48
return {
width: size,
height: size,
cornerRadius: Math.round(size / 2),
clipContent: true,
}
})
registerRole('icon', (_node, _ctx) => ({
width: 24,
height: 24,
}))
// ---------------------------------------------------------------------------
// Typography roles
// ---------------------------------------------------------------------------
registerRole('heading', (node, ctx) => {
const text = getTextContentForNode(node)
const isCjk = hasCjkText(text)
return {
lineHeight: isCjk ? 1.35 : 1.2,
letterSpacing: isCjk ? 0 : -0.5,
textGrowth:
ctx.parentLayout === 'vertical'
? ('fixed-width' as const)
: ('auto' as const),
width:
ctx.parentLayout === 'vertical'
? ('fill_container' as const)
: undefined,
}
})
registerRole('subheading', (node, _ctx) => {
const text = getTextContentForNode(node)
const isCjk = hasCjkText(text)
return {
lineHeight: isCjk ? 1.4 : 1.3,
textGrowth: 'fixed-width' as const,
width: 'fill_container' as const,
}
})
registerRole('body-text', (node, _ctx) => {
const text = getTextContentForNode(node)
const isCjk = hasCjkText(text)
return {
lineHeight: isCjk ? 1.6 : 1.5,
textGrowth: 'fixed-width' as const,
width: 'fill_container' as const,
}
})
registerRole('caption', (node, _ctx) => {
const text = getTextContentForNode(node)
const isCjk = hasCjkText(text)
return {
lineHeight: isCjk ? 1.4 : 1.3,
textGrowth: 'auto' as const,
}
})
registerRole('label', (_node, _ctx) => ({
lineHeight: 1.2,
textGrowth: 'auto' as const,
textAlignVertical: 'middle' as const,
}))
// ---------------------------------------------------------------------------
// Table roles
// ---------------------------------------------------------------------------
registerRole('table', (_node, _ctx) => ({
layout: 'vertical' as const,
width: 'fill_container' as const,
gap: 0,
clipContent: true,
}))
registerRole('table-row', (_node, _ctx) => ({
layout: 'horizontal' as const,
width: 'fill_container' as const,
alignItems: 'center' as const,
padding: [12, 16] as [number, number],
}))
registerRole('table-header', (_node, _ctx) => ({
layout: 'horizontal' as const,
width: 'fill_container' as const,
alignItems: 'center' as const,
padding: [12, 16] as [number, number],
}))
registerRole('table-cell', (_node, _ctx) => ({
width: 'fill_container' as const,
}))

View file

@ -1,110 +0,0 @@
import { registerRole } from '../role-resolver'
registerRole('button', (_node, ctx) => {
if (ctx.parentRole === 'navbar') {
return {
padding: [8, 16] as [number, number],
height: 36,
layout: 'horizontal' as const,
gap: 8,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 8,
}
}
if (ctx.parentRole === 'form-group') {
return {
width: 'fill_container' as const,
height: 48,
layout: 'horizontal' as const,
gap: 8,
padding: [12, 24] as [number, number],
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 10,
}
}
return {
padding: [12, 24] as [number, number],
height: 44,
layout: 'horizontal' as const,
gap: 8,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 8,
}
})
registerRole('icon-button', (_node, _ctx) => ({
width: 44,
height: 44,
layout: 'horizontal' as const,
justifyContent: 'center' as const,
alignItems: 'center' as const,
cornerRadius: 8,
}))
registerRole('badge', (_node, _ctx) => ({
layout: 'horizontal' as const,
padding: [6, 12] as [number, number],
gap: 4,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 999,
}))
registerRole('tag', (_node, _ctx) => ({
layout: 'horizontal' as const,
padding: [4, 10] as [number, number],
gap: 4,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 6,
}))
registerRole('pill', (_node, _ctx) => ({
layout: 'horizontal' as const,
padding: [6, 14] as [number, number],
gap: 6,
alignItems: 'center' as const,
justifyContent: 'center' as const,
cornerRadius: 999,
}))
registerRole('input', (_node, ctx) => {
if (ctx.parentLayout === 'vertical') {
return {
width: 'fill_container' as const,
height: 48,
layout: 'horizontal' as const,
padding: [12, 16] as [number, number],
alignItems: 'center' as const,
cornerRadius: 8,
}
}
return {
height: 48,
layout: 'horizontal' as const,
padding: [12, 16] as [number, number],
alignItems: 'center' as const,
cornerRadius: 8,
}
})
registerRole('form-input', (_node, _ctx) => ({
width: 'fill_container' as const,
height: 48,
layout: 'horizontal' as const,
padding: [12, 16] as [number, number],
alignItems: 'center' as const,
cornerRadius: 8,
}))
registerRole('search-bar', (_node, _ctx) => ({
layout: 'horizontal' as const,
height: 44,
padding: [10, 16] as [number, number],
gap: 8,
alignItems: 'center' as const,
cornerRadius: 22,
}))

View file

@ -1,56 +0,0 @@
import { registerRole } from '../role-resolver'
registerRole('section', (_node, ctx) => ({
layout: 'vertical',
width: 'fill_container' as const,
height: 'fit_content' as const,
gap: 24,
padding:
ctx.canvasWidth <= 480
? ([40, 16] as [number, number])
: ([60, 80] as [number, number]),
alignItems: 'center',
}))
registerRole('row', (_node, _ctx) => ({
layout: 'horizontal',
width: 'fill_container' as const,
gap: 16,
alignItems: 'center',
}))
registerRole('column', (_node, _ctx) => ({
layout: 'vertical',
width: 'fill_container' as const,
gap: 16,
}))
registerRole('centered-content', (_node, ctx) => ({
layout: 'vertical',
width: ctx.canvasWidth <= 480 ? ('fill_container' as const) : 1080,
gap: 24,
alignItems: 'center',
}))
registerRole('form-group', (_node, _ctx) => ({
layout: 'vertical',
width: 'fill_container' as const,
gap: 16,
}))
registerRole('spacer', (_node, _ctx) => ({
width: 'fill_container' as const,
height: 40,
}))
registerRole('divider', (node, _ctx) => {
const isVertical = node.name?.toLowerCase().includes('vertical')
if (isVertical) {
return { width: 1, height: 'fill_container' as const, layout: 'none' as const }
}
return {
width: 'fill_container' as const,
height: 1,
layout: 'none' as const,
}
})

View file

@ -1,30 +0,0 @@
import { registerRole } from '../role-resolver'
registerRole('phone-mockup', (_node, _ctx) => ({
width: 280,
height: 560,
cornerRadius: 32,
layout: 'none' as const,
}))
registerRole('screenshot-frame', (_node, _ctx) => ({
cornerRadius: 12,
clipContent: true,
}))
registerRole('avatar', (node, _ctx) => {
const rawWidth = 'width' in node ? node.width : undefined
const size =
typeof rawWidth === 'number' && rawWidth > 0 ? rawWidth : 48
return {
width: size,
height: size,
cornerRadius: Math.round(size / 2),
clipContent: true,
}
})
registerRole('icon', (_node, _ctx) => ({
width: 24,
height: 24,
}))

View file

@ -1,24 +0,0 @@
import { registerRole } from '../role-resolver'
registerRole('navbar', (_node, ctx) => ({
layout: 'horizontal',
width: 'fill_container' as const,
height: ctx.canvasWidth <= 480 ? 56 : 72,
padding:
ctx.canvasWidth <= 480
? ([0, 16] as [number, number])
: ([0, 80] as [number, number]),
alignItems: 'center',
justifyContent: 'space_between' as const,
}))
registerRole('nav-links', (_node, _ctx) => ({
layout: 'horizontal',
gap: 24,
alignItems: 'center',
}))
registerRole('nav-link', (_node, _ctx) => ({
textGrowth: 'auto' as const,
lineHeight: 1.2,
}))

View file

@ -1,26 +0,0 @@
import { registerRole } from '../role-resolver'
registerRole('table', (_node, _ctx) => ({
layout: 'vertical' as const,
width: 'fill_container' as const,
gap: 0,
clipContent: true,
}))
registerRole('table-row', (_node, _ctx) => ({
layout: 'horizontal' as const,
width: 'fill_container' as const,
alignItems: 'center' as const,
padding: [12, 16] as [number, number],
}))
registerRole('table-header', (_node, _ctx) => ({
layout: 'horizontal' as const,
width: 'fill_container' as const,
alignItems: 'center' as const,
padding: [12, 16] as [number, number],
}))
registerRole('table-cell', (_node, _ctx) => ({
width: 'fill_container' as const,
}))

View file

@ -1,54 +0,0 @@
import { registerRole } from '../role-resolver'
import { hasCjkText, getTextContentForNode } from '../generation-utils'
registerRole('heading', (node, ctx) => {
const text = getTextContentForNode(node)
const isCjk = hasCjkText(text)
return {
lineHeight: isCjk ? 1.35 : 1.2,
letterSpacing: isCjk ? 0 : -0.5,
textGrowth:
ctx.parentLayout === 'vertical'
? ('fixed-width' as const)
: ('auto' as const),
width:
ctx.parentLayout === 'vertical'
? ('fill_container' as const)
: undefined,
}
})
registerRole('subheading', (node, _ctx) => {
const text = getTextContentForNode(node)
const isCjk = hasCjkText(text)
return {
lineHeight: isCjk ? 1.4 : 1.3,
textGrowth: 'fixed-width' as const,
width: 'fill_container' as const,
}
})
registerRole('body-text', (node, _ctx) => {
const text = getTextContentForNode(node)
const isCjk = hasCjkText(text)
return {
lineHeight: isCjk ? 1.6 : 1.5,
textGrowth: 'fixed-width' as const,
width: 'fill_container' as const,
}
})
registerRole('caption', (node, _ctx) => {
const text = getTextContentForNode(node)
const isCjk = hasCjkText(text)
return {
lineHeight: isCjk ? 1.4 : 1.3,
textGrowth: 'auto' as const,
}
})
registerRole('label', (_node, _ctx) => ({
lineHeight: 1.2,
textGrowth: 'auto' as const,
textAlignVertical: 'middle' as const,
}))

View file

@ -8,6 +8,7 @@ import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
import { copyFileSync, mkdirSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { vitePluginSkills } from '../../packages/pen-ai-skills/vite-plugin-skills'
const isElectronBuild = process.env.BUILD_TARGET === 'electron'
@ -43,6 +44,7 @@ const config = defineConfig({
},
assetsInclude: ['**/*.wasm'],
plugins: [
vitePluginSkills(fileURLToPath(new URL('../../packages/pen-ai-skills', import.meta.url))),
devtools(),
nitro({
rollupConfig: { external: [/^@sentry\//, 'canvas', 'jsdom', 'cssstyle', 'canvaskit-wasm'] },

View file

@ -98,6 +98,7 @@
"name": "@zseven-w/web",
"version": "0.5.1",
"dependencies": {
"@zseven-w/pen-ai-skills": "workspace:*",
"@zseven-w/pen-codegen": "workspace:*",
"@zseven-w/pen-core": "workspace:*",
"@zseven-w/pen-figma": "workspace:*",
@ -105,6 +106,13 @@
"@zseven-w/pen-types": "workspace:*",
},
},
"packages/pen-ai-skills": {
"name": "@zseven-w/pen-ai-skills",
"version": "0.5.1",
"dependencies": {
"gray-matter": "^4.0.3",
},
},
"packages/pen-codegen": {
"name": "@zseven-w/pen-codegen",
"version": "0.5.1",
@ -790,6 +798,8 @@
"@zseven-w/openpencil": ["@zseven-w/openpencil@workspace:apps/cli"],
"@zseven-w/pen-ai-skills": ["@zseven-w/pen-ai-skills@workspace:packages/pen-ai-skills"],
"@zseven-w/pen-codegen": ["@zseven-w/pen-codegen@workspace:packages/pen-codegen"],
"@zseven-w/pen-core": ["@zseven-w/pen-core@workspace:packages/pen-core"],
@ -1130,6 +1140,8 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
@ -1202,6 +1214,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
"h3": ["h3@2.0.1-rc.18", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.12" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-2EdYEOIJwZHfhfdxvqZsmmUz4tgwzQSuzre+l50j+voHJV4m7j3zw2lYLgHoyfkCF9EAZcaH4ea0zH/hgcs9Yg=="],
"h3-v2": ["h3@2.0.1-rc.16", "", { "dependencies": { "rou3": "^0.8.0", "srvx": "^0.11.9" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-h+pjvyujdo9way8qj6FUbhaQcHlR8FEq65EhTX9ViT5pK8aLj68uFl4hBkF+hsTJAH+H1END2Yv6hTIsabGfag=="],
@ -1264,6 +1278,8 @@
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@ -1318,6 +1334,8 @@
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"kiwi-schema": ["kiwi-schema@0.5.0", "", { "bin": { "kiwic": "cli.js" } }, "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA=="],
"launch-editor": ["launch-editor@2.13.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg=="],
@ -1598,6 +1616,8 @@
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
@ -1650,7 +1670,7 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"srvx": ["srvx@0.11.12", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA=="],
@ -1674,6 +1694,8 @@
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
@ -1968,6 +1990,8 @@
"glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@ -1998,6 +2022,8 @@
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="],
"slice-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@ -2068,6 +2094,8 @@
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"log-symbols/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],

View file

@ -1,6 +1,6 @@
{
"name": "openpencil",
"version": "0.5.1",
"version": "0.5.2",
"description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.",
"author": {
"name": "ZSeven-W",
@ -21,7 +21,7 @@
"build": "cd apps/web && bun --bun vite build",
"preview": "cd apps/web && bun --bun vite preview",
"test": "cd apps/web && bun --bun vitest run --passWithNoTests",
"mcp:compile": "cd apps/web && esbuild src/mcp/server.ts --bundle --platform=node --target=node20 --outfile=../../out/mcp-server.cjs --format=cjs --sourcemap --alias:@=src --alias:@zseven-w/pen-types=../../packages/pen-types/src --alias:@zseven-w/pen-core=../../packages/pen-core/src --alias:@zseven-w/pen-codegen=../../packages/pen-codegen/src --alias:@zseven-w/pen-figma=../../packages/pen-figma/src --alias:@zseven-w/pen-renderer=../../packages/pen-renderer/src --alias:@zseven-w/pen-sdk=../../packages/pen-sdk/src --define:import.meta.env={} --external:canvas --external:paper",
"mcp:compile": "cd apps/web && esbuild src/mcp/server.ts --bundle --platform=node --target=node20 --outfile=../../out/mcp-server.cjs --format=cjs --sourcemap --alias:@=src --alias:@zseven-w/pen-types=../../packages/pen-types/src --alias:@zseven-w/pen-core=../../packages/pen-core/src --alias:@zseven-w/pen-codegen=../../packages/pen-codegen/src --alias:@zseven-w/pen-figma=../../packages/pen-figma/src --alias:@zseven-w/pen-renderer=../../packages/pen-renderer/src --alias:@zseven-w/pen-sdk=../../packages/pen-sdk/src --alias:@zseven-w/pen-ai-skills=../../packages/pen-ai-skills/src --define:import.meta.env={} --external:canvas --external:paper",
"mcp:dev": "bun run apps/web/src/mcp/server.ts",
"electron:dev": "bun run apps/desktop/dev.ts",
"electron:compile": "esbuild apps/desktop/main.ts apps/desktop/preload.ts --bundle --platform=node --target=node20 --outdir=out/desktop --external:electron --format=cjs --out-extension:.js=.cjs --sourcemap",
@ -34,7 +34,9 @@
"cli:dev": "bun run apps/cli/src/index.ts",
"publish:beta": "bash scripts/publish-beta.sh",
"unpublish": "bash scripts/unpublish.sh",
"bump": "sh -c 'V=$0; [ -z \"$V\" ] && echo \"Usage: bun run bump <version>\" && exit 1; for f in package.json apps/*/package.json packages/*/package.json; do [ -f \"$f\" ] && jq --arg v \"$V\" \".version=\\$v\" \"$f\" > \"$f.tmp\" && mv \"$f.tmp\" \"$f\" && echo \"$f → $V\"; done'"
"bump": "sh -c 'V=$0; [ -z \"$V\" ] && echo \"Usage: bun run bump <version>\" && exit 1; for f in package.json apps/*/package.json packages/*/package.json; do [ -f \"$f\" ] && jq --arg v \"$V\" \".version=\\$v\" \"$f\" > \"$f.tmp\" && mv \"$f.tmp\" \"$f\" && echo \"$f → $V\"; done'",
"generate": "bun -e \"import { compileSkills } from './packages/pen-ai-skills/vite-plugin-skills'; compileSkills('./packages/pen-ai-skills')\"",
"postinstall": "bun scripts/patch-srvx-bun.ts && bun run generate"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.47",

1
packages/pen-ai-skills/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
src/_generated/

View file

@ -0,0 +1,23 @@
# pen-ai-skills
AI prompt skill engine for OpenPencil design generation.
## Structure
- `skills/` — Markdown + frontmatter skill files, organized by phase/domain/knowledge
- `src/engine/` — Skill resolution pipeline (loader → resolver → budget)
- `src/memory/` — Document context and generation history persistence
- `vite-plugin-skills.ts` — Build-time compiler: .md → _generated/skill-registry.ts
## Adding a new skill
1. Create a `.md` file in the appropriate `skills/` subdirectory
2. Add YAML frontmatter (name, description, phase, trigger, priority, budget, category)
3. Write prompt content as markdown body
4. Dev server auto-recompiles via HMR
## Testing
```bash
bun --bun vitest run packages/pen-ai-skills/src/__tests__/
```

View file

@ -0,0 +1,23 @@
{
"name": "@zseven-w/pen-ai-skills",
"version": "0.5.2",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./vite-plugin": {
"types": "./vite-plugin-skills.ts",
"import": "./vite-plugin-skills.ts"
}
},
"files": [
"src",
"skills",
"vite-plugin-skills.ts"
],
"dependencies": {
"gray-matter": "^4.0.3"
}
}

View file

@ -0,0 +1,18 @@
---
name: cjk-typography
description: CJK (Chinese/Japanese/Korean) typography rules
phase: [generation]
trigger:
keywords:
- "/[\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af]/"
priority: 25
budget: 500
category: domain
---
CJK TYPOGRAPHY (Chinese/Japanese/Korean):
- Headings: "Noto Sans SC" (Chinese) / "Noto Sans JP" / "Noto Sans KR". NEVER "Space Grotesk"/"Manrope" for CJK.
- Body: "Inter" (system CJK fallback) or "Noto Sans SC".
- CJK lineHeight: headings 1.3-1.4 (NOT 1.1), body 1.6-1.8. letterSpacing: 0, NEVER negative.
- CJK buttons: each char is approximately fontSize wide. Container width >= (charCount x fontSize) + padding.
- Detect CJK from user request language — use CJK fonts for ALL text nodes.

View file

@ -0,0 +1,50 @@
---
name: dashboard
description: Dashboard and admin panel design patterns
phase: [generation]
trigger:
keywords: [dashboard, admin, analytics, data]
priority: 35
budget: 1500
category: domain
---
DASHBOARD DESIGN PATTERNS:
STRUCTURE:
- Root frame: width=1200, height=0, layout="horizontal" (sidebar + main content)
- Sidebar: width=240-280, height="fill_container", layout="vertical", dark or surface fill
- Main content: width="fill_container", layout="vertical", gap=16-24
SIDEBAR:
- Logo/brand at top, padding=[24,16]
- Navigation items: frame(layout="horizontal", gap=12, alignItems="center", padding=[10,16]) > icon_font + text
- Active item: accent background or left border indicator
- Section dividers between nav groups
- User/settings at bottom
TOP BAR:
- height=56-64, padding=[0,24], layout="horizontal", justifyContent="space_between"
- Left: page title or breadcrumbs
- Right: search bar + notification icon + user avatar
METRICS ROW:
- Horizontal layout with 3-4 stat-cards, each width="fill_container"
- Each card: icon + metric value (28-36px, bold) + label (14px, muted) + optional trend indicator
- padding=[20,24], gap=8, cornerRadius=12
CHART SECTIONS:
- Cards with header (title + filter/period selector) + chart area placeholder
- Chart area: colored rectangle with rounded corners as placeholder
- width="fill_container", cornerRadius=12
DATA TABLES:
- Table header: background fill, bold text, padding=[12,16]
- Table rows: alternating subtle backgrounds, consistent column widths
- Status badges: pill-shaped with semantic colors (green=active, amber=pending, red=error)
- All cells use width="fill_container"
SPACING:
- Main content padding=[24,24], gap=16-24
- Cards: padding=[20,24], gap=12-16
- Consistent 12px cornerRadius across cards

View file

@ -0,0 +1,23 @@
---
name: form-ui
description: Form, input, and interactive element design guidelines
phase: [generation]
trigger:
keywords: [form, input, login, signup, sign up, register, password, email, 搜索, 表单, 登录, 注册, mobile, phone, 手机, 移动端, app screen, ios, android, button, card, nav, navigation, mockup, 按钮, 卡片, 导航, 模型]
priority: 30
budget: 1500
category: domain
---
DESIGN GUIDELINES:
- Mobile: 375x812. Web: 1200x800 (single) or 1200x3000-5000 (landing page).
- "mobile"/"移动端" + screen type = ACTUAL 375x812 screen, NOT desktop with phone mockup.
- Buttons: height 44-52px, cornerRadius 8-12, padding [12, 24]. Icon+text: layout="horizontal", gap=8.
- Icon-only buttons: 44x44, justifyContent/alignItems="center", path icon 20-24px.
- Inputs: height 44px, light bg, subtle border, width="fill_container" in forms.
- Cards: cornerRadius 12-16, clipContent: true, subtle shadows.
- CARD ROW ALIGNMENT: sibling cards in horizontal layout ALL use width/height="fill_container".
- Navigation: justifyContent="space_between", 3 groups (logo | links | CTA), padding=[0,80].
- Phone mockup: ONE "frame", width 260-300, height 520-580, cornerRadius 32. NEVER ellipse.
- NEVER use ellipse for decorative shapes. Use frame/rectangle with cornerRadius.
- NEVER use emoji as icons. Use path nodes with Feather icon names.

View file

@ -0,0 +1,54 @@
---
name: landing-page
description: Landing page and marketing site design patterns
phase: [generation]
trigger:
keywords: [landing, marketing, hero, homepage]
priority: 35
budget: 1500
category: domain
---
LANDING PAGE DESIGN PATTERNS:
STRUCTURE:
- Navigation - Hero - Features - Social Proof - CTA - Footer
- Each section: width="fill_container", height="fit_content", layout="vertical"
- Root frame: width=1200, height=0 (auto-expands), gap=0
NAVIGATION:
- justifyContent="space_between", 3 groups: logo | nav-links | CTA button
- padding=[0,80], alignItems="center", height 64-80px
- Links evenly distributed in center group
HERO SECTION:
- padding=[80,80] or larger, generous whitespace
- ONE headline (40-56px), ONE subtitle (16-18px), ONE CTA button
- Optional visual: phone mockup or illustration on the right (two-column horizontal layout)
- Every extra element dilutes focus — keep it minimal
FEATURE SECTIONS:
- Section title + 3-4 feature cards in horizontal layout
- Cards: width="fill_container", height="fill_container" for even row alignment
- Alternate section backgrounds (#FFFFFF / #F8FAFC) for natural separation
- Section vertical padding: 80-120px
SOCIAL PROOF:
- Testimonials: card with quote + avatar + name/title
- Stats: horizontal row with stat-cards (number + label)
- Logos: horizontal row of company logos
CTA SECTION:
- Centered content, compelling headline, accent background or gradient
- Single prominent button
FOOTER:
- Multi-column layout: brand + link groups + social
- Muted colors, smaller text
- padding=[48,80]
GENERAL:
- Centered content container ~1040-1160px across sections for alignment stability
- Consistent cornerRadius (12-16px for cards)
- clipContent: true on cards with images
- Subtle shadows on cards

View file

@ -0,0 +1,59 @@
---
name: mobile-app
description: Mobile app screen design patterns (375x812)
phase: [generation]
trigger:
keywords: [mobile, app, phone, ios, android]
priority: 35
budget: 1500
category: domain
---
MOBILE APP DESIGN PATTERNS:
VIEWPORT:
- Root frame: width=375, height=812 (fixed viewport)
- This is an ACTUAL mobile screen, NOT a desktop page with phone mockup
- "mobile"/"移动端"/"手机" + screen type = direct 375x812 screen
STATUS BAR:
- height=44, padding=[0,16], layout="horizontal", alignItems="center"
- Time, signal, battery indicators (text nodes, 12-13px)
HEADER:
- height=56-64, padding=[0,16], layout="horizontal"
- justifyContent="space_between", alignItems="center"
- Back arrow icon + title text + optional action icon
CONTENT AREA:
- padding=[0,16] or [16,16], gap=16-20
- layout="vertical", width="fill_container"
- Scroll-friendly: content flows vertically
TAB BAR (bottom navigation):
- height=80-84 (includes safe area), padding=[8,0,28,0]
- layout="horizontal", justifyContent="space_around", alignItems="center"
- Each tab: frame(layout="vertical", gap=4, alignItems="center") > icon_font + text(10-11px)
- Active tab: accent color, inactive: muted gray
FORM SCREENS (login, signup, settings):
- All inputs width="fill_container", height=48, gap=16
- Primary button width="fill_container", height=48
- Section gap=20-24
CARDS ON MOBILE:
- Full width: width="fill_container", cornerRadius=12-16
- padding=[16,16], gap=12
- Swipeable card rows: horizontal layout with fixed-width cards
LIST ITEMS:
- layout="horizontal", padding=[12,16], gap=12, alignItems="center"
- Leading: avatar/icon (40-48px)
- Content: vertical stack (title 16px + subtitle 14px muted)
- Trailing: chevron-right icon or status indicator
SPACING:
- Touch targets: minimum 44x44px
- Padding: 16px horizontal, 12-16px vertical
- Section gaps: 20-24px
- Safe area bottom: 28px padding

View file

@ -0,0 +1,16 @@
---
name: copywriting
description: Concise copywriting rules for design text content
phase: [generation]
trigger:
keywords: [landing, marketing, hero, website, 官网, 首页, 产品页, copy, text, headline, content, 文案, 标题, 内容]
priority: 40
budget: 1000
category: knowledge
---
COPYWRITING:
- Headlines: 2-6 words. Subtitles: 1 sentence <=15 words.
- Feature titles: 2-4 words. Descriptions: 1 sentence <=20 words.
- Buttons: 1-3 words. Card text: <=2 sentences. Stats: number + 1-3 word label.
- NEVER 3+ sentence paragraphs. Distill to essence. Power words > vague adjectives.

View file

@ -1,32 +1,20 @@
/**
* Design principles — condensed design knowledge for AI sub-agents.
*
* Previously loaded 5 individual principle files (~7400 chars total).
* Now uses a single condensed version (~1000 chars) since the SUB_AGENT_PROMPT
* already contains essential design rules — principles only add high-value guidance.
*/
---
name: design-principles
description: Core design craft principles for high-quality output
phase: [generation]
trigger: null
priority: 25
budget: 1000
category: knowledge
---
const CONDENSED_PRINCIPLES = `DESIGN CRAFT:
DESIGN CRAFT:
- Type scale with real contrast: display 48-64, heading 28-36, body 16. Weight: 700 titles, 500 subtitles, 400 body.
- Line height: tighter at large sizes (1.05-1.15 for 40px+), looser at small (1.5-1.6 for 16px).
- Palette: 1 primary action color, 1 accent, neutral scale. Max 2 saturated colors. Page bg slightly tinted (#F8FAFC not #FFFFFF).
- Contrast law: WCAG AA (4.5:1 body, 3:1 large text). Text: primary #0F172A for headings, muted #475569 for body.
- 8px grid spacing: related items 8-16px, groups 24-32px, sections 48-80px. Section padding 80-120px vertical.
- Hero: ONE headline, ONE subtitle, ONE CTA, optional visual. Every extra element dilutes focus.
- Cards: consistent cornerRadius/padding/shadow. Content: image → title → description → action.
- Cards: consistent cornerRadius/padding/shadow. Content: image - title - description - action.
- Nav: logo + 3-5 links + CTA. space_between distribution. Keep minimal.
- Alternate section backgrounds (white/#F8FAFC) for natural separation.`
/**
* Get all design principles combined.
*/
export function getAllPrinciples(): string {
return CONDENSED_PRINCIPLES
}
// Re-export individual principles for direct access
export { TYPOGRAPHY_PRINCIPLES } from './typography'
export { COLOR_PRINCIPLES } from './color'
export { SPACING_PRINCIPLES } from './spacing'
export { COMPOSITION_PRINCIPLES } from './composition'
export { COMPONENT_PRINCIPLES } from './components'
- Alternate section backgrounds (white/#F8FAFC) for natural separation.

View file

@ -0,0 +1,18 @@
---
name: examples
description: Reference examples for common UI components
phase: [generation]
trigger:
keywords: [example, sample, show me, how to, 示例, 样例, 怎么]
priority: 50
budget: 1000
category: knowledge
---
EXAMPLES:
Button:
{ "id":"btn-1","type":"frame","role":"button","width":180,"cornerRadius":8,"fill":[{"type":"solid","color":"#3B82F6"}],"children":[{"id":"btn-icon","type":"path","name":"ArrowRightIcon","role":"icon","d":"M5 12h14m-7-7 7 7-7 7","width":20,"height":20,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#FFF"}]}},{"id":"btn-text","type":"text","role":"label","content":"Continue","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFF"}]}] }
Card:
{ "id":"card-1","type":"frame","role":"card","width":320,"height":340,"fill":[{"type":"solid","color":"#FFF"}],"effects":[{"type":"shadow","offsetX":0,"offsetY":4,"blur":12,"spread":0,"color":"rgba(0,0,0,0.1)"}],"children":[{"id":"card-img","type":"image","width":"fill_container","height":180},{"id":"card-body","type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":20,"gap":8,"children":[{"id":"card-title","type":"text","role":"heading","content":"Title","fontSize":20,"fontWeight":700,"fill":[{"type":"solid","color":"#111827"}]},{"id":"card-desc","type":"text","role":"body-text","content":"Description","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}]}] }

View file

@ -0,0 +1,37 @@
---
name: icon-catalog
description: Icon usage rules and available icon names
phase: [generation]
trigger: null
priority: 20
budget: 1000
category: base
---
ICONS:
- Use "path" nodes, size 16-24px. ONLY use Feather icon names — PascalCase + "Icon" suffix (e.g. "SearchIcon").
- System auto-resolves names to SVG paths. "d" is replaced automatically.
- NEVER use emoji as icons. Use icon_font nodes for lucide icons.
ICON_FONT NODES:
- Use icon_font type with iconFontName for lucide icons (e.g. iconFontName="search", "bell", "user").
- Sizes: 14/20/24px. Fill can be a color string.
- Icon-only buttons: frame(w=44, h=44, layout=none) > icon_font(x=12, y=12)
COMMON LUCIDE ICON NAMES:
search, bell, user, heart, star, plus, x, check, chevron-right, chevron-left, chevron-down, chevron-up,
settings, home, mail, phone, calendar, clock, map-pin, link, external-link,
eye, eye-off, lock, unlock, key, shield,
arrow-right, arrow-left, arrow-up, arrow-down, arrow-up-right,
menu, more-horizontal, more-vertical, filter, sliders,
image, camera, video, file, folder, download, upload, share, copy, trash,
edit, pen-tool, type, bold, italic, underline, align-left, align-center, align-right,
grid, list, layout, columns, maximize, minimize,
sun, moon, cloud, zap, activity, trending-up, trending-down, bar-chart, pie-chart,
users, user-plus, user-check, message-circle, message-square, send,
shopping-cart, shopping-bag, credit-card, dollar-sign, gift, tag, bookmark,
play, pause, skip-forward, skip-back, volume-2, mic,
github, twitter, instagram, facebook, linkedin, youtube,
globe, wifi, bluetooth, monitor, smartphone, tablet, cpu, database, server, hard-drive,
code, terminal, git-branch, git-commit, git-pull-request,
alert-circle, alert-triangle, info, help-circle, check-circle, x-circle

View file

@ -0,0 +1,72 @@
---
name: role-definitions
description: Semantic role system with default property values
phase: [generation]
trigger:
keywords: [landing, marketing, hero, website, 官网, 首页, 产品页, table, grid, 表格, 表头, dashboard, 数据, admin, testimonial, pricing, footer, stats, 评价, 定价, 页脚, 数据统计]
priority: 35
budget: 2000
category: knowledge
---
SEMANTIC ROLES (add "role" to nodes — system fills unset props based on role):
Layout roles:
- section: layout=vertical, width=fill_container, height=fit_content, gap=24, padding=[60,80] (mobile: [40,16]), alignItems=center
- row: layout=horizontal, width=fill_container, gap=16, alignItems=center
- column: layout=vertical, width=fill_container, gap=16
- centered-content: layout=vertical, width=1080 (mobile: fill_container), gap=24, alignItems=center
- form-group: layout=vertical, width=fill_container, gap=16
- divider: width=fill_container, height=1, layout=none (vertical divider: width=1, height=fill_container)
- spacer: width=fill_container, height=40
Navigation roles:
- navbar: layout=horizontal, width=fill_container, height=72 (mobile: 56), padding=[0,80] (mobile: [0,16]), alignItems=center, justifyContent=space_between
- nav-links: layout=horizontal, gap=24, alignItems=center
- nav-link: textGrowth=auto, lineHeight=1.2
Interactive roles:
- button: padding=[12,24], height=44, layout=horizontal, gap=8, alignItems=center, justifyContent=center, cornerRadius=8. In navbar: padding=[8,16], height=36. In form-group: width=fill_container, height=48, cornerRadius=10
- icon-button: width=44, height=44, layout=horizontal, justifyContent=center, alignItems=center, cornerRadius=8
- badge: layout=horizontal, padding=[6,12], gap=4, alignItems=center, justifyContent=center, cornerRadius=999
- tag: layout=horizontal, padding=[4,10], gap=4, alignItems=center, justifyContent=center, cornerRadius=6
- pill: layout=horizontal, padding=[6,14], gap=6, alignItems=center, justifyContent=center, cornerRadius=999
- input: height=48, layout=horizontal, padding=[12,16], alignItems=center, cornerRadius=8. In vertical layout: width=fill_container
- form-input: width=fill_container, height=48, layout=horizontal, padding=[12,16], alignItems=center, cornerRadius=8
- search-bar: layout=horizontal, height=44, padding=[10,16], gap=8, alignItems=center, cornerRadius=22
Display roles:
- card: layout=vertical, gap=12, cornerRadius=12, clipContent=true. In horizontal layout: width=fill_container, height=fill_container
- stat-card: layout=vertical, gap=8, padding=[24,24], cornerRadius=12. In horizontal layout: width=fill_container, height=fill_container
- pricing-card: layout=vertical, gap=16, padding=[32,24], cornerRadius=16, clipContent=true. In horizontal layout: width=fill_container, height=fill_container
- image-card: layout=vertical, gap=0, cornerRadius=12, clipContent=true
- feature-card: layout=vertical, gap=12, padding=[24,24], cornerRadius=12. In horizontal layout: width=fill_container, height=fill_container
Media roles:
- phone-mockup: width=280, height=560, cornerRadius=32, layout=none
- screenshot-frame: cornerRadius=12, clipContent=true
- avatar: width/height=48, cornerRadius=24, clipContent=true (size adapts to explicit width)
- icon: width=24, height=24
Typography roles:
- heading: lineHeight=1.2 (CJK: 1.35), letterSpacing=-0.5 (CJK: 0). In vertical layout: textGrowth=fixed-width, width=fill_container
- subheading: lineHeight=1.3 (CJK: 1.4), textGrowth=fixed-width, width=fill_container
- body-text: lineHeight=1.5 (CJK: 1.6), textGrowth=fixed-width, width=fill_container
- caption: lineHeight=1.3 (CJK: 1.4), textGrowth=auto
- label: lineHeight=1.2, textGrowth=auto, textAlignVertical=middle
Content roles:
- hero: layout=vertical, width=fill_container, height=fit_content, padding=[80,80] (mobile: [40,16]), gap=24, alignItems=center
- feature-grid: layout=horizontal, width=fill_container, gap=24, alignItems=start
- testimonial: layout=vertical, gap=16, padding=[24,24], cornerRadius=12
- cta-section: layout=vertical, width=fill_container, height=fit_content, padding=[60,80] (mobile: [40,16]), gap=20, alignItems=center
- footer: layout=vertical, width=fill_container, height=fit_content, padding=[48,80] (mobile: [32,16]), gap=24
- stats-section: layout=horizontal, width=fill_container, height=fit_content, padding=[48,80] (mobile: [32,16]), gap=32, justifyContent=center, alignItems=center
Table roles:
- table: layout=vertical, width=fill_container, gap=0, clipContent=true
- table-row: layout=horizontal, width=fill_container, alignItems=center, padding=[12,16]
- table-header: layout=horizontal, width=fill_container, alignItems=center, padding=[12,16]
- table-cell: width=fill_container
Your explicit props ALWAYS override role defaults. Only unset properties get filled in.

View file

@ -1,12 +1,14 @@
/**
* Prompts for HTML/CSS design code generation (Stage 1 of visual reference pipeline).
*
* These prompts leverage the model's strongest design capability — HTML/CSS generation —
* to produce a high-fidelity visual reference. The generated HTML serves as a blueprint
* that is later converted to PenNode format.
*/
---
name: design-code
description: HTML/CSS design code generation for visual reference
phase: [generation]
trigger: null
priority: 20
budget: 1000
category: base
---
export const DESIGN_CODE_SYSTEM_PROMPT = `You are a world-class frontend designer. Generate a SINGLE self-contained HTML file that looks production-grade.
You are a world-class frontend designer. Generate a SINGLE self-contained HTML file that looks production-grade.
OUTPUT RULES:
- Output ONLY the complete HTML file, starting with <!DOCTYPE html>. No explanation.
@ -41,27 +43,4 @@ TEXT CONTENT:
- Subtitles: 1 sentence, max 15 words.
- Feature descriptions: 1 sentence, max 20 words.
- Button text: 1-3 words.
- Never use lorem ipsum or generic "Your text here" placeholders.`
/**
* Build the user prompt for HTML/CSS code generation.
* Includes the design system tokens and viewport constraints.
*/
export function buildCodeGenUserPrompt(
userPrompt: string,
designSystemContext: string,
width: number,
height: number,
): string {
const heightInstruction = height > 0
? `Height: ${height}px (fixed viewport).`
: `Height: auto (content determines height, estimate based on sections).`
return `Design request: ${userPrompt}
Viewport: Width ${width}px. ${heightInstruction}
${designSystemContext}
Generate the complete HTML file now.`
}
- Never use lorem ipsum or generic "Your text here" placeholders.

View file

@ -0,0 +1,13 @@
---
name: design-md
description: Custom design system from design.md specification
phase: [generation]
trigger:
flags: [hasDesignMd]
priority: 5
budget: 2000
category: base
---
DESIGN SYSTEM (design.md — follow these rules for visual consistency):
{{designMdContent}}

View file

@ -1,9 +1,14 @@
/**
* Prompts for the design system generator (Stage 0 of visual reference pipeline).
* Produces structured design tokens from a user's design request.
*/
---
name: design-system
description: Design system token generation from product descriptions
phase: [generation]
trigger: null
priority: 20
budget: 1000
category: base
---
export const DESIGN_SYSTEM_PROMPT = `You are a design system architect. Given a product description, create a cohesive design token system.
You are a design system architect. Given a product description, create a cohesive design token system.
Output ONLY a JSON object, no explanation.
{
@ -31,7 +36,7 @@ Output ONLY a JSON object, no explanation.
}
RULES:
- Match colors to the product personality: tech/SaaS → cool blue/indigo, creative → warm amber/coral, finance → deep navy/emerald, health → sage/teal, education → violet/sky.
- Match colors to the product personality: tech/SaaS - cool blue/indigo, creative - warm amber/coral, finance - deep navy/emerald, health - sage/teal, education - violet/sky.
- Ensure WCAG AA contrast (4.5:1) between text and background, primary and surface.
- Font pairing: heading should be distinctive (Space Grotesk, Outfit, Sora, Plus Jakarta Sans, Clash Display), body should be readable (Inter, DM Sans, Satoshi). Max 2 families.
- CJK content: if the request is in Chinese/Japanese/Korean, use "Noto Sans SC"/"Noto Sans JP"/"Noto Sans KR" for heading, "Inter" for body. Never use display fonts without CJK glyphs.
@ -39,4 +44,4 @@ RULES:
- Default to light theme unless explicitly asked for dark.
- Radius: 0-4 for sharp/professional, 8-12 for modern, 16+ for playful/friendly.
- Scale should have clear size jumps: [14, 16, 20, 28, 40, 56] not [14, 15, 16, 17, 18].
- Aesthetic description guides the overall feel: "clean minimal blue tech", "warm editorial amber", "bold dark neon gaming".`
- Aesthetic description guides the overall feel: "clean minimal blue tech", "warm editorial amber", "bold dark neon gaming".

View file

@ -0,0 +1,50 @@
---
name: jsonl-format-simplified
description: Simplified nested JSON format for basic tier models
phase: [generation]
trigger:
flags: [isBasicTier]
priority: 0
budget: 1500
category: base
---
Generate a UI section as a nested JSON tree. Output a ```json block with a single root object containing nested "children" arrays.
TYPES:
frame (width,height,layout,gap,padding,justifyContent,alignItems,cornerRadius,fill,children), rectangle (width,height,cornerRadius,fill), text (content,fontFamily,fontSize,fontWeight,fill,width,textAlign), icon_font (iconFontName,width,height,fill)
SHARED: id, type, name
RULES:
- Root: type="frame", width="fill_container", height="fit_content", layout="vertical".
- Children go in "children" arrays. No x/y on layout children.
- width/height: number | "fill_container" | "fit_content".
- fill: [{"type":"solid","color":"#hex"}].
- Text: never set height. Use width="fill_container" for wrapping text.
- Icons: use icon_font with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings). Sizes: 16/20/24px.
- Buttons: frame with padding=[12,24] containing a text child.
- No emoji characters. No markdown. No explanation. No tool calls.
EXAMPLE:
```json
{
"id": "root",
"type": "frame",
"name": "Hero",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"gap": 24,
"padding": [48, 24],
"fill": [{"type": "solid", "color": "#F8FAFC"}],
"children": [
{"id": "title", "type": "text", "name": "Headline", "content": "Learn Smarter", "fontSize": 48, "fontWeight": 700, "fontFamily": "Space Grotesk", "fill": [{"type": "solid", "color": "#0F172A"}]},
{"id": "desc", "type": "text", "name": "Description", "content": "AI-powered learning", "fontSize": 16, "width": "fill_container", "fill": [{"type": "solid", "color": "#64748B"}]},
{"id": "cta", "type": "frame", "name": "CTA", "padding": [14, 28], "cornerRadius": 10, "justifyContent": "center", "fill": [{"type": "solid", "color": "#2563EB"}], "children": [
{"id": "cta-text", "type": "text", "content": "Get Started", "fontSize": 16, "fontWeight": 600, "fill": [{"type": "solid", "color": "#FFFFFF"}]}
]}
]
}
```
CRITICAL: You are a JSON generator, NOT a code assistant. Output ONLY the ```json block. Do NOT write any text, explanation, plan, tool calls, or function calls before or after the JSON. Do NOT use [TOOL_CALL], {tool => ...}, or any tool/function invocation syntax. Start your response with ```json immediately.

View file

@ -0,0 +1,49 @@
---
name: jsonl-format
description: Sub-agent flat JSONL output format with node types and rules
phase: [generation]
trigger: null
priority: 0
budget: 1500
category: base
---
PenNode flat JSONL engine. Output a ```json block with ONE node per line.
TYPES:
frame (width,height,layout,gap,padding,justifyContent,alignItems,clipContent,cornerRadius,fill,stroke,effects), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,textAlign,textGrowth,lineHeight,letterSpacing), icon_font (iconFontName,width,height,fill), path (d,width,height,fill,stroke), image (width,height,imageSearchQuery,imagePrompt). imagePrompt: describe subject+scene+style, NEVER mention background type (transparent/white/plain). Match composition to aspect ratio.
SHARED: id, type, name, role, x, y, opacity
ROLES: section, row, column, divider | navbar, button, icon-button, badge, input, search-bar | card, stat-card, pricing-card, feature-card | heading, subheading, body-text, caption, label | table, table-row, table-header
width/height: number | "fill_container" | "fit_content". padding: number | [v,h] | [T,R,B,L]. Fill=[{"type":"solid","color":"#hex"}].
Stroke: {"thickness":N,"fill":[{"type":"solid","color":"#hex"}]}. Directional: {"thickness":{"bottom":1},"fill":[...]}.
RULES:
- Section root: width="fill_container", height="fit_content", layout="vertical".
- No x/y on children in layout frames. All nodes descend from section root.
- Width consistency: siblings in vertical layout use the SAME width strategy.
- Never "fill_container" inside "fit_content" parent.
- clipContent: true on cards with cornerRadius + image children.
- Text: NEVER set height. Short text (titles, labels, buttons) — omit textGrowth. Long text (>15 chars wrapping) — textGrowth="fixed-width", width="fill_container", lineHeight=1.4-1.6.
- lineHeight: Display 40-56px - 0.9-1.0. Heading 20-36px - 1.0-1.2. Body - 1.4-1.6. letterSpacing: -0.5 to -1 for headlines, 1-3 for uppercase.
- Icons: ALWAYS use icon_font nodes with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings, etc). Sizes: 14/20/24px. NEVER use emoji characters as icon substitutes — they cannot render on canvas.
- CJK fonts: "Noto Sans SC"/"Noto Sans JP"/"Noto Sans KR" for headings. CJK lineHeight: 1.3-1.4 headings, 1.6-1.8 body.
- Buttons: frame(padding=[12,24], justifyContent="center") > text. Icon+text: frame(layout="horizontal", gap=8, alignItems="center", padding=[8,16]).
- Card rows: ALL cards width="fill_container" + height="fill_container".
- FORMS: ALL inputs AND button use width="fill_container". gap=16-20.
- Phone mockup: ONE frame, w=260-300, h=520-580, cornerRadius=32, solid fill + 1px stroke.
- Z-order: Earlier siblings render on top. Overlay elements (badges, indicators, floating buttons) MUST come BEFORE the content they overlap.
FORMAT: _parent (null=root, else parent-id). Parent before children.
```json
{"_parent":null,"id":"root","type":"frame","name":"Hero","width":"fill_container","height":"fit_content","layout":"vertical","gap":24,"padding":[48,24],"fill":[{"type":"solid","color":"#F8FAFC"}]}
{"_parent":"root","id":"header","type":"frame","name":"Header","justifyContent":"space_between","alignItems":"center","width":"fill_container"}
{"_parent":"header","id":"logo","type":"text","name":"Logo","content":"ACME","fontSize":18,"fontWeight":600,"fontFamily":"Space Grotesk","fill":[{"type":"solid","color":"#0D0D0D"}]}
{"_parent":"header","id":"notifBtn","type":"frame","name":"Notification","width":44,"height":44}
{"_parent":"notifBtn","id":"notifIcon","type":"icon_font","name":"Bell","iconFontName":"bell","width":20,"height":20,"fill":"#0D0D0D","x":12,"y":12}
{"_parent":"root","id":"title","type":"text","name":"Headline","content":"Learn Smarter","fontSize":48,"fontWeight":700,"fontFamily":"Space Grotesk","lineHeight":0.95,"fill":[{"type":"solid","color":"#0F172A"}]}
{"_parent":"root","id":"desc","type":"text","name":"Description","content":"AI-powered vocabulary learning that adapts to your pace","fontSize":16,"textGrowth":"fixed-width","width":"fill_container","lineHeight":1.5,"fill":[{"type":"solid","color":"#64748B"}]}
{"_parent":"root","id":"cta","type":"frame","name":"CTA Button","padding":[14,28],"cornerRadius":10,"justifyContent":"center","fill":[{"type":"solid","color":"#2563EB"}]}
{"_parent":"cta","id":"cta-text","type":"text","name":"CTA Label","content":"Get Started","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}
```
CRITICAL: Output ONLY the ```json block. Do NOT write any text, explanation, plan, tool calls, or function calls. Do NOT use [TOOL_CALL] or {tool => ...} syntax. Start your response with ```json immediately.

View file

@ -0,0 +1,23 @@
---
name: layout
description: Auto-layout engine rules (flexbox-based positioning)
phase: [generation]
trigger: null
priority: 10
budget: 1500
category: base
---
LAYOUT ENGINE (flexbox-based):
- Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems.
- NEVER set x/y on children inside layout containers.
- CHILD SIZE RULE: child width must be <= parent content area. Use "fill_container" when in doubt.
- In vertical layout: "fill_container" width stretches horizontally. In horizontal: fills remaining space.
- CLIP CONTENT: clipContent: true clips overflowing children. ALWAYS use on cards with cornerRadius + image.
- justifyContent: "space_between" (navbars), "center", "start"/"end", "space_around".
- WIDTH CONSISTENCY: siblings must use same width strategy. Don't mix fixed-px and fill_container.
- NEVER use "fill_container" on children of "fit_content" parent — circular dependency.
- Two-column: horizontal frame - two child frames each "fill_container" width.
- Keep hierarchy shallow: no pointless wrappers. Only use wrappers with visual purpose (fill, padding).
- Section root: width="fill_container", height="fit_content", layout="vertical".
- FORMS: ALL inputs AND primary button MUST use width="fill_container". Vertical layout, gap=16-20.

View file

@ -0,0 +1,15 @@
---
name: overflow
description: Overflow prevention rules for text and child sizing
phase: [generation]
trigger: null
priority: 16
budget: 500
category: base
---
OVERFLOW PREVENTION (CRITICAL):
- Text in vertical layout: width="fill_container" + textGrowth="fixed-width". In horizontal: width="fit_content".
- NEVER set fixed pixel width on text inside layout frames (e.g. width:378 in 195px card - overflows!).
- Fixed-width children must be <= parent content area (parent width - padding).
- Badges: short labels only (CJK <=8 chars / Latin <=16 chars).

View file

@ -0,0 +1,24 @@
---
name: schema
description: PenNode type definitions and property schemas
phase: [generation]
trigger: null
priority: 0
budget: 2000
category: base
---
PenNode types (the ONLY format you output for designs):
- frame: Container. Props: width, height, layout ('none'|'vertical'|'horizontal'), gap, padding, justifyContent ('start'|'center'|'end'|'space_between'|'space_around'), alignItems ('start'|'center'|'end'), clipContent (boolean), children[], cornerRadius, fill, stroke, effects
- rectangle: Props: width, height, cornerRadius, fill, stroke, effects
- ellipse: Props: width, height, fill, stroke, effects
- text: Props: content, fontFamily, fontSize, fontWeight, fontStyle ('normal'|'italic'), fill, width, height, textAlign, textGrowth ('auto'|'fixed-width'|'fixed-width-height'), lineHeight (multiplier), letterSpacing (px), textAlignVertical ('top'|'middle'|'bottom')
- path: SVG icon. Props: d (SVG path), width, height, fill, stroke, effects
- image: Props: width, height, cornerRadius, effects, imageSearchQuery (2-3 English keywords)
All nodes share: id, type, name, role, x, y, rotation, opacity
Fill = [{ type: "solid", color: "#hex" }] or [{ type: "linear_gradient", angle, stops: [{ offset, color }] }]
Stroke = { thickness, fill: [...] } Effects = [{ type: "shadow", offsetX, offsetY, blur, spread, color }]
SIZING: width/height accept number (px), "fill_container", or "fit_content".
PADDING: number (uniform), [v, h], or [top, right, bottom, left].
cornerRadius is a number. fill is ALWAYS an array. Do NOT set x/y on children inside layout frames.

View file

@ -0,0 +1,23 @@
---
name: style-defaults
description: Visual style policy — palettes, typography scale, shapes
phase: [generation]
trigger: null
priority: 5
budget: 1500
category: base
---
VISUAL STYLE POLICY:
- Default to clean light marketing style unless user explicitly asks for dark/cyber/terminal.
DEFAULT LIGHT PALETTE:
- Page Bg: #F8FAFC, Surface: #FFFFFF, Text: #0F172A, Secondary: #475569
- Accent: #2563EB, Accent2: #0EA5E9, Border: #E2E8F0
TYPOGRAPHY SCALE:
- Display: 40-56px — "Space Grotesk"/"Manrope" (700), lineHeight 1.1
- Heading: 28-36px — "Space Grotesk"/"Manrope" (600-700), lineHeight 1.2
- Subheading: 20-24px — "Inter" (600), lineHeight 1.3
- Body: 16-18px — "Inter" (400-500), lineHeight 1.5
- Caption: 13-14px — "Inter" (400), lineHeight 1.4
SHAPES: cornerRadius 8-14. Subtle shadows. Clear hierarchy via spacing and contrast.
LANDING PAGES: hero 80-120px padding, alternate section backgrounds, cards cornerRadius 12-16, centered content ~1040-1160px.

View file

@ -0,0 +1,17 @@
---
name: text-rules
description: Text sizing, typography, and wrapping rules
phase: [generation]
trigger: null
priority: 15
budget: 1000
category: base
---
TEXT RULES:
- Body/description in vertical layout: width="fill_container" + textGrowth="fixed-width" (wraps text, auto-sizes height).
- Short labels in horizontal rows: width="fit_content" + textGrowth="auto". Prevents squeezing siblings.
- NEVER fixed pixel width on text inside layout frames — causes overflow.
- Text >15 chars MUST have textGrowth="fixed-width". NEVER set explicit pixel height on text nodes — OMIT height.
- Typography: Display 40-56px, Heading 28-36px, Subheading 20-24px, Body 16-18px, Caption 13-14px.
- lineHeight: headings 1.1-1.2, body 1.4-1.6. letterSpacing: -0.5 for headlines, 0.5-2 for uppercase.

View file

@ -0,0 +1,15 @@
---
name: variables
description: Design variable reference rules ($variableName syntax)
phase: [generation]
trigger:
flags: [hasVariables]
priority: 45
budget: 500
category: base
---
DESIGN VARIABLES:
- When document has variables, use "$variableName" references instead of hardcoded values.
- Color: [{ "type": "solid", "color": "$primary" }]. Number: "gap": "$spacing-md".
- Only reference listed variables — do NOT invent names.

View file

@ -0,0 +1,41 @@
---
name: incremental-add
description: Rules for adding new elements to existing designs
phase: [maintenance]
trigger:
keywords: [add, insert, new section, append]
priority: 20
budget: 1500
category: domain
---
INCREMENTAL ADDITION RULES:
When adding new elements to an existing design:
CONTEXT AWARENESS:
- Analyze the existing design structure before adding new elements.
- Match the visual style (colors, fonts, spacing, cornerRadius) of existing siblings.
- Place new elements in logical positions within the hierarchy.
SIBLING CONSISTENCY:
- New cards in a card row MUST match existing cards' width/height strategy (typically fill_container).
- New inputs in a form MUST match existing inputs' width and height.
- New sections MUST use the same padding and gap patterns as existing sections.
INSERTION RULES:
- Use "_parent" to specify where the new node belongs in the tree.
- New sections append after the last existing section by default.
- New items within a list/grid append after the last existing item.
- Preserve z-order: overlay elements (badges, indicators) come BEFORE content.
COMMON PATTERNS:
- "Add a section" -> new frame with width="fill_container", height="fit_content", layout="vertical", matching section padding.
- "Add a card" -> new frame matching sibling card structure (same children pattern, same styles).
- "Add an input" -> new frame with role="input" or "form-input", width="fill_container", matching sibling inputs.
- "Add a button" -> new frame with role="button", matching existing button style.
- "Add a row" -> new frame with layout="horizontal", appropriate gap and alignment.
ID GENERATION:
- Use unique descriptive IDs for new nodes (e.g. "new-feature-card", "contact-section").
- Never reuse existing IDs.

View file

@ -0,0 +1,32 @@
---
name: local-edit
description: Design modification engine for updating existing PenNodes
phase: [maintenance]
trigger: null
priority: 0
budget: 2000
category: base
---
You are a Design Modification Engine. Your job is to UPDATE existing PenNodes based on user instructions.
INPUT:
1. "Context Nodes": A JSON array of the selected PenNodes that the user wants to modify.
2. "Instruction": The user's request.
OUTPUT:
- A JSON code block containing ONLY the modified PenNodes.
- You MUST return the nodes with the SAME IDs as the input.
- You MAY add/remove children if implied.
RULES:
- PRESERVE IDs: The most important rule. If you return a node with a new ID, it will be treated as a new object. To update, you MUST match the input ID.
- PARTIAL UPDATES: You can return the full node object with updated fields.
- DO NOT CHANGE UNRELATED PROPS: If the user says "change color", do not change the x/y position unless necessary.
- DESIGN VARIABLES: When the user message includes a DOCUMENT VARIABLES section, prefer "$variableName" references over hardcoded values for matching properties. Only reference listed variables.
RESPONSE FORMAT:
1. <step title="Checking guidelines">...</step>
2. <step title="Design">...</step>
3. ```json [...nodes] ```
4. A very brief 1-sentence confirmation.

View file

@ -0,0 +1,39 @@
---
name: style-consistency
description: Preserve visual consistency when modifying existing designs
phase: [maintenance]
trigger: null
priority: 10
budget: 1000
category: base
---
STYLE CONSISTENCY RULES:
When modifying an existing design, preserve visual coherence:
COLOR PALETTE:
- Extract the existing palette from context nodes before making changes.
- New elements MUST use colors from the existing palette unless the user explicitly requests new colors.
- Maintain the same accent color usage pattern (primary for CTAs, secondary for highlights).
TYPOGRAPHY:
- Match existing font families — do not introduce new fonts unless requested.
- Maintain the same type scale (heading sizes, body sizes, caption sizes).
- Preserve lineHeight and letterSpacing patterns from existing text nodes.
SPACING:
- Match existing padding and gap values when adding new sections or elements.
- Section padding should be consistent across the design.
- Card internal padding should match sibling cards.
VISUAL TREATMENT:
- cornerRadius should be consistent across similar element types.
- Shadow styles should match existing elements of the same category.
- Border/stroke styles should be consistent (same color, same thickness).
- clipContent should match sibling containers.
HIERARCHY:
- Maintain the same depth of nesting — do not add unnecessary wrapper frames.
- Keep the same layout pattern (vertical sections with horizontal content rows).
- Width strategies (fill_container vs fixed) should match siblings.

View file

@ -0,0 +1,56 @@
---
name: decomposition
description: Orchestrator task decomposition — splits UI requests into cohesive subtasks
phase: [planning]
trigger: null
priority: 0
budget: 3000
category: base
---
Split a UI request into cohesive subtasks. Each subtask = a meaningful UI section or component group. Output ONLY JSON, start with {.
DESIGN TYPE DETECTION:
Classify by the design's PURPOSE — reason about intent, do not keyword-match:
1. Multi-section page — marketing, promotional, or informational content designed to be scrolled (e.g. product sites, portfolios, company pages):
- Desktop: width=1200, height=0 (scrollable), 6-10 subtasks
- Structure: navigation - hero - content sections - CTA - footer
2. Single-task screen — functional UI focused on one user task (e.g. authentication, forms, settings, profiles, modals, onboarding):
- Mobile: width=375, height=812 (fixed viewport), 1-5 subtasks
- Structure: header + focused content area only, no navigation/hero/footer
3. Data-rich workspace — overview screens with metrics, tables, or management panels (e.g. dashboards, admin consoles, analytics):
- Desktop: width=1200, height=0, 2-5 subtasks
- Structure: sidebar or topbar + content panels
CRITICAL — "MOBILE" MEANS MOBILE-SIZED SCREEN, NOT A PHONE MOCKUP:
When the user says "mobile"/"移动端"/"手机" + a screen type (login, profile, settings, etc.), they want a DIRECT mobile-sized screen (375x812) — NOT a desktop landing page containing a phone mockup frame. A "mobile login page" = type 2 (375x812 login screen). Only use phone mockups when the user explicitly asks for a "mockup"/"展示"/"showcase"/"preview" of an app, or when designing a landing page that promotes a mobile app.
FORMAT:
{"rootFrame":{"id":"page","name":"Page","width":1200,"height":0,"layout":"vertical","gap":0,"fill":[{"type":"solid","color":"#F8FAFC"}]},"styleGuide":{"palette":{"background":"#F8FAFC","surface":"#FFFFFF","text":"#0F172A","secondary":"#64748B","accent":"#2563EB","accent2":"#0EA5E9","border":"#E2E8F0"},"fonts":{"heading":"Space Grotesk","body":"Inter"},"aesthetic":"clean modern with blue accents"},"subtasks":[{"id":"nav","label":"Navigation Bar","elements":"logo, nav links (Home, Features, Pricing, Blog), sign-in button, get-started CTA button","region":{"width":1200,"height":72}},{"id":"hero","label":"Hero Section","elements":"headline, subtitle, CTA button, hero illustration or phone mockup","region":{"width":1200,"height":560}},{"id":"features","label":"Feature Cards","elements":"section title, 3 feature cards each with icon + title + description","region":{"width":1200,"height":480}}]}
RULES:
- ELEMENT BOUNDARIES: Each subtask MUST have an "elements" field listing the specific UI elements it contains. Elements must NOT overlap between subtasks — each element belongs to exactly ONE subtask. Example: if "Login Form" has "email input, password input, submit button, forgot-password link", then "Social Login" must NOT repeat the submit button or form inputs.
- STYLE SELECTION: Choose light or dark theme based on user intent. Dark: user mentions dark/cyber/terminal/neon/夜间/暗黑/deep/gaming/noir. Light (default): all other cases — SaaS, marketing, education, e-commerce, productivity, social. Never default to dark unless the content clearly calls for it.
- Detect the design type FIRST, then choose the appropriate structure and subtask count.
- Multi-section pages (type 1): include Navigation Bar as the FIRST subtask, followed by Hero, feature sections, CTA, footer, etc. (6-10 subtasks)
- Single-task screens (type 2): do NOT include Navigation Bar, Hero, CTA, or footer. Only include the actual UI elements needed (1-5 subtasks).
- FORM INTEGRITY: Keep a form's core elements (inputs + submit button) in the same subtask. Splitting inputs into one subtask and the button into another causes duplicate buttons.
- Combine related elements: "Hero with title + image + CTA" = ONE subtask, not three.
- Each subtask generates a meaningful section (~10-30 nodes). Only split if it would exceed 40 nodes.
- REQUIRED: "styleGuide" must ALWAYS be included. Choose a distinctive visual direction (palette, fonts, aesthetic) that matches the product personality and target audience. Never use generic/default colors — each design should have its own identity.
- CJK FONT RULE: If the user's request is in Chinese/Japanese/Korean or the product targets CJK audiences, the styleGuide fonts MUST use CJK-compatible fonts: heading="Noto Sans SC" (Chinese) / "Noto Sans JP" (Japanese) / "Noto Sans KR" (Korean), body="Inter". NEVER use "Space Grotesk" or "Manrope" as heading font for CJK content — they have no CJK character support.
- Root frame fill must use the styleGuide palette background color.
- Root frame gap: Landing pages with distinct section backgrounds - gap=0 (sections flush). Mobile screens and dashboards - gap=16-24 (breathing room between sections). Always include "gap" in rootFrame.
- 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).
- WIDTH SELECTION: Single-task screens (type 2 above) - ALWAYS width=375, height=812 (mobile). Multi-section pages and data-rich workspaces (types 1 & 3) - width=1200, height=0 (desktop). This is mandatory.
- MULTI-SCREEN APPS: When the request involves multiple distinct screens/pages (e.g. "登录页+个人中心", "login and profile"), add "screen":"<name>" to each subtask to group sections that belong to the same page. Use a concise page name (e.g. "登录", "Profile"). Subtasks sharing the same "screen" are placed in one root frame. Single-screen requests don't need "screen". Example: [{"id":"brand","label":"Brand Area","screen":"Login","region":{...}},{"id":"form","label":"Login Form","screen":"Login","region":{...}},{"id":"card","label":"User Card","screen":"Profile","region":{...}}]
- NO explanation. NO markdown. NO tool calls. NO function calls. NO [TOOL_CALL]. JUST the JSON object. Start with {.

View file

@ -0,0 +1,33 @@
---
name: design-type
description: Design type detection and classification rules
phase: [planning]
trigger: null
priority: 5
budget: 1000
category: base
---
DESIGN TYPE DETECTION:
Classify by the design's PURPOSE — reason about intent, do not keyword-match:
1. Multi-section page — marketing, promotional, or informational content designed to be scrolled (e.g. product sites, portfolios, company pages):
- Desktop: width=1200, height=0 (scrollable), 6-10 subtasks
- Structure: navigation - hero - content sections - CTA - footer
2. Single-task screen — functional UI focused on one user task (e.g. authentication, forms, settings, profiles, modals, onboarding):
- Mobile: width=375, height=812 (fixed viewport), 1-5 subtasks
- Structure: header + focused content area only, no navigation/hero/footer
3. Data-rich workspace — overview screens with metrics, tables, or management panels (e.g. dashboards, admin consoles, analytics):
- Desktop: width=1200, height=0, 2-5 subtasks
- Structure: sidebar or topbar + content panels
WIDTH SELECTION RULES:
- Single-task screens (type 2) - ALWAYS width=375, height=812 (mobile).
- Multi-section pages and data-rich workspaces (types 1 & 3) - width=1200, height=0 (desktop).
- This mapping is mandatory.
MOBILE vs MOCKUP:
- "mobile"/"移动端"/"手机" + screen type (login, profile, settings) = ACTUAL mobile screen (375x812), NOT a desktop page with phone mockup.
- Phone mockups are ONLY for app showcase/marketing sections when the user explicitly asks for a "mockup"/"展示"/"showcase"/"preview".

View file

@ -0,0 +1,81 @@
---
name: vision-feedback
description: Vision-based design QA validation with screenshot analysis
phase: [validation]
trigger: null
priority: 0
budget: 3000
category: base
---
You are a design QA validator. You receive a screenshot of a UI design AND its node tree structure.
Cross-reference the visual issues you see in the screenshot with the node IDs in the tree.
Check for these issues:
1. WIDTH INCONSISTENCY: Form inputs, buttons, cards that are siblings but have different widths. They should all use "fill_container" width to match their parent.
2. ELEMENT TOO NARROW: Buttons or inputs that are much narrower than their parent container. Fix: width="fill_container".
3. SPACING: Uneven padding, elements too close to edges, inconsistent gaps between siblings.
4. OVERFLOW: Text or elements visually clipped or extending beyond their container.
5. ALIGNMENT: Elements that should be aligned but aren't (e.g. form fields not left-aligned).
6. TEXT CENTERING: Text that should be horizontally centered in its container but appears shifted left or right. Common in headings, buttons, divider text ("or continue with"), and footer text. Fix: ensure the parent container has alignItems="center" or the text node has width="fill_container".
7. MISSING ICONS: Path nodes that rendered as empty/invisible rectangles.
8. COLOR ISSUES: Text with poor contrast against its background, wrong background colors, inconsistent color usage across similar elements.
9. TYPOGRAPHY: Inconsistent font sizes between similar elements, wrong font weights for headings vs body text.
10. MISSING BORDERS: Input fields, cards, or containers that lack a visible border and blend into their parent background. Fix with strokeColor and strokeWidth.
11. STRUCTURAL INCONSISTENCY: Sibling elements that should follow the same pattern but have different child structures. For example, if one input field has a leading icon but a sibling input field does not, or a list item is missing an expected child element. Fix by adding the missing child node.
12. MISSING ELEMENTS: When a reference design is provided, check if important UI elements visible in the reference are missing or absent in the current design. Fix by adding the missing element as a child of the appropriate parent.
Output ONLY a JSON object. No explanation, no markdown fences.
{"qualityScore":8,"issues":["description1","description2"],"fixes":[{"nodeId":"actual-node-id","property":"width","value":"fill_container"}],"structuralFixes":[]}
qualityScore: Rate the overall design quality from 1-10.
- 9-10: Production-ready, polished design
- 7-8: Good design with minor issues
- 5-6: Acceptable but needs improvement
- 1-4: Significant problems
Allowed property fixes (update existing node):
- width: number | "fill_container" | "fit_content"
- height: number | "fill_container" | "fit_content"
- padding: number | [top,right,bottom,left]
- gap: number
- fontSize: number
- fontWeight: number (300-900)
- letterSpacing: number
- lineHeight: number
- cornerRadius: number
- opacity: number
- fillColor: "#hex" (background/fill color of the node)
- strokeColor: "#hex" (border/stroke color)
- strokeWidth: number (border/stroke width)
- textAlign: "left" | "center" | "right" (text horizontal alignment within its box)
- textGrowth: "auto" | "fixed-width" | "fixed-width-height" (text wrapping mode — "fixed-width" = wrap text and auto-size height)
- alignItems: "start" | "center" | "end"
- justifyContent: "start" | "center" | "end" | "space_between"
TEXT CLIPPING DETECTION:
- If a text node has an explicit pixel height (h=22, h=30 etc.) AND its content appears visually clipped or overlapping siblings, the fix is: set textGrowth="fixed-width" and height="fit_content". This lets the engine auto-calculate the correct height.
- Text nodes should almost NEVER have explicit pixel heights. The node tree shows textGrowth and lineHeight values — use these to diagnose text issues.
- Button text clipped at bottom: check if the parent frame's padding leaves enough space for the text height (fontSize x lineHeight). Fix the parent's padding or height, not the text's fontSize.
Structural fixes (add or remove nodes — use sparingly, only for clear structural issues):
- Add child: {"action":"addChild","parentId":"real-parent-id","index":0,"node":{"type":"path","name":"KeyIcon","width":18,"height":18}}
- Add child: {"action":"addChild","parentId":"real-parent-id","node":{"type":"text","name":"Label","content":"text","fontSize":14,"fillColor":"#hex"}}
- Add child: {"action":"addChild","parentId":"real-parent-id","node":{"type":"frame","name":"Divider","width":"fill_container","height":1,"fillColor":"#hex"}}
- Remove node: {"action":"removeNode","nodeId":"real-node-id"}
For addChild nodes:
- type: "frame" | "text" | "path" | "rectangle" | "ellipse"
- For path/icon nodes: set name to the icon name (e.g. "KeyIcon", "LockIcon", "EyeIcon"). The system resolves icon paths automatically.
- index is optional (defaults to 0 = first child). Use it to control insertion position among siblings.
- Specify width, height, fillColor as needed. Other properties are optional.
IMPORTANT:
- Use REAL node IDs from the provided tree — never guess or fabricate IDs.
- For form consistency issues, fix ALL inconsistent siblings, not just one.
- If the design looks correct, return: {"qualityScore":9,"issues":[],"fixes":[],"structuralFixes":[]}
- Keep fixes minimal — only fix clear visual bugs, not stylistic preferences.
- Focus on the most impactful issues first.
- For structuralFixes, only add elements that are clearly needed for consistency or completeness. Do not add decorative elements unless they are present in the reference.
- CRITICAL: When using addChild, ALWAYS include companion property fixes for the parent node to maintain correct layout. For example, if the parent has justifyContent="space_between" and adding a child would break the spacing, also add a property fix to change justifyContent and/or add a gap value. Look at sibling elements with the same pattern and match the parent's layout properties to theirs.
- CRITICAL: NEVER change height or width from "fit_content" to a fixed pixel value on a frame that has layout (auto-layout). This creates empty whitespace. If a container appears invisible, fix its opacity, fill color, or border instead — not its height.

View file

@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { estimateTokens, trimByBudget } from '../engine/budget'
import type { SkillRegistryEntry } from '../engine/types'
describe('estimateTokens', () => {
it('approximates tokens as chars / 4', () => {
expect(estimateTokens('abcd')).toBe(1)
expect(estimateTokens('a'.repeat(400))).toBe(100)
})
it('returns 0 for empty string', () => {
expect(estimateTokens('')).toBe(0)
})
})
describe('trimByBudget', () => {
const skill = (name: string, category: 'base' | 'domain' | 'knowledge', chars: number): SkillRegistryEntry => ({
meta: { name, description: '', phase: ['generation'], trigger: null, priority: 0, budget: 9999, category },
content: 'x'.repeat(chars),
})
it('keeps all skills when within budget', () => {
const skills = [skill('a', 'base', 40), skill('b', 'domain', 40)]
const result = trimByBudget(skills, 100)
expect(result).toHaveLength(2)
expect(result.every(s => !s.truncated)).toBe(true)
})
it('never cuts base category skills', () => {
const skills = [skill('a', 'base', 20000)]
const result = trimByBudget(skills, 100)
expect(result).toHaveLength(1)
expect(result[0].meta.name).toBe('a')
})
it('drops knowledge skills first when over budget', () => {
const skills = [
skill('base1', 'base', 400),
skill('domain1', 'domain', 400),
skill('know1', 'knowledge', 400),
]
const result = trimByBudget(skills, 250)
expect(result).toHaveLength(2)
expect(result.map(s => s.meta.name)).toEqual(['base1', 'domain1'])
})
it('truncates domain skill content when over budget after dropping knowledge', () => {
const skills = [
skill('base1', 'base', 400),
skill('domain1', 'domain', 2000),
]
const result = trimByBudget(skills, 200)
expect(result).toHaveLength(2)
const domain = result.find(s => s.meta.name === 'domain1')!
expect(domain.truncated).toBe(true)
expect(domain.tokenCount).toBeLessThanOrEqual(200)
})
it('respects per-skill budget cap', () => {
const s = skill('a', 'domain', 8000)
s.meta.budget = 500
const result = trimByBudget([s], 10000)
expect(result[0].tokenCount).toBeLessThanOrEqual(500)
expect(result[0].truncated).toBe(true)
})
})

View file

@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import {
createDesignContext,
extractDesignContext,
mergePreference,
} from '../memory/document-context'
describe('createDesignContext', () => {
it('creates context with null documentPath for unsaved docs', () => {
const ctx = createDesignContext(null)
expect(ctx.documentPath).toBeNull()
expect(ctx.createdAt).toBeTruthy()
expect(ctx.designSystem).toEqual({})
expect(ctx.structure).toEqual({})
expect(ctx.preferences).toEqual({})
})
it('creates context with file path', () => {
const ctx = createDesignContext('/path/to/design.op')
expect(ctx.documentPath).toBe('/path/to/design.op')
})
})
describe('extractDesignContext', () => {
it('extracts palette and aesthetic from orchestrator plan', () => {
const plan = {
styleGuide: { palette: ['#000', '#FFF'], aesthetic: 'minimal' },
subtasks: [{ label: 'Hero Section' }, { label: 'Features' }],
}
const ctx = createDesignContext('/test.op')
const updated = extractDesignContext(ctx, plan as any)
expect(updated.designSystem.palette).toEqual(['#000', '#FFF'])
expect(updated.designSystem.aesthetic).toBe('minimal')
expect(updated.structure.sections).toEqual(['Hero Section', 'Features'])
})
})
describe('mergePreference', () => {
it('adds a new override', () => {
const ctx = createDesignContext('/test.op')
const updated = mergePreference(ctx, { what: 'corner', from: 'rounded-lg', to: 'rounded-full' })
expect(updated.preferences.overrides).toHaveLength(1)
expect(updated.preferences.overrides![0].what).toBe('corner')
})
it('replaces existing override for same "what"', () => {
let ctx = createDesignContext('/test.op')
ctx = mergePreference(ctx, { what: 'corner', from: 'rounded-lg', to: 'rounded-full' })
ctx = mergePreference(ctx, { what: 'corner', from: 'rounded-full', to: 'rounded-none' })
expect(ctx.preferences.overrides).toHaveLength(1)
expect(ctx.preferences.overrides![0].to).toBe('rounded-none')
})
})

View file

@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import {
createHistoryEntry,
updateFeedback,
getRecentEntries,
} from '../memory/generation-history'
describe('createHistoryEntry', () => {
it('creates entry with required fields', () => {
const entry = createHistoryEntry({
documentPath: '/test.op',
prompt: 'build a landing page',
phase: 'generation',
skillsUsed: ['schema', 'layout', 'landing-page'],
nodeCount: 42,
sectionTypes: ['hero', 'features'],
})
expect(entry.id).toBeTruthy()
expect(entry.timestamp).toBeTruthy()
expect(entry.input.prompt).toBe('build a landing page')
expect(entry.output.nodeCount).toBe(42)
expect(entry.feedback).toBeUndefined()
})
})
describe('updateFeedback', () => {
it('sets feedback on an entry', () => {
const entry = createHistoryEntry({
documentPath: '/test.op',
prompt: 'test',
phase: 'generation',
skillsUsed: [],
nodeCount: 1,
sectionTypes: [],
})
const updated = updateFeedback(entry, 'accepted')
expect(updated.feedback).toBe('accepted')
})
})
describe('getRecentEntries', () => {
it('returns last N entries', () => {
const entries = Array.from({ length: 10 }, (_, i) =>
createHistoryEntry({
documentPath: '/test.op',
prompt: `prompt ${i}`,
phase: 'generation',
skillsUsed: [],
nodeCount: i,
sectionTypes: [],
})
)
const recent = getRecentEntries(entries, 3)
expect(recent).toHaveLength(3)
expect(recent[0].input.prompt).toBe('prompt 7')
})
it('filters by documentPath when provided', () => {
const entries = [
createHistoryEntry({ documentPath: '/a.op', prompt: 'a', phase: 'generation', skillsUsed: [], nodeCount: 1, sectionTypes: [] }),
createHistoryEntry({ documentPath: '/b.op', prompt: 'b', phase: 'generation', skillsUsed: [], nodeCount: 1, sectionTypes: [] }),
createHistoryEntry({ documentPath: '/a.op', prompt: 'c', phase: 'generation', skillsUsed: [], nodeCount: 1, sectionTypes: [] }),
]
const recent = getRecentEntries(entries, 5, '/a.op')
expect(recent).toHaveLength(2)
})
})

View file

@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { resolveSkills } from '../engine/resolve-skills'
import { setSkillRegistry } from '../engine/loader'
import type { SkillRegistryEntry } from '../engine/types'
const mkSkill = (
name: string,
phase: string[],
opts: Partial<SkillRegistryEntry['meta']> = {},
content = `content of ${name}`
): SkillRegistryEntry => ({
meta: {
name,
description: '',
phase: phase as any[],
trigger: null,
priority: 50,
budget: 2000,
category: 'base',
...opts,
},
content,
})
describe('resolveSkills', () => {
beforeEach(() => {
setSkillRegistry([
mkSkill('decomposition', ['planning'], { priority: 0 }),
mkSkill('schema', ['generation'], { priority: 0 }),
mkSkill('layout', ['generation'], { priority: 10 }),
mkSkill('landing', ['generation'], { trigger: { keywords: ['landing'] }, priority: 50, category: 'domain' }),
mkSkill('cjk', ['generation'], { trigger: { keywords: ['chinese'] }, priority: 25, category: 'domain' }),
mkSkill('variables', ['generation'], { trigger: { flags: ['hasVariables'] }, priority: 45 }),
mkSkill('vision', ['validation'], { priority: 0 }),
mkSkill('local-edit', ['maintenance'], { priority: 0 }),
])
})
it('filters by phase', () => {
const ctx = resolveSkills('planning', 'build a landing page')
expect(ctx.skills.map(s => s.meta.name)).toEqual(['decomposition'])
expect(ctx.phase).toBe('planning')
})
it('matches keywords in generation phase', () => {
const ctx = resolveSkills('generation', 'build a landing page')
expect(ctx.skills.map(s => s.meta.name)).toContain('landing')
})
it('does not include keyword-triggered skills when message does not match', () => {
const ctx = resolveSkills('generation', 'build a dashboard')
expect(ctx.skills.map(s => s.meta.name)).not.toContain('landing')
})
it('includes flag-triggered skills when flag is set', () => {
const ctx = resolveSkills('generation', 'build something', { flags: { hasVariables: true } })
expect(ctx.skills.map(s => s.meta.name)).toContain('variables')
})
it('excludes flag-triggered skills when flag is not set', () => {
const ctx = resolveSkills('generation', 'build something')
expect(ctx.skills.map(s => s.meta.name)).not.toContain('variables')
})
it('returns AgentContext with correct structure', () => {
const ctx = resolveSkills('validation', 'check design')
expect(ctx.role).toBe('general')
expect(ctx.phase).toBe('validation')
expect(ctx.budget.max).toBe(3000)
expect(ctx.budget.used).toBeGreaterThanOrEqual(0)
expect(ctx.memory).toEqual({})
})
it('injects dynamic content into placeholders', () => {
setSkillRegistry([
mkSkill('design-md', ['generation'], { trigger: { flags: ['hasDesignMd'] } }, 'Theme: {{designMdContent}}'),
])
const ctx = resolveSkills('generation', 'build', {
flags: { hasDesignMd: true },
dynamicContent: { designMdContent: 'Dark modern' },
})
expect(ctx.skills[0].content).toBe('Theme: Dark modern')
})
it('respects budgetOverride', () => {
const ctx = resolveSkills('generation', 'test', { budgetOverride: 500 })
expect(ctx.budget.max).toBe(500)
})
})

View file

@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { matchTrigger, filterByIntent } from '../engine/resolver'
import type { SkillRegistryEntry } from '../engine/types'
const skill = (
name: string,
trigger: null | { keywords: string[] } | { flags: string[] },
priority = 50
): SkillRegistryEntry => ({
meta: { name, description: '', phase: ['generation'], trigger, priority, budget: 2000, category: 'domain' },
content: `content of ${name}`,
})
describe('matchTrigger', () => {
it('null trigger always matches', () => {
expect(matchTrigger(null, 'any message', {})).toBe(true)
})
it('keyword trigger matches case-insensitively', () => {
expect(matchTrigger({ keywords: ['landing'] }, 'Build a Landing Page', {})).toBe(true)
expect(matchTrigger({ keywords: ['landing'] }, 'Build a dashboard', {})).toBe(false)
})
it('keyword trigger matches if any keyword matches', () => {
expect(matchTrigger({ keywords: ['dashboard', 'table'] }, 'Create a table view', {})).toBe(true)
})
it('flag trigger matches when all flags are true', () => {
expect(matchTrigger({ flags: ['hasVariables'] }, '', { hasVariables: true })).toBe(true)
expect(matchTrigger({ flags: ['hasVariables'] }, '', { hasVariables: false })).toBe(false)
expect(matchTrigger({ flags: ['hasVariables'] }, '', {})).toBe(false)
})
it('flag trigger requires ALL flags to be true', () => {
expect(
matchTrigger({ flags: ['hasVariables', 'hasDesignMd'] }, '', {
hasVariables: true,
hasDesignMd: false,
})
).toBe(false)
})
})
describe('filterByIntent', () => {
it('includes always-on skills', () => {
const skills = [skill('base', null), skill('landing', { keywords: ['landing'] })]
const result = filterByIntent(skills, 'build a dashboard', {})
expect(result.map(s => s.meta.name)).toEqual(['base'])
})
it('includes keyword-matched skills', () => {
const skills = [skill('base', null), skill('landing', { keywords: ['landing'] })]
const result = filterByIntent(skills, 'build a landing page', {})
expect(result.map(s => s.meta.name)).toEqual(['base', 'landing'])
})
it('sorts by priority', () => {
const skills = [
skill('b', null, 50),
skill('a', null, 10),
skill('c', null, 30),
]
const result = filterByIntent(skills, '', {})
expect(result.map(s => s.meta.name)).toEqual(['a', 'c', 'b'])
})
})

View file

@ -0,0 +1,31 @@
import { describe, it, expectTypeOf } from 'vitest'
import type {
Phase,
SkillTrigger,
AgentContext,
} from '../engine/types'
describe('engine types', () => {
it('Phase is a string union', () => {
expectTypeOf<Phase>().toEqualTypeOf<
'planning' | 'generation' | 'validation' | 'maintenance'
>()
})
it('SkillTrigger covers all trigger shapes', () => {
const alwaysOn: SkillTrigger = null
const keywords: SkillTrigger = { keywords: ['landing'] }
const flags: SkillTrigger = { flags: ['hasVariables'] }
expectTypeOf(alwaysOn).toMatchTypeOf<SkillTrigger>()
expectTypeOf(keywords).toMatchTypeOf<SkillTrigger>()
expectTypeOf(flags).toMatchTypeOf<SkillTrigger>()
})
it('AgentContext has role field for future differentiation', () => {
expectTypeOf<AgentContext>().toHaveProperty('role')
expectTypeOf<AgentContext>().toHaveProperty('phase')
expectTypeOf<AgentContext>().toHaveProperty('skills')
expectTypeOf<AgentContext>().toHaveProperty('memory')
expectTypeOf<AgentContext>().toHaveProperty('budget')
})
})

View file

@ -0,0 +1,72 @@
import type { SkillRegistryEntry, ResolvedSkill } from './types'
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4)
}
function truncateContent(content: string, maxTokens: number): string {
const maxChars = maxTokens * 4
if (content.length <= maxChars) return content
const truncated = content.slice(0, maxChars)
const lastNewline = truncated.lastIndexOf('\n')
return lastNewline > maxChars * 0.5 ? truncated.slice(0, lastNewline) : truncated
}
export function trimByBudget(
skills: SkillRegistryEntry[],
totalBudget: number
): ResolvedSkill[] {
// Step 1: Apply per-skill budget caps
const withTokens = skills.map(skill => {
const perSkillBudget = skill.meta.budget
const rawTokens = estimateTokens(skill.content)
const needsTruncate = rawTokens > perSkillBudget
const content = needsTruncate
? truncateContent(skill.content, perSkillBudget)
: skill.content
return {
meta: skill.meta,
content,
tokenCount: needsTruncate ? estimateTokens(content) : rawTokens,
truncated: needsTruncate,
}
})
// Step 2: Always keep base skills
const base = withTokens.filter(s => s.meta.category === 'base')
const domain = withTokens.filter(s => s.meta.category === 'domain')
const knowledge = withTokens.filter(s => s.meta.category === 'knowledge')
let usedTokens = base.reduce((sum, s) => sum + s.tokenCount, 0)
const result: ResolvedSkill[] = [...base]
// Step 3: Add domain skills, truncating last if needed
for (const skill of domain) {
const remaining = totalBudget - usedTokens
if (remaining <= 0) break
if (skill.tokenCount <= remaining) {
result.push(skill)
usedTokens += skill.tokenCount
} else {
const truncatedContent = truncateContent(skill.content, remaining)
result.push({
...skill,
content: truncatedContent,
tokenCount: estimateTokens(truncatedContent),
truncated: true,
})
usedTokens += estimateTokens(truncatedContent)
break
}
}
// Step 4: Add knowledge skills only if budget remains
for (const skill of knowledge) {
const remaining = totalBudget - usedTokens
if (remaining <= 0 || skill.tokenCount > remaining) break
result.push(skill)
usedTokens += skill.tokenCount
}
return result
}

View file

@ -0,0 +1,21 @@
import type { SkillRegistryEntry, Phase } from './types'
import { skillRegistry as generatedRegistry } from '../_generated/skill-registry'
let registry: SkillRegistryEntry[] = generatedRegistry ?? []
export function getSkillRegistry(): SkillRegistryEntry[] {
return registry
}
export function getSkillsByPhase(phase: Phase): SkillRegistryEntry[] {
return registry.filter(entry => entry.meta.phase.includes(phase))
}
export function getSkillByName(name: string): SkillRegistryEntry | undefined {
return registry.find(entry => entry.meta.name === name)
}
/** For testing: inject a custom registry */
export function setSkillRegistry(entries: SkillRegistryEntry[]): void {
registry = entries
}

View file

@ -0,0 +1,65 @@
import type { Phase, ResolveOptions, AgentContext } from './types'
import { DEFAULT_BUDGETS } from './types'
import { getSkillsByPhase } from './loader'
import { filterByIntent, injectDynamicContent } from './resolver'
import { trimByBudget } from './budget'
import { getRecentEntries } from '../memory/generation-history'
export function resolveSkills(
phase: Phase,
userMessage: string,
options?: ResolveOptions
): AgentContext {
const flags = options?.flags ?? {}
const dynamicContent = options?.dynamicContent
const totalBudget = options?.budgetOverride ?? DEFAULT_BUDGETS[phase]
// Step 1: Phase filter
const phaseSkills = getSkillsByPhase(phase)
// Step 2: Intent + flag match
const matched = filterByIntent(phaseSkills, userMessage, flags)
// Step 3: Dynamic content injection
const injected = matched.map(skill => ({
...skill,
content: injectDynamicContent(skill.content, dynamicContent),
}))
// Step 4: Budget trim
const trimmed = trimByBudget(injected, totalBudget)
const usedTokens = trimmed.reduce((sum, s) => sum + s.tokenCount, 0)
// Memory loading rules per phase
const memory: AgentContext['memory'] = {}
if (options?.memory) {
const { documentContext, generationHistory } = options.memory
const historyLimits: Record<Phase, number> = {
planning: 5,
generation: 0,
validation: 0,
maintenance: 3,
}
if (documentContext && phase !== 'validation') {
memory.documentContext = documentContext
}
const historyLimit = historyLimits[phase]
if (generationHistory?.length && historyLimit > 0) {
memory.generationHistory = getRecentEntries(
generationHistory,
historyLimit,
options.documentPath
)
}
}
return {
role: 'general',
phase,
skills: trimmed,
memory,
budget: { used: usedTokens, max: totalBudget },
}
}

View file

@ -0,0 +1,42 @@
import type { SkillTrigger, SkillRegistryEntry } from './types'
export function matchTrigger(
trigger: SkillTrigger,
userMessage: string,
flags: Record<string, boolean>
): boolean {
if (trigger === null) return true
if ('keywords' in trigger) {
const msg = userMessage.toLowerCase()
return trigger.keywords.some(kw => msg.includes(kw.toLowerCase()))
}
if ('flags' in trigger) {
return trigger.flags.every(flag => flags[flag] === true)
}
return false
}
export function filterByIntent(
skills: SkillRegistryEntry[],
userMessage: string,
flags: Record<string, boolean>
): SkillRegistryEntry[] {
return skills
.filter(skill => matchTrigger(skill.meta.trigger, userMessage, flags))
.sort((a, b) => a.meta.priority - b.meta.priority)
}
export function injectDynamicContent(
content: string,
dynamicContent?: Record<string, string>
): string {
if (!dynamicContent) return content
return content.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
if (key in dynamicContent) return dynamicContent[key]
console.warn(`[pen-ai-skills] Missing dynamic content for placeholder: {{${key}}}`)
return ''
})
}

View file

@ -0,0 +1,97 @@
export type Phase = 'planning' | 'generation' | 'validation' | 'maintenance'
export type SkillTrigger =
| null
| { keywords: string[] }
| { flags: string[] }
export type SkillCategory = 'base' | 'domain' | 'knowledge'
export interface SkillMeta {
name: string
description: string
phase: Phase[]
trigger: SkillTrigger
priority: number
budget: number
category: SkillCategory
}
export interface SkillRegistryEntry {
meta: SkillMeta
content: string
}
export interface ResolvedSkill {
meta: SkillMeta
content: string
tokenCount: number
truncated: boolean
}
export interface ResolveOptions {
flags?: Record<string, boolean>
dynamicContent?: Record<string, string>
documentPath?: string
budgetOverride?: number
memory?: {
documentContext?: DesignContext
generationHistory?: HistoryEntry[]
}
}
export interface DesignContext {
documentPath: string | null
createdAt: string
updatedAt: string
designSystem: {
palette?: string[]
typography?: string
spacing?: string
aesthetic?: string
}
structure: {
pageType?: string
sections?: string[]
componentPatterns?: string[]
}
preferences: {
overrides?: Array<{ what: string; from: string; to: string }>
}
}
export interface HistoryEntry {
id: string
timestamp: string
documentPath: string
input: {
prompt: string
phase: Phase
skillsUsed: string[]
}
output: {
nodeCount: number
sectionTypes: string[]
validationScore?: number
validationRounds?: number
}
feedback?: 'accepted' | 'modified' | 'regenerated' | 'deleted'
}
export interface AgentContext {
role: string
phase: Phase
skills: ResolvedSkill[]
memory: {
documentContext?: DesignContext
generationHistory?: HistoryEntry[]
}
budget: { used: number; max: number }
}
export const DEFAULT_BUDGETS: Record<Phase, number> = {
planning: 4000,
generation: 8000,
validation: 3000,
maintenance: 5000,
}

View file

@ -0,0 +1,30 @@
// Engine
export type {
Phase,
SkillTrigger,
SkillMeta,
ResolvedSkill,
ResolveOptions,
AgentContext,
} from './engine/types'
export { resolveSkills } from './engine/resolve-skills'
export { getSkillRegistry, getSkillByName, getSkillsByPhase } from './engine/loader'
// Memory
export type { DesignContext } from './memory/document-context'
export type { HistoryEntry } from './memory/generation-history'
export {
createDesignContext,
extractDesignContext,
mergePreference,
contextToPromptString,
} from './memory/document-context'
export {
createHistoryEntry,
updateFeedback,
getRecentEntries,
historyToPromptString,
} from './memory/generation-history'

View file

@ -0,0 +1,77 @@
import type { DesignContext } from '../engine/types'
export type { DesignContext }
export function createDesignContext(documentPath: string | null): DesignContext {
const now = new Date().toISOString()
return {
documentPath,
createdAt: now,
updatedAt: now,
designSystem: {},
structure: {},
preferences: {},
}
}
interface OrchestratorPlanLike {
styleGuide?: {
palette?: string[]
fonts?: string[]
aesthetic?: string
}
subtasks?: Array<{ label: string }>
}
export function extractDesignContext(
existing: DesignContext,
plan: OrchestratorPlanLike
): DesignContext {
return {
...existing,
updatedAt: new Date().toISOString(),
designSystem: {
...existing.designSystem,
palette: plan.styleGuide?.palette ?? existing.designSystem.palette,
aesthetic: plan.styleGuide?.aesthetic ?? existing.designSystem.aesthetic,
typography: plan.styleGuide?.fonts?.join(', ') ?? existing.designSystem.typography,
},
structure: {
...existing.structure,
sections: plan.subtasks?.map(s => s.label) ?? existing.structure.sections,
},
}
}
export function mergePreference(
ctx: DesignContext,
override: { what: string; from: string; to: string }
): DesignContext {
const overrides = [...(ctx.preferences.overrides ?? [])]
const existingIdx = overrides.findIndex(o => o.what === override.what)
if (existingIdx >= 0) {
overrides[existingIdx] = override
} else {
overrides.push(override)
}
return {
...ctx,
updatedAt: new Date().toISOString(),
preferences: { ...ctx.preferences, overrides },
}
}
export function contextToPromptString(ctx: DesignContext): string {
const parts: string[] = ['## Document Design Context']
if (ctx.designSystem.aesthetic) parts.push(`Aesthetic: ${ctx.designSystem.aesthetic}`)
if (ctx.designSystem.palette?.length) parts.push(`Palette: ${ctx.designSystem.palette.join(', ')}`)
if (ctx.designSystem.typography) parts.push(`Typography: ${ctx.designSystem.typography}`)
if (ctx.structure.pageType) parts.push(`Page Type: ${ctx.structure.pageType}`)
if (ctx.preferences.overrides?.length) {
parts.push('User Preferences:')
for (const o of ctx.preferences.overrides) {
parts.push(` - ${o.what}: changed from ${o.from} to ${o.to}`)
}
}
return parts.join('\n')
}

View file

@ -0,0 +1,62 @@
import type { HistoryEntry, Phase } from '../engine/types'
export type { HistoryEntry }
let idCounter = 0
export function createHistoryEntry(params: {
documentPath: string
prompt: string
phase: Phase
skillsUsed: string[]
nodeCount: number
sectionTypes: string[]
validationScore?: number
validationRounds?: number
}): HistoryEntry {
return {
id: `gen-${Date.now()}-${++idCounter}`,
timestamp: new Date().toISOString(),
documentPath: params.documentPath,
input: {
prompt: params.prompt,
phase: params.phase,
skillsUsed: params.skillsUsed,
},
output: {
nodeCount: params.nodeCount,
sectionTypes: params.sectionTypes,
validationScore: params.validationScore,
validationRounds: params.validationRounds,
},
}
}
export function updateFeedback(
entry: HistoryEntry,
feedback: NonNullable<HistoryEntry['feedback']>
): HistoryEntry {
return { ...entry, feedback }
}
export function getRecentEntries(
entries: HistoryEntry[],
limit: number,
documentPath?: string
): HistoryEntry[] {
const filtered = documentPath
? entries.filter(e => e.documentPath === documentPath)
: entries
return filtered.slice(-limit)
}
export function historyToPromptString(entries: HistoryEntry[]): string {
if (!entries.length) return ''
const lines = ['## Recent Generation History']
for (const entry of entries) {
const score = entry.output.validationScore ? ` (score: ${entry.output.validationScore})` : ''
const feedback = entry.feedback ? ` [${entry.feedback}]` : ''
lines.push(`- "${entry.input.prompt}" → ${entry.output.nodeCount} nodes${score}${feedback}`)
}
return lines.join('\n')
}

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