openvibers/openvibe.design Commits
The arena judges all nine of Bench's design kinds, signed in or not (#86)
Changes
@@@ -176,7 +176,7 @@ html[data-ov-motion=reduced] .ds-orb-ring { animation: none; }.ar-form textarea { resize: vertical; line-height: 1.5; }.ar-vote-pair { display: grid; gap: 18px; margin-top: 18px; }.ar-vote-side { min-width: 0; padding: 12px; border: 1px solid var(--border, #26344d); border-radius: 14px; background: var(--bg-card, #0f1729); }--.ar-vote-side h2 { margin: 0 0 8px; font-size: .82rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.ar-vote-side :is(h2, h3) { margin: 0 0 8px; font-size: .82rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }.ar-output { padding: 14px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); min-width: 0; }.ar-output-h { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 14px; margin-bottom: 10px; }.ar-output-h h2 { margin: 0; font-size: 1rem; }@@@ -217,10 +217,10 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef@media (min-width: 900px) { .ar-vote-pair { grid-template-columns: 1fr 1fr; align-items: start; gap: 18px; } }.ar-page:has(#ar-view-phone:checked) .ar-vote-pair { grid-template-columns: 1fr 1fr; gap: 10px; }.ar-vote-side { transition: border-color .15s, box-shadow .15s; }--.ar-vote-side h2 { display: flex; align-items: center; gap: 8px; }--#ar-vote-left h2::before, #ar-vote-right h2::before { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px; background: color-mix(in srgb, var(--room-strong) 18%, transparent); color: var(--room-strong); font-size: .78rem; }--#ar-vote-left h2::before { content: 'L'; }--#ar-vote-right h2::before { content: 'R'; }++.ar-vote-side :is(h2, h3) { display: flex; align-items: center; gap: 8px; }++#ar-vote-left :is(h2, h3)::before, #ar-vote-right :is(h2, h3)::before { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px; background: color-mix(in srgb, var(--room-strong) 18%, transparent); color: var(--room-strong); font-size: .78rem; }++#ar-vote-left :is(h2, h3)::before { content: 'A'; }++#ar-vote-right :is(h2, h3)::before { content: 'B'; }.ar-page:has(.ar-vote-btn[data-winner=left]:is(:hover, :focus-visible)) #ar-vote-left,.ar-page:has(.ar-vote-btn[data-winner=right]:is(:hover, :focus-visible)) #ar-vote-right,.ar-page:has(.ar-vote-btn[data-winner=tie]:is(:hover, :focus-visible)) .ar-vote-side { border-color: var(--room-strong); box-shadow: 0 0 0 1px var(--room-strong); }@@@ -577,3 +577,102 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef.lk-icons>span{display:inline-flex;gap:12px;font-size:15px}.lk-icons .lk-icons-big{font-size:26px;gap:16px}.lk-icons small{flex-basis:100%;color:var(--text-muted,#8a96a8);font-size:12.5px}++++/* ── The arena as a view of Bench's nine design kinds (plan L51 step 1) ──++ A sideways rail of kinds, the blind pair right under it, then one card per kind's board (a rail on a phone). */++.ar-hero--arena { padding-bottom: 6px; }++.ar-hero--arena #ar-total { margin: 8px 0 0; }++.ar-kinds { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(138px, 1fr); gap: 8px; margin: 18px 0 0; padding: 2px 2px 10px; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: thin;++ -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }++.ar-kind { scroll-snap-align: start; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 2px 9px; min-height: 58px; padding: 10px 11px; border: 1px solid var(--border, #26344d); border-radius: 14px;++ background: var(--bg-card, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s, background-color .15s; }++.ar-kind:hover { border-color: color-mix(in srgb, var(--room-strong) 45%, var(--border, #26344d)); }++.ar-kind:focus-visible { outline: 2px solid var(--room-strong); outline-offset: 2px; }++.ar-kind[aria-current] { border-color: var(--room-strong); background: color-mix(in srgb, var(--room-strong) 14%, var(--bg-card, #0f1729)); box-shadow: inset 0 0 0 1px var(--room-strong); }++.ar-kind-i { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: color-mix(in srgb, var(--room-strong) 16%, transparent); color: var(--room-strong); }++.ar-kind[aria-current] .ar-kind-i { background: var(--room-strong); color: var(--bg-primary, #0a0f1c); }++.ar-kind-t { display: grid; gap: 1px; min-width: 0; }++.ar-kind-t b { font-size: .88rem; font-weight: 800; line-height: 1.2; overflow-wrap: anywhere; hyphens: auto; }++.ar-kind-t small { font-size: .72rem; line-height: 1.3; color: var(--text-muted, #7386a3); }++++.ar-judge { margin-top: 18px; scroll-margin-top: 72px; }++.ar-brief h2 { margin: 0; font-size: clamp(1.25rem, 2.6vw, 1.7rem); letter-spacing: -.02em; text-wrap: balance; }++.ar-prompt { margin: 10px 0 0; max-width: 72ch; padding: 10px 14px; border-left: 3px solid var(--room-strong); border-radius: 0 10px 10px 0; background: color-mix(in srgb, var(--room-strong) 7%, transparent);++ color: var(--text-secondary, #96a7c2); line-height: 1.55; overflow-wrap: anywhere; }++/* A piece of art (illustration, motion, a chart, a 3D model, a shader, a component) is drawn once, 4:3, edge to edge */++.ar-art { position: relative; aspect-ratio: 4 / 3; max-width: 100%; overflow: hidden; border: 1px solid var(--border, #26344d); border-radius: 10px; background: #fff; }++.ar-art .ar-art-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #fff; }++.ar-arena .ar-vote-pair { grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); }++.ar-arena .ar-vote-actions .ovk-btn[data-winner=tie], .ar-arena .ar-vote-actions .ovk-btn[data-winner=both_bad] { min-width: 96px; }++.ar-who { margin: 10px 0 0; text-align: center; color: var(--text-muted, #7386a3); font-size: .84rem; line-height: 1.5; }++.ar-who a, .ar-notice a { color: var(--room-strong); font-weight: 750; }++/* The reveal: each side names its model; your pick wears the room colour */++.ar-model { margin: -2px 0 10px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; overflow-wrap: anywhere; }++.ar-model b { font-size: 1.02rem; }++.ar-pick { margin-left: auto; padding: 2px 9px; border-radius: 999px; background: var(--room-strong); color: var(--bg-primary, #0a0f1c); font-size: .68rem; letter-spacing: .06em; }++.ar-reveal-side.is-picked { border-color: var(--room-strong); box-shadow: 0 0 0 1px var(--room-strong); }++.ar-none { padding: clamp(20px, 4vw, 36px); border: 1px dashed var(--border, #26344d); border-radius: 16px; text-align: center; }++.ar-none h2 { margin: 0; font-size: 1.2rem; }++.ar-none p { margin: 8px auto 0; max-width: 56ch; color: var(--text-secondary, #96a7c2); }++.ar-none .ar-actions { justify-content: center; }++++/* Boards: one card per kind; who voted, the top five, a counted fold for the rest */++.ar-boards--kinds { margin-top: 36px; scroll-margin-top: 72px; }++.ar-boards--kinds > .ar-lb-head h2 { font-size: clamp(1.15rem, 2.4vw, 1.45rem); }++.ar-voters { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--border, #26344d); border-radius: 999px; background: var(--bg-card, #0f1729); }++.ar-voters a { min-height: 30px; display: inline-flex; align-items: center; padding: 0 12px; border-radius: 999px; font-weight: 750; font-size: .8rem; color: var(--text-secondary, #96a7c2); text-decoration: none; }++.ar-voters a[aria-current] { background: color-mix(in srgb, var(--room-strong) 20%, transparent); color: var(--text-primary, #e6edf7); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--room-strong) 45%, transparent); }++.ar-voters a:focus-visible { outline: 2px solid var(--room-strong); outline-offset: 2px; }++.ar-kboards { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); align-items: start; }++.ar-kb { min-width: 0; padding: 14px 14px 12px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); scroll-margin-top: 72px; }++.ar-kb.is-on { border-color: color-mix(in srgb, var(--room-strong) 55%, var(--border, #26344d)); }++.ar-kb-h { display: flex; align-items: center; gap: 9px; }++.ar-kb-h .ar-kind-i { width: 26px; height: 26px; border-radius: 8px; }++.ar-kb-h h3 { margin: 0; font-size: 1rem; }++.ar-kb-go { margin-left: auto; min-height: 30px; display: inline-flex; align-items: center; padding: 0 12px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--room-strong) 45%, var(--border, #26344d));++ color: var(--room-strong); font-weight: 800; font-size: .8rem; text-decoration: none; }++.ar-kb-go:hover { background: color-mix(in srgb, var(--room-strong) 12%, transparent); }++.ar-kb-count { margin: 6px 0 0; font-size: .76rem; }++.ar-kb-rows { list-style: none; margin: 10px 0 0; padding: 0; font-variant-numeric: tabular-nums; }++.ar-kb-row { display: grid; grid-template-columns: 1.6em minmax(0, 1fr) 3.4em 3.6em; align-items: baseline; gap: 6px; padding: 6px 2px; border-top: 1px solid color-mix(in srgb, var(--border, #26344d) 70%, transparent); font-size: .86rem; }++.ar-kb-row > :nth-child(n+3) { text-align: right; }++.ar-kb-th { border-top: 0; padding-top: 0; font-size: .66rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.ar-kb-rank { font-weight: 800; color: var(--text-muted, #7386a3); }++.ar-kb-model { font-weight: 700; overflow-wrap: anywhere; }++.ar-kb-elo { font-weight: 800; }++.ar-kb-games { color: var(--text-muted, #7386a3); }++.ar-kb-rows .ar-kb-row:nth-child(2) :is(.ar-kb-rank, .ar-kb-model, .ar-kb-elo) { color: var(--room-strong); }++.ar-kb-more { margin-top: 2px; }++.ar-kb-more summary { cursor: pointer; padding: 6px 2px; font-size: .8rem; font-weight: 750; color: var(--room-strong); }++.ar-kb-more .ar-kb-rows { margin-top: 0; }++.ar-kb-more .ar-kb-row:nth-child(2) :is(.ar-kb-rank, .ar-kb-model, .ar-kb-elo) { color: inherit; }++.ar-kb-empty { margin: 10px 0 0; color: var(--text-muted, #7386a3); font-size: .86rem; }++.ar-kb-links { margin: 10px 0 0; font-size: .78rem; color: var(--text-muted, #7386a3); }++.ar-kb-links a { color: var(--text-secondary, #96a7c2); }++.ar-provisional { padding: 0 6px; border-radius: 999px; font-size: .66rem; font-weight: 800; letter-spacing: .04em; vertical-align: 1px; background: color-mix(in srgb, var(--text-muted, #7386a3) 18%, transparent); color: var(--text-muted, #7386a3); }++++.ar-make { display: grid; gap: 12px; margin-top: 30px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); }++.ar-make-card { padding: 16px 18px; border: 1px dashed var(--border, #26344d); border-radius: 16px; }++.ar-make-card h2 { margin: 0; font-size: 1.05rem; }++.ar-make-card p { margin: 8px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++.ar-make-card .ovk-btn { margin: 4px 6px 0 0; }++.ar-costs { margin-top: 22px; border-top: 1px solid var(--border, #26344d); padding-top: 10px; }++.ar-costs > summary { cursor: pointer; display: flex; align-items: baseline; gap: 10px; padding: 6px 0; font-weight: 800; }++.ar-costs .ar-boards--cost { margin-top: 0; }++++/* The room's own heading and section rules (.ds h3, section spacing) are for its pages; the arena's cards keep theirs */++.ar-arena .ar-vote-side { margin: 0; }++.ar-arena .ar-vote-side h3 { margin: 0 0 10px; display: flex; align-items: center; gap: 8px; font-size: .8rem; letter-spacing: .06em; }++.ar-arena #ar-vote-left h3::before, .ar-arena #ar-vote-right h3::before { content: none; display: none; }++.ar-letter { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 8px; background: color-mix(in srgb, var(--room-strong) 18%, transparent); color: var(--room-strong); font-size: .86rem; font-weight: 900; letter-spacing: 0; }++.ar-arena .ar-kb-h h3 { margin: 0; font-size: 1rem; font-weight: 800; letter-spacing: -.01em; text-transform: none; color: var(--text-primary, #e6edf7); }++.ar-arena .ar-boards--kinds, .ar-arena .ar-judge { margin-bottom: 0; }++++@media (max-width: 759px) {++ .ar-kinds { grid-auto-columns: minmax(124px, 42%); margin-left: -2px; margin-right: -2px; }++ /* Nine boards side by side, a swipe apart, instead of nine cards down the page */++ .ar-kboards { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 86%; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 8px; }++ .ar-kb { scroll-snap-align: start; }++ .ar-who { text-align: left; }++}++@media (prefers-reduced-motion: reduce) { .ar-kind { transition: none; } }@@@ -93,6 +93,10 @@if (/Status:\s*(queued|generating)/i.test(state)) setTimeout(() => window.location.reload(), 4000);}++ // On a phone, a pair opens at the phone view: a 1280 px page drawn 270 px wide says little.++ var phoneView = $('ar-view-phone');++ if (phoneView && window.matchMedia && window.matchMedia('(max-width: 759px)').matches) phoneView.checked = true;++// Fit every generated frame to the column it sits in: a desktop frame fills the width, a phone stays a handset.var shots = document.querySelectorAll('.ar-shot');if (shots.length && 'ResizeObserver' in window) {@@@ -89,6 +89,8 @@ async function createApp(opts = {}) {const app = express();app.disable('x-powered-by');// Cloudflare Turnstile on the guest icon form (L50 step 5), only when both of its keys are set.++ // The arena draws each Bench output in a sandboxed frame from Bench's render origin (plan L51).++ const benchRender = config.bench && /^https?:\/\/[^/\s'";]+$/.test(String(config.bench.renderOrigin || '')) ? [config.bench.renderOrigin] : [];const turnstileSrc = config.glyphGuests && config.glyphGuests.turnstileSiteKey && config.glyphGuests.turnstileSecret ? ['https://challenges.cloudflare.com'] : [];app.set('trust proxy', config.trustProxy);app.locals.ctx = ctx;@@@ -116,7 +118,7 @@ async function createApp(opts = {}) {// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],// 'self' covers the Arena's about:srcdoc iframes; generated pages cannot navigate the top window.-- frameSrc: ["'self'", 'https://openvibe.network', ...turnstileSrc],++ frameSrc: ["'self'", 'https://openvibe.network', ...turnstileSrc, ...benchRender],frameAncestors: ["'none'"],objectSrc: ["'none'"],baseUri: ["'self'"],@@@ -44,6 +44,10 @@ function mapBenchProblem(status, body) {case 'bench.already_judged': return new ArenaError(409, 'arena.already_voted', detail);case 'bench.invalid_pair': return new ArenaError(422, 'arena.invalid_pair', detail);case 'bench.invalid_outcome': return new ArenaError(422, 'arena.invalid_winner', detail);++ // A vote relayed for someone who has not signed in (plan L51): Bench's caps all answer the same way.++ case 'bench.limit_anon': return new ArenaError(429, 'arena.limit_anon', 'Sign in to keep voting today.');++ case 'bench.anon_disabled': return new ArenaError(403, 'arena.anon_disabled', 'Voting without signing in is off at the moment. Sign in to vote.');++ case 'bench.anon_kind_not_allowed': return new ArenaError(403, 'arena.anon_kind_not_allowed', 'Sign in to judge this kind.');default: return new BenchClientError(status, code, detail);}}@@@ -78,6 +82,18 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = consolereturn { Accept: 'application/json', Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };}++ /**++ * Someone who has not signed in (plan L51): Design's own service token, and the voter id Design derived from its++ * own cookie in X-OV-Voter. Bench keeps only a hash of it, and caps each voter and Design's relay a day.++ */++ async function relayHeaders(voter) {++ const id = String(voter || '');++ if (!/^[A-Za-z0-9._~:-]{6,200}$/.test(id)) throw new ArenaError(401, 'token.required', 'Sign in to vote.');++ return { ...(await serviceHeaders()), 'X-OV-Voter': `anon:${id}` };++ }++++ const asViewer = async ({ token = null, voter = null }) => (token ? personHeaders(token) : await relayHeaders(voter));++async function request(method, path, { body = null, headers = null, token = null } = {}) {const h = headers || (token ? personHeaders(token) : await serviceHeaders());const url = `${bench.url.replace(/\/+$/, '')}${path}`;@@@ -166,11 +182,33 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console});}-- async function getBoard(kindId, { type = 'elo', token = null } = {}) {-- const out = await request('GET', `/api/v1/boards/${encodeURIComponent(kindId)}?type=${encodeURIComponent(type)}`, { token });++ /** The next blind pair of a kind for this viewer: a person (their token) or a voter Design relays. Null: none. */++ async function nextJudgement(kindId, { token = null, voter = null } = {}) {++ const out = await request('GET', `/api/v1/judgements/next?kind=${encodeURIComponent(kindId)}`, { headers: await asViewer({ token, voter }) });++ return out && out.round && out.left && out.right ? out : null;++ }++++ /** One judgement, as the person or the relayed voter. Bench answers with the two entries revealed. */++ async function castJudgement({ roundId, pair, outcome, token = null, voter = null }) {++ return await request('POST', `/api/v1/rounds/${encodeURIComponent(roundId)}/judgements`, {++ headers: await asViewer({ token, voter }),++ body: { pair, outcome },++ });++ }++++ /** A kind's board. voters: null (everyone), 'people' (signed-in votes only) or 'anon'. */++ async function getBoard(kindId, { type = 'elo', token = null, voters = null } = {}) {++ const q = new URLSearchParams({ type });++ if (voters === 'people' || voters === 'anon') q.set('voters', voters);++ const out = await request('GET', `/api/v1/boards/${encodeURIComponent(kindId)}?${q}`, { token });return out.board;}++ /** Where a model's output is drawn: Bench's cookie-less render origin, framed with sandbox="allow-scripts". */++ function renderUrl(entryId) {++ return `${String((bench && bench.renderOrigin) || 'https://bench-render.openvibe.services').replace(/\/+$/, '')}/render/e/${encodeURIComponent(entryId)}`;++ }++function datasetUrl(kindId) {return `${bench.publicUrl.replace(/\/+$/, '')}/api/v1/datasets/${encodeURIComponent(kindId)}.jsonl`;}@@@ -199,7 +237,10 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = consolenextPair,castVote,castImportedVote,++ nextJudgement,++ castJudgement,getBoard,++ renderUrl,datasetUrl,addEntry,};nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++++/**++ * The nine design kinds OpenVibe.Bench judges (plan L51 step 1). Design's /arena is a view of them: a blind pair of++ * any kind, judged on the page, and each kind's board.++ *++ * viewports the entry is a whole page, so it is compared at desktop (1280 px) and phone (390 px) widths;++ * the others are one piece of art, drawn once in a 4:3 frame.++ * glyph the Shared glyph the kind's chip wears.++ */++const DESIGN_KINDS = Object.freeze([++ { id: 'design.page', name: 'Pages', line: 'Whole landing pages', glyph: 'window', viewports: true },++ { id: 'design.ui', name: 'Screens', line: 'App and dashboard screens', glyph: 'computer', viewports: true },++ { id: 'design.interface', name: 'Systems', line: 'Components that belong together', glyph: 'layers', viewports: true },++ { id: 'design.component', name: 'Components', line: 'One component or a logo', glyph: 'shapes', viewports: false },++ { id: 'design.svg', name: 'Illustration', line: 'SVG art and icon sets', glyph: 'pencil', viewports: false },++ { id: 'design.motion', name: 'Motion', line: 'Animation and effects', glyph: 'film', viewports: false },++ { id: 'design.dataviz', name: 'Charts', line: 'Data drawn to be read', glyph: 'chart', viewports: false },++ { id: 'design.model3d', name: '3D models', line: 'Models you can turn', glyph: 'cubes', viewports: false },++ { id: 'design.shader', name: 'Shaders', line: 'Light and colour in GLSL', glyph: 'wave', viewports: false },++].map(Object.freeze));++++const BY_ID = new Map(DESIGN_KINDS.map((k) => [k.id, k]));++const DEFAULT_KIND = 'design.page';++++/** The kind a query names, by id ('design.svg') or short name ('svg'); anything else is the default. */++function kindOf(value) {++ const v = String(value == null ? '' : value).trim().toLowerCase();++ if (BY_ID.has(v)) return BY_ID.get(v);++ if (BY_ID.has(`design.${v}`)) return BY_ID.get(`design.${v}`);++ return BY_ID.get(DEFAULT_KIND);++}++++const isKind = (value) => BY_ID.has(String(value == null ? '' : value));++++module.exports = { DESIGN_KINDS, DEFAULT_KIND, kindOf, isKind };@@@ -13,6 +13,7 @@*/const { ArenaError, CATEGORIES, WINNERS, K, BASE_ELO, shuffle } = require('./legacy-store');const { sanitizeHtml } = require('./sanitize');++const { DESIGN_KINDS, kindOf, isKind } = require('./kinds');function entryToOutput(e) {const payload = e.payload || {};@@@ -153,6 +154,76 @@ function createArenaStore({ config, bench }) {return out;}++ // ── The nine design kinds (plan L51 step 1): a blind pair of any kind for a person or a relayed voter, the vote,++ // and every kind's board. The outputs are drawn by Bench's render origin, never by this document.++ const frameOf = (entryId) => (bench.renderUrl ? bench.renderUrl(entryId) : '');++++ async function pairFor(kindId, { token = null, voter = null } = {}) {++ const kind = kindOf(kindId);++ const pair = await bench.nextJudgement(kind.id, { token, voter });++ if (!pair) return null;++ return {++ kind: kind.id,++ round: { id: pair.round.id, prompt: String(pair.round.prompt || '') },++ left: { id: pair.left.id, frame: frameOf(pair.left.id) },++ right: { id: pair.right.id, frame: frameOf(pair.right.id) },++ };++ }++++ async function judge({ kind: kindId, roundId, left, right, outcome, token = null, voter = null }) {++ if (!WINNERS.includes(outcome)) throw new ArenaError(422, 'arena.invalid_winner', 'Pick left, right, a tie or both bad.');++ if (!left || !right || left === right) throw new ArenaError(422, 'arena.invalid_pair', 'A vote needs two different designs.');++ if (!roundId) throw new ArenaError(422, 'arena.invalid_pair', 'That pair has no round.');++ const out = await bench.castJudgement({ roundId, pair: { left, right }, outcome, token, voter });++ const side = (e, id) => ({++ id,++ frame: frameOf(id),++ model: (e && e.model) || 'unknown',++ provider: (e && e.provider) || null,++ cost_usd: e && e.cost_usd != null ? Number(e.cost_usd) : null,++ ms: e && e.ms != null ? Number(e.ms) : null,++ });++ const revealed = out.revealed || {};++ return {++ kind: isKind(kindId) ? kindId : kindOf(kindId).id,++ round: { id: roundId },++ outcome,++ left: side(revealed.left, left),++ right: side(revealed.right, right),++ };++ }++++ // Nine boards on every view of /arena: kept for a minute (Bench's own answer is public for 30 s).++ const boardCache = new Map();++ async function boardsAll({ voters = null, now = Date.now() } = {}) {++ const key = voters === 'people' || voters === 'anon' ? voters : 'all';++ const hit = boardCache.get(key);++ if (hit && now - hit.at < 60_000) return hit.boards;++ const boards = await Promise.all(DESIGN_KINDS.map(async (kind) => {++ try {++ const b = await bench.getBoard(kind.id, { type: 'elo', voters: key === 'all' ? null : key });++ const v = (b && b.voters) || {};++ return {++ kind: kind.id,++ rows: ((b && b.rows) || []).map(boardRowToLocal),++ voters: { person: Number(v.person || 0), anon: Number(v.anon || 0), total: Number(v.total || 0) },++ };++ } catch {++ return { kind: kind.id, rows: [], voters: null, failed: true };++ }++ }));++ if (!boards.every((b) => b.failed)) boardCache.set(key, { at: now, boards });++ return boards;++ }++++ let costCache = null;++ async function costAll({ now = Date.now() } = {}) {++ if (costCache && now - costCache.at < 60_000) return costCache.boards;++ const boards = await costLeaderboard(null);++ costCache = { at: now, boards };++ return boards;++ }++async function datasetJsonl() {// The open dataset now lives on Bench; Design redirects to it.return { redirect: bench.datasetUrl('design.page') };@@@ -161,6 +232,7 @@ function createArenaStore({ config, bench }) {return {CATEGORIES, WINNERS, K, BASE_ELO, shuffle,counts, createRound, getRound, nextPair, vote, leaderboard, costLeaderboard, datasetJsonl,++ pairFor, judge, boardsAll, costAll,};}@@@ -69,6 +69,8 @@ function load(env = process.env) {bench: {url: trim(env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230'),publicUrl: trim(env.OV_BENCH_URL || 'https://bench.openvibe.services'),++ // Bench's cookie-less render origin: the arena frames each output from it (plan L51).++ renderOrigin: trim(env.OV_BENCH_RENDER_ORIGIN || 'https://bench-render.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.entry.create bench.board.read',},@@@ -95,6 +97,9 @@ function load(env = process.env) {intervalMs: Math.max(250, int(env.DESIGN_ARENA_JOB_INTERVAL_MS, 1500)),pollMs: Math.max(250, int(env.DESIGN_ARENA_POLL_MS, 20_000)),maxPrompt: Math.max(20, int(env.DESIGN_ARENA_MAX_PROMPT, 4000)),++ // Judging without signing in (plan L51): Bench caps each voter (30 a day) and Design's relay, but not the++ // address behind a relayed vote, so Design caps that itself. 0 turns signed-out voting off.++ guestVotesPerAddress: Math.max(0, int(env.DESIGN_ARENA_GUEST_VOTES_PER_ADDRESS, 60)),},// OpenVibe.Events → this service for ADR-033 account export/deletion. Off when the secret is unset.@@@ -18,6 +18,7 @@*/const express = require('express');const path = require('path');++const nodeCrypto = require('crypto');const views = require('../views');const tokensView = require('../views/tokens');const lookView = require('../views/look');@@@ -31,7 +32,8 @@ const { baseUrl: layoutBase } = require('../render/layout');const { asyncRouter } = require('./router');const { send } = require('../render/layout');const { ArenaError } = require('../arena/store');--const { writeAllowed } = require('./same-origin');++const { writeAllowed, sameOrigin } = require('./same-origin');++const arenaKinds = require('../arena/kinds');function meOf(viewer) {if (!viewer || viewer.kind !== 'user') return { signedIn: false, user: null };@@@ -223,20 +225,63 @@ function createPageRoutes(ctx) {return page(res, found || !req.query.theme ? 200 : 404, tokensView.tokensPage({ themes, found, format, both }), req.viewer);});-- // ── Arena ───────────────────────────────────────────────++ // ── Arena: a view of Bench's nine design kinds (plan L51 step 1) ──++ // Someone who has not signed in may judge too. Their voter is a random id in an httpOnly cookie that only the++ // arena reads; Bench gets a keyed hash of it (X-OV-Voter) and caps each voter a day. Bench does not cap the++ // address behind a relayed vote, so Design does: a count per day of a keyed hash of the address, in memory.++ const VOTER_COOKIE = 'ovd_voter';++ const voterKey = (config.oauth && config.oauth.clientSecret) ? `arena-voter:${config.oauth.clientSecret}` : nodeCrypto.randomBytes(32).toString('hex');++ const keyed = (v) => nodeCrypto.createHmac('sha256', voterKey).update(String(v)).digest('hex').slice(0, 40);++ const guestCap = Math.max(0, Number(config.arena && config.arena.guestVotesPerAddress) || 0);++ const voterOf = (req, res, { make = false } = {}) => {++ let raw = String((req.cookies && req.cookies[VOTER_COOKIE]) || '');++ if (!/^[0-9a-f]{32}$/.test(raw)) {++ if (!make) return null;++ raw = nodeCrypto.randomBytes(16).toString('hex');++ res.cookie(VOTER_COOKIE, raw, { httpOnly: true, sameSite: 'lax', secure: /^https:/.test(String(config.baseUrl || '')), maxAge: 365 * 86400 * 1000, path: '/arena' });++ }++ return keyed(raw);++ };++ const addressVotes = { day: '', counts: new Map() };++ const addressAllows = (req, { spend = false } = {}) => {++ const day = new Date().toISOString().slice(0, 10);++ if (addressVotes.day !== day) { addressVotes.day = day; addressVotes.counts.clear(); }++ const key = keyed(`${day}|${req.ip || ''}`);++ const used = addressVotes.counts.get(key) || 0;++ if (used >= guestCap) return false;++ if (spend) addressVotes.counts.set(key, used + 1);++ return true;++ };++ const votersOf = (q) => (q === 'people' || q === 'anon' ? q : null);++ const arenaBoards = async (voters) => {++ const [boards, cost] = await Promise.all([++ arena.boardsAll({ voters }).catch((err) => { log.warn('[OpenVibe.Design] arena boards failed:', (err && err.message) || err); return []; }),++ arena.costAll().catch(() => []),++ ]);++ return { boards, cost };++ };++ const arenaPage = (res, status, req, data) => page(res, status, views.arenaHome({++ me: meOf(req.viewer), guestVotes: guestCap > 0, benchUrl: config.bench && config.bench.publicUrl, ...data,++ }), req.viewer, { cacheControl: 'private, no-store' });++r.get('/arena', async (req, res) => {-- const me = meOf(req.viewer);-- let boards;-- let costBoards;-- try {-- boards = await arena.leaderboard(null);-- costBoards = await arena.costLeaderboard(null);-- } catch (err) {-- log.warn('[OpenVibe.Design] leaderboard failed:', (err && err.message) || err);-- boards = categories.map((category) => ({ category, leaderboard: [] }));-- costBoards = [];++ const kind = arenaKinds.kindOf(req.query.kind);++ const voters = votersOf(req.query.voters);++ const token = tokenOf(req);++ let pair = null;++ let notice = null;++ if (token || guestCap > 0) {++ try {++ pair = await arena.pairFor(kind.id, token ? { token } : { voter: voterOf(req, res, { make: true }) });++ } catch (err) {++ if (err instanceof ArenaError) notice = err.detail;++ else {++ log.warn('[OpenVibe.Design] picking a pair failed:', (err && err.message) || err);++ notice = 'The next pair could not be read. Try again in a moment.';++ }++ }}-- return page(res, 200, views.arenaHome({ categories: boards, costCategories: costBoards, me, limits: await aria(req.viewer) }), req.viewer);++ return arenaPage(res, 200, req, { kind: kind.id, pair, notice, voters, ...(await arenaBoards(voters)) });});r.get('/arena/new', async (req, res) => {@@@ -259,41 +304,42 @@ function createPageRoutes(ctx) {}});-- r.get('/arena/vote', async (req, res) => {-- const me = meOf(req.viewer);-- let pair = null;-- let notice = null;-- if (subjectOf(req)) {-- try { pair = await arena.nextPair(subjectOf(req), { token: tokenOf(req), category: req.query.category === 'logo' ? 'logo' : 'landing', random: ctx.random || Math.random }); } catch (err) {-- log.warn('[OpenVibe.Design] picking a pair failed:', (err && err.message) || err);-- notice = 'The next pair could not be read. Try again in a moment.';-- }-- }-- return page(res, 200, views.arenaVote({ pair, me, notice }), req.viewer);++ // The old address of the vote: the vote is on /arena now, for every kind.++ r.get('/arena/vote', (req, res) => {++ const kind = req.query.category === 'logo' ? 'design.component' : arenaKinds.kindOf(req.query.kind).id;++ return res.redirect(302, `/arena?kind=${encodeURIComponent(kind)}#ar-pair`);});-- r.post('/arena/vote', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {-- const me = meOf(req.viewer);-- if (!subjectOf(req)) return res.redirect(303, '/auth/login?next=%2Farena%2Fvote');-- if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('A vote must come from openvibe.design itself.');++ // One vote, as the signed-in person or as a voter Design relays. The answer is the arena with the two designs++ // revealed. A plain form post, so it works without JavaScript.++ r.post('/arena/vote', express.urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {const b = req.body || {};++ const kind = arenaKinds.kindOf(b.kind || (b.category === 'logo' ? 'design.component' : null));++ const voters = votersOf(b.voters);++ const token = tokenOf(req);++ const again = (status, notice) => arenaBoards(voters).then((more) => arenaPage(res, status, req, { kind: kind.id, pair: null, notice, voters, ...more }));++ if (token) {++ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('A vote must come from openvibe.design itself.');++ } else {++ if (!guestCap) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/arena?kind=${kind.id}`)}`);++ if (!sameOrigin(req, config)) return res.status(403).type('text/plain').send('Vote on openvibe.design itself.');++ if (!addressAllows(req)) return again(429, 'Sign in to keep voting today.');++ }++ const voter = token ? null : voterOf(req, res, { make: true });try {-- const out = await arena.vote({++ const result = await arena.judge({++ kind: kind.id,roundId: String(b.round_id || ''),-- subject: subjectOf(req),-- leftOutput: String(b.left_output || ''),-- rightOutput: String(b.right_output || ''),-- winner: String(b.winner || ''),-- token: tokenOf(req),++ left: String(b.left_output || ''),++ right: String(b.right_output || ''),++ outcome: String(b.winner || ''),++ ...(token ? { token } : { voter }),});-- return page(res, 201, views.arenaVote({ me, result: {-- vote: { round: out.round.id, winner: out.winner, at: out.at },-- revealed: { left: out.left, right: out.right },-- } }), req.viewer);++ if (!token) addressAllows(req, { spend: true });++ return arenaPage(res, 200, req, { kind: kind.id, reveal: result, voters, ...(await arenaBoards(voters)) });} catch (err) {-- const notice = err instanceof ArenaError ? err.detail : 'The vote could not be recorded. Try again in a moment.';if (!(err instanceof ArenaError)) log.warn('[OpenVibe.Design] vote failed:', (err && err.message) || err);-- return page(res, err instanceof ArenaError ? err.status : 500, views.arenaVote({ me, notice }), req.viewer);++ return again(err instanceof ArenaError ? err.status : 500, err instanceof ArenaError ? err.detail : 'The vote could not be recorded. Try again in a moment.');}});@@@ -18,6 +18,8 @@ const { pickBlock, pickRules, foldAround } = require('../render/short');const sc = require('openvibe-shared/showcase');const rooms = require('openvibe-shared/rooms');const { iframeHtml } = require('../arena/sanitize');++const glyphs = require('openvibe-shared/glyphs');++const { DESIGN_KINDS, DEFAULT_KIND, kindOf } = require('../arena/kinds');const ovServe = require('openvibe-shared/serve');const { TARGETS } = require('openvibe-shared/tokens');@@@ -311,27 +313,125 @@ function arenaLeaderboard(category, rows, { type = 'elo' } = {}) {<tbody>${body}</tbody></table></section>`;}--function arenaHome({ categories = [], costCategories = [], me = {}, limits = null } = {}) {++/** One output of a pair, drawn by Bench's render origin. The frame runs the model's code with scripts only: no same++ * origin, no forms, no popups, no navigation of this page, and Bench's own CSP refuses every network request. */++function arenaFrame(src, { width, height, title, className }) {++ return `<iframe class="ar-frame ${className}" src="${esc(src)}" width="${width}" height="${height}" data-width="${width}" title="${esc(title)}" sandbox="allow-scripts" referrerpolicy="no-referrer" allow="" loading="eager"></iframe>`;++}++++/** A side of a pair: a page kind at phone and desktop widths, any other kind once, in a 4:3 frame. */++function arenaSide(kind, side, letter) {++ if (kind.viewports) {++ return ['390', '1280'].map((w) => {++ const phone = w === '390';++ return `<div class="ar-shot ${phone ? 'ar-shot--phone' : 'ar-shot--desktop'}" data-width="${w}"><div class="ar-shot-frame">${arenaFrame(side.frame, {++ width: phone ? 390 : 1280, height: phone ? 844 : 800, title: `Design ${letter} at ${w} pixels`, className: phone ? 'ar-frame--phone' : 'ar-frame--desktop',++ })}</div><p class="ar-shot-label">${phone ? 'Phone · 390 px' : 'Desktop · 1280 px'}</p></div>`;++ }).join('');++ }++ return `<div class="ar-art">${arenaFrame(side.frame, { width: 800, height: 600, title: `Design ${letter}`, className: 'ar-art-frame' })}</div>`;++}++++/** A side's name: the letter as a badge, "Design A" for a screen reader. */++const arenaLetter = (letter) => `<span class="ar-letter" aria-hidden="true">${letter}</span><span class="dv-visually-hidden">Design ${letter}</span>`;++const arenaSecs = (ms) => (ms == null ? null : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms)} ms`);++++/**++ * The Design Arena (plan L51 step 1): a view of OpenVibe.Bench's nine design kinds. A blind pair of the chosen kind++ * is judged right on the page, signed in or not; the vote reveals both models; every kind's board follows.++ *++ * kind the chosen kind's id pair { kind, round, left, right } or null++ * reveal the vote just cast, or null boards [{ kind, rows, voters }] cost [{ category, leaderboard }]++ * voters null | 'people' | 'anon' guestVotes whether someone signed out may vote++ */++function arenaHome({ kind: kindId = DEFAULT_KIND, pair = null, reveal = null, notice = null, boards = [], cost = [], voters = null, me = {}, guestVotes = true, benchUrl: bench = 'https://bench.openvibe.services' } = {}) {const signedIn = Boolean(me.signedIn);-- const boards = categories.map((c) => arenaLeaderboard(c.category, c.leaderboard || [])).join('');-- const costBoards = costCategories.map((c) => arenaLeaderboard(c.category, c.leaderboard || [], { type: 'cost' })).join('');-- const rows = categories.flatMap((c) => c.leaderboard || []);-- const body = `<div class="ar-page" id="ar-home">--<header class="ar-hero"><p class="ar-kicker">Design Arena</p><h1>Which model designs it best?</h1>--<p class="ar-lede">One prompt goes to three models. They each build the same page; you see two at phone and desktop sizes, vote blind, and an Elo leaderboard per category ranks the designers, with the cost and time of every generation.</p>--<div class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a> <a class="ovk-btn ovk-btn--quiet" href="/arena/vote">Vote blind</a></div>--<p class="ar-fine" id="ar-limits">${signedIn-- ? (limits ? `You have ${limits.remaining} of ${limits.per_day} rounds left today. The Arena has ${limits.site_remaining} of ${limits.site_daily} left.` : 'Signed in: you can start a round or vote.')-- : 'Sign in to start a round or vote. The leaderboard and the dataset are open to everyone.'}</p></header>--<section class="ar-boards" id="ar-leaderboard" aria-labelledby="ar-boards-h"><div class="ar-lb-head"><h2 id="ar-boards-h">The leaderboard</h2><span class="ar-fine">${rows.length} model${rows.length === 1 ? '' : 's'} ranked · K = 32</span></div>--${boards || '<p class="ar-empty">No rounds have been judged yet. Start the first one.</p>'}--<p class="ar-fine">A win rate counts a tie as half a win. <code>both_bad</code> is a game for neither model. The cost and time are the median over the outputs. Ranges are 95% confidence intervals; <span class="ar-provisional">provisional</span> means fewer than 10 games.</p></section>--<section class="ar-boards ar-boards--cost" id="ar-cost" aria-labelledby="ar-cost-h"><div class="ar-lb-head"><h2 id="ar-cost-h">Cost view</h2><span class="ar-fine">Per model across all entries</span></div>--${costBoards || '<p class="ar-empty">No entries yet.</p>'}</section>--<section class="ar-open" id="ar-open"><h2>Everything is open</h2><p class="ar-lede">The prompts, every generated page and every vote are downloadable. No voter is named.</p>--<p><a class="ovk-btn ovk-btn--quiet" href="/api/v1/arena/dataset.jsonl">Download dataset.jsonl</a></p></section>++ const kind = kindOf(kindId);++ const benchUrl = String(bench || 'https://bench.openvibe.services').replace(/\/+$/, '');++ const kindHref = (id, hash = '#ar-pair') => `/arena?kind=${encodeURIComponent(id)}${voters ? `&voters=${voters}` : ''}${hash}`;++ const signIn = `/auth/login?next=${encodeURIComponent(`/arena?kind=${kind.id}`)}`;++ const total = boards.reduce((a, b) => a + ((b.voters && b.voters.total) || 0), 0);++++ const rail = `<nav class="ar-kinds" id="ar-kinds" aria-label="Kinds of design">${DESIGN_KINDS.map((k) => `<a class="ar-kind" href="${kindHref(k.id)}"${k.id === kind.id ? ' aria-current="page"' : ''} data-kind="${esc(k.id)}">`++ + `<span class="ar-kind-i">${glyphs.svg(k.glyph, { size: 18 })}</span><span class="ar-kind-t"><b>${esc(k.name)}</b><small>${esc(k.line)}</small></span></a>`).join('')}</nav>`;++++ const who = signedIn++ ? `<p class="ar-who" id="ar-who">You vote as yourself: your votes count with the signed-in people.</p>`++ : `<p class="ar-who" id="ar-who">You are not signed in: your vote counts with the signed-out voters, up to 30 a day. <a href="${esc(signIn)}">Sign in</a> to vote as yourself.</p>`;++ const hidden = (name, value) => `<input type="hidden" name="${name}" value="${esc(value)}">`;++++ let judge;++ if (reveal) {++ const picked = { left: 'A', right: 'B', tie: 'a tie', both_bad: 'both bad' }[reveal.outcome] || reveal.outcome;++ const named = (s, letter, key) => {++ const won = reveal.outcome === key;++ const facts = [s.provider, s.cost_usd != null ? arenaCost(s.cost_usd) : null, arenaSecs(s.ms)].filter(Boolean).map(esc).join(' · ');++ return `<section class="ar-vote-side ar-reveal-side${won ? ' is-picked' : ''}" id="ar-vote-${key}" data-side="${key}"><h3>${arenaLetter(letter)}${won ? ' <span class="ar-pick">Your pick</span>' : ''}</h3>`++ + `<p class="ar-model"><b>${esc(s.model)}</b>${facts ? ` <span class="ar-fine">${facts}</span>` : ''}</p>${arenaSide(kind, s, letter)}</section>`;++ };++ judge = `<header class="ar-brief"><p class="ar-kicker">Vote recorded · ${esc(kind.name)}</p><h2 id="ar-pair-h">You picked ${esc(picked)}. Here are the models.</h2></header>++${kind.viewports ? arenaViewSwitch() : ''}++<div class="ar-vote-pair" id="ar-reveal">${named(reveal.left, 'A', 'left')}${named(reveal.right, 'B', 'right')}</div>++<div class="ar-vote-actions" id="ar-next"><a class="ovk-btn ovk-btn--primary" href="${kindHref(kind.id)}">Next pair ${glyphs.svg('arrow-right', { size: 16 })}</a><a class="ovk-btn ovk-btn--quiet" href="#ar-kb-${esc(kind.id.replace('.', '-'))}">See the ${esc(kind.name)} board</a></div>`;++ } else if (pair) {++ judge = `<header class="ar-brief"><p class="ar-kicker">Blind pair · ${esc(kind.name)}</p><h2 id="ar-pair-h">Which is better?</h2><blockquote class="ar-prompt" id="ar-prompt">${esc(String(pair.round.prompt).slice(0, 1200))}</blockquote></header>++<form id="ar-judge" class="ar-vote" method="post" action="/arena/vote">${hidden('kind', kind.id)}${hidden('round_id', pair.round.id)}${hidden('left_output', pair.left.id)}${hidden('right_output', pair.right.id)}${voters ? hidden('voters', voters) : ''}++${kind.viewports ? arenaViewSwitch() : ''}++<div class="ar-vote-pair" id="ar-vote-pair">++<section class="ar-vote-side" id="ar-vote-left" data-side="left"><h3>${arenaLetter('A')}</h3>${arenaSide(kind, pair.left, 'A')}</section>++<section class="ar-vote-side" id="ar-vote-right" data-side="right"><h3>${arenaLetter('B')}</h3>${arenaSide(kind, pair.right, 'B')}</section></div>++<div class="ar-vote-actions" id="ar-vote-actions"><button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="left" data-winner="left">A is better</button>++<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="tie" data-winner="tie">Tie</button>++<button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="right" data-winner="right">B is better</button>++<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="both_bad" data-winner="both_bad">Both bad</button></div>++${who}</form>`;++ } else if (!signedIn && !guestVotes) {++ judge = `<div class="ar-none" id="ar-none"><h2 id="ar-pair-h">Sign in to judge</h2><p>Every vote is blind: two designs from one brief, no model names until you pick.</p><p>${kit.button({ href: signIn, label: 'Sign in to vote' })}</p></div>`;++ } else {++ const next = DESIGN_KINDS[(DESIGN_KINDS.findIndex((k) => k.id === kind.id) + 1) % DESIGN_KINDS.length];++ judge = `<div class="ar-none" id="ar-none"><h2 id="ar-pair-h">${notice ? 'No pair right now' : `Nothing waiting in ${esc(kind.name)}`}</h2>++<p>${notice ? '' : `You have judged every ${esc(kind.name.toLowerCase())} pair there is, or none is ready yet. New rounds arrive every day.`}</p>++<p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="${kindHref(next.id)}">Try ${esc(next.name)}</a>${kind.id === 'design.page' || kind.id === 'design.component' ? ' <a class="ovk-btn ovk-btn--quiet" href="/arena/new">Start a round</a>' : ''}</p></div>`;++ }++ const alert = notice ? `<p class="ar-notice" id="ar-notice" role="alert">${esc(notice)}${!signedIn && /sign in/i.test(notice) ? ` <a href="${esc(signIn)}">Sign in</a>` : ''}</p>` : '';++++ const boardCard = (b) => {++ const k = kindOf(b.kind);++ const rows = b.rows || [];++ const row = (r, i) => `<li class="ar-kb-row" data-model="${esc(r.model)}"><span class="ar-kb-rank">${i + 1}</span><span class="ar-kb-model">${esc(r.model)}${r.provisional ? ' <span class="ar-provisional" title="Fewer than 10 games">new</span>' : ''}</span><span class="ar-kb-elo">${Number(r.elo).toFixed(0)}</span><span class="ar-kb-games">${r.games}</span></li>`;++ const v = b.voters;++ const counts = v ? `${v.total} vote${v.total === 1 ? '' : 's'} · ${v.person} signed in · ${v.anon} signed out` : 'The board could not be read just now.';++ const first = rows.slice(0, 5).map(row).join('');++ const rest = rows.slice(5);++ return `<li class="ar-kb${k.id === kind.id ? ' is-on' : ''}" id="ar-kb-${esc(k.id.replace('.', '-'))}" data-kind="${esc(k.id)}">++<div class="ar-kb-h"><span class="ar-kind-i">${glyphs.svg(k.glyph, { size: 16 })}</span><h3>${esc(k.name)}</h3><a class="ar-kb-go" href="${kindHref(k.id)}">Judge</a></div>++<p class="ar-fine ar-kb-count">${esc(counts)}</p>++${rows.length ? `<ol class="ar-kb-rows" aria-label="${esc(k.name)} board"><li class="ar-kb-row ar-kb-th" aria-hidden="true"><span></span><span>Model</span><span>Elo</span><span>Games</span></li>${first}</ol>` : '<p class="ar-kb-empty">No votes yet. Be the first to judge it.</p>'}++${rest.length ? `<details class="ar-kb-more"><summary>${rest.length} more</summary><ol class="ar-kb-rows" start="6">${rest.map((r, i) => row(r, i + 5)).join('')}</ol></details>` : ''}++<p class="ar-kb-links"><a href="${esc(`${benchUrl}/gallery?kind=${encodeURIComponent(k.id)}`)}">Every entry</a> · <a href="${esc(`${benchUrl}/api/v1/datasets/${encodeURIComponent(k.id)}.jsonl`)}">Dataset</a></p></li>`;++ };++ const filter = [[null, 'Everyone'], ['people', 'Signed in'], ['anon', 'Signed out']].map(([v, label]) => `<a href="/arena?kind=${encodeURIComponent(kind.id)}${v ? `&voters=${v}` : ''}#ar-boards"${(voters || null) === v ? ' aria-current="true"' : ''}>${label}</a>`).join('');++ const costBoards = cost.map((c) => arenaLeaderboard(c.category, c.leaderboard || [], { type: 'cost' })).join('');++++ const body = `<div class="ar-page ar-arena" id="ar-home" data-kind="${esc(kind.id)}">++<header class="ar-hero ar-hero--arena"><p class="ar-kicker">Design Arena · judged on OpenVibe.Bench</p><h1>Which AI designs it best?</h1>++<p class="ar-lede">Two designs from the same brief, the models hidden. Pick the better one and that kind's board moves. ${signedIn ? '' : 'No account needed.'}</p>++<p class="ar-fine" id="ar-total">${total ? `${total.toLocaleString('en-US')} votes so far across ${DESIGN_KINDS.length} kinds.` : `${DESIGN_KINDS.length} kinds of design, judged blind.`}</p></header>++${rail}++<section class="ar-judge" id="ar-pair" aria-labelledby="ar-pair-h">${alert}${judge}</section>++<section class="ar-boards ar-boards--kinds" id="ar-boards" aria-labelledby="ar-boards-h"><div class="ar-lb-head"><h2 id="ar-boards-h">The boards</h2><nav class="ar-voters" aria-label="Whose votes count">${filter}</nav></div>++<ul class="ar-kboards" id="ar-kboards">${boards.map(boardCard).join('')}</ul>++<p class="ar-fine">Elo from blind pairwise votes, K = 32. A tie is half a win; both bad is a game for neither model; <span class="ar-provisional">new</span> means fewer than 10 games. Signed-out votes are capped per voter and per day.</p></section>++<section class="ar-make" id="ar-make" aria-label="Make and download">++<div class="ar-make-card"><h2>Start a round</h2><p>Describe a page or a component once; three models build it, and the round joins the blind pairs. You cannot judge your own round.</p><p><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a></p></div>++<div class="ar-make-card"><h2>Everything is open</h2><p>Every brief, every output and every vote can be downloaded from Bench, per kind. No voter is named.</p><p><a class="ovk-btn ovk-btn--quiet" href="/api/v1/arena/dataset.jsonl">Pages dataset</a> <a class="ovk-btn ovk-btn--quiet" href="${esc(`${benchUrl}/gallery`)}">The gallery on Bench</a></p></div></section>++${costBoards ? `<details class="ar-costs" id="ar-cost"><summary>What each model costs <span class="ar-fine">${cost.length} kind${cost.length === 1 ? '' : 's'}</span></summary><div class="ar-boards ar-boards--cost">${costBoards}</div></details>` : ''}</div>`;-- return layout({ title: 'The Design Arena', description: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.', path: '/arena', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });++ return layout({++ title: `The Design Arena${kind.id === DEFAULT_KIND ? '' : ` · ${kind.name}`}`,++ description: 'Blind votes on AI designs across nine kinds (pages, screens, illustration, motion, charts, 3D, shaders and more), with an Elo board for each. No account needed.',++ path: '/arena', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'],++ });}function arenaNew({ me = {}, limits = null, categories = [], notice = null } = {}) {@@@ -352,39 +452,6 @@ ${signedIn ? '' : `<p class="ar-notice" id="ar-signin">${kit.button({ href: '/aureturn layout({ title: 'Start an Arena round', description: 'Describe a page once; three AI designers build it and the Arena ranks them.', path: '/arena/new', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });}--function arenaVote({ pair = null, me = {}, result = null, notice = null } = {}) {-- const signedIn = Boolean(me.signedIn);-- let body;-- if (!signedIn) {-- body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind</p><h1>Sign in to judge</h1><p class="ar-lede">The two designs are shown without their models. Sign in to record one vote per round; you cannot vote on a round you started.</p></header><p>${kit.button({ href: '/auth/login?next=%2Farena%2Fvote', label: 'Sign in to vote' })}</p></div>`;-- } else if (result) {-- const left = result.revealed.left;-- const right = result.revealed.right;-- body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote recorded</p><h1>The models are revealed</h1><p class="ar-lede">You chose <b>${esc(result.vote.winner === 'both_bad' ? 'both bad' : result.vote.winner)}</b>. The Elo leaderboard has been updated.</p></header>--<div class="ar-reveal" id="ar-reveal"><div class="ar-reveal-side" id="ar-reveal-left"><h2>Left</h2><p><b>${esc(left.model)}</b>${left.provider ? ` · ${esc(left.provider)}` : ''}</p><p class="ar-fine">${arenaCost(left.cost_usd)} · ${arenaMs(left.ms)} · ${left.tokens} tokens</p></div>--<div class="ar-reveal-side" id="ar-reveal-right"><h2>Right</h2><p><b>${esc(right.model)}</b>${right.provider ? ` · ${esc(right.provider)}` : ''}</p><p class="ar-fine">${arenaCost(right.cost_usd)} · ${arenaMs(right.ms)} · ${right.tokens} tokens</p></div></div>--<p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/vote">Vote on another</a> <a class="ovk-btn ovk-btn--quiet" href="/arena/r/${encodeURIComponent(result.vote.round)}">See the round</a></p></div>`;-- } else if (!pair) {-- body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind</p><h1>Nothing waiting</h1><p class="ar-lede">${esc(notice || 'There is no ready round you have not voted on. Start one, or come back when another is ready.')}</p></header><p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a> <a class="ovk-btn ovk-btn--quiet" href="/arena">The leaderboard</a></p></div>`;-- } else {-- body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind · ${esc(ARENA_CATEGORY[pair.round.category] || pair.round.category)}</p><h1>Which design is better?</h1><p class="ar-lede">${esc(pair.round.prompt)}</p><p class="ar-fine">The models are hidden until you vote. Compare at phone and desktop sizes.</p></header>--<form id="ar-vote-form" class="ar-vote" method="post" action="/arena/vote">--<input type="hidden" id="ar-vote-round" name="round_id" value="${esc(pair.round.id)}">--<input type="hidden" id="ar-vote-left-id" name="left_output" value="${esc(pair.left.id)}">--<input type="hidden" id="ar-vote-right-id" name="right_output" value="${esc(pair.right.id)}">--${arenaViewSwitch()}--<div class="ar-vote-pair" id="ar-vote-pair">--<section class="ar-vote-side" id="ar-vote-left" data-side="left"><h2>Left</h2>${arenaShot(pair.left, 390)}${arenaShot(pair.left, 1280)}</section>--<section class="ar-vote-side" id="ar-vote-right" data-side="right"><h2>Right</h2>${arenaShot(pair.right, 390)}${arenaShot(pair.right, 1280)}</section></div>--<div class="ar-vote-actions" id="ar-vote-actions"><button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="left" data-winner="left">Left is better</button>--<button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="right" data-winner="right">Right is better</button>--<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="tie" data-winner="tie">It is a tie</button>--<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="both_bad" data-winner="both_bad">Both are bad</button></div>--<p class="ar-status" id="ar-vote-status" role="status"></p></form></div>`;-- }-- return layout({ title: 'Vote blind · The Design Arena', description: 'Two generated designs, no model names: vote for the better one.', path: '/arena/vote', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });--}--function arenaRound({ round, me = {} } = {}) {const outputs = round.outputs || [];const ok = outputs.filter((o) => !o.error);@@@ -411,7 +478,7 @@ function notFound() {module.exports = {home, studio, theme, developers, notFound,-- arenaHome, arenaNew, arenaVote, arenaRound, ARENA_CATEGORY, arenaShot,++ arenaHome, arenaNew, arenaRound, ARENA_CATEGORY, arenaShot,layout,setRelease: layoutLib.setRelease,configure: layoutLib.configure,nnew file mode 100644@@@ -0,0 +1,190 @@++'use strict';++/**++ * The Design Arena as a view of Bench's nine design kinds (plan L51 step 1): a blind pair of any kind judged on++ * /arena, signed in or not. Signed out, Design relays the vote (its service token and X-OV-Voter: anon:<a keyed hash++ * of its own cookie>) and caps the address itself; Bench's own caps answer "Sign in to keep voting today". The++ * outputs are drawn by Bench's render origin in sandbox="allow-scripts" frames, the models stay hidden until the vote,++ * and every kind's board carries its voter counts.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const views = require('../server/views');++const { kindOf, DESIGN_KINDS } = require('../server/arena/kinds');++++const ORIGIN = 'https://openvibe.design';++const RENDER = 'https://bench-render.openvibe.services';++const pairPart = (html) => html.slice(html.indexOf('id="ar-pair"'), html.indexOf('id="ar-boards"'));++const voterCookie = (res) => {++ const m = /ovd_voter=([0-9a-f]{32})/.exec(res.headers.get('set-cookie') || '');++ return m ? `ovd_voter=${m[1]}` : null;++};++const field = (html, name) => (new RegExp(`name="${name}" value="([^"]+)"`).exec(html) || [])[1];++const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });++++(async () => {++ await check('the nine kinds, by id or short name', () => {++ assert.strictEqual(DESIGN_KINDS.length, 9);++ assert.strictEqual(kindOf('svg').id, 'design.svg');++ assert.strictEqual(kindOf('design.shader').id, 'design.shader');++ assert.strictEqual(kindOf('poster').id, 'design.page', 'anything else is the default');++ const off = views.arenaHome({ me: {}, guestVotes: false });++ assert.match(off, /Sign in to judge/, 'with signed-out voting off, the pair asks to sign in');++ });++++ await check('the nine boards are read once a minute, a failed kind stays a gap, and nothing is kept when all fail', async () => {++ const { createArenaStore } = require('../server/arena/store');++ const calls = [];++ let down = false;++ const bench = {++ getBoard: async (kind, { voters }) => {++ calls.push(`${kind}|${voters}`);++ if (down || kind === 'design.shader') throw new Error('unreachable');++ return { rows: [{ subject: 'm1', elo: 1010, games: 3 }], voters: { person: 1, anon: 2, total: 3 } };++ },++ };++ const store = createArenaStore({ config: { arena: { maxPrompt: 4000 } }, bench });++ const first = await store.boardsAll({ now: 1000 });++ assert.strictEqual(first.length, 9);++ assert.deepStrictEqual(first.find((b) => b.kind === 'design.svg').voters, { person: 1, anon: 2, total: 3 });++ assert.strictEqual(first.find((b) => b.kind === 'design.shader').failed, true);++ await store.boardsAll({ now: 50_000 });++ assert.strictEqual(calls.length, 9, 'kept for a minute');++ await store.boardsAll({ now: 1000, voters: 'people' });++ assert.strictEqual(calls.filter((c) => c.endsWith('|people')).length, 9, 'each filter has its own');++ down = true;++ await store.boardsAll({ now: 70_000 });++ await store.boardsAll({ now: 70_001 });++ assert.strictEqual(calls.length, 36, 'an all-failed read is not kept');++ });++++ await check('arena.js opens a pair at the phone view on a phone, and leaves it on a computer', () => {++ const fs = require('fs');++ const vm = require('vm');++ const src = fs.readFileSync(require.resolve('../public/js/arena.js'), 'utf8');++ const run = (narrow) => {++ const radio = { checked: false };++ const document = { getElementById: (id) => (id === 'ar-view-phone' ? radio : null), querySelectorAll: () => [], documentElement: { classList: { add() {} } } };++ const window = { matchMedia: (q) => ({ matches: narrow && /max-width: 759px/.test(q) }), location: {} };++ vm.runInNewContext(src, { document, window, setTimeout, fetch: () => null });++ return radio.checked;++ };++ assert.strictEqual(run(true), true);++ assert.strictEqual(run(false), false);++ });++++ const t = await boot({ env: { DESIGN_ARENA_GUEST_VOTES_PER_ADDRESS: '2' } });++ const ana = t.network.addUser('ana');++ const bo = t.network.addUser('bo');++ try {++ const svg = t.bench.seedRound({ kind: 'design.svg', prompt: 'A fox curled up asleep, as a flat SVG', createdBy: ana.subject });++ let cookie;++ let page;++ await check('signed out: a blind pair of the chosen kind, drawn by Bench\'s render origin', async () => {++ page = await t.get('/arena?kind=svg', { headers: { 'x-forwarded-for': '198.51.100.7' } });++ assert.strictEqual(page.status, 200, page.text);++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store', 'a pair is this visitor\'s own');++ cookie = voterCookie(page);++ assert.ok(cookie, 'a voter cookie for the arena');++ assert.match(page.headers.get('set-cookie'), /Path=\/arena/i);++ assert.match(page.headers.get('set-cookie'), /HttpOnly/i);++ assert.ok((page.headers.get('content-security-policy') || '').includes(`frame-src 'self' https://openvibe.network ${RENDER}`), 'the CSP lets the render origin be framed');++ const pair = pairPart(page.text);++ assert.ok(pair.includes('A fox curled up asleep'), 'the brief');++ assert.strictEqual((pair.match(new RegExp(`src="${RENDER}/render/e/bte_`, 'g')) || []).length, 2, 'one 4:3 frame a side for art');++ assert.strictEqual((pair.match(/sandbox="allow-scripts"/g) || []).length, 2, 'scripts only, never same origin');++ assert.ok(!/alpha-1|beta-2|acme/.test(pair), 'the models stay hidden');++ assert.strictEqual((page.text.match(/class="ar-kind"/g) || []).length, 9, 'a chip for each kind');++ assert.match(page.text, /data-kind="design\.svg" aria-current="page"|aria-current="page" data-kind="design\.svg"/);++ const asked = t.bench.state.requests.filter((r) => r.path === '/api/v1/judgements/next').at(-1);++ assert.match(asked.voter, /^anon:[0-9a-f]{40}$/, 'a keyed hash of the cookie, never the cookie');++ assert.ok(!asked.voter.includes(cookie.split('=')[1]));++ const service = t.network.state.tokenRequests.find((x) => x.audience === 'openvibe.bench');++ assert.ok(service && /bench\.judgement\.cast/.test(service.scope), 'Design\'s own service token relays it');++ });++++ await check('signed out: the vote is relayed, the models revealed, and that pair is done', async () => {++ const fields = { kind: 'design.svg', round_id: field(page.text, 'round_id'), left_output: field(page.text, 'left_output'), right_output: field(page.text, 'right_output'), winner: 'left' };++ const cross = await t.get('/arena/vote', { ...form(fields), cookie, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403, 'a cross-site post is refused');++ const r = await t.get('/arena/vote', { ...form(fields), cookie, origin: ORIGIN, headers: { ...form(fields).headers, 'x-forwarded-for': '198.51.100.7' } });++ assert.strictEqual(r.status, 200, r.text);++ const j = t.bench.state.judgements.at(-1);++ assert.strictEqual(j.round_id, svg.id);++ assert.strictEqual(j.voter_kind, 'anon');++ assert.match(j.subject, /^anon_/);++ assert.match(r.text, /You picked A/);++ assert.match(r.text, /Your pick/);++ assert.ok(/alpha-1/.test(pairPart(r.text)) && /beta-2/.test(pairPart(r.text)), 'both models named');++ assert.match(r.text, /href="\/arena\?kind=design\.svg#ar-pair">Next pair/);++ const after = await t.get('/arena?kind=svg', { cookie, headers: { 'x-forwarded-for': '198.51.100.7' } });++ assert.match(after.text, /Nothing waiting in Illustration/, 'the same voter is not shown it again');++ assert.match(after.text, /Try Motion/);++ });++++ await check('Bench\'s cap and Design\'s own address cap: "Sign in to keep voting today"', async () => {++ const more = t.bench.seedRound({ kind: 'design.shader', prompt: 'Northern lights', createdBy: ana.subject });++ const ask = async (addr, c) => t.get('/arena?kind=shader', { cookie: c, headers: { 'x-forwarded-for': addr } });++ const vote = async (addr, c, p) => t.get('/arena/vote', {++ ...form({ kind: 'design.shader', round_id: field(p.text, 'round_id'), left_output: field(p.text, 'left_output'), right_output: field(p.text, 'right_output'), winner: 'tie' }),++ cookie: c, origin: ORIGIN, headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': addr },++ });++ t.bench.state.anonPerDay = 0;++ const first = await ask('203.0.113.5', null);++ const c1 = voterCookie(first);++ const capped = await vote('203.0.113.5', c1, first);++ assert.strictEqual(capped.status, 429);++ assert.match(capped.text, /Sign in to keep voting today\. <a href="\/auth\/login\?next=/);++ t.bench.state.anonPerDay = 30;++ // Design counts the address: two votes from one address, then no more, whatever the cookie.++ const second = t.bench.seedRound({ kind: 'design.shader', prompt: 'Ink in water', createdBy: ana.subject });++ const p1 = await ask('203.0.113.9', null);++ assert.strictEqual((await vote('203.0.113.9', voterCookie(p1), p1)).status, 200);++ const p2 = await ask('203.0.113.9', null);++ assert.strictEqual((await vote('203.0.113.9', voterCookie(p2), p2)).status, 200);++ const p3 = await ask('203.0.113.9', null);++ const before = t.bench.state.requests.filter((r) => r.method === 'POST' && r.path.endsWith('/judgements')).length;++ const third = await vote('203.0.113.9', voterCookie(p3), p3);++ assert.strictEqual(third.status, 429);++ assert.match(third.text, /Sign in to keep voting today/);++ assert.strictEqual(t.bench.state.requests.filter((r) => r.method === 'POST' && r.path.endsWith('/judgements')).length, before, 'refused before Bench');++ assert.ok(more && second);++ });++++ await check('signed in: the person\'s own token, and the reveal', async () => {++ const made = await t.get('/api/v1/arena/rounds', { as: ana, json: { category: 'landing', prompt: 'A bakery landing page' }, origin: ORIGIN });++ assert.strictEqual(made.status, 201, made.text);++ await t.tick();++ const p = await t.get('/arena', { as: bo });++ assert.strictEqual(p.status, 200);++ const pair = pairPart(p.text);++ assert.ok(pair.includes('A bakery landing page'));++ assert.strictEqual((pair.match(/class="ar-shot ar-shot--(phone|desktop)"/g) || []).length, 4, 'a page kind at phone and desktop widths');++ assert.match(pair, /You vote as yourself/);++ const r = await t.get('/arena/vote', {++ as: bo, origin: ORIGIN,++ ...form({ kind: 'design.page', round_id: field(p.text, 'round_id'), left_output: field(p.text, 'left_output'), right_output: field(p.text, 'right_output'), winner: 'right' }),++ });++ assert.strictEqual(r.status, 200, r.text);++ assert.match(r.text, /You picked B/);++ const call = t.bench.state.requests.filter((x) => x.method === 'POST' && x.path.endsWith('/judgements')).at(-1);++ assert.strictEqual(call.voter, null, 'no relay for a person');++ assert.strictEqual(t.bench.state.judgements.at(-1).subject, bo.subject);++ });++++ await check('the boards: nine kinds, voter counts, whose votes', async () => {++ const p = await t.get('/arena?voters=anon');++ assert.strictEqual((p.text.match(/class="ar-kb( is-on)?"/g) || []).length, 9);++ assert.match(p.text, /id="ar-kb-design-svg"[\s\S]*?1 vote · 0 signed in · 1 signed out/);++ assert.ok(t.bench.state.requests.some((r) => r.path === '/api/v1/boards/design.shader' && /voters=anon/.test(r.query)));++ assert.match(p.text, /<a href="\/arena\?kind=design\.page&voters=anon#ar-boards" aria-current="true">Signed out<\/a>|<a href="\/arena\?kind=design\.page&voters=anon#ar-boards" aria-current="true">Signed out<\/a>/);++ assert.match(p.text, /What each model costs/);++ });++++ await check('the old vote address leads to the arena', async () => {++ const r = await t.get('/arena/vote?category=logo');++ assert.strictEqual(r.status, 302);++ assert.strictEqual(r.headers.get('location'), '/arena?kind=design.component#ar-pair');++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -74,8 +74,8 @@ const ORIGIN = 'https://openvibe.design';assert.ok(cost.json().leaderboard.some((r) => r.entries > 0 && r.cost_per_solved != null));assert.ok(t.bench.state.requests.some((x) => x.path === '/api/v1/boards/design.page' && x.query === '?type=cost'));const page = await t.get('/arena');-- assert.match(page.text, /Cost view/);-- assert.match(page.text, /provisional/);++ assert.match(page.text, /What each model costs/, 'the cost view, folded under the boards');++ assert.match(page.text, /class="ar-provisional" title="Fewer than 10 games">new</, 'a model with few games is marked new');});await check('logo uses design.component and dataset redirects to public Bench', async () => {@@@ -124,6 +124,22 @@ async function startBench({ network } = {}) {['design.page', { id: 'design.page', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design page arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } }],['design.component', { id: 'design.component', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design component arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } }],]);++ // The other seven design kinds (plan L51): Design's /arena shows all nine.++ for (const [kid, entry, field] of [['design.svg', 'svg_art', 'svg'], ['design.motion', 'motion_html', 'html'], ['design.ui', 'ui_page', 'html'], ['design.dataviz', 'dataviz_page', 'html'], ['design.model3d', 'gltf_model', 'gltf_base64'], ['design.shader', 'shader_glsl', 'glsl'], ['design.interface', 'component_set', 'html']]) {++ kinds.set(kid, { id: kid, room: 'design', entry, judge: 'human_pairwise', name: kid, metrics: ['elo'], schema: { entry, payload: { [field]: 'string' } } });++ }++ // Anonymous voters a first-party service relays (X-OV-Voter), as Bench does: a hash of the id, a daily cap.++ state.anonPerDay = 30;++ state.anonUsed = new Map();++ function relayed(req) {++ const header = String(req.headers['x-ov-voter'] || '');++ if (!header) return null;++ const v = verify(getAuth(req));++ if (!v || v.kind !== 'service') throw problem(403, 'bench.relay_not_allowed', 'Only a first-party service may relay an anonymous voter.');++ if (!v.cap.includes('bench.judgement.cast')) throw problem(403, 'bench.capability_denied', 'Need bench.judgement.cast.');++ if (!header.startsWith('anon:') || !/^[A-Za-z0-9._~:-]{6,200}$/.test(header.slice(5))) throw problem(400, 'bench.anon_voter_invalid', 'anon:<id>');++ return { subject: `anon_${crypto.createHash('sha256').update(`relay:design|${header.slice(5)}`).digest('hex').slice(0, 24)}`, creator: v.sub };++ }function defaultHtml(prompt) {const p = String(prompt || '').split('\n')[0].slice(0, 200);@@@ -375,7 +391,7 @@ async function startBench({ network } = {}) {let body = {};try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }const url = new URL(req.url, 'http://x');-- state.requests.push({ method: req.method, path: url.pathname, query: url.search, authorization: req.headers.authorization || null, body: text });++ state.requests.push({ method: req.method, path: url.pathname, query: url.search, authorization: req.headers.authorization || null, voter: req.headers['x-ov-voter'] || null, body: text });const json = (status, obj, headers = {}) => {res.writeHead(status, { 'Content-Type': 'application/json', ...headers });@@@ -471,6 +487,18 @@ async function startBench({ network } = {}) {return json(201, { entry: fullEntry(entry) });}++ if (req.method === 'GET' && url.pathname === '/api/v1/judgements/next' && req.headers['x-ov-voter']) {++ const anon = relayed(req);++ const kindId = url.searchParams.get('kind') || 'design.page';++ if (!kinds.has(kindId) || kinds.get(kindId).judge !== 'human_pairwise') return sendProblem(403, 'bench.anon_kind_not_allowed', 'Only pairwise kinds.');++ const round = [...state.rounds.values()].find((r) => r.kind_id === kindId && r.status === 'finished' && r.created_by !== 'service:design'++ && !state.judgements.some((j) => j.round_id === r.id && j.subject === anon.subject)++ && [...state.entries.values()].filter((e) => e.round_id === r.id && !e.error).length >= 2);++ if (!round) { res.writeHead(204); return res.end(); }++ const [left, right] = shuffle([...state.entries.values()].filter((e) => e.round_id === round.id && !e.error));++ return json(200, { round: { id: round.id, kind_id: round.kind_id, prompt: round.prompt }, left: { id: left.id, payload: left.payload }, right: { id: right.id, payload: right.payload } });++ }++if (req.method === 'GET' && url.pathname === '/api/v1/judgements/next') {const person = requirePerson(req);const kindId = url.searchParams.get('kind') || 'design.page';@@@ -491,12 +519,17 @@ async function startBench({ network } = {}) {}if (req.method === 'POST' && /^\/api\/v1\/rounds\/([^/]+)\/judgements$/.test(url.pathname)) {-- const person = requirePerson(req);++ const anon = relayed(req);++ const person = anon ? { subject: anon.subject, anon: true } : requirePerson(req);++ if (anon) {++ const used = state.anonUsed.get(anon.subject) || 0;++ if (used >= state.anonPerDay) return sendProblem(429, 'bench.limit_anon', 'Sign in to keep voting today');++ }const roundId = decodeURIComponent(url.pathname.split('/').slice(-2)[0]);const round = state.rounds.get(roundId);if (!round) return sendProblem(404, 'bench.round_not_found', 'No such round.');if (round.status !== 'finished') return sendProblem(409, 'bench.round_not_ready', 'Round not ready.');-- if (round.created_by === person.subject) return sendProblem(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');++ if (round.created_by === person.subject || (person.anon && round.created_by === 'service:design')) return sendProblem(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');const pair = body.pair || {};if (!pair.left || !pair.right || pair.left === pair.right) return sendProblem(422, 'bench.invalid_pair', 'Invalid pair.');if (state.judgements.some((j) => j.round_id === round.id && j.subject === person.subject)) return sendProblem(409, 'bench.already_judged', 'Already judged.');@@@ -516,7 +549,9 @@ async function startBench({ network } = {}) {subject: person.subject,at: new Date().toISOString(),};++ judgement.voter_kind = person.anon ? 'anon' : 'person';state.judgements.push(judgement);++ if (person.anon) state.anonUsed.set(person.subject, (state.anonUsed.get(person.subject) || 0) + 1);updateRating(round.kind_id, entries.find((e) => e.id === pair.left), entries.find((e) => e.id === pair.right), outcome);return json(201, { judgement, revealed: { left: fullEntry(entries.find((e) => e.id === pair.left)), right: fullEntry(entries.find((e) => e.id === pair.right)) } });}@@@ -526,6 +561,10 @@ async function startBench({ network } = {}) {const type = url.searchParams.get('type') || 'elo';const b = board(kindId, type);if (!b) return sendProblem(404, 'bench.kind_not_found', 'No such kind.');++ const js = state.judgements.filter((j) => j.kind_id === kindId);++ const anonN = js.filter((j) => j.voter_kind === 'anon').length;++ b.voters = { person: js.length - anonN, anon: anonN, service: 0, total: js.length };++ if (url.searchParams.get('voters')) b.category = url.searchParams.get('voters');return json(200, { board: b });}@@@ -563,7 +602,17 @@ async function startBench({ network } = {}) {await new Promise((r) => server.close(r));}-- return { base, url: base, state, tick, close, addEntry };++ /** A finished round of any kind, started by a person (so a relayed voter may judge it), with two entries. */++ function seedRound({ kind = 'design.svg', prompt = 'A small fox', createdBy = 'usr_seed', models = ['alpha-1', 'beta-2'] } = {}) {++ const task = { id: id('btk'), kind_id: kind, prompt, task_class: null, public: true, created_by: 'service:design', created_at: new Date().toISOString() };++ state.tasks.set(task.id, task);++ const round = { id: id('brd'), task_id: task.id, kind_id: kind, offers: [], status: 'running', seed: 0, pool_fallback: false, receipt: null, params: {}, created_by: createdBy, created_at: new Date().toISOString(), finished_at: null, task_class: null, prompt };++ state.rounds.set(round.id, round);++ models.forEach((model) => addEntry(round.id, { provider: 'acme', model, payload: { svg: '<svg viewBox="0 0 10 10"></svg>' }, cost_usd: 0.002, ms: 1500, tokens: 10 }));++ return round;++ }++++ return { base, url: base, state, tick, close, addEntry, seedRound };}module.exports = { startBench };
@@ -176,7 +176,7 @@ html[data-ov-motion=reduced] .ds-orb-ring { animation: none; }
@@ -176,7 +176,7 @@ html[data-ov-motion=reduced] .ds-orb-ring { animation: none; }
.ar-form textarea { resize: vertical; line-height: 1.5; }
.ar-form textarea { resize: vertical; line-height: 1.5; }
.ar-vote-pair { display: grid; gap: 18px; margin-top: 18px; }
.ar-vote-pair { display: grid; gap: 18px; margin-top: 18px; }
.ar-vote-side { min-width: 0; padding: 12px; border: 1px solid var(--border, #26344d); border-radius: 14px; background: var(--bg-card, #0f1729); }
.ar-vote-side { min-width: 0; padding: 12px; border: 1px solid var(--border, #26344d); border-radius: 14px; background: var(--bg-card, #0f1729); }
-.ar-vote-side h2 { margin: 0 0 8px; font-size: .82rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.ar-vote-side :is(h2, h3) { margin: 0 0 8px; font-size: .82rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.ar-output { padding: 14px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); min-width: 0; }
.ar-output { padding: 14px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); min-width: 0; }
.ar-output-h { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 14px; margin-bottom: 10px; }
.ar-output-h { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 14px; margin-bottom: 10px; }
.ar-output-h h2 { margin: 0; font-size: 1rem; }
.ar-output-h h2 { margin: 0; font-size: 1rem; }
@@ -217,10 +217,10 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -217,10 +217,10 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@media (min-width: 900px) { .ar-vote-pair { grid-template-columns: 1fr 1fr; align-items: start; gap: 18px; } }
@media (min-width: 900px) { .ar-vote-pair { grid-template-columns: 1fr 1fr; align-items: start; gap: 18px; } }
.ar-page:has(#ar-view-phone:checked) .ar-vote-pair { grid-template-columns: 1fr 1fr; gap: 10px; }
.ar-page:has(#ar-view-phone:checked) .ar-vote-pair { grid-template-columns: 1fr 1fr; gap: 10px; }
.ar-vote-side { transition: border-color .15s, box-shadow .15s; }
.ar-vote-side { transition: border-color .15s, box-shadow .15s; }
-.ar-vote-side h2 { display: flex; align-items: center; gap: 8px; }
-#ar-vote-left h2::before, #ar-vote-right h2::before { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px; background: color-mix(in srgb, var(--room-strong) 18%, transparent); color: var(--room-strong); font-size: .78rem; }
-#ar-vote-left h2::before { content: 'L'; }
-#ar-vote-right h2::before { content: 'R'; }
+.ar-vote-side :is(h2, h3) { display: flex; align-items: center; gap: 8px; }
+#ar-vote-left :is(h2, h3)::before, #ar-vote-right :is(h2, h3)::before { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px; background: color-mix(in srgb, var(--room-strong) 18%, transparent); color: var(--room-strong); font-size: .78rem; }
+#ar-vote-left :is(h2, h3)::before { content: 'A'; }
+#ar-vote-right :is(h2, h3)::before { content: 'B'; }
.ar-page:has(.ar-vote-btn[data-winner=left]:is(:hover, :focus-visible)) #ar-vote-left,
.ar-page:has(.ar-vote-btn[data-winner=left]:is(:hover, :focus-visible)) #ar-vote-left,
.ar-page:has(.ar-vote-btn[data-winner=right]:is(:hover, :focus-visible)) #ar-vote-right,
.ar-page:has(.ar-vote-btn[data-winner=right]:is(:hover, :focus-visible)) #ar-vote-right,
.ar-page:has(.ar-vote-btn[data-winner=tie]:is(:hover, :focus-visible)) .ar-vote-side { border-color: var(--room-strong); box-shadow: 0 0 0 1px var(--room-strong); }
.ar-page:has(.ar-vote-btn[data-winner=tie]:is(:hover, :focus-visible)) .ar-vote-side { border-color: var(--room-strong); box-shadow: 0 0 0 1px var(--room-strong); }
@@ -577,3 +577,102 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -577,3 +577,102 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.lk-icons>span{display:inline-flex;gap:12px;font-size:15px}
.lk-icons>span{display:inline-flex;gap:12px;font-size:15px}
.lk-icons .lk-icons-big{font-size:26px;gap:16px}
.lk-icons .lk-icons-big{font-size:26px;gap:16px}
.lk-icons small{flex-basis:100%;color:var(--text-muted,#8a96a8);font-size:12.5px}
.lk-icons small{flex-basis:100%;color:var(--text-muted,#8a96a8);font-size:12.5px}
+
+/* ── The arena as a view of Bench's nine design kinds (plan L51 step 1) ──
+ A sideways rail of kinds, the blind pair right under it, then one card per kind's board (a rail on a phone). */
+.ar-hero--arena { padding-bottom: 6px; }
+.ar-hero--arena #ar-total { margin: 8px 0 0; }
+.ar-kinds { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(138px, 1fr); gap: 8px; margin: 18px 0 0; padding: 2px 2px 10px; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: thin;
+ -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
+.ar-kind { scroll-snap-align: start; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 2px 9px; min-height: 58px; padding: 10px 11px; border: 1px solid var(--border, #26344d); border-radius: 14px;
+ background: var(--bg-card, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s, background-color .15s; }
+.ar-kind:hover { border-color: color-mix(in srgb, var(--room-strong) 45%, var(--border, #26344d)); }
+.ar-kind:focus-visible { outline: 2px solid var(--room-strong); outline-offset: 2px; }
+.ar-kind[aria-current] { border-color: var(--room-strong); background: color-mix(in srgb, var(--room-strong) 14%, var(--bg-card, #0f1729)); box-shadow: inset 0 0 0 1px var(--room-strong); }
+.ar-kind-i { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: color-mix(in srgb, var(--room-strong) 16%, transparent); color: var(--room-strong); }
+.ar-kind[aria-current] .ar-kind-i { background: var(--room-strong); color: var(--bg-primary, #0a0f1c); }
+.ar-kind-t { display: grid; gap: 1px; min-width: 0; }
+.ar-kind-t b { font-size: .88rem; font-weight: 800; line-height: 1.2; overflow-wrap: anywhere; hyphens: auto; }
+.ar-kind-t small { font-size: .72rem; line-height: 1.3; color: var(--text-muted, #7386a3); }
+
+.ar-judge { margin-top: 18px; scroll-margin-top: 72px; }
+.ar-brief h2 { margin: 0; font-size: clamp(1.25rem, 2.6vw, 1.7rem); letter-spacing: -.02em; text-wrap: balance; }
+.ar-prompt { margin: 10px 0 0; max-width: 72ch; padding: 10px 14px; border-left: 3px solid var(--room-strong); border-radius: 0 10px 10px 0; background: color-mix(in srgb, var(--room-strong) 7%, transparent);
+ color: var(--text-secondary, #96a7c2); line-height: 1.55; overflow-wrap: anywhere; }
+/* A piece of art (illustration, motion, a chart, a 3D model, a shader, a component) is drawn once, 4:3, edge to edge */
+.ar-art { position: relative; aspect-ratio: 4 / 3; max-width: 100%; overflow: hidden; border: 1px solid var(--border, #26344d); border-radius: 10px; background: #fff; }
+.ar-art .ar-art-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #fff; }
+.ar-arena .ar-vote-pair { grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); }
+.ar-arena .ar-vote-actions .ovk-btn[data-winner=tie], .ar-arena .ar-vote-actions .ovk-btn[data-winner=both_bad] { min-width: 96px; }
+.ar-who { margin: 10px 0 0; text-align: center; color: var(--text-muted, #7386a3); font-size: .84rem; line-height: 1.5; }
+.ar-who a, .ar-notice a { color: var(--room-strong); font-weight: 750; }
+/* The reveal: each side names its model; your pick wears the room colour */
+.ar-model { margin: -2px 0 10px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; overflow-wrap: anywhere; }
+.ar-model b { font-size: 1.02rem; }
+.ar-pick { margin-left: auto; padding: 2px 9px; border-radius: 999px; background: var(--room-strong); color: var(--bg-primary, #0a0f1c); font-size: .68rem; letter-spacing: .06em; }
+.ar-reveal-side.is-picked { border-color: var(--room-strong); box-shadow: 0 0 0 1px var(--room-strong); }
+.ar-none { padding: clamp(20px, 4vw, 36px); border: 1px dashed var(--border, #26344d); border-radius: 16px; text-align: center; }
+.ar-none h2 { margin: 0; font-size: 1.2rem; }
+.ar-none p { margin: 8px auto 0; max-width: 56ch; color: var(--text-secondary, #96a7c2); }
+.ar-none .ar-actions { justify-content: center; }
+
+/* Boards: one card per kind; who voted, the top five, a counted fold for the rest */
+.ar-boards--kinds { margin-top: 36px; scroll-margin-top: 72px; }
+.ar-boards--kinds > .ar-lb-head h2 { font-size: clamp(1.15rem, 2.4vw, 1.45rem); }
+.ar-voters { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--border, #26344d); border-radius: 999px; background: var(--bg-card, #0f1729); }
+.ar-voters a { min-height: 30px; display: inline-flex; align-items: center; padding: 0 12px; border-radius: 999px; font-weight: 750; font-size: .8rem; color: var(--text-secondary, #96a7c2); text-decoration: none; }
+.ar-voters a[aria-current] { background: color-mix(in srgb, var(--room-strong) 20%, transparent); color: var(--text-primary, #e6edf7); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--room-strong) 45%, transparent); }
+.ar-voters a:focus-visible { outline: 2px solid var(--room-strong); outline-offset: 2px; }
+.ar-kboards { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); align-items: start; }
+.ar-kb { min-width: 0; padding: 14px 14px 12px; border: 1px solid var(--border, #26344d); border-radius: 16px; background: var(--bg-card, #0f1729); scroll-margin-top: 72px; }
+.ar-kb.is-on { border-color: color-mix(in srgb, var(--room-strong) 55%, var(--border, #26344d)); }
+.ar-kb-h { display: flex; align-items: center; gap: 9px; }
+.ar-kb-h .ar-kind-i { width: 26px; height: 26px; border-radius: 8px; }
+.ar-kb-h h3 { margin: 0; font-size: 1rem; }
+.ar-kb-go { margin-left: auto; min-height: 30px; display: inline-flex; align-items: center; padding: 0 12px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--room-strong) 45%, var(--border, #26344d));
+ color: var(--room-strong); font-weight: 800; font-size: .8rem; text-decoration: none; }
+.ar-kb-go:hover { background: color-mix(in srgb, var(--room-strong) 12%, transparent); }
+.ar-kb-count { margin: 6px 0 0; font-size: .76rem; }
+.ar-kb-rows { list-style: none; margin: 10px 0 0; padding: 0; font-variant-numeric: tabular-nums; }
+.ar-kb-row { display: grid; grid-template-columns: 1.6em minmax(0, 1fr) 3.4em 3.6em; align-items: baseline; gap: 6px; padding: 6px 2px; border-top: 1px solid color-mix(in srgb, var(--border, #26344d) 70%, transparent); font-size: .86rem; }
+.ar-kb-row > :nth-child(n+3) { text-align: right; }
+.ar-kb-th { border-top: 0; padding-top: 0; font-size: .66rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.ar-kb-rank { font-weight: 800; color: var(--text-muted, #7386a3); }
+.ar-kb-model { font-weight: 700; overflow-wrap: anywhere; }
+.ar-kb-elo { font-weight: 800; }
+.ar-kb-games { color: var(--text-muted, #7386a3); }
+.ar-kb-rows .ar-kb-row:nth-child(2) :is(.ar-kb-rank, .ar-kb-model, .ar-kb-elo) { color: var(--room-strong); }
+.ar-kb-more { margin-top: 2px; }
+.ar-kb-more summary { cursor: pointer; padding: 6px 2px; font-size: .8rem; font-weight: 750; color: var(--room-strong); }
+.ar-kb-more .ar-kb-rows { margin-top: 0; }
+.ar-kb-more .ar-kb-row:nth-child(2) :is(.ar-kb-rank, .ar-kb-model, .ar-kb-elo) { color: inherit; }
+.ar-kb-empty { margin: 10px 0 0; color: var(--text-muted, #7386a3); font-size: .86rem; }
+.ar-kb-links { margin: 10px 0 0; font-size: .78rem; color: var(--text-muted, #7386a3); }
+.ar-kb-links a { color: var(--text-secondary, #96a7c2); }
+.ar-provisional { padding: 0 6px; border-radius: 999px; font-size: .66rem; font-weight: 800; letter-spacing: .04em; vertical-align: 1px; background: color-mix(in srgb, var(--text-muted, #7386a3) 18%, transparent); color: var(--text-muted, #7386a3); }
+
+.ar-make { display: grid; gap: 12px; margin-top: 30px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); }
+.ar-make-card { padding: 16px 18px; border: 1px dashed var(--border, #26344d); border-radius: 16px; }
+.ar-make-card h2 { margin: 0; font-size: 1.05rem; }
+.ar-make-card p { margin: 8px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+.ar-make-card .ovk-btn { margin: 4px 6px 0 0; }
+.ar-costs { margin-top: 22px; border-top: 1px solid var(--border, #26344d); padding-top: 10px; }
+.ar-costs > summary { cursor: pointer; display: flex; align-items: baseline; gap: 10px; padding: 6px 0; font-weight: 800; }
+.ar-costs .ar-boards--cost { margin-top: 0; }
+
+/* The room's own heading and section rules (.ds h3, section spacing) are for its pages; the arena's cards keep theirs */
+.ar-arena .ar-vote-side { margin: 0; }
+.ar-arena .ar-vote-side h3 { margin: 0 0 10px; display: flex; align-items: center; gap: 8px; font-size: .8rem; letter-spacing: .06em; }
+.ar-arena #ar-vote-left h3::before, .ar-arena #ar-vote-right h3::before { content: none; display: none; }
+.ar-letter { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 8px; background: color-mix(in srgb, var(--room-strong) 18%, transparent); color: var(--room-strong); font-size: .86rem; font-weight: 900; letter-spacing: 0; }
+.ar-arena .ar-kb-h h3 { margin: 0; font-size: 1rem; font-weight: 800; letter-spacing: -.01em; text-transform: none; color: var(--text-primary, #e6edf7); }
+.ar-arena .ar-boards--kinds, .ar-arena .ar-judge { margin-bottom: 0; }
+
+@media (max-width: 759px) {
+ .ar-kinds { grid-auto-columns: minmax(124px, 42%); margin-left: -2px; margin-right: -2px; }
+ /* Nine boards side by side, a swipe apart, instead of nine cards down the page */
+ .ar-kboards { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 86%; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 8px; }
+ .ar-kb { scroll-snap-align: start; }
+ .ar-who { text-align: left; }
+}
+@media (prefers-reduced-motion: reduce) { .ar-kind { transition: none; } }
@@ -93,6 +93,10 @@
@@ -93,6 +93,10 @@
if (/Status:\s*(queued|generating)/i.test(state)) setTimeout(() => window.location.reload(), 4000);
if (/Status:\s*(queued|generating)/i.test(state)) setTimeout(() => window.location.reload(), 4000);
}
}
+ // On a phone, a pair opens at the phone view: a 1280 px page drawn 270 px wide says little.
+ var phoneView = $('ar-view-phone');
+ if (phoneView && window.matchMedia && window.matchMedia('(max-width: 759px)').matches) phoneView.checked = true;
+
// Fit every generated frame to the column it sits in: a desktop frame fills the width, a phone stays a handset.
// Fit every generated frame to the column it sits in: a desktop frame fills the width, a phone stays a handset.
var shots = document.querySelectorAll('.ar-shot');
var shots = document.querySelectorAll('.ar-shot');
if (shots.length && 'ResizeObserver' in window) {
if (shots.length && 'ResizeObserver' in window) {
@@ -89,6 +89,8 @@ async function createApp(opts = {}) {
@@ -89,6 +89,8 @@ async function createApp(opts = {}) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
// Cloudflare Turnstile on the guest icon form (L50 step 5), only when both of its keys are set.
// Cloudflare Turnstile on the guest icon form (L50 step 5), only when both of its keys are set.
+ // The arena draws each Bench output in a sandboxed frame from Bench's render origin (plan L51).
+ const benchRender = config.bench && /^https?:\/\/[^/\s'";]+$/.test(String(config.bench.renderOrigin || '')) ? [config.bench.renderOrigin] : [];
const turnstileSrc = config.glyphGuests && config.glyphGuests.turnstileSiteKey && config.glyphGuests.turnstileSecret ? ['https://challenges.cloudflare.com'] : [];
const turnstileSrc = config.glyphGuests && config.glyphGuests.turnstileSiteKey && config.glyphGuests.turnstileSecret ? ['https://challenges.cloudflare.com'] : [];
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
app.locals.ctx = ctx;
app.locals.ctx = ctx;
@@ -116,7 +118,7 @@ async function createApp(opts = {}) {
@@ -116,7 +118,7 @@ async function createApp(opts = {}) {
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
// 'self' covers the Arena's about:srcdoc iframes; generated pages cannot navigate the top window.
// 'self' covers the Arena's about:srcdoc iframes; generated pages cannot navigate the top window.
- frameSrc: ["'self'", 'https://openvibe.network', ...turnstileSrc],
+ frameSrc: ["'self'", 'https://openvibe.network', ...turnstileSrc, ...benchRender],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
baseUri: ["'self'"],
@@ -44,6 +44,10 @@ function mapBenchProblem(status, body) {
@@ -44,6 +44,10 @@ function mapBenchProblem(status, body) {
case 'bench.already_judged': return new ArenaError(409, 'arena.already_voted', detail);
case 'bench.already_judged': return new ArenaError(409, 'arena.already_voted', detail);
case 'bench.invalid_pair': return new ArenaError(422, 'arena.invalid_pair', detail);
case 'bench.invalid_pair': return new ArenaError(422, 'arena.invalid_pair', detail);
case 'bench.invalid_outcome': return new ArenaError(422, 'arena.invalid_winner', detail);
case 'bench.invalid_outcome': return new ArenaError(422, 'arena.invalid_winner', detail);
+ // A vote relayed for someone who has not signed in (plan L51): Bench's caps all answer the same way.
+ case 'bench.limit_anon': return new ArenaError(429, 'arena.limit_anon', 'Sign in to keep voting today.');
+ case 'bench.anon_disabled': return new ArenaError(403, 'arena.anon_disabled', 'Voting without signing in is off at the moment. Sign in to vote.');
+ case 'bench.anon_kind_not_allowed': return new ArenaError(403, 'arena.anon_kind_not_allowed', 'Sign in to judge this kind.');
default: return new BenchClientError(status, code, detail);
default: return new BenchClientError(status, code, detail);
}
}
}
}
@@ -78,6 +82,18 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -78,6 +82,18 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
return { Accept: 'application/json', Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
return { Accept: 'application/json', Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}
}
+ /**
+ * Someone who has not signed in (plan L51): Design's own service token, and the voter id Design derived from its
+ * own cookie in X-OV-Voter. Bench keeps only a hash of it, and caps each voter and Design's relay a day.
+ */
+ async function relayHeaders(voter) {
+ const id = String(voter || '');
+ if (!/^[A-Za-z0-9._~:-]{6,200}$/.test(id)) throw new ArenaError(401, 'token.required', 'Sign in to vote.');
+ return { ...(await serviceHeaders()), 'X-OV-Voter': `anon:${id}` };
+ }
+
+ const asViewer = async ({ token = null, voter = null }) => (token ? personHeaders(token) : await relayHeaders(voter));
+
async function request(method, path, { body = null, headers = null, token = null } = {}) {
async function request(method, path, { body = null, headers = null, token = null } = {}) {
const h = headers || (token ? personHeaders(token) : await serviceHeaders());
const h = headers || (token ? personHeaders(token) : await serviceHeaders());
const url = `${bench.url.replace(/\/+$/, '')}${path}`;
const url = `${bench.url.replace(/\/+$/, '')}${path}`;
@@ -166,11 +182,33 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -166,11 +182,33 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
});
});
}
}
- async function getBoard(kindId, { type = 'elo', token = null } = {}) {
- const out = await request('GET', `/api/v1/boards/${encodeURIComponent(kindId)}?type=${encodeURIComponent(type)}`, { token });
+ /** The next blind pair of a kind for this viewer: a person (their token) or a voter Design relays. Null: none. */
+ async function nextJudgement(kindId, { token = null, voter = null } = {}) {
+ const out = await request('GET', `/api/v1/judgements/next?kind=${encodeURIComponent(kindId)}`, { headers: await asViewer({ token, voter }) });
+ return out && out.round && out.left && out.right ? out : null;
+ }
+
+ /** One judgement, as the person or the relayed voter. Bench answers with the two entries revealed. */
+ async function castJudgement({ roundId, pair, outcome, token = null, voter = null }) {
+ return await request('POST', `/api/v1/rounds/${encodeURIComponent(roundId)}/judgements`, {
+ headers: await asViewer({ token, voter }),
+ body: { pair, outcome },
+ });
+ }
+
+ /** A kind's board. voters: null (everyone), 'people' (signed-in votes only) or 'anon'. */
+ async function getBoard(kindId, { type = 'elo', token = null, voters = null } = {}) {
+ const q = new URLSearchParams({ type });
+ if (voters === 'people' || voters === 'anon') q.set('voters', voters);
+ const out = await request('GET', `/api/v1/boards/${encodeURIComponent(kindId)}?${q}`, { token });
return out.board;
return out.board;
}
}
+ /** Where a model's output is drawn: Bench's cookie-less render origin, framed with sandbox="allow-scripts". */
+ function renderUrl(entryId) {
+ return `${String((bench && bench.renderOrigin) || 'https://bench-render.openvibe.services').replace(/\/+$/, '')}/render/e/${encodeURIComponent(entryId)}`;
+ }
+
function datasetUrl(kindId) {
function datasetUrl(kindId) {
return `${bench.publicUrl.replace(/\/+$/, '')}/api/v1/datasets/${encodeURIComponent(kindId)}.jsonl`;
return `${bench.publicUrl.replace(/\/+$/, '')}/api/v1/datasets/${encodeURIComponent(kindId)}.jsonl`;
}
}
@@ -199,7 +237,10 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -199,7 +237,10 @@ function createBenchClient({ config, fetchImpl = globalThis.fetch, log = console
nextPair,
nextPair,
castVote,
castVote,
castImportedVote,
castImportedVote,
+ nextJudgement,
+ castJudgement,
getBoard,
getBoard,
+ renderUrl,
datasetUrl,
datasetUrl,
addEntry,
addEntry,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+
+/**
+ * The nine design kinds OpenVibe.Bench judges (plan L51 step 1). Design's /arena is a view of them: a blind pair of
+ * any kind, judged on the page, and each kind's board.
+ *
+ * viewports the entry is a whole page, so it is compared at desktop (1280 px) and phone (390 px) widths;
+ * the others are one piece of art, drawn once in a 4:3 frame.
+ * glyph the Shared glyph the kind's chip wears.
+ */
+const DESIGN_KINDS = Object.freeze([
+ { id: 'design.page', name: 'Pages', line: 'Whole landing pages', glyph: 'window', viewports: true },
+ { id: 'design.ui', name: 'Screens', line: 'App and dashboard screens', glyph: 'computer', viewports: true },
+ { id: 'design.interface', name: 'Systems', line: 'Components that belong together', glyph: 'layers', viewports: true },
+ { id: 'design.component', name: 'Components', line: 'One component or a logo', glyph: 'shapes', viewports: false },
+ { id: 'design.svg', name: 'Illustration', line: 'SVG art and icon sets', glyph: 'pencil', viewports: false },
+ { id: 'design.motion', name: 'Motion', line: 'Animation and effects', glyph: 'film', viewports: false },
+ { id: 'design.dataviz', name: 'Charts', line: 'Data drawn to be read', glyph: 'chart', viewports: false },
+ { id: 'design.model3d', name: '3D models', line: 'Models you can turn', glyph: 'cubes', viewports: false },
+ { id: 'design.shader', name: 'Shaders', line: 'Light and colour in GLSL', glyph: 'wave', viewports: false },
+].map(Object.freeze));
+
+const BY_ID = new Map(DESIGN_KINDS.map((k) => [k.id, k]));
+const DEFAULT_KIND = 'design.page';
+
+/** The kind a query names, by id ('design.svg') or short name ('svg'); anything else is the default. */
+function kindOf(value) {
+ const v = String(value == null ? '' : value).trim().toLowerCase();
+ if (BY_ID.has(v)) return BY_ID.get(v);
+ if (BY_ID.has(`design.${v}`)) return BY_ID.get(`design.${v}`);
+ return BY_ID.get(DEFAULT_KIND);
+}
+
+const isKind = (value) => BY_ID.has(String(value == null ? '' : value));
+
+module.exports = { DESIGN_KINDS, DEFAULT_KIND, kindOf, isKind };
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
*/
*/
const { ArenaError, CATEGORIES, WINNERS, K, BASE_ELO, shuffle } = require('./legacy-store');
const { ArenaError, CATEGORIES, WINNERS, K, BASE_ELO, shuffle } = require('./legacy-store');
const { sanitizeHtml } = require('./sanitize');
const { sanitizeHtml } = require('./sanitize');
+const { DESIGN_KINDS, kindOf, isKind } = require('./kinds');
function entryToOutput(e) {
function entryToOutput(e) {
const payload = e.payload || {};
const payload = e.payload || {};
@@ -153,6 +154,76 @@ function createArenaStore({ config, bench }) {
@@ -153,6 +154,76 @@ function createArenaStore({ config, bench }) {
return out;
return out;
}
}
+ // ── The nine design kinds (plan L51 step 1): a blind pair of any kind for a person or a relayed voter, the vote,
+ // and every kind's board. The outputs are drawn by Bench's render origin, never by this document.
+ const frameOf = (entryId) => (bench.renderUrl ? bench.renderUrl(entryId) : '');
+
+ async function pairFor(kindId, { token = null, voter = null } = {}) {
+ const kind = kindOf(kindId);
+ const pair = await bench.nextJudgement(kind.id, { token, voter });
+ if (!pair) return null;
+ return {
+ kind: kind.id,
+ round: { id: pair.round.id, prompt: String(pair.round.prompt || '') },
+ left: { id: pair.left.id, frame: frameOf(pair.left.id) },
+ right: { id: pair.right.id, frame: frameOf(pair.right.id) },
+ };
+ }
+
+ async function judge({ kind: kindId, roundId, left, right, outcome, token = null, voter = null }) {
+ if (!WINNERS.includes(outcome)) throw new ArenaError(422, 'arena.invalid_winner', 'Pick left, right, a tie or both bad.');
+ if (!left || !right || left === right) throw new ArenaError(422, 'arena.invalid_pair', 'A vote needs two different designs.');
+ if (!roundId) throw new ArenaError(422, 'arena.invalid_pair', 'That pair has no round.');
+ const out = await bench.castJudgement({ roundId, pair: { left, right }, outcome, token, voter });
+ const side = (e, id) => ({
+ id,
+ frame: frameOf(id),
+ model: (e && e.model) || 'unknown',
+ provider: (e && e.provider) || null,
+ cost_usd: e && e.cost_usd != null ? Number(e.cost_usd) : null,
+ ms: e && e.ms != null ? Number(e.ms) : null,
+ });
+ const revealed = out.revealed || {};
+ return {
+ kind: isKind(kindId) ? kindId : kindOf(kindId).id,
+ round: { id: roundId },
+ outcome,
+ left: side(revealed.left, left),
+ right: side(revealed.right, right),
+ };
+ }
+
+ // Nine boards on every view of /arena: kept for a minute (Bench's own answer is public for 30 s).
+ const boardCache = new Map();
+ async function boardsAll({ voters = null, now = Date.now() } = {}) {
+ const key = voters === 'people' || voters === 'anon' ? voters : 'all';
+ const hit = boardCache.get(key);
+ if (hit && now - hit.at < 60_000) return hit.boards;
+ const boards = await Promise.all(DESIGN_KINDS.map(async (kind) => {
+ try {
+ const b = await bench.getBoard(kind.id, { type: 'elo', voters: key === 'all' ? null : key });
+ const v = (b && b.voters) || {};
+ return {
+ kind: kind.id,
+ rows: ((b && b.rows) || []).map(boardRowToLocal),
+ voters: { person: Number(v.person || 0), anon: Number(v.anon || 0), total: Number(v.total || 0) },
+ };
+ } catch {
+ return { kind: kind.id, rows: [], voters: null, failed: true };
+ }
+ }));
+ if (!boards.every((b) => b.failed)) boardCache.set(key, { at: now, boards });
+ return boards;
+ }
+
+ let costCache = null;
+ async function costAll({ now = Date.now() } = {}) {
+ if (costCache && now - costCache.at < 60_000) return costCache.boards;
+ const boards = await costLeaderboard(null);
+ costCache = { at: now, boards };
+ return boards;
+ }
+
async function datasetJsonl() {
async function datasetJsonl() {
// The open dataset now lives on Bench; Design redirects to it.
// The open dataset now lives on Bench; Design redirects to it.
return { redirect: bench.datasetUrl('design.page') };
return { redirect: bench.datasetUrl('design.page') };
@@ -161,6 +232,7 @@ function createArenaStore({ config, bench }) {
@@ -161,6 +232,7 @@ function createArenaStore({ config, bench }) {
return {
return {
CATEGORIES, WINNERS, K, BASE_ELO, shuffle,
CATEGORIES, WINNERS, K, BASE_ELO, shuffle,
counts, createRound, getRound, nextPair, vote, leaderboard, costLeaderboard, datasetJsonl,
counts, createRound, getRound, nextPair, vote, leaderboard, costLeaderboard, datasetJsonl,
+ pairFor, judge, boardsAll, costAll,
};
};
}
}
@@ -69,6 +69,8 @@ function load(env = process.env) {
@@ -69,6 +69,8 @@ function load(env = process.env) {
bench: {
bench: {
url: trim(env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230'),
url: trim(env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230'),
publicUrl: trim(env.OV_BENCH_URL || 'https://bench.openvibe.services'),
publicUrl: trim(env.OV_BENCH_URL || 'https://bench.openvibe.services'),
+ // Bench's cookie-less render origin: the arena frames each output from it (plan L51).
+ renderOrigin: trim(env.OV_BENCH_RENDER_ORIGIN || 'https://bench-render.openvibe.services'),
audience: env.DESIGN_BENCH_AUDIENCE || 'openvibe.bench',
audience: env.DESIGN_BENCH_AUDIENCE || 'openvibe.bench',
scope: env.DESIGN_BENCH_SCOPE || 'bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.entry.create 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',
},
},
@@ -95,6 +97,9 @@ function load(env = process.env) {
@@ -95,6 +97,9 @@ function load(env = process.env) {
intervalMs: Math.max(250, int(env.DESIGN_ARENA_JOB_INTERVAL_MS, 1500)),
intervalMs: Math.max(250, int(env.DESIGN_ARENA_JOB_INTERVAL_MS, 1500)),
pollMs: Math.max(250, int(env.DESIGN_ARENA_POLL_MS, 20_000)),
pollMs: Math.max(250, int(env.DESIGN_ARENA_POLL_MS, 20_000)),
maxPrompt: Math.max(20, int(env.DESIGN_ARENA_MAX_PROMPT, 4000)),
maxPrompt: Math.max(20, int(env.DESIGN_ARENA_MAX_PROMPT, 4000)),
+ // Judging without signing in (plan L51): Bench caps each voter (30 a day) and Design's relay, but not the
+ // address behind a relayed vote, so Design caps that itself. 0 turns signed-out voting off.
+ guestVotesPerAddress: Math.max(0, int(env.DESIGN_ARENA_GUEST_VOTES_PER_ADDRESS, 60)),
},
},
// OpenVibe.Events → this service for ADR-033 account export/deletion. Off when the secret is unset.
// OpenVibe.Events → this service for ADR-033 account export/deletion. Off when the secret is unset.
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
*/
*/
const express = require('express');
const express = require('express');
const path = require('path');
const path = require('path');
+const nodeCrypto = require('crypto');
const views = require('../views');
const views = require('../views');
const tokensView = require('../views/tokens');
const tokensView = require('../views/tokens');
const lookView = require('../views/look');
const lookView = require('../views/look');
@@ -31,7 +32,8 @@ const { baseUrl: layoutBase } = require('../render/layout');
@@ -31,7 +32,8 @@ const { baseUrl: layoutBase } = require('../render/layout');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { ArenaError } = require('../arena/store');
const { ArenaError } = require('../arena/store');
-const { writeAllowed } = require('./same-origin');
+const { writeAllowed, sameOrigin } = require('./same-origin');
+const arenaKinds = require('../arena/kinds');
function meOf(viewer) {
function meOf(viewer) {
if (!viewer || viewer.kind !== 'user') return { signedIn: false, user: null };
if (!viewer || viewer.kind !== 'user') return { signedIn: false, user: null };
@@ -223,20 +225,63 @@ function createPageRoutes(ctx) {
@@ -223,20 +225,63 @@ function createPageRoutes(ctx) {
return page(res, found || !req.query.theme ? 200 : 404, tokensView.tokensPage({ themes, found, format, both }), req.viewer);
return page(res, found || !req.query.theme ? 200 : 404, tokensView.tokensPage({ themes, found, format, both }), req.viewer);
});
});
- // ── Arena ───────────────────────────────────────────────
+ // ── Arena: a view of Bench's nine design kinds (plan L51 step 1) ──
+ // Someone who has not signed in may judge too. Their voter is a random id in an httpOnly cookie that only the
+ // arena reads; Bench gets a keyed hash of it (X-OV-Voter) and caps each voter a day. Bench does not cap the
+ // address behind a relayed vote, so Design does: a count per day of a keyed hash of the address, in memory.
+ const VOTER_COOKIE = 'ovd_voter';
+ const voterKey = (config.oauth && config.oauth.clientSecret) ? `arena-voter:${config.oauth.clientSecret}` : nodeCrypto.randomBytes(32).toString('hex');
+ const keyed = (v) => nodeCrypto.createHmac('sha256', voterKey).update(String(v)).digest('hex').slice(0, 40);
+ const guestCap = Math.max(0, Number(config.arena && config.arena.guestVotesPerAddress) || 0);
+ const voterOf = (req, res, { make = false } = {}) => {
+ let raw = String((req.cookies && req.cookies[VOTER_COOKIE]) || '');
+ if (!/^[0-9a-f]{32}$/.test(raw)) {
+ if (!make) return null;
+ raw = nodeCrypto.randomBytes(16).toString('hex');
+ res.cookie(VOTER_COOKIE, raw, { httpOnly: true, sameSite: 'lax', secure: /^https:/.test(String(config.baseUrl || '')), maxAge: 365 * 86400 * 1000, path: '/arena' });
+ }
+ return keyed(raw);
+ };
+ const addressVotes = { day: '', counts: new Map() };
+ const addressAllows = (req, { spend = false } = {}) => {
+ const day = new Date().toISOString().slice(0, 10);
+ if (addressVotes.day !== day) { addressVotes.day = day; addressVotes.counts.clear(); }
+ const key = keyed(`${day}|${req.ip || ''}`);
+ const used = addressVotes.counts.get(key) || 0;
+ if (used >= guestCap) return false;
+ if (spend) addressVotes.counts.set(key, used + 1);
+ return true;
+ };
+ const votersOf = (q) => (q === 'people' || q === 'anon' ? q : null);
+ const arenaBoards = async (voters) => {
+ const [boards, cost] = await Promise.all([
+ arena.boardsAll({ voters }).catch((err) => { log.warn('[OpenVibe.Design] arena boards failed:', (err && err.message) || err); return []; }),
+ arena.costAll().catch(() => []),
+ ]);
+ return { boards, cost };
+ };
+ const arenaPage = (res, status, req, data) => page(res, status, views.arenaHome({
+ me: meOf(req.viewer), guestVotes: guestCap > 0, benchUrl: config.bench && config.bench.publicUrl, ...data,
+ }), req.viewer, { cacheControl: 'private, no-store' });
+
r.get('/arena', async (req, res) => {
r.get('/arena', async (req, res) => {
- const me = meOf(req.viewer);
- let boards;
- let costBoards;
- try {
- boards = await arena.leaderboard(null);
- costBoards = await arena.costLeaderboard(null);
- } catch (err) {
- log.warn('[OpenVibe.Design] leaderboard failed:', (err && err.message) || err);
- boards = categories.map((category) => ({ category, leaderboard: [] }));
- costBoards = [];
+ const kind = arenaKinds.kindOf(req.query.kind);
+ const voters = votersOf(req.query.voters);
+ const token = tokenOf(req);
+ let pair = null;
+ let notice = null;
+ if (token || guestCap > 0) {
+ try {
+ pair = await arena.pairFor(kind.id, token ? { token } : { voter: voterOf(req, res, { make: true }) });
+ } catch (err) {
+ if (err instanceof ArenaError) notice = err.detail;
+ else {
+ log.warn('[OpenVibe.Design] picking a pair failed:', (err && err.message) || err);
+ notice = 'The next pair could not be read. Try again in a moment.';
+ }
+ }
}
}
- return page(res, 200, views.arenaHome({ categories: boards, costCategories: costBoards, me, limits: await aria(req.viewer) }), req.viewer);
+ return arenaPage(res, 200, req, { kind: kind.id, pair, notice, voters, ...(await arenaBoards(voters)) });
});
});
r.get('/arena/new', async (req, res) => {
r.get('/arena/new', async (req, res) => {
@@ -259,41 +304,42 @@ function createPageRoutes(ctx) {
@@ -259,41 +304,42 @@ function createPageRoutes(ctx) {
}
}
});
});
- r.get('/arena/vote', async (req, res) => {
- const me = meOf(req.viewer);
- let pair = null;
- let notice = null;
- if (subjectOf(req)) {
- try { pair = await arena.nextPair(subjectOf(req), { token: tokenOf(req), category: req.query.category === 'logo' ? 'logo' : 'landing', random: ctx.random || Math.random }); } catch (err) {
- log.warn('[OpenVibe.Design] picking a pair failed:', (err && err.message) || err);
- notice = 'The next pair could not be read. Try again in a moment.';
- }
- }
- return page(res, 200, views.arenaVote({ pair, me, notice }), req.viewer);
+ // The old address of the vote: the vote is on /arena now, for every kind.
+ r.get('/arena/vote', (req, res) => {
+ const kind = req.query.category === 'logo' ? 'design.component' : arenaKinds.kindOf(req.query.kind).id;
+ return res.redirect(302, `/arena?kind=${encodeURIComponent(kind)}#ar-pair`);
});
});
- r.post('/arena/vote', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
- const me = meOf(req.viewer);
- if (!subjectOf(req)) return res.redirect(303, '/auth/login?next=%2Farena%2Fvote');
- if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('A vote must come from openvibe.design itself.');
+ // One vote, as the signed-in person or as a voter Design relays. The answer is the arena with the two designs
+ // revealed. A plain form post, so it works without JavaScript.
+ r.post('/arena/vote', express.urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {
const b = req.body || {};
const b = req.body || {};
+ const kind = arenaKinds.kindOf(b.kind || (b.category === 'logo' ? 'design.component' : null));
+ const voters = votersOf(b.voters);
+ const token = tokenOf(req);
+ const again = (status, notice) => arenaBoards(voters).then((more) => arenaPage(res, status, req, { kind: kind.id, pair: null, notice, voters, ...more }));
+ if (token) {
+ if (!writeAllowed(req, config)) return res.status(403).type('text/plain').send('A vote must come from openvibe.design itself.');
+ } else {
+ if (!guestCap) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/arena?kind=${kind.id}`)}`);
+ if (!sameOrigin(req, config)) return res.status(403).type('text/plain').send('Vote on openvibe.design itself.');
+ if (!addressAllows(req)) return again(429, 'Sign in to keep voting today.');
+ }
+ const voter = token ? null : voterOf(req, res, { make: true });
try {
try {
- const out = await arena.vote({
+ const result = await arena.judge({
+ kind: kind.id,
roundId: String(b.round_id || ''),
roundId: String(b.round_id || ''),
- subject: subjectOf(req),
- leftOutput: String(b.left_output || ''),
- rightOutput: String(b.right_output || ''),
- winner: String(b.winner || ''),
- token: tokenOf(req),
+ left: String(b.left_output || ''),
+ right: String(b.right_output || ''),
+ outcome: String(b.winner || ''),
+ ...(token ? { token } : { voter }),
});
});
- return page(res, 201, views.arenaVote({ me, result: {
- vote: { round: out.round.id, winner: out.winner, at: out.at },
- revealed: { left: out.left, right: out.right },
- } }), req.viewer);
+ if (!token) addressAllows(req, { spend: true });
+ return arenaPage(res, 200, req, { kind: kind.id, reveal: result, voters, ...(await arenaBoards(voters)) });
} catch (err) {
} catch (err) {
- const notice = err instanceof ArenaError ? err.detail : 'The vote could not be recorded. Try again in a moment.';
if (!(err instanceof ArenaError)) log.warn('[OpenVibe.Design] vote failed:', (err && err.message) || err);
if (!(err instanceof ArenaError)) log.warn('[OpenVibe.Design] vote failed:', (err && err.message) || err);
- return page(res, err instanceof ArenaError ? err.status : 500, views.arenaVote({ me, notice }), req.viewer);
+ return again(err instanceof ArenaError ? err.status : 500, err instanceof ArenaError ? err.detail : 'The vote could not be recorded. Try again in a moment.');
}
}
});
});
@@ -18,6 +18,8 @@ const { pickBlock, pickRules, foldAround } = require('../render/short');
@@ -18,6 +18,8 @@ const { pickBlock, pickRules, foldAround } = require('../render/short');
const sc = require('openvibe-shared/showcase');
const sc = require('openvibe-shared/showcase');
const rooms = require('openvibe-shared/rooms');
const rooms = require('openvibe-shared/rooms');
const { iframeHtml } = require('../arena/sanitize');
const { iframeHtml } = require('../arena/sanitize');
+const glyphs = require('openvibe-shared/glyphs');
+const { DESIGN_KINDS, DEFAULT_KIND, kindOf } = require('../arena/kinds');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const { TARGETS } = require('openvibe-shared/tokens');
const { TARGETS } = require('openvibe-shared/tokens');
@@ -311,27 +313,125 @@ function arenaLeaderboard(category, rows, { type = 'elo' } = {}) {
@@ -311,27 +313,125 @@ function arenaLeaderboard(category, rows, { type = 'elo' } = {}) {
<tbody>${body}</tbody></table></section>`;
<tbody>${body}</tbody></table></section>`;
}
}
-function arenaHome({ categories = [], costCategories = [], me = {}, limits = null } = {}) {
+/** One output of a pair, drawn by Bench's render origin. The frame runs the model's code with scripts only: no same
+ * origin, no forms, no popups, no navigation of this page, and Bench's own CSP refuses every network request. */
+function arenaFrame(src, { width, height, title, className }) {
+ return `<iframe class="ar-frame ${className}" src="${esc(src)}" width="${width}" height="${height}" data-width="${width}" title="${esc(title)}" sandbox="allow-scripts" referrerpolicy="no-referrer" allow="" loading="eager"></iframe>`;
+}
+
+/** A side of a pair: a page kind at phone and desktop widths, any other kind once, in a 4:3 frame. */
+function arenaSide(kind, side, letter) {
+ if (kind.viewports) {
+ return ['390', '1280'].map((w) => {
+ const phone = w === '390';
+ return `<div class="ar-shot ${phone ? 'ar-shot--phone' : 'ar-shot--desktop'}" data-width="${w}"><div class="ar-shot-frame">${arenaFrame(side.frame, {
+ width: phone ? 390 : 1280, height: phone ? 844 : 800, title: `Design ${letter} at ${w} pixels`, className: phone ? 'ar-frame--phone' : 'ar-frame--desktop',
+ })}</div><p class="ar-shot-label">${phone ? 'Phone · 390 px' : 'Desktop · 1280 px'}</p></div>`;
+ }).join('');
+ }
+ return `<div class="ar-art">${arenaFrame(side.frame, { width: 800, height: 600, title: `Design ${letter}`, className: 'ar-art-frame' })}</div>`;
+}
+
+/** A side's name: the letter as a badge, "Design A" for a screen reader. */
+const arenaLetter = (letter) => `<span class="ar-letter" aria-hidden="true">${letter}</span><span class="dv-visually-hidden">Design ${letter}</span>`;
+const arenaSecs = (ms) => (ms == null ? null : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms)} ms`);
+
+/**
+ * The Design Arena (plan L51 step 1): a view of OpenVibe.Bench's nine design kinds. A blind pair of the chosen kind
+ * is judged right on the page, signed in or not; the vote reveals both models; every kind's board follows.
+ *
+ * kind the chosen kind's id pair { kind, round, left, right } or null
+ * reveal the vote just cast, or null boards [{ kind, rows, voters }] cost [{ category, leaderboard }]
+ * voters null | 'people' | 'anon' guestVotes whether someone signed out may vote
+ */
+function arenaHome({ kind: kindId = DEFAULT_KIND, pair = null, reveal = null, notice = null, boards = [], cost = [], voters = null, me = {}, guestVotes = true, benchUrl: bench = 'https://bench.openvibe.services' } = {}) {
const signedIn = Boolean(me.signedIn);
const signedIn = Boolean(me.signedIn);
- const boards = categories.map((c) => arenaLeaderboard(c.category, c.leaderboard || [])).join('');
- const costBoards = costCategories.map((c) => arenaLeaderboard(c.category, c.leaderboard || [], { type: 'cost' })).join('');
- const rows = categories.flatMap((c) => c.leaderboard || []);
- const body = `<div class="ar-page" id="ar-home">
-<header class="ar-hero"><p class="ar-kicker">Design Arena</p><h1>Which model designs it best?</h1>
-<p class="ar-lede">One prompt goes to three models. They each build the same page; you see two at phone and desktop sizes, vote blind, and an Elo leaderboard per category ranks the designers, with the cost and time of every generation.</p>
-<div class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a> <a class="ovk-btn ovk-btn--quiet" href="/arena/vote">Vote blind</a></div>
-<p class="ar-fine" id="ar-limits">${signedIn
- ? (limits ? `You have ${limits.remaining} of ${limits.per_day} rounds left today. The Arena has ${limits.site_remaining} of ${limits.site_daily} left.` : 'Signed in: you can start a round or vote.')
- : 'Sign in to start a round or vote. The leaderboard and the dataset are open to everyone.'}</p></header>
-<section class="ar-boards" id="ar-leaderboard" aria-labelledby="ar-boards-h"><div class="ar-lb-head"><h2 id="ar-boards-h">The leaderboard</h2><span class="ar-fine">${rows.length} model${rows.length === 1 ? '' : 's'} ranked · K = 32</span></div>
-${boards || '<p class="ar-empty">No rounds have been judged yet. Start the first one.</p>'}
-<p class="ar-fine">A win rate counts a tie as half a win. <code>both_bad</code> is a game for neither model. The cost and time are the median over the outputs. Ranges are 95% confidence intervals; <span class="ar-provisional">provisional</span> means fewer than 10 games.</p></section>
-<section class="ar-boards ar-boards--cost" id="ar-cost" aria-labelledby="ar-cost-h"><div class="ar-lb-head"><h2 id="ar-cost-h">Cost view</h2><span class="ar-fine">Per model across all entries</span></div>
-${costBoards || '<p class="ar-empty">No entries yet.</p>'}</section>
-<section class="ar-open" id="ar-open"><h2>Everything is open</h2><p class="ar-lede">The prompts, every generated page and every vote are downloadable. No voter is named.</p>
-<p><a class="ovk-btn ovk-btn--quiet" href="/api/v1/arena/dataset.jsonl">Download dataset.jsonl</a></p></section>
+ const kind = kindOf(kindId);
+ const benchUrl = String(bench || 'https://bench.openvibe.services').replace(/\/+$/, '');
+ const kindHref = (id, hash = '#ar-pair') => `/arena?kind=${encodeURIComponent(id)}${voters ? `&voters=${voters}` : ''}${hash}`;
+ const signIn = `/auth/login?next=${encodeURIComponent(`/arena?kind=${kind.id}`)}`;
+ const total = boards.reduce((a, b) => a + ((b.voters && b.voters.total) || 0), 0);
+
+ const rail = `<nav class="ar-kinds" id="ar-kinds" aria-label="Kinds of design">${DESIGN_KINDS.map((k) => `<a class="ar-kind" href="${kindHref(k.id)}"${k.id === kind.id ? ' aria-current="page"' : ''} data-kind="${esc(k.id)}">`
+ + `<span class="ar-kind-i">${glyphs.svg(k.glyph, { size: 18 })}</span><span class="ar-kind-t"><b>${esc(k.name)}</b><small>${esc(k.line)}</small></span></a>`).join('')}</nav>`;
+
+ const who = signedIn
+ ? `<p class="ar-who" id="ar-who">You vote as yourself: your votes count with the signed-in people.</p>`
+ : `<p class="ar-who" id="ar-who">You are not signed in: your vote counts with the signed-out voters, up to 30 a day. <a href="${esc(signIn)}">Sign in</a> to vote as yourself.</p>`;
+ const hidden = (name, value) => `<input type="hidden" name="${name}" value="${esc(value)}">`;
+
+ let judge;
+ if (reveal) {
+ const picked = { left: 'A', right: 'B', tie: 'a tie', both_bad: 'both bad' }[reveal.outcome] || reveal.outcome;
+ const named = (s, letter, key) => {
+ const won = reveal.outcome === key;
+ const facts = [s.provider, s.cost_usd != null ? arenaCost(s.cost_usd) : null, arenaSecs(s.ms)].filter(Boolean).map(esc).join(' · ');
+ return `<section class="ar-vote-side ar-reveal-side${won ? ' is-picked' : ''}" id="ar-vote-${key}" data-side="${key}"><h3>${arenaLetter(letter)}${won ? ' <span class="ar-pick">Your pick</span>' : ''}</h3>`
+ + `<p class="ar-model"><b>${esc(s.model)}</b>${facts ? ` <span class="ar-fine">${facts}</span>` : ''}</p>${arenaSide(kind, s, letter)}</section>`;
+ };
+ judge = `<header class="ar-brief"><p class="ar-kicker">Vote recorded · ${esc(kind.name)}</p><h2 id="ar-pair-h">You picked ${esc(picked)}. Here are the models.</h2></header>
+${kind.viewports ? arenaViewSwitch() : ''}
+<div class="ar-vote-pair" id="ar-reveal">${named(reveal.left, 'A', 'left')}${named(reveal.right, 'B', 'right')}</div>
+<div class="ar-vote-actions" id="ar-next"><a class="ovk-btn ovk-btn--primary" href="${kindHref(kind.id)}">Next pair ${glyphs.svg('arrow-right', { size: 16 })}</a><a class="ovk-btn ovk-btn--quiet" href="#ar-kb-${esc(kind.id.replace('.', '-'))}">See the ${esc(kind.name)} board</a></div>`;
+ } else if (pair) {
+ judge = `<header class="ar-brief"><p class="ar-kicker">Blind pair · ${esc(kind.name)}</p><h2 id="ar-pair-h">Which is better?</h2><blockquote class="ar-prompt" id="ar-prompt">${esc(String(pair.round.prompt).slice(0, 1200))}</blockquote></header>
+<form id="ar-judge" class="ar-vote" method="post" action="/arena/vote">${hidden('kind', kind.id)}${hidden('round_id', pair.round.id)}${hidden('left_output', pair.left.id)}${hidden('right_output', pair.right.id)}${voters ? hidden('voters', voters) : ''}
+${kind.viewports ? arenaViewSwitch() : ''}
+<div class="ar-vote-pair" id="ar-vote-pair">
+<section class="ar-vote-side" id="ar-vote-left" data-side="left"><h3>${arenaLetter('A')}</h3>${arenaSide(kind, pair.left, 'A')}</section>
+<section class="ar-vote-side" id="ar-vote-right" data-side="right"><h3>${arenaLetter('B')}</h3>${arenaSide(kind, pair.right, 'B')}</section></div>
+<div class="ar-vote-actions" id="ar-vote-actions"><button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="left" data-winner="left">A is better</button>
+<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="tie" data-winner="tie">Tie</button>
+<button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="right" data-winner="right">B is better</button>
+<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="both_bad" data-winner="both_bad">Both bad</button></div>
+${who}</form>`;
+ } else if (!signedIn && !guestVotes) {
+ judge = `<div class="ar-none" id="ar-none"><h2 id="ar-pair-h">Sign in to judge</h2><p>Every vote is blind: two designs from one brief, no model names until you pick.</p><p>${kit.button({ href: signIn, label: 'Sign in to vote' })}</p></div>`;
+ } else {
+ const next = DESIGN_KINDS[(DESIGN_KINDS.findIndex((k) => k.id === kind.id) + 1) % DESIGN_KINDS.length];
+ judge = `<div class="ar-none" id="ar-none"><h2 id="ar-pair-h">${notice ? 'No pair right now' : `Nothing waiting in ${esc(kind.name)}`}</h2>
+<p>${notice ? '' : `You have judged every ${esc(kind.name.toLowerCase())} pair there is, or none is ready yet. New rounds arrive every day.`}</p>
+<p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="${kindHref(next.id)}">Try ${esc(next.name)}</a>${kind.id === 'design.page' || kind.id === 'design.component' ? ' <a class="ovk-btn ovk-btn--quiet" href="/arena/new">Start a round</a>' : ''}</p></div>`;
+ }
+ const alert = notice ? `<p class="ar-notice" id="ar-notice" role="alert">${esc(notice)}${!signedIn && /sign in/i.test(notice) ? ` <a href="${esc(signIn)}">Sign in</a>` : ''}</p>` : '';
+
+ const boardCard = (b) => {
+ const k = kindOf(b.kind);
+ const rows = b.rows || [];
+ const row = (r, i) => `<li class="ar-kb-row" data-model="${esc(r.model)}"><span class="ar-kb-rank">${i + 1}</span><span class="ar-kb-model">${esc(r.model)}${r.provisional ? ' <span class="ar-provisional" title="Fewer than 10 games">new</span>' : ''}</span><span class="ar-kb-elo">${Number(r.elo).toFixed(0)}</span><span class="ar-kb-games">${r.games}</span></li>`;
+ const v = b.voters;
+ const counts = v ? `${v.total} vote${v.total === 1 ? '' : 's'} · ${v.person} signed in · ${v.anon} signed out` : 'The board could not be read just now.';
+ const first = rows.slice(0, 5).map(row).join('');
+ const rest = rows.slice(5);
+ return `<li class="ar-kb${k.id === kind.id ? ' is-on' : ''}" id="ar-kb-${esc(k.id.replace('.', '-'))}" data-kind="${esc(k.id)}">
+<div class="ar-kb-h"><span class="ar-kind-i">${glyphs.svg(k.glyph, { size: 16 })}</span><h3>${esc(k.name)}</h3><a class="ar-kb-go" href="${kindHref(k.id)}">Judge</a></div>
+<p class="ar-fine ar-kb-count">${esc(counts)}</p>
+${rows.length ? `<ol class="ar-kb-rows" aria-label="${esc(k.name)} board"><li class="ar-kb-row ar-kb-th" aria-hidden="true"><span></span><span>Model</span><span>Elo</span><span>Games</span></li>${first}</ol>` : '<p class="ar-kb-empty">No votes yet. Be the first to judge it.</p>'}
+${rest.length ? `<details class="ar-kb-more"><summary>${rest.length} more</summary><ol class="ar-kb-rows" start="6">${rest.map((r, i) => row(r, i + 5)).join('')}</ol></details>` : ''}
+<p class="ar-kb-links"><a href="${esc(`${benchUrl}/gallery?kind=${encodeURIComponent(k.id)}`)}">Every entry</a> · <a href="${esc(`${benchUrl}/api/v1/datasets/${encodeURIComponent(k.id)}.jsonl`)}">Dataset</a></p></li>`;
+ };
+ const filter = [[null, 'Everyone'], ['people', 'Signed in'], ['anon', 'Signed out']].map(([v, label]) => `<a href="/arena?kind=${encodeURIComponent(kind.id)}${v ? `&voters=${v}` : ''}#ar-boards"${(voters || null) === v ? ' aria-current="true"' : ''}>${label}</a>`).join('');
+ const costBoards = cost.map((c) => arenaLeaderboard(c.category, c.leaderboard || [], { type: 'cost' })).join('');
+
+ const body = `<div class="ar-page ar-arena" id="ar-home" data-kind="${esc(kind.id)}">
+<header class="ar-hero ar-hero--arena"><p class="ar-kicker">Design Arena · judged on OpenVibe.Bench</p><h1>Which AI designs it best?</h1>
+<p class="ar-lede">Two designs from the same brief, the models hidden. Pick the better one and that kind's board moves. ${signedIn ? '' : 'No account needed.'}</p>
+<p class="ar-fine" id="ar-total">${total ? `${total.toLocaleString('en-US')} votes so far across ${DESIGN_KINDS.length} kinds.` : `${DESIGN_KINDS.length} kinds of design, judged blind.`}</p></header>
+${rail}
+<section class="ar-judge" id="ar-pair" aria-labelledby="ar-pair-h">${alert}${judge}</section>
+<section class="ar-boards ar-boards--kinds" id="ar-boards" aria-labelledby="ar-boards-h"><div class="ar-lb-head"><h2 id="ar-boards-h">The boards</h2><nav class="ar-voters" aria-label="Whose votes count">${filter}</nav></div>
+<ul class="ar-kboards" id="ar-kboards">${boards.map(boardCard).join('')}</ul>
+<p class="ar-fine">Elo from blind pairwise votes, K = 32. A tie is half a win; both bad is a game for neither model; <span class="ar-provisional">new</span> means fewer than 10 games. Signed-out votes are capped per voter and per day.</p></section>
+<section class="ar-make" id="ar-make" aria-label="Make and download">
+<div class="ar-make-card"><h2>Start a round</h2><p>Describe a page or a component once; three models build it, and the round joins the blind pairs. You cannot judge your own round.</p><p><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a></p></div>
+<div class="ar-make-card"><h2>Everything is open</h2><p>Every brief, every output and every vote can be downloaded from Bench, per kind. No voter is named.</p><p><a class="ovk-btn ovk-btn--quiet" href="/api/v1/arena/dataset.jsonl">Pages dataset</a> <a class="ovk-btn ovk-btn--quiet" href="${esc(`${benchUrl}/gallery`)}">The gallery on Bench</a></p></div></section>
+${costBoards ? `<details class="ar-costs" id="ar-cost"><summary>What each model costs <span class="ar-fine">${cost.length} kind${cost.length === 1 ? '' : 's'}</span></summary><div class="ar-boards ar-boards--cost">${costBoards}</div></details>` : ''}
</div>`;
</div>`;
- return layout({ title: 'The Design Arena', description: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.', path: '/arena', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });
+ return layout({
+ title: `The Design Arena${kind.id === DEFAULT_KIND ? '' : ` · ${kind.name}`}`,
+ description: 'Blind votes on AI designs across nine kinds (pages, screens, illustration, motion, charts, 3D, shaders and more), with an Elo board for each. No account needed.',
+ path: '/arena', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'],
+ });
}
}
function arenaNew({ me = {}, limits = null, categories = [], notice = null } = {}) {
function arenaNew({ me = {}, limits = null, categories = [], notice = null } = {}) {
@@ -352,39 +452,6 @@ ${signedIn ? '' : `<p class="ar-notice" id="ar-signin">${kit.button({ href: '/au
@@ -352,39 +452,6 @@ ${signedIn ? '' : `<p class="ar-notice" id="ar-signin">${kit.button({ href: '/au
return layout({ title: 'Start an Arena round', description: 'Describe a page once; three AI designers build it and the Arena ranks them.', path: '/arena/new', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });
return layout({ title: 'Start an Arena round', description: 'Describe a page once; three AI designers build it and the Arena ranks them.', path: '/arena/new', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });
}
}
-function arenaVote({ pair = null, me = {}, result = null, notice = null } = {}) {
- const signedIn = Boolean(me.signedIn);
- let body;
- if (!signedIn) {
- body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind</p><h1>Sign in to judge</h1><p class="ar-lede">The two designs are shown without their models. Sign in to record one vote per round; you cannot vote on a round you started.</p></header><p>${kit.button({ href: '/auth/login?next=%2Farena%2Fvote', label: 'Sign in to vote' })}</p></div>`;
- } else if (result) {
- const left = result.revealed.left;
- const right = result.revealed.right;
- body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote recorded</p><h1>The models are revealed</h1><p class="ar-lede">You chose <b>${esc(result.vote.winner === 'both_bad' ? 'both bad' : result.vote.winner)}</b>. The Elo leaderboard has been updated.</p></header>
-<div class="ar-reveal" id="ar-reveal"><div class="ar-reveal-side" id="ar-reveal-left"><h2>Left</h2><p><b>${esc(left.model)}</b>${left.provider ? ` · ${esc(left.provider)}` : ''}</p><p class="ar-fine">${arenaCost(left.cost_usd)} · ${arenaMs(left.ms)} · ${left.tokens} tokens</p></div>
-<div class="ar-reveal-side" id="ar-reveal-right"><h2>Right</h2><p><b>${esc(right.model)}</b>${right.provider ? ` · ${esc(right.provider)}` : ''}</p><p class="ar-fine">${arenaCost(right.cost_usd)} · ${arenaMs(right.ms)} · ${right.tokens} tokens</p></div></div>
-<p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/vote">Vote on another</a> <a class="ovk-btn ovk-btn--quiet" href="/arena/r/${encodeURIComponent(result.vote.round)}">See the round</a></p></div>`;
- } else if (!pair) {
- body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind</p><h1>Nothing waiting</h1><p class="ar-lede">${esc(notice || 'There is no ready round you have not voted on. Start one, or come back when another is ready.')}</p></header><p class="ar-actions"><a class="ovk-btn ovk-btn--primary" href="/arena/new">Start a round</a> <a class="ovk-btn ovk-btn--quiet" href="/arena">The leaderboard</a></p></div>`;
- } else {
- body = `<div class="ar-page" id="ar-vote"><header class="ar-page-h"><p class="ar-kicker">Vote blind · ${esc(ARENA_CATEGORY[pair.round.category] || pair.round.category)}</p><h1>Which design is better?</h1><p class="ar-lede">${esc(pair.round.prompt)}</p><p class="ar-fine">The models are hidden until you vote. Compare at phone and desktop sizes.</p></header>
-<form id="ar-vote-form" class="ar-vote" method="post" action="/arena/vote">
-<input type="hidden" id="ar-vote-round" name="round_id" value="${esc(pair.round.id)}">
-<input type="hidden" id="ar-vote-left-id" name="left_output" value="${esc(pair.left.id)}">
-<input type="hidden" id="ar-vote-right-id" name="right_output" value="${esc(pair.right.id)}">
-${arenaViewSwitch()}
-<div class="ar-vote-pair" id="ar-vote-pair">
-<section class="ar-vote-side" id="ar-vote-left" data-side="left"><h2>Left</h2>${arenaShot(pair.left, 390)}${arenaShot(pair.left, 1280)}</section>
-<section class="ar-vote-side" id="ar-vote-right" data-side="right"><h2>Right</h2>${arenaShot(pair.right, 390)}${arenaShot(pair.right, 1280)}</section></div>
-<div class="ar-vote-actions" id="ar-vote-actions"><button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="left" data-winner="left">Left is better</button>
-<button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn" name="winner" value="right" data-winner="right">Right is better</button>
-<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="tie" data-winner="tie">It is a tie</button>
-<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="both_bad" data-winner="both_bad">Both are bad</button></div>
-<p class="ar-status" id="ar-vote-status" role="status"></p></form></div>`;
- }
- return layout({ title: 'Vote blind · The Design Arena', description: 'Two generated designs, no model names: vote for the better one.', path: '/arena/vote', body: `<div class="ds-wrap">${body}</div>`, active: '/arena', scripts: ['js/arena.js'] });
-}
-
function arenaRound({ round, me = {} } = {}) {
function arenaRound({ round, me = {} } = {}) {
const outputs = round.outputs || [];
const outputs = round.outputs || [];
const ok = outputs.filter((o) => !o.error);
const ok = outputs.filter((o) => !o.error);
@@ -411,7 +478,7 @@ function notFound() {
@@ -411,7 +478,7 @@ function notFound() {
module.exports = {
module.exports = {
home, studio, theme, developers, notFound,
home, studio, theme, developers, notFound,
- arenaHome, arenaNew, arenaVote, arenaRound, ARENA_CATEGORY, arenaShot,
+ arenaHome, arenaNew, arenaRound, ARENA_CATEGORY, arenaShot,
layout,
layout,
setRelease: layoutLib.setRelease,
setRelease: layoutLib.setRelease,
configure: layoutLib.configure,
configure: layoutLib.configure,
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+'use strict';
+/**
+ * The Design Arena as a view of Bench's nine design kinds (plan L51 step 1): a blind pair of any kind judged on
+ * /arena, signed in or not. Signed out, Design relays the vote (its service token and X-OV-Voter: anon:<a keyed hash
+ * of its own cookie>) and caps the address itself; Bench's own caps answer "Sign in to keep voting today". The
+ * outputs are drawn by Bench's render origin in sandbox="allow-scripts" frames, the models stay hidden until the vote,
+ * and every kind's board carries its voter counts.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const views = require('../server/views');
+const { kindOf, DESIGN_KINDS } = require('../server/arena/kinds');
+
+const ORIGIN = 'https://openvibe.design';
+const RENDER = 'https://bench-render.openvibe.services';
+const pairPart = (html) => html.slice(html.indexOf('id="ar-pair"'), html.indexOf('id="ar-boards"'));
+const voterCookie = (res) => {
+ const m = /ovd_voter=([0-9a-f]{32})/.exec(res.headers.get('set-cookie') || '');
+ return m ? `ovd_voter=${m[1]}` : null;
+};
+const field = (html, name) => (new RegExp(`name="${name}" value="([^"]+)"`).exec(html) || [])[1];
+const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
+
+(async () => {
+ await check('the nine kinds, by id or short name', () => {
+ assert.strictEqual(DESIGN_KINDS.length, 9);
+ assert.strictEqual(kindOf('svg').id, 'design.svg');
+ assert.strictEqual(kindOf('design.shader').id, 'design.shader');
+ assert.strictEqual(kindOf('poster').id, 'design.page', 'anything else is the default');
+ const off = views.arenaHome({ me: {}, guestVotes: false });
+ assert.match(off, /Sign in to judge/, 'with signed-out voting off, the pair asks to sign in');
+ });
+
+ await check('the nine boards are read once a minute, a failed kind stays a gap, and nothing is kept when all fail', async () => {
+ const { createArenaStore } = require('../server/arena/store');
+ const calls = [];
+ let down = false;
+ const bench = {
+ getBoard: async (kind, { voters }) => {
+ calls.push(`${kind}|${voters}`);
+ if (down || kind === 'design.shader') throw new Error('unreachable');
+ return { rows: [{ subject: 'm1', elo: 1010, games: 3 }], voters: { person: 1, anon: 2, total: 3 } };
+ },
+ };
+ const store = createArenaStore({ config: { arena: { maxPrompt: 4000 } }, bench });
+ const first = await store.boardsAll({ now: 1000 });
+ assert.strictEqual(first.length, 9);
+ assert.deepStrictEqual(first.find((b) => b.kind === 'design.svg').voters, { person: 1, anon: 2, total: 3 });
+ assert.strictEqual(first.find((b) => b.kind === 'design.shader').failed, true);
+ await store.boardsAll({ now: 50_000 });
+ assert.strictEqual(calls.length, 9, 'kept for a minute');
+ await store.boardsAll({ now: 1000, voters: 'people' });
+ assert.strictEqual(calls.filter((c) => c.endsWith('|people')).length, 9, 'each filter has its own');
+ down = true;
+ await store.boardsAll({ now: 70_000 });
+ await store.boardsAll({ now: 70_001 });
+ assert.strictEqual(calls.length, 36, 'an all-failed read is not kept');
+ });
+
+ await check('arena.js opens a pair at the phone view on a phone, and leaves it on a computer', () => {
+ const fs = require('fs');
+ const vm = require('vm');
+ const src = fs.readFileSync(require.resolve('../public/js/arena.js'), 'utf8');
+ const run = (narrow) => {
+ const radio = { checked: false };
+ const document = { getElementById: (id) => (id === 'ar-view-phone' ? radio : null), querySelectorAll: () => [], documentElement: { classList: { add() {} } } };
+ const window = { matchMedia: (q) => ({ matches: narrow && /max-width: 759px/.test(q) }), location: {} };
+ vm.runInNewContext(src, { document, window, setTimeout, fetch: () => null });
+ return radio.checked;
+ };
+ assert.strictEqual(run(true), true);
+ assert.strictEqual(run(false), false);
+ });
+
+ const t = await boot({ env: { DESIGN_ARENA_GUEST_VOTES_PER_ADDRESS: '2' } });
+ const ana = t.network.addUser('ana');
+ const bo = t.network.addUser('bo');
+ try {
+ const svg = t.bench.seedRound({ kind: 'design.svg', prompt: 'A fox curled up asleep, as a flat SVG', createdBy: ana.subject });
+ let cookie;
+ let page;
+ await check('signed out: a blind pair of the chosen kind, drawn by Bench\'s render origin', async () => {
+ page = await t.get('/arena?kind=svg', { headers: { 'x-forwarded-for': '198.51.100.7' } });
+ assert.strictEqual(page.status, 200, page.text);
+ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store', 'a pair is this visitor\'s own');
+ cookie = voterCookie(page);
+ assert.ok(cookie, 'a voter cookie for the arena');
+ assert.match(page.headers.get('set-cookie'), /Path=\/arena/i);
+ assert.match(page.headers.get('set-cookie'), /HttpOnly/i);
+ assert.ok((page.headers.get('content-security-policy') || '').includes(`frame-src 'self' https://openvibe.network ${RENDER}`), 'the CSP lets the render origin be framed');
+ const pair = pairPart(page.text);
+ assert.ok(pair.includes('A fox curled up asleep'), 'the brief');
+ assert.strictEqual((pair.match(new RegExp(`src="${RENDER}/render/e/bte_`, 'g')) || []).length, 2, 'one 4:3 frame a side for art');
+ assert.strictEqual((pair.match(/sandbox="allow-scripts"/g) || []).length, 2, 'scripts only, never same origin');
+ assert.ok(!/alpha-1|beta-2|acme/.test(pair), 'the models stay hidden');
+ assert.strictEqual((page.text.match(/class="ar-kind"/g) || []).length, 9, 'a chip for each kind');
+ assert.match(page.text, /data-kind="design\.svg" aria-current="page"|aria-current="page" data-kind="design\.svg"/);
+ const asked = t.bench.state.requests.filter((r) => r.path === '/api/v1/judgements/next').at(-1);
+ assert.match(asked.voter, /^anon:[0-9a-f]{40}$/, 'a keyed hash of the cookie, never the cookie');
+ assert.ok(!asked.voter.includes(cookie.split('=')[1]));
+ const service = t.network.state.tokenRequests.find((x) => x.audience === 'openvibe.bench');
+ assert.ok(service && /bench\.judgement\.cast/.test(service.scope), 'Design\'s own service token relays it');
+ });
+
+ await check('signed out: the vote is relayed, the models revealed, and that pair is done', async () => {
+ const fields = { kind: 'design.svg', round_id: field(page.text, 'round_id'), left_output: field(page.text, 'left_output'), right_output: field(page.text, 'right_output'), winner: 'left' };
+ const cross = await t.get('/arena/vote', { ...form(fields), cookie, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403, 'a cross-site post is refused');
+ const r = await t.get('/arena/vote', { ...form(fields), cookie, origin: ORIGIN, headers: { ...form(fields).headers, 'x-forwarded-for': '198.51.100.7' } });
+ assert.strictEqual(r.status, 200, r.text);
+ const j = t.bench.state.judgements.at(-1);
+ assert.strictEqual(j.round_id, svg.id);
+ assert.strictEqual(j.voter_kind, 'anon');
+ assert.match(j.subject, /^anon_/);
+ assert.match(r.text, /You picked A/);
+ assert.match(r.text, /Your pick/);
+ assert.ok(/alpha-1/.test(pairPart(r.text)) && /beta-2/.test(pairPart(r.text)), 'both models named');
+ assert.match(r.text, /href="\/arena\?kind=design\.svg#ar-pair">Next pair/);
+ const after = await t.get('/arena?kind=svg', { cookie, headers: { 'x-forwarded-for': '198.51.100.7' } });
+ assert.match(after.text, /Nothing waiting in Illustration/, 'the same voter is not shown it again');
+ assert.match(after.text, /Try Motion/);
+ });
+
+ await check('Bench\'s cap and Design\'s own address cap: "Sign in to keep voting today"', async () => {
+ const more = t.bench.seedRound({ kind: 'design.shader', prompt: 'Northern lights', createdBy: ana.subject });
+ const ask = async (addr, c) => t.get('/arena?kind=shader', { cookie: c, headers: { 'x-forwarded-for': addr } });
+ const vote = async (addr, c, p) => t.get('/arena/vote', {
+ ...form({ kind: 'design.shader', round_id: field(p.text, 'round_id'), left_output: field(p.text, 'left_output'), right_output: field(p.text, 'right_output'), winner: 'tie' }),
+ cookie: c, origin: ORIGIN, headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': addr },
+ });
+ t.bench.state.anonPerDay = 0;
+ const first = await ask('203.0.113.5', null);
+ const c1 = voterCookie(first);
+ const capped = await vote('203.0.113.5', c1, first);
+ assert.strictEqual(capped.status, 429);
+ assert.match(capped.text, /Sign in to keep voting today\. <a href="\/auth\/login\?next=/);
+ t.bench.state.anonPerDay = 30;
+ // Design counts the address: two votes from one address, then no more, whatever the cookie.
+ const second = t.bench.seedRound({ kind: 'design.shader', prompt: 'Ink in water', createdBy: ana.subject });
+ const p1 = await ask('203.0.113.9', null);
+ assert.strictEqual((await vote('203.0.113.9', voterCookie(p1), p1)).status, 200);
+ const p2 = await ask('203.0.113.9', null);
+ assert.strictEqual((await vote('203.0.113.9', voterCookie(p2), p2)).status, 200);
+ const p3 = await ask('203.0.113.9', null);
+ const before = t.bench.state.requests.filter((r) => r.method === 'POST' && r.path.endsWith('/judgements')).length;
+ const third = await vote('203.0.113.9', voterCookie(p3), p3);
+ assert.strictEqual(third.status, 429);
+ assert.match(third.text, /Sign in to keep voting today/);
+ assert.strictEqual(t.bench.state.requests.filter((r) => r.method === 'POST' && r.path.endsWith('/judgements')).length, before, 'refused before Bench');
+ assert.ok(more && second);
+ });
+
+ await check('signed in: the person\'s own token, and the reveal', async () => {
+ const made = await t.get('/api/v1/arena/rounds', { as: ana, json: { category: 'landing', prompt: 'A bakery landing page' }, origin: ORIGIN });
+ assert.strictEqual(made.status, 201, made.text);
+ await t.tick();
+ const p = await t.get('/arena', { as: bo });
+ assert.strictEqual(p.status, 200);
+ const pair = pairPart(p.text);
+ assert.ok(pair.includes('A bakery landing page'));
+ assert.strictEqual((pair.match(/class="ar-shot ar-shot--(phone|desktop)"/g) || []).length, 4, 'a page kind at phone and desktop widths');
+ assert.match(pair, /You vote as yourself/);
+ const r = await t.get('/arena/vote', {
+ as: bo, origin: ORIGIN,
+ ...form({ kind: 'design.page', round_id: field(p.text, 'round_id'), left_output: field(p.text, 'left_output'), right_output: field(p.text, 'right_output'), winner: 'right' }),
+ });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.match(r.text, /You picked B/);
+ const call = t.bench.state.requests.filter((x) => x.method === 'POST' && x.path.endsWith('/judgements')).at(-1);
+ assert.strictEqual(call.voter, null, 'no relay for a person');
+ assert.strictEqual(t.bench.state.judgements.at(-1).subject, bo.subject);
+ });
+
+ await check('the boards: nine kinds, voter counts, whose votes', async () => {
+ const p = await t.get('/arena?voters=anon');
+ assert.strictEqual((p.text.match(/class="ar-kb( is-on)?"/g) || []).length, 9);
+ assert.match(p.text, /id="ar-kb-design-svg"[\s\S]*?1 vote · 0 signed in · 1 signed out/);
+ assert.ok(t.bench.state.requests.some((r) => r.path === '/api/v1/boards/design.shader' && /voters=anon/.test(r.query)));
+ assert.match(p.text, /<a href="\/arena\?kind=design\.page&voters=anon#ar-boards" aria-current="true">Signed out<\/a>|<a href="\/arena\?kind=design\.page&voters=anon#ar-boards" aria-current="true">Signed out<\/a>/);
+ assert.match(p.text, /What each model costs/);
+ });
+
+ await check('the old vote address leads to the arena', async () => {
+ const r = await t.get('/arena/vote?category=logo');
+ assert.strictEqual(r.status, 302);
+ assert.strictEqual(r.headers.get('location'), '/arena?kind=design.component#ar-pair');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -74,8 +74,8 @@ const ORIGIN = 'https://openvibe.design';
@@ -74,8 +74,8 @@ const ORIGIN = 'https://openvibe.design';
assert.ok(cost.json().leaderboard.some((r) => r.entries > 0 && r.cost_per_solved != null));
assert.ok(cost.json().leaderboard.some((r) => r.entries > 0 && r.cost_per_solved != null));
assert.ok(t.bench.state.requests.some((x) => x.path === '/api/v1/boards/design.page' && x.query === '?type=cost'));
assert.ok(t.bench.state.requests.some((x) => x.path === '/api/v1/boards/design.page' && x.query === '?type=cost'));
const page = await t.get('/arena');
const page = await t.get('/arena');
- assert.match(page.text, /Cost view/);
- assert.match(page.text, /provisional/);
+ assert.match(page.text, /What each model costs/, 'the cost view, folded under the boards');
+ assert.match(page.text, /class="ar-provisional" title="Fewer than 10 games">new</, 'a model with few games is marked new');
});
});
await check('logo uses design.component and dataset redirects to public Bench', async () => {
await check('logo uses design.component and dataset redirects to public Bench', async () => {
@@ -124,6 +124,22 @@ async function startBench({ network } = {}) {
@@ -124,6 +124,22 @@ async function startBench({ network } = {}) {
['design.page', { id: 'design.page', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design page arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } }],
['design.page', { id: 'design.page', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design page arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } }],
['design.component', { id: 'design.component', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design component arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } }],
['design.component', { id: 'design.component', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design component arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } }],
]);
]);
+ // The other seven design kinds (plan L51): Design's /arena shows all nine.
+ for (const [kid, entry, field] of [['design.svg', 'svg_art', 'svg'], ['design.motion', 'motion_html', 'html'], ['design.ui', 'ui_page', 'html'], ['design.dataviz', 'dataviz_page', 'html'], ['design.model3d', 'gltf_model', 'gltf_base64'], ['design.shader', 'shader_glsl', 'glsl'], ['design.interface', 'component_set', 'html']]) {
+ kinds.set(kid, { id: kid, room: 'design', entry, judge: 'human_pairwise', name: kid, metrics: ['elo'], schema: { entry, payload: { [field]: 'string' } } });
+ }
+ // Anonymous voters a first-party service relays (X-OV-Voter), as Bench does: a hash of the id, a daily cap.
+ state.anonPerDay = 30;
+ state.anonUsed = new Map();
+ function relayed(req) {
+ const header = String(req.headers['x-ov-voter'] || '');
+ if (!header) return null;
+ const v = verify(getAuth(req));
+ if (!v || v.kind !== 'service') throw problem(403, 'bench.relay_not_allowed', 'Only a first-party service may relay an anonymous voter.');
+ if (!v.cap.includes('bench.judgement.cast')) throw problem(403, 'bench.capability_denied', 'Need bench.judgement.cast.');
+ if (!header.startsWith('anon:') || !/^[A-Za-z0-9._~:-]{6,200}$/.test(header.slice(5))) throw problem(400, 'bench.anon_voter_invalid', 'anon:<id>');
+ return { subject: `anon_${crypto.createHash('sha256').update(`relay:design|${header.slice(5)}`).digest('hex').slice(0, 24)}`, creator: v.sub };
+ }
function defaultHtml(prompt) {
function defaultHtml(prompt) {
const p = String(prompt || '').split('\n')[0].slice(0, 200);
const p = String(prompt || '').split('\n')[0].slice(0, 200);
@@ -375,7 +391,7 @@ async function startBench({ network } = {}) {
@@ -375,7 +391,7 @@ async function startBench({ network } = {}) {
let body = {};
let body = {};
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
const url = new URL(req.url, 'http://x');
const url = new URL(req.url, 'http://x');
- state.requests.push({ method: req.method, path: url.pathname, query: url.search, authorization: req.headers.authorization || null, body: text });
+ state.requests.push({ method: req.method, path: url.pathname, query: url.search, authorization: req.headers.authorization || null, voter: req.headers['x-ov-voter'] || null, body: text });
const json = (status, obj, headers = {}) => {
const json = (status, obj, headers = {}) => {
res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
@@ -471,6 +487,18 @@ async function startBench({ network } = {}) {
@@ -471,6 +487,18 @@ async function startBench({ network } = {}) {
return json(201, { entry: fullEntry(entry) });
return json(201, { entry: fullEntry(entry) });
}
}
+ if (req.method === 'GET' && url.pathname === '/api/v1/judgements/next' && req.headers['x-ov-voter']) {
+ const anon = relayed(req);
+ const kindId = url.searchParams.get('kind') || 'design.page';
+ if (!kinds.has(kindId) || kinds.get(kindId).judge !== 'human_pairwise') return sendProblem(403, 'bench.anon_kind_not_allowed', 'Only pairwise kinds.');
+ const round = [...state.rounds.values()].find((r) => r.kind_id === kindId && r.status === 'finished' && r.created_by !== 'service:design'
+ && !state.judgements.some((j) => j.round_id === r.id && j.subject === anon.subject)
+ && [...state.entries.values()].filter((e) => e.round_id === r.id && !e.error).length >= 2);
+ if (!round) { res.writeHead(204); return res.end(); }
+ const [left, right] = shuffle([...state.entries.values()].filter((e) => e.round_id === round.id && !e.error));
+ return json(200, { round: { id: round.id, kind_id: round.kind_id, prompt: round.prompt }, left: { id: left.id, payload: left.payload }, right: { id: right.id, payload: right.payload } });
+ }
+
if (req.method === 'GET' && url.pathname === '/api/v1/judgements/next') {
if (req.method === 'GET' && url.pathname === '/api/v1/judgements/next') {
const person = requirePerson(req);
const person = requirePerson(req);
const kindId = url.searchParams.get('kind') || 'design.page';
const kindId = url.searchParams.get('kind') || 'design.page';
@@ -491,12 +519,17 @@ async function startBench({ network } = {}) {
@@ -491,12 +519,17 @@ async function startBench({ network } = {}) {
}
}
if (req.method === 'POST' && /^\/api\/v1\/rounds\/([^/]+)\/judgements$/.test(url.pathname)) {
if (req.method === 'POST' && /^\/api\/v1\/rounds\/([^/]+)\/judgements$/.test(url.pathname)) {
- const person = requirePerson(req);
+ const anon = relayed(req);
+ const person = anon ? { subject: anon.subject, anon: true } : requirePerson(req);
+ if (anon) {
+ const used = state.anonUsed.get(anon.subject) || 0;
+ if (used >= state.anonPerDay) return sendProblem(429, 'bench.limit_anon', 'Sign in to keep voting today');
+ }
const roundId = decodeURIComponent(url.pathname.split('/').slice(-2)[0]);
const roundId = decodeURIComponent(url.pathname.split('/').slice(-2)[0]);
const round = state.rounds.get(roundId);
const round = state.rounds.get(roundId);
if (!round) return sendProblem(404, 'bench.round_not_found', 'No such round.');
if (!round) return sendProblem(404, 'bench.round_not_found', 'No such round.');
if (round.status !== 'finished') return sendProblem(409, 'bench.round_not_ready', 'Round not ready.');
if (round.status !== 'finished') return sendProblem(409, 'bench.round_not_ready', 'Round not ready.');
- if (round.created_by === person.subject) return sendProblem(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');
+ if (round.created_by === person.subject || (person.anon && round.created_by === 'service:design')) return sendProblem(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');
const pair = body.pair || {};
const pair = body.pair || {};
if (!pair.left || !pair.right || pair.left === pair.right) return sendProblem(422, 'bench.invalid_pair', 'Invalid pair.');
if (!pair.left || !pair.right || pair.left === pair.right) return sendProblem(422, 'bench.invalid_pair', 'Invalid pair.');
if (state.judgements.some((j) => j.round_id === round.id && j.subject === person.subject)) return sendProblem(409, 'bench.already_judged', 'Already judged.');
if (state.judgements.some((j) => j.round_id === round.id && j.subject === person.subject)) return sendProblem(409, 'bench.already_judged', 'Already judged.');
@@ -516,7 +549,9 @@ async function startBench({ network } = {}) {
@@ -516,7 +549,9 @@ async function startBench({ network } = {}) {
subject: person.subject,
subject: person.subject,
at: new Date().toISOString(),
at: new Date().toISOString(),
};
};
+ judgement.voter_kind = person.anon ? 'anon' : 'person';
state.judgements.push(judgement);
state.judgements.push(judgement);
+ if (person.anon) state.anonUsed.set(person.subject, (state.anonUsed.get(person.subject) || 0) + 1);
updateRating(round.kind_id, entries.find((e) => e.id === pair.left), entries.find((e) => e.id === pair.right), outcome);
updateRating(round.kind_id, entries.find((e) => e.id === pair.left), entries.find((e) => e.id === pair.right), outcome);
return json(201, { judgement, revealed: { left: fullEntry(entries.find((e) => e.id === pair.left)), right: fullEntry(entries.find((e) => e.id === pair.right)) } });
return json(201, { judgement, revealed: { left: fullEntry(entries.find((e) => e.id === pair.left)), right: fullEntry(entries.find((e) => e.id === pair.right)) } });
}
}
@@ -526,6 +561,10 @@ async function startBench({ network } = {}) {
@@ -526,6 +561,10 @@ async function startBench({ network } = {}) {
const type = url.searchParams.get('type') || 'elo';
const type = url.searchParams.get('type') || 'elo';
const b = board(kindId, type);
const b = board(kindId, type);
if (!b) return sendProblem(404, 'bench.kind_not_found', 'No such kind.');
if (!b) return sendProblem(404, 'bench.kind_not_found', 'No such kind.');
+ const js = state.judgements.filter((j) => j.kind_id === kindId);
+ const anonN = js.filter((j) => j.voter_kind === 'anon').length;
+ b.voters = { person: js.length - anonN, anon: anonN, service: 0, total: js.length };
+ if (url.searchParams.get('voters')) b.category = url.searchParams.get('voters');
return json(200, { board: b });
return json(200, { board: b });
}
}
@@ -563,7 +602,17 @@ async function startBench({ network } = {}) {
@@ -563,7 +602,17 @@ async function startBench({ network } = {}) {
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
}
}
- return { base, url: base, state, tick, close, addEntry };
+ /** A finished round of any kind, started by a person (so a relayed voter may judge it), with two entries. */
+ function seedRound({ kind = 'design.svg', prompt = 'A small fox', createdBy = 'usr_seed', models = ['alpha-1', 'beta-2'] } = {}) {
+ const task = { id: id('btk'), kind_id: kind, prompt, task_class: null, public: true, created_by: 'service:design', created_at: new Date().toISOString() };
+ state.tasks.set(task.id, task);
+ const round = { id: id('brd'), task_id: task.id, kind_id: kind, offers: [], status: 'running', seed: 0, pool_fallback: false, receipt: null, params: {}, created_by: createdBy, created_at: new Date().toISOString(), finished_at: null, task_class: null, prompt };
+ state.rounds.set(round.id, round);
+ models.forEach((model) => addEntry(round.id, { provider: 'acme', model, payload: { svg: '<svg viewBox="0 0 10 10"></svg>' }, cost_usd: 0.002, ms: 1500, tokens: 10 }));
+ return round;
+ }
+
+ return { base, url: base, state, tick, close, addEntry, seedRound };
}
}
module.exports = { startBench };
module.exports = { startBench };