← back to Hormuzy

mockups/generate.mjs

124 lines

#!/usr/bin/env node
/**
 * Hormuz mockup generator — local-Ollama only.
 *
 * Reads manifest.json, generates one HTML file per direction. Skips entries
 * that already exist (so you can rerun safely after a partial run, and so
 * the 5 hand-built anchor variants aren't overwritten).
 *
 * Routes to MS1 by default (Steve directive 2026-05-05 — bulk LLM goes to
 * Mac Studio 1, never localhost) per ~/.claude/projects/-Users-stevestudio2/memory/feedback_ollama_default_ms1.md
 *
 * Each variant: ~150-200 line standalone HTML, no JS framework, all CSS
 * inline, web fonts via Google Fonts, no external images.
 *
 * Usage:
 *   node generate.mjs              # generate all missing entries
 *   LIMIT=10 node generate.mjs     # cap at 10 generations this run
 *   FORCE=1 node generate.mjs      # regenerate even if file exists (skips anchors by default)
 *   ANCHORS=1 node generate.mjs    # also regenerate the 5 anchors (1,2,3,4,5)
 */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MANIFEST = path.join(__dirname, 'manifest.json');
const OLLAMA   = process.env.OLLAMA_BASE || 'http://192.168.1.133:11434';
const MODEL    = process.env.LLM_MOCKUP_MODEL || 'gemma3:12b';
const LIMIT    = process.env.LIMIT ? Number(process.env.LIMIT) : 1000;
const FORCE    = process.env.FORCE === '1';
const ANCHORS  = process.env.ANCHORS === '1';
const ANCHOR_INDICES = new Set([1, 2, 3, 4]);
const TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 360_000);

const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
const dirs = manifest.directions;

const SYSTEM = `You are a senior visual designer outputting ONE complete <!doctype html> page. No preamble, no markdown fences, no commentary. All CSS inline in a single <style> tag. External assets only via Google Fonts <link>. No external images, no JS frameworks. The page is a mockup of an "agent design orchestrator" dashboard — its job is to visually express ONE specific aesthetic direction. The viewport is 1440x900.`;

function buildPrompt(d) {
  return `Build a single self-contained HTML mockup for the Four Horsemen Studio orchestrator front page.

AESTHETIC DIRECTION (this is the entire creative seed, do not deviate):
- Mood: ${d.mood}
- Palette: ${d.palette.join(', ')} (use them deliberately; ${d.palette[0]} is the ground, ${d.palette[1]} is the primary, others are supports)
- Type pair: ${d.type[0]} (display) + ${d.type[1]} (body)
- Voice: ${d.voice}

REQUIRED PAGE STRUCTURE (lay out in this order, but render in the chosen aesthetic):
1. Header bar — brand name (FOUR HORSEMEN / HORMUZ / similar), a project picker dropdown (with 3 sample project names), and a status indicator showing "idle" or "running"
2. Left pane (40% wide) — chat log showing 3-4 example messages. Include at least one /run-topic or /stampede slash-command exchange. Then a chat input bar at the bottom.
3. Right pane (60% wide) — 4 sub-panels arranged in a 2x2 grid (or stacked, dealer's choice for the aesthetic): Preview · Paper · Figma · Canva. Each panel needs a header label and an empty/placeholder body that fits the aesthetic (NOT generic "lorem ipsum" — invent in-character content per the voice).

REQUIREMENTS (non-negotiable):
- Real copy, in the chosen voice, for every label and placeholder. NEVER use lorem ipsum.
- Use only the named fonts via Google Fonts <link rel="stylesheet" href="https://fonts.googleapis.com/css2?...">
- No external images. SVG, CSS gradients, ASCII art, unicode block chars are all fair game.
- 1440x900 viewport target. Layout must not horizontally scroll.
- Title tag: "${d.mood} — Four Horsemen Studio"

Output ONLY the complete <!doctype html> document.`;
}

function stripFences(s) {
  return s.replace(/^```(?:html)?\s*/i, '').replace(/```\s*$/i, '').trim();
}

async function generateOne(d) {
  const t0 = Date.now();
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
  try {
    const r = await fetch(`${OLLAMA}/api/generate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      signal: ac.signal,
      body: JSON.stringify({
        model: MODEL,
        system: SYSTEM,
        prompt: buildPrompt(d),
        stream: false,
        options: { temperature: 0.74, top_p: 0.92, num_predict: 8000 },
      }),
    });
    clearTimeout(timer);
    if (!r.ok) return null;
    const j = await r.json();
    const raw = (j.response || '').trim();
    let html = stripFences(raw);
    if (!/<!doctype|<html/i.test(html)) return null;
    return { html, ms: Date.now() - t0 };
  } catch {
    clearTimeout(timer);
    return null;
  }
}

function fname(d) {
  const idx = String(d.i).padStart(3, '0');
  return `${idx}-${d.slug}.html`;
}

(async () => {
  console.log(`[hormuz-mockups] target=${manifest.target}`);
  console.log(`[hormuz-mockups] OLLAMA=${OLLAMA}  MODEL=${MODEL}`);
  console.log(`[hormuz-mockups] ${dirs.length} directions, LIMIT=${LIMIT}, FORCE=${FORCE}, ANCHORS=${ANCHORS}`);
  let ok = 0, skip = 0, fail = 0, attempted = 0;
  for (const d of dirs) {
    if (attempted >= LIMIT) break;
    const out = path.join(__dirname, fname(d));
    const isAnchor = ANCHOR_INDICES.has(d.i);
    if (fs.existsSync(out) && !FORCE) { skip++; continue; }
    if (isAnchor && !ANCHORS) { skip++; continue; }
    attempted++;
    process.stdout.write(`[${String(d.i).padStart(3,'0')}] ${d.slug.padEnd(28)}  ${d.mood.padEnd(34)}  ... `);
    const result = await generateOne(d);
    if (!result) { console.log('FAIL'); fail++; continue; }
    fs.writeFileSync(out, result.html);
    console.log(`${(result.ms/1000).toFixed(1)}s  ${(result.html.length/1024).toFixed(1)}kb`);
    ok++;
  }
  console.log(`[hormuz-mockups] done. ok=${ok} skip=${skip} fail=${fail}`);
})();