openvibers/openvibe.design Commits

Design Studio v1 part A: prompt → multi-model frames on a canvas (#6)

OP OpenVibers committed 15 days ago · 90e9fd59cb · 14 files, +558 −5

* Build Design Studio generation and canvas

* Keep lazy canvas frames in sync with theme

* Keep Design Studio HTML responses private

Changes

ddiff --git a/README.md b/README.md
iindex ef9f713..03490da 100644
---- a/README.md
++++ b/README.md
@@@ -36,6 +36,12 @@ Network's theme API.
- **Use, share, roll back:** *Use* is `PUT /api/me/theme` (source `studio`), *Share it in the gallery* submits the
theme for review, and *History* lists the person's earlier looks with a one-tap *Restore*.
++## Design Studio v1
++
++`/create` takes one prompt and shows three model variants on `/d/:id`. The variants use the Arena's OpenVibe.AI route pins and HTML sanitizer. The canvas supports phone, tablet and desktop widths, and changing the token theme repaints all frames. A signed-in design belongs to its Network subject. A guest receives a secret link, kept among the last three drafts in a private cookie. Guests can create two designs per day per IP; signed-in people can create ten. Generation progress is polled, and completed frames appear individually.
++
++The API is under `/api/v1/designs`; `/developers` lists the routes. `migrations/0002_design_studio.sql` stores each design and its JSON variants in one row, using the service's existing PostgreSQL or development PGlite setup. Production needs the existing `DATABASE_URL` and `DATABASE_DIRECT_URL` roles and working OpenVibe.AI service credentials. Editing, picking, checks, export, publishing and MCP are planned for part B.
++
## The Design Arena
- **A round:** a signed-in person writes one prompt in one of six categories. Design creates a public Bench task of
ddiff --git a/migrations/0002_design_studio.sql b/migrations/0002_design_studio.sql
nnew file mode 100644
iindex 0000000..2be363a
---- /dev/null
++++ b/migrations/0002_design_studio.sql
@@@ -0,0 +1,18 @@
++-- phase: expand
++-- A design and its independently generated frames live in one row.
++CREATE TABLE designs (
++ id text COLLATE "C" PRIMARY KEY,
++ prompt text NOT NULL,
++ kind text COLLATE "C" NOT NULL,
++ theme_id text COLLATE "C",
++ variants jsonb NOT NULL DEFAULT '[]'::jsonb,
++ owner text COLLATE "C",
++ guest_hash text,
++ guest_ip text,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT designs_kind CHECK (kind IN ('page', 'screen', 'component')),
++ CONSTRAINT designs_variants_array CHECK (jsonb_typeof(variants) = 'array')
++);
++CREATE INDEX designs_owner_recent ON designs (owner, created_at DESC);
++CREATE INDEX designs_guest_daily ON designs (guest_ip, created_at);
ddiff --git a/public/css/design.css b/public/css/design.css
iindex 8fee663..2f8305b 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -249,3 +249,50 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.ar-form select:focus-visible, .ar-form textarea:focus-visible { outline: 2px solid var(--room-strong); outline-offset: 1px; }
.ar-form textarea::placeholder { color: var(--text-muted, #7386a3); }
@media (prefers-reduced-motion: reduce) { .ar-vote-side { transition: none; } }
++/* Design Studio A: a quiet prompt and canvas. */
++.dv-visually-hidden { position: absolute; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; }
++.dv-create { max-width: 800px; margin: clamp(48px, 13vh, 150px) auto 100px; padding: 0 20px; }
++.ds .dv-create { min-height: calc(100svh - 110px); }
++.dv-create h1 { margin: 0 0 24px; font-size: clamp(2.2rem, 5vw, 3.6rem); letter-spacing: -.05em; line-height: 1.06; }
++.dv-create form { border: 1px solid var(--border); background: var(--bg-card); border-radius: 16px; padding: 12px; box-shadow: 0 16px 48px #0001; }
++.dv-create textarea { display: block; width: 100%; min-height: 150px; padding: 16px; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 1.15rem; }
++.dv-create textarea::placeholder { color: var(--text-muted); }
++.dv-create form button { display: block; margin: 0 0 2px auto; border: 0; border-radius: 9px; background: var(--accent); color: var(--on-accent); padding: 12px 18px; font: inherit; font-weight: 700; cursor: pointer; }
++.dv-examples { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
++.dv-examples button { min-height: 38px; border: 1px solid var(--border); border-radius: 99px; padding: 8px 13px; background: var(--bg-card); color: var(--text-muted); font: inherit; font-size: .84rem; cursor: pointer; }
++.dv-examples button:hover, .dv-examples button:focus-visible { color: var(--text-primary); border-color: var(--accent); }
++.dv-recent { margin-top: 48px; }
++.dv-recent h2 { font-size: 1rem; }
++.dv-recent ul { padding-left: 20px; }
++.dv-recent li { margin: 10px 0; overflow-wrap: anywhere; }
++.dv-canvas { min-height: 80vh; padding: 24px max(16px, 3vw) 120px; background: var(--bg-primary); color: var(--text-primary); }
++.dv-heading { max-width: 1320px; margin: 0 auto 24px; }
++.dv-heading a { font-size: .85rem; }
++.dv-heading h1 { max-width: 900px; margin: 18px 0 8px; font-size: clamp(1.45rem, 3vw, 2.5rem); letter-spacing: -.04em; line-height: 1.12; overflow-wrap: anywhere; }
++.dv-heading p { margin: 0; color: var(--text-muted); font-size: .9rem; }
++.dv-rail { display: flex; gap: 20px; max-width: 1600px; margin: 0 auto; padding: 8px 4px 24px; overflow-x: auto; scroll-snap-type: x mandatory; }
++.dv-card { flex: 0 0 min(34vw, 500px); min-width: 300px; scroll-snap-align: center; }
++.dv-frame { height: min(68vh, 680px); min-height: 440px; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); box-shadow: 0 14px 32px #0002; transition: box-shadow .2s, transform .2s; }
++.dv-frame iframe { display: block; border: 0; transform-origin: top left; max-width: none; }
++.dv-label { display: flex; justify-content: space-between; gap: 12px; padding: 12px 4px; font-size: .78rem; color: var(--text-muted); }
++.dv-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.dv-skeleton { display: grid; place-content: center; gap: 8px; height: 100%; text-align: center; background: linear-gradient(100deg, var(--bg-card), var(--bg-secondary), var(--bg-card)); background-size: 200% 100%; animation: dv-shimmer 1.6s linear infinite; }
++.dv-skeleton span { color: var(--text-primary); font-weight: 700; }
++.dv-skeleton small { color: var(--text-muted); }
++@keyframes dv-shimmer { to { background-position: -200% 0; } }
++.dv-toolbar { position: fixed; z-index: 20; bottom: 24px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 16px; max-width: calc(100vw - 24px); padding: 9px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); box-shadow: 0 12px 35px #0003; }
++.dv-toolbar form, .dv-toolbar label { display: flex; align-items: center; gap: 8px; margin: 0; white-space: nowrap; font-size: .8rem; }
++.dv-toolbar select, .dv-toolbar button { min-height: 36px; border: 1px solid var(--border); border-radius: 8px; padding: 4px 8px; background: var(--bg-card); color: var(--text-primary); font: inherit; }
++.dv-toolbar button { cursor: pointer; }
++.dv-enhanced #dv-theme-form button { display: none; }
++@media (min-width: 1100px) { .dv-card { flex-basis: calc((100% - 40px) / 3); min-width: 0; } .dv-frame { height: min(54vh, 560px); min-height: 400px; } }
++@media (max-width: 700px) {
++ .dv-create { margin-top: 62px; }
++ .dv-rail { margin: 0 -16px; padding: 6px 16px 22px; }
++ .dv-card { flex-basis: calc(100vw - 48px); min-width: 0; }
++ .dv-frame { height: min(66vh, 600px); }
++ .dv-toolbar { bottom: calc(var(--ovnav-appbar-h, 56px) + 12px); width: calc(100% - 24px); justify-content: space-between; gap: 4px; }
++ .dv-toolbar select { max-width: 102px; }
++ .dv-toolbar form, .dv-toolbar label { gap: 4px; font-size: .72rem; }
++}
++@media (prefers-reduced-motion: reduce) { .dv-frame { transition: none; } .dv-skeleton { animation: none; } }
ddiff --git a/public/js/design.js b/public/js/design.js
nnew file mode 100644
iindex 0000000..f5303bb
---- /dev/null
++++ b/public/js/design.js
@@@ -0,0 +1,70 @@
++(function () {
++ 'use strict';
++ const prompt = document.getElementById('dv-prompt');
++ if (prompt) {
++ document.querySelectorAll('[data-example]').forEach(button => button.addEventListener('click', () => {
++ prompt.value = button.dataset.example;
++ prompt.focus();
++ }));
++ return;
++ }
++ const canvas = document.querySelector('.dv-canvas');
++ if (!canvas) return;
++ const frames = () => [...canvas.querySelectorAll('.dv-card iframe')];
++ const device = document.getElementById('dv-device');
++ device.value = '390';
++ function resize() {
++ const width = Number(device.value);
++ frames().forEach(frame => {
++ const box = frame.closest('.dv-frame');
++ const scale = Math.min(1, box.clientWidth / width);
++ frame.style.width = `${width}px`;
++ frame.style.height = `${box.clientHeight / scale}px`;
++ frame.style.transform = `scale(${scale})`;
++ });
++ }
++ device.addEventListener('change', resize);
++ window.addEventListener('resize', resize);
++ resize();
++
++ let cssByTheme = {};
++ try { cssByTheme = JSON.parse(document.getElementById('dv-theme-css').textContent); } catch { /* form still works */ }
++ const themeForm = document.getElementById('dv-theme-form');
++ canvas.classList.add('dv-enhanced');
++ document.getElementById('dv-theme').addEventListener('change', () => themeForm.requestSubmit());
++ frames().forEach(frame => frame.addEventListener('load', () => {
++ const css = cssByTheme[document.getElementById('dv-theme').value];
++ if (css) frame.contentWindow.postMessage({ type: 'design-theme', css }, '*');
++ }));
++ themeForm.addEventListener('submit', async event => {
++ event.preventDefault();
++ const themeId = document.getElementById('dv-theme').value;
++ const css = cssByTheme[themeId];
++ if (!css) { themeForm.submit(); return; }
++ frames().forEach(frame => frame.contentWindow.postMessage({ type: 'design-theme', css }, '*'));
++ try {
++ const response = await fetch(`/api/v1/designs/${encodeURIComponent(canvas.dataset.id)}/theme`, {
++ method: 'POST', headers: { 'content-type': 'application/json', ...(canvas.dataset.key ? { 'x-design-key': canvas.dataset.key } : {}) },
++ body: JSON.stringify({ themeId }),
++ });
++ if (!response.ok) throw new Error('Could not save the theme.');
++ } catch (error) {
++ document.getElementById('dv-status').textContent = error.message;
++ }
++ });
++
++ if ([...canvas.querySelectorAll('.dv-card')].every(card => ['done', 'failed'].includes(card.dataset.status))) return;
++ const url = `/api/v1/designs/${encodeURIComponent(canvas.dataset.id)}${canvas.dataset.key ? `?key=${encodeURIComponent(canvas.dataset.key)}` : ''}`;
++ const initial = [...canvas.querySelectorAll('.dv-card')].map(card => card.dataset.status).join(',');
++ const timer = setInterval(async () => {
++ try {
++ const response = await fetch(url, { cache: 'no-store' });
++ if (!response.ok) return;
++ const data = await response.json();
++ if (data.variants.map(v => v.status).join(',') !== initial) {
++ clearInterval(timer);
++ location.reload();
++ }
++ } catch { /* try the next poll */ }
++ }, 1200);
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 74fa226..287d3f4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -33,6 +33,8 @@ const { createSso } = require('./auth/sso');
const { createThemeCatalog } = require('./themes');
const { createApi } = require('./http/api');
const { createArenaApi } = require('./http/arena-api');
++const { createDesignApi } = require('./http/design-api');
++const { createDesigns } = require('./designs');
const { createPageRoutes } = require('./http/pages');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createServiceReadiness } = require('./observability');
@@@ -64,13 +66,14 @@ async function createApp(opts = {}) {
const ai = opts.arenaAi || opts.ai || createArenaAi({ config, fetchImpl, log });
// The Arena generation job now lives on Bench; Design keeps a no-op loop for test compatibility.
const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };
++ const designs = opts.designs || createDesigns({ s, ai, catalog, log, now });
const accountData = accountDataLib.create({ db: s.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
-- config, s, store: s, keys, sso, catalog, arena, bench, ai, jobs, accountData, accountSend, log,
++ config, s, store: s, keys, sso, catalog, arena, bench, ai, jobs, designs, accountData, accountSend, log,
random: opts.random || Math.random,
};
@@@ -152,6 +155,7 @@ async function createApp(opts = {}) {
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
// ── API: the Arena, then the theme proxy ────────────────
++ app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api', createApi(ctx));
ddiff --git a/server/designs.js b/server/designs.js
nnew file mode 100644
iindex 0000000..951c8e9
---- /dev/null
++++ b/server/designs.js
@@@ -0,0 +1,163 @@
++'use strict';
++
++const crypto = require('crypto');
++const { sanitizeHtml, MAX_BYTES } = require('./arena/sanitize');
++
++const KINDS = ['page', 'screen', 'component'];
++const random = () => crypto.randomBytes(24).toString('base64url');
++const hash = (key) => crypto.createHash('sha256').update(key).digest();
++const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });
++
++function tokenCss(variables = {}) {
++ return Object.entries(variables)
++ .filter(([name, value]) => /^--[a-z][a-z0-9-]{1,40}$/.test(name) && /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\)|[0-9., %]+|light|dark)$/i.test(String(value)))
++ .map(([name, value]) => `${name}:${value}`).join(';');
++}
++
++function themeCss(variables) {
++ // The aliases let a sparse Network theme cover the usual surfaces without introducing a second palette.
++ const aliases = { '--bg-card': 'var(--bg-primary)', '--bg-secondary': 'var(--bg-primary)',
++ '--text-muted': 'var(--text-primary)', '--accent': 'var(--text-primary)',
++ '--on-accent': 'var(--bg-primary)', '--border': 'var(--text-primary)' };
++ const fallbacks = Object.entries(aliases).filter(([name]) => !(name in variables))
++ .map(([name, value]) => `${name}:${value}`).join(';');
++ return `:root{${tokenCss(variables)};${fallbacks}}html{color-scheme:var(--color-scheme,light)}body{background:var(--bg-primary);color:var(--text-primary)}`;
++}
++
++function tokenizeColors(html, variables) {
++ const colors = Object.entries(variables).filter(([, v]) => /^#[0-9a-f]{6}$/i.test(String(v)))
++ .map(([name, hex]) => ({ name, rgb: [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)) }));
++ function nearest(raw) {
++ let rgb;
++ if (raw.startsWith('#')) {
++ let hex = raw.slice(1);
++ if (hex.length === 3) hex = [...hex].map(c => c + c).join('');
++ if (hex.length !== 6) return 'var(--accent)';
++ rgb = [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16));
++ } else {
++ const numbers = raw.match(/\d+(?:\.\d+)?/g);
++ if (!numbers || numbers.length < 3) return 'var(--accent)';
++ rgb = numbers.slice(0, 3).map(Number);
++ }
++ if (!colors.length) return 'var(--accent)';
++ const best = colors.reduce((a, b) =>
++ b.rgb.reduce((sum, x, i) => sum + (x - rgb[i]) ** 2, 0) < a.rgb.reduce((sum, x, i) => sum + (x - rgb[i]) ** 2, 0) ? b : a);
++ return `var(${best.name})`;
++ }
++ const replace = css => css
++ .replace(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/gi, nearest)
++ .replace(/((?:color|background|border-color|fill|stroke)\s*:\s*)(white|black|red|blue|green|gray|grey)\b/gi, '$1var(--accent)')
++ .replace(/@import\s+[^;]+;?/gi, '')
++ .replace(/url\s*\([^)]*\)/gi, 'none');
++ return html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (match, css) => match.replace(css, replace(css)))
++ .replace(/\sstyle=(['"])(.*?)\1/gi, (match, quote, css) => ` style=${quote}${replace(css)}${quote}`);
++}
++
++function wrap(html, variables) {
++ let clean = sanitizeHtml(html, { maxBytes: MAX_BYTES - 4096 })
++ .replace(/<style\b[^>]*id=["']design-tokens["'][^>]*>[\s\S]*?<\/style>/gi, '')
++ .replace(/<link\b[^>]*>/gi, '')
++ .replace(/<meta\b[^>]*http-equiv=["']Content-Security-Policy["'][^>]*>/gi, '');
++ clean = tokenizeColors(clean, variables);
++ const tokens = `<style id="design-tokens">${themeCss(variables)}</style>`;
++ if (!/<html\b/i.test(clean)) clean = `<html><head></head><body>${clean}</body></html>`;
++ if (!/<head\b/i.test(clean)) clean = clean.replace(/<html\b[^>]*>/i, '$&<head></head>');
++ clean = clean.replace(/<head\b[^>]*>/i, `$&${tokens}`);
++ return `<!doctype html>${clean.replace(/^\s*<!doctype\s+html[^>]*>/i, '')}`;
++}
++
++function createDesigns({ s, ai, catalog, log = console, now = () => Date.now() }) {
++ const db = s.db;
++ const publicRow = d => ({ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
++ status: d.variants.every(v => v.status === 'done' || v.status === 'failed')
++ ? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
++ createdAt: d.created_at, updatedAt: d.updated_at });
++ function allowed(d, owner, key) {
++ if (d.owner && owner === d.owner) return true;
++ if (!d.guest_hash || !key) return false;
++ return crypto.timingSafeEqual(Buffer.from(d.guest_hash, 'hex'), hash(String(key)));
++ }
++ async function row(id, owner, key) {
++ const d = await db.maybe('SELECT * FROM designs WHERE id=$1', [id]);
++ if (!d) throw fail(404, 'design.not_found', 'Design not found.');
++ if (!allowed(d, owner, key)) throw fail(403, 'design.forbidden', 'Use the private link for this design.');
++ return d;
++ }
++ async function theme(id) {
++ const list = await catalog.list();
++ const selected = id ? list.themes.find(t => t.id === id) : list.themes[0];
++ if (!selected) throw fail(422, 'design.invalid_theme', 'Choose a theme from the catalog.');
++ return selected;
++ }
++ async function create({ prompt, kind = 'page', themeId, owner = null, ip = '' }) {
++ prompt = String(prompt || '').trim();
++ if (!prompt || prompt.length > 4000 || !KINDS.includes(kind))
++ throw fail(422, 'design.invalid_input', 'Provide a prompt up to 4000 characters and a valid kind.');
++ const cutoff = new Date(now() - 86400000).toISOString();
++ const count = Number(await db.value(`SELECT COUNT(*) FROM designs WHERE ${owner ? 'owner' : 'guest_ip'}=$1 AND created_at >= $2::timestamptz`, [owner || ip, cutoff]));
++ if (count >= (owner ? 10 : 2)) throw fail(429, 'design.daily_limit', 'Daily design limit reached.');
++ const selected = await theme(themeId);
++ const plan = await ai.slotPlan();
++ const slots = plan.slots.slice(0, 3);
++ const variants = slots.map((slot, index) => ({ id: s.newId('dva'), slot: index, provider: slot.provider,
++ model: slot.model, status: 'pending', html: '', ms: 0, costUsd: 0, error: null }));
++ const id = s.newId('dsn'), editKey = owner ? null : random();
++ await db.exec('INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at) VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz)',
++ [id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null, owner ? null : ip, s.iso()]);
++ run(id).catch(err => log.warn('[OpenVibe.Design] generation:', err.message));
++ return { design: publicRow({ id, prompt, kind, theme_id: selected.id, variants, created_at: s.iso(), updated_at: s.iso() }),
++ editKey, editUrl: editKey ? `/d/${id}?key=${encodeURIComponent(editKey)}` : null };
++ }
++ async function updateVariant(id, index, variant) {
++ await db.exec('UPDATE designs SET variants=jsonb_set(variants, ARRAY[$2::text], $3::jsonb, false), updated_at=$4::timestamptz WHERE id=$1',
++ [id, String(index), JSON.stringify(variant), s.iso()]);
++ }
++ async function run(id) {
++ const d = await db.maybe('SELECT * FROM designs WHERE id=$1', [id]);
++ if (!d) return;
++ await Promise.allSettled(d.variants.map(async (v, index) => {
++ if (v.status === 'done') return;
++ const running = { ...v, status: 'running', error: null };
++ await updateVariant(id, index, running);
++ try {
++ const t = await theme(d.theme_id);
++ const prompt = `${d.prompt}\nUse ONLY these theme CSS custom properties for all colors: ${Object.keys(t.variables || {}).join(', ')}. No external requests. Mobile first. Return one complete HTML document.`;
++ const out = await ai.generate({ prompt, category: d.kind === 'component' ? 'logo' : d.kind === 'screen' ? 'mobile_app' : 'landing',
++ slot: { provider: v.provider, model: v.model }, roundId: id, index });
++ const current = await db.maybe('SELECT theme_id FROM designs WHERE id=$1', [id]);
++ const currentTheme = await theme(current.theme_id);
++ await updateVariant(id, index, { ...running, status: 'done', html: wrap(out.html, currentTheme.variables || {}),
++ provider: out.provider, model: out.model, ms: out.ms, costUsd: out.cost_usd, error: null });
++ } catch (err) {
++ await updateVariant(id, index, { ...running, status: 'failed', error: String(err.message).slice(0, 300) });
++ }
++ }));
++ }
++ async function read(id, owner, key) {
++ const d = await row(id, owner, key), selected = await theme(d.theme_id);
++ return { design: publicRow(d), variants: d.variants.map(v => v.status === 'done'
++ ? { ...v, html: wrap(v.html, selected.variables || {}) } : v) };
++ }
++ async function setTheme(id, themeId, owner, key) {
++ await row(id, owner, key);
++ const selected = await theme(themeId);
++ await db.exec('UPDATE designs SET theme_id=$1,updated_at=$2::timestamptz WHERE id=$3', [selected.id, s.iso(), id]);
++ return { themeId: selected.id, variables: selected.variables || {} };
++ }
++ async function recent(owner, guestLinks = []) {
++ if (owner) return (await db.many('SELECT * FROM designs WHERE owner=$1 ORDER BY created_at DESC LIMIT 3', [owner]))
++ .map(d => ({ ...publicRow(d), url: `/d/${d.id}` }));
++ const result = [];
++ for (const item of guestLinks.slice(0, 3)) {
++ try { const d = await row(item.id, null, item.key); result.push({ ...publicRow(d), url: `/d/${d.id}?key=${encodeURIComponent(item.key)}` }); } catch { /* stale draft */ }
++ }
++ return result;
++ }
++ async function resume() {
++ const pending = await db.many("SELECT id FROM designs WHERE variants @> '[{\"status\":\"pending\"}]'::jsonb OR variants @> '[{\"status\":\"running\"}]'::jsonb ORDER BY created_at LIMIT 20");
++ for (const d of pending) run(d.id).catch(err => log.warn('[OpenVibe.Design] resume:', err.message));
++ }
++ return { create, read, setTheme, recent, resume, theme };
++}
++
++module.exports = { createDesigns, wrap, tokenCss, themeCss };
ddiff --git a/server/http/design-api.js b/server/http/design-api.js
nnew file mode 100644
iindex 0000000..04cb36f
---- /dev/null
++++ b/server/http/design-api.js
@@@ -0,0 +1,41 @@
++'use strict';
++
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const { requireSameOrigin } = require('./same-origin');
++
++const owner = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null;
++const key = req => req.get('x-design-key') || req.query.key;
++
++function createDesignApi(ctx) {
++ const routes = asyncRouter();
++ const safe = fn => async (req, res) => {
++ try { return await fn(req, res); }
++ catch (err) {
++ ctx.log.warn('[OpenVibe.Design] Studio:', err.message);
++ return http.sendProblem(res, err.status || 500, err.code || 'design.error', {
++ detail: err.status ? err.detail : 'The design could not be completed.', ctx: req.ov,
++ });
++ }
++ };
++ routes.post('/', requireSameOrigin(ctx.config), safe(async (req, res) => {
++ const body = req.body || {};
++ const result = await ctx.designs.create({ prompt: body.prompt, kind: body.kind || 'page',
++ themeId: body.themeId, owner: owner(req), ip: req.ip });
++ if (result.editKey) {
++ let links = [];
++ try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
++ links = [{ id: result.design.id, key: result.editKey }, ...links.filter(x => x.id !== result.design.id)].slice(0, 3);
++ res.cookie('design_drafts', JSON.stringify(links), { httpOnly: true, sameSite: 'lax',
++ secure: ctx.config.cookies.secure, maxAge: 30 * 86400000, path: '/' });
++ }
++ return res.status(201).set('Cache-Control', 'private, no-store').json(result);
++ }));
++ routes.get('/:id', safe(async (req, res) => res.set('Cache-Control', 'private, no-store')
++ .json(await ctx.designs.read(req.params.id, owner(req), key(req)))));
++ routes.post('/:id/theme', requireSameOrigin(ctx.config), safe(async (req, res) => res
++ .json(await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), key(req) || (req.body || {}).editKey))));
++ return routes;
++}
++
++module.exports = { createDesignApi };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex eafebb0..4017ce0 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -16,6 +16,7 @@
*/
const express = require('express');
const views = require('../views');
++const designView = require('../views/design');
const { asyncRouter } = require('./router');
const { send } = require('../render/layout');
const { ArenaError } = require('../arena/store');
@@@ -46,13 +47,60 @@ function createPageRoutes(ctx) {
log.warn('[OpenVibe.Design] page catalogue failed:', (err && err.message) || err);
return null;
});
-- const page = (res, status, html, viewer) => send(res, status, html, { viewer });
++ const page = (res, status, html, viewer, options = {}) => send(res, status, html, { viewer, ...options });
const categories = arena.CATEGORIES;
const aria = async (viewer) => {
const subject = viewer && viewer.kind === 'user' ? viewer.subject : null;
return subject ? await arena.counts(subject).catch(() => null) : null;
};
++ r.get('/create', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ let links = [];
++ try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
++ const recent = await ctx.designs.recent(subjectOf(req), links);
++ return page(res, 200, designView.create({ recent }), req.viewer, { cacheControl: 'private, no-store' });
++ });
++ r.post('/create', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
++ try {
++ const out = await ctx.designs.create({ prompt: req.body.prompt, kind: req.body.kind || 'page',
++ owner: subjectOf(req), ip: req.ip });
++ if (out.editKey) {
++ let links = [];
++ try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
++ links = [{ id: out.design.id, key: out.editKey }, ...links.filter(x => x.id !== out.design.id)].slice(0, 3);
++ res.cookie('design_drafts', JSON.stringify(links), { httpOnly: true, sameSite: 'lax',
++ secure: config.cookies.secure, maxAge: 30 * 86400000, path: '/' });
++ }
++ return res.redirect(303, out.editUrl || `/d/${out.design.id}`);
++ } catch (err) {
++ return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not create the design.');
++ }
++ });
++ r.get('/d/:id', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ res.set('Referrer-Policy', 'no-referrer');
++ try {
++ const data = await ctx.designs.read(req.params.id, subjectOf(req), req.query.key);
++ const themes = (await catalog.list()).themes;
++ return page(res, 200, designView.canvas({ data, themes, editKey: req.query.key || '' }), req.viewer,
++ { cacheControl: 'private, no-store' });
++ } catch (err) {
++ return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not read the design.');
++ }
++ });
++ r.post('/d/:id/theme', express.urlencoded({ extended: false }), async (req, res) => {
++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
++ try {
++ const key = req.body.key || '';
++ await ctx.designs.setTheme(req.params.id, req.body.themeId, subjectOf(req), key);
++ return res.redirect(303, `/d/${req.params.id}${key ? `?key=${encodeURIComponent(key)}` : ''}`);
++ } catch (err) {
++ return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not change theme.');
++ }
++ });
++
r.get('/', async (req, res) => {
const list = await safe(() => catalog.list());
return page(res, 200, views.home({ themes: list ? list.themes : null }), req.viewer);
ddiff --git a/server/index.js b/server/index.js
iindex abc5edc..825e19b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -37,6 +37,7 @@ async function start() {
ctx.keys.ensure().catch(() => {});
// Generate queued Arena rounds. DESIGN_DRILL=1 keeps the loop off (a test or a drill drives tick()).
ctx.jobs.start();
++ ctx.designs.resume().catch((err) => console.warn('[OpenVibe.Design] resume designs:', err.message));
});
server.keepAliveTimeout = 65_000;
createLifecycle({ server, ctx });
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 9cb9eb6..e8c61f1 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -23,6 +23,7 @@ const esc = kit.esc;
const NAV = [
{ label: 'Home', href: '/', icon: 'fa-house' },
++ { label: 'Create', href: '/create', icon: 'fa-palette' },
{ label: 'Studio', href: '/studio', icon: 'fa-palette' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
@@@ -86,9 +87,9 @@ document.addEventListener('DOMContentLoaded', function () { try { if (window.Ope
* Send a page; a page rendered for a signed-in person is private and never cached. A public page gets the short
* public window the whole network uses for HTML.
*/
--function send(res, status, html, { viewer } = {}) {
++function send(res, status, html, { viewer, cacheControl } = {}) {
const personal = viewer && viewer.kind === 'user';
-- res.set('Cache-Control', personal ? 'private, no-store' : 'public, max-age=120');
++ res.set('Cache-Control', cacheControl || (personal ? 'private, no-store' : 'public, max-age=120'));
res.set('Vary', 'Cookie');
res.status(status).type('html').send(html);
}
ddiff --git a/server/views/design.js b/server/views/design.js
nnew file mode 100644
iindex 0000000..4b74327
---- /dev/null
++++ b/server/views/design.js
@@@ -0,0 +1,51 @@
++'use strict';
++
++const { layout, esc } = require('../render/layout');
++const { attrEscape } = require('../arena/sanitize');
++const { themeCss } = require('../designs');
++
++const examples = ['Landing page for a coffee roaster', 'Dashboard for a fitness app', 'Pricing section', 'Mobile onboarding'];
++
++function create({ recent = [] } = {}) {
++ const body = `<section class="dv-create"><h1>What will you design?</h1>
++<form method="post" action="/create"><label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
++<textarea id="dv-prompt" name="prompt" required maxlength="4000" placeholder="Describe a page, an app screen or a component…"></textarea>
++<button type="submit">Create designs</button></form>
++<div class="dv-examples" aria-label="Try an example">${examples.map(x => `<button type="button" data-example="${esc(x)}">${esc(x)}</button>`).join('')}</div>
++${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map(d => `<li><a href="${esc(d.url)}">${esc(d.prompt)}</a></li>`).join('')}</ul></section>` : ''}</section>`;
++ return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });
++}
++
++const themeListener = `<script>(function(){addEventListener('message',function(event){if(event.source!==parent||!event.data||event.data.type!=='design-theme')return;var style=document.getElementById('design-tokens');if(style&&typeof event.data.css==='string')style.textContent=event.data.css;});})();</script>`;
++const framePolicy = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">`;
++
++function frame(html, title) {
++ const withPolicy = html.replace(/<head\b[^>]*>/i, `$&${framePolicy}`);
++ const document = /<\/body>/i.test(withPolicy)
++ ? withPolicy.replace(/<\/body>/i, `${themeListener}</body>`)
++ : /<\/html>/i.test(withPolicy) ? withPolicy.replace(/<\/html>/i, `${themeListener}</html>`) : withPolicy + themeListener;
++ return `<iframe title="${esc(title)}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" srcdoc="${attrEscape(document)}"></iframe>`;
++}
++
++function canvas({ data, themes = [], editKey = '' }) {
++ const { design, variants } = data;
++ const cards = variants.map(v => `<article class="dv-card" data-status="${esc(v.status)}">
++<div class="dv-frame">${v.status === 'done' ? frame(v.html, `${v.model || 'Design'} frame`) :
++ `<div class="dv-skeleton" role="status"><span>${esc(v.model || 'Designer')}</span><small>${esc(v.status === 'failed' ? 'Could not generate' : 'Creating…')}</small></div>`}</div>
++<div class="dv-label"><span>${esc(v.model || 'Designer')}</span>${v.status === 'done' ?
++ `<span>${(Number(v.ms || 0) / 1000).toFixed(1)} s · $${Number(v.costUsd || 0).toFixed(4)}</span>` : ''}</div></article>`).join('');
++ const themeOptions = themes.map(t => `<option value="${esc(t.id)}"${t.id === design.themeId ? ' selected' : ''}>${esc(t.name)}</option>`).join('');
++ const cssByTheme = Object.fromEntries(themes.map(t => [t.id, themeCss(t.variables || {})]));
++ const cssJson = JSON.stringify(cssByTheme).replace(/</g, '\\u003c');
++ const body = `<section class="dv-canvas" data-id="${esc(design.id)}" data-key="${esc(editKey)}">
++<header class="dv-heading"><a href="/create">← New design</a><h1>${esc(design.prompt)}</h1>
++<p id="dv-status" role="status">${esc(design.status === 'done' ? 'Three designs ready.' : design.status === 'failed' ? 'Generation failed.' : 'Designers are working…')}</p></header>
++<div class="dv-rail" id="dv-rail">${cards}</div>
++<nav class="dv-toolbar" aria-label="Canvas tools"><label>Device <select id="dv-device"><option value="390">Phone · 390</option><option value="834">Tablet · 834</option><option value="1280">Desktop · 1280</option></select></label>
++<form method="post" action="/d/${encodeURIComponent(design.id)}/theme" id="dv-theme-form"><input type="hidden" name="key" value="${esc(editKey)}"><label>Theme <select id="dv-theme" name="themeId">${themeOptions}</select></label><button type="submit">Apply</button></form></nav>
++</section><script type="application/json" id="dv-theme-css">${cssJson}</script>`;
++ return layout({ title: 'Design canvas', path: `/d/${encodeURIComponent(design.id)}`, robots: 'noindex, nofollow',
++ body, scripts: ['js/design.js'] });
++}
++
++module.exports = { create, canvas, frame };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 119663f..001a081 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -142,6 +142,9 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
function developers() {
const rows = [
++ ['POST /api/v1/designs', 'Create three model variants with {prompt, kind, themeId?}; guests receive a secret edit link.'],
++ ['GET /api/v1/designs/:id', 'Poll generation status and read each frame; guests send X-Design-Key.'],
++ ['POST /api/v1/designs/:id/theme', 'Change the token theme with {themeId}.'],
['GET /api/themes', 'Every public OpenVibe theme with its colours (Network\'s catalog).'],
['GET /api/themes/:id', 'One theme.'],
['GET /api/me/theme', 'Your active theme (signed in).'],
ddiff --git a/test/design.test.js b/test/design.test.js
nnew file mode 100644
iindex 0000000..74e53df
---- /dev/null
++++ b/test/design.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const html = '<!doctype html><html><head><style>body{background:#ffffff;color:#222222}</style></head><body><h1>Design</h1></body></html>';
++const slots = [{ provider: 'alpha', model: 'one' }, { provider: 'beta', model: 'two' }, { provider: 'gamma', model: 'three' }];
++const waiting = [];
++const ai = {
++ slotPlan: async () => ({ slots }),
++ generate: ({ slot, prompt }) => prompt.includes('Signed design')
++ ? Promise.resolve({ html, provider: slot.provider, model: slot.model, cost_usd: 0.001, ms: 125 })
++ : new Promise(resolve => waiting.push(() => resolve({ html, provider: slot.provider,
++ model: slot.model, cost_usd: 0.001, ms: 125 }))),
++};
++const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
++
++(async () => {
++ const t = await boot({ ai });
++ try {
++ let id, key, cookie;
++ const user = t.network.addUser('kim');
++ await check('unknown design returns 404', async () => {
++ const response = await t.get('/api/v1/designs/unknown');
++ assert.equal(response.status, 404);
++ });
++ await check('create queues three distinct AI route pins in one row', async () => {
++ const response = await t.get('/api/v1/designs', { json: { prompt: 'A coffee roaster landing page', kind: 'page' }, origin: 'https://openvibe.design' });
++ assert.equal(response.status, 201, response.text);
++ ({ id } = response.json().design);
++ key = response.json().editKey;
++ cookie = response.headers.get('set-cookie').split(';')[0];
++ assert.ok(key);
++ for (let i = 0; i < 30 && waiting.length < 3; i++) await pause(20);
++ assert.equal(waiting.length, 3);
++ const rows = await t.store.db.many('SELECT * FROM designs WHERE id=$1', [id]);
++ assert.equal(rows.length, 1);
++ assert.equal(rows[0].variants.length, 3);
++ assert.deepEqual(rows[0].variants.map(v => v.provider), ['alpha', 'beta', 'gamma']);
++ assert.equal((await t.get(`/api/v1/designs/${id}`)).status, 403);
++ const progress = (await t.get(`/api/v1/designs/${id}?key=${key}`)).json();
++ assert.equal(progress.variants.filter(v => v.status === 'running').length, 3);
++ });
++ await check('poll shows each completed frame while other models run', async () => {
++ waiting[0]();
++ let progress;
++ for (let i = 0; i < 30; i++) {
++ progress = (await t.get(`/api/v1/designs/${id}?key=${key}`)).json();
++ if (progress.variants[0].status === 'done') break;
++ await pause(20);
++ }
++ assert.equal(progress.variants[0].status, 'done');
++ assert.equal(progress.variants[1].status, 'running');
++ assert.match(progress.variants[0].html, /<style id="design-tokens">/);
++ assert.doesNotMatch(progress.variants[0].html, /background:#ffffff/);
++ waiting[1](); waiting[2]();
++ for (let i = 0; i < 30; i++) {
++ progress = (await t.get(`/api/v1/designs/${id}?key=${key}`)).json();
++ if (progress.design.status === 'done') break;
++ await pause(20);
++ }
++ assert.equal(progress.variants.filter(v => v.status === 'done').length, 3);
++ assert.equal(progress.design.status, 'done');
++ });
++ await check('canvas is server rendered and themes repaint every frame', async () => {
++ const page = await t.get(`/d/${id}?key=${key}`);
++ assert.equal(page.status, 200);
++ assert.equal(page.headers.get('cache-control'), 'private, no-store');
++ assert.equal(page.headers.get('referrer-policy'), 'no-referrer');
++ assert.equal((page.text.match(/sandbox="allow-scripts"/g) || []).length, 3);
++ assert.equal((page.text.match(/default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'/g) || []).length, 3,
++ 'each frame blocks external network requests through its CSP');
++ assert.match(page.text, /Desktop · 1280/);
++ const changed = await t.get(`/api/v1/designs/${id}/theme`, { json: { themeId: 'paper', editKey: key }, origin: 'https://openvibe.design' });
++ assert.equal(changed.status, 200, changed.text);
++ const progress = (await t.get(`/api/v1/designs/${id}?key=${key}`)).json();
++ assert.equal(progress.design.themeId, 'paper');
++ assert.ok(progress.variants.every(v => v.html.includes('#fbfaf7')));
++ const recent = await t.get('/create', { cookie });
++ assert.equal(recent.headers.get('cache-control'), 'private, no-store');
++ assert.match(recent.text, /A coffee roaster landing page/);
++ assert.match(recent.text, /Mobile onboarding/);
++ });
++ await check('guest and signed-in studio HTML stay private without changing public pages', async () => {
++ const guest = await t.get('/create');
++ assert.equal(guest.headers.get('cache-control'), 'private, no-store');
++ const signedCreate = await t.get('/create', { as: user });
++ assert.equal(signedCreate.headers.get('cache-control'), 'private, no-store');
++ const created = await t.get('/api/v1/designs', { as: user,
++ json: { prompt: 'Signed design', kind: 'page' }, origin: 'https://openvibe.design' });
++ assert.equal(created.status, 201, created.text);
++ const signedCanvas = await t.get(`/d/${created.json().design.id}`, { as: user });
++ assert.equal(signedCanvas.status, 200);
++ assert.equal(signedCanvas.headers.get('cache-control'), 'private, no-store');
++ assert.equal(signedCanvas.headers.get('referrer-policy'), 'no-referrer');
++ assert.equal((await t.get('/')).headers.get('cache-control'), 'public, max-age=120');
++ });
++ } finally { waiting.splice(0).forEach(resolve => resolve()); await t.close(); }
++ done();
++})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 1c2dcce..aa557d0 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -47,7 +47,7 @@ async function boot(opts = {}) {
const config = configLib.load(env);
const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
const store = opts.store || createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random });
++ const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;