← 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 };