openvibers/openvibe.design Commits
/brand/explore: three simple steps, real menu buttons, more logos, fonts saved and counted (#64)
Changes
nnew file mode 100644@@@ -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])$');@@@ -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}++}@@@ -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 */ });@@@ -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 };}@@@ -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’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’re choosing OpenVibe’s mark, and it’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’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’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’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 };@@@ -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’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 () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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])$');
@@ -317,3 +317,76 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
@@ -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-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)}
.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}
+}
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
(function () {
(function () {
'use strict';
'use strict';
var KEY = 'ov_mark_picks', VIEW = 'ov_mark_view', TAB = 'ov_mark_tab';
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';
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 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 */ } }
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 @@
@@ -59,10 +59,38 @@
if (card) card.classList.toggle('is-picked', on);
if (card) card.classList.toggle('is-picked', on);
});
});
if (mineBox) { mineBox.hidden = !picks.length; mineBox.querySelector('[data-mx-picks]').textContent = order(picks).join(', '); }
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);
store(KEY, picks);
if (history.replaceState && (picks.length || /picks=/.test(location.hash))) history.replaceState(null, '', picks.length ? '#picks=' + order(picks).join(',') : location.pathname);
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;
var saveTimer = 0;
function toggle(code) {
function toggle(code) {
var i = picks.indexOf(code);
var i = picks.indexOf(code);
@@ -70,6 +98,7 @@
@@ -70,6 +98,7 @@
render();
render();
var card = document.getElementById(code);
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 (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);
clearTimeout(saveTimer); saveTimer = setTimeout(sync, 700);
}
}
function say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }
function say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }
@@ -85,15 +114,22 @@
@@ -85,15 +114,22 @@
if (p.length) mark.push('the Pair (' + p.join(', ') + ')');
if (p.length) mark.push('the Pair (' + p.join(', ') + ')');
if (mark.length) parts.push('The mark: ' + mark.join(', ') + '.');
if (mark.length) parts.push('The mark: ' + mark.join(', ') + '.');
if (m.length) parts.push('The menu button: ' + m.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();
return 'My picks. ' + parts.join(' ') + ' ' + link();
}
}
// ── The tally at the top: saved picks, counted (server/brand-picks.js) ──
// ── The tally at the top: saved picks, counted (server/brand-picks.js) ──
var tally = root.querySelector('[data-mx-tally]');
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) {
function chip(t) {
var el = document.createElement('span');
var el = document.createElement('span');
el.className = 'mx-top'; el.setAttribute('data-code', t.code);
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); }
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 b = document.createElement('b'); b.textContent = t.code; el.appendChild(b);
var n = document.createElement('i'); n.textContent = String(t.n); el.appendChild(n);
var n = document.createElement('i'); n.textContent = String(t.n); el.appendChild(n);
@@ -111,7 +147,7 @@
@@ -111,7 +147,7 @@
if (!tally || !t) return;
if (!tally || !t) return;
var loop = t.lean.loop, pair = t.lean.pair, sum = loop + pair, pct = sum ? Math.round((loop / sum) * 100) : 50;
var loop = t.lean.loop, pair = t.lean.pair, sum = loop + pair, pct = sum ? Math.round((loop / sum) * 100) : 50;
tally.classList.toggle('is-empty', !t.people);
tally.classList.toggle('is-empty', !t.people);
- tally.querySelector('[data-mx-people]').textContent = t.people ? t.people + (t.people === 1 ? ' person 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('[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('.mx-lean').style.setProperty('--l', pct + '%');
tally.querySelector('[data-mx-loop]').textContent = sum ? pct + '%' : '–';
tally.querySelector('[data-mx-loop]').textContent = sum ? pct + '%' : '–';
@@ -119,6 +155,7 @@
@@ -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);
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]'), marks);
fill(tally.querySelector('[data-mx-tops-m]'), (t.top.M || []).slice(0, 4));
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]');
var savedNote = root.querySelector('[data-mx-saved]');
function sync() {
function sync() {
@@ -161,7 +198,12 @@
@@ -161,7 +198,12 @@
store(TAB, name);
store(TAB, name);
rails();
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) {
function go(name) {
+ if (name === 'font') loadFonts();
+ var nd = root.querySelector('[data-mx-nudge]'); if (nd) nd.hidden = true;
show(name);
show(name);
if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + name);
if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + name);
var p = panelOf(name);
var p = panelOf(name);
@@ -188,12 +230,12 @@
@@ -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);
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 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 ──
// ── Each tab's example walks through all fifty, so it is plain there are fifty ──
(function cycle() {
(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;
if (!boxes.length) return;
var state = boxes.map(function (box, k) {
var state = boxes.map(function (box, k) {
var name = box.getAttribute('data-mx-cycle');
var name = box.getAttribute('data-mx-cycle');
@@ -248,57 +290,43 @@
@@ -248,57 +290,43 @@
}
}
rails();
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 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);
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 ──
// ── The menu buttons: press to open, and each makes its move when it arrives ──
function play(btn) {
function play(btn) {
@@ -321,6 +349,7 @@
@@ -321,6 +349,7 @@
if (p) { toggle(p.getAttribute('data-pick')); return; }
if (p) { toggle(p.getAttribute('data-pick')); return; }
var share = e.target.closest('[data-mx-share]');
var share = e.target.closest('[data-mx-share]');
if (share) {
if (share) {
+ e.preventDefault();
var text = picks.length ? summary() : link();
var text = picks.length ? summary() : link();
var done = function () { share.classList.add('is-done'); setTimeout(function () { share.classList.remove('is-done'); }, 1400); };
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 */ });
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 */ });
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
*/
*/
const crypto = require('crypto');
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 COOKIE = 'ov_mx_voter';
const MAX = 40;
const MAX = 40;
@@ -50,7 +50,7 @@ function createBrandPicks({ db, now = () => Date.now() } = {}) {
@@ -50,7 +50,7 @@ function createBrandPicks({ db, now = () => Date.now() } = {}) {
const people = lean.length;
const people = lean.length;
let loop = 0, pair = 0;
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; } }
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 }); }
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 };
return { people, lean: { loop, pair }, top };
}
}
@@ -8,8 +8,8 @@
@@ -8,8 +8,8 @@
* font right… nice across all devices"; fifty ideas for the menu button (the nine-dot diamond beside the name); and
* 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".
* "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
* 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
* 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) {
@@ -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,
// 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).
// mx-dot a dot; mx-s strokes (drawn in), mx-f fills (faded in).
function loopArt(s, id) {
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 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 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);
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) {
@@ -165,6 +166,95 @@ function loopArt(s, id) {
return { defs: st.defs, body: treat(p, body) };
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 ─────────────────────────────────────────────────────────────────────────────────────────────────────
// ── 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
// 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
// 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) {
@@ -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.
// [name, one line, spec]. A row says what changes in it; no two marks share shape, construction and treatment.
const LOOP_ROWS = [
const LOOP_ROWS = [
{ title: 'Weight and proportion', line: 'how heavy the line is, how wide it opens, how deep the V goes', items: [
{ 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' }],
['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' }],
['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' }],
['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 = [
@@ -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' }],
['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' }],
['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 = [
const PAIR_ROWS = [
{ title: 'Weight and spacing', line: 'how heavy, and how the V crosses the O', items: [
{ title: 'Weight and spacing', line: 'how heavy, and how the V crosses the O', items: [
@@ -338,6 +454,18 @@ const PAIR_ROWS = [
@@ -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' }],
['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' }],
['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 ───────────────────────────────────────────────────────────────────────────────────────────
// ── Fifty menu buttons ───────────────────────────────────────────────────────────────────────────────────────────
@@ -370,88 +498,95 @@ const grid9 = (rc, re, rk) => [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1]
@@ -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`;
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]
// [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 = [
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>`],
['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('')],
['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>`],
['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)],
['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('')],
['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('')],
['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 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. */
/** 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'];
const SEPARATE_V = ['split', 'apart', 'float', 'cross', 'tipdot', 'dashed', 'segments', 'coin-ring'];
@@ -464,7 +599,8 @@ function idleOf(kind, spec, i) {
@@ -464,7 +599,8 @@ function idleOf(kind, spec, i) {
}
}
let seq = 0;
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 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`;
const delay = (i) => `--d:${f((i % 4) * 0.07)}s`;
@@ -493,27 +629,76 @@ function menuCard(code, [name, line_, anim, own, parts], i) {
@@ -493,27 +629,76 @@ function menuCard(code, [name, line_, anim, own, parts], i) {
</li>`;
</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');
const pad = (n) => String(n).padStart(2, '0');
function panel(id, kind, rows, label, hidden) {
function panel(id, kind, rows, label, hidden) {
let n = 0;
let n = 0;
const body = rows.map((row) => {
const body = rows.map((row) => {
const first = n + 1;
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>`;
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('');
}).join('');
const NEXT = {
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];
}[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’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. */
/** 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 = '') {
function miniArt(code, size = 26, tag = '') {
const i = Number(code.slice(1)) - 1;
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] === '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];
const item = (code[0] === 'L' ? LOOPS : PAIRS)[i];
if (!item) return '';
if (!item) return '';
const art = code[0] === 'L' ? loopArt(item[2], `t${tag}${code}`) : pairArt(item[2], `t${tag}${code}`);
const art = code[0] === 'L' ? loopArt(item[2], `t${tag}${code}`) : pairArt(item[2], `t${tag}${code}`);
@@ -527,65 +712,68 @@ function tallyBlock(t) {
@@ -527,65 +712,68 @@ function tallyBlock(t) {
const loopPct = loop + pair ? Math.round((loop / (loop + pair)) * 100) : 50;
const loopPct = loop + pair ? Math.round((loop / (loop + pair)) * 100) : 50;
const marks = t ? [...(t.top.L || []), ...(t.top.P || [])].sort((a, b) => b.n - a.n || (a.code < b.code ? -1 : 1)).slice(0, 6) : [];
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 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';
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">
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">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>`;
</section>`;
}
}
function explorePage({ tally = null } = {}) {
function explorePage({ tally = null } = {}) {
seq = 0;
seq = 0;
const today = pairArt(PAIR_ROWS[0].items[0][2], 'xpair');
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 pairEx = pairArt(PAIR_ROWS[0].items[0][2], 'xpairtab');
const m1 = MENU_ROWS[0].items[0];
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 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 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>
<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>
<noscript><style>.mx-panel[hidden]{display:block!important}</style></noscript>
<header class="mx-hero">
<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’re choosing OpenVibe’s mark, and it’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’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>
</header>
${tallyBlock(tally)}
${tallyBlock(tally)}
<nav class="mx-choose" role="tablist" aria-label="What to choose">
<nav class="mx-choose" role="tablist" aria-label="What to choose">
-<p class="mx-q" role="presentation"><span class="mx-qn">1</span> 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">
<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>
<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>
</div>
-<p class="mx-q" role="presentation"><span class="mx-qn">2</span> Separately: <b>the menu button’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>
<nav class="mx-steps" data-mx-steps aria-label="Jump between the choices" hidden>
<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>
<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>
<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>
</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)}
${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’s logo</a>.</p>
</div>`;
</div>`;
return layout({
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',
path: '/brand/explore',
active: '/brand',
active: '/brand',
robots: 'noindex, follow',
robots: 'noindex, follow',
@@ -598,4 +786,4 @@ ${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}
@@ -598,4 +786,4 @@ ${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}
const LOOPS = LOOP_ROWS.flatMap((r) => r.items);
const LOOPS = LOOP_ROWS.flatMap((r) => r.items);
const PAIRS = PAIR_ROWS.flatMap((r) => r.items);
const PAIRS = PAIR_ROWS.flatMap((r) => r.items);
const MENUS = MENU_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 };
@@ -12,10 +12,11 @@ const explore = require('../server/views/marks-explore');
@@ -12,10 +12,11 @@ const explore = require('../server/views/marks-explore');
(async () => {
(async () => {
await check('fifty distinct takes in each family: no two share their whole recipe', () => {
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]]) {
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)));
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');
@@ -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 () => {
await check('three tabs, a hundred and fifty numbered cards in rows, every one pickable', async () => {
const r = await t.get('/brand/explore');
const r = await t.get('/brand/explore');
assert.strictEqual(r.status, 200);
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');
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(`<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, new RegExp(`<section class="mx-panel" id="${tab}" role="tabpanel"`));
}
}
assert.match(r.text, /<noscript><style>\.mx-panel\[hidden\]\{display:block!important\}<\/style><\/noscript>/, 'without the script every tab shows');
assert.match(r.text, /<noscript><style>\.mx-panel\[hidden\]\{display:block!important\}<\/style><\/noscript>/, 'without the script every tab shows');
- assert.match(r.text, /<h1>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’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.ok((r.text.match(/<section class="mx-row">/g) || []).length >= 15, 'sorted into rows by idea');
assert.match(r.text, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');
assert.match(r.text, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');
assert.match(r.text, /data-mx-mine hidden/, 'your picks wait for a pick');
assert.match(r.text, /data-mx-mine hidden/, 'your picks wait for a pick');
assert.ok(!/data-mx-bar/.test(r.text), 'no bar over the marks: a card is picked where it is');
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 () => {
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');
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]);
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');
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));
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');
@@ -65,6 +70,8 @@ const explore = require('../server/views/marks-explore');
const key = (guest.headers.get('set-cookie') || '').match(/ov_mx_voter=([^;]+)/);
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(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');
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]}` });
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');
assert.deepStrictEqual(again.json().mine, ['L03'], 'the same browser replaces its picks');
const ana = t.network.addUser('ana');
const ana = t.network.addUser('ana');
@@ -77,15 +84,15 @@ const explore = require('../server/views/marks-explore');
@@ -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'] }, origin: 'https://evil.example' })).status, 403, 'another site cannot cast a ballot');
assert.strictEqual((await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L01'] } })).status, 403, 'nor a request that says nothing of where it came from');
assert.strictEqual((await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L01'] } })).status, 403, 'nor a request that says nothing of where it came from');
const page = await t.get('/brand/explore');
const page = await t.get('/brand/explore');
- assert.match(page.text, /<section class="mx-tally" data-mx-tally[\s\S]*?2 people 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 () => {
await check('the steps bar and "where next" connect the two choices', async () => {
const r = await t.get('/brand/explore');
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>/);
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 () => {
await check('the files at /brand/:file still answer', async () => {