openvibers/openvibe.design Commits

/brand/explore: the Wildcards, forty other ideas, each with its motion, status and lockup (#68)

OP OpenVibers committed 12 days ago · e40d5cc44d · 7 files, +650 −55

The owner, 11-01: 'an option 3… wildly different variations… completely different ideas for OpenVibe branding… 40 of
them… the Loop, the Pair and/or these… be thoughtful about animations and effects… status icons… how the label could be
attached'.

W01-W40, in eight rows of five (sound and signal, places, friendly and alive, made of parts, nature, things you hold,
signs and symbols, play and motion), drawn by hand in server/views/wild-marks.js. Each one:
- moves its own way while its card is on screen (bars bounce, the eye looks around and blinks, the sun rises, the door
  swings, the plane glides, the orbit turns…), with long rests and never under reduced motion;
- shows itself as a status: live (its key part turns red and pulses, a red dot) and new (a count on its corner);
- wears the name its own way: beside, in a pill, underlined by a running line, in a bubble, stacked, tagged, trailed.

A third tab beside the Loop and the Pair ('+' between them: any mix), a Wild step, and a tally that counts the three
kinds as one bar, a Wild row and Every vote. Migration 0007 admits W codes.

Changes

ddiff --git a/migrations/0007_brand_picks_wildcards.sql b/migrations/0007_brand_picks_wildcards.sql
nnew file mode 100644
iindex 0000000..677a2fa
---- /dev/null
++++ b/migrations/0007_brand_picks_wildcards.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- /brand/explore's third kind, the Wildcards (W01–W40; the owner, 2026-11-01: "an option 3… 40 of them"): the codes'
++-- check admits W beside L, P, M and F. Replacing a CHECK with a wider one keeps every saved row valid (N-1 safe).
++ALTER TABLE brand_picks DROP CONSTRAINT IF EXISTS brand_picks_code_check;
++ALTER TABLE brand_picks ADD CONSTRAINT brand_picks_code_check CHECK (code ~ '^[LPMFW](0[1-9]|[1-9][0-9])$');
ddiff --git a/public/css/marks.css b/public/css/marks.css
iindex 06b1366..7f66344 100644
---- a/public/css/marks.css
++++ b/public/css/marks.css
@@@ -427,3 +427,153 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
.mx-all-row{display:flex;align-items:flex-start;gap:8px;margin-top:6px}
.mx-all-list{display:flex;flex-wrap:wrap;gap:6px;min-width:0}
@media (max-width:640px){.mx-all-row{flex-direction:column;gap:4px}}
++
++/* ── The Wildcards (W01–W40): a third kind of logo, any mix with the Loop and the Pair ──────────────────────────── */
++.mx-vs{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr) auto minmax(0,1fr)}
++@media (max-width:640px){.mx-vs{gap:4px}.mx-vs .mx-or{font-size:12px}.mx-vs .mx-tab-t b{font-size:14px}.mx-vs .mx-tab-t em{font-size:10.5px}.mx-vs .mx-tab-art{width:50px;height:50px}.mx-vs .mx-tab-art svg{width:38px;height:38px}}
++
++/* The tally's three kinds as one bar: the Loop, the Pair and the Wildcards side by side. */
++.mx-split3{position:relative;display:grid;grid-template-columns:var(--l,34%) var(--p,33%) 1fr;height:30px;border-radius:999px;overflow:hidden;font-size:12.5px;font-weight:700;color:#fff;transition:grid-template-columns .5s cubic-bezier(.2,.8,.2,1)}
++.mx-split3[hidden]{display:none}
++.mx-s3{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-width:0;padding:0 6px;white-space:nowrap;overflow:hidden}
++.mx-s3 b{font-family:var(--mx-face);font-weight:900}
++.mx-s3 svg{flex:none;filter:drop-shadow(0 0 2px rgba(0,0,0,.35))}
++.mx-s3-l{background:#4f7cff}.mx-s3-p{background:#8b5cf6}.mx-s3-w{background:linear-gradient(90deg,#ec4899,#f59e0b)}
++@media (max-width:420px){.mx-s3 svg{display:none}}
++
++/* How the name attaches (lockups): beside, in a pill, underlined by the idea, in a bubble, stacked, tagged, trailed. */
++.mx-wild .mx-lock{justify-content:center;min-height:44px}
++.lk-pill{display:inline-flex;align-items:center;gap:6px;min-width:0;padding:4px 12px 4px 5px;border-radius:999px;background:linear-gradient(90deg,color-mix(in srgb,#4f7cff 30%,transparent),color-mix(in srgb,#8b5cf6 22%,transparent));box-shadow:inset 0 0 0 1px color-mix(in srgb,#8b5cf6 45%,transparent)}
++.lk-pill svg{background:#0b1020;border-radius:50%;padding:2px}
++.lk-under{position:relative;display:inline-grid;min-width:0;padding-bottom:6px}
++.lk-under .lk-line{position:absolute;left:0;right:0;bottom:0;width:100%;height:7px;fill:none;stroke:#8b5cf6;stroke-width:2.4;stroke-linecap:round}
++.mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .lk-under .lk-line path{stroke-dasharray:40 8;animation:lkRun 2.4s linear infinite}
++@keyframes lkRun{to{stroke-dashoffset:-96}}
++.mx-wild .mx-lock.lk-bubble{flex-wrap:nowrap}
++.lk-bubble{position:relative;display:inline-block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:4px 10px;border-radius:14px 14px 14px 4px;background:linear-gradient(135deg,#4f7cff,#8b5cf6)}
++.lk-bubble .mx-word{font-size:13.5px}
++.lk-bubble .mx-sep{color:#fff}
++.lk-bubble .mx-word,.lk-bubble .mx-site{color:#fff}
++.lk-stack{display:inline-flex;flex-direction:column;align-items:center;gap:2px;min-width:0}
++.lk-stack .mx-word{font-size:13px}
++.lk-tag{display:inline-flex;align-items:flex-start;gap:1px;min-width:0}
++.lk-tag svg{margin-top:-7px;margin-left:1px}
++.lk-trail{display:inline-flex;align-items:center;gap:4px;min-width:0}
++.lk-trail i{width:22px;height:0;border-top:2px dashed color-mix(in srgb,#8b5cf6 70%,transparent)}
++.mx-wrap.mx-live:not(.mx-still) .mx-card.is-on .lk-trail svg{animation:lkTow 3.2s ease-in-out infinite}
++@keyframes lkTow{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-2px)}}
++
++/* The statuses: as itself (16 and 28 px), live, and with something new. */
++.mx-status{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:6px}
++.mx-status>span{display:flex;align-items:center;justify-content:center;gap:6px;padding:6px 4px;border-radius:9px;background:#0d1322;min-height:46px}
++.mx-status .st{position:relative;flex-direction:column;gap:2px}
++.mx-status .st small{font-size:10.5px;font-weight:700;letter-spacing:.03em;color:var(--mx-muted)}
++.st-live small{color:#fca5a5!important}
++.st-live svg .w-k:not([fill=none]){fill:#ef4444}
++.st-live svg .w-k[fill=none]{stroke:#ef4444}
++.st-live svg .w-k{transform-box:fill-box;transform-origin:center;animation:stLive 1.3s ease-in-out infinite}
++.st-live::after{content:"";position:absolute;top:6px;right:calc(50% - 20px);width:8px;height:8px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 2px #0d1322;animation:stDot 1.3s ease-in-out infinite}
++.st-new::after{content:"3";position:absolute;top:3px;right:calc(50% - 24px);min-width:15px;height:15px;padding:0 3px;border-radius:999px;background:#8b5cf6;color:#fff;font:800 10px/15px var(--mx-face);text-align:center;box-shadow:0 0 0 2px #0d1322}
++@keyframes stLive{0%,100%{opacity:1}50%{opacity:.55}}
++@keyframes stDot{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.35);opacity:.6}}
++.mx-wrap[data-density=sheet] .mx-status{display:none}
++
++/* Each idea's own motion: only while its card is on screen, with long rests, and never under reduced motion. */
++.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] :is(.w-a,.w-b,.w-c){animation-duration:3.6s;animation-iteration-count:infinite;animation-timing-function: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=soundwave] .w-a{transform-origin:top;animation-name:wBar;animation-duration:1.6s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=ripple] .w-a{animation-name:wRipple;animation-duration:2.4s;animation-delay:calc(var(--i,0) * .25s)}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=fork] .w-a{transform-origin:bottom;animation-name:wHum}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=fork] .w-b{animation-name:wFlash}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=pulse] .w-a{stroke-dasharray:100;animation-name:wDraw;animation-duration:3s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=bubble] .w-a{animation-name:wBob}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=bubble] .w-b{stroke-dasharray:100;animation-name:wDraw}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=door] .w-a{transform-origin:right center;animation-name:wSwing;animation-duration:4.4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=door] .w-b{animation-name:wGlow;animation-duration:4.4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=planet] .w-a{animation-name:wTilt;animation-duration:6s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=sunrise] .w-a{animation-name:wRise;animation-duration:5s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=prism] .w-a{animation-name:wShimmer;animation-duration:2.4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=plane] .w-a{animation-name:wGlide;animation-duration:4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=plane] .w-b{animation-name:wFlash;animation-duration:4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=blob] .w-a{animation-name:wMorph;animation-duration:5s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=smile] .w-a{animation-name:wBlink;animation-duration:4.2s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=eye] .w-a{animation-name:wLook;animation-duration:5s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=eye] .w-b{animation-name:wBlink;animation-duration:5s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=spark] :is(.w-a,.w-b,.w-c){animation-name:wTwinkle;animation-duration:3s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=spark] .w-b{animation-delay:.5s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=spark] .w-c{animation-delay:1s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=overlap] .w-a{animation-name:wMeetL;animation-duration:4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=overlap] .w-b{animation-name:wMeetR;animation-duration:4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=stars] :is(.w-a,.w-b){animation-name:wTwinkle;animation-duration:2.8s;animation-delay:calc(var(--i,0) * .35s)}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=pixel] .w-a{animation-name:wPop;animation-duration:3.4s;animation-delay:calc(var(--i,0) * .06s)}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=ribbon] .w-a{transform-origin:top;animation-name:wSway;animation-duration:4.6s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=code] .w-a{animation-name:wCaret;animation-duration:1.2s;animation-timing-function:steps(1)}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=code] .w-b{animation-name:wOpenL}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=code] .w-c{animation-name:wOpenR}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) :is(svg[data-wild=badge],svg[data-wild=shield]) .w-a{animation-name:wShine;animation-duration:3.8s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=wave] .w-a{animation-name:wRoll;animation-duration:4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=wave] .w-b{animation-name:wSpray;animation-duration:4s;animation-delay:calc(var(--i,0) * .3s)}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=flame] .w-a{transform-origin:bottom;animation-name:wFlicker;animation-duration:1.8s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=leaf] .w-a{transform-origin:bottom;animation-name:wSway;animation-duration:4.6s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=moon] :is(.w-a,.w-b){animation-name:wTwinkle;animation-duration:3.2s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=bolt] .w-a{animation-name:wZap;animation-duration:3.4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=headphones] .w-a rect{transform-box:fill-box;transform-origin:center;animation:wBeat .9s ease-in-out infinite;animation-delay:calc(var(--i,0) * .15s)}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=play] .w-a{animation-name:wPress;animation-duration:3s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=play] .w-b{animation-name:wRing;animation-duration:3s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=antenna] .w-a path{animation:wFlash 2s ease-in-out infinite;animation-delay:calc(var(--i,0) * .35s)}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=gem] .w-a{animation-name:wTwinkle;animation-duration:3.2s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=dice] .w-a{animation-name:wRoll90;animation-duration:5s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=pin] .w-a{transform-origin:bottom;animation-name:wDrop;animation-duration:3.6s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=pin] .w-b{animation-name:wShadow;animation-duration:3.6s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=heart] .w-a{animation-name:wHeart;animation-duration:2.2s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=chevrons] .w-a{animation-name:wCascade;animation-duration:2.4s;animation-delay:calc(var(--i,0) * .22s)}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=infinity] .w-a{animation:wLoop 3.2s linear infinite}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=kite] .w-a{transform-origin:50% 0;animation-name:wSway;animation-duration:4.4s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=bird] :is(.w-a,.w-b){transform-origin:bottom;animation-name:wFlap;animation-duration:1.6s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=bounce] .w-a{animation-name:wHop;animation-duration:2.8s;animation-delay:calc(var(--i,0) * .12s)}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=orbit] .w-a{transform-box:view-box;transform-origin:50px 50px;animation:wOrbit 6s linear infinite}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=cursor] .w-a{animation-name:wClick;animation-duration:3s}
++ .mx-wrap.mx-live:not(.mx-still) :is(.mx-card.is-on,.st) svg[data-wild=cursor] .w-b{animation-name:wRing;animation-duration:3s}
++}
++@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)}}
++@keyframes wFlash{0%,35%,100%{opacity:.65}10%{opacity:1}22%{opacity:.25}}
++@keyframes wDraw{0%{stroke-dashoffset:100}35%,85%{stroke-dashoffset:0}100%{stroke-dashoffset:-100}}
++@keyframes wBob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
++@keyframes wSwing{0%,60%,100%{transform:scaleX(1)}20%,40%{transform:scaleX(.45)}}
++@keyframes wGlow{0%,60%,100%{opacity:.35}20%,40%{opacity:.8}}
++@keyframes wTilt{0%,100%{transform:rotate(0)}50%{transform:rotate(-8deg)}}
++@keyframes wRise{0%{transform:translateY(18px)}40%,80%{transform:translateY(0)}100%{transform:translateY(18px)}}
++@keyframes wShimmer{0%,100%{opacity:1}50%{opacity:.35}}
++@keyframes wGlide{0%,100%{transform:translate(0,0) rotate(0)}40%{transform:translate(5px,-4px) rotate(-3deg)}70%{transform:translate(2px,-1px) rotate(1deg)}}
++@keyframes wMorph{0%,100%{transform:scale(1,1)}25%{transform:scale(1.04,.96)}50%{transform:scale(.97,1.03)}75%{transform:scale(1.02,.98)}}
++@keyframes wBlink{0%,44%,52%,100%{transform:scaleY(1)}48%{transform:scaleY(.08)}}
++@keyframes wLook{0%,20%,100%{transform:translateX(0)}30%,45%{transform:translateX(-9px)}55%,70%{transform:translateX(9px)}}
++@keyframes wTwinkle{0%,100%{transform:scale(1) rotate(0);opacity:1}40%{transform:scale(.7) rotate(20deg);opacity:.6}60%{transform:scale(1.1) rotate(0)}}
++@keyframes wMeetL{0%,100%{transform:translateX(0)}50%{transform:translateX(-5px)}}
++@keyframes wMeetR{0%,100%{transform:translateX(0)}50%{transform:translateX(5px)}}
++@keyframes wPop{0%,70%,100%{transform:scale(1);opacity:1}8%{transform:scale(.2);opacity:0}16%{transform:scale(1.15);opacity:1}}
++@keyframes wSway{0%,100%{transform:rotate(0)}30%{transform:rotate(-3deg)}70%{transform:rotate(3deg)}}
++@keyframes wCaret{0%,100%{opacity:1}50%{opacity:.25}}
++@keyframes wOpenL{0%,100%{transform:translateX(0)}50%{transform:translateX(-3px)}}
++@keyframes wOpenR{0%,100%{transform:translateX(0)}50%{transform:translateX(3px)}}
++@keyframes wShine{0%,55%{transform:skewX(-20deg) translateX(0)}85%,100%{transform:skewX(-20deg) translateX(170px)}}
++@keyframes wRoll{0%,100%{transform:translateX(0)}50%{transform:translateX(-4px)}}
++@keyframes wSpray{0%,100%{opacity:0;transform:translateY(4px)}40%,60%{opacity:1;transform:translateY(0)}}
++@keyframes wFlicker{0%,100%{transform:scale(1,1) skewX(0)}25%{transform:scale(.97,1.04) skewX(2deg)}50%{transform:scale(1.02,.97) skewX(-2deg)}75%{transform:scale(.99,1.03) skewX(1deg)}}
++@keyframes wZap{0%,30%,40%,100%{opacity:1}33%,38%{opacity:.25}}
++@keyframes wBeat{0%,100%{transform:scaleY(1)}50%{transform:scaleY(.4)}}
++@keyframes wPress{0%,40%,100%{transform:scale(1)}10%{transform:scale(.85)}20%{transform:scale(1.06)}}
++@keyframes wRing{0%,10%{transform:scale(1);opacity:0}15%{opacity:.9}45%,100%{transform:scale(1.25);opacity:0}}
++@keyframes wRoll90{0%,60%{transform:rotate(0)}75%,100%{transform:rotate(90deg)}}
++@keyframes wDrop{0%,50%,100%{transform:translateY(0)}20%{transform:translateY(-9px)}35%{transform:translateY(0) scale(1.03,.96)}}
++@keyframes wShadow{0%,50%,100%{transform:scale(1);opacity:.3}20%{transform:scale(.6);opacity:.15}}
++@keyframes wHeart{0%,40%,100%{transform:scale(1)}10%{transform:scale(1.1)}20%{transform:scale(.97)}30%{transform:scale(1.06)}}
++@keyframes wCascade{0%,100%{opacity:.35}30%{opacity:1}}
++@keyframes wLoop{to{stroke-dashoffset:-100}}
++@keyframes wFlap{0%,100%{transform:scaleY(1)}50%{transform:scaleY(.55)}}
++@keyframes wHop{0%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}
++@keyframes wOrbit{to{transform:rotate(360deg)}}
++@keyframes wClick{0%,40%,100%{transform:scale(1)}10%{transform:scale(.88)}20%{transform:scale(1)}}
ddiff --git a/public/js/marks.js b/public/js/marks.js
iindex 4735ac1..0c91f07 100644
---- a/public/js/marks.js
++++ b/public/js/marks.js
@@@ -5,8 +5,8 @@
(function () {
'use strict';
var KEY = 'ov_mark_picks', VIEW = 'ov_mark_view', TAB = 'ov_mark_tab';
-- var CODE = /^[LPMF]\d{2}$/;
-- var TABS = { L: 'loop', P: 'pair', M: 'menu', F: 'font' };
++ var CODE = /^[LPMFW]\d{2}$/;
++ var TABS = { L: 'loop', P: 'pair', W: 'wild', M: 'menu', F: 'font' };
var API = '/api/v1/brand/picks';
function load(key, fallback) { try { var v = JSON.parse(localStorage.getItem(key) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } }
function store(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* a private window keeps it for the page only */ } }
@@@ -33,7 +33,7 @@
var box = document.createElement('span');
box.innerHTML = src.outerHTML.replace(/ id="([^"]+)"/g, ' id="$1' + tag + '"').replace(/url\(#([^)]+)\)/g, 'url(#$1' + tag + ')').replace(/href="#([^"]+)"/g, 'href="#$1' + tag + '"');
var svg = box.firstChild;
-- ['width', 'height', 'role', 'aria-label', 'data-idle'].forEach(function (a) { svg.removeAttribute(a); });
++ ['width', 'height', 'role', 'aria-label', 'data-idle'].forEach(function (a) { svg.removeAttribute(a); }); // data-wild stays: a copy moves like its card
svg.setAttribute('aria-hidden', 'true');
if (size) { svg.setAttribute('width', size); svg.setAttribute('height', size); }
if (code[0] === 'M') box.className = 'mb-btn';
@@@ -69,7 +69,7 @@
// defs, so it paints whichever tab is open.
var shownMark = null;
function syncMenuMark() {
-- var marks = picks.filter(function (c) { return c[0] === 'L' || c[0] === 'P'; });
++ var marks = picks.filter(function (c) { return c[0] === 'L' || c[0] === 'P' || c[0] === 'W'; });
var code = marks.length ? marks[marks.length - 1] : null;
all('.mx-tab-side, .mx-step-2, .mx-step-3', root).forEach(function (el) { el.classList.toggle('is-later', !code); });
syncPickFont();
@@@ -222,19 +222,24 @@
function showTally(t) {
if (!tally || !t) return;
lastTally = t;
-- var loop = t.lean.loop, pair = t.lean.pair, both = t.lean.both || 0, sum = loop + pair, pct = sum ? Math.round((loop / sum) * 100) : 50;
++ var loop = t.lean.loop, pair = t.lean.pair, wild = t.lean.wild || 0, both = t.lean.both || 0, sum = loop + pair + wild;
++ var pct = sum ? Math.round((loop / sum) * 100) : 34, ppct = sum ? Math.round((pair / sum) * 100) : 33;
var ppl = function (n) { return n + (n === 1 ? ' person' : ' people'); };
tally.classList.toggle('is-empty', !t.people);
tally.querySelector('[data-mx-people]').textContent = t.people ? ppl(t.people) : 'No votes yet. Tap any logo to vote.';
-- tally.querySelector('[data-mx-lead]').textContent = sum ? (loop === pair ? 'Even so far' : loop > pair ? 'More people like the Loop' : 'More people like the Pair') : '';
++ var top3 = Math.max(loop, pair, wild);
++ var leaders = [['the Loop', loop], ['the Pair', pair], ['the Wildcards', wild]].filter(function (x) { return x[1] === top3; });
++ tally.querySelector('[data-mx-lead]').textContent = sum ? (leaders.length > 1 ? 'Even so far' : 'More people like ' + leaders[0][0]) : '';
var split = tally.querySelector('[data-mx-split]');
split.hidden = !sum;
split.style.setProperty('--l', pct + '%');
++ split.style.setProperty('--p', ppct + '%');
tally.querySelector('[data-mx-loop]').textContent = String(loop);
tally.querySelector('[data-mx-pair]').textContent = String(pair);
++ var wildEl = tally.querySelector('[data-mx-wild]'); if (wildEl) wildEl.textContent = String(wild);
var bothEl = tally.querySelector('[data-mx-both]');
-- if (bothEl) { bothEl.hidden = !both; bothEl.textContent = ppl(both) + ' like both'; }
-- [['[data-mx-tops-l]', (t.top.L || []).slice(0, 4)], ['[data-mx-tops-p]', (t.top.P || []).slice(0, 4)], ['[data-mx-tops-m]', (t.top.M || []).slice(0, 3)], ['[data-mx-tops-f]', (t.top.F || []).slice(0, 3)]].forEach(function (row) {
++ if (bothEl) { bothEl.hidden = !both; bothEl.textContent = ppl(both) + ' like more than one kind'; }
++ [['[data-mx-tops-l]', (t.top.L || []).slice(0, 4)], ['[data-mx-tops-p]', (t.top.P || []).slice(0, 4)], ['[data-mx-tops-w]', (t.top.W || []).slice(0, 4)], ['[data-mx-tops-m]', (t.top.M || []).slice(0, 3)], ['[data-mx-tops-f]', (t.top.F || []).slice(0, 3)]].forEach(function (row) {
var list = tally.querySelector(row[0]);
if (!list) return;
list.closest('.mx-tops').hidden = !row[1].length;
@@@ -245,7 +250,7 @@
if (box) {
box.hidden = !every.length;
box.querySelector('[data-mx-all-n]').textContent = every.length + (every.length === 1 ? ' take' : ' takes');
-- ['L', 'P', 'M', 'F'].forEach(function (k) {
++ ['L', 'P', 'W', 'M', 'F'].forEach(function (k) {
var list = box.querySelector('[data-mx-all-' + k.toLowerCase() + ']');
if (!list) return;
var mine = every.filter(function (x) { return x.code[0] === k; });
@@@ -263,7 +268,7 @@
t.people = Math.max(0, t.people + (has && !had ? 1 : !has && had ? -1 : 0));
var bump = function (list, c, d) { var e = list.find(function (x) { return x.code === c; }); if (e) e.n += d; else if (d > 0) list.push({ code: c, n: d }); };
t.all = t.all || [];
-- ['L', 'P', 'M', 'F'].forEach(function (k) {
++ ['L', 'P', 'W', 'M', 'F'].forEach(function (k) {
var list = t.top[k] || (t.top[k] = []);
prev.filter(function (c) { return c[0] === k && picks.indexOf(c) === -1; }).forEach(function (c) { bump(list, c, -1); bump(t.all, c, -1); });
picks.filter(function (c) { return c[0] === k && prev.indexOf(c) === -1; }).forEach(function (c) { bump(list, c, 1); bump(t.all, c, 1); });
@@@ -271,11 +276,12 @@
});
t.all = t.all.filter(function (x) { return x.n > 0; }).sort(function (a, b) { return b.n - a.n || (a.code < b.code ? -1 : 1); });
var likes = function (arr, k) { return arr.some(function (c) { return c[0] === k; }) ? 1 : 0; };
-- var l0 = likes(prev, 'L'), p0 = likes(prev, 'P'), l1 = likes(picks, 'L'), p1 = likes(picks, 'P');
++ var l0 = likes(prev, 'L'), p0 = likes(prev, 'P'), w0 = likes(prev, 'W'), l1 = likes(picks, 'L'), p1 = likes(picks, 'P'), w1 = likes(picks, 'W');
t.lean = {
loop: Math.max(0, t.lean.loop - l0 + l1),
pair: Math.max(0, t.lean.pair - p0 + p1),
-- both: Math.max(0, (t.lean.both || 0) - (l0 && p0 ? 1 : 0) + (l1 && p1 ? 1 : 0)),
++ wild: Math.max(0, (t.lean.wild || 0) - w0 + w1),
++ both: Math.max(0, (t.lean.both || 0) - (l0 + p0 + w0 > 1 ? 1 : 0) + (l1 + p1 + w1 > 1 ? 1 : 0)),
};
showTally(t);
}
@@@ -352,8 +358,8 @@
if ('IntersectionObserver' in window && choose) new IntersectionObserver(function (en) { steps.classList.toggle('is-on', !en[0].isIntersecting && en[0].boundingClientRect.top < 0); }).observe(choose);
}
var h = (location.hash || '').slice(1);
-- var start = /^(loop|pair|menu|font)$/.test(h) ? h : CODE.test(h) ? TABS[h[0]] : picks.length ? TABS[order(picks)[0][0]] : load(TAB, 'loop');
-- show(/^(loop|pair|menu|font)$/.test(start) ? start : 'loop');
++ var start = /^(loop|pair|wild|menu|font)$/.test(h) ? h : CODE.test(h) ? TABS[h[0]] : picks.length ? TABS[order(picks)[0][0]] : load(TAB, 'loop');
++ show(/^(loop|pair|wild|menu|font)$/.test(start) ? start : 'loop');
// ── Each tab's example walks through all fifty, so it is plain there are fifty ──
(function cycle() {
@@@ -428,9 +434,9 @@
if (!nudge) return;
var has = function (k) { return picks.some(function (c) { return c[0] === k; }); };
var key, text, to, label;
-- if ((code[0] === 'L' || code[0] === 'P') && !has('M')) { key = '2'; text = code + ' voted. Next: pick a menu button.'; to = 'menu'; label = 'Step 2 \u2192'; }
++ if ((code[0] === 'L' || code[0] === 'P' || code[0] === 'W') && !has('M')) { key = '2'; text = code + ' voted. Next: pick a menu button.'; to = 'menu'; label = 'Step 2 \u2192'; }
else if (code[0] === 'M' && !has('F')) { key = '3'; text = code + ' voted. Last: pick a font.'; to = 'font'; label = 'Step 3 \u2192'; }
-- else if (code[0] === 'F' && (has('L') || has('P')) && has('M')) { key = 'done'; text = 'All three done. Thank you!'; to = null; label = 'Share'; }
++ else if (code[0] === 'F' && (has('L') || has('P') || has('W')) && has('M')) { key = 'done'; text = 'All three done. Thank you!'; to = null; label = 'Share'; }
else return;
var seen = {};
try { seen = JSON.parse(sessionStorage.getItem('ov_mark_nudged') || '{}') || {}; } catch (e) { seen = {}; }
@@@ -486,9 +492,23 @@
if (card && root.contains(card) && !e.target.closest('button, a, input')) toggle(card.getAttribute('data-code'));
});
++ // A wild mark as a status (the owner, 11-01: "status icons"): live (its key part turns red and pulses, with a red
++ // dot) and new (a count on its corner). Copies of the card's own drawing, so they move like it.
++ function statuses(card) {
++ all('.st[data-st]', card).forEach(function (slot) {
++ if (slot.firstChild) return;
++ var art = artOf(card.getAttribute('data-code'), 28);
++ if (!art) return;
++ slot.appendChild(art);
++ var label = document.createElement('small');
++ label.textContent = slot.getAttribute('data-st') === 'live' ? 'Live' : '3 new';
++ slot.appendChild(label);
++ });
++ }
++
// ── 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'); });
++ if (!('IntersectionObserver' in window)) cards.forEach(function (c) { c.classList.add('is-in'); if (c.classList.contains('mx-wild')) statuses(c); });
else {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
@@@ -496,6 +516,7 @@
if (en.isIntersecting) {
if (!c.classList.contains('is-in')) {
c.classList.add('is-in');
++ if (c.classList.contains('mx-wild')) statuses(c);
if (c.classList.contains('mb-card')) { var big = c.querySelector('.mb-big'); setTimeout(function () { play(big); }, 350); }
}
c.classList.add('is-on');
ddiff --git a/server/brand-picks.js b/server/brand-picks.js
iindex a170128..ba4a526 100644
---- a/server/brand-picks.js
++++ b/server/brand-picks.js
@@@ -8,11 +8,12 @@
* picks.voterOf(req, res) the voter: the signed-in subject, else 'anon:' + a hash of this browser's key
* picks.save(voter, codes, { signedIn }) replace their picks (up to 40 codes); returns the tally
* picks.mine(voter) their codes, sorted
-- * picks.tally() { people, lean: { loop, pair, both }, top: { L, P, M, F } (at most 8), all: [{ code, n }] }
++ * picks.tally() { people, lean: { loop, pair, wild, both }, top: { L, P, W, M, F } (at most 8), all: [{ code, n }] }
++ * (both: the people who like more than one kind)
*/
const crypto = require('crypto');
--const CODE = /^[LPMF](0[1-9]|[1-9][0-9])$/;
++const CODE = /^[LPMFW](0[1-9]|[1-9][0-9])$/;
const COOKIE = 'ov_mx_voter';
const MAX = 40;
@@@ -46,14 +47,14 @@ function createBrandPicks({ db, now = () => Date.now() } = {}) {
async function tally() {
const counts = await db.many('SELECT code, count(*)::int AS n FROM brand_picks GROUP BY code ORDER BY n DESC, code');
-- const voters = await db.many(`SELECT count(*) FILTER (WHERE code LIKE 'L%')::int AS l, count(*) FILTER (WHERE code LIKE 'P%')::int AS p
-- FROM brand_picks GROUP BY voter`);
++ const voters = await db.many(`SELECT count(*) FILTER (WHERE code LIKE 'L%')::int AS l, count(*) FILTER (WHERE code LIKE 'P%')::int AS p,
++ count(*) FILTER (WHERE code LIKE 'W%')::int AS w FROM brand_picks GROUP BY voter`);
const people = voters.length;
-- let loop = 0, pair = 0, both = 0;
-- for (const v of voters) { if (v.l) loop += 1; if (v.p) pair += 1; if (v.l && v.p) both += 1; }
-- const top = { L: [], P: [], M: [], F: [] };
++ let loop = 0, pair = 0, wild = 0, both = 0;
++ for (const v of voters) { if (v.l) loop += 1; if (v.p) pair += 1; if (v.w) wild += 1; if ((v.l ? 1 : 0) + (v.p ? 1 : 0) + (v.w ? 1 : 0) > 1) both += 1; }
++ const top = { L: [], P: [], W: [], M: [], F: [] };
for (const r of counts) { const list = top[r.code[0]]; if (list && list.length < 8) list.push({ code: r.code, n: r.n }); }
-- return { people, lean: { loop, pair, both }, top, all: counts.slice(0, 250).map((r) => ({ code: r.code, n: r.n })) };
++ return { people, lean: { loop, pair, wild, both }, top, all: counts.slice(0, 250).map((r) => ({ code: r.code, n: r.n })) };
}
return { voterOf, save, mine, tally, CODE, COOKIE, MAX };
ddiff --git a/server/views/marks-explore.js b/server/views/marks-explore.js
iindex 68dce74..6cf7d87 100644
---- a/server/views/marks-explore.js
++++ b/server/views/marks-explore.js
@@@ -599,12 +599,39 @@ function idleOf(kind, spec, i) {
}
let seq = 0;
++const { WILD_ROWS, wildArt } = require('./wild-marks');
// One real name as the example (the owner, 11-01: "see what it looks like with the full domain spelled out").
const word = () => '<span class="mx-word"><b>OpenVibe</b><span class="mx-sep">.</span><span class="mx-site">Chat</span></span>';
const useOf = (ref, size) => `<svg viewBox="0 0 100 100" width="${size}" height="${size}" aria-hidden="true"><use href="#${ref}"/></svg>`;
const delay = (i) => `--d:${f((i % 4) * 0.07)}s`;
--function markCard(code, kind, [name, line_, spec], i) {
++// How the name attaches to a wild mark (the owner, 11-01: "how the label could be attached… for the branding").
++function lockOf(kind, ref) {
++ const name = word();
++ switch (kind) {
++ case 'pill': return `<span class="lk-pill">${useOf(ref, 22)}${name}</span>`;
++ case 'under': return `${useOf(ref, 26)}<span class="lk-under">${name}<svg class="lk-line" viewBox="0 0 120 8" preserveAspectRatio="none" aria-hidden="true"><path d="M2 4 Q12 0 22 4 T42 4 T62 4 T82 4 T102 4 T118 4"/></svg></span>`;
++ case 'bubble': return `${useOf(ref, 24)}<span class="lk-bubble">${name}</span>`;
++ case 'stack': return `<span class="lk-stack">${useOf(ref, 30)}${name}</span>`;
++ case 'tag': return `<span class="lk-tag">${name}${useOf(ref, 18)}</span>`;
++ case 'trail': return `<span class="lk-trail">${name}<i aria-hidden="true"></i>${useOf(ref, 24)}</span>`;
++ default: return `${useOf(ref, 26)}${name}`;
++ }
++}
++
++function markCard(code, kind, [name, line_, spec, lock], i) {
++ if (kind === 'W') {
++ const art = wildArt(spec, `m${++seq}`);
++ const ref = `art-${code}`;
++ return `<li class="mx-card mx-wild" 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"><svg viewBox="0 0 100 100" width="132" height="132" role="img" aria-label="${code} ${esc(name)}" data-idle="wild" data-wild="${spec}"><defs>${art.defs}</defs><g id="${ref}">${art.body}</g></svg></div>
++<div class="mx-lock lk-${lock || 'side'}">${lockOf(lock, ref)}</div>
++<div class="mx-status" aria-label="As a status: live and new"><span class="mx-dark">${useOf(ref, 16)}${useOf(ref, 28)}</span><span class="st st-live" data-st="live" title="Live"></span><span class="st st-new" data-st="new" title="Something new"></span></div>
++<p class="mx-name"><b>${code}</b> ${esc(name)}</p>
++<p class="mx-line">${esc(line_)}</p>
++</li>`;
++ }
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)}">
@@@ -675,8 +702,9 @@ function panel(id, kind, rows, label, hidden) {
return `<section class="mx-row"><h3>${esc(row.title)} <small>${kind}${pad(first)}–${kind}${pad(n)} · ${row.items.length}</small><span class="mx-swipe" aria-hidden="true">swipe →</span></h3><p class="mx-row-line">${esc(row.line)}</p><ul class="mx-grid">${cards}</ul></section>`;
}).join('');
const NEXT = {
-- loop: [['pair', 'See the Pair', 'the other logo'], ['menu', 'Next: menu button', 'step 2']],
-- pair: [['loop', 'See the Loop', 'the other logo'], ['menu', 'Next: menu button', 'step 2']],
++ loop: [['pair', 'See the Pair', 'the second kind'], ['wild', 'See the Wildcards', 'forty other ideas'], ['menu', 'Next: menu button', 'step 2']],
++ pair: [['loop', 'See the Loop', 'the first kind'], ['wild', 'See the Wildcards', 'forty other ideas'], ['menu', 'Next: menu button', 'step 2']],
++ wild: [['loop', 'See the Loop', 'the first kind'], ['pair', 'See the Pair', 'the second kind'], ['menu', 'Next: menu button', 'step 2']],
menu: [['loop', 'Back', 'step 1, the logo'], ['font', 'Next: font', 'step 3']],
font: [['menu', 'Back', 'step 2, the menu button'], ['share', 'Done: share', 'your votes are in']],
}[id];
@@@ -684,8 +712,9 @@ function panel(id, kind, rows, label, hidden) {
? `<button type="button" class="mx-next-share" data-mx-share><b>${title} <span aria-hidden="true">✓</span></b><small>${line_}</small></button>`
: `<a href="#${to}" data-mx-go="${to}"><b>${title} <span aria-hidden="true">${/^Back/.test(title) ? '←' : '→'}</span></b><small>${line_}</small></a>`).join('')}</nav>`;
const STEP = {
-- loop: [1, 'Pick a logo: the Loop', 'Tap the ones you like. Look at the Pair too.'],
-- pair: [1, 'Pick a logo: the Pair', 'Tap the ones you like. Look at the Loop too.'],
++ loop: [1, 'Pick a logo: the Loop', 'Tap the ones you like. Look at the Pair and the Wildcards too.'],
++ pair: [1, 'Pick a logo: the Pair', 'Tap the ones you like. Look at the Loop and the Wildcards too.'],
++ wild: [1, 'Pick a logo: the Wildcards', 'Forty other ideas. Each moves, shows a status, and wears the name its own way.'],
menu: [2, 'Pick a menu button', 'Tap one to see it open.'],
font: [3, 'Pick a font', 'Tap the one that looks right.'],
}[id];
@@@ -699,6 +728,7 @@ function miniArt(code, size = 26, tag = '') {
const i = Number(code.slice(1)) - 1;
if (code[0] === 'M') { const m = MENUS[i]; return m ? `<span class="mb-btn mx-mini-m">${menuSvg(m[2], m[3], m[4], size - 4)}</span>` : ''; }
if (code[0] === 'F') { const fo = FONTS[i]; return fo ? `<span class="fx-mini" style="font-family:${esc(fo[2])};font-weight:${fo[3]}">Aa</span>` : ''; }
++ if (code[0] === 'W') { const w = WILD_ROWS.flatMap((r) => r.items)[i]; if (!w) return ''; const a = wildArt(w[2], `t${tag}${code}`); return `<svg viewBox="0 0 100 100" width="${size}" height="${size}" aria-hidden="true">${a.defs ? `<defs>${a.defs}</defs>` : ''}${a.body}</svg>`; }
const item = (code[0] === 'L' ? LOOPS : PAIRS)[i];
if (!item) return '';
const art = code[0] === 'L' ? loopArt(item[2], `t${tag}${code}`) : pairArt(item[2], `t${tag}${code}`);
@@@ -712,24 +742,28 @@ function tallyBlock(t) {
// each family (someone who picks from both counts for both), the top takes of each family on its own row, and
// every take's count in a fold anyone can open. marks.js redraws it the moment a card is tapped.
const people = (t && t.people) || 0;
-- const loop = (t && t.lean && t.lean.loop) || 0, pair = (t && t.lean && t.lean.pair) || 0, both = (t && t.lean && t.lean.both) || 0;
-- const loopPct = loop + pair ? Math.round((loop / (loop + pair)) * 100) : 50;
++ const loop = (t && t.lean && t.lean.loop) || 0, pair = (t && t.lean && t.lean.pair) || 0, wild = (t && t.lean && t.lean.wild) || 0, both = (t && t.lean && t.lean.both) || 0;
++ const sum3 = loop + pair + wild;
++ const loopPct = sum3 ? Math.round((loop / sum3) * 100) : 34, pairPct = sum3 ? Math.round((pair / sum3) * 100) : 33;
const fam = (k, n) => (t ? (t.top[k] || []).slice(0, n) : []);
-- const lead = loop + pair ? (loop === pair ? 'Even so far' : loop > pair ? 'More people like the Loop' : 'More people like the Pair') : '';
++ const top3 = Math.max(loop, pair, wild);
++ const leaders = [['the Loop', loop], ['the Pair', pair], ['the Wildcards', wild]].filter(([, n]) => n === top3);
++ const lead = sum3 ? (leaders.length > 1 ? 'Even so far' : `More people like ${leaders[0][0]}`) : '';
const ppl = (n) => `${n} ${n === 1 ? 'person' : 'people'}`;
const row = (label, attr, list) => `<div class="mx-tops"${list.length ? '' : ' hidden'}><span class="mx-tops-k">${label}</span><span class="mx-tops-list" ${attr}>${list.map(chipOf).join('')}</span></div>`;
const every = t && Array.isArray(t.all) ? t.all : [];
const allRow = (k, label) => { const list = every.filter((x) => x.code[0] === k); return `<div class="mx-all-row"${list.length ? '' : ' hidden'}><span class="mx-tops-k">${label}</span><span class="mx-all-list" data-mx-all-${k.toLowerCase()}>${list.map(chipOf).join('')}</span></div>`; };
return `<section class="mx-tally${people ? '' : ' is-empty'}" data-mx-tally aria-label="Votes so far" aria-live="polite">
<div class="mx-tally-h"><b>Votes so far</b><span class="mx-tally-n" data-mx-people>${people ? ppl(people) : 'No votes yet. Tap any logo to vote.'}</span><span class="mx-tally-lead" data-mx-lead>${lead}</span></div>
--<div class="mx-split" data-mx-split style="--l:${loopPct}%"${loop + pair ? '' : ' hidden'}><span class="mx-split-l">${miniArt('L01', 18, 'e')}Loop <b data-mx-loop>${loop}</b></span><span class="mx-split-r"><b data-mx-pair>${pair}</b> Pair${miniArt('P01', 18, 'e')}</span></div>
--<p class="mx-both" data-mx-both${both ? '' : ' hidden'}>${ppl(both)} like both</p>
++<div class="mx-split3" data-mx-split style="--l:${loopPct}%;--p:${pairPct}%"${sum3 ? '' : ' hidden'}><span class="mx-s3 mx-s3-l">${miniArt('L01', 16, 'e')}Loop <b data-mx-loop>${loop}</b></span><span class="mx-s3 mx-s3-p">${miniArt('P01', 16, 'e')}Pair <b data-mx-pair>${pair}</b></span><span class="mx-s3 mx-s3-w">${miniArt('W01', 16, 'e')}Wild <b data-mx-wild>${wild}</b></span></div>
++<p class="mx-both" data-mx-both${both ? '' : ' hidden'}>${ppl(both)} like more than one kind</p>
${row('Loop', 'data-mx-tops-l', fam('L', 4))}
${row('Pair', 'data-mx-tops-p', fam('P', 4))}
++${row('Wild', 'data-mx-tops-w', fam('W', 4))}
${row('Menu', 'data-mx-tops-m', fam('M', 3))}
${row('Font', 'data-mx-tops-f', fam('F', 3))}
<details class="mx-all" data-mx-all${every.length ? '' : ' hidden'}><summary>Every vote <span data-mx-all-n>${every.length} take${every.length === 1 ? '' : 's'}</span></summary>
--${allRow('L', 'Loop')}${allRow('P', 'Pair')}${allRow('M', 'Menu')}${allRow('F', 'Font')}</details>
++${allRow('L', 'Loop')}${allRow('P', 'Pair')}${allRow('W', 'Wild')}${allRow('M', 'Menu')}${allRow('F', 'Font')}</details>
<div class="mx-mine" data-mx-mine hidden><span class="mx-tops-k">Yours</span><b data-mx-picks></b><button type="button" class="mx-btn" data-mx-link>Copy link</button><button type="button" class="mx-btn mx-btn-quiet" data-mx-clear>Clear</button><em class="mx-saved" data-mx-saved>saved</em></div>
</section>`;
}
@@@ -739,6 +773,8 @@ function explorePage({ tally = null } = {}) {
const today = pairArt(PAIR_ROWS[0].items[0][2], 'xpair');
const loopEx = loopArt(LOOP_ROWS[0].items[0][2], 'xloop');
const pairEx = pairArt(PAIR_ROWS[0].items[0][2], 'xpairtab');
++ const wildEx = wildArt(WILD_ROWS[0].items[0][2], 'xwildtab');
++ const WN = WILD_ROWS.reduce((n, r) => n + r.items.length, 0);
const m1 = MENU_ROWS[0].items[0];
const tab = (id, title, line_, art, range, on, side) => `<a class="mx-tab${side ? ' mx-tab-side' : ''}" role="tab" id="mx-tab-${id}" href="#${id}" aria-controls="${id}" aria-selected="${on ? 'true' : 'false'}" data-mx-tab="${id}"><span class="mx-tab-art" data-mx-cycle="${id}"><span class="mx-ta">${art}</span><em class="mx-tab-code" aria-hidden="true"></em></span><span class="mx-tab-t"><b>${title}</b><small>${line_}</small><em>${range}</em></span></a>`;
const chip = (attr, value, label, extra = '') => `<button type="button" class="mx-chip" ${attr}="${esc(value)}" aria-pressed="false"${extra}>${label}</button>`;
@@@ -748,7 +784,7 @@ function explorePage({ tally = null } = {}) {
<header class="mx-hero">
<p class="mx-eye">OpenVibe logo vote</p>
<h1>Which logo should OpenVibe use?</h1>
--<p class="mx-lede">Tap every logo you like, from both kinds if you want. Then pick a menu button and a font. Votes count right away.</p>
++<p class="mx-lede">Tap every logo you like: the Loop, the Pair and/or the Wildcards, any mix. Then pick a menu button and a font. Votes count right away.</p>
</header>
${tallyBlock(tally)}
<nav class="mx-choose" role="tablist" aria-label="What to choose">
@@@ -757,6 +793,8 @@ ${tallyBlock(tally)}
${tab('loop', 'The Loop', 'A V inside a ring', `<svg viewBox="0 0 100 100" width="58" height="58" aria-hidden="true"><defs>${loopEx.defs}</defs>${loopEx.body}</svg>`, `L01–L${LOOPS.length} · ${LOOPS.length} takes`, true)}
<span class="mx-or" aria-hidden="true">+</span>
${tab('pair', 'The Pair', 'An O next to a V', `<svg viewBox="0 0 100 100" width="58" height="58" aria-hidden="true"><defs>${pairEx.defs}</defs>${pairEx.body}</svg>`, `P01–P${PAIRS.length} · ${PAIRS.length} takes`)}
++<span class="mx-or" aria-hidden="true">+</span>
++${tab('wild', 'The Wildcards', 'Forty other ideas', `<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)}
@@@ -767,6 +805,7 @@ ${tab('font', 'The font', 'The letters of the name', '<span class="fx-tabart" ar
<span class="mx-stepn" aria-hidden="true">1</span><span class="mx-steps-k">The mark</span>
<a href="#loop" data-mx-go="loop" class="mx-step">${miniArt('L01', 20, 's')}<span>Loop</span><i data-mx-count="L"></i></a>
<a href="#pair" data-mx-go="pair" class="mx-step">${miniArt('P01', 20, 's')}<span>Pair</span><i data-mx-count="P"></i></a>
++<a href="#wild" data-mx-go="wild" class="mx-step">${miniArt('W01', 20, 's')}<span>Wild</span><i data-mx-count="W"></i></a>
<span class="mx-steps-sep" aria-hidden="true">→</span><span class="mx-stepn" aria-hidden="true">2</span>
<a href="#menu" data-mx-go="menu" class="mx-step mx-step-2">${miniArt('M01', 22, 's')}<span>Menu button</span><i data-mx-count="M"></i></a>
<span class="mx-steps-sep" aria-hidden="true">→</span><span class="mx-stepn" aria-hidden="true">3</span>
@@@ -776,6 +815,7 @@ ${tab('font', 'The font', 'The letters of the name', '<span class="fx-tabart" ar
</nav>
${panel('loop', 'L', LOOP_ROWS, `The Loop: ${LOOPS.length} takes`, false)}
${panel('pair', 'P', PAIR_ROWS, `The Pair: ${PAIRS.length} takes`, true)}
++${panel('wild', 'W', WILD_ROWS, `The Wildcards: ${WN} ideas`, true)}
${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty 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>
@@@ -783,7 +823,7 @@ ${panel('font', 'F', FONT_ROWS, 'The font: twelve faces', true)}
</div>`;
return layout({
title: 'Help choose the OpenVibe mark',
-- description: 'Help choose the OpenVibe mark in three quick steps: the Loop or the Pair, the menu button beside it, and the font of the name.',
++ description: 'Help choose the OpenVibe mark in three quick steps: the Loop, the Pair and/or the Wildcards, the menu button beside it, and the font of the name.',
path: '/brand/explore',
active: '/brand',
robots: 'noindex, follow',
@@@ -796,4 +836,5 @@ ${panel('font', 'F', FONT_ROWS, 'The font: twelve faces', true)}
const LOOPS = LOOP_ROWS.flatMap((r) => r.items);
const PAIRS = PAIR_ROWS.flatMap((r) => r.items);
const MENUS = MENU_ROWS.flatMap((r) => r.items);
--module.exports = { explorePage, tallyBlock, FONTS, LOOPS, PAIRS, MENUS, LOOP_ROWS, PAIR_ROWS, MENU_ROWS, loopArt, pairArt };
++const WILDS = WILD_ROWS.flatMap((r) => r.items);
++module.exports = { explorePage, tallyBlock, FONTS, LOOPS, PAIRS, MENUS, WILDS, LOOP_ROWS, PAIR_ROWS, MENU_ROWS, WILD_ROWS, loopArt, pairArt, wildArt };
ddiff --git a/server/views/wild-marks.js b/server/views/wild-marks.js
nnew file mode 100644
iindex 0000000..b62a8b1
---- /dev/null
++++ b/server/views/wild-marks.js
@@@ -0,0 +1,359 @@
++'use strict';
++/**
++ * The third choice on /brand/explore (the owner, 2026-11-01: "an option 3… wildly different variations… be creative…
++ * completely different ideas for OpenVibe branding… 40 of them… so you can choose the Loop, the Pair and/or these…
++ * be thoughtful about animations and effects… status icons… how the label could be attached").
++ *
++ * Not an O and a V: forty other ideas of what OpenVibe is, in eight rows of five, each drawn by hand on the page's
++ * 100-unit grid in the brand's blue to violet (with a warm accent where the idea needs light). Each one also says:
++ * - its motion: parts of the drawing carry classes (w-a, w-b, w-c…) that css/marks.css animates for that idea (bars
++ * bounce, the eye blinks, the sun rises, the plane glides), only while the card is on screen and never under
++ * reduced motion;
++ * - its key part (w-k): what lights up when the mark shows a status (live: red and pulsing; new: a count badge);
++ * - its lockup: how the name attaches to it (side, pill, under, bubble, stack, tag, trail).
++ * Every drawing is a function of an id prefix, so a mark can be drawn more than once on a page with ids that never clash.
++ */
++
++const BV = [['0', '#4f7cff'], ['1', '#8b5cf6']];
++const lin = (id, stops, x1 = 0, y1 = 0, x2 = 1, y2 = 1) => `<linearGradient id="${id}" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stops.map(([o, c, a]) => `<stop offset="${o}" stop-color="${c}"${a != null ? ` stop-opacity="${a}"` : ''}/>`).join('')}</linearGradient>`;
++const rad = (id, stops) => `<radialGradient id="${id}" cx=".35" cy=".3" r=".8">${stops.map(([o, c, a]) => `<stop offset="${o}" stop-color="${c}"${a != null ? ` stop-opacity="${a}"` : ''}/>`).join('')}</radialGradient>`;
++const R = 'stroke-linecap="round" stroke-linejoin="round"';
++const star4 = (cx, cy, r) => `M${cx} ${cy - r} C${cx + r * 0.08} ${cy - r * 0.3} ${cx + r * 0.3} ${cy - r * 0.08} ${cx + r} ${cy} C${cx + r * 0.3} ${cy + r * 0.08} ${cx + r * 0.08} ${cy + r * 0.3} ${cx} ${cy + r} C${cx - r * 0.08} ${cy + r * 0.3} ${cx - r * 0.3} ${cy + r * 0.08} ${cx - r} ${cy} C${cx - r * 0.3} ${cy - r * 0.08} ${cx - r * 0.08} ${cy - r * 0.3} ${cx} ${cy - r}Z`;
++
++const ART = {
++ // ── Sound and signal ──
++ soundwave(id) {
++ const xs = [22, 31, 40, 50, 60, 69, 78];
++ const bars = xs.map((x, i) => { const h = 14 + 44 * (1 - Math.abs(x - 50) / 28); return `<rect class="w-a${x === 50 ? ' w-k' : ''}" style="--i:${i}" x="${x - 3.5}" y="20" width="7" height="${h.toFixed(1)}" rx="3.5"/>`; }).join('');
++ return { defs: lin(`${id}g`, BV, 0, 0, 1, 1), body: `<g fill="url(#${id}g)">${bars}</g><rect x="14" y="82" width="72" height="4" rx="2" fill="url(#${id}g)" opacity=".35"/>` };
++ },
++ ripple(id) {
++ const arc = (r, o, i) => `<path class="w-a" style="--i:${i}" d="M28 ${72 - r} A${r} ${r} 0 0 1 ${28 + r} 72" fill="none" stroke="url(#${id}g)" stroke-width="8" stroke-linecap="round" opacity="${o}"/>`;
++ return { defs: lin(`${id}g`, BV, 0, 1, 1, 0), body: `${arc(24, 1, 0)}${arc(39, 0.72, 1)}${arc(54, 0.45, 2)}<circle class="w-k" cx="28" cy="72" r="10" fill="url(#${id}g)"/>` };
++ },
++ fork(id) {
++ return {
++ defs: lin(`${id}g`, BV, 0, 0, 0, 1),
++ body: `<g class="w-a"><path class="w-k" d="M32 14 L50 56 L68 14" fill="none" stroke="url(#${id}g)" stroke-width="10" ${R}/></g><path d="M50 56 V88" stroke="url(#${id}g)" stroke-width="10" stroke-linecap="round"/>`
++ + `<g class="w-b" fill="none" stroke="#8b5cf6" stroke-width="4" stroke-linecap="round" opacity=".65"><path d="M18 20 q-7 9 0 18"/><path d="M10 15 q-10 14 0 28"/><path d="M82 20 q7 9 0 18"/><path d="M90 15 q10 14 0 28"/></g>`,
++ };
++ },
++ pulse(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#4f7cff'], ['.55', '#8b5cf6'], ['1', '#ec4899']], 0, 0, 1, 0),
++ body: `<rect x="6" y="14" width="88" height="72" rx="22" fill="#4f7cff" opacity=".1"/><path class="w-a w-k" pathLength="100" d="M8 54 H28 L36 38 L48 74 L60 22 L68 54 H92" fill="none" stroke="url(#${id}g)" stroke-width="8" ${R}/>`,
++ };
++ },
++ bubble(id) {
++ return {
++ defs: lin(`${id}g`, BV),
++ body: `<g class="w-a"><path class="w-k" d="M24 16 H76 a12 12 0 0 1 12 12 V60 a12 12 0 0 1 -12 12 H46 L28 88 V72 H24 a12 12 0 0 1 -12 -12 V28 a12 12 0 0 1 12 -12Z" fill="url(#${id}g)"/>`
++ + `<path class="w-b" pathLength="100" d="M35 34 L50 56 L65 34" fill="none" stroke="#fff" stroke-width="8" ${R}/></g>`,
++ };
++ },
++ // ── Places and worlds ──
++ door(id) {
++ return {
++ defs: lin(`${id}l`, [['0', '#fff7d6'], ['1', '#fbbf24']], 0, 0, 0, 1) + lin(`${id}g`, BV, 0, 0, 1, 1),
++ body: `<path class="w-k" d="M34 88 V42 a20 20 0 0 1 40 0 V88Z" fill="url(#${id}l)"/><path d="M34 88 V42 a20 20 0 0 1 40 0 V88" fill="none" stroke="url(#${id}g)" stroke-width="6" stroke-linejoin="round"/>`
++ + `<g class="w-a"><path d="M34 34 L16 24 V96 L34 88Z" fill="url(#${id}g)"/><circle cx="21" cy="62" r="2.6" fill="#fff"/></g><path class="w-b" d="M40 88 L74 88 L92 96 H50Z" fill="#fbbf24" opacity=".35"/>`,
++ };
++ },
++ planet(id) {
++ return {
++ defs: rad(`${id}p`, [['0', '#93c5fd'], ['.55', '#4f7cff'], ['1', '#6d28d9']]) + lin(`${id}r`, [['0', '#c4b5fd'], ['1', '#f0abfc']], 0, 0, 1, 0),
++ body: `<g class="w-a"><g transform="rotate(-18 50 50)"><ellipse cx="50" cy="50" rx="42" ry="12" fill="none" stroke="url(#${id}r)" stroke-width="5" opacity=".5"/></g></g>`
++ + `<circle class="w-k" cx="50" cy="50" r="25" fill="url(#${id}p)"/>`
++ + `<g class="w-a"><g transform="rotate(-18 50 50)"><path d="M8 50 A42 12 0 0 0 92 50" fill="none" stroke="url(#${id}r)" stroke-width="5" stroke-linecap="round"/></g></g>`,
++ };
++ },
++ sunrise(id) {
++ return {
++ defs: lin(`${id}s`, [['0', '#fde68a'], ['1', '#f97316']], 0, 0, 0, 1) + lin(`${id}m`, [['0', '#8b5cf6'], ['1', '#3b4cca']], 0, 0, 0, 1),
++ body: `<circle class="w-a w-k" cx="50" cy="50" r="16" fill="url(#${id}s)"/><path d="M4 88 L27 30 L50 72 L73 30 L96 88Z" fill="url(#${id}m)"/><path d="M27 30 L35 50 L31 47 L27 52 L22 44Z" fill="#fff" opacity=".7"/><path d="M73 30 L81 50 L77 47 L73 52 L68 44Z" fill="#fff" opacity=".7"/>`,
++ };
++ },
++ prism(id) {
++ const rays = [['#ef4444', 24], ['#f59e0b', 36], ['#22c55e', 48], ['#22d3ee', 60], ['#8b5cf6', 72]];
++ return {
++ defs: lin(`${id}g`, [['0', '#4f7cff', 0.55], ['1', '#8b5cf6', 0.85]], 0, 0, 1, 1),
++ body: `<path d="M4 44 H34" stroke="#fff" stroke-width="5" stroke-linecap="round"/>${rays.map(([c, y], i) => `<path class="w-a" style="--i:${i}" d="M64 48 L96 ${y}" stroke="${c}" stroke-width="5" stroke-linecap="round"/>`).join('')}`
++ + `<path class="w-k" d="M20 20 H80 L50 84Z" fill="url(#${id}g)" stroke="#c4b5fd" stroke-width="3" stroke-linejoin="round"/>`,
++ };
++ },
++ plane(id) {
++ return {
++ defs: lin(`${id}a`, [['0', '#a5b4fc'], ['1', '#6366f1']]) + lin(`${id}b`, [['0', '#8b5cf6'], ['1', '#4c1d95']]),
++ body: `<g class="w-a"><path class="w-k" d="M90 12 L8 44 L42 56Z" fill="url(#${id}a)"/><path d="M90 12 L42 56 L58 88Z" fill="url(#${id}b)"/><path d="M42 56 L40 74 L50 66Z" fill="#4c1d95"/></g>`
++ + `<g class="w-b" stroke="#8b5cf6" stroke-width="4" stroke-linecap="round" opacity=".5"><path d="M10 70 H26"/><path d="M16 82 H34"/></g>`,
++ };
++ },
++ // ── Friendly and alive ──
++ blob(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#22d3ee'], ['.5', '#4f7cff'], ['1', '#8b5cf6']]),
++ body: `<g class="w-a"><path class="w-k" d="M54 10 C77 12 93 31 88 53 C84 75 66 92 45 88 C23 85 7 67 12 45 C16 25 32 8 54 10Z" fill="url(#${id}g)"/><ellipse cx="34" cy="28" rx="10" ry="6" fill="#fff" opacity=".28" transform="rotate(-30 34 28)"/>`
++ + `<path d="M36 46 L50 64 L64 46" fill="none" stroke="#fff" stroke-width="8" ${R}/></g>`,
++ };
++ },
++ smile(id) {
++ return {
++ defs: rad(`${id}g`, [['0', '#93c5fd'], ['.6', '#4f7cff'], ['1', '#7c3aed']]),
++ body: `<circle class="w-k" cx="50" cy="50" r="40" fill="url(#${id}g)"/><g class="w-a" fill="#fff"><circle cx="37" cy="42" r="5.5"/><circle cx="63" cy="42" r="5.5"/></g><path d="M33 58 Q50 76 67 58" fill="none" stroke="#fff" stroke-width="6.5" stroke-linecap="round"/>`,
++ };
++ },
++ eye(id) {
++ return {
++ defs: lin(`${id}g`, BV, 0, 0, 1, 0) + rad(`${id}i`, [['0', '#a5f3fc'], ['.6', '#4f7cff'], ['1', '#6d28d9']]),
++ body: `<g class="w-b"><path d="M6 50 Q50 10 94 50 Q50 90 6 50Z" fill="#4f7cff" opacity=".12"/><path d="M6 50 Q50 10 94 50 Q50 90 6 50Z" fill="none" stroke="url(#${id}g)" stroke-width="6" stroke-linejoin="round"/>`
++ + `<g class="w-a"><circle class="w-k" cx="50" cy="50" r="17" fill="url(#${id}i)"/><circle cx="50" cy="50" r="7" fill="#0b1020"/><circle cx="55" cy="44" r="3.4" fill="#fff"/></g></g>`,
++ };
++ },
++ spark(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#4f7cff'], ['.6', '#8b5cf6'], ['1', '#f472b6']]),
++ body: `<path class="w-a w-k" d="${star4(44, 54, 36)}" fill="url(#${id}g)"/><path class="w-b" d="${star4(80, 20, 12)}" fill="#f472b6"/><path class="w-c" d="${star4(82, 76, 7)}" fill="#8b5cf6" opacity=".8"/>`,
++ };
++ },
++ overlap() {
++ return {
++ defs: '',
++ body: `<circle class="w-a" cx="37" cy="50" r="27" fill="#4f7cff" opacity=".9"/><circle class="w-b" cx="63" cy="50" r="27" fill="#8b5cf6" opacity=".9"/>`
++ + `<path class="w-k" d="M50 26.4 A27 27 0 0 1 50 73.6 A27 27 0 0 1 50 26.4Z" fill="#e0e7ff"/>`,
++ };
++ },
++ // ── Made of parts ──
++ stars(id) {
++ return {
++ defs: lin(`${id}g`, BV, 0, 0, 1, 1),
++ body: `<path d="M18 22 L34 50 L50 78 L66 50 L82 22" fill="none" stroke="url(#${id}g)" stroke-width="3.5" stroke-linejoin="round" opacity=".7"/>`
++ + `<g fill="url(#${id}g)"><circle class="w-a" style="--i:0" cx="18" cy="22" r="8"/><circle class="w-a" style="--i:2" cx="82" cy="22" r="8"/><circle class="w-a w-k" style="--i:4" cx="50" cy="78" r="9"/><circle class="w-a" style="--i:1" cx="34" cy="50" r="5.5"/><circle class="w-a" style="--i:3" cx="66" cy="50" r="5.5"/></g><circle class="w-b" cx="50" cy="30" r="3" fill="#c4b5fd"/>`,
++ };
++ },
++ pixel() {
++ const rows = [[0, 1, 7, 8], [1, 2, 6, 7], [2, 3, 5, 6], [3, 4, 5], [4]];
++ const cols = ['#4f7cff', '#5b74fb', '#6a6cf8', '#7a64f7', '#8b5cf6'];
++ let k = 0;
++ const px = rows.map((cs, r) => cs.map((c) => `<rect class="w-a${r === 4 ? ' w-k' : ''}" style="--i:${k++}" x="${(9 + c * 9.2).toFixed(1)}" y="${18 + r * 13}" width="8.2" height="11" rx="1.6" fill="${cols[r]}"/>`).join('')).join('');
++ return { defs: '', body: px };
++ },
++ ribbon(id) {
++ return {
++ defs: lin(`${id}a`, [['0', '#6366f1'], ['1', '#4c1d95']], 0, 0, 0, 1) + lin(`${id}b`, [['0', '#93c5fd'], ['1', '#4f7cff']], 0, 0, 0, 1),
++ body: `<g class="w-a"><path d="M86 16 H66 L44 78 L54 90Z" fill="url(#${id}a)"/><path class="w-k" d="M14 16 H34 L58 80 L46 90Z" fill="url(#${id}b)"/><path d="M44 78 L46 90 L54 90Z" fill="#3b0764" opacity=".6"/></g>`,
++ };
++ },
++ code(id) {
++ return {
++ defs: lin(`${id}g`, BV, 0, 0, 1, 0),
++ body: `<g fill="none" stroke="#94a3b8" stroke-width="7" ${R}><path class="w-b" d="M28 28 L10 50 L28 72"/><path class="w-c" d="M72 28 L90 50 L72 72"/></g><path class="w-a w-k" d="M37 32 L50 68 L63 32" fill="none" stroke="url(#${id}g)" stroke-width="9" ${R}/>`,
++ };
++ },
++ badge(id) {
++ return {
++ defs: lin(`${id}g`, BV) + lin(`${id}s`, [['0', '#fff', 0], ['.5', '#fff', 0.45], ['1', '#fff', 0]], 0, 0, 1, 0),
++ body: `<clipPath id="${id}c"><rect x="10" y="10" width="80" height="80" rx="26"/></clipPath><rect class="w-k" x="10" y="10" width="80" height="80" rx="26" fill="url(#${id}g)"/><g clip-path="url(#${id}c)"><rect class="w-a" x="-40" y="0" width="30" height="100" fill="url(#${id}s)" transform="skewX(-20)"/></g>`
++ + `<circle cx="37" cy="55" r="11.5" fill="none" stroke="#fff" stroke-width="8.5"/><path d="M55 44 L65 67 L75 44" fill="none" stroke="#fff" stroke-width="8.5" ${R}/>`,
++ };
++ },
++ // ── Nature ──
++ wave(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#22d3ee'], ['1', '#4f7cff']], 0, 0, 1, 1) + lin(`${id}d`, [['0', '#6366f1'], ['1', '#4c1d95']], 0, 0, 0, 1),
++ body: `<g class="w-a"><path class="w-k" d="M8 70 C20 70 26 40 46 30 C62 22 80 30 82 46 C70 38 58 44 58 56 C58 66 68 70 92 70 V86 H8Z" fill="url(#${id}g)"/><path d="M8 80 C30 76 60 84 92 78 V90 H8Z" fill="url(#${id}d)"/></g><circle class="w-b" cx="80" cy="24" r="3" fill="#a5f3fc"/><circle class="w-b" style="--i:1" cx="88" cy="34" r="2" fill="#a5f3fc"/>`,
++ };
++ },
++ flame(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#fbbf24'], ['.45', '#f97316'], ['1', '#8b5cf6']], 0, 0, 0, 1),
++ body: `<g class="w-a"><path class="w-k" d="M50 8 C58 26 76 34 76 58 C76 76 64 90 50 90 C36 90 24 76 24 58 C24 44 32 36 38 28 C40 40 44 44 48 46 C46 32 46 20 50 8Z" fill="url(#${id}g)"/>`
++ + `<path d="M40 58 L50 76 L60 58" fill="none" stroke="#fff" stroke-width="6" ${R} opacity=".9"/></g>`,
++ };
++ },
++ leaf(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#34d399'], ['1', '#4f7cff']], 0, 0, 1, 1),
++ body: `<g class="w-a"><path class="w-k" d="M50 90 C22 76 14 46 24 18 C42 24 82 30 82 58 C82 76 66 90 50 90Z" fill="url(#${id}g)"/><path d="M50 90 L50 70 M50 70 L36 48 M50 70 L64 50" fill="none" stroke="#fff" stroke-width="4.5" ${R} opacity=".85"/></g>`,
++ };
++ },
++ moon(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#c4b5fd'], ['1', '#4f7cff']], 0, 0, 1, 1),
++ body: `<path class="w-k" d="M58 12 A38 38 0 1 0 88 66 A30 30 0 1 1 58 12Z" fill="url(#${id}g)"/><path class="w-a" d="${star4(70, 30, 9)}" fill="#fde68a"/><circle class="w-b" cx="84" cy="44" r="2.5" fill="#fde68a"/>`,
++ };
++ },
++ bolt(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#fde68a'], ['.5', '#f59e0b'], ['1', '#8b5cf6']], 0, 0, 1, 1),
++ body: `<path class="w-a w-k" d="M58 6 L22 54 H46 L38 94 L78 40 H54 Z" fill="url(#${id}g)" stroke="#fff" stroke-opacity=".25" stroke-width="2" stroke-linejoin="round"/>`,
++ };
++ },
++ // ── Things you hold ──
++ headphones(id) {
++ return {
++ defs: lin(`${id}g`, BV, 0, 0, 1, 0),
++ body: `<path d="M18 60 V50 a32 32 0 0 1 64 0 V60" fill="none" stroke="url(#${id}g)" stroke-width="8" stroke-linecap="round"/><rect class="w-k" x="12" y="54" width="18" height="30" rx="8" fill="url(#${id}g)"/><rect x="70" y="54" width="18" height="30" rx="8" fill="url(#${id}g)"/>`
++ + `<g class="w-a" fill="#8b5cf6"><rect style="--i:0" x="39" y="58" width="5" height="18" rx="2.5"/><rect style="--i:1" x="47.5" y="52" width="5" height="30" rx="2.5"/><rect style="--i:2" x="56" y="58" width="5" height="18" rx="2.5"/></g>`,
++ };
++ },
++ play(id) {
++ return {
++ defs: lin(`${id}g`, BV),
++ body: `<circle class="w-k" cx="50" cy="50" r="40" fill="url(#${id}g)"/><circle class="w-b" cx="50" cy="50" r="40" fill="none" stroke="#8b5cf6" stroke-width="3"/><path class="w-a" d="M42 32 Q40 30 40 34 V66 Q40 70 43 68 L68 52 Q71 50 68 48Z" fill="#fff"/>`,
++ };
++ },
++ antenna(id) {
++ return {
++ defs: lin(`${id}g`, BV, 0, 0, 0, 1),
++ body: `<path d="M50 40 L34 90 M50 40 L66 90 M40 72 H60" fill="none" stroke="url(#${id}g)" stroke-width="6" ${R}/><circle class="w-k" cx="50" cy="36" r="7" fill="url(#${id}g)"/>`
++ + `<g class="w-a" fill="none" stroke="#8b5cf6" stroke-width="5" stroke-linecap="round"><path style="--i:0" d="M36 22 a20 20 0 0 0 0 28"/><path style="--i:0" d="M64 22 a20 20 0 0 1 0 28"/><path style="--i:1" d="M24 12 a34 34 0 0 0 0 48" opacity=".6"/><path style="--i:1" d="M76 12 a34 34 0 0 1 0 48" opacity=".6"/></g>`,
++ };
++ },
++ gem(id) {
++ return {
++ defs: lin(`${id}a`, [['0', '#a5b4fc'], ['1', '#4f7cff']]) + lin(`${id}b`, [['0', '#8b5cf6'], ['1', '#4c1d95']]),
++ body: `<path d="M26 22 H74 L90 40 L50 88 L10 40Z" fill="url(#${id}b)"/><path class="w-k" d="M26 22 L38 40 H62 L74 22Z" fill="url(#${id}a)"/><path d="M10 40 H90 M38 40 L50 88 L62 40" fill="none" stroke="#fff" stroke-width="2.5" stroke-linejoin="round" opacity=".55"/>`
++ + `<path class="w-a" d="${star4(70, 30, 8)}" fill="#fff"/>`,
++ };
++ },
++ dice(id) {
++ const pip = (x, y, i) => `<circle class="w-b" style="--i:${i}" cx="${x}" cy="${y}" r="6.5" fill="#fff"/>`;
++ return {
++ defs: lin(`${id}g`, BV),
++ body: `<g class="w-a"><rect class="w-k" x="14" y="14" width="72" height="72" rx="18" fill="url(#${id}g)"/>${pip(32, 32, 0)}${pip(68, 32, 1)}${pip(41, 50, 2)}${pip(59, 50, 3)}${pip(50, 68, 4)}</g>`,
++ };
++ },
++ // ── Signs and symbols ──
++ pin(id) {
++ return {
++ defs: lin(`${id}g`, BV, 0, 0, 0, 1),
++ body: `<ellipse class="w-b" cx="50" cy="91" rx="14" ry="3.5" fill="#4f7cff" opacity=".3"/><g class="w-a"><path class="w-k" d="M50 88 C40 72 20 58 20 38 a30 30 0 0 1 60 0 C80 58 60 72 50 88Z" fill="url(#${id}g)"/><circle cx="50" cy="38" r="11" fill="#fff"/></g>`,
++ };
++ },
++ heart(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#4f7cff'], ['.6', '#8b5cf6'], ['1', '#ec4899']]),
++ body: `<path class="w-a w-k" d="M50 86 L18 52 C6 38 12 16 30 16 C40 16 46 22 50 30 C54 22 60 16 70 16 C88 16 94 38 82 52Z" fill="url(#${id}g)"/>`,
++ };
++ },
++ shield(id) {
++ return {
++ defs: lin(`${id}g`, BV, 0, 0, 0, 1) + lin(`${id}s`, [['0', '#fff', 0], ['.5', '#fff', 0.4], ['1', '#fff', 0]], 0, 0, 1, 0),
++ body: `<clipPath id="${id}c"><path d="M50 8 L84 20 V48 C84 70 68 84 50 92 C32 84 16 70 16 48 V20Z"/></clipPath><path class="w-k" d="M50 8 L84 20 V48 C84 70 68 84 50 92 C32 84 16 70 16 48 V20Z" fill="url(#${id}g)"/><g clip-path="url(#${id}c)"><rect class="w-a" x="-40" y="0" width="26" height="100" fill="url(#${id}s)" transform="skewX(-20)"/></g>`
++ + `<path d="M36 36 L50 64 L64 36" fill="none" stroke="#fff" stroke-width="8" ${R}/>`,
++ };
++ },
++ chevrons(id) {
++ const v = (y, i, o) => `<path class="w-a${i === 2 ? ' w-k' : ''}" style="--i:${i}" d="M22 ${y} L50 ${y + 22} L78 ${y}" fill="none" stroke="url(#${id}g)" stroke-width="9" ${R} opacity="${o}"/>`;
++ return { defs: lin(`${id}g`, BV, 0, 0, 1, 0), body: `${v(14, 0, 0.35)}${v(36, 1, 0.65)}${v(58, 2, 1)}` };
++ },
++ infinity(id) {
++ return {
++ defs: lin(`${id}g`, [['0', '#4f7cff'], ['.5', '#8b5cf6'], ['1', '#4f7cff']], 0, 0, 1, 0),
++ body: `<path class="w-k" d="M50 50 C40 34 14 30 14 50 C14 70 40 66 50 50 C60 34 86 30 86 50 C86 70 60 66 50 50Z" fill="none" stroke="url(#${id}g)" stroke-width="10" ${R}/>`
++ + `<path class="w-a" pathLength="100" d="M50 50 C40 34 14 30 14 50 C14 70 40 66 50 50 C60 34 86 30 86 50 C86 70 60 66 50 50Z" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-dasharray="8 92" opacity=".85"/>`,
++ };
++ },
++ // ── Play and motion ──
++ kite(id) {
++ return {
++ defs: lin(`${id}a`, [['0', '#93c5fd'], ['1', '#4f7cff']]) + lin(`${id}b`, [['0', '#8b5cf6'], ['1', '#6d28d9']]),
++ body: `<g class="w-a"><path class="w-k" d="M50 8 L74 36 L50 64 Z" fill="url(#${id}a)"/><path d="M50 8 L26 36 L50 64Z" fill="url(#${id}b)"/><path d="M26 36 H74" stroke="#fff" stroke-width="2" opacity=".5"/>`
++ + `<path class="w-b" d="M50 64 C46 72 56 76 50 84 C46 88 52 92 48 96" fill="none" stroke="#8b5cf6" stroke-width="3" stroke-linecap="round"/><path d="M47 76 l-6 -3 m6 3 l-4 5 M51 88 l6 -2 m-6 2 l4 5" stroke="#f472b6" stroke-width="3" stroke-linecap="round"/></g>`,
++ };
++ },
++ bird(id) {
++ return {
++ defs: lin(`${id}a`, [['0', '#a5b4fc'], ['1', '#4f7cff']]) + lin(`${id}b`, [['0', '#8b5cf6'], ['1', '#4c1d95']]),
++ body: `<path class="w-a" d="M44 52 L18 22 L58 46Z" fill="url(#${id}a)"/><path class="w-k" d="M14 58 L58 46 L88 30 L74 56 L48 72Z" fill="url(#${id}b)"/><path class="w-b" d="M58 46 L82 18 L64 54Z" fill="url(#${id}a)" opacity=".85"/><path d="M88 30 L96 30 L86 36Z" fill="#f59e0b"/>`,
++ };
++ },
++ bounce(id) {
++ const dots = [[16, 20], [27, 40], [38, 62], [50, 80], [62, 62], [73, 40], [84, 20]];
++ return {
++ defs: lin(`${id}g`, BV, 0, 0, 1, 0),
++ body: `<path d="M16 20 Q34 70 50 82 Q66 70 84 20" fill="none" stroke="url(#${id}g)" stroke-width="2.5" stroke-dasharray="2 6" stroke-linecap="round" opacity=".55"/>${dots.map(([x, y], i) => `<circle class="w-a" style="--i:${i}" cx="${x}" cy="${y}" r="${i === 3 ? 10 : 5}" fill="url(#${id}g)"${i === 3 ? '' : ' opacity=".55"'}/>`).join('')}<ellipse class="w-k" cx="50" cy="93" rx="12" ry="3" fill="#4f7cff" opacity=".35"/>`,
++ };
++ },
++ orbit(id) {
++ return {
++ defs: lin(`${id}g`, BV),
++ body: `<circle cx="50" cy="50" r="30" fill="none" stroke="url(#${id}g)" stroke-width="10"/><circle cx="50" cy="50" r="42" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-dasharray="3 6" opacity=".55"/>`
++ + `<g class="w-a"><circle class="w-k" cx="50" cy="8" r="7" fill="#f472b6"/></g>`,
++ };
++ },
++ cursor(id) {
++ return {
++ defs: lin(`${id}g`, BV),
++ body: `<circle class="w-b" cx="30" cy="28" r="14" fill="none" stroke="#8b5cf6" stroke-width="3"/><g class="w-a"><path class="w-k" d="M30 28 L78 52 L56 58 L68 82 L58 86 L46 62 L30 76Z" fill="url(#${id}g)" stroke="#fff" stroke-width="3" stroke-linejoin="round"/></g><path d="${star4(80, 22, 9)}" fill="#f472b6"/>`,
++ };
++ },
++};
++
++/** [name, what the idea is, key, lockup] in eight rows of five. */
++const WILD_ROWS = [
++ { title: 'Sound and signal', line: 'vibe as in vibration: sound, waves, a heartbeat', items: [
++ ['Soundwave', 'a V drawn in sound bars: the bars bounce', 'soundwave', 'under'],
++ ['Ripple', 'a dot and its waves going out', 'ripple', 'side'],
++ ['Tuning fork', 'a V that rings when it is struck', 'fork', 'stack'],
++ ['Pulse', 'a heartbeat line that draws itself: alive, live', 'pulse', 'under'],
++ ['Speech bubble', 'a chat bubble the name sits in', 'bubble', 'bubble'],
++ ] },
++ { title: 'Places and worlds', line: 'somewhere to go: open, wide, bright', items: [
++ ['Open door', 'a door that swings open, light coming through', 'door', 'stack'],
++ ['Planet', 'a world with a ring: many sites, one place', 'planet', 'side'],
++ ['Sunrise', 'a sun rising between two hills: an O in a V', 'sunrise', 'stack'],
++ ['Prism', 'a V of glass splitting light into colour', 'prism', 'side'],
++ ['Paper plane', 'it glides, and pulls the name behind it', 'plane', 'trail'],
++ ] },
++ { title: 'Friendly and alive', line: 'a character more than a logo', items: [
++ ['Blob', 'a soft shape that breathes and smiles', 'blob', 'pill'],
++ ['Smile', 'a friendly face that blinks', 'smile', 'side'],
++ ['Eye', 'an open eye that looks around: watch, stream', 'eye', 'side'],
++ ['Spark', 'a spark that twinkles: making things', 'spark', 'tag'],
++ ['Overlap', 'two circles that meet: people together', 'overlap', 'side'],
++ ] },
++ { title: 'Made of parts', line: 'built from pieces: stars, pixels, code', items: [
++ ['Constellation', 'stars joined into a V, twinkling', 'stars', 'stack'],
++ ['Pixel V', 'a V in big pixels that pop in: play', 'pixel', 'side'],
++ ['Ribbon', 'a ribbon folded into a V', 'ribbon', 'tag'],
++ ['Code', 'a V between brackets: open source', 'code', 'side'],
++ ['Badge', 'ov on an app tile, with a shine', 'badge', 'pill'],
++ ] },
++ { title: 'Nature', line: 'things that move on their own', items: [
++ ['Wave', 'a wave that rolls: ride the vibe', 'wave', 'under'],
++ ['Flame', 'a flame with a V in its heart, flickering', 'flame', 'side'],
++ ['Leaf', 'a leaf with a V vein, swaying', 'leaf', 'side'],
++ ['Moon', 'a moon and a star for late streams', 'moon', 'tag'],
++ ['Bolt', 'a bolt of energy that flashes', 'bolt', 'side'],
++ ] },
++ { title: 'Things you hold', line: 'objects that say what OpenVibe does', items: [
++ ['Headphones', 'headphones with a beat between them', 'headphones', 'stack'],
++ ['Play', 'a play button: watch, stream, go', 'play', 'pill'],
++ ['Antenna', 'a tower sending out a signal: live', 'antenna', 'stack'],
++ ['Gem', 'a gem with a V cut and a glint', 'gem', 'side'],
++ ['Dice', 'five dots in a V on a die: play', 'dice', 'side'],
++ ] },
++ { title: 'Signs and symbols', line: 'shapes everyone already reads', items: [
++ ['Pin', 'a map pin is an O on a V: you are here', 'pin', 'tag'],
++ ['Heart', 'a heart whose point is the V, beating', 'heart', 'side'],
++ ['Shield', 'a V on a shield: safe, yours', 'shield', 'pill'],
++ ['Chevrons', 'three Vs that cascade', 'chevrons', 'stack'],
++ ['Infinity', 'a loop with a light running round it: open forever', 'infinity', 'under'],
++ ] },
++ { title: 'Play and motion', line: 'caught mid-move', items: [
++ ['Kite', 'a kite on the wind, its tail waving', 'kite', 'trail'],
++ ['Paper bird', 'a folded bird that flaps', 'bird', 'trail'],
++ ['Bounce', 'a ball bouncing a V into the air', 'bounce', 'stack'],
++ ['Orbit', 'a moon going round an O', 'orbit', 'side'],
++ ['Cursor', 'a pointer that clicks: agents doing things', 'cursor', 'tag'],
++ ] },
++];
++
++/** The drawing for one key: { defs, body }, every id starting with `id`. */
++function wildArt(key, id) {
++ const draw = ART[key];
++ return draw ? draw(id) : { defs: '', body: '' };
++}
++
++module.exports = { WILD_ROWS, wildArt, KEYS: Object.keys(ART) };
ddiff --git a/test/brand-explore.test.js b/test/brand-explore.test.js
iindex b94f442..9140fa4 100644
---- a/test/brand-explore.test.js
++++ b/test/brand-explore.test.js
@@@ -25,17 +25,17 @@ const explore = require('../server/views/marks-explore');
await check('three tabs, a hundred and fifty numbered cards in rows, every one pickable', async () => {
const r = await t.get('/brand/explore');
assert.strictEqual(r.status, 200);
-- const codes = [...r.text.matchAll(/<li class="mx-card(?: mb-card| fx-card)?" id="([LPMF]\d{2})" data-code="\1"/g)].map((m) => m[1]);
-- assert.strictEqual(codes.length, 194);
-- assert.strictEqual(new Set(codes).size, 194, 'each number once');
-- assert.deepStrictEqual(['L', 'P', 'M', 'F'].map((k) => codes.filter((c) => c[0] === k).length), [72, 60, 50, 12]);
++ 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, 234);
++ assert.strictEqual(new Set(codes).size, 234, 'each number once');
++ assert.deepStrictEqual(['L', 'P', 'W', 'M', 'F'].map((k) => codes.filter((c) => c[0] === k).length), [72, 60, 40, 50, 12], 'the Wildcards: forty other ideas (the owner, 11-01)');
assert.ok(codes.every((c) => r.text.includes(`data-pick="${c}"`)), 'each has its Pick button');
-- for (const tab of ['loop', 'pair', 'menu', 'font']) {
++ for (const tab of ['loop', 'pair', 'wild', 'menu', 'font']) {
assert.match(r.text, new RegExp(`<a class="mx-tab(?: mx-tab-side)?" role="tab" id="mx-tab-${tab}" href="#${tab}" aria-controls="${tab}"[^>]*><span class="mx-tab-art" data-mx-cycle="${tab}"><span class="mx-ta"><(svg|span)`), `the ${tab} tab shows an example`);
assert.match(r.text, new RegExp(`<section class="mx-panel" id="${tab}" role="tabpanel"`));
}
assert.match(r.text, /<noscript><style>\.mx-panel\[hidden\]\{display:block!important\}<\/style><\/noscript>/, 'without the script every tab shows');
-- assert.match(r.text, /<h1>Which logo should OpenVibe use\?<\/h1>\n<p class="mx-lede">Tap every logo you like, from both kinds if you want\. Then pick a menu button and a font\. Votes count right away\.<\/p>/, 'the top says it simply');
++ assert.match(r.text, /<h1>Which logo should OpenVibe use\?<\/h1>\n<p class="mx-lede">Tap every logo you like: the Loop, the Pair and\/or the Wildcards, any mix\. Then pick a menu button and a font\. Votes count right away\.<\/p>/, 'the top says it simply');
assert.match(r.text, /<span class="mx-qn">1<\/span> <b>Pick a logo<\/b>[\s\S]*?<span class="mx-or" aria-hidden="true">\+<\/span>[\s\S]*?<span class="mx-qn">2<\/span> <b>Pick a menu button<\/b>[\s\S]*?<span class="mx-qn">3<\/span> <b>Pick a font<\/b>/, 'three steps: a logo, a menu button, a font');
assert.ok((r.text.match(/<section class="mx-row">/g) || []).length >= 15, 'sorted into rows by idea');
assert.match(r.text, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');
@@@ -44,7 +44,7 @@ const explore = require('../server/views/marks-explore');
assert.ok(!/data-mx-tools|data-mx-set-site|data-mx-own/.test(r.text), 'no per-site picker: one example name');
assert.match(r.text, /<div class="mx-nudge" data-mx-nudge role="status" hidden>/, 'a nudge to step 2 waits for the first pick');
assert.match(r.text, /<span class="mx-stepn" aria-hidden="true">1<\/span>[\s\S]*?<span class="mx-stepn" aria-hidden="true">2<\/span>[\s\S]*?<span class="mx-stepn" aria-hidden="true">3<\/span>/, 'the steps bar numbers the three steps');
-- assert.strictEqual((r.text.match(/<div class="mx-stephead">/g) || []).length, 4, 'each section opens with its step');
++ assert.strictEqual((r.text.match(/<div class="mx-stephead">/g) || []).length, 5, 'each section opens with its step');
});
await check('each mark beside a site\'s name and small, reusing one drawing; every id unique', async () => {
@@@ -52,7 +52,7 @@ const explore = require('../server/views/marks-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">Chat<\/span><\/span><\/div>/);
assert.match(r.text, /<use href="#art-P60"\/>/);
assert.match(r.text, /<li class="mx-card" id="L72"/, 'the outside-the-box row ends the Loops');
-- const ids = [...r.text.matchAll(/<(?:linearGradient|mask|filter|clipPath|g) id="([^"]+)"/g)].map((m) => m[1]);
++ const ids = [...r.text.matchAll(/<(?:linearGradient|radialGradient|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');
@@@ -77,11 +77,16 @@ const explore = require('../server/views/marks-explore');
const ana = t.network.addUser('ana');
const signed = (await t.get('/api/v1/brand/picks', { method: 'PUT', as: ana, json: { picks: ['P01', 'P02', 'M07'] }, origin: own })).json();
assert.strictEqual(signed.tally.people, 2);
-- assert.deepStrictEqual(signed.tally.lean, { loop: 1, pair: 1, both: 0 });
++ assert.deepStrictEqual(signed.tally.lean, { loop: 1, pair: 1, wild: 0, both: 0 });
// Picking from both families counts for both (the owner, 11-01: "a combination is fine… gauge all of it").
const bea = t.network.addUser('bea');
const mixed = (await t.get('/api/v1/brand/picks', { method: 'PUT', as: bea, json: { picks: ['L03', 'L40', 'P02'] }, origin: own })).json();
-- assert.deepStrictEqual(mixed.tally.lean, { loop: 2, pair: 2, both: 1 }, 'two Loop takes and one Pair take: one person for each, and one who likes both');
++ assert.deepStrictEqual(mixed.tally.lean, { loop: 2, pair: 2, wild: 0, both: 1 }, 'two Loop takes and one Pair take: one person for each, and one who likes more than one kind');
++ const cal = t.network.addUser('cal');
++ const wild = (await t.get('/api/v1/brand/picks', { method: 'PUT', as: cal, json: { picks: ['W13', 'W40', 'P02'] }, origin: own })).json();
++ assert.deepStrictEqual(wild.mine, ['P02', 'W13', 'W40'], 'the Wildcards save (migration 0007)');
++ assert.deepStrictEqual(wild.tally.lean, { loop: 2, pair: 3, wild: 1, both: 2 });
++ assert.deepStrictEqual(wild.tally.top.W.map((x) => x.code), ['W13', 'W40']);
assert.deepStrictEqual(mixed.tally.top.P.map((x) => [x.code, x.n]), [['P02', 2], ['P01', 1]], 'the Pair take shows in the results');
assert.deepStrictEqual(mixed.tally.all.map((x) => x.code).sort(), ['L03', 'L40', 'M07', 'P01', 'P02'], 'every take that has a vote, for the next round');
assert.deepStrictEqual(signed.tally.top.P.map((x) => x.code), ['P01', 'P02']);
@@@ -90,10 +95,12 @@ const explore = require('../server/views/marks-explore');
assert.strictEqual((await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L01'] }, origin: 'https://evil.example' })).status, 403, 'another site cannot cast a ballot');
assert.strictEqual((await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L01'] } })).status, 403, 'nor a request that says nothing of where it came from');
const page = await t.get('/brand/explore');
-- assert.match(page.text, /<section class="mx-tally" data-mx-tally[\s\S]*?3 people<\/span>[\s\S]*?data-code="P0[12]"/, 'the tally is at the top of the page');
-- assert.match(page.text, /<p class="mx-both" data-mx-both>1 person like both<\/p>/);
++ assert.match(page.text, /<section class="mx-tally" data-mx-tally[\s\S]*?4 people<\/span>[\s\S]*?data-code="P0[12]"/, 'the tally is at the top of the page');
++ assert.match(page.text, /<p class="mx-both" data-mx-both>2 people like more than one kind<\/p>/);
++ assert.match(page.text, /<span class="mx-s3 mx-s3-w">[\s\S]*?Wild <b data-mx-wild>1<\/b>/, 'the bar counts the three kinds');
++ assert.match(page.text, /<span class="mx-tops-k">Wild<\/span><span class="mx-tops-list" data-mx-tops-w>/);
assert.match(page.text, /<span class="mx-tops-k">Loop<\/span><span class="mx-tops-list" data-mx-tops-l>[\s\S]*?<span class="mx-tops-k">Pair<\/span><span class="mx-tops-list" data-mx-tops-p>/, 'the Loop and the Pair on rows of their own');
-- assert.match(page.text, /<details class="mx-all" data-mx-all><summary>Every vote <span data-mx-all-n>5 takes<\/span>/, 'every vote, open to anyone');
++ assert.match(page.text, /<details class="mx-all" data-mx-all><summary>Every vote <span data-mx-all-n>7 takes<\/span>/, 'every vote, open to anyone');
const js = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'js', 'marks.js'), 'utf8');
assert.match(js, /function navPreview\(\)/, 'the site\'s own bar wears the picks while the page is open');
});
@@@ -101,9 +108,20 @@ const explore = require('../server/views/marks-explore');
await check('the steps bar and "where next" connect the two choices', async () => {
const r = await t.get('/brand/explore');
assert.match(r.text, /<nav class="mx-steps" data-mx-steps aria-label="Jump between the choices" hidden>/);
-- for (const to of ['loop', 'pair', 'menu', 'font']) assert.ok(r.text.includes(`data-mx-go="${to}"`), to);
-- assert.strictEqual((r.text.match(/<nav class="mx-next" aria-label="Where next">/g) || []).length, 4, 'each choice ends with where to go next');
-- assert.strictEqual((r.text.match(/data-mx-cycle="/g) || []).length, 4, 'each tab has its example');
++ for (const to of ['loop', 'pair', 'wild', 'menu', 'font']) assert.ok(r.text.includes(`data-mx-go="${to}"`), to);
++ assert.strictEqual((r.text.match(/<nav class="mx-next" aria-label="Where next">/g) || []).length, 5, 'each choice ends with where to go next');
++ assert.strictEqual((r.text.match(/data-mx-cycle="/g) || []).length, 5, 'each tab has its example');
++ // Each wildcard moves its own way, attaches the name its own way, and shows itself as a status.
++ const wilds = r.text.match(/<li class="mx-card mx-wild" id="W\d\d"[\s\S]*?<\/li>/g) || [];
++ assert.strictEqual(wilds.length, 40);
++ assert.strictEqual(new Set(wilds.map((c) => /data-wild="([a-z]+)"/.exec(c)[1])).size, 40, 'forty different ideas');
++ assert.ok(wilds.every((c) => /<div class="mx-lock lk-(side|pill|under|bubble|stack|tag|trail)">/.test(c)), 'a lockup each');
++ assert.ok(wilds.every((c) => /class="[^"]*\bw-k\b/.test(c)), 'a key part each, for the live status');
++ assert.ok(wilds.every((c) => /data-st="live"[\s\S]*data-st="new"/.test(c)), 'the live and new slots');
++ assert.ok(new Set(wilds.map((c) => /lk-([a-z]+)/.exec(c)[1])).size >= 6, 'the name attaches in several ways');
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'marks.css'), 'utf8');
++ const { KEYS } = require('../server/views/wild-marks');
++ assert.deepStrictEqual(KEYS.filter((k) => !css.includes(`svg[data-wild=${k}]`) && !css.includes(`svg[data-wild=${k}],`)), [], 'every idea has its motion');
});
await check('the files at /brand/:file still answer', async () => {