Merge branch 'v0.8.0-new' of github.com:ZSeven-W/openpencil into v0.8.0-new

This commit is contained in:
Fini 2026-05-24 00:39:09 +08:00
parent cb9a0ae692
commit 8a39bc7b03
29 changed files with 2799 additions and 768 deletions

View file

@ -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<string> {
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('<!DOCTYPE') || content.includes('<html')) {
return content;
}
}
// Check if the response itself starts with HTML
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
return trimmed;
}
// Try to find HTML document in the response
const htmlMatch = trimmed.match(/(<!DOCTYPE[\s\S]*<\/html>)/i);
if (htmlMatch) {
return htmlMatch[1];
}
// Last resort: wrap bare content
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Design</title></head>
<body>${trimmed}</body>
</html>`;
}
/**
* 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 = /<h([1-6])[^>]*>([\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.`;
}

View file

@ -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<DesignSystem> {
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<string, unknown>;
if (!obj.palette || typeof obj.palette !== 'object') return null;
if (!obj.typography || typeof obj.typography !== 'object') return null;
const p = obj.palette as Record<string, string>;
const t = obj.typography as Record<string, unknown>;
const s = (obj.spacing as Record<string, unknown>) ?? {
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<string, VariableDefinition> {
const vars: Record<string, VariableDefinition> = {};
// 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();
}

View file

@ -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) {

View file

@ -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<string> {
// 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<void>((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<void> {
// 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<void>((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<void>((resolve) => {
setTimeout(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve();
});
});
}, 300); // 300ms for CSS transitions and layout
});
}

View file

@ -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?.(`<step title="${title}" status="streaming">${detail}</step>`);
};
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',
};
}

View file

@ -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"
}
]

View file

@ -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"
}

View file

@ -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

View file

@ -81,6 +81,8 @@ fn make_req() -> crate::types::DesignRequest {
concurrency: 2,
append_context: None,
validation_enabled: true,
visual_ref_enabled: false,
}
}

View file

@ -23,6 +23,8 @@ fn make_req() -> crate::types::DesignRequest {
concurrency: 2,
append_context: None,
validation_enabled: true,
visual_ref_enabled: false,
}
}

View file

@ -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<f64>,
}
/// 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<f64>,
}
/// 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<String, String>,
pub typography: Typography,
pub spacing: Spacing,
/// Corner-radius scale in pixels. Port of `radius: number[]` in TS.
pub radius: Vec<f64>,
/// 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<DesignSystem> = 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<DesignSystem> {
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<f64> = t
.get("scale")
.and_then(|v| v.as_array())
.and_then(|arr| arr.iter().map(|v| v.as_f64()).collect::<Option<Vec<_>>>())
.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<f64> = 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::<Option<Vec<_>>>())
.unwrap_or_else(|| default.spacing.scale.clone());
// Radius — optional array
let radius: Vec<f64> = obj
.get("radius")
.and_then(|v| v.as_array())
.and_then(|arr| arr.iter().map(|v| v.as_f64()).collect::<Option<Vec<_>>>())
.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<EditorCommand> {
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<char> = 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::<Vec<_>>()
.join(", ");
// Spacing scale: "8, 16, 24, 32, 48, 64px"
let spacing_scale = ds
.spacing
.scale
.iter()
.map(|v| format_num(*v))
.collect::<Vec<_>>()
.join(", ");
let spacing_unit = format_num(ds.spacing.unit);
// Radius: "8, 12, 16px"
let radius = ds
.radius
.iter()
.map(|v| format_num(*v))
.collect::<Vec<_>>()
.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;

View file

@ -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<String> = 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<String> = 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<String> = 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"
);
}

View file

@ -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,
};

View file

@ -219,6 +219,8 @@ mod tests {
concurrency: 1,
append_context: None,
validation_enabled: true,
visual_ref_enabled: false,
}
}

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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(),

View file

@ -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,
}
}

View file

@ -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![

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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,

View file

@ -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<String> {
None
}
}

View file

@ -107,6 +107,8 @@ mod tests {
concurrency: 1,
append_context: None,
validation_enabled: true,
visual_ref_enabled: false,
}
}

View file

@ -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<String>;
}
// ── S4 end ────────────────────────────────────────────────────────────────────
// ── S3c end ───────────────────────────────────────────────────────────────────
/// 廉价可克隆的中止句柄(`Arc<AtomicBool>` 语义)。
@ -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("<html></html>", 1280.0, 800.0)
.is_none());
assert!(p.render_html_to_screenshot("", 0.0, 0.0).is_none());
assert!(p
.render_html_to_screenshot("<html><body>Hello</body></html>", 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<T: Send + Sync + ?Sized>() {}
assert_send_sync::<dyn VisualRefProvider>();
}
/// `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("<p>test</p>", 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");

View file

@ -29,6 +29,7 @@ fn make_request(validation_enabled: bool) -> DesignRequest {
concurrency: 1,
append_context: None,
validation_enabled,
visual_ref_enabled: false,
}
}

View file

@ -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();

View file

@ -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 (`<h1>`-`<h6>`), 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 `<!DOCTYPE` or `<html`, return the inner content (trimmed).
/// 2. Trimmed response itself starts with `<!DOCTYPE` or `<html` — return as-is.
/// 3. Case-insensitive find of `<!DOCTYPE…</html>` 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("<!DOCTYPE") || inner.contains("<html") {
return inner.to_string();
}
}
// Stage 2: trimmed starts with DOCTYPE / html
if trimmed.starts_with("<!DOCTYPE") || trimmed.starts_with("<html") {
return trimmed.to_string();
}
// Stage 3: embedded <!DOCTYPE…</html>
if let Some(html) = extract_doctype_to_html_close(trimmed) {
return html;
}
// Stage 4: wrap bare content in default scaffold
format!(
"<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Design</title></head>\n<body>{trimmed}</body>\n</html>"
)
}
/// 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 `<!DOCTYPE…</html>` 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 (`<!doctype` / `</html>`) 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<String> {
let bytes = text.as_bytes();
let doc_needle: &[u8] = b"<!doctype";
let close_needle: &[u8] = b"</html>";
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 (`<section|header|footer|nav|main|div>`) with a
/// `class` or `id` attribute — emits `- Section: {classOrId}` (skips
/// values containing `__` to filter BEM modifiers).
/// 2. Headings `<h1>`-`<h6>` — emits `- H{n}: "{content}"` (inner text
/// stripped of tags, truncated to 60 chars).
/// 3. CTAs: `<button|a>` 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<String> = 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: /<h([1-6])[^>]*>([\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<usize> {
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<String>) {
// 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 `<h1>`-`<h6>` elements and extract their text content.
fn extract_headings(html: &str, lines: &mut Vec<String>) {
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 `</h{level}>`
let closing = format!("</h{level}>");
let closing_lower = format!("</h{level}>");
// 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!("</H{level}>");
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 `<button>` and `<a>` elements whose class contains `btn`, `button`,
/// or `cta`, and extract their text content.
fn extract_ctas(html: &str, lines: &mut Vec<String>) {
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 `button` or `a` (case-insensitive)
let matched_tag: Option<(&str, &str)> = if html[tag_start..].len() >= 6
&& html[tag_start..tag_start + 6].eq_ignore_ascii_case("button")
&& tag_start + 6 < len
&& (bytes[tag_start + 6] == b' '
|| bytes[tag_start + 6] == b'\t'
|| bytes[tag_start + 6] == b'\n'
|| bytes[tag_start + 6] == b'\r')
{
Some(("button", "</button>"))
} 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", "</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<RunSummary, OrchestratorError>`.
///
/// ## 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<RunSummary, OrchestratorError> {
// 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;

View file

@ -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 = "<html><body>Hello</body></html>";
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("<!DOCTYPE html>".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("<div></div>");
assert_eq!(
result.lines().next().unwrap(),
"DESIGN REFERENCE STRUCTURE:"
);
}
#[test]
fn extract_structure_summary_section_with_class() {
let html = r#"<section class="hero"><h1>Welcome</h1></section>"#;
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("<section><h1>Hero</h1><a>CTA</a></section>")` → exact format
// Note: <section> has no class/id → no Section line.
// <h1> → H1 line.
// <a> has no class → no CTA line.
let html = "<section><h1>Hero</h1><a>CTA</a></section>";
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#"<div class="hero__inner">content</div>"#;
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#"<button class="btn-primary">Get Started</button>"#;
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 = "<a class=\"cta-link\" href=\"#\">Learn More</a>";
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 = "<a class=\"button-primary\" href=\"#\">Sign Up</a>";
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!("<h2>{long_title}</h2>");
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#"<button class="btn">{long_text}</button>"#);
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 = "<div><span>plain</span></div>";
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#"<section id="about">content</section>"#;
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 = "<h1>One</h1><h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>";
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 = "<h1><span>Hello</span> <em>World</em></h1>";
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<std::collections::VecDeque<ScriptResponse>>,
recorded: Mutex<Vec<CallRequest>>,
}
impl RecordingLlm {
fn new(responses: Vec<ScriptResponse>) -> Self {
Self {
responses: Mutex::new(responses.into()),
recorded: Mutex::new(Vec::new()),
}
}
fn calls(&self) -> Vec<CallRequest> {
self.recorded.lock().unwrap().clone()
}
}
impl LlmClient for RecordingLlm {
fn call(&self, req: CallRequest) -> BoxStream<'static, Result<LlmChunk, LlmError>> {
self.recorded.lock().unwrap().push(req);
let next = self.responses.lock().unwrap().pop_front();
let items: Vec<Result<LlmChunk, LlmError>> = 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<String> {
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<String> {
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 <h1>Hero</h1>)
// [2] Orchestrator planning → PLAN_JSON
// [3] Orchestrator subtask → node_json
let llm = RecordingLlm::new(vec![
ScriptResponse::Text(default_ds_json()),
ScriptResponse::Text("<html><body><h1>Hero</h1></body></html>".into()),
ScriptResponse::Text(PLAN_JSON.into()),
ScriptResponse::Text(node_json("hero")),
]);
let mut sink = VecDocSink::new();
let mut events: Vec<Progress> = 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 <h1>Hero</h1>).
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("<html><body><h1>Hero</h1></body></html>".into()),
ScriptResponse::Text(PLAN_JSON.into()),
ScriptResponse::Text(node_json("hero")),
]);
let mut sink = VecDocSink::new();
let mut events: Vec<Progress> = 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<Progress> = 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("<html><body>page</body></html>".into()),
ScriptResponse::Text(PLAN_JSON.into()),
ScriptResponse::Text(node_json("hero")),
]);
let mut sink = VecDocSink::new();
let mut events: Vec<Progress> = 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<!DOCTYPE html>\n<html><body>Hi</body></html>\n```\nDone.";
let html = extract_html_from_response(resp);
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.ends_with("</html>"));
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 <!DOCTYPE/<html); stage 2-3 don't match;
// stage 4 wraps the ORIGINAL trimmed response (fence chars and all).
let resp = "```\n<div>not a full doc</div>\n```";
let html = extract_html_from_response(resp);
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<body>```"));
}
#[test]
fn extract_html_starts_with_doctype_returns_trimmed_verbatim() {
// Stage 2: trimmed starts with <!DOCTYPE → return as-is.
let resp = " <!DOCTYPE html>\n<html><body>X</body></html> ";
let html = extract_html_from_response(resp);
assert_eq!(html, "<!DOCTYPE html>\n<html><body>X</body></html>");
}
#[test]
fn extract_html_starts_with_html_returns_verbatim() {
// Stage 2: trimmed starts with <html → return as-is.
let resp = "<html><body>Y</body></html>";
let html = extract_html_from_response(resp);
assert_eq!(html, "<html><body>Y</body></html>");
}
#[test]
fn extract_html_embedded_doctype_to_close_extracted() {
// Stage 3: chatty preamble + trailing text → slice out <!DOCTYPE…</html>.
let resp = "Here's the design:\n<!DOCTYPE html>\n<html><body>Hi</body></html>\nLet me know.";
let html = extract_html_from_response(resp);
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.ends_with("</html>"));
assert!(!html.contains("Let me know"));
assert!(!html.contains("Here's the design"));
}
#[test]
fn extract_html_case_insensitive_doctype_via_stage3() {
// Lowercase <!doctype doesn't pass case-sensitive stage 2 startsWith;
// stage 3 case-insensitive find catches it. Original case preserved.
let resp = "<!doctype html>\n<HTML><body>Z</body></HTML>";
let html = extract_html_from_response(resp);
assert_eq!(html, "<!doctype html>\n<HTML><body>Z</body></HTML>");
}
#[test]
fn extract_html_bare_text_wrapped_in_default_scaffold() {
// Stage 4: no fence, no <!DOCTYPE/<html → wrap in default scaffold.
let resp = "Hello world.";
let html = extract_html_from_response(resp);
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<body>Hello world.</body>"));
}
#[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("<!DOCTYPE html>"));
assert!(html.contains("<body></body>"));
}
#[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<!DOCTYPE html>\n<html><body>UTF-8 OK</body></html>\n注释";
let html = extract_html_from_response(resp);
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.ends_with("</html>"));
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<!DOCTYPE html>\n<html><body>你好 İstanbul</body></html>\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<!DocType html>\n<HTML><body>OK</body></HTML>";
let html = extract_html_from_response(resp);
assert_eq!(html, "<!DocType html>\n<HTML><body>OK</body></HTML>");
}