openvibers/openvibe.design Commits
/brand/explore: the Loop or the Pair, then the menu button; rows, labels, fonts, motion, phone rails (#59)
Changes
@@@ -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}++}@@@ -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();@@@ -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’re choosing OpenVibe’s mark, and it’s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you’d pick, and the takes you like best. Tap <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’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 };@@@ -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’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);
@@ -1,51 +1,245 @@
@@ -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-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-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: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{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-dark{background:#0d1322;color:#e8edf3}
.mx-light{background:#f4f6fb;color:#0f1419}
.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 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: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-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)}
.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-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)}
.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: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}
+}
@@ -1,46 +1,211 @@
@@ -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 () {
(function () {
'use strict';
'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() {
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]');
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 out = bar.querySelector('[data-mx-picks]');
+ var thumbs = bar.querySelector('[data-mx-thumbs]');
var picks = [];
var picks = [];
var fromHash = /(?:^#|&)picks=([A-Z0-9,]+)/.exec(location.hash || '');
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 order(list) { return list.slice().sort(); }
function link() { return location.origin + location.pathname + (picks.length ? '#picks=' + order(picks).join(',') : ''); }
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() {
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.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');
var card = b.closest('.mx-card');
if (card) card.classList.toggle('is-picked', on);
if (card) card.classList.toggle('is-picked', on);
});
});
bar.hidden = !picks.length;
bar.hidden = !picks.length;
out.textContent = order(picks).join(', ');
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 say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }
function copy(text, btn) {
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); });
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);
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) {
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();
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);
document.addEventListener('ov:boost:load', init);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
@@ -1,223 +1,550 @@
@@ -1,223 +1,550 @@
'use strict';
'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 { 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 f = (v) => +Number(v).toFixed(2);
const rad = (d) => (d * Math.PI) / 180;
const rad = (d) => (d * Math.PI) / 180;
function mix(hex, to, t) {
function mix(hex, to, t) {
const a = parseInt(hex.slice(1), 16), b = parseInt(to.slice(1), 16);
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('')}`;
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 ─────────────────────────────────────────────────────────────────────────────────────────────────────
// ── 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;
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;
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>
<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false">Pick ${code}</button>
</li>`;
</li>`;
}
}
-function family(letter, list) {
+
+const pad = (n) => String(n).padStart(2, '0');
+function panel(id, kind, rows, label, hidden) {
let n = 0;
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() {
function explorePage() {
seq = 0;
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">
<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’re choosing OpenVibe’s mark, and it’s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you’d pick, and the takes you like best. Tap <b>Pick</b> on anything you like, then send us your picks.</p>
</header>
</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’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>
-<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({
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',
path: '/brand/explore',
active: '/brand',
active: '/brand',
robots: 'noindex, follow',
robots: 'noindex, follow',
@@ -227,4 +554,7 @@ function explorePage() {
@@ -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 };
@@ -1,29 +1,59 @@
@@ -1,29 +1,59 @@
'use strict';
'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 assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
+const explore = require('../server/views/marks-explore');
(async () => {
(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();
const t = await boot();
try {
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');
const r = await t.get('/brand/explore');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- const codes = [...r.text.matchAll(/<li class="mx-card" 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');
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’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, /<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');
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 () => {
await check('the files at /brand/:file still answer', async () => {
const r = await t.get('/brand/openvibe-mark.svg');
const r = await t.get('/brand/openvibe-mark.svg');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);