#!/usr/bin/env node /** * Visual question server: present a decision to the user as a themed page * instead of a plain-text prompt, then block until they answer. * * The script IS the wait: run it via the shell, it serves the page, prints * the URL (and tries to open the default browser), and does not exit until * the user chooses. The answer lands on stdout as one line: * * ANSWER: {"optionId":"...","steer":"..."} * * Exit codes: 0 answered · 2 timed out or closed without answering. * * Payload (JSON file via --payload, or stdin): * { * "title": "Choose the visual world", * "question": "The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.", * "options": [ * { * "id": "assigned", // returned verbatim * "label": "Fillmore Handbill", * "kicker": "THE ROLL", // optional badge; the assigned option leads * "lineage": "1966-71 Fillmore ...", // optional * "body": "why it fits, first viewport, risk ...", // optional, plain text * "hero": "https://... or /abs/path.webp", // optional image * "board": "https://... or /abs/path.webp" // optional secondary image * }, ... * ], * "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"}) * "steer": true // adds a free-text steer field returned with any answer * } * * Options render as large cards: hero render first when present (the dealt * catalog worlds already have cards; grounded directions may present text-only * or a freshly generated mock). Local image paths are served by this server; * nothing is uploaded anywhere. * * node serve-question.mjs --payload question.json [--timeout 900] [--no-open] [--port 0] */ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process'; function arg(name, fallback = null) { const i = process.argv.indexOf(`--${name}`); if (i === -1) return fallback; const v = process.argv[i + 1]; return v && !v.startsWith('--') ? v : fallback; } const hasFlag = (name) => process.argv.includes(`--${name}`); const payloadPath = arg('payload'); const timeoutSec = Number(arg('timeout', '900')); const portArg = Number(arg('port', '0')); let raw; if (payloadPath) raw = fs.readFileSync(payloadPath, 'utf8'); else raw = fs.readFileSync(0, 'utf8'); const payload = JSON.parse(raw); if (!payload || !Array.isArray(payload.options) || payload.options.length === 0) { console.error('serve-question: payload needs an options array'); process.exit(1); } // Local images are served through /img//; remote URLs pass through. const localImages = []; function imageSrc(value) { if (!value) return null; if (/^https?:\/\//.test(value)) return value; const abs = path.resolve(value); if (!fs.existsSync(abs)) return null; localImages.push(abs); return `/img/${localImages.length - 1}`; } const options = payload.options.map((option) => ({ ...option, heroSrc: imageSrc(option.hero), boardSrc: imageSrc(option.board), })); const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); function page() { const cards = options.map((option, index) => `
${option.kicker ? `${esc(option.kicker)}` : ''} ${option.heroSrc ? `` : '
'}

${esc(option.label)}

${option.lineage ? `

${esc(option.lineage)}

` : ''} ${option.body ? `

${esc(option.body)}

` : ''} ${option.boardSrc ? `
design-system board
` : ''}
`).join('\n'); return ` ${esc(payload.title || 'impeccable · decision')}
impeccable

${esc(payload.title || 'Choose a direction')}

${payload.question ? `

${esc(payload.question)}

` : ''}
${cards}
${payload.steer ? '' : ''} ${payload.reroll ? '' : ''}
`; } const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(page()); return; } const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)$/); if (imageMatch) { const abs = localImages[Number(imageMatch[1])]; if (!abs) { res.writeHead(404); res.end(); return; } const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : 'image/jpeg'; res.writeHead(200, { 'content-type': type }); fs.createReadStream(abs).pipe(res); return; } if (req.method === 'POST' && req.url === '/answer') { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); let parsed = {}; try { parsed = JSON.parse(body); } catch { /* empty steer */ } console.log(`ANSWER: ${JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' })}`); setTimeout(() => process.exit(0), 150); }); return; } res.writeHead(404); res.end(); }); server.listen(portArg, '127.0.0.1', () => { const { port } = server.address(); const url = `http://127.0.0.1:${port}/`; console.log(`QUESTION URL: ${url}`); console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...'); if (!hasFlag('no-open')) { const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ } } if (timeoutSec > 0) { setTimeout(() => { console.log('serve-question: timed out with no answer'); process.exit(2); }, timeoutSec * 1000).unref?.(); } });