openvibers/openvibe.design Commits

The arena judges all nine of Bench's design kinds, signed in or not (#86)

OP OpenVibers committed 12 days ago · 8ea8c40abf · 12 files, +718 −107

/arena is now a view of OpenVibe.Bench's design kinds (plan L51 step 1): pages, screens,
systems, components, illustration, motion, charts, 3D models and shaders.

- A rail of the nine kinds, and a blind pair of the chosen one right on the page. Each
  output is drawn by Bench's cookie-less render origin in a sandbox="allow-scripts" frame,
  so script kinds (motion, charts, 3D, shaders) run; page kinds keep the desktop/phone switch,
  and a phone opens at the phone view.
- Signed out, the vote is relayed: Design's service token and X-OV-Voter: anon:<keyed hash
  of its own ovd_voter cookie>. Bench caps each voter; Design caps each address (60 a day,
  DESIGN_ARENA_GUEST_VOTES_PER_ADDRESS, 0 turns it off). Every cap says 'Sign in to keep
  voting today'. A signed-out vote must come from openvibe.design itself.
- The vote reveals both models with cost and time, then 'Next pair'.
- One card per kind's board: top five, a counted fold for the rest, the vote counts (signed
  in and signed out), and Everyone / Signed in / Signed out. Boards are kept a minute.
- /arena/vote now leads to /arena; the cost tables fold under the boards.

Changes

ddiff --git a/public/css/design.css b/public/css/design.css
iindex 0f049d3..16e2ce0 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -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; } }
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex b2b5376..e2c212a 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -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) {
ddiff --git a/server/app.js b/server/app.js
iindex fabcbf8..425a35f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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'"],
ddiff --git a/server/arena/bench.js b/server/arena/bench.js
iindex 3dc0ad4..ecd97fd 100644
---- a/server/arena/bench.js
++++ b/server/arena/bench.js
@@@ -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 = console
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 } = {}) {
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 = console
nextPair,
castVote,
castImportedVote,
++ nextJudgement,
++ castJudgement,
getBoard,
++ renderUrl,
datasetUrl,
addEntry,
};
ddiff --git a/server/arena/kinds.js b/server/arena/kinds.js
nnew file mode 100644
iindex 0000000..216ecc8
---- /dev/null
++++ b/server/arena/kinds.js
@@@ -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 };
ddiff --git a/server/arena/store.js b/server/arena/store.js
iindex 60f08e1..e699389 100644
---- a/server/arena/store.js
++++ b/server/arena/store.js
@@@ -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,
};
}
ddiff --git a/server/config.js b/server/config.js
iindex 43688bf..65a88ee 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 920da36..68c36ac 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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.');
}
});
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 51df954..3c1b8ea 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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: '/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'] });
}
--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,
ddiff --git a/test/arena-kinds.test.js b/test/arena-kinds.test.js
nnew file mode 100644
iindex 0000000..816a38a
---- /dev/null
++++ b/test/arena-kinds.test.js
@@@ -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&amp;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); });
ddiff --git a/test/arena.test.js b/test/arena.test.js
iindex fa6f00b..3111aa0 100644
---- a/test/arena.test.js
++++ b/test/arena.test.js
@@@ -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 () => {
ddiff --git a/test/helpers/bench.js b/test/helpers/bench.js
iindex c9258d1..4c0aae9 100644
---- a/test/helpers/bench.js
++++ b/test/helpers/bench.js
@@@ -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 };