← 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}`);
})();