openvibers/openvibe.design Commits

openvibe.design/brand/explore: a hundred OpenVibe marks for feedback (50 Loops, 50 Pairs) (#58)

OP OpenVibers committed 12 days ago · fe928dcf43 · 5 files, +364 −0

Changes

ddiff --git a/public/css/marks.css b/public/css/marks.css
nnew file mode 100644
iindex 0000000..45f2897
---- /dev/null
++++ b/public/css/marks.css
@@@ -0,0 +1,51 @@
++/* /brand/explore: a hundred takes on the OpenVibe marks (server/views/marks-explore.js). Theme variables from the
++ Frame; each mark keeps its own colours. Names in the brand face. */
++.mx-wrap{--mx-face:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;max-width:1180px;margin:0 auto;padding:clamp(18px,4vw,44px) clamp(16px,3vw,32px) 120px;color:var(--text-primary,#e8edf3)}
++.mx-wrap svg{display:block;overflow:visible}
++.mx-hero{display:grid;gap:12px;max-width:820px;margin:0 0 clamp(24px,4vw,40px)}
++.mx-eye{margin:0;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent,#4f7cff)}
++.mx-hero h1{margin:0;font-family:var(--mx-face);font-weight:900;font-size:clamp(30px,4.6vw,52px);line-height:1.05;letter-spacing:-.02em;text-wrap:balance}
++.mx-lede{margin:0;max-width:68ch;font-size:16.5px;line-height:1.6;color:var(--text-secondary,#b4bfcc)}
++.mx-jump{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px}
++.mx-jump a{padding:8px 14px;border-radius:999px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;font-weight:650;font-size:14px}
++.mx-jump a:hover{border-color:var(--accent,#4f7cff)}
++.mx-fam{margin-top:clamp(28px,5vw,48px)}
++.mx-fam>h2{margin:0 0 6px;font-family:var(--mx-face);font-weight:900;font-size:clamp(24px,3vw,34px);letter-spacing:-.01em}
++.mx-fam>h2 small,.mx-row h3 small{font-family:Inter,system-ui,sans-serif;font-weight:500;font-size:14px;color:var(--text-muted,#7386a3);letter-spacing:0}
++.mx-row{margin-top:22px}
++.mx-row h3{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;margin:0 0 10px;font-size:17px}
++.mx-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px}
++.mx-card{position:relative;display:grid;gap:8px;padding:10px 10px 12px;border-radius:18px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828);scroll-margin-top:90px}
++.mx-card:target{outline:2px solid var(--accent,#4f7cff);outline-offset:2px}
++.mx-card.is-picked{border-color:color-mix(in srgb,#8b5cf6 70%,var(--border,#1f2d47));box-shadow:0 0 0 1px color-mix(in srgb,#8b5cf6 55%,transparent),0 10px 30px color-mix(in srgb,#8b5cf6 18%,transparent)}
++.mx-stage{display:grid;place-items:center;aspect-ratio:1;border-radius:13px;color:#e8edf3;background:radial-gradient(ellipse 70% 60% at 50% 45%,color-mix(in srgb,#8b5cf6 14%,transparent),transparent 70%),#0d1322}
++.mx-stage svg{width:72%;height:auto}
++.mx-sizes{display:grid;grid-template-columns:1fr 1fr;gap:6px}
++.mx-sizes span{display:flex;align-items:center;justify-content:center;gap:10px;padding:8px 6px;border-radius:10px}
++.mx-dark{background:#0d1322;color:#e8edf3}
++.mx-light{background:#f4f6fb;color:#0f1419}
++.mx-name{margin:2px 2px 0;font-size:14px;line-height:1.3}
++.mx-name b{font-family:var(--mx-face);font-weight:900;color:var(--accent,#4f7cff);margin-right:2px}
++.mx-name small{display:block;color:var(--text-muted,#7386a3);font-size:12.5px}
++.mx-line{margin:0 2px;font-size:12.5px;line-height:1.4;color:var(--text-secondary,#b4bfcc)}
++.mx-pick{justify-self:start;padding:7px 12px;border-radius:999px;border:1px solid var(--border,#1f2d47);background:transparent;color:inherit;font:inherit;font-size:13px;font-weight:650;cursor:pointer;min-height:36px}
++.mx-pick:hover{border-color:#8b5cf6}
++.mx-pick[aria-pressed=true]{background:#8b5cf6;border-color:#8b5cf6;color:#fff}
++.mx-fine{margin-top:40px;font-size:14px;color:var(--text-muted,#7386a3)}
++.mx-fine a{color:var(--accent,#4f7cff)}
++/* Hover or focus: the mark draws itself in (strokes measure 100 along their length); fills fade in. */
++@media (hover:hover) and (prefers-reduced-motion:no-preference){
++ .mx-card:hover .mx-stage .mx-s,.mx-card:focus-within .mx-stage .mx-s{animation:mxDraw .9s cubic-bezier(.4,0,.2,1) both}
++ .mx-card:hover .mx-stage .mx-f,.mx-card:focus-within .mx-stage .mx-f{animation:mxFade .9s ease both}
++}
++@keyframes mxDraw{from{stroke-dasharray:100;stroke-dashoffset:100}to{stroke-dasharray:100;stroke-dashoffset:0}}
++@keyframes mxFade{from{opacity:0}to{opacity:1}}
++html[data-ov-motion=reduced] .mx-card .mx-s,html[data-ov-motion=reduced] .mx-card .mx-f{animation:none!important}
++/* The picks: a bar at the bottom while there are any. */
++.mx-bar{position:fixed;left:50%;bottom:calc(16px + env(safe-area-inset-bottom,0px) + var(--ovnav-appbar-h,0px));transform:translateX(-50%);z-index:60;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px;max-width:calc(100vw - 24px);padding:10px 12px 10px 16px;border-radius:18px;border:1px solid color-mix(in srgb,#8b5cf6 55%,var(--border,#1f2d47));background:color-mix(in srgb,var(--bg-secondary,#101828) 94%,transparent);backdrop-filter:blur(10px);box-shadow:0 16px 40px rgba(0,0,0,.35);font-size:14px}
++.mx-bar b{font-family:var(--mx-face);font-weight:900;overflow-wrap:anywhere}
++.mx-btn{padding:7px 12px;border-radius:999px;border:0;background:#8b5cf6;color:#fff;font:inherit;font-size:13px;font-weight:700;cursor:pointer;min-height:36px}
++.mx-btn-quiet{background:transparent;color:inherit;border:1px solid var(--border,#1f2d47)}
++@media (max-width:1100px){.mx-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
++@media (max-width:860px){.mx-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
++@media (max-width:600px){.mx-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.mx-sizes{grid-template-columns:1fr}.mx-line{display:none}}
ddiff --git a/public/js/marks.js b/public/js/marks.js
nnew file mode 100644
iindex 0000000..8acd61a
---- /dev/null
++++ b/public/js/marks.js
@@@ -0,0 +1,47 @@
++/* /brand/explore: pick the marks you like. The picks live in the page's link (#picks=L03,P17), so a link sent to
++ someone opens with them marked, and in this browser for next time. Copy puts "L03, P17" on the clipboard. */
++(function () {
++ 'use strict';
++ var KEY = 'ov_mark_picks';
++ function init() {
++ var bar = document.querySelector('[data-mx-bar]');
++ if (!bar || bar.getAttribute('data-ready')) return;
++ bar.setAttribute('data-ready', '1');
++ var out = bar.querySelector('[data-mx-picks]');
++ var picks = [];
++ var fromHash = /(?:^#|&)picks=([A-Z0-9,]+)/.exec(location.hash || '');
++ if (fromHash) picks = fromHash[1].split(',').filter(function (c) { return /^[LP]\d{2}$/.test(c); });
++ else { try { picks = JSON.parse(localStorage.getItem(KEY) || '[]').filter(function (c) { return /^[LP]\d{2}$/.test(c); }); } catch (e) { picks = []; } }
++ function order(list) { return list.slice().sort(); }
++ function link() { return location.origin + location.pathname + (picks.length ? '#picks=' + order(picks).join(',') : ''); }
++ function render() {
++ document.querySelectorAll('[data-pick]').forEach(function (b) {
++ var on = picks.indexOf(b.getAttribute('data-pick')) !== -1;
++ b.setAttribute('aria-pressed', on ? 'true' : 'false');
++ b.textContent = on ? 'Picked ' + b.getAttribute('data-pick') : 'Pick ' + b.getAttribute('data-pick');
++ var card = b.closest('.mx-card');
++ if (card) card.classList.toggle('is-picked', on);
++ });
++ bar.hidden = !picks.length;
++ out.textContent = order(picks).join(', ');
++ try { localStorage.setItem(KEY, JSON.stringify(picks)); } catch (e) { /* a private window keeps them for the page only */ }
++ if (history.replaceState) history.replaceState(null, '', picks.length ? '#picks=' + order(picks).join(',') : location.pathname);
++ }
++ function say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }
++ function copy(text, btn) {
++ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { say(btn, 'Copied'); }, function () { window.prompt('Copy this:', text); });
++ else window.prompt('Copy this:', text);
++ }
++ document.addEventListener('click', function (e) {
++ var b = e.target.closest('[data-pick]');
++ if (b) { var c = b.getAttribute('data-pick'); var i = picks.indexOf(c); if (i === -1) picks.push(c); else picks.splice(i, 1); render(); return; }
++ if (e.target.closest('[data-mx-copy]')) copy('My picks: ' + order(picks).join(', ') + ' (' + link() + ')', e.target.closest('[data-mx-copy]'));
++ if (e.target.closest('[data-mx-link]')) copy(link(), e.target.closest('[data-mx-link]'));
++ if (e.target.closest('[data-mx-clear]')) { picks = []; render(); }
++ });
++ render();
++ if (fromHash && picks.length) { var first = document.getElementById(order(picks)[0]); if (first) first.scrollIntoView({ block: 'center' }); }
++ }
++ document.addEventListener('ov:boost:load', init);
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
++})();
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex ab943d9..fd6ec79 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -68,6 +68,8 @@ function createPageRoutes(ctx) {
// ── Production patterns: one data-backed page and generated native spec per pattern ──
// ── The OpenVibe mark, and its files ──
r.get('/brand', (req, res) => page(res, 200, brandView.brandPage({ base: layoutBase() }), req.viewer));
++ // A hundred takes on the Loop and the Pair, for feedback (the owner, 2026-11-01). Before /brand/:file.
++ r.get('/brand/explore', (req, res) => page(res, 200, require('../views/marks-explore').explorePage(), req.viewer));
r.get('/brand/:file', (req, res) => {
const svg = brandView.file(req.params.file);
if (!svg) return page(res, 404, views.notFound(), req.viewer);
ddiff --git a/server/views/marks-explore.js b/server/views/marks-explore.js
nnew file mode 100644
iindex 0000000..319c0e5
---- /dev/null
++++ b/server/views/marks-explore.js
@@@ -0,0 +1,230 @@
++'use strict';
++/**
++ * openvibe.design/brand/explore — a hundred takes on the two OpenVibe marks, for feedback (the owner, 2026-11-01:
++ * "the V and the OV versions… a hundred different variations… 50 of each… put it on OpenVibe somewhere so that I can
++ * link it to other people to get their feedback").
++ *
++ * L01–L50 the Loop: a ring opened at the top, with the V folded into it (the mark of 31 October morning)
++ * P01–P50 the Pair: a ring O and a round V, the ground showing where they cross (the mark since 31 October)
++ *
++ * Each family is ten structures × five colour treatments, drawn from the same geometry as openvibe-shared/mark.js
++ * (the Pair) and Shared 4.58.0's ov-mark.js (the Loop), in a 100-unit box centred on (50, 50). The rules that hold
++ * for any OpenVibe mark hold here: centred, level, no liquid fusions, no pac-man shapes. Every card is numbered, so
++ * feedback is a list like "P17, L04"; a card can be picked, and the picks travel in the page's link.
++ */
++const { layout, esc } = require('../render/layout');
++
++const BLUE = '#4f7cff';
++const VIOLET = '#8b5cf6';
++const INK = '#e8edf3';
++const f = (v) => +Number(v).toFixed(2);
++const rad = (d) => (d * Math.PI) / 180;
++function mix(hex, to, t) {
++ const a = parseInt(hex.slice(1), 16), b = parseInt(to.slice(1), 16);
++ return `#${[16, 8, 0].map((sh) => Math.round(((a >> sh) & 255) * (1 - t) + ((b >> sh) & 255) * t).toString(16).padStart(2, '0')).join('')}`;
++}
++const light = (h, t = 0.3) => mix(h, '#ffffff', t);
++const deep = (h, t = 0.16) => mix(h, '#000000', t);
++
++// ── Colour treatments ────────────────────────────────────────────────────────────────────────────────────────────
++// Each returns paint for the two parts of a mark (a = the ring, b = the V) and the gradients it needs.
++const TONES = [
++ { id: 'grad', name: 'Blue to violet', line: 'the network\'s two colours as one sweep' },
++ { id: 'depth', name: 'Two colours, depth', line: 'each part in its own colour, lit from above' },
++ { id: 'flat', name: 'Flat', line: 'solid colours, nothing blended: the small-size test' },
++ { id: 'rev', name: 'Reversed', line: 'violet first, blue second' },
++ { id: 'ink', name: 'One ink', line: 'one colour, for print, stamps and a busy photo' },
++];
++function paint(tone, id) {
++ const lin = (gid, c1, c2, x1 = 0, y1 = 0, x2 = 1, y2 = 1) => `<linearGradient id="${gid}" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/></linearGradient>`;
++ if (tone === 'grad') return { defs: lin(`${id}g`, BLUE, VIOLET), a: `url(#${id}g)`, b: `url(#${id}g)` };
++ if (tone === 'depth') return { defs: lin(`${id}a`, light(BLUE), deep(BLUE), 0, 0, 0, 1) + lin(`${id}b`, light(VIOLET), deep(VIOLET), 0, 0, 0, 1), a: `url(#${id}a)`, b: `url(#${id}b)` };
++ if (tone === 'flat') return { defs: '', a: BLUE, b: VIOLET };
++ if (tone === 'rev') return { defs: lin(`${id}r`, VIOLET, BLUE), a: VIOLET, b: BLUE, sweep: `url(#${id}r)` };
++ return { defs: '', a: 'currentColor', b: 'currentColor' };
++}
++
++// ── The Loop ─────────────────────────────────────────────────────────────────────────────────────────────────────
++// A ring of radius r opened `open`° each side of the top; the V runs from the two ends of the opening to a tip `tip`
++// units below the centre. Every variant is centred on (50, 50) with its dot, if it has one.
++function loopGeo({ r = 33, sw = 13, open = 33, tip = 2, dot = null }) {
++ const a = rad(open);
++ const extra = dot === 'above' ? 4 : 0;
++ const cy = 50 + extra;
++ const L = [f(50 - r * Math.sin(a)), f(cy - r * Math.cos(a))];
++ const R = [f(50 + r * Math.sin(a)), f(cy - r * Math.cos(a))];
++ const T = [50, f(cy + tip)];
++ return { r, sw, cy, L, R, T, a };
++}
++const loopPath = (g) => `M${g.L[0]} ${g.L[1]}A${g.r} ${g.r} 0 1 0 ${g.R[0]} ${g.R[1]}L${g.T[0]} ${g.T[1]}Z`;
++const arcPath = (g) => `M${g.L[0]} ${g.L[1]}A${g.r} ${g.r} 0 1 0 ${g.R[0]} ${g.R[1]}`;
++const veePath = (g, inset = 0) => {
++ const k = inset / g.r;
++ const L = [f(g.L[0] + (50 - g.L[0]) * k), f(g.L[1] + (g.cy - g.L[1]) * k)];
++ const R = [f(g.R[0] - (g.R[0] - 50) * k), f(g.R[1] + (g.cy - g.R[1]) * k)];
++ return `M${L[0]} ${L[1]}L${g.T[0]} ${g.T[1]}L${R[0]} ${R[1]}`;
++};
++/** A rounded square through the same opening (superellipse, n = 4), as points. */
++function squirclePath(g) {
++ const pts = [];
++ const n = 4, steps = 90;
++ const start = -90 + (g.open || 33), end = 270 - (g.open || 33);
++ for (let i = 0; i <= steps; i++) {
++ const t = rad(start + ((end - start) * i) / steps);
++ const c = Math.cos(t), s = Math.sin(t);
++ const x = 50 + g.r * Math.sign(c) * Math.abs(c) ** (2 / n);
++ const y = g.cy + g.r * Math.sign(s) * Math.abs(s) ** (2 / n);
++ pts.push(`${f(x)} ${f(y)}`);
++ }
++ // From the right end of the opening, down to the tip and back up to the left end.
++ return `M${pts[pts.length - 1]}L${g.T[0]} ${g.T[1]}L${pts[0]}${pts.map((p) => `L${p}`).join('')}`;
++}
++
++const LOOPS = [
++ { id: 'today', name: 'Today', line: 'the Loop as the sites drew it: dot above the opening', g: { r: 31, sw: 12, open: 33, tip: 2, dot: 'above' } },
++ { id: 'refined', name: 'Refined', line: 'a heavier stroke, the V dipping to the middle', g: { r: 32, sw: 15, open: 30, tip: 0 } },
++ { id: 'dot', name: 'Refined, dot', line: 'the site\'s dot riding in the opening', g: { r: 32, sw: 15, open: 30, tip: 0, dot: 'in' } },
++ { id: 'bold', name: 'Bold', line: 'the strongest at 16 px: thick, a shorter V', g: { r: 30, sw: 19, open: 30, tip: -6 } },
++ { id: 'wide', name: 'Wide open', line: 'a generous opening, a shallow V', g: { r: 32, sw: 14, open: 45, tip: 4 } },
++ { id: 'narrow', name: 'Narrow', line: 'a tight opening, the V almost a notch', g: { r: 32, sw: 14, open: 22, tip: 0 } },
++ { id: 'deep', name: 'Deep V', line: 'the V reaching most of the way down', g: { r: 32, sw: 13, open: 32, tip: 17 } },
++ { id: 'apart', name: 'Apart', line: 'the ring and the V drawn as two strokes with the ground between', g: { r: 32, sw: 12, open: 34, tip: 4 }, apart: true },
++ { id: 'coin', name: 'Coin', line: 'a solid disc with the V cut through it', g: { r: 33, sw: 12, open: 33, tip: 4 }, coin: true },
++ { id: 'square', name: 'Rounded square', line: 'the Loop as an app tile shape', g: { r: 32, sw: 13, open: 30, tip: 2 }, square: true },
++];
++
++function loopArt(v, tone, id) {
++ const g = { ...loopGeo(v.g), open: v.g.open };
++ const p = paint(tone, id);
++ const body = [];
++ const stroke = (d, color, w = g.sw, extra = '') => `<path class="mx-s" d="${d}" pathLength="100" fill="none" stroke="${color}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
++ const sweep = tone === 'rev' ? p.sweep : p.a;
++ let defs = p.defs;
++ if (v.coin) {
++ defs += `<mask id="${id}m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100"><rect width="100" height="100" fill="#fff"/><path d="${veePath(g)}" fill="none" stroke="#000" stroke-width="${g.sw}" stroke-linecap="round" stroke-linejoin="round"/></mask>`;
++ body.push(`<circle class="mx-f" cx="50" cy="${g.cy}" r="${f(g.r + g.sw / 2)}" fill="${sweep}" mask="url(#${id}m)"/>`);
++ } else if (v.apart) {
++ defs += `<mask id="${id}m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100"><rect width="100" height="100" fill="#fff"/><path d="${veePath(g, 6)}" fill="none" stroke="#000" stroke-width="${g.sw + 7}" stroke-linecap="round" stroke-linejoin="round"/></mask>`;
++ body.push(`<g mask="url(#${id}m)">${stroke(arcPath(g), p.a)}</g>`, stroke(veePath(g, 6), tone === 'grad' || tone === 'ink' ? p.b : p.b));
++ } else if (v.square) {
++ body.push(stroke(squirclePath(g), sweep));
++ } else if (tone === 'depth' || tone === 'flat') {
++ // Two colours: the ring in one, the V in the other, the ground between them where the V meets the ring.
++ defs += `<mask id="${id}m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100"><rect width="100" height="100" fill="#fff"/><path d="${veePath(g)}" fill="none" stroke="#000" stroke-width="${g.sw + 5}" stroke-linecap="round" stroke-linejoin="round"/></mask>`;
++ body.push(`<g mask="url(#${id}m)">${stroke(arcPath(g), p.a)}</g>`, stroke(veePath(g), p.b));
++ } else {
++ body.push(stroke(loopPath(g), sweep));
++ }
++ if (v.g.dot === 'above') body.push(`<circle class="mx-f" cx="50" cy="${f(g.L[1] - 6)}" r="4.6" fill="${tone === 'ink' ? 'currentColor' : INK}"/>`);
++ if (v.g.dot === 'in') body.push(`<circle class="mx-f" cx="50" cy="${f(g.L[1] - 3)}" r="4.8" fill="${tone === 'ink' ? 'currentColor' : VIOLET}"/>`);
++ return { defs, body: body.join('') };
++}
++
++// ── The Pair ─────────────────────────────────────────────────────────────────────────────────────────────────────
++// openvibe-shared/mark.js's pair(): ring 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 ring's outer radius from its centre; the whole centred on (50, 50).
++function pairGeo({ r = 20, sw = 13, vsw = 13, a = 0.86, t = 1.5, b = 2.16, halo = 7 }) {
++ const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;
++ return {
++ r, sw, vsw, R, cx: f(cx), cy, halo,
++ A: [f(cx + a * R), f(cy - R + vsw / 2)], T: [f(cx + t * R), f(cy + R - vsw / 2)], B: [f(cx + b * R), f(cy - R + vsw / 2)],
++ };
++}
++const ring = (g) => `M${g.cx} ${f(g.cy - g.r)}A${g.r} ${g.r} 0 1 1 ${g.cx} ${f(g.cy + g.r)}A${g.r} ${g.r} 0 1 1 ${g.cx} ${f(g.cy - g.r)}`;
++const vee = (g) => `M${g.A[0]} ${g.A[1]}L${g.T[0]} ${g.T[1]}L${g.B[0]} ${g.B[1]}`;
++function squircleRing(g) {
++ const pts = [];
++ for (let i = 0; i <= 96; i++) {
++ const t = rad(-90 + (360 * i) / 96), c = Math.cos(t), s = Math.sin(t);
++ pts.push(`${f(g.cx + g.r * Math.sign(c) * Math.abs(c) ** 0.5)} ${f(g.cy + g.r * Math.sign(s) * Math.abs(s) ** 0.5)}`);
++ }
++ return `M${pts.join('L')}Z`;
++}
++
++const PAIRS = [
++ { id: 'pair', name: 'Pair', line: 'the mark today: ring 20 + 13, the V 13', g: {} },
++ { id: 'heavy', name: 'Heavy', line: 'the favicon form: thick strokes that hold at 16 px', g: { r: 18, sw: 18, vsw: 17 } },
++ { id: 'light', name: 'Light', line: 'a thinner line, more air inside the O', g: { r: 21, sw: 9, vsw: 9 } },
++ { id: 'gap', name: 'Wide gap', line: 'more ground around the V where it crosses', g: { halo: 13 } },
++ { id: 'touch', name: 'No gap', line: 'the V laid straight over the ring', g: { halo: 0 } },
++ { id: 'deep', name: 'Deep overlap', line: 'the V crossing further into the O', g: { a: 0.6, t: 1.3, b: 2.0 } },
++ { id: 'apart', name: 'Side by side', line: 'the V beside the O, just touching', g: { a: 1.12, t: 1.72, b: 2.32, halo: 6 } },
++ { id: 'behind', name: 'V behind', line: 'the O in front; the gap cut around the ring instead', g: {}, behind: true },
++ { id: 'outline', name: 'Outline', line: 'drawn as contours, hollow strokes', g: { r: 20, sw: 13, vsw: 13 }, outline: true },
++ { id: 'squircle', name: 'Squircle O', line: 'the O as a rounded square, like an app tile', g: {}, square: true },
++];
++
++function pairArt(v, tone, id) {
++ const g = pairGeo(v.g);
++ const p = paint(tone, id);
++ const O = v.square ? squircleRing(g) : ring(g);
++ const S = (d, color, w, extra = '') => `<path class="mx-s" d="${d}" pathLength="100" fill="none" stroke="${color}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
++ const oPaint = tone === 'rev' ? VIOLET : p.a, vPaint = tone === 'rev' ? BLUE : p.b;
++ let defs = p.defs;
++ const body = [];
++ if (v.outline) {
++ // Contours: a wide stroke in colour, the same path again narrower in the ground's colour.
++ defs += `<mask id="${id}m" maskUnits="userSpaceOnUse" x="-10" y="-10" width="120" height="120"><rect x="-10" y="-10" width="120" height="120" fill="#fff"/><path d="${vee(g)}" fill="none" stroke="#000" stroke-width="${g.vsw + g.halo}" stroke-linecap="round" stroke-linejoin="round"/><path d="${O}" fill="none" stroke="#000" stroke-width="${g.sw - 6}"/></mask>`;
++ defs += `<mask id="${id}n" maskUnits="userSpaceOnUse" x="-10" y="-10" width="120" height="120"><rect x="-10" y="-10" width="120" height="120" fill="#fff"/><path d="${vee(g)}" fill="none" stroke="#000" stroke-width="${g.vsw - 6}" stroke-linecap="round" stroke-linejoin="round"/></mask>`;
++ body.push(`<g mask="url(#${id}m)">${S(O, oPaint, g.sw)}</g>`, `<g mask="url(#${id}n)">${S(vee(g), vPaint, g.vsw)}</g>`);
++ return { defs, body: body.join('') };
++ }
++ if (g.halo > 0) {
++ const cut = v.behind ? O : vee(g);
++ const w = v.behind ? g.sw + g.halo : g.vsw + g.halo;
++ defs += `<mask id="${id}m" maskUnits="userSpaceOnUse" x="-10" y="-10" width="120" height="120"><rect x="-10" y="-10" width="120" height="120" fill="#fff"/><path d="${cut}" fill="none" stroke="#000" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/></mask>`;
++ }
++ if (v.behind) {
++ body.push(`<g${g.halo > 0 ? ` mask="url(#${id}m)"` : ''}>${S(vee(g), vPaint, g.vsw)}</g>`, S(O, oPaint, g.sw));
++ } else {
++ body.push(`<g${g.halo > 0 ? ` mask="url(#${id}m)"` : ''}>${S(O, oPaint, g.sw)}</g>`, S(vee(g), vPaint, g.vsw));
++ }
++ return { defs, body: body.join('') };
++}
++
++// ── Cards ────────────────────────────────────────────────────────────────────────────────────────────────────────
++let seq = 0;
++const svg = (art, size, label) => `<svg viewBox="0 0 100 100" width="${size}" height="${size}" role="img" aria-label="${esc(label)}">${art.defs ? `<defs>${art.defs}</defs>` : ''}${art.body}</svg>`;
++function card(code, family, v, tone) {
++ const draw = (sz) => { const id = `m${++seq}`; return svg(family === 'L' ? loopArt(v, tone.id, id) : pairArt(v, tone.id, id), sz, `${code} ${v.name}, ${tone.name}`); };
++ return `<li class="mx-card" id="${code}" data-code="${code}">
++<div class="mx-stage">${draw(132)}</div>
++<div class="mx-sizes" aria-label="At small sizes"><span class="mx-dark">${draw(16)}${draw(32)}</span><span class="mx-light">${draw(16)}${draw(32)}</span></div>
++<p class="mx-name"><b>${code}</b> ${esc(v.name)} <small>${esc(tone.name)}</small></p>
++<p class="mx-line">${esc(v.line)}; ${esc(tone.line)}.</p>
++<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false">Pick ${code}</button>
++</li>`;
++}
++function family(letter, list) {
++ let n = 0;
++ return list.map((v) => `<section class="mx-row" aria-labelledby="mx-${letter}-${v.id}"><h3 id="mx-${letter}-${v.id}">${esc(v.name)} <small>${esc(v.line)}</small></h3>
++<ul class="mx-grid">${TONES.map((t) => card(`${letter}${String(++n).padStart(2, '0')}`, letter, v, t)).join('')}</ul></section>`).join('');
++}
++
++function explorePage() {
++ seq = 0;
++ const body = `<div class="mx-wrap">
++<header class="mx-hero">
++<p class="mx-eye">The OpenVibe mark · for feedback</p>
++<h1>A hundred marks. Which ones feel like OpenVibe?</h1>
++<p class="mx-lede">Fifty takes on the <b>Loop</b> (the V folded into an open ring) and fifty on the <b>Pair</b> (a ring O and a round V). Each is shown large, then at 16 and 32 pixels on dark and on light, because a logo lives small. Pick the ones you like and send the numbers, or the link: it opens this page with your picks marked.</p>
++<nav class="mx-jump" aria-label="Jump to"><a href="#loop">The Loop · L01–L50</a><a href="#pair">The Pair · P01–P50</a></nav>
++</header>
++<section class="mx-fam" id="loop" aria-labelledby="mx-loop-h"><h2 id="mx-loop-h">The Loop <small>L01–L50 · ten shapes, five colourings each</small></h2>${family('L', LOOPS)}</section>
++<section class="mx-fam" id="pair" aria-labelledby="mx-pair-h"><h2 id="mx-pair-h">The Pair <small>P01–P50 · ten shapes, five colourings each</small></h2>${family('P', PAIRS)}</section>
++<p class="mx-fine">Every mark here is centred on its own middle, level, and drawn from the same geometry as the mark the sites use (openvibe-shared/mark). None of these replaces it until it is chosen: <a href="/brand">the mark today</a>.</p>
++</div>
++<div class="mx-bar" data-mx-bar hidden><span>Your picks: <b data-mx-picks></b></span><button type="button" class="mx-btn" data-mx-copy>Copy</button><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></div>`;
++ return layout({
++ title: 'A hundred OpenVibe marks',
++ description: 'Fifty takes on the Loop and fifty on the Pair, the two OpenVibe marks, at every size: pick the ones that feel like OpenVibe.',
++ path: '/brand/explore',
++ active: '/brand',
++ robots: 'noindex, follow',
++ styles: ['css/marks.css'],
++ scripts: ['js/marks.js'],
++ body,
++ });
++}
++
++module.exports = { explorePage, LOOPS, PAIRS, TONES, loopArt, pairArt };
ddiff --git a/test/brand-explore.test.js b/test/brand-explore.test.js
nnew file mode 100644
iindex 0000000..1b47cce
---- /dev/null
++++ b/test/brand-explore.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++/**
++ * /brand/explore: a hundred takes on the two OpenVibe marks for feedback (the owner, 2026-11-01). Fifty Loops and
++ * fifty Pairs, each numbered and pickable, each drawn large and at 16 and 32 px on dark and light; the page is not
++ * indexed (they are proposals, not the mark), and /brand/:file still serves the SVG files.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('a hundred numbered marks, fifty of each, 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" id="([LP]\d{2})" data-code="\1">/g)].map((m) => m[1]);
++ assert.strictEqual(codes.length, 100);
++ assert.strictEqual(new Set(codes).size, 100, 'each number once');
++ assert.strictEqual(codes.filter((c) => c[0] === 'L').length, 50);
++ assert.strictEqual(codes.filter((c) => c[0] === 'P').length, 50);
++ assert.ok(codes.every((c) => r.text.includes(`data-pick="${c}"`)), 'each has its Pick button');
++ assert.match(r.text, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');
++ assert.match(r.text, /data-mx-bar hidden/, 'the picks bar waits for a pick');
++ const ids = [...r.text.matchAll(/<(?:linearGradient|mask) id="([^"]+)"/g)].map((m) => m[1]);
++ assert.strictEqual(new Set(ids).size, ids.length, 'every gradient and mask id is unique on the page');
++ });
++ await check('the files at /brand/:file still answer', async () => {
++ const r = await t.get('/brand/openvibe-mark.svg');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type') || '', /image\/svg\+xml/);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });