openvibers/openvibe.design Commits
Design Studio v1 part A: prompt → multi-model frames on a canvas (#6)
Changes
@@@ -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 thetheme 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 ofnnew file mode 100644@@@ -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);@@@ -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; } }nnew file mode 100644@@@ -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);++})();@@@ -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));nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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);@@@ -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 });@@@ -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);}nnew file mode 100644@@@ -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 };@@@ -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).'],nnew file mode 100644@@@ -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); });@@@ -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}`;
@@ -36,6 +36,12 @@ Network's theme API.
@@ -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
- **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*.
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
## The Design Arena
- **A round:** a signed-in person writes one prompt in one of six categories. Design creates a public Bench task of
- **A round:** a signed-in person writes one prompt in one of six categories. Design creates a public Bench task of
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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);
@@ -249,3 +249,50 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -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 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); }
.ar-form textarea::placeholder { color: var(--text-muted, #7386a3); }
@media (prefers-reduced-motion: reduce) { .ar-vote-side { transition: none; } }
@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; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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);
+})();
@@ -33,6 +33,8 @@ const { createSso } = require('./auth/sso');
@@ -33,6 +33,8 @@ const { createSso } = require('./auth/sso');
const { createThemeCatalog } = require('./themes');
const { createThemeCatalog } = require('./themes');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createArenaApi } = require('./http/arena-api');
const { createArenaApi } = require('./http/arena-api');
+const { createDesignApi } = require('./http/design-api');
+const { createDesigns } = require('./designs');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
@@ -64,13 +66,14 @@ async function createApp(opts = {}) {
@@ -64,13 +66,14 @@ async function createApp(opts = {}) {
const ai = opts.arenaAi || opts.ai || createArenaAi({ config, fetchImpl, log });
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.
// 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 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 accountData = accountDataLib.create({ db: s.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? 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'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
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,
random: opts.random || Math.random,
};
};
@@ -152,6 +155,7 @@ async function createApp(opts = {}) {
@@ -152,6 +155,7 @@ async function createApp(opts = {}) {
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
// ── API: the Arena, then the theme proxy ────────────────
// ── 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', express.json({ limit: '64kb' }));
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api', createApi(ctx));
app.use('/api', createApi(ctx));
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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 };
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
*/
*/
const express = require('express');
const express = require('express');
const views = require('../views');
const views = require('../views');
+const designView = require('../views/design');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { ArenaError } = require('../arena/store');
const { ArenaError } = require('../arena/store');
@@ -46,13 +47,60 @@ function createPageRoutes(ctx) {
@@ -46,13 +47,60 @@ function createPageRoutes(ctx) {
log.warn('[OpenVibe.Design] page catalogue failed:', (err && err.message) || err);
log.warn('[OpenVibe.Design] page catalogue failed:', (err && err.message) || err);
return null;
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 categories = arena.CATEGORIES;
const aria = async (viewer) => {
const aria = async (viewer) => {
const subject = viewer && viewer.kind === 'user' ? viewer.subject : null;
const subject = viewer && viewer.kind === 'user' ? viewer.subject : null;
return subject ? await arena.counts(subject).catch(() => null) : 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) => {
r.get('/', async (req, res) => {
const list = await safe(() => catalog.list());
const list = await safe(() => catalog.list());
return page(res, 200, views.home({ themes: list ? list.themes : null }), req.viewer);
return page(res, 200, views.home({ themes: list ? list.themes : null }), req.viewer);
@@ -37,6 +37,7 @@ async function start() {
@@ -37,6 +37,7 @@ async function start() {
ctx.keys.ensure().catch(() => {});
ctx.keys.ensure().catch(() => {});
// Generate queued Arena rounds. DESIGN_DRILL=1 keeps the loop off (a test or a drill drives tick()).
// Generate queued Arena rounds. DESIGN_DRILL=1 keeps the loop off (a test or a drill drives tick()).
ctx.jobs.start();
ctx.jobs.start();
+ ctx.designs.resume().catch((err) => console.warn('[OpenVibe.Design] resume designs:', err.message));
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
createLifecycle({ server, ctx });
createLifecycle({ server, ctx });
@@ -23,6 +23,7 @@ const esc = kit.esc;
@@ -23,6 +23,7 @@ const esc = kit.esc;
const NAV = [
const NAV = [
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Home', href: '/', icon: 'fa-house' },
+ { label: 'Create', href: '/create', icon: 'fa-palette' },
{ label: 'Studio', href: '/studio', icon: 'fa-palette' },
{ label: 'Studio', href: '/studio', icon: 'fa-palette' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
@@ -86,9 +87,9 @@ document.addEventListener('DOMContentLoaded', function () { try { if (window.Ope
@@ -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
* 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.
* 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';
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.set('Vary', 'Cookie');
res.status(status).type('html').send(html);
res.status(status).type('html').send(html);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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 };
@@ -142,6 +142,9 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
@@ -142,6 +142,9 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
function developers() {
function developers() {
const rows = [
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', 'Every public OpenVibe theme with its colours (Network\'s catalog).'],
['GET /api/themes/:id', 'One theme.'],
['GET /api/themes/:id', 'One theme.'],
['GET /api/me/theme', 'Your active theme (signed in).'],
['GET /api/me/theme', 'Your active theme (signed in).'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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); });
@@ -47,7 +47,7 @@ async function boot(opts = {}) {
@@ -47,7 +47,7 @@ async function boot(opts = {}) {
const config = configLib.load(env);
const config = configLib.load(env);
const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
const store = opts.store || createStore(testdb.db, { now: opts.now });
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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;