openvibers/openvibe.design Commits
Add Design Studio pick, edits, versions and Arena vote (#8)
Changes
@@@ -45,7 +45,10 @@ OV_AI_INTERNAL_URL=http://127.0.0.1:4700OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230# OV_BENCH_URL=https://bench.openvibe.services# DESIGN_BENCH_AUDIENCE=openvibe.bench--# DESIGN_BENCH_SCOPE=bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.board.read++# DESIGN_BENCH_SCOPE=bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.entry.create bench.board.read++++# Design Studio part B: AI edits allowed per design (each accepted chat edit is one paid AI run).++# DESIGN_CHAT_EDITS_PER_DESIGN=5# Legacy Arena settings retained for compatibility; Bench enforces active round limits and runs generation.# DESIGN_ARENA_PER_DAY=3@@@ -40,7 +40,9 @@ Network's theme API.`/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.++Pick one frame with **Use this design**: it becomes the working frame, its HTML becomes version 1 and the pick is recorded as one Arena vote through OpenVibe.Bench (a signed-in person votes once; a guest's pick is saved without a vote). Edit it by chat (`POST /api/v1/designs/:id/edit`) or by clicking an element: the iframe reports `body > …:nth-of-type(n)`, and the floating panel changes its text, theme-token colour, padding or alignment. The server validates the path, tag and values, escapes the text, rebuilds only the inline style and saves a new version; the overlay script is injected at render time and is never stored with the model HTML. Versions are append-only in `design_versions`; undo/redo move the pointer (`design_picks.current_seq`), and an edit after undo appends a new version instead of deleting the old ones. Chat edits cost AI money, so each design has a small AI-edit budget (`DESIGN_CHAT_EDITS_PER_DESIGN`, default 5); a failed attempt spends budget but never replaces the last good version.++++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, and `0003_design_working_versions.sql` adds the pick, its Bench vote fields, the AI-edit counter and the version table, 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 and OpenVibe.Bench service credentials (the Bench scope now includes `bench.entry.create` so Design can submit the generated variants to the pick-vote round). Checks, HTML/zip export, preview publishing and MCP remain part C.## The Design Arena@@@ -100,6 +102,13 @@ a Bearer write is not cookie-authenticated and is unaffected.| `GET /api/me/theme/history` | cookie | Network `GET /api/themes/me/history` || `POST /api/me/theme/history/:id/restore` | cookie + same-origin | Network's restore; `id` must be a positive integer, else 400 || `POST /api/themes/submit` | cookie + same-origin | Network `POST /api/themes` with `{ name, slug, description, mode, variables, tags }` |++| `POST /api/v1/designs` | none/cookie + same-origin | create three variants `{ prompt, kind, themeId? }`; a guest gets a secret edit key |++| `GET /api/v1/designs/:id` | owner key or cookie | frames, the working frame, the pick and the versions; guest key in `X-Design-Key` or `?key=` |++| `POST /api/v1/designs/:id/pick` | owner key or cookie + same-origin | `{ variantIndex }`; idempotent, one Arena vote |++| `POST /api/v1/designs/:id/edit` | owner key or cookie + same-origin | chat edit `{ instruction }`; one AI run from the picked variant's model, small per-design budget |++| `POST /api/v1/designs/:id/click` | owner key or cookie + same-origin | `{ path, text?, color?, spacing?, align? }`, validated, saves a version |++| `GET /api/v1/designs/:id/versions` | owner key or cookie | the version list with model/provider provenance |++| `POST /api/v1/designs/:id/undo`, `/redo` | owner key or cookie + same-origin | move the working-frame pointer; history stays || `POST /api/v1/arena/rounds` | cookie + same-origin | queue a round `{ category, prompt }` (subject to the two daily budgets) || `GET /api/v1/arena/rounds/:id` | none | one round; entries are blind until Bench reveals them to this viewer || `GET /api/v1/arena/vote[?category=logo]` | cookie | the next blind pair for this person (models hidden) |@@@ -140,7 +149,9 @@ The Network signs the access token (RS256); `server/auth/keys.js` fetches and reSee [.env.example](.env.example). Required in production: `BASE_URL` and `OV_OAUTH_CLIENT_SECRET` (the `design`OAuth client on the Network). Optional: `OV_NETWORK_URL` (default `https://openvibe.network`),`OV_NETWORK_INTERNAL_URL` (default `http://127.0.0.1:4000`), `OV_NETWORK_ISSUER`, `OV_OAUTH_REDIRECT_URI`,--`DESIGN_THEME_CACHE_MS`, `DESIGN_UPSTREAM_TIMEOUT_MS`, `DESIGN_RATE_LIMIT_PER_MIN` and `COOKIE_SECURE`.++`DESIGN_THEME_CACHE_MS`, `DESIGN_UPSTREAM_TIMEOUT_MS`, `DESIGN_RATE_LIMIT_PER_MIN`, `DESIGN_CHAT_EDITS_PER_DESIGN`++(default 5) and `COOKIE_SECURE`. The Bench scope must include `bench.entry.create` in addition to the Arena++capabilities, so Design can fill the pick-vote round with the variants it already generated.## How to run@@@ -32,10 +32,15 @@"Blind voting and Elo: two outputs in random order with models hidden, creator cannot vote, one vote per person per round, K=32, tie=0.5, both_bad counts for neither","Arena pages: /arena (leaderboard and start), /arena/new, /arena/vote, /arena/r/:id with 390 px and 1280 px frames","ADR-033 account export/deletion for a person's rounds, outputs and votes through /internal/events",-- "Tests: the migration, the generation job, the sanitizer and iframe markup, daily limits, blind pairs, one vote, creator refusal, Elo win/tie/both_bad, leaderboard numbers, dataset privacy and account export/erase"++ "Tests: the migration, the generation job, the sanitizer and iframe markup, daily limits, blind pairs, one vote, creator refusal, Elo win/tie/both_bad, leaderboard numbers, dataset privacy and account export/erase",++ "Design Studio pick: one idempotent working frame per design, persisted with its Arena vote (Bench task/round, room-submitted entries, one signed-in person's judgement; a guest pick is saved without a vote)",++ "Design Studio chat edit: POST /api/v1/designs/:id/edit, the picked variant's model via OpenVibe.AI, the previous HTML in context, theme-token re-wrapping, sanitization and the render-time frame CSP; a small per-design paid-AI budget",++ "Design Studio click edit: a render-time iframe overlay reports a validated body > \u2026:nth-of-type path; the floating panel edits text, a theme-token colour, padding and alignment; paths, tags and CSS values are allowlisted server-side and every accepted change is a version",++ "Design Studio versions: migrations/0003_design_working_versions.sql keeps design_picks and an append-only design_versions table with model/provider provenance; undo/redo move a pointer and an edit after undo appends instead of deleting",++ "Tests: pick/vote idempotence, owner and guest-key authorization, chat edit with a fake AI and budget, click-edit safety, CSP after editing, version history/undo/redo and the no-JS page forms"],"notYet": [-- "The pages' design and public/js/studio.js: the main session writes them; today the views are plain placeholders and design.css and studio.js are one-comment files",++ "Design Studio part C: checks on the working frame, HTML/zip export, preview publishing and MCP","A Design service manifest and capabilities in OpenVibe.Contracts: this service has no capability of its own and no other service calls it","Deployment: the deploy files are written, but the service is not deployed yet"],@@@ -56,7 +61,11 @@"Arena generation jobs","Blind voting and Elo","Open Arena dataset",-- "Account export/deletion"++ "Account export/deletion",++ "Design Studio pick and Arena vote",++ "Design Studio chat edit",++ "Design Studio click edit",++ "Design Studio versions and undo/redo"],-- "updated": "2026-10-28"++ "updated": "2026-10-29"}nnew file mode 100644@@@ -0,0 +1,51 @@++-- phase: expand++-- Part B: the chosen working frame, its Arena vote, the per-design AI edit budget and every saved version.++-- The generated JSON variants stay where part A put them; a version is a full HTML document and a pick points at++-- the current one. History is append-only: undo/redo move the pointer, they never delete a version.++++CREATE TABLE design_picks (++ design_id text COLLATE "C" PRIMARY KEY REFERENCES designs (id) ON DELETE CASCADE,++ variant_index integer NOT NULL,++ variant_id text COLLATE "C" NOT NULL,++ voter_subject text COLLATE "C",++ current_seq integer NOT NULL DEFAULT 1,++ redo_stack jsonb NOT NULL DEFAULT '[]'::jsonb,++ chat_edits integer NOT NULL DEFAULT 0,++ bench_round_id text COLLATE "C",++ bench_left_output text COLLATE "C",++ bench_right_output text COLLATE "C",++ bench_judgement_id text COLLATE "C",++ vote_state text COLLATE "C" NOT NULL DEFAULT 'none',++ vote_error text,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT design_picks_variant_index CHECK (variant_index >= 0),++ CONSTRAINT design_picks_current_seq CHECK (current_seq >= 1),++ CONSTRAINT design_picks_chat_edits CHECK (chat_edits >= 0),++ CONSTRAINT design_picks_redo_stack CHECK (jsonb_typeof(redo_stack) = 'array'),++ CONSTRAINT design_picks_vote_state CHECK (vote_state IN ('none', 'guest', 'pending', 'recorded', 'unavailable', 'failed'))++);++++CREATE TABLE design_versions (++ design_id text COLLATE "C" NOT NULL REFERENCES designs (id) ON DELETE CASCADE,++ seq integer NOT NULL,++ variant_index integer NOT NULL,++ variant_id text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL,++ html text NOT NULL,++ instruction text,++ change jsonb,++ model text COLLATE "C",++ provider text COLLATE "C",++ cost_usd numeric(12, 6) NOT NULL DEFAULT 0,++ ms integer NOT NULL DEFAULT 0,++ parent_seq integer,++ created_by text COLLATE "C",++ created_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (design_id, seq),++ CONSTRAINT design_versions_seq CHECK (seq >= 1),++ CONSTRAINT design_versions_kind CHECK (kind IN ('generated', 'chat', 'click')),++ CONSTRAINT design_versions_parent CHECK (parent_seq IS NULL OR parent_seq < seq)++);++++CREATE INDEX design_versions_by_design ON design_versions (design_id, seq);@@@ -296,3 +296,58 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef.dv-toolbar form, .dv-toolbar label { gap: 4px; font-size: .72rem; }}@media (prefers-reduced-motion: reduce) { .dv-frame { transition: none; } .dv-skeleton { animation: none; } }++++/* Part B: the working frame, the chat composer, versions and the click-edit panel. One toolbar stays the only++ floating control bar; the composer and the version list are in the page flow, and the element panel opens only++ after a click inside the working frame. */++.dv-chat { max-width: 1320px; margin: 0 auto 16px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); }++.dv-chat > label { display: block; margin-bottom: 6px; font-size: .78rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); }++.dv-chat-row { display: flex; align-items: center; gap: 8px; }++.dv-chat-row input { flex: 1; min-width: 0; min-height: 42px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-input, var(--bg-primary)); color: var(--text-primary); font: inherit; font-size: .92rem; }++.dv-chat-row button, .dv-element button[type=submit] { min-height: 42px; padding: 0 16px; border: 0; border-radius: 10px; background: var(--accent); color: var(--on-accent); font: inherit; font-weight: 800; cursor: pointer; }++.dv-chat-row button:disabled, .dv-element button[type=submit]:disabled { opacity: .55; cursor: not-allowed; }++.dv-chat-status, .dv-element .dv-status { min-height: 1.3em; margin: 8px 0 0; font-size: .84rem; color: var(--text-muted); }++.dv-chat-status.is-error, .dv-element .dv-status.is-error { color: var(--danger-text, #f87171); }++.dv-chat .dv-fine { margin: 2px 0 0; font-size: .76rem; }++.dv-card[data-picked="true"] .dv-frame { box-shadow: 0 0 0 2px var(--room-strong), 0 16px 38px #0003; }++.dv-card-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; min-height: 38px; padding: 0 4px 10px; }++.dv-card-actions form { margin: 0; }++.dv-card-actions .dv-fine { font-size: .74rem; }++.dv-pick { min-height: 34px; padding: 5px 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-card); color: var(--text-primary); font: inherit; font-size: .8rem; font-weight: 700; cursor: pointer; transition: border-color .15s, background .15s; }++.dv-pick:hover, .dv-pick:focus-visible { border-color: var(--room-strong); background: color-mix(in srgb, var(--room-strong) 12%, var(--bg-card)); outline: none; }++.dv-picked { display: inline-flex; align-items: center; gap: 6px; font-size: .78rem; font-weight: 800; color: var(--room-strong); }++.dv-picked::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--room-strong); }++.dv-versions { max-width: 1320px; margin: 0 auto 92px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); }++.dv-versions summary { display: flex; align-items: center; gap: 8px; min-height: 34px; cursor: pointer; font-weight: 800; font-size: .92rem; }++.dv-versions summary span { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 99px; background: var(--bg-secondary); color: var(--text-muted); font-size: .74rem; font-variant-numeric: tabular-nums; }++.dv-vcontrols { display: flex; gap: 8px; margin: 8px 0; }++.dv-vcontrols form { margin: 0; }++.dv-vcontrols button { min-height: 34px; padding: 4px 14px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-secondary); color: var(--text-primary); font: inherit; font-size: .82rem; font-weight: 700; cursor: pointer; }++.dv-vcontrols button:disabled { opacity: .5; cursor: not-allowed; }++.dv-vlist { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 2px; max-height: 300px; overflow: auto; }++.dv-vlist li { display: flex; gap: 10px; padding: 8px 8px; border-top: 1px solid var(--border); font-size: .84rem; border-radius: 8px; }++.dv-vlist li.is-current { background: color-mix(in srgb, var(--room-strong) 12%, transparent); }++.dv-vseq { min-width: 30px; font-weight: 800; color: var(--text-muted); font-variant-numeric: tabular-nums; }++.dv-vbody { display: flex; flex-direction: column; gap: 2px; min-width: 0; }++.dv-vbody small { color: var(--text-muted); font-size: .76rem; }++.dv-vbody q, .dv-vbody .dv-fine { color: var(--text-secondary); font-size: .8rem; overflow-wrap: anywhere; }++.dv-vnow { display: inline-block; padding: 1px 7px; border-radius: 99px; background: var(--bg-secondary); color: var(--room-strong); font-size: .68rem; font-weight: 800; vertical-align: 1px; }++.dv-element { position: fixed; z-index: 30; right: 24px; bottom: 92px; width: min(340px, calc(100vw - 24px)); max-height: min(68vh, 620px); overflow: auto; padding: 14px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card); box-shadow: 0 18px 50px #0005; }++.dv-element[hidden] { display: none; }++.dv-element-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }++.dv-element-head b { font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: var(--room-strong); }++.dv-element-head button { min-height: 30px; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--text-muted); font: inherit; font-size: .78rem; cursor: pointer; }++.dv-element form { display: grid; gap: 8px; }++.dv-element label { font-size: .76rem; font-weight: 800; color: var(--text-muted); }++.dv-element textarea, .dv-element select { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-input, var(--bg-primary)); color: var(--text-primary); font: inherit; font-size: .86rem; resize: vertical; }++.dv-element .dv-fine { margin: 0; font-size: .76rem; }++.dv-element button[type=submit] { width: 100%; }++@media (max-width: 700px) {++ .dv-chat { margin-bottom: 12px; padding: 10px 12px; }++ .dv-chat-row { flex-wrap: wrap; }++ .dv-chat-row button { width: 100%; }++ .dv-card-actions { min-height: 34px; }++ .dv-versions { margin-bottom: 104px; }++ .dv-element { left: 12px; right: 12px; bottom: calc(var(--ovnav-appbar-h, 56px) + 76px); width: auto; max-height: 56vh; }++}++@media (prefers-reduced-motion: reduce) { .dv-pick { transition: none; } }@@@ -10,7 +10,27 @@}const canvas = document.querySelector('.dv-canvas');if (!canvas) return;++ const designId = canvas.dataset.id;++ const designKey = canvas.dataset.key;++ const picked = canvas.dataset.picked == null || canvas.dataset.picked === '' ? -1 : Number(canvas.dataset.picked);++ const status = document.getElementById('dv-status');++ const say = (text, isError = false) => {++ if (!status) return;++ status.textContent = text || '';++ status.classList.toggle('is-error', Boolean(isError));++ };++ const api = (path, body) => fetch(`/api/v1/designs/${encodeURIComponent(designId)}${path}`, {++ method: 'POST',++ headers: { 'content-type': 'application/json', ...(designKey ? { 'x-design-key': designKey } : {}) },++ body: JSON.stringify(body || {}),++ });++ const problem = async (response) => {++ try { const body = await response.json(); return body.detail || body.title || `Request failed (${response.status}).`; }++ catch { return `Request failed (${response.status}).`; }++ };const frames = () => [...canvas.querySelectorAll('.dv-card iframe')];++++ // ── Device widths and theme repainting ──const device = document.getElementById('dv-device');device.value = '390';function resize() {@@@ -30,31 +50,150 @@let cssByTheme = {};try { cssByTheme = JSON.parse(document.getElementById('dv-theme-css').textContent); } catch { /* form still works */ }const themeForm = document.getElementById('dv-theme-form');++ const themeSelect = document.getElementById('dv-theme');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];++ const css = cssByTheme[themeSelect.value];if (css) frame.contentWindow.postMessage({ type: 'design-theme', css }, '*');}));++ themeSelect.addEventListener('change', () => themeForm.requestSubmit());themeForm.addEventListener('submit', async event => {event.preventDefault();-- const themeId = document.getElementById('dv-theme').value;++ const themeId = themeSelect.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.');++ const response = await api('/theme', { themeId });++ if (!response.ok) throw new Error(await problem(response));++ } catch (error) {++ say(error.message, true);++ }++ });++++ // ── Pick one frame: one Arena vote, idempotent on the server ──++ document.getElementById('dv-rail').addEventListener('submit', async event => {++ const form = event.target.closest('form');++ const button = form && form.querySelector('.dv-pick');++ if (!button) return;++ event.preventDefault();++ const variantIndex = Number(button.dataset.pick);++ button.disabled = true;++ say('Choosing this frame…');++ try {++ const response = await api('/pick', { variantIndex });++ if (!response.ok) throw new Error(await problem(response));++ location.reload();++ } catch (error) {++ button.disabled = false;++ say(error.message, true);++ }++ });++++ // ── Chat edit: progress and errors keep the last good version on screen ──++ const chatForm = document.getElementById('dv-chat-form');++ chatForm.addEventListener('submit', async event => {++ event.preventDefault();++ const input = document.getElementById('dv-instruction');++ const button = chatForm.querySelector('button[type=submit]');++ const editStatus = document.getElementById('dv-edit-status');++ const instruction = input.value.trim();++ const note = (text, isError = false) => {++ editStatus.textContent = text || '';++ editStatus.classList.toggle('is-error', Boolean(isError));++ };++ if (!instruction) { note('Describe the change first.', true); input.focus(); return; }++ button.disabled = true;++ input.disabled = true;++ note('Editing the working frame… this can take a moment.');++ try {++ const response = await api('/edit', { instruction });++ if (!response.ok) throw new Error(await problem(response));++ const body = await response.json();++ note(`Saved as version ${body.version ? body.version.seq : '—'}. Reloading…`);++ location.reload();++ } catch (error) {++ note(error.message, true);++ button.disabled = false;++ input.disabled = false;++ }++ });++++ // ── Click edit: validate the iframe message before trusting any field ──++ const panel = document.getElementById('dv-element');++ const panelForm = document.getElementById('dv-element-form');++ const elementText = document.getElementById('dv-element-text');++ const elementNote = document.getElementById('dv-element-note');++ const elementColor = document.getElementById('dv-element-color');++ const elementSpacing = document.getElementById('dv-element-spacing');++ const elementAlign = document.getElementById('dv-element-align');++ const elementStatus = document.getElementById('dv-element-status');++ const elementClose = document.getElementById('dv-element-close');++ const pathPattern = /^body(?: > [a-z][a-z0-9-]*:nth-of-type\(\d{1,4}\))*$/;++ let selection = null;++ const panelSay = (text, isError = false) => {++ elementStatus.textContent = text || '';++ elementStatus.classList.toggle('is-error', Boolean(isError));++ };++ function closePanel() {++ panel.hidden = true;++ selection = null;++ }++ elementClose.addEventListener('click', closePanel);++ document.addEventListener('keydown', event => { if (event.key === 'Escape' && !panel.hidden) closePanel(); });++++ window.addEventListener('message', event => {++ if (picked < 0 || !event.data || typeof event.data !== 'object' || Array.isArray(event.data)) return;++ const data = event.data;++ if (data.type !== 'design-select') return;++ if (event.origin !== 'null' && event.origin !== '') return;++ const allowed = new Set(['type', 'path', 'tag', 'leaf', 'text']);++ if (!Object.keys(data).every(key => allowed.has(key))) return;++ if (typeof data.path !== 'string' || data.path.length > 400 || !pathPattern.test(data.path)) return;++ if (typeof data.tag !== 'string' || !/^[a-z][a-z0-9-]*$/.test(data.tag) || data.tag.length > 32) return;++ if (typeof data.leaf !== 'boolean' || typeof data.text !== 'string' || data.text.length > 400) return;++ const target = frames().find(frame => frame.contentWindow === event.source);++ if (!target || Number(target.closest('.dv-card').dataset.index) !== picked) return;++ selection = { path: data.path, tag: data.tag, leaf: data.leaf, text: data.text };++ document.getElementById('dv-element-tag').textContent = data.tag;++ elementText.value = data.text;++ elementText.disabled = !data.leaf;++ elementText.hidden = !data.leaf;++ elementNote.hidden = data.leaf;++ elementColor.value = '';++ elementSpacing.value = '';++ elementAlign.value = '';++ panelSay('');++ panel.hidden = false;++ if (data.leaf) elementText.focus(); else elementColor.focus();++ });++++ panelForm.addEventListener('submit', async event => {++ event.preventDefault();++ if (!selection) return;++ const body = { path: selection.path };++ if (elementText.value !== selection.text) body.text = elementText.value;++ if (elementColor.value) body.color = elementColor.value;++ if (elementSpacing.value) body.spacing = elementSpacing.value;++ if (elementAlign.value) body.align = elementAlign.value;++ if (Object.keys(body).length === 1) { panelSay('Choose at least one change.', true); return; }++ const button = panelForm.querySelector('button[type=submit]');++ button.disabled = true;++ panelSay('Saving a new version…');++ try {++ const response = await api('/click', body);++ if (!response.ok) throw new Error(await problem(response));++ const saved = await response.json();++ panelSay(`Saved as version ${saved.version ? saved.version.seq : '—'}. Reloading…`);++ location.reload();} catch (error) {-- document.getElementById('dv-status').textContent = error.message;++ panelSay(error.message, true);++ button.disabled = false;}});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 url = `/api/v1/designs/${encodeURIComponent(designId)}${designKey ? `?key=${encodeURIComponent(designKey)}` : ''}`;const initial = [...canvas.querySelectorAll('.dv-card')].map(card => card.dataset.status).join(',');const timer = setInterval(async () => {try {@@@ -66,7 +66,7 @@ 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 designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, 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 })@@@ -15,6 +15,7 @@ const BENCH_CAPABILITIES = Object.freeze(['bench.round.create','bench.round.read','bench.judgement.cast',++ 'bench.entry.create','bench.board.read',].join(' '));@@@ -115,6 +116,19 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = consolereturn { task, round: { ...out.round, task_class: task.task_class, prompt: task.prompt } };}++ /**++ * A round the room fills with its own entries (Bench's POST /rounds/:id/entries). It is created with the++ * service token, so the person who later chooses between the room's entries is not the round's creator and++ * Bench accepts their one vote. The service token needs bench.round.create and bench.entry.create.++ */++ async function createRoomRound({ category, prompt, offers = null }) {++ const task = await createTask({ category, prompt });++ const body = {};++ if (offers) body.offers = offers;++ const out = await request('POST', `/api/v1/tasks/${encodeURIComponent(task.id)}/rounds`, { body });++ return { task, round: { ...out.round, task_class: task.task_class, prompt: task.prompt } };++ }++async function getTask(id) {const out = await request('GET', `/api/v1/tasks/${encodeURIComponent(id)}`);return out.task;@@@ -180,6 +194,7 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = consolekindForCategory,createTask,createRound,++ createRoomRound,getRound,nextPair,castVote,@@@ -70,7 +70,12 @@ function load(env = process.env) {url: trim(env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230'),publicUrl: trim(env.OV_BENCH_URL || 'https://bench.openvibe.services'),audience: env.DESIGN_BENCH_AUDIENCE || 'openvibe.bench',-- scope: env.DESIGN_BENCH_SCOPE || 'bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.board.read',++ scope: env.DESIGN_BENCH_SCOPE || 'bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.entry.create bench.board.read',++ },++++ // Design Studio part B: a small per-design AI edit budget (click edits and undo do not spend AI).++ studio: {++ chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),},// Arena budgets and jobs. Both limits are per UTC day; the drill flag keeps the worker from starting.nnew file mode 100644@@@ -0,0 +1,286 @@++'use strict';++++/**++ * Click edits: turn a clicked element path into one safe change, server-side.++ *++ * The iframe reports `body > section:nth-of-type(1) > h1:nth-of-type(1)`; nothing else about the document is++ * trusted. This module finds that element in the saved HTML, accepts only the four changes the panel offers++ * (text, a theme token colour, a spacing preset and an alignment), and writes an escaped text node and a rebuilt++ * inline `style` attribute. It never takes an attribute name, a CSS property or a CSS value from the client, so a++ * path can select an element but cannot add a tag, an event handler, a URL or a fetch.++ */++++const PATH_MAX = 400;++const DEPTH_MAX = 12;++const TEXT_MAX = 1000;++const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);++const RAW_TEXT = new Set(['style', 'textarea', 'title', 'xmp']);++// Tags a click edit may never target: they are document plumbing or active content, and the sanitizer has++// already removed most of them. `button` and `a` stay allowed: rewriting their label is a normal design edit.++const UNSAFE_TAGS = new Set(['script', 'style', 'html', 'head', 'title', 'meta', 'link', 'base', 'iframe', 'object',++ 'embed', 'applet', 'template', 'noscript', 'svg', 'math', 'form', 'input', 'textarea', 'select']);++const TOKEN_ALIASES = Object.freeze(['--bg-card', '--bg-secondary', '--text-muted', '--accent', '--on-accent', '--border']);++const SPACING = Object.freeze({ none: '0', sm: '8px', md: '16px', lg: '24px' });++const ALIGN = Object.freeze(['left', 'center', 'right']);++const CHANGED_PROPERTIES = Object.freeze(['color', 'padding', 'text-align']);++++class EditError extends Error {++ constructor(status, code, detail) {++ super(detail);++ this.name = 'EditError';++ this.status = status;++ this.code = code;++ this.detail = detail;++ }++}++++const bad = (detail, code = 'design.invalid_change') => new EditError(422, code, detail);++++function tagEnd(s, start) {++ let quote = null;++ for (let i = start + 1; i < s.length; i++) {++ const c = s[i];++ if (quote) { if (c === quote) quote = null; }++ else if (c === '"' || c === "'") quote = c;++ else if (c === '>') return i;++ }++ return -1;++}++++const pathRegex = /^body(?: > [a-z][a-z0-9-]*:nth-of-type\(\d{1,4}\))*$/;++++/** The requested path, or 422. */++function readPath(raw) {++ if (typeof raw !== 'string' || !raw || raw.length > PATH_MAX) throw bad('Send the clicked element path.');++ if (!pathRegex.test(raw)) throw bad('That element path is not allowed.');++ const segments = raw.split(' > ');++ if (segments.length - 1 > DEPTH_MAX) throw bad('That element is nested too deeply.');++ const tail = segments.slice(1).map((seg) => {++ const m = /^([a-z][a-z0-9-]*):nth-of-type\((\d{1,4})\)$/.exec(seg);++ if (!m) throw bad('That element path is not allowed.');++ const tag = m[1];++ const index = Number(m[2]);++ if (UNSAFE_TAGS.has(tag)) throw bad(`A ${tag} element cannot be edited.`, 'design.unsafe_element');++ if (index < 1 || index > 500) throw bad('That element path is not allowed.');++ return { tag, index };++ });++ return { raw, tail };++}++++/** A flat tree walk that mirrors the browser's element order closely enough for stable nth-of-type paths. */++function tree(html) {++ const root = { tag: '#root', children: [], parent: null, start: 0, openEnd: 0, closeStart: null, closeEnd: null, index: 0 };++ const stack = [root];++ const closeImplicit = (tag, at) => {++ const top = stack[stack.length - 1];++ const closes = (tag === 'p' && top.tag === 'p') || (tag === 'li' && top.tag === 'li')++ || ((tag === 'tr') && top.tag === 'tr')++ || ((tag === 'td' || tag === 'th') && (top.tag === 'td' || top.tag === 'th'))++ || (tag === 'option' && top.tag === 'option')++ || ((tag === 'thead' || tag === 'tbody' || tag === 'tfoot') && ['thead', 'tbody', 'tfoot'].includes(top.tag));++ if (!closes) return;++ stack.pop();++ if (top.closeStart == null) { top.closeStart = at; top.closeEnd = at; }++ };++ let i = 0;++ while (i < html.length) {++ const lt = html.indexOf('<', i);++ if (lt === -1) break;++ const next = html[lt + 1] || '';++ if (next === '!' || next === '?') {++ const end = html.indexOf('>', lt);++ i = end === -1 ? html.length : end + 1;++ continue;++ }++ if (next === '/') {++ const m = /^<\/\s*([a-z][a-z0-9-]*)/i.exec(html.slice(lt, lt + 80));++ if (!m) { i = lt + 1; continue; }++ const tag = m[1].toLowerCase();++ const end = tagEnd(html, lt);++ if (end === -1) break;++ let k = stack.length - 1;++ while (k > 0 && stack[k].tag !== tag) k--;++ if (k > 0) {++ for (let j = stack.length - 1; j > k; j--) {++ const el = stack[j];++ if (el.closeStart == null) { el.closeStart = lt; el.closeEnd = lt; }++ }++ const el = stack[k];++ if (el.closeStart == null) { el.closeStart = lt; el.closeEnd = end + 1; }++ stack.length = k;++ }++ i = end + 1;++ continue;++ }++ if (!/[a-z]/i.test(next)) { i = lt + 1; continue; }++ const end = tagEnd(html, lt);++ if (end === -1) break;++ const raw = html.slice(lt, end + 1);++ const m = /^<\s*([a-z][a-z0-9-]*)/i.exec(raw);++ if (!m) { i = end + 1; continue; }++ const tag = m[1].toLowerCase();++ closeImplicit(tag, lt);++ const parent = stack[stack.length - 1];++ const index = parent.children.filter((c) => c.tag === tag).length + 1;++ const el = { tag, start: lt, openEnd: end + 1, closeStart: null, closeEnd: null, parent, children: [], index };++ parent.children.push(el);++ if (!VOID.has(tag) && !/\/\s*>$/.test(raw)) {++ if (RAW_TEXT.has(tag)) {++ const close = new RegExp(`</\\s*${tag}\\s*>`, 'i');++ const rest = html.slice(end + 1);++ const found = close.exec(rest);++ if (found) {++ el.closeStart = end + 1 + found.index;++ el.closeEnd = el.closeStart + found[0].length;++ i = el.closeEnd;++ continue;++ }++ }++ stack.push(el);++ }++ i = end + 1;++ }++ // Mark every unclosed element at the end of the document as implicitly closed at the end.++ for (let j = stack.length - 1; j > 0; j--) {++ const el = stack[j];++ if (el.closeStart == null) { el.closeStart = html.length; el.closeEnd = html.length; }++ }++ return root;++}++++function pathOf(el) {++ const parts = [];++ let cur = el;++ while (cur && cur.tag !== '#root') {++ if (cur.tag === 'body') { parts.unshift('body'); break; }++ if (cur.tag === 'html' || cur.tag === 'head') return null;++ parts.unshift(`${cur.tag}:nth-of-type(${cur.index})`);++ cur = cur.parent;++ }++ return parts[0] === 'body' ? parts.join(' > ') : null;++}++++function findBody(node) {++ for (const child of node.children) {++ if (child.tag === 'body') return child;++ const found = findBody(child);++ if (found) return found;++ }++ return null;++}++++function findElement(html, rawPath) {++ const path = readPath(rawPath);++ const root = tree(html);++ const body = findBody(root);++ if (!body) throw bad('That element is not in the saved document.', 'design.path_not_found');++ let current = body;++ for (let i = 0; i < path.tail.length; i++) {++ const segment = path.tail[i];++ const found = current.children.find((el) => el.tag === segment.tag && el.index === segment.index);++ if (!found) {++ // A browser inserts tbody when the saved table has a tr directly under it; the overlay path includes++ // the virtual wrapper, so skip it while there is a real element after it.++ if (segment.tag === 'tbody' && current.tag === 'table' && i < path.tail.length - 1) continue;++ throw bad('That element is not in the saved document.', 'design.path_not_found');++ }++ current = found;++ }++ if (!pathOf(current)) throw bad('That element cannot be edited.', 'design.unsafe_element');++ return { path, element: current, body };++}++++/** A style attribute rebuilt from safe declarations only; target properties are replaced, the rest are kept. */++function safeDeclaration(property, value) {++ return /^-?[a-z][a-z0-9-]*$/.test(property)++ && typeof value === 'string' && value.length <= 200++ && !/[<>\\{}]/.test(value)++ && !/url\s*\(|expression|javascript\s*:|@import|behavior\s*:|-moz-binding/i.test(value);++}++++function parseStyle(style) {++ return String(style || '').split(';').map((part) => part.trim()).filter(Boolean).map((part) => {++ const colon = part.indexOf(':');++ if (colon < 1) return null;++ const property = part.slice(0, colon).trim().toLowerCase();++ const value = part.slice(colon + 1).trim();++ return safeDeclaration(property, value) ? { property, value } : null;++ }).filter(Boolean);++}++++function changedStyle(existing, changes) {++ const replacing = new Set(CHANGED_PROPERTIES.filter((p) => changes[p] != null));++ const kept = parseStyle(existing).filter((decl) => !replacing.has(decl.property));++ return kept.concat(CHANGED_PROPERTIES.filter((p) => changes[p] != null).map((property) => ({ property, value: changes[property] })))++ .map((decl) => `${decl.property}:${decl.value}`).join(';');++}++++function rewriteOpeningTag(raw, changes) {++ const existing = /\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(raw);++ if (Object.keys(changes).length === 0) return raw;++ const style = changedStyle(existing ? (existing[1] != null ? existing[1] : existing[2]) : '', changes);++ const value = style.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');++ const attribute = ` style="${value}"`;++ if (existing) {++ return raw.replace(/\sstyle\s*=\s*(?:"[^"]*"|'[^']*')/i, attribute);++ }++ return /\/\s*>$/.test(raw) ? raw.replace(/\/\s*>$/, `${attribute}/>`) : raw.replace(/\s*>$/, `${attribute}>`);++}++++const escapeText = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');++++/** Validate one change against the theme; returns only known properties with server-owned values. */++function validateChange({ path, text, color, spacing, align }, variables) {++ const requested = readPath(path);++ const changes = {};++ let any = false;++ if (text !== undefined && text !== null) {++ if (typeof text !== 'string' || text.length > TEXT_MAX) throw bad(`The text must be at most ${TEXT_MAX} characters.`);++ if (/[\u0000]/.test(text)) throw bad('That text is not allowed.');++ changes.text = text;++ any = true;++ }++ if (color !== undefined && color !== null && color !== '') {++ if (typeof color !== 'string' || !/^--[a-z][a-z0-9-]{1,40}$/.test(color)) throw bad('Pick a theme colour token.', 'design.unsafe_css');++ const value = variables && Object.prototype.hasOwnProperty.call(variables, color) ? String(variables[color]) : null;++ const colourValue = value != null && /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\()/i.test(value);++ if (!colourValue && !TOKEN_ALIASES.includes(color)) throw bad('That colour token is not part of this theme.', 'design.unsafe_css');++ changes.color = `var(${color})`;++ any = true;++ }++ if (spacing !== undefined && spacing !== null && spacing !== '') {++ if (typeof spacing !== 'string' || !Object.prototype.hasOwnProperty.call(SPACING, spacing)) throw bad('Pick a spacing preset.', 'design.unsafe_css');++ changes.padding = Object.prototype.hasOwnProperty.call(SPACING, spacing) ? SPACING[spacing] : null;++ any = true;++ }++ if (align !== undefined && align !== null && align !== '') {++ if (typeof align !== 'string' || !ALIGN.includes(align)) throw bad('Pick left, centre or right.', 'design.unsafe_css');++ changes['text-align'] = align;++ any = true;++ }++ if (!any) throw bad('Choose at least one change.');++ return { ...requested, path: requested.raw, changes, spacing: spacing || null };++}++++function applyChange(html, change) {++ const found = findElement(html, change.path);++ const { element } = found;++ if (change.changes.text !== undefined && element.children.length > 0)++ throw bad('This element contains other elements, so its text cannot be replaced. Pick the text element inside it.', 'design.not_leaf');++ let out = String(html);++ if (change.changes.text !== undefined) {++ if (element.closeStart == null) throw bad('That element is not closed, so it cannot be edited.', 'design.invalid_change');++ out = `${out.slice(0, element.openEnd)}${escapeText(change.changes.text)}${out.slice(element.closeStart)}`;++ }++ const styleChanges = {};++ for (const property of CHANGED_PROPERTIES) if (change.changes[property] !== undefined) styleChanges[property] = change.changes[property];++ if (Object.keys(styleChanges).length) {++ const open = out.slice(element.start, element.openEnd);++ const rewritten = rewriteOpeningTag(open, styleChanges);++ out = `${out.slice(0, element.start)}${rewritten}${out.slice(element.openEnd)}`;++ }++ return out;++}++++module.exports = { EditError, readPath, findElement, tree, pathOf, validateChange, applyChange, changedStyle,++ escapeText, TOKEN_ALIASES, SPACING, ALIGN, UNSAFE_TAGS, CHANGED_PROPERTIES, TEXT_MAX };@@@ -2,11 +2,14 @@const crypto = require('crypto');const { sanitizeHtml, MAX_BYTES } = require('./arena/sanitize');++const { validateChange, applyChange } = require('./design-edit');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 });++const CHAT_INSTRUCTION_MAX = 1000;++const CHAT_EDITS_DEFAULT = 5;function tokenCss(variables = {}) {return Object.entries(variables)@@@ -62,12 +65,19 @@ function wrap(html, 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>');++ // Click edits address elements by a `body > …` path, so every stored document has an explicit body.++ if (!/<body\b/i.test(clean)) clean = clean.replace(/<\/head\s*>/i, '</head><body>');++ if (!/<body\b/i.test(clean)) clean = clean.replace(/<html\b[^>]*>/i, '$&<body>');++ if (!/<\/body\s*>/i.test(clean)) {++ clean = /<\/html\s*>/i.test(clean) ? clean.replace(/<\/html\s*>/i, '</body></html>') : `${clean}</body>`;++ }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() }) {++function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now() }) {const db = s.db;++ const chatEditLimit = Math.max(1, Number(config.studio && config.studio.chatEditsPerDesign) || CHAT_EDITS_DEFAULT);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',@@@ -133,11 +143,286 @@ function createDesigns({ s, ai, catalog, log = console, now = () => Date.now() }}}));}++++ // ── The working frame, its versions and the Arena vote ──────────────────++ const pickRow = (id) => db.maybe('SELECT * FROM design_picks WHERE design_id=$1', [id]);++ const versionRow = (id, seq) => db.maybe('SELECT * FROM design_versions WHERE design_id=$1 AND seq=$2', [id, seq]);++ const versionPublic = (v, currentSeq) => ({++ seq: Number(v.seq), kind: v.kind, instruction: v.instruction || null,++ change: v.change && typeof v.change === 'object' ? v.change : null,++ model: v.model || null, provider: v.provider || null,++ costUsd: Number(v.cost_usd || 0), ms: Number(v.ms || 0),++ parentSeq: v.parent_seq == null ? null : Number(v.parent_seq),++ createdAt: v.created_at, current: Number(v.seq) === Number(currentSeq),++ });++ function pickPublic(p, current) {++ if (!p) return null;++ const working = current ? versionPublic(current, p.current_seq) : null;++ return {++ variantIndex: Number(p.variant_index), variantId: p.variant_id,++ currentSeq: Number(p.current_seq), chatEdits: Number(p.chat_edits || 0), chatEditLimit,++ vote: { state: p.vote_state, roundId: p.bench_round_id || null, judgementId: p.bench_judgement_id || null,++ error: p.vote_error || null },++ working, createdAt: p.created_at, updatedAt: p.updated_at,++ canUndo: Boolean(current && current.parent_seq != null),++ canRedo: Array.isArray(p.redo_stack) && p.redo_stack.length > 0,++ };++ }++ const categoryOf = (kind) => kind === 'component' ? 'logo' : kind === 'screen' ? 'mobile_app' : 'landing';++ const pickRunId = (v) => `dsp_${v.id}`;++++ /** Record the pick once: create the Arena round, submit the variants as room entries and cast one vote. */++ async function recordPickVote(d, subject, token) {++ let p = await pickRow(d.id);++ if (!p) return { state: 'none' };++ if (p.vote_state === 'recorded') return { state: 'recorded', roundId: p.bench_round_id, judgementId: p.bench_judgement_id };++ if (!subject || !token) {++ await db.exec("UPDATE design_picks SET vote_state='guest', updated_at=$2 WHERE design_id=$1 AND vote_state<>'recorded'", [d.id, s.iso()]);++ return { state: 'guest', detail: 'Sign in to count this pick as one Arena vote.' };++ }++ if (!bench || !bench.enabled || typeof bench.createRoomRound !== 'function') {++ await db.exec("UPDATE design_picks SET vote_state='unavailable', updated_at=$2 WHERE design_id=$1 AND vote_state<>'recorded'", [d.id, s.iso()]);++ return { state: 'unavailable', detail: 'The Arena is not configured; the working frame is still saved.' };++ }++ // One in-flight vote per design: a double click must not create two Bench rounds. A pending claim older++ // than five minutes is retried, so a process that stopped halfway does not lose the vote forever.++ const claimed = await db.maybe(`UPDATE design_picks SET vote_state='pending', updated_at=$2++ WHERE design_id=$1 AND (vote_state IN ('none','guest','unavailable','failed')++ OR (vote_state='pending' AND updated_at < $2::timestamptz - interval '5 minutes'))++ RETURNING *`, [d.id, s.iso()]);++ if (!claimed) {++ const now = await pickRow(d.id);++ if (now && now.vote_state === 'recorded') return { state: 'recorded', roundId: now.bench_round_id, judgementId: now.bench_judgement_id };++ return { state: 'pending', detail: 'The Arena vote is being recorded.' };++ }++ p = claimed;++ try {++ let roundId = p.bench_round_id;++ if (!roundId) {++ const out = await bench.createRoomRound({ category: categoryOf(d.kind), prompt: d.prompt });++ roundId = out && out.round && out.round.id;++ if (!roundId) throw new Error('OpenVibe.Bench did not return a round.');++ await db.exec("UPDATE design_picks SET bench_round_id=$2, vote_state='pending', vote_error=NULL, updated_at=$3 WHERE design_id=$1", [d.id, roundId, s.iso()]);++ p = await pickRow(d.id);++ }++ if (!p.bench_left_output || !p.bench_right_output) {++ const round = await bench.getRound(roundId);++ const seen = new Map(((round && round.entries) || []).map((e) => [e.run_id, e.id]));++ const done = d.variants.map((v, index) => ({ v, index })).filter((x) => x.v.status === 'done');++ const entries = [];++ for (const { v, index } of done) {++ let entryId = seen.get(pickRunId(v));++ if (!entryId) {++ const added = await bench.addEntry({ roundId, provider: v.provider, model: v.model,++ route: { provider: v.provider, model: v.model }, payload: { html: v.html },++ costUsd: Number(v.costUsd || 0), ms: Number(v.ms || 0), tokens: Number(v.tokens || 0), runId: pickRunId(v) });++ entryId = added && added.entry ? added.entry.id : (added && added.id);++ }++ if (entryId) entries.push({ index, id: entryId });++ }++ const chosen = entries.find((e) => e.index === Number(p.variant_index));++ const other = entries.find((e) => e.index !== Number(p.variant_index));++ if (!chosen || !other) {++ await db.exec("UPDATE design_picks SET vote_state='unavailable', vote_error='A pick vote needs two finished variants.', updated_at=$2 WHERE design_id=$1", [d.id, s.iso()]);++ return { state: 'unavailable', detail: 'A pick vote needs two finished variants.' };++ }++ await db.exec("UPDATE design_picks SET bench_left_output=$2, bench_right_output=$3, vote_state='pending', vote_error=NULL, updated_at=$4 WHERE design_id=$1", [d.id, chosen.id, other.id, s.iso()]);++ p = await pickRow(d.id);++ }++ if (!p.bench_judgement_id) {++ const out = await bench.castVote({ roundId, token, pair: { left: p.bench_left_output, right: p.bench_right_output }, winner: 'left' });++ const judgementId = out && out.judgement ? out.judgement.id : null;++ await db.exec("UPDATE design_picks SET vote_state='recorded', bench_judgement_id=$2, vote_error=NULL, updated_at=$3 WHERE design_id=$1", [d.id, judgementId, s.iso()]);++ return { state: 'recorded', roundId, judgementId };++ }++ await db.exec("UPDATE design_picks SET vote_state='recorded', vote_error=NULL, updated_at=$2 WHERE design_id=$1", [d.id, s.iso()]);++ return { state: 'recorded', roundId, judgementId: p.bench_judgement_id };++ } catch (err) {++ if (err && err.code === 'arena.already_voted') {++ await db.exec("UPDATE design_picks SET vote_state='recorded', vote_error=NULL, updated_at=$2 WHERE design_id=$1", [d.id, s.iso()]);++ return { state: 'recorded', roundId: p.bench_round_id || null, detail: 'This pick was already counted.' };++ }++ const detail = String((err && (err.detail || err.message)) || err).slice(0, 300);++ await db.exec("UPDATE design_picks SET vote_state='failed', vote_error=$2, updated_at=$3 WHERE design_id=$1", [d.id, detail, s.iso()]);++ log.warn('[OpenVibe.Design] pick vote:', detail);++ return { state: 'failed', detail: 'The working frame is saved; the Arena vote could not be recorded.' };++ }++ }++++ async function ensureBaseVersion(d, p, variant) {++ const count = Number(await db.value('SELECT COUNT(*) FROM design_versions WHERE design_id=$1', [d.id]));++ if (count > 0) return;++ await db.exec(`INSERT INTO design_versions(design_id,seq,variant_index,variant_id,kind,html,model,provider,cost_usd,ms,parent_seq,created_by,created_at)++ VALUES($1,1,$2,$3,'generated',$4,$5,$6,$7,$8,NULL,$9,$10::timestamptz) ON CONFLICT (design_id,seq) DO NOTHING`,++ [d.id, Number(p.variant_index), p.variant_id, variant.html || '', variant.model || null, variant.provider || null,++ Number(variant.costUsd || 0), Number(variant.ms || 0), d.owner || 'guest', s.iso()]);++ await db.exec('UPDATE design_picks SET current_seq=1, updated_at=$2 WHERE design_id=$1', [d.id, s.iso()]);++ }++++ async function addVersion(d, p, data) {++ // The sequence is allocated inside the INSERT, so two accepted edits at once cannot pick the same seq.++ const row = await db.maybe(`INSERT INTO design_versions(design_id,seq,variant_index,variant_id,kind,html,instruction,change,model,provider,cost_usd,ms,parent_seq,created_by,created_at)++ SELECT $1, COALESCE(MAX(seq),0)+1, $2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,$11,$12,$13,$14::timestamptz++ FROM design_versions WHERE design_id=$1 RETURNING *`,++ [d.id, Number(p.variant_index), p.variant_id, data.kind, data.html, data.instruction || null,++ data.change ? JSON.stringify(data.change) : null, data.model || null, data.provider || null,++ Number(data.costUsd || 0), Number(data.ms || 0), Number(p.current_seq), data.createdBy || (d.owner || 'guest'), s.iso()]);++ await db.exec("UPDATE design_picks SET current_seq=$2, redo_stack='[]'::jsonb, updated_at=$3 WHERE design_id=$1", [d.id, Number(row.seq), s.iso()]);++ return row;++ }++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) };++ const p = await pickRow(id);++ const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];++ const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;++ const variants = d.variants.map((v, index) => {++ if (p && index === Number(p.variant_index) && current) {++ return { ...v, html: wrap(current.html, selected.variables || {}), currentSeq: Number(current.seq), edited: current.kind !== 'generated' };++ }++ return v.status === 'done' ? { ...v, html: wrap(v.html, selected.variables || {}) } : v;++ });++ return { design: publicRow(d), variants, pick: pickPublic(p, current), versions: versions.map(v => versionPublic(v, p ? p.current_seq : 1)) };++ }++++ async function pick({ id, variantIndex, variantId, owner, key, subject = null, token = null }) {++ const d = await row(id, owner, key);++ let index = variantIndex == null || variantIndex === '' ? null : Number(variantIndex);++ if (variantId) {++ const found = d.variants.findIndex(v => String(v.id) === String(variantId));++ if (found === -1) throw fail(422, 'design.invalid_variant', 'That variant is not part of this design.');++ if (index != null && index !== found) throw fail(422, 'design.invalid_variant', 'That variant is not part of this design.');++ index = found;++ }++ if (index == null || !Number.isInteger(index) || index < 0 || index >= d.variants.length)++ throw fail(422, 'design.invalid_variant', 'Pick one of the generated variants.');++ const variant = d.variants[index];++ if (!variant || variant.status !== 'done') throw fail(409, 'design.variant_not_ready', 'That frame is not ready yet.');++ const existing = await pickRow(id);++ if (existing) {++ if (Number(existing.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');++ const vote = await recordPickVote(d, subject, token);++ const data = await read(id, owner, key);++ return { ...data, vote, idempotent: true };++ }++ const inserted = await db.maybe(`INSERT INTO design_picks(design_id,variant_index,variant_id,voter_subject,created_at,updated_at)++ VALUES($1,$2,$3,$4,$5::timestamptz,$5::timestamptz) ON CONFLICT (design_id) DO NOTHING RETURNING *`,++ [id, index, variant.id, subject || null, s.iso()]);++ const p = inserted || await pickRow(id);++ if (!p || Number(p.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');++ await ensureBaseVersion(d, p, variant);++ const vote = await recordPickVote(d, subject, token);++ const data = await read(id, owner, key);++ return { ...data, vote, idempotent: !inserted };++ }++++ async function currentForEdit(id, owner, key) {++ const d = await row(id, owner, key);++ const p = await pickRow(id);++ if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before editing it.');++ const selected = await theme(d.theme_id);++ const variant = d.variants[Number(p.variant_index)];++ if (!variant || variant.status !== 'done') throw fail(409, 'design.variant_not_ready', 'The working frame is not ready yet.');++ let current = await versionRow(id, p.current_seq);++ if (!current) {++ await ensureBaseVersion(d, p, variant);++ current = await versionRow(id, p.current_seq);++ }++ return { d, p, selected, variant, current };++ }++++ async function chatEdit({ id, instruction, owner, key, subject = null }) {++ instruction = String(instruction == null ? '' : instruction).trim();++ if (!instruction || instruction.length > CHAT_INSTRUCTION_MAX)++ throw fail(422, 'design.invalid_instruction', `Write an instruction up to ${CHAT_INSTRUCTION_MAX} characters.`);++ const { d, p, selected, variant } = await currentForEdit(id, owner, key);++ const used = await db.maybe('UPDATE design_picks SET chat_edits=chat_edits+1, updated_at=$2 WHERE design_id=$1 AND chat_edits < $3 RETURNING chat_edits',++ [d.id, s.iso(), chatEditLimit]);++ if (!used) throw fail(429, 'design.edit_limit', `This design has used its ${chatEditLimit} AI edits. Click edits and undo still work.`);++ const current = await versionRow(id, p.current_seq);++ const variables = selected.variables || {};++ const names = Object.keys(variables).join(', ');++ const prompt = [++ 'Edit one existing generated design. Return one complete HTML document and nothing else.',++ `The person's instruction: ${instruction}`,++ 'The current document is between the markers. Keep its theme, its content and its structure unless the instruction asks otherwise.',++ `<<<CURRENT_DESIGN\n${String(current.html || variant.html || '').slice(0, 80000)}\nCURRENT_DESIGN>>>`,++ `Use ONLY these theme CSS custom properties for every colour: ${names || '(the document already uses them)'}. Do not add scripts, event-handler attributes, links, web fonts, remote images or any external request. Mobile first.`,++ ].join('\n\n');++ const out = await ai.generate({ prompt, category: categoryOf(d.kind), slot: { provider: variant.provider, model: variant.model }, roundId: d.id, index: variant.slot });++ const html = wrap(out.html, variables);++ const version = await addVersion(d, p, { kind: 'chat', html, instruction, model: out.model || variant.model,++ provider: out.provider || variant.provider, costUsd: out.cost_usd, ms: out.ms, createdBy: subject || (d.owner ? 'owner' : 'guest') });++ const fresh = await pickRow(id);++ const data = await read(id, owner, key);++ return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };++ }++++ async function clickEdit({ id, path, text, color, spacing, align, owner, key, subject = null }) {++ const { d, p, selected, variant } = await currentForEdit(id, owner, key);++ const current = await versionRow(id, p.current_seq);++ let change;++ try {++ change = validateChange({ path, text, color, spacing, align }, selected.variables || {});++ } catch (err) {++ throw fail(err.status || 422, err.code || 'design.invalid_change', err.detail || err.message);++ }++ let modified;++ try {++ // Normalise older part A rows (and any row without an explicit body) before addressing elements.++ modified = applyChange(wrap(current.html, selected.variables || {}), change);++ } catch (err) {++ throw fail(err.status || 422, err.code || 'design.invalid_change', err.detail || err.message);++ }++ const html = wrap(modified, selected.variables || {});++ const record = { path: change.path };++ if (change.changes.text !== undefined) record.text = change.changes.text;++ if (change.changes.color !== undefined) record.color = color;++ if (change.changes.padding !== undefined) record.spacing = spacing;++ if (change.changes['text-align'] !== undefined) record.align = align;++ const version = await addVersion(d, p, { kind: 'click', html, change: record, model: variant.model,++ provider: variant.provider, costUsd: 0, ms: 0, createdBy: subject || (d.owner ? 'owner' : 'guest') });++ const fresh = await pickRow(id);++ const data = await read(id, owner, key);++ return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };++ }++++ async function versions(id, owner, key) {++ const d = await row(id, owner, key);++ const p = await pickRow(id);++ const list = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];++ return { designId: d.id, currentSeq: p ? Number(p.current_seq) : null, versions: list.map(v => versionPublic(v, p ? p.current_seq : 1)) };++ }++++ async function undo(id, owner, key) {++ const d = await row(id, owner, key);++ const p = await pickRow(id);++ if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before undoing an edit.');++ const current = await versionRow(id, p.current_seq);++ if (!current || current.parent_seq == null) {++ return { changed: false, ...(await read(id, owner, key)) };++ }++ const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];++ stack.push(Number(current.seq));++ await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',++ [d.id, Number(current.parent_seq), JSON.stringify(stack.slice(-100)), s.iso()]);++ return { changed: true, ...(await read(id, owner, key)) };}++++ async function redo(id, owner, key) {++ const d = await row(id, owner, key);++ const p = await pickRow(id);++ if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before redoing an edit.');++ const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];++ if (!stack.length) return { changed: false, ...(await read(id, owner, key)) };++ const seq = Number(stack.pop());++ const target = await versionRow(id, seq);++ if (!target) return { changed: false, ...(await read(id, owner, key)) };++ await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',++ [d.id, seq, JSON.stringify(stack), s.iso()]);++ return { changed: true, ...(await read(id, owner, key)) };++ }++async function setTheme(id, themeId, owner, key) {await row(id, owner, key);const selected = await theme(themeId);@@@ -157,7 +442,7 @@ function createDesigns({ s, ai, catalog, log = console, now = () => Date.now() }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 };++ return { create, read, setTheme, recent, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit };}module.exports = { createDesigns, wrap, tokenCss, themeCss };@@@ -1,14 +1,32 @@'use strict';++/**++ * The Design Studio API, /api/v1/designs:++ *++ * POST / create three model variants (part A)++ * GET /:id generation status, the working frame and the version list++ * POST /:id/theme change the token theme++ * POST /:id/pick choose the working frame; the pick is one Arena vote++ * POST /:id/edit chat edit the working frame (paid AI, small per-design budget)++ * POST /:id/click apply one validated click edit to the working frame++ * GET /:id/versions the readable, append-only version list++ * POST /:id/undo, /:id/redo move the working-frame pointer; history is never deleted++ *++ * A design is private: its owner sees it through the session, a guest through the secret key header or query.++ * A cookie-authenticated write must come from this site; every answer is `private, no-store`.++ */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;++const token = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null;++const keyOf = (req) => req.get('x-design-key') || req.query.key || (req.body && req.body.editKey) || null;function createDesignApi(ctx) {const routes = asyncRouter();++ const sameOrigin = requireSameOrigin(ctx.config);++ const priv = (res, status, body) => res.status(status).set('Cache-Control', 'private, no-store').json(body);const safe = fn => async (req, res) => {try { return await fn(req, res); }catch (err) {@@@ -18,7 +36,7 @@ function createDesignApi(ctx) {});}};-- routes.post('/', requireSameOrigin(ctx.config), safe(async (req, res) => {++ routes.post('/', sameOrigin, 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 });@@@ -29,12 +47,36 @@ function createDesignApi(ctx) {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);++ return priv(res, 201, 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))));++ routes.get('/:id', safe(async (req, res) => priv(res, 200,++ await ctx.designs.read(req.params.id, owner(req), keyOf(req)))));++ routes.post('/:id/theme', sameOrigin, safe(async (req, res) => priv(res, 200,++ await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), keyOf(req)))));++ routes.post('/:id/pick', sameOrigin, safe(async (req, res) => {++ const body = req.body || {};++ const result = await ctx.designs.pick({ id: req.params.id, variantIndex: body.variantIndex,++ variantId: body.variantId, owner: owner(req), key: keyOf(req), subject: owner(req), token: token(req) });++ return priv(res, result.idempotent ? 200 : 201, result);++ }));++ routes.post('/:id/edit', sameOrigin, safe(async (req, res) => {++ const body = req.body || {};++ const result = await ctx.designs.chatEdit({ id: req.params.id, instruction: body.instruction,++ owner: owner(req), key: keyOf(req), subject: owner(req) });++ return priv(res, 200, result);++ }));++ routes.post('/:id/click', sameOrigin, safe(async (req, res) => {++ const body = req.body || {};++ const result = await ctx.designs.clickEdit({ id: req.params.id, path: body.path, text: body.text,++ color: body.color, spacing: body.spacing, align: body.align, owner: owner(req), key: keyOf(req), subject: owner(req) });++ return priv(res, 201, result);++ }));++ routes.get('/:id/versions', safe(async (req, res) => priv(res, 200,++ await ctx.designs.versions(req.params.id, owner(req), keyOf(req)))));++ routes.post('/:id/undo', sameOrigin, safe(async (req, res) => priv(res, 200,++ await ctx.designs.undo(req.params.id, owner(req), keyOf(req)))));++ routes.post('/:id/redo', sameOrigin, safe(async (req, res) => priv(res, 200,++ await ctx.designs.redo(req.params.id, owner(req), keyOf(req)))));return routes;}@@@ -100,6 +100,41 @@ function createPageRoutes(ctx) {return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not change theme.');}});++ const backToCanvas = (req, res, key) => res.redirect(303, `/d/${encodeURIComponent(req.params.id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`);++ r.post('/d/:id/pick', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ const key = req.body.key || '';++ try {++ await ctx.designs.pick({ id: req.params.id, variantIndex: req.body.variantIndex, variantId: req.body.variantId,++ owner: subjectOf(req), key, subject: subjectOf(req), token: tokenOf(req) });++ return backToCanvas(req, res, key);++ } catch (err) {++ return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not choose that frame.');++ }++ });++ r.post('/d/:id/edit', express.urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ const key = req.body.key || '';++ try {++ await ctx.designs.chatEdit({ id: req.params.id, instruction: req.body.instruction,++ owner: subjectOf(req), key, subject: subjectOf(req) });++ return backToCanvas(req, res, key);++ } catch (err) {++ return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not edit that frame.');++ }++ });++ for (const action of ['undo', 'redo']) {++ r.post(`/d/:id/${action}`, express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ const key = req.body.key || '';++ try {++ await ctx.designs[action](req.params.id, subjectOf(req), key);++ return backToCanvas(req, res, key);++ } catch (err) {++ return res.status(err.status || 500).type('text/plain').send(err.detail || `Could not ${action} that edit.`);++ }++ });++ }r.get('/', async (req, res) => {const list = await safe(() => catalog.list());@@@ -16,36 +16,138 @@ ${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${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 themeListener = `<script data-dv-theme="1">(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>`;++++/**++ * The click-edit overlay. It is appended to the rendered srcdoc at render time and never stored with the model++ * HTML. It reports a stable nth-of-type path, a tag and whether the element is a leaf; the parent validates every++ * field before it sends one change back through the server. The script only uses DOM APIs and postMessage, so the++ * frame's `default-src 'none'` CSP still blocks every network request.++ */++const overlayScript = `<script data-dv-overlay="1">(function(){++'use strict';++var selected=null,lastOutline='';++function tagName(el){return el&&el.tagName?el.tagName.toLowerCase():'';}++function segment(el){var i=1,s=el;while((s=s.previousElementSibling)){if(s.tagName===el.tagName)i++;}return tagName(el)+':nth-of-type('+i+')';}++function pathOf(el){var parts=[];while(el&&el!==document.documentElement){if(el===document.body){parts.unshift('body');return parts.join(' > ');}parts.unshift(segment(el));el=el.parentElement;}return null;}++function allowed(el){var t=tagName(el);return !!t&&t!=='html'&&t!=='body'&&t!=='script'&&t!=='style'&&t!=='head'&&t!=='title'&&t!=='meta'&&t!=='link'&&t!=='iframe'&&t!=='object'&&t!=='embed'&&t!=='form'&&t!=='input'&&t!=='textarea'&&t!=='select';}++document.addEventListener('click',function(event){var el=event.target;if(!el||el.nodeType!==1)return;event.preventDefault();event.stopPropagation();if(!allowed(el))return;++if(selected&&selected.style){selected.style.outline=lastOutline;selected.style.outlineOffset='';}++selected=el;lastOutline=el.style.outline||'';el.style.outline='2px solid #7aa7ff';el.style.outlineOffset='2px';++var text=(el.textContent||'').replace(/\\s+/g,' ').trim().slice(0,400);++parent.postMessage({type:'design-select',path:pathOf(el),tag:tagName(el),leaf:el.children.length===0,text:text},'*');++},true);++document.addEventListener('submit',function(event){event.preventDefault();},true);++document.addEventListener('mouseover',function(event){var el=event.target;if(el&&el.nodeType===1&&allowed(el))el.style.cursor='crosshair';},true);++})();</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) {++function frame(html, title, { interactive = false } = {}) {const withPolicy = html.replace(/<head\b[^>]*>/i, `$&${framePolicy}`);++ const injected = `${themeListener}${interactive ? overlayScript : ''}`;const document = /<\/body>/i.test(withPolicy)-- ? withPolicy.replace(/<\/body>/i, `${themeListener}</body>`)-- : /<\/html>/i.test(withPolicy) ? withPolicy.replace(/<\/html>/i, `${themeListener}</html>`) : withPolicy + themeListener;++ ? withPolicy.replace(/<\/body>/i, `${injected}</body>`)++ : /<\/html>/i.test(withPolicy) ? withPolicy.replace(/<\/html>/i, `${injected}</html>`) : withPolicy + injected;return `<iframe title="${esc(title)}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" srcdoc="${attrEscape(document)}"></iframe>`;}++const VERSION_KIND = { generated: 'Picked frame', chat: 'AI edit', click: 'Click edit' };++const versionTime = (value) => {++ const date = new Date(value);++ return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(11, 16);++};++const versionDetail = (v) => v.kind === 'chat' && v.instruction++ ? `<q>${esc(v.instruction)}</q>`++ : v.kind === 'click' && v.change++ ? `<span class="dv-fine">${esc((v.change.path || '').replace(/^body > /, ''))}${v.change.text !== undefined ? ' · text' : ''}${v.change.color ? ' · colour' : ''}${v.change.spacing ? ' · spacing' : ''}${v.change.align ? ' · alignment' : ''}</span>`++ : '';++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`) :++ const { design, variants, pick, versions = [] } = data;++ const selectedTheme = themes.find(t => t.id === design.themeId) || themes[0] || { variables: {} };++ const variables = selectedTheme.variables || {};++ const pickedIndex = pick ? Number(pick.variantIndex) : -1;++ const pickedVariant = pickedIndex >= 0 ? variants[pickedIndex] : null;++ const chatLeft = pick ? Math.max(0, Number(pick.chatEditLimit || 0) - Number(pick.chatEdits || 0)) : 0;++ const canChat = Boolean(pick) && chatLeft > 0;++ const cards = variants.map((v, index) => {++ const picked = index === pickedIndex;++ const done = v.status === 'done';++ return `<article class="dv-card" data-status="${esc(v.status)}" data-index="${index}" data-picked="${picked ? 'true' : 'false'}" data-variant-id="${esc(v.id || '')}">++<div class="dv-frame">${done ? frame(v.html, `${v.model || 'Design'} frame`, { interactive: picked }) :`<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('');++<div class="dv-label"><span class="dv-model">${esc(v.model || 'Designer')}</span>${done ?++ `<span>${(Number(v.ms || 0) / 1000).toFixed(1)} s · $${Number(v.costUsd || 0).toFixed(4)}</span>` : ''}</div>++<div class="dv-card-actions">${picked++ ? `<span class="dv-picked">Working frame</span><span class="dv-fine">Click any element to edit it</span>`++ : done ? (pick ? '<span class="dv-fine">Not the working frame</span>'++ : `<form method="post" action="/d/${encodeURIComponent(design.id)}/pick"><input type="hidden" name="key" value="${esc(editKey)}"><input type="hidden" name="variantIndex" value="${index}"><button type="submit" class="dv-pick" data-pick="${index}">Use this design</button></form>`)++ : '<span class="dv-fine">Waiting for this designer…</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 aliasTokens = new Set(['--bg-card', '--bg-secondary', '--text-muted', '--accent', '--on-accent', '--border']);++ const tokenNames = [...new Set([...Object.keys(variables),++ '--text-primary', '--text-muted', '--accent', '--bg-primary', '--bg-card', '--border'])]++ .filter(name => /^--[a-z][a-z0-9-]{1,40}$/.test(name)++ && (aliasTokens.has(name) || /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\()/i.test(String(variables[name] || ''))));++ const tokenOptions = tokenNames.map(name => `<option value="${esc(name)}">${esc(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)}">++ const versionRows = versions.map(v => `<li${v.current ? ' class="is-current"' : ''}>++<span class="dv-vseq">v${v.seq}</span>++<span class="dv-vbody"><b>${esc(VERSION_KIND[v.kind] || v.kind)}</b>${v.current ? ' <span class="dv-vnow">now</span>' : ''}++<small>${esc(v.model || 'unknown model')}${v.provider ? ` · ${esc(v.provider)}` : ''}${v.createdAt ? ` · ${esc(versionTime(v.createdAt))}` : ''}${v.kind === 'chat' && v.costUsd ? ` · $${Number(v.costUsd).toFixed(4)}` : ''}</small>++${versionDetail(v)}</span></li>`).join('');++ const voteNote = pick && pick.vote++ ? pick.vote.state === 'recorded' ? ' Arena vote recorded.'++ : pick.vote.state === 'guest' ? ' Guest pick saved; sign in through the same secret link to count it as an Arena vote.'++ : pick.vote.state === 'failed' ? ' The Arena vote could not be recorded; the working frame is still saved.'++ : pick.vote.state === 'pending' ? ' Recording the Arena vote…'++ : '' : '';++ const statusText = pick ? `Working frame selected. ${versions.length} version${versions.length === 1 ? '' : 's'} kept.${voteNote}`++ : design.status === 'done' ? 'Three designs ready. Pick one to start editing.'++ : design.status === 'failed' ? 'Generation failed.' : 'Designers are working…';++ const keyInput = `<input type="hidden" name="key" value="${esc(editKey)}">`;++ const body = `<section class="dv-canvas" data-id="${esc(design.id)}" data-key="${esc(editKey)}" data-picked="${pick ? pickedIndex : ''}" data-variant-id="${esc(pickedVariant && pickedVariant.id || '')}"><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>++<p id="dv-status" role="status">${esc(statusText)}</p></header>++<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">++${keyInput}++<label for="dv-instruction">Edit with AI</label>++<div class="dv-chat-row"><input id="dv-instruction" name="instruction" maxlength="1000" placeholder="${pick ? 'Make the hero bolder, add a pricing section…' : 'Pick a frame first'}"${canChat ? '' : ' disabled'}>++<button type="submit"${canChat ? '' : ' disabled'}>Send</button></div>++<p class="dv-chat-status" id="dv-edit-status" role="status" aria-live="polite"></p>++<p class="dv-fine">${pick ? `${chatLeft} of ${pick.chatEditLimit} AI edits left on this design. Click edits and undo are free.` : 'Chat edits work on the picked frame.'}</p>++</form><div class="dv-rail" id="dv-rail">${cards}</div>++<details class="dv-versions" id="dv-versions" open>++<summary>Versions <span>${versions.length}</span></summary>++<div class="dv-vcontrols">++<form method="post" action="/d/${encodeURIComponent(design.id)}/undo">${keyInput}<button type="submit"${pick && pick.canUndo ? '' : ' disabled'}>Undo</button></form>++<form method="post" action="/d/${encodeURIComponent(design.id)}/redo">${keyInput}<button type="submit"${pick && pick.canRedo ? '' : ' disabled'}>Redo</button></form>++</div>++${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '<p class="dv-fine">Pick a frame; its HTML becomes version 1. Every accepted edit adds a version and nothing is deleted.</p>'}++</details><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>`;++<form method="post" action="/d/${encodeURIComponent(design.id)}/theme" id="dv-theme-form">${keyInput}<label>Theme <select id="dv-theme" name="themeId">${themeOptions}</select></label><button type="submit">Apply</button></form></nav>++</section>++<aside class="dv-element" id="dv-element" hidden aria-label="Edit the selected element">++<div class="dv-element-head"><b id="dv-element-tag">Element</b><button type="button" id="dv-element-close">Close</button></div>++<form id="dv-element-form">++<label for="dv-element-text">Text</label><textarea id="dv-element-text" rows="3" maxlength="1000"></textarea>++<p class="dv-fine" id="dv-element-note" hidden>This element contains other elements. Edit the text element inside it; colour, spacing and alignment still apply.</p>++<label for="dv-element-color">Text colour (theme token)</label><select id="dv-element-color"><option value="">Keep</option>${tokenOptions}</select>++<label for="dv-element-spacing">Spacing (padding)</label><select id="dv-element-spacing"><option value="">Keep</option><option value="none">None</option><option value="sm">Small · 8px</option><option value="md">Medium · 16px</option><option value="lg">Large · 24px</option></select>++<label for="dv-element-align">Alignment</label><select id="dv-element-align"><option value="">Keep</option><option value="left">Left</option><option value="center">Centre</option><option value="right">Right</option></select>++<button type="submit">Save as a new version</button>++<p class="dv-status" id="dv-element-status" role="status" aria-live="polite"></p>++</form>++</aside>++<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 };++module.exports = { create, canvas, frame, overlayScript };@@@ -143,8 +143,12 @@ ${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.'],++ ['GET /api/v1/designs/:id', 'Poll generation status, read each frame, the pick and the version list; guests send X-Design-Key.'],['POST /api/v1/designs/:id/theme', 'Change the token theme with {themeId}.'],++ ['POST /api/v1/designs/:id/pick', 'Choose the working frame with {variantIndex}; the pick is recorded as one Arena vote.'],++ ['POST /api/v1/designs/:id/edit', 'Chat edit the working frame with {instruction}; one paid AI run and a small per-design budget.'],++ ['POST /api/v1/designs/:id/click', 'Apply {path, text?, color?, spacing?, align?} to one element; validated paths and theme tokens only.'],++ ['GET /api/v1/designs/:id/versions', 'Every saved version with its model/provider provenance; POST /undo and /redo move the pointer.'],['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,314 @@++'use strict';++++/**++ * Design Studio part B: picking the working frame (and its one Arena vote), chat and click edits, the append-only++ * version list with undo/redo, quotas, authorization and the CSP after an edit. The AI and Bench are in-process++ * fakes from test/helpers, so nothing here spends money or touches the network.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const slots = [{ provider: 'alpha', model: 'one' }, { provider: 'beta', model: 'two' }, { provider: 'gamma', model: 'three' }];++const page = (title) => `<!doctype html><html><head><style>body{background:#ffffff;color:#222222}</style></head><body><section><h1>${title}</h1><p>Body copy</p><button>Get started</button></section></body></html>`;++let editCalls = 0;++let failEdit = false;++let lastEditPrompt = '';++const ai = {++ slotPlan: async () => ({ slots }),++ generate: async ({ slot, prompt }) => {++ if (prompt.includes('CURRENT_DESIGN')) {++ editCalls++;++ lastEditPrompt = prompt;++ if (failEdit) throw Object.assign(new Error('fake AI failed'), { status: 502, code: 'ai.run_failed', detail: 'fake AI failed' });++ const asked = /instruction: (.*)/.exec(prompt);++ const unsafe = '<script>alert(1)</script><img src="https://evil.example/x.png" onerror="alert(2)">';++ return { html: page(`Edited ${asked ? asked[1] : ''}${unsafe}`), provider: slot.provider, model: slot.model, cost_usd: 0.002, ms: 80 };++ }++ return { html: page('Generated'), provider: slot.provider, model: slot.model, cost_usd: 0.001, ms: 50 };++ },++};++const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));++++(async () => {++ const t = await boot({ ai, env: { DESIGN_CHAT_EDITS_PER_DESIGN: '2' } });++ const kim = t.network.addUser('kim');++ const bob = t.network.addUser('bob');++ const POST = { origin: 'https://openvibe.design' };++ async function waitDone(id, key, as) {++ for (let i = 0; i < 80; i++) {++ const response = await t.get(`/api/v1/designs/${id}${key ? `?key=${encodeURIComponent(key)}` : ''}`, { as });++ if (response.json().design.status === 'done') return response.json();++ await pause(20);++ }++ throw new Error('design generation did not finish');++ }++ let guestIp = 0;++ async function guestDesign(prompt = 'A coffee roaster landing page') {++ // One address per guest design, so the part A two-a-day design cap does not mask the part B behaviour.++ const created = await t.get('/api/v1/designs', { ...POST, headers: { 'x-forwarded-for': `198.51.100.${++guestIp}` }, json: { prompt, kind: 'page' } });++ assert.equal(created.status, 201, created.text);++ const id = created.json().design.id;++ const key = created.json().editKey;++ assert.ok(key);++ await waitDone(id, key);++ return { id, key };++ }++ async function userDesign(prompt = 'A signed-in design') {++ const created = await t.get('/api/v1/designs', { ...POST, as: kim, json: { prompt, kind: 'page' } });++ assert.equal(created.status, 201, created.text);++ const id = created.json().design.id;++ await waitDone(id, null, kim);++ return { id };++ }++ const pick = (id, variantIndex, { key, as } = {}) => t.get(`/api/v1/designs/${id}/pick`, {++ ...POST, as, ...(key ? { headers: { 'x-design-key': key } } : {}), json: { variantIndex },++ });++ const click = (id, body, { key, as } = {}) => t.get(`/api/v1/designs/${id}/click`, {++ ...POST, as, ...(key ? { headers: { 'x-design-key': key } } : {}), json: body,++ });++ const edit = (id, instruction, { key, as, origin = true } = {}) => t.get(`/api/v1/designs/${id}/edit`, {++ ...(origin ? POST : {}), as, ...(key ? { headers: { 'x-design-key': key } } : {}), json: { instruction },++ });++ const versions = (id, { key, as } = {}) => t.get(`/api/v1/designs/${id}/versions${key ? `?key=${encodeURIComponent(key)}` : ''}`, { as });++++ try {++ await check('pick is idempotent, persists the working frame and starts one version', async () => {++ const { id, key } = await guestDesign();++ const first = await pick(id, 1, { key });++ assert.equal(first.status, 201, first.text);++ const one = first.json();++ assert.equal(one.pick.variantIndex, 1);++ assert.equal(one.pick.currentSeq, 1);++ assert.equal(one.versions.length, 1);++ assert.equal(one.versions[0].kind, 'generated');++ assert.equal(one.vote.state, 'guest');++ assert.equal(one.variants[1].currentSeq, 1);++ const again = await pick(id, 1, { key });++ assert.equal(again.status, 200, again.text);++ assert.equal(again.json().idempotent, true);++ assert.equal(again.json().versions.length, 1);++ const wrong = await pick(id, 2, { key });++ assert.equal(wrong.status, 409);++ const rows = await t.store.db.many('SELECT * FROM design_picks WHERE design_id=$1', [id]);++ assert.equal(rows.length, 1);++ assert.equal(rows[0].variant_index, 1);++ assert.equal((await versions(id, { key })).headers.get('cache-control'), 'private, no-store');++ });++++ await check('owner and guest-key authorization is enforced on pick and reads', async () => {++ const { id } = await userDesign('Owner only');++ assert.equal((await pick(id, 0, { as: bob })).status, 403);++ assert.equal((await pick(id, 0)).status, 403);++ assert.equal((await t.get(`/api/v1/designs/${id}`)).status, 403);++ assert.equal((await pick(id, 0, { as: kim })).status, 201);++ const guest = await guestDesign('Guest secret');++ assert.equal((await pick(guest.id, 0, { key: 'not-the-key' })).status, 403);++ assert.equal((await t.get(`/api/v1/designs/${guest.id}?key=not-the-key`)).status, 403);++ assert.equal((await pick(guest.id, 0, { key: guest.key })).status, 201);++ });++++ await check('a signed-in pick records exactly one Bench round, three entries and one Arena vote', async () => {++ const { id } = await userDesign('Arena vote design');++ const response = await pick(id, 2, { as: kim });++ assert.equal(response.status, 201, response.text);++ const out = response.json();++ assert.equal(out.vote.state, 'recorded', JSON.stringify(out.vote));++ assert.ok(out.vote.roundId);++ const round = t.bench.state.rounds.get(out.vote.roundId);++ assert.ok(round, 'the pick created a Bench round');++ assert.equal(round.status, 'finished');++ const entries = [...t.bench.state.entries.values()].filter(e => e.round_id === round.id);++ assert.equal(entries.length, 3);++ assert.deepEqual(entries.map(e => e.model).sort(), ['one', 'three', 'two']);++ const votes = t.bench.state.judgements.filter(j => j.round_id === round.id);++ assert.equal(votes.length, 1);++ assert.equal(votes[0].subject, kim.subject);++ assert.equal(votes[0].outcome, 'left');++ assert.equal(votes[0].pair.left, entries.find(e => e.model === 'three').id);++ const again = await pick(id, 2, { as: kim });++ assert.equal(again.status, 200, again.text);++ assert.equal(t.bench.state.judgements.filter(j => j.round_id === round.id).length, 1, 'a repeated click is not a second vote');++ const rounds = [...t.bench.state.rounds.values()].filter(r => r.prompt === 'Arena vote design');++ assert.equal(rounds.length, 1, 'a repeated click is not a second Bench round');++ });++++ await check('two simultaneous picks still record one vote', async () => {++ const { id } = await userDesign('Concurrent pick');++ const [a, b] = await Promise.all([pick(id, 1, { as: kim }), pick(id, 1, { as: kim })]);++ assert.deepEqual([a.status, b.status].sort(), [200, 201], a.text + b.text);++ const rounds = [...t.bench.state.rounds.values()].filter(r => r.prompt === 'Concurrent pick');++ assert.equal(rounds.length, 1, 'one Bench round for one design');++ assert.equal(t.bench.state.judgements.filter(j => j.round_id === rounds[0].id).length, 1);++ const list = await t.get(`/api/v1/designs/${id}/versions`, { as: kim });++ assert.equal(list.json().versions.length, 1);++ });++++ await check('chat edit changes only the working frame, keeps its tokens and saves a new version', async () => {++ const { id, key } = await guestDesign('Chat edit design');++ assert.equal((await pick(id, 1, { key })).status, 201);++ const before = editCalls;++ const response = await edit(id, 'make the headline warmer', { key });++ assert.equal(response.status, 200, response.text);++ const out = response.json();++ assert.equal(out.version.seq, 2);++ assert.equal(out.version.kind, 'chat');++ assert.equal(out.version.model, 'two');++ assert.equal(editCalls, before + 1);++ assert.match(lastEditPrompt, /<<<CURRENT_DESIGN\n[\s\S]*<h1>Generated<\/h1>/);++ assert.match(lastEditPrompt, /--bg-primary/);++ assert.match(lastEditPrompt, /make the headline warmer/);++ const data = (await t.get(`/api/v1/designs/${id}?key=${encodeURIComponent(key)}`)).json();++ assert.match(data.variants[1].html, /Edited make the headline warmer/);++ assert.match(data.variants[1].html, /<style id="design-tokens">/);++ assert.doesNotMatch(data.variants[1].html, /<script\b/i);++ assert.doesNotMatch(data.variants[1].html, /onerror/i);++ assert.doesNotMatch(data.variants[0].html, /Edited/);++ assert.doesNotMatch(data.variants[2].html, /Edited/);++ assert.equal(data.versions.length, 2);++ assert.equal(data.versions[0].kind, 'generated');++ assert.equal(data.pick.currentSeq, 2);++ const stored = await t.store.db.many('SELECT html FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]);++ assert.equal(stored.length, 2);++ assert.match(stored[0].html, /Generated/);++ assert.match(stored[1].html, /Edited/);++ });++++ await check('chat edit enforces ownership, same-origin and the per-design AI budget', async () => {++ const owner = await userDesign('Chat auth design');++ assert.equal((await pick(owner.id, 0, { as: kim })).status, 201);++ assert.equal((await edit(owner.id, 'change it', { as: bob })).status, 403);++ assert.equal((await edit(owner.id, 'change it', { as: kim, origin: false })).status, 403, 'a cookie write without a same-origin signal is refused');++ assert.equal((await edit(owner.id, 'change it', { key: 'wrong' })).status, 403);++++ const { id, key } = await guestDesign('Quota design');++ assert.equal((await pick(id, 0, { key })).status, 201);++ failEdit = true;++ const failed = await edit(id, 'this one fails', { key });++ assert.equal(failed.status, 502);++ failEdit = false;++ const ok = await edit(id, 'this one works', { key });++ assert.equal(ok.status, 200, ok.text);++ assert.equal(ok.json().version.seq, 2, 'the failed attempt spent budget but did not create a version');++ const refused = await edit(id, 'one too many', { key });++ assert.equal(refused.status, 429);++ assert.match(refused.json().detail, /2 AI edits/);++ const list = (await versions(id, { key })).json();++ assert.equal(list.versions.length, 2, 'the last good version survives a failed AI call and the quota refusal');++ const stored = await t.store.db.many('SELECT html FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]);++ assert.match(stored[1].html, /this one works/);++ });++++ await check('click edit validates the path and values and saves an escaped, token-only change', async () => {++ const { id, key } = await guestDesign('Click edit design');++ assert.equal((await pick(id, 1, { key })).status, 201);++ const path = 'body > section:nth-of-type(1) > h1:nth-of-type(1)';++ const response = await click(id, { path, text: 'Hello <img src=x onerror=alert(1)>', color: '--accent', spacing: 'md', align: 'center' }, { key });++ assert.equal(response.status, 201, response.text);++ const out = response.json();++ assert.equal(out.version.seq, 2);++ assert.equal(out.version.kind, 'click');++ assert.match(out.version.change.path, /^body > section/);++ const data = (await t.get(`/api/v1/designs/${id}?key=${encodeURIComponent(key)}`)).json();++ assert.match(data.variants[1].html, /Hello <img src=x onerror=alert\(1\)>/);++ assert.doesNotMatch(data.variants[1].html, /<img/);++ assert.match(data.variants[1].html, /style="[^"]*color:var\(--accent\)[^"]*padding:16px[^"]*text-align:center/);++ assert.match(data.variants[1].html, /<style id="design-tokens">/);++++ const refused = [++ { path: 'body > script:nth-of-type(1)', text: 'x' },++ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', color: 'red' },++ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', color: '--not-in-the-theme' },++ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', spacing: '999px' },++ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', align: 'justify' },++ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', text: 'x', color: 'url(javascript:alert(1))' },++ { path: 'body > section:nth-of-type(1)', text: 'wipes the children' },++ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1) > span:nth-of-type(1)', text: 'missing' },++ { path: 'body #id', text: 'x' },++ ];++ for (const body of refused) {++ const bad = await click(id, body, { key });++ assert.equal(bad.status, 422, `${JSON.stringify(body)} should be refused`);++ }++ assert.equal((await click(id, { path, text: 'x' }, { key: 'wrong' })).status, 403);++ assert.equal((await click(id, { path, text: 'x' }, { as: bob })).status, 403);++ });++++ await check('the rendered canvas keeps the frame CSP and injects the overlay outside stored HTML', async () => {++ const { id, key } = await guestDesign('CSP after edit');++ assert.equal((await pick(id, 1, { key })).status, 201);++ assert.equal((await click(id, { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', text: 'Safe' }, { key })).status, 201);++ const canvas = await t.get(`/d/${id}?key=${encodeURIComponent(key)}`);++ assert.equal(canvas.status, 200);++ assert.equal(canvas.headers.get('cache-control'), 'private, no-store');++ assert.equal((canvas.text.match(/sandbox="allow-scripts"/g) || []).length, 3);++ assert.equal((canvas.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);++ assert.equal((canvas.text.match(/data-dv-overlay="1"/g) || []).length, 1, 'only the working frame carries the click overlay');++ const data = (await t.get(`/api/v1/designs/${id}?key=${encodeURIComponent(key)}`)).json();++ assert.doesNotMatch(data.variants[1].html, /data-dv-overlay/);++ assert.doesNotMatch(data.variants[1].html, /<script\b/i);++ assert.doesNotMatch(data.variants[1].html, /https?:\/\//);++ });++++ await check('the page forms work without JavaScript (pick, chat edit, undo)', async () => {++ const { id, key } = await guestDesign('No-JS canvas');++ const form = { 'content-type': 'application/x-www-form-urlencoded' };++ const picked = await t.get(`/d/${id}/pick`, { headers: form, body: `key=${encodeURIComponent(key)}&variantIndex=0` });++ assert.equal(picked.status, 303);++ assert.match(picked.headers.get('location'), new RegExp(`^/d/${id}\\?key=`));++ const page1 = await t.get(`/d/${id}?key=${encodeURIComponent(key)}`);++ assert.match(page1.text, /Working frame/);++ const edited = await t.get(`/d/${id}/edit`, { headers: form, body: `key=${encodeURIComponent(key)}&instruction=no-js+edit` });++ assert.equal(edited.status, 303);++ const v = (await versions(id, { key })).json();++ assert.equal(v.versions.length, 2);++ assert.equal(v.versions[1].kind, 'chat');++ const undone = await t.get(`/d/${id}/undo`, { headers: form, body: `key=${encodeURIComponent(key)}` });++ assert.equal(undone.status, 303);++ assert.equal((await versions(id, { key })).json().currentSeq, 1);++ });++++ await check('versions, undo, redo and a new edit after undo keep every version', async () => {++ const { id, key } = await guestDesign('Version history');++ assert.equal((await pick(id, 0, { key })).status, 201);++ assert.equal((await edit(id, 'second version', { key })).status, 200);++ const path = 'body > section:nth-of-type(1) > h1:nth-of-type(1)';++ assert.equal((await click(id, { path, color: '--accent' }, { key })).status, 201);++ let list = (await versions(id, { key })).json();++ assert.deepEqual(list.versions.map(v => v.seq), [1, 2, 3]);++ assert.equal(list.currentSeq, 3);++++ const undo1 = await t.get(`/api/v1/designs/${id}/undo`, { ...POST, headers: { 'x-design-key': key }, json: {} });++ assert.equal(undo1.status, 200, undo1.text);++ assert.equal(undo1.json().changed, true);++ assert.equal(undo1.json().pick.currentSeq, 2);++ assert.match(undo1.json().variants[0].html, /Edited second version/);++ const redo = await t.get(`/api/v1/designs/${id}/redo`, { ...POST, headers: { 'x-design-key': key }, json: {} });++ assert.equal(redo.status, 200, redo.text);++ assert.equal(redo.json().pick.currentSeq, 3);++ assert.match(redo.json().variants[0].html, /color:var\(--accent\)/);++++ const undo2 = await t.get(`/api/v1/designs/${id}/undo`, { ...POST, headers: { 'x-design-key': key }, json: {} });++ const undo3 = await t.get(`/api/v1/designs/${id}/undo`, { ...POST, headers: { 'x-design-key': key }, json: {} });++ assert.equal(undo2.json().pick.currentSeq, 2);++ assert.equal(undo3.json().pick.currentSeq, 1);++ const baseUndo = await t.get(`/api/v1/designs/${id}/undo`, { ...POST, headers: { 'x-design-key': key }, json: {} });++ assert.equal(baseUndo.status, 200);++ assert.equal(baseUndo.json().changed, false);++++ const fresh = await click(id, { path, spacing: 'lg' }, { key });++ assert.equal(fresh.status, 201, fresh.text);++ assert.equal(fresh.json().version.seq, 4);++ list = (await versions(id, { key })).json();++ assert.deepEqual(list.versions.map(v => v.seq), [1, 2, 3, 4], 'undo + a new edit keeps the old versions');++ assert.equal(list.versions[1].kind, 'chat');++ assert.equal(list.versions[2].kind, 'click');++ assert.equal(list.currentSeq, 4);++ const noRedo = await t.get(`/api/v1/designs/${id}/redo`, { ...POST, headers: { 'x-design-key': key }, json: {} });++ assert.equal(noRedo.json().changed, false, 'a new edit clears the redo branch');++ assert.equal((await t.get(`/api/v1/designs/${id}/undo`, { ...POST, json: {} })).status, 403);++ });++ } finally {++ await t.close();++ }++ done();++})().catch(err => { console.error(err); process.exit(1); });
@@ -45,7 +45,10 @@ OV_AI_INTERNAL_URL=http://127.0.0.1:4700
@@ -45,7 +45,10 @@ OV_AI_INTERNAL_URL=http://127.0.0.1:4700
OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
# OV_BENCH_URL=https://bench.openvibe.services
# OV_BENCH_URL=https://bench.openvibe.services
# DESIGN_BENCH_AUDIENCE=openvibe.bench
# DESIGN_BENCH_AUDIENCE=openvibe.bench
-# DESIGN_BENCH_SCOPE=bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.board.read
+# DESIGN_BENCH_SCOPE=bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.entry.create bench.board.read
+
+# Design Studio part B: AI edits allowed per design (each accepted chat edit is one paid AI run).
+# DESIGN_CHAT_EDITS_PER_DESIGN=5
# Legacy Arena settings retained for compatibility; Bench enforces active round limits and runs generation.
# Legacy Arena settings retained for compatibility; Bench enforces active round limits and runs generation.
# DESIGN_ARENA_PER_DAY=3
# DESIGN_ARENA_PER_DAY=3
@@ -40,7 +40,9 @@ Network's theme API.
@@ -40,7 +40,9 @@ Network's theme API.
`/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.
`/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.
+Pick one frame with **Use this design**: it becomes the working frame, its HTML becomes version 1 and the pick is recorded as one Arena vote through OpenVibe.Bench (a signed-in person votes once; a guest's pick is saved without a vote). Edit it by chat (`POST /api/v1/designs/:id/edit`) or by clicking an element: the iframe reports `body > …:nth-of-type(n)`, and the floating panel changes its text, theme-token colour, padding or alignment. The server validates the path, tag and values, escapes the text, rebuilds only the inline style and saves a new version; the overlay script is injected at render time and is never stored with the model HTML. Versions are append-only in `design_versions`; undo/redo move the pointer (`design_picks.current_seq`), and an edit after undo appends a new version instead of deleting the old ones. Chat edits cost AI money, so each design has a small AI-edit budget (`DESIGN_CHAT_EDITS_PER_DESIGN`, default 5); a failed attempt spends budget but never replaces the last good version.
+
+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, and `0003_design_working_versions.sql` adds the pick, its Bench vote fields, the AI-edit counter and the version table, 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 and OpenVibe.Bench service credentials (the Bench scope now includes `bench.entry.create` so Design can submit the generated variants to the pick-vote round). Checks, HTML/zip export, preview publishing and MCP remain part C.
## The Design Arena
## The Design Arena
@@ -100,6 +102,13 @@ a Bearer write is not cookie-authenticated and is unaffected.
@@ -100,6 +102,13 @@ a Bearer write is not cookie-authenticated and is unaffected.
| `GET /api/me/theme/history` | cookie | Network `GET /api/themes/me/history` |
| `GET /api/me/theme/history` | cookie | Network `GET /api/themes/me/history` |
| `POST /api/me/theme/history/:id/restore` | cookie + same-origin | Network's restore; `id` must be a positive integer, else 400 |
| `POST /api/me/theme/history/:id/restore` | cookie + same-origin | Network's restore; `id` must be a positive integer, else 400 |
| `POST /api/themes/submit` | cookie + same-origin | Network `POST /api/themes` with `{ name, slug, description, mode, variables, tags }` |
| `POST /api/themes/submit` | cookie + same-origin | Network `POST /api/themes` with `{ name, slug, description, mode, variables, tags }` |
+| `POST /api/v1/designs` | none/cookie + same-origin | create three variants `{ prompt, kind, themeId? }`; a guest gets a secret edit key |
+| `GET /api/v1/designs/:id` | owner key or cookie | frames, the working frame, the pick and the versions; guest key in `X-Design-Key` or `?key=` |
+| `POST /api/v1/designs/:id/pick` | owner key or cookie + same-origin | `{ variantIndex }`; idempotent, one Arena vote |
+| `POST /api/v1/designs/:id/edit` | owner key or cookie + same-origin | chat edit `{ instruction }`; one AI run from the picked variant's model, small per-design budget |
+| `POST /api/v1/designs/:id/click` | owner key or cookie + same-origin | `{ path, text?, color?, spacing?, align? }`, validated, saves a version |
+| `GET /api/v1/designs/:id/versions` | owner key or cookie | the version list with model/provider provenance |
+| `POST /api/v1/designs/:id/undo`, `/redo` | owner key or cookie + same-origin | move the working-frame pointer; history stays |
| `POST /api/v1/arena/rounds` | cookie + same-origin | queue a round `{ category, prompt }` (subject to the two daily budgets) |
| `POST /api/v1/arena/rounds` | cookie + same-origin | queue a round `{ category, prompt }` (subject to the two daily budgets) |
| `GET /api/v1/arena/rounds/:id` | none | one round; entries are blind until Bench reveals them to this viewer |
| `GET /api/v1/arena/rounds/:id` | none | one round; entries are blind until Bench reveals them to this viewer |
| `GET /api/v1/arena/vote[?category=logo]` | cookie | the next blind pair for this person (models hidden) |
| `GET /api/v1/arena/vote[?category=logo]` | cookie | the next blind pair for this person (models hidden) |
@@ -140,7 +149,9 @@ The Network signs the access token (RS256); `server/auth/keys.js` fetches and re
@@ -140,7 +149,9 @@ The Network signs the access token (RS256); `server/auth/keys.js` fetches and re
See [.env.example](.env.example). Required in production: `BASE_URL` and `OV_OAUTH_CLIENT_SECRET` (the `design`
See [.env.example](.env.example). Required in production: `BASE_URL` and `OV_OAUTH_CLIENT_SECRET` (the `design`
OAuth client on the Network). Optional: `OV_NETWORK_URL` (default `https://openvibe.network`),
OAuth client on the Network). Optional: `OV_NETWORK_URL` (default `https://openvibe.network`),
`OV_NETWORK_INTERNAL_URL` (default `http://127.0.0.1:4000`), `OV_NETWORK_ISSUER`, `OV_OAUTH_REDIRECT_URI`,
`OV_NETWORK_INTERNAL_URL` (default `http://127.0.0.1:4000`), `OV_NETWORK_ISSUER`, `OV_OAUTH_REDIRECT_URI`,
-`DESIGN_THEME_CACHE_MS`, `DESIGN_UPSTREAM_TIMEOUT_MS`, `DESIGN_RATE_LIMIT_PER_MIN` and `COOKIE_SECURE`.
+`DESIGN_THEME_CACHE_MS`, `DESIGN_UPSTREAM_TIMEOUT_MS`, `DESIGN_RATE_LIMIT_PER_MIN`, `DESIGN_CHAT_EDITS_PER_DESIGN`
+(default 5) and `COOKIE_SECURE`. The Bench scope must include `bench.entry.create` in addition to the Arena
+capabilities, so Design can fill the pick-vote round with the variants it already generated.
## How to run
## How to run
@@ -32,10 +32,15 @@
@@ -32,10 +32,15 @@
"Blind voting and Elo: two outputs in random order with models hidden, creator cannot vote, one vote per person per round, K=32, tie=0.5, both_bad counts for neither",
"Blind voting and Elo: two outputs in random order with models hidden, creator cannot vote, one vote per person per round, K=32, tie=0.5, both_bad counts for neither",
"Arena pages: /arena (leaderboard and start), /arena/new, /arena/vote, /arena/r/:id with 390 px and 1280 px frames",
"Arena pages: /arena (leaderboard and start), /arena/new, /arena/vote, /arena/r/:id with 390 px and 1280 px frames",
"ADR-033 account export/deletion for a person's rounds, outputs and votes through /internal/events",
"ADR-033 account export/deletion for a person's rounds, outputs and votes through /internal/events",
- "Tests: the migration, the generation job, the sanitizer and iframe markup, daily limits, blind pairs, one vote, creator refusal, Elo win/tie/both_bad, leaderboard numbers, dataset privacy and account export/erase"
+ "Tests: the migration, the generation job, the sanitizer and iframe markup, daily limits, blind pairs, one vote, creator refusal, Elo win/tie/both_bad, leaderboard numbers, dataset privacy and account export/erase",
+ "Design Studio pick: one idempotent working frame per design, persisted with its Arena vote (Bench task/round, room-submitted entries, one signed-in person's judgement; a guest pick is saved without a vote)",
+ "Design Studio chat edit: POST /api/v1/designs/:id/edit, the picked variant's model via OpenVibe.AI, the previous HTML in context, theme-token re-wrapping, sanitization and the render-time frame CSP; a small per-design paid-AI budget",
+ "Design Studio click edit: a render-time iframe overlay reports a validated body > \u2026:nth-of-type path; the floating panel edits text, a theme-token colour, padding and alignment; paths, tags and CSS values are allowlisted server-side and every accepted change is a version",
+ "Design Studio versions: migrations/0003_design_working_versions.sql keeps design_picks and an append-only design_versions table with model/provider provenance; undo/redo move a pointer and an edit after undo appends instead of deleting",
+ "Tests: pick/vote idempotence, owner and guest-key authorization, chat edit with a fake AI and budget, click-edit safety, CSP after editing, version history/undo/redo and the no-JS page forms"
],
],
"notYet": [
"notYet": [
- "The pages' design and public/js/studio.js: the main session writes them; today the views are plain placeholders and design.css and studio.js are one-comment files",
+ "Design Studio part C: checks on the working frame, HTML/zip export, preview publishing and MCP",
"A Design service manifest and capabilities in OpenVibe.Contracts: this service has no capability of its own and no other service calls it",
"A Design service manifest and capabilities in OpenVibe.Contracts: this service has no capability of its own and no other service calls it",
"Deployment: the deploy files are written, but the service is not deployed yet"
"Deployment: the deploy files are written, but the service is not deployed yet"
],
],
@@ -56,7 +61,11 @@
@@ -56,7 +61,11 @@
"Arena generation jobs",
"Arena generation jobs",
"Blind voting and Elo",
"Blind voting and Elo",
"Open Arena dataset",
"Open Arena dataset",
- "Account export/deletion"
+ "Account export/deletion",
+ "Design Studio pick and Arena vote",
+ "Design Studio chat edit",
+ "Design Studio click edit",
+ "Design Studio versions and undo/redo"
],
],
- "updated": "2026-10-28"
+ "updated": "2026-10-29"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+-- phase: expand
+-- Part B: the chosen working frame, its Arena vote, the per-design AI edit budget and every saved version.
+-- The generated JSON variants stay where part A put them; a version is a full HTML document and a pick points at
+-- the current one. History is append-only: undo/redo move the pointer, they never delete a version.
+
+CREATE TABLE design_picks (
+ design_id text COLLATE "C" PRIMARY KEY REFERENCES designs (id) ON DELETE CASCADE,
+ variant_index integer NOT NULL,
+ variant_id text COLLATE "C" NOT NULL,
+ voter_subject text COLLATE "C",
+ current_seq integer NOT NULL DEFAULT 1,
+ redo_stack jsonb NOT NULL DEFAULT '[]'::jsonb,
+ chat_edits integer NOT NULL DEFAULT 0,
+ bench_round_id text COLLATE "C",
+ bench_left_output text COLLATE "C",
+ bench_right_output text COLLATE "C",
+ bench_judgement_id text COLLATE "C",
+ vote_state text COLLATE "C" NOT NULL DEFAULT 'none',
+ vote_error text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT design_picks_variant_index CHECK (variant_index >= 0),
+ CONSTRAINT design_picks_current_seq CHECK (current_seq >= 1),
+ CONSTRAINT design_picks_chat_edits CHECK (chat_edits >= 0),
+ CONSTRAINT design_picks_redo_stack CHECK (jsonb_typeof(redo_stack) = 'array'),
+ CONSTRAINT design_picks_vote_state CHECK (vote_state IN ('none', 'guest', 'pending', 'recorded', 'unavailable', 'failed'))
+);
+
+CREATE TABLE design_versions (
+ design_id text COLLATE "C" NOT NULL REFERENCES designs (id) ON DELETE CASCADE,
+ seq integer NOT NULL,
+ variant_index integer NOT NULL,
+ variant_id text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL,
+ html text NOT NULL,
+ instruction text,
+ change jsonb,
+ model text COLLATE "C",
+ provider text COLLATE "C",
+ cost_usd numeric(12, 6) NOT NULL DEFAULT 0,
+ ms integer NOT NULL DEFAULT 0,
+ parent_seq integer,
+ created_by text COLLATE "C",
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (design_id, seq),
+ CONSTRAINT design_versions_seq CHECK (seq >= 1),
+ CONSTRAINT design_versions_kind CHECK (kind IN ('generated', 'chat', 'click')),
+ CONSTRAINT design_versions_parent CHECK (parent_seq IS NULL OR parent_seq < seq)
+);
+
+CREATE INDEX design_versions_by_design ON design_versions (design_id, seq);
@@ -296,3 +296,58 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -296,3 +296,58 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.dv-toolbar form, .dv-toolbar label { gap: 4px; font-size: .72rem; }
.dv-toolbar form, .dv-toolbar label { gap: 4px; font-size: .72rem; }
}
}
@media (prefers-reduced-motion: reduce) { .dv-frame { transition: none; } .dv-skeleton { animation: none; } }
@media (prefers-reduced-motion: reduce) { .dv-frame { transition: none; } .dv-skeleton { animation: none; } }
+
+/* Part B: the working frame, the chat composer, versions and the click-edit panel. One toolbar stays the only
+ floating control bar; the composer and the version list are in the page flow, and the element panel opens only
+ after a click inside the working frame. */
+.dv-chat { max-width: 1320px; margin: 0 auto 16px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); }
+.dv-chat > label { display: block; margin-bottom: 6px; font-size: .78rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); }
+.dv-chat-row { display: flex; align-items: center; gap: 8px; }
+.dv-chat-row input { flex: 1; min-width: 0; min-height: 42px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-input, var(--bg-primary)); color: var(--text-primary); font: inherit; font-size: .92rem; }
+.dv-chat-row button, .dv-element button[type=submit] { min-height: 42px; padding: 0 16px; border: 0; border-radius: 10px; background: var(--accent); color: var(--on-accent); font: inherit; font-weight: 800; cursor: pointer; }
+.dv-chat-row button:disabled, .dv-element button[type=submit]:disabled { opacity: .55; cursor: not-allowed; }
+.dv-chat-status, .dv-element .dv-status { min-height: 1.3em; margin: 8px 0 0; font-size: .84rem; color: var(--text-muted); }
+.dv-chat-status.is-error, .dv-element .dv-status.is-error { color: var(--danger-text, #f87171); }
+.dv-chat .dv-fine { margin: 2px 0 0; font-size: .76rem; }
+.dv-card[data-picked="true"] .dv-frame { box-shadow: 0 0 0 2px var(--room-strong), 0 16px 38px #0003; }
+.dv-card-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; min-height: 38px; padding: 0 4px 10px; }
+.dv-card-actions form { margin: 0; }
+.dv-card-actions .dv-fine { font-size: .74rem; }
+.dv-pick { min-height: 34px; padding: 5px 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-card); color: var(--text-primary); font: inherit; font-size: .8rem; font-weight: 700; cursor: pointer; transition: border-color .15s, background .15s; }
+.dv-pick:hover, .dv-pick:focus-visible { border-color: var(--room-strong); background: color-mix(in srgb, var(--room-strong) 12%, var(--bg-card)); outline: none; }
+.dv-picked { display: inline-flex; align-items: center; gap: 6px; font-size: .78rem; font-weight: 800; color: var(--room-strong); }
+.dv-picked::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--room-strong); }
+.dv-versions { max-width: 1320px; margin: 0 auto 92px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); }
+.dv-versions summary { display: flex; align-items: center; gap: 8px; min-height: 34px; cursor: pointer; font-weight: 800; font-size: .92rem; }
+.dv-versions summary span { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 99px; background: var(--bg-secondary); color: var(--text-muted); font-size: .74rem; font-variant-numeric: tabular-nums; }
+.dv-vcontrols { display: flex; gap: 8px; margin: 8px 0; }
+.dv-vcontrols form { margin: 0; }
+.dv-vcontrols button { min-height: 34px; padding: 4px 14px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-secondary); color: var(--text-primary); font: inherit; font-size: .82rem; font-weight: 700; cursor: pointer; }
+.dv-vcontrols button:disabled { opacity: .5; cursor: not-allowed; }
+.dv-vlist { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 2px; max-height: 300px; overflow: auto; }
+.dv-vlist li { display: flex; gap: 10px; padding: 8px 8px; border-top: 1px solid var(--border); font-size: .84rem; border-radius: 8px; }
+.dv-vlist li.is-current { background: color-mix(in srgb, var(--room-strong) 12%, transparent); }
+.dv-vseq { min-width: 30px; font-weight: 800; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+.dv-vbody { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.dv-vbody small { color: var(--text-muted); font-size: .76rem; }
+.dv-vbody q, .dv-vbody .dv-fine { color: var(--text-secondary); font-size: .8rem; overflow-wrap: anywhere; }
+.dv-vnow { display: inline-block; padding: 1px 7px; border-radius: 99px; background: var(--bg-secondary); color: var(--room-strong); font-size: .68rem; font-weight: 800; vertical-align: 1px; }
+.dv-element { position: fixed; z-index: 30; right: 24px; bottom: 92px; width: min(340px, calc(100vw - 24px)); max-height: min(68vh, 620px); overflow: auto; padding: 14px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card); box-shadow: 0 18px 50px #0005; }
+.dv-element[hidden] { display: none; }
+.dv-element-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
+.dv-element-head b { font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: var(--room-strong); }
+.dv-element-head button { min-height: 30px; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--text-muted); font: inherit; font-size: .78rem; cursor: pointer; }
+.dv-element form { display: grid; gap: 8px; }
+.dv-element label { font-size: .76rem; font-weight: 800; color: var(--text-muted); }
+.dv-element textarea, .dv-element select { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-input, var(--bg-primary)); color: var(--text-primary); font: inherit; font-size: .86rem; resize: vertical; }
+.dv-element .dv-fine { margin: 0; font-size: .76rem; }
+.dv-element button[type=submit] { width: 100%; }
+@media (max-width: 700px) {
+ .dv-chat { margin-bottom: 12px; padding: 10px 12px; }
+ .dv-chat-row { flex-wrap: wrap; }
+ .dv-chat-row button { width: 100%; }
+ .dv-card-actions { min-height: 34px; }
+ .dv-versions { margin-bottom: 104px; }
+ .dv-element { left: 12px; right: 12px; bottom: calc(var(--ovnav-appbar-h, 56px) + 76px); width: auto; max-height: 56vh; }
+}
+@media (prefers-reduced-motion: reduce) { .dv-pick { transition: none; } }
@@ -10,7 +10,27 @@
@@ -10,7 +10,27 @@
}
}
const canvas = document.querySelector('.dv-canvas');
const canvas = document.querySelector('.dv-canvas');
if (!canvas) return;
if (!canvas) return;
+ const designId = canvas.dataset.id;
+ const designKey = canvas.dataset.key;
+ const picked = canvas.dataset.picked == null || canvas.dataset.picked === '' ? -1 : Number(canvas.dataset.picked);
+ const status = document.getElementById('dv-status');
+ const say = (text, isError = false) => {
+ if (!status) return;
+ status.textContent = text || '';
+ status.classList.toggle('is-error', Boolean(isError));
+ };
+ const api = (path, body) => fetch(`/api/v1/designs/${encodeURIComponent(designId)}${path}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...(designKey ? { 'x-design-key': designKey } : {}) },
+ body: JSON.stringify(body || {}),
+ });
+ const problem = async (response) => {
+ try { const body = await response.json(); return body.detail || body.title || `Request failed (${response.status}).`; }
+ catch { return `Request failed (${response.status}).`; }
+ };
const frames = () => [...canvas.querySelectorAll('.dv-card iframe')];
const frames = () => [...canvas.querySelectorAll('.dv-card iframe')];
+
+ // ── Device widths and theme repainting ──
const device = document.getElementById('dv-device');
const device = document.getElementById('dv-device');
device.value = '390';
device.value = '390';
function resize() {
function resize() {
@@ -30,31 +50,150 @@
@@ -30,31 +50,150 @@
let cssByTheme = {};
let cssByTheme = {};
try { cssByTheme = JSON.parse(document.getElementById('dv-theme-css').textContent); } catch { /* form still works */ }
try { cssByTheme = JSON.parse(document.getElementById('dv-theme-css').textContent); } catch { /* form still works */ }
const themeForm = document.getElementById('dv-theme-form');
const themeForm = document.getElementById('dv-theme-form');
+ const themeSelect = document.getElementById('dv-theme');
canvas.classList.add('dv-enhanced');
canvas.classList.add('dv-enhanced');
- document.getElementById('dv-theme').addEventListener('change', () => themeForm.requestSubmit());
frames().forEach(frame => frame.addEventListener('load', () => {
frames().forEach(frame => frame.addEventListener('load', () => {
- const css = cssByTheme[document.getElementById('dv-theme').value];
+ const css = cssByTheme[themeSelect.value];
if (css) frame.contentWindow.postMessage({ type: 'design-theme', css }, '*');
if (css) frame.contentWindow.postMessage({ type: 'design-theme', css }, '*');
}));
}));
+ themeSelect.addEventListener('change', () => themeForm.requestSubmit());
themeForm.addEventListener('submit', async event => {
themeForm.addEventListener('submit', async event => {
event.preventDefault();
event.preventDefault();
- const themeId = document.getElementById('dv-theme').value;
+ const themeId = themeSelect.value;
const css = cssByTheme[themeId];
const css = cssByTheme[themeId];
if (!css) { themeForm.submit(); return; }
if (!css) { themeForm.submit(); return; }
frames().forEach(frame => frame.contentWindow.postMessage({ type: 'design-theme', css }, '*'));
frames().forEach(frame => frame.contentWindow.postMessage({ type: 'design-theme', css }, '*'));
try {
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.');
+ const response = await api('/theme', { themeId });
+ if (!response.ok) throw new Error(await problem(response));
+ } catch (error) {
+ say(error.message, true);
+ }
+ });
+
+ // ── Pick one frame: one Arena vote, idempotent on the server ──
+ document.getElementById('dv-rail').addEventListener('submit', async event => {
+ const form = event.target.closest('form');
+ const button = form && form.querySelector('.dv-pick');
+ if (!button) return;
+ event.preventDefault();
+ const variantIndex = Number(button.dataset.pick);
+ button.disabled = true;
+ say('Choosing this frame…');
+ try {
+ const response = await api('/pick', { variantIndex });
+ if (!response.ok) throw new Error(await problem(response));
+ location.reload();
+ } catch (error) {
+ button.disabled = false;
+ say(error.message, true);
+ }
+ });
+
+ // ── Chat edit: progress and errors keep the last good version on screen ──
+ const chatForm = document.getElementById('dv-chat-form');
+ chatForm.addEventListener('submit', async event => {
+ event.preventDefault();
+ const input = document.getElementById('dv-instruction');
+ const button = chatForm.querySelector('button[type=submit]');
+ const editStatus = document.getElementById('dv-edit-status');
+ const instruction = input.value.trim();
+ const note = (text, isError = false) => {
+ editStatus.textContent = text || '';
+ editStatus.classList.toggle('is-error', Boolean(isError));
+ };
+ if (!instruction) { note('Describe the change first.', true); input.focus(); return; }
+ button.disabled = true;
+ input.disabled = true;
+ note('Editing the working frame… this can take a moment.');
+ try {
+ const response = await api('/edit', { instruction });
+ if (!response.ok) throw new Error(await problem(response));
+ const body = await response.json();
+ note(`Saved as version ${body.version ? body.version.seq : '—'}. Reloading…`);
+ location.reload();
+ } catch (error) {
+ note(error.message, true);
+ button.disabled = false;
+ input.disabled = false;
+ }
+ });
+
+ // ── Click edit: validate the iframe message before trusting any field ──
+ const panel = document.getElementById('dv-element');
+ const panelForm = document.getElementById('dv-element-form');
+ const elementText = document.getElementById('dv-element-text');
+ const elementNote = document.getElementById('dv-element-note');
+ const elementColor = document.getElementById('dv-element-color');
+ const elementSpacing = document.getElementById('dv-element-spacing');
+ const elementAlign = document.getElementById('dv-element-align');
+ const elementStatus = document.getElementById('dv-element-status');
+ const elementClose = document.getElementById('dv-element-close');
+ const pathPattern = /^body(?: > [a-z][a-z0-9-]*:nth-of-type\(\d{1,4}\))*$/;
+ let selection = null;
+ const panelSay = (text, isError = false) => {
+ elementStatus.textContent = text || '';
+ elementStatus.classList.toggle('is-error', Boolean(isError));
+ };
+ function closePanel() {
+ panel.hidden = true;
+ selection = null;
+ }
+ elementClose.addEventListener('click', closePanel);
+ document.addEventListener('keydown', event => { if (event.key === 'Escape' && !panel.hidden) closePanel(); });
+
+ window.addEventListener('message', event => {
+ if (picked < 0 || !event.data || typeof event.data !== 'object' || Array.isArray(event.data)) return;
+ const data = event.data;
+ if (data.type !== 'design-select') return;
+ if (event.origin !== 'null' && event.origin !== '') return;
+ const allowed = new Set(['type', 'path', 'tag', 'leaf', 'text']);
+ if (!Object.keys(data).every(key => allowed.has(key))) return;
+ if (typeof data.path !== 'string' || data.path.length > 400 || !pathPattern.test(data.path)) return;
+ if (typeof data.tag !== 'string' || !/^[a-z][a-z0-9-]*$/.test(data.tag) || data.tag.length > 32) return;
+ if (typeof data.leaf !== 'boolean' || typeof data.text !== 'string' || data.text.length > 400) return;
+ const target = frames().find(frame => frame.contentWindow === event.source);
+ if (!target || Number(target.closest('.dv-card').dataset.index) !== picked) return;
+ selection = { path: data.path, tag: data.tag, leaf: data.leaf, text: data.text };
+ document.getElementById('dv-element-tag').textContent = data.tag;
+ elementText.value = data.text;
+ elementText.disabled = !data.leaf;
+ elementText.hidden = !data.leaf;
+ elementNote.hidden = data.leaf;
+ elementColor.value = '';
+ elementSpacing.value = '';
+ elementAlign.value = '';
+ panelSay('');
+ panel.hidden = false;
+ if (data.leaf) elementText.focus(); else elementColor.focus();
+ });
+
+ panelForm.addEventListener('submit', async event => {
+ event.preventDefault();
+ if (!selection) return;
+ const body = { path: selection.path };
+ if (elementText.value !== selection.text) body.text = elementText.value;
+ if (elementColor.value) body.color = elementColor.value;
+ if (elementSpacing.value) body.spacing = elementSpacing.value;
+ if (elementAlign.value) body.align = elementAlign.value;
+ if (Object.keys(body).length === 1) { panelSay('Choose at least one change.', true); return; }
+ const button = panelForm.querySelector('button[type=submit]');
+ button.disabled = true;
+ panelSay('Saving a new version…');
+ try {
+ const response = await api('/click', body);
+ if (!response.ok) throw new Error(await problem(response));
+ const saved = await response.json();
+ panelSay(`Saved as version ${saved.version ? saved.version.seq : '—'}. Reloading…`);
+ location.reload();
} catch (error) {
} catch (error) {
- document.getElementById('dv-status').textContent = error.message;
+ panelSay(error.message, true);
+ button.disabled = false;
}
}
});
});
if ([...canvas.querySelectorAll('.dv-card')].every(card => ['done', 'failed'].includes(card.dataset.status))) return;
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 url = `/api/v1/designs/${encodeURIComponent(designId)}${designKey ? `?key=${encodeURIComponent(designKey)}` : ''}`;
const initial = [...canvas.querySelectorAll('.dv-card')].map(card => card.dataset.status).join(',');
const initial = [...canvas.querySelectorAll('.dv-card')].map(card => card.dataset.status).join(',');
const timer = setInterval(async () => {
const timer = setInterval(async () => {
try {
try {
@@ -66,7 +66,7 @@ async function createApp(opts = {}) {
@@ -66,7 +66,7 @@ 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 designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, 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 })
@@ -15,6 +15,7 @@ const BENCH_CAPABILITIES = Object.freeze([
@@ -15,6 +15,7 @@ const BENCH_CAPABILITIES = Object.freeze([
'bench.round.create',
'bench.round.create',
'bench.round.read',
'bench.round.read',
'bench.judgement.cast',
'bench.judgement.cast',
+ 'bench.entry.create',
'bench.board.read',
'bench.board.read',
].join(' '));
].join(' '));
@@ -115,6 +116,19 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -115,6 +116,19 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
return { task, round: { ...out.round, task_class: task.task_class, prompt: task.prompt } };
return { task, round: { ...out.round, task_class: task.task_class, prompt: task.prompt } };
}
}
+ /**
+ * A round the room fills with its own entries (Bench's POST /rounds/:id/entries). It is created with the
+ * service token, so the person who later chooses between the room's entries is not the round's creator and
+ * Bench accepts their one vote. The service token needs bench.round.create and bench.entry.create.
+ */
+ async function createRoomRound({ category, prompt, offers = null }) {
+ const task = await createTask({ category, prompt });
+ const body = {};
+ if (offers) body.offers = offers;
+ const out = await request('POST', `/api/v1/tasks/${encodeURIComponent(task.id)}/rounds`, { body });
+ return { task, round: { ...out.round, task_class: task.task_class, prompt: task.prompt } };
+ }
+
async function getTask(id) {
async function getTask(id) {
const out = await request('GET', `/api/v1/tasks/${encodeURIComponent(id)}`);
const out = await request('GET', `/api/v1/tasks/${encodeURIComponent(id)}`);
return out.task;
return out.task;
@@ -180,6 +194,7 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -180,6 +194,7 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
kindForCategory,
kindForCategory,
createTask,
createTask,
createRound,
createRound,
+ createRoomRound,
getRound,
getRound,
nextPair,
nextPair,
castVote,
castVote,
@@ -70,7 +70,12 @@ function load(env = process.env) {
@@ -70,7 +70,12 @@ function load(env = process.env) {
url: trim(env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230'),
url: trim(env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230'),
publicUrl: trim(env.OV_BENCH_URL || 'https://bench.openvibe.services'),
publicUrl: trim(env.OV_BENCH_URL || 'https://bench.openvibe.services'),
audience: env.DESIGN_BENCH_AUDIENCE || 'openvibe.bench',
audience: env.DESIGN_BENCH_AUDIENCE || 'openvibe.bench',
- scope: env.DESIGN_BENCH_SCOPE || 'bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.board.read',
+ scope: env.DESIGN_BENCH_SCOPE || 'bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.entry.create bench.board.read',
+ },
+
+ // Design Studio part B: a small per-design AI edit budget (click edits and undo do not spend AI).
+ studio: {
+ chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),
},
},
// Arena budgets and jobs. Both limits are per UTC day; the drill flag keeps the worker from starting.
// Arena budgets and jobs. Both limits are per UTC day; the drill flag keeps the worker from starting.
new file mode 100644
new file mode 100644
@@ -0,0 +1,286 @@
@@ -0,0 +1,286 @@
+'use strict';
+
+/**
+ * Click edits: turn a clicked element path into one safe change, server-side.
+ *
+ * The iframe reports `body > section:nth-of-type(1) > h1:nth-of-type(1)`; nothing else about the document is
+ * trusted. This module finds that element in the saved HTML, accepts only the four changes the panel offers
+ * (text, a theme token colour, a spacing preset and an alignment), and writes an escaped text node and a rebuilt
+ * inline `style` attribute. It never takes an attribute name, a CSS property or a CSS value from the client, so a
+ * path can select an element but cannot add a tag, an event handler, a URL or a fetch.
+ */
+
+const PATH_MAX = 400;
+const DEPTH_MAX = 12;
+const TEXT_MAX = 1000;
+const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
+const RAW_TEXT = new Set(['style', 'textarea', 'title', 'xmp']);
+// Tags a click edit may never target: they are document plumbing or active content, and the sanitizer has
+// already removed most of them. `button` and `a` stay allowed: rewriting their label is a normal design edit.
+const UNSAFE_TAGS = new Set(['script', 'style', 'html', 'head', 'title', 'meta', 'link', 'base', 'iframe', 'object',
+ 'embed', 'applet', 'template', 'noscript', 'svg', 'math', 'form', 'input', 'textarea', 'select']);
+const TOKEN_ALIASES = Object.freeze(['--bg-card', '--bg-secondary', '--text-muted', '--accent', '--on-accent', '--border']);
+const SPACING = Object.freeze({ none: '0', sm: '8px', md: '16px', lg: '24px' });
+const ALIGN = Object.freeze(['left', 'center', 'right']);
+const CHANGED_PROPERTIES = Object.freeze(['color', 'padding', 'text-align']);
+
+class EditError extends Error {
+ constructor(status, code, detail) {
+ super(detail);
+ this.name = 'EditError';
+ this.status = status;
+ this.code = code;
+ this.detail = detail;
+ }
+}
+
+const bad = (detail, code = 'design.invalid_change') => new EditError(422, code, detail);
+
+function tagEnd(s, start) {
+ let quote = null;
+ for (let i = start + 1; i < s.length; i++) {
+ const c = s[i];
+ if (quote) { if (c === quote) quote = null; }
+ else if (c === '"' || c === "'") quote = c;
+ else if (c === '>') return i;
+ }
+ return -1;
+}
+
+const pathRegex = /^body(?: > [a-z][a-z0-9-]*:nth-of-type\(\d{1,4}\))*$/;
+
+/** The requested path, or 422. */
+function readPath(raw) {
+ if (typeof raw !== 'string' || !raw || raw.length > PATH_MAX) throw bad('Send the clicked element path.');
+ if (!pathRegex.test(raw)) throw bad('That element path is not allowed.');
+ const segments = raw.split(' > ');
+ if (segments.length - 1 > DEPTH_MAX) throw bad('That element is nested too deeply.');
+ const tail = segments.slice(1).map((seg) => {
+ const m = /^([a-z][a-z0-9-]*):nth-of-type\((\d{1,4})\)$/.exec(seg);
+ if (!m) throw bad('That element path is not allowed.');
+ const tag = m[1];
+ const index = Number(m[2]);
+ if (UNSAFE_TAGS.has(tag)) throw bad(`A ${tag} element cannot be edited.`, 'design.unsafe_element');
+ if (index < 1 || index > 500) throw bad('That element path is not allowed.');
+ return { tag, index };
+ });
+ return { raw, tail };
+}
+
+/** A flat tree walk that mirrors the browser's element order closely enough for stable nth-of-type paths. */
+function tree(html) {
+ const root = { tag: '#root', children: [], parent: null, start: 0, openEnd: 0, closeStart: null, closeEnd: null, index: 0 };
+ const stack = [root];
+ const closeImplicit = (tag, at) => {
+ const top = stack[stack.length - 1];
+ const closes = (tag === 'p' && top.tag === 'p') || (tag === 'li' && top.tag === 'li')
+ || ((tag === 'tr') && top.tag === 'tr')
+ || ((tag === 'td' || tag === 'th') && (top.tag === 'td' || top.tag === 'th'))
+ || (tag === 'option' && top.tag === 'option')
+ || ((tag === 'thead' || tag === 'tbody' || tag === 'tfoot') && ['thead', 'tbody', 'tfoot'].includes(top.tag));
+ if (!closes) return;
+ stack.pop();
+ if (top.closeStart == null) { top.closeStart = at; top.closeEnd = at; }
+ };
+ let i = 0;
+ while (i < html.length) {
+ const lt = html.indexOf('<', i);
+ if (lt === -1) break;
+ const next = html[lt + 1] || '';
+ if (next === '!' || next === '?') {
+ const end = html.indexOf('>', lt);
+ i = end === -1 ? html.length : end + 1;
+ continue;
+ }
+ if (next === '/') {
+ const m = /^<\/\s*([a-z][a-z0-9-]*)/i.exec(html.slice(lt, lt + 80));
+ if (!m) { i = lt + 1; continue; }
+ const tag = m[1].toLowerCase();
+ const end = tagEnd(html, lt);
+ if (end === -1) break;
+ let k = stack.length - 1;
+ while (k > 0 && stack[k].tag !== tag) k--;
+ if (k > 0) {
+ for (let j = stack.length - 1; j > k; j--) {
+ const el = stack[j];
+ if (el.closeStart == null) { el.closeStart = lt; el.closeEnd = lt; }
+ }
+ const el = stack[k];
+ if (el.closeStart == null) { el.closeStart = lt; el.closeEnd = end + 1; }
+ stack.length = k;
+ }
+ i = end + 1;
+ continue;
+ }
+ if (!/[a-z]/i.test(next)) { i = lt + 1; continue; }
+ const end = tagEnd(html, lt);
+ if (end === -1) break;
+ const raw = html.slice(lt, end + 1);
+ const m = /^<\s*([a-z][a-z0-9-]*)/i.exec(raw);
+ if (!m) { i = end + 1; continue; }
+ const tag = m[1].toLowerCase();
+ closeImplicit(tag, lt);
+ const parent = stack[stack.length - 1];
+ const index = parent.children.filter((c) => c.tag === tag).length + 1;
+ const el = { tag, start: lt, openEnd: end + 1, closeStart: null, closeEnd: null, parent, children: [], index };
+ parent.children.push(el);
+ if (!VOID.has(tag) && !/\/\s*>$/.test(raw)) {
+ if (RAW_TEXT.has(tag)) {
+ const close = new RegExp(`</\\s*${tag}\\s*>`, 'i');
+ const rest = html.slice(end + 1);
+ const found = close.exec(rest);
+ if (found) {
+ el.closeStart = end + 1 + found.index;
+ el.closeEnd = el.closeStart + found[0].length;
+ i = el.closeEnd;
+ continue;
+ }
+ }
+ stack.push(el);
+ }
+ i = end + 1;
+ }
+ // Mark every unclosed element at the end of the document as implicitly closed at the end.
+ for (let j = stack.length - 1; j > 0; j--) {
+ const el = stack[j];
+ if (el.closeStart == null) { el.closeStart = html.length; el.closeEnd = html.length; }
+ }
+ return root;
+}
+
+function pathOf(el) {
+ const parts = [];
+ let cur = el;
+ while (cur && cur.tag !== '#root') {
+ if (cur.tag === 'body') { parts.unshift('body'); break; }
+ if (cur.tag === 'html' || cur.tag === 'head') return null;
+ parts.unshift(`${cur.tag}:nth-of-type(${cur.index})`);
+ cur = cur.parent;
+ }
+ return parts[0] === 'body' ? parts.join(' > ') : null;
+}
+
+function findBody(node) {
+ for (const child of node.children) {
+ if (child.tag === 'body') return child;
+ const found = findBody(child);
+ if (found) return found;
+ }
+ return null;
+}
+
+function findElement(html, rawPath) {
+ const path = readPath(rawPath);
+ const root = tree(html);
+ const body = findBody(root);
+ if (!body) throw bad('That element is not in the saved document.', 'design.path_not_found');
+ let current = body;
+ for (let i = 0; i < path.tail.length; i++) {
+ const segment = path.tail[i];
+ const found = current.children.find((el) => el.tag === segment.tag && el.index === segment.index);
+ if (!found) {
+ // A browser inserts tbody when the saved table has a tr directly under it; the overlay path includes
+ // the virtual wrapper, so skip it while there is a real element after it.
+ if (segment.tag === 'tbody' && current.tag === 'table' && i < path.tail.length - 1) continue;
+ throw bad('That element is not in the saved document.', 'design.path_not_found');
+ }
+ current = found;
+ }
+ if (!pathOf(current)) throw bad('That element cannot be edited.', 'design.unsafe_element');
+ return { path, element: current, body };
+}
+
+/** A style attribute rebuilt from safe declarations only; target properties are replaced, the rest are kept. */
+function safeDeclaration(property, value) {
+ return /^-?[a-z][a-z0-9-]*$/.test(property)
+ && typeof value === 'string' && value.length <= 200
+ && !/[<>\\{}]/.test(value)
+ && !/url\s*\(|expression|javascript\s*:|@import|behavior\s*:|-moz-binding/i.test(value);
+}
+
+function parseStyle(style) {
+ return String(style || '').split(';').map((part) => part.trim()).filter(Boolean).map((part) => {
+ const colon = part.indexOf(':');
+ if (colon < 1) return null;
+ const property = part.slice(0, colon).trim().toLowerCase();
+ const value = part.slice(colon + 1).trim();
+ return safeDeclaration(property, value) ? { property, value } : null;
+ }).filter(Boolean);
+}
+
+function changedStyle(existing, changes) {
+ const replacing = new Set(CHANGED_PROPERTIES.filter((p) => changes[p] != null));
+ const kept = parseStyle(existing).filter((decl) => !replacing.has(decl.property));
+ return kept.concat(CHANGED_PROPERTIES.filter((p) => changes[p] != null).map((property) => ({ property, value: changes[property] })))
+ .map((decl) => `${decl.property}:${decl.value}`).join(';');
+}
+
+function rewriteOpeningTag(raw, changes) {
+ const existing = /\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(raw);
+ if (Object.keys(changes).length === 0) return raw;
+ const style = changedStyle(existing ? (existing[1] != null ? existing[1] : existing[2]) : '', changes);
+ const value = style.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
+ const attribute = ` style="${value}"`;
+ if (existing) {
+ return raw.replace(/\sstyle\s*=\s*(?:"[^"]*"|'[^']*')/i, attribute);
+ }
+ return /\/\s*>$/.test(raw) ? raw.replace(/\/\s*>$/, `${attribute}/>`) : raw.replace(/\s*>$/, `${attribute}>`);
+}
+
+const escapeText = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
+
+/** Validate one change against the theme; returns only known properties with server-owned values. */
+function validateChange({ path, text, color, spacing, align }, variables) {
+ const requested = readPath(path);
+ const changes = {};
+ let any = false;
+ if (text !== undefined && text !== null) {
+ if (typeof text !== 'string' || text.length > TEXT_MAX) throw bad(`The text must be at most ${TEXT_MAX} characters.`);
+ if (/[\u0000]/.test(text)) throw bad('That text is not allowed.');
+ changes.text = text;
+ any = true;
+ }
+ if (color !== undefined && color !== null && color !== '') {
+ if (typeof color !== 'string' || !/^--[a-z][a-z0-9-]{1,40}$/.test(color)) throw bad('Pick a theme colour token.', 'design.unsafe_css');
+ const value = variables && Object.prototype.hasOwnProperty.call(variables, color) ? String(variables[color]) : null;
+ const colourValue = value != null && /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\()/i.test(value);
+ if (!colourValue && !TOKEN_ALIASES.includes(color)) throw bad('That colour token is not part of this theme.', 'design.unsafe_css');
+ changes.color = `var(${color})`;
+ any = true;
+ }
+ if (spacing !== undefined && spacing !== null && spacing !== '') {
+ if (typeof spacing !== 'string' || !Object.prototype.hasOwnProperty.call(SPACING, spacing)) throw bad('Pick a spacing preset.', 'design.unsafe_css');
+ changes.padding = Object.prototype.hasOwnProperty.call(SPACING, spacing) ? SPACING[spacing] : null;
+ any = true;
+ }
+ if (align !== undefined && align !== null && align !== '') {
+ if (typeof align !== 'string' || !ALIGN.includes(align)) throw bad('Pick left, centre or right.', 'design.unsafe_css');
+ changes['text-align'] = align;
+ any = true;
+ }
+ if (!any) throw bad('Choose at least one change.');
+ return { ...requested, path: requested.raw, changes, spacing: spacing || null };
+}
+
+function applyChange(html, change) {
+ const found = findElement(html, change.path);
+ const { element } = found;
+ if (change.changes.text !== undefined && element.children.length > 0)
+ throw bad('This element contains other elements, so its text cannot be replaced. Pick the text element inside it.', 'design.not_leaf');
+ let out = String(html);
+ if (change.changes.text !== undefined) {
+ if (element.closeStart == null) throw bad('That element is not closed, so it cannot be edited.', 'design.invalid_change');
+ out = `${out.slice(0, element.openEnd)}${escapeText(change.changes.text)}${out.slice(element.closeStart)}`;
+ }
+ const styleChanges = {};
+ for (const property of CHANGED_PROPERTIES) if (change.changes[property] !== undefined) styleChanges[property] = change.changes[property];
+ if (Object.keys(styleChanges).length) {
+ const open = out.slice(element.start, element.openEnd);
+ const rewritten = rewriteOpeningTag(open, styleChanges);
+ out = `${out.slice(0, element.start)}${rewritten}${out.slice(element.openEnd)}`;
+ }
+ return out;
+}
+
+module.exports = { EditError, readPath, findElement, tree, pathOf, validateChange, applyChange, changedStyle,
+ escapeText, TOKEN_ALIASES, SPACING, ALIGN, UNSAFE_TAGS, CHANGED_PROPERTIES, TEXT_MAX };
@@ -2,11 +2,14 @@
@@ -2,11 +2,14 @@
const crypto = require('crypto');
const crypto = require('crypto');
const { sanitizeHtml, MAX_BYTES } = require('./arena/sanitize');
const { sanitizeHtml, MAX_BYTES } = require('./arena/sanitize');
+const { validateChange, applyChange } = require('./design-edit');
const KINDS = ['page', 'screen', 'component'];
const KINDS = ['page', 'screen', 'component'];
const random = () => crypto.randomBytes(24).toString('base64url');
const random = () => crypto.randomBytes(24).toString('base64url');
const hash = (key) => crypto.createHash('sha256').update(key).digest();
const hash = (key) => crypto.createHash('sha256').update(key).digest();
const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });
const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });
+const CHAT_INSTRUCTION_MAX = 1000;
+const CHAT_EDITS_DEFAULT = 5;
function tokenCss(variables = {}) {
function tokenCss(variables = {}) {
return Object.entries(variables)
return Object.entries(variables)
@@ -62,12 +65,19 @@ function wrap(html, variables) {
@@ -62,12 +65,19 @@ function wrap(html, variables) {
const tokens = `<style id="design-tokens">${themeCss(variables)}</style>`;
const tokens = `<style id="design-tokens">${themeCss(variables)}</style>`;
if (!/<html\b/i.test(clean)) clean = `<html><head></head><body>${clean}</body></html>`;
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>');
if (!/<head\b/i.test(clean)) clean = clean.replace(/<html\b[^>]*>/i, '$&<head></head>');
+ // Click edits address elements by a `body > …` path, so every stored document has an explicit body.
+ if (!/<body\b/i.test(clean)) clean = clean.replace(/<\/head\s*>/i, '</head><body>');
+ if (!/<body\b/i.test(clean)) clean = clean.replace(/<html\b[^>]*>/i, '$&<body>');
+ if (!/<\/body\s*>/i.test(clean)) {
+ clean = /<\/html\s*>/i.test(clean) ? clean.replace(/<\/html\s*>/i, '</body></html>') : `${clean}</body>`;
+ }
clean = clean.replace(/<head\b[^>]*>/i, `$&${tokens}`);
clean = clean.replace(/<head\b[^>]*>/i, `$&${tokens}`);
return `<!doctype html>${clean.replace(/^\s*<!doctype\s+html[^>]*>/i, '')}`;
return `<!doctype html>${clean.replace(/^\s*<!doctype\s+html[^>]*>/i, '')}`;
}
}
-function createDesigns({ s, ai, catalog, log = console, now = () => Date.now() }) {
+function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now() }) {
const db = s.db;
const db = s.db;
+ const chatEditLimit = Math.max(1, Number(config.studio && config.studio.chatEditsPerDesign) || CHAT_EDITS_DEFAULT);
const publicRow = d => ({ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
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')
status: d.variants.every(v => v.status === 'done' || v.status === 'failed')
? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
@@ -133,11 +143,286 @@ function createDesigns({ s, ai, catalog, log = console, now = () => Date.now() }
@@ -133,11 +143,286 @@ function createDesigns({ s, ai, catalog, log = console, now = () => Date.now() }
}
}
}));
}));
}
}
+
+ // ── The working frame, its versions and the Arena vote ──────────────────
+ const pickRow = (id) => db.maybe('SELECT * FROM design_picks WHERE design_id=$1', [id]);
+ const versionRow = (id, seq) => db.maybe('SELECT * FROM design_versions WHERE design_id=$1 AND seq=$2', [id, seq]);
+ const versionPublic = (v, currentSeq) => ({
+ seq: Number(v.seq), kind: v.kind, instruction: v.instruction || null,
+ change: v.change && typeof v.change === 'object' ? v.change : null,
+ model: v.model || null, provider: v.provider || null,
+ costUsd: Number(v.cost_usd || 0), ms: Number(v.ms || 0),
+ parentSeq: v.parent_seq == null ? null : Number(v.parent_seq),
+ createdAt: v.created_at, current: Number(v.seq) === Number(currentSeq),
+ });
+ function pickPublic(p, current) {
+ if (!p) return null;
+ const working = current ? versionPublic(current, p.current_seq) : null;
+ return {
+ variantIndex: Number(p.variant_index), variantId: p.variant_id,
+ currentSeq: Number(p.current_seq), chatEdits: Number(p.chat_edits || 0), chatEditLimit,
+ vote: { state: p.vote_state, roundId: p.bench_round_id || null, judgementId: p.bench_judgement_id || null,
+ error: p.vote_error || null },
+ working, createdAt: p.created_at, updatedAt: p.updated_at,
+ canUndo: Boolean(current && current.parent_seq != null),
+ canRedo: Array.isArray(p.redo_stack) && p.redo_stack.length > 0,
+ };
+ }
+ const categoryOf = (kind) => kind === 'component' ? 'logo' : kind === 'screen' ? 'mobile_app' : 'landing';
+ const pickRunId = (v) => `dsp_${v.id}`;
+
+ /** Record the pick once: create the Arena round, submit the variants as room entries and cast one vote. */
+ async function recordPickVote(d, subject, token) {
+ let p = await pickRow(d.id);
+ if (!p) return { state: 'none' };
+ if (p.vote_state === 'recorded') return { state: 'recorded', roundId: p.bench_round_id, judgementId: p.bench_judgement_id };
+ if (!subject || !token) {
+ await db.exec("UPDATE design_picks SET vote_state='guest', updated_at=$2 WHERE design_id=$1 AND vote_state<>'recorded'", [d.id, s.iso()]);
+ return { state: 'guest', detail: 'Sign in to count this pick as one Arena vote.' };
+ }
+ if (!bench || !bench.enabled || typeof bench.createRoomRound !== 'function') {
+ await db.exec("UPDATE design_picks SET vote_state='unavailable', updated_at=$2 WHERE design_id=$1 AND vote_state<>'recorded'", [d.id, s.iso()]);
+ return { state: 'unavailable', detail: 'The Arena is not configured; the working frame is still saved.' };
+ }
+ // One in-flight vote per design: a double click must not create two Bench rounds. A pending claim older
+ // than five minutes is retried, so a process that stopped halfway does not lose the vote forever.
+ const claimed = await db.maybe(`UPDATE design_picks SET vote_state='pending', updated_at=$2
+ WHERE design_id=$1 AND (vote_state IN ('none','guest','unavailable','failed')
+ OR (vote_state='pending' AND updated_at < $2::timestamptz - interval '5 minutes'))
+ RETURNING *`, [d.id, s.iso()]);
+ if (!claimed) {
+ const now = await pickRow(d.id);
+ if (now && now.vote_state === 'recorded') return { state: 'recorded', roundId: now.bench_round_id, judgementId: now.bench_judgement_id };
+ return { state: 'pending', detail: 'The Arena vote is being recorded.' };
+ }
+ p = claimed;
+ try {
+ let roundId = p.bench_round_id;
+ if (!roundId) {
+ const out = await bench.createRoomRound({ category: categoryOf(d.kind), prompt: d.prompt });
+ roundId = out && out.round && out.round.id;
+ if (!roundId) throw new Error('OpenVibe.Bench did not return a round.');
+ await db.exec("UPDATE design_picks SET bench_round_id=$2, vote_state='pending', vote_error=NULL, updated_at=$3 WHERE design_id=$1", [d.id, roundId, s.iso()]);
+ p = await pickRow(d.id);
+ }
+ if (!p.bench_left_output || !p.bench_right_output) {
+ const round = await bench.getRound(roundId);
+ const seen = new Map(((round && round.entries) || []).map((e) => [e.run_id, e.id]));
+ const done = d.variants.map((v, index) => ({ v, index })).filter((x) => x.v.status === 'done');
+ const entries = [];
+ for (const { v, index } of done) {
+ let entryId = seen.get(pickRunId(v));
+ if (!entryId) {
+ const added = await bench.addEntry({ roundId, provider: v.provider, model: v.model,
+ route: { provider: v.provider, model: v.model }, payload: { html: v.html },
+ costUsd: Number(v.costUsd || 0), ms: Number(v.ms || 0), tokens: Number(v.tokens || 0), runId: pickRunId(v) });
+ entryId = added && added.entry ? added.entry.id : (added && added.id);
+ }
+ if (entryId) entries.push({ index, id: entryId });
+ }
+ const chosen = entries.find((e) => e.index === Number(p.variant_index));
+ const other = entries.find((e) => e.index !== Number(p.variant_index));
+ if (!chosen || !other) {
+ await db.exec("UPDATE design_picks SET vote_state='unavailable', vote_error='A pick vote needs two finished variants.', updated_at=$2 WHERE design_id=$1", [d.id, s.iso()]);
+ return { state: 'unavailable', detail: 'A pick vote needs two finished variants.' };
+ }
+ await db.exec("UPDATE design_picks SET bench_left_output=$2, bench_right_output=$3, vote_state='pending', vote_error=NULL, updated_at=$4 WHERE design_id=$1", [d.id, chosen.id, other.id, s.iso()]);
+ p = await pickRow(d.id);
+ }
+ if (!p.bench_judgement_id) {
+ const out = await bench.castVote({ roundId, token, pair: { left: p.bench_left_output, right: p.bench_right_output }, winner: 'left' });
+ const judgementId = out && out.judgement ? out.judgement.id : null;
+ await db.exec("UPDATE design_picks SET vote_state='recorded', bench_judgement_id=$2, vote_error=NULL, updated_at=$3 WHERE design_id=$1", [d.id, judgementId, s.iso()]);
+ return { state: 'recorded', roundId, judgementId };
+ }
+ await db.exec("UPDATE design_picks SET vote_state='recorded', vote_error=NULL, updated_at=$2 WHERE design_id=$1", [d.id, s.iso()]);
+ return { state: 'recorded', roundId, judgementId: p.bench_judgement_id };
+ } catch (err) {
+ if (err && err.code === 'arena.already_voted') {
+ await db.exec("UPDATE design_picks SET vote_state='recorded', vote_error=NULL, updated_at=$2 WHERE design_id=$1", [d.id, s.iso()]);
+ return { state: 'recorded', roundId: p.bench_round_id || null, detail: 'This pick was already counted.' };
+ }
+ const detail = String((err && (err.detail || err.message)) || err).slice(0, 300);
+ await db.exec("UPDATE design_picks SET vote_state='failed', vote_error=$2, updated_at=$3 WHERE design_id=$1", [d.id, detail, s.iso()]);
+ log.warn('[OpenVibe.Design] pick vote:', detail);
+ return { state: 'failed', detail: 'The working frame is saved; the Arena vote could not be recorded.' };
+ }
+ }
+
+ async function ensureBaseVersion(d, p, variant) {
+ const count = Number(await db.value('SELECT COUNT(*) FROM design_versions WHERE design_id=$1', [d.id]));
+ if (count > 0) return;
+ await db.exec(`INSERT INTO design_versions(design_id,seq,variant_index,variant_id,kind,html,model,provider,cost_usd,ms,parent_seq,created_by,created_at)
+ VALUES($1,1,$2,$3,'generated',$4,$5,$6,$7,$8,NULL,$9,$10::timestamptz) ON CONFLICT (design_id,seq) DO NOTHING`,
+ [d.id, Number(p.variant_index), p.variant_id, variant.html || '', variant.model || null, variant.provider || null,
+ Number(variant.costUsd || 0), Number(variant.ms || 0), d.owner || 'guest', s.iso()]);
+ await db.exec('UPDATE design_picks SET current_seq=1, updated_at=$2 WHERE design_id=$1', [d.id, s.iso()]);
+ }
+
+ async function addVersion(d, p, data) {
+ // The sequence is allocated inside the INSERT, so two accepted edits at once cannot pick the same seq.
+ const row = await db.maybe(`INSERT INTO design_versions(design_id,seq,variant_index,variant_id,kind,html,instruction,change,model,provider,cost_usd,ms,parent_seq,created_by,created_at)
+ SELECT $1, COALESCE(MAX(seq),0)+1, $2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,$11,$12,$13,$14::timestamptz
+ FROM design_versions WHERE design_id=$1 RETURNING *`,
+ [d.id, Number(p.variant_index), p.variant_id, data.kind, data.html, data.instruction || null,
+ data.change ? JSON.stringify(data.change) : null, data.model || null, data.provider || null,
+ Number(data.costUsd || 0), Number(data.ms || 0), Number(p.current_seq), data.createdBy || (d.owner || 'guest'), s.iso()]);
+ await db.exec("UPDATE design_picks SET current_seq=$2, redo_stack='[]'::jsonb, updated_at=$3 WHERE design_id=$1", [d.id, Number(row.seq), s.iso()]);
+ return row;
+ }
+
async function read(id, owner, key) {
async function read(id, owner, key) {
const d = await row(id, owner, key), selected = await theme(d.theme_id);
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) };
+ const p = await pickRow(id);
+ const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
+ const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;
+ const variants = d.variants.map((v, index) => {
+ if (p && index === Number(p.variant_index) && current) {
+ return { ...v, html: wrap(current.html, selected.variables || {}), currentSeq: Number(current.seq), edited: current.kind !== 'generated' };
+ }
+ return v.status === 'done' ? { ...v, html: wrap(v.html, selected.variables || {}) } : v;
+ });
+ return { design: publicRow(d), variants, pick: pickPublic(p, current), versions: versions.map(v => versionPublic(v, p ? p.current_seq : 1)) };
+ }
+
+ async function pick({ id, variantIndex, variantId, owner, key, subject = null, token = null }) {
+ const d = await row(id, owner, key);
+ let index = variantIndex == null || variantIndex === '' ? null : Number(variantIndex);
+ if (variantId) {
+ const found = d.variants.findIndex(v => String(v.id) === String(variantId));
+ if (found === -1) throw fail(422, 'design.invalid_variant', 'That variant is not part of this design.');
+ if (index != null && index !== found) throw fail(422, 'design.invalid_variant', 'That variant is not part of this design.');
+ index = found;
+ }
+ if (index == null || !Number.isInteger(index) || index < 0 || index >= d.variants.length)
+ throw fail(422, 'design.invalid_variant', 'Pick one of the generated variants.');
+ const variant = d.variants[index];
+ if (!variant || variant.status !== 'done') throw fail(409, 'design.variant_not_ready', 'That frame is not ready yet.');
+ const existing = await pickRow(id);
+ if (existing) {
+ if (Number(existing.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');
+ const vote = await recordPickVote(d, subject, token);
+ const data = await read(id, owner, key);
+ return { ...data, vote, idempotent: true };
+ }
+ const inserted = await db.maybe(`INSERT INTO design_picks(design_id,variant_index,variant_id,voter_subject,created_at,updated_at)
+ VALUES($1,$2,$3,$4,$5::timestamptz,$5::timestamptz) ON CONFLICT (design_id) DO NOTHING RETURNING *`,
+ [id, index, variant.id, subject || null, s.iso()]);
+ const p = inserted || await pickRow(id);
+ if (!p || Number(p.variant_index) !== index) throw fail(409, 'design.already_picked', 'This design already has a working frame.');
+ await ensureBaseVersion(d, p, variant);
+ const vote = await recordPickVote(d, subject, token);
+ const data = await read(id, owner, key);
+ return { ...data, vote, idempotent: !inserted };
+ }
+
+ async function currentForEdit(id, owner, key) {
+ const d = await row(id, owner, key);
+ const p = await pickRow(id);
+ if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before editing it.');
+ const selected = await theme(d.theme_id);
+ const variant = d.variants[Number(p.variant_index)];
+ if (!variant || variant.status !== 'done') throw fail(409, 'design.variant_not_ready', 'The working frame is not ready yet.');
+ let current = await versionRow(id, p.current_seq);
+ if (!current) {
+ await ensureBaseVersion(d, p, variant);
+ current = await versionRow(id, p.current_seq);
+ }
+ return { d, p, selected, variant, current };
+ }
+
+ async function chatEdit({ id, instruction, owner, key, subject = null }) {
+ instruction = String(instruction == null ? '' : instruction).trim();
+ if (!instruction || instruction.length > CHAT_INSTRUCTION_MAX)
+ throw fail(422, 'design.invalid_instruction', `Write an instruction up to ${CHAT_INSTRUCTION_MAX} characters.`);
+ const { d, p, selected, variant } = await currentForEdit(id, owner, key);
+ const used = await db.maybe('UPDATE design_picks SET chat_edits=chat_edits+1, updated_at=$2 WHERE design_id=$1 AND chat_edits < $3 RETURNING chat_edits',
+ [d.id, s.iso(), chatEditLimit]);
+ if (!used) throw fail(429, 'design.edit_limit', `This design has used its ${chatEditLimit} AI edits. Click edits and undo still work.`);
+ const current = await versionRow(id, p.current_seq);
+ const variables = selected.variables || {};
+ const names = Object.keys(variables).join(', ');
+ const prompt = [
+ 'Edit one existing generated design. Return one complete HTML document and nothing else.',
+ `The person's instruction: ${instruction}`,
+ 'The current document is between the markers. Keep its theme, its content and its structure unless the instruction asks otherwise.',
+ `<<<CURRENT_DESIGN\n${String(current.html || variant.html || '').slice(0, 80000)}\nCURRENT_DESIGN>>>`,
+ `Use ONLY these theme CSS custom properties for every colour: ${names || '(the document already uses them)'}. Do not add scripts, event-handler attributes, links, web fonts, remote images or any external request. Mobile first.`,
+ ].join('\n\n');
+ const out = await ai.generate({ prompt, category: categoryOf(d.kind), slot: { provider: variant.provider, model: variant.model }, roundId: d.id, index: variant.slot });
+ const html = wrap(out.html, variables);
+ const version = await addVersion(d, p, { kind: 'chat', html, instruction, model: out.model || variant.model,
+ provider: out.provider || variant.provider, costUsd: out.cost_usd, ms: out.ms, createdBy: subject || (d.owner ? 'owner' : 'guest') });
+ const fresh = await pickRow(id);
+ const data = await read(id, owner, key);
+ return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };
+ }
+
+ async function clickEdit({ id, path, text, color, spacing, align, owner, key, subject = null }) {
+ const { d, p, selected, variant } = await currentForEdit(id, owner, key);
+ const current = await versionRow(id, p.current_seq);
+ let change;
+ try {
+ change = validateChange({ path, text, color, spacing, align }, selected.variables || {});
+ } catch (err) {
+ throw fail(err.status || 422, err.code || 'design.invalid_change', err.detail || err.message);
+ }
+ let modified;
+ try {
+ // Normalise older part A rows (and any row without an explicit body) before addressing elements.
+ modified = applyChange(wrap(current.html, selected.variables || {}), change);
+ } catch (err) {
+ throw fail(err.status || 422, err.code || 'design.invalid_change', err.detail || err.message);
+ }
+ const html = wrap(modified, selected.variables || {});
+ const record = { path: change.path };
+ if (change.changes.text !== undefined) record.text = change.changes.text;
+ if (change.changes.color !== undefined) record.color = color;
+ if (change.changes.padding !== undefined) record.spacing = spacing;
+ if (change.changes['text-align'] !== undefined) record.align = align;
+ const version = await addVersion(d, p, { kind: 'click', html, change: record, model: variant.model,
+ provider: variant.provider, costUsd: 0, ms: 0, createdBy: subject || (d.owner ? 'owner' : 'guest') });
+ const fresh = await pickRow(id);
+ const data = await read(id, owner, key);
+ return { version: versionPublic(version, fresh.current_seq), html, pick: data.pick, versions: data.versions };
+ }
+
+ async function versions(id, owner, key) {
+ const d = await row(id, owner, key);
+ const p = await pickRow(id);
+ const list = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
+ return { designId: d.id, currentSeq: p ? Number(p.current_seq) : null, versions: list.map(v => versionPublic(v, p ? p.current_seq : 1)) };
+ }
+
+ async function undo(id, owner, key) {
+ const d = await row(id, owner, key);
+ const p = await pickRow(id);
+ if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before undoing an edit.');
+ const current = await versionRow(id, p.current_seq);
+ if (!current || current.parent_seq == null) {
+ return { changed: false, ...(await read(id, owner, key)) };
+ }
+ const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];
+ stack.push(Number(current.seq));
+ await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',
+ [d.id, Number(current.parent_seq), JSON.stringify(stack.slice(-100)), s.iso()]);
+ return { changed: true, ...(await read(id, owner, key)) };
}
}
+
+ async function redo(id, owner, key) {
+ const d = await row(id, owner, key);
+ const p = await pickRow(id);
+ if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before redoing an edit.');
+ const stack = Array.isArray(p.redo_stack) ? p.redo_stack.slice() : [];
+ if (!stack.length) return { changed: false, ...(await read(id, owner, key)) };
+ const seq = Number(stack.pop());
+ const target = await versionRow(id, seq);
+ if (!target) return { changed: false, ...(await read(id, owner, key)) };
+ await db.exec('UPDATE design_picks SET current_seq=$2, redo_stack=$3::jsonb, updated_at=$4 WHERE design_id=$1',
+ [d.id, seq, JSON.stringify(stack), s.iso()]);
+ return { changed: true, ...(await read(id, owner, key)) };
+ }
+
async function setTheme(id, themeId, owner, key) {
async function setTheme(id, themeId, owner, key) {
await row(id, owner, key);
await row(id, owner, key);
const selected = await theme(themeId);
const selected = await theme(themeId);
@@ -157,7 +442,7 @@ function createDesigns({ s, ai, catalog, log = console, now = () => Date.now() }
@@ -157,7 +442,7 @@ function createDesigns({ s, ai, catalog, log = console, now = () => Date.now() }
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");
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));
for (const d of pending) run(d.id).catch(err => log.warn('[OpenVibe.Design] resume:', err.message));
}
}
- return { create, read, setTheme, recent, resume, theme };
+ return { create, read, setTheme, recent, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit };
}
}
module.exports = { createDesigns, wrap, tokenCss, themeCss };
module.exports = { createDesigns, wrap, tokenCss, themeCss };
@@ -1,14 +1,32 @@
@@ -1,14 +1,32 @@
'use strict';
'use strict';
+/**
+ * The Design Studio API, /api/v1/designs:
+ *
+ * POST / create three model variants (part A)
+ * GET /:id generation status, the working frame and the version list
+ * POST /:id/theme change the token theme
+ * POST /:id/pick choose the working frame; the pick is one Arena vote
+ * POST /:id/edit chat edit the working frame (paid AI, small per-design budget)
+ * POST /:id/click apply one validated click edit to the working frame
+ * GET /:id/versions the readable, append-only version list
+ * POST /:id/undo, /:id/redo move the working-frame pointer; history is never deleted
+ *
+ * A design is private: its owner sees it through the session, a guest through the secret key header or query.
+ * A cookie-authenticated write must come from this site; every answer is `private, no-store`.
+ */
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { requireSameOrigin } = require('./same-origin');
const { requireSameOrigin } = require('./same-origin');
const owner = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null;
const owner = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null;
-const key = req => req.get('x-design-key') || req.query.key;
+const token = req => req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null;
+const keyOf = (req) => req.get('x-design-key') || req.query.key || (req.body && req.body.editKey) || null;
function createDesignApi(ctx) {
function createDesignApi(ctx) {
const routes = asyncRouter();
const routes = asyncRouter();
+ const sameOrigin = requireSameOrigin(ctx.config);
+ const priv = (res, status, body) => res.status(status).set('Cache-Control', 'private, no-store').json(body);
const safe = fn => async (req, res) => {
const safe = fn => async (req, res) => {
try { return await fn(req, res); }
try { return await fn(req, res); }
catch (err) {
catch (err) {
@@ -18,7 +36,7 @@ function createDesignApi(ctx) {
@@ -18,7 +36,7 @@ function createDesignApi(ctx) {
});
});
}
}
};
};
- routes.post('/', requireSameOrigin(ctx.config), safe(async (req, res) => {
+ routes.post('/', sameOrigin, safe(async (req, res) => {
const body = req.body || {};
const body = req.body || {};
const result = await ctx.designs.create({ prompt: body.prompt, kind: body.kind || 'page',
const result = await ctx.designs.create({ prompt: body.prompt, kind: body.kind || 'page',
themeId: body.themeId, owner: owner(req), ip: req.ip });
themeId: body.themeId, owner: owner(req), ip: req.ip });
@@ -29,12 +47,36 @@ function createDesignApi(ctx) {
@@ -29,12 +47,36 @@ function createDesignApi(ctx) {
res.cookie('design_drafts', JSON.stringify(links), { httpOnly: true, sameSite: 'lax',
res.cookie('design_drafts', JSON.stringify(links), { httpOnly: true, sameSite: 'lax',
secure: ctx.config.cookies.secure, maxAge: 30 * 86400000, path: '/' });
secure: ctx.config.cookies.secure, maxAge: 30 * 86400000, path: '/' });
}
}
- return res.status(201).set('Cache-Control', 'private, no-store').json(result);
+ return priv(res, 201, 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))));
+ routes.get('/:id', safe(async (req, res) => priv(res, 200,
+ await ctx.designs.read(req.params.id, owner(req), keyOf(req)))));
+ routes.post('/:id/theme', sameOrigin, safe(async (req, res) => priv(res, 200,
+ await ctx.designs.setTheme(req.params.id, (req.body || {}).themeId, owner(req), keyOf(req)))));
+ routes.post('/:id/pick', sameOrigin, safe(async (req, res) => {
+ const body = req.body || {};
+ const result = await ctx.designs.pick({ id: req.params.id, variantIndex: body.variantIndex,
+ variantId: body.variantId, owner: owner(req), key: keyOf(req), subject: owner(req), token: token(req) });
+ return priv(res, result.idempotent ? 200 : 201, result);
+ }));
+ routes.post('/:id/edit', sameOrigin, safe(async (req, res) => {
+ const body = req.body || {};
+ const result = await ctx.designs.chatEdit({ id: req.params.id, instruction: body.instruction,
+ owner: owner(req), key: keyOf(req), subject: owner(req) });
+ return priv(res, 200, result);
+ }));
+ routes.post('/:id/click', sameOrigin, safe(async (req, res) => {
+ const body = req.body || {};
+ const result = await ctx.designs.clickEdit({ id: req.params.id, path: body.path, text: body.text,
+ color: body.color, spacing: body.spacing, align: body.align, owner: owner(req), key: keyOf(req), subject: owner(req) });
+ return priv(res, 201, result);
+ }));
+ routes.get('/:id/versions', safe(async (req, res) => priv(res, 200,
+ await ctx.designs.versions(req.params.id, owner(req), keyOf(req)))));
+ routes.post('/:id/undo', sameOrigin, safe(async (req, res) => priv(res, 200,
+ await ctx.designs.undo(req.params.id, owner(req), keyOf(req)))));
+ routes.post('/:id/redo', sameOrigin, safe(async (req, res) => priv(res, 200,
+ await ctx.designs.redo(req.params.id, owner(req), keyOf(req)))));
return routes;
return routes;
}
}
@@ -100,6 +100,41 @@ function createPageRoutes(ctx) {
@@ -100,6 +100,41 @@ function createPageRoutes(ctx) {
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not change theme.');
return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not change theme.');
}
}
});
});
+ const backToCanvas = (req, res, key) => res.redirect(303, `/d/${encodeURIComponent(req.params.id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`);
+ r.post('/d/:id/pick', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ const key = req.body.key || '';
+ try {
+ await ctx.designs.pick({ id: req.params.id, variantIndex: req.body.variantIndex, variantId: req.body.variantId,
+ owner: subjectOf(req), key, subject: subjectOf(req), token: tokenOf(req) });
+ return backToCanvas(req, res, key);
+ } catch (err) {
+ return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not choose that frame.');
+ }
+ });
+ r.post('/d/:id/edit', express.urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ const key = req.body.key || '';
+ try {
+ await ctx.designs.chatEdit({ id: req.params.id, instruction: req.body.instruction,
+ owner: subjectOf(req), key, subject: subjectOf(req) });
+ return backToCanvas(req, res, key);
+ } catch (err) {
+ return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not edit that frame.');
+ }
+ });
+ for (const action of ['undo', 'redo']) {
+ r.post(`/d/:id/${action}`, express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ const key = req.body.key || '';
+ try {
+ await ctx.designs[action](req.params.id, subjectOf(req), key);
+ return backToCanvas(req, res, key);
+ } catch (err) {
+ return res.status(err.status || 500).type('text/plain').send(err.detail || `Could not ${action} that edit.`);
+ }
+ });
+ }
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const list = await safe(() => catalog.list());
const list = await safe(() => catalog.list());
@@ -16,36 +16,138 @@ ${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${
@@ -16,36 +16,138 @@ ${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${
return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });
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 themeListener = `<script data-dv-theme="1">(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>`;
+
+/**
+ * The click-edit overlay. It is appended to the rendered srcdoc at render time and never stored with the model
+ * HTML. It reports a stable nth-of-type path, a tag and whether the element is a leaf; the parent validates every
+ * field before it sends one change back through the server. The script only uses DOM APIs and postMessage, so the
+ * frame's `default-src 'none'` CSP still blocks every network request.
+ */
+const overlayScript = `<script data-dv-overlay="1">(function(){
+'use strict';
+var selected=null,lastOutline='';
+function tagName(el){return el&&el.tagName?el.tagName.toLowerCase():'';}
+function segment(el){var i=1,s=el;while((s=s.previousElementSibling)){if(s.tagName===el.tagName)i++;}return tagName(el)+':nth-of-type('+i+')';}
+function pathOf(el){var parts=[];while(el&&el!==document.documentElement){if(el===document.body){parts.unshift('body');return parts.join(' > ');}parts.unshift(segment(el));el=el.parentElement;}return null;}
+function allowed(el){var t=tagName(el);return !!t&&t!=='html'&&t!=='body'&&t!=='script'&&t!=='style'&&t!=='head'&&t!=='title'&&t!=='meta'&&t!=='link'&&t!=='iframe'&&t!=='object'&&t!=='embed'&&t!=='form'&&t!=='input'&&t!=='textarea'&&t!=='select';}
+document.addEventListener('click',function(event){var el=event.target;if(!el||el.nodeType!==1)return;event.preventDefault();event.stopPropagation();if(!allowed(el))return;
+if(selected&&selected.style){selected.style.outline=lastOutline;selected.style.outlineOffset='';}
+selected=el;lastOutline=el.style.outline||'';el.style.outline='2px solid #7aa7ff';el.style.outlineOffset='2px';
+var text=(el.textContent||'').replace(/\\s+/g,' ').trim().slice(0,400);
+parent.postMessage({type:'design-select',path:pathOf(el),tag:tagName(el),leaf:el.children.length===0,text:text},'*');
+},true);
+document.addEventListener('submit',function(event){event.preventDefault();},true);
+document.addEventListener('mouseover',function(event){var el=event.target;if(el&&el.nodeType===1&&allowed(el))el.style.cursor='crosshair';},true);
+})();</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'">`;
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) {
+function frame(html, title, { interactive = false } = {}) {
const withPolicy = html.replace(/<head\b[^>]*>/i, `$&${framePolicy}`);
const withPolicy = html.replace(/<head\b[^>]*>/i, `$&${framePolicy}`);
+ const injected = `${themeListener}${interactive ? overlayScript : ''}`;
const document = /<\/body>/i.test(withPolicy)
const document = /<\/body>/i.test(withPolicy)
- ? withPolicy.replace(/<\/body>/i, `${themeListener}</body>`)
- : /<\/html>/i.test(withPolicy) ? withPolicy.replace(/<\/html>/i, `${themeListener}</html>`) : withPolicy + themeListener;
+ ? withPolicy.replace(/<\/body>/i, `${injected}</body>`)
+ : /<\/html>/i.test(withPolicy) ? withPolicy.replace(/<\/html>/i, `${injected}</html>`) : withPolicy + injected;
return `<iframe title="${esc(title)}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" srcdoc="${attrEscape(document)}"></iframe>`;
return `<iframe title="${esc(title)}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" srcdoc="${attrEscape(document)}"></iframe>`;
}
}
+const VERSION_KIND = { generated: 'Picked frame', chat: 'AI edit', click: 'Click edit' };
+const versionTime = (value) => {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(11, 16);
+};
+const versionDetail = (v) => v.kind === 'chat' && v.instruction
+ ? `<q>${esc(v.instruction)}</q>`
+ : v.kind === 'click' && v.change
+ ? `<span class="dv-fine">${esc((v.change.path || '').replace(/^body > /, ''))}${v.change.text !== undefined ? ' · text' : ''}${v.change.color ? ' · colour' : ''}${v.change.spacing ? ' · spacing' : ''}${v.change.align ? ' · alignment' : ''}</span>`
+ : '';
+
function canvas({ data, themes = [], editKey = '' }) {
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`) :
+ const { design, variants, pick, versions = [] } = data;
+ const selectedTheme = themes.find(t => t.id === design.themeId) || themes[0] || { variables: {} };
+ const variables = selectedTheme.variables || {};
+ const pickedIndex = pick ? Number(pick.variantIndex) : -1;
+ const pickedVariant = pickedIndex >= 0 ? variants[pickedIndex] : null;
+ const chatLeft = pick ? Math.max(0, Number(pick.chatEditLimit || 0) - Number(pick.chatEdits || 0)) : 0;
+ const canChat = Boolean(pick) && chatLeft > 0;
+ const cards = variants.map((v, index) => {
+ const picked = index === pickedIndex;
+ const done = v.status === 'done';
+ return `<article class="dv-card" data-status="${esc(v.status)}" data-index="${index}" data-picked="${picked ? 'true' : 'false'}" data-variant-id="${esc(v.id || '')}">
+<div class="dv-frame">${done ? frame(v.html, `${v.model || 'Design'} frame`, { interactive: picked }) :
`<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-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('');
+<div class="dv-label"><span class="dv-model">${esc(v.model || 'Designer')}</span>${done ?
+ `<span>${(Number(v.ms || 0) / 1000).toFixed(1)} s · $${Number(v.costUsd || 0).toFixed(4)}</span>` : ''}</div>
+<div class="dv-card-actions">${picked
+ ? `<span class="dv-picked">Working frame</span><span class="dv-fine">Click any element to edit it</span>`
+ : done ? (pick ? '<span class="dv-fine">Not the working frame</span>'
+ : `<form method="post" action="/d/${encodeURIComponent(design.id)}/pick"><input type="hidden" name="key" value="${esc(editKey)}"><input type="hidden" name="variantIndex" value="${index}"><button type="submit" class="dv-pick" data-pick="${index}">Use this design</button></form>`)
+ : '<span class="dv-fine">Waiting for this designer…</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 themeOptions = themes.map(t => `<option value="${esc(t.id)}"${t.id === design.themeId ? ' selected' : ''}>${esc(t.name)}</option>`).join('');
+ const aliasTokens = new Set(['--bg-card', '--bg-secondary', '--text-muted', '--accent', '--on-accent', '--border']);
+ const tokenNames = [...new Set([...Object.keys(variables),
+ '--text-primary', '--text-muted', '--accent', '--bg-primary', '--bg-card', '--border'])]
+ .filter(name => /^--[a-z][a-z0-9-]{1,40}$/.test(name)
+ && (aliasTokens.has(name) || /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\()/i.test(String(variables[name] || ''))));
+ const tokenOptions = tokenNames.map(name => `<option value="${esc(name)}">${esc(name)}</option>`).join('');
const cssByTheme = Object.fromEntries(themes.map(t => [t.id, themeCss(t.variables || {})]));
const cssByTheme = Object.fromEntries(themes.map(t => [t.id, themeCss(t.variables || {})]));
const cssJson = JSON.stringify(cssByTheme).replace(/</g, '\\u003c');
const cssJson = JSON.stringify(cssByTheme).replace(/</g, '\\u003c');
- const body = `<section class="dv-canvas" data-id="${esc(design.id)}" data-key="${esc(editKey)}">
+ const versionRows = versions.map(v => `<li${v.current ? ' class="is-current"' : ''}>
+<span class="dv-vseq">v${v.seq}</span>
+<span class="dv-vbody"><b>${esc(VERSION_KIND[v.kind] || v.kind)}</b>${v.current ? ' <span class="dv-vnow">now</span>' : ''}
+<small>${esc(v.model || 'unknown model')}${v.provider ? ` · ${esc(v.provider)}` : ''}${v.createdAt ? ` · ${esc(versionTime(v.createdAt))}` : ''}${v.kind === 'chat' && v.costUsd ? ` · $${Number(v.costUsd).toFixed(4)}` : ''}</small>
+${versionDetail(v)}</span></li>`).join('');
+ const voteNote = pick && pick.vote
+ ? pick.vote.state === 'recorded' ? ' Arena vote recorded.'
+ : pick.vote.state === 'guest' ? ' Guest pick saved; sign in through the same secret link to count it as an Arena vote.'
+ : pick.vote.state === 'failed' ? ' The Arena vote could not be recorded; the working frame is still saved.'
+ : pick.vote.state === 'pending' ? ' Recording the Arena vote…'
+ : '' : '';
+ const statusText = pick ? `Working frame selected. ${versions.length} version${versions.length === 1 ? '' : 's'} kept.${voteNote}`
+ : design.status === 'done' ? 'Three designs ready. Pick one to start editing.'
+ : design.status === 'failed' ? 'Generation failed.' : 'Designers are working…';
+ const keyInput = `<input type="hidden" name="key" value="${esc(editKey)}">`;
+ const body = `<section class="dv-canvas" data-id="${esc(design.id)}" data-key="${esc(editKey)}" data-picked="${pick ? pickedIndex : ''}" data-variant-id="${esc(pickedVariant && pickedVariant.id || '')}">
<header class="dv-heading"><a href="/create">← New design</a><h1>${esc(design.prompt)}</h1>
<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>
+<p id="dv-status" role="status">${esc(statusText)}</p></header>
+<form class="dv-chat" id="dv-chat-form" method="post" action="/d/${encodeURIComponent(design.id)}/edit">
+${keyInput}
+<label for="dv-instruction">Edit with AI</label>
+<div class="dv-chat-row"><input id="dv-instruction" name="instruction" maxlength="1000" placeholder="${pick ? 'Make the hero bolder, add a pricing section…' : 'Pick a frame first'}"${canChat ? '' : ' disabled'}>
+<button type="submit"${canChat ? '' : ' disabled'}>Send</button></div>
+<p class="dv-chat-status" id="dv-edit-status" role="status" aria-live="polite"></p>
+<p class="dv-fine">${pick ? `${chatLeft} of ${pick.chatEditLimit} AI edits left on this design. Click edits and undo are free.` : 'Chat edits work on the picked frame.'}</p>
+</form>
<div class="dv-rail" id="dv-rail">${cards}</div>
<div class="dv-rail" id="dv-rail">${cards}</div>
+<details class="dv-versions" id="dv-versions" open>
+<summary>Versions <span>${versions.length}</span></summary>
+<div class="dv-vcontrols">
+<form method="post" action="/d/${encodeURIComponent(design.id)}/undo">${keyInput}<button type="submit"${pick && pick.canUndo ? '' : ' disabled'}>Undo</button></form>
+<form method="post" action="/d/${encodeURIComponent(design.id)}/redo">${keyInput}<button type="submit"${pick && pick.canRedo ? '' : ' disabled'}>Redo</button></form>
+</div>
+${versions.length ? `<ol class="dv-vlist" id="dv-vlist">${versionRows}</ol>` : '<p class="dv-fine">Pick a frame; its HTML becomes version 1. Every accepted edit adds a version and nothing is deleted.</p>'}
+</details>
<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>
<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>`;
+<form method="post" action="/d/${encodeURIComponent(design.id)}/theme" id="dv-theme-form">${keyInput}<label>Theme <select id="dv-theme" name="themeId">${themeOptions}</select></label><button type="submit">Apply</button></form></nav>
+</section>
+<aside class="dv-element" id="dv-element" hidden aria-label="Edit the selected element">
+<div class="dv-element-head"><b id="dv-element-tag">Element</b><button type="button" id="dv-element-close">Close</button></div>
+<form id="dv-element-form">
+<label for="dv-element-text">Text</label><textarea id="dv-element-text" rows="3" maxlength="1000"></textarea>
+<p class="dv-fine" id="dv-element-note" hidden>This element contains other elements. Edit the text element inside it; colour, spacing and alignment still apply.</p>
+<label for="dv-element-color">Text colour (theme token)</label><select id="dv-element-color"><option value="">Keep</option>${tokenOptions}</select>
+<label for="dv-element-spacing">Spacing (padding)</label><select id="dv-element-spacing"><option value="">Keep</option><option value="none">None</option><option value="sm">Small · 8px</option><option value="md">Medium · 16px</option><option value="lg">Large · 24px</option></select>
+<label for="dv-element-align">Alignment</label><select id="dv-element-align"><option value="">Keep</option><option value="left">Left</option><option value="center">Centre</option><option value="right">Right</option></select>
+<button type="submit">Save as a new version</button>
+<p class="dv-status" id="dv-element-status" role="status" aria-live="polite"></p>
+</form>
+</aside>
+<script type="application/json" id="dv-theme-css">${cssJson}</script>`;
return layout({ title: 'Design canvas', path: `/d/${encodeURIComponent(design.id)}`, robots: 'noindex, nofollow',
return layout({ title: 'Design canvas', path: `/d/${encodeURIComponent(design.id)}`, robots: 'noindex, nofollow',
body, scripts: ['js/design.js'] });
body, scripts: ['js/design.js'] });
}
}
-module.exports = { create, canvas, frame };
+module.exports = { create, canvas, frame, overlayScript };
@@ -143,8 +143,12 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
@@ -143,8 +143,12 @@ ${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.'],
['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.'],
+ ['GET /api/v1/designs/:id', 'Poll generation status, read each frame, the pick and the version list; guests send X-Design-Key.'],
['POST /api/v1/designs/:id/theme', 'Change the token theme with {themeId}.'],
['POST /api/v1/designs/:id/theme', 'Change the token theme with {themeId}.'],
+ ['POST /api/v1/designs/:id/pick', 'Choose the working frame with {variantIndex}; the pick is recorded as one Arena vote.'],
+ ['POST /api/v1/designs/:id/edit', 'Chat edit the working frame with {instruction}; one paid AI run and a small per-design budget.'],
+ ['POST /api/v1/designs/:id/click', 'Apply {path, text?, color?, spacing?, align?} to one element; validated paths and theme tokens only.'],
+ ['GET /api/v1/designs/:id/versions', 'Every saved version with its model/provider provenance; POST /undo and /redo move the pointer.'],
['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,314 @@
@@ -0,0 +1,314 @@
+'use strict';
+
+/**
+ * Design Studio part B: picking the working frame (and its one Arena vote), chat and click edits, the append-only
+ * version list with undo/redo, quotas, authorization and the CSP after an edit. The AI and Bench are in-process
+ * fakes from test/helpers, so nothing here spends money or touches the network.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const slots = [{ provider: 'alpha', model: 'one' }, { provider: 'beta', model: 'two' }, { provider: 'gamma', model: 'three' }];
+const page = (title) => `<!doctype html><html><head><style>body{background:#ffffff;color:#222222}</style></head><body><section><h1>${title}</h1><p>Body copy</p><button>Get started</button></section></body></html>`;
+let editCalls = 0;
+let failEdit = false;
+let lastEditPrompt = '';
+const ai = {
+ slotPlan: async () => ({ slots }),
+ generate: async ({ slot, prompt }) => {
+ if (prompt.includes('CURRENT_DESIGN')) {
+ editCalls++;
+ lastEditPrompt = prompt;
+ if (failEdit) throw Object.assign(new Error('fake AI failed'), { status: 502, code: 'ai.run_failed', detail: 'fake AI failed' });
+ const asked = /instruction: (.*)/.exec(prompt);
+ const unsafe = '<script>alert(1)</script><img src="https://evil.example/x.png" onerror="alert(2)">';
+ return { html: page(`Edited ${asked ? asked[1] : ''}${unsafe}`), provider: slot.provider, model: slot.model, cost_usd: 0.002, ms: 80 };
+ }
+ return { html: page('Generated'), provider: slot.provider, model: slot.model, cost_usd: 0.001, ms: 50 };
+ },
+};
+const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
+
+(async () => {
+ const t = await boot({ ai, env: { DESIGN_CHAT_EDITS_PER_DESIGN: '2' } });
+ const kim = t.network.addUser('kim');
+ const bob = t.network.addUser('bob');
+ const POST = { origin: 'https://openvibe.design' };
+ async function waitDone(id, key, as) {
+ for (let i = 0; i < 80; i++) {
+ const response = await t.get(`/api/v1/designs/${id}${key ? `?key=${encodeURIComponent(key)}` : ''}`, { as });
+ if (response.json().design.status === 'done') return response.json();
+ await pause(20);
+ }
+ throw new Error('design generation did not finish');
+ }
+ let guestIp = 0;
+ async function guestDesign(prompt = 'A coffee roaster landing page') {
+ // One address per guest design, so the part A two-a-day design cap does not mask the part B behaviour.
+ const created = await t.get('/api/v1/designs', { ...POST, headers: { 'x-forwarded-for': `198.51.100.${++guestIp}` }, json: { prompt, kind: 'page' } });
+ assert.equal(created.status, 201, created.text);
+ const id = created.json().design.id;
+ const key = created.json().editKey;
+ assert.ok(key);
+ await waitDone(id, key);
+ return { id, key };
+ }
+ async function userDesign(prompt = 'A signed-in design') {
+ const created = await t.get('/api/v1/designs', { ...POST, as: kim, json: { prompt, kind: 'page' } });
+ assert.equal(created.status, 201, created.text);
+ const id = created.json().design.id;
+ await waitDone(id, null, kim);
+ return { id };
+ }
+ const pick = (id, variantIndex, { key, as } = {}) => t.get(`/api/v1/designs/${id}/pick`, {
+ ...POST, as, ...(key ? { headers: { 'x-design-key': key } } : {}), json: { variantIndex },
+ });
+ const click = (id, body, { key, as } = {}) => t.get(`/api/v1/designs/${id}/click`, {
+ ...POST, as, ...(key ? { headers: { 'x-design-key': key } } : {}), json: body,
+ });
+ const edit = (id, instruction, { key, as, origin = true } = {}) => t.get(`/api/v1/designs/${id}/edit`, {
+ ...(origin ? POST : {}), as, ...(key ? { headers: { 'x-design-key': key } } : {}), json: { instruction },
+ });
+ const versions = (id, { key, as } = {}) => t.get(`/api/v1/designs/${id}/versions${key ? `?key=${encodeURIComponent(key)}` : ''}`, { as });
+
+ try {
+ await check('pick is idempotent, persists the working frame and starts one version', async () => {
+ const { id, key } = await guestDesign();
+ const first = await pick(id, 1, { key });
+ assert.equal(first.status, 201, first.text);
+ const one = first.json();
+ assert.equal(one.pick.variantIndex, 1);
+ assert.equal(one.pick.currentSeq, 1);
+ assert.equal(one.versions.length, 1);
+ assert.equal(one.versions[0].kind, 'generated');
+ assert.equal(one.vote.state, 'guest');
+ assert.equal(one.variants[1].currentSeq, 1);
+ const again = await pick(id, 1, { key });
+ assert.equal(again.status, 200, again.text);
+ assert.equal(again.json().idempotent, true);
+ assert.equal(again.json().versions.length, 1);
+ const wrong = await pick(id, 2, { key });
+ assert.equal(wrong.status, 409);
+ const rows = await t.store.db.many('SELECT * FROM design_picks WHERE design_id=$1', [id]);
+ assert.equal(rows.length, 1);
+ assert.equal(rows[0].variant_index, 1);
+ assert.equal((await versions(id, { key })).headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('owner and guest-key authorization is enforced on pick and reads', async () => {
+ const { id } = await userDesign('Owner only');
+ assert.equal((await pick(id, 0, { as: bob })).status, 403);
+ assert.equal((await pick(id, 0)).status, 403);
+ assert.equal((await t.get(`/api/v1/designs/${id}`)).status, 403);
+ assert.equal((await pick(id, 0, { as: kim })).status, 201);
+ const guest = await guestDesign('Guest secret');
+ assert.equal((await pick(guest.id, 0, { key: 'not-the-key' })).status, 403);
+ assert.equal((await t.get(`/api/v1/designs/${guest.id}?key=not-the-key`)).status, 403);
+ assert.equal((await pick(guest.id, 0, { key: guest.key })).status, 201);
+ });
+
+ await check('a signed-in pick records exactly one Bench round, three entries and one Arena vote', async () => {
+ const { id } = await userDesign('Arena vote design');
+ const response = await pick(id, 2, { as: kim });
+ assert.equal(response.status, 201, response.text);
+ const out = response.json();
+ assert.equal(out.vote.state, 'recorded', JSON.stringify(out.vote));
+ assert.ok(out.vote.roundId);
+ const round = t.bench.state.rounds.get(out.vote.roundId);
+ assert.ok(round, 'the pick created a Bench round');
+ assert.equal(round.status, 'finished');
+ const entries = [...t.bench.state.entries.values()].filter(e => e.round_id === round.id);
+ assert.equal(entries.length, 3);
+ assert.deepEqual(entries.map(e => e.model).sort(), ['one', 'three', 'two']);
+ const votes = t.bench.state.judgements.filter(j => j.round_id === round.id);
+ assert.equal(votes.length, 1);
+ assert.equal(votes[0].subject, kim.subject);
+ assert.equal(votes[0].outcome, 'left');
+ assert.equal(votes[0].pair.left, entries.find(e => e.model === 'three').id);
+ const again = await pick(id, 2, { as: kim });
+ assert.equal(again.status, 200, again.text);
+ assert.equal(t.bench.state.judgements.filter(j => j.round_id === round.id).length, 1, 'a repeated click is not a second vote');
+ const rounds = [...t.bench.state.rounds.values()].filter(r => r.prompt === 'Arena vote design');
+ assert.equal(rounds.length, 1, 'a repeated click is not a second Bench round');
+ });
+
+ await check('two simultaneous picks still record one vote', async () => {
+ const { id } = await userDesign('Concurrent pick');
+ const [a, b] = await Promise.all([pick(id, 1, { as: kim }), pick(id, 1, { as: kim })]);
+ assert.deepEqual([a.status, b.status].sort(), [200, 201], a.text + b.text);
+ const rounds = [...t.bench.state.rounds.values()].filter(r => r.prompt === 'Concurrent pick');
+ assert.equal(rounds.length, 1, 'one Bench round for one design');
+ assert.equal(t.bench.state.judgements.filter(j => j.round_id === rounds[0].id).length, 1);
+ const list = await t.get(`/api/v1/designs/${id}/versions`, { as: kim });
+ assert.equal(list.json().versions.length, 1);
+ });
+
+ await check('chat edit changes only the working frame, keeps its tokens and saves a new version', async () => {
+ const { id, key } = await guestDesign('Chat edit design');
+ assert.equal((await pick(id, 1, { key })).status, 201);
+ const before = editCalls;
+ const response = await edit(id, 'make the headline warmer', { key });
+ assert.equal(response.status, 200, response.text);
+ const out = response.json();
+ assert.equal(out.version.seq, 2);
+ assert.equal(out.version.kind, 'chat');
+ assert.equal(out.version.model, 'two');
+ assert.equal(editCalls, before + 1);
+ assert.match(lastEditPrompt, /<<<CURRENT_DESIGN\n[\s\S]*<h1>Generated<\/h1>/);
+ assert.match(lastEditPrompt, /--bg-primary/);
+ assert.match(lastEditPrompt, /make the headline warmer/);
+ const data = (await t.get(`/api/v1/designs/${id}?key=${encodeURIComponent(key)}`)).json();
+ assert.match(data.variants[1].html, /Edited make the headline warmer/);
+ assert.match(data.variants[1].html, /<style id="design-tokens">/);
+ assert.doesNotMatch(data.variants[1].html, /<script\b/i);
+ assert.doesNotMatch(data.variants[1].html, /onerror/i);
+ assert.doesNotMatch(data.variants[0].html, /Edited/);
+ assert.doesNotMatch(data.variants[2].html, /Edited/);
+ assert.equal(data.versions.length, 2);
+ assert.equal(data.versions[0].kind, 'generated');
+ assert.equal(data.pick.currentSeq, 2);
+ const stored = await t.store.db.many('SELECT html FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]);
+ assert.equal(stored.length, 2);
+ assert.match(stored[0].html, /Generated/);
+ assert.match(stored[1].html, /Edited/);
+ });
+
+ await check('chat edit enforces ownership, same-origin and the per-design AI budget', async () => {
+ const owner = await userDesign('Chat auth design');
+ assert.equal((await pick(owner.id, 0, { as: kim })).status, 201);
+ assert.equal((await edit(owner.id, 'change it', { as: bob })).status, 403);
+ assert.equal((await edit(owner.id, 'change it', { as: kim, origin: false })).status, 403, 'a cookie write without a same-origin signal is refused');
+ assert.equal((await edit(owner.id, 'change it', { key: 'wrong' })).status, 403);
+
+ const { id, key } = await guestDesign('Quota design');
+ assert.equal((await pick(id, 0, { key })).status, 201);
+ failEdit = true;
+ const failed = await edit(id, 'this one fails', { key });
+ assert.equal(failed.status, 502);
+ failEdit = false;
+ const ok = await edit(id, 'this one works', { key });
+ assert.equal(ok.status, 200, ok.text);
+ assert.equal(ok.json().version.seq, 2, 'the failed attempt spent budget but did not create a version');
+ const refused = await edit(id, 'one too many', { key });
+ assert.equal(refused.status, 429);
+ assert.match(refused.json().detail, /2 AI edits/);
+ const list = (await versions(id, { key })).json();
+ assert.equal(list.versions.length, 2, 'the last good version survives a failed AI call and the quota refusal');
+ const stored = await t.store.db.many('SELECT html FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]);
+ assert.match(stored[1].html, /this one works/);
+ });
+
+ await check('click edit validates the path and values and saves an escaped, token-only change', async () => {
+ const { id, key } = await guestDesign('Click edit design');
+ assert.equal((await pick(id, 1, { key })).status, 201);
+ const path = 'body > section:nth-of-type(1) > h1:nth-of-type(1)';
+ const response = await click(id, { path, text: 'Hello <img src=x onerror=alert(1)>', color: '--accent', spacing: 'md', align: 'center' }, { key });
+ assert.equal(response.status, 201, response.text);
+ const out = response.json();
+ assert.equal(out.version.seq, 2);
+ assert.equal(out.version.kind, 'click');
+ assert.match(out.version.change.path, /^body > section/);
+ const data = (await t.get(`/api/v1/designs/${id}?key=${encodeURIComponent(key)}`)).json();
+ assert.match(data.variants[1].html, /Hello <img src=x onerror=alert\(1\)>/);
+ assert.doesNotMatch(data.variants[1].html, /<img/);
+ assert.match(data.variants[1].html, /style="[^"]*color:var\(--accent\)[^"]*padding:16px[^"]*text-align:center/);
+ assert.match(data.variants[1].html, /<style id="design-tokens">/);
+
+ const refused = [
+ { path: 'body > script:nth-of-type(1)', text: 'x' },
+ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', color: 'red' },
+ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', color: '--not-in-the-theme' },
+ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', spacing: '999px' },
+ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', align: 'justify' },
+ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', text: 'x', color: 'url(javascript:alert(1))' },
+ { path: 'body > section:nth-of-type(1)', text: 'wipes the children' },
+ { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1) > span:nth-of-type(1)', text: 'missing' },
+ { path: 'body #id', text: 'x' },
+ ];
+ for (const body of refused) {
+ const bad = await click(id, body, { key });
+ assert.equal(bad.status, 422, `${JSON.stringify(body)} should be refused`);
+ }
+ assert.equal((await click(id, { path, text: 'x' }, { key: 'wrong' })).status, 403);
+ assert.equal((await click(id, { path, text: 'x' }, { as: bob })).status, 403);
+ });
+
+ await check('the rendered canvas keeps the frame CSP and injects the overlay outside stored HTML', async () => {
+ const { id, key } = await guestDesign('CSP after edit');
+ assert.equal((await pick(id, 1, { key })).status, 201);
+ assert.equal((await click(id, { path: 'body > section:nth-of-type(1) > h1:nth-of-type(1)', text: 'Safe' }, { key })).status, 201);
+ const canvas = await t.get(`/d/${id}?key=${encodeURIComponent(key)}`);
+ assert.equal(canvas.status, 200);
+ assert.equal(canvas.headers.get('cache-control'), 'private, no-store');
+ assert.equal((canvas.text.match(/sandbox="allow-scripts"/g) || []).length, 3);
+ assert.equal((canvas.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);
+ assert.equal((canvas.text.match(/data-dv-overlay="1"/g) || []).length, 1, 'only the working frame carries the click overlay');
+ const data = (await t.get(`/api/v1/designs/${id}?key=${encodeURIComponent(key)}`)).json();
+ assert.doesNotMatch(data.variants[1].html, /data-dv-overlay/);
+ assert.doesNotMatch(data.variants[1].html, /<script\b/i);
+ assert.doesNotMatch(data.variants[1].html, /https?:\/\//);
+ });
+
+ await check('the page forms work without JavaScript (pick, chat edit, undo)', async () => {
+ const { id, key } = await guestDesign('No-JS canvas');
+ const form = { 'content-type': 'application/x-www-form-urlencoded' };
+ const picked = await t.get(`/d/${id}/pick`, { headers: form, body: `key=${encodeURIComponent(key)}&variantIndex=0` });
+ assert.equal(picked.status, 303);
+ assert.match(picked.headers.get('location'), new RegExp(`^/d/${id}\\?key=`));
+ const page1 = await t.get(`/d/${id}?key=${encodeURIComponent(key)}`);
+ assert.match(page1.text, /Working frame/);
+ const edited = await t.get(`/d/${id}/edit`, { headers: form, body: `key=${encodeURIComponent(key)}&instruction=no-js+edit` });
+ assert.equal(edited.status, 303);
+ const v = (await versions(id, { key })).json();
+ assert.equal(v.versions.length, 2);
+ assert.equal(v.versions[1].kind, 'chat');
+ const undone = await t.get(`/d/${id}/undo`, { headers: form, body: `key=${encodeURIComponent(key)}` });
+ assert.equal(undone.status, 303);
+ assert.equal((await versions(id, { key })).json().currentSeq, 1);
+ });
+
+ await check('versions, undo, redo and a new edit after undo keep every version', async () => {
+ const { id, key } = await guestDesign('Version history');
+ assert.equal((await pick(id, 0, { key })).status, 201);
+ assert.equal((await edit(id, 'second version', { key })).status, 200);
+ const path = 'body > section:nth-of-type(1) > h1:nth-of-type(1)';
+ assert.equal((await click(id, { path, color: '--accent' }, { key })).status, 201);
+ let list = (await versions(id, { key })).json();
+ assert.deepEqual(list.versions.map(v => v.seq), [1, 2, 3]);
+ assert.equal(list.currentSeq, 3);
+
+ const undo1 = await t.get(`/api/v1/designs/${id}/undo`, { ...POST, headers: { 'x-design-key': key }, json: {} });
+ assert.equal(undo1.status, 200, undo1.text);
+ assert.equal(undo1.json().changed, true);
+ assert.equal(undo1.json().pick.currentSeq, 2);
+ assert.match(undo1.json().variants[0].html, /Edited second version/);
+ const redo = await t.get(`/api/v1/designs/${id}/redo`, { ...POST, headers: { 'x-design-key': key }, json: {} });
+ assert.equal(redo.status, 200, redo.text);
+ assert.equal(redo.json().pick.currentSeq, 3);
+ assert.match(redo.json().variants[0].html, /color:var\(--accent\)/);
+
+ const undo2 = await t.get(`/api/v1/designs/${id}/undo`, { ...POST, headers: { 'x-design-key': key }, json: {} });
+ const undo3 = await t.get(`/api/v1/designs/${id}/undo`, { ...POST, headers: { 'x-design-key': key }, json: {} });
+ assert.equal(undo2.json().pick.currentSeq, 2);
+ assert.equal(undo3.json().pick.currentSeq, 1);
+ const baseUndo = await t.get(`/api/v1/designs/${id}/undo`, { ...POST, headers: { 'x-design-key': key }, json: {} });
+ assert.equal(baseUndo.status, 200);
+ assert.equal(baseUndo.json().changed, false);
+
+ const fresh = await click(id, { path, spacing: 'lg' }, { key });
+ assert.equal(fresh.status, 201, fresh.text);
+ assert.equal(fresh.json().version.seq, 4);
+ list = (await versions(id, { key })).json();
+ assert.deepEqual(list.versions.map(v => v.seq), [1, 2, 3, 4], 'undo + a new edit keeps the old versions');
+ assert.equal(list.versions[1].kind, 'chat');
+ assert.equal(list.versions[2].kind, 'click');
+ assert.equal(list.currentSeq, 4);
+ const noRedo = await t.get(`/api/v1/designs/${id}/redo`, { ...POST, headers: { 'x-design-key': key }, json: {} });
+ assert.equal(noRedo.json().changed, false, 'a new edit clears the redo branch');
+ assert.equal((await t.get(`/api/v1/designs/${id}/undo`, { ...POST, json: {} })).status, 403);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch(err => { console.error(err); process.exit(1); });