* chore(docs): add video demo to README.md for enhanced project visibility

* refactor(ai): centralize Claude Agent SDK env resolution with debug logging and model retry

Extract shared buildClaudeAgentEnv() and getClaudeAgentDebugFilePath() into
resolve-claude-agent-env.ts to eliminate duplicated env setup across all
server endpoints. Add model fallback retry logic (retry without explicit
model on exit code 1) and diagnostic hints from debug log tail on failures.

* fix(canvas): normalize layout justify/align values from CSS aliases

AI-generated nodes may use CSS-style values like "flex-start",
"space-between", "middle" etc. Add normalizeJustifyContent and
normalizeAlignItems to map these to internal enum values in both
the layout engine and the property panel.

* fix(canvas): improve text and path rendering accuracy

- Remove icon-specific bounding box override on path scaling to prevent
  pathOffset drift that visually offsets icons in logo containers
- Only use Textbox for explicit fixed-width text modes instead of any
  node with width > 0, preventing unwanted word wrapping on auto-width text
- Add widthSafetyFactor for Latin text estimation (1.14x vs 1.06x CJK)
  to reduce accidental line wraps from font width variation

* feat(ai): add input icon affordance rules and trailing icon alignment

Prompt AI to include semantic icons in form inputs (search, password,
email). Add normalizeInputTrailingIconAlignment post-pass in
role-resolver to auto-set justifyContent="space_between" on input
frames with a trailing icon node.

* feat(ai): persist model selection across sessions via localStorage

Add preferredModel and selectModel to ai-store with localStorage
read/write. Hydrate on mount to restore user's last chosen model.
Add isHydrated flag to agent-settings-store to prevent race
conditions during provider list construction.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
Kayshen Xu 2026-02-26 09:38:48 +08:00 committed by GitHub
parent 24ed397f0f
commit 8dde4c43e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 608 additions and 124 deletions

View file

@ -13,6 +13,8 @@ Design visually on an infinite canvas, generate code from designs, and let AI bu
![OpenPencil Editor](./screenshot/demo.png)
<video src="https://github.com/user-attachments/assets/5bf66acb-724f-4f7e-9f9b-cc219ce8f484" controls width="800"></video>
## Highlights
- **Infinite canvas** — pan, zoom, smart alignment guides, drag-and-drop into auto-layout frames

View file

