openvibers/openvibe.design Commits

/brand/explore: the bar wears your picks live; every pick counts, both kinds at once (#67)

OP OpenVibers committed 12 days ago · 8d1f430818 · 5 files, +171 −47

The owner, 11-01:
- 'when you tap an icon it changes the one in your current page in real time': the navbar at the top now shows the
  logo picked last in place of its mark, the menu button picked last in place of its menu button, and the font picked
  last on the name, with a short glow; only on this page, nothing saved, Clear puts the bar back.
- 'I have the Pair selected and it doesn't show in the vote results… let them choose both… a combination is fine… so
  I can take the final results and generate more': the tally no longer gives each person one lean. It counts the people
  who like the Loop and those who like the Pair (someone who picks from both counts for both, and the tally says how
  many like both), shows each family's top takes on its own row, and folds every take's count under 'Every vote'.

Changes

ddiff --git a/public/css/marks.css b/public/css/marks.css
iindex e0accd2..06b1366 100644
---- a/public/css/marks.css
++++ b/public/css/marks.css
@@@ -407,3 +407,23 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
/* The mock bars and the font step's word wear the font picked in step 3, if any (marks.js sets --mx-pick-font). */
.mb-nav .mx-word{font-family:var(--mx-pick-font,var(--mx-face));font-weight:var(--mx-pick-weight,800)}
++
++/* The site's bar wears the picks while this page is open (marks.js navPreview). */
++.openvibe-navbar .flame .mx-navmark{display:block;width:28px;height:28px;overflow:visible}
++.openvibe-navbar .ovnav-orb .mx-navmenu{all:unset;position:relative;display:grid;place-items:center;width:24px;height:24px;color:inherit}
++.openvibe-navbar .ovnav-orb .mx-navmenu svg{width:22px;height:22px}
++.openvibe-navbar .mx-nav-flash{animation:mxNavFlash 1.1s ease-out}
++@keyframes mxNavFlash{0%{box-shadow:0 0 0 0 color-mix(in srgb,#8b5cf6 70%,transparent);border-radius:12px}60%{box-shadow:0 0 0 10px transparent;border-radius:12px}100%{box-shadow:none}}
++@media (prefers-reduced-motion:reduce){.openvibe-navbar .mx-nav-flash{animation:none}}
++
++/* The tally counts both families (a person can like both), and every vote sits in a fold under it. */
++.mx-both{margin:-2px 0 0;font-size:12.5px;color:var(--mx-muted);text-align:center}
++.mx-all{border-top:1px solid var(--mx-line);padding-top:6px}
++.mx-all>summary{cursor:pointer;font-size:13px;font-weight:700;color:var(--text-secondary,#b4bfcc);list-style:none;display:flex;gap:6px;align-items:center}
++.mx-all>summary::-webkit-details-marker{display:none}
++.mx-all>summary::before{content:"\25B8";font-size:11px;transition:transform .2s}
++.mx-all[open]>summary::before{transform:rotate(90deg)}
++.mx-all>summary span{color:var(--mx-muted);font-weight:600}
++.mx-all-row{display:flex;align-items:flex-start;gap:8px;margin-top:6px}
++.mx-all-list{display:flex;flex-wrap:wrap;gap:6px;min-width:0}
++@media (max-width:640px){.mx-all-row{flex-direction:column;gap:4px}}
ddiff --git a/public/js/marks.js b/public/js/marks.js
iindex 8b721a8..4735ac1 100644
---- a/public/js/marks.js
++++ b/public/js/marks.js
@@@ -73,7 +73,7 @@
var code = marks.length ? marks[marks.length - 1] : null;
all('.mx-tab-side, .mx-step-2, .mx-step-3', root).forEach(function (el) { el.classList.toggle('is-later', !code); });
syncPickFont();
-- if (code === shownMark) return;
++ if (code === shownMark) { navPreview(); return; }
shownMark = code;
var defs = root.querySelector('.mx-defs');
var old = defs && defs.querySelector('[data-chosen]');
@@@ -89,8 +89,73 @@
ref = 'art-' + code + tag;
}
all('.mb-nav use, .fx-mark use', root).forEach(function (u) { u.setAttribute('href', '#' + ref); });
++ navRef = code ? ref : null;
all('[data-mb-with]', root).forEach(function (el) { el.textContent = code ? 'Next to your logo, ' + code + '.' : 'Next to today\u2019s logo. Pick a logo first to see yours here.'; });
++ navPreview();
}
++ // The site's own bar at the top wears the picks as you make them (the owner, 11-01: "when you tap an icon… it
++ // changes the one in your current page in real time, so you can see what it looks like"): the logo picked last
++ // in place of the bar's mark, the menu button picked last in place of its menu button, the font picked last on
++ // the name. Only on this page while it is open; nothing about the bar is saved, and clearing a pick puts the
++ // bar's own back. The navbar mounts after this script and can redraw itself, so it is re-applied when it does.
++ var navRef = null, navSeen = '';
++ function lastPick(k) { var l = picks.filter(function (c) { return c[0] === k; }); return l.length ? l[l.length - 1] : null; }
++ function swapIn(box, cls, make) {
++ var mine = box.querySelector(':scope > .' + cls);
++ var own = Array.prototype.filter.call(box.children, function (c) { return !c.classList.contains(cls); });
++ if (make) {
++ if (!mine) { mine = make(); box.appendChild(mine); }
++ own.forEach(function (c) { if (!c.hasAttribute('data-mx-hid')) { c.setAttribute('data-mx-hid', c.style.display || ''); c.style.display = 'none'; } });
++ return mine;
++ }
++ if (mine) mine.parentNode.removeChild(mine);
++ own.forEach(function (c) { if (c.hasAttribute('data-mx-hid')) { c.style.display = c.getAttribute('data-mx-hid'); c.removeAttribute('data-mx-hid'); } });
++ return null;
++ }
++ function navPreview() {
++ var bar = document.querySelector('.openvibe-navbar');
++ var brand = bar && bar.querySelector('.openvibe-navbar-brand');
++ if (!brand) return;
++ var menu = lastPick('M'), font = lastPick('F');
++ var flame = brand.querySelector('.flame');
++ if (flame) {
++ var mark = swapIn(flame, 'mx-navmark', navRef ? function () {
++ var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
++ s.setAttribute('class', 'mx-navmark'); s.setAttribute('viewBox', '0 0 100 100'); s.setAttribute('aria-hidden', 'true');
++ s.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'use'));
++ return s;
++ } : null);
++ if (mark) mark.firstChild.setAttribute('href', '#' + navRef);
++ }
++ var orb = bar.querySelector('.ovnav-orb');
++ var src = menu && document.getElementById(menu) && document.getElementById(menu).querySelector('.mb-nav .mb-btn svg');
++ if (orb) {
++ var box = swapIn(orb, 'mx-navmenu', src ? function () { var b = document.createElement('span'); b.className = 'mx-navmenu mb-btn'; return b; } : null);
++ if (box && box.getAttribute('data-code') !== menu) { box.innerHTML = src.outerHTML; box.setAttribute('data-code', menu); }
++ }
++ var name = brand.querySelector('.name');
++ var word = font && document.getElementById(font) && document.getElementById(font).querySelector('.fx-word');
++ if (name) name.style.fontFamily = word && word.style.fontFamily ? word.style.fontFamily : '';
++ // A short glow on what changed, so the eye goes to the bar.
++ var now = [navRef, menu, font].join('|');
++ if (navSeen && now !== navSeen) { brand.classList.remove('mx-nav-flash'); if (orb) orb.classList.remove('mx-nav-flash'); void brand.offsetWidth; brand.classList.add('mx-nav-flash'); if (orb && menu) orb.classList.add('mx-nav-flash'); }
++ navSeen = now;
++ }
++ (function watchNav() {
++ var tries = 0, obs = null;
++ function hook() {
++ var bar = document.querySelector('.openvibe-navbar');
++ if (!bar) { if (++tries < 40) setTimeout(hook, 250); return; }
++ navPreview();
++ if (obs || !window.MutationObserver) return;
++ // The navbar redraws its brand on sign-in and account changes: put the picks back when it does.
++ obs = new MutationObserver(function () {
++ if ((navRef && !bar.querySelector('.mx-navmark')) || (lastPick('M') && !bar.querySelector('.mx-navmenu'))) navPreview();
++ });
++ obs.observe(bar, { childList: true, subtree: true });
++ }
++ hook();
++ })();
// The last font picked dresses the name in the menu bars too, so the bar shows all three picks together.
function syncPickFont() {
var fonts = picks.filter(function (c) { return c[0] === 'F'; });
@@@ -157,40 +222,61 @@
function showTally(t) {
if (!tally || !t) return;
lastTally = t;
-- var loop = t.lean.loop, pair = t.lean.pair, sum = loop + pair, pct = sum ? Math.round((loop / sum) * 100) : 50;
++ var loop = t.lean.loop, pair = t.lean.pair, both = t.lean.both || 0, sum = loop + pair, pct = sum ? Math.round((loop / sum) * 100) : 50;
++ var ppl = function (n) { return n + (n === 1 ? ' person' : ' people'); };
tally.classList.toggle('is-empty', !t.people);
-- tally.querySelector('[data-mx-people]').textContent = t.people ? t.people + (t.people === 1 ? ' person' : ' people') : 'No votes yet. Tap any logo to vote.';
-- tally.querySelector('[data-mx-lead]').textContent = sum ? (loop === pair ? 'Tied' : loop > pair ? 'The Loop leads' : 'The Pair leads') : '';
++ tally.querySelector('[data-mx-people]').textContent = t.people ? ppl(t.people) : 'No votes yet. Tap any logo to vote.';
++ tally.querySelector('[data-mx-lead]').textContent = sum ? (loop === pair ? 'Even so far' : loop > pair ? 'More people like the Loop' : 'More people like the Pair') : '';
var split = tally.querySelector('[data-mx-split]');
split.hidden = !sum;
split.style.setProperty('--l', pct + '%');
-- tally.querySelector('[data-mx-loop]').textContent = pct + '%';
-- tally.querySelector('[data-mx-pair]').textContent = (100 - pct) + '%';
-- var marks = (t.top.L || []).concat(t.top.P || []).sort(function (a, b) { return b.n - a.n || (a.code < b.code ? -1 : 1); }).slice(0, 5);
-- [['[data-mx-tops]', marks], ['[data-mx-tops-m]', (t.top.M || []).slice(0, 3)], ['[data-mx-tops-f]', (t.top.F || []).slice(0, 3)]].forEach(function (pair2) {
-- var list = tally.querySelector(pair2[0]);
++ tally.querySelector('[data-mx-loop]').textContent = String(loop);
++ tally.querySelector('[data-mx-pair]').textContent = String(pair);
++ var bothEl = tally.querySelector('[data-mx-both]');
++ if (bothEl) { bothEl.hidden = !both; bothEl.textContent = ppl(both) + ' like both'; }
++ [['[data-mx-tops-l]', (t.top.L || []).slice(0, 4)], ['[data-mx-tops-p]', (t.top.P || []).slice(0, 4)], ['[data-mx-tops-m]', (t.top.M || []).slice(0, 3)], ['[data-mx-tops-f]', (t.top.F || []).slice(0, 3)]].forEach(function (row) {
++ var list = tally.querySelector(row[0]);
if (!list) return;
-- list.closest('.mx-tops').hidden = !pair2[1].length;
-- fill(list, pair2[1]);
++ list.closest('.mx-tops').hidden = !row[1].length;
++ fill(list, row[1]);
});
++ var every = t.all || [];
++ var box = tally.querySelector('[data-mx-all]');
++ if (box) {
++ box.hidden = !every.length;
++ box.querySelector('[data-mx-all-n]').textContent = every.length + (every.length === 1 ? ' take' : ' takes');
++ ['L', 'P', 'M', 'F'].forEach(function (k) {
++ var list = box.querySelector('[data-mx-all-' + k.toLowerCase() + ']');
++ if (!list) return;
++ var mine = every.filter(function (x) { return x.code[0] === k; });
++ list.closest('.mx-all-row').hidden = !mine.length;
++ if (box.open || !list.childElementCount || list.childElementCount !== mine.length) fill(list, mine);
++ });
++ }
}
// The tap shows at once: this person's vote is counted into the last tally on the page before the server answers
-- // (then the server's own count replaces it).
-- var before = null;
++ // (then the server's own count replaces it). Liking both families counts for both.
function countLocally(prev) {
if (!lastTally) return;
var t = JSON.parse(JSON.stringify(lastTally));
var had = prev.length > 0, has = picks.length > 0;
t.people = Math.max(0, t.people + (has && !had ? 1 : !has && had ? -1 : 0));
++ var bump = function (list, c, d) { var e = list.find(function (x) { return x.code === c; }); if (e) e.n += d; else if (d > 0) list.push({ code: c, n: d }); };
++ t.all = t.all || [];
['L', 'P', 'M', 'F'].forEach(function (k) {
var list = t.top[k] || (t.top[k] = []);
-- prev.filter(function (c) { return c[0] === k && picks.indexOf(c) === -1; }).forEach(function (c) { var e = list.find(function (x) { return x.code === c; }); if (e) e.n -= 1; });
-- picks.filter(function (c) { return c[0] === k && prev.indexOf(c) === -1; }).forEach(function (c) { var e = list.find(function (x) { return x.code === c; }); if (e) e.n += 1; else list.push({ code: c, n: 1 }); });
-- t.top[k] = list.filter(function (x) { return x.n > 0; }).sort(function (a, b) { return b.n - a.n; });
++ prev.filter(function (c) { return c[0] === k && picks.indexOf(c) === -1; }).forEach(function (c) { bump(list, c, -1); bump(t.all, c, -1); });
++ picks.filter(function (c) { return c[0] === k && prev.indexOf(c) === -1; }).forEach(function (c) { bump(list, c, 1); bump(t.all, c, 1); });
++ t.top[k] = list.filter(function (x) { return x.n > 0; }).sort(function (a, b) { return b.n - a.n || (a.code < b.code ? -1 : 1); });
});
-- var lean = function (arr) { var l = arr.filter(function (c) { return c[0] === 'L'; }).length, p = arr.filter(function (c) { return c[0] === 'P'; }).length; return l > p ? [1, 0] : p > l ? [0, 1] : l ? [0.5, 0.5] : [0, 0]; };
-- var a = lean(prev), b = lean(picks);
-- t.lean = { loop: Math.max(0, t.lean.loop - a[0] + b[0]), pair: Math.max(0, t.lean.pair - a[1] + b[1]) };
++ t.all = t.all.filter(function (x) { return x.n > 0; }).sort(function (a, b) { return b.n - a.n || (a.code < b.code ? -1 : 1); });
++ var likes = function (arr, k) { return arr.some(function (c) { return c[0] === k; }) ? 1 : 0; };
++ var l0 = likes(prev, 'L'), p0 = likes(prev, 'P'), l1 = likes(picks, 'L'), p1 = likes(picks, 'P');
++ t.lean = {
++ loop: Math.max(0, t.lean.loop - l0 + l1),
++ pair: Math.max(0, t.lean.pair - p0 + p1),
++ both: Math.max(0, (t.lean.both || 0) - (l0 && p0 ? 1 : 0) + (l1 && p1 ? 1 : 0)),
++ };
showTally(t);
}
var savedNote = root.querySelector('[data-mx-saved]');
ddiff --git a/server/brand-picks.js b/server/brand-picks.js
iindex 1e9bf40..a170128 100644
---- a/server/brand-picks.js
++++ b/server/brand-picks.js
@@@ -1,13 +1,14 @@
'use strict';
/**
* /brand/explore's picks, saved and counted (migrations/0005_brand_picks.sql). A person's picks replace their last
-- * ones; the tally says how many people picked, which way they lean (the Loop or the Pair: whichever they picked more
-- * of, a tie counting half each way), and the most-picked takes in each family.
++ * ones; the tally says how many people picked, how many like the Loop and how many the Pair (a person who picks takes
++ * from both counts for both: the owner, 11-01, "if they want a combination, that's fine… gauge all of it"), the
++ * most-picked takes in each family, and every take's count (to make the next round from what people chose).
*
* picks.voterOf(req, res) the voter: the signed-in subject, else 'anon:' + a hash of this browser's key
* picks.save(voter, codes, { signedIn }) replace their picks (up to 40 codes); returns the tally
* picks.mine(voter) their codes, sorted
-- * picks.tally() { people, lean: { loop, pair }, top: { L, P, M } } (top: [{ code, n }], at most 6)
++ * picks.tally() { people, lean: { loop, pair, both }, top: { L, P, M, F } (at most 8), all: [{ code, n }] }
*/
const crypto = require('crypto');
@@@ -45,14 +46,14 @@ function createBrandPicks({ db, now = () => Date.now() } = {}) {
async function tally() {
const counts = await db.many('SELECT code, count(*)::int AS n FROM brand_picks GROUP BY code ORDER BY n DESC, code');
-- const lean = await db.many(`SELECT count(*) FILTER (WHERE code LIKE 'L%')::int AS l, count(*) FILTER (WHERE code LIKE 'P%')::int AS p
++ const voters = await db.many(`SELECT count(*) FILTER (WHERE code LIKE 'L%')::int AS l, count(*) FILTER (WHERE code LIKE 'P%')::int AS p
FROM brand_picks GROUP BY voter`);
-- const people = lean.length;
-- let loop = 0, pair = 0;
-- for (const v of lean) { if (v.l > v.p) loop += 1; else if (v.p > v.l) pair += 1; else if (v.l) { loop += 0.5; pair += 0.5; } }
++ const people = voters.length;
++ let loop = 0, pair = 0, both = 0;
++ for (const v of voters) { if (v.l) loop += 1; if (v.p) pair += 1; if (v.l && v.p) both += 1; }
const top = { L: [], P: [], M: [], F: [] };
-- for (const r of counts) { const list = top[r.code[0]]; if (list && list.length < 6) list.push({ code: r.code, n: r.n }); }
-- return { people, lean: { loop, pair }, top };
++ for (const r of counts) { const list = top[r.code[0]]; if (list && list.length < 8) list.push({ code: r.code, n: r.n }); }
++ return { people, lean: { loop, pair, both }, top, all: counts.slice(0, 250).map((r) => ({ code: r.code, n: r.n })) };
}
return { voterOf, save, mine, tally, CODE, COOKIE, MAX };
ddiff --git a/server/views/marks-explore.js b/server/views/marks-explore.js
iindex 4498014..68dce74 100644
---- a/server/views/marks-explore.js
++++ b/server/views/marks-explore.js
@@@ -707,23 +707,29 @@ function miniArt(code, size = 26, tag = '') {
const chipOf = ({ code, n }) => `<span class="mx-top" data-code="${code}">${miniArt(code)}<b>${code}</b><i>${n}</i></span>`;
/** What people picked so far (server/brand-picks.js tally), at the top; marks.js redraws it after each save. */
function tallyBlock(t) {
-- // Clean (the owner, 11-01: "a mess… see which is the most popular easily… everyone else can too"): one line while
-- // there are no votes; then who leads, the Loop against the Pair as one labelled bar, and a short row of the top picks
-- // for each step that has votes. marks.js redraws it the moment a card is tapped.
++ // Every pick counts (the owner, 11-01: "I have the Pair selected and it doesn't show in the vote results… let them
++ // choose both… a combination is fine… so I can take the final results and generate more"): how many people like
++ // each family (someone who picks from both counts for both), the top takes of each family on its own row, and
++ // every take's count in a fold anyone can open. marks.js redraws it the moment a card is tapped.
const people = (t && t.people) || 0;
-- const loop = (t && t.lean && t.lean.loop) || 0, pair = (t && t.lean && t.lean.pair) || 0;
++ const loop = (t && t.lean && t.lean.loop) || 0, pair = (t && t.lean && t.lean.pair) || 0, both = (t && t.lean && t.lean.both) || 0;
const loopPct = loop + pair ? Math.round((loop / (loop + pair)) * 100) : 50;
-- const marks = t ? [...(t.top.L || []), ...(t.top.P || [])].sort((a, b) => b.n - a.n || (a.code < b.code ? -1 : 1)).slice(0, 5) : [];
-- const menus = t ? (t.top.M || []).slice(0, 3) : [];
-- const fonts = t ? (t.top.F || []).slice(0, 3) : [];
-- const lead = loop + pair ? (loop === pair ? 'Tied' : loop > pair ? 'The Loop leads' : 'The Pair leads') : '';
++ const fam = (k, n) => (t ? (t.top[k] || []).slice(0, n) : []);
++ const lead = loop + pair ? (loop === pair ? 'Even so far' : loop > pair ? 'More people like the Loop' : 'More people like the Pair') : '';
++ const ppl = (n) => `${n} ${n === 1 ? 'person' : 'people'}`;
const row = (label, attr, list) => `<div class="mx-tops"${list.length ? '' : ' hidden'}><span class="mx-tops-k">${label}</span><span class="mx-tops-list" ${attr}>${list.map(chipOf).join('')}</span></div>`;
++ const every = t && Array.isArray(t.all) ? t.all : [];
++ const allRow = (k, label) => { const list = every.filter((x) => x.code[0] === k); return `<div class="mx-all-row"${list.length ? '' : ' hidden'}><span class="mx-tops-k">${label}</span><span class="mx-all-list" data-mx-all-${k.toLowerCase()}>${list.map(chipOf).join('')}</span></div>`; };
return `<section class="mx-tally${people ? '' : ' is-empty'}" data-mx-tally aria-label="Votes so far" aria-live="polite">
--<div class="mx-tally-h"><b>Votes so far</b><span class="mx-tally-n" data-mx-people>${people ? `${people} ${people === 1 ? 'person' : 'people'}` : 'No votes yet. Tap any logo to vote.'}</span><span class="mx-tally-lead" data-mx-lead>${lead}</span></div>
--<div class="mx-split" data-mx-split style="--l:${loopPct}%"${loop + pair ? '' : ' hidden'}><span class="mx-split-l">${miniArt('L01', 18, 'e')}Loop <b data-mx-loop>${loopPct}%</b></span><span class="mx-split-r"><b data-mx-pair>${100 - loopPct}%</b> Pair${miniArt('P01', 18, 'e')}</span></div>
--${row('Logos', 'data-mx-tops', marks)}
--${row('Menu', 'data-mx-tops-m', menus)}
--${row('Font', 'data-mx-tops-f', fonts)}
++<div class="mx-tally-h"><b>Votes so far</b><span class="mx-tally-n" data-mx-people>${people ? ppl(people) : 'No votes yet. Tap any logo to vote.'}</span><span class="mx-tally-lead" data-mx-lead>${lead}</span></div>
++<div class="mx-split" data-mx-split style="--l:${loopPct}%"${loop + pair ? '' : ' hidden'}><span class="mx-split-l">${miniArt('L01', 18, 'e')}Loop <b data-mx-loop>${loop}</b></span><span class="mx-split-r"><b data-mx-pair>${pair}</b> Pair${miniArt('P01', 18, 'e')}</span></div>
++<p class="mx-both" data-mx-both${both ? '' : ' hidden'}>${ppl(both)} like both</p>
++${row('Loop', 'data-mx-tops-l', fam('L', 4))}
++${row('Pair', 'data-mx-tops-p', fam('P', 4))}
++${row('Menu', 'data-mx-tops-m', fam('M', 3))}
++${row('Font', 'data-mx-tops-f', fam('F', 3))}
++<details class="mx-all" data-mx-all${every.length ? '' : ' hidden'}><summary>Every vote <span data-mx-all-n>${every.length} take${every.length === 1 ? '' : 's'}</span></summary>
++${allRow('L', 'Loop')}${allRow('P', 'Pair')}${allRow('M', 'Menu')}${allRow('F', 'Font')}</details>
<div class="mx-mine" data-mx-mine hidden><span class="mx-tops-k">Yours</span><b data-mx-picks></b><button type="button" class="mx-btn" data-mx-link>Copy link</button><button type="button" class="mx-btn mx-btn-quiet" data-mx-clear>Clear</button><em class="mx-saved" data-mx-saved>saved</em></div>
</section>`;
}
@@@ -742,14 +748,14 @@ function explorePage({ tally = null } = {}) {
<header class="mx-hero">
<p class="mx-eye">OpenVibe logo vote</p>
<h1>Which logo should OpenVibe use?</h1>
--<p class="mx-lede">Tap the ones you like. Then pick a menu button and a font. Votes count right away.</p>
++<p class="mx-lede">Tap every logo you like, from both kinds if you want. Then pick a menu button and a font. Votes count right away.</p>
</header>
${tallyBlock(tally)}
<nav class="mx-choose" role="tablist" aria-label="What to choose">
<p class="mx-q" role="presentation"><span class="mx-qn">1</span> <b>Pick a logo</b></p>
<div class="mx-vs" role="presentation">
${tab('loop', 'The Loop', 'A V inside a ring', `<svg viewBox="0 0 100 100" width="58" height="58" aria-hidden="true"><defs>${loopEx.defs}</defs>${loopEx.body}</svg>`, `L01–L${LOOPS.length} · ${LOOPS.length} takes`, true)}
--<span class="mx-or" aria-hidden="true">or</span>
++<span class="mx-or" aria-hidden="true">+</span>
${tab('pair', 'The Pair', 'An O next to a V', `<svg viewBox="0 0 100 100" width="58" height="58" aria-hidden="true"><defs>${pairEx.defs}</defs>${pairEx.body}</svg>`, `P01–P${PAIRS.length} · ${PAIRS.length} takes`)}
</div>
<p class="mx-q" role="presentation"><span class="mx-qn">2</span> <b>Pick a menu button</b> <small>it sits next to the logo</small></p>
ddiff --git a/test/brand-explore.test.js b/test/brand-explore.test.js
iindex 8e8d7df..b94f442 100644
---- a/test/brand-explore.test.js
++++ b/test/brand-explore.test.js
@@@ -35,8 +35,8 @@ const explore = require('../server/views/marks-explore');
assert.match(r.text, new RegExp(`<section class="mx-panel" id="${tab}" role="tabpanel"`));
}
assert.match(r.text, /<noscript><style>\.mx-panel\[hidden\]\{display:block!important\}<\/style><\/noscript>/, 'without the script every tab shows');
-- assert.match(r.text, /<h1>Which logo should OpenVibe use\?<\/h1>\n<p class="mx-lede">Tap the ones you like\. Then pick a menu button and a font\. Votes count right away\.<\/p>/, 'the top says it simply');
-- assert.match(r.text, /<span class="mx-qn">1<\/span> <b>Pick a logo<\/b>[\s\S]*?<span class="mx-or" aria-hidden="true">or<\/span>[\s\S]*?<span class="mx-qn">2<\/span> <b>Pick a menu button<\/b>[\s\S]*?<span class="mx-qn">3<\/span> <b>Pick a font<\/b>/, 'three steps: a logo, a menu button, a font');
++ assert.match(r.text, /<h1>Which logo should OpenVibe use\?<\/h1>\n<p class="mx-lede">Tap every logo you like, from both kinds if you want\. Then pick a menu button and a font\. Votes count right away\.<\/p>/, 'the top says it simply');
++ assert.match(r.text, /<span class="mx-qn">1<\/span> <b>Pick a logo<\/b>[\s\S]*?<span class="mx-or" aria-hidden="true">\+<\/span>[\s\S]*?<span class="mx-qn">2<\/span> <b>Pick a menu button<\/b>[\s\S]*?<span class="mx-qn">3<\/span> <b>Pick a font<\/b>/, 'three steps: a logo, a menu button, a font');
assert.ok((r.text.match(/<section class="mx-row">/g) || []).length >= 15, 'sorted into rows by idea');
assert.match(r.text, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');
assert.match(r.text, /data-mx-mine hidden/, 'your picks wait for a pick');
@@@ -77,14 +77,25 @@ const explore = require('../server/views/marks-explore');
const ana = t.network.addUser('ana');
const signed = (await t.get('/api/v1/brand/picks', { method: 'PUT', as: ana, json: { picks: ['P01', 'P02', 'M07'] }, origin: own })).json();
assert.strictEqual(signed.tally.people, 2);
-- assert.deepStrictEqual(signed.tally.lean, { loop: 1, pair: 1 });
++ assert.deepStrictEqual(signed.tally.lean, { loop: 1, pair: 1, both: 0 });
++ // Picking from both families counts for both (the owner, 11-01: "a combination is fine… gauge all of it").
++ const bea = t.network.addUser('bea');
++ const mixed = (await t.get('/api/v1/brand/picks', { method: 'PUT', as: bea, json: { picks: ['L03', 'L40', 'P02'] }, origin: own })).json();
++ assert.deepStrictEqual(mixed.tally.lean, { loop: 2, pair: 2, both: 1 }, 'two Loop takes and one Pair take: one person for each, and one who likes both');
++ assert.deepStrictEqual(mixed.tally.top.P.map((x) => [x.code, x.n]), [['P02', 2], ['P01', 1]], 'the Pair take shows in the results');
++ assert.deepStrictEqual(mixed.tally.all.map((x) => x.code).sort(), ['L03', 'L40', 'M07', 'P01', 'P02'], 'every take that has a vote, for the next round');
assert.deepStrictEqual(signed.tally.top.P.map((x) => x.code), ['P01', 'P02']);
assert.deepStrictEqual(signed.tally.top.M, [{ code: 'M07', n: 1 }]);
assert.deepStrictEqual((await t.get('/api/v1/brand/picks', { as: ana })).json().mine, ['M07', 'P01', 'P02'], 'a person sees their own picks');
assert.strictEqual((await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L01'] }, origin: 'https://evil.example' })).status, 403, 'another site cannot cast a ballot');
assert.strictEqual((await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L01'] } })).status, 403, 'nor a request that says nothing of where it came from');
const page = await t.get('/brand/explore');
-- assert.match(page.text, /<section class="mx-tally" data-mx-tally[\s\S]*?2 people<\/span>[\s\S]*?data-code="P01"/, 'the tally is at the top of the page');
++ assert.match(page.text, /<section class="mx-tally" data-mx-tally[\s\S]*?3 people<\/span>[\s\S]*?data-code="P0[12]"/, 'the tally is at the top of the page');
++ assert.match(page.text, /<p class="mx-both" data-mx-both>1 person like both<\/p>/);
++ assert.match(page.text, /<span class="mx-tops-k">Loop<\/span><span class="mx-tops-list" data-mx-tops-l>[\s\S]*?<span class="mx-tops-k">Pair<\/span><span class="mx-tops-list" data-mx-tops-p>/, 'the Loop and the Pair on rows of their own');
++ assert.match(page.text, /<details class="mx-all" data-mx-all><summary>Every vote <span data-mx-all-n>5 takes<\/span>/, 'every vote, open to anyone');
++ const js = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'js', 'marks.js'), 'utf8');
++ assert.match(js, /function navPreview\(\)/, 'the site\'s own bar wears the picks while the page is open');
});
await check('the steps bar and "where next" connect the two choices', async () => {