openvibers/openvibe.design Commits

/brand/explore: picks saved and counted at the top, tap to pick, a steps bar between the choices (#60)

OP OpenVibers committed 12 days ago · 8351880d40 · 10 files, +517 −96

The owner's notes, 11-01: picks save to the server and the top of the page shows what people picked (the Loop
against the Pair, the most-picked takes, the leading menu buttons, and your own with Copy link); a card is picked
by tapping it (a tick in its corner, a violet glow) instead of a bar over the marks; once the big tabs scroll away
a steps bar (Loop, Pair, Menu button, with your counts, and Share) stays under the navbar, and each choice ends with
where to go next, so switching never means scrolling back up; each tab's example cycles through its fifty with its
number; on a phone the rails scroll sideways only (overflow-y hidden, so a vertical swipe moves the page), fade at
the edge while there is more, say 'swipe', and nudge once when first seen.

brand_picks (migration 0005, expand): one row per voter and code; a guest is a hash of an httpOnly browser key; a
signed-in person's picks are in their account export and deletion. PUT /api/v1/brand/picks only from this site.

Changes

ddiff --git a/migrations/0005_brand_picks.sql b/migrations/0005_brand_picks.sql
nnew file mode 100644
iindex 0000000..2ce5349
---- /dev/null
++++ b/migrations/0005_brand_picks.sql
@@@ -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);
ddiff --git a/public/css/marks.css b/public/css/marks.css
iindex 8efad2a..e937766 100644
---- a/public/css/marks.css
++++ b/public/css/marks.css
@@@ -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)}
ddiff --git a/public/js/marks.js b/public/js/marks.js
iindex 731e9cd..18b74c9 100644
---- a/public/js/marks.js
++++ b/public/js/marks.js
@@@ -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 ──
ddiff --git a/server/app.js b/server/app.js
iindex a949d52..9963ffd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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', {
ddiff --git a/server/brand-picks.js b/server/brand-picks.js
nnew file mode 100644
iindex 0000000..5de2832
---- /dev/null
++++ b/server/brand-picks.js
@@@ -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 };
ddiff --git a/server/http/brand-api.js b/server/http/brand-api.js
nnew file mode 100644
iindex 0000000..8f84c66
---- /dev/null
++++ b/server/http/brand-api.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex fd6ec79..63e25e3 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex a5f9314..45263ff 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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' },
];
ddiff --git a/server/views/marks-explore.js b/server/views/marks-explore.js
iindex 719cc0c..0b64c66 100644
---- a/server/views/marks-explore.js
++++ b/server/views/marks-explore.js
@@@ -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="${s
function menuCard(code, [name, line_, anim, own, parts], i) {
const bar = (tone) => `<div class="mb-nav mb-${tone}">${useOf('art-pair-today', 22)}${word()}<button type="button" class="mb-btn" data-mb aria-pressed="false" aria-label="${code} at its real size: press to open">${menuSvg(anim, own, parts, 22)}</button></div>`;
return `<li class="mx-card mb-card" id="${code}" data-code="${code}" style="${delay(i)}">
++<button type="button" class="mx-pick" data-pick="${code}" aria-pressed="false" aria-label="Pick ${code}"></button>
<div class="mx-stage"><button type="button" class="mb-btn mb-big" data-mb aria-pressed="false" aria-label="${code} ${esc(name)}: press to open it">${menuSvg(anim, own, parts, 84)}</button></div>
<div class="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&rsquo;re choosing OpenVibe&rsquo;s mark, and it&rsquo;s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you&rsquo;d pick, and the takes you like best. Tap <b>Pick</b> on anything you like, then send us your picks.</p>
++<p class="mx-lede">We&rsquo;re choosing OpenVibe&rsquo;s mark, and it&rsquo;s between two: the <b>Loop</b>, a V folded into a ring, and the <b>Pair</b>, an O beside a V. Each has fifty takes: tell us which of the two you&rsquo;d pick, and the takes you like best. Tap 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&rsquo;s look</b> <small>it stays beside the name whichever mark wins</small></p>
${tab('menu', 'The menu button', 'Opens search and every site. Fifty looks for it, each one moves when you hover or tap it.', `<span class="mb-btn mb-tabbtn">${menuSvg(m1[2], m1[3], m1[4], 30)}</span>`, 'M01–M50', false, true)}
</nav>
++<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 };
ddiff --git a/test/brand-explore.test.js b/test/brand-explore.test.js
iindex 21c7f15..effc0a5 100644
---- a/test/brand-explore.test.js
++++ b/test/brand-explore.test.js
@@@ -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&rsquo;s look<\/b>/, 'two questions: the mark, then separately the menu button');
assert.ok((r.text.match(/<section class="mx-row">/g) || []).length >= 15, 'sorted into rows by idea');
assert.match(r.text, /<meta name="robots" content="noindex, follow">/, 'proposals are not indexed');
-- assert.match(r.text, /data-mx-bar hidden/, 'the picks bar waits for a pick');
++ 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);