AI chat polishing: skeleton workflow, batched tools, visual feedback, stock photos (#111)

* Fix text rendering: always use paragraph wrapping, font fallback, batch tools, severity levels

Rendering:
- renderText: always use paragraph when fontProvider available, not just
  when node-specific font loaded — fixes single-line overflow for Georgia/
  non-Inter fonts
- buildParagraph: add Inter as fallback fontFamily so paragraph wraps text
  even when requested font not yet loaded
- measureTextNode/buildTextPicture: remove isNodeFontLoaded gate — measure
  with fallback font for consistent layout↔rendering
- TEXT_PICTURE_KEYS: add width/height so textPicture invalidates on resize
- renderText drawText fallback: clip to node bounds
- onAfterExecute: invalidate textPictures after font load

Layout:
- estimateTextSize: accept maxWidth, compute multi-line height
- toJSON: depth parameter with childCount fallback

AI tools:
- batch_update tool: multiple set_layout/set_layout_child in one call
- describe: accept ids array for multi-node inspection
- Issue severity (error/warning/info) with pattern classification
- checkNestedFlexWithoutFill: cross-axis fill tolerance
- CORE_TOOLS trimmed 34→27, rest moved to EXTENDED
- System prompt: batch_update, describe ids, severity rules, font/text
  wrapping guidance, card grid patterns

* Add Pexels stock photo integration for AI chat

- stock_photo tool: search Pexels by query, download image, apply as
  IMAGE fill on any node. Picks resolution based on node dimensions.
- Pexels API key stored in localStorage, synced to core via
  setPexelsApiKey on change
- ProviderSettings UI: Pexels API key input with link to get free key
- System prompt: stock photo usage guidance
- Added to CORE_TOOLS so AI can use it in every session

* Batch stock_photo + calc, 4-phase workflow, fix wrap false positives

- stock_photo: accepts JSON array of requests, all fetched in parallel.
  Rejects nodes with children (leaf shapes only). One call replaces 14.
- calc: accepts JSON array of expressions or single string. One call
  replaces 3 separate calc invocations.
- System prompt: 4-phase workflow (plan → skeleton → content → polish).
  Batch stock_photo and calc examples. Updated step budget.
- describe-layout-issues: skip 'nested flex may collapse' for wrap
  containers (pill/tag frames are intentionally HUG). Extract
  childNeedsFill helper to reduce complexity.

* Fix nested flex false positive for pills, single-root render rule

- describe-layout-issues: skip 'nested flex may collapse' when a sibling
  already has grow or fill — pill/badge frames next to flex-grow content
  are intentionally small (e.g. BreakingLabel next to ticker text)
- System prompt: render must have exactly one root element. Stock_photo
  401 fallback: tell user, don't eval gradients.

* Rework workflow: render complete sections, not empty frames

Phase 2 now prescribes 5-7 renders, each producing a complete section
(nav, hero, stories, opinions, sidebar, footer) with placeholder images,
real text, proper auto-layout. No more empty frames to fill later.
Removes the fill content phase — skeleton IS the content, just without
real photos. Phase 3 is now just polish (batch stock_photo + fixes).
Step budget: 12-15 steps instead of 15-30.

* Pexels key input: match AI key behavior

Same pattern as the AI provider key — intermediate ref, 'Key saved —
enter new to replace' placeholder, Clear button, save on change. No
longer exposes the raw key in the input field.

* No empty frames in skeleton, select nodes after each tool

- System prompt: explicit rules against empty frames, no pre-creating
  wrapper frames for future content. Each render = complete section with
  visible placeholders. Render 1 = only nav+ticker, not the whole page
  with empty containers.
- tools.ts: set selection to flash node IDs after each mutating tool,
  so user sees which section AI is working on (selection border persists
  vs flash which is transient).

* AI overlay: pulsing blue while working, green flash on done; skeleton workflow

Rendering:
- ai-overlays.ts: new module with pulsing blue border (sinusoidal
  opacity 0.3-0.8 at 1.5s period) for nodes AI is working on, green
  fade-out flash (800ms) when done
- renderer.ts: aiMarkActive/aiMarkDone/aiClearAll methods, drawn after
  regular flashes
- constants.ts: AI_ACTIVE_COLOR, AI_DONE_COLOR, timing constants

Integration:
- ai-adapter.ts: onBeforeExecute now receives args (for target ID)
- tools.ts: marks target node (id or parent_id) active before tool
  executes, marks done via onFlashNodes after completion
- editor.ts: aiMarkActive/aiMarkDone/aiClearAll forwarded to renderer

Workflow:
- System prompt: 3-phase skeleton workflow. Phase 2 renders gray
  placeholder blocks (Rectangle bg=#E2E8F0) for every section — page
  looks like wireframe before any real content. Phase 3 replaces
  skeletons with real content. Phase 4 batch stock_photo + polish.

* render replace_id: atomic skeleton-to-content swap, fix AI overlay lifecycle

render tool:
- New replace_id parameter — renders new content, places it at the
  skeleton's position in parent, then deletes the skeleton. No visual
  gap between skeleton removal and content appearance.

AI overlay fixes:
- Track lastActiveIds across before/after execute — aiMarkDone clears
  the exact nodes that were marked active, not just the result IDs
- aiFlashDone: direct green flash on result nodes (not gated by
  _aiActiveNodes membership)
- replace_id included in target ID detection for active marking

System prompt:
- Phase 3 uses render({ replace_id }) instead of delete_node + render

* Fix gradient fills, nested flex false positives, render insert_index, h=hug prompt

- set_fill: gradient support with color_end + direction (top-bottom,
  left-right, etc). Uses proper Matrix type. No more eval for gradients.
- describe-layout-issues: skip nested flex warning for all non-MIN
  alignments (SPACE_BETWEEN, CENTER, MAX) — not just SPACE_BETWEEN
- render: insert_index parameter for controlling child order
- system prompt: page frame h="hug" not fixed height
- AI overlay: mark active on any tool with target ID (not just
  mutating), clear previous active in onBeforeExecute

* Add desktop business news site example to system prompt

Full workflow example: skeleton with gray placeholders → replace_id
content fills → batch stock_photo. Shows 12-col grid pattern, card
components, section headers, sidebar widgets, h=hug page frame.
Complements the existing mobile app UI example.

* Drop blue active pulse, keep only green done flash

Blue active marking was invisible — tool execution is 20-80ms (one
frame), AI thinking happens between tool calls where no hooks fire.
Green done flash on result nodes works and is visible.

* Blue pulse on skeleton during AI tool streaming

Watch streaming message parts in ChatPanel — when a tool part appears
with state 'call'/'partial-call' and has replace_id or parent_id in
args, mark that node active (blue pulse). When tool transitions to
'output-available', mark done (green flash). This shows the pulsing
blue border on the skeleton wireframe frame while AI generates JSX,
and switches to green when the content replaces it.

* AI overlay debug log section, watch tool parts by toolCallId

- chat-debug.ts: aiOverlayLog array + AI OVERLAY LOG section in debug
  output showing every part/mark-active/mark-done event with timestamps
- ChatPanel.vue: check parts by toolCallId instead of isToolUIPart,
  log all overlay events to aiOverlayLog for debugging

* Fix AI overlay: use 'input' not 'args' for tool part params

Vercel AI SDK UIToolInvocation uses 'input' field (not 'args') for
tool call parameters. The watch was checking p.args which was always
undefined, so no overlay events ever fired. Also use p.type instead
of p.toolName for the log.

* Move AI overlay to onBeforeExecute/onFlashNodes, reorder render params

- onBeforeExecute: mark replace_id/parent_id active (blue pulse)
- onFlashNodes: clear active, flash done on new nodes (green)
- Reorder render tool params: replace_id first so streaming partial
  input reveals target ID before JSX body
- Remove ChatPanel watch — all overlay logic in tools.ts now
- Add aiClearActive() to renderer (clears active without touching
  done flashes)

* Wait 2 rAF frames after marking active before tool execute

onBeforeExecute marks the target node active (blue pulse), but
execute ran immediately after without yielding to the render loop.
The blue frame was never painted because markActive → execute →
clearActive all happened in the same JS task. Now await two
requestAnimationFrame callbacks so the canvas renders at least one
blue-pulse frame before execution starts.

* Add comprehensive AI overlay debug logging to chat debug log

Both streaming watch (ChatPanel) and tool execution hooks (tools.ts)
now log every event to aiOverlayLog with full state: renderer exists,
activeNodes count, hasFlashes, node existence. This will show exactly
what happens and why the blue pulse is or isn't visible.

* Filter partial node IDs in streaming overlay watch

During input-streaming, replace_id may be partially received (e.g.
'0:' without the number part). Guard with isValidNodeId check.

Also refactor watch callback into collectPendingTargets +
syncOverlayState to stay under complexity limit.

Analysis of last debug log:
- Hero: only 235ms active (streaming didn't deliver replace_id early)
- Stories: 14.8s active — should be clearly visible
- Opinions: 12.5s, LatestNews: 11.5s, Stocks: 9.8s — all long enough
- Hero was the only one too fast to notice

* Fix undo race, JSX pragma conflict, partial ID overlay, add deletion log

- Fix undo snapshot race condition: replace shared `let beforeSnapshot`
  with a per-tool stack. Vercel AI SDK runs tool calls from one step via
  Promise.all — concurrent mutating tools overwrote the shared snapshot,
  causing wrong undo entries and potential content loss.

- Fix 'h is not a function' JSX error: rename sucrase pragma from `h`
  to `__h`. When AI writes `.map((h, i) => ...)`, the loop variable
  shadowed the pragma, crashing render.

- Fix partial node ID `0:1` in overlay: strengthen `isValidNodeId` to
  require both parts ≥2 chars and verify node exists in graph. During
  streaming, `replace_id: "0:1811"` arrives as `0:1` → `0:18` → etc.

- Add deletion log to SceneGraph: `_deletionLog` records id, name,
  type, parentId, timestamp, and truncated stack for last 200 deletions.
  Included in debug log as NODE DELETION LOG section to diagnose the
  intermittent frame disappearance bug.

* Reduce find_nodes spam and describe bloat in system prompt

- Instruct AI to reuse IDs from render results and describe output
  instead of calling find_nodes to rediscover them (saves 8+ calls)
- Recommend depth=1 for full-page describe, depth=2 only for sections

* Auto-depth describe + validate-after-render workflow

Describe tool now auto-picks depth based on subtree size when depth
is omitted: ≤15 nodes → depth 4, ≤40 → 3, ≤100 → 2, larger → 1.
Max raised to 5 for explicit overrides.

Prompt updated: after each render replace_id, call describe on that
block (auto depth gives full detail on small sections). Every 2-3
content renders, describe root at depth=1 to catch cross-section
layout issues early.

* Enforce describe-after-every-render in system prompt

Show explicit render → describe → fix pattern with code example.
AI was skipping per-block validation and batching all describes
at the end, causing compounding layout errors.

* Add describe rules: nested text, missing padding, button padding, row centering

Four new layout issue detectors in describe-issues.ts:

- Nested Text (error): Text node inside Text causes overflow.
  Suggestion: split into separate Text elements in flex row.

- Zero padding container (warning): Frame with fill + text children
  but no padding — content touches edges.

- Button without padding (warning): Button-like frame with children
  but zero horizontal padding.

- Row without vertical centering (warning): Horizontal flex with
  items=start, tall container, and short children — suggests
  items=center.

All verified with bun -e tests including false positive checks.

* Fix gradient alpha loss, add undersize child detection

Gradient fix: set_fill with color_end was overwriting alpha to 1
on both gradient stops (`{ ...c, a: 1 }`). Now preserves original
alpha from parsed hex — #00000000 → a:0, #000000AA → a:0.667.
This caused transparent-to-dark overlays to render as solid black.

New describe rule: detect child significantly narrower than parent
in a container without auto-layout (e.g. 100px Rectangle inside
896px Frame). Suggests adding flex + w=fill. Caught the HeroImg
bug that describe previously missed.

* Auto-include issues in render result, no separate describe needed

Render tool now scans the created subtree for errors/warnings and
includes them in the response. AI gets issues automatically without
needing to call describe — eliminates the pattern where AI skips
describe on 'simple' sidebar blocks.

Example response:
  { id: '0:42', name: 'Card', issues: [
    { node: 'Text', id: '0:45', message: 'no color', severity: 'error' }
  ] }

* Run layout before collecting render issues

Issues were checked on raw node sizes (default 100px) before
computeAllLayouts ran in onAfterExecute. Now render calls
computeAllLayouts inline before collectSubtreeIssues, so issues
reflect actual computed dimensions.

This fixes hundreds of false 'X 100px overflows parent' warnings
that made AI chase nonexistent problems.

* Revert auto-issues in render and layout-before-issues

Reverts 09a9b89 and 70c116a. Auto-including issues in render
result caused false positives (100px default sizes before layout)
and made AI chase nonexistent problems, degrading output quality.

* Clean up AI chat PR: remove debug scaffolding, add stock photo provider adapter

- Delete _deletionLog (stack traces on every node deletion)
- Delete aiOverlayLog and all debug push() calls
- Delete succesful_prompt_diffucult_ux.md (raw debug dump) and tokens.md (empty)
- Remove double-rAF hack from ai-adapter (added 32ms to every tool call)
- Restore simple beforeSnapshot for undo (was matching by tool name)
- Remove 70-line overlay state tracker from ChatPanel.vue
- Remove Math.* regex hack from calc (expr-eval has builtins)
- Refactor stock-photo into provider adapter: Pexels + Unsplash
- Add Unsplash key management to settings UI and use-chat composable

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
This commit is contained in:
Anton Soldatov 2026-03-15 15:42:41 +03:00 committed by GitHub
parent 7ac4c4c1a9
commit 7305dbe634
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1417 additions and 170 deletions

View file

@ -80,6 +80,11 @@ export const FLASH_PADDING = 5
export const FLASH_OVERSHOOT = 30
export const FLASH_RADIUS = 4
export const AI_ACTIVE_COLOR = { r: 0.26, g: 0.52, b: 0.96 }
export const AI_DONE_COLOR = { r: 0.16, g: 0.73, b: 0.36 }
export const AI_PULSE_PERIOD_MS = 1500
export const AI_DONE_DURATION_MS = 800
export const TEXT_SELECTION_COLOR = { r: 0.26, g: 0.52, b: 0.96, a: 0.3 }
export const TEXT_CARET_COLOR = BLACK
export const TEXT_CARET_WIDTH = 1

View file

@ -949,7 +949,7 @@ export class FigmaNodeProxy {
// --- Serialization ---
toJSON(): Record<string, unknown> {
toJSON(maxDepth?: number, currentDepth = 0): Record<string, unknown> {
const n = this._raw()
const obj: Record<string, unknown> = {
id: n.id,
@ -973,7 +973,11 @@ export class FigmaNodeProxy {
}
const children = this[INTERNAL_GRAPH].getChildren(this[INTERNAL_ID])
if (children.length > 0) {
obj.children = children.map((c) => this[INTERNAL_API].wrapNode(c.id).toJSON())
if (maxDepth !== undefined && currentDepth >= maxDepth) {
obj.childCount = children.length
} else {
obj.children = children.map((c) => this[INTERNAL_API].wrapNode(c.id).toJSON(maxDepth, currentDepth + 1))
}
}
return obj
}

View file

@ -246,3 +246,11 @@ export {
} from './kiwi'
export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt'
export {
setPexelsApiKey,
setUnsplashAccessKey,
registerStockPhotoProvider,
setActiveStockPhotoProvider,
getStockPhotoProviders
} from './tools/stock-photo'
export type { StockPhotoProvider, StockPhotoResult } from './tools/stock-photo'

View file

@ -30,14 +30,18 @@ const GLYPH_WIDTH_FACTOR = 0.6
// DO NOT REMOVE: without this, text nodes keep their 100×100 default size
// and blow up every HUG container. The real MeasureFunc (CanvasKit) overrides
// this when available — this is only the fallback.
function estimateTextSize(node: SceneNode): { width: number; height: number } {
function estimateTextSize(node: SceneNode, maxWidth?: number): { width: number; height: number } {
const fontSize = node.fontSize || 14
const text = node.text || ''
const charWidth = fontSize * GLYPH_WIDTH_FACTOR
return {
width: Math.ceil(text.length * charWidth),
height: Math.ceil(fontSize * 1.2),
const singleLineWidth = Math.ceil(text.length * charWidth)
const lineH = (node.lineHeight ?? 0) > 0 ? (node.lineHeight as number) : Math.ceil(fontSize * 1.4)
if (maxWidth && maxWidth > 0 && singleLineWidth > maxWidth) {
const lines = Math.ceil(singleLineWidth / maxWidth)
return { width: maxWidth, height: Math.ceil(lines * lineH) }
}
return { width: singleLineWidth, height: lineH }
}
export function setTextMeasurer(measurer: TextMeasurer | null): void {
@ -365,8 +369,8 @@ function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: Sce
} else if (isText && !globalTextMeasurer && child.textAutoResize !== 'NONE') {
// No CanvasKit — use rough estimate so HUG containers don't inherit
// the 100×100 default SceneNode size. See estimateTextSize above.
const est = estimateTextSize(child)
if (child.textAutoResize === 'WIDTH_AND_HEIGHT') {
const est = estimateTextSize(child)
yogaChild.setWidth(est.width)
yogaChild.setHeight(est.height)
} else if (child.textAutoResize === 'HEIGHT') {
@ -375,6 +379,7 @@ function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: Sce
if (!(!isRow && stretches)) {
yogaChild.setWidth(child.width)
}
const est = estimateTextSize(child, child.width)
yogaChild.setHeight(est.height)
}
} else {
@ -410,7 +415,7 @@ function configureTextLeaf(
if (cached) return cached
const measured = globalTextMeasurer?.(child, maxW)
const result = measured ?? estimateTextSize(child)
const result = measured ?? estimateTextSize(child, maxW)
cache.set(cacheKey, result)
return result
})
@ -434,7 +439,7 @@ function configureTextLeaf(
if (cached) return cached
const measured = globalTextMeasurer?.(child, constraintW)
const result = { width: constraintW, height: measured?.height ?? estimateTextSize(child).height }
const result = { width: constraintW, height: measured?.height ?? estimateTextSize(child, constraintW).height }
cache.set(cacheKey, result)
return result
})

View file

@ -12,7 +12,7 @@ import type { SceneGraph } from '../scene-graph'
*/
export function buildComponent(jsxString: string): () => unknown {
const code = `
const h = React.createElement
const __h = React.createElement
const Frame = 'frame', Text = 'text', Rectangle = 'rectangle', Ellipse = 'ellipse'
const Line = 'line', Star = 'star', Polygon = 'polygon', Vector = 'vector'
const Group = 'group', Section = 'section', View = 'frame', Rect = 'rectangle'
@ -22,7 +22,7 @@ export function buildComponent(jsxString: string): () => unknown {
const result = transform(code, {
transforms: ['typescript', 'jsx'],
jsxPragma: 'h',
jsxPragma: '__h',
production: true
})

View file

@ -0,0 +1,73 @@
import {
FLASH_PADDING,
FLASH_STROKE_WIDTH,
FLASH_RADIUS,
AI_ACTIVE_COLOR,
AI_DONE_COLOR,
AI_PULSE_PERIOD_MS,
AI_DONE_DURATION_MS
} from '../constants'
import type { SceneGraph } from '../scene-graph'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
function drawAiNodeOverlay(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
nodeId: string,
color: { r: number; g: number; b: number },
opacity: number
): void {
const node = graph.getNode(nodeId)
if (!node) return
const ck = r.ck
const abs = graph.getAbsolutePosition(nodeId)
const cx = (abs.x + node.width / 2) * r.zoom + r.panX
const cy = (abs.y + node.height / 2) * r.zoom + r.panY
const hw = (node.width / 2) * r.zoom
const hh = (node.height / 2) * r.zoom
const pad = FLASH_PADDING
if (!r._flashPaint) {
r._flashPaint = new ck.Paint()
r._flashPaint.setStyle(ck.PaintStyle.Stroke)
r._flashPaint.setAntiAlias(true)
}
const paint = r._flashPaint
paint.setColor(ck.Color4f(color.r, color.g, color.b, opacity))
paint.setStrokeWidth(FLASH_STROKE_WIDTH)
canvas.save()
if (node.rotation !== 0) canvas.rotate(node.rotation, cx, cy)
const rect = ck.RRectXY(
ck.LTRBRect(cx - hw - pad, cy - hh - pad, cx + hw + pad, cy + hh + pad),
FLASH_RADIUS, FLASH_RADIUS
)
canvas.drawRRect(rect, paint)
canvas.restore()
}
export function drawAiOverlays(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
const now = performance.now()
for (const nodeId of r._aiActiveNodes) {
const phase = (now % AI_PULSE_PERIOD_MS) / AI_PULSE_PERIOD_MS
const opacity = 0.3 + 0.5 * (0.5 + 0.5 * Math.sin(phase * Math.PI * 2))
drawAiNodeOverlay(r, canvas, graph, nodeId, AI_ACTIVE_COLOR, opacity)
}
for (let i = r._aiDoneFlashes.length - 1; i >= 0; i--) {
const flash = r._aiDoneFlashes[i]
const elapsed = now - flash.startTime
if (elapsed > AI_DONE_DURATION_MS) {
r._aiDoneFlashes.splice(i, 1)
continue
}
const t = elapsed / AI_DONE_DURATION_MS
const opacity = t < 0.3 ? t / 0.3 : 1 - (t - 0.3) / 0.7
drawAiNodeOverlay(r, canvas, graph, flash.nodeId, AI_DONE_COLOR, opacity)
}
}

View file

@ -66,6 +66,7 @@ import {
drawLayoutInsertIndicator as drawLayoutInsertIndicatorFn,
drawTextEditOverlay as drawTextEditOverlayFn,
} from './overlays'
import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays'
import {
drawPenOverlay as drawPenOverlayFn,
drawRemoteCursors as drawRemoteCursorsFn
@ -221,6 +222,8 @@ export class SkiaRenderer {
_culledCount = 0
_flashes: Array<{ nodeId: string; startTime: number }> = []
_flashPaint: Paint | null = null
_aiActiveNodes: Set<string> = new Set()
_aiDoneFlashes: Array<{ nodeId: string; startTime: number }> = []
readonly DEFAULT_FONT_SIZE = DEFAULT_FONT_SIZE
readonly COMPONENT_SET_BORDER_WIDTH = COMPONENT_SET_BORDER_WIDTH
@ -464,8 +467,37 @@ export class SkiaRenderer {
this._flashes.push({ nodeId, startTime: performance.now() })
}
aiMarkActive(nodeIds: string[]): void {
for (const id of nodeIds) this._aiActiveNodes.add(id)
}
aiMarkDone(nodeIds: string[]): void {
const now = performance.now()
for (const id of nodeIds) {
if (this._aiActiveNodes.delete(id)) {
this._aiDoneFlashes.push({ nodeId: id, startTime: now })
}
}
}
aiFlashDone(nodeIds: string[]): void {
const now = performance.now()
for (const id of nodeIds) {
this._aiDoneFlashes.push({ nodeId: id, startTime: now })
}
}
aiClearActive(): void {
this._aiActiveNodes.clear()
}
aiClearAll(): void {
this._aiActiveNodes.clear()
this._aiDoneFlashes = []
}
get hasActiveFlashes(): boolean {
return this._flashes.length > 0
return this._flashes.length > 0 || this._aiActiveNodes.size > 0 || this._aiDoneFlashes.length > 0
}
hitTestSectionTitle(graph: SceneGraph, canvasX: number, canvasY: number): SceneNode | null {
@ -926,6 +958,7 @@ export class SkiaRenderer {
private drawFlashes(canvas: Canvas, graph: SceneGraph): void {
drawFlashesFn(this, canvas, graph)
drawAiOverlaysFn(this, canvas, graph)
}
private drawLayoutInsertIndicator(canvas: Canvas, indicator?: RenderOverlays['layoutInsertIndicator']): void {

View file

@ -566,11 +566,14 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): vo
return
}
}
if (r.fontsLoaded && r.fontProvider && r.isNodeFontLoaded(node)) {
if (r.fontsLoaded && r.fontProvider) {
const paragraph = r.buildParagraph(node, r.fillPaint.getColor(), { halfLeading: true })
canvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
} else if (r.textFont) {
canvas.save()
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)
canvas.restore()
}
}

View file

@ -24,7 +24,7 @@ export function measureTextNode(
node: SceneNode,
maxWidth?: number
): { width: number; height: number } | null {
if (!r.fontsLoaded || !r.fontProvider || !isNodeFontLoaded(r, node)) return null
if (!r.fontsLoaded || !r.fontProvider) return null
if (node.type !== 'TEXT' || !node.text) return null
const paragraph = buildParagraph(r, node)
@ -42,7 +42,7 @@ export function measureTextNode(
}
export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array | null {
if (!r.fontsLoaded || !r.fontProvider || !isNodeFontLoaded(r, node)) return null
if (!r.fontsLoaded || !r.fontProvider) return null
if (node.type !== 'TEXT' || !node.text) return null
const ck = r.ck
@ -170,8 +170,12 @@ export function buildParagraph(
const truncateOpts = buildTruncateOpts(node, baseFontSize)
const fontFamilies = (primary: string) =>
cjkFallback ? [primary, cjkFallback] : [primary]
const fontFamilies = (primary: string) => {
const families = [primary]
if (primary !== DEFAULT_FONT_FAMILY) families.push(DEFAULT_FONT_FAMILY)
if (cjkFallback) families.push(cjkFallback)
return families
}
const paraStyle = new ck.ParagraphStyle({
textAlign: getTextAlign(ck, node.textAlignHorizontal),

View file

@ -750,6 +750,7 @@ export class SceneGraph {
'text', 'fontSize', 'fontFamily', 'fontWeight', 'italic',
'textAlignHorizontal', 'textAlignVertical', 'lineHeight',
'letterSpacing', 'textDecoration', 'textCase', 'styleRuns', 'fills',
'width', 'height',
])
updateNode(id: string, changes: Partial<SceneNode>): void {

View file

@ -45,7 +45,7 @@ export interface StepBudget {
export interface AIAdapterOptions {
getFigma: () => FigmaAPI
onBeforeExecute?: (def: ToolDef) => void
onAfterExecute?: (def: ToolDef) => void
onAfterExecute?: (def: ToolDef) => Promise<void> | void
onFlashNodes?: (nodeIds: string[]) => void
onToolLog?: (entry: ToolLogEntry) => void
getStepBudget?: () => StepBudget
@ -179,7 +179,7 @@ export function toolsToAI(
emitToolLog(options, def, args, startTime, figma, nodeBefore, null, errorMsg)
return { error: errorMsg }
} finally {
options.onAfterExecute?.(def)
await options.onAfterExecute?.(def)
}
}
}

View file

@ -4,29 +4,45 @@ import { defineTool } from './schema'
const parser = new ExprEval.Parser()
function evalExpr(expr: string): { expr: string; result: number } | { expr: string; error: string } {
try {
const result = parser.evaluate(expr)
if (!Number.isFinite(result)) {
return { expr, error: `Produced ${String(result)}` }
}
return { expr, result }
} catch (e) {
return { expr, error: e instanceof Error ? e.message : String(e) }
}
}
export const calc = defineTool({
name: 'calc',
description:
'Arithmetic calculator. ALWAYS use this instead of mental math for layout calculations. ' +
'Evaluates a math expression and returns the numeric result. ' +
'Arithmetic calculator. ALWAYS use instead of mental math. ' +
'Pass one expression or a JSON array of expressions — all evaluated in one call. ' +
'Supports: + - * / % ** ( ) min max floor ceil round abs sqrt pow. ' +
'Examples: "844 - 56 - 96 - 82" → 610, "floor(390 * 0.6)" → 234',
'Examples: "844 - 56 - 96 - 82", \'["1440 * 8 / 12", "(952 - 16) / 2", "floor(390 * 0.6)"]\'',
params: {
expr: {
type: 'string',
description: 'Math expression, e.g. "844 - 56 - 96 - 82" or "min(300, 400 - 2 * 20)"',
description: 'Single expression or JSON array of expressions',
required: true
}
},
execute: (_figma, { expr }) => {
let exprs: string[]
try {
const result = parser.evaluate(expr)
if (!Number.isFinite(result)) {
return { error: `Expression "${expr}" produced ${String(result)}` }
}
return { expr, result }
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) }
const parsed = JSON.parse(expr)
exprs = Array.isArray(parsed) ? parsed : [expr]
} catch {
exprs = [expr]
}
if (exprs.length === 1) {
return evalExpr(exprs[0])
}
return { results: exprs.map(evalExpr) }
}
})

View file

@ -53,20 +53,45 @@ export const render = defineTool({
name: 'render',
mutates: true,
description:
'Render JSX to design nodes. Primary creation tool — creates entire component trees in one call. Example: <Frame name="Card" w={320} h="hug" flex="col" gap={16} p={24} bg="#FFF" rounded={16}><Text size={18} weight="bold">Title</Text></Frame>',
'Render JSX to design nodes. Use replace_id to swap a skeleton placeholder with real content (keeps position in parent). Example: <Frame name="Card" w={320} h="hug" flex="col" gap={16} p={24} bg="#FFF" rounded={16}><Text size={18} weight="bold">Title</Text></Frame>',
params: {
jsx: { type: 'string', description: 'JSX string to render', required: true },
replace_id: { type: 'string', description: 'Node ID to replace — new node takes its position in parent, old node is deleted' },
parent_id: { type: 'string', description: 'Parent node ID to render into' },
insert_index: { type: 'number', description: 'Position among siblings (0 = first child). Omit to append at end.' },
x: { type: 'number', description: 'X position of the root node' },
y: { type: 'number', description: 'Y position of the root node' },
parent_id: { type: 'string', description: 'Parent node ID to render into' }
jsx: { type: 'string', description: 'JSX string to render', required: true },
},
execute: async (figma, args) => {
const { renderJSX } = await import('../render/render-jsx.js')
let parentId = args.parent_id ?? figma.currentPageId
let replaceIndex = -1
if (args.replace_id) {
const target = figma.graph.getNode(args.replace_id)
if (target?.parentId) {
parentId = target.parentId
const parent = figma.graph.getNode(parentId)
if (parent) {
replaceIndex = parent.childIds.indexOf(args.replace_id)
}
}
}
const result = await renderJSX(figma.graph, args.jsx, {
parentId: args.parent_id ?? figma.currentPageId,
parentId,
x: args.x,
y: args.y
})
if (args.replace_id && replaceIndex >= 0) {
figma.graph.reorderChild(result.id, parentId, replaceIndex)
figma.graph.deleteNode(args.replace_id)
} else if (args.insert_index !== undefined) {
figma.graph.reorderChild(result.id, parentId, args.insert_index)
}
return { id: result.id, name: result.name, type: result.type, children: result.childIds }
}
})

View file

@ -11,7 +11,10 @@ const LOW_CONTRAST_THRESHOLD = 15
const SHAPE_TYPES = new Set(['RECTANGLE', 'ELLIPSE', 'STAR', 'POLYGON', 'LINE'])
const ICON_MAX_SIZE = 48
export type IssueSeverity = 'error' | 'warning' | 'info'
export interface DescribeIssue {
severity?: IssueSeverity
message: string
suggestion?: string
}
@ -149,6 +152,73 @@ function checkImagePlaceholder(node: SceneNode, graph: SceneGraph, issues: Descr
}
}
function checkNestedText(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (node.type !== 'TEXT') return
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (child?.type === 'TEXT') {
issues.push({
message: `Nested Text "${child.text.slice(0, 20)}" inside "${node.text.slice(0, 20)}" — causes overflow`,
suggestion: 'Split into separate <Text> elements in a flex="row" wrapper'
})
}
}
}
function checkZeroPaddingContainer(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
if (!CONTAINER_TYPES.has(node.type)) return
if (node.layoutMode === 'NONE') return
if (node.childIds.length < 3) return
const allPad = node.paddingTop + node.paddingRight + node.paddingBottom + node.paddingLeft
if (allPad > 0) return
const hasTextChild = node.childIds.some((id) => {
const c = graph.getNode(id)
return c?.type === 'TEXT' && c.visible
})
const hasFill = node.fills.some((f) => f.visible)
if (hasTextChild && hasFill) {
issues.push({
message: `"${node.name}" has text content with fill but no padding`,
suggestion: 'Add p={16} or px={16} py={12}'
})
}
}
function checkButtonWithoutPadding(node: SceneNode, _graph: SceneGraph, issues: DescribeIssue[]): void {
if (!looksLikeButton(node)) return
if (node.layoutMode === 'NONE') return
const hPad = node.paddingLeft + node.paddingRight
if (hPad === 0 && node.childIds.length > 0) {
issues.push({
message: `Button "${node.name}" has no horizontal padding`,
suggestion: 'Add px={16} or px={12}'
})
}
}
function checkFlexContainerWithoutAlignment(node: SceneNode, _graph: SceneGraph, issues: DescribeIssue[]): void {
if (!CONTAINER_TYPES.has(node.type)) return
if (node.layoutMode === 'NONE') return
if (node.childIds.length === 0) return
const isRow = node.layoutMode === 'HORIZONTAL'
if (isRow && node.counterAxisAlign === 'MIN' && node.height > 60) {
const pad = node.paddingTop + node.paddingBottom
const contentH = node.height - pad
if (contentH > 50 && node.childIds.length <= 5) {
const maxChildH = node.childIds.reduce((max, id) => {
const c = _graph.getNode(id)
return c?.visible ? Math.max(max, c.height) : max
}, 0)
if (maxChildH > 0 && maxChildH < contentH * 0.6) {
issues.push({
message: `"${node.name}" row children top-aligned with large empty space below`,
suggestion: 'Add items="center" to vertically center content'
})
}
}
}
}
function detectVisibilityIssues(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void {
for (const fill of node.fills) {
if (!fill.visible || fill.type !== 'SOLID') continue
@ -269,6 +339,22 @@ function detectSpacingIssues(node: SceneNode, graph: SceneGraph, gridSize: numbe
}
}
const ERROR_PATTERNS = [
/overflow/i, /invisible/i, /no color/i, /collapses/i, /no fill and no stroke/i,
/dark on dark/i, /Touch target too small/i, /Nested Text/i
]
const INFO_PATTERNS = [
/children named/i, /uppercase at/i, /fill.*matches parent/i,
/radius.*should be/i, /width while siblings/i, /height while siblings/i,
/icon.*width/i
]
function classifySeverity(message: string): IssueSeverity {
if (ERROR_PATTERNS.some((p) => p.test(message))) return 'error'
if (INFO_PATTERNS.some((p) => p.test(message))) return 'info'
return 'warning'
}
export function detectIssues(node: SceneNode, gridSize: number, graph: SceneGraph): DescribeIssue[] {
const issues: DescribeIssue[] = []
detectStructuralIssues(node, gridSize, graph, issues)
@ -277,5 +363,12 @@ export function detectIssues(node: SceneNode, gridSize: number, graph: SceneGrap
detectRadiusIssues(node, graph, issues)
detectTypographyIssues(node, graph, issues)
detectSpacingIssues(node, graph, gridSize, issues)
checkNestedText(node, graph, issues)
checkZeroPaddingContainer(node, graph, issues)
checkButtonWithoutPadding(node, graph, issues)
checkFlexContainerWithoutAlignment(node, graph, issues)
for (const issue of issues) {
issue.severity = classifySeverity(issue.message)
}
return issues
}

View file

@ -250,6 +250,21 @@ function checkSiblingHeightConsistency(ctx: LayoutContext): void {
}
}
function checkChildUndersize(ctx: LayoutContext): void {
const { node, graph, issues } = ctx
if (node.layoutMode !== 'NONE') return
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child?.visible) continue
if (child.width > 0 && node.width > 0 && child.width < node.width * 0.3 && child.height >= node.height * 0.5) {
issues.push({
message: `"${child.name}" is ${Math.round(child.width)}px wide inside ${Math.round(node.width)}px "${node.name}" (no auto-layout)`,
suggestion: `Add flex="col" to "${node.name}" and w="fill" to "${child.name}"`
})
}
}
}
function checkCrossAxisOverflow(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
if (node.clipsContent) return
@ -287,26 +302,49 @@ function checkAbsoluteInFlex(_ctx: LayoutContext): void {
// Not reported — describe summary already shows "positioned: ABSOLUTE".
}
function effectivelyFillsCrossAxis(child: SceneNode, parent: SceneNode, isRow: boolean): boolean {
const childCross = isRow ? child.height : child.width
const parentCrossContent = isRow
? parent.height - parent.paddingTop - parent.paddingBottom
: parent.width - parent.paddingLeft - parent.paddingRight
return Math.abs(childCross - parentCrossContent) < 2
}
function childNeedsFill(child: SceneNode, parent: SceneNode, isRow: boolean): boolean {
if (child.layoutMode === 'NONE') return false
const crossDim = isRow ? child.width : child.height
const crossSizing = isRow ? child.counterAxisSizing : child.primaryAxisSizing
if (crossDim <= 0 && crossSizing !== 'FILL') return false
const mainSizing = isRow ? child.primaryAxisSizing : child.counterAxisSizing
if (mainSizing === 'FIXED') return false
if (effectivelyFillsCrossAxis(child, parent, isRow)) return false
if (child.childIds.length === 0) return false
return isRow
? child.width < parent.width * 0.3 && child.counterAxisSizing !== 'FILL' && child.layoutGrow <= 0
: child.height < parent.height * 0.3 && child.primaryAxisSizing !== 'FILL' && child.layoutGrow <= 0
}
function hasSiblingWithGrowOrFill(children: SceneNode[], exclude: SceneNode, isRow: boolean): boolean {
return children.some((c) => {
if (c === exclude) return false
if (c.layoutGrow > 0) return true
const sizing = isRow ? c.counterAxisSizing : c.primaryAxisSizing
return sizing === 'FILL'
})
}
function checkNestedFlexWithoutFill(ctx: LayoutContext): void {
const { node, isRow, children, issues } = ctx
if (node.layoutMode === 'NONE') return
if (node.primaryAxisAlign === 'SPACE_BETWEEN' || node.primaryAxisAlign === 'CENTER') return
if (node.primaryAxisAlign !== 'MIN') return
if (node.layoutWrap === 'WRAP') return
for (const child of children) {
if (child.layoutMode === 'NONE') continue
const crossDim = isRow ? child.width : child.height
const crossSizing = isRow ? child.counterAxisSizing : child.primaryAxisSizing
if (crossDim <= 0 && crossSizing !== 'FILL') continue
const mainSizing = isRow ? child.primaryAxisSizing : child.counterAxisSizing
if (mainSizing === 'FIXED') continue
const needsFill = isRow
? child.width < node.width * 0.3 && child.counterAxisSizing !== 'FILL' && child.layoutGrow <= 0
: child.height < node.height * 0.3 && child.primaryAxisSizing !== 'FILL' && child.layoutGrow <= 0
if (needsFill && child.childIds.length > 0) {
issues.push({
message: `Nested flex "${child.name}" may collapse — no fill or grow in "${node.name}"`,
suggestion: 'Add w="fill" or grow={1}'
})
}
if (!childNeedsFill(child, node, isRow)) continue
if (hasSiblingWithGrowOrFill(children, child, isRow)) continue
issues.push({
message: `Nested flex "${child.name}" may collapse — no fill or grow in "${node.name}"`,
suggestion: 'Add w="fill" or grow={1}'
})
}
}
@ -341,6 +379,7 @@ export function detectLayoutIssues(node: SceneNode, graph: SceneGraph, issues: D
checkTextVisibility(ctx)
checkDuplicateNames(ctx)
checkFillWithoutFlex(ctx)
checkChildUndersize(ctx)
checkAbsoluteInFlex(ctx)
if (node.layoutMode === 'NONE') return

View file

@ -183,45 +183,83 @@ function describeChild(node: SceneNode, graph: SceneGraph, depth: number, gridSi
return result
}
function describeOneNode(
figma: { graph: SceneGraph },
nodeId: string,
depth: number,
gridSize: number
): Record<string, unknown> {
const raw = figma.graph.getNode(nodeId)
if (!raw) return { id: nodeId, error: `Node "${nodeId}" not found` }
const role = detectRole(raw)
const visual = describeVisual(raw)
const layout = describeLayout(raw)
const issues = detectIssues(raw, gridSize, figma.graph)
const children: ChildDescription[] = []
for (const childId of raw.childIds) {
const child = figma.graph.getNode(childId)
if (!child || !child.visible) continue
children.push(describeChild(child, figma.graph, depth - 1, gridSize))
}
const result: Record<string, unknown> = {
id: raw.id,
name: raw.name,
type: raw.type,
role,
size: `${raw.width}×${raw.height}`,
visual,
}
if (layout) result.layout = layout
if (children.length > 0) result.children = children
if (issues.length > 0) result.issues = issues
return result
}
function countDescendants(graph: SceneGraph, nodeId: string): number {
const node = graph.getNode(nodeId)
if (!node) return 0
let count = 0
for (const childId of node.childIds) {
count += 1 + countDescendants(graph, childId)
}
return count
}
function autoDepth(graph: SceneGraph, nodeId: string): number {
const size = countDescendants(graph, nodeId)
if (size <= 15) return 4
if (size <= 40) return 3
if (size <= 100) return 2
return 1
}
export const describe = defineTool({
name: 'describe',
description:
'Semantic description of a node: role, visual style, layout, children summary, and design issues. Use depth=2 to see grandchildren (recommended for root frames).',
'Semantic description of one or more nodes. Pass `id` for a single node, or `ids` for multiple nodes in one call. Omit depth for auto — adapts to subtree size (small block → deeper, large page → shallower).',
params: {
id: { type: 'string', description: 'Node ID', required: true },
depth: { type: 'number', description: 'How many levels of children to include (default: 1, max: 3)' },
id: { type: 'string', description: 'Node ID (single node)' },
ids: { type: 'string[]', description: 'Node IDs (multiple nodes in one call)' },
depth: { type: 'number', description: 'Override depth (auto if omitted, max: 5)' },
grid: { type: 'number', description: 'Grid size for alignment checks (default: 8)' }
},
execute: (figma, args) => {
const gridSize = args.grid ?? 8
const depth = Math.min(args.depth ?? 1, 3)
const raw = figma.graph.getNode(args.id)
if (!raw) return { error: `Node "${args.id}" not found` }
const role = detectRole(raw)
const visual = describeVisual(raw)
const layout = describeLayout(raw)
const issues = detectIssues(raw, gridSize, figma.graph)
const children: ChildDescription[] = []
for (const childId of raw.childIds) {
const child = figma.graph.getNode(childId)
if (!child || !child.visible) continue
children.push(describeChild(child, figma.graph, depth - 1, gridSize))
if (args.ids && Array.isArray(args.ids)) {
return {
nodes: args.ids.map((nodeId) => {
const depth = Math.min(args.depth ?? autoDepth(figma.graph, nodeId), 5)
return describeOneNode(figma, nodeId, depth, gridSize)
})
}
}
const result: Record<string, unknown> = {
id: raw.id,
name: raw.name,
type: raw.type,
role,
size: `${raw.width}×${raw.height}`,
visual,
}
if (layout) result.layout = layout
if (children.length > 0) result.children = children
if (issues.length > 0) result.issues = issues
return result
if (!args.id) return { error: 'Provide id (string) or ids (string[])' }
const depth = Math.min(args.depth ?? autoDepth(figma.graph, args.id), 5)
return describeOneNode(figma, args.id, depth, gridSize)
}
})

View file

@ -2,22 +2,52 @@
import { parseColor } from '../color'
import { DEFAULT_SHADOW_COLOR } from '../constants'
import type { CharacterStyleOverride, Effect, StyleRun } from '../scene-graph'
import type { Matrix } from '../types'
import { defineTool } from './schema'
export const setFill = defineTool({
name: 'set_fill',
mutates: true,
description: 'Set the fill color of a node. Accepts hex (#ff0000) or named color.',
description: 'Set fill on a node. Solid: color="#ff0000". Linear gradient: gradient="top-bottom" or "left-right" with color (start) and color_end (end).',
params: {
id: { type: 'string', description: 'Node ID', required: true },
color: { type: 'color', description: 'Color value (hex like #ff0000)', required: true }
color: { type: 'color', description: 'Color (hex). For gradient: start color.', required: true },
color_end: { type: 'color', description: 'End color for gradient (if omitted, solid fill)' },
gradient: {
type: 'string',
description: 'Gradient direction',
enum: ['top-bottom', 'bottom-top', 'left-right', 'right-left']
}
},
execute: (figma, { id, color }) => {
execute: (figma, { id, color, color_end, gradient }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
const c = parseColor(color)
if (gradient && color_end) {
const cEnd = parseColor(color_end)
const transforms: Record<string, Matrix> = {
'top-bottom': { m00: 0, m01: 1, m02: 0, m10: -1, m11: 0, m12: 1 },
'bottom-top': { m00: 0, m01: -1, m02: 1, m10: 1, m11: 0, m12: 0 },
'left-right': { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
'right-left': { m00: -1, m01: 0, m02: 1, m10: 0, m11: -1, m12: 1 }
}
node.fills = [{
type: 'GRADIENT_LINEAR',
color: c,
opacity: 1,
visible: true,
gradientStops: [
{ position: 0, color: c },
{ position: 1, color: cEnd }
],
gradientTransform: transforms[gradient] ?? transforms['top-bottom']
}]
return { id, gradient, start: c, end: cEnd }
}
node.fills = [{ type: 'SOLID', color: c, opacity: 1, visible: true }]
return { id, color: c }
}

View file

@ -49,14 +49,15 @@ export const getPageTree = defineTool({
export const getNode = defineTool({
name: 'get_node',
description: 'Get detailed properties of a node by ID.',
description: 'Get detailed properties of a node by ID. Use depth to limit child recursion (0 = node only, 1 = direct children, etc). Default: unlimited.',
params: {
id: { type: 'string', description: 'Node ID', required: true }
id: { type: 'string', description: 'Node ID', required: true },
depth: { type: 'number', description: 'Max depth of children to include (0 = no children). Default: unlimited' }
},
execute: (figma, { id }) => {
execute: (figma, { id, depth }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
return nodeToResult(node)
return nodeToResult(node, depth)
}
})

View file

@ -18,7 +18,8 @@ import {
import {
deleteNode, cloneNode, renameNode, reparentNode, groupNodes, ungroupNode,
flattenNodes, nodeToComponent, nodeBounds, nodeMove, nodeResize,
nodeAncestors, nodeChildren, nodeTree, nodeBindings, nodeReplaceWith, arrangeNodes
nodeAncestors, nodeChildren, nodeTree, nodeBindings, nodeReplaceWith, arrangeNodes,
batchUpdate
} from './structure'
import {
listVariables, listCollections, getVariable, findVariables,
@ -38,6 +39,7 @@ import {
import { describe } from './describe'
import { designToTokens, designToComponentMap } from './codegen'
import { calc } from './calc'
import { stockPhoto } from './stock-photo'
/**
* Core tools registered by default in AI chat (~30 tools, ~3K schema tokens).
@ -46,38 +48,27 @@ import { calc } from './calc'
export const CORE_TOOLS: ToolDef[] = [
// Read
getSelection,
getPageTree,
getNode,
findNodes,
getCurrentPage,
listPages,
getJsx,
selectNodes,
// Create
render,
createShape,
searchIconsTool,
insertIcon,
fetchIconsTool,
// Modify
setFill,
setStroke,
setEffects,
updateNode,
setLayout,
setLayoutChild,
setRadius,
setOpacity,
setFill,
setStroke,
setText,
setFont,
setTextProperties,
setVisible,
// Structure
deleteNode,
cloneNode,
reparentNode,
nodeMove,
nodeResize,
batchUpdate,
// Stock photos
stockPhoto,
// Inspect & utility
describe,
calc,
@ -91,6 +82,10 @@ export const CORE_TOOLS: ToolDef[] = [
*/
export const EXTENDED_TOOLS: ToolDef[] = [
// Read (advanced)
getPageTree,
getCurrentPage,
listPages,
selectNodes,
queryNodes,
getComponents,
switchPage,
@ -98,12 +93,20 @@ export const EXTENDED_TOOLS: ToolDef[] = [
listFonts,
diffJsx,
// Create (advanced)
createShape,
searchIconsTool,
insertIcon,
fetchIconsTool,
createComponent,
createInstance,
createPage,
createVector,
createSlice,
// Modify (advanced)
setEffects,
setOpacity,
setFont,
setVisible,
setConstraints,
setRotation,
setMinMax,
@ -114,6 +117,8 @@ export const EXTENDED_TOOLS: ToolDef[] = [
setStrokeAlign,
setImageFill,
// Structure (advanced)
cloneNode,
nodeMove,
renameNode,
groupNodes,
ungroupNode,

View file

@ -56,8 +56,8 @@ export function defineTool<P extends Record<string, ParamDef>>(def: {
return def as unknown as ToolDef
}
export function nodeToResult(node: FigmaNodeProxy): Record<string, unknown> {
return node.toJSON()
export function nodeToResult(node: FigmaNodeProxy, maxDepth?: number): Record<string, unknown> {
return node.toJSON(maxDepth)
}
export function nodeSummary(node: FigmaNodeProxy): { id: string; name: string; type: string } {

View file

@ -0,0 +1,257 @@
import { defineTool } from './schema'
import type { FigmaAPI } from '../figma-api'
export interface StockPhotoResult {
url: string
width: number
height: number
photographer: string
sourceId: string
}
export interface StockPhotoProvider {
name: string
search(query: string, options: {
perPage: number
orientation: 'landscape' | 'portrait' | 'square'
targetDim: number
}): Promise<StockPhotoResult[]>
}
const providers = new Map<string, StockPhotoProvider>()
let activeProviderId: string | null = null
export function registerStockPhotoProvider(provider: StockPhotoProvider): void {
providers.set(provider.name, provider)
if (!activeProviderId) activeProviderId = provider.name
}
export function setActiveStockPhotoProvider(name: string | null): void {
activeProviderId = name
}
export function getStockPhotoProviders(): string[] {
return [...providers.keys()]
}
function getActiveProvider(): StockPhotoProvider | null {
if (!activeProviderId) return null
return providers.get(activeProviderId) ?? null
}
// --- Pexels provider ---
interface PexelsPhoto {
id: number
width: number
height: number
photographer: string
src: {
original: string
large2x: string
large: string
medium: string
small: string
landscape: string
}
}
function pickPexelsSize(src: PexelsPhoto['src'], targetDim: number): string {
if (targetDim <= 200) return src.small
if (targetDim <= 400) return src.medium
if (targetDim <= 800) return src.large
if (targetDim <= 1600) return src.large2x
return src.original
}
let pexelsApiKey: string | null = null
export function setPexelsApiKey(key: string | null): void {
pexelsApiKey = key
if (key) {
registerStockPhotoProvider(pexelsProvider)
setActiveStockPhotoProvider('pexels')
}
}
const pexelsProvider: StockPhotoProvider = {
name: 'pexels',
async search(query, { perPage, orientation, targetDim }) {
if (!pexelsApiKey) throw new Error('Pexels API key not configured')
const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${perPage}&orientation=${orientation}`
const resp = await fetch(url, { headers: { Authorization: pexelsApiKey } })
if (!resp.ok) throw new Error(`Pexels ${resp.status}`)
const data = (await resp.json()) as { photos: PexelsPhoto[] }
return data.photos.map((p) => ({
url: pickPexelsSize(p.src, targetDim),
width: p.width,
height: p.height,
photographer: p.photographer,
sourceId: String(p.id)
}))
}
}
// --- Unsplash provider ---
let unsplashAccessKey: string | null = null
export function setUnsplashAccessKey(key: string | null): void {
unsplashAccessKey = key
if (key) {
registerStockPhotoProvider(unsplashProvider)
}
}
interface UnsplashPhoto {
id: string
width: number
height: number
urls: { raw: string; full: string; regular: string; small: string; thumb: string }
user: { name: string }
links: { download_location: string }
}
function pickUnsplashSize(urls: UnsplashPhoto['urls'], targetDim: number): string {
if (targetDim <= 200) return urls.thumb
if (targetDim <= 400) return urls.small
if (targetDim <= 1080) return urls.regular
return urls.full
}
const unsplashProvider: StockPhotoProvider = {
name: 'unsplash',
async search(query, { perPage, orientation }) {
if (!unsplashAccessKey) throw new Error('Unsplash access key not configured')
const orient = orientation === 'square' ? 'squarish' : orientation
const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&per_page=${perPage}&orientation=${orient}`
const resp = await fetch(url, {
headers: {
Authorization: `Client-ID ${unsplashAccessKey}`,
'Accept-Version': 'v1'
}
})
if (!resp.ok) throw new Error(`Unsplash ${resp.status}`)
const data = (await resp.json()) as { results: UnsplashPhoto[] }
return data.results.map((p) => ({
url: pickUnsplashSize(p.urls, 1080),
width: p.width,
height: p.height,
photographer: p.user.name,
sourceId: p.id
}))
}
}
// --- Tool ---
interface PhotoRequest {
id: string
query: string
index?: number
orientation?: 'landscape' | 'portrait' | 'square'
}
interface PhotoResult {
id: string
photo?: { sourceId: string; photographer: string; width: number; height: number; provider: string }
error?: string
}
async function applyPhoto(
figma: FigmaAPI,
provider: StockPhotoProvider,
req: PhotoRequest
): Promise<PhotoResult> {
const node = figma.getNodeById(req.id)
if (!node) return { id: req.id, error: 'Not found' }
const children = 'children' in node ? (node as { children: unknown[] }).children : []
if (children.length > 0) {
return { id: req.id, error: `"${node.name}" has children — use a leaf shape` }
}
const perPage = Math.min((req.index ?? 0) + 3, 15)
const orientation = req.orientation ?? 'landscape'
const targetDim = Math.max(node.width, node.height)
let results: StockPhotoResult[]
try {
results = await provider.search(req.query, { perPage, orientation, targetDim })
} catch (err) {
return { id: req.id, error: err instanceof Error ? err.message : String(err) }
}
if (results.length === 0) return { id: req.id, error: `No photos for "${req.query}"` }
const photo = results[Math.min(req.index ?? 0, results.length - 1)]
let imageBytes: Uint8Array
try {
const imgResp = await fetch(photo.url)
if (!imgResp.ok) return { id: req.id, error: `Download ${imgResp.status}` }
imageBytes = new Uint8Array(await imgResp.arrayBuffer())
} catch (err) {
return { id: req.id, error: `Download: ${err instanceof Error ? err.message : String(err)}` }
}
const image = figma.createImage(imageBytes)
node.fills = [{
type: 'IMAGE',
color: { r: 1, g: 1, b: 1, a: 1 },
imageHash: image.hash,
imageScaleMode: 'FILL',
visible: true,
opacity: 1
}]
return {
id: node.id,
photo: {
sourceId: photo.sourceId,
photographer: photo.photographer,
width: photo.width,
height: photo.height,
provider: provider.name
}
}
}
export const stockPhoto = defineTool({
name: 'stock_photo',
mutates: true,
description:
'Search stock photos and apply to nodes. Pass a JSON array — all fetched in parallel. ' +
'Each item: {id, query, index?, orientation?}. Only works on leaf shapes (Rectangle/Ellipse).',
params: {
requests: {
type: 'string',
description:
'JSON array: [{"id":"0:5","query":"mountain sunset"},{"id":"0:8","query":"business team","orientation":"square"}]',
required: true
}
},
execute: async (figma, { requests }) => {
const provider = getActiveProvider()
if (!provider) {
return {
error: `No stock photo provider configured. Ask the user to add an API key in AI chat settings. Available providers: Pexels, Unsplash.`
}
}
let reqs: PhotoRequest[]
try {
const parsed = JSON.parse(String(requests))
reqs = Array.isArray(parsed) ? parsed : [parsed]
} catch {
return { error: 'Invalid JSON in requests' }
}
if (reqs.length === 0) return { error: 'Empty requests array' }
const results = await Promise.all(reqs.map((r) => applyPhoto(figma, provider, r)))
const ok = results.filter((r) => r.photo).length
return { applied: ok, failed: results.length - ok, provider: provider.name, results }
}
})

View file

@ -346,3 +346,83 @@ export const arrangeNodes = defineTool({
return { arranged: nodes.length, mode }
}
})
interface BatchOp {
id: string
props: Record<string, unknown>
}
function str(v: unknown): string { return typeof v === 'string' ? v : '' }
function num(v: unknown): number { return typeof v === 'number' ? v : 0 }
function applyBatchProps(node: FigmaNodeProxy, p: Record<string, unknown>): string[] {
const updated: string[] = []
if (p.spacing !== undefined) { node.itemSpacing = num(p.spacing); updated.push('spacing') }
if (p.padding !== undefined) {
const v = num(p.padding)
node.paddingTop = v; node.paddingRight = v; node.paddingBottom = v; node.paddingLeft = v
updated.push('padding')
}
if (p.padding_horizontal !== undefined) {
node.paddingLeft = num(p.padding_horizontal); node.paddingRight = num(p.padding_horizontal)
updated.push('padding_horizontal')
}
if (p.padding_vertical !== undefined) {
node.paddingTop = num(p.padding_vertical); node.paddingBottom = num(p.padding_vertical)
updated.push('padding_vertical')
}
if (p.counter_align !== undefined) { node.counterAxisAlignItems = str(p.counter_align); updated.push('counter_align') }
if (p.align !== undefined) { node.primaryAxisAlignItems = str(p.align); updated.push('align') }
if (p.sizing_horizontal !== undefined) { node.layoutSizingHorizontal = str(p.sizing_horizontal); updated.push('sizing_horizontal') }
if (p.sizing_vertical !== undefined) { node.layoutSizingVertical = str(p.sizing_vertical); updated.push('sizing_vertical') }
if (p.grow !== undefined) { node.layoutGrow = num(p.grow); updated.push('grow') }
if (p.name !== undefined) { node.name = str(p.name); updated.push('name') }
if (p.visible !== undefined) { node.visible = Boolean(p.visible); updated.push('visible') }
if (p.corner_radius !== undefined) { node.cornerRadius = num(p.corner_radius); updated.push('corner_radius') }
if (p.opacity !== undefined) { node.opacity = num(p.opacity); updated.push('opacity') }
if (p.auto_resize !== undefined) { node.textAutoResize = str(p.auto_resize); updated.push('auto_resize') }
if (p.direction !== undefined) { node.layoutMode = str(p.direction) as 'HORIZONTAL' | 'VERTICAL'; updated.push('direction') }
return updated
}
export const batchUpdate = defineTool({
name: 'batch_update',
mutates: true,
description:
'Execute multiple modifications in one call. Each operation is {id, props} where props can include: spacing, padding, padding_horizontal, padding_vertical, counter_align, sizing_horizontal, sizing_vertical, grow, name, visible, corner_radius, auto_resize (for text), direction. Runs all updates with one layout recompute.',
params: {
operations: {
type: 'string',
description:
'JSON array: [{"id":"0:5","props":{"spacing":8}},{"id":"0:6","props":{"sizing_horizontal":"FILL","grow":1}}]',
required: true
}
},
execute: (figma, { operations }) => {
let ops: BatchOp[]
try {
ops = JSON.parse(String(operations))
} catch {
return { error: 'Invalid JSON in operations' }
}
if (!Array.isArray(ops)) return { error: 'operations must be a JSON array' }
const results: Array<{ id: string; updated: string[] }> = []
const errors: string[] = []
for (const op of ops) {
const node = figma.getNodeById(op.id)
if (!node) { errors.push(`Node "${op.id}" not found`); continue }
const updated = applyBatchProps(node, op.props)
if (updated.length > 0) results.push({ id: op.id, updated })
}
const out: Record<string, unknown> = { updated: results.length }
if (results.length > 0) out.results = results
if (errors.length > 0) out.errors = errors
return out
}
})

View file

@ -1,7 +1,7 @@
import { getStepUsages, getToolLogEntries } from '@/ai/tools'
import { buildDebugLog } from '@open-pencil/core'
import { buildDebugLog } from '@open-pencil/core/tools'
import type { ToolDebugLog, ToolLogEntry } from '@open-pencil/core'
import type { ToolDebugLog, ToolLogEntry } from '@open-pencil/core/tools'
import type { UIMessage } from 'ai'
function formatToolPart(part: Record<string, unknown>): string {

View file

@ -4,7 +4,7 @@ After completing a design, give a **2–3 line** summary: frame size, accent col
# Rendering
The `render` tool takes JSX and produces design nodes. JavaScript expressions (map, ternaries, Array.from) work inside JSX.
The `render` tool takes JSX and produces design nodes. JavaScript expressions (map, ternaries, Array.from) work inside JSX. **Each render call must have exactly ONE root element.** To add multiple siblings to the same parent, use separate render calls or wrap in a Fragment-like parent Frame.
Available elements: Frame, Text, Rectangle, Ellipse, Line, Star, Polygon, Group, Section, Component, Icon.
@ -44,7 +44,7 @@ Nested flex containers need w="fill" at EVERY level to stretch. `grow={1}` insid
No margin property. For single-child offset, wrap in Frame with padding.
**Text in fixed containers:** Set `w` or `w="fill"` on multiword Text. For fixed-height rows, add `maxLines={1}`. In wrap layouts, calculate: columns = floor((available + gap) / (child_w + gap)).
**Text wrapping (CRITICAL):** Multiline text MUST have `w="fill"` (not `w={N}`). Use `w="fill"` on Text inside `flex="col"` cards — this stretches text to card width and enables auto-wrapping. Never use fixed `w={N}` on text that should wrap — the width may not match the parent due to font metric differences. For fixed-height rows, add `maxLines={1}`. In wrap layouts, calculate: columns = floor((available + gap) / (child_w + gap)).
## Corner radius
@ -64,7 +64,7 @@ Split into **2–3 render calls**:
2. Fill section A (poster, header)
3. Fill section B (content, details)
🧮 **Use `calc` for ALL layout arithmetic** — never mental math. `calc("844 - 72 - 116 - 87")` → verify before rendering. LLMs make arithmetic errors.
🧮 **Use `calc` for ALL layout arithmetic** — never mental math. Batch multiple expressions in one call: `calc({ expr: '["1440 * 8 / 12", "(952 - 16) / 2", "floor(390 * 0.6)"]' })`. Single expression also works: `calc({ expr: "844 - 72 - 116 - 87" })`.
## Typography
@ -72,6 +72,8 @@ Split into **2–3 render calls**:
Hierarchy via one property at a time: size OR weight OR color. Light bg: primary #111827, secondary #6B7280, tertiary #9CA3AF. Dark bg: #FFFFFF, #FFFFFF99, #FFFFFF66.
Fonts are loaded automatically — use any Google Fonts family (Inter, Georgia, Roboto, Playfair Display, etc.). The first render with a new font may take a moment to load.
## Prohibited
No style={{}}, className, CSS. No named colors or rgb(). No percentage values. No TypeScript casts. No Math.random(). No `Math.` prefix in calc — use `floor(x)` not `Math.floor(x)`. No emoji in UI elements (use `<Icon>` instead) — emoji renders as □.
@ -84,42 +86,134 @@ No style={{}}, className, CSS. No named colors or rgb(). No percentage values. N
**Don't mix `w={N}` and `grow={N}`** — grow overrides width.
**Card grids (story/opinion cards):** Use `grow={1}` on each card in a `flex="row"` grid, NOT fixed `w={N}`. Inside each card, use `w="fill"` for images and `w="fill"` for title text. This ensures text wraps within the card regardless of font metrics. Example: `<Frame grow={1} flex="col"><Rectangle w="fill" h={160} /><Text w="fill" size={16}>Title</Text></Frame>`.
**Tab bar / Bottom nav:** Outer frame `flex="row" w="fill" justify="between" px={32}`. Each tab `flex="col" items="center" gap={4}`. Tab items are HUG-width — `justify="between"` distributes them. Don't use `grow` on individual tabs.
**Dividers:** Vertical `w={1} h="fill"` inside flex="row". Horizontal `h={1} w="fill"` inside flex="col".
**Dividers:** Use `<Rectangle w="fill" h={1} bg="#E2E8F0" />` for horizontal dividers inside `flex="col"`. Use `<Rectangle w={1} h="fill" bg="#E2E8F0" />` for vertical dividers inside `flex="row"`. ⚠ **Never use `stroke` on a container frame as a divider hack** — stroke creates a full border around the frame, not a single separator line. Set the parent `gap={0}` and interleave Rectangle dividers between items.
# Stock Photos
`stock_photo` places real Pexels images on leaf shapes (Rectangle/Ellipse). Pass a JSON array — **all photos fetched in parallel**:
```
stock_photo({ requests: '[{"id":"0:30","query":"wall street trading floor"},{"id":"0:58","query":"AI chip semiconductor"},{"id":"0:65","query":"bank finance credit card"}]' })
```
- Batch all photos in one call — don't call stock_photo 14 times separately
- Only apply to leaf shapes (Rectangle/Ellipse), NOT to Frames with children
- Use descriptive English queries: "aerial city skyline sunset", not "image1"
- Orientation: "landscape" (default), "portrait" for tall cards, "square" for avatars
- If Pexels key is not configured or returns 401, tell the user to add/check it in AI chat settings. Do NOT fall back to `eval` with manual gradients — leave placeholder colors as-is
# Workflow (MANDATORY)
1. `calc` — compute section heights
2. `render` — skeleton
3. `describe` root with `depth=2` — verify
4. `render` into section A
5. `describe` root with `depth=2` — verify
6. `render` into section B
7. `describe` root with `depth=2` — check issues
8. Fix ALL issues from `describe` with `set_*` / `update_node`
9. `describe` root with `depth=2` — **MANDATORY final verify after fixes**
## Phase 1 — Plan (text only, no tools)
Typically **3 renders + 3–4 describes**. `describe` the root with `depth=2` — shows sections AND their children in one call.
Write a brief plan as numbered sections: what blocks, rough dimensions, layout approach. Example:
⚠ **Issues from `describe` are bugs.** Fix every issue before finishing. Common ones:
> 1. NavBar 1440×56 dark, row
> 2. Hero 1440×500 with image placeholder + overlay text
> 3. Stories grid: 2×2 cards in wrap row, grow cards
> 4. Sidebar: news feed + stocks widget + newsletter
> 5. Footer 3-col links
## Phase 2 — Skeleton (visible placeholders for every section)
Build the ENTIRE page with visible skeleton placeholders. Every section shows gray blocks where content will go — the page looks like a wireframe with correct proportions and spacing.
1. `calc` — batch all dimension arithmetic
2. **Render 1** — page frame (`h="hug"`, NOT fixed height) + nav bar + ticker (real text content)
3. **Render 2** — hero skeleton: gray image block `<Rectangle bg="#E2E8F0" w="fill" h={420} rounded={8} />` + text placeholder lines `<Rectangle bg="#CBD5E1" w={400} h={28} rounded={4} />`
4. **Render 3** — stories skeleton: real section header + main story card (gray image + gray text lines) + 3 sub-cards (same pattern)
5. **Render 4** — opinions skeleton (same pattern as stories)
6. **Render 5** — sidebar skeleton: news list (gray text lines), stocks (gray rows), newsletter (dark block with gray input)
7. **Render 6** — footer (final content — simple enough)
8. `describe` root `depth=2` — verify layout, proportions, spacing
9. `batch_update` — fix ALL issues before filling real content
**Skeleton card pattern:**
```jsx
<Frame name="StoryCard1" grow={1} flex="col" bg="#FFFFFF" rounded={8} overflow="hidden">
<Rectangle name="StoryImg1" w="fill" h={160} bg="#E2E8F0" />
<Frame w="fill" flex="col" gap={8} p={16}>
<Rectangle w={60} h={12} bg="#CBD5E1" rounded={4} />
<Rectangle w="fill" h={20} bg="#CBD5E1" rounded={4} />
<Rectangle w={180} h={14} bg="#E2E8F0" rounded={4} />
</Frame>
</Frame>
```
After Phase 2 the page looks like a complete wireframe — all sections visible, correct sizes, verified layout.
## Phase 3 — Fill content (replace skeletons with real content)
For each skeleton section, use `render` with `replace_id` — the new content takes the skeleton's position and the skeleton is deleted atomically. No separate `delete_node` needed:
```
render({ jsx: "<Frame ...real content...", replace_id: "0:29" })
```
The skeleton stays visible until the real content appears — no visual gap.
**MANDATORY pattern for EVERY content render:**
```
render({ replace_id: "0:39", jsx: "..." }) // 1. render
describe({ id: "0:210" }) // 2. IMMEDIATELY describe the new node
batch_update({ operations: "[...]" }) // 3. fix ALL errors + warnings
// ONLY NOW proceed to next section
```
Never skip step 2. Never defer describes to the end. Never batch multiple renders without describing each one. Errors compound — a missed `w="fill"` in Hero breaks Stories layout below it.
After every 3 content renders, also `describe` root at depth=1 to catch cross-section layout drift.
## Phase 4 — Polish
1. `stock_photo` — batch ALL named image placeholders in one call
2. `describe` root `depth=1` — final check
3. `batch_update` — fix remaining issues
Typically: 1 calc + 6 skeleton renders + describe + fixes + 6 content renders + 1 stock_photo + final describe = 20-25 steps.
⚠ **Issues from `describe` have severity levels.** Fix `error` issues always. Fix `warning` issues when possible. Ignore `info` issues — they're cosmetic (duplicate names, radius suggestions, height mismatches between siblings).
⚠ **Omit `depth` — it auto-adapts** to subtree size (small block → deeper, full page → shallower). Override only when you need a specific level.
Common errors:
- "overflows" → set `w="fill"` or `overflow="hidden"`
- "collapses to zero" → fix grow/fill chain
- "invisible" / "no color" → add bg/color
- "dark on dark" → change text color
Common warnings:
- "gap N not on 8px grid" → fix the gap
- "grow inside HUG parent" → set parent to fixed size or use h="fill"
- "duplicate sibling names" → rename
- "near-invisible fill" → increase alpha
⚠ **If a `set_*` fix doesn't work after 2 attempts — delete the node and re-render with corrections. Do NOT debug with `eval`.**
⚠ **Use `batch_update` for multiple fixes.** Instead of 10 separate `set_layout` / `set_layout_child` calls, pass them all at once:
`batch_update({ operations: '[{"id":"0:5","props":{"spacing":8}},{"id":"0:6","props":{"sizing_horizontal":"FILL","grow":1}},{"id":"0:7","props":{"auto_resize":"HEIGHT"}}]' })`
⚠ **Use `describe` with `ids` array to inspect multiple nodes at once:** `describe({ ids: ["0:5", "0:6", "0:7"], depth: 1 })`
⚠ **If a fix doesn't work after 2 attempts — delete the node and re-render with corrections. Do NOT debug with `eval`.**
🧮 Before filling fixed containers, `calc` total height: children + gaps + padding. Compare to available space from `describe`.
🚫 Do NOT put everything in one render. Do NOT skip `describe`. Do NOT `describe` individual children when `depth=2` covers them. Do NOT skip the final describe after fixes.
⚠ **Reuse IDs from render results and describe output.** Render returns `{ id, children: [...] }`. Describe at depth=2 returns every child's `id`. These ARE the IDs for `replace_id` — use them directly. Do NOT call `find_nodes` to rediscover IDs already visible in previous tool results. Save 8+ tool calls and 16+ seconds per page. Only use `find_nodes` when you genuinely lost track of an ID.
⚠ **Don't call `viewport_zoom_to_fit` or `describe` with the same arguments as a previous call in the same conversation.** Check your last calls before repeating.
🚫 **Never use `export_image`** — slow and wastes tokens. Use `describe` instead.
## Step budget
You have **50 steps** per message. Budget: ~3 renders + ~3 describes + fixes = 15–25 steps. If `_warning` appears, wrap up immediately.
You have **50 steps** per message. Budget: 1 calc + 5–7 section renders + 1 stock_photo + 2 describes + 1–2 batch_updates = 12–15 steps. If `_warning` appears, wrap up immediately.
## Advanced tools
@ -134,23 +228,39 @@ This is a **mobile interface app** (390×844) — dark theme, floating panels, t
**Step 1** — calc + search_icons for all needed icons upfront.
**Step 2** — Skeleton render:
```jsx
<Frame name="DesignApp" w={390} h={844} bg="#1C1C1E" flex="col">
<Frame name="StatusBar" w="fill" h={44} flex="row" px={20} items="center" justify="between">
<Text color="#FFFFFFCC" size={14} weight="medium">9:41</Text>
<Text color="#FFFFFFCC" size={12} weight="medium">Canvas</Text>
<Text color="#FFFFFFCC" size={14} weight="medium">
9:41
</Text>
<Text color="#FFFFFFCC" size={12} weight="medium">
Canvas
</Text>
<Frame flex="row" gap={4} items="center">
<Rectangle w={18} h={10} bg="#FFFFFF99" rounded={2} />
<Rectangle w={4} h={10} bg="#FFFFFF44" rounded={1} />
</Frame>
</Frame>
<Frame name="TopToolbar" w="fill" h={52} bg="#2C2C2E" flex="row" items="center" justify="between" px={16}>
<Frame
name="TopToolbar"
w="fill"
h={52}
bg="#2C2C2E"
flex="row"
items="center"
justify="between"
px={16}
>
<Frame name="LeftActions" flex="row" gap={16} items="center">
<Icon name="lucide:undo-2" size={20} color="#FFFFFFCC" />
<Icon name="lucide:redo-2" size={20} color="#FFFFFF55" />
</Frame>
<Frame name="DocTitle" flex="row" gap={8} items="center">
<Text color="#FFFFFF" size={15} weight="medium">Untitled Design</Text>
<Text color="#FFFFFF" size={15} weight="medium">
Untitled Design
</Text>
<Icon name="lucide:chevron-down" size={14} color="#FFFFFF88" />
</Frame>
<Frame name="RightActions" flex="row" gap={16} items="center">
@ -159,7 +269,16 @@ This is a **mobile interface app** (390×844) — dark theme, floating panels, t
</Frame>
</Frame>
<Frame name="CanvasArea" w="fill" grow={1} bg="#0D0D0F" overflow="hidden">
<Frame name="ArtboardOnCanvas" x={55} y={80} w={280} h={400} bg="#FFFFFF" rounded={4} shadow="0 8 32 #00000066" />
<Frame
name="ArtboardOnCanvas"
x={55}
y={80}
w={280}
h={400}
bg="#FFFFFF"
rounded={4}
shadow="0 8 32 #00000066"
/>
</Frame>
<Frame name="BottomDock" w="fill" h={120} bg="#2C2C2E" flex="col" roundedTL={20} roundedTR={20} />
</Frame>
@ -168,11 +287,16 @@ This is a **mobile interface app** (390×844) — dark theme, floating panels, t
**Step 3** — describe root depth=2, fix issues (rename duplicate Text nodes, fix spacing).
**Step 4** — Fill artboard content into parent "ArtboardOnCanvas":
```jsx
<Frame name="SampleDesign" w={280} h={400} flex="col" bg="#FFFFFF">
<Frame w="fill" h={120} bg="#6C5CE7" flex="col" justify="end" p={16}>
<Text color="#FFFFFF" size={8} weight="bold" textCase="upper" letterSpacing={1}>MOBILE APP</Text>
<Text color="#FFFFFFCC" size={6}>Sample Design Preview</Text>
<Text color="#FFFFFF" size={8} weight="bold" textCase="upper" letterSpacing={1}>
MOBILE APP
</Text>
<Text color="#FFFFFFCC" size={6}>
Sample Design Preview
</Text>
</Frame>
<Frame w="fill" grow={1} flex="col" gap={12} p={16}>
<Rectangle w="fill" h={32} bg="#F0F0F5" rounded={6} />
@ -189,6 +313,7 @@ This is a **mobile interface app** (390×844) — dark theme, floating panels, t
```
**Step 5** — Fill bottom dock into parent "BottomDock":
```jsx
<Frame name="DockContent" w="fill" h="fill" flex="col" gap={8} pt={12} pb={8} px={16}>
<Frame name="ToolRow" w="fill" h={44} bg="#3A3A3C" rounded={22} flex="row" items="center" px={4} justify="between">
@ -216,10 +341,26 @@ This is a **mobile interface app** (390×844) — dark theme, floating panels, t
```
**Step 6** — Add floating overlays into "CanvasArea" (selection handles, zoom, properties):
```jsx
<Frame name="FloatingZoom" x={12} y={540} w={44} h={120} bg="#2C2C2ECC" rounded={22} flex="col" items="center" justify="center" gap={16} py={12}>
<Frame
name="FloatingZoom"
x={12}
y={540}
w={44}
h={120}
bg="#2C2C2ECC"
rounded={22}
flex="col"
items="center"
justify="center"
gap={16}
py={12}
>
<Icon name="lucide:plus" size={16} color="#FFFFFFCC" />
<Text color="#FFFFFF88" size={10} weight="medium">75%</Text>
<Text color="#FFFFFF88" size={10} weight="medium">
75%
</Text>
<Icon name="lucide:minus" size={16} color="#FFFFFFCC" />
</Frame>
```
@ -227,9 +368,203 @@ This is a **mobile interface app** (390×844) — dark theme, floating panels, t
**Step 7** — describe depth=2, fix remaining issues, add shadows, final describe.
Key patterns in this example:
- **Every multi-child Frame has `flex`** — no exceptions
- **Named all nodes** — Tool_Select, Tool_Move, BrushSizeSlider, etc.
- **Floating panels use x/y** — inside non-flex CanvasArea parent
- **Procreate aesthetic**: `#2C2C2ECC` semi-transparent panels, `rounded={22}` pill shapes, `shadow` for depth
- **Icons with explicit color** — `color="#FFFFFFCC"` or `color="#FFFFFF88"` for hierarchy
- **3 renders** (skeleton → content A → content B) + **3 describes** + fix pass
# Example: desktop business news site
User prompt: "business media desktop site with real images, 12-col grid, 8 cols main, 4 cols sidebar, breaking news, hero, stories, opinions, sidebar news + stocks + newsletter, footer"
This is a **desktop media site** (1440px wide, scrollable) — light theme, 12-col grid, card-based layout.
**Step 1** — calc all grid dimensions in one batch:
```
calc({ expr: '["1440 - 48 - 48 - 24", "floor((1320) * 8 / 12)", "floor((1320) * 4 / 12)"]' })
```
→ Content area 1320px, Main 880px, Sidebar 440px.
**Step 2** — Skeleton render (entire page with gray placeholders):
```jsx
<Frame name="BusinessMediaSite" w={1440} h="hug" bg="#F5F5F0" flex="col">
{/* NavBar — real content */}
<Frame
name="NavBar"
w="fill"
h={56}
bg="#0F1923"
flex="row"
items="center"
justify="between"
px={48}
>
<Frame name="NavLeft" flex="row" gap={32} items="center">
<Text name="Logo" color="#FFFFFF" size={22} weight="bold" font="Playfair Display">
THE MARKETS
</Text>
<Frame name="NavLinks" flex="row" gap={24} items="center">
<Text color="#FFFFFFCC" size={14} weight="medium">
Markets
</Text>
<Text color="#FFFFFFCC" size={14} weight="medium">
Economy
</Text>
<Text color="#FFFFFFCC" size={14} weight="medium">
Technology
</Text>
</Frame>
</Frame>
<Frame name="NavRight" flex="row" gap={16} items="center">
<Icon name="lucide:search" size={18} color="#FFFFFFCC" />
<Frame name="SubscribeBtn" h={32} px={16} bg="#D4382C" rounded={4} flex="row" items="center">
<Text color="#FFFFFF" size={13} weight="bold">
Subscribe
</Text>
</Frame>
</Frame>
</Frame>
{/* Breaking News — real content */}
<Frame
name="BreakingNewsTicker"
w="fill"
h={40}
bg="#D4382C"
flex="row"
items="center"
px={48}
gap={16}
>
<Frame bg="#FFFFFF" px={12} py={4} rounded={2} flex="row" items="center">
<Text color="#D4382C" size={11} weight="bold" textCase="upper">
BREAKING
</Text>
</Frame>
<Text color="#FFFFFF" size={13} weight="medium">
Fed signals rate cut — S&P 500 hits record
</Text>
</Frame>
{/* Content area with skeleton placeholders */}
<Frame name="ContentArea" w="fill" flex="row" px={48} py={32} gap={24}>
<Frame name="MainColumn" w={880} flex="col" gap={32}>
{/* Hero skeleton */}
<Frame name="HeroArticle" w="fill" flex="col" bg="#FFFFFF" rounded={8} overflow="hidden">
<Rectangle name="HeroImg" w="fill" h={420} bg="#E2E8F0" />
<Frame w="fill" flex="col" gap={12} p={24}>
<Rectangle w={100} h={14} bg="#D4382C" rounded={4} />
<Rectangle w="fill" h={32} bg="#CBD5E1" rounded={4} />
<Rectangle w={600} h={32} bg="#CBD5E1" rounded={4} />
<Rectangle w={200} h={14} bg="#E2E8F0" rounded={4} />
</Frame>
</Frame>
{/* Stories skeleton */}
<Frame name="StoriesSection" w="fill" flex="col" gap={20}>
<Rectangle w={120} h={24} bg="#CBD5E1" rounded={4} />
<Frame w="fill" flex="row" gap={20}>
<Frame name="StoryMain" w={440} flex="col" bg="#FFFFFF" rounded={8} overflow="hidden">
<Rectangle name="StoryMainImg" w="fill" h={240} bg="#E2E8F0" />
<Frame w="fill" flex="col" gap={8} p={16}>
<Rectangle w={80} h={12} bg="#CBD5E1" rounded={4} />
<Rectangle w="fill" h={20} bg="#CBD5E1" rounded={4} />
</Frame>
</Frame>
<Frame w={420} flex="col" gap={16}>
{Array.from({ length: 3 }, (_, i) => (
<Frame
name={`StoryCard${i + 1}`}
key={i}
w="fill"
flex="row"
bg="#FFFFFF"
rounded={8}
overflow="hidden"
h={120}
>
<Rectangle name={`StoryCardImg${i + 1}`} w={160} h="fill" bg="#E2E8F0" />
<Frame w="fill" flex="col" gap={6} p={12}>
<Rectangle w={60} h={10} bg="#CBD5E1" rounded={4} />
<Rectangle w="fill" h={16} bg="#CBD5E1" rounded={4} />
</Frame>
</Frame>
))}
</Frame>
</Frame>
</Frame>
{/* Opinions skeleton — same pattern */}
<Frame name="OpinionsSection" w="fill" flex="col" gap={20}>
{/* ... same structure as StoriesSection ... */}
</Frame>
</Frame>
{/* Sidebar skeletons */}
<Frame name="Sidebar" w={440} flex="col" gap={24}>
<Frame name="LatestNewsBlock" w="fill" flex="col" bg="#FFFFFF" rounded={8} overflow="hidden">
<Frame w="fill" h={48} bg="#0F1923" flex="row" items="center" px={16}>
<Rectangle w={120} h={18} bg="#FFFFFF44" rounded={4} />
</Frame>
{Array.from({ length: 6 }, (_, i) => (
<Frame key={i} w="fill" flex="row" gap={12} p={16}>
<Rectangle w={80} h={60} bg="#E2E8F0" rounded={4} />
<Frame w="fill" flex="col" gap={6}>
<Rectangle w="fill" h={14} bg="#CBD5E1" rounded={4} />
<Rectangle w={80} h={10} bg="#E2E8F0" rounded={4} />
</Frame>
</Frame>
))}
</Frame>
<Frame name="StocksWidget" w="fill" h={360} bg="#FFFFFF" rounded={8} />
<Frame name="NewsletterBlock" w="fill" bg="#0F1923" rounded={8} p={24} gap={16}>
<Rectangle w={200} h={22} bg="#FFFFFF22" rounded={4} />
<Rectangle w="fill" h={44} bg="#D4382C" rounded={8} />
</Frame>
</Frame>
</Frame>
{/* Footer — real content */}
<Frame name="Footer" w="fill" flex="col" bg="#0F1923" px={48} pt={48} pb={24} gap={32}>
{/* ... footer columns ... */}
</Frame>
</Frame>
```
**Step 3** — `describe` root depth=2, fix layout with `batch_update`.
**Steps 4–9** — Replace each skeleton with real content using `replace_id`:
```
render({ jsx: "<Frame name=\"HeroArticle\" ...real content...", replace_id: "0:25" })
render({ jsx: "<Frame name=\"StoriesSection\" ...real content...", replace_id: "0:33" })
render({ jsx: "<Frame name=\"OpinionsSection\" ...real content...", replace_id: "0:65" })
render({ jsx: "<Frame name=\"LatestNewsBlock\" ...real content...", replace_id: "0:98" })
render({ jsx: "<Frame name=\"StocksWidget\" ...real content...", replace_id: "0:138" })
render({ jsx: "<Frame name=\"NewsletterBlock\" ...real content...", replace_id: "0:162" })
```
**Step 10** — `describe` depth=2, `batch_update` fixes.
**Step 11** — `stock_photo` batch all image placeholders in one call:
```
stock_photo({ requests: '[{"id":"0:203","query":"federal reserve building"},{"id":"0:221","query":"apple silicon valley technology"},...]' })
```
**Step 12** — Final `describe` depth=1, viewport_zoom_to_fit.
Key patterns in this example:
- **h="hug" on page frame** — never fixed height, content determines page length
- **Skeleton first** — gray `#E2E8F0` / `#CBD5E1` placeholders show layout before content
- **replace_id** — skeleton stays visible until content replaces it atomically
- **Named all image placeholders** — `HeroImg`, `StoryMainImg`, `StoryCardImg1` etc. for stock_photo
- **12-col grid** — MainColumn w={880} + Sidebar w={440} + gap 24 + padding 48×2 = 1440
- **Card pattern**: white bg + rounded + overflow hidden + shadow. Image rectangle + text frame with padding.
- **Section header pattern**: row with title + "See all →" link, red accent bar `<Rectangle w={4} h={24} bg="#D4382C" />`
- **One batch stock_photo** — 17 images in parallel, not 17 sequential calls
- **Footer real content from skeleton** — simple enough to render once
- **Total: 1 calc + 1 skeleton + 6 replace renders + 1 stock_photo + 2 describes + fixes = ~15 steps**

View file

@ -3,7 +3,14 @@ import { tool } from 'ai'
import * as v from 'valibot'
import { makeFigmaFromStore } from '@/automation/figma-factory'
import { CORE_TOOLS, computeAllLayouts, toolsToAI } from '@open-pencil/core'
import {
CORE_TOOLS,
collectFontKeys,
computeAllLayouts,
isFontLoaded,
loadFont,
toolsToAI
} from '@open-pencil/core'
import type { EditorStore } from '@/stores/editor'
import type { SceneNode, StepBudget, ToolLogEntry } from '@open-pencil/core'
@ -80,9 +87,25 @@ export function createAITools(store: EditorStore) {
beforeSnapshot = store.snapshotPage()
}
},
onAfterExecute: (def) => {
onAfterExecute: async (def) => {
if (def.mutates) {
computeAllLayouts(store.graph, store.state.currentPageId)
const pageId = store.state.currentPageId
const pageNode = store.graph.getNode(pageId)
if (pageNode) {
const fontKeys = collectFontKeys(store.graph, pageNode.childIds)
const missing = fontKeys.filter(([family]) => !isFontLoaded(family))
if (missing.length > 0) {
const results = await Promise.all(
missing.map(([family, style]) => loadFont(family, style))
)
if (results.some((r) => r !== null)) {
for (const [, node] of store.graph.nodes) {
if (node.type === 'TEXT' && node.textPicture) node.textPicture = null
}
}
}
}
computeAllLayouts(store.graph, pageId)
store.requestRender()
if (beforeSnapshot) {
const before = beforeSnapshot
@ -97,7 +120,10 @@ export function createAITools(store: EditorStore) {
}
},
onFlashNodes: (nodeIds) => {
store.flashNodes(nodeIds)
store.renderer?.aiClearActive()
if (nodeIds.length > 0) {
store.aiFlashDone(nodeIds)
}
},
onToolLog: (entry) => {
runState.toolLog.push(entry)

View file

@ -24,13 +24,19 @@ const {
customBaseURL,
customModelID,
customAPIType,
maxOutputTokens
maxOutputTokens,
pexelsApiKey,
unsplashAccessKey
} = useAIChat()
const keyInput = ref('')
const pexelsKeyInput = ref('')
const unsplashKeyInput = ref('')
const baseURLInput = ref(customBaseURL.value)
const customModelInput = ref(customModelID.value)
const hasExistingKey = ref(!!apiKey.value)
const hasExistingPexelsKey = ref(!!pexelsApiKey.value)
const hasExistingUnsplashKey = ref(!!unsplashAccessKey.value)
watch(providerID, () => {
keyInput.value = ''
@ -45,6 +51,16 @@ function save() {
hasExistingKey.value = true
keyInput.value = ''
}
if (pexelsKeyInput.value.trim()) {
pexelsApiKey.value = pexelsKeyInput.value.trim()
hasExistingPexelsKey.value = true
pexelsKeyInput.value = ''
}
if (unsplashKeyInput.value.trim()) {
unsplashAccessKey.value = unsplashKeyInput.value.trim()
hasExistingUnsplashKey.value = true
unsplashKeyInput.value = ''
}
if (providerDef.value.supportsCustomBaseURL) {
customBaseURL.value = baseURLInput.value.trim()
}
@ -58,6 +74,18 @@ function clearKey() {
keyInput.value = ''
hasExistingKey.value = false
}
function clearPexelsKey() {
pexelsApiKey.value = ''
pexelsKeyInput.value = ''
hasExistingPexelsKey.value = false
}
function clearUnsplashKey() {
unsplashAccessKey.value = ''
unsplashKeyInput.value = ''
hasExistingUnsplashKey.value = false
}
</script>
<template>
@ -167,6 +195,74 @@ function clearKey() {
/>
</div>
<!-- Pexels stock photos -->
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">Pexels API Key (stock photos)</label>
<button
v-if="pexelsApiKey"
class="cursor-pointer text-[10px] text-muted hover:text-surface"
data-test-id="provider-settings-clear-pexels-key"
@click="clearPexelsKey"
>
Clear
</button>
</div>
<input
v-model="pexelsKeyInput"
type="password"
data-test-id="provider-settings-pexels-key"
:placeholder="
hasExistingPexelsKey
? 'Key saved — enter new to replace'
: 'Optional — for stock_photo tool'
"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
<a
href="https://www.pexels.com/api/"
target="_blank"
class="text-[9px] text-muted underline hover:text-surface"
>
Get free Pexels API key →
</a>
</div>
<!-- Unsplash stock photos -->
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">Unsplash Access Key</label>
<button
v-if="unsplashAccessKey"
class="cursor-pointer text-[10px] text-muted hover:text-surface"
data-test-id="provider-settings-clear-unsplash-key"
@click="clearUnsplashKey"
>
Clear
</button>
</div>
<input
v-model="unsplashKeyInput"
type="password"
data-test-id="provider-settings-unsplash-key"
:placeholder="
hasExistingUnsplashKey
? 'Key saved — enter new to replace'
: 'Optional — alternative to Pexels'
"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
<a
href="https://unsplash.com/oauth/applications"
target="_blank"
class="text-[9px] text-muted underline hover:text-surface"
>
Get free Unsplash access key →
</a>
</div>
<!-- API key -->
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">

View file

@ -10,7 +10,13 @@ import { computed, ref, watch } from 'vue'
import SYSTEM_PROMPT from '@/ai/system-prompt.md?raw'
import { MAX_AGENT_STEPS, createAITools, recordStepUsage, resetRunSteps } from '@/ai/tools'
import { useEditorStore } from '@/stores/editor'
import { AI_PROVIDERS, DEFAULT_AI_MODEL, DEFAULT_AI_PROVIDER } from '@open-pencil/core'
import {
AI_PROVIDERS,
DEFAULT_AI_MODEL,
DEFAULT_AI_PROVIDER,
setPexelsApiKey,
setUnsplashAccessKey
} from '@open-pencil/core'
import type { AIProviderID } from '@open-pencil/core'
import type { LanguageModel, UIMessage } from 'ai'
@ -49,6 +55,8 @@ const customAPIType = useLocalStorage<'completions' | 'responses'>(
'completions'
)
const maxOutputTokens = useLocalStorage(`${STORAGE_PREFIX}ai-max-output-tokens`, 16384)
const pexelsApiKey = useLocalStorage(`${STORAGE_PREFIX}pexels-api-key`, '')
const unsplashAccessKey = useLocalStorage(`${STORAGE_PREFIX}unsplash-access-key`, '')
const activeTab = ref<'design' | 'ai'>('design')
const providerDef = computed(
@ -69,6 +77,22 @@ function markTransportDirty() {
transportDirty = true
}
watch(
pexelsApiKey,
(key) => {
setPexelsApiKey(key || null)
},
{ immediate: true }
)
watch(
unsplashAccessKey,
(key) => {
setUnsplashAccessKey(key || null)
},
{ immediate: true }
)
watch(providerID, (id) => {
const def = AI_PROVIDERS.find((p) => p.id === id)
if (def?.defaultModel) {
@ -240,6 +264,8 @@ export function useAIChat() {
customModelID,
customAPIType,
maxOutputTokens,
pexelsApiKey,
unsplashAccessKey,
activeTab,
isConfigured,
ensureChat,

View file

@ -1,5 +1,5 @@
import { onUnmounted } from 'vue'
import { useFileDialog } from '@vueuse/core'
import { onUnmounted } from 'vue'
import { IS_TAURI } from '@/constants'
import { useEditorStore } from '@/stores/editor'

View file

@ -264,6 +264,29 @@ export function createEditorStore() {
if (!flashRafId) pumpFlashes()
}
function aiMarkActive(nodeIds: string[]) {
if (!_renderer) return
_renderer.aiMarkActive(nodeIds)
if (!flashRafId) pumpFlashes()
}
function aiMarkDone(nodeIds: string[]) {
if (!_renderer) return
_renderer.aiMarkDone(nodeIds)
if (!flashRafId) pumpFlashes()
}
function aiFlashDone(nodeIds: string[]) {
if (!_renderer) return
_renderer.aiFlashDone(nodeIds)
if (!flashRafId) pumpFlashes()
}
function aiClearAll() {
if (!_renderer) return
_renderer.aiClearAll()
}
function pumpFlashes() {
if (!_renderer?.hasActiveFlashes) {
flashRafId = 0
@ -2345,6 +2368,10 @@ export function createEditorStore() {
requestRender,
requestRepaint,
flashNodes,
aiMarkActive,
aiMarkDone,
aiFlashDone,
aiClearAll,
setTool,
select,
clearSelection,

View file

@ -1053,6 +1053,37 @@ describe('Auto Layout', () => {
expect(updatedText.height).toBeGreaterThan(0)
})
test('without measurer, HEIGHT text estimates multi-line wrapping', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 269,
height: 400,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
})
graph.createNode('TEXT', frame.id, {
width: 269,
height: 20,
text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected',
fontSize: 15,
lineHeight: 22,
textAutoResize: 'HEIGHT' as const,
})
setTextMeasurer(null)
computeAllLayouts(graph)
const children = graph.getChildren(frame.id)
const updatedText = children[0]
// 74 chars × 15 × 0.6 = 666px single line, in 269px → ~3 lines × 22px = 66px
expect(updatedText.height).toBeGreaterThan(22)
expect(updatedText.width).toBe(269)
})
test('text with w="fill" in flex="col" stretches to parent width', () => {
const graph = new SceneGraph()
const pid = pageId(graph)

View file

@ -10,7 +10,10 @@ function createMockCanvas() {
return {
drawParagraph: mock(() => {}),
drawPicture: mock(() => {}),
drawText: mock(() => {})
drawText: mock(() => {}),
save: mock(() => {}),
restore: mock(() => {}),
clipRect: mock(() => {})
}
}
@ -29,7 +32,11 @@ function createMockRenderer(overrides: Partial<Record<string, unknown>> = {}) {
fontProvider: {},
textFont: {},
fillPaint: { getColor: () => new Float32Array([0, 0, 0, 1]) },
ck: { MakePicture: mock(() => createMockPicture()) },
ck: {
MakePicture: mock(() => createMockPicture()),
LTRBRect: mock((...args: number[]) => args),
ClipOp: { Intersect: 0 }
},
DEFAULT_FONT_SIZE: 14,
isNodeFontLoaded: mock(() => true),
buildParagraph: mock(() => paragraph),
@ -60,30 +67,19 @@ describe('renderText', () => {
expect(r._paragraph.delete).toHaveBeenCalledTimes(1)
})
test('falls back to drawText when fonts loaded but node font NOT available', () => {
test('uses paragraph even when node font is NOT available (fallback to default)', () => {
const r = createMockRenderer({ isNodeFontLoaded: mock(() => false) })
const canvas = createMockCanvas()
renderText(r, canvas as never, textNode())
expect(r.buildParagraph).not.toHaveBeenCalled()
expect(canvas.drawText).toHaveBeenCalledTimes(1)
})
test('prefers textPicture when node font is NOT available and textPicture exists', () => {
const r = createMockRenderer({ isNodeFontLoaded: mock(() => false) })
const canvas = createMockCanvas()
const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) })
renderText(r, canvas as never, node)
expect(canvas.drawPicture).toHaveBeenCalledTimes(1)
expect(r.buildParagraph).not.toHaveBeenCalled()
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
expect(canvas.drawText).not.toHaveBeenCalled()
})
test('prefers textPicture even when node font IS loaded', () => {
const r = createMockRenderer({ isNodeFontLoaded: mock(() => true) })
test('prefers textPicture over paragraph', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) })
@ -93,16 +89,6 @@ describe('renderText', () => {
expect(r.buildParagraph).not.toHaveBeenCalled()
})
test('uses drawText when fonts loaded but node font not available and no textPicture', () => {
const r = createMockRenderer({ isNodeFontLoaded: mock(() => false) })
const canvas = createMockCanvas()
renderText(r, canvas as never, textNode())
expect(canvas.drawText).toHaveBeenCalledTimes(1)
expect(r.buildParagraph).not.toHaveBeenCalled()
})
test('falls back to drawText only when fonts are NOT loaded', () => {
const r = createMockRenderer({ fontsLoaded: false, fontProvider: null })
const canvas = createMockCanvas()