mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
V0.5.2 (#82)
* 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:
parent
2592bf116f
commit
3caaa495bb
110 changed files with 2847 additions and 1742 deletions
3
.github/workflows/build-electron.yml
vendored
3
.github/workflows/build-electron.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
3
.github/workflows/publish-cli.yml
vendored
3
.github/workflows/publish-cli.yml
vendored
|
|
@ -1,6 +1,9 @@
|
|||
name: Publish npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@zseven-w/desktop",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.2",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: section→padding:[60,80], navbar→height:72/layout:horizontal/space_between, hero→padding:[80,80], button→padding:[12,24]/height:44, card→gap:12/cornerRadius:12/clipContent:true.
|
||||
Your explicit props ALWAYS override role defaults.`,
|
||||
triggers: [
|
||||
'landing', 'marketing', 'hero', 'website', '官网', '首页', '产品页',
|
||||
'table', 'grid', '表格', '表头', 'dashboard', '数据', 'admin',
|
||||
'testimonial', 'pricing', 'footer', 'stats',
|
||||
'评价', '定价', '页脚', '数据统计',
|
||||
],
|
||||
priority: 35,
|
||||
},
|
||||
|
||||
copywriting: {
|
||||
content: `COPYWRITING:
|
||||
- Headlines: 2-6 words. Subtitles: 1 sentence ≤15 words.
|
||||
- Feature titles: 2-4 words. Descriptions: 1 sentence ≤20 words.
|
||||
- Buttons: 1-3 words. Card text: ≤2 sentences. Stats: number + 1-3 word label.
|
||||
- NEVER 3+ sentence paragraphs. Distill to essence. Power words > vague adjectives.`,
|
||||
triggers: [
|
||||
'landing', 'marketing', 'hero', 'website', '官网', '首页', '产品页',
|
||||
'copy', 'text', 'headline', 'content',
|
||||
'文案', '标题', '内容',
|
||||
],
|
||||
priority: 40,
|
||||
},
|
||||
|
||||
cjk: {
|
||||
content: `CJK TYPOGRAPHY (Chinese/Japanese/Korean):
|
||||
- Headings: "Noto Sans SC" (Chinese) / "Noto Sans JP" / "Noto Sans KR". NEVER "Space Grotesk"/"Manrope" for CJK.
|
||||
- Body: "Inter" (system CJK fallback) or "Noto Sans SC".
|
||||
- CJK lineHeight: headings 1.3-1.4 (NOT 1.1), body 1.6-1.8. letterSpacing: 0, NEVER negative.
|
||||
- CJK buttons: each char ≈ fontSize wide. Container width ≥ (charCount × fontSize) + padding.
|
||||
- Detect CJK from user request language — use CJK fonts for ALL text nodes.`,
|
||||
// CJK is triggered by Unicode range detection, not keywords
|
||||
triggers: ['/[\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af]/'],
|
||||
priority: 25,
|
||||
},
|
||||
|
||||
variables: {
|
||||
content: `DESIGN VARIABLES:
|
||||
- When document has variables, use "$variableName" references instead of hardcoded values.
|
||||
- Color: [{ "type": "solid", "color": "$primary" }]. Number: "gap": "$spacing-md".
|
||||
- Only reference listed variables — do NOT invent names.`,
|
||||
flag: 'hasVariables',
|
||||
priority: 45,
|
||||
},
|
||||
|
||||
examples: {
|
||||
content: `EXAMPLES:
|
||||
Button: { "id":"btn-1","type":"frame","role":"button","width":180,"cornerRadius":8,"fill":[{"type":"solid","color":"#3B82F6"}],"children":[{"id":"btn-icon","type":"path","name":"ArrowRightIcon","role":"icon","d":"M5 12h14m-7-7 7 7-7 7","width":20,"height":20,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#FFF"}]}},{"id":"btn-text","type":"text","role":"label","content":"Continue","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFF"}]}] }
|
||||
Card: { "id":"card-1","type":"frame","role":"card","width":320,"height":340,"fill":[{"type":"solid","color":"#FFF"}],"effects":[{"type":"shadow","offsetX":0,"offsetY":4,"blur":12,"spread":0,"color":"rgba(0,0,0,0.1)"}],"children":[{"id":"card-img","type":"image","width":"fill_container","height":180},{"id":"card-body","type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":20,"gap":8,"children":[{"id":"card-title","type":"text","role":"heading","content":"Title","fontSize":20,"fontWeight":700,"fill":[{"type":"solid","color":"#111827"}]},{"id":"card-desc","type":"text","role":"body-text","content":"Description","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}]}] }`,
|
||||
triggers: [
|
||||
'example', 'sample', 'show me', 'how to',
|
||||
'示例', '样例', '怎么',
|
||||
],
|
||||
priority: 50,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compatible flat content map (used by MCP design-prompt.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PROMPT_SECTIONS: Record<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')
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
|
|
@ -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.`
|
||||
|
|
@ -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.`
|
||||
|
|
@ -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.`
|
||||
|
|
@ -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.`
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
@ -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,
|
||||
}))
|
||||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
}))
|
||||
|
|
@ -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,
|
||||
}))
|
||||
|
|
@ -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,
|
||||
}))
|
||||
|
|
@ -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,
|
||||
}))
|
||||
|
|
@ -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'] },
|
||||
|
|
|
|||
30
bun.lock
30
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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
1
packages/pen-ai-skills/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
src/_generated/
|
||||
23
packages/pen-ai-skills/CLAUDE.md
Normal file
23
packages/pen-ai-skills/CLAUDE.md
Normal 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__/
|
||||
```
|
||||
23
packages/pen-ai-skills/package.json
Normal file
23
packages/pen-ai-skills/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
0
packages/pen-ai-skills/skills/domains/.gitkeep
Normal file
0
packages/pen-ai-skills/skills/domains/.gitkeep
Normal file
18
packages/pen-ai-skills/skills/domains/cjk-typography.md
Normal file
18
packages/pen-ai-skills/skills/domains/cjk-typography.md
Normal 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.
|
||||
50
packages/pen-ai-skills/skills/domains/dashboard.md
Normal file
50
packages/pen-ai-skills/skills/domains/dashboard.md
Normal 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
|
||||
23
packages/pen-ai-skills/skills/domains/form-ui.md
Normal file
23
packages/pen-ai-skills/skills/domains/form-ui.md
Normal 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.
|
||||
54
packages/pen-ai-skills/skills/domains/landing-page.md
Normal file
54
packages/pen-ai-skills/skills/domains/landing-page.md
Normal 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
|
||||
59
packages/pen-ai-skills/skills/domains/mobile-app.md
Normal file
59
packages/pen-ai-skills/skills/domains/mobile-app.md
Normal 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
|
||||
0
packages/pen-ai-skills/skills/knowledge/.gitkeep
Normal file
0
packages/pen-ai-skills/skills/knowledge/.gitkeep
Normal file
16
packages/pen-ai-skills/skills/knowledge/copywriting.md
Normal file
16
packages/pen-ai-skills/skills/knowledge/copywriting.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
18
packages/pen-ai-skills/skills/knowledge/examples.md
Normal file
18
packages/pen-ai-skills/skills/knowledge/examples.md
Normal 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"}]}]}] }
|
||||
37
packages/pen-ai-skills/skills/knowledge/icon-catalog.md
Normal file
37
packages/pen-ai-skills/skills/knowledge/icon-catalog.md
Normal 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
|
||||
72
packages/pen-ai-skills/skills/knowledge/role-definitions.md
Normal file
72
packages/pen-ai-skills/skills/knowledge/role-definitions.md
Normal 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.
|
||||
0
packages/pen-ai-skills/skills/phases/generation/.gitkeep
Normal file
0
packages/pen-ai-skills/skills/phases/generation/.gitkeep
Normal 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.
|
||||
13
packages/pen-ai-skills/skills/phases/generation/design-md.md
Normal file
13
packages/pen-ai-skills/skills/phases/generation/design-md.md
Normal 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}}
|
||||
|
|
@ -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".
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
23
packages/pen-ai-skills/skills/phases/generation/layout.md
Normal file
23
packages/pen-ai-skills/skills/phases/generation/layout.md
Normal 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.
|
||||
15
packages/pen-ai-skills/skills/phases/generation/overflow.md
Normal file
15
packages/pen-ai-skills/skills/phases/generation/overflow.md
Normal 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).
|
||||
24
packages/pen-ai-skills/skills/phases/generation/schema.md
Normal file
24
packages/pen-ai-skills/skills/phases/generation/schema.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
15
packages/pen-ai-skills/skills/phases/generation/variables.md
Normal file
15
packages/pen-ai-skills/skills/phases/generation/variables.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 {.
|
||||
33
packages/pen-ai-skills/skills/phases/planning/design-type.md
Normal file
33
packages/pen-ai-skills/skills/phases/planning/design-type.md
Normal 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".
|
||||
0
packages/pen-ai-skills/skills/phases/validation/.gitkeep
Normal file
0
packages/pen-ai-skills/skills/phases/validation/.gitkeep
Normal 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.
|
||||
66
packages/pen-ai-skills/src/__tests__/budget.test.ts
Normal file
66
packages/pen-ai-skills/src/__tests__/budget.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
89
packages/pen-ai-skills/src/__tests__/resolve-skills.test.ts
Normal file
89
packages/pen-ai-skills/src/__tests__/resolve-skills.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
66
packages/pen-ai-skills/src/__tests__/resolver.test.ts
Normal file
66
packages/pen-ai-skills/src/__tests__/resolver.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
31
packages/pen-ai-skills/src/__tests__/types.test.ts
Normal file
31
packages/pen-ai-skills/src/__tests__/types.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
72
packages/pen-ai-skills/src/engine/budget.ts
Normal file
72
packages/pen-ai-skills/src/engine/budget.ts
Normal 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
|
||||
}
|
||||
21
packages/pen-ai-skills/src/engine/loader.ts
Normal file
21
packages/pen-ai-skills/src/engine/loader.ts
Normal 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
|
||||
}
|
||||
65
packages/pen-ai-skills/src/engine/resolve-skills.ts
Normal file
65
packages/pen-ai-skills/src/engine/resolve-skills.ts
Normal 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 },
|
||||
}
|
||||
}
|
||||
42
packages/pen-ai-skills/src/engine/resolver.ts
Normal file
42
packages/pen-ai-skills/src/engine/resolver.ts
Normal 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 ''
|
||||
})
|
||||
}
|
||||
97
packages/pen-ai-skills/src/engine/types.ts
Normal file
97
packages/pen-ai-skills/src/engine/types.ts
Normal 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,
|
||||
}
|
||||
30
packages/pen-ai-skills/src/index.ts
Normal file
30
packages/pen-ai-skills/src/index.ts
Normal 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'
|
||||
77
packages/pen-ai-skills/src/memory/document-context.ts
Normal file
77
packages/pen-ai-skills/src/memory/document-context.ts
Normal 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')
|
||||
}
|
||||
62
packages/pen-ai-skills/src/memory/generation-history.ts
Normal file
62
packages/pen-ai-skills/src/memory/generation-history.ts
Normal 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
Loading…
Reference in a new issue