openvibers/openvibe.design Commits

openvibe-shared v4.59.0 (was v4.58.0) (#42)

OP OpenVibers committed 13 days ago · 62a7dfddd4 · 12 files, +481 −10

* openvibe-shared v4.59.0 (was v4.58.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v4.59.0 release.

* The OpenVibe mark: /brand, with its moves, forms, colours, names, files and code

The owner's new logo (the Pair: a ring O and a round V, a gap of the ground around the V) for anyone who wants to
use it: the mark large with its intro, every state it shows and what each means (with a live stage), its forms at
every size, the grounds it holds on, the names beside it in the brand face, its colours, the SVG files
(/brand/<file>.svg, from openvibe-shared/mark), the app icon and the font, and the code to use it on any page or
server. Whole without JavaScript; brand.js adds the state buttons, the page-load demo and the copy buttons. In the
navbar's More, the sitemap and llms.txt; the mark pattern points to it.

Changes

ddiff --git a/README.md b/README.md
iindex fc046ee..f6abc30 100644
---- a/README.md
++++ b/README.md
@@@ -209,5 +209,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
<!-- versions:start -->
- openvibe-contracts: v0.236.0
- openvibe-sdk: v0.74.0
--- openvibe-shared: v4.58.0
++- openvibe-shared: v4.59.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 7ac830f..42b19d5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,11 +10,11 @@
"domain": "openvibe.design",
"contracts": "openvibe-contracts v0.236.0",
"sdk": "openvibe-sdk v0.74.0",
-- "shared": "openvibe-shared v4.58.0",
++ "shared": "openvibe-shared v4.59.0",
"packages": [
"openvibe-contracts v0.236.0",
"openvibe-sdk v0.74.0",
-- "openvibe-shared v4.58.0"
++ "openvibe-shared v4.59.0"
],
"summary": "The service skeleton for openvibe.design: Network sign-in as OAuth client `design` (PKCE S256, httpOnly design_at/design_rt cookies) and a theme API proxy to OpenVibe.Network's /api/themes. The proxy serves the public catalogue (cached 5 minutes), one theme, the person's active theme and custom variables, saving a theme with source 'studio', its history and a restore by id, and a submission to the gallery. Writes need this site's own Origin; Network 4xx answers pass through and a Network failure is 502. The studio is designed: a gallery, previews on a Live channel, a Chat room and a shop's grid (with colour-vision views), a six-colour editor that derives the full token set, WCAG checks that gate Use, sharing to the gallery, and History with Restore. The Arena queues one prompt for three models through OpenVibe.AI (client credentials, ai.run.create), stores sanitized HTML with reported model/provider/cost/time/tokens, renders it only in sandboxed CSP-capped iframes, draws blind pairs, records one vote per person per round, and keeps Elo ratings per category plus median cost and time. The prompts, outputs and votes are an open JSONL dataset; rounds and votes follow ADR-033 export and deletion.",
"works": [
ddiff --git a/package-lock.json b/package-lock.json
iindex f65d9d8..3b36fb8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.58.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
"pg": "^8.23.1"
},
"devDependencies": {
@@@ -1114,9 +1114,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.58.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.58.0",
-- "integrity": "sha512-cYcVMbYJ+3oN0iiWGa8Swa5lsr169mvoboVRGJr9BpRdxshpAtSuy4u0cx5FmgdoeYqRRtl/qu26kBqKj1t9yg==",
++ "version": "4.59.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
++ "integrity": "sha512-kaJ6Tcz7WCewK/kCx4NzNWRNI4U1Z1lpwCQHWD115YtcaJps8Ulqpu1y2ArLpywARHtYf3ddPH/hL9wkb6MJlw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 1838766..260dd56 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.58.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
"pg": "^8.23.1"
},
"engines": {
ddiff --git a/public/css/brand.css b/public/css/brand.css
nnew file mode 100644
iindex 0000000..907d2d8
---- /dev/null
++++ b/public/css/brand.css
@@@ -0,0 +1,143 @@
++/* /brand: the OpenVibe mark (server/views/brand.js). Theme variables from the Frame; the mark's own colours from
++ openvibe-shared/mark. Names in the brand face the navbar declares ('OV Brand', Nunito). */
++.br{--br-o:#4f7cff;--br-v:#8b5cf6;--br-face:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;max-width:1120px;margin:0 auto;padding:clamp(18px,4vw,44px) clamp(16px,3vw,32px) 96px;color:var(--text-primary)}
++.br svg{display:block;overflow:visible}
++/* The mark as it is: this page shows its own colours, not the season's (a state still recolours it). */
++.br .ov-mark{--ovm-o:var(--br-o);--ovm-v:var(--br-v)}
++.br .ov-mark .sh,.br .ov-mark .sw2{display:none!important}
++
++/* The hero: the mark large on its own stage, the words beside it. */
++.br-hero{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:clamp(20px,4vw,56px);align-items:center;padding:clamp(8px,2vw,20px) 0 clamp(28px,5vw,56px)}
++.br-hero-stage{position:relative;display:grid;place-items:center;aspect-ratio:5/4;border-radius:28px;background:radial-gradient(ellipse 70% 60% at 50% 46%,color-mix(in srgb,var(--br-v) 22%,transparent),transparent 70%),var(--bg-secondary);border:1px solid var(--border);overflow:hidden}
++.br-hero-stage::after{content:'';position:absolute;inset:0;background-image:radial-gradient(color-mix(in srgb,var(--text-primary) 9%,transparent) 1px,transparent 1.2px);background-size:22px 22px;-webkit-mask-image:radial-gradient(ellipse at 50% 50%,transparent 30%,#000 80%);mask-image:radial-gradient(ellipse at 50% 50%,transparent 30%,#000 80%);pointer-events:none}
++.br-hero-stage .ov-mark{position:relative;z-index:1;width:min(300px,64%)!important;filter:drop-shadow(0 18px 40px color-mix(in srgb,var(--br-v) 30%,transparent))}
++.br-eye{margin:0 0 10px;color:var(--accent);font-size:12px;font-weight:750;letter-spacing:.12em;text-transform:uppercase}
++.br-h1{margin:0;font-family:var(--br-face);font-weight:900;font-size:clamp(36px,6vw,64px);line-height:1.02;letter-spacing:-.02em;text-wrap:balance}
++.br-lede{margin:16px 0 0;max-width:52ch;color:var(--text-secondary);font-size:clamp(15px,1.6vw,17px);line-height:1.6}
++.br-acts{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:22px}
++.br-replay{all:unset;cursor:pointer;min-height:44px;padding:0 6px;color:var(--text-secondary);font-size:14px;font-weight:650;text-decoration:underline;text-underline-offset:3px}
++.br-replay:hover,.br-replay:focus-visible{color:var(--text-primary)}
++.br-replay:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
++
++/* Sections. */
++.br-sec{padding:clamp(26px,4vw,44px) 0;border-top:1px solid var(--border)}
++.br-sec-head{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:8px 32px;align-items:baseline;margin-bottom:20px}
++.br-sec-head h2{margin:0;font-family:var(--br-face);font-weight:900;font-size:clamp(22px,3vw,30px);line-height:1.15;letter-spacing:-.01em;text-wrap:balance}
++.br-sec-head p{margin:0;max-width:60ch;color:var(--text-secondary);line-height:1.55}
++.br h3{margin:0 0 10px;font-size:14px;font-weight:750}
++
++/* The moves: one stage, the states as chips, and every meaning listed beside its own small mark. */
++.br-play{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr);gap:18px;align-items:start}
++.br-play-stage{position:sticky;top:76px;display:grid;place-items:center;min-height:300px;border-radius:24px;background:var(--bg-secondary);border:1px solid var(--border)}
++.br-play-stage .ov-mark{width:min(190px,58%)!important}
++.br-states{display:flex;flex-wrap:wrap;gap:8px}
++.br-chip{min-height:40px;padding:0 15px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit;font-size:14px;font-weight:650;cursor:pointer;transition:background .18s,border-color .18s,color .18s}
++.br-chip:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}
++.br-chip[aria-pressed=true]{background:var(--text-primary);border-color:var(--text-primary);color:var(--bg-primary)}
++.br-chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
++.br-say{margin:14px 0 0;min-height:3em;color:var(--text-secondary);line-height:1.5}
++.br-meanings{list-style:none;margin:6px 0 0;padding:0;display:grid;gap:2px}
++.br-meanings li{display:grid;grid-template-columns:56px minmax(0,1fr);gap:12px;align-items:center;padding:8px 10px;border-radius:14px;color:var(--text-secondary);line-height:1.45;font-size:14.5px}
++.br-meanings li:hover{background:var(--bg-secondary)}
++.br-meanings b{color:var(--text-primary);font-weight:700;margin-right:2px}
++.br-mini{display:grid;place-items:center}
++.br-play-side .br-states:has(button:not([hidden]))~.br-meanings{margin-top:14px}
++
++/* Sizes, side by side on a baseline. */
++.br-sizes{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:center;gap:clamp(16px,4vw,44px);padding:26px clamp(16px,3vw,28px);border-radius:22px;background:var(--bg-secondary);border:1px solid var(--border)}
++.br-sizes figure{margin:0;display:grid;justify-items:center;gap:8px}
++.br-size-box{display:grid;place-items:center;width:var(--s);height:var(--s)}
++.br-size-box svg{width:100%;height:100%}
++.br-sizes figcaption{display:grid;justify-items:center;font-size:13px;font-weight:700;font-variant-numeric:tabular-nums}
++.br-sizes small{color:var(--text-muted);font-weight:600;font-size:11.5px}
++
++/* Grounds. */
++.br-grounds{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
++.br-g{position:relative;margin:0;display:grid;place-items:center;aspect-ratio:16/10;border-radius:20px;overflow:hidden;border:1px solid var(--border)}
++.br-g svg{width:min(150px,52%);height:auto}
++.br-g figcaption{position:absolute;left:12px;bottom:10px;padding:3px 9px;border-radius:999px;font-size:12px;font-weight:700;background:rgba(0,0,0,.38);color:#fff;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}
++.br-g--night{background:#0a0f1c}
++.br-g--day{background:#f6f7fb}
++.br-g--day figcaption,.br-g--paper figcaption{background:rgba(255,255,255,.7);color:#141a2e}
++.br-g--photo{background:radial-gradient(circle at 22% 30%,#ffb86b 0,transparent 42%),radial-gradient(circle at 78% 70%,#3dd6c6 0,transparent 46%),radial-gradient(circle at 70% 18%,#ff6b9a 0,transparent 40%),linear-gradient(135deg,#2b2f6e,#0f3b4a)}
++.br-g--tile{background:linear-gradient(135deg,#4f7cff,#8b5cf6)}
++.br-g--paper{background:#efe9dc;color:#1c1a17}
++.br-g--app{background:radial-gradient(ellipse at 50% 40%,#16223a,#0a0f1c 70%)}
++.br-g--app img{width:min(96px,34%);height:auto;border-radius:22%;box-shadow:0 14px 30px rgba(0,0,0,.45)}
++
++/* Names: lockups, and the two faces. */
++.br-locks{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
++.br-lock{display:flex;align-items:center;gap:10px;min-width:0;padding:16px 18px;border-radius:18px;background:var(--bg-secondary);border:1px solid var(--border)}
++.br-lock .ov-mark{flex:none}
++.br-word{min-width:0;font-family:var(--br-face);font-weight:800;font-size:clamp(19px,2.2vw,23px);letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
++.br-dot{color:var(--accent);opacity:.8}
++.br-tld{color:var(--accent-light,var(--accent))}
++.br-type{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:10px}
++.br-face{padding:20px 22px;border-radius:18px;background:var(--bg-secondary);border:1px solid var(--border);min-width:0}
++.br-face-name{margin:0;font-weight:750}
++.br-face-name small{color:var(--text-muted);font-weight:600;margin-left:6px}
++.br-face-sample{margin:14px 0 4px;font-size:clamp(28px,4vw,44px);line-height:1.1;letter-spacing:-.015em;overflow-wrap:anywhere}
++.br-face-set{margin:0;color:var(--text-secondary);font-size:17px}
++.br-face--brand .br-face-sample,.br-face--brand .br-face-set{font-family:var(--br-face);font-weight:900}
++.br-face--text .br-face-sample{font-family:Inter,-apple-system,BlinkMacSystemFont,sans-serif;font-weight:650}
++.br-face--text .br-face-set{font-family:Inter,-apple-system,BlinkMacSystemFont,sans-serif}
++
++/* Colours. */
++.br-sws{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
++.br-sw{display:flex;align-items:center;gap:12px;min-width:0;padding:12px 12px 12px 12px;border-radius:16px;background:var(--bg-secondary);border:1px solid var(--border)}
++.br-sw-chip{flex:none;width:44px;height:44px;border-radius:12px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.12)}
++.br-sw-t{display:grid;min-width:0;flex:1}
++.br-sw-t b{font-weight:700}
++.br-sw-t code{font-size:13px;color:var(--text-secondary)}
++.br-sw-t small{color:var(--text-muted);font-size:12px}
++.br-copy{min-height:36px;padding:0 12px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-primary);font:inherit;font-size:13px;font-weight:650;cursor:pointer}
++.br-copy:hover{background:var(--bg-tertiary,var(--bg-primary))}
++.br-copy:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
++
++/* Use it: snippets and files. */
++.br-use{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:14px}
++.br-use>div{min-width:0}
++.br-use>div:first-child{grid-row:span 2}
++.br-use pre{font-size:12.5px;white-space:pre;overflow-x:auto;max-height:none;padding-right:52px}
++.br-files-h{margin-top:26px!important}
++.br-files{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px}
++.br .br-files a{display:flex;align-items:center;gap:12px;min-height:60px;padding:8px 12px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);text-decoration:none;transition:border-color .18s,transform .18s}
++.br .br-files a:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border));transform:translateY(-1px)}
++.br .br-files a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
++.br-files b{display:block;font-weight:700}
++.br-files small{color:var(--text-muted);font-size:12px}
++.br-file-art{flex:none;display:grid;place-items:center;width:52px;height:44px;border-radius:10px;background:var(--bg-primary);color:var(--text-primary)}
++.br-file-art svg{width:40px;height:auto}
++.br-file-art.is-white{background:linear-gradient(135deg,#4f7cff,#8b5cf6)}
++.br-file-art img{border-radius:9px}
++.br-file-aa{font-family:var(--br-face);font-weight:900;font-size:20px}
++
++/* Rules. */
++.br-rules{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
++.br-rule{display:grid;grid-template-rows:auto 1fr;border-radius:18px;overflow:hidden;background:var(--bg-secondary);border:1px solid var(--border)}
++.br-rule-art{position:relative;display:grid;place-items:center;height:130px;background:var(--bg-primary);border-bottom:3px solid var(--success,#22c55e)}
++.br-rule.is-dont .br-rule-art{border-bottom-color:var(--danger,#ef4444)}
++.br-rule-art svg{width:96px;height:auto}
++.br-rule p{margin:0;padding:14px 16px;color:var(--text-secondary);line-height:1.5;font-size:14px}
++.br-rule p b{display:block;margin-bottom:2px;color:var(--text-primary)}
++.br-room{display:grid;place-items:center;padding:10px;border:1px dashed color-mix(in srgb,var(--text-muted) 70%,transparent);border-radius:8px}
++.br-room svg{width:80px}
++.br-wrong{display:grid;place-items:center}
++.br-wrong--spin svg{transform:rotate(-24deg)}
++
++@media (max-width:900px){
++ .br-hero,.br-play,.br-sec-head{grid-template-columns:minmax(0,1fr)}
++ .br-hero-stage{aspect-ratio:16/10}
++ .br-locks,.br-sws{grid-template-columns:repeat(2,minmax(0,1fr))}
++ .br-play-stage{position:static}
++ .br-use{grid-template-columns:minmax(0,1fr)}
++ .br-rules{grid-template-columns:repeat(2,minmax(0,1fr))}
++}
++@media (max-width:560px){
++ .br-grounds,.br-locks,.br-sws,.br-type,.br-rules{grid-template-columns:minmax(0,1fr)}
++ .br-grounds{grid-template-columns:repeat(2,minmax(0,1fr))}
++ .br-play-stage{min-height:220px}
++ .br-sizes{gap:18px 22px}
++}
++@media (prefers-reduced-motion:reduce){.br-chip,.br-files a{transition:none}.br .br-files a:hover{transform:none}}
++.br-h3-note{margin-left:6px;color:var(--text-muted);font-weight:600;font-size:12px}
ddiff --git a/public/js/brand.js b/public/js/brand.js
nnew file mode 100644
iindex 0000000..18e6547
---- /dev/null
++++ b/public/js/brand.js
@@@ -0,0 +1,83 @@
++/**
++ * /brand's demos (server/views/brand.js): the state buttons set data-state on the stage's mark (ov-mark.js does the
++ * rest), "A page on its way" plays the navbar's page-load ring on it at this site's usual pace, the hero can replay
++ * its intro (OVMark.intro), and the colours copy. Without JavaScript the buttons stay hidden and the page still says
++ * what every move means.
++ */
++(function () {
++ 'use strict';
++ const $ = (s, el = document) => el.querySelector(s);
++ const $$ = (s, el = document) => [...el.querySelectorAll(s)];
++ const demo = $('[data-br-demo]');
++ const say = $('[data-br-say]');
++ const SAY = {};
++ let loadTimer = 0;
++
++ function press(btn) { $$('.br-states .br-chip').forEach((b) => b.setAttribute('aria-pressed', b === btn ? 'true' : 'false')); }
++ function stopLoad() {
++ clearTimeout(loadTimer); loadTimer = 0;
++ if (!demo) return;
++ demo.removeAttribute('data-nav'); demo.removeAttribute('data-ovm-arrive'); demo.style.removeProperty('--ovm-fill');
++ }
++
++ function setState(btn) {
++ stopLoad();
++ const st = btn.getAttribute('data-br-state');
++ if (st) demo.setAttribute('data-state', st); else demo.removeAttribute('data-state');
++ press(btn);
++ if (say) say.textContent = SAY[st] || '';
++ }
++
++ // The navbar's page-load ring, on the stage: it empties, draws round at the usual pace (never closing early),
++ // then closes and the V hops.
++ function playLoad(btn) {
++ stopLoad();
++ demo.removeAttribute('data-state');
++ press(btn);
++ if (say) say.textContent = 'The ring empties, then draws itself round as the next page loads, at the pace this site usually takes in your browser. When the page is here it closes and the V hops.';
++ const avg = Math.max(900, (window.OVMark && window.OVMark.usual && window.OVMark.usual('page')) || 1600) * 1.4;
++ const t0 = Date.now(), done = t0 + avg;
++ const tick = () => {
++ const t = Date.now() - t0;
++ if (Date.now() >= done) {
++ demo.style.setProperty('--ovm-fill', '1'); demo.setAttribute('data-ovm-arrive', '');
++ loadTimer = setTimeout(() => { stopLoad(); loadTimer = setTimeout(() => playLoad(btn), 900); }, 620);
++ return;
++ }
++ demo.setAttribute('data-nav', '');
++ demo.style.setProperty('--ovm-fill', Math.min(0.94, 0.08 + 0.86 * (1 - Math.exp(-1.6 * t / (avg / 1.4)))).toFixed(3));
++ loadTimer = setTimeout(tick, 120);
++ };
++ tick();
++ }
++
++ if (demo) {
++ $$('.br-states .br-chip').forEach((b) => {
++ b.hidden = false;
++ const st = b.getAttribute('data-br-state');
++ if (st !== null) {
++ SAY[st] = b.getAttribute('data-say') || '';
++ b.addEventListener('click', () => setState(b));
++ } else if (b.hasAttribute('data-br-load')) {
++ b.addEventListener('click', () => playLoad(b));
++ }
++ });
++ }
++
++ const hero = $('[data-br-hero]'), replay = $('[data-br-replay]');
++ if (hero && replay) {
++ const ready = () => window.OVMark && typeof window.OVMark.intro === 'function';
++ const show = () => { if (ready()) replay.hidden = false; else setTimeout(show, 300); };
++ show();
++ replay.addEventListener('click', () => { if (ready()) window.OVMark.intro(hero); });
++ }
++
++ $$('[data-br-copy]').forEach((b) => {
++ b.hidden = false;
++ b.addEventListener('click', () => {
++ const v = b.getAttribute('data-br-copy');
++ const ok = () => { b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy'; }, 1400); };
++ try { navigator.clipboard.writeText(v).then(ok, () => {}); } catch { /* no clipboard */ }
++ });
++ });
++})();
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 7a5111c..7197d32 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -32,6 +32,7 @@ function publicPages(themes = []) {
{ path: '/look', changefreq: 'monthly', priority: 0.6, title: 'Your look', text: 'Your theme, light or dark, text size, motion and seasonal themes for every OpenVibe site, in one place.' },
{ path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The OpenVibe design system\'s components, each with its parts, tokens, states and accessibility, and code for the web, Jetpack Compose and SwiftUI.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
++ { path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
];
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
@@@ -72,6 +73,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
++ { title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
] },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a3fe348..56a2162 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -10,6 +10,8 @@
* /arena/vote the next blind pair, and the reveal after a vote
* /arena/r/:id one round: every output at 390 px and 1280 px, with cost and time
* /developers the API
++ * /brand the OpenVibe mark: its moves, forms, colours, names, files and code
++ * /brand/<file>.svg the mark's files (server/views/brand.js FILES)
*
* Every page is server-rendered. The Arena's model HTML is only ever inside `<iframe sandbox srcdoc>`; the pages
* themselves never contain it. `me` comes from the session the SSO middleware already resolved.
@@@ -23,6 +25,8 @@ const componentsView = require('../views/components');
const themeTokens = require('../tokens');
const designView = require('../views/design');
const patternsView = require('../views/patterns');
++const brandView = require('../views/brand');
++const { baseUrl: layoutBase } = require('../render/layout');
const { asyncRouter } = require('./router');
const { send } = require('../render/layout');
const { ArenaError } = require('../arena/store');
@@@ -61,6 +65,13 @@ function createPageRoutes(ctx) {
};
// ── Production patterns: one data-backed page and generated native spec per pattern ──
++ // ── The OpenVibe mark, and its files ──
++ r.get('/brand', (req, res) => page(res, 200, brandView.brandPage({ base: layoutBase() }), 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);
++ res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*').type('image/svg+xml').send(svg);
++ });
r.get('/patterns', (req, res) => page(res, 200, patternsView.index(), req.viewer));
r.get('/patterns/mark/studies', (req, res) => res.sendFile(path.join(__dirname, '../../public/mark-studies.html')));
r.get('/patterns/:slug', (req, res) => {
ddiff --git a/server/patterns/catalog.js b/server/patterns/catalog.js
iindex 258f621..4f96e38 100644
---- a/server/patterns/catalog.js
++++ b/server/patterns/catalog.js
@@@ -43,7 +43,7 @@ const patterns = [
['measure-at-rest','Layout that holds','Measure at rest', 'Read an element’s box only after its entrance animation has ended. getBoundingClientRect includes transforms, so an element measured mid-entrance looks smaller or further away than it is.', '2026-10-30 · A Dock card measured 362 × 506 instead of 400 × 560, because it was read during its scale-in (Shared 4.51.0).', 'Wait for the motion to settle before you measure.', t('durationBase','easingStandard')],
['drag-or-tap','Layout that holds','Drag or tap, never both', 'A press becomes a drag only after the pointer moves 6 px. Listen to the moves on the window, because a quick drag leaves the handle before then. Swallow the click that ends a drag, so a drag that began on a tab does not select it.', '2026-10-30 · A fast drag from a Dock tab never started (the moves left the head before 6 px), and a slow one would have changed tabs (Shared 4.51.0).', 'Small moves are taps; real moves are drags.', t('tap','durationFast')],
['floating-clears-toasts','Layout that holds','Floating surfaces clear the toasts', 'A floating control or card in a corner tells the page how much room it takes (a CSS variable). Toasts in that corner stack above it instead of covering it.', '2026-10-30 · The Dock’s pill and desktop toasts both lived in the bottom-right corner (Shared 4.51.0).', 'Two things in one corner never cover each other.', t('spaceMd','durationBase','easingStandard')],
-- ['mark-system','Brand','The mark studies', 'Treat the mark as a working component with a loading state and a per-site morph. Compare the five studies in the live study page before choosing the mark used by the kit.', '2026-10-30 · A static logo could not communicate loading or adapt to a site identity.', 'The mark carries its identity through loading and site changes.', t('durationSlow','easingStandard','easingBack')],
++ ['mark-system','Brand','The mark at work', 'Treat the mark as a working component: it shows a page on its way, work, done, failed and live in one shape, and moves only when it has something to say. The mark is the Pair (2026-10-31); its moves, forms and files are on /brand.', '2026-10-30 · A static logo could not communicate loading or adapt to a site identity.', 'The mark carries its identity through loading and site changes.', t('durationSlow','easingStandard','easingBack')],
].map(([slug,group,title,rule,bug,summary,tokens]) => ({ slug, group, title, rule, bug, summary, tokens: [...new Set([...tokens, 'tap', 'spaceMd', 'durationBase'])] }));
function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
@@@ -77,7 +77,7 @@ function page(p) {
<aside class="pbug"><h2>The bug that taught it</h2><p><time datetime="${esc(p.bug.slice(0,10))}">${esc(p.bug)}</time></p></aside>
<section class="ptokens"><h2>Tokens used</h2><ul>${tokenRows(p)}</ul></section>
<section class="pnative"><h2>Native spec · generated from this pattern and the shared token registry</h2><div class="pcodegrid"><figure><figcaption>SwiftUI</figcaption><pre><code>${esc(spec.swift)}</code></pre></figure><figure><figcaption>Compose</figcaption><pre><code>${esc(spec.compose)}</code></pre></figure></div></section>
-- <nav class="pnext" aria-label="Pattern navigation"><a href="/patterns">All patterns</a>${p.slug==='mark-system'?'<a href="/patterns/mark/studies">Open all five mark studies ↗</a>':''}</nav>`;
++ <nav class="pnext" aria-label="Pattern navigation"><a href="/patterns">All patterns</a>${p.slug==='mark-system'?'<a href="/brand">The OpenVibe mark ↗</a><a href="/patterns/mark/studies">The earlier studies ↗</a>':''}</nav>`;
return pageLayout({ title: `${p.title} pattern`, description:p.rule, path:`/patterns/${p.slug}`, active:'/patterns', scripts:['js/pattern-kit.js','js/patterns.js'], body:`<article class="pattern-page">${body}</article>` });
}
function index() {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex bb1b055..07e5e71 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -28,6 +28,7 @@ const NAV = [
{ label: 'Your look', href: '/look', icon: 'fa-circle-half-stroke' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
++ { label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
ddiff --git a/server/views/brand.js b/server/views/brand.js
nnew file mode 100644
iindex 0000000..f99cee3
---- /dev/null
++++ b/server/views/brand.js
@@@ -0,0 +1,171 @@
++'use strict';
++/**
++ * /brand: the OpenVibe mark, for anyone who wants to use it. The mark (the Pair: a ring O and a round V with a gap of
++ * the ground around the V), what each of its moves means, its forms at every size, the grounds it sits on, the names
++ * beside it in the brand face, its colours, the files to download and the code to use it on any site.
++ *
++ * Server-rendered and whole without JavaScript: every mark here is openvibe-shared/mark's static SVG, and the shared
++ * ov-mark.js (loaded by the navbar) brings the live ones to life. brand.js adds the state buttons and the progress
++ * demo; kit-web.js adds the copy buttons.
++ *
++ * brandPage({ base }) base: this site's origin, for the snippets
++ * FILES the downloads served under /brand/: { name, form, tone, wide, title }
++ */
++const kit = require('openvibe-shared/kit');
++const mark = require('openvibe-shared/mark');
++const appIcons = require('openvibe-shared/app-icons');
++const { layout, esc } = require('../render/layout');
++
++const FILES = Object.freeze([
++ { name: 'openvibe-mark.svg', label: 'The mark', note: 'blue and violet, square', form: 'pair', tone: 'brand', wide: false },
++ { name: 'openvibe-mark-wide.svg', label: 'Wide', note: 'the box hugs the mark, for beside a name', form: 'pair', tone: 'brand', wide: true },
++ { name: 'openvibe-mark-small.svg', label: 'Small', note: 'the heavy form, for 22 px and under', form: 'heavy', tone: 'brand', wide: false },
++ { name: 'openvibe-mark-ink.svg', label: 'Ink', note: 'one colour: it takes the text colour', form: 'pair', tone: 'ink', wide: false },
++ { name: 'openvibe-mark-white.svg', label: 'White', note: 'for a colour or a photo', form: 'pair', tone: 'white', wide: false },
++]);
++
++let n = 0;
++/** A static mark, its mask ids unique on the page. */
++const art = (o = {}) => mark.svg({ id: `br${++n}`, title: 'OpenVibe', ...o });
++/** A live mark: the static SVG until ov-mark.js mounts it. */
++const live = (attrs = '', o = {}) => `<span class="ov-mark" ${attrs}>${art(o)}</span>`;
++
++const STATES = [
++ ['', 'At rest', 'Still. It moves for a moment on your first visit, and when you point at it.'],
++ ['busy', 'Working', 'The V bobs while the O breathes, in turn: something is on its way.'],
++ ['ok', 'Done', 'The V becomes a tick and goes green. The tick only ever means done.'],
++ ['error', 'Failed', 'The V becomes an exclamation mark, red, and the mark shakes once.'],
++ ['no', 'Declined', 'The V becomes a cross, grey: cancelled, turned down, not now.'],
++ ['notify', 'Something new', 'A dot pops up by the V: something is waiting for you.'],
++ ['live', 'Live', 'The O is the record light and the signal goes out from it.'],
++ ['sleep', 'Asleep', 'The O closes like an eye: offline, or down for maintenance.'],
++];
++
++const SIZES = [16, 20, 24, 32, 48, 96];
++
++function brandPage({ base = 'https://openvibe.design' } = {}) {
++ n = 0;
++ const C = mark.COLORS;
++ const hero = `<header class="br-hero">
++ <div class="br-hero-stage">${live('data-size="300" data-wide data-br-hero', { wide: true })}</div>
++ <div class="br-hero-words">
++ <p class="br-eye">The OpenVibe mark</p>
++ <h1 class="br-h1">A ring and a round&nbsp;V.</h1>
++ <p class="br-lede">The O is the network, open to everyone; the V is the vibe in it. Where they cross, the ground shows through, so the mark holds on anything: a dark theme, a light one, a photo. It moves only when it has something to say.</p>
++ <div class="br-acts">${kit.button({ href: '/brand/openvibe-mark.svg', label: 'Download the SVG', icon: 'download', attrs: { download: 'openvibe-mark.svg' } })}${kit.button({ href: '#use', label: 'Use it on your site', kind: 'quiet', icon: 'code' })}<button type="button" class="br-replay" data-br-replay hidden>Play the intro again</button></div>
++ </div>
++</header>`;
++
++ const moves = `<section class="br-sec" aria-labelledby="br-moves">
++ <div class="br-sec-head"><h2 id="br-moves">It moves when it means something</h2><p>Every move is a word: one shape says working, done, failed and live, in colour and in form. Nothing spins.</p></div>
++ <div class="br-play">
++ <div class="br-play-stage">${live('data-size="190" data-wide data-br-demo', { wide: true })}</div>
++ <div class="br-play-side">
++ <div class="br-states" role="group" aria-label="Show a state">${STATES.map(([id, label, line], i) => `<button type="button" class="br-chip" data-br-state="${esc(id)}" data-say="${esc(line)}" aria-pressed="${i === 0 ? 'true' : 'false'}" hidden>${esc(label)}</button>`).join('')}<button type="button" class="br-chip" data-br-load hidden>A page on its way</button></div>
++ <p class="br-say" data-br-say aria-live="polite">${esc(STATES[0][2])}</p>
++ <ul class="br-meanings">${STATES.slice(1).map(([id, label, say]) => `<li><span class="br-mini">${live(`data-size="44" data-state="${esc(id)}" data-static`)}</span><span><b>${esc(label)}</b> ${esc(say)}</span></li>`).join('')}
++ <li><span class="br-mini">${live('data-size="44" data-progress="0.62" style="--ovm-p:.62" data-static')}</span><span><b>A page on its way</b> Follow a link and the navbar&rsquo;s ring empties, then draws itself round as the next page loads, at the pace this site usually takes in your browser. It closes and the V hops when the page is here.</span></li></ul>
++ </div>
++ </div>
++</section>`;
++
++ const sizes = `<section class="br-sec" aria-labelledby="br-sizes">
++ <div class="br-sec-head"><h2 id="br-sizes">One shape at every size</h2><p>At 22 px and under the strokes thicken (the heavy form), so the gap around the V stays open on a favicon or inside a button.</p></div>
++ <div class="br-sizes">${SIZES.map((s) => `<figure><div class="br-size-box" style="--s:${s}px">${art({ form: s <= 22 ? 'heavy' : 'pair', size: s })}</div><figcaption>${s} px<small>${s <= 22 ? 'heavy' : 'pair'}</small></figcaption></figure>`).join('')}</div>
++</section>`;
++
++ const grounds = `<section class="br-sec" aria-labelledby="br-grounds">
++ <div class="br-sec-head"><h2 id="br-grounds">On any ground</h2><p>The gap is cut out of the ring, not painted, so whatever is behind the mark shows through it.</p></div>
++ <div class="br-grounds">
++ <figure class="br-g br-g--night">${art({})}<figcaption>Night</figcaption></figure>
++ <figure class="br-g br-g--day">${art({})}<figcaption>Day</figcaption></figure>
++ <figure class="br-g br-g--photo">${art({})}<figcaption>A picture</figcaption></figure>
++ <figure class="br-g br-g--tile">${art({ tone: 'white' })}<figcaption>White, on colour</figcaption></figure>
++ <figure class="br-g br-g--paper">${art({ tone: 'ink' })}<figcaption>Ink, one colour</figcaption></figure>
++ <figure class="br-g br-g--app"><img src="${esc(appIcons.url('network', 192))}" width="96" height="96" alt="The OpenVibe app icon"><figcaption>The app icon</figcaption></figure>
++ </div>
++</section>`;
++
++ const site = (name, variant, tld) => `<div class="br-lock"><span class="ov-mark" data-size="44" data-wide data-variant="${esc(variant)}">${art({ wide: true })}</span><span class="br-word">${name}${tld ? `<span class="br-dot">.</span><span class="br-tld">${esc(tld)}</span>` : ''}</span></div>`;
++ const names = `<section class="br-sec" aria-labelledby="br-names">
++ <div class="br-sec-head"><h2 id="br-names">The names beside it</h2><p>Names are set in Nunito, round like the mark, at 800 and 900. Everything you read is set in Inter where you have it, and otherwise in your system&rsquo;s own face.</p></div>
++ <div class="br-locks">${site('OpenVibe', 'network', '')}${site('OpenVibe', 'live', 'Live')}${site('OpenVibe', 'codes', 'Codes')}${site('OpenVibe', 'chat', 'Chat')}${site('OpenVibe', 'games', 'Games')}${site('OpenVibe', 'vip', 'VIP')}</div>
++ <div class="br-type">
++ <div class="br-face br-face--brand"><p class="br-face-name">Nunito <small>for names · 800, 900</small></p><p class="br-face-sample" aria-hidden="true">OpenVibe.Live</p><p class="br-face-set" aria-hidden="true">Aa Bb Cc 0123456789</p></div>
++ <div class="br-face br-face--text"><p class="br-face-name">Inter <small>for everything you read, or your system&rsquo;s face</small></p><p class="br-face-sample" aria-hidden="true">Go live from your browser</p><p class="br-face-set" aria-hidden="true">Aa Bb Cc 0123456789</p></div>
++ </div>
++</section>`;
++
++ const swatch = (name, hex, note) => `<div class="br-sw"><span class="br-sw-chip" style="background:${esc(hex)}"></span><span class="br-sw-t"><b>${esc(name)}</b><code>${esc(hex)}</code><small>${esc(note)}</small></span><button type="button" class="br-copy" data-br-copy="${esc(hex)}" aria-label="Copy ${esc(hex)}" hidden>Copy</button></div>`;
++ const colours = `<section class="br-sec" aria-labelledby="br-colours">
++ <div class="br-sec-head"><h2 id="br-colours">Colours</h2><p>Two for the mark. The rest belong to its states, and come from the theme where the theme has them.</p></div>
++ <div class="br-sws">${swatch('O blue', C.o, 'the ring')}${swatch('V violet', C.v, 'the V')}${swatch('Done', '#22c55e', 'the tick')}${swatch('Failed', '#ef4444', 'the "!"')}${swatch('Live', '#ff3b5c', 'the record light')}${swatch('Declined', '#94a3b8', 'the cross')}</div>
++</section>`;
++
++ const useScript = `<script src="${base}/shared/ov-mark.js" async></script>
++
++<span class="ov-mark" data-size="48"></span>
++<span class="ov-mark" data-size="34" data-wide></span>
++<span class="ov-mark" data-size="20" data-state="busy"></span>
++
++<!-- data-state busy, ok, error, no, notify, live, sleep
++ data-progress 0.4, with style="--ovm-p:.4"
++ data-tone ink or white: one colour
++ data-wide the box hugs the mark
++ OVMark.intro(el) plays the intro again -->`;
++ const useNode = `const mark = require('openvibe-shared/mark');
++
++mark.svg({ size: 64 }); // 64 px square
++mark.svg({ wide: true, size: 32 }); // 53 × 32
++mark.svg({ tone: 'white' }); // one colour
++mark.svg({ form: 'heavy' }); // 22 px and under`;
++ const useFont = `@font-face {
++ font-family: 'OV Brand';
++ src: url(${base}/shared/ov-brand.woff2) format('woff2');
++ font-weight: 700 900;
++ font-display: swap;
++}
++.name { font: 800 1em 'OV Brand', Nunito, sans-serif; }`;
++ const use = `<section class="br-sec" id="use" aria-labelledby="br-use">
++ <div class="br-sec-head"><h2 id="br-use">Use it</h2><p>On any page, in any app, with every move above. It is open source (MIT); the font is Nunito (SIL Open Font License).</p></div>
++ <div class="br-use">
++ <div><h3>On a web page</h3>${kit.snippet({ code: useScript, id: 'br-use-web', label: 'Copy the HTML' })}</div>
++ <div><h3>On a server, or in an app <small class="br-h3-note">openvibe-shared v4.59.0 or later</small></h3>${kit.snippet({ code: useNode, id: 'br-use-node', label: 'Copy the code' })}</div>
++ <div><h3>The names&rsquo; face</h3>${kit.snippet({ code: useFont, id: 'br-use-font', label: 'Copy the CSS' })}</div>
++ </div>
++ <h3 class="br-files-h">Files</h3>
++ <ul class="br-files">${FILES.map((f) => `<li><a href="/brand/${esc(f.name)}" download="${esc(f.name)}"><span class="br-file-art${f.tone === 'white' ? ' is-white' : ''}">${art({ form: f.form, tone: f.tone, wide: f.wide })}</span><span><b>${esc(f.label)}</b><small>${esc(f.note)} · SVG</small></span></a></li>`).join('')}
++ <li><a href="${esc(appIcons.url('network', 512))}" download="openvibe-app-icon-512.png"><span class="br-file-art"><img src="${esc(appIcons.url('network', 192))}" width="40" height="40" alt=""></span><span><b>App icon</b><small>512 px · PNG</small></span></a></li>
++ <li><a href="/shared/ov-brand.woff2" download="ov-brand.woff2"><span class="br-file-art br-file-aa" aria-hidden="true">Aa</span><span><b>The brand face</b><small>Nunito, Latin, 700 to 900 · WOFF2</small></span></a></li></ul>
++</section>`;
++
++ const rule = (ok, title, text, demo) => `<div class="br-rule${ok ? '' : ' is-dont'}"><div class="br-rule-art">${demo}</div><p><b>${ok ? 'Do' : 'Don&rsquo;t'}: ${title}</b> ${text}</p></div>`;
++ const rules = `<section class="br-sec" aria-labelledby="br-rules">
++ <div class="br-sec-head"><h2 id="br-rules">Keep it itself</h2></div>
++ <div class="br-rules">
++ ${rule(true, 'give it room.', 'Leave the width of the ring&rsquo;s stroke clear on every side, and more beside a name.', `<span class="br-room">${art({})}</span>`)}
++ ${rule(true, 'use one colour when two won&rsquo;t do.', 'Ink on paper and white on colour are the only other ways to draw it.', `${art({ tone: 'ink' })}`)}
++ ${rule(false, 'recolour, rotate or outline it.', 'A violet O or a slanted V is someone else&rsquo;s mark.', `<span class="br-wrong">${art({ tone: { o: C.v, v: C.o } })}</span>`)}
++ ${rule(false, 'spin it, or tick it for anything but done.', 'A loading mark bobs and breathes; the tick is the only way it says yes.', `<span class="br-wrong br-wrong--spin">${art({})}</span>`)}
++ </div>
++</section>`;
++
++ const body = `<article class="br">${hero}${moves}${sizes}${grounds}${names}${colours}${use}${rules}</article>`;
++ return layout({
++ title: 'The OpenVibe mark',
++ description: 'The OpenVibe mark: a ring O and a round V. What each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.',
++ path: '/brand',
++ active: '/brand',
++ styles: ['css/brand.css'],
++ scripts: ['js/brand.js'],
++ body,
++ });
++}
++
++/** One of the downloads, as a standalone SVG; null for a name that is not one. */
++function file(name) {
++ const f = FILES.find((x) => x.name === name);
++ return f ? mark.svg({ form: f.form, tone: f.tone, wide: f.wide, title: 'OpenVibe', id: 'ov' }) : null;
++}
++
++module.exports = { brandPage, file, FILES, STATES };
ddiff --git a/test/brand.test.js b/test/brand.test.js
nnew file mode 100644
iindex 0000000..576b5fd
---- /dev/null
++++ b/test/brand.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++// /brand: the OpenVibe mark for anyone who wants to use it (2026-10-31, the Pair). The page is whole without
++// JavaScript (every mark on it is openvibe-shared/mark's static SVG), says what each move means, and offers the files
++// and the code; the files are standalone SVGs anyone can fetch.
++const assert = require('node:assert/strict');
++const { boot, check, done } = require('./helpers/boot');
++const mark = require('openvibe-shared/mark');
++const { FILES, STATES } = require('../server/views/brand');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the page shows the mark, every move in words, and how to use it', async () => {
++ const res = await t.get('/brand');
++ assert.equal(res.status, 200);
++ const html = res.text;
++ assert.ok(html.includes('<h1 class="br-h1">A ring and a round&nbsp;V.</h1>'), 'its heading');
++ assert.ok((html.match(/<mask id="br\d+h"/g) || []).length >= 20, 'every mark is drawn on the server, each with its own mask');
++ assert.equal(new Set(html.match(/<mask id="br\d+h"/g)).size, (html.match(/<mask id="br\d+h"/g) || []).length, 'no two masks share an id');
++ for (const [id, label, line] of STATES) {
++ assert.ok(html.includes(`>${label}</button>`), `the ${label} button`);
++ if (id) assert.ok(html.includes(`data-state="${id}" data-static`) && html.includes(line), `${label}: its small mark and what it means`);
++ }
++ assert.ok(html.includes('A page on its way'), 'the page-load ring, in words');
++ assert.ok(html.includes('data-br-hero') && html.includes('data-br-demo'), 'the live marks for ov-mark.js');
++ assert.ok(html.includes('/shared/ov-mark.js&quot; async&gt;&lt;/script&gt;'), 'the script snippet');
++ assert.ok(html.includes('require(&#39;openvibe-shared/mark&#39;)'), 'the Node snippet');
++ assert.ok(html.includes('/shared/ov-brand.woff2'), 'the font snippet');
++ for (const f of FILES) assert.ok(html.includes(`href="/brand/${f.name}" download="${f.name}"`), `the ${f.label} file`);
++ assert.ok(html.includes(mark.COLORS.o) && html.includes(mark.COLORS.v), 'its two colours');
++ assert.ok(html.includes('/css/brand.css') && html.includes('/js/brand.js'), 'its style and demos');
++ });
++
++ await check('the files are standalone SVGs of the mark, anyone can fetch them', async () => {
++ for (const f of FILES) {
++ const res = await t.get(`/brand/${f.name}`);
++ assert.equal(res.status, 200, f.name);
++ assert.match(res.headers.get('content-type'), /^image\/svg\+xml/);
++ assert.equal(res.headers.get('access-control-allow-origin'), '*');
++ assert.equal(res.text, mark.svg({ form: f.form, tone: f.tone, wide: f.wide, title: 'OpenVibe', id: 'ov' }), f.name);
++ }
++ const none = await t.get('/brand/not-a-file.svg');
++ assert.equal(none.status, 404);
++ });
++
++ await check('it is in the navbar, the sitemap and llms.txt, and the mark pattern points to it', async () => {
++ const map = await t.get('/sitemap.xml');
++ assert.ok(map.text.includes('/brand</loc>'), 'sitemap');
++ const llms = await t.get('/llms.txt');
++ assert.ok(llms.text.includes('/brand'), 'llms.txt');
++ const pattern = await t.get('/patterns/mark-system');
++ assert.ok(pattern.text.includes('href="/brand"'), 'the mark pattern links it');
++ const page = await t.get('/brand');
++ assert.ok(/"href":"\/brand"[^}]*"active":true/.test(page.text), 'the navbar marks it');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });