openvibers/openvibe.design Commits
/brand/explore: picks saved and counted at the top, tap to pick, a steps bar between the choices (#60)
Changes
nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- /brand/explore picks (the owner, 2026-11-01: "when they choose options it'll save to the server, and that shows at++-- the top of the page what people have picked"). One row per voter and picked code (L01–L50 the Loop, P01–P50 the++-- Pair, M01–M50 the menu button). A voter is a signed-in subject (usr_…), or 'anon:' and a hash of a random key kept++-- in this browser's cookie; the key itself is never stored.++++CREATE TABLE brand_picks (++ voter text COLLATE "C" NOT NULL,++ code text COLLATE "C" NOT NULL CHECK (code ~ '^[LPM](0[1-9]|[1-4][0-9]|50)$'),++ signed_in boolean NOT NULL DEFAULT false,++ created_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (voter, code)++);++CREATE INDEX brand_picks_code ON brand_picks (code);@@@ -5,13 +5,39 @@.mx-wrap{--mx-face:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;--mx-lockface:'OV Brand',Nunito,sans-serif;--mx-card:var(--bg-secondary,#101828);--mx-line:var(--border,#1f2d47);--mx-muted:var(--text-muted,#7386a3);max-width:1180px;margin:0 auto;padding:clamp(14px,3vw,36px) clamp(12px,3vw,32px) 120px;color:var(--text-primary,#e8edf3)}.mx-wrap svg{display:block;overflow:visible}.mx-defs{position:absolute;width:0;height:0;overflow:hidden}--.mx-wrap [hidden],.mx-bar[hidden]{display:none!important}++.mx-wrap [hidden]{display:none!important}.mx-hero{display:grid;gap:8px;max-width:860px;margin:0 0 clamp(14px,2.4vw,24px)}.mx-eye{margin:0;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent,#4f7cff)}.mx-hero h1{margin:0;font-family:var(--mx-face);font-weight:900;font-size:clamp(26px,4.2vw,46px);line-height:1.05;letter-spacing:-.02em;text-wrap:balance}.mx-lede,.mx-how{margin:0;max-width:72ch;font-size:clamp(14.5px,1.6vw,16px);line-height:1.55;color:var(--text-secondary,#b4bfcc)}.mx-lede b,.mx-how b{color:var(--text-primary,#e8edf3)}++/* What people picked so far (marks.js redraws it after each save): the Loop against the Pair, and the most picked. */++.mx-tally{display:grid;gap:8px;margin:0 0 12px;padding:10px 12px;border-radius:16px;border:1px solid color-mix(in srgb,#8b5cf6 40%,var(--mx-line));background:linear-gradient(135deg,color-mix(in srgb,#4f7cff 10%,var(--mx-card)),color-mix(in srgb,#8b5cf6 12%,var(--mx-card)))}++.mx-tally-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;font-size:14px;color:var(--text-secondary,#b4bfcc)}++.mx-tally-h b{font-family:var(--mx-face);font-weight:900;font-size:17px;color:var(--text-primary,#e8edf3)}++.mx-tally-lead{margin-left:auto;font-weight:700;color:#c4b5fd}++.mx-lean{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px}++.mx-lean-end{display:flex;align-items:center;gap:6px;font-weight:700;font-size:14px}++.mx-lean-end b{font-family:var(--mx-face);font-weight:900;font-size:16px;min-width:3ch}++.mx-lean-r b{text-align:right}++.mx-lean-bar{position:relative;height:10px;border-radius:999px;background:#8b5cf6;overflow:hidden}++.mx-lean-bar i{position:absolute;inset:0 auto 0 0;width:var(--l,50%);background:#4f7cff;border-right:2px solid var(--mx-card);transition:width .6s cubic-bezier(.2,.8,.2,1)}++.mx-tops{display:flex;align-items:center;gap:8px;min-width:0}++.mx-tops-k{flex:none;width:92px;font-size:11px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--mx-muted)}++.mx-tops-list{display:flex;gap:6px;min-width:0;overflow-x:auto;scrollbar-width:none;padding:1px}++.mx-tops-list::-webkit-scrollbar{display:none}++.mx-tops-list em{font-size:13px;color:var(--mx-muted)}++.mx-top{flex:none;display:inline-flex;align-items:center;gap:5px;padding:3px 9px 3px 4px;border-radius:999px;background:#0d1322;color:#e8edf3;font-size:12.5px}++.mx-top b{font-family:var(--mx-face);font-weight:900}++.mx-top i{font-style:normal;font-weight:800;color:#c4b5fd}++.mx-top svg,.mx-lean-end svg{display:block;flex:none}++.mx-top.is-new{animation:mxTopIn .5s cubic-bezier(.2,.9,.25,1.2) both}++@keyframes mxTopIn{from{opacity:0;transform:scale(.6)}to{opacity:1;transform:none}}++.mx-mini-m{width:26px;height:26px;border-radius:50%;--mb-c:#c9d3e0}++.mx-tally.is-empty .mx-lean-bar{opacity:.4}++@media (max-width:640px){.mx-tops-k{width:auto}.mx-tops{flex-direction:column;align-items:flex-start;gap:4px}.mx-tops-list{max-width:100%}}++/* The choice: 1, the mark (the Loop or the Pair, side by side); 2, separately, the menu button's look. */.mx-choose{display:grid;gap:8px;margin:0 0 12px}.mx-q{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 8px;margin:6px 0 0;font-size:15px;color:var(--text-secondary,#b4bfcc)}@@@ -23,8 +49,16 @@.mx-tab{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:16px;border:1px solid var(--mx-line);background:var(--mx-card);color:inherit;text-decoration:none;transition:border-color .2s,background .2s}.mx-tab:hover{border-color:color-mix(in srgb,#8b5cf6 55%,var(--mx-line))}.mx-tab[aria-selected=true]{border-color:#8b5cf6;background:color-mix(in srgb,#8b5cf6 12%,var(--mx-card));box-shadow:0 0 0 1px #8b5cf6 inset}--.mx-tab-art{flex:none;display:grid;place-items:center;width:62px;height:62px;border-radius:14px;background:#0d1322;color:#e8edf3}++.mx-tab-art{position:relative;flex:none;display:grid;place-items:center;width:62px;height:62px;border-radius:14px;background:#0d1322;color:#e8edf3;overflow:hidden}.mx-tab-art svg{width:46px;height:46px}++/* The tab's example walks through all fifty (marks.js): each take rises in as the last one leaves, its number below. */++.mx-ta{position:absolute;inset:0;display:grid;place-items:center}++.mx-ta.is-in{animation:mxTaIn .62s cubic-bezier(.2,.9,.25,1.15) both}++.mx-ta.is-out{animation:mxTaOut .42s cubic-bezier(.4,0,.6,1) both}++@keyframes mxTaIn{from{opacity:0;transform:translateY(55%) scale(.6)}to{opacity:1;transform:none}}++@keyframes mxTaOut{from{opacity:1;transform:none}to{opacity:0;transform:translateY(-55%) scale(.6)}}++.mx-tab-code{position:absolute;right:3px;bottom:3px;padding:0 4px;border-radius:5px;background:#8b5cf6;color:#fff;font:800 9.5px/14px Inter,system-ui,sans-serif;font-style:normal;letter-spacing:.02em}++.mx-tab-code:empty{display:none}.mx-tab-t{display:grid;gap:2px;min-width:0}.mx-tab-t b{font-family:var(--mx-face);font-weight:900;font-size:19px;line-height:1.15}.mx-tab-t small{font-size:13px;line-height:1.35;color:var(--text-secondary,#b4bfcc)}@@@ -34,6 +68,27 @@.mx-tab-side .mx-tab-art{width:46px;height:46px;border-radius:12px}.mx-tab-side .mx-tab-t b{font-size:16px}++/* The steps bar: once the big tabs have scrolled away it waits under the navbar, so switching between the mark and the++ menu button never means scrolling back up (the owner, 11-01). Each step shows how many you picked there. */++.mx-steps{position:fixed;z-index:55;left:50%;top:calc(var(--ovnav-real-h,56px) + 8px);display:flex;align-items:center;gap:4px;max-width:calc(100vw - 16px);padding:4px;border-radius:999px;border:1px solid color-mix(in srgb,#8b5cf6 45%,var(--mx-line));background:color-mix(in srgb,var(--mx-card) 92%,transparent);backdrop-filter:blur(12px);box-shadow:0 10px 30px rgba(0,0,0,.35);transform:translate(-50%,-12px);opacity:0;pointer-events:none;transition:opacity .25s,transform .35s cubic-bezier(.2,.9,.25,1.1)}++.mx-steps.is-on{opacity:1;transform:translate(-50%,0);pointer-events:auto}++.mx-steps-k{padding:0 4px 0 8px;font-size:10.5px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--mx-muted)}++.mx-steps-sep{width:1px;height:22px;margin:0 2px;background:var(--mx-line)}++.mx-step{display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:3px 11px 3px 6px;border-radius:999px;color:inherit;text-decoration:none;font-weight:750;font-size:13.5px;white-space:nowrap}++.mx-step svg,.mx-step .mb-btn{flex:none}++.mx-step .mb-btn{width:22px;height:22px}++.mx-step[aria-current=true]{background:#8b5cf6;color:#fff}++.mx-step[aria-current=true] .mb-btn{--mb-c:#fff;--mb-a:#fff}++.mx-step i{font-style:normal;font-size:11px;font-weight:800;padding:0 5px;border-radius:999px;background:#ffffff26}++.mx-step i:empty{display:none}++/* The end of each choice: where to go next. */++.mx-next{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:22px}++.mx-next a{display:grid;gap:2px;padding:14px 16px;border-radius:16px;border:1px solid var(--mx-line);background:var(--mx-card);color:inherit;text-decoration:none}++.mx-next a:last-child{border-color:#8b5cf6;background:color-mix(in srgb,#8b5cf6 14%,var(--mx-card))}++.mx-next b{font-family:var(--mx-face);font-weight:900;font-size:17px}++.mx-next small{font-size:13px;color:var(--text-secondary,#b4bfcc)}++@media (max-width:640px){.mx-steps-k{display:none}.mx-step{padding:3px 9px 3px 5px;font-size:12.5px;gap:5px}.mx-next{grid-template-columns:1fr}}++/* The bar: the name, its font and style, the view. Sticky on wide screens. */.mx-tools{display:flex;flex-wrap:wrap;gap:6px 18px;align-items:center;margin:0 0 6px;padding:8px 10px;border-radius:14px;border:1px solid var(--mx-line);background:color-mix(in srgb,var(--mx-card) 92%,transparent);backdrop-filter:blur(10px);z-index:20}.mx-tool{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%}@@@ -51,6 +106,7 @@/* Rows of cards. */.mx-row{margin-top:18px}.mx-wrap .mx-row h3:not(.ovk-card-title){display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;margin:0;font-family:var(--mx-face);font-weight:900;font-size:19px;text-transform:none;letter-spacing:-.01em;color:var(--text-primary,#e8edf3)}++.mx-swipe{display:none;margin-left:auto;font-family:Inter,system-ui,sans-serif;font-size:12px;font-weight:700;color:#a78bfa}.mx-row h3 small{font-family:Inter,system-ui,sans-serif;font-weight:600;font-size:12.5px;color:var(--mx-muted)}.mx-row-line{margin:2px 0 8px;font-size:13.5px;color:var(--text-secondary,#b4bfcc)}.mx-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}@@@ -76,23 +132,41 @@.mx-name{margin:0 2px;font-size:14px;line-height:1.25}.mx-name b{font-family:var(--mx-face);font-weight:900;color:var(--accent,#4f7cff);margin-right:2px}.mx-line{margin:0 2px;font-size:12.5px;line-height:1.35;color:var(--text-secondary,#b4bfcc)}--.mx-pick{position:relative;justify-self:start;padding:5px 12px;border-radius:999px;border:1px solid var(--mx-line);background:transparent;color:inherit;font:inherit;font-size:13px;font-weight:700;cursor:pointer;min-height:34px}--.mx-pick:hover{border-color:#8b5cf6}--.mx-pick::after{content:"";position:absolute;inset:-6px -4px}--.mx-pick[aria-pressed=true]{background:#8b5cf6;border-color:#8b5cf6;color:#fff}.mx-fine{margin-top:28px;font-size:14px;color:var(--mx-muted)}.mx-fine a{color:var(--accent,#4f7cff)}++/* Picking: tap a card (or its corner toggle). A picked card glows and wears a tick; nothing covers the marks. */++.mx-card{cursor:pointer;-webkit-tap-highlight-color:transparent}++.mx-pick{position:absolute;z-index:2;top:13px;right:13px;display:grid;place-items:center;width:30px;height:30px;padding:0;border-radius:50%;border:1.5px solid #ffffff4d;background:#0d1322b3;color:#e8edf3;cursor:pointer;backdrop-filter:blur(4px);transition:background .2s,border-color .2s,transform .25s cubic-bezier(.2,1.4,.3,1)}++.mx-pick::before{content:"+";font:700 18px/1 Inter,system-ui,sans-serif}++.mx-pick::after{content:"";position:absolute;inset:-7px}++.mx-card:hover .mx-pick{border-color:#a78bfa}++.mx-pick[aria-pressed=true]{background:#8b5cf6;border-color:#c4b5fd;transform:scale(1.08)}++.mx-pick[aria-pressed=true]::before{content:"\2713";font-size:15px}++.mx-pick:focus-visible{outline:2px solid #c4b5fd;outline-offset:2px}++.mx-card.is-picked{border-color:#8b5cf6;background:color-mix(in srgb,#8b5cf6 13%,var(--mx-card));box-shadow:0 0 0 1px #8b5cf6,0 12px 30px color-mix(in srgb,#8b5cf6 24%,transparent)}++.mx-card.is-picked .mx-stage{box-shadow:inset 0 0 0 1px color-mix(in srgb,#8b5cf6 55%,transparent)}++.mx-card.just-picked{animation:mxPicked .45s cubic-bezier(.2,1.4,.3,1)}++@keyframes mxPicked{0%,100%{scale:1}40%{scale:.96}75%{scale:1.02}}++/* Your picks, in the tally at the top: copy a link to them or clear them. */++.mx-mine{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;min-width:0}++.mx-mine b{font-family:var(--mx-face);font-weight:900;font-size:14px;overflow-wrap:anywhere}++.mx-btn{padding:5px 12px;border-radius:999px;border:0;background:#8b5cf6;color:#fff;font:inherit;font-size:12.5px;font-weight:700;cursor:pointer;min-height:32px}++.mx-btn-quiet{background:transparent;color:inherit;border:1px solid var(--mx-line)}++.mx-saved{font-style:normal;font-size:12px;font-weight:700;color:#86efac;opacity:0;transition:opacity .3s}++.mx-saved.is-on{opacity:1}++.mx-share{border:0;background:transparent;font:inherit;font-weight:750;font-size:13.5px;cursor:pointer;color:inherit}++.mx-share.is-done span::after{content:" \2713"}++@media (max-width:640px){.mx-share span{display:none}.mx-pick{top:10px;right:10px}}++/* Sheet: all fifty at once, just the marks; tap one to pick it. */.mx-wrap[data-density=sheet] .mx-grid{grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:8px}--.mx-wrap[data-density=sheet] .mx-card{padding:5px 5px 7px;gap:4px;border-radius:13px;cursor:pointer}++.mx-wrap[data-density=sheet] .mx-card{padding:5px 5px 7px;gap:4px;border-radius:13px}++.mx-wrap[data-density=sheet] .mx-pick{top:9px;right:9px;width:24px;height:24px}++.mx-wrap[data-density=sheet] .mx-pick::before{font-size:15px}.mx-wrap[data-density=sheet] :is(.mx-lock,.mx-sizes,.mx-line,.mb-bars,.mx-row-line){display:none}.mx-wrap[data-density=sheet] .mx-stage{aspect-ratio:1}.mx-wrap[data-density=sheet] .mx-name{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}--.mx-wrap[data-density=sheet] .mx-pick{position:absolute;top:8px;right:8px;display:grid;place-items:center;width:24px;height:24px;min-height:0;padding:0;border-radius:50%;font-size:0;background:#0d1322cc;color:#e8edf3;border-color:#ffffff38}--.mx-wrap[data-density=sheet] .mx-pick::before{content:"+";font-size:16px;font-weight:700;line-height:1}--.mx-wrap[data-density=sheet] .mx-pick[aria-pressed=true]{background:#8b5cf6;border-color:#8b5cf6}--.mx-wrap[data-density=sheet] .mx-pick[aria-pressed=true]::before{content:"\2713";font-size:13px}.mx-wrap[data-density=sheet] .mx-row{margin-top:12px}/* ── Motion ─────────────────────────────────────────────────────────────────────────────────────────────────────@@@ -121,8 +195,9 @@@keyframes mxNod{0%,60%,100%{transform:translateY(0)}72%{transform:translateY(3px)}84%{transform:translateY(-1px)}}@keyframes mxDot{0%,100%{transform:scale(1)}50%{transform:scale(1.28)}}@keyframes mxPulse{0%,100%{opacity:.4}50%{opacity:.95}}++@keyframes mxPeek{0%,100%{transform:none}34%{transform:translateX(-68px)}58%{transform:translateX(6px)}76%{transform:translateX(-16px)}}@media (prefers-reduced-motion:reduce){.mx-wrap.mx-live .mx-card,.mx-wrap.mx-live .mx-stage :is(.mx-s,.mx-f){transition:none!important}.mx-wrap.mx-live .mx-card:not(.is-in){opacity:1;transform:none}.mx-wrap.mx-live .mx-card:not(.is-in) .mx-stage :is(.mx-s,.mx-f){stroke-dashoffset:0;opacity:1}}--html[data-ov-motion=reduced] .mx-wrap .mx-card,html[data-ov-motion=reduced] .mx-wrap .mx-card *{animation:none!important;transition:none!important}++html[data-ov-motion=reduced] .mx-wrap .mx-card,html[data-ov-motion=reduced] .mx-wrap .mx-card *,html[data-ov-motion=reduced] .mx-ta{animation:none!important;transition:none!important}html[data-ov-motion=reduced] .mx-wrap .mx-card:not(.is-in){opacity:1!important;transform:none!important}/* ── The menu buttons ───────────────────────────────────────────────────────────────────────────────────────────@@@ -199,16 +274,6 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}.mb-light .mb-btn{--mb-c:#55627a;--mb-hc:#0f1419;--mb-bg:#f4f6fb;--mb-a:#3b6cff}.mb-light .mx-sep,.mb-light .mx-site{background:linear-gradient(90deg,#3b6cff,#7c3aed);-webkit-background-clip:text;background-clip:text}--/* The picks: a bar at the bottom while there are any, with the picked marks small. */--.mx-bar{position:fixed;left:50%;bottom:calc(14px + env(safe-area-inset-bottom,0px) + var(--ovnav-appbar-h,0px));transform:translateX(-50%);z-index:60;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px;max-width:calc(100vw - 24px);padding:8px 10px 8px 12px;border-radius:18px;border:1px solid color-mix(in srgb,#8b5cf6 55%,var(--border,#1f2d47));background:color-mix(in srgb,var(--bg-secondary,#101828) 94%,transparent);backdrop-filter:blur(10px);box-shadow:0 16px 40px rgba(0,0,0,.35);font-size:14px}--.mx-bar-thumbs{display:flex;gap:3px;max-width:42vw;overflow-x:auto;scrollbar-width:none}--.mx-bar-thumbs>*{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:8px;background:#0d1322;color:#e8edf3}--.mx-bar-thumbs svg{width:24px;height:24px}--.mx-bar-thumbs:empty{display:none}--.mx-bar b{font-family:var(--mx-face);font-weight:900;overflow-wrap:anywhere}--.mx-btn{padding:6px 12px;border-radius:999px;border:0;background:#8b5cf6;color:#fff;font:inherit;font-size:13px;font-weight:700;cursor:pointer;min-height:34px}--.mx-btn-quiet{background:transparent;color:inherit;border:1px solid var(--border,#1f2d47)}--/* Widths. A phone gets sideways rails: a row is one swipe, not a page of cards. */@media (max-width:1100px){.mx-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (max-width:860px){.mx-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.mx-vs .mx-tab-t small{display:none}}@@@ -228,8 +293,17 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}.mx-row{margin-top:14px}.mx-wrap .mx-row h3:not(.ovk-card-title){font-size:17px}.mx-row-line{margin-bottom:6px;font-size:12.5px}-- .mx-wrap:not([data-density=sheet]) .mx-grid{display:grid;grid-auto-flow:column;grid-auto-columns:46%;grid-template-columns:none;gap:8px;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scroll-padding-left:2px;padding:2px 2px 6px;margin:0 -2px;scrollbar-width:thin}++ /* A rail scrolls sideways only: overflow-y hidden, so a vertical swipe that starts on it moves the page (the owner,++ 11-01: "you can't scroll without going outside the box"). Its right edge fades while there is more, and the first++ time it comes into view it nudges left and back (marks.js), so it reads as something to swipe. */++ .mx-wrap:not([data-density=sheet]) .mx-grid{display:grid;grid-auto-flow:column;grid-auto-columns:46%;grid-template-columns:none;gap:8px;overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;touch-action:pan-x pan-y;scroll-snap-type:x proximity;scroll-padding-left:2px;padding:2px 2px 6px;margin:0 -2px;scrollbar-width:none}++ .mx-wrap:not([data-density=sheet]) .mx-grid::-webkit-scrollbar{display:none}++ .mx-wrap:not([data-density=sheet]) .mx-grid.has-more{-webkit-mask-image:linear-gradient(90deg,#000 78%,transparent 99%);mask-image:linear-gradient(90deg,#000 78%,transparent 99%)}++ .mx-wrap:not([data-density=sheet]) .mx-grid.has-more.is-scrolled{-webkit-mask-image:linear-gradient(90deg,transparent,#000 8%,#000 78%,transparent 99%);mask-image:linear-gradient(90deg,transparent,#000 8%,#000 78%,transparent 99%)}++ .mx-wrap:not([data-density=sheet]) .mx-grid.is-scrolled:not(.has-more){-webkit-mask-image:linear-gradient(90deg,transparent,#000 8%);mask-image:linear-gradient(90deg,transparent,#000 8%)}.mx-wrap:not([data-density=sheet]) .mx-card{scroll-snap-align:start}++ .mx-wrap:not([data-density=sheet]) .mx-swipe{display:inline}++ .mx-wrap:not([data-density=sheet]) .mx-grid.is-peek>li{animation:mxPeek 1.15s cubic-bezier(.3,.7,.3,1)}.mx-card{padding:6px 6px 8px;gap:5px;border-radius:14px}.mx-lock{padding:6px 7px;gap:6px}.mx-lock svg{width:20px;height:20px}@@@ -239,7 +313,7 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}.mx-sizes span{gap:6px;padding:5px 4px}.mx-name{font-size:13px}.mx-line{font-size:12px}-- .mx-pick{min-height:34px;padding:4px 10px;font-size:12.5px}.mx-chip,.mx-own{min-height:40px}.mx-wrap[data-density=sheet] .mx-grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px}}++.mx-panel{scroll-margin-top:calc(var(--ovnav-real-h,56px) + 64px)}@@@ -1,99 +1,174 @@--/* /brand/explore: the tabs, the name/font/style bar, the arrival and idle motion, the menu buttons, and the picks.-- The picks live in the page's link (#picks=L03,P17,M09), so a link sent to someone opens with them marked, and in-- this browser for next time; the bar's choices are remembered here too. Without this script every tab shows, one-- after another, and every mark is drawn still. */++/* /brand/explore: the choices (the Loop or the Pair, then the menu button), the name/font/style bar, the motion, the++ menu buttons, and the picks. The picks live in the page's link (#picks=L03,P17,M09) so a link sent to someone opens++ with them marked, in this browser for next time, and on the server (PUT /api/v1/brand/picks), which counts them into++ the tally at the top. Without this script every choice shows one after another and every mark is drawn still. */(function () {'use strict';var KEY = 'ov_mark_picks', VIEW = 'ov_mark_view', TAB = 'ov_mark_tab';var CODE = /^[LPM]\d{2}$/;var TABS = { L: 'loop', P: 'pair', M: 'menu' };++ 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 save(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* a private window keeps it for the page only */ } }++ function store(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* a private window keeps it for the page only */ } }function all(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }++ function calm() {++ try { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true; } catch (e) { /* */ }++ return document.documentElement.getAttribute('data-ov-motion') === 'reduced';++ }function init() {var root = document.querySelector('[data-mx]');if (!root || root.getAttribute('data-ready')) return;root.setAttribute('data-ready', '1');root.classList.add('mx-live');++ var still = function () { return calm() || root.classList.contains('mx-still'); };++++ // ── A copy of a card's drawing, with its ids made its own (a hidden tab's gradients do not paint elsewhere) ──++ var copies = 0;++ function artOf(code, size) {++ var card = document.getElementById(code);++ var src = card && card.querySelector('.mx-stage svg');++ if (!src) return null;++ var tag = '-c' + (++copies);++ 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); });++ svg.setAttribute('aria-hidden', 'true');++ if (size) { svg.setAttribute('width', size); svg.setAttribute('height', size); }++ if (code[0] === 'M') box.className = 'mb-btn';++ else { box = svg; }++ return box;++ }// ── Picks ──-- var bar = document.querySelector('[data-mx-bar]');-- var out = bar.querySelector('[data-mx-picks]');-- var thumbs = bar.querySelector('[data-mx-thumbs]');++ var mineBox = root.querySelector('[data-mx-mine]');var picks = [];var fromHash = /(?:^#|&)picks=([A-Z0-9,]+)/.exec(location.hash || '');++ var fromHere = !!fromHash;if (fromHash) picks = fromHash[1].split(',').filter(function (c) { return CODE.test(c); });-- else picks = (load(KEY, []) || []).filter(function (c) { return CODE.test(c); });++ else { picks = (load(KEY, []) || []).filter(function (c) { return CODE.test(c); }); fromHere = picks.length > 0; }function order(list) { return list.slice().sort(); }function link() { return location.origin + location.pathname + (picks.length ? '#picks=' + order(picks).join(',') : ''); }-- function thumb(code) {-- var card = document.getElementById(code);-- if (!card || !card.querySelector('.mx-stage svg')) return null;-- var box = document.createElement('span');-- if (code[0] === 'M') box.className = 'mb-btn';-- // The large drawing, with its ids made its own: a hidden tab's gradients and masks do not paint elsewhere.-- var stage = card.querySelector('.mx-stage svg');-- var html = stage.outerHTML.replace(/ id="([^"]+)"/g, ' id="$1-t"').replace(/url\(#([^)]+)\)/g, 'url(#$1-t)').replace(/href="#([^"]+)"/g, 'href="#$1-t"');-- box.innerHTML = html;-- var svg = box.firstChild;-- svg.removeAttribute('width'); svg.removeAttribute('height'); svg.removeAttribute('role'); svg.setAttribute('aria-hidden', 'true');-- box.title = code;-- return box;-- }function render() {all('[data-pick]').forEach(function (b) {var c = b.getAttribute('data-pick'), on = picks.indexOf(c) !== -1;b.setAttribute('aria-pressed', on ? 'true' : 'false');-- b.textContent = (on ? 'Picked ' : 'Pick ') + c;++ b.setAttribute('aria-label', (on ? 'Picked ' : 'Pick ') + c);var card = b.closest('.mx-card');if (card) card.classList.toggle('is-picked', on);});-- bar.hidden = !picks.length;-- out.textContent = order(picks).join(', ');-- while (thumbs.firstChild) thumbs.removeChild(thumbs.firstChild);-- order(picks).forEach(function (c) { var t = thumb(c); if (t) thumbs.appendChild(t); });-- save(KEY, picks);++ if (mineBox) { mineBox.hidden = !picks.length; mineBox.querySelector('[data-mx-picks]').textContent = order(picks).join(', '); }++ all('[data-mx-count]').forEach(function (el) { var k = el.getAttribute('data-mx-count'); var n = picks.filter(function (c) { return c[0] === k; }).length; el.textContent = n ? String(n) : ''; });++ store(KEY, picks);if (history.replaceState && (picks.length || /picks=/.test(location.hash))) history.replaceState(null, '', picks.length ? '#picks=' + order(picks).join(',') : location.pathname);}++ var saveTimer = 0;++ function toggle(code) {++ var i = picks.indexOf(code);++ if (i === -1) picks.push(code); else picks.splice(i, 1);++ render();++ var card = document.getElementById(code);++ if (card && i === -1 && !still()) { card.classList.remove('just-picked'); void card.offsetWidth; card.classList.add('just-picked'); setTimeout(function () { card.classList.remove('just-picked'); }, 500); }++ clearTimeout(saveTimer); saveTimer = setTimeout(sync, 700);++ }++ function say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }++ function copy(text, btn) {++ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { say(btn, 'Copied'); }, function () { window.prompt('Copy this:', text); });++ else window.prompt('Copy this:', text);++ }// "The mark: the Loop (L03, L07), the Pair (P17). The menu button: M09." The two questions stay apart.function summary() {var of = function (k) { return order(picks).filter(function (c) { return c[0] === k; }); };-- var l = of('L'), p = of('P'), m = of('M'), mark = [];++ var l = of('L'), p = of('P'), m = of('M'), mark = [], parts = [];if (l.length) mark.push('the Loop (' + l.join(', ') + ')');if (p.length) mark.push('the Pair (' + p.join(', ') + ')');-- var parts = [];if (mark.length) parts.push('The mark: ' + mark.join(', ') + '.');if (m.length) parts.push('The menu button: ' + m.join(', ') + '.');return 'My picks. ' + parts.join(' ') + ' ' + link();}-- function toggle(code) { var i = picks.indexOf(code); if (i === -1) picks.push(code); else picks.splice(i, 1); render(); }-- function say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }-- function copy(text, btn) {-- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { say(btn, 'Copied'); }, function () { window.prompt('Copy this:', text); });-- else window.prompt('Copy this:', text);++++ // ── The tally at the top: saved picks, counted (server/brand-picks.js) ──++ var tally = root.querySelector('[data-mx-tally]');++ function chip(t) {++ var el = document.createElement('span');++ el.className = 'mx-top'; el.setAttribute('data-code', t.code);++ var art = artOf(t.code, 26);++ if (art) { if (t.code[0] === 'M') art.classList.add('mx-mini-m'); el.appendChild(art); }++ var b = document.createElement('b'); b.textContent = t.code; el.appendChild(b);++ var n = document.createElement('i'); n.textContent = String(t.n); el.appendChild(n);++ return el;++ }++ function fill(list, items) {++ if (!list) return;++ var before = {};++ all('.mx-top', list).forEach(function (el) { before[el.getAttribute('data-code')] = true; });++ while (list.firstChild) list.removeChild(list.firstChild);++ if (!items.length) { var em = document.createElement('em'); em.textContent = 'none yet'; list.appendChild(em); return; }++ items.forEach(function (t) { var c = chip(t); if (!before[t.code]) c.classList.add('is-new'); list.appendChild(c); });++ }++ function showTally(t) {++ if (!tally || !t) return;++ var loop = t.lean.loop, pair = t.lean.pair, sum = loop + pair, pct = sum ? Math.round((loop / sum) * 100) : 50;++ tally.classList.toggle('is-empty', !t.people);++ tally.querySelector('[data-mx-people]').textContent = t.people ? t.people + (t.people === 1 ? ' person has picked' : ' people have picked') : 'Nobody has picked yet. Be the first.';++ tally.querySelector('[data-mx-lead]').textContent = sum ? (loop === pair ? 'Level so far' : loop > pair ? 'The Loop leads' : 'The Pair leads') : 'No picks yet';++ tally.querySelector('.mx-lean').style.setProperty('--l', pct + '%');++ tally.querySelector('[data-mx-loop]').textContent = sum ? pct + '%' : '–';++ tally.querySelector('[data-mx-pair]').textContent = sum ? (100 - pct) + '%' : '–';++ var marks = (t.top.L || []).concat(t.top.P || []).sort(function (a, b) { return b.n - a.n || (a.code < b.code ? -1 : 1); }).slice(0, 6);++ fill(tally.querySelector('[data-mx-tops]'), marks);++ fill(tally.querySelector('[data-mx-tops-m]'), (t.top.M || []).slice(0, 4));++ }++ var savedNote = root.querySelector('[data-mx-saved]');++ function sync() {++ if (!window.fetch) return;++ fetch(API, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ picks: order(picks) }) })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) {++ if (!d) return;++ showTally(d.tally);++ if (savedNote && picks.length) { savedNote.classList.add('is-on'); setTimeout(function () { savedNote.classList.remove('is-on'); }, 1600); }++ })++ .catch(function () { /* the picks stay in the link and this browser */ });++ }++ if (window.fetch) {++ fetch(API, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {++ if (!d) return;++ showTally(d.tally);++ var mine = (d.mine || []).filter(function (c) { return CODE.test(c); });++ if (!fromHere && mine.length) { picks = mine; render(); }++ else if (fromHere && order(picks).join() !== order(mine).join() && !fromHash) sync();++ }).catch(function () { /* */ });}-- // ── Tabs ──++ // ── The choices: the big tabs, the steps bar, and "where next" at the end of each ──var tabs = all('[data-mx-tab]', root);-- function panelOf(t) { return document.getElementById(t.getAttribute('aria-controls')); }++ var steps = root.querySelector('[data-mx-steps]');++ function panelOf(name) { return document.getElementById(name); }++ var current = 'loop';function show(name, focus) {++ current = name;tabs.forEach(function (t) {var on = t.getAttribute('data-mx-tab') === name;t.setAttribute('aria-selected', on ? 'true' : 'false');t.tabIndex = on ? 0 : -1;-- var p = panelOf(t);++ var p = panelOf(t.getAttribute('data-mx-tab'));if (p) p.hidden = !on;if (on && focus) t.focus();});-- save(TAB, name);++ all('[data-mx-go]', steps).forEach(function (a) { a.setAttribute('aria-current', a.getAttribute('data-mx-go') === name ? 'true' : 'false'); });++ store(TAB, name);++ rails();++ }++ function go(name) {++ show(name);++ if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + name);++ var p = panelOf(name);++ if (p) p.scrollIntoView({ behavior: still() ? 'auto' : 'smooth', block: 'start' });}tabs.forEach(function (t, i) {-- t.addEventListener('click', function (e) {-- e.preventDefault();-- show(t.getAttribute('data-mx-tab'));-- if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + t.getAttribute('data-mx-tab'));-- });++ t.addEventListener('click', function (e) { e.preventDefault(); show(t.getAttribute('data-mx-tab')); if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + t.getAttribute('data-mx-tab')); });t.addEventListener('keydown', function (e) {var k = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;if (!k) return;@@@ -101,10 +176,78 @@show(tabs[(i + k + tabs.length) % tabs.length].getAttribute('data-mx-tab'), true);});});++ root.addEventListener('click', function (e) {++ var a = e.target.closest('[data-mx-go]');++ if (!a) return;++ e.preventDefault();++ go(a.getAttribute('data-mx-go'));++ });++ if (steps) {++ steps.hidden = false;++ var choose = root.querySelector('.mx-choose');++ 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)$/.test(h) ? h : CODE.test(h) ? TABS[h[0]] : picks.length ? TABS[order(picks)[0][0]] : load(TAB, 'loop');show(/^(loop|pair|menu)$/.test(start) ? start : 'loop');++ // ── Each tab's example walks through all fifty, so it is plain there are fifty ──++ (function cycle() {++ var boxes = all('[data-mx-cycle]', root);++ if (!boxes.length) return;++ var state = boxes.map(function (box, k) {++ var name = box.getAttribute('data-mx-cycle');++ return { box: box, codes: all('.mx-card', panelOf(name)).map(function (c) { return c.getAttribute('data-code'); }), i: 0, kind: name, offset: k };++ });++ var visible = true, tick = 0;++ if ('IntersectionObserver' in window) new IntersectionObserver(function (en) { visible = en[0].isIntersecting; }).observe(root.querySelector('.mx-choose'));++ setInterval(function () {++ tick += 1;++ if (!visible || document.hidden || still()) return;++ state.forEach(function (s) {++ if ((tick + s.offset) % 3) return; // each moves every third beat, the three a beat apart++ if (!s.codes.length) return;++ s.i = (s.i + 7) % s.codes.length; // a stride of 7 visits all fifty, each unlike the last++ var code = s.codes[s.i];++ var art = artOf(code, s.kind === 'menu' ? 30 : 46);++ if (!art) return;++ var layer = document.createElement('span');++ layer.className = 'mx-ta is-in';++ if (s.kind === 'menu') { art.classList.add('mb-tabbtn'); art.classList.add('is-play'); }++ layer.appendChild(art);++ all('.mx-ta', s.box).forEach(function (old) { old.classList.remove('is-in'); old.classList.add('is-out'); setTimeout(function () { if (old.parentNode) old.parentNode.removeChild(old); }, 450); });++ s.box.insertBefore(layer, s.box.querySelector('.mx-tab-code'));++ var tag = s.box.querySelector('.mx-tab-code');++ if (tag) tag.textContent = code;++ });++ }, 650);++ })();++++ // ── Sideways rails on a phone: fade the edge while there is more, nudge once when first seen ──++ var nudged = typeof WeakSet === 'function' ? new WeakSet() : null;++ function edge(g) {++ var more = g.scrollWidth - g.clientWidth - g.scrollLeft > 6;++ g.classList.toggle('has-more', more);++ g.classList.toggle('is-scrolled', g.scrollLeft > 6);++ }++ function rails() { all('.mx-grid', root).forEach(edge); }++ all('.mx-grid', root).forEach(function (g) { g.addEventListener('scroll', function () { edge(g); }, { passive: true }); });++ window.addEventListener('resize', rails);++ if ('IntersectionObserver' in window && nudged) {++ var railIo = new IntersectionObserver(function (entries) {++ entries.forEach(function (en) {++ var g = en.target;++ if (!en.isIntersecting || nudged.has(g)) return;++ edge(g);++ if (!g.classList.contains('has-more') || g.scrollLeft > 6 || still() || root.getAttribute('data-density') === 'sheet') return;++ nudged.add(g);++ setTimeout(function () { g.classList.add('is-peek'); setTimeout(function () { g.classList.remove('is-peek'); }, 1200); }, 350);++ });++ }, { threshold: 0.6 });++ all('.mx-grid', root).forEach(function (g) { railIo.observe(g); });++ }++ rails();++// ── The bar: the name, its font and style, the view ──var tools = root.querySelector('[data-mx-tools]');var view = load(VIEW, {}) || {};@@@ -127,14 +270,14 @@view.site = text; view.typed = !!typed;}function setFont(key) {-- var chip = tools.querySelector('[data-mx-font="' + key + '"]') || tools.querySelector('[data-mx-font]');-- if (chip.getAttribute('data-mx-font') !== 'nunito') loadFonts();-- root.style.setProperty('--mx-lockface', chip.getAttribute('data-face'));-- press('data-mx-font', chip.getAttribute('data-mx-font'));-- view.font = chip.getAttribute('data-mx-font');++ var chipEl = tools.querySelector('[data-mx-font="' + key + '"]') || tools.querySelector('[data-mx-font]');++ if (chipEl.getAttribute('data-mx-font') !== 'nunito') loadFonts();++ root.style.setProperty('--mx-lockface', chipEl.getAttribute('data-face'));++ press('data-mx-font', chipEl.getAttribute('data-mx-font'));++ view.font = chipEl.getAttribute('data-mx-font');}function setStyle(key) { key = /^(dot|space|stack)$/.test(key) ? key : 'dot'; root.setAttribute('data-lock', key); press('data-mx-lockstyle', key); view.lock = key; }-- function setDensity(on) { if (on) root.setAttribute('data-density', 'sheet'); else root.removeAttribute('data-density'); press('data-mx-density', on ? 'sheet' : ''); view.sheet = !!on; }++ function setDensity(on) { if (on) root.setAttribute('data-density', 'sheet'); else root.removeAttribute('data-density'); press('data-mx-density', on ? 'sheet' : ''); view.sheet = !!on; rails(); }function setStill(on) { root.classList.toggle('mx-still', !!on); press('data-mx-still', on ? '1' : ''); view.still = !!on; }setSite(view.site, view.typed);if (view.typed) own.value = view.site || '';@@@ -145,7 +288,7 @@tools.hidden = false;tools.querySelector('[data-mx-fonts]').addEventListener('pointerenter', loadFonts);tools.querySelector('[data-mx-fonts]').addEventListener('focusin', loadFonts);-- own.addEventListener('input', function () { setSite(own.value || 'Live', !!own.value); save(VIEW, view); });++ own.addEventListener('input', function () { setSite(own.value || 'Live', !!own.value); store(VIEW, view); });tools.addEventListener('click', function (e) {var b = e.target.closest('button');if (!b) return;@@@ -154,7 +297,7 @@else if (b.hasAttribute('data-mx-lockstyle')) setStyle(b.getAttribute('data-mx-lockstyle'));else if (b.hasAttribute('data-mx-density')) setDensity(!view.sheet);else if (b.hasAttribute('data-mx-still')) setStill(!view.still);-- save(VIEW, view);++ store(VIEW, view);});// ── The menu buttons: press to open, and each makes its move when it arrives ──@@@ -176,12 +319,20 @@}var p = e.target.closest('[data-pick]');if (p) { toggle(p.getAttribute('data-pick')); return; }-- if (e.target.closest('[data-mx-copy]')) { copy(summary(), e.target.closest('[data-mx-copy]')); return; }++ var share = e.target.closest('[data-mx-share]');++ if (share) {++ var text = picks.length ? summary() : link();++ var done = function () { share.classList.add('is-done'); setTimeout(function () { share.classList.remove('is-done'); }, 1400); };++ if (navigator.share && /Mobi|Android/i.test(navigator.userAgent || '')) navigator.share({ title: 'My picks for the OpenVibe mark', text: text.replace(/ https?:\S+$/, ''), url: link() }).then(done, function () { /* closed */ });++ else if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { window.prompt('Copy this:', text); });++ else window.prompt('Copy this:', text);++ return;++ }if (e.target.closest('[data-mx-link]')) { copy(link(), e.target.closest('[data-mx-link]')); return; }-- if (e.target.closest('[data-mx-clear]')) { picks = []; render(); return; }-- // In the sheet, a tap anywhere on a card picks it.-- var card = root.getAttribute('data-density') === 'sheet' && e.target.closest('.mx-card');-- if (card) toggle(card.getAttribute('data-code'));++ if (e.target.closest('[data-mx-clear]')) { picks = []; render(); clearTimeout(saveTimer); saveTimer = setTimeout(sync, 300); return; }++ // A tap anywhere on a card picks it (but not a tap on one of its own buttons, like a menu button's demo).++ var card = e.target.closest('.mx-card');++ if (card && root.contains(card) && !e.target.closest('button, a, input')) toggle(card.getAttribute('data-code'));});// ── Arrival and idle: a card is "in" once it has been seen, and "on" (idling) while it is on screen ──@@@ -72,12 +72,13 @@ async function createApp(opts = {}) {const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now });const accountData = accountDataLib.create({ db: s.db, log });++ const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });const accountSend = opts.accountSend || (config.oauth.clientSecret? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });const ctx = {-- config, s, store: s, keys, sso, catalog, arena, bench, ai, jobs, designs, accountData, accountSend, log,++ config, s, store: s, keys, sso, catalog, arena, bench, ai, jobs, designs, accountData, accountSend, log, picks,random: opts.random || Math.random,};@@@ -172,6 +173,7 @@ async function createApp(opts = {}) {app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));app.use('/api', express.json({ limit: '64kb' }));app.use('/api/v1/arena', createArenaApi(ctx));++ app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));app.use(createIconRoutes(ctx));app.use('/api', createApi(ctx));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++/**++ * /brand/explore's picks, saved and counted (migrations/0005_brand_picks.sql). A person's picks replace their last++ * ones; the tally says how many people picked, which way they lean (the Loop or the Pair: whichever they picked more++ * of, a tie counting half each way), and the most-picked takes in each family.++ *++ * 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 }, top: { L, P, M } } (top: [{ code, n }], at most 6)++ */++const crypto = require('crypto');++++const CODE = /^[LPM](0[1-9]|[1-4][0-9]|50)$/;++const COOKIE = 'ov_mx_voter';++const MAX = 40;++++function createBrandPicks({ db, now = () => Date.now() } = {}) {++ function voterOf(req, res, subject) {++ if (subject) return { voter: subject, signedIn: true };++ let key = req.cookies && req.cookies[COOKIE];++ if (!/^[A-Za-z0-9_-]{32}$/.test(String(key || ''))) {++ if (!res) return null;++ key = crypto.randomBytes(24).toString('base64url');++ res.cookie(COOKIE, key, { httpOnly: true, secure: req.secure || req.get('x-forwarded-proto') === 'https', sameSite: 'lax', maxAge: 365 * 864e5, path: '/' });++ }++ return { voter: `anon:${crypto.createHash('sha256').update(key).digest('base64url').slice(0, 32)}`, signedIn: false };++ }++++ const clean = (codes) => [...new Set((Array.isArray(codes) ? codes : []).map(String).filter((c) => CODE.test(c)))].slice(0, MAX).sort();++++ async function save(voter, codes, { signedIn = false } = {}) {++ const list = clean(codes);++ await db.tx(async () => {++ await db.exec('DELETE FROM brand_picks WHERE voter = $1', [voter]);++ for (const c of list) await db.exec('INSERT INTO brand_picks (voter, code, signed_in, created_at) VALUES ($1, $2, $3, $4)', [voter, c, !!signedIn, new Date(now()).toISOString()]);++ });++ return list;++ }++++ async function mine(voter) {++ if (!voter) return [];++ return (await db.many('SELECT code FROM brand_picks WHERE voter = $1 ORDER BY code', [voter])).map((r) => r.code);++ }++++ 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 lean = 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 people = lean.length;++ let loop = 0, pair = 0;++ for (const v of lean) { if (v.l > v.p) loop += 1; else if (v.p > v.l) pair += 1; else if (v.l) { loop += 0.5; pair += 0.5; } }++ const top = { L: [], P: [], M: [] };++ for (const r of counts) { const list = top[r.code[0]]; if (list && list.length < 6) list.push({ code: r.code, n: r.n }); }++ return { people, lean: { loop, pair }, top };++ }++++ return { voterOf, save, mine, tally, CODE, COOKIE, MAX };++}++++module.exports = { createBrandPicks };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++/**++ * /brand/explore's picks (server/brand-picks.js), for the page's own script:++ *++ * GET /api/v1/brand/picks { tally, mine } what everyone picked so far, and this person's (or browser's) picks++ * PUT /api/v1/brand/picks { picks: ['L03', 'P17', 'M09'] } → { tally, mine } replaces their picks++ *++ * A PUT must come from this site (Sec-Fetch-Site: same-origin, or this site's Origin), signed in or not, so another++ * page cannot cast a ballot for a visitor. Anyone may pick: a signed-in person once per account, a guest once per++ * browser (an httpOnly cookie key, stored only as a hash).++ */++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { sameOrigin } = require('./same-origin');++++const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);++++function createBrandApi(ctx) {++ const { picks, config } = ctx;++ const r = asyncRouter();++ r.get('/picks', async (req, res) => {++ const who = picks.voterOf(req, null, subjectOf(req));++ res.set('Cache-Control', 'private, no-store');++ res.json({ tally: await picks.tally(), mine: who ? await picks.mine(who.voter) : [] });++ });++ r.put('/picks', async (req, res) => {++ if (!sameOrigin(req, config)) return http.sendProblem(res, 403, 'design.cross_site', { detail: 'Picks are saved from openvibe.design itself.', ctx: req.ov });++ const list = req.body && req.body.picks;++ if (!Array.isArray(list) || list.length > 60) return http.sendProblem(res, 400, 'design.picks_invalid', { detail: 'Send { picks: ["L03", "P17"] }, up to 40 codes.', ctx: req.ov });++ const who = picks.voterOf(req, res, subjectOf(req));++ const mine = await picks.save(who.voter, list, { signedIn: who.signedIn });++ res.set('Cache-Control', 'private, no-store');++ res.json({ tally: await picks.tally(), mine });++ });++ return r;++}++++module.exports = { createBrandApi };@@@ -69,7 +69,11 @@ function createPageRoutes(ctx) {// ── The OpenVibe mark, and its files ──r.get('/brand', (req, res) => page(res, 200, brandView.brandPage({ base: layoutBase() }), req.viewer));// A hundred takes on the Loop and the Pair, for feedback (the owner, 2026-11-01). Before /brand/:file.-- r.get('/brand/explore', (req, res) => page(res, 200, require('../views/marks-explore').explorePage(), req.viewer));++ r.get('/brand/explore', async (req, res) => {++ // What people picked so far, at the top (a database hiccup leaves the page whole, without it).++ const tally = ctx.picks ? await safe(() => ctx.picks.tally()) : null;++ page(res, 200, require('../views/marks-explore').explorePage({ tally }), req.viewer);++ });r.get('/brand/:file', (req, res) => {const svg = brandView.file(req.params.file);if (!svg) return page(res, 404, views.notFound(), req.viewer);@@@ -17,6 +17,8 @@ const TABLES = [{ table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'created_at', 'updated_at'] },// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.++ // The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).++ { table: 'brand_picks', subject: 'voter', file: 'brand_picks.json', order: 'created_at', columns: ['code', 'created_at'], erase: 'delete' },{ table: 'glyph_drafts', subject: 'subject', file: 'glyph_drafts.json', order: 'created_at', columns: ['id', 'name', 'sentence', 'svg', 'model', 'status', 'created_at', 'reviewed_at'], erase: 'delete' },];@@@ -472,12 +472,12 @@ function markCard(code, kind, [name, line_, spec], i) {const art = kind === 'L' ? loopArt(spec, `m${++seq}`) : pairArt(spec, `m${++seq}`);const ref = `art-${code}`;return `<li class="mx-card" id="${code}" data-code="${code}" style="${delay(i)}">++<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="${idleOf(kind, spec, i)}"><defs>${art.defs}</defs><g id="${ref}">${art.body}</g></svg></div><div class="mx-lock">${useOf(ref, 26)}${word()}</div><div class="mx-sizes" aria-label="At 16 and 32 pixels"><span class="mx-dark">${useOf(ref, 16)}${useOf(ref, 32)}</span><span class="mx-light">${useOf(ref, 16)}${useOf(ref, 32)}</span></div><p class="mx-name"><b>${code}</b> ${esc(name)}</p><p class="mx-line">${esc(line_)}</p>--<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false">Pick ${code}</button></li>`;}@@@ -485,11 +485,11 @@ const menuSvg = (anim, own, parts, size) => `<svg viewBox="0 0 24 24" width="${sfunction 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="mb-bars">${bar('dark')}${bar('light')}</div><p class="mx-name"><b>${code}</b> ${esc(name)}</p><p class="mx-line">${esc(line_)}</p>--<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false">Pick ${code}</button></li>`;}@@@ -499,18 +499,51 @@ function panel(id, kind, rows, label, hidden) {const body = rows.map((row) => {const first = n + 1;const cards = row.items.map((item) => { n += 1; const code = `${kind}${pad(n)}`; return kind === 'M' ? menuCard(code, item, n - 1) : markCard(code, kind, item, n - 1); }).join('');-- return `<section class="mx-row"><h3>${esc(row.title)} <small>${kind}${pad(first)}–${kind}${pad(n)} · ${row.items.length}</small></h3><p class="mx-row-line">${esc(row.line)}</p><ul class="mx-grid">${cards}</ul></section>`;++ 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('');-- return `<section class="mx-panel" id="${id}" role="tabpanel" aria-labelledby="mx-tab-${id}" aria-label="${esc(label)}"${hidden ? ' hidden' : ''}>${body}</section>`;++ const NEXT = {++ loop: [['pair', 'See the Pair', 'the other direction for the mark'], ['menu', 'Next: the menu button', 'it stays whichever mark wins']],++ pair: [['loop', 'See the Loop', 'the other direction for the mark'], ['menu', 'Next: the menu button', 'it stays whichever mark wins']],++ menu: [['loop', 'Back to the Loop', 'the mark, one direction'], ['pair', 'Back to the Pair', 'the mark, the other']],++ }[id];++ const next = `<nav class="mx-next" aria-label="Where next">${NEXT.map(([to, title, line_]) => `<a href="#${to}" data-mx-go="${to}"><b>${title} <span aria-hidden="true">${to === 'menu' || id !== 'menu' ? '→' : '↑'}</span></b><small>${line_}</small></a>`).join('')}</nav>`;++ return `<section class="mx-panel" id="${id}" role="tabpanel" aria-labelledby="mx-tab-${id}" aria-label="${esc(label)}"${hidden ? ' hidden' : ''}>${body}${next}</section>`;}--function explorePage() {++/** One small mark for the tally, drawn on its own (its own ids), so it shows whichever tab is open. */++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>` : ''; }++ 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}`);++ return `<svg viewBox="0 0 100 100" width="${size}" height="${size}" aria-hidden="true">${art.defs ? `<defs>${art.defs}</defs>` : ''}${art.body}</svg>`;++}++const chipOf = ({ code, n }) => `<span class="mx-top" data-code="${code}">${miniArt(code)}<b>${code}</b><i>${n}</i></span>`;++/** What people picked so far (server/brand-picks.js tally), at the top; marks.js redraws it after each save. */++function tallyBlock(t) {++ const people = (t && t.people) || 0;++ const loop = (t && t.lean && t.lean.loop) || 0, pair = (t && t.lean && t.lean.pair) || 0;++ const loopPct = loop + pair ? Math.round((loop / (loop + pair)) * 100) : 50;++ const marks = t ? [...(t.top.L || []), ...(t.top.P || [])].sort((a, b) => b.n - a.n || (a.code < b.code ? -1 : 1)).slice(0, 6) : [];++ const menus = t ? (t.top.M || []).slice(0, 4) : [];++ const lead = loop + pair ? (loop === pair ? 'Level so far' : loop > pair ? 'The Loop leads' : 'The Pair leads') : 'No picks yet';++ return `<section class="mx-tally${people ? '' : ' is-empty'}" data-mx-tally aria-label="What people picked so far">++<div class="mx-tally-h"><b>So far</b><span data-mx-people>${people ? `${people} ${people === 1 ? 'person has' : 'people have'} picked` : 'Nobody has picked yet. Be the first.'}</span><span class="mx-tally-lead" data-mx-lead>${lead}</span></div>++<div class="mx-lean" style="--l:${loopPct}%"><span class="mx-lean-end">${miniArt('L02', 24, 'e')}<span>Loop</span><b data-mx-loop>${loop + pair ? `${loopPct}%` : '–'}</b></span><span class="mx-lean-bar" aria-hidden="true"><i></i></span><span class="mx-lean-end mx-lean-r"><b data-mx-pair>${loop + pair ? `${100 - loopPct}%` : '–'}</b><span>Pair</span>${miniArt('P01', 24, 'e')}</span></div>++<div class="mx-tops"><span class="mx-tops-k">Most picked</span><span class="mx-tops-list" data-mx-tops>${marks.map(chipOf).join('') || '<em>none yet</em>'}</span></div>++<div class="mx-mine" data-mx-mine hidden><span class="mx-tops-k">Your picks</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>++<div class="mx-tops"><span class="mx-tops-k">Menu button</span><span class="mx-tops-list" data-mx-tops-m>${menus.map(chipOf).join('') || '<em>none yet</em>'}</span></div>++</section>`;++}++++function explorePage({ tally = null } = {}) {seq = 0;const today = pairArt(PAIR_ROWS[0].items[0][2], 'xpair');const loopEx = loopArt(LOOP_ROWS[0].items[1][2], 'xloop');const pairEx = pairArt(PAIR_ROWS[0].items[0][2], 'xpairtab');const m1 = MENU_ROWS[0].items[0];-- const tab = (id, title, line_, art, range, on, side) => `<a class="mx-tab${side ? ' mx-tab-side' : ''}" role="tab" id="mx-tab-${id}" href="#${id}" aria-controls="${id}" aria-selected="${on ? 'true' : 'false'}" data-mx-tab="${id}"><span class="mx-tab-art">${art}</span><span class="mx-tab-t"><b>${title}</b><small>${line_}</small><em>${range}</em></span></a>`;++ const 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>`;const body = `<div class="mx-wrap" data-mx data-lock="dot" data-mx-fontcss="${esc(FONT_CSS)}"><svg class="mx-defs" width="0" height="0" aria-hidden="true" focusable="false"><defs>${today.defs}</defs><g id="art-pair-today">${today.body}</g></svg>@@@ -518,8 +551,9 @@ function explorePage() {<header class="mx-hero"><p class="mx-eye">The OpenVibe mark · help us choose</p><h1>The Loop or the Pair?</h1>--<p class="mx-lede">We’re choosing OpenVibe’s mark, and it’s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you’d pick, and the takes you like best. Tap <b>Pick</b> on anything you like, then send us your picks.</p>++<p class="mx-lede">We’re choosing OpenVibe’s mark, and it’s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you’d pick, and the takes you like best. Tap the ones you like: they are saved and counted in the tally below.</p></header>++${tallyBlock(tally)}<nav class="mx-choose" role="tablist" aria-label="What to choose"><p class="mx-q" role="presentation"><span class="mx-qn">1</span> The mark: <b>the Loop or the Pair?</b></p><div class="mx-vs" role="presentation">@@@ -530,6 +564,14 @@ ${tab('pair', 'The Pair', 'An O and a V side by side, reading as the letters O V<p class="mx-q" role="presentation"><span class="mx-qn">2</span> Separately: <b>the menu button’s look</b> <small>it stays beside the name whichever mark wins</small></p>${tab('menu', 'The menu button', 'Opens search and every site. Fifty looks for it, each one moves when you hover or tap it.', `<span class="mb-btn mb-tabbtn">${menuSvg(m1[2], m1[3], m1[4], 30)}</span>`, 'M01–M50', false, true)}</nav>++<nav class="mx-steps" data-mx-steps aria-label="Jump between the choices" hidden>++<span class="mx-steps-k">Mark</span>++<a href="#loop" data-mx-go="loop" class="mx-step">${miniArt('L02', 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>++<span class="mx-steps-sep" aria-hidden="true"></span>++<a href="#menu" data-mx-go="menu" class="mx-step">${miniArt('M01', 22, 's')}<span>Menu button</span><i data-mx-count="M"></i></a>++<button type="button" class="mx-step mx-share" data-mx-share aria-label="Copy your picks and a link to them"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V3.5M7.5 8 12 3.5 16.5 8M5 13v6a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6"/></svg><span>Share</span></button>++</nav><div class="mx-tools" data-mx-tools hidden><div class="mx-tool"><span class="mx-tool-k">Name</span><div class="mx-chips" role="group" aria-label="The site's name beside the mark">${SITES.map((s) => chip('data-mx-set-site', s, esc(s))).join('')}<input class="mx-own" type="text" maxlength="14" placeholder="Type one" aria-label="Type a site name" data-mx-own spellcheck="false" autocomplete="off"></div></div><div class="mx-tool"><span class="mx-tool-k">Font</span><div class="mx-chips" role="group" aria-label="The font of the name" data-mx-fonts>${FONTS.map(([k, label, stack, note]) => chip('data-mx-font', k, `${esc(label)}${note ? ` <small>${note}</small>` : ''}`, ` data-face="${esc(stack)}" style="font-family:${esc(stack)}"`)).join('')}</div></div>@@@ -540,8 +582,7 @@ ${panel('loop', 'L', LOOP_ROWS, 'The Loop: fifty takes', false)}${panel('pair', 'P', PAIR_ROWS, 'The Pair: fifty takes', true)}${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}<p class="mx-fine">Every mark here is centred and level. None of them replaces the mark the sites use until one is chosen: <a href="/brand">the mark today</a>.</p>--</div>--<div class="mx-bar" data-mx-bar hidden><span class="mx-bar-thumbs" data-mx-thumbs aria-hidden="true"></span><span class="mx-bar-t">Your picks: <b data-mx-picks></b></span><button type="button" class="mx-btn" data-mx-copy>Copy</button><button type="button" class="mx-btn" data-mx-link>Copy link</button><button type="button" class="mx-btn mx-btn-quiet" data-mx-clear>Clear</button></div>`;++</div>`;return layout({title: 'The Loop or the Pair? Help choose the OpenVibe mark',description: 'The OpenVibe mark is between two: the Loop and the Pair, fifty takes on each. Plus fifty looks for the menu button beside the name.',@@@ -557,4 +598,4 @@ ${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', 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, LOOPS, PAIRS, MENUS, LOOP_ROWS, PAIR_ROWS, MENU_ROWS, loopArt, pairArt };++module.exports = { explorePage, tallyBlock, LOOPS, PAIRS, MENUS, LOOP_ROWS, PAIR_ROWS, MENU_ROWS, loopArt, pairArt };@@@ -30,7 +30,7 @@ const explore = require('../server/views/marks-explore');for (const k of 'LPM') assert.strictEqual(codes.filter((c) => c[0] === k).length, 50, k);assert.ok(codes.every((c) => r.text.includes(`data-pick="${c}"`)), 'each has its Pick button');for (const tab of ['loop', 'pair', 'menu']) {-- assert.match(r.text, new RegExp(`<a class="mx-tab(?: mx-tab-side)?" role="tab" id="mx-tab-${tab}" href="#${tab}" aria-controls="${tab}"[^>]*><span class="mx-tab-art"><(svg|span)`), `the ${tab} tab shows an example`);++ assert.match(r.text, new RegExp(`<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');@@@ -38,7 +38,8 @@ const explore = require('../server/views/marks-explore');assert.match(r.text, /<span class="mx-qn">1<\/span> The mark: <b>the Loop or the Pair\?<\/b>[\s\S]*?<span class="mx-or" aria-hidden="true">or<\/span>[\s\S]*?<span class="mx-qn">2<\/span> Separately: <b>the menu button’s look<\/b>/, 'two questions: the mark, then separately the menu button');assert.ok((r.text.match(/<section class="mx-row">/g) || []).length >= 15, 'sorted into rows by idea');assert.match(r.text, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');-- assert.match(r.text, /data-mx-bar hidden/, 'the picks bar waits for a pick');++ assert.match(r.text, /data-mx-mine hidden/, 'your picks wait for a pick');++ assert.ok(!/data-mx-bar/.test(r.text), 'no bar over the marks: a card is picked where it is');assert.match(r.text, /data-mx-tools hidden/, 'the name and font bar waits for the script');});@@@ -54,6 +55,39 @@ const explore = require('../server/views/marks-explore');assert.ok(!/class="mx-s[^"]*"[^>]*class=/.test(r.text), 'no element carries two class attributes');});++ await check('picks are saved and counted: one ballot per person or browser, from this site only', async () => {++ const own = t.config.baseUrl.replace(/\/$/, '');++ const empty = (await t.get('/api/v1/brand/picks')).json();++ assert.deepStrictEqual([empty.tally.people, empty.mine], [0, []]);++ const guest = await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L03', 'P01', 'P17', 'M07', 'junk', 'L03'] }, origin: own });++ assert.strictEqual(guest.status, 200, guest.text);++ assert.deepStrictEqual(guest.json().mine, ['L03', 'M07', 'P01', 'P17'], 'only real codes, once each');++ const key = (guest.headers.get('set-cookie') || '').match(/ov_mx_voter=([^;]+)/);++ assert.ok(key && /HttpOnly/i.test(guest.headers.get('set-cookie')), 'a guest gets an httpOnly key');++ assert.ok(!(await t.dbDump()).includes(key[1]), 'the key itself is never stored');++ const again = await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L03'] }, origin: own, cookie: `ov_mx_voter=${key[1]}` });++ assert.deepStrictEqual(again.json().mine, ['L03'], 'the same browser replaces its picks');++ 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 });++ assert.deepStrictEqual(signed.tally.top.P.map((x) => x.code), ['P01', 'P02']);++ assert.deepStrictEqual(signed.tally.top.M, [{ code: 'M07', n: 1 }]);++ assert.deepStrictEqual((await t.get('/api/v1/brand/picks', { as: ana })).json().mine, ['M07', 'P01', 'P02'], 'a person sees their own picks');++ 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]*?2 people have picked[\s\S]*?data-code="P01"/, 'the tally is at the top of the page');++ });++++ 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']) 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, 3, 'each choice ends with where to go next');++ assert.strictEqual((r.text.match(/data-mx-cycle="/g) || []).length, 3, 'each tab example cycles through its fifty');++ });++await check('the files at /brand/:file still answer', async () => {const r = await t.get('/brand/openvibe-mark.svg');assert.strictEqual(r.status, 200);
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- /brand/explore picks (the owner, 2026-11-01: "when they choose options it'll save to the server, and that shows at
+-- the top of the page what people have picked"). One row per voter and picked code (L01–L50 the Loop, P01–P50 the
+-- Pair, M01–M50 the menu button). A voter is a signed-in subject (usr_…), or 'anon:' and a hash of a random key kept
+-- in this browser's cookie; the key itself is never stored.
+
+CREATE TABLE brand_picks (
+ voter text COLLATE "C" NOT NULL,
+ code text COLLATE "C" NOT NULL CHECK (code ~ '^[LPM](0[1-9]|[1-4][0-9]|50)$'),
+ signed_in boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (voter, code)
+);
+CREATE INDEX brand_picks_code ON brand_picks (code);
@@ -5,13 +5,39 @@
@@ -5,13 +5,39 @@
.mx-wrap{--mx-face:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;--mx-lockface:'OV Brand',Nunito,sans-serif;--mx-card:var(--bg-secondary,#101828);--mx-line:var(--border,#1f2d47);--mx-muted:var(--text-muted,#7386a3);max-width:1180px;margin:0 auto;padding:clamp(14px,3vw,36px) clamp(12px,3vw,32px) 120px;color:var(--text-primary,#e8edf3)}
.mx-wrap{--mx-face:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;--mx-lockface:'OV Brand',Nunito,sans-serif;--mx-card:var(--bg-secondary,#101828);--mx-line:var(--border,#1f2d47);--mx-muted:var(--text-muted,#7386a3);max-width:1180px;margin:0 auto;padding:clamp(14px,3vw,36px) clamp(12px,3vw,32px) 120px;color:var(--text-primary,#e8edf3)}
.mx-wrap svg{display:block;overflow:visible}
.mx-wrap svg{display:block;overflow:visible}
.mx-defs{position:absolute;width:0;height:0;overflow:hidden}
.mx-defs{position:absolute;width:0;height:0;overflow:hidden}
-.mx-wrap [hidden],.mx-bar[hidden]{display:none!important}
+.mx-wrap [hidden]{display:none!important}
.mx-hero{display:grid;gap:8px;max-width:860px;margin:0 0 clamp(14px,2.4vw,24px)}
.mx-hero{display:grid;gap:8px;max-width:860px;margin:0 0 clamp(14px,2.4vw,24px)}
.mx-eye{margin:0;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent,#4f7cff)}
.mx-eye{margin:0;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent,#4f7cff)}
.mx-hero h1{margin:0;font-family:var(--mx-face);font-weight:900;font-size:clamp(26px,4.2vw,46px);line-height:1.05;letter-spacing:-.02em;text-wrap:balance}
.mx-hero h1{margin:0;font-family:var(--mx-face);font-weight:900;font-size:clamp(26px,4.2vw,46px);line-height:1.05;letter-spacing:-.02em;text-wrap:balance}
.mx-lede,.mx-how{margin:0;max-width:72ch;font-size:clamp(14.5px,1.6vw,16px);line-height:1.55;color:var(--text-secondary,#b4bfcc)}
.mx-lede,.mx-how{margin:0;max-width:72ch;font-size:clamp(14.5px,1.6vw,16px);line-height:1.55;color:var(--text-secondary,#b4bfcc)}
.mx-lede b,.mx-how b{color:var(--text-primary,#e8edf3)}
.mx-lede b,.mx-how b{color:var(--text-primary,#e8edf3)}
+/* What people picked so far (marks.js redraws it after each save): the Loop against the Pair, and the most picked. */
+.mx-tally{display:grid;gap:8px;margin:0 0 12px;padding:10px 12px;border-radius:16px;border:1px solid color-mix(in srgb,#8b5cf6 40%,var(--mx-line));background:linear-gradient(135deg,color-mix(in srgb,#4f7cff 10%,var(--mx-card)),color-mix(in srgb,#8b5cf6 12%,var(--mx-card)))}
+.mx-tally-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;font-size:14px;color:var(--text-secondary,#b4bfcc)}
+.mx-tally-h b{font-family:var(--mx-face);font-weight:900;font-size:17px;color:var(--text-primary,#e8edf3)}
+.mx-tally-lead{margin-left:auto;font-weight:700;color:#c4b5fd}
+.mx-lean{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px}
+.mx-lean-end{display:flex;align-items:center;gap:6px;font-weight:700;font-size:14px}
+.mx-lean-end b{font-family:var(--mx-face);font-weight:900;font-size:16px;min-width:3ch}
+.mx-lean-r b{text-align:right}
+.mx-lean-bar{position:relative;height:10px;border-radius:999px;background:#8b5cf6;overflow:hidden}
+.mx-lean-bar i{position:absolute;inset:0 auto 0 0;width:var(--l,50%);background:#4f7cff;border-right:2px solid var(--mx-card);transition:width .6s cubic-bezier(.2,.8,.2,1)}
+.mx-tops{display:flex;align-items:center;gap:8px;min-width:0}
+.mx-tops-k{flex:none;width:92px;font-size:11px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--mx-muted)}
+.mx-tops-list{display:flex;gap:6px;min-width:0;overflow-x:auto;scrollbar-width:none;padding:1px}
+.mx-tops-list::-webkit-scrollbar{display:none}
+.mx-tops-list em{font-size:13px;color:var(--mx-muted)}
+.mx-top{flex:none;display:inline-flex;align-items:center;gap:5px;padding:3px 9px 3px 4px;border-radius:999px;background:#0d1322;color:#e8edf3;font-size:12.5px}
+.mx-top b{font-family:var(--mx-face);font-weight:900}
+.mx-top i{font-style:normal;font-weight:800;color:#c4b5fd}
+.mx-top svg,.mx-lean-end svg{display:block;flex:none}
+.mx-top.is-new{animation:mxTopIn .5s cubic-bezier(.2,.9,.25,1.2) both}
+@keyframes mxTopIn{from{opacity:0;transform:scale(.6)}to{opacity:1;transform:none}}
+.mx-mini-m{width:26px;height:26px;border-radius:50%;--mb-c:#c9d3e0}
+.mx-tally.is-empty .mx-lean-bar{opacity:.4}
+@media (max-width:640px){.mx-tops-k{width:auto}.mx-tops{flex-direction:column;align-items:flex-start;gap:4px}.mx-tops-list{max-width:100%}}
+
/* The choice: 1, the mark (the Loop or the Pair, side by side); 2, separately, the menu button's look. */
/* The choice: 1, the mark (the Loop or the Pair, side by side); 2, separately, the menu button's look. */
.mx-choose{display:grid;gap:8px;margin:0 0 12px}
.mx-choose{display:grid;gap:8px;margin:0 0 12px}
.mx-q{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 8px;margin:6px 0 0;font-size:15px;color:var(--text-secondary,#b4bfcc)}
.mx-q{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 8px;margin:6px 0 0;font-size:15px;color:var(--text-secondary,#b4bfcc)}
@@ -23,8 +49,16 @@
@@ -23,8 +49,16 @@
.mx-tab{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:16px;border:1px solid var(--mx-line);background:var(--mx-card);color:inherit;text-decoration:none;transition:border-color .2s,background .2s}
.mx-tab{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:16px;border:1px solid var(--mx-line);background:var(--mx-card);color:inherit;text-decoration:none;transition:border-color .2s,background .2s}
.mx-tab:hover{border-color:color-mix(in srgb,#8b5cf6 55%,var(--mx-line))}
.mx-tab:hover{border-color:color-mix(in srgb,#8b5cf6 55%,var(--mx-line))}
.mx-tab[aria-selected=true]{border-color:#8b5cf6;background:color-mix(in srgb,#8b5cf6 12%,var(--mx-card));box-shadow:0 0 0 1px #8b5cf6 inset}
.mx-tab[aria-selected=true]{border-color:#8b5cf6;background:color-mix(in srgb,#8b5cf6 12%,var(--mx-card));box-shadow:0 0 0 1px #8b5cf6 inset}
-.mx-tab-art{flex:none;display:grid;place-items:center;width:62px;height:62px;border-radius:14px;background:#0d1322;color:#e8edf3}
+.mx-tab-art{position:relative;flex:none;display:grid;place-items:center;width:62px;height:62px;border-radius:14px;background:#0d1322;color:#e8edf3;overflow:hidden}
.mx-tab-art svg{width:46px;height:46px}
.mx-tab-art svg{width:46px;height:46px}
+/* The tab's example walks through all fifty (marks.js): each take rises in as the last one leaves, its number below. */
+.mx-ta{position:absolute;inset:0;display:grid;place-items:center}
+.mx-ta.is-in{animation:mxTaIn .62s cubic-bezier(.2,.9,.25,1.15) both}
+.mx-ta.is-out{animation:mxTaOut .42s cubic-bezier(.4,0,.6,1) both}
+@keyframes mxTaIn{from{opacity:0;transform:translateY(55%) scale(.6)}to{opacity:1;transform:none}}
+@keyframes mxTaOut{from{opacity:1;transform:none}to{opacity:0;transform:translateY(-55%) scale(.6)}}
+.mx-tab-code{position:absolute;right:3px;bottom:3px;padding:0 4px;border-radius:5px;background:#8b5cf6;color:#fff;font:800 9.5px/14px Inter,system-ui,sans-serif;font-style:normal;letter-spacing:.02em}
+.mx-tab-code:empty{display:none}
.mx-tab-t{display:grid;gap:2px;min-width:0}
.mx-tab-t{display:grid;gap:2px;min-width:0}
.mx-tab-t b{font-family:var(--mx-face);font-weight:900;font-size:19px;line-height:1.15}
.mx-tab-t b{font-family:var(--mx-face);font-weight:900;font-size:19px;line-height:1.15}
.mx-tab-t small{font-size:13px;line-height:1.35;color:var(--text-secondary,#b4bfcc)}
.mx-tab-t small{font-size:13px;line-height:1.35;color:var(--text-secondary,#b4bfcc)}
@@ -34,6 +68,27 @@
@@ -34,6 +68,27 @@
.mx-tab-side .mx-tab-art{width:46px;height:46px;border-radius:12px}
.mx-tab-side .mx-tab-art{width:46px;height:46px;border-radius:12px}
.mx-tab-side .mx-tab-t b{font-size:16px}
.mx-tab-side .mx-tab-t b{font-size:16px}
+/* The steps bar: once the big tabs have scrolled away it waits under the navbar, so switching between the mark and the
+ menu button never means scrolling back up (the owner, 11-01). Each step shows how many you picked there. */
+.mx-steps{position:fixed;z-index:55;left:50%;top:calc(var(--ovnav-real-h,56px) + 8px);display:flex;align-items:center;gap:4px;max-width:calc(100vw - 16px);padding:4px;border-radius:999px;border:1px solid color-mix(in srgb,#8b5cf6 45%,var(--mx-line));background:color-mix(in srgb,var(--mx-card) 92%,transparent);backdrop-filter:blur(12px);box-shadow:0 10px 30px rgba(0,0,0,.35);transform:translate(-50%,-12px);opacity:0;pointer-events:none;transition:opacity .25s,transform .35s cubic-bezier(.2,.9,.25,1.1)}
+.mx-steps.is-on{opacity:1;transform:translate(-50%,0);pointer-events:auto}
+.mx-steps-k{padding:0 4px 0 8px;font-size:10.5px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--mx-muted)}
+.mx-steps-sep{width:1px;height:22px;margin:0 2px;background:var(--mx-line)}
+.mx-step{display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:3px 11px 3px 6px;border-radius:999px;color:inherit;text-decoration:none;font-weight:750;font-size:13.5px;white-space:nowrap}
+.mx-step svg,.mx-step .mb-btn{flex:none}
+.mx-step .mb-btn{width:22px;height:22px}
+.mx-step[aria-current=true]{background:#8b5cf6;color:#fff}
+.mx-step[aria-current=true] .mb-btn{--mb-c:#fff;--mb-a:#fff}
+.mx-step i{font-style:normal;font-size:11px;font-weight:800;padding:0 5px;border-radius:999px;background:#ffffff26}
+.mx-step i:empty{display:none}
+/* The end of each choice: where to go next. */
+.mx-next{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:22px}
+.mx-next a{display:grid;gap:2px;padding:14px 16px;border-radius:16px;border:1px solid var(--mx-line);background:var(--mx-card);color:inherit;text-decoration:none}
+.mx-next a:last-child{border-color:#8b5cf6;background:color-mix(in srgb,#8b5cf6 14%,var(--mx-card))}
+.mx-next b{font-family:var(--mx-face);font-weight:900;font-size:17px}
+.mx-next small{font-size:13px;color:var(--text-secondary,#b4bfcc)}
+@media (max-width:640px){.mx-steps-k{display:none}.mx-step{padding:3px 9px 3px 5px;font-size:12.5px;gap:5px}.mx-next{grid-template-columns:1fr}}
+
/* The bar: the name, its font and style, the view. Sticky on wide screens. */
/* The bar: the name, its font and style, the view. Sticky on wide screens. */
.mx-tools{display:flex;flex-wrap:wrap;gap:6px 18px;align-items:center;margin:0 0 6px;padding:8px 10px;border-radius:14px;border:1px solid var(--mx-line);background:color-mix(in srgb,var(--mx-card) 92%,transparent);backdrop-filter:blur(10px);z-index:20}
.mx-tools{display:flex;flex-wrap:wrap;gap:6px 18px;align-items:center;margin:0 0 6px;padding:8px 10px;border-radius:14px;border:1px solid var(--mx-line);background:color-mix(in srgb,var(--mx-card) 92%,transparent);backdrop-filter:blur(10px);z-index:20}
.mx-tool{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%}
.mx-tool{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%}
@@ -51,6 +106,7 @@
@@ -51,6 +106,7 @@
/* Rows of cards. */
/* Rows of cards. */
.mx-row{margin-top:18px}
.mx-row{margin-top:18px}
.mx-wrap .mx-row h3:not(.ovk-card-title){display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;margin:0;font-family:var(--mx-face);font-weight:900;font-size:19px;text-transform:none;letter-spacing:-.01em;color:var(--text-primary,#e8edf3)}
.mx-wrap .mx-row h3:not(.ovk-card-title){display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;margin:0;font-family:var(--mx-face);font-weight:900;font-size:19px;text-transform:none;letter-spacing:-.01em;color:var(--text-primary,#e8edf3)}
+.mx-swipe{display:none;margin-left:auto;font-family:Inter,system-ui,sans-serif;font-size:12px;font-weight:700;color:#a78bfa}
.mx-row h3 small{font-family:Inter,system-ui,sans-serif;font-weight:600;font-size:12.5px;color:var(--mx-muted)}
.mx-row h3 small{font-family:Inter,system-ui,sans-serif;font-weight:600;font-size:12.5px;color:var(--mx-muted)}
.mx-row-line{margin:2px 0 8px;font-size:13.5px;color:var(--text-secondary,#b4bfcc)}
.mx-row-line{margin:2px 0 8px;font-size:13.5px;color:var(--text-secondary,#b4bfcc)}
.mx-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}
.mx-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}
@@ -76,23 +132,41 @@
@@ -76,23 +132,41 @@
.mx-name{margin:0 2px;font-size:14px;line-height:1.25}
.mx-name{margin:0 2px;font-size:14px;line-height:1.25}
.mx-name b{font-family:var(--mx-face);font-weight:900;color:var(--accent,#4f7cff);margin-right:2px}
.mx-name b{font-family:var(--mx-face);font-weight:900;color:var(--accent,#4f7cff);margin-right:2px}
.mx-line{margin:0 2px;font-size:12.5px;line-height:1.35;color:var(--text-secondary,#b4bfcc)}
.mx-line{margin:0 2px;font-size:12.5px;line-height:1.35;color:var(--text-secondary,#b4bfcc)}
-.mx-pick{position:relative;justify-self:start;padding:5px 12px;border-radius:999px;border:1px solid var(--mx-line);background:transparent;color:inherit;font:inherit;font-size:13px;font-weight:700;cursor:pointer;min-height:34px}
-.mx-pick:hover{border-color:#8b5cf6}
-.mx-pick::after{content:"";position:absolute;inset:-6px -4px}
-.mx-pick[aria-pressed=true]{background:#8b5cf6;border-color:#8b5cf6;color:#fff}
.mx-fine{margin-top:28px;font-size:14px;color:var(--mx-muted)}
.mx-fine{margin-top:28px;font-size:14px;color:var(--mx-muted)}
.mx-fine a{color:var(--accent,#4f7cff)}
.mx-fine a{color:var(--accent,#4f7cff)}
+/* Picking: tap a card (or its corner toggle). A picked card glows and wears a tick; nothing covers the marks. */
+.mx-card{cursor:pointer;-webkit-tap-highlight-color:transparent}
+.mx-pick{position:absolute;z-index:2;top:13px;right:13px;display:grid;place-items:center;width:30px;height:30px;padding:0;border-radius:50%;border:1.5px solid #ffffff4d;background:#0d1322b3;color:#e8edf3;cursor:pointer;backdrop-filter:blur(4px);transition:background .2s,border-color .2s,transform .25s cubic-bezier(.2,1.4,.3,1)}
+.mx-pick::before{content:"+";font:700 18px/1 Inter,system-ui,sans-serif}
+.mx-pick::after{content:"";position:absolute;inset:-7px}
+.mx-card:hover .mx-pick{border-color:#a78bfa}
+.mx-pick[aria-pressed=true]{background:#8b5cf6;border-color:#c4b5fd;transform:scale(1.08)}
+.mx-pick[aria-pressed=true]::before{content:"\2713";font-size:15px}
+.mx-pick:focus-visible{outline:2px solid #c4b5fd;outline-offset:2px}
+.mx-card.is-picked{border-color:#8b5cf6;background:color-mix(in srgb,#8b5cf6 13%,var(--mx-card));box-shadow:0 0 0 1px #8b5cf6,0 12px 30px color-mix(in srgb,#8b5cf6 24%,transparent)}
+.mx-card.is-picked .mx-stage{box-shadow:inset 0 0 0 1px color-mix(in srgb,#8b5cf6 55%,transparent)}
+.mx-card.just-picked{animation:mxPicked .45s cubic-bezier(.2,1.4,.3,1)}
+@keyframes mxPicked{0%,100%{scale:1}40%{scale:.96}75%{scale:1.02}}
+/* Your picks, in the tally at the top: copy a link to them or clear them. */
+.mx-mine{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;min-width:0}
+.mx-mine b{font-family:var(--mx-face);font-weight:900;font-size:14px;overflow-wrap:anywhere}
+.mx-btn{padding:5px 12px;border-radius:999px;border:0;background:#8b5cf6;color:#fff;font:inherit;font-size:12.5px;font-weight:700;cursor:pointer;min-height:32px}
+.mx-btn-quiet{background:transparent;color:inherit;border:1px solid var(--mx-line)}
+.mx-saved{font-style:normal;font-size:12px;font-weight:700;color:#86efac;opacity:0;transition:opacity .3s}
+.mx-saved.is-on{opacity:1}
+.mx-share{border:0;background:transparent;font:inherit;font-weight:750;font-size:13.5px;cursor:pointer;color:inherit}
+.mx-share.is-done span::after{content:" \2713"}
+@media (max-width:640px){.mx-share span{display:none}.mx-pick{top:10px;right:10px}}
+
/* Sheet: all fifty at once, just the marks; tap one to pick it. */
/* Sheet: all fifty at once, just the marks; tap one to pick it. */
.mx-wrap[data-density=sheet] .mx-grid{grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:8px}
.mx-wrap[data-density=sheet] .mx-grid{grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:8px}
-.mx-wrap[data-density=sheet] .mx-card{padding:5px 5px 7px;gap:4px;border-radius:13px;cursor:pointer}
+.mx-wrap[data-density=sheet] .mx-card{padding:5px 5px 7px;gap:4px;border-radius:13px}
+.mx-wrap[data-density=sheet] .mx-pick{top:9px;right:9px;width:24px;height:24px}
+.mx-wrap[data-density=sheet] .mx-pick::before{font-size:15px}
.mx-wrap[data-density=sheet] :is(.mx-lock,.mx-sizes,.mx-line,.mb-bars,.mx-row-line){display:none}
.mx-wrap[data-density=sheet] :is(.mx-lock,.mx-sizes,.mx-line,.mb-bars,.mx-row-line){display:none}
.mx-wrap[data-density=sheet] .mx-stage{aspect-ratio:1}
.mx-wrap[data-density=sheet] .mx-stage{aspect-ratio:1}
.mx-wrap[data-density=sheet] .mx-name{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mx-wrap[data-density=sheet] .mx-name{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.mx-wrap[data-density=sheet] .mx-pick{position:absolute;top:8px;right:8px;display:grid;place-items:center;width:24px;height:24px;min-height:0;padding:0;border-radius:50%;font-size:0;background:#0d1322cc;color:#e8edf3;border-color:#ffffff38}
-.mx-wrap[data-density=sheet] .mx-pick::before{content:"+";font-size:16px;font-weight:700;line-height:1}
-.mx-wrap[data-density=sheet] .mx-pick[aria-pressed=true]{background:#8b5cf6;border-color:#8b5cf6}
-.mx-wrap[data-density=sheet] .mx-pick[aria-pressed=true]::before{content:"\2713";font-size:13px}
.mx-wrap[data-density=sheet] .mx-row{margin-top:12px}
.mx-wrap[data-density=sheet] .mx-row{margin-top:12px}
/* ── Motion ─────────────────────────────────────────────────────────────────────────────────────────────────────
/* ── Motion ─────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -121,8 +195,9 @@
@@ -121,8 +195,9 @@
@keyframes mxNod{0%,60%,100%{transform:translateY(0)}72%{transform:translateY(3px)}84%{transform:translateY(-1px)}}
@keyframes mxNod{0%,60%,100%{transform:translateY(0)}72%{transform:translateY(3px)}84%{transform:translateY(-1px)}}
@keyframes mxDot{0%,100%{transform:scale(1)}50%{transform:scale(1.28)}}
@keyframes mxDot{0%,100%{transform:scale(1)}50%{transform:scale(1.28)}}
@keyframes mxPulse{0%,100%{opacity:.4}50%{opacity:.95}}
@keyframes mxPulse{0%,100%{opacity:.4}50%{opacity:.95}}
+@keyframes mxPeek{0%,100%{transform:none}34%{transform:translateX(-68px)}58%{transform:translateX(6px)}76%{transform:translateX(-16px)}}
@media (prefers-reduced-motion:reduce){.mx-wrap.mx-live .mx-card,.mx-wrap.mx-live .mx-stage :is(.mx-s,.mx-f){transition:none!important}.mx-wrap.mx-live .mx-card:not(.is-in){opacity:1;transform:none}.mx-wrap.mx-live .mx-card:not(.is-in) .mx-stage :is(.mx-s,.mx-f){stroke-dashoffset:0;opacity:1}}
@media (prefers-reduced-motion:reduce){.mx-wrap.mx-live .mx-card,.mx-wrap.mx-live .mx-stage :is(.mx-s,.mx-f){transition:none!important}.mx-wrap.mx-live .mx-card:not(.is-in){opacity:1;transform:none}.mx-wrap.mx-live .mx-card:not(.is-in) .mx-stage :is(.mx-s,.mx-f){stroke-dashoffset:0;opacity:1}}
-html[data-ov-motion=reduced] .mx-wrap .mx-card,html[data-ov-motion=reduced] .mx-wrap .mx-card *{animation:none!important;transition:none!important}
+html[data-ov-motion=reduced] .mx-wrap .mx-card,html[data-ov-motion=reduced] .mx-wrap .mx-card *,html[data-ov-motion=reduced] .mx-ta{animation:none!important;transition:none!important}
html[data-ov-motion=reduced] .mx-wrap .mx-card:not(.is-in){opacity:1!important;transform:none!important}
html[data-ov-motion=reduced] .mx-wrap .mx-card:not(.is-in){opacity:1!important;transform:none!important}
/* ── The menu buttons ───────────────────────────────────────────────────────────────────────────────────────────
/* ── The menu buttons ───────────────────────────────────────────────────────────────────────────────────────────
@@ -199,16 +274,6 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
@@ -199,16 +274,6 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
.mb-light .mb-btn{--mb-c:#55627a;--mb-hc:#0f1419;--mb-bg:#f4f6fb;--mb-a:#3b6cff}
.mb-light .mb-btn{--mb-c:#55627a;--mb-hc:#0f1419;--mb-bg:#f4f6fb;--mb-a:#3b6cff}
.mb-light .mx-sep,.mb-light .mx-site{background:linear-gradient(90deg,#3b6cff,#7c3aed);-webkit-background-clip:text;background-clip:text}
.mb-light .mx-sep,.mb-light .mx-site{background:linear-gradient(90deg,#3b6cff,#7c3aed);-webkit-background-clip:text;background-clip:text}
-/* The picks: a bar at the bottom while there are any, with the picked marks small. */
-.mx-bar{position:fixed;left:50%;bottom:calc(14px + env(safe-area-inset-bottom,0px) + var(--ovnav-appbar-h,0px));transform:translateX(-50%);z-index:60;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px;max-width:calc(100vw - 24px);padding:8px 10px 8px 12px;border-radius:18px;border:1px solid color-mix(in srgb,#8b5cf6 55%,var(--border,#1f2d47));background:color-mix(in srgb,var(--bg-secondary,#101828) 94%,transparent);backdrop-filter:blur(10px);box-shadow:0 16px 40px rgba(0,0,0,.35);font-size:14px}
-.mx-bar-thumbs{display:flex;gap:3px;max-width:42vw;overflow-x:auto;scrollbar-width:none}
-.mx-bar-thumbs>*{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:8px;background:#0d1322;color:#e8edf3}
-.mx-bar-thumbs svg{width:24px;height:24px}
-.mx-bar-thumbs:empty{display:none}
-.mx-bar b{font-family:var(--mx-face);font-weight:900;overflow-wrap:anywhere}
-.mx-btn{padding:6px 12px;border-radius:999px;border:0;background:#8b5cf6;color:#fff;font:inherit;font-size:13px;font-weight:700;cursor:pointer;min-height:34px}
-.mx-btn-quiet{background:transparent;color:inherit;border:1px solid var(--border,#1f2d47)}
-
/* Widths. A phone gets sideways rails: a row is one swipe, not a page of cards. */
/* Widths. A phone gets sideways rails: a row is one swipe, not a page of cards. */
@media (max-width:1100px){.mx-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media (max-width:1100px){.mx-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media (max-width:860px){.mx-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.mx-vs .mx-tab-t small{display:none}}
@media (max-width:860px){.mx-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.mx-vs .mx-tab-t small{display:none}}
@@ -228,8 +293,17 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
@@ -228,8 +293,17 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
.mx-row{margin-top:14px}
.mx-row{margin-top:14px}
.mx-wrap .mx-row h3:not(.ovk-card-title){font-size:17px}
.mx-wrap .mx-row h3:not(.ovk-card-title){font-size:17px}
.mx-row-line{margin-bottom:6px;font-size:12.5px}
.mx-row-line{margin-bottom:6px;font-size:12.5px}
- .mx-wrap:not([data-density=sheet]) .mx-grid{display:grid;grid-auto-flow:column;grid-auto-columns:46%;grid-template-columns:none;gap:8px;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scroll-padding-left:2px;padding:2px 2px 6px;margin:0 -2px;scrollbar-width:thin}
+ /* A rail scrolls sideways only: overflow-y hidden, so a vertical swipe that starts on it moves the page (the owner,
+ 11-01: "you can't scroll without going outside the box"). Its right edge fades while there is more, and the first
+ time it comes into view it nudges left and back (marks.js), so it reads as something to swipe. */
+ .mx-wrap:not([data-density=sheet]) .mx-grid{display:grid;grid-auto-flow:column;grid-auto-columns:46%;grid-template-columns:none;gap:8px;overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;touch-action:pan-x pan-y;scroll-snap-type:x proximity;scroll-padding-left:2px;padding:2px 2px 6px;margin:0 -2px;scrollbar-width:none}
+ .mx-wrap:not([data-density=sheet]) .mx-grid::-webkit-scrollbar{display:none}
+ .mx-wrap:not([data-density=sheet]) .mx-grid.has-more{-webkit-mask-image:linear-gradient(90deg,#000 78%,transparent 99%);mask-image:linear-gradient(90deg,#000 78%,transparent 99%)}
+ .mx-wrap:not([data-density=sheet]) .mx-grid.has-more.is-scrolled{-webkit-mask-image:linear-gradient(90deg,transparent,#000 8%,#000 78%,transparent 99%);mask-image:linear-gradient(90deg,transparent,#000 8%,#000 78%,transparent 99%)}
+ .mx-wrap:not([data-density=sheet]) .mx-grid.is-scrolled:not(.has-more){-webkit-mask-image:linear-gradient(90deg,transparent,#000 8%);mask-image:linear-gradient(90deg,transparent,#000 8%)}
.mx-wrap:not([data-density=sheet]) .mx-card{scroll-snap-align:start}
.mx-wrap:not([data-density=sheet]) .mx-card{scroll-snap-align:start}
+ .mx-wrap:not([data-density=sheet]) .mx-swipe{display:inline}
+ .mx-wrap:not([data-density=sheet]) .mx-grid.is-peek>li{animation:mxPeek 1.15s cubic-bezier(.3,.7,.3,1)}
.mx-card{padding:6px 6px 8px;gap:5px;border-radius:14px}
.mx-card{padding:6px 6px 8px;gap:5px;border-radius:14px}
.mx-lock{padding:6px 7px;gap:6px}
.mx-lock{padding:6px 7px;gap:6px}
.mx-lock svg{width:20px;height:20px}
.mx-lock svg{width:20px;height:20px}
@@ -239,7 +313,7 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
@@ -239,7 +313,7 @@ html[data-ov-motion=reduced] .mb-btn *{animation:none!important}
.mx-sizes span{gap:6px;padding:5px 4px}
.mx-sizes span{gap:6px;padding:5px 4px}
.mx-name{font-size:13px}
.mx-name{font-size:13px}
.mx-line{font-size:12px}
.mx-line{font-size:12px}
- .mx-pick{min-height:34px;padding:4px 10px;font-size:12.5px}
.mx-chip,.mx-own{min-height:40px}
.mx-chip,.mx-own{min-height:40px}
.mx-wrap[data-density=sheet] .mx-grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px}
.mx-wrap[data-density=sheet] .mx-grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px}
}
}
+.mx-panel{scroll-margin-top:calc(var(--ovnav-real-h,56px) + 64px)}
@@ -1,99 +1,174 @@
@@ -1,99 +1,174 @@
-/* /brand/explore: the tabs, the name/font/style bar, the arrival and idle motion, the menu buttons, and the picks.
- The picks live in the page's link (#picks=L03,P17,M09), so a link sent to someone opens with them marked, and in
- this browser for next time; the bar's choices are remembered here too. Without this script every tab shows, one
- after another, and every mark is drawn still. */
+/* /brand/explore: the choices (the Loop or the Pair, then the menu button), the name/font/style bar, the motion, the
+ menu buttons, and the picks. The picks live in the page's link (#picks=L03,P17,M09) so a link sent to someone opens
+ with them marked, in this browser for next time, and on the server (PUT /api/v1/brand/picks), which counts them into
+ the tally at the top. Without this script every choice shows one after another and every mark is drawn still. */
(function () {
(function () {
'use strict';
'use strict';
var KEY = 'ov_mark_picks', VIEW = 'ov_mark_view', TAB = 'ov_mark_tab';
var KEY = 'ov_mark_picks', VIEW = 'ov_mark_view', TAB = 'ov_mark_tab';
var CODE = /^[LPM]\d{2}$/;
var CODE = /^[LPM]\d{2}$/;
var TABS = { L: 'loop', P: 'pair', M: 'menu' };
var TABS = { L: 'loop', P: 'pair', M: 'menu' };
+ 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 load(key, fallback) { try { var v = JSON.parse(localStorage.getItem(key) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } }
- function save(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* a private window keeps it for the page only */ } }
+ function store(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* a private window keeps it for the page only */ } }
function all(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
function all(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
+ function calm() {
+ try { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true; } catch (e) { /* */ }
+ return document.documentElement.getAttribute('data-ov-motion') === 'reduced';
+ }
function init() {
function init() {
var root = document.querySelector('[data-mx]');
var root = document.querySelector('[data-mx]');
if (!root || root.getAttribute('data-ready')) return;
if (!root || root.getAttribute('data-ready')) return;
root.setAttribute('data-ready', '1');
root.setAttribute('data-ready', '1');
root.classList.add('mx-live');
root.classList.add('mx-live');
+ var still = function () { return calm() || root.classList.contains('mx-still'); };
+
+ // ── A copy of a card's drawing, with its ids made its own (a hidden tab's gradients do not paint elsewhere) ──
+ var copies = 0;
+ function artOf(code, size) {
+ var card = document.getElementById(code);
+ var src = card && card.querySelector('.mx-stage svg');
+ if (!src) return null;
+ var tag = '-c' + (++copies);
+ 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); });
+ svg.setAttribute('aria-hidden', 'true');
+ if (size) { svg.setAttribute('width', size); svg.setAttribute('height', size); }
+ if (code[0] === 'M') box.className = 'mb-btn';
+ else { box = svg; }
+ return box;
+ }
// ── Picks ──
// ── Picks ──
- var bar = document.querySelector('[data-mx-bar]');
- var out = bar.querySelector('[data-mx-picks]');
- var thumbs = bar.querySelector('[data-mx-thumbs]');
+ var mineBox = root.querySelector('[data-mx-mine]');
var picks = [];
var picks = [];
var fromHash = /(?:^#|&)picks=([A-Z0-9,]+)/.exec(location.hash || '');
var fromHash = /(?:^#|&)picks=([A-Z0-9,]+)/.exec(location.hash || '');
+ var fromHere = !!fromHash;
if (fromHash) picks = fromHash[1].split(',').filter(function (c) { return CODE.test(c); });
if (fromHash) picks = fromHash[1].split(',').filter(function (c) { return CODE.test(c); });
- else picks = (load(KEY, []) || []).filter(function (c) { return CODE.test(c); });
+ else { picks = (load(KEY, []) || []).filter(function (c) { return CODE.test(c); }); fromHere = picks.length > 0; }
function order(list) { return list.slice().sort(); }
function order(list) { return list.slice().sort(); }
function link() { return location.origin + location.pathname + (picks.length ? '#picks=' + order(picks).join(',') : ''); }
function link() { return location.origin + location.pathname + (picks.length ? '#picks=' + order(picks).join(',') : ''); }
- function thumb(code) {
- var card = document.getElementById(code);
- if (!card || !card.querySelector('.mx-stage svg')) return null;
- var box = document.createElement('span');
- if (code[0] === 'M') box.className = 'mb-btn';
- // The large drawing, with its ids made its own: a hidden tab's gradients and masks do not paint elsewhere.
- var stage = card.querySelector('.mx-stage svg');
- var html = stage.outerHTML.replace(/ id="([^"]+)"/g, ' id="$1-t"').replace(/url\(#([^)]+)\)/g, 'url(#$1-t)').replace(/href="#([^"]+)"/g, 'href="#$1-t"');
- box.innerHTML = html;
- var svg = box.firstChild;
- svg.removeAttribute('width'); svg.removeAttribute('height'); svg.removeAttribute('role'); svg.setAttribute('aria-hidden', 'true');
- box.title = code;
- return box;
- }
function render() {
function render() {
all('[data-pick]').forEach(function (b) {
all('[data-pick]').forEach(function (b) {
var c = b.getAttribute('data-pick'), on = picks.indexOf(c) !== -1;
var c = b.getAttribute('data-pick'), on = picks.indexOf(c) !== -1;
b.setAttribute('aria-pressed', on ? 'true' : 'false');
b.setAttribute('aria-pressed', on ? 'true' : 'false');
- b.textContent = (on ? 'Picked ' : 'Pick ') + c;
+ b.setAttribute('aria-label', (on ? 'Picked ' : 'Pick ') + c);
var card = b.closest('.mx-card');
var card = b.closest('.mx-card');
if (card) card.classList.toggle('is-picked', on);
if (card) card.classList.toggle('is-picked', on);
});
});
- bar.hidden = !picks.length;
- out.textContent = order(picks).join(', ');
- while (thumbs.firstChild) thumbs.removeChild(thumbs.firstChild);
- order(picks).forEach(function (c) { var t = thumb(c); if (t) thumbs.appendChild(t); });
- save(KEY, picks);
+ if (mineBox) { mineBox.hidden = !picks.length; mineBox.querySelector('[data-mx-picks]').textContent = order(picks).join(', '); }
+ all('[data-mx-count]').forEach(function (el) { var k = el.getAttribute('data-mx-count'); var n = picks.filter(function (c) { return c[0] === k; }).length; el.textContent = n ? String(n) : ''; });
+ store(KEY, picks);
if (history.replaceState && (picks.length || /picks=/.test(location.hash))) history.replaceState(null, '', picks.length ? '#picks=' + order(picks).join(',') : location.pathname);
if (history.replaceState && (picks.length || /picks=/.test(location.hash))) history.replaceState(null, '', picks.length ? '#picks=' + order(picks).join(',') : location.pathname);
}
}
+ var saveTimer = 0;
+ function toggle(code) {
+ var i = picks.indexOf(code);
+ if (i === -1) picks.push(code); else picks.splice(i, 1);
+ render();
+ var card = document.getElementById(code);
+ if (card && i === -1 && !still()) { card.classList.remove('just-picked'); void card.offsetWidth; card.classList.add('just-picked'); setTimeout(function () { card.classList.remove('just-picked'); }, 500); }
+ clearTimeout(saveTimer); saveTimer = setTimeout(sync, 700);
+ }
+ function say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }
+ function copy(text, btn) {
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { say(btn, 'Copied'); }, function () { window.prompt('Copy this:', text); });
+ else window.prompt('Copy this:', text);
+ }
// "The mark: the Loop (L03, L07), the Pair (P17). The menu button: M09." The two questions stay apart.
// "The mark: the Loop (L03, L07), the Pair (P17). The menu button: M09." The two questions stay apart.
function summary() {
function summary() {
var of = function (k) { return order(picks).filter(function (c) { return c[0] === k; }); };
var of = function (k) { return order(picks).filter(function (c) { return c[0] === k; }); };
- var l = of('L'), p = of('P'), m = of('M'), mark = [];
+ var l = of('L'), p = of('P'), m = of('M'), mark = [], parts = [];
if (l.length) mark.push('the Loop (' + l.join(', ') + ')');
if (l.length) mark.push('the Loop (' + l.join(', ') + ')');
if (p.length) mark.push('the Pair (' + p.join(', ') + ')');
if (p.length) mark.push('the Pair (' + p.join(', ') + ')');
- var parts = [];
if (mark.length) parts.push('The mark: ' + mark.join(', ') + '.');
if (mark.length) parts.push('The mark: ' + mark.join(', ') + '.');
if (m.length) parts.push('The menu button: ' + m.join(', ') + '.');
if (m.length) parts.push('The menu button: ' + m.join(', ') + '.');
return 'My picks. ' + parts.join(' ') + ' ' + link();
return 'My picks. ' + parts.join(' ') + ' ' + link();
}
}
- function toggle(code) { var i = picks.indexOf(code); if (i === -1) picks.push(code); else picks.splice(i, 1); render(); }
- function say(btn, words) { var was = btn.textContent; btn.textContent = words; setTimeout(function () { btn.textContent = was; }, 1400); }
- function copy(text, btn) {
- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { say(btn, 'Copied'); }, function () { window.prompt('Copy this:', text); });
- else window.prompt('Copy this:', text);
+
+ // ── The tally at the top: saved picks, counted (server/brand-picks.js) ──
+ var tally = root.querySelector('[data-mx-tally]');
+ function chip(t) {
+ var el = document.createElement('span');
+ el.className = 'mx-top'; el.setAttribute('data-code', t.code);
+ var art = artOf(t.code, 26);
+ if (art) { if (t.code[0] === 'M') art.classList.add('mx-mini-m'); el.appendChild(art); }
+ var b = document.createElement('b'); b.textContent = t.code; el.appendChild(b);
+ var n = document.createElement('i'); n.textContent = String(t.n); el.appendChild(n);
+ return el;
+ }
+ function fill(list, items) {
+ if (!list) return;
+ var before = {};
+ all('.mx-top', list).forEach(function (el) { before[el.getAttribute('data-code')] = true; });
+ while (list.firstChild) list.removeChild(list.firstChild);
+ if (!items.length) { var em = document.createElement('em'); em.textContent = 'none yet'; list.appendChild(em); return; }
+ items.forEach(function (t) { var c = chip(t); if (!before[t.code]) c.classList.add('is-new'); list.appendChild(c); });
+ }
+ function showTally(t) {
+ if (!tally || !t) return;
+ var loop = t.lean.loop, pair = t.lean.pair, sum = loop + pair, pct = sum ? Math.round((loop / sum) * 100) : 50;
+ tally.classList.toggle('is-empty', !t.people);
+ tally.querySelector('[data-mx-people]').textContent = t.people ? t.people + (t.people === 1 ? ' person has picked' : ' people have picked') : 'Nobody has picked yet. Be the first.';
+ tally.querySelector('[data-mx-lead]').textContent = sum ? (loop === pair ? 'Level so far' : loop > pair ? 'The Loop leads' : 'The Pair leads') : 'No picks yet';
+ tally.querySelector('.mx-lean').style.setProperty('--l', pct + '%');
+ tally.querySelector('[data-mx-loop]').textContent = sum ? pct + '%' : '–';
+ tally.querySelector('[data-mx-pair]').textContent = sum ? (100 - pct) + '%' : '–';
+ var marks = (t.top.L || []).concat(t.top.P || []).sort(function (a, b) { return b.n - a.n || (a.code < b.code ? -1 : 1); }).slice(0, 6);
+ fill(tally.querySelector('[data-mx-tops]'), marks);
+ fill(tally.querySelector('[data-mx-tops-m]'), (t.top.M || []).slice(0, 4));
+ }
+ var savedNote = root.querySelector('[data-mx-saved]');
+ function sync() {
+ if (!window.fetch) return;
+ fetch(API, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ picks: order(picks) }) })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ if (!d) return;
+ showTally(d.tally);
+ if (savedNote && picks.length) { savedNote.classList.add('is-on'); setTimeout(function () { savedNote.classList.remove('is-on'); }, 1600); }
+ })
+ .catch(function () { /* the picks stay in the link and this browser */ });
+ }
+ if (window.fetch) {
+ fetch(API, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
+ if (!d) return;
+ showTally(d.tally);
+ var mine = (d.mine || []).filter(function (c) { return CODE.test(c); });
+ if (!fromHere && mine.length) { picks = mine; render(); }
+ else if (fromHere && order(picks).join() !== order(mine).join() && !fromHash) sync();
+ }).catch(function () { /* */ });
}
}
- // ── Tabs ──
+ // ── The choices: the big tabs, the steps bar, and "where next" at the end of each ──
var tabs = all('[data-mx-tab]', root);
var tabs = all('[data-mx-tab]', root);
- function panelOf(t) { return document.getElementById(t.getAttribute('aria-controls')); }
+ var steps = root.querySelector('[data-mx-steps]');
+ function panelOf(name) { return document.getElementById(name); }
+ var current = 'loop';
function show(name, focus) {
function show(name, focus) {
+ current = name;
tabs.forEach(function (t) {
tabs.forEach(function (t) {
var on = t.getAttribute('data-mx-tab') === name;
var on = t.getAttribute('data-mx-tab') === name;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
t.tabIndex = on ? 0 : -1;
- var p = panelOf(t);
+ var p = panelOf(t.getAttribute('data-mx-tab'));
if (p) p.hidden = !on;
if (p) p.hidden = !on;
if (on && focus) t.focus();
if (on && focus) t.focus();
});
});
- save(TAB, name);
+ all('[data-mx-go]', steps).forEach(function (a) { a.setAttribute('aria-current', a.getAttribute('data-mx-go') === name ? 'true' : 'false'); });
+ store(TAB, name);
+ rails();
+ }
+ function go(name) {
+ show(name);
+ if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + name);
+ var p = panelOf(name);
+ if (p) p.scrollIntoView({ behavior: still() ? 'auto' : 'smooth', block: 'start' });
}
}
tabs.forEach(function (t, i) {
tabs.forEach(function (t, i) {
- t.addEventListener('click', function (e) {
- e.preventDefault();
- show(t.getAttribute('data-mx-tab'));
- if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + t.getAttribute('data-mx-tab'));
- });
+ t.addEventListener('click', function (e) { e.preventDefault(); show(t.getAttribute('data-mx-tab')); if (!picks.length && history.replaceState) history.replaceState(null, '', '#' + t.getAttribute('data-mx-tab')); });
t.addEventListener('keydown', function (e) {
t.addEventListener('keydown', function (e) {
var k = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
var k = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!k) return;
if (!k) return;
@@ -101,10 +176,78 @@
@@ -101,10 +176,78 @@
show(tabs[(i + k + tabs.length) % tabs.length].getAttribute('data-mx-tab'), true);
show(tabs[(i + k + tabs.length) % tabs.length].getAttribute('data-mx-tab'), true);
});
});
});
});
+ root.addEventListener('click', function (e) {
+ var a = e.target.closest('[data-mx-go]');
+ if (!a) return;
+ e.preventDefault();
+ go(a.getAttribute('data-mx-go'));
+ });
+ if (steps) {
+ steps.hidden = false;
+ var choose = root.querySelector('.mx-choose');
+ 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 h = (location.hash || '').slice(1);
var start = /^(loop|pair|menu)$/.test(h) ? h : CODE.test(h) ? TABS[h[0]] : picks.length ? TABS[order(picks)[0][0]] : load(TAB, 'loop');
var start = /^(loop|pair|menu)$/.test(h) ? h : CODE.test(h) ? TABS[h[0]] : picks.length ? TABS[order(picks)[0][0]] : load(TAB, 'loop');
show(/^(loop|pair|menu)$/.test(start) ? start : 'loop');
show(/^(loop|pair|menu)$/.test(start) ? start : 'loop');
+ // ── Each tab's example walks through all fifty, so it is plain there are fifty ──
+ (function cycle() {
+ var boxes = all('[data-mx-cycle]', root);
+ if (!boxes.length) return;
+ var state = boxes.map(function (box, k) {
+ var name = box.getAttribute('data-mx-cycle');
+ return { box: box, codes: all('.mx-card', panelOf(name)).map(function (c) { return c.getAttribute('data-code'); }), i: 0, kind: name, offset: k };
+ });
+ var visible = true, tick = 0;
+ if ('IntersectionObserver' in window) new IntersectionObserver(function (en) { visible = en[0].isIntersecting; }).observe(root.querySelector('.mx-choose'));
+ setInterval(function () {
+ tick += 1;
+ if (!visible || document.hidden || still()) return;
+ state.forEach(function (s) {
+ if ((tick + s.offset) % 3) return; // each moves every third beat, the three a beat apart
+ if (!s.codes.length) return;
+ s.i = (s.i + 7) % s.codes.length; // a stride of 7 visits all fifty, each unlike the last
+ var code = s.codes[s.i];
+ var art = artOf(code, s.kind === 'menu' ? 30 : 46);
+ if (!art) return;
+ var layer = document.createElement('span');
+ layer.className = 'mx-ta is-in';
+ if (s.kind === 'menu') { art.classList.add('mb-tabbtn'); art.classList.add('is-play'); }
+ layer.appendChild(art);
+ all('.mx-ta', s.box).forEach(function (old) { old.classList.remove('is-in'); old.classList.add('is-out'); setTimeout(function () { if (old.parentNode) old.parentNode.removeChild(old); }, 450); });
+ s.box.insertBefore(layer, s.box.querySelector('.mx-tab-code'));
+ var tag = s.box.querySelector('.mx-tab-code');
+ if (tag) tag.textContent = code;
+ });
+ }, 650);
+ })();
+
+ // ── Sideways rails on a phone: fade the edge while there is more, nudge once when first seen ──
+ var nudged = typeof WeakSet === 'function' ? new WeakSet() : null;
+ function edge(g) {
+ var more = g.scrollWidth - g.clientWidth - g.scrollLeft > 6;
+ g.classList.toggle('has-more', more);
+ g.classList.toggle('is-scrolled', g.scrollLeft > 6);
+ }
+ function rails() { all('.mx-grid', root).forEach(edge); }
+ all('.mx-grid', root).forEach(function (g) { g.addEventListener('scroll', function () { edge(g); }, { passive: true }); });
+ window.addEventListener('resize', rails);
+ if ('IntersectionObserver' in window && nudged) {
+ var railIo = new IntersectionObserver(function (entries) {
+ entries.forEach(function (en) {
+ var g = en.target;
+ if (!en.isIntersecting || nudged.has(g)) return;
+ edge(g);
+ if (!g.classList.contains('has-more') || g.scrollLeft > 6 || still() || root.getAttribute('data-density') === 'sheet') return;
+ nudged.add(g);
+ setTimeout(function () { g.classList.add('is-peek'); setTimeout(function () { g.classList.remove('is-peek'); }, 1200); }, 350);
+ });
+ }, { threshold: 0.6 });
+ all('.mx-grid', root).forEach(function (g) { railIo.observe(g); });
+ }
+ rails();
+
// ── The bar: the name, its font and style, the view ──
// ── The bar: the name, its font and style, the view ──
var tools = root.querySelector('[data-mx-tools]');
var tools = root.querySelector('[data-mx-tools]');
var view = load(VIEW, {}) || {};
var view = load(VIEW, {}) || {};
@@ -127,14 +270,14 @@
@@ -127,14 +270,14 @@
view.site = text; view.typed = !!typed;
view.site = text; view.typed = !!typed;
}
}
function setFont(key) {
function setFont(key) {
- var chip = tools.querySelector('[data-mx-font="' + key + '"]') || tools.querySelector('[data-mx-font]');
- if (chip.getAttribute('data-mx-font') !== 'nunito') loadFonts();
- root.style.setProperty('--mx-lockface', chip.getAttribute('data-face'));
- press('data-mx-font', chip.getAttribute('data-mx-font'));
- view.font = chip.getAttribute('data-mx-font');
+ var chipEl = tools.querySelector('[data-mx-font="' + key + '"]') || tools.querySelector('[data-mx-font]');
+ if (chipEl.getAttribute('data-mx-font') !== 'nunito') loadFonts();
+ root.style.setProperty('--mx-lockface', chipEl.getAttribute('data-face'));
+ press('data-mx-font', chipEl.getAttribute('data-mx-font'));
+ view.font = chipEl.getAttribute('data-mx-font');
}
}
function setStyle(key) { key = /^(dot|space|stack)$/.test(key) ? key : 'dot'; root.setAttribute('data-lock', key); press('data-mx-lockstyle', key); view.lock = key; }
function setStyle(key) { key = /^(dot|space|stack)$/.test(key) ? key : 'dot'; root.setAttribute('data-lock', key); press('data-mx-lockstyle', key); view.lock = key; }
- function setDensity(on) { if (on) root.setAttribute('data-density', 'sheet'); else root.removeAttribute('data-density'); press('data-mx-density', on ? 'sheet' : ''); view.sheet = !!on; }
+ function setDensity(on) { if (on) root.setAttribute('data-density', 'sheet'); else root.removeAttribute('data-density'); press('data-mx-density', on ? 'sheet' : ''); view.sheet = !!on; rails(); }
function setStill(on) { root.classList.toggle('mx-still', !!on); press('data-mx-still', on ? '1' : ''); view.still = !!on; }
function setStill(on) { root.classList.toggle('mx-still', !!on); press('data-mx-still', on ? '1' : ''); view.still = !!on; }
setSite(view.site, view.typed);
setSite(view.site, view.typed);
if (view.typed) own.value = view.site || '';
if (view.typed) own.value = view.site || '';
@@ -145,7 +288,7 @@
@@ -145,7 +288,7 @@
tools.hidden = false;
tools.hidden = false;
tools.querySelector('[data-mx-fonts]').addEventListener('pointerenter', loadFonts);
tools.querySelector('[data-mx-fonts]').addEventListener('pointerenter', loadFonts);
tools.querySelector('[data-mx-fonts]').addEventListener('focusin', loadFonts);
tools.querySelector('[data-mx-fonts]').addEventListener('focusin', loadFonts);
- own.addEventListener('input', function () { setSite(own.value || 'Live', !!own.value); save(VIEW, view); });
+ own.addEventListener('input', function () { setSite(own.value || 'Live', !!own.value); store(VIEW, view); });
tools.addEventListener('click', function (e) {
tools.addEventListener('click', function (e) {
var b = e.target.closest('button');
var b = e.target.closest('button');
if (!b) return;
if (!b) return;
@@ -154,7 +297,7 @@
@@ -154,7 +297,7 @@
else if (b.hasAttribute('data-mx-lockstyle')) setStyle(b.getAttribute('data-mx-lockstyle'));
else if (b.hasAttribute('data-mx-lockstyle')) setStyle(b.getAttribute('data-mx-lockstyle'));
else if (b.hasAttribute('data-mx-density')) setDensity(!view.sheet);
else if (b.hasAttribute('data-mx-density')) setDensity(!view.sheet);
else if (b.hasAttribute('data-mx-still')) setStill(!view.still);
else if (b.hasAttribute('data-mx-still')) setStill(!view.still);
- save(VIEW, view);
+ store(VIEW, view);
});
});
// ── The menu buttons: press to open, and each makes its move when it arrives ──
// ── The menu buttons: press to open, and each makes its move when it arrives ──
@@ -176,12 +319,20 @@
@@ -176,12 +319,20 @@
}
}
var p = e.target.closest('[data-pick]');
var p = e.target.closest('[data-pick]');
if (p) { toggle(p.getAttribute('data-pick')); return; }
if (p) { toggle(p.getAttribute('data-pick')); return; }
- if (e.target.closest('[data-mx-copy]')) { copy(summary(), e.target.closest('[data-mx-copy]')); return; }
+ var share = e.target.closest('[data-mx-share]');
+ if (share) {
+ var text = picks.length ? summary() : link();
+ var done = function () { share.classList.add('is-done'); setTimeout(function () { share.classList.remove('is-done'); }, 1400); };
+ if (navigator.share && /Mobi|Android/i.test(navigator.userAgent || '')) navigator.share({ title: 'My picks for the OpenVibe mark', text: text.replace(/ https?:\S+$/, ''), url: link() }).then(done, function () { /* closed */ });
+ else if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { window.prompt('Copy this:', text); });
+ else window.prompt('Copy this:', text);
+ return;
+ }
if (e.target.closest('[data-mx-link]')) { copy(link(), e.target.closest('[data-mx-link]')); return; }
if (e.target.closest('[data-mx-link]')) { copy(link(), e.target.closest('[data-mx-link]')); return; }
- if (e.target.closest('[data-mx-clear]')) { picks = []; render(); return; }
- // In the sheet, a tap anywhere on a card picks it.
- var card = root.getAttribute('data-density') === 'sheet' && e.target.closest('.mx-card');
- if (card) toggle(card.getAttribute('data-code'));
+ if (e.target.closest('[data-mx-clear]')) { picks = []; render(); clearTimeout(saveTimer); saveTimer = setTimeout(sync, 300); return; }
+ // A tap anywhere on a card picks it (but not a tap on one of its own buttons, like a menu button's demo).
+ var card = e.target.closest('.mx-card');
+ if (card && root.contains(card) && !e.target.closest('button, a, input')) toggle(card.getAttribute('data-code'));
});
});
// ── Arrival and idle: a card is "in" once it has been seen, and "on" (idling) while it is on screen ──
// ── Arrival and idle: a card is "in" once it has been seen, and "on" (idling) while it is on screen ──
@@ -72,12 +72,13 @@ async function createApp(opts = {}) {
@@ -72,12 +72,13 @@ async function createApp(opts = {}) {
const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };
const jobs = opts.jobs || { tick: async () => null, start: () => null, stop: () => null, get running() { return false; } };
const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now });
const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now });
const accountData = accountDataLib.create({ db: s.db, log });
const accountData = accountDataLib.create({ db: s.db, log });
+ const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
const ctx = {
- config, s, store: s, keys, sso, catalog, arena, bench, ai, jobs, designs, accountData, accountSend, log,
+ config, s, store: s, keys, sso, catalog, arena, bench, ai, jobs, designs, accountData, accountSend, log, picks,
random: opts.random || Math.random,
random: opts.random || Math.random,
};
};
@@ -172,6 +173,7 @@ async function createApp(opts = {}) {
@@ -172,6 +173,7 @@ async function createApp(opts = {}) {
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api/v1/arena', createArenaApi(ctx));
+ app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.use(createIconRoutes(ctx));
app.use(createIconRoutes(ctx));
app.use('/api', createApi(ctx));
app.use('/api', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+/**
+ * /brand/explore's picks, saved and counted (migrations/0005_brand_picks.sql). A person's picks replace their last
+ * ones; the tally says how many people picked, which way they lean (the Loop or the Pair: whichever they picked more
+ * of, a tie counting half each way), and the most-picked takes in each family.
+ *
+ * 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 }, top: { L, P, M } } (top: [{ code, n }], at most 6)
+ */
+const crypto = require('crypto');
+
+const CODE = /^[LPM](0[1-9]|[1-4][0-9]|50)$/;
+const COOKIE = 'ov_mx_voter';
+const MAX = 40;
+
+function createBrandPicks({ db, now = () => Date.now() } = {}) {
+ function voterOf(req, res, subject) {
+ if (subject) return { voter: subject, signedIn: true };
+ let key = req.cookies && req.cookies[COOKIE];
+ if (!/^[A-Za-z0-9_-]{32}$/.test(String(key || ''))) {
+ if (!res) return null;
+ key = crypto.randomBytes(24).toString('base64url');
+ res.cookie(COOKIE, key, { httpOnly: true, secure: req.secure || req.get('x-forwarded-proto') === 'https', sameSite: 'lax', maxAge: 365 * 864e5, path: '/' });
+ }
+ return { voter: `anon:${crypto.createHash('sha256').update(key).digest('base64url').slice(0, 32)}`, signedIn: false };
+ }
+
+ const clean = (codes) => [...new Set((Array.isArray(codes) ? codes : []).map(String).filter((c) => CODE.test(c)))].slice(0, MAX).sort();
+
+ async function save(voter, codes, { signedIn = false } = {}) {
+ const list = clean(codes);
+ await db.tx(async () => {
+ await db.exec('DELETE FROM brand_picks WHERE voter = $1', [voter]);
+ for (const c of list) await db.exec('INSERT INTO brand_picks (voter, code, signed_in, created_at) VALUES ($1, $2, $3, $4)', [voter, c, !!signedIn, new Date(now()).toISOString()]);
+ });
+ return list;
+ }
+
+ async function mine(voter) {
+ if (!voter) return [];
+ return (await db.many('SELECT code FROM brand_picks WHERE voter = $1 ORDER BY code', [voter])).map((r) => r.code);
+ }
+
+ 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 lean = 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 people = lean.length;
+ let loop = 0, pair = 0;
+ for (const v of lean) { if (v.l > v.p) loop += 1; else if (v.p > v.l) pair += 1; else if (v.l) { loop += 0.5; pair += 0.5; } }
+ const top = { L: [], P: [], M: [] };
+ for (const r of counts) { const list = top[r.code[0]]; if (list && list.length < 6) list.push({ code: r.code, n: r.n }); }
+ return { people, lean: { loop, pair }, top };
+ }
+
+ return { voterOf, save, mine, tally, CODE, COOKIE, MAX };
+}
+
+module.exports = { createBrandPicks };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+/**
+ * /brand/explore's picks (server/brand-picks.js), for the page's own script:
+ *
+ * GET /api/v1/brand/picks { tally, mine } what everyone picked so far, and this person's (or browser's) picks
+ * PUT /api/v1/brand/picks { picks: ['L03', 'P17', 'M09'] } → { tally, mine } replaces their picks
+ *
+ * A PUT must come from this site (Sec-Fetch-Site: same-origin, or this site's Origin), signed in or not, so another
+ * page cannot cast a ballot for a visitor. Anyone may pick: a signed-in person once per account, a guest once per
+ * browser (an httpOnly cookie key, stored only as a hash).
+ */
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./same-origin');
+
+const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
+
+function createBrandApi(ctx) {
+ const { picks, config } = ctx;
+ const r = asyncRouter();
+ r.get('/picks', async (req, res) => {
+ const who = picks.voterOf(req, null, subjectOf(req));
+ res.set('Cache-Control', 'private, no-store');
+ res.json({ tally: await picks.tally(), mine: who ? await picks.mine(who.voter) : [] });
+ });
+ r.put('/picks', async (req, res) => {
+ if (!sameOrigin(req, config)) return http.sendProblem(res, 403, 'design.cross_site', { detail: 'Picks are saved from openvibe.design itself.', ctx: req.ov });
+ const list = req.body && req.body.picks;
+ if (!Array.isArray(list) || list.length > 60) return http.sendProblem(res, 400, 'design.picks_invalid', { detail: 'Send { picks: ["L03", "P17"] }, up to 40 codes.', ctx: req.ov });
+ const who = picks.voterOf(req, res, subjectOf(req));
+ const mine = await picks.save(who.voter, list, { signedIn: who.signedIn });
+ res.set('Cache-Control', 'private, no-store');
+ res.json({ tally: await picks.tally(), mine });
+ });
+ return r;
+}
+
+module.exports = { createBrandApi };
@@ -69,7 +69,11 @@ function createPageRoutes(ctx) {
@@ -69,7 +69,11 @@ function createPageRoutes(ctx) {
// ── The OpenVibe mark, and its files ──
// ── The OpenVibe mark, and its files ──
r.get('/brand', (req, res) => page(res, 200, brandView.brandPage({ base: layoutBase() }), req.viewer));
r.get('/brand', (req, res) => page(res, 200, brandView.brandPage({ base: layoutBase() }), req.viewer));
// A hundred takes on the Loop and the Pair, for feedback (the owner, 2026-11-01). Before /brand/:file.
// A hundred takes on the Loop and the Pair, for feedback (the owner, 2026-11-01). Before /brand/:file.
- r.get('/brand/explore', (req, res) => page(res, 200, require('../views/marks-explore').explorePage(), req.viewer));
+ r.get('/brand/explore', async (req, res) => {
+ // What people picked so far, at the top (a database hiccup leaves the page whole, without it).
+ const tally = ctx.picks ? await safe(() => ctx.picks.tally()) : null;
+ page(res, 200, require('../views/marks-explore').explorePage({ tally }), req.viewer);
+ });
r.get('/brand/:file', (req, res) => {
r.get('/brand/:file', (req, res) => {
const svg = brandView.file(req.params.file);
const svg = brandView.file(req.params.file);
if (!svg) return page(res, 404, views.notFound(), req.viewer);
if (!svg) return page(res, 404, views.notFound(), req.viewer);
@@ -17,6 +17,8 @@ const TABLES = [
@@ -17,6 +17,8 @@ const TABLES = [
{ table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'created_at', 'updated_at'] },
{ table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'created_at', 'updated_at'] },
// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved
// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved
// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.
// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.
+ // The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).
+ { table: 'brand_picks', subject: 'voter', file: 'brand_picks.json', order: 'created_at', columns: ['code', 'created_at'], erase: 'delete' },
{ table: 'glyph_drafts', subject: 'subject', file: 'glyph_drafts.json', order: 'created_at', columns: ['id', 'name', 'sentence', 'svg', 'model', 'status', 'created_at', 'reviewed_at'], erase: 'delete' },
{ table: 'glyph_drafts', subject: 'subject', file: 'glyph_drafts.json', order: 'created_at', columns: ['id', 'name', 'sentence', 'svg', 'model', 'status', 'created_at', 'reviewed_at'], erase: 'delete' },
];
];
@@ -472,12 +472,12 @@ function markCard(code, kind, [name, line_, spec], i) {
@@ -472,12 +472,12 @@ function markCard(code, kind, [name, line_, spec], i) {
const art = kind === 'L' ? loopArt(spec, `m${++seq}`) : pairArt(spec, `m${++seq}`);
const art = kind === 'L' ? loopArt(spec, `m${++seq}`) : pairArt(spec, `m${++seq}`);
const ref = `art-${code}`;
const ref = `art-${code}`;
return `<li class="mx-card" id="${code}" data-code="${code}" style="${delay(i)}">
return `<li class="mx-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"><svg viewBox="0 0 100 100" width="132" height="132" role="img" aria-label="${code} ${esc(name)}" data-idle="${idleOf(kind, spec, i)}"><defs>${art.defs}</defs><g id="${ref}">${art.body}</g></svg></div>
<div class="mx-stage"><svg viewBox="0 0 100 100" width="132" height="132" role="img" aria-label="${code} ${esc(name)}" data-idle="${idleOf(kind, spec, i)}"><defs>${art.defs}</defs><g id="${ref}">${art.body}</g></svg></div>
<div class="mx-lock">${useOf(ref, 26)}${word()}</div>
<div class="mx-lock">${useOf(ref, 26)}${word()}</div>
<div class="mx-sizes" aria-label="At 16 and 32 pixels"><span class="mx-dark">${useOf(ref, 16)}${useOf(ref, 32)}</span><span class="mx-light">${useOf(ref, 16)}${useOf(ref, 32)}</span></div>
<div class="mx-sizes" aria-label="At 16 and 32 pixels"><span class="mx-dark">${useOf(ref, 16)}${useOf(ref, 32)}</span><span class="mx-light">${useOf(ref, 16)}${useOf(ref, 32)}</span></div>
<p class="mx-name"><b>${code}</b> ${esc(name)}</p>
<p class="mx-name"><b>${code}</b> ${esc(name)}</p>
<p class="mx-line">${esc(line_)}</p>
<p class="mx-line">${esc(line_)}</p>
-<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false">Pick ${code}</button>
</li>`;
</li>`;
}
}
@@ -485,11 +485,11 @@ const menuSvg = (anim, own, parts, size) => `<svg viewBox="0 0 24 24" width="${s
@@ -485,11 +485,11 @@ const menuSvg = (anim, own, parts, size) => `<svg viewBox="0 0 24 24" width="${s
function menuCard(code, [name, line_, anim, own, parts], i) {
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>`;
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)}">
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"><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>
<div class="mb-bars">${bar('dark')}${bar('light')}</div>
<p class="mx-name"><b>${code}</b> ${esc(name)}</p>
<p class="mx-name"><b>${code}</b> ${esc(name)}</p>
<p class="mx-line">${esc(line_)}</p>
<p class="mx-line">${esc(line_)}</p>
-<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false">Pick ${code}</button>
</li>`;
</li>`;
}
}
@@ -499,18 +499,51 @@ function panel(id, kind, rows, label, hidden) {
@@ -499,18 +499,51 @@ function panel(id, kind, rows, label, hidden) {
const body = rows.map((row) => {
const body = rows.map((row) => {
const first = n + 1;
const first = n + 1;
const cards = row.items.map((item) => { n += 1; const code = `${kind}${pad(n)}`; return kind === 'M' ? menuCard(code, item, n - 1) : markCard(code, kind, item, n - 1); }).join('');
const cards = row.items.map((item) => { n += 1; const code = `${kind}${pad(n)}`; return kind === 'M' ? menuCard(code, item, n - 1) : markCard(code, kind, item, n - 1); }).join('');
- return `<section class="mx-row"><h3>${esc(row.title)} <small>${kind}${pad(first)}–${kind}${pad(n)} · ${row.items.length}</small></h3><p class="mx-row-line">${esc(row.line)}</p><ul class="mx-grid">${cards}</ul></section>`;
+ 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('');
}).join('');
- return `<section class="mx-panel" id="${id}" role="tabpanel" aria-labelledby="mx-tab-${id}" aria-label="${esc(label)}"${hidden ? ' hidden' : ''}>${body}</section>`;
+ const NEXT = {
+ loop: [['pair', 'See the Pair', 'the other direction for the mark'], ['menu', 'Next: the menu button', 'it stays whichever mark wins']],
+ pair: [['loop', 'See the Loop', 'the other direction for the mark'], ['menu', 'Next: the menu button', 'it stays whichever mark wins']],
+ menu: [['loop', 'Back to the Loop', 'the mark, one direction'], ['pair', 'Back to the Pair', 'the mark, the other']],
+ }[id];
+ const next = `<nav class="mx-next" aria-label="Where next">${NEXT.map(([to, title, line_]) => `<a href="#${to}" data-mx-go="${to}"><b>${title} <span aria-hidden="true">${to === 'menu' || id !== 'menu' ? '→' : '↑'}</span></b><small>${line_}</small></a>`).join('')}</nav>`;
+ return `<section class="mx-panel" id="${id}" role="tabpanel" aria-labelledby="mx-tab-${id}" aria-label="${esc(label)}"${hidden ? ' hidden' : ''}>${body}${next}</section>`;
}
}
-function explorePage() {
+/** One small mark for the tally, drawn on its own (its own ids), so it shows whichever tab is open. */
+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>` : ''; }
+ 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}`);
+ return `<svg viewBox="0 0 100 100" width="${size}" height="${size}" aria-hidden="true">${art.defs ? `<defs>${art.defs}</defs>` : ''}${art.body}</svg>`;
+}
+const chipOf = ({ code, n }) => `<span class="mx-top" data-code="${code}">${miniArt(code)}<b>${code}</b><i>${n}</i></span>`;
+/** What people picked so far (server/brand-picks.js tally), at the top; marks.js redraws it after each save. */
+function tallyBlock(t) {
+ const people = (t && t.people) || 0;
+ const loop = (t && t.lean && t.lean.loop) || 0, pair = (t && t.lean && t.lean.pair) || 0;
+ const loopPct = loop + pair ? Math.round((loop / (loop + pair)) * 100) : 50;
+ const marks = t ? [...(t.top.L || []), ...(t.top.P || [])].sort((a, b) => b.n - a.n || (a.code < b.code ? -1 : 1)).slice(0, 6) : [];
+ const menus = t ? (t.top.M || []).slice(0, 4) : [];
+ const lead = loop + pair ? (loop === pair ? 'Level so far' : loop > pair ? 'The Loop leads' : 'The Pair leads') : 'No picks yet';
+ return `<section class="mx-tally${people ? '' : ' is-empty'}" data-mx-tally aria-label="What people picked so far">
+<div class="mx-tally-h"><b>So far</b><span data-mx-people>${people ? `${people} ${people === 1 ? 'person has' : 'people have'} picked` : 'Nobody has picked yet. Be the first.'}</span><span class="mx-tally-lead" data-mx-lead>${lead}</span></div>
+<div class="mx-lean" style="--l:${loopPct}%"><span class="mx-lean-end">${miniArt('L02', 24, 'e')}<span>Loop</span><b data-mx-loop>${loop + pair ? `${loopPct}%` : '–'}</b></span><span class="mx-lean-bar" aria-hidden="true"><i></i></span><span class="mx-lean-end mx-lean-r"><b data-mx-pair>${loop + pair ? `${100 - loopPct}%` : '–'}</b><span>Pair</span>${miniArt('P01', 24, 'e')}</span></div>
+<div class="mx-tops"><span class="mx-tops-k">Most picked</span><span class="mx-tops-list" data-mx-tops>${marks.map(chipOf).join('') || '<em>none yet</em>'}</span></div>
+<div class="mx-mine" data-mx-mine hidden><span class="mx-tops-k">Your picks</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>
+<div class="mx-tops"><span class="mx-tops-k">Menu button</span><span class="mx-tops-list" data-mx-tops-m>${menus.map(chipOf).join('') || '<em>none yet</em>'}</span></div>
+</section>`;
+}
+
+function explorePage({ tally = null } = {}) {
seq = 0;
seq = 0;
const today = pairArt(PAIR_ROWS[0].items[0][2], 'xpair');
const today = pairArt(PAIR_ROWS[0].items[0][2], 'xpair');
const loopEx = loopArt(LOOP_ROWS[0].items[1][2], 'xloop');
const loopEx = loopArt(LOOP_ROWS[0].items[1][2], 'xloop');
const pairEx = pairArt(PAIR_ROWS[0].items[0][2], 'xpairtab');
const pairEx = pairArt(PAIR_ROWS[0].items[0][2], 'xpairtab');
const m1 = MENU_ROWS[0].items[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">${art}</span><span class="mx-tab-t"><b>${title}</b><small>${line_}</small><em>${range}</em></span></a>`;
+ 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>`;
const chip = (attr, value, label, extra = '') => `<button type="button" class="mx-chip" ${attr}="${esc(value)}" aria-pressed="false"${extra}>${label}</button>`;
const body = `<div class="mx-wrap" data-mx data-lock="dot" data-mx-fontcss="${esc(FONT_CSS)}">
const body = `<div class="mx-wrap" data-mx data-lock="dot" data-mx-fontcss="${esc(FONT_CSS)}">
<svg class="mx-defs" width="0" height="0" aria-hidden="true" focusable="false"><defs>${today.defs}</defs><g id="art-pair-today">${today.body}</g></svg>
<svg class="mx-defs" width="0" height="0" aria-hidden="true" focusable="false"><defs>${today.defs}</defs><g id="art-pair-today">${today.body}</g></svg>
@@ -518,8 +551,9 @@ function explorePage() {
@@ -518,8 +551,9 @@ function explorePage() {
<header class="mx-hero">
<header class="mx-hero">
<p class="mx-eye">The OpenVibe mark · help us choose</p>
<p class="mx-eye">The OpenVibe mark · help us choose</p>
<h1>The Loop or the Pair?</h1>
<h1>The Loop or the Pair?</h1>
-<p class="mx-lede">We’re choosing OpenVibe’s mark, and it’s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you’d pick, and the takes you like best. Tap <b>Pick</b> on anything you like, then send us your picks.</p>
+<p class="mx-lede">We’re choosing OpenVibe’s mark, and it’s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you’d pick, and the takes you like best. Tap the ones you like: they are saved and counted in the tally below.</p>
</header>
</header>
+${tallyBlock(tally)}
<nav class="mx-choose" role="tablist" aria-label="What to choose">
<nav class="mx-choose" role="tablist" aria-label="What to choose">
<p class="mx-q" role="presentation"><span class="mx-qn">1</span> The mark: <b>the Loop or the Pair?</b></p>
<p class="mx-q" role="presentation"><span class="mx-qn">1</span> The mark: <b>the Loop or the Pair?</b></p>
<div class="mx-vs" role="presentation">
<div class="mx-vs" role="presentation">
@@ -530,6 +564,14 @@ ${tab('pair', 'The Pair', 'An O and a V side by side, reading as the letters O V
@@ -530,6 +564,14 @@ ${tab('pair', 'The Pair', 'An O and a V side by side, reading as the letters O V
<p class="mx-q" role="presentation"><span class="mx-qn">2</span> Separately: <b>the menu button’s look</b> <small>it stays beside the name whichever mark wins</small></p>
<p class="mx-q" role="presentation"><span class="mx-qn">2</span> Separately: <b>the menu button’s look</b> <small>it stays beside the name whichever mark wins</small></p>
${tab('menu', 'The menu button', 'Opens search and every site. Fifty looks for it, each one moves when you hover or tap it.', `<span class="mb-btn mb-tabbtn">${menuSvg(m1[2], m1[3], m1[4], 30)}</span>`, 'M01–M50', false, true)}
${tab('menu', 'The menu button', 'Opens search and every site. Fifty looks for it, each one moves when you hover or tap it.', `<span class="mb-btn mb-tabbtn">${menuSvg(m1[2], m1[3], m1[4], 30)}</span>`, 'M01–M50', false, true)}
</nav>
</nav>
+<nav class="mx-steps" data-mx-steps aria-label="Jump between the choices" hidden>
+<span class="mx-steps-k">Mark</span>
+<a href="#loop" data-mx-go="loop" class="mx-step">${miniArt('L02', 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>
+<span class="mx-steps-sep" aria-hidden="true"></span>
+<a href="#menu" data-mx-go="menu" class="mx-step">${miniArt('M01', 22, 's')}<span>Menu button</span><i data-mx-count="M"></i></a>
+<button type="button" class="mx-step mx-share" data-mx-share aria-label="Copy your picks and a link to them"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V3.5M7.5 8 12 3.5 16.5 8M5 13v6a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6"/></svg><span>Share</span></button>
+</nav>
<div class="mx-tools" data-mx-tools hidden>
<div class="mx-tools" data-mx-tools hidden>
<div class="mx-tool"><span class="mx-tool-k">Name</span><div class="mx-chips" role="group" aria-label="The site's name beside the mark">${SITES.map((s) => chip('data-mx-set-site', s, esc(s))).join('')}<input class="mx-own" type="text" maxlength="14" placeholder="Type one" aria-label="Type a site name" data-mx-own spellcheck="false" autocomplete="off"></div></div>
<div class="mx-tool"><span class="mx-tool-k">Name</span><div class="mx-chips" role="group" aria-label="The site's name beside the mark">${SITES.map((s) => chip('data-mx-set-site', s, esc(s))).join('')}<input class="mx-own" type="text" maxlength="14" placeholder="Type one" aria-label="Type a site name" data-mx-own spellcheck="false" autocomplete="off"></div></div>
<div class="mx-tool"><span class="mx-tool-k">Font</span><div class="mx-chips" role="group" aria-label="The font of the name" data-mx-fonts>${FONTS.map(([k, label, stack, note]) => chip('data-mx-font', k, `${esc(label)}${note ? ` <small>${note}</small>` : ''}`, ` data-face="${esc(stack)}" style="font-family:${esc(stack)}"`)).join('')}</div></div>
<div class="mx-tool"><span class="mx-tool-k">Font</span><div class="mx-chips" role="group" aria-label="The font of the name" data-mx-fonts>${FONTS.map(([k, label, stack, note]) => chip('data-mx-font', k, `${esc(label)}${note ? ` <small>${note}</small>` : ''}`, ` data-face="${esc(stack)}" style="font-family:${esc(stack)}"`)).join('')}</div></div>
@@ -540,8 +582,7 @@ ${panel('loop', 'L', LOOP_ROWS, 'The Loop: fifty takes', false)}
@@ -540,8 +582,7 @@ ${panel('loop', 'L', LOOP_ROWS, 'The Loop: fifty takes', false)}
${panel('pair', 'P', PAIR_ROWS, 'The Pair: fifty takes', true)}
${panel('pair', 'P', PAIR_ROWS, 'The Pair: fifty takes', true)}
${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}
${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}
<p class="mx-fine">Every mark here is centred and level. None of them replaces the mark the sites use until one is chosen: <a href="/brand">the mark today</a>.</p>
<p class="mx-fine">Every mark here is centred and level. None of them replaces the mark the sites use until one is chosen: <a href="/brand">the mark today</a>.</p>
-</div>
-<div class="mx-bar" data-mx-bar hidden><span class="mx-bar-thumbs" data-mx-thumbs aria-hidden="true"></span><span class="mx-bar-t">Your picks: <b data-mx-picks></b></span><button type="button" class="mx-btn" data-mx-copy>Copy</button><button type="button" class="mx-btn" data-mx-link>Copy link</button><button type="button" class="mx-btn mx-btn-quiet" data-mx-clear>Clear</button></div>`;
+</div>`;
return layout({
return layout({
title: 'The Loop or the Pair? Help choose the OpenVibe mark',
title: 'The Loop or the Pair? Help choose the OpenVibe mark',
description: 'The OpenVibe mark is between two: the Loop and the Pair, fifty takes on each. Plus fifty looks for the menu button beside the name.',
description: 'The OpenVibe mark is between two: the Loop and the Pair, fifty takes on each. Plus fifty looks for the menu button beside the name.',
@@ -557,4 +598,4 @@ ${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}
@@ -557,4 +598,4 @@ ${panel('menu', 'M', MENU_ROWS, 'The menu button: fifty ideas', true)}
const LOOPS = LOOP_ROWS.flatMap((r) => r.items);
const LOOPS = LOOP_ROWS.flatMap((r) => r.items);
const PAIRS = PAIR_ROWS.flatMap((r) => r.items);
const PAIRS = PAIR_ROWS.flatMap((r) => r.items);
const MENUS = MENU_ROWS.flatMap((r) => r.items);
const MENUS = MENU_ROWS.flatMap((r) => r.items);
-module.exports = { explorePage, LOOPS, PAIRS, MENUS, LOOP_ROWS, PAIR_ROWS, MENU_ROWS, loopArt, pairArt };
+module.exports = { explorePage, tallyBlock, LOOPS, PAIRS, MENUS, LOOP_ROWS, PAIR_ROWS, MENU_ROWS, loopArt, pairArt };
@@ -30,7 +30,7 @@ const explore = require('../server/views/marks-explore');
@@ -30,7 +30,7 @@ const explore = require('../server/views/marks-explore');
for (const k of 'LPM') assert.strictEqual(codes.filter((c) => c[0] === k).length, 50, k);
for (const k of 'LPM') assert.strictEqual(codes.filter((c) => c[0] === k).length, 50, k);
assert.ok(codes.every((c) => r.text.includes(`data-pick="${c}"`)), 'each has its Pick button');
assert.ok(codes.every((c) => r.text.includes(`data-pick="${c}"`)), 'each has its Pick button');
for (const tab of ['loop', 'pair', 'menu']) {
for (const tab of ['loop', 'pair', 'menu']) {
- assert.match(r.text, new RegExp(`<a class="mx-tab(?: mx-tab-side)?" role="tab" id="mx-tab-${tab}" href="#${tab}" aria-controls="${tab}"[^>]*><span class="mx-tab-art"><(svg|span)`), `the ${tab} tab shows an example`);
+ assert.match(r.text, new RegExp(`<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, 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, /<noscript><style>\.mx-panel\[hidden\]\{display:block!important\}<\/style><\/noscript>/, 'without the script every tab shows');
@@ -38,7 +38,8 @@ const explore = require('../server/views/marks-explore');
@@ -38,7 +38,8 @@ const explore = require('../server/views/marks-explore');
assert.match(r.text, /<span class="mx-qn">1<\/span> The mark: <b>the Loop or the Pair\?<\/b>[\s\S]*?<span class="mx-or" aria-hidden="true">or<\/span>[\s\S]*?<span class="mx-qn">2<\/span> Separately: <b>the menu button’s look<\/b>/, 'two questions: the mark, then separately the menu button');
assert.match(r.text, /<span class="mx-qn">1<\/span> The mark: <b>the Loop or the Pair\?<\/b>[\s\S]*?<span class="mx-or" aria-hidden="true">or<\/span>[\s\S]*?<span class="mx-qn">2<\/span> Separately: <b>the menu button’s look<\/b>/, 'two questions: the mark, then separately the menu button');
assert.ok((r.text.match(/<section class="mx-row">/g) || []).length >= 15, 'sorted into rows by idea');
assert.ok((r.text.match(/<section class="mx-row">/g) || []).length >= 15, 'sorted into rows by idea');
assert.match(r.text, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');
assert.match(r.text, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');
- assert.match(r.text, /data-mx-bar hidden/, 'the picks bar waits for a pick');
+ assert.match(r.text, /data-mx-mine hidden/, 'your picks wait for a pick');
+ assert.ok(!/data-mx-bar/.test(r.text), 'no bar over the marks: a card is picked where it is');
assert.match(r.text, /data-mx-tools hidden/, 'the name and font bar waits for the script');
assert.match(r.text, /data-mx-tools hidden/, 'the name and font bar waits for the script');
});
});
@@ -54,6 +55,39 @@ const explore = require('../server/views/marks-explore');
@@ -54,6 +55,39 @@ const explore = require('../server/views/marks-explore');
assert.ok(!/class="mx-s[^"]*"[^>]*class=/.test(r.text), 'no element carries two class attributes');
assert.ok(!/class="mx-s[^"]*"[^>]*class=/.test(r.text), 'no element carries two class attributes');
});
});
+ await check('picks are saved and counted: one ballot per person or browser, from this site only', async () => {
+ const own = t.config.baseUrl.replace(/\/$/, '');
+ const empty = (await t.get('/api/v1/brand/picks')).json();
+ assert.deepStrictEqual([empty.tally.people, empty.mine], [0, []]);
+ const guest = await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L03', 'P01', 'P17', 'M07', 'junk', 'L03'] }, origin: own });
+ assert.strictEqual(guest.status, 200, guest.text);
+ assert.deepStrictEqual(guest.json().mine, ['L03', 'M07', 'P01', 'P17'], 'only real codes, once each');
+ const key = (guest.headers.get('set-cookie') || '').match(/ov_mx_voter=([^;]+)/);
+ assert.ok(key && /HttpOnly/i.test(guest.headers.get('set-cookie')), 'a guest gets an httpOnly key');
+ assert.ok(!(await t.dbDump()).includes(key[1]), 'the key itself is never stored');
+ const again = await t.get('/api/v1/brand/picks', { method: 'PUT', json: { picks: ['L03'] }, origin: own, cookie: `ov_mx_voter=${key[1]}` });
+ assert.deepStrictEqual(again.json().mine, ['L03'], 'the same browser replaces its picks');
+ 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 });
+ assert.deepStrictEqual(signed.tally.top.P.map((x) => x.code), ['P01', 'P02']);
+ assert.deepStrictEqual(signed.tally.top.M, [{ code: 'M07', n: 1 }]);
+ assert.deepStrictEqual((await t.get('/api/v1/brand/picks', { as: ana })).json().mine, ['M07', 'P01', 'P02'], 'a person sees their own picks');
+ 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]*?2 people have picked[\s\S]*?data-code="P01"/, 'the tally is at the top of the page');
+ });
+
+ 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']) 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, 3, 'each choice ends with where to go next');
+ assert.strictEqual((r.text.match(/data-mx-cycle="/g) || []).length, 3, 'each tab example cycles through its fifty');
+ });
+
await check('the files at /brand/:file still answer', async () => {
await check('the files at /brand/:file still answer', async () => {
const r = await t.get('/brand/openvibe-mark.svg');
const r = await t.get('/brand/openvibe-mark.svg');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);