openvibers/openvibe.design Commits

Eleven more menu buttons made for this button; pressing one shows the menu it opens (#237)

OP OpenVibers committed 3 days ago · 91edd403e4 · 6 files, +167 −27

* Eleven more menu buttons, made for this button; pressing one shows the menu it opens

The owner, 2026-11-10: "generate more icons for the menu, considering what the menu opens... and considering it will
turn into a close button... add some more that we can vote for that are way nicer... and improve the animation and
effects and make it even cooler."

Two new families, shown first and numbered last (M51-M61), so M01-M50 and their votes stay where they were:
- From the logo: a V is the top half of an x, so its strokes grow through into one (V in the O; the V and its dot);
  two Vs slide until their points meet; the spark; the orbit; the nine dots in the logo colours.
- What it opens: the search bar over three apps; you with eight apps round you and the nine dots with a V lit in them
  (both leave an x of dots); four petals that turn into an x; a phone home screen.

Pressing any of the 61 now opens it everywhere on its card, and the stage shows the menu it opens (search, three
tabs, your apps) growing out of the button, which goes to that menu top right as its close button. A ring leaves
the button as it is pressed.

The rule is in the register: "The button that opens it closes it." Three wrappers that kept moves only for people
who had not asked for less motion are gone (Calm plays them).

* The picked menu button is worn by the bar on a phone, and by the two close buttons; the bar stays

The owner, 2026-11-10: "when you select an icon, it sets that as the icon in your nav bar actively... the nav bar
will just go away... while you scroll... disable that on that page... it should change the close button and on the
other, and then I can choose which one I like most."

- Every menu button the bar has wears the pick (it was only the one by the name, which a phone does not show).
- The menu own close button and the Dock close button wear it too, open, over their own drawing (kept in place,
  unseen, so the Dock can still measure it to start on the bar button).
- On this page a phone bar does not hide as you scroll; it still does everywhere else.

* Orbit swings round and back

The dot goes most of the way round its ring and returns; no keyframe on this site turns a whole circle (no-spin).

Changes

ddiff --git a/public/css/marks.css b/public/css/marks.css
iindex 0fdab18..09e51e3 100644
---- a/public/css/marks.css
++++ b/public/css/marks.css
@@@ -179,7 +179,6 @@
.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 var(--ov-dur-4, .8s) var(--ov-ease-out, 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 var(--ov-ease-in-out, 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 var(--ov-ease-in-out, 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 var(--ov-ease-in-out, ease-in-out) calc(1.3s + var(--d,0s)) infinite}
@@@ -187,7 +186,6 @@
.mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .mx-stage>svg[data-idle=nod] .mx-b{animation:mxNod 4.2s var(--ov-ease-in-out, 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 var(--ov-ease-in-out, 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 var(--ov-ease-in-out, ease-in-out) calc(1.3s + var(--d,0s)) infinite}
--}
@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)}}
@@@ -233,7 +231,47 @@ html[data-ov-motion=off] .mx-wrap .mx-card:not(.is-in){opacity:1!important;trans
.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){
++/* 2026-11-10, the buttons drawn from the logo and from the menu. A V is the top half of an ×: its two strokes are
++ drawn half way (mb-h) and grow through to the other side as it opens. Two Vs slide until their points meet. Four
++ petals turn an eighth. A ring of apps, and the launcher's grid with a V lit in it, drop their edge dots and
++ leave an × of dots. */
++.mb-btn .mb-h{stroke-dasharray:50 100;transition:stroke-dasharray var(--ov-dur-4, .45s) var(--ov-ease-out, cubic-bezier(.2,.8,.2,1)),transform var(--ov-dur-4, .45s) var(--ov-ease-spring, cubic-bezier(.2,1.3,.3,1)),opacity var(--ov-dur-2, .25s)}
++.mb-btn.is-open svg:is([data-own=grow],[data-own=growdot]) .mb-h{stroke-dasharray:100 100}
++.mb-btn.is-open svg[data-own=growdot] .mb-o{transform:translateY(-6.4px) scale(.84)}
++.mb-btn.is-open svg[data-own=meet] .mb-u{transform:translateY(2.4px)}
++.mb-btn.is-open svg[data-own=meet] .mb-d{transform:translateY(-2.4px)}
++.mb-btn.is-open svg:is([data-own=gather],[data-own=vgrid]) .mb-r1{opacity:0;transform:scale(0)}
++.mb-btn.is-open svg[data-own=gather] .mb-r2{transform:scale(1.75)}
++.mb-btn.is-open svg[data-own=vgrid] .mb-r2{fill:var(--mb-a);opacity:1;transform:scale(1.14)}
++.mb-btn .mb-c1{fill:var(--mb-c1,#22d3ee)}
++.mb-btn .mb-c3{fill:var(--mb-c3,#a78bfa)}
++.mb-light .mb-btn{--mb-c1:#0891b2;--mb-c3:#7c3aed}
++.mb-btn .mb-orb{transform-box:view-box;transform-origin:12px 12px}
++/* Pressed, a ring leaves the button (once). */
++.mb-btn::after{content:"";position:absolute;inset:0;border-radius:50%;border:2px solid var(--mb-a);opacity:0;pointer-events:none}
++.mb-btn.is-open::after{animation:mbBurst var(--ov-dur-4, .55s) var(--ov-ease-out, cubic-bezier(.2,.8,.2,1)) both}
++@keyframes mbBurst{from{opacity:.75;transform:scale(.35)}to{opacity:0;transform:scale(1.85)}}
++/* What it opens, on the stage behind the button: the menu in small (its search bar, its three tabs, your apps), grown
++ out of the button. The button itself goes to the menu's top right and is its close button: the one button the whole
++ way (the rule "the button that opens it closes it"). A picture, not a control. */
++.mb-card .mx-stage{position:relative;overflow:hidden;container-type:size}
++.mb-card .mb-big{position:relative;z-index:2;transition:transform var(--ov-dur-4, .5s) var(--ov-ease-spring, cubic-bezier(.2,1.15,.3,1))}
++/* Beside the card's own + (which keeps the corner), on the search bar's line. */
++.mb-card .mx-stage:has(.mb-big.is-open) .mb-big{transform:translate(calc(50cqw - 72px),calc(-50cqh + 24px)) scale(.4)}
++.mb-peek{position:absolute;inset:0;z-index:1;display:grid;grid-template-rows:auto auto 1fr;gap:8px;padding:12px;box-sizing:border-box;pointer-events:none;opacity:0;
++ clip-path:circle(0 at 50% 50%);transition:clip-path var(--ov-dur-4, .5s) var(--ov-ease-out, cubic-bezier(.2,.8,.2,1)),opacity var(--ov-dur-2, .25s);
++ background:linear-gradient(180deg,color-mix(in srgb,#3b82f6 10%,#0d1322),#0b1020)}
++.mx-stage:has(.mb-big.is-open) .mb-peek{opacity:1;clip-path:circle(150% at 50% 50%)}
++.mb-peek i{display:block;opacity:0;transform:translateY(calc(8px * var(--ov-move, 1))) scale(.94);transition:opacity var(--ov-dur-3, .3s),transform var(--ov-dur-4, .45s) var(--ov-ease-spring, cubic-bezier(.2,1.2,.3,1));transition-delay:0s}
++.mx-stage:has(.mb-big.is-open) .mb-peek i{opacity:1;transform:none;transition-delay:calc(90ms + var(--i, 0) * 32ms)}
++.mb-peek-s{height:24px;margin-right:88px;border-radius:999px;background:color-mix(in srgb,#e8edf3 7%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,#e8edf3 14%,transparent);position:relative}
++.mb-peek-s::before{content:"";position:absolute;left:9px;top:50%;width:8px;height:8px;margin-top:-5px;border-radius:50%;border:1.6px solid #7aa2ff}
++.mb-peek-t{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
++.mb-peek-t i{height:16px;border-radius:8px;background:color-mix(in srgb,#e8edf3 8%,transparent)}
++.mb-peek-t i:last-child{background:color-mix(in srgb,#3b82f6 34%,#131c2e)}
++.mb-peek-g{display:grid;grid-template-columns:repeat(4,1fr);gap:8px 10px;align-content:start;justify-items:center}
++.mb-peek-g i{width:min(100%,34px);aspect-ratio:1;border-radius:28%;background:linear-gradient(150deg,hsl(var(--h) 72% 58% / .5),hsl(var(--h) 70% 38% / .28));box-shadow:inset 0 0 0 1px hsl(var(--h) 70% 64% / .32)}
++html[data-ov-motion=off] .mb-peek,html[data-ov-motion=off] .mb-peek i{transition:none}
.mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=ripple] .mb-p{animation:mbRipple var(--ov-dur-4, .75s) var(--ov-ease-out, 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 var(--ov-dur-4, .7s) var(--ov-ease-in-out, 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 var(--ov-dur-4, .7s) var(--ov-ease-out, cubic-bezier(.3,.7,.3,1)) calc(var(--i,0) * .06s)}
@@@ -246,7 +284,10 @@ html[data-ov-motion=off] .mx-wrap .mx-card:not(.is-in){opacity:1!important;trans
.mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg:is([data-anim=pop],[data-anim=pulse]) .mb-i{animation:mbPop var(--ov-dur-4, .6s) var(--ov-ease-out, 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 var(--ov-dur-4, .8s) var(--ov-ease-in-out, ease-in-out)}
.mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=nod] .mb-i{animation:mbNod var(--ov-dur-4, .8s) var(--ov-ease-in-out, ease-in-out)}
--}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=apart] .mb-u{animation:mbApart var(--ov-dur-4, .8s) var(--ov-ease-in-out, ease-in-out)}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=apart] .mb-d{animation:mbApart var(--ov-dur-4, .8s) var(--ov-ease-in-out, ease-in-out) reverse}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=orbit] .mb-orb{animation:mbOrbit 1.2s var(--ov-ease-in-out, cubic-bezier(.65,0,.35,1))}
++ .mb-btn:not(.is-open):is(:hover,:focus-visible,.is-play) svg[data-anim=spin] .mb-g{animation:mbSpin .9s var(--ov-ease-in-out, cubic-bezier(.65,0,.35,1))}
@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)}}
@@@ -259,6 +300,9 @@ html[data-ov-motion=off] .mx-wrap .mx-card:not(.is-in){opacity:1!important;trans
@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)}}
++@keyframes mbApart{0%,100%{transform:translateY(0)}45%{transform:translateY(-1.6px)}}
++@keyframes mbOrbit{50%{transform:rotate(200deg)}}
++@keyframes mbSpin{to{transform:rotate(90deg)}}
.mb-card .mx-stage{color:#e8edf3}
.mb-big{width:auto;height:auto;padding:14px;border-radius:24px}
.mb-big::before{inset:8px}
@@@ -410,6 +454,16 @@ html[data-ov-motion=off] .mx-wrap .mx-card:not(.is-in){opacity:1!important;trans
.openvibe-navbar .ovnav-orb .mx-navmenu{all:unset;position:relative;display:grid;place-items:center;width:24px;height:24px;color:inherit}
.openvibe-navbar .ovnav-orb .mx-navmenu svg{width:22px;height:22px}
.openvibe-navbar .mx-nav-flash{animation:mxNavFlash 1.1s var(--ov-ease-out, ease-out)}
++/* The two close buttons the picked menu button becomes (the menu's own and the Dock's) wear it too, open. Their own
++ drawing stays in place, unseen: the Dock measures it to start on the bar's button. */
++.mx-skinned > *{grid-area:1/1}
++.mx-skinned > svg{opacity:0}
++.mx-skinned > .mx-navmenu{all:unset;grid-area:1/1;display:grid;place-items:center;width:24px;height:24px;color:inherit;pointer-events:none}
++.mx-skinned > .mx-navmenu svg{width:22px;height:22px}
++/* On this page the bar is the preview, so it stays in view (the owner, 11-10: "the nav bar will just go away… while you
++ scroll… disable that on that page"). Everywhere else a phone's bar still hides as you scroll down. */
++html:has(.mx-wrap) .openvibe-navbar.ovnav-hide{transform:none!important}
++html:has(.mx-wrap) #navbar-mount:has(> .ovnav-hide){pointer-events:auto}
@keyframes mxNavFlash{0%{box-shadow:0 0 0 0 color-mix(in srgb,#8b5cf6 70%,transparent);border-radius:12px}60%{box-shadow:0 0 0 10px transparent;border-radius:12px}100%{box-shadow:none}}
/* The tally counts both families (a person can like both), and every vote sits in a fold under it. */
@@@ -477,7 +531,6 @@ html[data-ov-motion=off] .mx-wrap .mx-card:not(.is-in){opacity:1!important;trans
/* Each wildcard moves by its kind of motion (data-wild-motion): only while its card is on screen, with long rests,
and never under reduced motion. The parts that move are w-a, w-b and w-c. */
.mx-wild svg :is(.w-a,.w-b,.w-c){transform-box:fill-box;transform-origin:center}
--@media (prefers-reduced-motion:no-preference){
.mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild-motion] :is(.w-a,.w-b,.w-c){animation-duration:3.6s;animation-iteration-count:infinite;animation-timing-function:var(--ov-ease-in-out, ease-in-out);animation-delay:calc(var(--d,0s) + var(--i,0) * .09s)}
.mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild-motion=draw] .w-a{stroke-dasharray:100;animation-name:wDraw;animation-duration:3.4s}
.mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild-motion=join] .w-a{animation-name:wJoinL}
@@@ -508,7 +561,6 @@ html[data-ov-motion=off] .mx-wrap .mx-card:not(.is-in){opacity:1!important;trans
.mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild-motion=glitch] .w-a{animation-name:wGlitchL;animation-duration:2.8s;animation-timing-function:steps(1)}
.mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild-motion=glitch] .w-b{animation-name:wGlitchR;animation-duration:2.8s;animation-timing-function:steps(1)}
.mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild-motion=caret] .w-a{animation-name:wCaret;animation-duration:1.1s;animation-timing-function:steps(1)}
--}
@keyframes wBar{0%,100%{transform:scaleY(1)}30%{transform:scaleY(.55)}60%{transform:scaleY(1.08)}}
@keyframes wRipple{0%,60%,100%{opacity:1;transform:scale(1)}25%{opacity:.2;transform:scale(1.08)}}
@keyframes wHum{0%,40%,100%{transform:rotate(0)}5%{transform:rotate(1.6deg)}10%{transform:rotate(-1.6deg)}15%{transform:rotate(1.1deg)}20%{transform:rotate(-1.1deg)}25%{transform:rotate(.5deg)}}
ddiff --git a/public/css/patterns.css b/public/css/patterns.css
iindex d09624e..cfed7b3 100644
---- a/public/css/patterns.css
++++ b/public/css/patterns.css
@@@ -116,7 +116,7 @@ html[data-ov-motion=off] .pcard:hover{transform:none}html[data-ov-motion=off] .d
.line-act{margin-left:auto;font-size:13px;color:var(--text-secondary)}
.line-work{position:relative;display:grid;place-items:center;height:110px;margin:0 10px 10px;border-radius:12px;background:var(--bg-secondary);color:var(--text-secondary)}
.line-work.covered::after{content:"Building\u2026";position:absolute;inset:0;display:grid;place-items:center;border-radius:12px;background:color-mix(in srgb,var(--bg-primary) 88%,transparent);backdrop-filter:blur(4px);color:var(--text-primary);font-weight:700}
--@media (prefers-reduced-motion:no-preference){.line-demo[data-state=running] i{animation:line-live 1.8s ease-out infinite}}
++.line-demo[data-state=running] i{animation:line-live 1.8s ease-out infinite}
@keyframes line-live{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--accent) 60%,transparent)}70%,100%{box-shadow:0 0 0 6px transparent}}
.even-demo{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:8px;padding:10px}
.even-demo span{display:grid;place-items:center;height:44px;border-radius:10px;background:var(--bg-secondary);font-weight:800}
ddiff --git a/public/js/marks.js b/public/js/marks.js
iindex 690ba93..bdf8f39 100644
---- a/public/js/marks.js
++++ b/public/js/marks.js
@@@ -110,6 +110,22 @@
own.forEach(function (c) { if (c.hasAttribute('data-mx-hid')) { c.style.display = c.getAttribute('data-mx-hid'); c.removeAttribute('data-mx-hid'); } });
return null;
}
++ // A class is set only when it changes (this page watches these elements; an unchanged write would call it back).
++ function flag(el, cls, on) { if (el.classList.contains(cls) !== !!on) el.classList.toggle(cls, !!on); }
++ // The close buttons the menu button becomes (the owner, 11-10: "it should change the close button and on the
++ // other… then I can choose which one I like most"): the menu's own and the Dock's wear the pick, open, over
++ // their own drawing, which stays in place unseen (the Dock measures it to start on the bar's button).
++ function wearClose(src, code) {
++ all('.ovs-orb, .ovdk-orbx').forEach(function (host) {
++ var mine = host.querySelector(':scope > .mx-navmenu');
++ if (!src) { if (mine) mine.parentNode.removeChild(mine); flag(host, 'mx-skinned', false); return; }
++ if (!mine) { mine = document.createElement('span'); mine.className = 'mx-navmenu mb-btn'; host.appendChild(mine); }
++ if (mine.getAttribute('data-code') !== code) { mine.innerHTML = src.outerHTML; mine.setAttribute('data-code', code); }
++ flag(host, 'mx-skinned', true);
++ var dock = host.closest('.ovdk');
++ flag(mine, 'is-open', dock ? !dock.hidden && !host.classList.contains('is-from') : host.classList.contains('is-x'));
++ });
++ }
function navPreview() {
var bar = document.querySelector('.openvibe-navbar');
var brand = bar && bar.querySelector('.openvibe-navbar-brand');
@@@ -125,18 +141,24 @@
} : null);
if (mark) mark.firstChild.setAttribute('href', '#' + navRef);
}
-- var orb = bar.querySelector('.ovnav-orb');
++ // Every menu button the bar has wears the pick (a phone shows the one at the bar's right end, a computer the
++ // one by the name; the owner, 11-10: "when you select an icon, it sets that as the icon in your nav bar
++ // actively"), open when its menu is. So do the two close buttons that button becomes (wearClose).
++ var orbs = all('.ovnav-orb', bar), orb = orbs[0] || null;
var src = menu && document.getElementById(menu) && document.getElementById(menu).querySelector('.mb-nav .mb-btn svg');
-- if (orb) {
-- var box = swapIn(orb, 'mx-navmenu', src ? function () { var b = document.createElement('span'); b.className = 'mx-navmenu mb-btn'; return b; } : null);
-- if (box && box.getAttribute('data-code') !== menu) { box.innerHTML = src.outerHTML; box.setAttribute('data-code', menu); }
-- }
++ orbs.forEach(function (o) {
++ var box = swapIn(o, 'mx-navmenu', src ? function () { var b = document.createElement('span'); b.className = 'mx-navmenu mb-btn'; return b; } : null);
++ if (!box) return;
++ if (box.getAttribute('data-code') !== menu) { box.innerHTML = src.outerHTML; box.setAttribute('data-code', menu); }
++ flag(box, 'is-open', o.getAttribute('aria-expanded') === 'true' || o.classList.contains('is-dock'));
++ });
++ wearClose(src, menu);
var name = brand.querySelector('.name');
var word = font && document.getElementById(font) && document.getElementById(font).querySelector('.fx-word');
if (name) name.style.fontFamily = word && word.style.fontFamily ? word.style.fontFamily : '';
// A short glow on what changed, so the eye goes to the bar.
var now = [navRef, menu, font].join('|');
-- if (navSeen && now !== navSeen) { brand.classList.remove('mx-nav-flash'); if (orb) orb.classList.remove('mx-nav-flash'); void brand.offsetWidth; brand.classList.add('mx-nav-flash'); if (orb && menu) orb.classList.add('mx-nav-flash'); }
++ if (navSeen && now !== navSeen) { brand.classList.remove('mx-nav-flash'); orbs.forEach(function (o) { o.classList.remove('mx-nav-flash'); }); void brand.offsetWidth; brand.classList.add('mx-nav-flash'); if (menu) orbs.forEach(function (o) { o.classList.add('mx-nav-flash'); }); }
navSeen = now;
}
(function watchNav() {
@@@ -146,6 +168,16 @@
if (!bar) { if (++tries < 40) setTimeout(hook, 250); return; }
navPreview();
if (obs || !window.MutationObserver) return;
++ // The menu and the Dock are built when first opened, and their buttons change as they open and close: the
++ // pick follows them, once a frame at most.
++ var due = 0, WATCH = '.ovnav-orb, .ovs-orb, .ovdk-orbx, .ovdk';
++ new MutationObserver(function (ms) {
++ if (due || !lastPick('M')) return;
++ for (var i = 0; i < ms.length; i++) {
++ var m = ms[i], hit = m.type === 'attributes' ? (m.target.matches && m.target.matches(WATCH)) : Array.prototype.some.call(m.addedNodes, function (n) { return n.nodeType === 1 && (n.matches(WATCH) || n.querySelector(WATCH)); });
++ if (hit) { due = requestAnimationFrame(function () { due = 0; navPreview(); }); return; }
++ }
++ }).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'hidden', 'aria-expanded'] });
// The navbar redraws its brand on sign-in and account changes: put the picks back when it does.
obs = new MutationObserver(function () {
if ((navRef && !bar.querySelector('.mx-navmark')) || (lastPick('M') && !bar.querySelector('.mx-navmenu'))) navPreview();
@@@ -467,9 +499,13 @@
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);
++ // One press opens it everywhere on its card: the big one (with the menu it opens behind it) and both bars.
++ var card = mb.closest('.mb-card');
++ (card ? all('[data-mb]', card) : [mb]).forEach(function (b) {
++ b.classList.toggle('is-open', open);
++ b.setAttribute('aria-pressed', open ? 'true' : 'false');
++ if (!open) play(b);
++ });
return;
}
var p = e.target.closest('[data-pick]');
ddiff --git a/server/rules/register.js b/server/rules/register.js
iindex 7b82d6f..d5bbf8e 100644
---- a/server/rules/register.js
++++ b/server/rules/register.js
@@@ -35,6 +35,10 @@ const RULES = Object.freeze([
'There are two sets: a site’s own mark (live, chat, quest: drawn in the page) and the glyphs (wrench, heart, camera: drawn as SVG). A name from one written as the other draws the fallback mark, a bare "V". A page’s checks fail when it asks for a mark that does not exist.',
'2026-11-10', 'Most of Quest’s icons were a "V": glyph names written as site marks, and the check looked at the wrong list.',
[{ label: 'Icons', href: '/icons' }]),
++ R('opens-it-closes-it', 'navigation', 'The button that opens it closes it.',
++ 'A panel that covers the screen grows out of the button that opened it, and that button, in the same place, is its close button: the menu button turns into the close mark and back, and the panel folds back into it. The panel carries no second close button of its own; a different one somewhere else is a second answer to one question.',
++ '2026-11-10', 'A phone\u2019s Dock slid up from the bottom carrying its own close button, while the menu button it belongs to sat untouched in the bar.',
++ [{ label: 'The menu button, opening', href: '/brand/explore#menu' }, { label: 'Motion', href: '/motion' }], ['openvibe-shared test: menu-home-screen-chrome']),
R('calm-reduces-off-removes', 'honest', 'Calm makes motion smaller and slower. Only Off removes it.',
'Motion has four settings: Automatic (follows the device), Full, Calm and Off. Under Calm, and on a device that asks for less motion, every effect still plays: a third of the distance, no overshoot, endless loops at 0.6 of their speed. A page never stops an effect because motion is reduced; a rule that stops one belongs to Off (html[data-ov-motion=off]). A distance of 24px or more is multiplied by --ov-move. Only what starts by itself and fills the view (a video, a carousel) still waits for a tap under Calm.',
'2026-11-10', 'Reduced motion gave every animation no length at all, so Calm pages looked broken: the owner asked for motion reduced, not removed.',
ddiff --git a/server/views/marks-explore.js b/server/views/marks-explore.js
iindex fb24509..e5bb662 100644
---- a/server/views/marks-explore.js
++++ b/server/views/marks-explore.js
@@@ -10,7 +10,8 @@
*
* L01–L72 the Loop: a ring opened at the top with the V folded into it
* P01–P60 the Pair: a ring O and a V side by side, the ground showing where they cross
-- * M01–M50 the menu button: search and every OpenVibe site, beside the name on every site
++ * M01–M61 the menu button: search and every OpenVibe site, beside the name on every site (M51–M61, 2026-11-10:
++ * drawn from the logo, and the menu in miniature; shown first)
*
* 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),
@@@ -584,6 +585,26 @@ const MENU_ROWS = [
['Tiles in a circle', 'four tiles in a ring', 'bloom', '', ring(12, 12, 10, ' mb-faint', 0, 1.6) + tiles(2, 4.4, 1.6, 1.2, 1)],
['Chevron in a circle', 'a V pointing down; open, it flips', 'nod', 'flip', ring(12, 12, 8.8, ' mb-faint', 0, 1.8) + `<g class="mb-g">${line('M8.2 10.4 12 14.2 15.8 10.4', ' mb-sa', 1, 2.4)}</g>`],
] },
++ // ── Eleven more (the owner, 2026-11-10: "generate more icons for the menu, considering what the menu opens… and
++ // considering it'll turn into a close button… and OpenVibe in general, try and theme it better"). Two families made
++ // for this button and no other: drawn from the logo, so the × is already in them (a V is the top half of an ×);
++ // and the menu itself in miniature (search on top, your apps, you in the middle). They are appended, so M01–M50
++ // keep their numbers and the votes cast for them; `lead` shows these two rows first.
++ { title: 'From the logo', line: 'the O and the V as a button; most open into an × of their own', lead: true, items: [
++ ['V in the O', 'the mark in a ring; open, the V grows through into an ×', 'pop', 'grow', ring(12, 12, 9.4, ' mb-faint', 0, 1.9) + line('M7.5 7.5L16.5 16.5', ' mb-h', 1, 2.5) + line('M16.5 7.5L7.5 16.5', ' mb-h', 2, 2.5)],
++ ['The V and its dot', 'a V with the O as a lit dot; open, the dot rises to the middle of an ×', 'nod', 'growdot', line('M6.4 6.4L17.6 17.6', ' mb-h', 0, 2.8) + line('M17.6 6.4L6.4 17.6', ' mb-h', 1, 2.8) + dot(12, 18.4, 2.3, ' mb-a mb-o', 2)],
++ ['Two Vs', 'a V over its reflection; open, they meet point to point', 'apart', 'meet', line('M6.8 4.4L12 9.6L17.2 4.4', ' mb-sa mb-u', 0, 2.6) + line('M6.8 19.6L12 14.4L17.2 19.6', ' mb-d', 1, 2.6)],
++ ['Spark', 'the spark you ask Vibe with; open, an ×', 'twinkle', '', fill(spark(12, 12.4, 8.8), '', 0) + fill(spark(19, 5, 2.9), ' mb-a', 1)],
++ ['Orbit', 'you, and something circling you', 'orbit', '', ring(12, 12, 8.4, ' mb-faint', 0, 1.7) + dot(12, 12, 2.4, '', 0) + `<g class="mb-orb">${dot(...polar(8.4, -45), 2.3, ' mb-a', 1)}</g>`],
++ ['Today, in the logo\'s colours', 'the nine dots, cyan into blue into violet', 'ripple', 'diamond', `<g class="mb-g">${[[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]].map(([x, y, n]) => dot(x, y, n ? 2.15 : 2.5, ` mb-r${n} ${x < 12 ? 'mb-c1' : x > 12 ? 'mb-c3' : 'mb-a'}`, n)).join('')}</g>`],
++ ] },
++ { title: 'What it opens', line: 'the menu itself, small: search, your apps, you', lead: true, items: [
++ ['Search and apps', 'the search bar over three apps', 'wave', '', frame(4, 5.2, 16, 5.6, 2.8, '', 0, 1.9) + dot(7.4, 8, 1.15, ' mb-a', 0) + box(4.2, 14.6, 4, 4, 1.3, '', 1) + box(10, 14.6, 4, 4, 1.3, ' mb-a', 2) + box(15.8, 14.6, 4, 4, 1.3, '', 3)],
++ ['You and your apps', 'you in the middle, eight apps round you; open, an × of dots', 'spin', 'gather', `<g class="mb-g">${dot(12, 12, 2.7, ' mb-a mb-r0', 0)}${[0, 1, 2, 3, 4, 5, 6, 7].map((k) => dot(...polar(8, k * 45), 1.45, k % 2 ? ' mb-r2' : ' mb-r1', 1 + (k % 2))).join('')}</g>`],
++ ['The V in the grid', 'the launcher\'s nine dots with a V lit in them; open, an × of dots', 'ripple', 'vgrid', `<g class="mb-g">${[[12, 12, 0, 1], [12, 5, 1, 0], [19, 12, 1, 0], [12, 19, 1, 0], [5, 12, 1, 0], [5, 5, 2, 1], [19, 5, 2, 1], [19, 19, 2, 0], [5, 19, 2, 0]].map(([x, y, n, lit]) => dot(x, y, n ? 2.1 : 2.4, ` mb-r${n}${lit ? ' mb-a' : ' mb-soft'}`, n)).join('')}</g>`],
++ ['Petals', 'four petals round a lit centre; open, they turn into an ×', 'bloom', 'plus', `<g class="mb-g">${box(10.4, 3, 3.2, 7.2, 1.6, '', 1)}${box(13.8, 10.4, 7.2, 3.2, 1.6, '', 2)}${box(10.4, 13.8, 3.2, 7.2, 1.6, '', 3)}${box(3, 10.4, 7.2, 3.2, 1.6, '', 4)}${dot(12, 12, 1.5, ' mb-a', 0)}</g>`],
++ ['Home screen', 'six apps and a dock, as on a phone', 'bloom', '', [0, 1].map((r) => [0, 1, 2].map((c) => box(4.2 + c * 5.8, 4 + r * 5.8, 4, 4, 1.3, r === 0 && c === 2 ? ' mb-a' : '', r + c)).join('')).join('') + box(5.2, 16.8, 13.6, 2.8, 1.4, ' mb-soft', 3)],
++ ] },
];
// ── The page ─────────────────────────────────────────────────────────────────────────────────────────────────────
@@@ -645,11 +666,14 @@ function markCard(code, kind, [name, line_, spec, lock, motion], i) {
}
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>`;
++// What the button opens, behind it on the stage: the menu in small (its search bar, its three tabs, your apps). Pressed,
++// the button goes to the menu's top right and is its close button (marks.css .mb-peek); it is a picture, not a control.
++const PEEK = `<span class="mb-peek" aria-hidden="true"><i class="mb-peek-s"></i><span class="mb-peek-t">${[0, 1, 2].map((k) => `<i style="--i:${k + 1}"></i>`).join('')}</span><span class="mb-peek-g">${[212, 268, 28, 205, 152, 318, 48, 172].map((hue, k) => `<i style="--i:${k + 3};--h:${hue}"></i>`).join('')}</span></span>`;
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)}">
<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false" aria-label="Pick ${code}"></button>
--<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="mx-stage">${PEEK}<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>
@@@ -696,11 +720,13 @@ function fontCard(code, [name, line_, stack, weight], i) {
const pad = (n) => String(n).padStart(2, '0');
function panel(id, kind, rows, label, hidden) {
let n = 0;
-- const body = rows.map((row) => {
++ // A row marked `lead` is shown first; its numbers still come from its place in the list, so no number ever moves.
++ const sections = 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) : kind === 'F' ? fontCard(code, item, n - 1) : markCard(code, kind, item, n - 1); }).join('');
return `<section class="mx-row"><h3>${esc(row.title)} <small>${kind}${pad(first)}–${kind}${pad(n)} · ${row.items.length}</small><span class="mx-swipe" aria-hidden="true">swipe →</span></h3><p class="mx-row-line">${esc(row.line)}</p><ul class="mx-grid">${cards}</ul></section>`;
-- }).join('');
++ });
++ const body = rows.map((row, k) => [row.lead ? 0 : 1, k]).sort((a, b) => a[0] - b[0] || a[1] - b[1]).map(([, k]) => sections[k]).join('');
const NEXT = {
loop: [['pair', 'See the Pair', 'the other kind'], ['menu', 'Next: menu button', 'step 2']],
pair: [['loop', 'See the Loop', 'the other kind'], ['menu', 'Next: menu button', 'step 2']],
@@@ -715,7 +741,7 @@ function panel(id, kind, rows, label, hidden) {
loop: [1, 'Pick a logo: the Loop', 'Tap the ones you like. Look at the Pair too.'],
pair: [1, 'Pick a logo: the Pair', 'Tap the ones you like. Look at the Loop too.'],
wild: [1, 'Pick a logo: the Wildcards', 'The O and the V, forty other ways. Each moves, shows a status, and wears the name its own way.'],
-- menu: [2, 'Pick a menu button', 'Tap one to see it open.'],
++ menu: [2, 'Pick a menu button', 'Tap one: it opens the menu and becomes its close button.'],
font: [3, 'Pick a font', 'Tap the one that looks right.'],
}[id];
const head = `<div class="mx-stephead"><span class="mx-stepn mx-stepn-lg" aria-hidden="true">${STEP[0]}</span><div><b>Step ${STEP[0]} of 3 · ${esc(STEP[1])}</b><small>${esc(STEP[2])}</small></div></div>`;
@@@ -798,7 +824,7 @@ ${wild ? `<span class="mx-or" aria-hidden="true">+</span>
${tab('wild', 'The Wildcards', 'OV, forty other ways', `<svg viewBox="0 0 100 100" width="58" height="58" aria-hidden="true"><defs>${wildEx.defs}</defs>${wildEx.body}</svg>`, `W01–W${pad(WN)} · ${WN} ideas`)}` : ''}
</div>
<p class="mx-q" role="presentation"><span class="mx-qn">2</span> <b>Pick a menu button</b> <small>it sits next to the logo</small></p>
--${tab('menu', 'The menu button', 'The button next to the logo', `<span class="mb-btn mb-tabbtn">${menuSvg(m1[2], m1[3], m1[4], 30)}</span>`, 'M01–M50', false, true)}
++${tab('menu', 'The menu button', 'The button next to the logo', `<span class="mb-btn mb-tabbtn">${menuSvg(m1[2], m1[3], m1[4], 30)}</span>`, `M01–M${pad(MENU_ROWS.reduce((t, r) => t + r.items.length, 0))}`, false, true)}
<p class="mx-q" role="presentation"><span class="mx-qn">3</span> <b>Pick a font</b> <small>for the name</small></p>
${tab('font', 'The font', 'The letters of the name', '<span class="fx-tabart" aria-hidden="true">Aa</span>', `F01–F${pad(FONTS.length)}`, false, true)}
</nav>
@@@ -817,7 +843,7 @@ ${wild ? `<a href="#wild" data-mx-go="wild" class="mx-step">${miniArt('W01', 20,
${panel('loop', 'L', LOOP_ROWS, `The Loop: ${LOOPS.length} takes`, false)}
${panel('pair', 'P', PAIR_ROWS, `The Pair: ${PAIRS.length} takes`, true)}
${wild ? panel('wild', 'W', WILD_ROWS, `The Wildcards: ${WN} ideas`, true) : ''}
--${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}
++${panel('menu', 'M', MENU_ROWS, `The menu button: ${MENU_ROWS.reduce((t, r) => t + r.items.length, 0)} ideas`, true)}
${panel('font', 'F', FONT_ROWS, 'The font: twelve faces', true)}
<div class="mx-nudge" data-mx-nudge role="status" hidden><span data-mx-nudge-text></span><a href="#menu" data-mx-go="menu" class="mx-btn" data-mx-nudge-go>Go to step 2 →</a><button type="button" class="mx-btn mx-btn-quiet" data-mx-nudge-close aria-label="Close">✕</button></div>
<p class="mx-fine">Nothing changes on the sites until a logo wins. <a href="/brand">Today&rsquo;s logo</a>.</p>
ddiff --git a/test/brand-explore.test.js b/test/brand-explore.test.js
iindex 23cf431..fff3d22 100644
---- a/test/brand-explore.test.js
++++ b/test/brand-explore.test.js
@@@ -12,7 +12,7 @@ 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]]) {
-- const want = { Loop: 72, Pair: 60, menu: 50 }[name];
++ const want = { Loop: 72, Pair: 60, menu: 61 }[name];
assert.strictEqual(list.length, want, `${name}: ${want}`);
const recipes = list.map((x) => JSON.stringify(x.slice(2)));
assert.strictEqual(new Set(recipes).size, want, `${name}: every recipe differs`);
@@@ -20,15 +20,37 @@ const explore = require('../server/views/marks-explore');
}
});
++ await check('the bar is the preview: every menu button it has wears the pick, so do the two close buttons, and it stays in view', () => {
++ const fs = require('fs'), path = require('path');
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'marks.js'), 'utf8');
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'marks.css'), 'utf8');
++ assert.ok(js.includes("var orbs = all('.ovnav-orb', bar)"), 'every menu button in the bar (a phone shows the one at its right end)');
++ assert.ok(js.includes("all('.ovs-orb, .ovdk-orbx').forEach"), 'the menu\'s own close button and the Dock\'s wear it too');
++ assert.ok(js.includes("flag(mine, 'is-open', dock ? !dock.hidden && !host.classList.contains('is-from') : host.classList.contains('is-x'))"), 'open when the sheet is, at rest while it is still on the bar\'s button');
++ assert.ok(/\.mx-skinned > svg\{opacity:0\}/.test(css) && /\.mx-skinned > \.mx-navmenu\{all:unset;grid-area:1\/1;/.test(css), 'over their own drawing, which keeps its place (the Dock measures it)');
++ assert.ok(css.includes('html:has(.mx-wrap) .openvibe-navbar.ovnav-hide{transform:none!important}'), 'on this page a phone\'s bar does not hide as you scroll');
++ assert.ok(!/prefers-reduced-motion:\s*no-preference/.test(css), 'no move is kept only for people who have not asked for less motion');
++ });
++
const t = await boot();
try {
await check('three tabs, a hundred and fifty numbered cards in rows, every one pickable', async () => {
const r = await t.get('/brand/explore');
assert.strictEqual(r.status, 200);
const codes = [...r.text.matchAll(/<li class="mx-card(?: mb-card| fx-card| mx-wild)?" id="([LPMFW]\d{2})" data-code="\1"/g)].map((m) => m[1]);
-- assert.strictEqual(codes.length, 194, 'the Loop, the Pair, the menu buttons and the fonts: the Wildcards are off the vote');
-- assert.strictEqual(new Set(codes).size, 194, 'each number once');
-- assert.deepStrictEqual(['L', 'P', 'W', 'M', 'F'].map((k) => codes.filter((c) => c[0] === k).length), [72, 60, 0, 50, 12]);
++ assert.strictEqual(codes.length, 205, 'the Loop, the Pair, the menu buttons and the fonts: the Wildcards are off the vote');
++ assert.strictEqual(new Set(codes).size, 205, 'each number once');
++ // Eleven more menu buttons (2026-11-10), made for this button: drawn from the logo, and the menu in small. They
++ // are shown first and numbered last, so M01–M50 and the votes cast for them stay where they were.
++ const menus = codes.filter((c) => c[0] === 'M');
++ assert.deepStrictEqual(menus.slice(0, 12), ['M51', 'M52', 'M53', 'M54', 'M55', 'M56', 'M57', 'M58', 'M59', 'M60', 'M61', 'M01'], 'the two new families lead, then the diamond');
++ assert.strictEqual(explore.MENUS[7][0], 'Lit corners', 'M08 is still the idea it was');
++ assert.match(r.text, /<h3>From the logo <small>M51–M56 · 6<\/small>/);
++ assert.match(r.text, /<h3>What it opens <small>M57–M61 · 5<\/small>/);
++ assert.match(r.text, /Tap one: it opens the menu and becomes its close button\./);
++ assert.match(r.text, /<div class="mx-stage"><span class="mb-peek" aria-hidden="true">[\s\S]*?<\/span><button type="button" class="mb-btn mb-big" data-mb/, 'each stage holds the menu it opens, as a picture behind the button');
++ for (const own of ['grow', 'growdot', 'meet', 'gather', 'vgrid']) assert.ok(r.text.includes(`data-own="${own}"`), `a button that opens its own way: ${own}`);
++ assert.deepStrictEqual(['L', 'P', 'W', 'M', 'F'].map((k) => codes.filter((c) => c[0] === k).length), [72, 60, 0, 61, 12]);
// The owner, 11-01: "I've changed my mind on them… keep them, they're cool to look at": ?wild=1 still shows them.
const withWild = await t.get('/brand/explore?wild=1');
assert.strictEqual((withWild.text.match(/<li class="mx-card mx-wild" id="W\d\d"/g) || []).length, 40, 'the Wildcards, to look at');