openvibers/openvibe.design Commits

Arena: tap a design to vote, one slim vote bar, kinds and votes swap in place (#190)

OP OpenVibers committed 5 days ago · 887a7b1356 · 4 files, +299 −79

The owner, 11-08: make A and B clickable instead of buttons at the bottom, easier to scroll, and smoother when
moving between kinds (it jumped around).

- Each side of a pair is the vote: a transparent submit button over the drawing, with a Pick A/B tag that floats
  above the vote bar. A swipe scrolls the page, never the frame. Try it lifts the cover to scroll, turn or hover
  a design first (a checkbox that belongs to no form; works without JavaScript).
- The four verdicts sit in one slim floating bar (A better, Tie, Both bad, B better) instead of a 2x2 block.
- The picked side lights and ticks at once and the other steps back; the reveal swaps in place.
- A kind, a vote, Next pair, a board's Judge and the voters filter swap the pair and the boards in place with a
  view transition (fetch of the server's own page; history and back work), instead of a full load and anchor jump.
- Frame boxes are sized by CSS alone and the screen width picks Desktop or Phone before any script runs, so
  nothing moves after the page draws; swapped-in frames fade in over a quiet placeholder.
- The kinds rail on a phone keeps whole words (icon beside the name, the line under both).

Changes

ddiff --git a/public/css/design.css b/public/css/design.css
iindex 637cff2..df331a4 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -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; }
++}
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex e2c212a..627ca0d 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -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 });
++ });
})();
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 17f02c4..ac4a153 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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>`;
ddiff --git a/test/arena-kinds.test.js b/test/arena-kinds.test.js
iindex 816a38a..7e2920a 100644
---- a/test/arena-kinds.test.js
++++ b/test/arena-kinds.test.js
@@@ -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');
});
-- 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' } });