openvibers/openvibe.design Commits
Arena: tap a design to vote, one slim vote bar, kinds and votes swap in place (#190)
Changes
@@@ -744,3 +744,119 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef@media (max-width: 1000px) { .ds-system-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (max-width: 520px) { .ds-system-grid { grid-template-columns: minmax(0, 1fr); } }@media (prefers-reduced-motion: reduce) { .ds-sys { transition: none; } .ds-sys:hover { transform: none; } }++++/* ── The arena, easier to judge (owner 11-08: "make A and B clickable", "smoother between things") ──++ The drawing is the vote: a transparent button lies over each frame, so a tap picks it and a swipe scrolls the page++ (never the frame). "Try it" lifts the cover to scroll, turn or hover a design first. The four verdicts sit in one++ slim floating bar. Frame boxes are sized by CSS alone, so nothing moves when the script scales the drawing. */++.ar-side-h { display: flex; align-items: center; gap: 8px; min-height: 32px; margin: 0 0 10px; }++.ar-arena .ar-side-h h3 { margin: 0; }++.ar-try-in { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }++.ar-try { margin-left: auto; display: inline-flex; align-items: center; min-height: 30px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--border, #26344d);++ color: var(--text-secondary, #96a7c2); font-size: .78rem; font-weight: 750; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;++ transition: background-color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), color var(--ov-dur-2, .15s); }++.ar-try > span { display: inline-flex; align-items: center; gap: 5px; }++.ar-try:hover { border-color: color-mix(in srgb, var(--room-strong) 50%, var(--border, #26344d)); color: var(--text-primary, #e6edf7); }++.ar-try .ar-try-on, .ar-try-in:checked + .ar-try .ar-try-off { display: none; }++.ar-try-in:checked + .ar-try .ar-try-on { display: inline-flex; }++.ar-try-in:checked + .ar-try { background: color-mix(in srgb, var(--room-strong) 18%, transparent); border-color: var(--room-strong); color: var(--text-primary, #e6edf7); }++.ar-try-in:focus-visible + .ar-try { outline: 2px solid var(--room-strong); outline-offset: 2px; }++++.ar-side-body { position: relative; }++.ar-cover { all: unset; box-sizing: border-box; position: absolute; inset: 0; z-index: 2; display: flex; align-items: flex-end; justify-content: center; padding: 0 10px 34px;++ cursor: pointer; border-radius: 12px; -webkit-tap-highlight-color: transparent; touch-action: pan-x pan-y; }++.ar-pick-tag { display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 0 14px 0 6px; border-radius: 999px; max-width: 100%;++ background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 80%, transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);++ border: 1px solid color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); color: var(--text-primary, #e6edf7); font-size: .86rem; font-weight: 800; white-space: nowrap;++ box-shadow: 0 8px 22px -8px rgba(0, 0, 0, .55); transform: translateY(3px); transition: transform .22s cubic-bezier(.2, .8, .2, 1), background-color .18s, color .18s, box-shadow .22s; }++.ar-cover .ar-pick-tag { position: sticky; bottom: calc(var(--ovnav-appbar-h, 0px) + 88px + env(safe-area-inset-bottom, 0px)); }++.ar-pick-l { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 999px; flex: none; background: color-mix(in srgb, var(--room-strong) 24%, transparent); color: var(--room-strong); font-weight: 900; }++.ar-pick-tag svg { display: none; }++.ar-cover:is(:hover, :focus-visible) .ar-pick-tag { transform: none; background: var(--room-strong); color: var(--bg-primary, #0a0f1c); box-shadow: 0 10px 26px -8px color-mix(in srgb, var(--room-strong) 70%, transparent); }++.ar-cover:is(:hover, :focus-visible) .ar-pick-l { background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 22%, transparent); color: var(--bg-primary, #0a0f1c); }++.ar-cover:focus-visible { outline: none; }++.ar-pickable { transition: border-color .18s, box-shadow .22s, transform .18s cubic-bezier(.2, .8, .2, 1), opacity .25s, filter .25s; }++.ar-pickable:has(.ar-cover:is(:hover, :focus-visible)) { border-color: var(--room-strong); box-shadow: 0 0 0 1px var(--room-strong), 0 16px 36px -20px color-mix(in srgb, var(--room-strong) 80%, transparent); }++.ar-pickable:has(.ar-cover:active) { transform: scale(.985); }++.ar-pickable:has(.ar-try-in:checked) .ar-cover { display: none; }++.ar-pickable:has(.ar-try-in:checked) { border-color: color-mix(in srgb, var(--room-strong) 55%, var(--border, #26344d)); }++/* A phone handset is narrow: the tag is just the letter and the tick */++.ar-page:has(#ar-view-phone:checked) .ar-pick-t { display: none; }++.ar-page:has(#ar-view-phone:checked) .ar-pick-tag { padding: 0 6px; }++@media (max-width: 759px) {++ .ar-page:has(.ar-view):not(:has(.ar-view input:checked)) .ar-pick-t { display: none; }++ .ar-page:has(.ar-view):not(:has(.ar-view input:checked)) .ar-pick-tag { padding: 0 6px; }++}++.ar-art + .ar-cover { padding-bottom: 14px; }++++/* The verdict, the moment it is cast: the pick lights and ticks, the other side steps back, then the answer swaps in */++.ar-vote-pair[data-pick] .ar-pickable { pointer-events: none; }++.ar-vote-pair[data-pick=left] #ar-vote-right, .ar-vote-pair[data-pick=right] #ar-vote-left { opacity: .42; filter: saturate(.5); transform: scale(.98); }++.ar-vote-pair:is([data-pick=left]) #ar-vote-left, .ar-vote-pair:is([data-pick=right]) #ar-vote-right, .ar-vote-pair[data-pick=tie] .ar-pickable {++ border-color: var(--room-strong); box-shadow: 0 0 0 2px var(--room-strong), 0 18px 40px -20px color-mix(in srgb, var(--room-strong) 80%, transparent); }++.ar-vote-pair[data-pick=both_bad] .ar-pickable { border-color: color-mix(in srgb, #ef4444 65%, var(--border, #26344d)); opacity: .7; filter: saturate(.6); }++.ar-vote-pair:is([data-pick=left]) #ar-vote-left .ar-pick-tag, .ar-vote-pair:is([data-pick=right]) #ar-vote-right .ar-pick-tag { transform: none; background: var(--room-strong); color: var(--bg-primary, #0a0f1c); animation: ar-picked .42s cubic-bezier(.2, .9, .3, 1.4); }++.ar-vote-pair:is([data-pick=left]) #ar-vote-left .ar-pick-tag svg, .ar-vote-pair:is([data-pick=right]) #ar-vote-right .ar-pick-tag svg { display: block; }++@keyframes ar-picked { 0% { transform: scale(.9); } 55% { transform: scale(1.08); } 100% { transform: none; } }++++/* One slim floating bar for the four verdicts (and for "Next pair" after a vote), above the phone's app bar */++.ar-arena :is(#ar-vote-actions, #ar-next) { display: grid; gap: 6px; width: min(100%, 560px); box-sizing: border-box; margin: 16px auto 0; padding: 6px;++ bottom: calc(var(--ovnav-appbar-h, 0px) + 10px + env(safe-area-inset-bottom, 0px)); border: 1px solid var(--border, #26344d); border-radius: 18px;++ background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 82%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.2); backdrop-filter: blur(14px) saturate(1.2);++ box-shadow: 0 14px 34px -16px rgba(0, 0, 0, .6); }++.ar-arena #ar-vote-actions { grid-template-columns: minmax(0, 1fr) auto auto minmax(0, 1fr); }++.ar-arena #ar-next { grid-template-columns: 1fr 1fr; }++.ar-arena :is(#ar-vote-actions, #ar-next) .ovk-btn { min-width: 0; width: 100%; min-height: 44px; padding: 0 12px; justify-content: center; white-space: nowrap; border-radius: 13px; font-size: .9rem; }++.ar-vote-l { display: inline-grid; place-items: center; width: 22px; height: 22px; margin-right: -1px; border-radius: 7px; background: color-mix(in srgb, currentColor 20%, transparent); font-weight: 900; }++.ar-arena #ar-vote-actions .ovk-btn:disabled { opacity: .55; }++@media (max-width: 420px) { .ar-arena :is(#ar-vote-actions, #ar-next) .ovk-btn { padding: 0 9px; font-size: .84rem; } .ar-vote-l { width: 20px; height: 20px; } }++++/* Frame boxes sized by CSS alone: a desktop drawing fills its column, a phone is a handset at most 281 px wide */++.ar-shot--desktop .ar-shot-frame, .ar-shot--desktop::before { width: 100%; }++.ar-shot--phone .ar-shot-frame { width: min(calc(100% - 14px), 281px); }++++/* Desktop or Phone: until someone chooses, the screen decides (a phone opens at the phone view, with no jump) */++@media (min-width: 760px) { .ar-page:not(:has(.ar-view input:checked)) .ar-shot--phone { display: none; }++ .ar-view:not(:has(input:checked)) label[for=ar-view-desktop] { 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); } }++@media (max-width: 759px) { .ar-page:not(:has(.ar-view input:checked)) .ar-shot--desktop { display: none; }++ .ar-page:has(.ar-view):not(:has(.ar-view input:checked)) .ar-vote-pair { grid-template-columns: 1fr 1fr; gap: 10px; }++ .ar-view:not(:has(input:checked)) label[for=ar-view-phone] { 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); } }++/* A page pair drawn as two handsets side by side leaves little room: the cards hug them */++.ar-page:has(#ar-view-phone:checked) .ar-vote-side, .ar-vote-pair .ar-shot--phone:only-child { padding: 8px; }++@media (max-width: 759px) { .ar-page:not(:has(.ar-view input:checked)) .ar-vote-side:has(.ar-shot--phone) { padding: 8px; } }++++/* Moving between kinds, votes and pairs: the pair and the boards cross over in place, the rail stays put */++.ar-arena #ar-pair { view-transition-name: ar-pair; }++.ar-arena #ar-boards { view-transition-name: ar-boards; }++.ar-arena #ar-kinds { view-transition-name: ar-kinds; }++::view-transition-group(ar-pair), ::view-transition-group(ar-boards) { animation-duration: .34s; animation-timing-function: cubic-bezier(.2, .8, .2, 1); }++::view-transition-old(ar-pair) { animation: ar-vt-out .16s ease-in both; }++::view-transition-new(ar-pair) { animation: ar-vt-in .34s cubic-bezier(.2, .8, .2, 1) .05s both; }++@keyframes ar-vt-out { to { opacity: 0; transform: translateY(-6px); } }++@keyframes ar-vt-in { from { opacity: 0; transform: translateY(12px); } }++.ar-arena #ar-pair { transition: opacity .2s; }++.ar-arena.is-loading #ar-pair { opacity: .55; }++.ar-arena.is-loading #ar-kinds .ar-kind[aria-current] .ar-kind-i { animation: ar-breathe 1s ease-in-out infinite alternate; }++@keyframes ar-breathe { to { opacity: .55; } }++/* A frame that was swapped in waits on a quiet surface and fades in once drawn, instead of flashing white */++.ar-wait { background: linear-gradient(100deg, var(--bg-card, #0f1729) 40%, color-mix(in srgb, var(--text-primary, #e6edf7) 7%, var(--bg-card, #0f1729)) 50%, var(--bg-card, #0f1729) 60%) 0 0 / 220% 100%;++ animation: ar-shimmer 1.3s linear infinite; }++.ar-wait > .ar-frame { opacity: 0; transition: opacity .3s ease-out; }++.ar-wait.is-loaded { animation: none; background: #fff; }++.ar-wait.is-loaded > .ar-frame { opacity: 1; }++@keyframes ar-shimmer { to { background-position: -220% 0; } }++@media (prefers-reduced-motion: reduce) {++ .ar-pickable, .ar-pick-tag, .ar-arena #ar-pair { transition: none; }++ .ar-vote-pair[data-pick] .ar-pick-tag, .ar-wait, .ar-arena.is-loading #ar-kinds .ar-kind[aria-current] .ar-kind-i { animation: none; }++ .ar-vote-pair[data-pick] .ar-pickable { transform: none; }++}++++/* The kinds rail on a phone: the icon beside the name, the line under both, no word broken mid-way */++@media (max-width: 759px) {++ .ar-kinds { grid-auto-columns: minmax(148px, 44%); }++ .ar-kind { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: 'i b' 's s'; align-items: center; gap: 6px 8px; min-height: 0; padding: 10px; }++ .ar-kind-i { grid-area: i; width: 26px; height: 26px; border-radius: 8px; }++ .ar-kind-t { display: contents; }++ .ar-kind-t b { grid-area: b; overflow-wrap: normal; hyphens: manual; }++ .ar-kind-t small { grid-area: s; }++}@@@ -2,8 +2,10 @@* The Arena's small in-browser conveniences. The pages work without this file: /arena/new is a real form that* posts to the page, /arena/vote is a real form that posts the winner, and every iframe is already sandboxed.*-- * With it, the vote and create forms stay on the page (fetch + JSON), a refusal is shown in words, and a round-- * that is still generating refreshes itself. Nothing from a model is ever inserted into this document.++ * With it, the create form stays on the page (fetch + JSON) and a refusal is shown in words, a round that is still++ * generating refreshes itself, and on /arena a kind, a vote or the next pair swaps in place (the server's own page,++ * parsed, never a model's output). Nothing from a model is ever inserted into this document: it stays in the++ * sandboxed frames from Bench's render origin.*/(function () {'use strict';@@@ -25,49 +27,6 @@return json;}-- const vote = $('ar-vote-form');-- if (vote) {-- vote.addEventListener('submit', async (event) => {-- const button = event.submitter;-- if (!button || !button.dataset.winner) return;-- event.preventDefault();-- const out = $('ar-vote-status');-- status(out, 'Recording your vote…');-- try {-- const data = await postJson('/api/v1/arena/vote', {-- round_id: $('ar-vote-round').value,-- left_output: $('ar-vote-left-id').value,-- right_output: $('ar-vote-right-id').value,-- winner: button.dataset.winner,-- });-- const left = data.revealed.left;-- const right = data.revealed.right;-- const reveal = document.createElement('div');-- reveal.id = 'ar-reveal';-- reveal.className = 'ar-reveal';-- for (const [side, o] of [['Left', left], ['Right', right]]) {-- const box = document.createElement('div');-- box.className = 'ar-reveal-side';-- const h = document.createElement('h2');-- h.textContent = side;-- const p = document.createElement('p');-- const b = document.createElement('b');-- b.textContent = o.model;-- p.append(b, document.createTextNode(o.provider ? ` · ${o.provider}` : ''));-- const meta = document.createElement('p');-- meta.className = 'ar-fine';-- meta.textContent = `$${Number(o.cost_usd || 0).toFixed(4)} · ${o.ms} ms · ${o.tokens} tokens`;-- box.append(h, p, meta);-- reveal.append(box);-- }-- vote.replaceWith(reveal);-- status(out, `Vote recorded: ${data.vote.winner === 'both_bad' ? 'both bad' : data.vote.winner}. The models are revealed above.`);-- } catch (err) {-- status(out, err.message || 'The vote could not be recorded. Try again.');-- }-- });-- }--const create = $('ar-new-form');if (create) {create.addEventListener('submit', async (event) => {@@@ -93,23 +52,143 @@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) {++ // Fit every generated frame to the box it sits in. The box's size is set by CSS alone (so nothing moves when this++ // runs); only the drawing inside is scaled to it.++ var ro = 'ResizeObserver' in window ? new ResizeObserver(function (entries) {++ entries.forEach(function (entry) {++ var frame = entry.target, iframe = frame.querySelector('iframe');++ var w = frame.clientWidth, base = Number(iframe && iframe.getAttribute('width')) || 1280;++ if (w) frame.style.setProperty('--s', String(Math.max(0.12, w / base)));++ });++ }) : null;++ function fit(root) {++ if (!ro) return;document.documentElement.classList.add('ar-fit');-- var ro = new ResizeObserver(function (entries) {-- entries.forEach(function (entry) {-- var shot = entry.target, frame = shot.querySelector('.ar-shot-frame'), iframe = shot.querySelector('iframe');-- var w = entry.contentRect.width, base = Number(iframe && iframe.getAttribute('width')) || 1280;-- if (!frame || !w) return;-- var s = shot.classList.contains('ar-shot--phone') ? Math.min(0.72, (w - 14) / base) : w / base;-- frame.style.setProperty('--s', String(Math.max(0.12, s)));-- });++ root.querySelectorAll('.ar-shot-frame').forEach(function (frame) { ro.observe(frame); });++ }++ fit(document);++++ // The arena page itself: choosing a kind, voting and "Next pair" swap the pair and the boards in place, with a++ // short transition, instead of loading the whole page again and jumping to the anchor. Every link and the vote++ // form still work as plain navigation and a plain form post when this does not run.++ var home = $('ar-home');++ if (!home || !window.fetch || !window.DOMParser || !window.history || !history.pushState) return;++ var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;++ var busy = null;++++ // A frame swapped in fades in once it has drawn, over a quiet placeholder, instead of flashing white.++ function waitForFrames(root) {++ root.querySelectorAll('.ar-shot-frame, .ar-art').forEach(function (box) {++ var iframe = box.querySelector('iframe');++ if (!iframe) return;++ box.classList.add('ar-wait');++ var done = function () { box.classList.add('is-loaded'); };++ iframe.addEventListener('load', done, { once: true });++ setTimeout(done, 5000);++ });++ }++++ // Keep the viewport the person chose (Desktop or Phone) across pairs.++ var view = null;++ home.addEventListener('change', function (event) {++ var t = event.target;++ if (t && t.name === 'ar_view') view = t.value;++ });++++ function railCurrent(kind) {++ home.querySelectorAll('.ar-kind').forEach(function (a) {++ if (a.getAttribute('data-kind') === kind) a.setAttribute('aria-current', 'page');++ else a.removeAttribute('aria-current');});-- shots.forEach(function (shot) { ro.observe(shot); });}++++ // Bring the pair into view when it is not (a board's "Judge", "Next pair" from far down the page).++ function reveal() {++ var rail = $('ar-kinds') || $('ar-pair');++ if (!rail) return;++ var top = rail.getBoundingClientRect().top;++ var bar = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ovnav-h')) || 64;++ if (top < bar || top > window.innerHeight * 0.6) {++ window.scrollTo({ top: Math.max(0, window.scrollY + top - bar - 8), behavior: reduced ? 'auto' : 'smooth' });++ }++ }++++ async function swap(url, init, opts) {++ if (busy) busy.abort();++ var ctl = busy = new AbortController();++ var slow = setTimeout(function () { home.classList.add('is-loading'); }, 140);++ try {++ var res = await fetch(url, Object.assign({ credentials: 'same-origin', headers: { accept: 'text/html' }, signal: ctl.signal }, init || {}));++ var doc = new DOMParser().parseFromString(await res.text(), 'text/html');++ var next = doc.getElementById('ar-home');++ if (!next || !doc.getElementById('ar-pair')) { window.location.assign(res.redirected ? res.url : (opts.fallback || url)); return; }++ var apply = function () {++ ['ar-pair', 'ar-boards', 'ar-total'].forEach(function (id) {++ var mine = $(id), theirs = doc.getElementById(id);++ if (!mine || !theirs) return;++ if (ro) mine.querySelectorAll('.ar-shot-frame').forEach(function (f) { ro.unobserve(f); });++ var node = document.importNode(theirs, true);++ waitForFrames(node);++ mine.replaceWith(node);++ });++ var kind = next.getAttribute('data-kind');++ home.setAttribute('data-kind', kind);++ railCurrent(kind);++ if (doc.title) document.title = doc.title;++ if (view) { var r = $('ar-view-' + view); if (r) r.checked = true; }++ fit($('ar-pair') || document);++ };++ if (document.startViewTransition && !reduced) {++ var vt = document.startViewTransition(apply);++ await vt.updateCallbackDone;++ } else apply();++ if (opts.push) {++ if (opts.push === window.location.pathname + window.location.search) history.replaceState({ arena: true }, '', opts.push);++ else history.pushState({ arena: true }, '', opts.push);++ }++ if (opts.reveal) reveal();++ } catch (err) {++ if (err && err.name === 'AbortError') return;++ window.location.assign(opts.fallback || url);++ } finally {++ clearTimeout(slow);++ if (busy === ctl) { busy = null; home.classList.remove('is-loading'); }++ }++ }++++ // Same-page arena links: a kind in the rail, a board's "Judge", "Next pair", whose votes the boards count.++ home.addEventListener('click', function (event) {++ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;++ var a = event.target.closest && event.target.closest('a[href]');++ if (!a || !home.contains(a)) return;++ var u = new URL(a.href, window.location.href);++ if (u.origin !== window.location.origin || u.pathname !== '/arena') return;++ event.preventDefault();++ var kind = u.searchParams.get('kind');++ if (kind && a.classList.contains('ar-kind')) railCurrent(kind);++ swap(u.pathname + u.search, null, { push: u.pathname + u.search, reveal: u.hash !== '#ar-boards', fallback: u.href });++ });++++ // The vote: the tapped side lights up at once, the answer (the two models, the boards moved) swaps in.++ home.addEventListener('submit', function (event) {++ var form = event.target;++ if (!form || form.id !== 'ar-judge') return;++ var button = event.submitter;++ var winner = button && button.getAttribute('data-winner');++ if (!winner) return;++ event.preventDefault();++ var body = new URLSearchParams();++ new FormData(form).forEach(function (v, k) { body.append(k, v); });++ body.set('winner', winner);++ var pair = $('ar-vote-pair');++ if (pair) pair.setAttribute('data-pick', winner);++ form.querySelectorAll('button[type=submit]').forEach(function (b) { b.disabled = true; });++ swap('/arena/vote', { method: 'POST', body: body, headers: { accept: 'text/html', 'content-type': 'application/x-www-form-urlencoded' } },++ { fallback: window.location.href, reveal: false });++ });++++ window.addEventListener('popstate', function () {++ var u = new URL(window.location.href);++ if (u.pathname === '/arena') swap(u.pathname + u.search, null, { fallback: u.href });++ });})();@@@ -306,7 +306,7 @@ function arenaShot(o, width) {// Which viewport the pair (or the round's outputs) shows. The radios belong to no form, so a vote never sends them.function arenaViewSwitch() {return `<div class="ar-view" role="radiogroup" aria-label="Viewport">--<input type="radio" id="ar-view-desktop" name="ar_view" value="desktop" form="ar-view-none" checked><label for="ar-view-desktop">Desktop</label>++<input type="radio" id="ar-view-desktop" name="ar_view" value="desktop" form="ar-view-none"><label for="ar-view-desktop">Desktop</label><input type="radio" id="ar-view-phone" name="ar_view" value="phone" form="ar-view-none"><label for="ar-view-phone">Phone</label></div>`;}@@@ -362,6 +362,21 @@ function arenaSide(kind, side, letter) {/** 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>`;++/**++ * One side of a blind pair as the thing you tap to vote: the whole drawing is a submit button laid over the frame++ * (so a tap picks it and a swipe still scrolls the page, never the frame), and "Try it" lifts that cover for a++ * design you want to scroll, turn or hover first. Both work without JavaScript: the toggle is a checkbox that++ * belongs to no form.++ */++function arenaPickSide(kind, side, letter, key) {++ const tryId = `ar-try-${key}`;++ return `<section class="ar-vote-side ar-pickable" id="ar-vote-${key}" data-side="${key}">`++ + `<div class="ar-side-h"><h3>${arenaLetter(letter)}</h3>`++ + `<input type="checkbox" class="ar-try-in" id="${tryId}" form="ar-view-none"><label class="ar-try" for="${tryId}"><span class="ar-try-off">${glyphs.svg('hand', { size: 14 })}Try it</span><span class="ar-try-on">${glyphs.svg('check', { size: 14 })}Done</span></label></div>`++ + `<div class="ar-side-body">${arenaSide(kind, side, letter)}`++ + `<button type="submit" class="ar-cover" name="winner" value="${key}" data-winner="${key}" aria-label="Design ${letter} is better"><span class="ar-pick-tag" aria-hidden="true"><span class="ar-pick-l">${letter}</span><span class="ar-pick-t">Pick ${letter}</span>${glyphs.svg('check', { size: 15 })}</span></button>`++ + `</div></section>`;++}const arenaSecs = (ms) => (ms == null ? null : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms)} ms`);/**@@@ -405,13 +420,11 @@ ${kind.viewports ? arenaViewSwitch() : ''}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>++<div class="ar-vote-pair" id="ar-vote-pair">${arenaPickSide(kind, pair.left, 'A', 'left')}${arenaPickSide(kind, pair.right, 'B', 'right')}</div>++<div class="ar-vote-actions" id="ar-vote-actions"><button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn ar-vote-ab" name="winner" value="left" data-winner="left"><span class="ar-vote-l" aria-hidden="true">A</span> 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>++<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="both_bad" data-winner="both_bad">Both bad</button>++<button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn ar-vote-ab" name="winner" value="right" data-winner="right"><span class="ar-vote-l" aria-hidden="true">B</span> better</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>`;@@@ -57,19 +57,31 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headerassert.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', () => {++ await check('a pair opens at the phone view on a phone and the desktop view on a computer, by CSS, with no jump', () => {const fs = require('fs');-- const vm = require('vm');++ const views = require('../server/views');++ const css = fs.readFileSync(require.resolve('../public/css/design.css'), 'utf8');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 pair = { round: { id: 'r1', prompt: 'A page' }, left: { id: 'a', frame: 'https://render.test/a' }, right: { id: 'b', frame: 'https://render.test/b' } };++ const html = views.arenaHome({ kind: 'design.page', pair });++ // Neither viewport is checked until someone chooses: the screen width decides, before any script runs.++ assert.doesNotMatch(html, /id="ar-view-(desktop|phone)"[^>]*checked/);++ assert.match(css, /@media \(max-width: 759px\) \{ \.ar-page:not\(:has\(\.ar-view input:checked\)\) \.ar-shot--desktop \{ display: none; \}/);++ assert.match(css, /@media \(min-width: 760px\) \{ \.ar-page:not\(:has\(\.ar-view input:checked\)\) \.ar-shot--phone \{ display: none; \}/);++ assert.doesNotMatch(src, /ar-view-phone'\)\.checked|phoneView\.checked/, 'the script no longer flips the view after the page has drawn');++ });++++ await check('each side of a pair is the vote: a cover button over the drawing, a "Try it" toggle, and the four verdicts', () => {++ const views = require('../server/views');++ const pair = { round: { id: 'r1', prompt: 'A fox' }, left: { id: 'a', frame: 'https://render.test/a' }, right: { id: 'b', frame: 'https://render.test/b' } };++ const html = views.arenaHome({ kind: 'design.svg', pair });++ assert.match(html, /<button type="submit" class="ar-cover" name="winner" value="left" data-winner="left" aria-label="Design A is better">/);++ assert.match(html, /<button type="submit" class="ar-cover" name="winner" value="right" data-winner="right" aria-label="Design B is better">/);++ // The toggle belongs to no form, so it is never sent with a vote.++ assert.match(html, /<input type="checkbox" class="ar-try-in" id="ar-try-left" form="ar-view-none">/);++ for (const w of ['left', 'tie', 'both_bad', 'right']) assert.match(html, new RegExp(`class="ovk-btn[^"]*ar-vote-btn[^"]*" name="winner" value="${w}"`));++ // The model's drawing is only ever a sandboxed frame from the render origin.++ assert.match(html, /<iframe class="ar-frame ar-art-frame" src="https:\/\/render\.test\/a"[^>]*sandbox="allow-scripts"/);});const t = await boot({ env: { DESIGN_ARENA_GUEST_VOTES_PER_ADDRESS: '2' } });
@@ -744,3 +744,119 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -744,3 +744,119 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@media (max-width: 1000px) { .ds-system-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 1000px) { .ds-system-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 520px) { .ds-system-grid { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 520px) { .ds-system-grid { grid-template-columns: minmax(0, 1fr); } }
@media (prefers-reduced-motion: reduce) { .ds-sys { transition: none; } .ds-sys:hover { transform: none; } }
@media (prefers-reduced-motion: reduce) { .ds-sys { transition: none; } .ds-sys:hover { transform: none; } }
+
+/* ── The arena, easier to judge (owner 11-08: "make A and B clickable", "smoother between things") ──
+ The drawing is the vote: a transparent button lies over each frame, so a tap picks it and a swipe scrolls the page
+ (never the frame). "Try it" lifts the cover to scroll, turn or hover a design first. The four verdicts sit in one
+ slim floating bar. Frame boxes are sized by CSS alone, so nothing moves when the script scales the drawing. */
+.ar-side-h { display: flex; align-items: center; gap: 8px; min-height: 32px; margin: 0 0 10px; }
+.ar-arena .ar-side-h h3 { margin: 0; }
+.ar-try-in { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
+.ar-try { margin-left: auto; display: inline-flex; align-items: center; min-height: 30px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--border, #26344d);
+ color: var(--text-secondary, #96a7c2); font-size: .78rem; font-weight: 750; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;
+ transition: background-color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), color var(--ov-dur-2, .15s); }
+.ar-try > span { display: inline-flex; align-items: center; gap: 5px; }
+.ar-try:hover { border-color: color-mix(in srgb, var(--room-strong) 50%, var(--border, #26344d)); color: var(--text-primary, #e6edf7); }
+.ar-try .ar-try-on, .ar-try-in:checked + .ar-try .ar-try-off { display: none; }
+.ar-try-in:checked + .ar-try .ar-try-on { display: inline-flex; }
+.ar-try-in:checked + .ar-try { background: color-mix(in srgb, var(--room-strong) 18%, transparent); border-color: var(--room-strong); color: var(--text-primary, #e6edf7); }
+.ar-try-in:focus-visible + .ar-try { outline: 2px solid var(--room-strong); outline-offset: 2px; }
+
+.ar-side-body { position: relative; }
+.ar-cover { all: unset; box-sizing: border-box; position: absolute; inset: 0; z-index: 2; display: flex; align-items: flex-end; justify-content: center; padding: 0 10px 34px;
+ cursor: pointer; border-radius: 12px; -webkit-tap-highlight-color: transparent; touch-action: pan-x pan-y; }
+.ar-pick-tag { display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 0 14px 0 6px; border-radius: 999px; max-width: 100%;
+ background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 80%, transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
+ border: 1px solid color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); color: var(--text-primary, #e6edf7); font-size: .86rem; font-weight: 800; white-space: nowrap;
+ box-shadow: 0 8px 22px -8px rgba(0, 0, 0, .55); transform: translateY(3px); transition: transform .22s cubic-bezier(.2, .8, .2, 1), background-color .18s, color .18s, box-shadow .22s; }
+.ar-cover .ar-pick-tag { position: sticky; bottom: calc(var(--ovnav-appbar-h, 0px) + 88px + env(safe-area-inset-bottom, 0px)); }
+.ar-pick-l { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 999px; flex: none; background: color-mix(in srgb, var(--room-strong) 24%, transparent); color: var(--room-strong); font-weight: 900; }
+.ar-pick-tag svg { display: none; }
+.ar-cover:is(:hover, :focus-visible) .ar-pick-tag { transform: none; background: var(--room-strong); color: var(--bg-primary, #0a0f1c); box-shadow: 0 10px 26px -8px color-mix(in srgb, var(--room-strong) 70%, transparent); }
+.ar-cover:is(:hover, :focus-visible) .ar-pick-l { background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 22%, transparent); color: var(--bg-primary, #0a0f1c); }
+.ar-cover:focus-visible { outline: none; }
+.ar-pickable { transition: border-color .18s, box-shadow .22s, transform .18s cubic-bezier(.2, .8, .2, 1), opacity .25s, filter .25s; }
+.ar-pickable:has(.ar-cover:is(:hover, :focus-visible)) { border-color: var(--room-strong); box-shadow: 0 0 0 1px var(--room-strong), 0 16px 36px -20px color-mix(in srgb, var(--room-strong) 80%, transparent); }
+.ar-pickable:has(.ar-cover:active) { transform: scale(.985); }
+.ar-pickable:has(.ar-try-in:checked) .ar-cover { display: none; }
+.ar-pickable:has(.ar-try-in:checked) { border-color: color-mix(in srgb, var(--room-strong) 55%, var(--border, #26344d)); }
+/* A phone handset is narrow: the tag is just the letter and the tick */
+.ar-page:has(#ar-view-phone:checked) .ar-pick-t { display: none; }
+.ar-page:has(#ar-view-phone:checked) .ar-pick-tag { padding: 0 6px; }
+@media (max-width: 759px) {
+ .ar-page:has(.ar-view):not(:has(.ar-view input:checked)) .ar-pick-t { display: none; }
+ .ar-page:has(.ar-view):not(:has(.ar-view input:checked)) .ar-pick-tag { padding: 0 6px; }
+}
+.ar-art + .ar-cover { padding-bottom: 14px; }
+
+/* The verdict, the moment it is cast: the pick lights and ticks, the other side steps back, then the answer swaps in */
+.ar-vote-pair[data-pick] .ar-pickable { pointer-events: none; }
+.ar-vote-pair[data-pick=left] #ar-vote-right, .ar-vote-pair[data-pick=right] #ar-vote-left { opacity: .42; filter: saturate(.5); transform: scale(.98); }
+.ar-vote-pair:is([data-pick=left]) #ar-vote-left, .ar-vote-pair:is([data-pick=right]) #ar-vote-right, .ar-vote-pair[data-pick=tie] .ar-pickable {
+ border-color: var(--room-strong); box-shadow: 0 0 0 2px var(--room-strong), 0 18px 40px -20px color-mix(in srgb, var(--room-strong) 80%, transparent); }
+.ar-vote-pair[data-pick=both_bad] .ar-pickable { border-color: color-mix(in srgb, #ef4444 65%, var(--border, #26344d)); opacity: .7; filter: saturate(.6); }
+.ar-vote-pair:is([data-pick=left]) #ar-vote-left .ar-pick-tag, .ar-vote-pair:is([data-pick=right]) #ar-vote-right .ar-pick-tag { transform: none; background: var(--room-strong); color: var(--bg-primary, #0a0f1c); animation: ar-picked .42s cubic-bezier(.2, .9, .3, 1.4); }
+.ar-vote-pair:is([data-pick=left]) #ar-vote-left .ar-pick-tag svg, .ar-vote-pair:is([data-pick=right]) #ar-vote-right .ar-pick-tag svg { display: block; }
+@keyframes ar-picked { 0% { transform: scale(.9); } 55% { transform: scale(1.08); } 100% { transform: none; } }
+
+/* One slim floating bar for the four verdicts (and for "Next pair" after a vote), above the phone's app bar */
+.ar-arena :is(#ar-vote-actions, #ar-next) { display: grid; gap: 6px; width: min(100%, 560px); box-sizing: border-box; margin: 16px auto 0; padding: 6px;
+ bottom: calc(var(--ovnav-appbar-h, 0px) + 10px + env(safe-area-inset-bottom, 0px)); border: 1px solid var(--border, #26344d); border-radius: 18px;
+ background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 82%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.2); backdrop-filter: blur(14px) saturate(1.2);
+ box-shadow: 0 14px 34px -16px rgba(0, 0, 0, .6); }
+.ar-arena #ar-vote-actions { grid-template-columns: minmax(0, 1fr) auto auto minmax(0, 1fr); }
+.ar-arena #ar-next { grid-template-columns: 1fr 1fr; }
+.ar-arena :is(#ar-vote-actions, #ar-next) .ovk-btn { min-width: 0; width: 100%; min-height: 44px; padding: 0 12px; justify-content: center; white-space: nowrap; border-radius: 13px; font-size: .9rem; }
+.ar-vote-l { display: inline-grid; place-items: center; width: 22px; height: 22px; margin-right: -1px; border-radius: 7px; background: color-mix(in srgb, currentColor 20%, transparent); font-weight: 900; }
+.ar-arena #ar-vote-actions .ovk-btn:disabled { opacity: .55; }
+@media (max-width: 420px) { .ar-arena :is(#ar-vote-actions, #ar-next) .ovk-btn { padding: 0 9px; font-size: .84rem; } .ar-vote-l { width: 20px; height: 20px; } }
+
+/* Frame boxes sized by CSS alone: a desktop drawing fills its column, a phone is a handset at most 281 px wide */
+.ar-shot--desktop .ar-shot-frame, .ar-shot--desktop::before { width: 100%; }
+.ar-shot--phone .ar-shot-frame { width: min(calc(100% - 14px), 281px); }
+
+/* Desktop or Phone: until someone chooses, the screen decides (a phone opens at the phone view, with no jump) */
+@media (min-width: 760px) { .ar-page:not(:has(.ar-view input:checked)) .ar-shot--phone { display: none; }
+ .ar-view:not(:has(input:checked)) label[for=ar-view-desktop] { 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); } }
+@media (max-width: 759px) { .ar-page:not(:has(.ar-view input:checked)) .ar-shot--desktop { display: none; }
+ .ar-page:has(.ar-view):not(:has(.ar-view input:checked)) .ar-vote-pair { grid-template-columns: 1fr 1fr; gap: 10px; }
+ .ar-view:not(:has(input:checked)) label[for=ar-view-phone] { 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); } }
+/* A page pair drawn as two handsets side by side leaves little room: the cards hug them */
+.ar-page:has(#ar-view-phone:checked) .ar-vote-side, .ar-vote-pair .ar-shot--phone:only-child { padding: 8px; }
+@media (max-width: 759px) { .ar-page:not(:has(.ar-view input:checked)) .ar-vote-side:has(.ar-shot--phone) { padding: 8px; } }
+
+/* Moving between kinds, votes and pairs: the pair and the boards cross over in place, the rail stays put */
+.ar-arena #ar-pair { view-transition-name: ar-pair; }
+.ar-arena #ar-boards { view-transition-name: ar-boards; }
+.ar-arena #ar-kinds { view-transition-name: ar-kinds; }
+::view-transition-group(ar-pair), ::view-transition-group(ar-boards) { animation-duration: .34s; animation-timing-function: cubic-bezier(.2, .8, .2, 1); }
+::view-transition-old(ar-pair) { animation: ar-vt-out .16s ease-in both; }
+::view-transition-new(ar-pair) { animation: ar-vt-in .34s cubic-bezier(.2, .8, .2, 1) .05s both; }
+@keyframes ar-vt-out { to { opacity: 0; transform: translateY(-6px); } }
+@keyframes ar-vt-in { from { opacity: 0; transform: translateY(12px); } }
+.ar-arena #ar-pair { transition: opacity .2s; }
+.ar-arena.is-loading #ar-pair { opacity: .55; }
+.ar-arena.is-loading #ar-kinds .ar-kind[aria-current] .ar-kind-i { animation: ar-breathe 1s ease-in-out infinite alternate; }
+@keyframes ar-breathe { to { opacity: .55; } }
+/* A frame that was swapped in waits on a quiet surface and fades in once drawn, instead of flashing white */
+.ar-wait { background: linear-gradient(100deg, var(--bg-card, #0f1729) 40%, color-mix(in srgb, var(--text-primary, #e6edf7) 7%, var(--bg-card, #0f1729)) 50%, var(--bg-card, #0f1729) 60%) 0 0 / 220% 100%;
+ animation: ar-shimmer 1.3s linear infinite; }
+.ar-wait > .ar-frame { opacity: 0; transition: opacity .3s ease-out; }
+.ar-wait.is-loaded { animation: none; background: #fff; }
+.ar-wait.is-loaded > .ar-frame { opacity: 1; }
+@keyframes ar-shimmer { to { background-position: -220% 0; } }
+@media (prefers-reduced-motion: reduce) {
+ .ar-pickable, .ar-pick-tag, .ar-arena #ar-pair { transition: none; }
+ .ar-vote-pair[data-pick] .ar-pick-tag, .ar-wait, .ar-arena.is-loading #ar-kinds .ar-kind[aria-current] .ar-kind-i { animation: none; }
+ .ar-vote-pair[data-pick] .ar-pickable { transform: none; }
+}
+
+/* The kinds rail on a phone: the icon beside the name, the line under both, no word broken mid-way */
+@media (max-width: 759px) {
+ .ar-kinds { grid-auto-columns: minmax(148px, 44%); }
+ .ar-kind { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: 'i b' 's s'; align-items: center; gap: 6px 8px; min-height: 0; padding: 10px; }
+ .ar-kind-i { grid-area: i; width: 26px; height: 26px; border-radius: 8px; }
+ .ar-kind-t { display: contents; }
+ .ar-kind-t b { grid-area: b; overflow-wrap: normal; hyphens: manual; }
+ .ar-kind-t small { grid-area: s; }
+}
@@ -2,8 +2,10 @@
@@ -2,8 +2,10 @@
* The Arena's small in-browser conveniences. The pages work without this file: /arena/new is a real form that
* The Arena's small in-browser conveniences. The pages work without this file: /arena/new is a real form that
* posts to the page, /arena/vote is a real form that posts the winner, and every iframe is already sandboxed.
* posts to the page, /arena/vote is a real form that posts the winner, and every iframe is already sandboxed.
*
*
- * With it, the vote and create forms stay on the page (fetch + JSON), a refusal is shown in words, and a round
- * that is still generating refreshes itself. Nothing from a model is ever inserted into this document.
+ * With it, the create form stays on the page (fetch + JSON) and a refusal is shown in words, a round that is still
+ * generating refreshes itself, and on /arena a kind, a vote or the next pair swaps in place (the server's own page,
+ * parsed, never a model's output). Nothing from a model is ever inserted into this document: it stays in the
+ * sandboxed frames from Bench's render origin.
*/
*/
(function () {
(function () {
'use strict';
'use strict';
@@ -25,49 +27,6 @@
@@ -25,49 +27,6 @@
return json;
return json;
}
}
- const vote = $('ar-vote-form');
- if (vote) {
- vote.addEventListener('submit', async (event) => {
- const button = event.submitter;
- if (!button || !button.dataset.winner) return;
- event.preventDefault();
- const out = $('ar-vote-status');
- status(out, 'Recording your vote…');
- try {
- const data = await postJson('/api/v1/arena/vote', {
- round_id: $('ar-vote-round').value,
- left_output: $('ar-vote-left-id').value,
- right_output: $('ar-vote-right-id').value,
- winner: button.dataset.winner,
- });
- const left = data.revealed.left;
- const right = data.revealed.right;
- const reveal = document.createElement('div');
- reveal.id = 'ar-reveal';
- reveal.className = 'ar-reveal';
- for (const [side, o] of [['Left', left], ['Right', right]]) {
- const box = document.createElement('div');
- box.className = 'ar-reveal-side';
- const h = document.createElement('h2');
- h.textContent = side;
- const p = document.createElement('p');
- const b = document.createElement('b');
- b.textContent = o.model;
- p.append(b, document.createTextNode(o.provider ? ` · ${o.provider}` : ''));
- const meta = document.createElement('p');
- meta.className = 'ar-fine';
- meta.textContent = `$${Number(o.cost_usd || 0).toFixed(4)} · ${o.ms} ms · ${o.tokens} tokens`;
- box.append(h, p, meta);
- reveal.append(box);
- }
- vote.replaceWith(reveal);
- status(out, `Vote recorded: ${data.vote.winner === 'both_bad' ? 'both bad' : data.vote.winner}. The models are revealed above.`);
- } catch (err) {
- status(out, err.message || 'The vote could not be recorded. Try again.');
- }
- });
- }
-
const create = $('ar-new-form');
const create = $('ar-new-form');
if (create) {
if (create) {
create.addEventListener('submit', async (event) => {
create.addEventListener('submit', async (event) => {
@@ -93,23 +52,143 @@
@@ -93,23 +52,143 @@
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.
- var shots = document.querySelectorAll('.ar-shot');
- if (shots.length && 'ResizeObserver' in window) {
+ // Fit every generated frame to the box it sits in. The box's size is set by CSS alone (so nothing moves when this
+ // runs); only the drawing inside is scaled to it.
+ var ro = 'ResizeObserver' in window ? new ResizeObserver(function (entries) {
+ entries.forEach(function (entry) {
+ var frame = entry.target, iframe = frame.querySelector('iframe');
+ var w = frame.clientWidth, base = Number(iframe && iframe.getAttribute('width')) || 1280;
+ if (w) frame.style.setProperty('--s', String(Math.max(0.12, w / base)));
+ });
+ }) : null;
+ function fit(root) {
+ if (!ro) return;
document.documentElement.classList.add('ar-fit');
document.documentElement.classList.add('ar-fit');
- var ro = new ResizeObserver(function (entries) {
- entries.forEach(function (entry) {
- var shot = entry.target, frame = shot.querySelector('.ar-shot-frame'), iframe = shot.querySelector('iframe');
- var w = entry.contentRect.width, base = Number(iframe && iframe.getAttribute('width')) || 1280;
- if (!frame || !w) return;
- var s = shot.classList.contains('ar-shot--phone') ? Math.min(0.72, (w - 14) / base) : w / base;
- frame.style.setProperty('--s', String(Math.max(0.12, s)));
- });
+ root.querySelectorAll('.ar-shot-frame').forEach(function (frame) { ro.observe(frame); });
+ }
+ fit(document);
+
+ // The arena page itself: choosing a kind, voting and "Next pair" swap the pair and the boards in place, with a
+ // short transition, instead of loading the whole page again and jumping to the anchor. Every link and the vote
+ // form still work as plain navigation and a plain form post when this does not run.
+ var home = $('ar-home');
+ if (!home || !window.fetch || !window.DOMParser || !window.history || !history.pushState) return;
+ var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ var busy = null;
+
+ // A frame swapped in fades in once it has drawn, over a quiet placeholder, instead of flashing white.
+ function waitForFrames(root) {
+ root.querySelectorAll('.ar-shot-frame, .ar-art').forEach(function (box) {
+ var iframe = box.querySelector('iframe');
+ if (!iframe) return;
+ box.classList.add('ar-wait');
+ var done = function () { box.classList.add('is-loaded'); };
+ iframe.addEventListener('load', done, { once: true });
+ setTimeout(done, 5000);
+ });
+ }
+
+ // Keep the viewport the person chose (Desktop or Phone) across pairs.
+ var view = null;
+ home.addEventListener('change', function (event) {
+ var t = event.target;
+ if (t && t.name === 'ar_view') view = t.value;
+ });
+
+ function railCurrent(kind) {
+ home.querySelectorAll('.ar-kind').forEach(function (a) {
+ if (a.getAttribute('data-kind') === kind) a.setAttribute('aria-current', 'page');
+ else a.removeAttribute('aria-current');
});
});
- shots.forEach(function (shot) { ro.observe(shot); });
}
}
+
+ // Bring the pair into view when it is not (a board's "Judge", "Next pair" from far down the page).
+ function reveal() {
+ var rail = $('ar-kinds') || $('ar-pair');
+ if (!rail) return;
+ var top = rail.getBoundingClientRect().top;
+ var bar = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ovnav-h')) || 64;
+ if (top < bar || top > window.innerHeight * 0.6) {
+ window.scrollTo({ top: Math.max(0, window.scrollY + top - bar - 8), behavior: reduced ? 'auto' : 'smooth' });
+ }
+ }
+
+ async function swap(url, init, opts) {
+ if (busy) busy.abort();
+ var ctl = busy = new AbortController();
+ var slow = setTimeout(function () { home.classList.add('is-loading'); }, 140);
+ try {
+ var res = await fetch(url, Object.assign({ credentials: 'same-origin', headers: { accept: 'text/html' }, signal: ctl.signal }, init || {}));
+ var doc = new DOMParser().parseFromString(await res.text(), 'text/html');
+ var next = doc.getElementById('ar-home');
+ if (!next || !doc.getElementById('ar-pair')) { window.location.assign(res.redirected ? res.url : (opts.fallback || url)); return; }
+ var apply = function () {
+ ['ar-pair', 'ar-boards', 'ar-total'].forEach(function (id) {
+ var mine = $(id), theirs = doc.getElementById(id);
+ if (!mine || !theirs) return;
+ if (ro) mine.querySelectorAll('.ar-shot-frame').forEach(function (f) { ro.unobserve(f); });
+ var node = document.importNode(theirs, true);
+ waitForFrames(node);
+ mine.replaceWith(node);
+ });
+ var kind = next.getAttribute('data-kind');
+ home.setAttribute('data-kind', kind);
+ railCurrent(kind);
+ if (doc.title) document.title = doc.title;
+ if (view) { var r = $('ar-view-' + view); if (r) r.checked = true; }
+ fit($('ar-pair') || document);
+ };
+ if (document.startViewTransition && !reduced) {
+ var vt = document.startViewTransition(apply);
+ await vt.updateCallbackDone;
+ } else apply();
+ if (opts.push) {
+ if (opts.push === window.location.pathname + window.location.search) history.replaceState({ arena: true }, '', opts.push);
+ else history.pushState({ arena: true }, '', opts.push);
+ }
+ if (opts.reveal) reveal();
+ } catch (err) {
+ if (err && err.name === 'AbortError') return;
+ window.location.assign(opts.fallback || url);
+ } finally {
+ clearTimeout(slow);
+ if (busy === ctl) { busy = null; home.classList.remove('is-loading'); }
+ }
+ }
+
+ // Same-page arena links: a kind in the rail, a board's "Judge", "Next pair", whose votes the boards count.
+ home.addEventListener('click', function (event) {
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ var a = event.target.closest && event.target.closest('a[href]');
+ if (!a || !home.contains(a)) return;
+ var u = new URL(a.href, window.location.href);
+ if (u.origin !== window.location.origin || u.pathname !== '/arena') return;
+ event.preventDefault();
+ var kind = u.searchParams.get('kind');
+ if (kind && a.classList.contains('ar-kind')) railCurrent(kind);
+ swap(u.pathname + u.search, null, { push: u.pathname + u.search, reveal: u.hash !== '#ar-boards', fallback: u.href });
+ });
+
+ // The vote: the tapped side lights up at once, the answer (the two models, the boards moved) swaps in.
+ home.addEventListener('submit', function (event) {
+ var form = event.target;
+ if (!form || form.id !== 'ar-judge') return;
+ var button = event.submitter;
+ var winner = button && button.getAttribute('data-winner');
+ if (!winner) return;
+ event.preventDefault();
+ var body = new URLSearchParams();
+ new FormData(form).forEach(function (v, k) { body.append(k, v); });
+ body.set('winner', winner);
+ var pair = $('ar-vote-pair');
+ if (pair) pair.setAttribute('data-pick', winner);
+ form.querySelectorAll('button[type=submit]').forEach(function (b) { b.disabled = true; });
+ swap('/arena/vote', { method: 'POST', body: body, headers: { accept: 'text/html', 'content-type': 'application/x-www-form-urlencoded' } },
+ { fallback: window.location.href, reveal: false });
+ });
+
+ window.addEventListener('popstate', function () {
+ var u = new URL(window.location.href);
+ if (u.pathname === '/arena') swap(u.pathname + u.search, null, { fallback: u.href });
+ });
})();
})();
@@ -306,7 +306,7 @@ function arenaShot(o, width) {
@@ -306,7 +306,7 @@ function arenaShot(o, width) {
// Which viewport the pair (or the round's outputs) shows. The radios belong to no form, so a vote never sends them.
// Which viewport the pair (or the round's outputs) shows. The radios belong to no form, so a vote never sends them.
function arenaViewSwitch() {
function arenaViewSwitch() {
return `<div class="ar-view" role="radiogroup" aria-label="Viewport">
return `<div class="ar-view" role="radiogroup" aria-label="Viewport">
-<input type="radio" id="ar-view-desktop" name="ar_view" value="desktop" form="ar-view-none" checked><label for="ar-view-desktop">Desktop</label>
+<input type="radio" id="ar-view-desktop" name="ar_view" value="desktop" form="ar-view-none"><label for="ar-view-desktop">Desktop</label>
<input type="radio" id="ar-view-phone" name="ar_view" value="phone" form="ar-view-none"><label for="ar-view-phone">Phone</label></div>`;
<input type="radio" id="ar-view-phone" name="ar_view" value="phone" form="ar-view-none"><label for="ar-view-phone">Phone</label></div>`;
}
}
@@ -362,6 +362,21 @@ function arenaSide(kind, side, letter) {
@@ -362,6 +362,21 @@ function arenaSide(kind, side, letter) {
/** A side's name: the letter as a badge, "Design A" for a screen reader. */
/** 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 arenaLetter = (letter) => `<span class="ar-letter" aria-hidden="true">${letter}</span><span class="dv-visually-hidden">Design ${letter}</span>`;
+/**
+ * One side of a blind pair as the thing you tap to vote: the whole drawing is a submit button laid over the frame
+ * (so a tap picks it and a swipe still scrolls the page, never the frame), and "Try it" lifts that cover for a
+ * design you want to scroll, turn or hover first. Both work without JavaScript: the toggle is a checkbox that
+ * belongs to no form.
+ */
+function arenaPickSide(kind, side, letter, key) {
+ const tryId = `ar-try-${key}`;
+ return `<section class="ar-vote-side ar-pickable" id="ar-vote-${key}" data-side="${key}">`
+ + `<div class="ar-side-h"><h3>${arenaLetter(letter)}</h3>`
+ + `<input type="checkbox" class="ar-try-in" id="${tryId}" form="ar-view-none"><label class="ar-try" for="${tryId}"><span class="ar-try-off">${glyphs.svg('hand', { size: 14 })}Try it</span><span class="ar-try-on">${glyphs.svg('check', { size: 14 })}Done</span></label></div>`
+ + `<div class="ar-side-body">${arenaSide(kind, side, letter)}`
+ + `<button type="submit" class="ar-cover" name="winner" value="${key}" data-winner="${key}" aria-label="Design ${letter} is better"><span class="ar-pick-tag" aria-hidden="true"><span class="ar-pick-l">${letter}</span><span class="ar-pick-t">Pick ${letter}</span>${glyphs.svg('check', { size: 15 })}</span></button>`
+ + `</div></section>`;
+}
const arenaSecs = (ms) => (ms == null ? null : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms)} ms`);
const arenaSecs = (ms) => (ms == null ? null : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms)} ms`);
/**
/**
@@ -405,13 +420,11 @@ ${kind.viewports ? arenaViewSwitch() : ''}
@@ -405,13 +420,11 @@ ${kind.viewports ? arenaViewSwitch() : ''}
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>
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) : ''}
<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() : ''}
${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>
+<div class="ar-vote-pair" id="ar-vote-pair">${arenaPickSide(kind, pair.left, 'A', 'left')}${arenaPickSide(kind, pair.right, 'B', 'right')}</div>
+<div class="ar-vote-actions" id="ar-vote-actions"><button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn ar-vote-ab" name="winner" value="left" data-winner="left"><span class="ar-vote-l" aria-hidden="true">A</span> 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--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>
+<button type="submit" class="ovk-btn ovk-btn--quiet ar-vote-btn" name="winner" value="both_bad" data-winner="both_bad">Both bad</button>
+<button type="submit" class="ovk-btn ovk-btn--primary ar-vote-btn ar-vote-ab" name="winner" value="right" data-winner="right"><span class="ar-vote-l" aria-hidden="true">B</span> better</button></div>
${who}</form>`;
${who}</form>`;
} else if (!signedIn && !guestVotes) {
} 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>`;
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>`;
@@ -57,19 +57,31 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
@@ -57,19 +57,31 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
assert.strictEqual(calls.length, 36, 'an all-failed read is not kept');
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', () => {
+ await check('a pair opens at the phone view on a phone and the desktop view on a computer, by CSS, with no jump', () => {
const fs = require('fs');
const fs = require('fs');
- const vm = require('vm');
+ const views = require('../server/views');
+ const css = fs.readFileSync(require.resolve('../public/css/design.css'), 'utf8');
const src = fs.readFileSync(require.resolve('../public/js/arena.js'), 'utf8');
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 pair = { round: { id: 'r1', prompt: 'A page' }, left: { id: 'a', frame: 'https://render.test/a' }, right: { id: 'b', frame: 'https://render.test/b' } };
+ const html = views.arenaHome({ kind: 'design.page', pair });
+ // Neither viewport is checked until someone chooses: the screen width decides, before any script runs.
+ assert.doesNotMatch(html, /id="ar-view-(desktop|phone)"[^>]*checked/);
+ assert.match(css, /@media \(max-width: 759px\) \{ \.ar-page:not\(:has\(\.ar-view input:checked\)\) \.ar-shot--desktop \{ display: none; \}/);
+ assert.match(css, /@media \(min-width: 760px\) \{ \.ar-page:not\(:has\(\.ar-view input:checked\)\) \.ar-shot--phone \{ display: none; \}/);
+ assert.doesNotMatch(src, /ar-view-phone'\)\.checked|phoneView\.checked/, 'the script no longer flips the view after the page has drawn');
+ });
+
+ await check('each side of a pair is the vote: a cover button over the drawing, a "Try it" toggle, and the four verdicts', () => {
+ const views = require('../server/views');
+ const pair = { round: { id: 'r1', prompt: 'A fox' }, left: { id: 'a', frame: 'https://render.test/a' }, right: { id: 'b', frame: 'https://render.test/b' } };
+ const html = views.arenaHome({ kind: 'design.svg', pair });
+ assert.match(html, /<button type="submit" class="ar-cover" name="winner" value="left" data-winner="left" aria-label="Design A is better">/);
+ assert.match(html, /<button type="submit" class="ar-cover" name="winner" value="right" data-winner="right" aria-label="Design B is better">/);
+ // The toggle belongs to no form, so it is never sent with a vote.
+ assert.match(html, /<input type="checkbox" class="ar-try-in" id="ar-try-left" form="ar-view-none">/);
+ for (const w of ['left', 'tie', 'both_bad', 'right']) assert.match(html, new RegExp(`class="ovk-btn[^"]*ar-vote-btn[^"]*" name="winner" value="${w}"`));
+ // The model's drawing is only ever a sandboxed frame from the render origin.
+ assert.match(html, /<iframe class="ar-frame ar-art-frame" src="https:\/\/render\.test\/a"[^>]*sandbox="allow-scripts"/);
});
});
const t = await boot({ env: { DESIGN_ARENA_GUEST_VOTES_PER_ADDRESS: '2' } });
const t = await boot({ env: { DESIGN_ARENA_GUEST_VOTES_PER_ADDRESS: '2' } });