openvibers/openvibe.design Commits
/brand/explore: the bar wears your picks live; every pick counts, both kinds at once (#67)
Changes
@@@ -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}}@@@ -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]');@@@ -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 pFROM 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 };@@@ -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>@@@ -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 () => {
@@ -407,3 +407,23 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
@@ -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). */
/* 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)}
.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}}
@@ -73,7 +73,7 @@
@@ -73,7 +73,7 @@
var code = marks.length ? marks[marks.length - 1] : null;
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); });
all('.mx-tab-side, .mx-step-2, .mx-step-3', root).forEach(function (el) { el.classList.toggle('is-later', !code); });
syncPickFont();
syncPickFont();
- if (code === shownMark) return;
+ if (code === shownMark) { navPreview(); return; }
shownMark = code;
shownMark = code;
var defs = root.querySelector('.mx-defs');
var defs = root.querySelector('.mx-defs');
var old = defs && defs.querySelector('[data-chosen]');
var old = defs && defs.querySelector('[data-chosen]');
@@ -89,8 +89,73 @@
@@ -89,8 +89,73 @@
ref = 'art-' + code + tag;
ref = 'art-' + code + tag;
}
}
all('.mb-nav use, .fx-mark use', root).forEach(function (u) { u.setAttribute('href', '#' + ref); });
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.'; });
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.
// The last font picked dresses the name in the menu bars too, so the bar shows all three picks together.
function syncPickFont() {
function syncPickFont() {
var fonts = picks.filter(function (c) { return c[0] === 'F'; });
var fonts = picks.filter(function (c) { return c[0] === 'F'; });
@@ -157,40 +222,61 @@
@@ -157,40 +222,61 @@
function showTally(t) {
function showTally(t) {
if (!tally || !t) return;
if (!tally || !t) return;
lastTally = t;
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.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]');
var split = tally.querySelector('[data-mx-split]');
split.hidden = !sum;
split.hidden = !sum;
split.style.setProperty('--l', pct + '%');
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;
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
// 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) {
function countLocally(prev) {
if (!lastTally) return;
if (!lastTally) return;
var t = JSON.parse(JSON.stringify(lastTally));
var t = JSON.parse(JSON.stringify(lastTally));
var had = prev.length > 0, has = picks.length > 0;
var had = prev.length > 0, has = picks.length > 0;
t.people = Math.max(0, t.people + (has && !had ? 1 : !has && had ? -1 : 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) {
['L', 'P', 'M', 'F'].forEach(function (k) {
var list = t.top[k] || (t.top[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);
showTally(t);
}
}
var savedNote = root.querySelector('[data-mx-saved]');
var savedNote = root.querySelector('[data-mx-saved]');
@@ -1,13 +1,14 @@
@@ -1,13 +1,14 @@
'use strict';
'use strict';
/**
/**
* /brand/explore's picks, saved and counted (migrations/0005_brand_picks.sql). A person's picks replace their last
* /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.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.save(voter, codes, { signedIn }) replace their picks (up to 40 codes); returns the tally
* picks.mine(voter) their codes, sorted
* 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');
const crypto = require('crypto');
@@ -45,14 +46,14 @@ function createBrandPicks({ db, now = () => Date.now() } = {}) {
@@ -45,14 +46,14 @@ function createBrandPicks({ db, now = () => Date.now() } = {}) {
async function tally() {
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 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`);
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: [] };
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 };
return { voterOf, save, mine, tally, CODE, COOKIE, MAX };
@@ -707,23 +707,29 @@ function miniArt(code, size = 26, tag = '') {
@@ -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>`;
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. */
/** What people picked so far (server/brand-picks.js tally), at the top; marks.js redraws it after each save. */
function tallyBlock(t) {
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 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 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 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">
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>
<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>`;
</section>`;
}
}
@@ -742,14 +748,14 @@ function explorePage({ tally = null } = {}) {
@@ -742,14 +748,14 @@ function explorePage({ tally = null } = {}) {
<header class="mx-hero">
<header class="mx-hero">
<p class="mx-eye">OpenVibe logo vote</p>
<p class="mx-eye">OpenVibe logo vote</p>
<h1>Which logo should OpenVibe use?</h1>
<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>
</header>
${tallyBlock(tally)}
${tallyBlock(tally)}
<nav class="mx-choose" role="tablist" aria-label="What to choose">
<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>
<p class="mx-q" role="presentation"><span class="mx-qn">1</span> <b>Pick a logo</b></p>
<div class="mx-vs" role="presentation">
<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)}
${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`)}
${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>
</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>
<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>
@@ -35,8 +35,8 @@ const explore = require('../server/views/marks-explore');
@@ -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, 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, /<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.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, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');
assert.match(r.text, /data-mx-mine hidden/, 'your picks wait for a pick');
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');
@@ -77,14 +77,25 @@ const explore = require('../server/views/marks-explore');
const ana = t.network.addUser('ana');
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();
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.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.P.map((x) => x.code), ['P01', 'P02']);
assert.deepStrictEqual(signed.tally.top.M, [{ code: 'M07', n: 1 }]);
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.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'] }, 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');
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');
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 () => {
await check('the steps bar and "where next" connect the two choices', async () => {