openvibers/openvibe.design Commits

/brand/explore: votes show the moment you tap; a clean tally (#65)

OP OpenVibers committed 12 days ago · 85f2f7a9b8 · 3 files, +65 −21

The owner, 11-01: "clicking should update the vote immediately… see which is the most popular easily… in a more
clean way". A tap counts into the tally on the page at once (the server's count replaces it a quarter second later).
The tally: one line while empty; who leads, the Loop against the Pair as one labelled bar, and short rows of the top
logos, menu buttons and fonts, each row only once it has votes.

Changes

ddiff --git a/public/css/marks.css b/public/css/marks.css
iindex 786b619..e3b75dd 100644
---- a/public/css/marks.css
++++ b/public/css/marks.css
@@@ -390,3 +390,15 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
.mx-nudge span{flex:1;min-width:0}
.mx-nudge .mx-btn{flex:none;padding:5px 10px}
}
++
++/* The tally, clean: one line while empty; a labelled split bar; short rows of the top picks. */
++.mx-tally{gap:8px}
++.mx-tally.is-empty{padding:10px 12px}
++.mx-tally-n{color:var(--text-secondary,#b4bfcc);font-size:14px}
++.mx-split{position:relative;display:flex;justify-content:space-between;align-items:center;height:30px;padding:0 10px;border-radius:999px;overflow:hidden;background:#8b5cf6;color:#fff;font-size:13px;font-weight:700;isolation:isolate}
++.mx-split::before{content:"";position:absolute;inset:0 auto 0 0;width:var(--l,50%);background:#4f7cff;z-index:-1;transition:width .5s cubic-bezier(.2,.8,.2,1)}
++.mx-split span{display:inline-flex;align-items:center;gap:5px}
++.mx-split b{font-family:var(--mx-face);font-weight:900}
++.mx-split svg{filter:drop-shadow(0 0 2px rgba(0,0,0,.35))}
++.mx-tops-k{width:56px}
++@media (max-width:640px){.mx-tops-k{width:44px}}
ddiff --git a/public/js/marks.js b/public/js/marks.js
iindex d2157a2..379a5de 100644
---- a/public/js/marks.js
++++ b/public/js/marks.js
@@@ -93,13 +93,15 @@
}
var saveTimer = 0;
function toggle(code) {
++ var prev = picks.slice();
var i = picks.indexOf(code);
if (i === -1) picks.push(code); else picks.splice(i, 1);
render();
var card = document.getElementById(code);
if (card && i === -1 && !still()) { card.classList.remove('just-picked'); void card.offsetWidth; card.classList.add('just-picked'); setTimeout(function () { card.classList.remove('just-picked'); }, 500); }
if (i === -1 && typeof offerStep2 === 'function') offerStep2(code);
-- clearTimeout(saveTimer); saveTimer = setTimeout(sync, 700);
++ countLocally(prev);
++ clearTimeout(saveTimer); saveTimer = setTimeout(sync, 250);
}
function say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }
function copy(text, btn) {
@@@ -143,19 +145,45 @@
if (!items.length) { var em = document.createElement('em'); em.textContent = 'none yet'; list.appendChild(em); return; }
items.forEach(function (t) { var c = chip(t); if (!before[t.code]) c.classList.add('is-new'); list.appendChild(c); });
}
++ var lastTally = null;
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;
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. Be the first.';
-- tally.querySelector('[data-mx-lead]').textContent = sum ? (loop === pair ? 'Level so far' : loop > pair ? 'The Loop leads' : 'The Pair leads') : 'No picks yet';
-- tally.querySelector('.mx-lean').style.setProperty('--l', pct + '%');
-- tally.querySelector('[data-mx-loop]').textContent = sum ? pct + '%' : '–';
-- tally.querySelector('[data-mx-pair]').textContent = sum ? (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, 6);
-- fill(tally.querySelector('[data-mx-tops]'), marks);
-- fill(tally.querySelector('[data-mx-tops-m]'), (t.top.M || []).slice(0, 4));
-- fill(tally.querySelector('[data-mx-tops-f]'), (t.top.F || []).slice(0, 4));
++ 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') : '';
++ 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]);
++ if (!list) return;
++ list.closest('.mx-tops').hidden = !pair2[1].length;
++ fill(list, pair2[1]);
++ });
++ }
++ // 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;
++ 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));
++ ['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; });
++ });
++ 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]) };
++ showTally(t);
}
var savedNote = root.querySelector('[data-mx-saved]');
function sync() {
ddiff --git a/server/views/marks-explore.js b/server/views/marks-explore.js
iindex ab773c0..4498014 100644
---- a/server/views/marks-explore.js
++++ b/server/views/marks-explore.js
@@@ -707,20 +707,24 @@ 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.
const people = (t && t.people) || 0;
const loop = (t && t.lean && t.lean.loop) || 0, pair = (t && t.lean && t.lean.pair) || 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, 6) : [];
-- const menus = t ? (t.top.M || []).slice(0, 4) : [];
-- const fonts = t ? (t.top.F || []).slice(0, 4) : [];
-- const lead = loop + pair ? (loop === pair ? 'Level so far' : loop > pair ? 'The Loop leads' : 'The Pair leads') : 'No picks yet';
-- return `<section class="mx-tally${people ? '' : ' is-empty'}" data-mx-tally aria-label="What people picked so far">
--<div class="mx-tally-h"><b>Votes so far</b><span data-mx-people>${people ? `${people} ${people === 1 ? 'person' : 'people'}` : 'No votes yet. Be the first.'}</span><span class="mx-tally-lead" data-mx-lead>${lead}</span></div>
--<div class="mx-lean" style="--l:${loopPct}%"><span class="mx-lean-end">${miniArt('L01', 24, 'e')}<span>Loop</span><b data-mx-loop>${loop + pair ? `${loopPct}%` : '–'}</b></span><span class="mx-lean-bar" aria-hidden="true"><i></i></span><span class="mx-lean-end mx-lean-r"><b data-mx-pair>${loop + pair ? `${100 - loopPct}%` : '–'}</b><span>Pair</span>${miniArt('P01', 24, 'e')}</span></div>
--<div class="mx-tops"><span class="mx-tops-k">Top logos</span><span class="mx-tops-list" data-mx-tops>${marks.map(chipOf).join('') || '<em>none yet</em>'}</span></div>
--<div class="mx-mine" data-mx-mine hidden><span class="mx-tops-k">Your votes</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-tops"><span class="mx-tops-k">Menu button</span><span class="mx-tops-list" data-mx-tops-m>${menus.map(chipOf).join('') || '<em>none yet</em>'}</span></div>
--<div class="mx-tops"><span class="mx-tops-k">Font</span><span class="mx-tops-list" data-mx-tops-f>${fonts.map(chipOf).join('') || '<em>none yet</em>'}</span></div>
++ 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 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>`;
++ 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-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>`;
}