openvibers/openvibe.design Commits

/brand/explore: three simple steps, real menu buttons, more logos, fonts saved and counted (#64)

OP OpenVibers committed 12 days ago · c1162b4c54 · 6 files, +487 −184

The owner's notes, 11-01 night:
- simple words: "Which logo should OpenVibe use? Tap the ones you like. Then pick a menu button and a font";
- tighter boxes, and each section opens with Step N of 3;
- a numbered steps bar with ticks; a nudge to the next step after each pick; a thank-you at the end;
- steps 2 and 3 wait softly for step 1, and show the logo you picked;
- no per-site picker: one example name, OpenVibe.Chat; on a phone the menu mock is just the logo and the button;
- the font is back as step 3: twelve faces in three families, each setting OpenVibe beside your logo, with
  OpenVibe.Live, .Video and .Pics; votes saved and counted in the tally;
- the fifty menu ideas are all menu buttons now (diamonds, dot grids, launcher tiles, menu lines, three dots,
  framed), no moons or magnifiers;
- L01 is the plain Loop (the dot above read as a status badge); ten more takes on each logo, and twelve
  fundamentally different Loops (Outside the box: circled V, notch, round V, woven, split disc, drop, O of Vs,
  target, cup, pick, echo, weighted ring).

brand_picks takes the new codes (migration 0006, expand: L01–L99, P, M and F).

Changes

ddiff --git a/migrations/0006_brand_picks_more_codes.sql b/migrations/0006_brand_picks_more_codes.sql
nnew file mode 100644
iindex 0000000..f42e563
---- /dev/null
++++ b/migrations/0006_brand_picks_more_codes.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- /brand/explore grew (the owner, 2026-11-01): more takes (L01–L72, P01–P60) and a third step, the name's font
++-- (F01–F12). The code check widens to any family letter and 01–99; every code already stored still passes it.
++
++ALTER TABLE brand_picks DROP CONSTRAINT IF EXISTS brand_picks_code_check;
++ALTER TABLE brand_picks ADD CONSTRAINT brand_picks_code_check CHECK (code ~ '^[LPMF](0[1-9]|[1-9][0-9])$');
ddiff --git a/public/css/marks.css b/public/css/marks.css
iindex e937766..786b619 100644
---- a/public/css/marks.css
++++ b/public/css/marks.css
@@@ -317,3 +317,76 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
.mx-wrap[data-density=sheet] .mx-grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px}
}
.mx-panel{scroll-margin-top:calc(var(--ovnav-real-h,56px) + 64px)}
++/* Two steps, numbered in the steps bar; after the first mark is picked, step 2 calls once and a nudge says where it is
++ (the owner, 11-01: "they don't realise they have to scroll to the top to switch to the menu button"). */
++.mx-stepn{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:50%;background:#8b5cf6;color:#fff;font-size:11.5px;font-weight:800}
++.mx-steps .mx-steps-sep{width:auto;height:auto;background:none;color:var(--mx-muted);font-weight:800;margin:0 2px}
++.mx-step.is-call{animation:mxCall 1.4s cubic-bezier(.2,.9,.3,1) 2}
++@keyframes mxCall{0%,100%{box-shadow:0 0 0 0 rgba(139,92,246,0)}40%{box-shadow:0 0 0 6px rgba(139,92,246,.45)}}
++.mx-sheet{border:0;background:transparent;color:inherit;cursor:pointer;padding:3px 9px}
++.mx-sheet[aria-pressed=true]{background:#8b5cf6;color:#fff}
++.mx-nudge{position:fixed;left:50%;bottom:calc(16px + var(--ovnav-appbar-h,0px) + env(safe-area-inset-bottom,0px));transform:translateX(-50%);z-index:60;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px;width:max-content;max-width:calc(100vw - 20px);padding:10px 12px;border-radius:16px;border:1px solid #8b5cf6;background:color-mix(in srgb,var(--mx-card) 95%,transparent);backdrop-filter:blur(10px);box-shadow:0 14px 34px rgba(0,0,0,.4);font-size:14px;animation:mxNudgeIn .4s cubic-bezier(.2,.9,.25,1.1)}
++.mx-nudge a.mx-btn{text-decoration:none;display:inline-grid;place-items:center}
++@keyframes mxNudgeIn{from{opacity:0;transform:translate(-50%,12px)}to{opacity:1;transform:translateX(-50%)}}
++@media (prefers-reduced-motion:reduce){.mx-step.is-call,.mx-nudge{animation:none}}
++@media (max-width:640px){.mx-steps .mx-stepn{width:18px;height:18px;font-size:10.5px}.mx-steps .mx-steps-sep{display:none}}
++
++/* The menu buttons beside the chosen mark; on a phone the mock bar is just the mark and the button, to compare at a
++ glance (the name cut off before). Step 2 waits softly until a mark is picked. */
++.mb-with{margin:6px 0 2px;padding:8px 12px;border-radius:12px;background:color-mix(in srgb,#8b5cf6 12%,var(--mx-card));font-size:13.5px;color:var(--text-secondary,#b4bfcc)}
++.mx-tab-side.is-later{opacity:.7}
++.mx-tab-side.is-later .mx-tab-t em::after{content:" · after step 1";color:#c4b5fd}
++.mx-step-2.is-later{opacity:.6}
++@media (max-width:640px){
++ .mb-nav{justify-content:space-between;padding:6px 8px}
++ .mb-nav .mx-word{display:none}
++ .mb-nav>svg{width:26px;height:26px}
++ .mb-nav .mb-btn{width:38px;height:38px}
++ .mb-nav .mb-btn svg{width:24px;height:24px}
++}
++
++/* Simpler and tighter (the owner, 11-01: "way too much… explain it in simple words… compact"). */
++.mx-hero{gap:6px;margin-bottom:12px}
++.mx-hero h1{font-size:clamp(24px,3.6vw,38px)}
++.mx-lede{font-size:15px}
++.mx-tally{gap:6px;padding:9px 11px}
++.mx-choose{gap:6px}
++.mx-q{margin-top:2px}
++.mx-tab{padding:10px 12px}
++.mx-tab-t small{font-size:12.5px}
++.mx-stephead{display:flex;align-items:center;gap:10px;margin:6px 0 4px;padding:10px 12px;border-radius:14px;background:var(--mx-card);border:1px solid var(--mx-line)}
++.mx-stephead b{display:block;font-family:var(--mx-face);font-weight:900;font-size:17px}
++.mx-stephead small{display:block;font-size:13px;color:var(--text-secondary,#b4bfcc)}
++.mx-stepn-lg{width:30px;height:30px;font-size:15px}
++.mx-row{margin-top:12px}
++.mx-next{margin-top:16px}
++.mx-next a,.mx-next button{padding:11px 14px}
++.mx-next-share{display:grid;gap:2px;border-radius:16px;border:1px solid #8b5cf6;background:color-mix(in srgb,#8b5cf6 14%,var(--mx-card));color:inherit;font:inherit;text-align:left;cursor:pointer}
++.mx-next b{font-size:16px}
++/* Step 3: the name in each face, beside the chosen logo. */
++.fx-stage{aspect-ratio:auto;min-height:96px;padding:14px 10px}
++.fx-lock{display:flex;align-items:center;gap:10px;min-width:0}
++.fx-word{font-size:clamp(22px,2.4vw,28px);letter-spacing:-.01em;white-space:nowrap;color:#f2f5fb}
++.fx-light{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:10px;background:#f4f6fb;color:#0f1419;font-size:17px;overflow:hidden;white-space:nowrap}
++.fx-sample{margin:0 2px;font-size:14px;color:var(--text-secondary,#b4bfcc)}
++.fx-mini{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:50%;background:#0d1322;color:#e8edf3;font-weight:800;font-size:12px}
++.fx-tabart{font-family:'OV Brand',Nunito,sans-serif;font-weight:900;font-size:20px}
++.mx-tab-side .mx-tab-art .fx-tabart{color:#e8edf3}
++.mx-step-3.is-later{opacity:.6}
++.mx-step.is-done i::after{content:" \2713"}
++@media (max-width:640px){.mx-tab-side .mx-tab-t small{display:none}.fx-word{font-size:22px}.mx-stephead{padding:8px 10px}.mx-stephead b{font-size:15.5px}}
++.fx-site{background:linear-gradient(90deg,#3b6cff,#7c3aed);-webkit-background-clip:text;background-clip:text;color:transparent}
++.fx-sample{line-height:1.5;overflow-wrap:anywhere}
++/* Phone: the tally's rows on one line each; the font rows wider so the whole name fits; the nudge on one line. */
++@media (max-width:640px){
++ .mx-tops{flex-direction:row;align-items:center;gap:8px}
++ .mx-tops-k{width:64px;font-size:10px}
++ .mx-tally-lead{margin-left:0}
++ .mx-wrap:not([data-density=sheet]) #font .mx-grid{grid-auto-columns:80%}
++ .fx-word{font-size:20px}
++ .fx-light{font-size:15px}
++ .fx-sample{font-size:13px}
++ .mx-nudge{flex-wrap:nowrap;gap:6px;padding:8px 8px 8px 12px;font-size:13px;width:calc(100vw - 20px)}
++ .mx-nudge span{flex:1;min-width:0}
++ .mx-nudge .mx-btn{flex:none;padding:5px 10px}
++}
ddiff --git a/public/js/marks.js b/public/js/marks.js
iindex 18b74c9..d2157a2 100644
---- a/public/js/marks.js
++++ b/public/js/marks.js
@@@ -5,8 +5,8 @@
(function () {
'use strict';
var KEY = 'ov_mark_picks', VIEW = 'ov_mark_view', TAB = 'ov_mark_tab';
-- var CODE = /^[LPM]\d{2}$/;
-- var TABS = { L: 'loop', P: 'pair', M: 'menu' };
++ var CODE = /^[LPMF]\d{2}$/;
++ var TABS = { L: 'loop', P: 'pair', M: 'menu', F: 'font' };
var API = '/api/v1/brand/picks';
function load(key, fallback) { try { var v = JSON.parse(localStorage.getItem(key) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } }
function store(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* a private window keeps it for the page only */ } }
@@@ -59,10 +59,38 @@
if (card) card.classList.toggle('is-picked', on);
});
if (mineBox) { mineBox.hidden = !picks.length; mineBox.querySelector('[data-mx-picks]').textContent = order(picks).join(', '); }
-- all('[data-mx-count]').forEach(function (el) { var k = el.getAttribute('data-mx-count'); var n = picks.filter(function (c) { return c[0] === k; }).length; el.textContent = n ? String(n) : ''; });
++ syncMenuMark();
++ all('[data-mx-count]').forEach(function (el) { var k = el.getAttribute('data-mx-count'); var n = picks.filter(function (c) { return c[0] === k; }).length; el.textContent = n ? String(n) : ''; var st = el.closest('.mx-step'); if (st) st.classList.toggle('is-done', n > 0); });
store(KEY, picks);
if (history.replaceState && (picks.length || /picks=/.test(location.hash))) history.replaceState(null, '', picks.length ? '#picks=' + order(picks).join(',') : location.pathname);
}
++ // The menu buttons sit beside the mark this person picked last in step 1 (or today's mark until they pick one),
++ // so they judge each button where it will live (the owner, 11-01). A copy with its own ids, in the page's visible
++ // defs, so it paints whichever tab is open.
++ var shownMark = null;
++ function syncMenuMark() {
++ var marks = picks.filter(function (c) { return c[0] === 'L' || c[0] === 'P'; });
++ var code = marks.length ? marks[marks.length - 1] : null;
++ var withEl = root.querySelector('[data-mb-with]');
++ all('.mx-tab-side, .mx-step-2, .mx-step-3', root).forEach(function (el) { el.classList.toggle('is-later', !code); });
++ if (code === shownMark) return;
++ shownMark = code;
++ var defs = root.querySelector('.mx-defs');
++ var old = defs && defs.querySelector('[data-chosen]');
++ if (old) old.parentNode.removeChild(old);
++ var ref = 'art-pair-today';
++ var src = code && document.getElementById(code) && document.getElementById(code).querySelector('.mx-stage svg');
++ if (src && defs) {
++ var tag = '-ch' + (++copies);
++ var g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
++ g.setAttribute('data-chosen', code);
++ g.innerHTML = src.innerHTML.replace(/ id="([^"]+)"/g, ' id="$1' + tag + '"').replace(/url\(#([^)]+)\)/g, 'url(#$1' + tag + ')').replace(/href="#([^"]+)"/g, 'href="#$1' + tag + '"');
++ defs.appendChild(g);
++ ref = 'art-' + code + tag;
++ }
++ all('.mb-nav use, .fx-mark use', root).forEach(function (u) { u.setAttribute('href', '#' + ref); });
++ 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.'; });
++ }
var saveTimer = 0;
function toggle(code) {
var i = picks.indexOf(code);
@@@ -70,6 +98,7 @@
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);
}
function say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }
@@@ -85,15 +114,22 @@
if (p.length) mark.push('the Pair (' + p.join(', ') + ')');
if (mark.length) parts.push('The mark: ' + mark.join(', ') + '.');
if (m.length) parts.push('The menu button: ' + m.join(', ') + '.');
++ var fo = of('F'); if (fo.length) parts.push('The font: ' + fo.join(', ') + '.');
return 'My picks. ' + parts.join(' ') + ' ' + link();
}
// ── The tally at the top: saved picks, counted (server/brand-picks.js) ──
var tally = root.querySelector('[data-mx-tally]');
++ function fontChip(code) {
++ var card = document.getElementById(code), word = card && card.querySelector('.fx-word');
++ var el = document.createElement('span'); el.className = 'fx-mini'; el.textContent = 'Aa';
++ if (word) { el.style.fontFamily = word.style.fontFamily; el.style.fontWeight = word.style.fontWeight; }
++ return el;
++ }
function chip(t) {
var el = document.createElement('span');
el.className = 'mx-top'; el.setAttribute('data-code', t.code);
-- var art = artOf(t.code, 26);
++ var art = t.code[0] === 'F' ? fontChip(t.code) : artOf(t.code, 26);
if (art) { if (t.code[0] === 'M') art.classList.add('mx-mini-m'); el.appendChild(art); }
var b = document.createElement('b'); b.textContent = t.code; el.appendChild(b);
var n = document.createElement('i'); n.textContent = String(t.n); el.appendChild(n);
@@@ -111,7 +147,7 @@
if (!tally || !t) return;
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 has picked' : ' people have picked') : 'Nobody has picked yet. Be the first.';
++ 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 + '%' : '–';
@@@ -119,6 +155,7 @@
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));
}
var savedNote = root.querySelector('[data-mx-saved]');
function sync() {
@@@ -161,7 +198,12 @@
store(TAB, name);
rails();
}
++ var fontsIn = false;
++ function loadFonts() { if (fontsIn) return; fontsIn = true; var css = root.getAttribute('data-mx-fontcss'); if (!css) return; var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = css; document.head.appendChild(l); }
++ (window.requestIdleCallback || function (f) { return setTimeout(f, 2500); })(loadFonts);
function go(name) {
++ if (name === 'font') loadFonts();
++ var nd = root.querySelector('[data-mx-nudge]'); if (nd) nd.hidden = true;
show(name);
if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + name);
var p = panelOf(name);
@@@ -188,12 +230,12 @@
if ('IntersectionObserver' in window && choose) new IntersectionObserver(function (en) { steps.classList.toggle('is-on', !en[0].isIntersecting && en[0].boundingClientRect.top < 0); }).observe(choose);
}
var h = (location.hash || '').slice(1);
-- var start = /^(loop|pair|menu)$/.test(h) ? h : CODE.test(h) ? TABS[h[0]] : picks.length ? TABS[order(picks)[0][0]] : load(TAB, 'loop');
-- show(/^(loop|pair|menu)$/.test(start) ? start : 'loop');
++ var start = /^(loop|pair|menu|font)$/.test(h) ? h : CODE.test(h) ? TABS[h[0]] : picks.length ? TABS[order(picks)[0][0]] : load(TAB, 'loop');
++ show(/^(loop|pair|menu|font)$/.test(start) ? start : 'loop');
// ── Each tab's example walks through all fifty, so it is plain there are fifty ──
(function cycle() {
-- var boxes = all('[data-mx-cycle]', root);
++ var boxes = all('[data-mx-cycle]', root).filter(function (b) { return b.getAttribute('data-mx-cycle') !== 'font'; });
if (!boxes.length) return;
var state = boxes.map(function (box, k) {
var name = box.getAttribute('data-mx-cycle');
@@@ -248,57 +290,43 @@
}
rails();
-- // ── The bar: the name, its font and style, the view ──
-- var tools = root.querySelector('[data-mx-tools]');
++ // ── The view: cards, or Sheet (all at once), from the steps bar ──
var view = load(VIEW, {}) || {};
-- var fontsLoaded = false;
-- function loadFonts() {
-- if (fontsLoaded) return;
-- fontsLoaded = true;
-- var l = document.createElement('link');
-- l.rel = 'stylesheet';
-- l.href = root.getAttribute('data-mx-fontcss');
-- document.head.appendChild(l);
-- }
-- function press(attr, value) { all('[' + attr + ']', tools).forEach(function (b) { b.setAttribute('aria-pressed', b.getAttribute(attr) === String(value) ? 'true' : 'false'); }); }
-- var own = tools.querySelector('[data-mx-own]');
-- function setSite(name, typed) {
-- var text = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 14) || 'Live';
-- all('[data-mx-site]').forEach(function (s) { s.textContent = text; });
-- press('data-mx-set-site', typed ? '' : text);
-- if (!typed) own.value = '';
-- view.site = text; view.typed = !!typed;
++ function setDensity(on) {
++ if (on) root.setAttribute('data-density', 'sheet'); else root.removeAttribute('data-density');
++ all('[data-mx-density]', root).forEach(function (b) { b.setAttribute('aria-pressed', on ? 'true' : 'false'); });
++ view.sheet = !!on; store(VIEW, view); rails();
}
-- function setFont(key) {
-- var chipEl = tools.querySelector('[data-mx-font="' + key + '"]') || tools.querySelector('[data-mx-font]');
-- if (chipEl.getAttribute('data-mx-font') !== 'nunito') loadFonts();
-- root.style.setProperty('--mx-lockface', chipEl.getAttribute('data-face'));
-- press('data-mx-font', chipEl.getAttribute('data-mx-font'));
-- view.font = chipEl.getAttribute('data-mx-font');
-- }
-- function setStyle(key) { key = /^(dot|space|stack)$/.test(key) ? key : 'dot'; root.setAttribute('data-lock', key); press('data-mx-lockstyle', key); view.lock = key; }
-- function setDensity(on) { if (on) root.setAttribute('data-density', 'sheet'); else root.removeAttribute('data-density'); press('data-mx-density', on ? 'sheet' : ''); view.sheet = !!on; rails(); }
-- function setStill(on) { root.classList.toggle('mx-still', !!on); press('data-mx-still', on ? '1' : ''); view.still = !!on; }
-- setSite(view.site, view.typed);
-- if (view.typed) own.value = view.site || '';
-- setFont(view.font || 'nunito');
-- setStyle(view.lock);
setDensity(view.sheet);
-- setStill(view.still);
-- tools.hidden = false;
-- tools.querySelector('[data-mx-fonts]').addEventListener('pointerenter', loadFonts);
-- tools.querySelector('[data-mx-fonts]').addEventListener('focusin', loadFonts);
-- own.addEventListener('input', function () { setSite(own.value || 'Live', !!own.value); store(VIEW, view); });
-- tools.addEventListener('click', function (e) {
-- var b = e.target.closest('button');
-- if (!b) return;
-- if (b.hasAttribute('data-mx-set-site')) setSite(b.getAttribute('data-mx-set-site'));
-- else if (b.hasAttribute('data-mx-font')) setFont(b.getAttribute('data-mx-font'));
-- else if (b.hasAttribute('data-mx-lockstyle')) setStyle(b.getAttribute('data-mx-lockstyle'));
-- else if (b.hasAttribute('data-mx-density')) setDensity(!view.sheet);
-- else if (b.hasAttribute('data-mx-still')) setStill(!view.still);
-- store(VIEW, view);
-- });
++ root.addEventListener('click', function (e) { var b = e.target.closest('[data-mx-density]'); if (b) { e.preventDefault(); setDensity(!view.sheet); } });
++
++ // ── Step 2 is not forgotten: after the first mark is picked, a nudge to the menu button (once a visit) ──
++ var nudge = root.querySelector('[data-mx-nudge]');
++ function offerStep2(code) {
++ if (!nudge) return;
++ var has = function (k) { return picks.some(function (c) { return c[0] === k; }); };
++ var key, text, to, label;
++ if ((code[0] === 'L' || code[0] === 'P') && !has('M')) { key = '2'; text = code + ' voted. Next: pick a menu button.'; to = 'menu'; label = 'Step 2 \u2192'; }
++ else if (code[0] === 'M' && !has('F')) { key = '3'; text = code + ' voted. Last: pick a font.'; to = 'font'; label = 'Step 3 \u2192'; }
++ else if (code[0] === 'F' && (has('L') || has('P')) && has('M')) { key = 'done'; text = 'All three done. Thank you!'; to = null; label = 'Share'; }
++ else return;
++ var seen = {};
++ try { seen = JSON.parse(sessionStorage.getItem('ov_mark_nudged') || '{}') || {}; } catch (e) { seen = {}; }
++ if (seen[key]) return;
++ seen[key] = 1;
++ try { sessionStorage.setItem('ov_mark_nudged', JSON.stringify(seen)); } catch (e) { /* */ }
++ nudge.querySelector('[data-mx-nudge-text]').textContent = text;
++ var go2 = nudge.querySelector('[data-mx-nudge-go]');
++ go2.textContent = label;
++ if (to) { go2.setAttribute('href', '#' + to); go2.setAttribute('data-mx-go', to); go2.removeAttribute('data-mx-share'); }
++ else { go2.removeAttribute('data-mx-go'); go2.setAttribute('href', '#'); go2.setAttribute('data-mx-share', ''); }
++ nudge.hidden = false;
++ clearTimeout(nudge._t);
++ nudge._t = setTimeout(function () { nudge.hidden = true; }, 9000);
++ var step = steps && to && steps.querySelector('[data-mx-go="' + to + '"]');
++ if (step && !still()) { step.classList.remove('is-call'); void step.offsetWidth; step.classList.add('is-call'); }
++ }
++ if (nudge) nudge.addEventListener('click', function (e) { if (e.target.closest('[data-mx-nudge-close]') || e.target.closest('[data-mx-go]') || e.target.closest('[data-mx-share]')) nudge.hidden = true; });
// ── The menu buttons: press to open, and each makes its move when it arrives ──
function play(btn) {
@@@ -321,6 +349,7 @@
if (p) { toggle(p.getAttribute('data-pick')); return; }
var share = e.target.closest('[data-mx-share]');
if (share) {
++ e.preventDefault();
var text = picks.length ? summary() : link();
var done = function () { share.classList.add('is-done'); setTimeout(function () { share.classList.remove('is-done'); }, 1400); };
if (navigator.share && /Mobi|Android/i.test(navigator.userAgent || '')) navigator.share({ title: 'My picks for the OpenVibe mark', text: text.replace(/ https?:\S+$/, ''), url: link() }).then(done, function () { /* closed */ });
ddiff --git a/server/brand-picks.js b/server/brand-picks.js
iindex 5de2832..1e9bf40 100644
---- a/server/brand-picks.js
++++ b/server/brand-picks.js
@@@ -11,7 +11,7 @@
*/
const crypto = require('crypto');
--const CODE = /^[LPM](0[1-9]|[1-4][0-9]|50)$/;
++const CODE = /^[LPMF](0[1-9]|[1-9][0-9])$/;
const COOKIE = 'ov_mx_voter';
const MAX = 40;
@@@ -50,7 +50,7 @@ function createBrandPicks({ db, now = () => Date.now() } = {}) {
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 top = { L: [], P: [], M: [] };
++ 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 };
}
ddiff --git a/server/views/marks-explore.js b/server/views/marks-explore.js
iindex 0b64c66..ab773c0 100644
---- a/server/views/marks-explore.js
++++ b/server/views/marks-explore.js
@@@ -8,8 +8,8 @@
* font right… nice across all devices"; fifty ideas for the menu button (the nine-dot diamond beside the name); and
* "the page is really long… on mobile a pain".
*
-- * L01–L50 the Loop: a ring opened at the top with the V folded into it
-- * P01–P50 the Pair: a ring O and a V side by side, the ground showing where they cross
++ * L01–L72 the Loop: a ring opened at the top with the V folded into it
++ * P01–P60 the Pair: a ring O and a V side by side, the ground showing where they cross
* M01–M50 the menu button: search and every OpenVibe site, beside the name on every site
*
* Each tab is sorted into rows by idea (proportion, shape, construction, colour, app icon…), so a row shows what
@@@ -110,6 +110,7 @@ function masker(state) {
// segments (the ring in three arcs), tipdot, half (the lower half filled). Part classes: mx-a the ring, mx-b the V,
// mx-dot a dot; mx-s strokes (drawn in), mx-f fills (faded in).
function loopArt(s, id) {
++ if (s.special) return specialArt(s, id);
const shape = s.shape || 'circle', r = s.r || 31, sw = s.sw || 13, open = s.open || 32, vsw = s.vsw || sw;
const cx = 50, cy = 50 + (s.dot === 'above' ? 4 : 0);
const left = boundary(shape, cx, cy, r, 270 - open), right = boundary(shape, cx, cy, r, -90 + open);
@@@ -165,6 +166,95 @@ function loopArt(s, id) {
return { defs: st.defs, body: treat(p, body) };
}
++
++// ── Outside the box (the owner, 2026-11-01: "the single combined thing… variations that are different fundamentally…
++// the O a full circle and the V just part of it… be creative"). One mark each, built its own way, still centred on
++// (50, 50) and level. s.special names the construction; s.paint its treatment, as for every Loop.
++function specialArt(s, id) {
++ const p = paint(s.paint, id);
++ const st = { defs: p.defs };
++ const mask = masker(st);
++ const S = (d, color, w, part, extra = '') => `<path class="mx-s mx-${part}" d="${d}" pathLength="100" fill="none" stroke="${color}" stroke-width="${f(w)}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
++ const F = (d, color, part, extra = '') => `<path class="mx-f mx-${part}" d="${d}" fill="${color}"${extra}/>`;
++ const circle = (cx, cy, r) => `M${f(cx)} ${f(cy - r)}A${f(r)} ${f(r)} 0 1 1 ${f(cx)} ${f(cy + r)}A${f(r)} ${f(r)} 0 1 1 ${f(cx)} ${f(cy - r)}Z`;
++ const cut = (d, w, extra = '') => `<path d="${d}" fill="none" stroke="#000" stroke-width="${f(w)}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
++ let body = '';
++ switch (s.special) {
++ case 'circled': // a full O with the V inside it, touching nothing
++ body = S(circle(50, 50, 36), p.a, 9, 'a') + S('M34 36L50 64L66 36', p.b, 11, 'b');
++ break;
++ case 'notch': { // a solid disc with a V bitten out of its top
++ const m = mask(`${id}m`, '<path d="M24 -2L50 56L76 -2Z" fill="#000"/>');
++ body = F(circle(50, 52, 39), p.a, 'a', ` mask="${m}"`);
++ break;
++ }
++ case 'roundv': // the V's arms are an O cut in half: round, meeting in a point
++ body = S('M18 24C18 60 34 78 50 84C66 78 82 60 82 24', p.a, 13, 'a');
++ break;
++ case 'woven': { // the V over the ring on the left, under it on the right
++ st.defs += `<clipPath id="${id}l"><rect x="-10" y="-10" width="60" height="120"/></clipPath><clipPath id="${id}r"><rect x="50" y="-10" width="60" height="120"/></clipPath>`;
++ const V = 'M22 16L50 82L78 16';
++ const ring = circle(50, 52, 30);
++ const ringMask = mask(`${id}m`, `<g clip-path="url(#${id}l)">${cut(V, 21)}</g>`);
++ const vMask = mask(`${id}n`, `<g clip-path="url(#${id}r)">${cut(ring, 20)}</g>`);
++ body = `<g mask="${ringMask}">${S(ring, p.a, 11, 'a')}</g><g mask="${vMask}">${S(V, p.b, 11, 'b')}</g>`;
++ break;
++ }
++ case 'split': { // a disc whose top wedge, a V, wears the second colour
++ st.defs += `<clipPath id="${id}c"><path d="${circle(50, 50, 40)}"/></clipPath>`;
++ body = F(circle(50, 50, 40), p.a, 'a') + `<g clip-path="url(#${id}c)">${F('M20 0L50 60L80 0Z', p.b, 'b')}</g>` + S('M20 0L50 60L80 0', '#ffffff', 3, 'b', ' opacity=".55" clip-path="url(#' + id + 'c)"');
++ break;
++ }
++ case 'drop': // the O sits on the V's point: a drop, a pin, a balloon
++ body = S(circle(50, 36, 19), p.a, 10, 'a') + S('M33 46L50 86L67 46', p.b, 10, 'b');
++ break;
++ case 'ovs': { // an O made of small Vs, all pointing to the middle
++ const n = 9;
++ for (let i = 0; i < n; i++) {
++ const a = (-90 + (360 / n) * i) * Math.PI / 180;
++ const cx = 50 + 31 * Math.cos(a), cy = 50 + 31 * Math.sin(a);
++ const tip = [cx - 7 * Math.cos(a), cy - 7 * Math.sin(a)];
++ const side = (k) => [cx + 3 * Math.cos(a) + 7 * k * -Math.sin(a), cy + 3 * Math.sin(a) + 7 * k * Math.cos(a)];
++ const l = side(1), r = side(-1);
++ body += S(`M${f(l[0])} ${f(l[1])}L${f(tip[0])} ${f(tip[1])}L${f(r[0])} ${f(r[1])}`, i === 0 ? p.b : p.a, 5.5, i === 0 ? 'b' : 'a');
++ }
++ break;
++ }
++ case 'target': // a dot in a ring, the V cut across both
++ {
++ const V = 'M30 18L50 62L70 18';
++ const m = mask(`${id}m`, cut(V, 18));
++ body = `<g mask="${m}">${S(circle(50, 52, 33), p.a, 9, 'a')}<circle class="mx-f mx-a" cx="50" cy="66" r="7" fill="${p.a}"/></g>` + S(V, p.b, 9, 'b');
++ }
++ break;
++ case 'cup': // a bowl holding the V
++ body = S('M16 46A34 34 0 0 0 84 46', p.a, 11, 'a') + S('M30 20L50 58L70 20', p.b, 11, 'b');
++ break;
++ case 'pick': { // a guitar pick (a rounded V) with an O cut through it
++ const pick = 'M50 88C41 80 15 52 15 34C15 20 30 13 50 13C70 13 85 20 85 34C85 52 59 80 50 88Z';
++ const m = mask(`${id}m`, `<path d="${circle(50, 38, 13)}" fill="#000"/>`);
++ body = F(pick, p.a, 'a', ` mask="${m}"`);
++ break;
++ }
++ case 'echo': // the Loop, and an outer arc echoing it, like sound
++ {
++ const arc = (r, open) => { const a1 = (270 - open) * Math.PI / 180, a2 = (-90 + open) * Math.PI / 180; return `M${f(50 + r * Math.cos(a1))} ${f(54 + r * Math.sin(a1))}A${r} ${r} 0 1 0 ${f(50 + r * Math.cos(a2))} ${f(54 + r * Math.sin(a2))}`; };
++ const l = [50 + 23 * Math.cos((270 - 34) * Math.PI / 180), 54 + 23 * Math.sin((270 - 34) * Math.PI / 180)];
++ const r = [50 + 23 * Math.cos((-90 + 34) * Math.PI / 180), 54 + 23 * Math.sin((-90 + 34) * Math.PI / 180)];
++ body = S(`${arc(23, 34)}L50 56Z`.replace(/^M/, 'M'), p.a, 10, 'a') + S(arc(39, 42), p.b, 6, 'b', ' opacity=".8"');
++ void l; void r;
++ }
++ break;
++ case 'weighted': { // a ring heavy at the bottom and light at the top, cradling a V
++ body = F(`${circle(50, 52, 39)}${circle(50, 45, 31)}`, p.a, 'a', ' fill-rule="evenodd"') + S('M36 24L50 54L64 24', p.b, 10, 'b');
++ break;
++ }
++ default:
++ body = S(circle(50, 50, 34), p.a, 10, 'a');
++ }
++ return { defs: st.defs, body: treat(p, body) };
++}
++
// ── The Pair ─────────────────────────────────────────────────────────────────────────────────────────────────────
// openvibe-shared/mark.js's pair(): the O's radius r and stroke sw, the V's stroke vsw; the V's left cap a, tip t and
// right cap b as multiples of the O's outer radius from its centre; the whole centred on (50, 50). Constructions: gap
@@@ -213,7 +303,7 @@ function pairArt(s, id) {
// [name, one line, spec]. A row says what changes in it; no two marks share shape, construction and treatment.
const LOOP_ROWS = [
{ title: 'Weight and proportion', line: 'how heavy the line is, how wide it opens, how deep the V goes', items: [
-- ['Today', 'the Loop as the sites drew it, dot above', { r: 30, sw: 12, open: 33, tip: 2, dot: 'above', paint: 'sweep' }],
++ ['The Loop', 'the baseline: one ring, one V, nothing added', { r: 31, sw: 13, open: 32, tip: 2, paint: 'sweep' }],
['Refined', 'a heavier stroke, the V to the middle', { r: 32, sw: 15, open: 30, tip: 0, paint: 'sweep' }],
['Two colours', 'the ring blue, the V violet, the ground between', { r: 32, sw: 14, open: 31, tip: 1, mode: 'split', paint: 'duo' }],
['Bold', 'thick and short: the strongest at 16 px', { r: 30, sw: 19, open: 30, tip: -6, paint: 'vertical' }],
@@@ -274,6 +364,32 @@ const LOOP_ROWS = [
['Floating tile', 'a small V in the ring, on a night tile', { r: 25, sw: 9, open: 28, tip: 3, vsw: 8, mode: 'float', paint: 'tile-night' }],
['Orbit tile', 'the moon dot on a gradient tile', { r: 25, sw: 10, open: 31, tip: 1, dot: 'orbit', paint: 'tile' }],
] },
++ { title: 'Ten more takes', line: 'more of the main idea, in other shapes and constructions', items: [
++ ['Squircle, apart', 'a soft-square ring and a free V', { shape: 'squircle', r: 32, sw: 12, open: 30, tip: 4, mode: 'apart', paint: 'duo' }],
++ ['Oval outline', 'a tall, hollow Loop', { shape: 'oval', r: 34, sw: 14, open: 34, tip: 8, mode: 'outline', paint: 'vertical' }],
++ ['Square coin', 'an app-shaped coin with the V cut through', { shape: 'rsquare', r: 31, sw: 10, open: 28, tip: 3, vsw: 11, mode: 'coin', paint: 'duo-rev' }],
++ ['Octagon, tip dot', 'a badge whose V ends in a dot', { shape: 'oct', r: 33, sw: 12, open: 26, tip: 6, mode: 'tipdot', paint: 'sweep' }],
++ ['Hex, crossing', 'six sides, the arms run past', { shape: 'hex', r: 33, sw: 11, open: 30, tip: 4, mode: 'cross', paint: 'aurora' }],
++ ['Squircle in arcs', 'a soft square in three pieces', { shape: 'squircle', r: 32, sw: 12, open: 28, tip: 2, mode: 'segments', paint: 'duo' }],
++ ['Oval of dots', 'a tall dotted ring and a solid V', { shape: 'oval', r: 34, sw: 9, open: 32, tip: 8, vsw: 12, mode: 'dashed', paint: 'vertical' }],
++ ['Small V, square ring', 'a small V inside an app-shaped ring', { shape: 'rsquare', r: 31, sw: 11, open: 28, tip: 3, vsw: 9, mode: 'float', paint: 'duo' }],
++ ['Coin ring, aurora', 'a filled centre and a thin ring, three colours', { r: 31, sw: 12, open: 32, tip: 3, mode: 'coin-ring', paint: 'aurora' }],
++ ['Deep and wide', 'a wide opening and a deep V', { r: 32, sw: 13, open: 44, tip: 16, paint: 'ember' }],
++ ] },
++ { title: 'Outside the box', line: 'the O and the V as one mark, built a different way each time', items: [
++ ['Circled V', 'a full O with the V inside it', { special: 'circled', paint: 'duo' }],
++ ['Notch', 'a solid disc with a V bitten from its top', { special: 'notch', paint: 'sweep' }],
++ ['Round V', 'the V\'s arms curve, an O cut in half', { special: 'roundv', paint: 'vertical' }],
++ ['Woven', 'the V over the ring on one side, under it on the other', { special: 'woven', paint: 'duo' }],
++ ['Split disc', 'a disc with a V-shaped wedge in the second colour', { special: 'split', paint: 'duo' }],
++ ['Drop', 'the O sitting on the V\'s point', { special: 'drop', paint: 'duo-rev' }],
++ ['O of Vs', 'a ring made of small Vs, all pointing in', { special: 'ovs', paint: 'sweep' }],
++ ['Target', 'a dot and a ring, the V cut through both', { special: 'target', paint: 'duo' }],
++ ['Cup', 'a bowl holding the V', { special: 'cup', paint: 'aurora' }],
++ ['Pick', 'a guitar pick, a rounded V, with an O through it', { special: 'pick', paint: 'ember' }],
++ ['Echo', 'the Loop and an arc echoing it, like sound', { special: 'echo', paint: 'duo' }],
++ ['Weighted ring', 'heavy at the bottom, light at the top, cradling the V', { special: 'weighted', paint: 'depth' }],
++ ] },
];
const PAIR_ROWS = [
{ title: 'Weight and spacing', line: 'how heavy, and how the V crosses the O', items: [
@@@ -338,6 +454,18 @@ const PAIR_ROWS = [
['Centre dot tile', 'a dot in the O, white on a tile', { mode: 'dot', g: { r: 15, sw: 10, vsw: 10 }, paint: 'tile' }],
['Outline tile', 'contours in white on a tile', { mode: 'outline', g: { r: 15, sw: 11, vsw: 11 }, paint: 'tile' }],
] },
++ { title: 'Ten more takes', line: 'more of the main idea, in other shapes and constructions', items: [
++ ['Squircle, solid V', 'a soft-square O and a filled V', { shape: 'squircle', mode: 'solid-v', paint: 'duo-rev' }],
++ ['Hex, double', 'a six-sided O as two thin rings', { shape: 'hex', mode: 'double', g: { r: 21, sw: 13, vsw: 12 }, paint: 'duo' }],
++ ['Tall eye', 'an oval solid O with a small opening', { shape: 'oval', mode: 'eye', g: { r: 21, sw: 12, vsw: 12, a: 0.78, t: 1.38, b: 1.98 }, paint: 'vertical' }],
++ ['Square outline', 'an app-shaped O, both hollow', { shape: 'rsquare', mode: 'outline', g: { r: 19, sw: 15, vsw: 15 }, paint: 'sweep' }],
++ ['Octagon, centre dot', 'a badge O with a dot in it', { shape: 'oct', mode: 'dot', g: { r: 21, sw: 11 }, paint: 'duo' }],
++ ['Heavy, no gap', 'thick strokes laid straight over', { mode: 'over', g: { r: 18, sw: 18, vsw: 17 }, paint: 'vertical' }],
++ ['Squircle, behind', 'a soft-square O in front of the V', { shape: 'squircle', mode: 'behind', paint: 'depth' }],
++ ['Hex inline', 'six sides with a hairline in each stroke', { shape: 'hex', mode: 'inline', g: { r: 21, sw: 15, vsw: 15 }, paint: 'vertical' }],
++ ['Tall, dotted', 'an oval O in dots and a solid V', { shape: 'oval', mode: 'dashed', g: { r: 21, sw: 10, vsw: 13, a: 0.78, t: 1.38, b: 1.98 }, paint: 'duo' }],
++ ['Light, side by side', 'thin lines, the V beside the O', { g: { r: 21, sw: 8, vsw: 8, a: 1.12, t: 1.72, b: 2.32 }, halo: 6, paint: 'aurora' }],
++ ] },
];
// ── Fifty menu buttons ───────────────────────────────────────────────────────────────────────────────────────────
@@@ -370,88 +498,95 @@ const grid9 = (rc, re, rk) => [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1]
const LOOP_ICON = `M${f(12 + 7.4 * Math.cos(rad(237)))} ${f(12.6 + 7.4 * Math.sin(rad(237)))}A7.4 7.4 0 1 0 ${f(12 + 7.4 * Math.cos(rad(-57)))} ${f(12.6 + 7.4 * Math.sin(rad(-57)))}L12 14Z`;
// [name, one line, move, own open state, parts]
++// Every one of them is a menu button (the owner, 11-01: "this is supposed to be a menu button… they're voting on just
++// the menu button: variations of the menu button, that's it"): the diamond it is today, dot grids, launcher tiles, menu
++// lines, three dots and framed ones. A dot grid of any size, centred on (12, 12): cols × rows at `gap`, dot radius r.
++const gridDots = (cols, rows, gap, r, lit = -1, extra = '') => {
++ let out = '', k = 0;
++ const x0 = 12 - ((cols - 1) * gap) / 2, y0 = 12 - ((rows - 1) * gap) / 2;
++ for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++, k++) out += dot(x0 + x * gap, y0 + y * gap, r, `${k === lit ? ' mb-a' : ''}${extra}`, Math.round(Math.hypot(x - (cols - 1) / 2, y - (rows - 1) / 2)));
++ return out;
++};
++const tiles = (n, size, gap, rx, lit = -1, kind = 'box') => {
++ let out = '', k = 0;
++ const x0 = 12 - (n * size + (n - 1) * gap) / 2;
++ for (let y = 0; y < n; y++) for (let x = 0; x < n; x++, k++) {
++ const px = x0 + x * (size + gap), py = x0 + y * (size + gap);
++ out += kind === 'frame' ? frame(f(px), f(py), size, size, rx, k === lit ? ' mb-sa' : '', k, 1.8)
++ : kind === 'circle' ? dot(px + size / 2, py + size / 2, size / 2, k === lit ? ' mb-a' : '', k)
++ : box(f(px), f(py), size, size, rx, k === lit ? ' mb-a' : '', k);
++ }
++ return out;
++};
++const rot = (deg, inner, sc = 1) => `<g transform="translate(12 12) rotate(${deg}) scale(${sc}) translate(-12 -12)">${inner}</g>`;
++const LINES = (ys, x1, x2, k = '', w = 2.4) => ys.map((y, i) => line(`M${x1} ${y}H${x2}`, typeof k === 'function' ? k(i) : k, i, w)).join('');
++
const MENU_ROWS = [
-- { title: 'Dots', line: 'the family the button has today', items: [
++ { title: 'The diamond', line: 'the button as it is today, and its close relatives', items: [
['Today', 'nine dots as a diamond; open, an ×', 'ripple', 'diamond', `<g class="mb-g">${grid9(2.5, 2.15, 2.15)}</g>`],
-- ['Launcher grid', 'nine dots square, like an app launcher', 'ripple', 'grid', `<g class="mb-g">${grid9(2.2, 2, 2)}</g>`],
-- ['Four dots', 'two by two, one lit', 'wave', '', [[8, 8], [16, 8], [8, 16], [16, 16]].map(([x, y], i) => dot(x, y, 2.6, i === 1 ? ' mb-a' : '', i)).join('')],
-- ['Three, stacked', 'three dots in a column', 'wave', '', [5, 12, 19].map((y, i) => dot(12, y, 2.3, i === 1 ? ' mb-a' : '', i)).join('')],
-- ['Three in a row', 'three dots across', 'wave', '', [5, 12, 19].map((x, i) => dot(x, 12, 2.3, i === 1 ? ' mb-a' : '', i)).join('')],
-- ['Plus of dots', 'five dots as a plus; open, it turns to an ×', 'ripple', 'plus', `<g class="mb-g">${dot(12, 12, 2.5, ' mb-a', 0)}${[[12, 5], [19, 12], [12, 19], [5, 12]].map(([x, y]) => dot(x, y, 2.15, '', 1)).join('')}</g>`],
-- ['Ring of six', 'six dots round a lit centre', 'ripple', '', dot(12, 12, 2.4, ' mb-a', 0) + [0, 1, 2, 3, 4, 5].map((k) => { const [x, y] = polar(7.6, -90 + k * 60); return dot(x, y, 1.9, '', 1); }).join('')],
++ ['Five-dot diamond', 'four points and a lit centre; open, an ×', 'ripple', 'plus', `<g class="mb-g">${dot(12, 12, 2.5, ' mb-a', 0)}${[[12, 5], [19, 12], [12, 19], [5, 12]].map(([x, y]) => dot(x, y, 2.2, '', 1)).join('')}</g>`],
++ ['Big diamond', 'sixteen small dots, set on their corner', 'ripple', '', rot(45, gridDots(4, 4, 4.4, 1.45, 5), 0.92)],
++ ['Lit centre', 'the diamond with a bigger, lit middle', 'ripple', 'diamond', `<g class="mb-g">${grid9(3.1, 1.8, 1.8)}</g>`],
++ ['Diamond of squares', 'nine small squares on their corner', 'ripple', '', rot(45, [5, 12, 19].map((y) => [5, 12, 19].map((x) => box(x - 2, y - 2, 4, 4, 1.2, x === 12 && y === 12 ? ' mb-a' : '', Math.abs(x - 12) / 7 + Math.abs(y - 12) / 7)).join('')).join(''), 0.8)],
++ ['Diamond outline', 'a diamond drawn round a lit dot', 'pop', '', line('M12 3.5 20.5 12 12 20.5 3.5 12Z', '', 0, 2.1) + dot(12, 12, 2.4, ' mb-a', 1)],
++ ['Four points', 'four dots on their corner, no middle', 'ripple', '', [[12, 5.5], [18.5, 12], [12, 18.5], [5.5, 12]].map(([x, y], k) => dot(x, y, 2.5, k === 0 ? ' mb-a' : '', k)).join('')],
++ ['Lit corners', 'the diamond with its four tips lit', 'ripple', 'diamond', `<g class="mb-g">${[[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]].map(([x, y, n]) => dot(x, y, 2.1, ` mb-r${n}${n === 2 ? ' mb-a' : ''}`, n)).join('')}</g>`],
++ ['Diamond in a ring', 'the nine dots inside a circle', 'ripple', '', ring(12, 12, 10, ' mb-faint', 0, 1.6) + rot(45, gridDots(3, 3, 5, 1.55, 4), 0.95)],
++ ['Wide diamond', 'nine dots with more air', 'ripple', 'diamond', `<g class="mb-g">${grid9(2.2, 1.9, 1.9)}</g>`],
++ ] },
++ { title: 'Dot grids', line: 'the app launcher, in dots', items: [
++ ['Launcher grid', 'nine dots square; open, an ×', 'ripple', 'grid', `<g class="mb-g">${grid9(2.2, 2, 2)}</g>`],
++ ['Four dots', 'two by two', 'wave', '', gridDots(2, 2, 8, 2.7, 1)],
++ ['Six dots', 'two across, three down', 'wave', '', gridDots(2, 3, 6.6, 2.2, 3)],
++ ['Small grid, lit centre', 'nine small dots, the middle one lit', 'ripple', '', gridDots(3, 3, 6.4, 1.7, 4)],
++ ['Lit corner', 'nine dots, the top right one lit', 'ripple', '', gridDots(3, 3, 7, 2.1, 2)],
['Honeycomb', 'seven dots packed close', 'bloom', '', dot(12, 12, 2.5, ' mb-a', 0) + [0, 1, 2, 3, 4, 5].map((k) => { const [x, y] = polar(6.4, -90 + k * 60); return dot(x, y, 2.5, '', k + 1); }).join('')],
-- ['Triangle of three', 'three dots, the top one lit', 'pop', '', dot(12, 5.5, 2.7, ' mb-a', 0) + dot(18.6, 17, 2.7, '', 1) + dot(5.4, 17, 2.7, '', 2)],
-- ['V of dots', 'five dots making the V', 'wave', '', [[4.5, 7], [8.2, 12.2], [12, 17.5], [15.8, 12.2], [19.5, 7]].map(([x, y], i) => dot(x, y, 2, i === 2 ? ' mb-a' : '', i)).join('')],
['Dots by size', 'nine dots, biggest in the middle', 'ripple', 'grid', `<g class="mb-g">${grid9(3, 2, 1.3)}</g>`],
-- ['Constellation', 'five stars joined by faint lines', 'twinkle', '', line('M5 8L10 5L14 11L19 7M14 11L16 18', ' mb-faint', 0, 1) + [[5, 8, 1.7], [10, 5, 1.9], [14, 11, 2.4], [19, 7, 1.7], [16, 18, 2]].map(([x, y, r], i) => dot(x, y, r, i === 2 ? ' mb-a' : '', i)).join('')],
++ ['Wide grid', 'three across, two down', 'wave', '', gridDots(3, 2, 7, 2.2, 1)],
++ ['Grid of rings', 'nine small rings', 'ripple', '', [0, 1, 2].map((y) => [0, 1, 2].map((x) => ring(5 + x * 7, 5 + y * 7, 1.9, x === 1 && y === 1 ? ' mb-sa' : '', Math.abs(x - 1) + Math.abs(y - 1), 1.6)).join('')).join('')],
++ ['Dense grid', 'sixteen tiny dots', 'ripple', '', gridDots(4, 4, 5, 1.35, 5)],
] },
-- { title: 'Tiles and shapes', line: 'squares, circles and petals', items: [
-- ['Four tiles', 'two by two rounded squares, one lit', 'bloom', '', [[4, 4], [13, 4], [4, 13], [13, 13]].map(([x, y], i) => box(x, y, 7, 7, 2, i === 1 ? ' mb-a' : '', i)).join('')],
-- ['O, V, square, dot', 'four little shapes, the V lit', 'bloom', '', ring(7.6, 7.6, 3.2, '', 0, 2.2) + line('M13.6 5.2L16.5 10L19.4 5.2', ' mb-sa', 1, 2.2) + box(4.4, 13.4, 6.4, 6.4, 1.6, '', 2) + dot(16.5, 16.6, 3.1, '', 3)],
++ { title: 'Launcher tiles', line: 'every site as a tile', items: [
++ ['Four tiles', 'two by two rounded squares, one lit', 'bloom', '', tiles(2, 7, 2, 2, 1)],
++ ['Four circles', 'two by two discs', 'bloom', '', tiles(2, 7, 2, 0, 1, 'circle')],
++ ['Nine tiles', 'three by three small squares', 'bloom', '', tiles(3, 4.6, 1.8, 1.2, 4)],
++ ['Soft tiles', 'two by two very round squares', 'bloom', '', tiles(2, 7.4, 1.8, 3.2, 0)],
['Bento', 'one tall tile and two small', 'bloom', '', box(4, 4, 7, 16, 2.2, ' mb-a', 0) + box(13, 4, 7, 7, 2.2, '', 1) + box(13, 13, 7, 7, 2.2, '', 2)],
-- ['Stack of cards', 'three cards that fan out', 'fan', '', frame(8.5, 3.5, 12, 9, 2.4, ' mb-faint', 0) + frame(6.2, 6.8, 12, 9, 2.4, '', '1;--dx:-.8px;--dy:.8px') + box(3.5, 10.5, 12, 9.5, 2.4, ' mb-a', '2;--dx:-1.8px;--dy:1.8px')],
-- ['Two circles', 'two rings overlapping', 'fan', '', ring(9, 12, 5.4, '', '0;--dx:-1.6px') + ring(15, 12, 5.4, ' mb-sa', '1;--dx:1.6px')],
-- ['Clover', 'four round petals', 'bloom', '', [[12, 7.4], [16.6, 12], [12, 16.6], [7.4, 12]].map(([x, y], i) => dot(x, y, 4, i === 0 ? ' mb-a' : ' mb-soft', i)).join('')],
-- ['Pinwheel', 'four quarter circles', 'tilt', '', fill('M11 11H4A7 7 0 0 1 11 4Z', ' mb-a', 0) + fill('M13 11V4A7 7 0 0 1 20 11Z', '', 1) + fill('M13 13H20A7 7 0 0 1 13 20Z', '', 2) + fill('M11 13V20A7 7 0 0 1 4 13Z', '', 3)],
-- ['Diamond and dot', 'a diamond outline round a lit dot', 'pop', '', line('M12 3.5L20.5 12L12 20.5L3.5 12Z', '', 0, 2.1) + dot(12, 12, 2.5, ' mb-a', 1)],
-- ['Framed dots', 'four dots in a rounded square', 'ripple', '', frame(3.5, 3.5, 17, 17, 5.5, '', 0) + [[9, 9], [15, 9], [9, 15], [15, 15]].map(([x, y], i) => dot(x, y, 1.75, i === 1 ? ' mb-a' : '', 1)).join('')],
-- ['Framed plus', 'a plus in a soft square; open, an ×', 'pop', 'plus', frame(3.5, 3.5, 17, 17, 6, ' mb-faint', 0) + `<g class="mb-g">${line('M12 7.8V16.2M7.8 12H16.2', ' mb-sa', 1, 2.4)}</g>`],
-- ] },
-- { title: 'Search', line: 'it opens search first, so say so', items: [
-- ['Magnifier', 'the plain search glass', 'tilt', '', ring(10.5, 10.5, 6, '', 0, 2.4) + line('M15 15L19.8 19.8', ' mb-sa', 1, 2.8)],
-- ['Glass and spark', 'search, with a spark of AI', 'twinkle', '', ring(10, 11.2, 5.6, '', 0, 2.3) + line('M14.2 15.4L19 20.2', '', 1, 2.6) + fill(spark(18.4, 5.4, 3.6), ' mb-a', 2)],
-- ['Glass with a dot', 'a lit dot in the lens', 'pop', '', ring(10.5, 10.5, 6.2, '', 0, 2.3) + dot(10.5, 10.5, 2.1, ' mb-a', 1) + line('M15.2 15.2L19.8 19.8', '', 2, 2.7)],
-- ['Slash key', 'the / key that opens it', 'draw', '', frame(3.5, 3.5, 17, 17, 4.8, '', 0) + line('M14 7.6L10 16.4', ' mb-sa', 1, 2.5)],
-- ['Slash in a ring', 'the / key, round', 'draw', '', ring(12, 12, 8.6, '', 0, 2.1) + line('M14.4 7.4L9.6 16.6', ' mb-sa', 1, 2.5)],
-- ['Glass of dots', 'four dots seen through the lens', 'ripple', '', ring(10.4, 10.4, 6.4, '', 0, 2.2) + line('M15.1 15.1L19.8 19.8', '', 0, 2.7) + [[8.6, 8.6], [12.2, 8.6], [8.6, 12.2], [12.2, 12.2]].map(([x, y], i) => dot(x, y, 1.05, i === 0 ? ' mb-a' : '', 1)).join('')],
-- ['Prompt', 'a > and a blinking line, like a terminal', 'blink', '', line('M5 7L10 12L5 17', '', 0, 2.5) + line('M12.8 17.2H19.2', ' mb-sa mb-k', 1, 2.5)],
-- ['List and glass', 'lines of results with a small lens', 'slide', '', line('M4 6H14', '', 0, 2.2) + line('M4 11H10', '', 1, 2.2) + line('M4 16H9', '', 2, 2.2) + ring(16, 14, 3.3, ' mb-sa', 3, 2.1) + line('M18.4 16.4L20.4 18.4', ' mb-sa', 3, 2.2)],
++ ['One open tile', 'three tiles and one outline', 'bloom', '', box(4, 4, 7, 7, 2, '', 0) + frame(13.4, 4.4, 6.2, 6.2, 1.8, ' mb-sa', 1, 1.8) + box(4, 13, 7, 7, 2, '', 2) + box(13, 13, 7, 7, 2, '', 3)],
++ ['Tiles on their corner', 'four squares as a diamond', 'bloom', '', rot(45, tiles(2, 6.2, 2.2, 1.6, 0), 0.95)],
++ ['Four outlines', 'two by two square outlines', 'bloom', '', tiles(2, 6.4, 2.6, 1.8, 1, 'frame')],
++ ['Tile and dots', 'one tile and three dots', 'bloom', '', box(4, 4, 7, 7, 2, ' mb-a', 0) + dot(16.5, 7.5, 3, '', 1) + dot(7.5, 16.5, 3, '', 2) + dot(16.5, 16.5, 3, '', 3)],
++ ['Circles and squares', 'two discs, two tiles', 'bloom', '', dot(7.5, 7.5, 3.5, '', 0) + box(13, 4, 7, 7, 2, ' mb-a', 1) + box(4, 13, 7, 7, 2, '', 2) + dot(16.5, 16.5, 3.5, '', 3)],
] },
-- { title: 'Sparks and stars', line: 'a bit of magic', items: [
-- ['Sparkle', 'one four-point spark', 'twinkle', '', fill(spark(12, 12, 9), ' mb-a', 0)],
-- ['Two sparkles', 'a big spark and a small one', 'twinkle', '', fill(spark(10, 13.5, 7.4), '', 0) + fill(spark(18.2, 5.6, 3.4), ' mb-a', 1)],
-- ['Asterisk', 'three strokes through the middle', 'draw', '', line('M12 3.5V20.5', '', 0, 2.4) + line('M4.6 7.75L19.4 16.25', ' mb-sa', 1, 2.4) + line('M4.6 16.25L19.4 7.75', '', 2, 2.4)],
-- ['Sun', 'a lit dot and eight short rays', 'ripple', '', dot(12, 12, 3.3, ' mb-a', 0) + [0, 1, 2, 3, 4, 5, 6, 7].map((k) => seg2(6.4, 9.2, k * 45, '', 1, 2)).join('')],
-- ['Compass', 'a four-point compass star', 'tilt', '', fill('M12 2.5L14 10L21.5 12L14 14L12 21.5L10 14L2.5 12L10 10Z', '', 0) + dot(12, 12, 1.7, ' mb-a mb-hole', 1)],
-- ['Star', 'a five-point star, drawn', 'draw', '', line(star(12, 12.6, 9.4, 4), '', 0, 2)],
-- ['Burst', 'dots and ticks around a centre', 'ripple', '', dot(12, 12, 2.4, ' mb-a', 0) + [0, 90, 180, 270].map((a) => seg2(5, 9, a, '', 1, 2.2)).join('') + [45, 135, 225, 315].map((a) => { const [x, y] = polar(7.2, a); return dot(x, y, 1.6, '', 2); }).join('')],
-- ['Orb', 'a lit ball, the button\'s own name', 'pulse', '', ring(12, 12, 10, ' mb-faint', 0, 1.2) + dot(12, 12, 7.6, ' mb-a', 1) + dot(9.4, 9.2, 2.3, ' mb-shine', 2)],
-- ] },
-- { title: 'From the mark', line: 'the logo\'s own shapes, small', items: [
-- ['Pair', 'a tiny O and V', 'draw', '', ring(8, 12, 4.6, '', 0, 2.4) + line('M12.3 7.4L15.6 16.6L18.9 7.4', ' mb-sa', 1, 2.4)],
-- ['Loop', 'a tiny Loop', 'draw', '', line(LOOP_ICON, ' mb-sa', 0, 2.4)],
-- ['V in a ring', 'a chevron inside a circle', 'nod', '', ring(12, 12, 8.6, '', 0, 2.1) + line('M8.4 10.4L12 14L15.6 10.4', ' mb-sa', 1, 2.5)],
-- ['Chevron', 'a V pointing down; open, it flips', 'nod', 'flip', `<g class="mb-g">${line('M6 9.2L12 15.2L18 9.2', ' mb-sa', 0, 2.8)}</g>`],
-- ['Crescent', 'a moon with a lit star in its curve', 'tilt', '', fill('M10.46 3.64A8.5 8.5 0 1 0 20.36 13.54A7 7 0 0 1 10.46 3.64Z', '', 0) + fill(spark(17.6, 6.4, 2.9), ' mb-a', 1)],
-- ['Half and half', 'a circle half lit', 'tilt', '', ring(12, 12, 8.6, '', 0, 2) + fill('M12 3.4A8.6 8.6 0 0 1 12 20.6Z', ' mb-a', 1)],
-- ] },
-- { title: 'Lines and waves', line: 'menus and sound', items: [
-- ['Three lines', 'the classic menu; open, the lines cross', 'slide', 'burger', [6.5, 12, 17.5].map((y, i) => line(`M4.5 ${y}H19.5`, `${i === 1 ? ' mb-sa' : ''} mb-l${i}`, i, 2.4)).join('')],
++ { title: 'Menu lines', line: 'the classic menu, in many cuts', items: [
++ ['Three lines', 'the classic; open, the lines cross', 'slide', 'burger', [6.5, 12, 17.5].map((y, i) => line(`M4.5 ${y}H19.5`, `${i === 1 ? ' mb-sa' : ''} mb-l${i}`, i, 2.4)).join('')],
['Two lines', 'a quieter menu; open, an ×', 'slide', 'burger2', [9, 15].map((y, i) => line(`M5 ${y}H19`, ` mb-l${i * 2}`, i, 2.4)).join('')],
++ ['Stepped lines', 'long, middle, short', 'slide', '', line('M4.5 6.5H19.5', '', 0, 2.4) + line('M4.5 12H15', ' mb-sa', 1, 2.4) + line('M4.5 17.5H10.5', '', 2, 2.4)],
++ ['Centred lines', 'long, short, long', 'slide', '', line('M4.5 6.5H19.5', '', 0, 2.4) + line('M8 12H16', ' mb-sa', 1, 2.4) + line('M4.5 17.5H19.5', '', 2, 2.4)],
['Bullet list', 'three dots and three lines', 'slide', '', [6.5, 12, 17.5].map((y, i) => dot(5, y, 1.6, ' mb-a', i) + line(`M9 ${y}H19.5`, '', i, 2.2)).join('')],
-- ['Equaliser', 'four bars, like sound', 'bars', '', [[4.2, 8], [8.9, 14], [13.6, 10], [18.3, 16]].map(([x, h], i) => box(x, 20 - h, 2.6, h, 1.3, i === 3 ? ' mb-a' : '', i)).join('')],
-- ['Wave', 'one smooth wave', 'draw', '', line('M3 12Q6 5.5 9 12T15 12T21 12', ' mb-sa', 0, 2.5)],
-- ['Hub', 'a centre joined to four sites', 'ripple', '', [0, 90, 180, 270].map((a) => seg2(3.8, 5.7, a, ' mb-faint', 1, 1.5)).join('') + dot(12, 12, 2.6, ' mb-a', 0) + [0, 90, 180, 270].map((a) => { const [x, y] = polar(7.6, a); return dot(x, y, 1.9, '', 1); }).join('')],
++ ['Heavy bars', 'three thick bars', 'slide', '', LINES([6.5, 12, 17.5], 5, 19, (i) => (i === 1 ? ' mb-sa' : ''), 3.2)],
++ ['Right-aligned', 'lines that end on the right', 'slide', '', line('M4.5 6.5H19.5', '', 0, 2.4) + line('M9 12H19.5', '', 1, 2.4) + line('M13.5 17.5H19.5', ' mb-sa', 2, 2.4)],
++ ['Lit middle', 'three lines, the middle one lit', 'slide', '', LINES([6.5, 12, 17.5], 4.5, 19.5, (i) => (i === 1 ? ' mb-sa' : ''), 2.4)],
++ ['Short lines', 'three short lines, centred', 'slide', '', LINES([7.5, 12, 16.5], 8, 16, (i) => (i === 0 ? ' mb-sa' : ''), 2.4)],
++ ['Lines in a pill', 'three lines in a rounded frame', 'slide', '', frame(3, 4.5, 18, 15, 5, ' mb-faint', 0, 1.6) + LINES([9, 12, 15], 8, 16, (i) => (i === 1 ? ' mb-sa' : ''), 2)],
++ ] },
++ { title: 'Three dots', line: 'the "more" button', items: [
++ ['Three, stacked', 'three dots in a column', 'wave', '', [5, 12, 19].map((y, i) => dot(12, y, 2.3, i === 1 ? ' mb-a' : '', i)).join('')],
++ ['Three in a row', 'three dots across', 'wave', '', [5, 12, 19].map((x, i) => dot(x, 12, 2.3, i === 1 ? ' mb-a' : '', i)).join('')],
++ ['Big three', 'three bigger dots across', 'wave', '', [4.8, 12, 19.2].map((x, i) => dot(x, 12, 3, i === 2 ? ' mb-a' : '', i)).join('')],
++ ['Three in a pill', 'three dots in a rounded frame', 'wave', '', frame(2.5, 7, 19, 10, 5, ' mb-faint', 0, 1.6) + [7.5, 12, 16.5].map((x, i) => dot(x, 12, 1.7, i === 1 ? ' mb-a' : '', i)).join('')],
++ ['Triangle of three', 'three dots, the top one lit', 'pop', '', dot(12, 5.5, 2.7, ' mb-a', 0) + dot(18.6, 17, 2.7, '', 1) + dot(5.4, 17, 2.7, '', 2)],
++ ] },
++ { title: 'Framed', line: 'a menu inside a shape', items: [
++ ['Grid in a square', 'nine dots in a rounded square', 'ripple', '', frame(3, 3, 18, 18, 5.5, ' mb-faint', 0, 1.6) + gridDots(3, 3, 4.6, 1.3, 4)],
++ ['Lines in a circle', 'three lines in a ring', 'slide', '', ring(12, 12, 9.5, ' mb-faint', 0, 1.6) + LINES([8.5, 12, 15.5], 8, 16, (i) => (i === 1 ? ' mb-sa' : ''), 2)],
++ ['Diamond in a squircle', 'the nine dots in a soft square', 'ripple', '', frame(2.5, 2.5, 19, 19, 7, ' mb-faint', 0, 1.6) + rot(45, gridDots(3, 3, 4.6, 1.3, 4), 1)],
++ ['Tiles in a circle', 'four tiles in a ring', 'bloom', '', ring(12, 12, 10, ' mb-faint', 0, 1.6) + tiles(2, 4.4, 1.6, 1.2, 1)],
++ ['Chevron in a circle', 'a V pointing down; open, it flips', 'nod', 'flip', ring(12, 12, 8.8, ' mb-faint', 0, 1.8) + `<g class="mb-g">${line('M8.2 10.4 12 14.2 15.8 10.4', ' mb-sa', 1, 2.4)}</g>`],
] },
];
// ── The page ─────────────────────────────────────────────────────────────────────────────────────────────────────
--// The site names a reader can try beside the mark, and the fonts for them (Nunito is the brand face today: 'OV Brand').
--const SITES = ['Live', 'Video', 'Pics', 'Chat', 'Today', 'Codes', 'Design', 'Space'];
--const FONTS = [
-- ['nunito', 'Nunito', "'OV Brand',Nunito,sans-serif", 'today'],
-- ['outfit', 'Outfit', 'Outfit,sans-serif'],
-- ['sora', 'Sora', 'Sora,sans-serif'],
-- ['manrope', 'Manrope', 'Manrope,sans-serif'],
-- ['jakarta', 'Plus Jakarta', "'Plus Jakarta Sans',sans-serif"],
-- ['fredoka', 'Fredoka', 'Fredoka,sans-serif'],
-- ['lexend', 'Lexend', 'Lexend,sans-serif'],
-- ['rubik', 'Rubik', 'Rubik,sans-serif'],
-- ['grotesk', 'Space Grotesk', "'Space Grotesk',sans-serif"],
-- ['unbounded', 'Unbounded', 'Unbounded,sans-serif'],
--];
--const FONT_CSS = 'https://fonts.googleapis.com/css2?family=Outfit:wght@500;800&family=Sora:wght@500;800&family=Manrope:wght@500;800&family=Plus+Jakarta+Sans:wght@500;800&family=Fredoka:wght@500;700&family=Lexend:wght@500;800&family=Rubik:wght@500;800&family=Space+Grotesk:wght@500;700&family=Unbounded:wght@500;800&display=swap';
--const STYLES = [['dot', 'OpenVibe.Live'], ['space', 'OpenVibe Live'], ['stack', 'Stacked']];
/** Which idle move a mark makes once it is drawn: one that suits it, varied along the row. */
const SEPARATE_V = ['split', 'apart', 'float', 'cross', 'tipdot', 'dashed', 'segments', 'coin-ring'];
@@@ -464,7 +599,8 @@ function idleOf(kind, spec, i) {
}
let seq = 0;
--const word = () => '<span class="mx-word"><b>OpenVibe</b><span class="mx-sep">.</span><span class="mx-site" data-mx-site>Live</span></span>';
++// One real name as the example (the owner, 11-01: "see what it looks like with the full domain spelled out").
++const word = () => '<span class="mx-word"><b>OpenVibe</b><span class="mx-sep">.</span><span class="mx-site">Chat</span></span>';
const useOf = (ref, size) => `<svg viewBox="0 0 100 100" width="${size}" height="${size}" aria-hidden="true"><use href="#${ref}"/></svg>`;
const delay = (i) => `--d:${f((i % 4) * 0.07)}s`;
@@@ -493,27 +629,76 @@ function menuCard(code, [name, line_, anim, own, parts], i) {
</li>`;
}
++
++// ── Step 3: the name's font (the owner, 2026-11-01: "re-add the font choice… saved… show what people chose… guide them
++// through each step"). Twelve faces in three families, each setting "OpenVibe" beside the mark this person picked.
++const FONT_ROWS = [
++ { title: 'Rounded and friendly', line: 'soft corners, like the mark', items: [
++ ['Nunito', 'the brand face today', "'OV Brand',Nunito,sans-serif", 800],
++ ['Fredoka', 'round and playful', 'Fredoka,sans-serif', 700],
++ ['Quicksand', 'light, round, airy', 'Quicksand,sans-serif', 700],
++ ['Rubik', 'slightly rounded, sturdy', 'Rubik,sans-serif', 800],
++ ] },
++ { title: 'Geometric', line: 'circles and straight lines, clean', items: [
++ ['Outfit', 'geometric and wide', 'Outfit,sans-serif', 800],
++ ['Sora', 'tech, open shapes', 'Sora,sans-serif', 800],
++ ['Lexend', 'made for easy reading', 'Lexend,sans-serif', 800],
++ ['Poppins', 'the classic geometric', 'Poppins,sans-serif', 800],
++ ] },
++ { title: 'Sharp and modern', line: 'crisper corners, more character', items: [
++ ['Manrope', 'modern grotesque', 'Manrope,sans-serif', 800],
++ ['Plus Jakarta Sans', 'friendly grotesque', "'Plus Jakarta Sans',sans-serif", 800],
++ ['Space Grotesk', 'quirky and technical', "'Space Grotesk',sans-serif", 700],
++ ['Unbounded', 'wide display face', 'Unbounded,sans-serif', 800],
++ ] },
++];
++const FONTS = FONT_ROWS.flatMap((r) => r.items);
++const FONT_CSS = 'https://fonts.googleapis.com/css2?family=Fredoka:wght@500;700&family=Quicksand:wght@500;700&family=Rubik:wght@500;800&family=Outfit:wght@500;800&family=Sora:wght@500;800&family=Lexend:wght@500;800&family=Poppins:wght@500;800&family=Manrope:wght@500;800&family=Plus+Jakarta+Sans:wght@500;800&family=Space+Grotesk:wght@500;700&family=Unbounded:wght@500;800&display=swap';
++function fontCard(code, [name, line_, stack, weight], i) {
++ const face = esc(stack);
++ return `<li class="mx-card fx-card" id="${code}" data-code="${code}" style="${delay(i)}">
++<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false" aria-label="Pick ${code}"></button>
++<div class="mx-stage fx-stage"><span class="fx-lock"><svg class="fx-mark" viewBox="0 0 100 100" width="44" height="44" aria-hidden="true"><use href="#art-pair-today"/></svg><span class="fx-word" style="font-family:${face};font-weight:${weight}">OpenVibe</span></span></div>
++<div class="fx-light"><svg class="fx-mark" viewBox="0 0 100 100" width="26" height="26" aria-hidden="true"><use href="#art-pair-today"/></svg><span style="font-family:${face};font-weight:${weight}">OpenVibe<span class="fx-site">.Chat</span></span></div>
++<p class="fx-sample" style="font-family:${face};font-weight:${weight}">OpenVibe.Live · OpenVibe.Video · OpenVibe.Pics</p>
++<p class="mx-name"><b>${code}</b> ${esc(name)}</p>
++<p class="mx-line">${esc(line_)}</p>
++</li>`;
++}
++
const pad = (n) => String(n).padStart(2, '0');
function panel(id, kind, rows, label, hidden) {
let n = 0;
const body = rows.map((row) => {
const first = n + 1;
-- const cards = row.items.map((item) => { n += 1; const code = `${kind}${pad(n)}`; return kind === 'M' ? menuCard(code, item, n - 1) : markCard(code, kind, item, n - 1); }).join('');
++ const cards = row.items.map((item) => { n += 1; const code = `${kind}${pad(n)}`; return kind === 'M' ? menuCard(code, item, n - 1) : kind === 'F' ? fontCard(code, item, n - 1) : markCard(code, kind, item, n - 1); }).join('');
return `<section class="mx-row"><h3>${esc(row.title)} <small>${kind}${pad(first)}–${kind}${pad(n)} · ${row.items.length}</small><span class="mx-swipe" aria-hidden="true">swipe →</span></h3><p class="mx-row-line">${esc(row.line)}</p><ul class="mx-grid">${cards}</ul></section>`;
}).join('');
const NEXT = {
-- loop: [['pair', 'See the Pair', 'the other direction for the mark'], ['menu', 'Next: the menu button', 'it stays whichever mark wins']],
-- pair: [['loop', 'See the Loop', 'the other direction for the mark'], ['menu', 'Next: the menu button', 'it stays whichever mark wins']],
-- menu: [['loop', 'Back to the Loop', 'the mark, one direction'], ['pair', 'Back to the Pair', 'the mark, the other']],
++ loop: [['pair', 'See the Pair', 'the other logo'], ['menu', 'Next: menu button', 'step 2']],
++ pair: [['loop', 'See the Loop', 'the other logo'], ['menu', 'Next: menu button', 'step 2']],
++ menu: [['loop', 'Back', 'step 1, the logo'], ['font', 'Next: font', 'step 3']],
++ font: [['menu', 'Back', 'step 2, the menu button'], ['share', 'Done: share', 'your votes are in']],
++ }[id];
++ const next = `<nav class="mx-next" aria-label="Where next">${NEXT.map(([to, title, line_]) => to === 'share'
++ ? `<button type="button" class="mx-next-share" data-mx-share><b>${title} <span aria-hidden="true">✓</span></b><small>${line_}</small></button>`
++ : `<a href="#${to}" data-mx-go="${to}"><b>${title} <span aria-hidden="true">${/^Back/.test(title) ? '←' : '→'}</span></b><small>${line_}</small></a>`).join('')}</nav>`;
++ const STEP = {
++ loop: [1, 'Pick a logo: the Loop', 'Tap the ones you like. Look at the Pair too.'],
++ pair: [1, 'Pick a logo: the Pair', 'Tap the ones you like. Look at the Loop too.'],
++ menu: [2, 'Pick a menu button', 'Tap one to see it open.'],
++ font: [3, 'Pick a font', 'Tap the one that looks right.'],
}[id];
-- const next = `<nav class="mx-next" aria-label="Where next">${NEXT.map(([to, title, line_]) => `<a href="#${to}" data-mx-go="${to}"><b>${title} <span aria-hidden="true">${to === 'menu' || id !== 'menu' ? '→' : '↑'}</span></b><small>${line_}</small></a>`).join('')}</nav>`;
-- return `<section class="mx-panel" id="${id}" role="tabpanel" aria-labelledby="mx-tab-${id}" aria-label="${esc(label)}"${hidden ? ' hidden' : ''}>${body}${next}</section>`;
++ const head = `<div class="mx-stephead"><span class="mx-stepn mx-stepn-lg" aria-hidden="true">${STEP[0]}</span><div><b>Step ${STEP[0]} of 3 · ${esc(STEP[1])}</b><small>${esc(STEP[2])}</small></div></div>`;
++ const with_ = id === 'menu' || id === 'font' ? '<p class="mb-with" data-mb-with>Next to today&rsquo;s logo. Pick a logo first to see yours here.</p>' : '';
++ return `<section class="mx-panel" id="${id}" role="tabpanel" aria-labelledby="mx-tab-${id}" aria-label="${esc(label)}"${hidden ? ' hidden' : ''}>${head}${with_}${body}${next}</section>`;
}
/** One small mark for the tally, drawn on its own (its own ids), so it shows whichever tab is open. */
function miniArt(code, size = 26, tag = '') {
const i = Number(code.slice(1)) - 1;
if (code[0] === 'M') { const m = MENUS[i]; return m ? `<span class="mb-btn mx-mini-m">${menuSvg(m[2], m[3], m[4], size - 4)}</span>` : ''; }
++ if (code[0] === 'F') { const fo = FONTS[i]; return fo ? `<span class="fx-mini" style="font-family:${esc(fo[2])};font-weight:${fo[3]}">Aa</span>` : ''; }
const item = (code[0] === 'L' ? LOOPS : PAIRS)[i];
if (!item) return '';
const art = code[0] === 'L' ? loopArt(item[2], `t${tag}${code}`) : pairArt(item[2], `t${tag}${code}`);
@@@ -527,65 +712,68 @@ function tallyBlock(t) {
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>So far</b><span data-mx-people>${people ? `${people} ${people === 1 ? 'person has' : 'people have'} picked` : 'Nobody has picked 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('L02', 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">Most picked</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 picks</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-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>
</section>`;
}
function explorePage({ tally = null } = {}) {
seq = 0;
const today = pairArt(PAIR_ROWS[0].items[0][2], 'xpair');
-- const loopEx = loopArt(LOOP_ROWS[0].items[1][2], 'xloop');
++ const loopEx = loopArt(LOOP_ROWS[0].items[0][2], 'xloop');
const pairEx = pairArt(PAIR_ROWS[0].items[0][2], 'xpairtab');
const m1 = MENU_ROWS[0].items[0];
const tab = (id, title, line_, art, range, on, side) => `<a class="mx-tab${side ? ' mx-tab-side' : ''}" role="tab" id="mx-tab-${id}" href="#${id}" aria-controls="${id}" aria-selected="${on ? 'true' : 'false'}" data-mx-tab="${id}"><span class="mx-tab-art" data-mx-cycle="${id}"><span class="mx-ta">${art}</span><em class="mx-tab-code" aria-hidden="true"></em></span><span class="mx-tab-t"><b>${title}</b><small>${line_}</small><em>${range}</em></span></a>`;
const chip = (attr, value, label, extra = '') => `<button type="button" class="mx-chip" ${attr}="${esc(value)}" aria-pressed="false"${extra}>${label}</button>`;
-- const body = `<div class="mx-wrap" data-mx data-lock="dot" data-mx-fontcss="${esc(FONT_CSS)}">
++ const body = `<div class="mx-wrap" data-mx data-mx-fontcss="${esc(FONT_CSS)}">
<svg class="mx-defs" width="0" height="0" aria-hidden="true" focusable="false"><defs>${today.defs}</defs><g id="art-pair-today">${today.body}</g></svg>
<noscript><style>.mx-panel[hidden]{display:block!important}</style></noscript>
<header class="mx-hero">
--<p class="mx-eye">The OpenVibe mark · help us choose</p>
--<h1>The Loop or the Pair?</h1>
--<p class="mx-lede">We&rsquo;re choosing OpenVibe&rsquo;s mark, and it&rsquo;s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you&rsquo;d pick, and the takes you like best. Tap the ones you like: they are saved and counted in the tally below.</p>
++<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>
</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> The mark: <b>the Loop or the Pair?</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">
--${tab('loop', 'The Loop', 'A V folded into a ring that opens at the top: one shape, like a badge.', `<svg viewBox="0 0 100 100" width="58" height="58" aria-hidden="true"><defs>${loopEx.defs}</defs>${loopEx.body}</svg>`, 'L01–L50 · fifty 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>
--${tab('pair', 'The Pair', 'An O and a V side by side, reading as the letters O V.', `<svg viewBox="0 0 100 100" width="58" height="58" aria-hidden="true"><defs>${pairEx.defs}</defs>${pairEx.body}</svg>`, 'P01–P50 · fifty 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>
--<p class="mx-q" role="presentation"><span class="mx-qn">2</span> Separately: <b>the menu button&rsquo;s look</b> <small>it stays beside the name whichever mark wins</small></p>
--${tab('menu', 'The menu button', 'Opens search and every site. Fifty looks for it, each one moves when you hover or tap it.', `<span class="mb-btn mb-tabbtn">${menuSvg(m1[2], m1[3], m1[4], 30)}</span>`, 'M01–M50', false, true)}
++<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>
++${tab('menu', 'The menu button', 'The button next to the logo', `<span class="mb-btn mb-tabbtn">${menuSvg(m1[2], m1[3], m1[4], 30)}</span>`, 'M01–M50', false, true)}
++<p class="mx-q" role="presentation"><span class="mx-qn">3</span> <b>Pick a font</b> <small>for the name</small></p>
++${tab('font', 'The font', 'The letters of the name', '<span class="fx-tabart" aria-hidden="true">Aa</span>', `F01–F${pad(FONTS.length)}`, false, true)}
</nav>
<nav class="mx-steps" data-mx-steps aria-label="Jump between the choices" hidden>
--<span class="mx-steps-k">Mark</span>
--<a href="#loop" data-mx-go="loop" class="mx-step">${miniArt('L02', 20, 's')}<span>Loop</span><i data-mx-count="L"></i></a>
++<span class="mx-stepn" aria-hidden="true">1</span><span class="mx-steps-k">The mark</span>
++<a href="#loop" data-mx-go="loop" class="mx-step">${miniArt('L01', 20, 's')}<span>Loop</span><i data-mx-count="L"></i></a>
<a href="#pair" data-mx-go="pair" class="mx-step">${miniArt('P01', 20, 's')}<span>Pair</span><i data-mx-count="P"></i></a>
--<span class="mx-steps-sep" aria-hidden="true"></span>
--<a href="#menu" data-mx-go="menu" class="mx-step">${miniArt('M01', 22, 's')}<span>Menu button</span><i data-mx-count="M"></i></a>
++<span class="mx-steps-sep" aria-hidden="true">→</span><span class="mx-stepn" aria-hidden="true">2</span>
++<a href="#menu" data-mx-go="menu" class="mx-step mx-step-2">${miniArt('M01', 22, 's')}<span>Menu button</span><i data-mx-count="M"></i></a>
++<span class="mx-steps-sep" aria-hidden="true">→</span><span class="mx-stepn" aria-hidden="true">3</span>
++<a href="#font" data-mx-go="font" class="mx-step mx-step-3"><span class="fx-mini" aria-hidden="true">Aa</span><span>Font</span><i data-mx-count="F"></i></a>
++<button type="button" class="mx-step mx-sheet" data-mx-density="sheet" aria-pressed="false" aria-label="Sheet: show all at once"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><rect x="4" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.5"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.5"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.5"/></svg></button>
<button type="button" class="mx-step mx-share" data-mx-share aria-label="Copy your picks and a link to them"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V3.5M7.5 8 12 3.5 16.5 8M5 13v6a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6"/></svg><span>Share</span></button>
</nav>
--<div class="mx-tools" data-mx-tools hidden>
--<div class="mx-tool"><span class="mx-tool-k">Name</span><div class="mx-chips" role="group" aria-label="The site's name beside the mark">${SITES.map((s) => chip('data-mx-set-site', s, esc(s))).join('')}<input class="mx-own" type="text" maxlength="14" placeholder="Type one" aria-label="Type a site name" data-mx-own spellcheck="false" autocomplete="off"></div></div>
--<div class="mx-tool"><span class="mx-tool-k">Font</span><div class="mx-chips" role="group" aria-label="The font of the name" data-mx-fonts>${FONTS.map(([k, label, stack, note]) => chip('data-mx-font', k, `${esc(label)}${note ? ` <small>${note}</small>` : ''}`, ` data-face="${esc(stack)}" style="font-family:${esc(stack)}"`)).join('')}</div></div>
--<div class="mx-tool"><span class="mx-tool-k">Style</span><div class="mx-chips" role="group" aria-label="How the name is set">${STYLES.map(([k, label]) => chip('data-mx-lockstyle', k, esc(label))).join('')}</div></div>
--<div class="mx-tool"><span class="mx-tool-k">View</span><div class="mx-chips" role="group" aria-label="View">${chip('data-mx-density', 'sheet', 'Sheet: all at once')}${chip('data-mx-still', '1', 'Still')}</div></div>
--</div>
--${panel('loop', 'L', LOOP_ROWS, 'The Loop: fifty takes', false)}
--${panel('pair', 'P', PAIR_ROWS, 'The Pair: fifty takes', true)}
++${panel('loop', 'L', LOOP_ROWS, `The Loop: ${LOOPS.length} takes`, false)}
++${panel('pair', 'P', PAIR_ROWS, `The Pair: ${PAIRS.length} takes`, true)}
${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}
--<p class="mx-fine">Every mark here is centred and level. None of them replaces the mark the sites use until one is chosen: <a href="/brand">the mark today</a>.</p>
++${panel('font', 'F', FONT_ROWS, 'The font: twelve faces', true)}
++<div class="mx-nudge" data-mx-nudge role="status" hidden><span data-mx-nudge-text></span><a href="#menu" data-mx-go="menu" class="mx-btn" data-mx-nudge-go>Go to step 2 →</a><button type="button" class="mx-btn mx-btn-quiet" data-mx-nudge-close aria-label="Close">✕</button></div>
++<p class="mx-fine">Nothing changes on the sites until a logo wins. <a href="/brand">Today&rsquo;s logo</a>.</p>
</div>`;
return layout({
-- title: 'The Loop or the Pair? Help choose the OpenVibe mark',
-- description: 'The OpenVibe mark is between two: the Loop and the Pair, fifty takes on each. Plus fifty looks for the menu button beside the name.',
++ title: 'Help choose the OpenVibe mark',
++ description: 'Help choose the OpenVibe mark in three quick steps: the Loop or the Pair, the menu button beside it, and the font of the name.',
path: '/brand/explore',
active: '/brand',
robots: 'noindex, follow',
@@@ -598,4 +786,4 @@ ${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}
const LOOPS = LOOP_ROWS.flatMap((r) => r.items);
const PAIRS = PAIR_ROWS.flatMap((r) => r.items);
const MENUS = MENU_ROWS.flatMap((r) => r.items);
--module.exports = { explorePage, tallyBlock, LOOPS, PAIRS, MENUS, LOOP_ROWS, PAIR_ROWS, MENU_ROWS, loopArt, pairArt };
++module.exports = { explorePage, tallyBlock, FONTS, LOOPS, PAIRS, MENUS, LOOP_ROWS, PAIR_ROWS, MENU_ROWS, loopArt, pairArt };
ddiff --git a/test/brand-explore.test.js b/test/brand-explore.test.js
iindex effc0a5..8e8d7df 100644
---- a/test/brand-explore.test.js
++++ b/test/brand-explore.test.js
@@@ -12,10 +12,11 @@ const explore = require('../server/views/marks-explore');
(async () => {
await check('fifty distinct takes in each family: no two share their whole recipe', () => {
for (const [name, list] of [['Loop', explore.LOOPS], ['Pair', explore.PAIRS], ['menu', explore.MENUS]]) {
-- assert.strictEqual(list.length, 50, `${name}: fifty`);
++ const want = { Loop: 72, Pair: 60, menu: 50 }[name];
++ assert.strictEqual(list.length, want, `${name}: ${want}`);
const recipes = list.map((x) => JSON.stringify(x.slice(2)));
-- assert.strictEqual(new Set(recipes).size, 50, `${name}: every recipe differs`);
-- assert.strictEqual(new Set(list.map((x) => x[0])).size, 50, `${name}: every name differs`);
++ assert.strictEqual(new Set(recipes).size, want, `${name}: every recipe differs`);
++ assert.strictEqual(new Set(list.map((x) => x[0])).size, want, `${name}: every name differs`);
}
});
@@@ -24,29 +25,33 @@ const explore = require('../server/views/marks-explore');
await check('three tabs, a hundred and fifty numbered cards in rows, every one pickable', async () => {
const r = await t.get('/brand/explore');
assert.strictEqual(r.status, 200);
-- const codes = [...r.text.matchAll(/<li class="mx-card(?: mb-card)?" id="([LPM]\d{2})" data-code="\1"/g)].map((m) => m[1]);
-- assert.strictEqual(codes.length, 150);
-- assert.strictEqual(new Set(codes).size, 150, 'each number once');
-- for (const k of 'LPM') assert.strictEqual(codes.filter((c) => c[0] === k).length, 50, k);
++ const codes = [...r.text.matchAll(/<li class="mx-card(?: mb-card| fx-card)?" id="([LPMF]\d{2})" data-code="\1"/g)].map((m) => m[1]);
++ assert.strictEqual(codes.length, 194);
++ assert.strictEqual(new Set(codes).size, 194, 'each number once');
++ assert.deepStrictEqual(['L', 'P', 'M', 'F'].map((k) => codes.filter((c) => c[0] === k).length), [72, 60, 50, 12]);
assert.ok(codes.every((c) => r.text.includes(`data-pick="${c}"`)), 'each has its Pick button');
-- for (const tab of ['loop', 'pair', 'menu']) {
++ for (const tab of ['loop', 'pair', 'menu', 'font']) {
assert.match(r.text, new RegExp(`<a class="mx-tab(?: mx-tab-side)?" role="tab" id="mx-tab-${tab}" href="#${tab}" aria-controls="${tab}"[^>]*><span class="mx-tab-art" data-mx-cycle="${tab}"><span class="mx-ta"><(svg|span)`), `the ${tab} tab shows an example`);
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>The Loop or the Pair\?<\/h1>/, 'the top says the mark is between two');
-- assert.match(r.text, /<span class="mx-qn">1<\/span> The mark: <b>the Loop or the Pair\?<\/b>[\s\S]*?<span class="mx-or" aria-hidden="true">or<\/span>[\s\S]*?<span class="mx-qn">2<\/span> Separately: <b>the menu button&rsquo;s look<\/b>/, 'two questions: the mark, then separately the menu button');
++ 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.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');
assert.ok(!/data-mx-bar/.test(r.text), 'no bar over the marks: a card is picked where it is');
-- assert.match(r.text, /data-mx-tools hidden/, 'the name and font bar waits for the script');
++ assert.ok(!/data-mx-tools|data-mx-set-site|data-mx-own/.test(r.text), 'no per-site picker: one example name');
++ assert.match(r.text, /<div class="mx-nudge" data-mx-nudge role="status" hidden>/, 'a nudge to step 2 waits for the first pick');
++ assert.match(r.text, /<span class="mx-stepn" aria-hidden="true">1<\/span>[\s\S]*?<span class="mx-stepn" aria-hidden="true">2<\/span>[\s\S]*?<span class="mx-stepn" aria-hidden="true">3<\/span>/, 'the steps bar numbers the three steps');
++ assert.strictEqual((r.text.match(/<div class="mx-stephead">/g) || []).length, 4, 'each section opens with its step');
});
await check('each mark beside a site\'s name and small, reusing one drawing; every id unique', async () => {
const r = await t.get('/brand/explore');
-- assert.match(r.text, /<div class="mx-lock"><svg viewBox="0 0 100 100" width="26" height="26" aria-hidden="true"><use href="#art-L01"\/><\/svg><span class="mx-word"><b>OpenVibe<\/b><span class="mx-sep">\.<\/span><span class="mx-site" data-mx-site>Live<\/span><\/span><\/div>/);
-- assert.match(r.text, /<use href="#art-P50"\/>/);
++ assert.match(r.text, /<div class="mx-lock"><svg viewBox="0 0 100 100" width="26" height="26" aria-hidden="true"><use href="#art-L01"\/><\/svg><span class="mx-word"><b>OpenVibe<\/b><span class="mx-sep">\.<\/span><span class="mx-site">Chat<\/span><\/span><\/div>/);
++ assert.match(r.text, /<use href="#art-P60"\/>/);
++ assert.match(r.text, /<li class="mx-card" id="L72"/, 'the outside-the-box row ends the Loops');
const ids = [...r.text.matchAll(/<(?:linearGradient|mask|filter|clipPath|g) id="([^"]+)"/g)].map((m) => m[1]);
assert.strictEqual(new Set(ids).size, ids.length, 'every gradient, mask and drawing id is unique on the page');
const refs = [...r.text.matchAll(/(?:href="#|url\(#)([^")]+)/g)].map((m) => m[1]).filter((x) => /^(art-|m\d|x)/.test(x));
@@@ -65,6 +70,8 @@ const explore = require('../server/views/marks-explore');
const key = (guest.headers.get('set-cookie') || '').match(/ov_mx_voter=([^;]+)/);
assert.ok(key && /HttpOnly/i.test(guest.headers.get('set-cookie')), 'a guest gets an httpOnly key');
assert.ok(!(await t.dbDump()).includes(key[1]), 'the key itself is never stored');
++ const wide = await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L72', 'P60', 'F12'] }, origin: own, cookie: `ov_mx_voter=${key[1]}` });
++ assert.deepStrictEqual(wide.json().mine, ['F12', 'L72', 'P60'], 'the new takes and the fonts save (migration 0006)');
const again = await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L03'] }, origin: own, cookie: `ov_mx_voter=${key[1]}` });
assert.deepStrictEqual(again.json().mine, ['L03'], 'the same browser replaces its picks');
const ana = t.network.addUser('ana');
@@@ -77,15 +84,15 @@ const explore = require('../server/views/marks-explore');
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 have picked[\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]*?2 people<\/span>[\s\S]*?data-code="P01"/, 'the tally is at the top of the page');
});
await check('the steps bar and "where next" connect the two choices', async () => {
const r = await t.get('/brand/explore');
assert.match(r.text, /<nav class="mx-steps" data-mx-steps aria-label="Jump between the choices" hidden>/);
-- for (const to of ['loop', 'pair', 'menu']) assert.ok(r.text.includes(`data-mx-go="${to}"`), to);
-- assert.strictEqual((r.text.match(/<nav class="mx-next" aria-label="Where next">/g) || []).length, 3, 'each choice ends with where to go next');
-- assert.strictEqual((r.text.match(/data-mx-cycle="/g) || []).length, 3, 'each tab example cycles through its fifty');
++ for (const to of ['loop', 'pair', 'menu', 'font']) assert.ok(r.text.includes(`data-mx-go="${to}"`), to);
++ assert.strictEqual((r.text.match(/<nav class="mx-next" aria-label="Where next">/g) || []).length, 4, 'each choice ends with where to go next');
++ assert.strictEqual((r.text.match(/data-mx-cycle="/g) || []).length, 4, 'each tab has its example');
});
await check('the files at /brand/:file still answer', async () => {