diff --git a/src/components/panels/ai-chat-panel.tsx b/src/components/panels/ai-chat-panel.tsx
index 0a391e253..69e071406 100644
--- a/src/components/panels/ai-chat-panel.tsx
+++ b/src/components/panels/ai-chat-panel.tsx
@@ -74,7 +74,8 @@ function isDesignRequest(text: string): boolean {
function buildContextString(): string {
const selectedIds = useCanvasStore.getState().selection.selectedIds
- const flatNodes = useDocumentStore.getState().getFlatNodes()
+ const { getFlatNodes, document: doc } = useDocumentStore.getState()
+ const flatNodes = getFlatNodes()
const parts: string[] = []
@@ -96,6 +97,14 @@ function buildContextString(): string {
parts.push(`Selected: ${selectedSummary}`)
}
+ // Include variable summary so chat mode also knows about design tokens
+ if (doc.variables && Object.keys(doc.variables).length > 0) {
+ const varNames = Object.entries(doc.variables)
+ .map(([n, d]) => `$${n}(${d.type})`)
+ .join(', ')
+ parts.push(`Variables: ${varNames}`)
+ }
+
return parts.length > 0 ? `\n\n[Canvas context: ${parts.join('. ')}]` : ''
}
@@ -164,14 +173,17 @@ function useChatHandlers() {
if (isDesign) {
if (isModification) {
// --- MODIFICATION MODE ---
- const { getNodeById } = useDocumentStore.getState()
+ const { getNodeById, document: modDoc } = useDocumentStore.getState()
const selectedNodes = selectedIds.map(id => getNodeById(id)).filter(Boolean) as any[]
-
+
// We update the UI to show we are working
accumulated = 'Analyzing modification request...'
updateLastMessage(accumulated)
- const { rawResponse, nodes } = await generateDesignModification(selectedNodes, messageText)
+ const { rawResponse, nodes } = await generateDesignModification(selectedNodes, messageText, {
+ variables: modDoc.variables,
+ themes: modDoc.themes,
+ })
accumulated = rawResponse
updateLastMessage(accumulated)
@@ -180,11 +192,14 @@ function useChatHandlers() {
appliedCount += count
} else {
// --- GENERATION MODE (animated) ---
+ const doc = useDocumentStore.getState().document
const { rawResponse, nodes } = await generateDesign({
prompt: fullUserMessage,
context: {
canvasSize: { width: 1200, height: 800 },
documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`,
+ variables: doc.variables,
+ themes: doc.themes,
},
}, {
animated: true,
diff --git a/src/services/ai/ai-prompts.ts b/src/services/ai/ai-prompts.ts
index e49b0e676..ba20566b6 100644
--- a/src/services/ai/ai-prompts.ts
+++ b/src/services/ai/ai-prompts.ts
@@ -125,7 +125,13 @@ DESIGN GUIDELINES:
- Consistent color palette
- Use path nodes for icons (SVG d path data). Size icons 16-24px. Preserve the natural aspect ratio of the SVG path — do NOT force all icons to square
- Use image nodes for photos/illustrations with picsum.photos placeholder URLs
-- Buttons, nav items, and list items should include icons when appropriate for better UX`
+- Buttons, nav items, and list items should include icons when appropriate for better UX
+
+DESIGN VARIABLES:
+- When the user message includes a DOCUMENT VARIABLES section, use "$variableName" references instead of hardcoded values wherever a matching variable exists.
+- Color variables: use in fill color, stroke color, shadow color. Example: [{ "type": "solid", "color": "$primary" }]
+- Number variables: use for gap, padding, opacity. Example: "gap": "$spacing-md"
+- Only reference variables that are listed — do NOT invent new variable names.`
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON generation engine. Your ONLY job is to convert design descriptions into PenNode JSON.
@@ -183,6 +189,13 @@ VISUAL QUALITY GUARDRAILS:
- Avoid oversized decorations outside the root frame (max ~10% bleed allowed)
- Do not use emoji in headings or body copy unless the user explicitly asks for it
+DESIGN VARIABLES:
+- When the user message includes a DOCUMENT VARIABLES section, use "$variableName" references instead of hardcoded values wherever a matching variable exists.
+- Color variables: use in fill color, stroke color, shadow color. Example: [{ "type": "solid", "color": "$primary" }]
+- Number variables: use for gap, padding, opacity. Example: "gap": "$spacing-md"
+- Only reference variables that are listed — do NOT invent new variable names.
+- If no variables are provided, use hardcoded values as usual.
+
Design like a professional: visual hierarchy, contrast, whitespace, consistent palette, purposeful iconography.`
export const CODE_GENERATOR_PROMPT = `You are a code generation engine for OpenPencil. Convert PenNode design descriptions into clean, production-ready code.
@@ -214,6 +227,7 @@ RULES:
- PRESERVE IDs: The most important rule. If you return a node with a new ID, it will be treated as a new object. To update, you MUST match the input ID.
- PARTIAL UPDATES: You can return the full node object with updated fields.
- DO NOT CHANGE UNRELATED PROPS: If the user says "change color", do not change the x/y position unless necessary.
+- DESIGN VARIABLES: When the user message includes a DOCUMENT VARIABLES section, prefer "$variableName" references over hardcoded values for matching properties. Only reference listed variables.
RESPONSE FORMAT:
1. ...
diff --git a/src/services/ai/ai-types.ts b/src/services/ai/ai-types.ts
index dcbcfc1ff..37ae8212b 100644
--- a/src/services/ai/ai-types.ts
+++ b/src/services/ai/ai-types.ts
@@ -12,6 +12,8 @@ export interface AIDesignRequest {
selectedNodes?: string[]
documentSummary?: string
canvasSize?: { width: number; height: number }
+ variables?: Record
+ themes?: Record
}
}
diff --git a/src/services/ai/design-generator.ts b/src/services/ai/design-generator.ts
index 144a18993..019320ce1 100644
--- a/src/services/ai/design-generator.ts
+++ b/src/services/ai/design-generator.ts
@@ -1,4 +1,5 @@
import type { PenNode } from '@/types/pen'
+import type { VariableDefinition, ThemedValue } from '@/types/variables'
import type { AIDesignRequest } from './ai-types'
import { streamChat } from './ai-service'
import { DESIGN_GENERATOR_PROMPT, DESIGN_MODIFIER_PROMPT } from './ai-prompts'
@@ -78,12 +79,48 @@ function buildContextMessage(request: AIDesignRequest): string {
message += `\n\nCurrent document: ${request.context.documentSummary}`
}
+ // Append variable context so AI can use $variable references
+ const varContext = buildVariableContext(request.context?.variables, request.context?.themes)
+ if (varContext) {
+ message += `\n\n${varContext}`
+ }
+
// FORCE override to prevent tool usage
message += `\n\nIMPORTANT: You remain in DIRECT RESPONSE MODE. Do NOT use the "Write" tool or any other function. I cannot see tool outputs. Just write the JSON response directly.`
return message
}
+/** Build a concise summary of document variables for AI context. */
+function buildVariableContext(
+ variables?: Record,
+ themes?: Record,
+): string | null {
+ if (!variables || Object.keys(variables).length === 0) return null
+
+ const lines: string[] = ['DOCUMENT VARIABLES (use "$name" to reference, e.g. fill color "$color-1"):']
+
+ for (const [name, def] of Object.entries(variables)) {
+ const val = def.value
+ if (Array.isArray(val)) {
+ // Themed variable — show default value
+ const defaultVal = (val as ThemedValue[])[0]?.value ?? '?'
+ lines.push(` - ${name} (${def.type}): ${defaultVal} [themed]`)
+ } else {
+ lines.push(` - ${name} (${def.type}): ${val}`)
+ }
+ }
+
+ if (themes && Object.keys(themes).length > 0) {
+ const themeSummary = Object.entries(themes)
+ .map(([axis, values]) => `${axis}: [${values.join(', ')}]`)
+ .join('; ')
+ lines.push(`Themes: ${themeSummary}`)
+ }
+
+ return lines.join('\n')
+}
+
/**
* Helper to find all complete JSON blocks in text
*/
@@ -259,6 +296,10 @@ function tryParseNodes(json: string): PenNode[] | null {
export async function generateDesignModification(
nodesToModify: PenNode[],
instruction: string,
+ options?: {
+ variables?: Record
+ themes?: Record
+ },
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
// Build context from selected nodes
const contextJson = JSON.stringify(nodesToModify, (_key, value) => {
@@ -267,7 +308,13 @@ export async function generateDesignModification(
})
// We use standard string concatenation to avoid backtick issues in tool calls
- const userMessage = "CONTEXT NODES:\n" + contextJson + "\n\nINSTRUCTION:\n" + instruction
+ let userMessage = "CONTEXT NODES:\n" + contextJson + "\n\nINSTRUCTION:\n" + instruction
+
+ // Append variable context so AI can use $variable references
+ const varContext = buildVariableContext(options?.variables, options?.themes)
+ if (varContext) {
+ userMessage += "\n\n" + varContext
+ }
let fullResponse = ''
let streamError: string | null = null