openvibers/openvibe.design Commits

Add Design Studio pick, edits, versions and Arena vote (#8)

OP OpenVibers committed 15 days ago · 48c3896dc6 · 16 files, +1401 −45

Changes

ddiff --git a/.env.example b/.env.example
iindex 19f4c18..c8ab1f0 100644
---- a/.env.example
++++ b/.env.example
@@@ -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_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
ddiff --git a/README.md b/README.md
iindex 03490da..bcf3a2f 100644
---- a/README.md
++++ b/README.md
@@@ -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 re
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`),
`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
ddiff --git a/STATUS.json b/STATUS.json
iindex aeadafd..0e2b7a6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/migrations/0003_design_working_versions.sql b/migrations/0003_design_working_versions.sql
nnew file mode 100644
iindex 0000000..5d15662
---- /dev/null
++++ b/migrations/0003_design_working_versions.sql
@@@ -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);
ddiff --git a/public/css/design.css b/public/css/design.css
iindex 2f8305b..2c46cd5 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -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; } }
ddiff --git a/public/js/design.js b/public/js/design.js
iindex f5303bb..d354e82 100644
---- a/public/js/design.js
++++ b/public/js/design.js
@@@ -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 {
ddiff --git a/server/app.js b/server/app.js
iindex 287d3f4..23ac263 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 })
ddiff --git a/server/arena/bench.js b/server/arena/bench.js
iindex ddc9405..3dc0ad4 100644
---- a/server/arena/bench.js
++++ b/server/arena/bench.js
@@@ -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 = console
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) {
const out = await request('GET', `/api/v1/tasks/${encodeURIComponent(id)}`);
return out.task;
@@@ -180,6 +194,7 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
kindForCategory,
createTask,
createRound,
++ createRoomRound,
getRound,
nextPair,
castVote,
ddiff --git a/server/config.js b/server/config.js
iindex f5ed08a..814d63e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/design-edit.js b/server/design-edit.js
nnew file mode 100644
iindex 0000000..8d6ba27
---- /dev/null
++++ b/server/design-edit.js
@@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
++ 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
++
++/** 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 };
ddiff --git a/server/designs.js b/server/designs.js
iindex 951c8e9..6703389 100644
---- a/server/designs.js
++++ b/server/designs.js
@@@ -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 };
ddiff --git a/server/http/design-api.js b/server/http/design-api.js
iindex 04cb36f..c8b0204 100644
---- a/server/http/design-api.js
++++ b/server/http/design-api.js
@@@ -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;
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 4017ce0..c75cb6b 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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());
ddiff --git a/server/views/design.js b/server/views/design.js
iindex 4b74327..3d39011 100644
---- a/server/views/design.js
++++ b/server/views/design.js
@@@ -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 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 001a081..32a62c0 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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).'],
ddiff --git a/test/design-studio-b.test.js b/test/design-studio-b.test.js
nnew file mode 100644
iindex 0000000..16415ac
---- /dev/null
++++ b/test/design-studio-b.test.js
@@@ -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 &lt;img src=x onerror=alert\(1\)&gt;/);
++ 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=&quot;1&quot;/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); });