import { FONT_STACK, MONO_STACK, xmlEscape } from "./design.js";
import type { SceneInput, SceneOutput } from "./types.js";
export function round(value: number): number {
return Math.round(value * 100) / 100;
}
function rect(
x: number,
y: number,
width: number,
height: number,
fill: string,
radius = 0,
extra = "",
): string {
return ``;
}
function text(
x: number,
y: number,
content: string,
fill: string,
size: number,
weight = 500,
anchor: "start" | "middle" | "end" = "start",
stack = FONT_STACK,
): string {
return `${xmlEscape(
content,
)}`;
}
function placeholderLines(
x: number,
y: number,
width: number,
count: number,
fill: string,
rng: () => number,
lineHeight = 14,
gap = 10,
): string {
const parts: string[] = [];
for (let i = 0; i < count; i += 1) {
const w = width * (0.55 + rng() * 0.45);
parts.push(rect(x, y + i * (lineHeight + gap), w, lineHeight, fill, lineHeight / 2, 'fill-opacity="0.55"'));
}
return parts.join("\n ");
}
function truncate(value: string, max: number): string {
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
}
/* ------------------------------------------------------------------ */
/* scene renderers */
/* ------------------------------------------------------------------ */
export function renderBrowser(input: SceneInput): SceneOutput {
const { tokens, rng } = input;
const width = 1280;
const height = 800;
const pad = 48;
const winX = pad;
const winY = pad;
const winW = width - pad * 2;
const winH = height - pad * 2;
const chromeH = 88;
const tabH = 38;
const r = tokens.radius + 4;
const tabs = ["Overview", truncate(input.product, 18), "Docs"];
let tabX = winX + 76;
const tabParts: string[] = [];
tabs.forEach((label, i) => {
const w = 34 + label.length * 8.2;
const active = i === 1;
tabParts.push(
rect(tabX, winY + 10, w, tabH, active ? tokens.surface : tokens.surfaceAlt, 8),
text(tabX + 16, winY + 10 + tabH / 2, label, active ? tokens.text : tokens.muted, 13, active ? 600 : 500),
);
tabX += w + 8;
});
const contentY = winY + chromeH;
const contentH = winH - chromeH;
const heroPad = 56;
const body = `
${rect(winX, winY, winW, winH, tokens.surface, r)}
${rect(winX, winY, winW, winH, "none", r, `stroke="${tokens.border}" stroke-width="1"`)}
${rect(winX, winY, winW, chromeH, tokens.surfaceAlt, r)}
${rect(winX, winY + chromeH - r, winW, r, tokens.surfaceAlt, 0)}
${tabParts.join("\n ")}
${rect(winX + 24, winY + 56, winW - 168, 24, tokens.dark ? tokens.background : "#FFFFFF", 12, `stroke="${tokens.border}" stroke-width="1"`)}
${text(winX + 58, winY + 68, input.url, tokens.muted, 12, 500, "start", MONO_STACK)}
${rect(winX + winW - 128, winY + 58, 20, 20, tokens.border, 6)}
${rect(winX + winW - 100, winY + 58, 20, 20, tokens.border, 6)}
${rect(winX + heroPad, contentY + 56, 120, 26, tokens.accentSoft, 13)}
${text(winX + heroPad + 60, contentY + 69, "NEW", tokens.accent, 12, 700, "middle")}
${text(winX + heroPad, contentY + 120, truncate(input.title, 42), tokens.text, 44, 700)}
${placeholderLines(winX + heroPad, contentY + 168, 520, 3, tokens.muted, rng)}
${rect(winX + heroPad, contentY + 258, 168, 48, tokens.accent, tokens.radius)}
${text(winX + heroPad + 84, contentY + 282, "Get started", tokens.dark ? "#0B0D12" : "#FFFFFF", 15, 600, "middle")}
${rect(winX + heroPad + 188, contentY + 258, 148, 48, "none", tokens.radius, `stroke="${tokens.border}" stroke-width="1.5"`)}
${text(winX + heroPad + 262, contentY + 282, "See a demo", tokens.text, 15, 600, "middle")}
${rect(winX + winW - heroPad - 460, contentY + 56, 460, contentH - 112, tokens.surfaceAlt, tokens.radius + 4, `stroke="${tokens.border}" stroke-width="1"`)}
${rect(winX + winW - heroPad - 424, contentY + 96, 388, 34, tokens.border, 8, 'fill-opacity="0.6"')}
${[0, 1, 2]
.map((i) =>
rect(
winX + winW - heroPad - 424,
contentY + 154 + i * 78,
388,
62,
tokens.surface,
tokens.radius,
`stroke="${tokens.border}" stroke-width="1"`,
),
)
.join("\n ")}
${[0, 1, 2]
.map((i) => ``)
.join("\n ")}
`;
return {
width,
height,
body,
layers: ["canvas", "window", "chrome", "tab-bar", "url-pill", "page-content"],
notes: "Desktop browser frame at 1280x800 with traffic lights, a three-tab bar, a URL pill, and a two-column marketing page body.",
};
}
export function renderPhone(input: SceneInput): SceneOutput {
const { tokens, rng } = input;
const width = 720;
const height = 1000;
const bodyW = 360;
const bodyH = 740;
const bodyX = (width - bodyW) / 2;
const bodyY = (height - bodyH) / 2;
const bezel = 12;
const screenX = bodyX + bezel;
const screenY = bodyY + bezel;
const screenW = bodyW - bezel * 2;
const screenH = bodyH - bezel * 2;
const cards = [0, 1, 2].map((i) =>
[
rect(screenX + 20, screenY + 250 + i * 108, screenW - 40, 92, tokens.surfaceAlt, tokens.radius, `stroke="${tokens.border}" stroke-width="1"`),
``,
rect(screenX + 84, screenY + 278 + i * 108, 140, 12, tokens.text, 6, 'fill-opacity="0.75"'),
rect(screenX + 84, screenY + 300 + i * 108, 96, 10, tokens.muted, 5, 'fill-opacity="0.6"'),
].join("\n "),
);
const body = `
${rect(bodyX, bodyY, bodyW, bodyH, tokens.dark ? "#15171C" : "#1B1D22", 46)}
${rect(bodyX, bodyY, bodyW, bodyH, "none", 46, 'stroke="#000000" stroke-opacity="0.35" stroke-width="2"')}
${rect(bodyX + bodyW, bodyY + 190, 3, 74, "#2A2D34", 2)}
${rect(bodyX - 3, bodyY + 160, 3, 46, "#2A2D34", 2)}
${rect(screenX, screenY, screenW, screenH, tokens.surface, 36)}
${text(screenX + 26, screenY + 30, "9:41", tokens.text, 13, 700)}
${rect(screenX + screenW - 74, screenY + 24, 18, 11, tokens.text, 2, 'fill-opacity="0.8"')}
${rect(screenX + screenW - 50, screenY + 23, 26, 13, tokens.text, 4, 'fill-opacity="0.8"')}
${rect(screenX + screenW / 2 - 46, screenY + 6, 92, 26, "#0B0B0F", 13)}
${text(screenX + 26, screenY + 92, truncate(input.title, 22), tokens.text, 26, 700)}
${placeholderLines(screenX + 26, screenY + 124, screenW - 80, 2, tokens.muted, rng, 11, 9)}
${rect(screenX + 26, screenY + 182, screenW - 52, 44, tokens.accent, tokens.radius)}
${text(screenX + screenW / 2, screenY + 204, "Continue", tokens.dark ? "#0B0D12" : "#FFFFFF", 15, 600, "middle")}
${cards.join("\n ")}
${rect(screenX, screenY + screenH - 76, screenW, 76, tokens.surfaceAlt, 0)}
${[0, 1, 2, 3]
.map((i) => {
const cx = screenX + screenW * (0.16 + i * 0.226);
const active = i === 0;
return ``;
})
.join("\n ")}
${rect(screenX + screenW / 2 - 62, screenY + screenH - 16, 124, 5, tokens.muted, 3)}
`;
return {
width,
height,
body,
layers: ["canvas", "device-body", "screen", "status-bar", "app-content", "tab-bar"],
notes: "Phone bezel at 720x1000 with a rounded body, side buttons, notch, status bar, app content stack, and a four-item tab bar.",
};
}
export function renderLaptop(input: SceneInput): SceneOutput {
const { tokens, rng } = input;
const width = 1280;
const height = 840;
const lidW = 960;
const lidH = 600;
const lidX = (width - lidW) / 2;
const lidY = 60;
const bezel = 18;
const screenX = lidX + bezel;
const screenY = lidY + bezel + 10;
const screenW = lidW - bezel * 2;
const screenH = lidH - bezel * 2 - 10;
const body = `
${rect(lidX, lidY, lidW, lidH, tokens.dark ? "#15171C" : "#1B1D22", 18)}
${rect(screenX, screenY, screenW, screenH, tokens.surface, 6)}
${rect(screenX, screenY, screenW, 44, tokens.surfaceAlt, 6)}
${text(screenX + screenW / 2, screenY + 22, truncate(input.product, 46), tokens.muted, 13, 500, "middle")}
${text(screenX + 48, screenY + 116, truncate(input.title, 40), tokens.text, 34, 700)}
${placeholderLines(screenX + 48, screenY + 158, 420, 3, tokens.muted, rng)}
${rect(screenX + 48, screenY + 244, 152, 42, tokens.accent, tokens.radius)}
${text(screenX + 124, screenY + 265, "Start free", tokens.dark ? "#0B0D12" : "#FFFFFF", 14, 600, "middle")}
${rect(screenX + screenW - 400, screenY + 88, 348, screenH - 152, tokens.surfaceAlt, tokens.radius, `stroke="${tokens.border}" stroke-width="1"`)}
${[0, 1, 2, 3, 4, 5, 6]
.map((i) => {
const h = 30 + Math.floor(rng() * 130);
const x = screenX + screenW - 368 + i * 44;
const y = screenY + screenH - 120 - h;
return rect(x, y, 26, h, i % 2 === 0 ? tokens.accent : tokens.accentSoft, 6);
})
.join("\n ")}
${rect(width / 2 - 70, lidY + lidH + 4, 140, 6, "#33363D", 3)}
`;
return {
width,
height,
body,
layers: ["canvas", "lid", "screen", "app-window", "base"],
notes: "Laptop mockup at 1280x840 with a bezelled lid, camera dot, in-screen app window, bar chart panel, and a tapered base.",
};
}
export function renderDashboard(input: SceneInput): SceneOutput {
const { tokens, rng } = input;
const width = 1440;
const height = 900;
const sidebarW = 240;
const headerH = 72;
const gutter = 28;
const contentX = sidebarW + gutter;
const contentW = width - sidebarW - gutter * 2;
const navItems = ["Overview", "Usage", "Customers", "Billing", "Reports", "Settings"];
const nav = navItems
.map((label, i) => {
const y = headerH + 34 + i * 46;
const active = i === 0;
return [
active ? rect(14, y - 4, sidebarW - 28, 38, tokens.accentSoft, tokens.radius) : "",
``,
text(58, y + 15, label, active ? tokens.text : tokens.muted, 14, active ? 600 : 500),
]
.filter(Boolean)
.join("\n ");
})
.join("\n ");
const kpiSpecs: Array<{ label: string; value: () => string }> = [
{ label: "MRR", value: () => `$${(Math.floor(rng() * 800) + 120).toLocaleString("en-US")}k` },
{ label: "Active seats", value: () => `${(Math.floor(rng() * 9000) + 400).toLocaleString("en-US")}` },
{ label: "Churn", value: () => `${(rng() * 3 + 0.4).toFixed(1)}%` },
{ label: "NRR", value: () => `${Math.floor(rng() * 40) + 100}%` },
];
const kpiW = (contentW - gutter * 3) / 4;
const kpis = kpiSpecs
.map(({ label, value: makeValue }, i) => {
const x = contentX + i * (kpiW + gutter);
const y = headerH + gutter;
const value = makeValue();
const delta = `${rng() > 0.35 ? "+" : "-"}${(rng() * 12).toFixed(1)}%`;
return [
rect(x, y, kpiW, 118, tokens.surface, tokens.radius, `stroke="${tokens.border}" stroke-width="1"`),
text(x + 20, y + 30, label, tokens.muted, 12, 600),
text(x + 20, y + 66, value, tokens.text, 30, 700),
rect(x + 20, y + 90, 64, 20, tokens.accentSoft, 10),
text(x + 52, y + 100, delta, tokens.accent, 11, 600, "middle"),
].join("\n ");
})
.join("\n ");
const chartY = headerH + gutter + 118 + gutter;
const chartH = 300;
const chartW = contentW * 0.62;
const bars = Array.from({ length: 12 }, (_, i) => {
const h = 40 + Math.floor(rng() * (chartH - 130));
const barW = (chartW - 96) / 12 - 12;
const x = contentX + 48 + i * ((chartW - 96) / 12);
return rect(x, chartY + chartH - 48 - h, barW, h, i === 8 ? tokens.accent : tokens.accentSoft, 6);
}).join("\n ");
const linePoints = Array.from({ length: 12 }, (_, i) => {
const x = contentX + chartW + gutter + 40 + i * ((contentW - chartW - gutter - 80) / 11);
const y = chartY + 90 + Math.floor(rng() * 140);
return `${round(x)},${round(y)}`;
}).join(" ");
const tableY = chartY + chartH + gutter;
const rows = Array.from({ length: 4 }, (_, i) => {
const y = tableY + 66 + i * 44;
return [
``,
rect(contentX + 56, y - 6, 150 + Math.floor(rng() * 80), 12, tokens.text, 6, 'fill-opacity="0.72"'),
rect(contentX + contentW * 0.45, y - 6, 90, 12, tokens.muted, 6, 'fill-opacity="0.5"'),
rect(contentX + contentW * 0.66, y - 6, 70, 12, tokens.muted, 6, 'fill-opacity="0.5"'),
rect(contentX + contentW - 96, y - 12, 72, 24, tokens.accentSoft, 12),
].join("\n ");
}).join("\n ");
const body = `
${kpis}
${rect(contentX, chartY, chartW, chartH, tokens.surface, tokens.radius, `stroke="${tokens.border}" stroke-width="1"`)}
${text(contentX + 24, chartY + 32, "Revenue by month", tokens.text, 14, 600)}
${bars}
${rect(contentX + chartW + gutter, chartY, contentW - chartW - gutter, chartH, tokens.surface, tokens.radius, `stroke="${tokens.border}" stroke-width="1"`)}
${text(contentX + chartW + gutter + 24, chartY + 32, "Activation trend", tokens.text, 14, 600)}
${rect(contentX, tableY, contentW, height - tableY - gutter, tokens.surface, tokens.radius, `stroke="${tokens.border}" stroke-width="1"`)}
${text(contentX + 24, tableY + 30, "Recent accounts", tokens.text, 14, 600)}
${rows}
`;
return {
width,
height,
body,
layers: ["canvas", "sidebar", "header", "kpi-row", "chart-bar", "chart-line", "table"],
notes: "Analytics dashboard at 1440x900 with a six-item sidebar, header search, four KPI cards, a bar chart, a line chart, and a four-row table.",
};
}
/* ------------------------------------------------------------------ */
/* cli */
/* ------------------------------------------------------------------ */