openvibers/openvibe.design Commits

/brand/explore: the Loop or the Pair, then the menu button; rows, labels, fonts, motion, phone rails (#59)

OP OpenVibers committed 12 days ago · 39adf9b8f2 · 4 files, +966 −247

Two questions, said plainly at the top: the mark (the Loop or the Pair, side by side with 'or') and, separately,
the menu button beside the name, which stays whichever mark wins. Fifty takes on each, now sorted into rows by
idea (weight, shape, construction, mixed, finish, app icon) and each a real recipe of its own; fifty menu-button
ideas (dots, tiles, search, sparks, the mark's own shapes, lines) that make their move on hover, tap or arrival and
show they are open.

Every mark sits beside a site's name (Live, Video, Pics… or one you type) in a font you pick (Nunito today, Outfit,
Sora, Manrope, Plus Jakarta, Fredoka, Lexend, Rubik, Space Grotesk, Unbounded) and a style (OpenVibe.Live, OpenVibe
Live, stacked). Marks draw themselves in as they scroll into view, then idle (breathe, nod, drift, rock, float);
nothing spins; Still and reduced motion stop it. On a phone every row is a sideways rail, and Sheet shows all
fifty at once (tap to pick), so the page is a few swipes long instead of a hundred cards. Picks show as small
marks in the bar and copy as 'The mark: the Loop (…), the Pair (…). The menu button: ….'

Changes

ddiff --git a/public/css/marks.css b/public/css/marks.css
iindex 45f2897..8efad2a 100644
---- a/public/css/marks.css
++++ b/public/css/marks.css
@@@ -1,51 +1,245 @@
--/* /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)}
++/* /brand/explore: help choose the OpenVibe mark and the menu button (server/views/marks-explore.js). Theme variables
++ from the Frame; each mark keeps its own colours. Tight on a phone: every row is a sideways rail there, and "Sheet"
++ shows all fifty at once. Motion: cards rise and their marks draw in as they arrive, then idle; nothing spins, and
++ nothing moves for a reader who asks for less motion (or presses Still). */
++.mx-wrap{--mx-face:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;--mx-lockface:'OV Brand',Nunito,sans-serif;--mx-card:var(--bg-secondary,#101828);--mx-line:var(--border,#1f2d47);--mx-muted:var(--text-muted,#7386a3);max-width:1180px;margin:0 auto;padding:clamp(14px,3vw,36px) clamp(12px,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-defs{position:absolute;width:0;height:0;overflow:hidden}
++.mx-wrap [hidden],.mx-bar[hidden]{display:none!important}
++.mx-hero{display:grid;gap:8px;max-width:860px;margin:0 0 clamp(14px,2.4vw,24px)}
.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-hero h1{margin:0;font-family:var(--mx-face);font-weight:900;font-size:clamp(26px,4.2vw,46px);line-height:1.05;letter-spacing:-.02em;text-wrap:balance}
++.mx-lede,.mx-how{margin:0;max-width:72ch;font-size:clamp(14.5px,1.6vw,16px);line-height:1.55;color:var(--text-secondary,#b4bfcc)}
++.mx-lede b,.mx-how b{color:var(--text-primary,#e8edf3)}
++
++/* The choice: 1, the mark (the Loop or the Pair, side by side); 2, separately, the menu button's look. */
++.mx-choose{display:grid;gap:8px;margin:0 0 12px}
++.mx-q{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 8px;margin:6px 0 0;font-size:15px;color:var(--text-secondary,#b4bfcc)}
++.mx-q b{color:var(--text-primary,#e8edf3);font-family:var(--mx-face);font-weight:900;font-size:17px}
++.mx-q small{font-size:13px;color:var(--mx-muted)}
++.mx-qn{display:inline-grid;place-items:center;align-self:center;width:22px;height:22px;border-radius:50%;background:#8b5cf6;color:#fff;font-size:12.5px;font-weight:800}
++.mx-vs{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:stretch;gap:10px}
++.mx-or{align-self:center;font-family:var(--mx-face);font-weight:900;font-size:15px;letter-spacing:.04em;text-transform:uppercase;color:var(--mx-muted)}
++.mx-tab{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:16px;border:1px solid var(--mx-line);background:var(--mx-card);color:inherit;text-decoration:none;transition:border-color .2s,background .2s}
++.mx-tab:hover{border-color:color-mix(in srgb,#8b5cf6 55%,var(--mx-line))}
++.mx-tab[aria-selected=true]{border-color:#8b5cf6;background:color-mix(in srgb,#8b5cf6 12%,var(--mx-card));box-shadow:0 0 0 1px #8b5cf6 inset}
++.mx-tab-art{flex:none;display:grid;place-items:center;width:62px;height:62px;border-radius:14px;background:#0d1322;color:#e8edf3}
++.mx-tab-art svg{width:46px;height:46px}
++.mx-tab-t{display:grid;gap:2px;min-width:0}
++.mx-tab-t b{font-family:var(--mx-face);font-weight:900;font-size:19px;line-height:1.15}
++.mx-tab-t small{font-size:13px;line-height:1.35;color:var(--text-secondary,#b4bfcc)}
++.mx-tab-t em{font-style:normal;font-size:12px;font-weight:700;letter-spacing:.04em;color:var(--mx-muted)}
++.mx-tab-side{padding:8px 12px;border-style:dashed;max-width:620px}
++.mx-tab-side[aria-selected=true]{border-style:solid}
++.mx-tab-side .mx-tab-art{width:46px;height:46px;border-radius:12px}
++.mx-tab-side .mx-tab-t b{font-size:16px}
++
++/* The bar: the name, its font and style, the view. Sticky on wide screens. */
++.mx-tools{display:flex;flex-wrap:wrap;gap:6px 18px;align-items:center;margin:0 0 6px;padding:8px 10px;border-radius:14px;border:1px solid var(--mx-line);background:color-mix(in srgb,var(--mx-card) 92%,transparent);backdrop-filter:blur(10px);z-index:20}
++.mx-tool{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%}
++.mx-tool-k{flex:none;font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--mx-muted)}
++.mx-chips{display:flex;gap:4px;align-items:center;min-width:0;overflow-x:auto;scrollbar-width:none;padding:2px}
++.mx-chips::-webkit-scrollbar{display:none}
++.mx-chip{flex:none;padding:5px 10px;min-height:32px;border-radius:999px;border:1px solid var(--mx-line);background:transparent;color:inherit;font-size:13.5px;font-weight:700;cursor:pointer;white-space:nowrap}
++.mx-chip small{font-family:Inter,system-ui,sans-serif;font-size:10.5px;font-weight:600;color:var(--mx-muted)}
++.mx-chip:hover{border-color:#8b5cf6}
++.mx-chip[aria-pressed=true]{background:#8b5cf6;border-color:#8b5cf6;color:#fff}
++.mx-chip[aria-pressed=true] small{color:#eee8ff}
++.mx-own{flex:none;width:96px;min-height:32px;padding:4px 10px;border-radius:999px;border:1px dashed var(--mx-line);background:transparent;color:inherit;font:inherit;font-size:13.5px}
++.mx-own:focus{outline:none;border-color:#8b5cf6;border-style:solid}
++
++/* Rows of cards. */
++.mx-row{margin-top:18px}
++.mx-wrap .mx-row h3:not(.ovk-card-title){display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;margin:0;font-family:var(--mx-face);font-weight:900;font-size:19px;text-transform:none;letter-spacing:-.01em;color:var(--text-primary,#e8edf3)}
++.mx-row h3 small{font-family:Inter,system-ui,sans-serif;font-weight:600;font-size:12.5px;color:var(--mx-muted)}
++.mx-row-line{margin:2px 0 8px;font-size:13.5px;color:var(--text-secondary,#b4bfcc)}
++.mx-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}
++.mx-card{position:relative;display:grid;align-content:start;gap:6px;padding:8px 8px 10px;border-radius:16px;border:1px solid var(--mx-line);background:var(--mx-card);scroll-margin-top:110px}
.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-card.is-picked{border-color:color-mix(in srgb,#8b5cf6 70%,var(--mx-line));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:5/4;border-radius:12px;color:#e8edf3;background:radial-gradient(ellipse 70% 60% at 50% 45%,color-mix(in srgb,#8b5cf6 14%,transparent),transparent 70%),#0d1322;overflow:hidden}
++.mx-stage>svg{width:66%;height:auto;max-height:86%}
++.mx-lock{display:flex;align-items:center;gap:7px;min-width:0;padding:7px 9px;border-radius:10px;background:#0d1322;color:#e8edf3}
++.mx-lock svg{flex:none}
++.mx-word{display:flex;align-items:baseline;min-width:0;overflow:hidden;white-space:nowrap;font-family:var(--mx-lockface);font-weight:800;font-size:17px;line-height:1.1;letter-spacing:-.01em}
++.mx-word b{font-weight:800}
++.mx-sep,.mx-site{background:linear-gradient(90deg,#7c9bff,#b28cff);-webkit-background-clip:text;background-clip:text;color:transparent}
++.mx-wrap[data-lock=space] .mx-sep{display:none}
++.mx-wrap[data-lock=space] .mx-site{margin-left:.26em;font-weight:500;background:none;color:inherit;opacity:.8}
++.mx-wrap[data-lock=stack] .mx-word{flex-direction:column;align-items:flex-start;line-height:1}
++.mx-wrap[data-lock=stack] .mx-sep{display:none}
++.mx-wrap[data-lock=stack] .mx-site{margin-top:.18em;font-size:.56em;font-weight:800;letter-spacing:.22em;text-transform:uppercase}
.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-sizes span{display:flex;align-items:center;justify-content:center;gap:10px;padding:6px;border-radius:9px}
.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{margin:0 2px;font-size:14px;line-height:1.25}
.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-line{margin:0 2px;font-size:12.5px;line-height:1.35;color:var(--text-secondary,#b4bfcc)}
++.mx-pick{position:relative;justify-self:start;padding:5px 12px;border-radius:999px;border:1px solid var(--mx-line);background:transparent;color:inherit;font:inherit;font-size:13px;font-weight:700;cursor:pointer;min-height:34px}
.mx-pick:hover{border-color:#8b5cf6}
++.mx-pick::after{content:"";position:absolute;inset:-6px -4px}
.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{margin-top:28px;font-size:14px;color:var(--mx-muted)}
.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}
++
++/* Sheet: all fifty at once, just the marks; tap one to pick it. */
++.mx-wrap[data-density=sheet] .mx-grid{grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:8px}
++.mx-wrap[data-density=sheet] .mx-card{padding:5px 5px 7px;gap:4px;border-radius:13px;cursor:pointer}
++.mx-wrap[data-density=sheet] :is(.mx-lock,.mx-sizes,.mx-line,.mb-bars,.mx-row-line){display:none}
++.mx-wrap[data-density=sheet] .mx-stage{aspect-ratio:1}
++.mx-wrap[data-density=sheet] .mx-name{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
++.mx-wrap[data-density=sheet] .mx-pick{position:absolute;top:8px;right:8px;display:grid;place-items:center;width:24px;height:24px;min-height:0;padding:0;border-radius:50%;font-size:0;background:#0d1322cc;color:#e8edf3;border-color:#ffffff38}
++.mx-wrap[data-density=sheet] .mx-pick::before{content:"+";font-size:16px;font-weight:700;line-height:1}
++.mx-wrap[data-density=sheet] .mx-pick[aria-pressed=true]{background:#8b5cf6;border-color:#8b5cf6}
++.mx-wrap[data-density=sheet] .mx-pick[aria-pressed=true]::before{content:"\2713";font-size:13px}
++.mx-wrap[data-density=sheet] .mx-row{margin-top:12px}
++
++/* ── Motion ─────────────────────────────────────────────────────────────────────────────────────────────────────
++ Arrival: transitions (the card rises, strokes draw along their 100-unit length, fills fade). Idle: animations,
++ only while a card is on screen (is-on). Parts turn about their own middles. */
++.mx-stage>svg,.mx-stage .mx-a,.mx-stage .mx-b,.mx-stage .mx-dot,.mx-stage .mx-glow{transform-box:fill-box;transform-origin:center}
++.mx-wrap.mx-live .mx-card{transition:opacity .5s ease var(--d,0s),transform .6s cubic-bezier(.2,.8,.2,1) var(--d,0s),border-color .2s,box-shadow .2s}
++.mx-wrap.mx-live .mx-card:not(.is-in){opacity:0;transform:translateY(14px)}
++.mx-wrap.mx-live .mx-stage .mx-s:not([stroke-dasharray]){stroke-dasharray:100;transition:stroke-dashoffset 1.1s cubic-bezier(.45,0,.2,1) calc(var(--d,0s) + .1s)}
++.mx-wrap.mx-live .mx-card:not(.is-in) .mx-stage .mx-s:not([stroke-dasharray]){stroke-dashoffset:100}
++.mx-wrap.mx-live .mx-stage .mx-f{transition:opacity .8s ease calc(var(--d,0s) + .5s)}
++.mx-wrap.mx-live .mx-card:not(.is-in) .mx-stage .mx-f{opacity:0}
++@media (prefers-reduced-motion:no-preference){
++ .mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .mx-stage>svg[data-idle=breathe]{animation:mxBreathe 5s ease-in-out calc(1.3s + var(--d,0s)) infinite}
++ .mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .mx-stage>svg[data-idle=drift]{animation:mxDrift 9s ease-in-out calc(1.3s + var(--d,0s)) infinite}
++ .mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .mx-stage>svg[data-idle=rock]{animation:mxRock 6.5s ease-in-out calc(1.3s + var(--d,0s)) infinite}
++ .mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .mx-stage>svg[data-idle=float]{animation:mxFloat 4.6s ease-in-out calc(1.3s + var(--d,0s)) infinite}
++ .mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .mx-stage>svg[data-idle=nod] .mx-b{animation:mxNod 4.2s ease-in-out calc(1.3s + var(--d,0s)) infinite}
++ .mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .mx-stage>svg[data-idle=dot] .mx-dot{animation:mxDot 3.4s ease-in-out calc(1.3s + var(--d,0s)) infinite}
++ .mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .mx-stage>svg[data-idle=pulse] .mx-glow{animation:mxPulse 3.6s ease-in-out calc(1.3s + var(--d,0s)) infinite}
}
--@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}
++@keyframes mxBreathe{0%,100%{transform:scale(1)}50%{transform:scale(1.045)}}
++@keyframes mxDrift{0%,100%{filter:hue-rotate(0deg)}50%{filter:hue-rotate(-32deg) saturate(1.15)}}
++@keyframes mxRock{0%,100%{transform:rotate(0deg)}30%{transform:rotate(-3.5deg)}70%{transform:rotate(3.5deg)}}
++@keyframes mxFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-4%)}}
++@keyframes mxNod{0%,60%,100%{transform:translateY(0)}72%{transform:translateY(3px)}84%{transform:translateY(-1px)}}
++@keyframes mxDot{0%,100%{transform:scale(1)}50%{transform:scale(1.28)}}
++@keyframes mxPulse{0%,100%{opacity:.4}50%{opacity:.95}}
++@media (prefers-reduced-motion:reduce){.mx-wrap.mx-live .mx-card,.mx-wrap.mx-live .mx-stage :is(.mx-s,.mx-f){transition:none!important}.mx-wrap.mx-live .mx-card:not(.is-in){opacity:1;transform:none}.mx-wrap.mx-live .mx-card:not(.is-in) .mx-stage :is(.mx-s,.mx-f){stroke-dashoffset:0;opacity:1}}
++html[data-ov-motion=reduced] .mx-wrap .mx-card,html[data-ov-motion=reduced] .mx-wrap .mx-card *{animation:none!important;transition:none!important}
++html[data-ov-motion=reduced] .mx-wrap .mx-card:not(.is-in){opacity:1!important;transform:none!important}
++
++/* ── The menu buttons ───────────────────────────────────────────────────────────────────────────────────────────
++ Drawn in currentColor with the accent (mb-a fills, mb-sa strokes); hovered, tapped or scrolled in (is-play) they
++ make their move; pressed (is-open) they show they are open. */
++.mb-btn{all:unset;box-sizing:border-box;position:relative;display:inline-grid;place-items:center;flex:none;width:34px;height:34px;border-radius:11px;cursor:pointer;color:var(--mb-c);--mb-c:#a8b3c4;--mb-hc:#eef2f8;--mb-bg:#0d1322;--mb-a:#7aa2ff}
++.mb-btn:focus-visible{outline:2px solid #8b5cf6;outline-offset:2px}
++.mb-btn::before{content:"";position:absolute;inset:-3px;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--mb-a) 34%,transparent),transparent);opacity:0;transform:scale(.5);transition:opacity .25s,transform .4s cubic-bezier(.2,1.4,.3,1);pointer-events:none}
++.mb-btn:is(:hover,:focus-visible,.is-play,.is-open)::before{opacity:1;transform:none}
++.mb-btn:is(:hover,:focus-visible,.is-play,.is-open){color:var(--mb-hc)}
++.mb-btn svg{position:relative;display:block;overflow:visible;fill:currentColor}
++.mb-btn .mb-s{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round}
++.mb-btn .mb-a{fill:var(--mb-a)}
++.mb-btn .mb-s.mb-a,.mb-btn .mb-sa{fill:none;stroke:var(--mb-a)}
++.mb-btn .mb-faint{opacity:.45}
++.mb-btn .mb-soft{opacity:.6}
++.mb-btn .mb-shine{fill:#fff;opacity:.6}
++.mb-btn .mb-hole{stroke:var(--mb-bg);stroke-width:1.8}
++.mb-btn .mb-p{transform-box:fill-box;transform-origin:center;transition:transform .45s cubic-bezier(.2,1.3,.3,1),opacity .25s}
++.mb-btn :is(.mb-i,.mb-x,.mb-g){transform-box:view-box;transform-origin:12px 12px;transition:transform .5s cubic-bezier(.2,1.3,.3,1),opacity .25s}
++.mb-btn .mb-x{opacity:0;transform:rotate(-90deg) scale(.4)}
++.mb-btn .mb-x path{fill:none;stroke:currentColor;stroke-width:2.5;stroke-linecap:round}
++.mb-btn.is-open svg:not([data-own]) .mb-i{opacity:0;transform:rotate(90deg) scale(.4)}
++.mb-btn.is-open .mb-x{opacity:1;transform:none}
++.mb-btn svg[data-own=diamond] .mb-g{transform:rotate(45deg) scale(.8)}
++.mb-btn.is-open svg[data-own=diamond] .mb-g{transform:rotate(180deg) scale(.86)}
++.mb-btn.is-open svg[data-own=grid] .mb-g{transform:rotate(90deg)}
++.mb-btn.is-open svg[data-own=plus] .mb-g{transform:rotate(45deg)}
++.mb-btn.is-open svg[data-own=flip] .mb-g{transform:rotate(180deg)}
++.mb-btn.is-open svg:is([data-own=diamond],[data-own=grid]) .mb-r1{opacity:0;transform:scale(0)}
++.mb-btn.is-open svg:is([data-own=diamond],[data-own=grid]) .mb-r0{fill:currentColor}
++.mb-btn.is-open svg[data-own=burger] .mb-l0{transform:translateY(5.5px) rotate(45deg)}
++.mb-btn.is-open svg[data-own=burger] .mb-l1{opacity:0;transform:scaleX(0)}
++.mb-btn.is-open svg[data-own=burger] .mb-l2{transform:translateY(-5.5px) rotate(-45deg)}
++.mb-btn.is-open svg[data-own=burger2] .mb-l0{transform:translateY(3px) rotate(45deg)}
++.mb-btn.is-open svg[data-own=burger2] .mb-l2{transform:translateY(-3px) rotate(-45deg)}
++@media (prefers-reduced-motion:no-preference){
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=ripple] .mb-p{animation:mbRipple .75s cubic-bezier(.3,.7,.3,1) calc(var(--i,0) * .08s)}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=wave] .mb-p{animation:mbWave .7s ease-in-out calc(var(--i,0) * .07s)}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=bloom] .mb-p{animation:mbBloom .7s cubic-bezier(.3,.7,.3,1) calc(var(--i,0) * .06s)}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=twinkle] .mb-p{animation:mbTwinkle .8s ease-in-out calc(var(--i,0) * .12s)}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=slide] .mb-p{animation:mbSlide .6s ease-in-out calc(var(--i,0) * .07s)}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=fan] .mb-p{animation:mbFan .8s cubic-bezier(.3,.7,.3,1)}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=bars] .mb-p{transform-origin:center bottom;animation:mbBars .8s ease-in-out calc(var(--i,0) * .08s)}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=draw] .mb-s{animation:mbDraw .8s cubic-bezier(.45,0,.2,1) calc(var(--i,0) * .14s) both}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=blink] .mb-k{animation:mbBlink 1s steps(1) 2}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg:is([data-anim=pop],[data-anim=pulse]) .mb-i{animation:mbPop .6s cubic-bezier(.3,.7,.3,1)}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=tilt] .mb-i{animation:mbTilt .8s ease-in-out}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=nod] .mb-i{animation:mbNod .8s ease-in-out}
++}
++@keyframes mbRipple{0%,100%{transform:scale(1)}35%{transform:scale(.5)}70%{transform:scale(1.2)}}
++@keyframes mbWave{0%,100%{transform:translateY(0)}40%{transform:translateY(-2.6px)}}
++@keyframes mbBloom{0%,100%{transform:scale(1)}45%{transform:scale(1.24)}}
++@keyframes mbTwinkle{0%,100%{opacity:1;transform:scale(1)}40%{opacity:.25;transform:scale(.6)}}
++@keyframes mbSlide{0%,100%{transform:translateX(0)}45%{transform:translateX(2.6px)}}
++@keyframes mbFan{0%,100%{transform:translate(0,0)}50%{transform:translate(var(--dx,0px),var(--dy,0px))}}
++@keyframes mbBars{0%,100%{transform:scaleY(1)}30%{transform:scaleY(.35)}65%{transform:scaleY(1.15)}}
++@keyframes mbDraw{from{stroke-dasharray:100;stroke-dashoffset:100}to{stroke-dasharray:100;stroke-dashoffset:0}}
++@keyframes mbBlink{0%{opacity:1}50%{opacity:0}}
++@keyframes mbPop{0%,100%{transform:scale(1)}40%{transform:scale(.8)}75%{transform:scale(1.12)}}
++@keyframes mbTilt{0%,100%{transform:rotate(0)}25%{transform:rotate(-14deg)}55%{transform:rotate(9deg)}80%{transform:rotate(-4deg)}}
++@keyframes mbNod{0%,100%{transform:translateY(0)}30%{transform:translateY(2.4px)}60%{transform:translateY(-.6px)}80%{transform:translateY(1.2px)}}
++html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
++.mb-card .mx-stage{color:#e8edf3}
++.mb-big{width:auto;height:auto;padding:14px;border-radius:24px}
++.mb-big::before{inset:8px}
++.mb-tabbtn{pointer-events:none;width:46px;height:46px}
++.mb-bars{display:grid;gap:5px}
++.mb-nav{display:flex;align-items:center;gap:6px;min-width:0;padding:5px 6px 5px 9px;border-radius:10px}
++.mb-nav>svg{flex:none}
++.mb-nav .mx-word{font-size:15px;flex:1}
++.mb-dark{background:#0d1322;color:#e8edf3}
++.mb-light{background:#f4f6fb;color:#0f1419}
++.mb-light .mb-btn{--mb-c:#55627a;--mb-hc:#0f1419;--mb-bg:#f4f6fb;--mb-a:#3b6cff}
++.mb-light .mx-sep,.mb-light .mx-site{background:linear-gradient(90deg,#3b6cff,#7c3aed);-webkit-background-clip:text;background-clip:text}
++
++/* The picks: a bar at the bottom while there are any, with the picked marks small. */
++.mx-bar{position:fixed;left:50%;bottom:calc(14px + 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:8px 10px 8px 12px;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-thumbs{display:flex;gap:3px;max-width:42vw;overflow-x:auto;scrollbar-width:none}
++.mx-bar-thumbs>*{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:8px;background:#0d1322;color:#e8edf3}
++.mx-bar-thumbs svg{width:24px;height:24px}
++.mx-bar-thumbs:empty{display:none}
.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{padding:6px 12px;border-radius:999px;border:0;background:#8b5cf6;color:#fff;font:inherit;font-size:13px;font-weight:700;cursor:pointer;min-height:34px}
.mx-btn-quiet{background:transparent;color:inherit;border:1px solid var(--border,#1f2d47)}
++
++/* Widths. A phone gets sideways rails: a row is one swipe, not a page of cards. */
@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}}
++@media (max-width:860px){.mx-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.mx-vs .mx-tab-t small{display:none}}
++@media (max-width:640px){
++ .mx-vs{gap:6px}
++ .mx-vs .mx-tab{flex-direction:column;gap:6px;padding:10px 4px;text-align:center;border-radius:14px}
++ .mx-vs .mx-tab-art{width:56px;height:56px;border-radius:12px}
++ .mx-vs .mx-tab-art svg{width:42px;height:42px}
++ .mx-vs .mx-tab-t b{font-size:16px}
++ .mx-tab-t em{font-size:11px}
++ .mx-tab-side .mx-tab-t small{display:block;font-size:12px}
++ .mx-q{font-size:13.5px}
++ .mx-q b{font-size:15.5px}
++ .mx-tools{gap:4px;padding:6px 8px}
++ .mx-tool{width:100%}
++ .mx-tool-k{width:40px}
++ .mx-row{margin-top:14px}
++ .mx-wrap .mx-row h3:not(.ovk-card-title){font-size:17px}
++ .mx-row-line{margin-bottom:6px;font-size:12.5px}
++ .mx-wrap:not([data-density=sheet]) .mx-grid{display:grid;grid-auto-flow:column;grid-auto-columns:46%;grid-template-columns:none;gap:8px;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scroll-padding-left:2px;padding:2px 2px 6px;margin:0 -2px;scrollbar-width:thin}
++ .mx-wrap:not([data-density=sheet]) .mx-card{scroll-snap-align:start}
++ .mx-card{padding:6px 6px 8px;gap:5px;border-radius:14px}
++ .mx-lock{padding:6px 7px;gap:6px}
++ .mx-lock svg{width:20px;height:20px}
++ .mx-word{font-size:14px}
++ .mb-nav .mx-word{font-size:13px}
++ .mx-sizes{gap:4px}
++ .mx-sizes span{gap:6px;padding:5px 4px}
++ .mx-name{font-size:13px}
++ .mx-line{font-size:12px}
++ .mx-pick{min-height:34px;padding:4px 10px;font-size:12.5px}
++ .mx-chip,.mx-own{min-height:40px}
++ .mx-wrap[data-density=sheet] .mx-grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px}
++}
ddiff --git a/public/js/marks.js b/public/js/marks.js
iindex 8acd61a..731e9cd 100644
---- a/public/js/marks.js
++++ b/public/js/marks.js
@@@ -1,46 +1,211 @@
--/* /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. */
++/* /brand/explore: the tabs, the name/font/style bar, the arrival and idle motion, the menu buttons, and the picks.
++ The picks live in the page's link (#picks=L03,P17,M09), so a link sent to someone opens with them marked, and in
++ this browser for next time; the bar's choices are remembered here too. Without this script every tab shows, one
++ after another, and every mark is drawn still. */
(function () {
'use strict';
-- var KEY = 'ov_mark_picks';
++ 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' };
++ function load(key, fallback) { try { var v = JSON.parse(localStorage.getItem(key) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } }
++ function save(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* a private window keeps it for the page only */ } }
++ function all(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
++
function init() {
++ var root = document.querySelector('[data-mx]');
++ if (!root || root.getAttribute('data-ready')) return;
++ root.setAttribute('data-ready', '1');
++ root.classList.add('mx-live');
++
++ // ── Picks ──
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 thumbs = bar.querySelector('[data-mx-thumbs]');
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 = []; } }
++ if (fromHash) picks = fromHash[1].split(',').filter(function (c) { return CODE.test(c); });
++ else picks = (load(KEY, []) || []).filter(function (c) { return CODE.test(c); });
function order(list) { return list.slice().sort(); }
function link() { return location.origin + location.pathname + (picks.length ? '#picks=' + order(picks).join(',') : ''); }
++ function thumb(code) {
++ var card = document.getElementById(code);
++ if (!card || !card.querySelector('.mx-stage svg')) return null;
++ var box = document.createElement('span');
++ if (code[0] === 'M') box.className = 'mb-btn';
++ // The large drawing, with its ids made its own: a hidden tab's gradients and masks do not paint elsewhere.
++ var stage = card.querySelector('.mx-stage svg');
++ var html = stage.outerHTML.replace(/ id="([^"]+)"/g, ' id="$1-t"').replace(/url\(#([^)]+)\)/g, 'url(#$1-t)').replace(/href="#([^"]+)"/g, 'href="#$1-t"');
++ box.innerHTML = html;
++ var svg = box.firstChild;
++ svg.removeAttribute('width'); svg.removeAttribute('height'); svg.removeAttribute('role'); svg.setAttribute('aria-hidden', 'true');
++ box.title = code;
++ return box;
++ }
function render() {
-- document.querySelectorAll('[data-pick]').forEach(function (b) {
-- var on = picks.indexOf(b.getAttribute('data-pick')) !== -1;
++ all('[data-pick]').forEach(function (b) {
++ var c = b.getAttribute('data-pick'), on = picks.indexOf(c) !== -1;
b.setAttribute('aria-pressed', on ? 'true' : 'false');
-- b.textContent = on ? 'Picked ' + b.getAttribute('data-pick') : 'Pick ' + b.getAttribute('data-pick');
++ b.textContent = (on ? 'Picked ' : 'Pick ') + c;
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);
++ while (thumbs.firstChild) thumbs.removeChild(thumbs.firstChild);
++ order(picks).forEach(function (c) { var t = thumb(c); if (t) thumbs.appendChild(t); });
++ save(KEY, picks);
++ if (history.replaceState && (picks.length || /picks=/.test(location.hash))) history.replaceState(null, '', picks.length ? '#picks=' + order(picks).join(',') : location.pathname);
++ }
++ // "The mark: the Loop (L03, L07), the Pair (P17). The menu button: M09." The two questions stay apart.
++ function summary() {
++ var of = function (k) { return order(picks).filter(function (c) { return c[0] === k; }); };
++ var l = of('L'), p = of('P'), m = of('M'), mark = [];
++ if (l.length) mark.push('the Loop (' + l.join(', ') + ')');
++ if (p.length) mark.push('the Pair (' + p.join(', ') + ')');
++ var parts = [];
++ if (mark.length) parts.push('The mark: ' + mark.join(', ') + '.');
++ if (m.length) parts.push('The menu button: ' + m.join(', ') + '.');
++ return 'My picks. ' + parts.join(' ') + ' ' + link();
}
++ function toggle(code) { var i = picks.indexOf(code); if (i === -1) picks.push(code); else picks.splice(i, 1); render(); }
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);
}
++
++ // ── Tabs ──
++ var tabs = all('[data-mx-tab]', root);
++ function panelOf(t) { return document.getElementById(t.getAttribute('aria-controls')); }
++ function show(name, focus) {
++ tabs.forEach(function (t) {
++ var on = t.getAttribute('data-mx-tab') === name;
++ t.setAttribute('aria-selected', on ? 'true' : 'false');
++ t.tabIndex = on ? 0 : -1;
++ var p = panelOf(t);
++ if (p) p.hidden = !on;
++ if (on && focus) t.focus();
++ });
++ save(TAB, name);
++ }
++ tabs.forEach(function (t, i) {
++ t.addEventListener('click', function (e) {
++ e.preventDefault();
++ show(t.getAttribute('data-mx-tab'));
++ if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + t.getAttribute('data-mx-tab'));
++ });
++ t.addEventListener('keydown', function (e) {
++ var k = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
++ if (!k) return;
++ e.preventDefault();
++ show(tabs[(i + k + tabs.length) % tabs.length].getAttribute('data-mx-tab'), true);
++ });
++ });
++ 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');
++
++ // ── The bar: the name, its font and style, the view ──
++ var tools = root.querySelector('[data-mx-tools]');
++ 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 setFont(key) {
++ var chip = tools.querySelector('[data-mx-font="' + key + '"]') || tools.querySelector('[data-mx-font]');
++ if (chip.getAttribute('data-mx-font') !== 'nunito') loadFonts();
++ root.style.setProperty('--mx-lockface', chip.getAttribute('data-face'));
++ press('data-mx-font', chip.getAttribute('data-mx-font'));
++ view.font = chip.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; }
++ 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); save(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);
++ save(VIEW, view);
++ });
++
++ // ── The menu buttons: press to open, and each makes its move when it arrives ──
++ function play(btn) {
++ btn.classList.remove('is-play');
++ void btn.offsetWidth;
++ btn.classList.add('is-play');
++ clearTimeout(btn._mxPlay);
++ btn._mxPlay = setTimeout(function () { btn.classList.remove('is-play'); }, 1100);
++ }
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(); }
++ var mb = e.target.closest('[data-mb]');
++ if (mb) {
++ var open = !mb.classList.contains('is-open');
++ mb.classList.toggle('is-open', open);
++ mb.setAttribute('aria-pressed', open ? 'true' : 'false');
++ if (!open) play(mb);
++ return;
++ }
++ var p = e.target.closest('[data-pick]');
++ if (p) { toggle(p.getAttribute('data-pick')); return; }
++ if (e.target.closest('[data-mx-copy]')) { copy(summary(), e.target.closest('[data-mx-copy]')); return; }
++ if (e.target.closest('[data-mx-link]')) { copy(link(), e.target.closest('[data-mx-link]')); return; }
++ if (e.target.closest('[data-mx-clear]')) { picks = []; render(); return; }
++ // In the sheet, a tap anywhere on a card picks it.
++ var card = root.getAttribute('data-density') === 'sheet' && e.target.closest('.mx-card');
++ if (card) toggle(card.getAttribute('data-code'));
});
++
++ // ── Arrival and idle: a card is "in" once it has been seen, and "on" (idling) while it is on screen ──
++ var cards = all('.mx-card', root);
++ if (!('IntersectionObserver' in window)) cards.forEach(function (c) { c.classList.add('is-in'); });
++ else {
++ var io = new IntersectionObserver(function (entries) {
++ entries.forEach(function (en) {
++ var c = en.target;
++ if (en.isIntersecting) {
++ if (!c.classList.contains('is-in')) {
++ c.classList.add('is-in');
++ if (c.classList.contains('mb-card')) { var big = c.querySelector('.mb-big'); setTimeout(function () { play(big); }, 350); }
++ }
++ c.classList.add('is-on');
++ } else c.classList.remove('is-on');
++ });
++ }, { threshold: 0.12 });
++ cards.forEach(function (c) { io.observe(c); });
++ }
++
render();
-- if (fromHash && picks.length) { var first = document.getElementById(order(picks)[0]); if (first) first.scrollIntoView({ block: 'center' }); }
++ var focusCode = CODE.test(h) ? h : fromHash && picks.length ? order(picks)[0] : null;
++ if (focusCode) { var first = document.getElementById(focusCode); if (first) { first.classList.add('is-in'); first.scrollIntoView({ block: 'center' }); } }
}
document.addEventListener('ov:boost:load', init);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
ddiff --git a/server/views/marks-explore.js b/server/views/marks-explore.js
iindex 319c0e5..719cc0c 100644
---- a/server/views/marks-explore.js
++++ b/server/views/marks-explore.js
@@@ -1,223 +1,550 @@
'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").
++ * openvibe.design/brand/explore: help choose the OpenVibe mark, and the menu button beside it, with fifty takes on each.
*
-- * 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)
++ * The owner, 2026-11-01: "the V and the OV versions… 50 of each… so that I can link it to other people to get their
++ * feedback"; then "two tabs, one for each type… show an example in the tab… explain at the top… add more variety";
++ * "animations and effects as you scroll… idle animation… the labels too: OpenVibe and whatever site it is… get the
++ * 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".
*
-- * 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.
++ * 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
++ * 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
++ * changes. Every mark is drawn large, beside a site's name (OpenVibe.Live, .Video, .Pics…, in a font the reader picks),
++ * and at 16 and 32 px on dark and light. On a phone each row is a sideways rail; "Sheet" shows all fifty at once.
++ * Marks draw themselves in as they scroll into view, then idle (breathe, nod, drift, rock); nothing spins, and nothing
++ * moves for a reader who asks for less motion. The rules for any OpenVibe mark hold: centred, level, no liquid
++ * fusions, no pac-man shapes. Picks travel in the link (#picks=L04,P17,M09).
*/
const { layout, esc } = require('../render/layout');
--const BLUE = '#4f7cff';
--const VIOLET = '#8b5cf6';
--const INK = '#e8edf3';
++const BLUE = '#4f7cff', VIOLET = '#8b5cf6', CYAN = '#22d3ee', PINK = '#ec4899', NIGHT = '#0d1322', PAPER = '#f4f6fb';
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' };
++
++// ── Paint ────────────────────────────────────────────────────────────────────────────────────────────────────────
++// A treatment gives the two parts their paint (a: the ring or the O, b: the V), its gradients and filters, and an
++// optional tile behind the mark (an app icon's square).
++function paint(kind, id) {
++ const lin = (gid, stops, x1 = 18, y1 = 18, x2 = 82, y2 = 82) => `<linearGradient id="${gid}" gradientUnits="userSpaceOnUse" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stops.map((c, i) => `<stop offset="${f(i / (stops.length - 1))}" stop-color="${c}"/>`).join('')}</linearGradient>`;
++ const one = (c) => ({ a: c, b: c, defs: '' });
++ switch (kind) {
++ case 'sweep': return { a: `url(#${id}g)`, b: `url(#${id}g)`, defs: lin(`${id}g`, [BLUE, VIOLET]) };
++ case 'vertical': return { a: `url(#${id}g)`, b: `url(#${id}g)`, defs: lin(`${id}g`, [VIOLET, BLUE], 50, 14, 50, 86) };
++ case 'duo': return { a: BLUE, b: VIOLET, defs: '' };
++ case 'duo-rev': return { a: VIOLET, b: BLUE, defs: '' };
++ case 'depth': return {
++ a: `url(#${id}a)`, b: `url(#${id}b)`,
++ defs: lin(`${id}a`, [mix(BLUE, '#ffffff', 0.32), mix(BLUE, '#000000', 0.18)], 50, 14, 50, 86) + lin(`${id}b`, [mix(VIOLET, '#ffffff', 0.3), mix(VIOLET, '#000000', 0.16)], 50, 14, 50, 86),
++ };
++ case 'aurora': return { a: `url(#${id}g)`, b: `url(#${id}g)`, defs: lin(`${id}g`, [CYAN, BLUE, VIOLET]) };
++ case 'ember': return { a: `url(#${id}g)`, b: `url(#${id}g)`, defs: lin(`${id}g`, [VIOLET, PINK]) };
++ case 'blue': return one(BLUE);
++ case 'violet': return one(VIOLET);
++ case 'ink': return one('currentColor');
++ case 'glow': return { a: `url(#${id}g)`, b: `url(#${id}g)`, defs: lin(`${id}g`, [BLUE, VIOLET]) + `<filter id="${id}f" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="4.5"/></filter>`, glow: `${id}f` };
++ case 'tile': return { a: '#ffffff', b: '#ffffff', defs: lin(`${id}t`, [BLUE, VIOLET], 0, 0, 100, 100), tile: `url(#${id}t)`, white: true };
++ case 'tile-night': return { a: `url(#${id}g)`, b: `url(#${id}g)`, defs: lin(`${id}g`, [BLUE, VIOLET]), tile: NIGHT, tileLine: true };
++ case 'tile-paper': return { a: BLUE, b: VIOLET, defs: '', tile: PAPER };
++ case 'extrude': return { a: `url(#${id}g)`, b: `url(#${id}g)`, defs: lin(`${id}g`, [BLUE, VIOLET]), extrude: mix(VIOLET, '#000000', 0.55) };
++ default: return one(BLUE);
++ }
++}
++/** The mark's parts in their treatment: the tile under it, the glow or the solid edge behind it. */
++function treat(p, body) {
++ const out = [];
++ if (p.tile) out.push(`<rect x="2" y="2" width="96" height="96" rx="24" fill="${p.tile}"${p.tileLine ? ' stroke="#ffffff22" stroke-width="1"' : ''}/>`);
++ if (p.glow) out.push(`<g class="mx-glow" filter="url(#${p.glow})" opacity=".75">${body}</g>`);
++ // The edge: the same parts in one dark colour, a little down and right (masks stay; only the paint changes).
++ if (p.extrude) out.push(`<g transform="translate(2.4 3.2)" opacity=".9">${body.replace(/(fill|stroke)="url\(#[^)]+\)"/g, `$1="${p.extrude}"`)}</g>`);
++ out.push(body);
++ const all = out.join('');
++ return p.white ? all.replace(/currentColor/g, '#ffffff') : all;
}
--// ── 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)}`);
++// ── Shapes ───────────────────────────────────────────────────────────────────────────────────────────────────────
++// A closed shape's boundary point at angle θ (degrees, 0 = right, 90 = down), centred on (cx, cy) with "radius" r.
++function boundary(shape, cx, cy, r, deg) {
++ const t = rad(deg), c = Math.cos(t), s = Math.sin(t);
++ const sp = (n) => [cx + r * Math.sign(c) * Math.abs(c) ** (2 / n), cy + r * Math.sign(s) * Math.abs(s) ** (2 / n)];
++ if (shape === 'squircle') return sp(4);
++ if (shape === 'rsquare') return sp(7);
++ if (shape === 'oval') return [cx + r * 0.84 * c, cy + r * s];
++ if (shape === 'hex' || shape === 'oct') {
++ const k = shape === 'hex' ? 6 : 8, seg = (2 * Math.PI) / k, th0 = rad(-90);
++ const local = ((((t - th0) % seg) + seg) % seg) - seg / 2;
++ const rr = (r * Math.cos(Math.PI / k)) / Math.cos(local);
++ return [cx + rr * c, cy + rr * s];
}
-- // 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 },
--];
++ return [cx + r * c, cy + r * s];
++}
++const pts = (list) => list.map((p) => `${f(p[0])} ${f(p[1])}`);
++/** An open stretch of the boundary from angle a to angle b (degrees). */
++function arcPts(shape, cx, cy, r, a, b, steps = 96) {
++ const out = [];
++ for (let i = 0; i <= steps; i++) out.push(boundary(shape, cx, cy, r, a + ((b - a) * i) / steps));
++ return out;
++}
++/** The whole closed boundary as a path (a circle as two arcs). */
++function closedPath(shape, cx, cy, r) {
++ if (shape === 'circle') return `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 p = pts(arcPts(shape, cx, cy, r, -90, 270, 144));
++ return `M${p[0]}L${p.slice(1).join('L')}Z`;
++}
++/** A mask that keeps everything but what `inner` paints black; its definition goes into state.defs. */
++function masker(state) {
++ return (mid, inner) => { state.defs += `<mask id="${mid}" maskUnits="userSpaceOnUse" x="-10" y="-10" width="120" height="120"><rect x="-10" y="-10" width="120" height="120" fill="#fff"/>${inner}</mask>`; return `url(#${mid})`; };
++}
--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));
++// ── The Loop ─────────────────────────────────────────────────────────────────────────────────────────────────────
++// The ring opens `open`° each side of the top; the V runs from the opening's two ends to a tip `tip` below the centre.
++// Constructions: joined (one stroke), split (two strokes, the ground between), apart, float (a small V inside), cross
++// (the arms run past the ring), coin (a solid shape, the V cut through), coin-ring, outline, inline, dashed (dots),
++// 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) {
++ 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);
++ const T = [50, cy + (s.tip == null ? 2 : s.tip)];
++ const ringD = `M${pts(arcPts(shape, cx, cy, r, 270 - open, -90 + open)).join('L')}`;
++ const joinedD = `${ringD}L${f(T[0])} ${f(T[1])}Z`;
++ // A V whose arms start `ext` of the radius outside the ring's ends (negative: inside).
++ const out = (q, ext) => [q[0] - (cx - q[0]) * ext, q[1] - (cy - q[1]) * ext];
++ const vD = (ext = 0) => { const L = out(left, ext), R = out(right, ext); return `M${f(L[0])} ${f(L[1])}L${f(T[0])} ${f(T[1])}L${f(R[0])} ${f(R[1])}`; };
++ const p = paint(s.paint, id);
++ const st = { defs: p.defs };
++ const mask = masker(st);
++ const cap = s.cap || 'round', join = s.join || 'round';
++ 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="${cap}" stroke-linejoin="${join}"${extra}/>`;
++ const cut = (d, w, part) => `<path class="mx-${part}" d="${d}" fill="none" stroke="#000" stroke-width="${f(w)}" stroke-linecap="round" stroke-linejoin="round"/>`;
++ let body;
++ switch (s.mode) {
++ case 'split': body = `<g mask="${mask(`${id}m`, cut(vD(), vsw + 5, 'b'))}">${S(ringD, p.a, sw, 'a')}</g>${S(vD(), p.b, vsw, 'b')}`; break;
++ case 'apart': body = `<g mask="${mask(`${id}m`, cut(vD(-0.18), vsw + 7, 'b'))}">${S(ringD, p.a, sw, 'a')}</g>${S(vD(-0.18), p.b, vsw, 'b')}`; break;
++ case 'float': body = `${S(ringD, p.a, sw, 'a')}${S(vD(-0.42), p.b, vsw, 'b')}`; break;
++ case 'cross': body = `<g mask="${mask(`${id}m`, cut(vD(0.28), vsw + 6, 'b'))}">${S(closedPath(shape, cx, cy, r), p.a, sw, 'a')}</g>${S(vD(0.28), p.b, vsw, 'b')}`; break;
++ case 'coin': body = `<path class="mx-f mx-a" d="${closedPath(shape, cx, cy, r + sw / 2)}" fill="${p.a}" mask="${mask(`${id}m`, cut(vD(0.4), vsw, 'b'))}"/>`; break;
++ case 'coin-ring': {
++ const m = mask(`${id}m`, cut(vD(0.2), vsw + 4, 'b'));
++ body = `<g mask="${m}"><path class="mx-f mx-a" d="${closedPath(shape, cx, cy, r - sw * 0.9)}" fill="${p.a}"/>${S(closedPath(shape, cx, cy, r + sw * 0.15), p.a, sw * 0.45, 'a')}</g>${S(vD(0.2), p.b, vsw * 0.8, 'b')}`;
++ break;
++ }
++ case 'outline': body = `<g mask="${mask(`${id}m`, cut(joinedD, sw - 5.5, 'a'))}">${S(joinedD, p.a, sw, 'a')}</g>`; break;
++ case 'inline': body = `<g mask="${mask(`${id}m`, cut(joinedD, 2.2, 'a'))}">${S(joinedD, p.a, sw, 'a')}</g>`; break;
++ case 'dashed': body = `${S(ringD, p.a, sw, 'a', ' stroke-dasharray="0.01 8.6"')}${S(vD(), p.b, vsw, 'b')}`; break;
++ case 'segments': {
++ const a1 = 270 - open, b1 = -90 + open, span = a1 - b1, gap = 14;
++ const seg = (from, to) => `M${pts(arcPts(shape, cx, cy, r, from, to, 40)).join('L')}`;
++ body = `${S(seg(a1, a1 - span / 3 + gap / 2), p.a, sw, 'a')}${S(seg(a1 - span / 3 - gap / 2, b1 + span / 3 + gap / 2), p.a, sw, 'a')}${S(seg(b1 + span / 3 - gap / 2, b1), p.a, sw, 'a')}${S(vD(), p.b, vsw, 'b')}`;
++ break;
++ }
++ case 'tipdot': {
++ const stop = (q) => [q[0] + (T[0] - q[0]) * 0.72, q[1] + (T[1] - q[1]) * 0.72];
++ const l2 = stop(left), r2 = stop(right);
++ body = `${S(ringD, p.a, sw, 'a')}${S(`M${f(left[0])} ${f(left[1])}L${f(l2[0])} ${f(l2[1])}`, p.b, vsw, 'b')}${S(`M${f(right[0])} ${f(right[1])}L${f(r2[0])} ${f(r2[1])}`, p.b, vsw, 'b')}<circle class="mx-f mx-dot" cx="${T[0]}" cy="${f(T[1] + 1)}" r="${f(vsw * 0.62)}" fill="${p.b}"/>`;
++ break;
++ }
++ case 'half': {
++ st.defs += `<clipPath id="${id}c"><rect x="0" y="${cy}" width="100" height="60"/></clipPath>`;
++ body = `<path class="mx-f mx-a" d="${closedPath(shape, cx, cy, r)}" fill="${p.a}" opacity=".35" clip-path="url(#${id}c)" mask="${mask(`${id}m`, cut(vD(), vsw + 5, 'b'))}"/>${S(joinedD, p.a, sw, 'a')}`;
++ break;
++ }
++ default: body = S(joinedD, p.a, sw, 'a');
}
-- 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('') };
++ if (s.dot === 'above') body += `<circle class="mx-f mx-dot" cx="50" cy="${f(left[1] - sw / 2 - 5)}" r="${f(Math.max(3.6, sw * 0.34))}" fill="${s.paint === 'ink' ? 'currentColor' : PINK}"/>`;
++ if (s.dot === 'orbit') { const o = boundary(shape, cx, cy, r, 38); body += `<circle class="mx-f mx-dot" cx="${f(o[0])}" cy="${f(o[1])}" r="${f(sw * 0.72)}" fill="${p.tile ? '#ffffff' : CYAN}" stroke="${p.tile ? 'none' : NIGHT}" stroke-width="2.5"/>`; }
++ if (s.dot === 'inner') body += `<circle class="mx-f mx-dot" cx="50" cy="${f(cy + r * 0.52)}" r="${f(sw * 0.38)}" fill="${p.b}"/>`;
++ return { defs: st.defs, body: treat(p, body) };
}
// ── 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 }) {
++// 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
++// (the ground around the V where it crosses, `halo` wide), over, behind (the O in front), solid-v, double, dashed,
++// outline, inline, eye (a solid O with a small opening), dot (a dot in the O), sharp (mitred, square ends).
++function pairGeo({ r = 20, sw = 13, vsw = 13, a = 0.86, t = 1.5, b = 2.16 }) {
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 { r, sw, vsw, R, cx: f(cx), cy, 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)] };
++}
++function pairArt(s, id) {
++ const g = pairGeo(s.g || {});
++ const shape = s.shape || 'circle';
++ const O = closedPath(shape, g.cx, g.cy, g.r);
++ const V = `M${g.A[0]} ${g.A[1]}L${g.T[0]} ${g.T[1]}L${g.B[0]} ${g.B[1]}`;
++ const p = paint(s.paint, id);
++ const st = { defs: p.defs };
++ const mask = masker(st);
++ const cap = s.mode === 'sharp' ? 'square' : 'round', join = s.mode === 'sharp' ? 'miter' : 'round';
++ 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="${cap}" stroke-linejoin="${join}"${extra}/>`;
++ const cut = (d, w, part) => `<path class="mx-${part}" d="${d}" fill="none" stroke="#000" stroke-width="${f(w)}" stroke-linecap="${cap}" stroke-linejoin="${join}"/>`;
++ const halo = s.halo == null ? 7 : s.halo;
++ // The O with the ground cut around the V where it crosses (plus any other cut, `inner`).
++ const gapO = (content = '', inner = '') => `<g mask="${mask(`${id}m`, cut(V, g.vsw + halo, 'b') + inner)}">${content || S(O, p.a, g.sw, 'a')}</g>`;
++ let body;
++ switch (s.mode) {
++ case 'over': body = `${S(O, p.a, g.sw, 'a')}${S(V, p.b, g.vsw, 'b')}`; break;
++ case 'behind': body = `<g mask="${mask(`${id}m`, cut(O, g.sw + halo, 'a'))}">${S(V, p.b, g.vsw, 'b')}</g>${S(O, p.a, g.sw, 'a')}`; break;
++ case 'solid-v': {
++ const tri = `M${g.A[0]} ${g.A[1]}L${g.T[0]} ${g.T[1]}L${g.B[0]} ${g.B[1]}Z`;
++ const m = mask(`${id}m`, `<path class="mx-b" d="${tri}" fill="#000" stroke="#000" stroke-width="${f(g.vsw * 0.55 + halo)}" stroke-linejoin="round"/>`);
++ body = `<g mask="${m}">${S(O, p.a, g.sw, 'a')}</g><path class="mx-f mx-b" d="${tri}" fill="${p.b}" stroke="${p.b}" stroke-width="${f(g.vsw * 0.55)}" stroke-linejoin="round"/>`;
++ break;
++ }
++ case 'double': body = gapO(S(closedPath(shape, g.cx, g.cy, g.r - g.sw * 0.3), p.a, g.sw * 0.34, 'a') + S(closedPath(shape, g.cx, g.cy, g.r + g.sw * 0.3), p.a, g.sw * 0.34, 'a')) + S(V, p.b, g.vsw, 'b'); break;
++ case 'dashed': body = gapO(S(O, p.a, g.sw, 'a', ' stroke-dasharray="0.01 11.1"')) + S(V, p.b, g.vsw, 'b'); break;
++ case 'outline': body = gapO('', cut(O, g.sw - 5.5, 'a')) + `<g mask="${mask(`${id}n`, cut(V, g.vsw - 5.5, 'b'))}">${S(V, p.b, g.vsw, 'b')}</g>`; break;
++ case 'inline': body = gapO('', cut(O, 2, 'a')) + `<g mask="${mask(`${id}n`, cut(V, 2, 'b'))}">${S(V, p.b, g.vsw, 'b')}</g>`; break;
++ case 'eye': body = gapO(`<path class="mx-f mx-a" d="${closedPath(shape, g.cx, g.cy, g.R)}" fill="${p.a}"/>`, `<path d="${closedPath(shape, g.cx, g.cy, g.r * 0.42)}" fill="#000"/>`) + S(V, p.b, g.vsw, 'b'); break;
++ case 'dot': body = gapO() + S(V, p.b, g.vsw, 'b') + `<circle class="mx-f mx-dot" cx="${g.cx}" cy="${g.cy}" r="${f(g.r * 0.34)}" fill="${p.b}"/>`; break;
++ default: body = gapO() + S(V, p.b, g.vsw, 'b');
}
-- 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 },
++ return { defs: st.defs, body: treat(p, body) };
++}
++
++// ── Fifty Loops and fifty Pairs, in rows by idea ─────────────────────────────────────────────────────────────────
++// [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' }],
++ ['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' }],
++ ['Hairline', 'a thin, elegant line', { r: 33, sw: 6, open: 32, tip: 2, paint: 'sweep' }],
++ ['Heavy, narrow', 'a thick ring, a small notch of a V', { r: 30, sw: 20, open: 20, tip: -10, paint: 'violet' }],
++ ['Wide open', 'a generous opening, a shallow V', { r: 32, sw: 14, open: 46, tip: 6, paint: 'vertical' }],
++ ['Deep V', 'the V reaches near the bottom', { r: 32, sw: 12, open: 32, tip: 18, paint: 'sweep' }],
++ ['Sharp', 'mitred corners and square ends', { r: 32, sw: 13, open: 31, tip: 3, join: 'miter', cap: 'square', paint: 'blue' }],
++ ['Thin V, heavy ring', 'the ring carries the weight, the V is a line', { r: 31, sw: 17, open: 30, tip: 2, vsw: 7, mode: 'split', paint: 'duo' }],
++ ['Heavy V, thin ring', 'the V carries the weight, the ring is a line', { r: 33, sw: 6, open: 32, tip: 4, vsw: 16, mode: 'split', paint: 'duo-rev' }],
++ ] },
++ { title: 'The ring\'s shape', line: 'the same idea in other outlines', items: [
++ ['Squircle', 'the ring as a soft square', { shape: 'squircle', r: 32, sw: 13, open: 30, tip: 2, paint: 'sweep' }],
++ ['Hexagon', 'six sides, the V where the top point was', { shape: 'hex', r: 34, sw: 12, open: 30, tip: 3, paint: 'depth', mode: 'split' }],
++ ['Octagon', 'eight sides, a badge', { shape: 'oct', r: 33, sw: 12, open: 24, tip: 2, paint: 'violet' }],
++ ['Tall oval', 'a narrower ring, a taller V', { shape: 'oval', r: 34, sw: 12, open: 34, tip: 8, paint: 'vertical' }],
++ ['Rounded square', 'an app-shaped ring with firm corners', { shape: 'rsquare', r: 31, sw: 12, open: 28, tip: 3, paint: 'depth', mode: 'split' }],
++ ['Oval, apart', 'a tall ring and a separate V', { shape: 'oval', r: 34, sw: 11, open: 32, tip: 8, mode: 'apart', paint: 'duo-rev' }],
++ ['Octagon, apart', 'a badge ring and a free V', { shape: 'oct', r: 33, sw: 11, open: 26, tip: 3, mode: 'apart', paint: 'duo' }],
++ ] },
++ { title: 'How the V meets the ring', line: 'joined, apart, floating, crossing, cut through, drawn in outline', items: [
++ ['Apart', 'ring and V as two strokes with air between', { r: 32, sw: 11, open: 34, tip: 5, mode: 'apart', paint: 'duo' }],
++ ['Floating V', 'a small V hangs in the open ring', { r: 33, sw: 11, open: 28, tip: 3, vsw: 9, mode: 'float', paint: 'duo-rev' }],
++ ['Crossing', 'the V\'s arms run up past the ring', { r: 30, sw: 11, open: 32, tip: 4, mode: 'cross', paint: 'sweep' }],
++ ['Outline', 'drawn as a contour, hollow', { r: 32, sw: 15, open: 30, tip: 1, mode: 'outline', paint: 'sweep' }],
++ ['Inline', 'a hairline down the middle of the stroke', { r: 32, sw: 16, open: 30, tip: 1, mode: 'inline', paint: 'vertical' }],
++ ['Dotted ring', 'the ring in dots, the V solid', { r: 32, sw: 9, open: 30, tip: 2, vsw: 12, mode: 'dashed', paint: 'duo' }],
++ ['Three arcs', 'the ring in three pieces around a solid V', { r: 32, sw: 12, open: 28, tip: 2, mode: 'segments', paint: 'sweep' }],
++ ['Tip dot', 'the V\'s arms stop short of a dot', { r: 32, sw: 12, open: 32, tip: 6, mode: 'tipdot', paint: 'duo' }],
++ ['Half moon', 'the lower half softly filled', { r: 32, sw: 11, open: 31, tip: 2, mode: 'half', paint: 'blue' }],
++ ['Orbit', 'a dot sits on the ring like a moon', { r: 31, sw: 12, open: 31, tip: 1, dot: 'orbit', paint: 'sweep' }],
++ ['Inner dot', 'a dot in the ring under the V', { r: 32, sw: 12, open: 32, tip: 0, dot: 'inner', paint: 'duo' }],
++ ['Coin', 'a solid disc with the V cut through', { r: 32, sw: 12, open: 33, tip: 4, vsw: 11, mode: 'coin', paint: 'sweep' }],
++ ['Coin ring', 'a filled centre in a thin ring, the V across both', { r: 31, sw: 12, open: 32, tip: 3, mode: 'coin-ring', paint: 'duo' }],
++ ['Squircle coin', 'a solid soft square, the V cut through', { shape: 'squircle', r: 32, sw: 10, open: 30, tip: 3, vsw: 11, mode: 'coin', paint: 'vertical' }],
++ ] },
++ { title: 'Mixed', line: 'a shape and a construction together', items: [
++ ['Hex outline', 'a hollow six-sided Loop', { shape: 'hex', r: 34, sw: 14, open: 30, tip: 3, mode: 'outline', paint: 'aurora' }],
++ ['Half moon hex', 'six sides, half filled', { shape: 'hex', r: 34, sw: 11, open: 30, tip: 3, mode: 'half', paint: 'violet' }],
++ ['Inline hex', 'six sides with a hairline inside', { shape: 'hex', r: 34, sw: 15, open: 30, tip: 3, mode: 'inline', paint: 'sweep' }],
++ ['Three arcs, one ink', 'the pieces in one colour', { r: 32, sw: 13, open: 28, tip: 2, mode: 'segments', paint: 'ink' }],
++ ['Tip dot, warm', 'the dot at the tip, violet into pink', { r: 32, sw: 12, open: 32, tip: 6, mode: 'tipdot', paint: 'ember' }],
++ ['Coin with an edge', 'a solid coin with a solid edge under it', { r: 31, sw: 12, open: 33, tip: 4, vsw: 11, mode: 'coin', paint: 'extrude' }],
++ ['Dotted glow', 'a glowing ring of dots', { r: 32, sw: 9, open: 30, tip: 2, vsw: 12, mode: 'dashed', paint: 'glow' }],
++ ] },
++ { title: 'Colour and finish', line: 'one shape, five finishes', items: [
++ ['Glow', 'the Loop lit from behind', { r: 31, sw: 13, open: 31, tip: 1, paint: 'glow' }],
++ ['Solid edge', 'an edge under the stroke, like a key', { r: 31, sw: 13, open: 31, tip: 1, paint: 'extrude' }],
++ ['Aurora', 'cyan into blue into violet', { r: 32, sw: 14, open: 30, tip: 1, paint: 'aurora' }],
++ ['Ember', 'violet into pink, warmer', { r: 32, sw: 14, open: 30, tip: 1, paint: 'ember' }],
++ ['One ink', 'a single colour, for print and stamps', { r: 32, sw: 15, open: 30, tip: 0, paint: 'ink' }],
++ ] },
++ { title: 'As an app icon', line: 'how it sits on a phone\'s home screen', items: [
++ ['App tile', 'white on a gradient tile', { r: 25, sw: 11, open: 32, tip: 1, paint: 'tile' }],
++ ['Night tile', 'the gradient Loop on a dark tile', { r: 25, sw: 11, open: 32, tip: 1, paint: 'tile-night' }],
++ ['Paper tile', 'two colours on a light tile', { r: 25, sw: 11, open: 32, tip: 1, mode: 'split', paint: 'tile-paper' }],
++ ['Crossing tile', 'arms past the ring, white on a tile', { r: 25, sw: 10, open: 32, tip: 4, mode: 'cross', paint: 'tile' }],
++ ['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' }],
++ ] },
++];
++const PAIR_ROWS = [
++ { title: 'Weight and spacing', line: 'how heavy, and how the V crosses the O', items: [
++ ['Pair', 'the mark today: ring 20 + 13, the V 13', { paint: 'depth' }],
++ ['Heavy', 'the favicon form, thick strokes', { g: { r: 18, sw: 18, vsw: 17 }, paint: 'sweep' }],
++ ['Light', 'a thin line, more air in the O', { g: { r: 21, sw: 8, vsw: 8 }, paint: 'duo' }],
++ ['Hairline', 'a thin, elegant pair', { g: { r: 22, sw: 5, vsw: 5 }, halo: 5, paint: 'sweep' }],
++ ['Heavy V', 'a light O, a heavy V', { g: { r: 21, sw: 7, vsw: 17 }, paint: 'duo' }],
++ ['Heavy O', 'a heavy O, a light V', { g: { r: 19, sw: 17, vsw: 7 }, paint: 'duo' }],
++ ['Wide gap', 'more ground around the V where it crosses', { halo: 14, paint: 'duo' }],
++ ['No gap', 'the V laid straight over the O', { mode: 'over', paint: 'duo' }],
++ ['V behind', 'the O in front, cut around', { mode: 'behind', paint: 'depth' }],
++ ['Behind, wide gap', 'the O in front with plenty of air', { mode: 'behind', halo: 13, paint: 'duo' }],
++ ['Side by side', 'the V beside the O, just touching', { g: { a: 1.12, t: 1.72, b: 2.32 }, halo: 6, paint: 'sweep' }],
++ ['Deep overlap', 'the V crosses far into the O', { g: { a: 0.58, t: 1.28, b: 1.98 }, paint: 'duo' }],
++ ] },
++ { title: 'The O\'s shape', line: 'the same pair with other outlines', items: [
++ ['Squircle O', 'the O as a soft square', { shape: 'squircle', paint: 'duo' }],
++ ['Hex O', 'a six-sided O', { shape: 'hex', g: { r: 21, sw: 12 }, paint: 'depth' }],
++ ['Octagon O', 'an eight-sided badge O', { shape: 'oct', g: { r: 21, sw: 11 }, paint: 'sweep' }],
++ ['Rounded square O', 'an app-shaped O with firm corners', { shape: 'rsquare', g: { r: 19, sw: 12 }, paint: 'depth' }],
++ ['Tall O', 'an oval O, a taller pair', { shape: 'oval', g: { r: 21, sw: 12, vsw: 12, a: 0.78, t: 1.38, b: 1.98 }, paint: 'vertical' }],
++ ['Tall, apart', 'an oval O and a free V', { shape: 'oval', g: { r: 21, sw: 11, vsw: 11, a: 1.1, t: 1.7, b: 2.3 }, halo: 6, paint: 'duo-rev' }],
++ ] },
++ { title: 'Construction', line: 'filled, doubled, dotted, hollow, sharp', items: [
++ ['Solid V', 'the V as a filled, softened triangle', { mode: 'solid-v', paint: 'duo' }],
++ ['Double O', 'the O as two thin rings', { mode: 'double', g: { r: 20, sw: 13, vsw: 12 }, paint: 'duo' }],
++ ['Dotted O', 'the O in dots, the V solid', { mode: 'dashed', g: { r: 20, sw: 10, vsw: 13 }, paint: 'duo' }],
++ ['Outline', 'both drawn as contours', { mode: 'outline', g: { r: 20, sw: 15, vsw: 15 }, paint: 'sweep' }],
++ ['Inline', 'a hairline down each stroke', { mode: 'inline', g: { r: 20, sw: 15, vsw: 15 }, paint: 'vertical' }],
++ ['Eye', 'a solid O with a small opening', { mode: 'eye', paint: 'duo' }],
++ ['Centre dot', 'a dot in the O', { mode: 'dot', paint: 'duo' }],
++ ['Sharp', 'mitred corners and square ends', { mode: 'sharp', paint: 'blue' }],
++ ] },
++ { title: 'Mixed', line: 'a construction and a finish together', items: [
++ ['Hex outline', 'a six-sided O, hollow', { shape: 'hex', mode: 'outline', g: { r: 21, sw: 15, vsw: 15 }, paint: 'aurora' }],
++ ['Eye glow', 'a solid O, lit', { mode: 'eye', paint: 'glow' }],
++ ['Double, edged', 'two thin rings and an edged V', { mode: 'double', g: { r: 20, sw: 13, vsw: 12 }, paint: 'extrude' }],
++ ['Sharp ember', 'mitred and warm', { mode: 'sharp', paint: 'ember' }],
++ ['No gap, one ink', 'laid over, in one colour', { mode: 'over', g: { r: 19, sw: 15, vsw: 15 }, paint: 'ink' }],
++ ['Dotted glow', 'a glowing O of dots', { mode: 'dashed', g: { r: 20, sw: 10, vsw: 13 }, paint: 'glow' }],
++ ['Inline, one ink', 'hairlines in one colour', { mode: 'inline', g: { r: 20, sw: 15, vsw: 15 }, paint: 'ink' }],
++ ['Deep, behind', 'a deep cross with the O in front', { mode: 'behind', g: { a: 0.58, t: 1.28, b: 1.98 }, paint: 'aurora' }],
++ ['Solid V, warm', 'a filled V, violet into pink', { mode: 'solid-v', paint: 'ember' }],
++ ['Eye, one ink', 'the solid O in one colour', { mode: 'eye', g: { r: 19, sw: 14, vsw: 14 }, paint: 'ink' }],
++ ['Light glow', 'thin lines, lit', { g: { r: 21, sw: 7, vsw: 7 }, paint: 'glow' }],
++ ] },
++ { title: 'Colour and finish', line: 'one shape, six finishes', items: [
++ ['Glow', 'lit from behind', { paint: 'glow' }],
++ ['Solid edge', 'an edge under the strokes', { paint: 'extrude' }],
++ ['Aurora', 'cyan into blue into violet across both', { paint: 'aurora' }],
++ ['Ember', 'violet into pink', { paint: 'ember' }],
++ ['One ink', 'a single colour, for print and stamps', { g: { r: 19, sw: 15, vsw: 15 }, paint: 'ink' }],
++ ['Violet O', 'the colours swapped', { paint: 'duo-rev' }],
++ ] },
++ { title: 'As an app icon', line: 'how it sits on a phone\'s home screen', items: [
++ ['App tile', 'white on a gradient tile', { g: { r: 15, sw: 10, vsw: 10 }, paint: 'tile' }],
++ ['Night tile', 'the gradient Pair on a dark tile', { g: { r: 15, sw: 10, vsw: 10 }, paint: 'tile-night' }],
++ ['Paper tile', 'two colours on a light tile', { g: { r: 15, sw: 10, vsw: 10 }, paint: 'tile-paper' }],
++ ['Squircle tile', 'a soft-square O on a gradient tile', { shape: 'squircle', g: { r: 15, sw: 10, vsw: 10 }, paint: 'tile' }],
++ ['Solid V tile', 'a filled V on a night tile', { mode: 'solid-v', g: { r: 15, sw: 10, vsw: 10 }, paint: 'tile-night' }],
++ ['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' }],
++ ] },
];
--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('') };
++// ── Fifty menu buttons ───────────────────────────────────────────────────────────────────────────────────────────
++// The button beside the site's name opens search and every OpenVibe site (openvibe-shared navbar.js orbHTML: today
++// nine dots set as a diamond). Each idea is drawn in a 24-unit box: its parts (mb-p, numbered --i so a move runs
++// through them in turn), the move it makes when hovered, tapped or scrolled into view (data-anim), and how it shows it
++// is open: most turn into an ×; a few become one themselves (data-own: the diamond and the grid drop their edge dots,
++// a plus turns an eighth, the lines cross, the chevron flips). Colour: currentColor and the accent (mb-a, mb-sa).
++// `i` may carry more custom properties after the index ('2;--dx:-1.8px').
++const dot = (x, y, r, k = '', i = 0) => `<circle class="mb-p${k}" cx="${f(x)}" cy="${f(y)}" r="${r}" style="--i:${i}"/>`;
++const line = (d, k = '', i = 0, w = 2.2) => `<path class="mb-p mb-s${k}" d="${d}" pathLength="100" stroke-width="${w}" style="--i:${i}"/>`;
++const fill = (d, k = '', i = 0) => `<path class="mb-p${k}" d="${d}" style="--i:${i}"/>`;
++const box = (x, y, w, h, rx, k = '', i = 0) => `<rect class="mb-p${k}" x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" style="--i:${i}"/>`;
++const frame = (x, y, w, h, rx, k = '', i = 0, sw = 2) => `<rect class="mb-p mb-s${k}" x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" stroke-width="${sw}" pathLength="100" style="--i:${i}"/>`;
++const ring = (x, y, r, k = '', i = 0, sw = 2.2) => `<circle class="mb-p mb-s${k}" cx="${f(x)}" cy="${f(y)}" r="${r}" stroke-width="${sw}" pathLength="100" style="--i:${i}"/>`;
++function spark(cx, cy, s) {
++ const k = s * 0.16;
++ return `M${f(cx)} ${f(cy - s)}C${f(cx + k)} ${f(cy - k)} ${f(cx + k)} ${f(cy - k)} ${f(cx + s)} ${f(cy)}C${f(cx + k)} ${f(cy + k)} ${f(cx + k)} ${f(cy + k)} ${f(cx)} ${f(cy + s)}C${f(cx - k)} ${f(cy + k)} ${f(cx - k)} ${f(cy + k)} ${f(cx - s)} ${f(cy)}C${f(cx - k)} ${f(cy - k)} ${f(cx - k)} ${f(cy - k)} ${f(cx)} ${f(cy - s)}Z`;
++}
++function star(cx, cy, ro, ri) {
++ const p = [];
++ for (let i = 0; i < 10; i++) { const r = i % 2 ? ri : ro, a = rad(-90 + i * 36); p.push(`${f(cx + r * Math.cos(a))} ${f(cy + r * Math.sin(a))}`); }
++ return `M${p.join('L')}Z`;
++}
++const polar = (r, deg) => [12 + r * Math.cos(rad(deg)), 12 + r * Math.sin(rad(deg))];
++const seg2 = (r1, r2, deg, k, i, w) => { const [x1, y1] = polar(r1, deg), [x2, y2] = polar(r2, deg); return line(`M${f(x1)} ${f(y1)}L${f(x2)} ${f(y2)}`, k, i, w); };
++// The nine dots: 0 the centre, 1 the edges, 2 the corners (the ripple runs out in that order).
++const grid9 = (rc, re, rk) => [[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, n === 0 ? rc : n === 1 ? re : rk, ` mb-r${n}${n === 0 ? ' mb-a' : ''}`, n)).join('');
++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]
++const MENU_ROWS = [
++ { title: 'Dots', line: 'the family the button has today', 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('')],
++ ['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('')],
++ ] },
++ { 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)],
++ ['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)],
++ ] },
++ { 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('')],
++ ['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('')],
++ ['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('')],
++ ] },
++];
++
++// ── 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'];
++function idleOf(kind, spec, i) {
++ if (spec.paint === 'glow') return 'pulse';
++ if (spec.dot) return 'dot';
++ if (/^tile/.test(spec.paint || '')) return 'float';
++ const cycle = kind === 'P' || SEPARATE_V.includes(spec.mode) ? ['nod', 'breathe', 'drift', 'rock'] : ['breathe', 'drift', 'rock'];
++ return cycle[i % cycle.length];
}
--// ── 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>
++const word = () => '<span class="mx-word"><b>OpenVibe</b><span class="mx-sep">.</span><span class="mx-site" data-mx-site>Live</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`;
++
++function markCard(code, kind, [name, line_, spec], i) {
++ const art = kind === 'L' ? loopArt(spec, `m${++seq}`) : pairArt(spec, `m${++seq}`);
++ const ref = `art-${code}`;
++ return `<li class="mx-card" id="${code}" data-code="${code}" style="${delay(i)}">
++<div class="mx-stage"><svg viewBox="0 0 100 100" width="132" height="132" role="img" aria-label="${code} ${esc(name)}" data-idle="${idleOf(kind, spec, i)}"><defs>${art.defs}</defs><g id="${ref}">${art.body}</g></svg></div>
++<div class="mx-lock">${useOf(ref, 26)}${word()}</div>
++<div class="mx-sizes" aria-label="At 16 and 32 pixels"><span class="mx-dark">${useOf(ref, 16)}${useOf(ref, 32)}</span><span class="mx-light">${useOf(ref, 16)}${useOf(ref, 32)}</span></div>
++<p class="mx-name"><b>${code}</b> ${esc(name)}</p>
++<p class="mx-line">${esc(line_)}</p>
++<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false">Pick ${code}</button>
++</li>`;
++}
++
++const menuSvg = (anim, own, parts, size) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" data-anim="${anim}"${own ? ` data-own="${own}"` : ''}><g class="mb-i">${parts}</g>${own ? '' : '<g class="mb-x"><path d="M7 7L17 17M17 7L7 17"/></g>'}</svg>`;
++function menuCard(code, [name, line_, anim, own, parts], i) {
++ const bar = (tone) => `<div class="mb-nav mb-${tone}">${useOf('art-pair-today', 22)}${word()}<button type="button" class="mb-btn" data-mb aria-pressed="false" aria-label="${code} at its real size: press to open">${menuSvg(anim, own, parts, 22)}</button></div>`;
++ return `<li class="mx-card mb-card" id="${code}" data-code="${code}" style="${delay(i)}">
++<div class="mx-stage"><button type="button" class="mb-btn mb-big" data-mb aria-pressed="false" aria-label="${code} ${esc(name)}: press to open it">${menuSvg(anim, own, parts, 84)}</button></div>
++<div class="mb-bars">${bar('dark')}${bar('light')}</div>
++<p class="mx-name"><b>${code}</b> ${esc(name)}</p>
++<p class="mx-line">${esc(line_)}</p>
<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false">Pick ${code}</button>
</li>`;
}
--function family(letter, list) {
++
++const pad = (n) => String(n).padStart(2, '0');
++function panel(id, kind, rows, label, hidden) {
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('');
++ 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('');
++ return `<section class="mx-row"><h3>${esc(row.title)} <small>${kind}${pad(first)}–${kind}${pad(n)} · ${row.items.length}</small></h3><p class="mx-row-line">${esc(row.line)}</p><ul class="mx-grid">${cards}</ul></section>`;
++ }).join('');
++ return `<section class="mx-panel" id="${id}" role="tabpanel" aria-labelledby="mx-tab-${id}" aria-label="${esc(label)}"${hidden ? ' hidden' : ''}>${body}</section>`;
}
function explorePage() {
seq = 0;
-- const body = `<div class="mx-wrap">
++ const today = pairArt(PAIR_ROWS[0].items[0][2], 'xpair');
++ const loopEx = loopArt(LOOP_ROWS[0].items[1][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">${art}</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)}">
++<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 · 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>
++<p class="mx-eye">The OpenVibe mark · help us choose</p>
++<h1>The Loop or the Pair?</h1>
++<p class="mx-lede">We&rsquo;re choosing OpenVibe&rsquo;s mark, and it&rsquo;s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you&rsquo;d pick, and the takes you like best. Tap <b>Pick</b> on anything you like, then send us your picks.</p>
</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>
++<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>
++<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)}
++<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')}
++</div>
++<p class="mx-q" role="presentation"><span class="mx-qn">2</span> Separately: <b>the menu button&rsquo;s look</b> <small>it stays beside the name whichever mark wins</small></p>
++${tab('menu', 'The menu button', 'Opens search and every site. Fifty looks for it, each one moves when you hover or tap it.', `<span class="mb-btn mb-tabbtn">${menuSvg(m1[2], m1[3], m1[4], 30)}</span>`, 'M01–M50', false, true)}
++</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('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>
</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>`;
++<div class="mx-bar" data-mx-bar hidden><span class="mx-bar-thumbs" data-mx-thumbs aria-hidden="true"></span><span class="mx-bar-t">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.',
++ 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.',
path: '/brand/explore',
active: '/brand',
robots: 'noindex, follow',
@@@ -227,4 +554,7 @@ function explorePage() {
});
}
--module.exports = { explorePage, LOOPS, PAIRS, TONES, loopArt, pairArt };
++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, LOOPS, PAIRS, MENUS, LOOP_ROWS, PAIR_ROWS, MENU_ROWS, loopArt, pairArt };
ddiff --git a/test/brand-explore.test.js b/test/brand-explore.test.js
iindex 1b47cce..21c7f15 100644
---- a/test/brand-explore.test.js
++++ b/test/brand-explore.test.js
@@@ -1,29 +1,59 @@
'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.
++ * /brand/explore: help choose the OpenVibe mark (the owner, 2026-11-01). Three tabs, each showing what it holds:
++ * fifty Loops, fifty Pairs and fifty menu buttons, sorted into rows by idea, each numbered and pickable. Every mark is
++ * drawn large, beside a site's name, and at 16 and 32 px; the small copies reuse the large drawing. The page is not
++ * indexed (they are proposals, not the mark); without the script every tab still shows; /brand/:file still serves.
*/
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
++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 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`);
++ }
++ });
++
const t = await boot();
try {
-- await check('a hundred numbered marks, fifty of each, 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');
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);
++ 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);
assert.ok(codes.every((c) => r.text.includes(`data-pick="${c}"`)), 'each has its Pick button');
++ for (const tab of ['loop', 'pair', 'menu']) {
++ 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"><(svg|span)`), `the ${tab} tab shows an example`);
++ assert.match(r.text, new RegExp(`<section class="mx-panel" id="${tab}" role="tabpanel"`));
++ }
++ assert.match(r.text, /<noscript><style>\.mx-panel\[hidden\]\{display:block!important\}<\/style><\/noscript>/, 'without the script every tab shows');
++ assert.match(r.text, /<h1>The Loop or the Pair\?<\/h1>/, 'the top says the mark is between two');
++ assert.match(r.text, /<span class="mx-qn">1<\/span> The mark: <b>the Loop or the Pair\?<\/b>[\s\S]*?<span class="mx-or" aria-hidden="true">or<\/span>[\s\S]*?<span class="mx-qn">2<\/span> Separately: <b>the menu button&rsquo;s look<\/b>/, 'two questions: the mark, then separately the menu button');
++ assert.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-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');
++ assert.match(r.text, /data-mx-tools hidden/, 'the name and font bar waits for the script');
});
++
++ 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"\/>/);
++ 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));
++ assert.ok(refs.every((x) => ids.includes(x)), 'every reference has its target');
++ assert.match(r.text, /data-mb aria-pressed="false"/, 'the menu buttons can be pressed');
++ assert.ok(!/class="mx-s[^"]*"[^>]*class=/.test(r.text), 'no element carries two class attributes');
++ });
++
await check('the files at /brand/:file still answer', async () => {
const r = await t.get('/brand/openvibe-mark.svg');
assert.strictEqual(r.status, 200);