From 8a39bc7b033a7b9a3c4d201a28362cf84d48e388 Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 24 May 2026 00:39:09 +0800 Subject: [PATCH] Merge branch 'v0.8.0-new' of github.com:ZSeven-W/openpencil into v0.8.0-new --- .../src/services/ai/design-code-generator.ts | 192 ----- .../services/ai/design-system-generator.ts | 174 ---- apps/web/src/services/ai/design-validation.ts | 25 +- apps/web/src/services/ai/html-renderer.ts | 125 --- .../services/ai/visual-ref-orchestrator.ts | 243 ------ .../fixtures/golden/edge-section-padding.json | 7 +- .../golden/stacked-horizontal-padding.json | 7 +- .../op-host-desktop/src/chat_orchestrator.rs | 3 +- .../op-orchestrator/src/concurrent_tests.rs | 2 + .../src/concurrent_tests_b2.rs | 2 + crates/op-orchestrator/src/design_system.rs | 478 +++++++++++ .../src/design_system_tests.rs | 560 ++++++++++++ crates/op-orchestrator/src/lib.rs | 15 +- crates/op-orchestrator/src/plan.rs | 2 + crates/op-orchestrator/src/plan_normalize.rs | 4 + .../op-orchestrator/src/plan_repair_tests.rs | 4 + crates/op-orchestrator/src/prompt.rs | 16 + crates/op-orchestrator/src/run_tests.rs | 6 + crates/op-orchestrator/src/run_tests_b4.rs | 8 + crates/op-orchestrator/src/run_tests_c2.rs | 4 + crates/op-orchestrator/src/run_tests_c3.rs | 4 + crates/op-orchestrator/src/run_tests_d1.rs | 12 + crates/op-orchestrator/src/stub_providers.rs | 16 +- crates/op-orchestrator/src/subagent.rs | 2 + crates/op-orchestrator/src/types.rs | 101 +++ .../src/validation_tests_c2.rs | 1 + crates/op-orchestrator/src/variables.rs | 2 + crates/op-orchestrator/src/visual_ref.rs | 758 +++++++++++++++++ .../op-orchestrator/src/visual_ref_tests.rs | 794 ++++++++++++++++++ 29 files changed, 2799 insertions(+), 768 deletions(-) delete mode 100644 apps/web/src/services/ai/design-code-generator.ts delete mode 100644 apps/web/src/services/ai/design-system-generator.ts delete mode 100644 apps/web/src/services/ai/html-renderer.ts delete mode 100644 apps/web/src/services/ai/visual-ref-orchestrator.ts create mode 100644 crates/op-orchestrator/src/design_system.rs create mode 100644 crates/op-orchestrator/src/design_system_tests.rs create mode 100644 crates/op-orchestrator/src/visual_ref.rs create mode 100644 crates/op-orchestrator/src/visual_ref_tests.rs diff --git a/apps/web/src/services/ai/design-code-generator.ts b/apps/web/src/services/ai/design-code-generator.ts deleted file mode 100644 index 11656bddc..000000000 --- a/apps/web/src/services/ai/design-code-generator.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Design Code Generator (Stage 1 of visual reference pipeline). - * - * Generates self-contained HTML/CSS code using the model's strongest design - * capability. The output is a visual reference that guides PenNode generation. - * Design principles are included to ensure consistent visual quality. - */ - -import type { DesignSystem } from './ai-types'; -import type { AIProviderType } from '@/types/agent-settings'; -import { generateCompletion } from './ai-service'; -import { getSkillByName } from '@zseven-w/pen-ai-skills'; -import { designSystemToPromptContext } from './design-system-generator'; - -interface CodeGenOptions { - width: number; - height: number; - model?: string; - provider?: AIProviderType; -} - -/** - * Generate self-contained HTML/CSS code for a design request. - * The code is production-grade and serves as a visual blueprint. - */ -export async function generateDesignCode( - prompt: string, - designSystem: DesignSystem, - options: CodeGenOptions, -): Promise { - const designCodeSkill = getSkillByName('design-code')?.content ?? ''; - const principles = getSkillByName('design-principles')?.content ?? ''; - - // Build the system prompt with principles injected - const systemPrompt = principles ? `${designCodeSkill}\n\n${principles}` : designCodeSkill; - - // Build the user prompt with design system context - const dsContext = designSystemToPromptContext(designSystem); - const userPrompt = buildCodeGenUserPrompt(prompt, dsContext, options.width, options.height); - - const response = await generateCompletion( - systemPrompt, - userPrompt, - options.model, - options.provider, - ); - - return extractHtmlFromResponse(response); -} - -/** - * Extract the HTML content from an AI response. - * Handles responses with code fences, markdown, or bare HTML. - */ -function extractHtmlFromResponse(response: string): string { - const trimmed = response.trim(); - - // Check for code fence wrapped HTML - const fenceMatch = trimmed.match(/```(?:html)?\s*\n?([\s\S]*?)\n?```/); - if (fenceMatch) { - const content = fenceMatch[1].trim(); - if (content.includes(')/i); - if (htmlMatch) { - return htmlMatch[1]; - } - - // Last resort: wrap bare content - return ` - -Design -${trimmed} -`; -} - -/** - * Extract a structural summary from HTML for use as sub-agent reference. - * Produces a concise text description of the HTML structure. - */ -export function extractStructureSummary(html: string): string { - const lines: string[] = ['DESIGN REFERENCE STRUCTURE:']; - - // Extract section-level elements - const sectionPattern = - /<(?:section|header|footer|nav|main|div)\s+[^>]*(?:class|id)="([^"]*)"[^>]*>/gi; - let match: RegExpExecArray | null; - while ((match = sectionPattern.exec(html)) !== null) { - const classOrId = match[1]; - if (classOrId && !classOrId.includes('__')) { - lines.push(`- Section: ${classOrId}`); - } - } - - // Extract heading content for structure hints - const headingPattern = /]*>([\s\S]*?)<\/h\1>/gi; - while ((match = headingPattern.exec(html)) !== null) { - const level = match[1]; - const content = match[2] - .replace(/<[^>]+>/g, '') - .trim() - .slice(0, 60); - if (content) { - lines.push(`- H${level}: "${content}"`); - } - } - - // Extract button/CTA text - const buttonPattern = - /<(?:button|a)\s+[^>]*class="[^"]*(?:btn|button|cta)[^"]*"[^>]*>([\s\S]*?)<\/(?:button|a)>/gi; - while ((match = buttonPattern.exec(html)) !== null) { - const text = match[1] - .replace(/<[^>]+>/g, '') - .trim() - .slice(0, 30); - if (text) { - lines.push(`- CTA: "${text}"`); - } - } - - // If we couldn't extract structure, provide a generic summary - if (lines.length <= 1) { - lines.push('(HTML structure extracted — use as visual layout reference)'); - } - - return lines.join('\n'); -} - -/** - * Extract the HTML section relevant to a specific subtask label. - * Uses heuristic matching on section/div IDs, classes, and heading content. - */ -export function extractHtmlSection(html: string, subtaskLabel: string): string | null { - const labelLower = subtaskLabel.toLowerCase(); - - // Try to find a matching section by common keywords - const keywords = labelLower - .replace(/[((].+[))]/g, '') - .split(/[\s,/]+/) - .filter((w) => w.length > 2); - - if (keywords.length === 0) return null; - - // Build a regex to match section containers - const keywordPattern = keywords.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); - const sectionRegex = new RegExp( - `<(?:section|div|header|footer|nav)[^>]*(?:class|id)="[^"]*(?:${keywordPattern})[^"]*"[^>]*>[\\s\\S]*?(?=<(?:section|div|header|footer|nav)[^>]*(?:class|id)="|$)`, - 'i', - ); - - const match = sectionRegex.exec(html); - if (match) { - // Truncate to reasonable length for context - const section = match[0].slice(0, 1500); - return `HTML reference for "${subtaskLabel}":\n${section}`; - } - - return null; -} - -/** - * Build the user prompt for HTML/CSS code generation. - * Includes the design system tokens and viewport constraints. - */ -function buildCodeGenUserPrompt( - userPrompt: string, - designSystemContext: string, - width: number, - height: number, -): string { - const heightInstruction = - height > 0 - ? `Height: ${height}px (fixed viewport).` - : `Height: auto (content determines height, estimate based on sections).`; - - return `Design request: ${userPrompt} - -Viewport: Width ${width}px. ${heightInstruction} - -${designSystemContext} - -Generate the complete HTML file now.`; -} diff --git a/apps/web/src/services/ai/design-system-generator.ts b/apps/web/src/services/ai/design-system-generator.ts deleted file mode 100644 index 0bd006b65..000000000 --- a/apps/web/src/services/ai/design-system-generator.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Design System Generator (Stage 0 of visual reference pipeline). - * - * Generates structured design tokens (colors, typography, spacing) from - * a user's design request. The tokens serve dual purpose: - * 1. Guide HTML code generation for consistent design - * 2. Map to PenDocument.variables for design system integration - */ - -import type { DesignSystem } from './ai-types'; -import type { AIProviderType } from '@/types/agent-settings'; -import type { VariableDefinition } from '@/types/variables'; -import { generateCompletion } from './ai-service'; -import { getSkillByName } from '@zseven-w/pen-ai-skills'; - -/** - * Generate a design system from a user's prompt. - * Uses a fast model (Haiku-class) for speed since output is small JSON. - */ -export async function generateDesignSystem( - prompt: string, - model?: string, - provider?: AIProviderType, -): Promise { - const designSystemPrompt = getSkillByName('design-system')?.content ?? ''; - const response = await generateCompletion(designSystemPrompt, prompt, model, provider); - - return parseDesignSystem(response); -} - -/** - * Parse a design system from AI response text. - * Tolerant of code fences and surrounding text. - */ -function parseDesignSystem(text: string): DesignSystem { - const trimmed = text.trim(); - - // Try direct parse - const direct = tryParseDS(trimmed); - if (direct) return direct; - - // Try extracting from code fences - const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); - if (fenceMatch) { - const fenced = tryParseDS(fenceMatch[1].trim()); - if (fenced) return fenced; - } - - // Try extracting first { ... } block - const firstBrace = trimmed.indexOf('{'); - const lastBrace = trimmed.lastIndexOf('}'); - if (firstBrace >= 0 && lastBrace > firstBrace) { - const braced = tryParseDS(trimmed.slice(firstBrace, lastBrace + 1)); - if (braced) return braced; - } - - // Fallback: return default design system - return DEFAULT_DESIGN_SYSTEM; -} - -function tryParseDS(json: string): DesignSystem | null { - try { - const obj = JSON.parse(json) as Record; - if (!obj.palette || typeof obj.palette !== 'object') return null; - if (!obj.typography || typeof obj.typography !== 'object') return null; - - const p = obj.palette as Record; - const t = obj.typography as Record; - const s = (obj.spacing as Record) ?? { - unit: 8, - scale: [8, 16, 24, 32, 48, 64], - }; - - return { - palette: { - background: p.background ?? '#F8FAFC', - surface: p.surface ?? '#FFFFFF', - text: p.text ?? '#0F172A', - textSecondary: p.textSecondary ?? '#475569', - primary: p.primary ?? '#2563EB', - primaryLight: p.primaryLight ?? '#DBEAFE', - accent: p.accent ?? '#0EA5E9', - border: p.border ?? '#E2E8F0', - }, - typography: { - headingFont: (t.headingFont as string) ?? 'Space Grotesk', - bodyFont: (t.bodyFont as string) ?? 'Inter', - scale: Array.isArray(t.scale) ? (t.scale as number[]) : [14, 16, 20, 28, 40, 56], - }, - spacing: { - unit: (s.unit as number) ?? 8, - scale: Array.isArray(s.scale) ? (s.scale as number[]) : [8, 16, 24, 32, 48, 64], - }, - radius: Array.isArray(obj.radius) ? (obj.radius as number[]) : [8, 12, 16], - aesthetic: (obj.aesthetic as string) ?? 'clean modern', - }; - } catch { - return null; - } -} - -const DEFAULT_DESIGN_SYSTEM: DesignSystem = { - palette: { - background: '#F8FAFC', - surface: '#FFFFFF', - text: '#0F172A', - textSecondary: '#475569', - primary: '#2563EB', - primaryLight: '#DBEAFE', - accent: '#0EA5E9', - border: '#E2E8F0', - }, - typography: { - headingFont: 'Space Grotesk', - bodyFont: 'Inter', - scale: [14, 16, 20, 28, 40, 56], - }, - spacing: { - unit: 8, - scale: [8, 16, 24, 32, 48, 64], - }, - radius: [8, 12, 16], - aesthetic: 'clean modern blue', -}; - -// --------------------------------------------------------------------------- -// Map design system → PenDocument.variables -// --------------------------------------------------------------------------- - -/** - * Convert a DesignSystem into PenDocument variable definitions. - * These are stored in the document and referenced as $variable-name in nodes. - */ -export function designSystemToVariables(ds: DesignSystem): Record { - const vars: Record = {}; - - // Colors - for (const [key, value] of Object.entries(ds.palette)) { - const name = `color-${kebab(key)}`; - vars[name] = { type: 'color', value }; - } - - // Spacing - const spacingNames = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl']; - for (let i = 0; i < ds.spacing.scale.length && i < spacingNames.length; i++) { - vars[`spacing-${spacingNames[i]}`] = { type: 'number', value: ds.spacing.scale[i] }; - } - - // Radius - const radiusNames = ['sm', 'md', 'lg', 'xl']; - for (let i = 0; i < ds.radius.length && i < radiusNames.length; i++) { - vars[`radius-${radiusNames[i]}`] = { type: 'number', value: ds.radius[i] }; - } - - return vars; -} - -/** - * Build a concise design system context string for AI prompts. - */ -export function designSystemToPromptContext(ds: DesignSystem): string { - const p = ds.palette; - return `DESIGN SYSTEM (use these values consistently): -Colors: bg ${p.background}, surface ${p.surface}, text ${p.text}, muted ${p.textSecondary}, primary ${p.primary}, primaryLight ${p.primaryLight}, accent ${p.accent}, border ${p.border} -Fonts: heading "${ds.typography.headingFont}", body "${ds.typography.bodyFont}" -Type scale: ${ds.typography.scale.join(', ')}px -Spacing: ${ds.spacing.scale.join(', ')}px (${ds.spacing.unit}px grid) -Radius: ${ds.radius.join(', ')}px -Style: ${ds.aesthetic}`; -} - -function kebab(str: string): string { - return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); -} diff --git a/apps/web/src/services/ai/design-validation.ts b/apps/web/src/services/ai/design-validation.ts index fa9f01e81..9c420d7fc 100644 --- a/apps/web/src/services/ai/design-validation.ts +++ b/apps/web/src/services/ai/design-validation.ts @@ -18,7 +18,6 @@ import { } from './ai-runtime-config'; import type { PenNode } from '@/types/pen'; import type { AIProviderType } from '@/types/agent-settings'; -import { getCurrentVisualReference, clearVisualReference } from './visual-ref-orchestrator'; import { resolveSkills } from '@zseven-w/pen-ai-skills'; import { runPreValidationFixesDetailed } from './design-pre-validation'; @@ -310,7 +309,6 @@ export async function runPostGenerationValidation(options?: { // If LLM validation is disabled, stop after pre-checks if (!VALIDATION_ENABLED) { - clearVisualReference(); const breakdown = formatCategoryBreakdown(preFix.byCategory); emit( 'done', @@ -328,7 +326,6 @@ export async function runPostGenerationValidation(options?: { // outputs (one badge, one chart) are not worth the round-trip. const nodeCount = countNodesInActivePage(); if (nodeCount < VALIDATION_NODE_COUNT_THRESHOLD) { - clearVisualReference(); const breakdown = formatCategoryBreakdown(preFix.byCategory); emit( 'done', @@ -369,7 +366,6 @@ export async function runPostGenerationValidation(options?: { console.warn(`[Validation] Round ${round}: could not capture screenshot — stopping`); if (isFirstRound) { emit('done', '[error] Screenshot failed'); - clearVisualReference(); return { applied: 0, skipped: true }; } break; @@ -386,17 +382,14 @@ export async function runPostGenerationValidation(options?: { console.log(`[Validation] Node tree dump:\n${nodeTreeDump}`); } - // Reference comparison only on first round - const visualRef = isFirstRound ? getCurrentVisualReference() : null; - const hasReference = visualRef?.screenshot && visualRef.screenshot.length > 0; - + // Reference-comparison path was deleted with the visual-ref pipeline + // (no caller ever populated `currentReference`). The Rust port keeps + // the `referenceScreenshot` parameter on `validateDesignScreenshot` + // so a future visual-ref source can be plumbed back in without + // touching this control flow. emit( 'streaming', - hasReference && isFirstRound - ? '[pending] Comparing with design reference...' - : isFirstRound - ? '[pending] Analyzing design...' - : `[pending] Analyzing (round ${round})...`, + isFirstRound ? '[pending] Analyzing design...' : `[pending] Analyzing (round ${round})...`, ); const result = await validateDesignScreenshot( @@ -404,7 +397,7 @@ export async function runPostGenerationValidation(options?: { nodeTreeDump, options?.model, options?.provider, - hasReference ? visualRef!.screenshot : undefined, + undefined, round, ); @@ -420,7 +413,6 @@ export async function runPostGenerationValidation(options?: { : 'timeout or provider error'; log[log.length - 1] = `[error] Analysis skipped (${reasonShort})`; if (isFirstRound) { - clearVisualReference(); emit('done'); return { applied: 0, skipped: true }; } @@ -504,9 +496,6 @@ export async function runPostGenerationValidation(options?: { emit('streaming'); } - // Cleanup visual reference after all rounds - clearVisualReference(); - // Final summary line const qualityInfo = lastQualityScore > 0 ? ` — quality: ${lastQualityScore}/10` : ''; if (totalApplied > 0) { diff --git a/apps/web/src/services/ai/html-renderer.ts b/apps/web/src/services/ai/html-renderer.ts deleted file mode 100644 index 5c7b6fbd0..000000000 --- a/apps/web/src/services/ai/html-renderer.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * HTML Renderer (Stage 2 of visual reference pipeline). - * - * Renders generated HTML/CSS to a screenshot using a hidden iframe + html2canvas. - * Runs entirely client-side — no external browser process needed. - */ - -import html2canvas from 'html2canvas'; - -/** - * Render an HTML string to a base64 PNG screenshot. - * Creates a hidden iframe, writes the HTML, and captures with html2canvas. - * - * @param html - Complete HTML document string - * @param width - Viewport width in pixels - * @param height - Viewport height in pixels (0 = auto based on content) - * @returns Base64 PNG string (without data: URL prefix) - */ -export async function renderHtmlToScreenshot( - html: string, - width: number, - height: number, -): Promise { - // Safety check — only runs in browser - if (typeof document === 'undefined') { - throw new Error('renderHtmlToScreenshot requires a browser environment'); - } - - const iframe = document.createElement('iframe'); - - try { - // Position off-screen - iframe.style.cssText = ` - position: fixed; - left: -9999px; - top: 0; - width: ${width}px; - height: ${height > 0 ? `${height}px` : '4000px'}; - border: none; - opacity: 0; - pointer-events: none; - `; - document.body.appendChild(iframe); - - const iframeDoc = iframe.contentDocument; - if (!iframeDoc) { - throw new Error('Could not access iframe document'); - } - - // Write the HTML into the iframe (same-origin blob) - iframeDoc.open(); - iframeDoc.write(html); - iframeDoc.close(); - - // Wait for fonts and rendering to settle - await waitForRender(iframeDoc); - - // Determine actual content height if height was auto - const captureHeight = height > 0 ? height : Math.min(iframeDoc.body.scrollHeight || 4000, 6000); - - // Resize iframe to actual content height - if (height <= 0) { - iframe.style.height = `${captureHeight}px`; - // Wait one more frame for resize to apply - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); - } - - // Capture with html2canvas - const canvas = await html2canvas(iframeDoc.body, { - width, - height: captureHeight, - windowWidth: width, - windowHeight: captureHeight, - useCORS: true, - allowTaint: true, - scale: 1, // 1x is sufficient for reference (saves memory/bandwidth) - logging: false, - backgroundColor: null, // Preserve transparency - }); - - // Convert to base64 PNG (strip the data:image/png;base64, prefix) - const dataUrl = canvas.toDataURL('image/png'); - const base64 = dataUrl.replace(/^data:image\/png;base64,/, ''); - - return base64; - } finally { - // Cleanup - if (iframe.parentNode) { - document.body.removeChild(iframe); - } - } -} - -/** - * Wait for the iframe document to finish rendering. - * Waits for fonts, images, and layout to stabilize. - */ -async function waitForRender(doc: Document): Promise { - // Wait for fonts to load (if the document's fonts API is available) - try { - if (doc.fonts && typeof doc.fonts.ready === 'object') { - await Promise.race([ - doc.fonts.ready, - new Promise((r) => setTimeout(r, 3000)), // Max 3s for fonts - ]); - } - } catch { - // Fonts API not available in iframe — continue anyway - } - - // Wait for general rendering to stabilize (2 animation frames + small delay) - await new Promise((resolve) => { - setTimeout(() => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - resolve(); - }); - }); - }, 300); // 300ms for CSS transitions and layout - }); -} diff --git a/apps/web/src/services/ai/visual-ref-orchestrator.ts b/apps/web/src/services/ai/visual-ref-orchestrator.ts deleted file mode 100644 index 7e59dbf8f..000000000 --- a/apps/web/src/services/ai/visual-ref-orchestrator.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Visual Reference Orchestrator — full A+B+C pipeline. - * - * Orchestrates the visual reference pipeline: - * Stage 0: Generate design system tokens (Phase B) - * Stage 1: Generate HTML/CSS code with skill-enhanced prompts (Phase A+C) - * Stage 2: Render HTML to screenshot (Phase C) - * Stage 3: Run PenNode generation with visual reference context - * Stage 4: Validate against reference screenshot - * - * The key insight: separating "what looks good" (Stages 0-2) from - * "how to encode it" (Stage 3) lets each LLM call focus on what it's best at. - */ - -import type { PenNode } from '@/types/pen'; -import type { AIDesignRequest, DesignSystem, VisualReference } from './ai-types'; -import { - generateDesignSystem, - designSystemToVariables, - designSystemToPromptContext, -} from './design-system-generator'; -import { - generateDesignCode, - extractStructureSummary, - extractHtmlSection, -} from './design-code-generator'; -import { renderHtmlToScreenshot } from './html-renderer'; -import { executeOrchestration } from './orchestrator'; -import { useDocumentStore } from '@/stores/document-store'; - -// --------------------------------------------------------------------------- -// Module state — reference data for the current generation -// --------------------------------------------------------------------------- - -let currentReference: VisualReference | null = null; - -export function getCurrentVisualReference(): VisualReference | null { - return currentReference; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export async function executeVisualRefOrchestration( - request: AIDesignRequest, - callbacks?: { - onApplyPartial?: (count: number) => void; - onTextUpdate?: (text: string) => void; - animated?: boolean; - }, - abortSignal?: AbortSignal, -): Promise<{ nodes: PenNode[]; rawResponse: string }> { - currentReference = null; - - const emitStatus = (title: string, detail: string) => { - callbacks?.onTextUpdate?.(`${detail}`); - }; - - try { - // -- Stage 0: Generate Design System -- - emitStatus('Crafting design system', 'Selecting colors, typography, and spacing...'); - - if (abortSignal?.aborted) throw new Error('Aborted'); - - let designSystem: DesignSystem; - try { - designSystem = await generateDesignSystem(request.prompt, request.model, request.provider); - } catch (err) { - console.warn('[VisualRef] Design system generation failed, using defaults:', err); - designSystem = getDefaultDesignSystem(); - } - - // Write design tokens to document variables - const variables = designSystemToVariables(designSystem); - const store = useDocumentStore.getState(); - for (const [name, def] of Object.entries(variables)) { - store.setVariable(name, def); - } - - emitStatus('Crafting design system', `Style: ${designSystem.aesthetic}`); - - if (abortSignal?.aborted) throw new Error('Aborted'); - - // -- Stage 1: Generate HTML/CSS Code -- - emitStatus('Generating design reference', 'Creating high-fidelity HTML blueprint...'); - - // Determine viewport from request context or defaults - const width = request.context?.canvasSize?.width ?? 1200; - const height = request.context?.canvasSize?.height ?? 0; - - let html: string; - try { - html = await generateDesignCode(request.prompt, designSystem, { - width, - height, - model: request.model, - provider: request.provider, - }); - } catch (err) { - console.warn('[VisualRef] Code generation failed, falling back to direct pipeline:', err); - return executeOrchestration(request, callbacks, abortSignal); - } - - emitStatus('Generating design reference', 'HTML blueprint ready'); - - if (abortSignal?.aborted) throw new Error('Aborted'); - - // -- Stage 2: Render to Screenshot -- - emitStatus('Rendering reference', 'Capturing visual reference...'); - - let screenshot: string; - try { - screenshot = await renderHtmlToScreenshot(html, width, height); - } catch (err) { - console.warn( - '[VisualRef] Screenshot rendering failed, continuing without visual reference:', - err, - ); - // Continue without screenshot — sub-agents still get the HTML structure - screenshot = ''; - } - - if (screenshot) { - emitStatus('Rendering reference', 'Visual reference captured'); - } - - // Build structure summary for orchestrator context - const structureSummary = extractStructureSummary(html); - - // Store the reference for validation phase - currentReference = { - html, - screenshot, - designSystem, - structureSummary, - }; - - if (abortSignal?.aborted) throw new Error('Aborted'); - - // -- Stage 3: PenNode Generation with Reference -- - // Enhance the request prompt with design reference context - const dsContext = designSystemToPromptContext(designSystem); - const enhancedPrompt = buildEnhancedPrompt(request.prompt, structureSummary, dsContext); - - const enhancedRequest: AIDesignRequest = { - ...request, - prompt: enhancedPrompt, - // Pass through existing context, adding our variables - context: { - ...request.context, - variables: { - ...request.context?.variables, - ...variables, - }, - }, - }; - - // Run the existing orchestration pipeline with enhanced context - const result = await executeOrchestration(enhancedRequest, callbacks, abortSignal); - - return result; - } finally { - // Don't clear currentReference here — validation needs it - } -} - -// --------------------------------------------------------------------------- -// Prompt enhancement -// --------------------------------------------------------------------------- - -function buildEnhancedPrompt( - originalPrompt: string, - structureSummary: string, - designSystemContext: string, -): string { - return `${originalPrompt} - -${structureSummary} - -${designSystemContext} - -IMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition.`; -} - -// --------------------------------------------------------------------------- -// HTML section extraction for sub-agents -// --------------------------------------------------------------------------- - -/** - * Enrich subtasks with HTML reference snippets from the current visual reference. - * Called after orchestrator planning to give each sub-agent structural context. - */ -export function enrichSubtasksWithHtmlReference( - subtasks: Array<{ id: string; label: string; htmlReference?: string }>, -): void { - if (!currentReference) return; - - for (const subtask of subtasks) { - const section = extractHtmlSection(currentReference.html, subtask.label); - if (section) { - subtask.htmlReference = section; - } - } -} - -// --------------------------------------------------------------------------- -// Cleanup -// --------------------------------------------------------------------------- - -export function clearVisualReference(): void { - currentReference = null; -} - -// --------------------------------------------------------------------------- -// Fallback design system -// --------------------------------------------------------------------------- - -function getDefaultDesignSystem(): DesignSystem { - return { - palette: { - background: '#F8FAFC', - surface: '#FFFFFF', - text: '#0F172A', - textSecondary: '#475569', - primary: '#2563EB', - primaryLight: '#DBEAFE', - accent: '#0EA5E9', - border: '#E2E8F0', - }, - typography: { - headingFont: 'Space Grotesk', - bodyFont: 'Inter', - scale: [14, 16, 20, 28, 40, 56], - }, - spacing: { - unit: 8, - scale: [8, 16, 24, 32, 48, 64], - }, - radius: [8, 12, 16], - aesthetic: 'clean modern', - }; -} diff --git a/crates/op-design-lint/tests/fixtures/golden/edge-section-padding.json b/crates/op-design-lint/tests/fixtures/golden/edge-section-padding.json index 9158694df..7ced3d384 100644 --- a/crates/op-design-lint/tests/fixtures/golden/edge-section-padding.json +++ b/crates/op-design-lint/tests/fixtures/golden/edge-section-padding.json @@ -5,12 +5,7 @@ "severity": "warning", "property": "padding", "currentValue": null, - "suggestedValue": [ - 0, - 16, - 0, - 16 - ], + "suggestedValue": [0, 16, 0, 16], "reason": "mobile root has 0 horizontal padding while 2 content section(s) glue text/icon to screen edge" } ] diff --git a/crates/op-design-lint/tests/fixtures/golden/stacked-horizontal-padding.json b/crates/op-design-lint/tests/fixtures/golden/stacked-horizontal-padding.json index c42bd069d..066f706c1 100644 --- a/crates/op-design-lint/tests/fixtures/golden/stacked-horizontal-padding.json +++ b/crates/op-design-lint/tests/fixtures/golden/stacked-horizontal-padding.json @@ -4,12 +4,7 @@ "category": "stacked-horizontal-padding", "severity": "info", "property": "padding", - "currentValue": [ - 0, - 24, - 0, - 24 - ], + "currentValue": [0, 24, 0, 24], "suggestedValue": null, "reason": "section h-padding [24/24] stacks with root h-padding [16/16] — combined gutter 40/40" } diff --git a/crates/op-host-desktop/src/chat_orchestrator.rs b/crates/op-host-desktop/src/chat_orchestrator.rs index 1ed170163..922358bd5 100644 --- a/crates/op-host-desktop/src/chat_orchestrator.rs +++ b/crates/op-host-desktop/src/chat_orchestrator.rs @@ -186,11 +186,12 @@ pub async fn run_design_request( model, provider: provider_id, design_md: state.doc.design_md.clone(), - // S3b-2 / S3b-4 / S3c additions — host 暂无路由,统一保守值。 + // S3b-2 / S3b-4 / S3c / S4 additions — host 暂无路由,统一保守值。 // 真实接线在 task #27 走 chat_runtime intent gate 时定。 append_context: None, concurrency: 1, validation_enabled: false, + visual_ref_enabled: false, }; let mut sink = DesktopDocSink::new(state); // Stub validation providers — production-visible, no-op. Host can diff --git a/crates/op-orchestrator/src/concurrent_tests.rs b/crates/op-orchestrator/src/concurrent_tests.rs index 5269eb99d..0332869b1 100644 --- a/crates/op-orchestrator/src/concurrent_tests.rs +++ b/crates/op-orchestrator/src/concurrent_tests.rs @@ -81,6 +81,8 @@ fn make_req() -> crate::types::DesignRequest { concurrency: 2, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/concurrent_tests_b2.rs b/crates/op-orchestrator/src/concurrent_tests_b2.rs index 03000f53d..57bf60fcf 100644 --- a/crates/op-orchestrator/src/concurrent_tests_b2.rs +++ b/crates/op-orchestrator/src/concurrent_tests_b2.rs @@ -23,6 +23,8 @@ fn make_req() -> crate::types::DesignRequest { concurrency: 2, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/design_system.rs b/crates/op-orchestrator/src/design_system.rs new file mode 100644 index 000000000..5c83ad6f5 --- /dev/null +++ b/crates/op-orchestrator/src/design_system.rs @@ -0,0 +1,478 @@ +//! `design_system.rs` — S4 A1 + B1: `DesignSystem` struct, parse, defaults, +//! LLM generator, variable seeding, and prompt context. +//! +//! Port of `design-system-generator.ts` (deleted in commit `0f12b6e9`): +//! - L20-29: `generateDesignSystem` entry (→ `generate_design_system`). +//! - L40-100: `parseDesignSystem` + `tryParseDS` 4-stage fallback chain. +//! - L102-124: `DEFAULT_DESIGN_SYSTEM` constant values. +//! - L134-156: `designSystemToVariables` (→ `design_system_to_seed_commands`). +//! - L161-170: `designSystemToPromptContext` (→ `design_system_to_prompt_context`). +//! - L59-81: `DesignSystem` interface (via `ai-types.ts`). + +use futures::StreamExt; +use op_editor_core::{EditorCommand, VariableScalarPayload}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use crate::types::{AbortFlag, CallRequest, LlmChunk, LlmClient}; + +// ── DesignSystem struct (mirrors TS ai-types.ts:59-81) ──────────────────────── + +/// Typography section of the design system. +/// +/// Port of `DesignSystem.typography` in `ai-types.ts`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Typography { + pub heading_font: String, + pub body_font: String, + pub scale: Vec, +} + +/// Spacing section of the design system. +/// +/// Port of `DesignSystem.spacing` in `ai-types.ts`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Spacing { + pub unit: f64, + pub scale: Vec, +} + +/// Structured design tokens (colors, typography, spacing, radius, aesthetic). +/// +/// Port of the `DesignSystem` interface in `ai-types.ts:59-81`. +/// +/// ```text +/// palette — keyed color tokens (background, surface, text, textSecondary, +/// primary, primaryLight, accent, border) +/// typography — heading/body font names + type scale (px) +/// spacing — unit grid size + scale steps (px) +/// radius — corner-radius steps (px) +/// aesthetic — prose adjectives describing the visual style +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DesignSystem { + /// Color palette tokens. Keys match TS camelCase names + /// (`background`, `surface`, `text`, `textSecondary`, `primary`, + /// `primaryLight`, `accent`, `border`). + pub palette: BTreeMap, + pub typography: Typography, + pub spacing: Spacing, + /// Corner-radius scale in pixels. Port of `radius: number[]` in TS. + pub radius: Vec, + /// Short aesthetic description (e.g. "clean modern blue"). + pub aesthetic: String, +} + +// ── DEFAULT_DESIGN_SYSTEM (mirrors TS L102-124) ─────────────────────────────── + +fn build_default() -> DesignSystem { + let mut palette = BTreeMap::new(); + palette.insert("background".into(), "#F8FAFC".into()); + palette.insert("surface".into(), "#FFFFFF".into()); + palette.insert("text".into(), "#0F172A".into()); + palette.insert("textSecondary".into(), "#475569".into()); + palette.insert("primary".into(), "#2563EB".into()); + palette.insert("primaryLight".into(), "#DBEAFE".into()); + palette.insert("accent".into(), "#0EA5E9".into()); + palette.insert("border".into(), "#E2E8F0".into()); + + DesignSystem { + palette, + typography: Typography { + heading_font: "Space Grotesk".into(), + body_font: "Inter".into(), + scale: vec![14.0, 16.0, 20.0, 28.0, 40.0, 56.0], + }, + spacing: Spacing { + unit: 8.0, + scale: vec![8.0, 16.0, 24.0, 32.0, 48.0, 64.0], + }, + radius: vec![8.0, 12.0, 16.0], + aesthetic: "clean modern blue".into(), + } +} + +/// Lazily-initialized default design system. +/// +/// Mirrors `DEFAULT_DESIGN_SYSTEM` in `design-system-generator.ts:102-124`. +/// Using `OnceLock` gives a `&'static DesignSystem` that callers can deref +/// without cloning in the common case; callers that need an owned copy call +/// `.clone()`. +pub static DEFAULT_DESIGN_SYSTEM: OnceLock = OnceLock::new(); + +/// Convenience accessor — initialises on first call. +pub fn default_design_system() -> &'static DesignSystem { + DEFAULT_DESIGN_SYSTEM.get_or_init(build_default) +} + +// ── parse_design_system: 4-stage fallback chain (mirrors TS L40-100) ───────── + +/// Parse a `DesignSystem` from LLM response text. +/// +/// Tolerant 4-stage fallback chain — faithfully mirrors `parseDesignSystem` +/// in `design-system-generator.ts:40-100`: +/// +/// 1. Direct `serde_json::from_str` of trimmed input. +/// 2. Strip code fences (` ```json ... ``` ` or ` ``` ... ``` `). +/// 3. Find first `{` and last `}`, substring, retry parse. +/// 4. Return `DEFAULT_DESIGN_SYSTEM` (cloned). +pub fn parse_design_system(text: &str) -> DesignSystem { + let trimmed = text.trim(); + + // Stage 1: direct parse + if let Some(ds) = try_parse_ds(trimmed) { + return ds; + } + + // Stage 2: strip code fences (```json ... ``` or ``` ... ```) + // Regex pattern: ```(?:json)?\s*\n?([\s\S]*?)\n?``` + if let Some(inner) = extract_code_fence(trimmed) { + if let Some(ds) = try_parse_ds(inner.trim()) { + return ds; + } + } + + // Stage 3: first `{` … last `}` + if let (Some(first), Some(last)) = (trimmed.find('{'), trimmed.rfind('}')) { + if last > first { + if let Some(ds) = try_parse_ds(&trimmed[first..=last]) { + return ds; + } + } + } + + // Stage 4: fallback + default_design_system().clone() +} + +/// Try to parse JSON into a `DesignSystem`, returning `None` on any failure. +/// +/// Port of `tryParseDS` in `design-system-generator.ts:62-100`: +/// validates that `palette` and `typography` fields are present objects, +/// then fills missing sub-fields with defaults. +fn try_parse_ds(json: &str) -> Option { + let obj: serde_json::Value = serde_json::from_str(json).ok()?; + + // Must have palette object and typography object + let p = obj.get("palette").and_then(|v| v.as_object())?; + let t = obj.get("typography").and_then(|v| v.as_object())?; + + let default = default_design_system(); + + // Build palette — fill missing keys with defaults + let mut palette = BTreeMap::new(); + let dp = &default.palette; + let palette_keys = [ + "background", + "surface", + "text", + "textSecondary", + "primary", + "primaryLight", + "accent", + "border", + ]; + for key in palette_keys { + let val = p + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| dp.get(key).map(|s| s.as_str()).unwrap_or("")) + .to_string(); + palette.insert(key.to_string(), val); + } + + // Typography — fill missing fields with defaults + let heading_font = t + .get("headingFont") + .and_then(|v| v.as_str()) + .unwrap_or(&default.typography.heading_font) + .to_string(); + let body_font = t + .get("bodyFont") + .and_then(|v| v.as_str()) + .unwrap_or(&default.typography.body_font) + .to_string(); + let type_scale: Vec = t + .get("scale") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.iter().map(|v| v.as_f64()).collect::>>()) + .unwrap_or_else(|| default.typography.scale.clone()); + + // Spacing — optional, fill with defaults + let s_obj = obj.get("spacing").and_then(|v| v.as_object()); + let spacing_unit = s_obj + .and_then(|s| s.get("unit")) + .and_then(|v| v.as_f64()) + .unwrap_or(default.spacing.unit); + let spacing_scale: Vec = s_obj + .and_then(|s| s.get("scale")) + .and_then(|v| v.as_array()) + .and_then(|arr| arr.iter().map(|v| v.as_f64()).collect::>>()) + .unwrap_or_else(|| default.spacing.scale.clone()); + + // Radius — optional array + let radius: Vec = obj + .get("radius") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.iter().map(|v| v.as_f64()).collect::>>()) + .unwrap_or_else(|| default.radius.clone()); + + // Aesthetic — optional string + let aesthetic = obj + .get("aesthetic") + .and_then(|v| v.as_str()) + .unwrap_or(&default.aesthetic) + .to_string(); + + Some(DesignSystem { + palette, + typography: Typography { + heading_font, + body_font, + scale: type_scale, + }, + spacing: Spacing { + unit: spacing_unit, + scale: spacing_scale, + }, + radius, + aesthetic, + }) +} + +/// Extract the content between code fences (` ```json ... ``` ` or ` ``` ... ``` `). +/// Returns `None` if no fence is found. +fn extract_code_fence(text: &str) -> Option<&str> { + // Find opening fence: ``` optionally followed by "json" + let fence_start = text.find("```")?; + let after_open = &text[fence_start + 3..]; + + // Skip optional "json" language tag and the following newline + let content_start = after_open.strip_prefix("json").unwrap_or(after_open); + + // Skip leading newline + let content_start = content_start.strip_prefix('\n').unwrap_or(content_start); + + // Find closing fence + let fence_end = content_start.find("```")?; + + // Strip trailing newline before closing fence + let inner = &content_start[..fence_end]; + let inner = inner.strip_suffix('\n').unwrap_or(inner); + + Some(inner) +} + +// ── B1: generate_design_system (port of TS L20-29) ─────────────────────────── + +/// Generate a `DesignSystem` from a user prompt via a single LLM call. +/// +/// Port of `generateDesignSystem` in `design-system-generator.ts:20-29`: +/// 1. Loads the `design-system` skill as system prompt via +/// `op_ai_skills::get_skill_by_name`. +/// 2. Makes a single (non-streaming) `LlmClient::call` with the user prompt. +/// 3. Collects all text chunks, then parses via `parse_design_system`. +/// 4. Falls back to `DEFAULT_DESIGN_SYSTEM` on any parse/LLM failure. +/// +/// The `model` / `provider` / `abort` params match the existing `CallRequest` +/// shape used by `subagent.rs` and `prompt.rs`. +pub async fn generate_design_system( + prompt: &str, + llm: &dyn LlmClient, + model: Option<&str>, + provider: Option<&str>, + abort: &AbortFlag, +) -> DesignSystem { + // Load the design-system skill content as system prompt. + let system_prompt = op_ai_skills::get_skill_by_name("design-system") + .map(|e| e.content.as_str()) + .unwrap_or("") + .to_string(); + + let req = CallRequest { + system_prompt, + user_prompt: prompt.to_string(), + model: model.map(|s| s.to_string()), + provider: provider.map(|s| s.to_string()), + timeout: std::time::Duration::from_secs(30), + abort: abort.clone(), + no_text_timeout: None, + first_text_timeout: None, + }; + + // Collect all text chunks. + let mut stream = llm.call(req); + let mut text = String::new(); + while let Some(item) = stream.next().await { + match item { + Ok(LlmChunk::Text(t)) => text.push_str(&t), + Ok(LlmChunk::Thinking(_)) => {} + Err(_) => break, + } + } + + // Parse and return; falls back to DEFAULT on failure. + parse_design_system(&text) +} + +// ── B1: design_system_to_seed_commands (port of TS L134-156) ───────────────── + +/// Convert a `DesignSystem` into `EditorCommand::SetVariable*` commands. +/// +/// Faithful port of `designSystemToVariables` in +/// `design-system-generator.ts:134-156`. Emits ONLY: +/// - Palette: `color-{kebab(key)}` → `SetVariableColor` (one per palette entry) +/// - Spacing scale: `spacing-{xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl}` → +/// `SetVariableScalar::Number` (capped at `spacingNames.length`) +/// - Radius: `radius-{sm|md|lg|xl}` → `SetVariableScalar::Number` (capped at +/// `radiusNames.length`) +/// +/// Typography is NOT seeded into document variables — the TS source feeds +/// heading/body fonts + type scale into the LLM via +/// `design_system_to_prompt_context` only. +/// +/// DEFAULT_DESIGN_SYSTEM emits exactly 17 commands: 8 palette + 6 spacing + 3 radius. +pub fn design_system_to_seed_commands(ds: &DesignSystem) -> Vec { + let mut cmds = Vec::new(); + + // Colors: palette → SetVariableColor with kebab-case name. + // TS: `for (const [key, value] of Object.entries(ds.palette))` + // Note: BTreeMap iterates in sorted key order, which is fine for determinism. + for (key, value) in &ds.palette { + let name = format!("color-{}", camel_to_kebab(key)); + cmds.push(EditorCommand::SetVariableColor { + name, + hex: value.clone(), + }); + } + + // Spacing scale → spacing-xs/sm/md/lg/xl/2xl/3xl/4xl/5xl/6xl + // TS: `const spacingNames = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl']` + const SPACING_NAMES: &[&str] = &[ + "xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", + ]; + for (i, &label) in SPACING_NAMES + .iter() + .enumerate() + .take(ds.spacing.scale.len()) + { + let name = format!("spacing-{label}"); + cmds.push(EditorCommand::SetVariableScalar { + name, + scalar: VariableScalarPayload::Number(ds.spacing.scale[i]), + }); + } + + // Radius → radius-sm/md/lg/xl + // TS: `const radiusNames = ['sm', 'md', 'lg', 'xl']` + const RADIUS_NAMES: &[&str] = &["sm", "md", "lg", "xl"]; + for (i, &label) in RADIUS_NAMES.iter().enumerate().take(ds.radius.len()) { + let name = format!("radius-{label}"); + cmds.push(EditorCommand::SetVariableScalar { + name, + scalar: VariableScalarPayload::Number(ds.radius[i]), + }); + } + + cmds +} + +/// Convert a camelCase string to kebab-case. +/// +/// Port of `kebab` in `design-system-generator.ts`: +/// `str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()` +fn camel_to_kebab(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + let chars: Vec = s.chars().collect(); + for i in 0..chars.len() { + let c = chars[i]; + if i > 0 && c.is_uppercase() && chars[i - 1].is_lowercase() { + out.push('-'); + } + out.push(c.to_ascii_lowercase()); + } + out +} + +// ── B1: design_system_to_prompt_context (port of TS L161-170) ──────────────── + +/// Build a fixed-form design-system context string for AI prompts. +/// +/// Port of `designSystemToPromptContext` in `design-system-generator.ts:161-170`: +/// ```text +/// DESIGN SYSTEM (use these values consistently): +/// Colors: bg {bg}, surface {surface}, text {text}, muted {muted}, ... +/// Fonts: heading "{headingFont}", body "{bodyFont}" +/// Type scale: {scale}px +/// Spacing: {scale}px ({unit}px grid) +/// Radius: {radius}px +/// Style: {aesthetic} +/// ``` +pub fn design_system_to_prompt_context(ds: &DesignSystem) -> String { + let p = &ds.palette; + let bg = p.get("background").map(|s| s.as_str()).unwrap_or(""); + let surface = p.get("surface").map(|s| s.as_str()).unwrap_or(""); + let text = p.get("text").map(|s| s.as_str()).unwrap_or(""); + let muted = p.get("textSecondary").map(|s| s.as_str()).unwrap_or(""); + let primary = p.get("primary").map(|s| s.as_str()).unwrap_or(""); + let primary_light = p.get("primaryLight").map(|s| s.as_str()).unwrap_or(""); + let accent = p.get("accent").map(|s| s.as_str()).unwrap_or(""); + let border = p.get("border").map(|s| s.as_str()).unwrap_or(""); + + // Type scale: "14, 16, 20, 28, 40, 56px" + let type_scale = ds + .typography + .scale + .iter() + .map(|v| format_num(*v)) + .collect::>() + .join(", "); + + // Spacing scale: "8, 16, 24, 32, 48, 64px" + let spacing_scale = ds + .spacing + .scale + .iter() + .map(|v| format_num(*v)) + .collect::>() + .join(", "); + let spacing_unit = format_num(ds.spacing.unit); + + // Radius: "8, 12, 16px" + let radius = ds + .radius + .iter() + .map(|v| format_num(*v)) + .collect::>() + .join(", "); + + format!( + "DESIGN SYSTEM (use these values consistently):\n\ +Colors: bg {bg}, surface {surface}, text {text}, muted {muted}, primary {primary}, primaryLight {primary_light}, accent {accent}, border {border}\n\ +Fonts: heading \"{heading}\", body \"{body}\"\n\ +Type scale: {type_scale}px\n\ +Spacing: {spacing_scale}px ({spacing_unit}px grid)\n\ +Radius: {radius}px\n\ +Style: {aesthetic}", + heading = ds.typography.heading_font, + body = ds.typography.body_font, + aesthetic = ds.aesthetic, + ) +} + +/// Format a float as an integer if it has no fractional part, or with 1 +/// decimal place otherwise. Matches the JS number-to-string behaviour for +/// the values in the design system (all are whole numbers in practice). +fn format_num(v: f64) -> String { + if v.fract() == 0.0 { + format!("{}", v as i64) + } else { + format!("{v:.1}") + } +} + +#[cfg(test)] +#[path = "design_system_tests.rs"] +mod tests; diff --git a/crates/op-orchestrator/src/design_system_tests.rs b/crates/op-orchestrator/src/design_system_tests.rs new file mode 100644 index 000000000..1273371a1 --- /dev/null +++ b/crates/op-orchestrator/src/design_system_tests.rs @@ -0,0 +1,560 @@ +//! Tests for `design_system.rs` — A1 step 1 (failing tests first, TDD). +//! B1 tests appended at the bottom. + +use crate::design_system::{default_design_system, parse_design_system, DesignSystem}; +use crate::types::{DesignRequest, Progress}; + +// ── parse_design_system: direct JSON round-trip ─────────────────────────── + +#[test] +fn parse_design_system_direct_json() { + let ds = default_design_system(); + let json = serde_json::to_string(ds).expect("serialize default"); + let parsed = parse_design_system(&json); + assert_eq!(parsed.palette, ds.palette); + assert_eq!(parsed.aesthetic, ds.aesthetic); +} + +// ── parse_design_system: code-fence stripping ───────────────────────────── + +#[test] +fn parse_design_system_strips_json_code_fence() { + let ds = default_design_system(); + let inner = serde_json::to_string(ds).expect("serialize"); + let fenced = format!("```json\n{inner}\n```"); + let parsed = parse_design_system(&fenced); + assert_eq!(parsed.palette, ds.palette); +} + +#[test] +fn parse_design_system_strips_bare_code_fence() { + let ds = default_design_system(); + let inner = serde_json::to_string(ds).expect("serialize"); + let fenced = format!("```\n{inner}\n```"); + let parsed = parse_design_system(&fenced); + assert_eq!(parsed.palette, ds.palette); +} + +// ── parse_design_system: brace extraction ──────────────────────────────── + +#[test] +fn parse_design_system_brace_extract() { + let ds = default_design_system(); + let inner = serde_json::to_string(ds).expect("serialize"); + let wrapped = format!("Some preamble text\n{inner}\nSome trailing text."); + let parsed = parse_design_system(&wrapped); + assert_eq!(parsed.palette, ds.palette); +} + +// ── parse_design_system: fallback to DEFAULT on garbage ────────────────── + +#[test] +fn parse_design_system_fallback_on_garbage() { + let ds = default_design_system(); + let parsed = parse_design_system("not json at all"); + assert_eq!(parsed.palette, ds.palette); + assert_eq!(parsed.aesthetic, ds.aesthetic); +} + +#[test] +fn parse_design_system_fallback_on_missing_palette() { + // Valid JSON but missing palette field → fallback + let ds = default_design_system(); + let parsed = parse_design_system(r#"{"aesthetic": "flat"}"#); + assert_eq!(parsed.palette, ds.palette); +} + +// ── DEFAULT_DESIGN_SYSTEM round-trips via serde ─────────────────────────── + +#[test] +fn default_design_system_serde_round_trip() { + let ds = default_design_system(); + let json = serde_json::to_string(ds).expect("serialize"); + let back: DesignSystem = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.palette, ds.palette); + assert_eq!(back.typography.heading_font, ds.typography.heading_font); + assert_eq!(back.typography.body_font, ds.typography.body_font); + assert_eq!(back.typography.scale, ds.typography.scale); + assert_eq!(back.spacing.unit, ds.spacing.unit); + assert_eq!(back.spacing.scale, ds.spacing.scale); + assert_eq!(back.radius, ds.radius); + assert_eq!(back.aesthetic, ds.aesthetic); +} + +// ── DEFAULT_DESIGN_SYSTEM exact values (port faithful to TS) ───────────── + +#[test] +fn default_design_system_palette_values() { + let p = &default_design_system().palette; + assert_eq!(p["background"], "#F8FAFC"); + assert_eq!(p["surface"], "#FFFFFF"); + assert_eq!(p["text"], "#0F172A"); + assert_eq!(p["textSecondary"], "#475569"); + assert_eq!(p["primary"], "#2563EB"); + assert_eq!(p["primaryLight"], "#DBEAFE"); + assert_eq!(p["accent"], "#0EA5E9"); + assert_eq!(p["border"], "#E2E8F0"); +} + +#[test] +fn default_design_system_typography_values() { + let t = &default_design_system().typography; + assert_eq!(t.heading_font, "Space Grotesk"); + assert_eq!(t.body_font, "Inter"); + assert_eq!(t.scale, vec![14.0, 16.0, 20.0, 28.0, 40.0, 56.0]); +} + +#[test] +fn default_design_system_spacing_values() { + let s = &default_design_system().spacing; + assert_eq!(s.unit, 8.0); + assert_eq!(s.scale, vec![8.0, 16.0, 24.0, 32.0, 48.0, 64.0]); +} + +#[test] +fn default_design_system_radius_values() { + assert_eq!(default_design_system().radius, vec![8.0, 12.0, 16.0]); +} + +#[test] +fn default_design_system_aesthetic_value() { + assert_eq!(default_design_system().aesthetic, "clean modern blue"); +} + +// ── DesignRequest.visual_ref_enabled defaults to false ──────────────────── + +#[test] +fn design_request_visual_ref_enabled_defaults_false() { + // JSON without `visualRefEnabled` field + let json = r#"{"prompt":"test","concurrency":1}"#; + let req: DesignRequest = serde_json::from_str(json).expect("deserialize"); + assert!( + !req.visual_ref_enabled, + "visual_ref_enabled should default to false" + ); +} + +#[test] +fn design_request_visual_ref_enabled_can_be_set_true() { + let json = r#"{"prompt":"test","concurrency":1,"visualRefEnabled":true}"#; + let req: DesignRequest = serde_json::from_str(json).expect("deserialize"); + assert!(req.visual_ref_enabled); +} + +#[test] +fn design_request_visual_ref_enabled_literal_compiles() { + // Verify that the field can be written in struct literal form + let req = DesignRequest { + prompt: "test".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: None, + validation_enabled: true, + visual_ref_enabled: false, + }; + assert!(!req.visual_ref_enabled); +} + +// ── Progress::VisualRef* variants compile and pattern-match ────────────── + +#[test] +fn progress_visual_ref_variants_compile() { + let variants = vec![ + Progress::VisualRefStarted, + Progress::VisualRefDesignSystem { var_count: 25 }, + Progress::VisualRefHtmlGenerated { byte_len: 4096 }, + Progress::VisualRefScreenshotReady { skipped: false }, + Progress::VisualRefFallback { + reason: "LLM returned empty HTML".into(), + }, + ]; + for v in variants { + match v { + Progress::VisualRefStarted => {} + Progress::VisualRefDesignSystem { var_count } => { + assert_eq!(var_count, 25); + } + Progress::VisualRefHtmlGenerated { byte_len } => { + assert_eq!(byte_len, 4096); + } + Progress::VisualRefScreenshotReady { skipped } => { + assert!(!skipped); + } + Progress::VisualRefFallback { reason } => { + assert!(!reason.is_empty()); + } + _ => {} + } + } +} + +// ── DesignSystem struct fields exist and are accessible ─────────────────── + +#[test] +fn design_system_struct_fields_accessible() { + let ds = DesignSystem { + palette: { + let mut m = std::collections::BTreeMap::new(); + m.insert("background".to_string(), "#F8FAFC".to_string()); + m + }, + typography: crate::design_system::Typography { + heading_font: "Space Grotesk".into(), + body_font: "Inter".into(), + scale: vec![14.0, 16.0], + }, + spacing: crate::design_system::Spacing { + unit: 8.0, + scale: vec![8.0, 16.0], + }, + radius: vec![8.0], + aesthetic: "clean".into(), + }; + assert_eq!(ds.palette["background"], "#F8FAFC"); + assert_eq!(ds.typography.heading_font, "Space Grotesk"); + assert_eq!(ds.spacing.unit, 8.0); +} + +// ── Task B1: generate_design_system ────────────────────────────────────── + +/// Scripted LLM returning valid JSON design-system → parsed DesignSystem +/// (not the default — the LLM-provided values win). +#[tokio::test] +async fn generate_design_system_happy_path() { + use crate::design_system::generate_design_system; + use crate::test_support::{ScriptResponse, ScriptedLlm}; + use crate::types::AbortFlag; + + // Craft a valid JSON that differs from DEFAULT_DESIGN_SYSTEM so we + // can confirm the LLM value was used. + // Build the JSON string using serde_json to avoid raw-string delimiter conflicts. + let custom_json = serde_json::json!({ + "palette": { + "background": "\u{23}111111", + "surface": "\u{23}222222", + "text": "\u{23}FFFFFF", + "textSecondary": "\u{23}AAAAAA", + "primary": "\u{23}FF0000", + "primaryLight": "\u{23}FF9999", + "accent": "\u{23}00FF00", + "border": "\u{23}333333" + }, + "typography": { + "headingFont": "Roboto", + "bodyFont": "Open Sans", + "scale": [12.0_f64, 14.0, 18.0, 24.0, 36.0, 48.0] + }, + "spacing": { "unit": 4.0_f64, "scale": [4.0_f64, 8.0, 12.0, 16.0, 24.0, 32.0] }, + "radius": [4.0_f64, 8.0, 12.0], + "aesthetic": "dark minimal" + }) + .to_string(); + + let llm = ScriptedLlm::new(vec![ScriptResponse::Text(custom_json.to_string())]); + let abort = AbortFlag::new(); + let ds = generate_design_system("a dark app", &llm, None, None, &abort).await; + + // LLM-provided values must win over default + assert_eq!(ds.palette["background"], "#111111"); + assert_eq!(ds.typography.heading_font, "Roboto"); + assert_eq!(ds.typography.body_font, "Open Sans"); + assert_eq!(ds.aesthetic, "dark minimal"); + assert_eq!(ds.radius, vec![4.0, 8.0, 12.0]); +} + +/// Scripted LLM returning garbage → fallback to DEFAULT_DESIGN_SYSTEM. +#[tokio::test] +async fn generate_design_system_garbage_falls_back_to_default() { + use crate::design_system::generate_design_system; + use crate::test_support::{ScriptResponse, ScriptedLlm}; + use crate::types::AbortFlag; + + let llm = ScriptedLlm::new(vec![ScriptResponse::Text( + "not valid json at all!".to_string(), + )]); + let abort = AbortFlag::new(); + let ds = generate_design_system("any prompt", &llm, None, None, &abort).await; + let default = default_design_system(); + + assert_eq!(ds.palette, default.palette); + assert_eq!(ds.aesthetic, default.aesthetic); +} + +/// LLM returning JSON wrapped in code fence → parsed correctly. +#[tokio::test] +async fn generate_design_system_code_fence_response() { + use crate::design_system::generate_design_system; + use crate::test_support::{ScriptResponse, ScriptedLlm}; + use crate::types::AbortFlag; + + let ds_default = default_design_system(); + let inner = serde_json::to_string(ds_default).unwrap(); + let fenced = format!("```json\n{inner}\n```"); + + let llm = ScriptedLlm::new(vec![ScriptResponse::Text(fenced)]); + let abort = AbortFlag::new(); + let ds = generate_design_system("prompt", &llm, None, None, &abort).await; + assert_eq!(ds.palette, ds_default.palette); +} + +// ── Task B1: design_system_to_seed_commands ─────────────────────────────── + +/// DEFAULT_DESIGN_SYSTEM → expected number of SetVariable* commands. +/// Faithful to TS `designSystemToVariables` (L134-156): typography is NOT +/// seeded into document variables, only colors + spacing + radius. +/// 8 palette (color) + 6 spacing scale + 3 radius = 17. +#[test] +fn seed_commands_default_count() { + use crate::design_system::design_system_to_seed_commands; + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + assert_eq!( + cmds.len(), + 17, + "expected 17 seed commands (8 palette + 6 spacing + 3 radius), got {}", + cmds.len() + ); +} + +/// DEFAULT_DESIGN_SYSTEM → no typography variables emitted. +/// Faithful to TS — typography reaches the LLM via prompt context, not vars. +#[test] +fn seed_commands_no_typography_variables() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::EditorCommand; + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + + let has_font_var = cmds.iter().any(|c| match c { + EditorCommand::SetVariableColor { name, .. } + | EditorCommand::SetVariableScalar { name, .. } => { + name.starts_with("font-") || name.starts_with("typography-") + } + _ => false, + }); + assert!( + !has_font_var, + "typography MUST NOT be seeded into document variables (faithful to TS)" + ); +} + +/// Palette colors → SetVariableColor with kebab-case names. +#[test] +fn seed_commands_palette_color_names() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::EditorCommand; + + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + + // Collect all SetVariableColor names + let color_names: Vec = cmds + .iter() + .filter_map(|c| { + if let EditorCommand::SetVariableColor { name, .. } = c { + Some(name.clone()) + } else { + None + } + }) + .collect(); + + // Verify all 8 palette keys are present in kebab-case + assert!( + color_names.contains(&"color-background".to_string()), + "missing color-background" + ); + assert!( + color_names.contains(&"color-text".to_string()), + "missing color-text" + ); + assert!( + color_names.contains(&"color-text-secondary".to_string()), + "missing color-text-secondary (textSecondary → text-secondary)" + ); + assert!( + color_names.contains(&"color-primary-light".to_string()), + "missing color-primary-light (primaryLight → primary-light)" + ); + assert_eq!(color_names.len(), 8, "expected 8 color variables"); +} + +/// Palette color value is correctly mapped. +#[test] +fn seed_commands_palette_color_value() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::EditorCommand; + + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + + let bg_cmd = cmds.iter().find( + |c| matches!(c, EditorCommand::SetVariableColor { name, .. } if name == "color-background"), + ); + assert!(bg_cmd.is_some(), "missing color-background command"); + if let Some(EditorCommand::SetVariableColor { hex, .. }) = bg_cmd { + assert_eq!(hex, "#F8FAFC", "wrong color-background value"); + } +} + +/// Spacing scale → SetVariableScalar::Number with spacing-xs/sm/... names. +#[test] +fn seed_commands_spacing_scale_names() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::{EditorCommand, VariableScalarPayload}; + + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + + let spacing_names: Vec = cmds + .iter() + .filter_map(|c| { + if let EditorCommand::SetVariableScalar { + name, + scalar: VariableScalarPayload::Number(_), + } = c + { + if name.starts_with("spacing-") { + return Some(name.clone()); + } + } + None + }) + .collect(); + + assert!( + spacing_names.contains(&"spacing-xs".to_string()), + "missing spacing-xs" + ); + assert!( + spacing_names.contains(&"spacing-sm".to_string()), + "missing spacing-sm" + ); + assert!( + spacing_names.contains(&"spacing-md".to_string()), + "missing spacing-md" + ); + assert!( + spacing_names.contains(&"spacing-lg".to_string()), + "missing spacing-lg" + ); + assert!( + spacing_names.contains(&"spacing-xl".to_string()), + "missing spacing-xl" + ); + assert!( + spacing_names.contains(&"spacing-2xl".to_string()), + "missing spacing-2xl" + ); + assert_eq!(spacing_names.len(), 6, "expected 6 spacing variables"); +} + +/// Radius steps → SetVariableScalar::Number with radius-sm/md/lg names. +#[test] +fn seed_commands_radius_names() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::{EditorCommand, VariableScalarPayload}; + + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + + let radius_names: Vec = cmds + .iter() + .filter_map(|c| { + if let EditorCommand::SetVariableScalar { + name, + scalar: VariableScalarPayload::Number(_), + } = c + { + if name.starts_with("radius-") { + return Some(name.clone()); + } + } + None + }) + .collect(); + + assert!( + radius_names.contains(&"radius-sm".to_string()), + "missing radius-sm" + ); + assert!( + radius_names.contains(&"radius-md".to_string()), + "missing radius-md" + ); + assert!( + radius_names.contains(&"radius-lg".to_string()), + "missing radius-lg" + ); + assert_eq!(radius_names.len(), 3, "expected 3 radius variables"); +} + +// ── Task B1: design_system_to_prompt_context ───────────────────────────── + +/// `design_system_to_prompt_context` produces the exact TS template. +#[test] +fn prompt_context_exact_template() { + use crate::design_system::design_system_to_prompt_context; + + let ds = default_design_system(); + let ctx = design_system_to_prompt_context(ds); + + // Verify structural lines (port of TS L161-170 format) + assert!( + ctx.starts_with("DESIGN SYSTEM (use these values consistently):"), + "wrong header: {ctx}" + ); + assert!(ctx.contains("Colors: bg #F8FAFC"), "missing Colors line"); + assert!(ctx.contains("surface #FFFFFF"), "missing surface"); + assert!(ctx.contains("text #0F172A"), "missing text"); + assert!(ctx.contains("muted #475569"), "missing muted"); + assert!(ctx.contains("primary #2563EB"), "missing primary"); + assert!(ctx.contains("primaryLight #DBEAFE"), "missing primaryLight"); + assert!(ctx.contains("accent #0EA5E9"), "missing accent"); + assert!(ctx.contains("border #E2E8F0"), "missing border"); + assert!( + ctx.contains(r#"Fonts: heading "Space Grotesk""#), + "missing heading font" + ); + assert!(ctx.contains(r#"body "Inter""#), "missing body font"); + assert!( + ctx.contains("Type scale: 14, 16, 20, 28, 40, 56px"), + "wrong type scale line: {ctx}" + ); + assert!( + ctx.contains("Spacing: 8, 16, 24, 32, 48, 64px (8px grid)"), + "wrong spacing line: {ctx}" + ); + assert!( + ctx.contains("Radius: 8, 12, 16px"), + "wrong radius line: {ctx}" + ); + assert!( + ctx.contains("Style: clean modern blue"), + "wrong style line: {ctx}" + ); +} + +/// Byte-exact match of the TS template for DEFAULT_DESIGN_SYSTEM. +#[test] +fn prompt_context_byte_exact() { + use crate::design_system::design_system_to_prompt_context; + + let ds = default_design_system(); + let ctx = design_system_to_prompt_context(ds); + + let expected = "DESIGN SYSTEM (use these values consistently):\n\ +Colors: bg #F8FAFC, surface #FFFFFF, text #0F172A, muted #475569, primary #2563EB, primaryLight #DBEAFE, accent #0EA5E9, border #E2E8F0\n\ +Fonts: heading \"Space Grotesk\", body \"Inter\"\n\ +Type scale: 14, 16, 20, 28, 40, 56px\n\ +Spacing: 8, 16, 24, 32, 48, 64px (8px grid)\n\ +Radius: 8, 12, 16px\n\ +Style: clean modern blue"; + + assert_eq!( + ctx, expected, + "prompt context does not match TS template byte-exactly" + ); +} diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index 506ba51ab..b28af765f 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -11,6 +11,7 @@ pub mod compact_prompt; pub mod compact_skills; pub mod dashboard_columns; pub mod design_md_policy; +pub mod design_system; pub mod design_type; pub mod intent; pub mod model_profile; @@ -39,6 +40,7 @@ pub mod run_dashboard; pub mod scaffold; pub mod scaffold_dashboard; pub mod subagent; +pub mod visual_ref; #[cfg(test)] mod test_support; @@ -47,10 +49,21 @@ pub use compact_prompt::{build_compact_planning_prompt, CompactPlanningPrompt}; pub use design_md_policy::{ build_design_md_style_policy, guess_neutral_background_from_theme, infer_design_md_background, }; +pub use design_system::{ + default_design_system, design_system_to_prompt_context, design_system_to_seed_commands, + generate_design_system, parse_design_system, DesignSystem, Spacing, Typography, +}; pub use design_type::{detect_design_type, DesignType, DesignTypePreset}; pub use intent::classify_intent; pub use model_profile::{resolve_model_profile, ModelProfile, ModelTier}; pub use prompt::build_orchestrator_prompt; pub use run::Orchestrator; -pub use stub_providers::{SkippedPreValidator, SkippedScreenshotProvider, SkippedVisionLlmClient}; +pub use stub_providers::{ + SkippedPreValidator, SkippedScreenshotProvider, SkippedVisionLlmClient, + SkippedVisualRefProvider, +}; pub use types::*; +pub use visual_ref::{ + build_enhanced_prompt, execute_visual_ref_orchestration, extract_structure_summary, + generate_design_code, +}; diff --git a/crates/op-orchestrator/src/plan.rs b/crates/op-orchestrator/src/plan.rs index c9a000ccd..d42dd2d67 100644 --- a/crates/op-orchestrator/src/plan.rs +++ b/crates/op-orchestrator/src/plan.rs @@ -219,6 +219,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/plan_normalize.rs b/crates/op-orchestrator/src/plan_normalize.rs index 96f817983..bcee9c910 100644 --- a/crates/op-orchestrator/src/plan_normalize.rs +++ b/crates/op-orchestrator/src/plan_normalize.rs @@ -84,6 +84,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } @@ -177,6 +179,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/plan_repair_tests.rs b/crates/op-orchestrator/src/plan_repair_tests.rs index ce6dd3aaf..56a3e135b 100644 --- a/crates/op-orchestrator/src/plan_repair_tests.rs +++ b/crates/op-orchestrator/src/plan_repair_tests.rs @@ -245,6 +245,8 @@ fn req(prompt: &str) -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } @@ -270,6 +272,8 @@ fn req_with_design_md(prompt: &str) -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/prompt.rs b/crates/op-orchestrator/src/prompt.rs index 66c7c4ea8..e844a0e5d 100644 --- a/crates/op-orchestrator/src/prompt.rs +++ b/crates/op-orchestrator/src/prompt.rs @@ -252,6 +252,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } @@ -398,6 +400,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }; let full_cr = build_subagent_prompt(&st, &plan(), &basic_req, AbortFlag::new(), false, false); @@ -488,6 +492,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }; let long_prompt = "x".repeat(5000); // >= 4200 chars let long_req = DesignRequest { @@ -498,6 +504,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }; let short_pp = build_orchestrator_prompt(&short_req, PlanningMode::Rich, AbortFlag::new()); let long_pp = build_orchestrator_prompt(&long_req, PlanningMode::Rich, AbortFlag::new()); @@ -519,6 +527,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }; let pp = build_orchestrator_prompt(&ds_req, PlanningMode::Rich, AbortFlag::new()); // Short bucket base: 300_000ms × 2.0 = 600_000ms @@ -577,6 +587,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }; let long_req = DesignRequest { prompt: "x".repeat(5000), @@ -586,6 +598,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }; let short_cr = build_subagent_prompt( &subtask(), @@ -620,6 +634,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }; let cr = build_subagent_prompt( &subtask(), diff --git a/crates/op-orchestrator/src/run_tests.rs b/crates/op-orchestrator/src/run_tests.rs index ef57218d1..e4339b6ae 100644 --- a/crates/op-orchestrator/src/run_tests.rs +++ b/crates/op-orchestrator/src/run_tests.rs @@ -27,6 +27,8 @@ fn req() -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } @@ -41,6 +43,8 @@ fn req_standard() -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } @@ -55,6 +59,8 @@ fn req_basic() -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/run_tests_b4.rs b/crates/op-orchestrator/src/run_tests_b4.rs index 2c4b60390..e4be4e7cb 100644 --- a/crates/op-orchestrator/src/run_tests_b4.rs +++ b/crates/op-orchestrator/src/run_tests_b4.rs @@ -89,6 +89,8 @@ fn req_append(live_target_id: &str) -> DesignRequest { is_mobile: false, }), validation_enabled: true, + + visual_ref_enabled: false, } } @@ -106,6 +108,8 @@ fn req_append_concurrent(live_target_id: &str) -> DesignRequest { is_mobile: false, }), validation_enabled: true, + + visual_ref_enabled: false, } } @@ -353,6 +357,8 @@ fn non_append_mode_takes_normal_sequential_path() { concurrency: 1, append_context: None, // no append context validation_enabled: true, + + visual_ref_enabled: false, }; let summary = futures::executor::block_on(Orchestrator::new().run( @@ -446,6 +452,8 @@ fn append_mode_wins_over_dashboard_branch() { is_mobile: false, }), validation_enabled: true, + + visual_ref_enabled: false, }; let llm = ScriptedLlm::new(vec![ diff --git a/crates/op-orchestrator/src/run_tests_c2.rs b/crates/op-orchestrator/src/run_tests_c2.rs index 3be0f3c35..1ceb03bc7 100644 --- a/crates/op-orchestrator/src/run_tests_c2.rs +++ b/crates/op-orchestrator/src/run_tests_c2.rs @@ -71,6 +71,8 @@ fn req_concurrent() -> DesignRequest { concurrency: 2, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } @@ -85,6 +87,8 @@ fn req_sequential() -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/run_tests_c3.rs b/crates/op-orchestrator/src/run_tests_c3.rs index a9b7c7ca7..f165535a1 100644 --- a/crates/op-orchestrator/src/run_tests_c3.rs +++ b/crates/op-orchestrator/src/run_tests_c3.rs @@ -70,6 +70,8 @@ fn req_dashboard() -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } @@ -82,6 +84,8 @@ fn req_non_dashboard() -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/run_tests_d1.rs b/crates/op-orchestrator/src/run_tests_d1.rs index 6ab52994b..60316b3e7 100644 --- a/crates/op-orchestrator/src/run_tests_d1.rs +++ b/crates/op-orchestrator/src/run_tests_d1.rs @@ -91,6 +91,8 @@ fn req_validation_enabled() -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } @@ -103,6 +105,8 @@ fn req_validation_disabled() -> DesignRequest { concurrency: 1, append_context: None, validation_enabled: false, + + visual_ref_enabled: false, } } @@ -321,6 +325,8 @@ fn dashboard_validation_enabled_emits_validation_done() { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }, &mut sink, &llm, @@ -380,6 +386,8 @@ fn dashboard_validation_disabled_no_validation_events() { concurrency: 1, append_context: None, validation_enabled: false, + + visual_ref_enabled: false, }, &mut sink, &llm, @@ -422,6 +430,8 @@ fn concurrent_validation_enabled_emits_validation_done() { concurrency: 2, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }, &mut sink, &llm, @@ -481,6 +491,8 @@ fn concurrent_validation_disabled_no_validation_events() { concurrency: 2, append_context: None, validation_enabled: false, + + visual_ref_enabled: false, }, &mut sink, &llm, diff --git a/crates/op-orchestrator/src/stub_providers.rs b/crates/op-orchestrator/src/stub_providers.rs index 1abbcc721..1c0aed3c3 100644 --- a/crates/op-orchestrator/src/stub_providers.rs +++ b/crates/op-orchestrator/src/stub_providers.rs @@ -1,5 +1,5 @@ //! Production-visible stub provider implementations for the vision -//! validation pipeline (S3c). +//! validation pipeline (S3c) and visual-ref pipeline (S4). //! //! Hosts use these when the real implementations are not yet wired — //! `op-design-lint` integration is pending host wiring, screenshot @@ -14,6 +14,8 @@ //! loop exits before any vision round. //! - `SkippedVisionLlmClient::validate` → `VisionResponse::Skipped` //! (defensive in case `capture_root_frame` ever returns `Some`). +//! - `SkippedVisualRefProvider::render_html_to_screenshot` → `None`, +//! so the visual-ref pipeline skips immediately to plain orchestration. //! //! Net result: the Progress stream emits `ValidationStarted` → //! `ValidationPreCheckDone { applied: 0, .. }` → @@ -23,7 +25,7 @@ use crate::types::{ DocSink, PreValidationResult, PreValidator, ScreenshotProvider, VisionCallRequest, - VisionLlmClient, VisionResponse, + VisionLlmClient, VisionResponse, VisualRefProvider, }; /// Stub `PreValidator` — always returns zero fixes; no side effects. @@ -53,3 +55,13 @@ impl VisionLlmClient for SkippedVisionLlmClient { VisionResponse::Skipped { reason: None } } } + +/// Stub `VisualRefProvider` — always returns `None`. Visual-ref pipeline +/// short-circuits to plain orchestration without rendering any HTML. +pub struct SkippedVisualRefProvider; + +impl VisualRefProvider for SkippedVisualRefProvider { + fn render_html_to_screenshot(&self, _html: &str, _width: f64, _height: f64) -> Option { + None + } +} diff --git a/crates/op-orchestrator/src/subagent.rs b/crates/op-orchestrator/src/subagent.rs index eaf631842..8842e9d6e 100644 --- a/crates/op-orchestrator/src/subagent.rs +++ b/crates/op-orchestrator/src/subagent.rs @@ -107,6 +107,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/types.rs b/crates/op-orchestrator/src/types.rs index 059ce9a17..cb22a8cab 100644 --- a/crates/op-orchestrator/src/types.rs +++ b/crates/op-orchestrator/src/types.rs @@ -160,6 +160,23 @@ pub struct ValidationProviders<'a> { pub system_prompt: String, } +// ── S4: VisualRefProvider ───────────────────────────────────────────────────── + +/// Visual-reference rendering outlet. +/// +/// host 实现把 HTML 字符串渲染成 base64 PNG 截图;stub 返回 `None` +/// 表示"跳过视觉参考阶段"。 +/// +/// Port of the `renderHtmlToScreenshot` call-site shape in +/// `visual-ref-orchestrator.ts:108-122` + spec §4.5. +pub trait VisualRefProvider: Send + Sync { + /// 将 HTML 字符串渲染为给定像素尺寸的截图,返回 base64 PNG; + /// `None` 表示不可用 / 跳过。 + fn render_html_to_screenshot(&self, html: &str, width: f64, height: f64) -> Option; +} + +// ── S4 end ──────────────────────────────────────────────────────────────────── + // ── S3c end ─────────────────────────────────────────────────────────────────── /// 廉价可克隆的中止句柄(`Arc` 语义)。 @@ -246,6 +263,40 @@ pub enum Progress { ValidationDone { total_applied: usize, }, + // ── S4: Visual-Ref pipeline progress variants ───────────────────────────── + /// Visual-ref pipeline started (before design-system generation). + /// + /// Port of the entry point in `visual-ref-orchestrator.ts:62`. + VisualRefStarted, + /// Design system generated and variables seeded. + /// + /// Port of stage 1 in `visual-ref-orchestrator.ts:74-90`. + VisualRefDesignSystem { + /// Number of `SetVariable*` commands emitted (one per palette/spacing/radius token). + var_count: usize, + }, + /// HTML code generated from the design system. + /// + /// Port of stage 2 in `visual-ref-orchestrator.ts:92-106`. + VisualRefHtmlGenerated { + /// Byte length of the generated HTML string. + byte_len: usize, + }, + /// Screenshot step complete (or skipped when `VisualRefProvider` returns `None`). + /// + /// Port of stage 3 in `visual-ref-orchestrator.ts:108-122`. + VisualRefScreenshotReady { + /// `true` when the screenshot was skipped (provider returned `None`). + skipped: bool, + }, + /// Visual-ref pipeline fell back to plain orchestration. + /// + /// Emitted when any stage fails or the provider skips. Port of the + /// fallback path in `visual-ref-orchestrator.ts:124-140`. + VisualRefFallback { + /// Human-readable reason for the fallback. + reason: String, + }, } /// 单个 subtask 的执行结果。`error` 带值但 `node_count > 0` 表示 @@ -333,12 +384,22 @@ pub struct DesignRequest { /// Port of `VALIDATION_ENABLED` in `ai-runtime-config.ts:109`. #[serde(default = "default_validation_enabled")] pub validation_enabled: bool, + /// 是否在编排器执行前运行视觉参考(visual-ref)流水线(S4)。 + /// 默认 `false`(host 明确选择才启用)。 + /// Port of the `executeVisualRefOrchestration` vs `executeOrchestration` + /// dispatch pattern in `visual-ref-orchestrator.ts`. + #[serde(default = "default_visual_ref_enabled")] + pub visual_ref_enabled: bool, } fn default_validation_enabled() -> bool { true } +fn default_visual_ref_enabled() -> bool { + false +} + #[cfg(test)] mod tests { use super::*; @@ -407,6 +468,7 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + visual_ref_enabled: false, }; assert!(req.append_context.is_none()); } @@ -428,6 +490,7 @@ mod tests { concurrency: 1, append_context: Some(ctx), validation_enabled: true, + visual_ref_enabled: false, }; assert!(req.append_context.is_some()); } @@ -443,6 +506,7 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + visual_ref_enabled: false, }; let json = serde_json::to_string(&req).expect("serialize"); assert!( @@ -517,6 +581,42 @@ mod tests { } } + // ── Task A2: VisualRefProvider trait + SkippedVisualRefProvider stub ───────── + + /// `SkippedVisualRefProvider` returns `None` for any input. + #[test] + fn skipped_visual_ref_provider_returns_none() { + use crate::stub_providers::SkippedVisualRefProvider; + let p = SkippedVisualRefProvider; + assert!(p + .render_html_to_screenshot("", 1280.0, 800.0) + .is_none()); + assert!(p.render_html_to_screenshot("", 0.0, 0.0).is_none()); + assert!(p + .render_html_to_screenshot("Hello", 390.0, 844.0) + .is_none()); + } + + /// `VisualRefProvider` trait is `Send + Sync`. + #[test] + fn visual_ref_provider_is_send_sync() { + use crate::types::VisualRefProvider; + fn assert_send_sync() {} + assert_send_sync::(); + } + + /// `op_orchestrator::SkippedVisualRefProvider` resolves from a host-style import. + #[test] + fn skipped_visual_ref_provider_resolves_from_crate_root() { + use crate::{SkippedVisualRefProvider, VisualRefProvider}; + let p: &dyn VisualRefProvider = &SkippedVisualRefProvider; + assert!(p + .render_html_to_screenshot("

test

", 800.0, 600.0) + .is_none()); + } + + // ── Task A2 end ─────────────────────────────────────────────────────────────── + /// `DesignRequest.validation_enabled` defaults to `true` when omitted from JSON. #[test] fn design_request_validation_enabled_defaults_true() { @@ -540,6 +640,7 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: false, + visual_ref_enabled: false, }; let json = serde_json::to_string(&req).expect("serialize"); let back: DesignRequest = serde_json::from_str(&json).expect("deserialize"); diff --git a/crates/op-orchestrator/src/validation_tests_c2.rs b/crates/op-orchestrator/src/validation_tests_c2.rs index 0d7ef087d..9b9947f92 100644 --- a/crates/op-orchestrator/src/validation_tests_c2.rs +++ b/crates/op-orchestrator/src/validation_tests_c2.rs @@ -29,6 +29,7 @@ fn make_request(validation_enabled: bool) -> DesignRequest { concurrency: 1, append_context: None, validation_enabled, + visual_ref_enabled: false, } } diff --git a/crates/op-orchestrator/src/variables.rs b/crates/op-orchestrator/src/variables.rs index 4386f6262..9c16ad3c6 100644 --- a/crates/op-orchestrator/src/variables.rs +++ b/crates/op-orchestrator/src/variables.rs @@ -49,6 +49,8 @@ mod tests { concurrency: 1, append_context: None, validation_enabled: true, + + visual_ref_enabled: false, }); assert!(seed_commands(&plan).is_empty()); let sink = VecDocSink::new(); diff --git a/crates/op-orchestrator/src/visual_ref.rs b/crates/op-orchestrator/src/visual_ref.rs new file mode 100644 index 000000000..929086fd0 --- /dev/null +++ b/crates/op-orchestrator/src/visual_ref.rs @@ -0,0 +1,758 @@ +//! `visual_ref.rs` — S4 B2+C1: HTML helpers + main orchestration for the +//! visual-reference pipeline. +//! +//! Functions ported from the deleted TS source (`commit 0f12b6e9^`): +//! +//! - [`generate_design_code`] — LLM call using `design-code` + +//! `design-principles` skills as the system prompt. Returns HTML verbatim. +//! Port of `design-code-generator.ts:26-49`. +//! +//! - [`extract_structure_summary`] — hand-rolled HTML scanner. Extracts +//! section containers (by class/id), headings (`

`-`

`), and CTA +//! elements (buttons/anchors whose class contains `btn`, `button`, or `cta`). +//! Returns an indented text summary. Port of `design-code-generator.ts:90-136`. +//! +//! - [`build_enhanced_prompt`] — string concatenation producing the exact +//! prompt template from `visual-ref-orchestrator.ts:172-184`. +//! +//! - [`execute_visual_ref_orchestration`] — 5-stage visual-ref pipeline. +//! Port of `executeVisualRefOrchestration` in +//! `visual-ref-orchestrator.ts:45-166`. +//! +//! No `regex` crate dependency (not in workspace Cargo.toml). The scanner uses +//! a hand-rolled byte-level approach consistent with the S3b-4 precedent. + +use futures::StreamExt; + +use crate::design_system::{ + design_system_to_prompt_context, design_system_to_seed_commands, generate_design_system, + DesignSystem, +}; +use crate::run::Orchestrator; +use crate::types::{ + AbortFlag, CallRequest, DesignRequest, DocSink, LlmChunk, LlmClient, OrchestratorError, + Progress, RunSummary, ValidationProviders, VisualRefProvider, +}; + +// ── generate_design_code ────────────────────────────────────────────────────── + +/// Generate self-contained HTML/CSS code for a design request. +/// +/// Port of `generateDesignCode` in `design-code-generator.ts:26-49`. +/// +/// System prompt = `design-code` skill content + `\n\n` + `design-principles` +/// skill content (when principles are non-empty). +/// User prompt = `buildCodeGenUserPrompt(prompt, dsContext, width, height)`. +/// Returns the raw LLM output (HTML verbatim — no post-processing here; +/// the TS `extractHtmlFromResponse` is intentionally omitted to keep this +/// function a minimal faithful port of the I/O shape only). +#[allow(clippy::too_many_arguments)] +pub async fn generate_design_code( + prompt: &str, + ds: &DesignSystem, + width: f64, + height: f64, + llm: &dyn LlmClient, + model: Option<&str>, + provider: Option<&str>, + abort: &AbortFlag, +) -> String { + let design_code = op_ai_skills::get_skill_by_name("design-code") + .map(|e| e.content.as_str()) + .unwrap_or("") + .to_string(); + let principles = op_ai_skills::get_skill_by_name("design-principles") + .map(|e| e.content.as_str()) + .unwrap_or("") + .to_string(); + + // TS: `const systemPrompt = principles ? \`${designCodeSkill}\n\n${principles}\` : designCodeSkill` + let system_prompt = if principles.is_empty() { + design_code + } else { + format!("{design_code}\n\n{principles}") + }; + + let ds_context = design_system_to_prompt_context(ds); + let user_prompt = build_code_gen_user_prompt(prompt, &ds_context, width, height); + + let req = CallRequest { + system_prompt, + user_prompt, + model: model.map(|s| s.to_string()), + provider: provider.map(|s| s.to_string()), + timeout: std::time::Duration::from_secs(60), + abort: abort.clone(), + no_text_timeout: None, + first_text_timeout: None, + }; + + let mut stream = llm.call(req); + let mut text = String::new(); + while let Some(item) = stream.next().await { + match item { + Ok(LlmChunk::Text(t)) => text.push_str(&t), + Ok(LlmChunk::Thinking(_)) => {} + Err(_) => break, + } + } + // Empty text = LLM error or truly-empty response. We can't distinguish + // here (no exceptions in Rust streams). Return empty so the caller's + // `html.is_empty()` fall-back path fires — TS catches the LLM error via + // try/catch and falls back; this is the equivalent shortcut. Non-empty + // text gets the full 4-stage normalization (TS extractHtmlFromResponse). + if text.is_empty() { + text + } else { + extract_html_from_response(&text) + } +} + +/// Normalize an LLM response into clean HTML. +/// +/// Port of `extractHtmlFromResponse` in `design-code-generator.ts:54-87`. +/// Faithful 4-stage chain — without this, code-fence-wrapped HTML +/// (the most common LLM output shape) leaks ``` markers into the +/// downstream `extract_structure_summary` scan. +/// +/// 1. ` ```(html)?\s*\n?…\n?``` ` fenced block — if the inner content +/// contains `` substring — return slice. +/// 4. Wrap bare content in a default HTML document scaffold. +pub(crate) fn extract_html_from_response(response: &str) -> String { + let trimmed = response.trim(); + + // Stage 1: code fence with HTML inside + if let Some(content) = extract_html_fenced_content(trimmed) { + let inner = content.trim(); + if inner.contains(" + if let Some(html) = extract_doctype_to_html_close(trimmed) { + return html; + } + + // Stage 4: wrap bare content in default scaffold + format!( + "\n\nDesign\n{trimmed}\n" + ) +} + +/// Find content inside ` ```(html)?…``` `. Returns raw inner (caller trims). +/// Matches TS regex `/```(?:html)?\s*\n?([\s\S]*?)\n?```/`. +fn extract_html_fenced_content(text: &str) -> Option<&str> { + let start = text.find("```")?; + let after = &text[start + 3..]; + + // Optional "html" language tag + let after = after.strip_prefix("html").unwrap_or(after); + // \s* — strip any leading whitespace (incl. \n) + let after = after.trim_start(); + + // Closing ``` + let end = after.find("```")?; + Some(&after[..end]) +} + +/// ASCII case-insensitive find of `` in `text`. Returns +/// the slice from `text` (preserving original case) between those markers. +/// +/// **UTF-8 safety**: we scan byte windows with `eq_ignore_ascii_case` +/// instead of `text.to_lowercase()`. The `to_lowercase()` approach can +/// change byte lengths for non-ASCII (e.g. Turkish `İ` 2 bytes → +/// `i\u{0307}` 3 bytes), making lower-string indices unsafe to use on +/// the original `text` — slicing on a non-char-boundary panics. ASCII +/// needles (``) always land on byte positions that +/// are valid UTF-8 char boundaries (any byte ≤ 0x7F is a char start in +/// UTF-8), so direct byte-index slicing on `text` is safe. +fn extract_doctype_to_html_close(text: &str) -> Option { + let bytes = text.as_bytes(); + let doc_needle: &[u8] = b""; + + let doc_start = bytes.windows(doc_needle.len()).position(|w| { + w.iter() + .zip(doc_needle) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) + })?; + + let close_rel = bytes[doc_start..] + .windows(close_needle.len()) + .position(|w| { + w.iter() + .zip(close_needle) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) + })?; + + let end = doc_start + close_rel + close_needle.len(); + Some(text[doc_start..end].to_string()) +} + +/// Build the user prompt for HTML/CSS code generation. +/// +/// Port of `buildCodeGenUserPrompt` in `design-code-generator.ts:168-185`. +fn build_code_gen_user_prompt( + user_prompt: &str, + design_system_context: &str, + width: f64, + height: f64, +) -> String { + let height_instruction = if height > 0.0 { + format!("Height: {}px (fixed viewport).", height as i64) + } else { + "Height: auto (content determines height, estimate based on sections).".to_string() + }; + + format!( + "Design request: {user_prompt}\n\nViewport: Width {width}px. {height_instruction}\n\n{design_system_context}\n\nGenerate the complete HTML file now.", + width = width as i64, + ) +} + +// ── extract_structure_summary ───────────────────────────────────────────────── + +/// Extract a structural summary from HTML for use as sub-agent reference. +/// +/// Port of `extractStructureSummary` in `design-code-generator.ts:90-136`. +/// +/// Scans the HTML string for: +/// 1. Section-level containers (``) with a +/// `class` or `id` attribute — emits `- Section: {classOrId}` (skips +/// values containing `__` to filter BEM modifiers). +/// 2. Headings `

`-`

` — emits `- H{n}: "{content}"` (inner text +/// stripped of tags, truncated to 60 chars). +/// 3. CTAs: `` whose `class` attribute contains `btn`, `button`, or +/// `cta` — emits `- CTA: "{text}"` (inner text stripped, truncated to 30 chars). +/// +/// Returns a single string with `DESIGN REFERENCE STRUCTURE:` as the first +/// line. If nothing was extracted, appends a generic fallback line. +pub fn extract_structure_summary(html: &str) -> String { + let mut lines: Vec = vec!["DESIGN REFERENCE STRUCTURE:".to_string()]; + + // ── 1. Section-level containers ────────────────────────────────────────── + // TS regex: /<(?:section|header|footer|nav|main|div)\s+[^>]*(?:class|id)="([^"]*)"[^>]*>/gi + extract_section_containers(html, &mut lines); + + // ── 2. Headings ────────────────────────────────────────────────────────── + // TS regex: /]*>([\s\S]*?)<\/h\1>/gi + extract_headings(html, &mut lines); + + // ── 3. CTA buttons / links ─────────────────────────────────────────────── + // TS regex: /<(?:button|a)\s+[^>]*class="[^"]*(?:btn|button|cta)[^"]*"[^>]*>([\s\S]*?)<\/(?:button|a)>/gi + extract_ctas(html, &mut lines); + + // Fallback when nothing was extracted + if lines.len() <= 1 { + lines.push("(HTML structure extracted — use as visual layout reference)".to_string()); + } + + lines.join("\n") +} + +/// Strip HTML tags from a string, returning plain text. +fn strip_tags(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut in_tag = false; + for c in s.chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(c), + _ => {} + } + } + out +} + +/// Extract the value of `class` or `id` attribute from an opening tag string. +/// +/// Returns the first match found (class takes precedence over id). +/// `tag_inner` is the content between `<` and `>` (exclusive). +fn extract_class_or_id(tag_inner: &str) -> Option<&str> { + for attr in ["class", "id"] { + // Search for `class="..."` or `id="..."` + if let Some(pos) = find_attr(tag_inner, attr) { + let rest = &tag_inner[pos..]; + // Skip `attr="` + let start = attr.len() + 2; // `attr="` length + if rest.len() > start { + let value_start = start; + if let Some(end) = rest[value_start..].find('"') { + let val = &rest[value_start..value_start + end]; + if !val.is_empty() { + return Some(val); + } + } + } + } + } + None +} + +/// Find a word-boundary attribute name within `tag_inner` followed by `="`. +/// +/// Returns the byte offset of the start of `attr="` in `tag_inner`. +fn find_attr(tag_inner: &str, attr: &str) -> Option { + let pattern = format!("{attr}=\""); + let bytes = tag_inner.as_bytes(); + let pat_bytes = pattern.as_bytes(); + let pat_len = pat_bytes.len(); + + let mut i = 0usize; + while i + pat_len <= bytes.len() { + if bytes[i..i + pat_len] == *pat_bytes { + // Check word boundary: previous char must be whitespace or start + if i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t' || bytes[i - 1] == b'\n' { + return Some(i); + } + } + i += 1; + } + None +} + +/// Scan for section-level container opening tags and extract class/id values. +fn extract_section_containers(html: &str, lines: &mut Vec) { + // Container tag names (lowercase) + const CONTAINERS: &[&str] = &["section", "header", "footer", "nav", "main", "div"]; + + let bytes = html.as_bytes(); + let len = bytes.len(); + let mut i = 0; + + while i < len { + // Look for '<' + if bytes[i] != b'<' { + i += 1; + continue; + } + // Skip '<' + let tag_start = i + 1; + if tag_start >= len { + break; + } + + // Try to match one of our container tag names (case-insensitive) + let mut matched_tag: Option<&str> = None; + for &tag in CONTAINERS { + let tag_len = tag.len(); + if tag_start + tag_len <= len { + let slice = &html[tag_start..tag_start + tag_len]; + if slice.eq_ignore_ascii_case(tag) { + // Must be followed by whitespace (has attributes) or '>' + let after = tag_start + tag_len; + if after < len + && (bytes[after] == b' ' + || bytes[after] == b'\t' + || bytes[after] == b'\n' + || bytes[after] == b'\r') + { + matched_tag = Some(tag); + break; + } + } + } + } + + if let Some(_tag) = matched_tag { + // Find the end of this opening tag + if let Some(end_offset) = html[i..].find('>') { + let tag_inner = &html[tag_start..i + end_offset]; + if let Some(class_or_id) = extract_class_or_id(tag_inner) { + // TS: `if (classOrId && !classOrId.includes('__'))` + if !class_or_id.contains("__") { + lines.push(format!("- Section: {class_or_id}")); + } + } + i = i + end_offset + 1; + continue; + } + } + + i += 1; + } +} + +/// Scan for `

`-`

` elements and extract their text content. +fn extract_headings(html: &str, lines: &mut Vec) { + let bytes = html.as_bytes(); + let len = bytes.len(); + let mut i = 0; + + while i < len { + if bytes[i] != b'<' { + i += 1; + continue; + } + let tag_start = i + 1; + if tag_start >= len { + break; + } + + // Match `h` followed by digit 1-6 + if bytes[tag_start] != b'h' && bytes[tag_start] != b'H' { + i += 1; + continue; + } + if tag_start + 1 >= len { + i += 1; + continue; + } + let digit = bytes[tag_start + 1]; + if !(b'1'..=b'6').contains(&digit) { + i += 1; + continue; + } + let level = digit - b'0'; + // Must be followed by '>' or whitespace (no other chars) + let after = tag_start + 2; + if after < len + && bytes[after] != b'>' + && bytes[after] != b' ' + && bytes[after] != b'\t' + && bytes[after] != b'\n' + && bytes[after] != b'\r' + { + i += 1; + continue; + } + + // Find end of opening tag + let Some(end_offset) = html[i..].find('>') else { + i += 1; + continue; + }; + let after_open_tag = i + end_offset + 1; + + // Build closing tag `` + let closing = format!(""); + let closing_lower = format!(""); + + // Find closing tag (case-insensitive: just check both cases since h is ASCII) + let rest = &html[after_open_tag..]; + let inner_end = rest + .find(&closing) + .or_else(|| rest.find(&closing_lower)) + .or_else(|| { + // Try uppercase H + let closing_upper = format!(""); + rest.find(&closing_upper) + }); + + if let Some(inner_len) = inner_end { + let inner_html = &rest[..inner_len]; + let content = strip_tags(inner_html).trim().to_string(); + // Truncate to 60 chars (TS: `.slice(0, 60)`) + let content: String = content.chars().take(60).collect(); + if !content.is_empty() { + lines.push(format!("- H{level}: \"{content}\"")); + } + i = after_open_tag + inner_len + closing.len(); + continue; + } + + i += 1; + } +} + +/// Scan for `")) + } else if !html[tag_start..].is_empty() + && html[tag_start..tag_start + 1].eq_ignore_ascii_case("a") + && tag_start + 1 < len + && (bytes[tag_start + 1] == b' ' + || bytes[tag_start + 1] == b'\t' + || bytes[tag_start + 1] == b'\n' + || bytes[tag_start + 1] == b'\r') + { + Some(("a", "")) + } else { + None + }; + + if let Some((_tag_name, closing)) = matched_tag { + // Find end of opening tag + let Some(end_offset) = html[i..].find('>') else { + i += 1; + continue; + }; + let tag_inner = &html[tag_start..i + end_offset]; + + // Check class contains btn/button/cta + // TS regex: class="[^"]*(?:btn|button|cta)[^"]*" + let has_cta_class = if let Some(class_pos) = find_attr(tag_inner, "class") { + let rest = &tag_inner[class_pos + 7..]; // skip `class="` + if let Some(end) = rest.find('"') { + let class_val = &rest[..end]; + let cv_lower = class_val.to_ascii_lowercase(); + cv_lower.contains("btn") + || cv_lower.contains("button") + || cv_lower.contains("cta") + } else { + false + } + } else { + false + }; + + if has_cta_class { + let after_open_tag = i + end_offset + 1; + let rest = &html[after_open_tag..]; + + // Find closing tag (case-insensitive check: try lowercase and uppercase A) + let inner_end = rest.find(closing).or_else(|| { + let closing_upper = closing.to_ascii_uppercase(); + rest.find(&closing_upper) + }); + + if let Some(inner_len) = inner_end { + let inner_html = &rest[..inner_len]; + let text = strip_tags(inner_html).trim().to_string(); + // Truncate to 30 chars (TS: `.slice(0, 30)`) + let text: String = text.chars().take(30).collect(); + if !text.is_empty() { + lines.push(format!("- CTA: \"{text}\"")); + } + i = after_open_tag + inner_len + closing.len(); + continue; + } + } + + i = i + end_offset + 1; + continue; + } + + i += 1; + } +} + +// ── build_enhanced_prompt ───────────────────────────────────────────────────── + +/// Build the enhanced orchestration prompt that incorporates the visual reference. +/// +/// Port of `buildEnhancedPrompt` in `visual-ref-orchestrator.ts:172-184`. +/// +/// Template (byte-exact from TS): +/// ```text +/// {originalPrompt} +/// +/// {structureSummary} +/// +/// {designSystemContext} +/// +/// IMPORTANT: Follow the design reference structure closely. The design system +/// colors, fonts, and spacing have already been determined — use them +/// consistently. The reference structure shows the intended layout — match its +/// section order and composition. +/// ``` +pub fn build_enhanced_prompt(original: &str, structure: &str, ds_context: &str) -> String { + format!( + "{original}\n\n{structure}\n\n{ds_context}\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition." + ) +} + +// ── execute_visual_ref_orchestration ───────────────────────────────────────── + +/// 5-stage visual-reference pipeline. +/// +/// Port of `executeVisualRefOrchestration` in +/// `visual-ref-orchestrator.ts:45-166`. +/// +/// ## Stage flow +/// +/// 1. Emit `Progress::VisualRefStarted`. Check abort. Call +/// `generate_design_system`, apply `design_system_to_seed_commands` to +/// `sink`, emit `Progress::VisualRefDesignSystem { var_count }`. +/// +/// 2. Check abort. Call `generate_design_code`. If the result is empty, +/// emit `Progress::VisualRefFallback` and fall back to `Orchestrator::run` +/// with the original (un-enhanced) request. +/// Otherwise emit `Progress::VisualRefHtmlGenerated { byte_len }`. +/// +/// 3. Check abort. Call `visual_ref.render_html_to_screenshot`. Emit +/// `Progress::VisualRefScreenshotReady { skipped: screenshot.is_none() }`. +/// Stage 3 **does not fall back** — the pipeline continues to stages 4+5 +/// regardless of whether a screenshot was returned. The screenshot is an +/// optional reference for the downstream vision-validation loop +/// (currently always `None` in Rust S3c; future plumb-through to +/// `validation.rs::reference_screenshot` is out of S4 scope). +/// +/// 4. Build `enhanced_request` with prompt replaced by the output of +/// `build_enhanced_prompt(original_prompt, extract_structure_summary(&html), +/// design_system_to_prompt_context(&ds))`. +/// +/// 5. Call `Orchestrator::new().run(enhanced_request, ...)` and return its +/// `Result`. +/// +/// ## Abort handling +/// +/// `abort.is_set()` is checked before each stage. If set, returns +/// `Err(OrchestratorError::Aborted)` immediately. +/// +/// ## Fallback semantics +/// +/// Fallback to plain `Orchestrator::run(original_request, ...)` happens +/// ONLY on: +/// - Stage 2 failure (`generate_design_code` returns empty string). +/// +/// Stage 1 (`generate_design_system`) already has internal fallback to +/// `DEFAULT_DESIGN_SYSTEM` on any LLM/parse failure, so it never +/// short-circuits. Stage 3 (`render_html_to_screenshot` returning `None`) +/// is informational only — the pipeline continues to stages 4+5 without +/// a screenshot. +/// +/// ## Canvas size +/// +/// The Rust `DesignRequest` does not carry a `canvas_size` field. The TS +/// source used `request.context?.canvasSize?.width ?? 1200` and +/// `?? 0` for height, so this function uses the same defaults: +/// `width = 1200.0, height = 0.0`. +#[allow(clippy::too_many_arguments)] +pub async fn execute_visual_ref_orchestration( + sink: &mut dyn DocSink, + llm: &dyn LlmClient, + providers: &ValidationProviders<'_>, + visual_ref: &dyn VisualRefProvider, + request: DesignRequest, + on_progress: &mut dyn FnMut(Progress), + abort: &AbortFlag, +) -> Result { + // Default canvas dimensions: matches TS `request.context?.canvasSize?.{width,height} ?? default` + const PLAN_WIDTH: f64 = 1200.0; + const PLAN_HEIGHT: f64 = 0.0; + + // Keep a clone of the original request for fallback paths. + let original_request = request.clone(); + + // ── Stage 0: emit started, check abort ──────────────────────────────────── + on_progress(Progress::VisualRefStarted); + + if abort.is_set() { + return Err(OrchestratorError::Aborted); + } + + // ── Stage 1: design system ──────────────────────────────────────────────── + + // generate_design_system falls back to DEFAULT on any LLM/parse failure. + let ds = generate_design_system( + &request.prompt, + llm, + request.model.as_deref(), + request.provider.as_deref(), + abort, + ) + .await; + + // Seed design-system variables into the document. + let seed_cmds = design_system_to_seed_commands(&ds); + let var_count = seed_cmds.len(); + for cmd in seed_cmds { + sink.apply(cmd); + } + + on_progress(Progress::VisualRefDesignSystem { var_count }); + + if abort.is_set() { + return Err(OrchestratorError::Aborted); + } + + // ── Stage 2: generate HTML/CSS ──────────────────────────────────────────── + + let html = generate_design_code( + &request.prompt, + &ds, + PLAN_WIDTH, + PLAN_HEIGHT, + llm, + request.model.as_deref(), + request.provider.as_deref(), + abort, + ) + .await; + + if html.is_empty() { + on_progress(Progress::VisualRefFallback { + reason: "design-code failed".into(), + }); + return Orchestrator::new() + .run(original_request, sink, llm, on_progress, abort, providers) + .await; + } + + on_progress(Progress::VisualRefHtmlGenerated { + byte_len: html.len(), + }); + + if abort.is_set() { + return Err(OrchestratorError::Aborted); + } + + // ── Stage 3: render screenshot ───────────────────────────────────────────── + // + // Informational stage: a `None` screenshot is OK — we still proceed to + // stages 4+5 with the HTML structure summary + design system context. + // The screenshot is reserved for a future plumb-through to S3c's + // `validation.rs::reference_screenshot` (currently always `None`). + let screenshot = visual_ref.render_html_to_screenshot(&html, PLAN_WIDTH, PLAN_HEIGHT); + on_progress(Progress::VisualRefScreenshotReady { + skipped: screenshot.is_none(), + }); + let _ = screenshot; // currently unused downstream; see doc-comment + + if abort.is_set() { + return Err(OrchestratorError::Aborted); + } + + // ── Stage 4 + 5: build enhanced request → run Orchestrator ─────────────── + + let structure_summary = extract_structure_summary(&html); + let ds_context = design_system_to_prompt_context(&ds); + let enhanced_prompt = build_enhanced_prompt(&request.prompt, &structure_summary, &ds_context); + + let enhanced_request = DesignRequest { + prompt: enhanced_prompt, + ..request + }; + + Orchestrator::new() + .run(enhanced_request, sink, llm, on_progress, abort, providers) + .await +} + +#[cfg(test)] +#[path = "visual_ref_tests.rs"] +mod tests; diff --git a/crates/op-orchestrator/src/visual_ref_tests.rs b/crates/op-orchestrator/src/visual_ref_tests.rs new file mode 100644 index 000000000..bce4efa5b --- /dev/null +++ b/crates/op-orchestrator/src/visual_ref_tests.rs @@ -0,0 +1,794 @@ +//! Tests for `visual_ref.rs` — S4 B2 + C1. + +use crate::design_system::default_design_system; +use crate::test_support::{ + ScriptResponse, ScriptedLlm, SkippedPreValidator, SkippedScreenshotProvider, + SkippedVisionLlmClient, VecDocSink, +}; +use crate::types::{ + AbortFlag, CallRequest, DesignRequest, LlmChunk, LlmClient, LlmError, OrchestratorError, + Progress, ValidationProviders, VisualRefProvider, +}; +use crate::visual_ref::{ + build_enhanced_prompt, execute_visual_ref_orchestration, extract_html_from_response, + extract_structure_summary, generate_design_code, +}; +use futures::stream::BoxStream; +use std::sync::Mutex; + +// ── generate_design_code ────────────────────────────────────────────────── + +#[tokio::test] +async fn generate_design_code_returns_llm_output_verbatim() { + let expected = "Hello"; + let llm = ScriptedLlm::new(vec![ScriptResponse::Text(expected.to_string())]); + let abort = AbortFlag::new(); + let ds = default_design_system(); + + let result = + generate_design_code("a login page", ds, 1440.0, 900.0, &llm, None, None, &abort).await; + + assert_eq!(result, expected); +} + +#[tokio::test] +async fn generate_design_code_returns_empty_on_llm_error() { + use crate::types::LlmError; + let llm = ScriptedLlm::new(vec![ScriptResponse::Fail(LlmError { + message: "timeout".into(), + aborted: false, + })]); + let abort = AbortFlag::new(); + let ds = default_design_system(); + + let result = + generate_design_code("a dashboard", ds, 1440.0, 900.0, &llm, None, None, &abort).await; + + assert_eq!(result, ""); +} + +#[tokio::test] +async fn generate_design_code_respects_model_and_provider() { + // This test just checks the call doesn't panic with model/provider set. + let llm = ScriptedLlm::new(vec![ScriptResponse::Text("".to_string())]); + let abort = AbortFlag::new(); + let ds = default_design_system(); + + let result = generate_design_code( + "a settings page", + ds, + 375.0, + 812.0, + &llm, + Some("claude-3-5-sonnet"), + Some("anthropic"), + &abort, + ) + .await; + + assert!(!result.is_empty()); +} + +// ── extract_structure_summary ───────────────────────────────────────────── + +#[test] +fn extract_structure_summary_header_line() { + let result = extract_structure_summary("
"); + assert_eq!( + result.lines().next().unwrap(), + "DESIGN REFERENCE STRUCTURE:" + ); +} + +#[test] +fn extract_structure_summary_section_with_class() { + let html = r#"

Welcome

"#; + let result = extract_structure_summary(html); + assert!( + result.contains("- Section: hero"), + "expected Section: hero in:\n{result}" + ); + assert!( + result.contains("- H1: \"Welcome\""), + "expected H1: Welcome in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_section_h1_cta() { + // This is the byte-exact test from the plan: + // `extract_structure_summary("

Hero

CTA
")` → exact format + // Note:
has no class/id → no Section line. + //

→ H1 line. + // has no class → no CTA line. + let html = "

Hero

CTA
"; + let result = extract_structure_summary(html); + let lines: Vec<&str> = result.lines().collect(); + assert_eq!(lines[0], "DESIGN REFERENCE STRUCTURE:"); + assert_eq!(lines[1], "- H1: \"Hero\""); + // Only 2 lines (header + H1) — no section/CTA since no class attrs + assert_eq!(lines.len(), 2); +} + +#[test] +fn extract_structure_summary_skips_bem_modifier_classes() { + // classOrId containing `__` must be skipped + let html = r#"
content
"#; + let result = extract_structure_summary(html); + assert!( + !result.contains("hero__inner"), + "BEM modifier should be skipped" + ); +} + +#[test] +fn extract_structure_summary_cta_with_btn_class() { + let html = r#""#; + let result = extract_structure_summary(html); + assert!( + result.contains("- CTA: \"Get Started\""), + "expected CTA in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_cta_with_cta_class() { + let html = "Learn More"; + let result = extract_structure_summary(html); + assert!( + result.contains("- CTA: \"Learn More\""), + "expected CTA in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_cta_with_button_class() { + let html = "Sign Up"; + let result = extract_structure_summary(html); + assert!( + result.contains("- CTA: \"Sign Up\""), + "expected CTA in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_headings_truncated_to_60_chars() { + let long_title = "A".repeat(80); + let html = format!("