@ -1,6 +1,11 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { readFile } from 'node:fs/promises'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { runCodexExec } from '../../utils/codex-client'
import {
buildClaudeAgentEnv,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
interface ChatBody {
system: string
@ -12,6 +17,41 @@ interface ChatBody {
effort?: 'low' | 'medium' | 'high' | 'max'
}
async function readDebugTail(path?: string, maxLines = 40): Promise<string[] | undefined> {
if (!path) return undefined
try {
const raw = await readFile(path, 'utf-8')
const lines = raw.split('\n').filter((l) => l.trim().length > 0)
return lines.slice(-maxLines)
} catch {
return undefined
}
}
function shouldRetryClaudeWithoutModel(raw: string): boolean {
return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)
}
function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | undefined {
if (!/process exited with code 1/i.test(rawError)) return undefined
if (!debugTail || debugTail.length === 0) return undefined
const text = debugTail.join('\n')
const hints: string[] = []
if (/Failed to save config with lock: Error: EPERM|operation not permitted, .*\.claude\.json/i.test(text)) {
hints.push('Claude Code cannot write ~/.claude.json in the current runtime (permission denied).')
}
if (/Connection error|Could not resolve host|Failed to connect/i.test(text)) {
hints.push('Upstream API connection failed (check proxy/DNS/network reachability to your ANTHROPIC_BASE_URL).')
}
if (/ANTHROPIC_CUSTOM_HEADERS present: false, has Authorization header: false/i.test(text)) {
hints.push('No API auth header detected by Claude runtime; verify token/header env mapping.')
}
if (hints.length === 0) return undefined
return `${rawError}\n${hints.join(' ')}`
}
/**
* Streaming chat endpoint.
* Tries ANTHROPIC_API_KEY first (via Anthropic SDK);
@ -145,6 +185,8 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`))
} catch { /* stream already closed */ }
}, KEEPALIVE_INTERVAL_MS)
let emittedText = false
let debugFile: string | undefined
try {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
@ -154,52 +196,76 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
let prompt = lastUserMsg?.content ?? ''
// Remove CLAUDECODE env to allow running from within a CC terminal
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const env = buildClaudeAgentEnv()
debugFile = getClaudeAgentDebugFilePath()
const claudePath = resolveClaudeCli()
const thinking = getAgentThinkingConfig(body)
const q = query({
prompt,
options: {
systemPrompt: body.system,
model: model || 'claude-sonnet-4-6',
maxTurns: 1,
includePartialMessages: true,
tools: [],
plugins: [],
permissionMode: 'plan',
persistSession: false,
...(body.effort ? { effort: body.effort } : {}),
...(thinking ? { thinking } : {}),
env,
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
const runQuery = async (modelOverride?: string) => {
const q = query({
prompt,
options: {
systemPrompt: body.system,
...(modelOverride ? { model: modelOverride } : {}),
maxTurns: 1,
includePartialMessages: true,
tools: [],
plugins: [],
permissionMode: 'plan',
persistSession: false,
...(body.effort ? { effort: body.effort } : {}),
...(thinking ? { thinking } : {}),
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
for await (const message of q) {
if (message.type === 'stream_event') {
const ev = message.event
if (ev.type === 'content_block_delta') {
if (ev.delta.type === 'text_delta') {
clearInterval(pingTimer)
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
} else if (ev.delta.type === 'thinking_delta') {
// Keep pings alive during thinking — only stop on text output
const data = JSON.stringify({ type: 'thinking', content: (ev.delta as any).thinking })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
try {
for await (const message of q) {
if (message.type === 'stream_event') {
const ev = message.event
if (ev.type === 'content_block_delta') {
if (ev.delta.type === 'text_delta') {
emittedText = true
clearInterval(pingTimer)
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
} else if (ev.delta.type === 'thinking_delta') {
// Keep pings alive during thinking — only stop on text output
const data = JSON.stringify({ type: 'thinking', content: (ev.delta as any).thinking })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
}
}
} else if (message.type === 'result') {
const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error)
if (message.subtype !== 'success' || isErrorResult) {
const errors = 'errors' in message ? (message.errors as string[]) : []
const resultText = 'result' in message ? String(message.result ?? '') : ''
const content = errors.join('; ') || resultText || `Query ended with: ${message.subtype}`
if (modelOverride && !emittedText && shouldRetryClaudeWithoutModel(content)) {
throw new Error(content)
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
)
}
}
}
} else if (message.type === 'result') {
if (message.subtype !== 'success') {
const errors = 'errors' in message ? (message.errors as string[]) : []
const content = errors.join('; ') || `Query ended with: ${message.subtype}`
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
)
}
} finally {
q.close()
}
}
try {
await runQuery(model)
} catch (error) {
const raw = error instanceof Error ? error.message : String(error)
if (model && !emittedText && shouldRetryClaudeWithoutModel(raw)) {
await runQuery(undefined)
} else {
throw error
}
}
@ -207,7 +273,10 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
)
} catch (error) {
const content = error instanceof Error ? error.message : 'Unknown error'
const rawContent = error instanceof Error ? error.message : 'Unknown error'
const tail = await readDebugTail(debugFile)
const hintedContent = buildClaudeExitHint(rawContent, tail)
const content = hintedContent ?? rawContent
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
)

View file

@ -1,6 +1,10 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import type { GroupedModel } from '../../../src/types/agent-settings'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import {
buildClaudeAgentEnv,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
interface ConnectBody {
agent: 'claude-code' | 'codex-cli' | 'opencode'
@ -44,20 +48,20 @@ async function connectClaudeCode(): Promise<ConnectResult> {
try {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
const claudePath = resolveClaudeCli()
const q = query({
prompt: '',
options: {
model: 'claude-sonnet-4-6',
maxTurns: 1,
tools: [],
permissionMode: 'plan',
persistSession: false,
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
@ -81,8 +85,11 @@ async function connectClaudeCode(): Promise<ConnectResult> {
/** Map raw Agent SDK errors to user-friendly messages */
function friendlyClaudeError(raw: string): string {
if (/process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)) {
return 'Claude Code exited with code 1. Check your model mapping (e.g. ANTHROPIC_MODEL / default Sonnet model) and run "claude login" if needed.'
}
if (/exited with code/i.test(raw)) {
return 'Unable to connect. Please run "claude login" in your terminal first.'
return 'Unable to connect. Claude Code process exited unexpectedly.'
}
if (/not found|ENOENT/i.test(raw)) {
return 'Claude Code CLI not found. Please install it first.'

View file

@ -1,6 +1,10 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { runCodexExec } from '../../utils/codex-client'
import {
buildClaudeAgentEnv,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
interface GenerateBody {
system: string
@ -12,6 +16,10 @@ interface GenerateBody {
effort?: 'low' | 'medium' | 'high' | 'max'
}
function shouldRetryClaudeWithoutModel(raw: string): boolean {
return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)
}
/**
* Non-streaming AI generation endpoint.
* Tries ANTHROPIC_API_KEY first (via Anthropic SDK);
@ -67,12 +75,12 @@ async function generateViaAnthropicSDK(apiKey: string, body: GenerateBody, model
/** 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 }> {
try {
const runQuery = async (modelOverride?: string): 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 = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
const claudePath = resolveClaudeCli()
@ -80,30 +88,53 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
prompt: body.message,
options: {
systemPrompt: body.system,
model: model || 'claude-sonnet-4-6',
...(modelOverride ? { model: modelOverride } : {}),
maxTurns: 1,
tools: [],
plugins: [],
permissionMode: 'plan',
persistSession: false,
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
for await (const message of q) {
if (message.type === 'result') {
if (message.subtype === 'success') {
return { text: message.result }
try {
for await (const message of q) {
if (message.type === 'result') {
const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error)
if (message.subtype === 'success' && !isErrorResult) {
return { text: message.result }
}
const errors = 'errors' in message ? (message.errors as string[]) : []
const resultText = 'result' in message ? String(message.result ?? '') : ''
return { error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}` }
}
const errors = 'errors' in message ? (message.errors as string[]) : []
return { error: errors.join('; ') || `Query ended with: ${message.subtype}` }
}
} finally {
q.close()
}
return { error: 'No result received from Claude Agent SDK' }
}
try {
const first = await runQuery(model)
if (model && first.error && shouldRetryClaudeWithoutModel(first.error)) {
return await runQuery(undefined)
}
return first
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
const message = error instanceof Error ? error.message : String(error)
if (model && shouldRetryClaudeWithoutModel(message)) {
try {
return await runQuery(undefined)
} catch (retryError) {
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError)
return { error: retryMessage }
}
}
return { error: message }
}
}

View file

@ -1,4 +1,8 @@
import { defineEventHandler } from 'h3'
import {
buildClaudeAgentEnv,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
interface ModelInfo {
value: string
@ -20,18 +24,18 @@ export default defineEventHandler(async () => {
try {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
const q = query({
prompt: '',
options: {
model: 'claude-sonnet-4-6',
maxTurns: 1,
tools: [],
permissionMode: 'plan',
persistSession: false,
env,
...(debugFile ? { debugFile } : {}),
},
})

View file

@ -1,5 +1,9 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import {
buildClaudeAgentEnv,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
import { writeFile, unlink, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -12,6 +16,10 @@ interface ValidateBody {
provider?: string
}
function shouldRetryClaudeWithoutModel(raw: string): boolean {
return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)
}
/**
* Vision-based validation endpoint.
* Accepts a base64 PNG screenshot and a text prompt, sends multimodal
@ -111,8 +119,8 @@ async function validateViaAgentSDK(
const { query } = await import('@anthropic-ai/claude-agent-sdk')
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
const claudePath = resolveClaudeCli()
@ -125,31 +133,59 @@ ${body.system}
Output ONLY the JSON object, no markdown fences, no explanation.`
const q = query({
prompt,
options: {
model: model || 'claude-sonnet-4-6',
maxTurns: 2,
tools: [],
plugins: [],
permissionMode: 'plan',
persistSession: false,
env,
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
const runQuery = async (modelOverride?: string): Promise<{ text: string; skipped?: boolean; error?: string }> => {
const q = query({
prompt,
options: {
...(modelOverride ? { model: modelOverride } : {}),
maxTurns: 2,
tools: [],
plugins: [],
permissionMode: 'plan',
persistSession: false,
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
for await (const message of q) {
if (message.type === 'result') {
if (message.subtype === 'success') {
return { text: message.result }
try {
for await (const message of q) {
if (message.type === 'result') {
const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error)
if (message.subtype === 'success' && !isErrorResult) {
return { text: message.result }
}
const errors = 'errors' in message ? (message.errors as string[]) : []
const resultText = 'result' in message ? String(message.result ?? '') : ''
return { error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}`, text: '' }
}
}
const errors = 'errors' in message ? (message.errors as string[]) : []
return { error: errors.join('; ') || `Query ended with: ${message.subtype}`, text: '' }
} finally {
q.close()
}
return { text: '', skipped: true }
}
return { text: '', skipped: true }
try {
const first = await runQuery(model)
if (model && first.error && shouldRetryClaudeWithoutModel(first.error)) {
return await runQuery(undefined)
}
return first
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (model && shouldRetryClaudeWithoutModel(message)) {
try {
return await runQuery(undefined)
} catch (retryError) {
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError)
return { error: retryMessage, text: '' }
}
}
return { error: message, text: '' }
}
} finally {
// Clean up temp file
try {

View file

@ -0,0 +1,79 @@
import { mkdirSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
type EnvLike = Record<string, string | undefined>
interface ClaudeSettings {
env?: Record<string, unknown>
}
function normalizeEnvValue(value: unknown): string | undefined {
if (value == null) return undefined
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return undefined
}
function readClaudeSettingsEnv(): EnvLike {
try {
const path = join(homedir(), '.claude', 'settings.json')
const raw = readFileSync(path, 'utf-8')
const parsed = JSON.parse(raw) as ClaudeSettings
if (!parsed.env || typeof parsed.env !== 'object') return {}
const env: EnvLike = {}
for (const [key, value] of Object.entries(parsed.env)) {
const normalized = normalizeEnvValue(value)
if (normalized !== undefined) {
env[key] = normalized
}
}
return env
} catch {
return {}
}
}
/**
* Build env passed to Claude Agent SDK.
* Priority: current process env > ~/.claude/settings.json env.
*/
export function buildClaudeAgentEnv(): EnvLike {
const merged: EnvLike = {
...readClaudeSettingsEnv(),
...(process.env as EnvLike),
}
// Compatibility: some Claude-compatible gateways expose token as ANTHROPIC_AUTH_TOKEN.
// Claude Code primarily understands ANTHROPIC_API_KEY / ANTHROPIC_CUSTOM_HEADERS.
const authToken = merged.ANTHROPIC_AUTH_TOKEN
if (authToken && !merged.ANTHROPIC_API_KEY) {
merged.ANTHROPIC_API_KEY = authToken
}
if (authToken && !merged.ANTHROPIC_CUSTOM_HEADERS) {
merged.ANTHROPIC_CUSTOM_HEADERS = JSON.stringify({
Authorization: `Bearer ${authToken}`,
})
}
// Running inside Claude terminal can break nested Claude invocations.
delete merged.CLAUDECODE
return merged
}
/**
* Force Claude CLI debug output into a writable temp location.
* This avoids crashes in restricted environments where ~/.claude/debug is not writable.
*/
export function getClaudeAgentDebugFilePath(): string | undefined {
try {
const dir = join('/tmp', 'openpencil-claude-debug')
mkdirSync(dir, { recursive: true })
return join(dir, 'claude-agent.log')
} catch {
return undefined
}
}

View file

@ -223,8 +223,8 @@ export function computeLayoutPositions(
const parentH = typeof pH === 'number' ? pH : 100
const pad = resolvePadding(c.padding)
const gap = typeof c.gap === 'number' ? c.gap : 0
const justify = c.justifyContent ?? 'start'
const align = c.alignItems ?? 'start'
const justify = normalizeJustifyContent(c.justifyContent)
const align = normalizeAlignItems(c.alignItems)
const isVertical = layout === 'vertical'
const availW = parentW - pad.left - pad.right
@ -359,5 +359,57 @@ export function computeLayoutPositions(
})
}
function normalizeJustifyContent(
value: unknown,
): 'start' | 'center' | 'end' | 'space_between' | 'space_around' {
if (typeof value !== 'string') return 'start'
const v = value.trim().toLowerCase()
switch (v) {
case 'start':
case 'flex-start':
case 'left':
case 'top':
return 'start'
case 'center':
case 'middle':
return 'center'
case 'end':
case 'flex-end':
case 'right':
case 'bottom':
return 'end'
case 'space_between':
case 'space-between':
return 'space_between'
case 'space_around':
case 'space-around':
return 'space_around'
default:
return 'start'
}
}
function normalizeAlignItems(value: unknown): 'start' | 'center' | 'end' {
if (typeof value !== 'string') return 'start'
const v = value.trim().toLowerCase()
switch (v) {
case 'start':
case 'flex-start':
case 'left':
case 'top':
return 'start'
case 'center':
case 'middle':
return 'center'
case 'end':
case 'flex-end':
case 'right':
case 'bottom':
return 'end'
default:
return 'start'
}
}
// Re-export estimateLineWidth for convenience (used by drag-into-layout etc.)
export { estimateLineWidth }

View file

@ -148,6 +148,11 @@ function shouldSplitByGrapheme(text: string): boolean {
return hasCjk && hasLongCjkRun
}
function isFixedWidthText(node: PenNode): boolean {
if (node.type !== 'text') return false
return node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height'
}
function sizeToNumber(
val: number | string | undefined,
fallback: number,
@ -320,14 +325,9 @@ export function createFabricObject(
if (pw > 0 && ph > 0 && obj.width && obj.height) {
// Uniform scale — preserve aspect ratio so icons don't get squished
const uniformScale = Math.min(pw / obj.width, ph / obj.height)
if (node.iconId) {
// For icon nodes: expand the Fabric bounding box to pw×ph so the selection
// handles always form a perfect square. The path content is naturally centered
// by Fabric's pathOffset (center of path's bbox).
obj.set({ width: pw / uniformScale, height: ph / uniformScale, scaleX: uniformScale, scaleY: uniformScale })
} else {
obj.set({ scaleX: uniformScale, scaleY: uniformScale })
}
// Keep native path width/height. Overriding width/height can shift pathOffset
// and make icons appear visually off-center in logos.
obj.set({ scaleX: uniformScale, scaleY: uniformScale })
}
break
}
@ -352,8 +352,7 @@ export function createFabricObject(
}
// Use Textbox for fixed-width / fixed-size modes (word wrapping).
// Use IText for auto-width mode (no wrapping, expands horizontally).
const growth = node.textGrowth
const useTextbox = growth === 'fixed-width' || growth === 'fixed-width-height' || w > 0
const useTextbox = isFixedWidthText(node)
if (useTextbox) {
obj = new fabric.Textbox(textContent, {
...textProps,

View file

@ -37,6 +37,11 @@ function shouldSplitByGrapheme(text: string): boolean {
return hasCjk && hasLongCjkRun
}
function isFixedWidthText(node: PenNode): boolean {
if (node.type !== 'text') return false
return node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height'
}
export function syncFabricObject(
obj: FabricObjectWithPenId,
node: PenNode,
@ -122,6 +127,7 @@ export function syncFabricObject(
? node.content
: node.content.map((s) => s.text).join('')
const w = sizeToNumber(node.width, 0)
const fixedWidthText = isFixedWidthText(node)
const fontSize = node.fontSize ?? 16
const splitByGrapheme = shouldSplitByGrapheme(content)
obj.set({
@ -139,8 +145,8 @@ export function syncFabricObject(
})
if (obj instanceof fabric.Textbox) {
obj.set({ splitByGrapheme } as Partial<fabric.Textbox>)
if (fixedWidthText && w > 0) obj.set({ width: w })
}
if (w > 0) obj.set({ width: w })
break
}
case 'polygon':
@ -187,13 +193,9 @@ export function syncFabricObject(
if (node.type === 'path') {
// Uniform scale — preserve aspect ratio so icons don't get squished
const uniformScale = Math.min(w / nw, h / nh)
if (node.iconId) {
// Expand bounding box to w×h so selection handles form a perfect square.
// Path content is centered automatically by Fabric's pathOffset.
obj.set({ width: w / uniformScale, height: h / uniformScale, scaleX: uniformScale, scaleY: uniformScale })
} else {
obj.set({ width: nw, height: nh, scaleX: uniformScale, scaleY: uniformScale })
}
// Keep native width/height to avoid pathOffset drift that can visually
// offset icons inside logo containers.
obj.set({ width: nw, height: nh, scaleX: uniformScale, scaleY: uniformScale })
} else {
obj.set({ width: nw, height: nh, scaleX: w / nw, scaleY: h / nh })
}

View file

@ -69,10 +69,19 @@ export function estimateLineWidth(
return Math.max(0, width)
}
function widthSafetyFactor(text: string): number {
// Latin fonts vary a lot by weight/family; use a larger safety margin to
// avoid underestimating width and causing accidental wraps.
return hasCjkText(text) ? 1.06 : 1.14
}
export function estimateTextWidth(text: string, fontSize: number, letterSpacing = 0): number {
const lines = text.split(/\r?\n/)
const maxLine = lines.reduce((max, line) =>
Math.max(max, estimateLineWidth(line, fontSize, letterSpacing)), 0)
const maxLine = lines.reduce((max, line) => {
const lineWidth = estimateLineWidth(line, fontSize, letterSpacing)
const safeLineWidth = lineWidth * widthSafetyFactor(line)
return Math.max(max, safeLineWidth)
}, 0)
return maxLine
}
@ -154,7 +163,7 @@ export function estimateTextHeight(node: PenNode, availableWidth?: number): numb
const letterSpacing = (typeof n.letterSpacing === 'number' ? n.letterSpacing : 0)
const rawLines = content.split(/\r?\n/)
const wrappedLineCount = rawLines.reduce((sum, line) => {
const lineWidth = estimateLineWidth(line, fontSize, letterSpacing)
const lineWidth = estimateLineWidth(line, fontSize, letterSpacing) * widthSafetyFactor(line)
return sum + Math.max(1, Math.ceil(lineWidth / textWidth))
}, 0)

View file

@ -435,8 +435,7 @@ export function useCanvasSync() {
// (IText ↔ Textbox are different Fabric classes).
if (existingObj && node.type === 'text') {
const growth = node.textGrowth
const w = typeof node.width === 'number' ? node.width : 0
const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height' || w > 0
const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height'
const isTextbox = existingObj instanceof fabric.Textbox
if (needsTextbox !== isTextbox) {
canvas.remove(existingObj)

View file

@ -50,6 +50,17 @@ const CORNER_CLASSES: Record<PanelCorner, string> = {
'bottom-right': 'bottom-3 right-3',
}
function resolveNextModel(
models: Array<{ value: string }>,
currentModel: string,
preferredModel: string,
): string | null {
if (models.length === 0) return null
if (models.some((m) => m.value === currentModel)) return currentModel
if (models.some((m) => m.value === preferredModel)) return preferredModel
return models[0].value
}
/**
* Minimized AI bar a compact clickable pill.
* Parent is responsible for placing it in the layout.
@ -97,8 +108,10 @@ export default function AIChatPanel() {
const chatTitle = useAIStore((s) => s.chatTitle)
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
const toggleMinimize = useAIStore((s) => s.toggleMinimize)
const hydrateModelPreference = useAIStore((s) => s.hydrateModelPreference)
const model = useAIStore((s) => s.model)
const setModel = useAIStore((s) => s.setModel)
const selectModel = useAIStore((s) => s.selectModel)
const availableModels = useAIStore((s) => s.availableModels)
const setAvailableModels = useAIStore((s) => s.setAvailableModels)
const modelGroups = useAIStore((s) => s.modelGroups)
@ -106,6 +119,7 @@ export default function AIChatPanel() {
const isLoadingModels = useAIStore((s) => s.isLoadingModels)
const setLoadingModels = useAIStore((s) => s.setLoadingModels)
const providers = useAgentSettingsStore((s) => s.providers)
const providersHydrated = useAgentSettingsStore((s) => s.isHydrated)
const [modelDropdownOpen, setModelDropdownOpen] = useState(false)
const { input, setInput, handleSend } = useChatHandlers()
@ -113,9 +127,20 @@ export default function AIChatPanel() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Ensure model preference is restored from localStorage on page refresh.
useEffect(() => {
hydrateModelPreference()
}, [hydrateModelPreference])
// Build model list from connected providers in agent-settings-store.
// Falls back to Agent SDK legacy fetch when no providers are connected.
useEffect(() => {
if (!providersHydrated) {
setLoadingModels(true)
return
}
let cancelled = false
const connectedProviders = (Object.keys(providers) as AIProviderType[]).filter(
(p) => providers[p].isConnected && (providers[p].models?.length ?? 0) > 0,
)
@ -141,26 +166,49 @@ export default function AIChatPanel() {
)
setModelGroups(groups)
setAvailableModels(flat)
// If current model not in list, select first
if (!flat.some((m) => m.value === model)) {
setModel(flat[0].value)
// Keep current when valid; otherwise restore remembered model; otherwise fallback to first.
const { model: currentModel, preferredModel } = useAIStore.getState()
const nextModel = resolveNextModel(flat, currentModel, preferredModel)
if (nextModel && nextModel !== currentModel) {
setModel(nextModel)
}
setLoadingModels(false)
} else {
// No providers connected — fall back to Agent SDK legacy model list
setModelGroups([])
setLoadingModels(true)
fetchAvailableModels().then((models) => {
return
}
// No providers connected — fall back to Agent SDK legacy model list.
setModelGroups([])
setLoadingModels(true)
fetchAvailableModels()
.then((models) => {
if (cancelled) return
// Provider state might have changed while request was in flight.
const latestProviders = useAgentSettingsStore.getState().providers
const stillNoConnected = (Object.keys(latestProviders) as AIProviderType[]).every(
(p) => !(latestProviders[p].isConnected && (latestProviders[p].models?.length ?? 0) > 0),
)
if (!stillNoConnected) return
if (models.length > 0) {
setAvailableModels(models)
if (!models.some((m) => m.value === model)) {
setModel(models[0].value)
const { model: currentModel, preferredModel } = useAIStore.getState()
const nextModel = resolveNextModel(models, currentModel, preferredModel)
if (nextModel && nextModel !== currentModel) {
setModel(nextModel)
}
}
setLoadingModels(false)
})
.finally(() => {
if (!cancelled) {
setLoadingModels(false)
}
})
return () => {
cancelled = true
}
}, [providers]) // eslint-disable-line react-hooks/exhaustive-deps
}, [providers, providersHydrated]) // eslint-disable-line react-hooks/exhaustive-deps
// Close model dropdown when clicking outside
useEffect(() => {
@ -549,7 +597,7 @@ export default function AIChatPanel() {
key={m.value}
type="button"
onClick={() => {
setModel(m.value)
selectModel(m.value)
setModelDropdownOpen(false)
}}
className={cn(
@ -581,7 +629,7 @@ export default function AIChatPanel() {
key={m.value}
type="button"
onClick={() => {
setModel(m.value)
selectModel(m.value)
setModelDropdownOpen(false)
}}
className={cn(

View file

@ -19,6 +19,58 @@ const POSITIONS = ['start', 'center', 'end'] as const
type GapMode = 'numeric' | 'space_between' | 'space_around'
type PaddingMode = 'single' | 'axis' | 'individual'
type JustifyValue = 'start' | 'center' | 'end' | 'space_between' | 'space_around'
type AlignValue = 'start' | 'center' | 'end'
function normalizeJustifyValue(value: unknown): JustifyValue {
if (typeof value !== 'string') return 'start'
const v = value.trim().toLowerCase()
switch (v) {
case 'start':
case 'flex-start':
case 'left':
case 'top':
return 'start'
case 'center':
case 'middle':
return 'center'
case 'end':
case 'flex-end':
case 'right':
case 'bottom':
return 'end'
case 'space_between':
case 'space-between':
return 'space_between'
case 'space_around':
case 'space-around':
return 'space_around'
default:
return 'start'
}
}
function normalizeAlignValue(value: unknown): AlignValue {
if (typeof value !== 'string') return 'start'
const v = value.trim().toLowerCase()
switch (v) {
case 'start':
case 'flex-start':
case 'left':
case 'top':
return 'start'
case 'center':
case 'middle':
return 'center'
case 'end':
case 'flex-end':
case 'right':
case 'bottom':
return 'end'
default:
return 'start'
}
}
// ---------------------------------------------------------------------------
// Padding Icons (small SVG indicators for V/H padding)
@ -109,8 +161,8 @@ function AlignmentGrid({
onUpdate,
}: {
layout: 'none' | 'vertical' | 'horizontal'
justifyContent: string
alignItems: string
justifyContent: JustifyValue
alignItems: AlignValue
isSpaceMode: boolean
onUpdate: (updates: Partial<PenNode>) => void
}) {
@ -615,8 +667,8 @@ export default function LayoutSection({
const layout = node.layout ?? 'none'
const hasLayout = layout !== 'none'
const justifyContent = node.justifyContent ?? 'start'
const alignItems = node.alignItems ?? 'start'
const justifyContent = normalizeJustifyValue(node.justifyContent)
const alignItems = normalizeAlignValue(node.alignItems)
const rawGap = node.gap
const gap = typeof rawGap === 'number' ? rawGap : 0

View file

@ -58,6 +58,9 @@ RULES:
- 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.
@ -207,6 +210,10 @@ DESIGN GUIDELINES:
- 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

View file

@ -97,6 +97,10 @@ DESIGN RULES:
- CJK fonts: use "Noto Sans SC" (CN) / "Noto Sans JP" (JP) / "Noto Sans KR" (KR) for headings. Never "Space Grotesk"/"Manrope" for CJK. CJK lineHeight: 1.3-1.4 headings, 1.6-1.8 body. CJK letterSpacing: 0, never negative.
- Card rows: ALL cards use width="fill_container" + height="fill_container" for even distribution and equal height. Dense rows (5+): use short titles, max 2 text blocks per card.
- Icons: "path" nodes with descriptive names ("SearchIcon", "MenuIcon" etc.). System auto-resolves to SVG. Size 16-24px. Never use emoji as icons.
- Semantic inputs should include affordance icons when appropriate:
- search bars: leading SearchIcon
- password fields: trailing EyeIcon or EyeOffIcon (use justifyContent="space_between")
- email/account fields: leading MailIcon or UserIcon
- Phone mockup: ONE frame, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. No ellipse for mockups. At most ONE centered text child inside.
- Never ellipse for decorative shapes use frame/rectangle with cornerRadius.
- Use style guide colors/fonts consistently. No random colors.

View file

@ -203,6 +203,11 @@ export function resolveTreePostPass(
normalizeFormInputWidths(root, children)
}
// --- Input trailing icon alignment ---
if (root.layout === 'horizontal' && children.length >= 2) {
normalizeInputTrailingIconAlignment(root, children)
}
// --- Text height estimation ---
if (root.layout && root.layout !== 'none') {
fixTextHeights(root, children, canvasWidth)
@ -361,6 +366,40 @@ function normalizeFormInputWidths(
}
}
function normalizeInputTrailingIconAlignment(
parent: FrameNode,
children: PenNode[],
): void {
if (parent.role !== 'input' && parent.role !== 'form-input') return
if (parent.justifyContent && parent.justifyContent !== 'start') return
const visibleChildren = children.filter((c) => c.visible !== false)
if (visibleChildren.length < 2) return
const trailing = visibleChildren[visibleChildren.length - 1]
if (!isIconLikeNode(trailing)) return
const hasTextBeforeTrailing = visibleChildren
.slice(0, -1)
.some((child) => child.type === 'text')
if (!hasTextBeforeTrailing) return
;(parent as unknown as Record<string, unknown>).justifyContent = 'space_between'
}
function isIconLikeNode(node: PenNode): boolean {
if (node.type === 'path' || node.type === 'image') return true
if (node.type === 'frame') {
if (node.role === 'icon' || node.role === 'icon-button') return true
const w = toSizeNumber(node.width, 0)
const h = toSizeNumber(node.height, 0)
if (w > 0 && h > 0 && Math.max(w, h) <= 32) return true
}
return false
}
function fixTextHeights(
parent: FrameNode,
children: PenNode[],

View file

@ -15,6 +15,7 @@ interface PersistedState {
interface AgentSettingsState extends PersistedState {
dialogOpen: boolean
isHydrated: boolean
connectProvider: (
provider: AIProviderType,
@ -64,6 +65,7 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
providers: { ...DEFAULT_PROVIDERS },
mcpIntegrations: [...DEFAULT_MCP_INTEGRATIONS],
dialogOpen: false,
isHydrated: false,
connectProvider: (provider, method, models) =>
set((s) => ({
@ -126,6 +128,8 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
if (data.mcpIntegrations) set({ mcpIntegrations: data.mcpIntegrations })
} catch {
// ignore
} finally {
set({ isHydrated: true })
}
},
}))

View file

@ -4,6 +4,33 @@ import type { ModelGroup } from '@/types/agent-settings'
export type PanelCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'
const MODEL_PREFERENCE_STORAGE_KEY = 'openpencil-ai-model-preference'
function readStoredModelPreference(): string | null {
if (typeof window === 'undefined') return null
try {
const value = window.localStorage.getItem(MODEL_PREFERENCE_STORAGE_KEY)
if (!value || value.trim().length === 0) return null
return value
} catch {
return null
}
}
function writeStoredModelPreference(model: string): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(MODEL_PREFERENCE_STORAGE_KEY, model)
} catch {
// Ignore storage failures (private mode, quota, etc.)
}
}
// Keep SSR/CSR first render deterministic to avoid hydration mismatch.
// Real preference is loaded on mount via hydrateModelPreference().
const initialPreferredModel = DEFAULT_MODEL
export interface AIModelInfo {
value: string
displayName: string
@ -18,6 +45,7 @@ interface AIState {
generatedCode: string
codeFormat: 'react-tailwind' | 'html-css' | 'react-inline'
model: string
preferredModel: string
availableModels: AIModelInfo[]
modelGroups: ModelGroup[]
isLoadingModels: boolean
@ -29,6 +57,8 @@ interface AIState {
setChatTitle: (title: string) => void
setGenerationProgress: (progress: { current: number; total: number } | null) => void
hydrateModelPreference: () => void
selectModel: (model: string) => void
setModel: (model: string) => void
setAvailableModels: (models: AIModelInfo[]) => void
setModelGroups: (groups: ModelGroup[]) => void
@ -53,7 +83,8 @@ export const useAIStore = create<AIState>((set) => ({
activeTab: 'chat',
generatedCode: '',
codeFormat: 'react-tailwind',
model: 'claude-sonnet-4-5-20250929',
model: initialPreferredModel,
preferredModel: initialPreferredModel,
availableModels: [],
modelGroups: [],
isLoadingModels: false,
@ -65,6 +96,12 @@ export const useAIStore = create<AIState>((set) => ({
setChatTitle: (chatTitle) => set({ chatTitle }),
setGenerationProgress: (generationProgress) => set({ generationProgress }),
hydrateModelPreference: () => {
const stored = readStoredModelPreference()
if (!stored) return
set({ model: stored, preferredModel: stored })
},
addMessage: (msg) =>
set((s) => ({ messages: [...s.messages, msg] })),
@ -90,6 +127,10 @@ export const useAIStore = create<AIState>((set) => ({
setCodeFormat: (codeFormat) => set({ codeFormat }),
selectModel: (model) => {
writeStoredModelPreference(model)
set({ model, preferredModel: model })
},
setModel: (model) => set({ model }),
setAvailableModels: (availableModels) => set({ availableModels }),
setModelGroups: (modelGroups) => set({ modelGroups }),