← back to Visual Factory
src/stages/compose.js
59 lines
// Stage 3 — compose. qwen3:14b writes a single self-contained HTML+CSS doc that fills
// the target dimensions exactly and respects the spec's palette/typography/content.
const fs = require('node:fs/promises');
const path = require('node:path');
const { ollama } = require('../llm');
const SANDBOX_ROOT = path.join(__dirname, '..', '..', 'output');
const SYSTEM = `You write a SINGLE self-contained HTML document that renders a polished visual at exact target dimensions. Output ONLY raw HTML — no code fences, no commentary.
REQUIREMENTS:
- One <!DOCTYPE html> file, all CSS inline in <style>, no external assets, no JS frameworks.
- The visible canvas is exactly width × height pixels. Use:
body { margin: 0; width: <W>px; height: <H>px; overflow: hidden; }
- Use Google Fonts via <link> for typography ("serif"=Playfair Display, "sans"=Inter, "display"=Bebas Neue, "mono"=JetBrains Mono).
- Honor the spec.palette colors precisely.
- Render every content_block. Use spec.background ("solid"/"gradient"/"pattern") for the canvas.
- **TEXT MUST READ.** If background is "pattern", confine the pattern to the EDGES (e.g., a top/bottom band, an asymmetric corner block) so headline + subhead sit on a clean solid panel — NEVER tile a busy stripe/dot pattern under the type. Headlines must hit AAA contrast against whatever is behind them.
- For image_placeholder blocks, render a styled container with subtle texture (CSS gradients/patterns). Never reference external image URLs.
- Accessibility-grade contrast on all text.
- No lorem ipsum — use the block.text values verbatim.
- Polished, intentional layout. NOT a generic landing-page hero.`;
async function runCompose({ runId, spec, signal = null }) {
const userPrompt = `Spec:
${JSON.stringify(spec, null, 2)}
Width: ${spec.width}px
Height: ${spec.height}px
Write the HTML now.`;
const html = stripFences(await ollama({
model: process.env.COMPOSE_MODEL || 'qwen3:14b',
system: SYSTEM,
prompt: userPrompt,
temperature: 0.4,
signal
})).trim();
const runDir = path.join(SANDBOX_ROOT, `run-${runId}`);
await fs.mkdir(runDir, { recursive: true });
const htmlPath = path.join(runDir, `${spec.artifact_name}.html`);
await fs.writeFile(htmlPath, html, 'utf8');
const errors = [];
if (!/<!DOCTYPE\s+html/i.test(html)) errors.push('missing <!DOCTYPE html>');
if (!/<style[\s>]/i.test(html)) errors.push('missing inline <style>');
if (html.length < 400) errors.push(`html too short (${html.length} bytes)`);
return { htmlPath, html, errors };
}
function stripFences(s) {
return s.replace(/^```(?:html)?\s*\n/i, '').replace(/\n```\s*$/, '');
}
module.exports = { runCompose };