{long_title}

"); + let result = extract_structure_summary(&html); + // Should be truncated to 60 chars + let expected_content: String = "A".repeat(60); + assert!( + result.contains(&format!("- H2: \"{expected_content}\"")), + "should truncate to 60 chars:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_cta_text_truncated_to_30_chars() { + let long_text = "B".repeat(50); + let html = format!(r#""#); + let result = extract_structure_summary(&html); + let expected_text: String = "B".repeat(30); + assert!( + result.contains(&format!("- CTA: \"{expected_text}\"")), + "should truncate to 30 chars:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_fallback_when_no_structure() { + let html = "
plain
"; + let result = extract_structure_summary(html); + assert!( + result.contains("(HTML structure extracted"), + "expected fallback line:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_section_with_id_attribute() { + let html = r#"
content
"#; + let result = extract_structure_summary(html); + assert!( + result.contains("- Section: about"), + "expected Section: about in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_all_heading_levels() { + let html = "

One

Two

Three

Four

Five
Six
"; + let result = extract_structure_summary(html); + for (level, text) in [ + (1, "One"), + (2, "Two"), + (3, "Three"), + (4, "Four"), + (5, "Five"), + (6, "Six"), + ] { + assert!( + result.contains(&format!("- H{level}: \"{text}\"")), + "missing H{level} in:\n{result}" + ); + } +} + +#[test] +fn extract_structure_summary_strips_tags_from_headings() { + let html = "

Hello World

"; + let result = extract_structure_summary(html); + assert!( + result.contains("- H1: \"Hello World\""), + "should strip tags:\n{result}" + ); +} + +// ── build_enhanced_prompt ───────────────────────────────────────────────── + +#[test] +fn build_enhanced_prompt_exact_template() { + let original = "Design a login screen"; + let structure = "DESIGN REFERENCE STRUCTURE:\n- H1: \"Login\""; + let ds_context = "DESIGN SYSTEM (use these values consistently):\nColors: bg #F8FAFC"; + + let result = build_enhanced_prompt(original, structure, ds_context); + + let expected = format!( + "{original}\n\n{structure}\n\n{ds_context}\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition." + ); + assert_eq!(result, expected); +} + +#[test] +fn build_enhanced_prompt_contains_important_instruction() { + let result = build_enhanced_prompt("p", "s", "d"); + assert!(result.contains("IMPORTANT: Follow the design reference structure closely.")); + assert!(result.contains("use them consistently")); + assert!(result.contains("match its section order and composition.")); +} + +#[test] +fn build_enhanced_prompt_double_newline_separators() { + let result = build_enhanced_prompt("p", "s", "d"); + // Check the exact separators: p\n\ns\n\nd\n\nIMPORTANT... + assert!(result.starts_with("p\n\ns\n\nd\n\nIMPORTANT:")); +} + +#[test] +fn build_enhanced_prompt_empty_inputs() { + let result = build_enhanced_prompt("", "", ""); + // Should still produce the IMPORTANT line + assert!(result.contains("IMPORTANT:")); + assert_eq!(result, "\n\n\n\n\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition."); +} + +// ── execute_visual_ref_orchestration — C1 tests ─────────────────────────── + +// Shared test fixtures --------------------------------------------------- + +fn make_request() -> DesignRequest { + DesignRequest { + prompt: "a landing page".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: None, + validation_enabled: false, + visual_ref_enabled: true, + } +} + +fn stub_providers() -> ValidationProviders<'static> { + ValidationProviders { + pre_validator: &SkippedPreValidator, + screenshot: &SkippedScreenshotProvider, + vision: &SkippedVisionLlmClient, + system_prompt: String::new(), + } +} + +/// Minimal valid plan JSON for the Orchestrator. +const PLAN_JSON: &str = r##"{ + "rootFrame": { "id": "root", "name": "Page", "width": 1200, "height": 800, + "layout": "vertical", "gap": 0, + "fill": [{ "type": "solid", "color": "#FFFFFF" }] }, + "subtasks": [ +{ "id": "hero", "label": "Hero", "region": { "width": 1200, "height": 400 } } + ] +}"##; + +fn node_json(prefix: &str) -> String { + format!( + r#"[{{"type":"frame","id":"{prefix}-1","name":"Sec","x":0,"y":0,"width":1200,"height":300,"children":[]}}]"# + ) +} + +fn default_ds_json() -> String { + let ds = default_design_system(); + serde_json::to_string(ds).expect("serialize default DS") +} + +/// An `LlmClient` that records every `CallRequest` it sees while still +/// returning scripted responses in order. Used to verify the enhanced +/// prompt reaches the underlying orchestrator. +struct RecordingLlm { + responses: Mutex>, + recorded: Mutex>, +} + +impl RecordingLlm { + fn new(responses: Vec) -> Self { + Self { + responses: Mutex::new(responses.into()), + recorded: Mutex::new(Vec::new()), + } + } + + fn calls(&self) -> Vec { + self.recorded.lock().unwrap().clone() + } +} + +impl LlmClient for RecordingLlm { + fn call(&self, req: CallRequest) -> BoxStream<'static, Result> { + self.recorded.lock().unwrap().push(req); + let next = self.responses.lock().unwrap().pop_front(); + let items: Vec> = match next { + Some(ScriptResponse::Text(t)) => vec![Ok(LlmChunk::Text(t))], + Some(ScriptResponse::Fail(e)) => vec![Err(e)], + None => vec![Err(LlmError { + message: "RecordingLlm exhausted".into(), + aborted: false, + })], + }; + Box::pin(futures::stream::iter(items)) + } +} + +// A `VisualRefProvider` that returns Some(base64) for any call. +struct MockVisualRefProvider; +impl VisualRefProvider for MockVisualRefProvider { + fn render_html_to_screenshot(&self, _html: &str, _w: f64, _h: f64) -> Option { + Some("base64screenshot==".to_string()) + } +} + +// A `VisualRefProvider` that always returns None. +struct NoneVisualRefProvider; +impl VisualRefProvider for NoneVisualRefProvider { + fn render_html_to_screenshot(&self, _html: &str, _w: f64, _h: f64) -> Option { + None + } +} + +// ── Test 1: None screenshot → still runs enhanced orchestration ────────── + +/// When `VisualRefProvider` returns `None`, the pipeline does NOT fall back +/// to plain orchestration — it emits +/// `Progress::VisualRefScreenshotReady { skipped: true }` and continues +/// to the enhanced `Orchestrator::run`. No `VisualRefFallback` event is +/// emitted. Matches TS `visual-ref-orchestrator.ts:109-122` semantics. +#[tokio::test] +async fn execute_visual_ref_none_screenshot_still_runs_enhanced_orchestration() { + // LLM call order: + // [0] generate_design_system → DS JSON + // [1] generate_design_code → HTML (contains

Hero

) + // [2] Orchestrator planning → PLAN_JSON + // [3] Orchestrator subtask → node_json + let llm = RecordingLlm::new(vec![ + ScriptResponse::Text(default_ds_json()), + ScriptResponse::Text("

Hero

".into()), + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("hero")), + ]); + let mut sink = VecDocSink::new(); + let mut events: Vec = Vec::new(); + let providers = stub_providers(); + + let result = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &NoneVisualRefProvider, + make_request(), + &mut |p| events.push(p), + &AbortFlag::new(), + ) + .await; + + assert!(result.is_ok(), "expected Ok, got {:?}", result.err()); + + // All four pre-orchestrator events + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefStarted)), + "missing VisualRefStarted in {:?}", + events + ); + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefDesignSystem { .. })), + "missing VisualRefDesignSystem in {:?}", + events + ); + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefHtmlGenerated { .. })), + "missing VisualRefHtmlGenerated in {:?}", + events + ); + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefScreenshotReady { skipped: true })), + "expected VisualRefScreenshotReady{{skipped:true}} in {:?}", + events + ); + + // CRITICAL: no VisualRefFallback should be emitted when screenshot is None. + let no_fallback = events + .iter() + .all(|e| !matches!(e, Progress::VisualRefFallback { .. })); + assert!( + no_fallback, + "stage 3 None → must NOT emit VisualRefFallback; got {:?}", + events + ); + + // Orchestrator emits Planning when it runs. + assert!( + events.iter().any(|e| matches!(e, Progress::Planning)), + "expected Planning event from enhanced Orchestrator::run, got {:?}", + events + ); + + // CRITICAL: the planning call (call index 2, after DS + codegen) must + // have received the ENHANCED prompt (not the original "a landing page"). + // The enhanced prompt contains the IMPORTANT-instruction tail. + let calls = llm.calls(); + assert!( + calls.len() >= 3, + "expected ≥3 LLM calls (DS + codegen + planning), got {}", + calls.len() + ); + let planning_user_prompt = &calls[2].user_prompt; + assert!( + planning_user_prompt.contains("IMPORTANT: Follow the design reference structure closely."), + "planning user prompt should carry the enhanced-prompt instruction tail; got:\n{}", + planning_user_prompt + ); + // It should also carry the structure summary marker (since HTML had

Hero

). + assert!( + planning_user_prompt.contains("DESIGN REFERENCE STRUCTURE:"), + "planning user prompt should carry the structure summary header; got:\n{}", + planning_user_prompt + ); +} + +// ── Test 2: MockVisualRefProvider → all 5 stages + Orchestrator::run ────── + +/// When `VisualRefProvider` returns `Some(base64)`, all 5 stages run and +/// `Orchestrator::run` is called with the enhanced prompt. +#[tokio::test] +async fn execute_visual_ref_with_screenshot_runs_all_stages() { + // LLM call order: + // [0] generate_design_system → DS JSON + // [1] generate_design_code → HTML + // [2] Orchestrator planning → PLAN_JSON + // [3] Orchestrator subtask → node_json + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(default_ds_json()), + ScriptResponse::Text("

Hero

".into()), + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("hero")), + ]); + let mut sink = VecDocSink::new(); + let mut events: Vec = Vec::new(); + let providers = stub_providers(); + + let result = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &MockVisualRefProvider, + make_request(), + &mut |p| events.push(p), + &AbortFlag::new(), + ) + .await; + + assert!(result.is_ok(), "expected Ok, got {:?}", result.err()); + + // VisualRefStarted + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefStarted)), + "missing VisualRefStarted" + ); + // VisualRefDesignSystem + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefDesignSystem { .. })), + "missing VisualRefDesignSystem" + ); + // VisualRefHtmlGenerated + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefHtmlGenerated { .. })), + "missing VisualRefHtmlGenerated" + ); + // VisualRefScreenshotReady { skipped: false } + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefScreenshotReady { skipped: false })), + "missing VisualRefScreenshotReady{{skipped:false}}" + ); + // No fallback + let no_fallback = events + .iter() + .all(|e| !matches!(e, Progress::VisualRefFallback { .. })); + assert!(no_fallback, "unexpected VisualRefFallback in {:?}", events); + + // Orchestrator events present (Planning at minimum) + let has_planning = events.iter().any(|e| matches!(e, Progress::Planning)); + assert!(has_planning, "expected Planning in {:?}", events); +} + +// ── Test 3: generate_design_code returns empty → fallback ───────────────── + +/// When `generate_design_code` returns an empty string (LLM fails or returns +/// nothing), the function emits `VisualRefFallback` and falls back. +#[tokio::test] +async fn execute_visual_ref_empty_html_falls_back() { + // LLM call order: + // [0] generate_design_system → DS JSON + // [1] generate_design_code → empty (simulated via LLM error) + // [2] Orchestrator planning → PLAN_JSON (fallback runs Orchestrator) + // [3] Orchestrator subtask → node_json + use crate::types::LlmError; + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(default_ds_json()), + ScriptResponse::Fail(LlmError { + message: "codegen timeout".into(), + aborted: false, + }), + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("hero")), + ]); + let mut sink = VecDocSink::new(); + let mut events: Vec = Vec::new(); + let providers = stub_providers(); + + let result = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &MockVisualRefProvider, + make_request(), + &mut |p| events.push(p), + &AbortFlag::new(), + ) + .await; + + assert!(result.is_ok(), "expected Ok, got {:?}", result.err()); + + let has_fallback = events + .iter() + .any(|e| matches!(e, Progress::VisualRefFallback { .. })); + assert!( + has_fallback, + "expected VisualRefFallback for empty HTML in {:?}", + events + ); + // No VisualRefHtmlGenerated (never got past code gen) + let no_html_event = events + .iter() + .all(|e| !matches!(e, Progress::VisualRefHtmlGenerated { .. })); + assert!( + no_html_event, + "unexpected VisualRefHtmlGenerated when HTML empty" + ); +} + +// ── Test 4: abort before stage 1 → Err(Aborted) ────────────────────────── + +/// When the abort flag is set before the call, the function returns +/// `Err(OrchestratorError::Aborted)`. +#[tokio::test] +async fn execute_visual_ref_abort_before_stage_1_returns_aborted() { + let llm = ScriptedLlm::new(vec![]); + let mut sink = VecDocSink::new(); + let providers = stub_providers(); + let abort = AbortFlag::new(); + abort.set(); // fire before any call + + let result = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &NoneVisualRefProvider, + make_request(), + &mut |_| {}, + &abort, + ) + .await; + + assert!( + matches!(result, Err(OrchestratorError::Aborted)), + "expected Aborted, got {:?}", + result + ); +} + +// ── Test 5: DesignSystem var_count matches seeded commands ───────────────── + +/// `VisualRefDesignSystem.var_count` equals the number of commands emitted +/// by `design_system_to_seed_commands(default)` = 17. +#[tokio::test] +async fn execute_visual_ref_ds_progress_reports_correct_var_count() { + // Use NoneVisualRefProvider to keep test simple (fallback after screenshot) + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(default_ds_json()), + ScriptResponse::Text("page".into()), + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("hero")), + ]); + let mut sink = VecDocSink::new(); + let mut events: Vec = Vec::new(); + let providers = stub_providers(); + + let _ = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &NoneVisualRefProvider, + make_request(), + &mut |p| events.push(p), + &AbortFlag::new(), + ) + .await; + + // Find the VisualRefDesignSystem event and check var_count + let ds_event = events.iter().find_map(|e| { + if let Progress::VisualRefDesignSystem { var_count } = e { + Some(*var_count) + } else { + None + } + }); + assert!( + ds_event.is_some(), + "missing VisualRefDesignSystem event in {:?}", + events + ); + // DEFAULT_DESIGN_SYSTEM: 8 palette + 6 spacing + 3 radius = 17 + assert_eq!( + ds_event.unwrap(), + 17, + "expected 17 vars from DEFAULT_DESIGN_SYSTEM" + ); +} + +// ── extract_html_from_response ──────────────────────────────────────────── + +#[test] +fn extract_html_fenced_with_doctype_returns_inner() { + // Stage 1: ```html ... ``` wraps a full HTML document → return inner (trimmed). + let resp = + "Sure here's the HTML:\n```html\n\nHi\n```\nDone."; + let html = extract_html_from_response(resp); + assert!(html.starts_with("")); + assert!(html.ends_with("")); + assert!(!html.contains("```")); + assert!(!html.contains("Done.")); +} + +#[test] +fn extract_html_fenced_no_doctype_marker_falls_through_to_wrap() { + // Stage 1 falls through (inner has no not a full doc\n```"; + let html = extract_html_from_response(resp); + assert!(html.starts_with("")); + assert!(html.contains("```")); +} + +#[test] +fn extract_html_starts_with_doctype_returns_trimmed_verbatim() { + // Stage 2: trimmed starts with \nX "; + let html = extract_html_from_response(resp); + assert_eq!(html, "\nX"); +} + +#[test] +fn extract_html_starts_with_html_returns_verbatim() { + // Stage 2: trimmed starts with Y"); +} + +#[test] +fn extract_html_embedded_doctype_to_close_extracted() { + // Stage 3: chatty preamble + trailing text → slice out . + let resp = "Here's the design:\n\nHi\nLet me know."; + let html = extract_html_from_response(resp); + assert!(html.starts_with("")); + assert!(html.ends_with("")); + assert!(!html.contains("Let me know")); + assert!(!html.contains("Here's the design")); +} + +#[test] +fn extract_html_case_insensitive_doctype_via_stage3() { + // Lowercase \nZ"; + let html = extract_html_from_response(resp); + assert_eq!(html, "\nZ"); +} + +#[test] +fn extract_html_bare_text_wrapped_in_default_scaffold() { + // Stage 4: no fence, no ")); + assert!(html.contains("Hello world.")); +} + +#[test] +fn extract_html_empty_response_wrapped_as_empty_body() { + // Stage 4 with empty trimmed content. + let html = extract_html_from_response(""); + assert!(html.starts_with("")); + assert!(html.contains("")); +} + +#[test] +fn extract_html_utf8_preamble_does_not_panic_stage3() { + // Regression guard: `text.to_lowercase()` would change byte length for + // some non-ASCII chars (e.g. Turkish İ → i\u{0307}, German ẞ in some + // locales), making indices derived from the lowercased string unsafe + // on the original UTF-8 text. The ASCII-byte-scan implementation lets + // any UTF-8 preamble pass through cleanly. Asserts no panic + correct + // slice extraction. + let resp = "解释一下:İ\n\nUTF-8 OK\n注释"; + let html = extract_html_from_response(resp); + assert!(html.starts_with("")); + assert!(html.ends_with("")); + assert!(!html.contains("解释一下")); + assert!(!html.contains("注释")); + assert!(!html.contains("İ")); +} + +#[test] +fn extract_html_utf8_inside_body_preserved() { + // Multi-byte chars INSIDE the extracted HTML must round-trip intact. + let resp = "Here:\n\n你好 İstanbul\n done"; + let html = extract_html_from_response(resp); + assert!(html.contains("你好")); + assert!(html.contains("İstanbul")); + assert!(!html.contains("Here:")); + assert!(!html.contains("done")); +} + +#[test] +fn extract_html_mixed_case_doctype_with_utf8_preamble() { + // Combine case-insensitivity + UTF-8 preamble. ASCII-byte scan handles + // both without to_lowercase() byte-shift. + let resp = "Note: ß and İ are tricky.\n\nOK"; + let html = extract_html_from_response(resp); + assert_eq!(html, "\nOK"); +}