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:
parent
7ac4c4c1a9
commit
7305dbe634
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
73
packages/core/src/renderer/ai-overlays.ts
Normal file
73
packages/core/src/renderer/ai-overlays.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } {
|
||||
|
|
|
|||
257
packages/core/src/tools/stock-photo.ts
Normal file
257
packages/core/src/tools/stock-photo.ts
Normal 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 }
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue