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