openvibers/openvibe.design Commits
/motion: how OpenVibe moves: every shared effect live, the curves drawn, the rules and the code (#50)
Changes
nnew file mode 100644@@@ -0,0 +1,97 @@++/* /motion: how OpenVibe moves (server/views/motion.js). Theme variables from the Frame; the effects themselves come from++ openvibe-shared/ov-fx (loaded by the navbar because the page has data-fx). Names in the brand face, as on /brand. */++.mo{--mo-o:#4f7cff;--mo-v:#8b5cf6;--mo-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)}++.mo svg{display:block;overflow:visible}++.mo .ov-mark{--ovm-o:var(--mo-o);--ovm-v:var(--mo-v)}++.mo .ov-mark .sh,.mo .ov-mark .sw2{display:none!important}++.mo :not(pre)>code{font:600 .88em/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;padding:1px 6px;border-radius:6px;background:color-mix(in srgb,var(--text-primary) 7%,transparent)}++++/* The hero: the mark on a stage of its own two colours, the words beside it. */++.mo-hero{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.05fr);gap:clamp(20px,4vw,56px);align-items:center;padding:clamp(8px,2vw,20px) 0 clamp(28px,5vw,56px)}++.mo-hero-stage{position:relative;display:grid;place-items:center;aspect-ratio:1;max-height:440px;border-radius:32px;overflow:hidden;background:radial-gradient(60% 50% at 38% 52%,color-mix(in srgb,var(--mo-o) 26%,transparent),transparent 70%),radial-gradient(55% 50% at 66% 46%,color-mix(in srgb,var(--mo-v) 26%,transparent),transparent 70%),var(--bg-secondary);border:1px solid var(--border)}++.mo-hero-stage::after{content:'';position:absolute;inset:0;background-image:radial-gradient(color-mix(in srgb,var(--text-primary) 8%,transparent) 1px,transparent 1.2px);background-size:22px 22px;-webkit-mask-image:radial-gradient(closest-side,transparent 40%,#000);mask-image:radial-gradient(closest-side,transparent 40%,#000);pointer-events:none}++.mo-hero-stage .ov-mark{position:relative;z-index:1;width:min(260px,62%)!important;height:auto!important;aspect-ratio:1}++.mo-replay{position:absolute;z-index:2;bottom:14px;left:50%;transform:translateX(-50%);min-height:38px;padding:0 16px;border-radius:999px;border:1px solid var(--border);background:color-mix(in srgb,var(--bg-primary) 70%,transparent);backdrop-filter:blur(8px);color:var(--text-primary);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer}++.mo-replay:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}++.mo-replay:focus-visible,.mo-btn:focus-visible,.mo-card:focus-visible,.mo-poster:focus-visible{outline:2px solid var(--accent);outline-offset:3px}++.mo-eye{margin:0 0 10px;color:var(--accent);font-size:12px;font-weight:750;letter-spacing:.12em;text-transform:uppercase}++.mo-h1{margin:0;font-family:var(--mo-face);font-weight:900;font-size:clamp(36px,6vw,64px);line-height:1.02;letter-spacing:-.02em;text-wrap:balance}++.mo-lede{margin:16px 0 0;max-width:54ch;color:var(--text-secondary);font-size:clamp(15px,1.6vw,17px);line-height:1.6}++.mo-acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}++++/* Sections, as on /brand. */++.mo-sec{padding:clamp(26px,4vw,44px) 0;border-top:1px solid var(--border)}++.mo-sec-head{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:8px 32px;align-items:baseline;margin-bottom:22px}++.mo-sec-head h2{margin:0;font-family:var(--mo-face);font-weight:900;font-size:clamp(22px,3vw,30px);line-height:1.15;letter-spacing:-.01em;text-wrap:balance}++.mo-sec-head p{margin:0;max-width:60ch;color:var(--text-secondary);line-height:1.55}++.mo h3{margin:0 0 10px;font-size:14px;font-weight:750}++++/* The effects: a stage for each, its name, what it says and the word that turns it on. */++.mo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(196px,1fr));gap:14px}++.mo-fx{margin:0;display:grid;grid-template-rows:auto 1fr;border-radius:22px;border:1px solid var(--border);background:var(--bg-card,var(--bg-secondary));overflow:hidden;min-width:0}++.mo-fx-stage{display:grid;place-items:center;min-height:170px;padding:22px;background:radial-gradient(80% 70% at 50% 40%,color-mix(in srgb,var(--accent) 9%,transparent),transparent),var(--bg-secondary);border-bottom:1px solid var(--border)}++.mo-fx figcaption{display:grid;align-content:start;gap:6px;padding:14px 16px 16px}++.mo-fx figcaption b{font-size:15px}++.mo-fx figcaption span{color:var(--text-secondary);font-size:13.5px;line-height:1.5}++.mo-fx figcaption code{justify-self:start;margin-top:4px;font-size:12px}++.mo-btn{min-height:44px;padding:0 20px;border-radius:12px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:14.5px;font-weight:700;cursor:pointer}++.mo-btn:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}++.mo-btn-strong{background:var(--accent-strong,var(--accent));border-color:transparent;color:var(--on-accent-strong,#fff)}++.mo-card{display:flex;align-items:center;gap:10px;width:100%;max-width:250px;padding:10px 12px;border-radius:16px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);text-decoration:none}++.mo-card-art{flex:none;width:40px;height:40px;display:grid;place-items:center;border-radius:12px;background:var(--bg-secondary)}++.mo-card-art svg{width:30px;height:30px}++.mo-card span:last-child{display:grid;gap:2px;min-width:0}++.mo-card b{font-size:14px}++.mo-card small{color:var(--text-muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}++.mo-poster{display:grid;justify-items:start;align-content:end;gap:2px;width:min(170px,100%);aspect-ratio:4/5;padding:16px;border-radius:18px;color:#fff;background:radial-gradient(90% 60% at 30% 25%,color-mix(in srgb,var(--mo-o) 85%,#fff 0%),transparent 70%),linear-gradient(160deg,var(--mo-o),var(--mo-v) 70%,#2e1065);box-shadow:0 18px 40px -18px color-mix(in srgb,var(--mo-v) 70%,transparent)}++.mo-poster-art{margin-bottom:auto}++.mo-poster-art svg{width:44px;height:44px}++.mo-poster b{font-family:var(--mo-face);font-weight:900;font-size:17px}++.mo-poster small{opacity:.85;font-size:12px}++.mo-list{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;justify-content:center;gap:8px}++.mo-list li{padding:8px 14px;border-radius:999px;border:1px solid var(--border);background:var(--bg-primary);font-size:13.5px;font-weight:650}++.mo-num{margin:0;display:grid;justify-items:center;gap:4px;text-align:center}++.mo-num b{font-family:var(--mo-face);font-weight:900;font-size:clamp(34px,4.2vw,46px);letter-spacing:-.02em;font-variant-numeric:tabular-nums;background:linear-gradient(120deg,var(--mo-o),var(--mo-v));-webkit-background-clip:text;background-clip:text;color:transparent}++.mo-num span{color:var(--text-secondary);font-size:13px}++.mo-skel{display:grid;gap:10px;width:100%;max-width:230px}++.mo-skel span{display:block;height:14px;border-radius:7px;background:color-mix(in srgb,var(--text-muted) 14%,transparent)}++.mo-skel span:first-child{height:96px;border-radius:14px}++.mo-skel span:last-child{width:62%}++++/* The timing: each curve drawn, with a dot that races along it; the lengths as bars. */++.mo-curves{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}++.mo-curve{display:grid;gap:8px;padding:16px;border-radius:20px;border:1px solid var(--border);background:var(--bg-secondary);min-width:0}++.mo-curve svg{width:100%;max-width:200px;height:auto;justify-self:center}++.mo-curve-grid{fill:none;stroke:var(--border);stroke-width:1}++.mo-curve-line{fill:none;stroke:var(--accent);stroke-width:3.5;stroke-linecap:round}++.mo-curve p{margin:0;font-size:14px}++.mo-curve-use{color:var(--text-secondary);font-size:13px!important;line-height:1.5}++.mo-race{position:relative;height:10px;border-radius:5px;background:color-mix(in srgb,var(--text-muted) 14%,transparent)}++.mo-race i{position:absolute;top:50%;left:0;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:linear-gradient(135deg,var(--mo-o),var(--mo-v));box-shadow:0 0 0 3px var(--bg-secondary)}++.mo-race.is-run i{left:100%;transition:left 1.1s var(--mo-ease,ease)}++.mo-durs{list-style:none;margin:16px 0 14px;padding:0;display:grid;gap:8px}++.mo-durs li{display:grid;grid-template-columns:110px minmax(0,1fr) 64px minmax(0,150px);gap:12px;align-items:center;font-size:13.5px}++.mo-dur-bar{height:8px;width:var(--w);border-radius:4px;background:linear-gradient(90deg,var(--mo-o),var(--mo-v))}++.mo-durs small{color:var(--text-secondary)}++++/* The rules. */++.mo-rules{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}++.mo-rule{padding:16px 18px;border-radius:18px;border:1px solid var(--border);background:var(--bg-secondary)}++.mo-rule b{font-size:15px}++.mo-rule p{margin:6px 0 0;color:var(--text-secondary);font-size:13.5px;line-height:1.55}++++/* Use it. */++.mo-use{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}++.mo-use>div{min-width:0}++++@media (max-width:760px){++ .mo-hero,.mo-sec-head{grid-template-columns:1fr}++ /* On a phone the effects and the rules run sideways, every one of them there to swipe to. */++ .mo-grid,.mo-rules,.mo-curves{grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:minmax(250px,80%);overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;padding-bottom:8px;scrollbar-width:thin}++ .mo-grid>*,.mo-rules>*,.mo-curves>*{scroll-snap-align:start}++ .mo-hero-stage{max-height:none}++ .mo-durs li{grid-template-columns:96px minmax(0,1fr) 56px}++ .mo-durs small{display:none}++}++@media (prefers-reduced-motion:reduce){.mo-race.is-run i{transition:none}}++html[data-ov-motion=reduced] .mo-race.is-run i{transition:none}nnew file mode 100644@@@ -0,0 +1,24 @@++/**++ * /motion's conveniences (server/views/motion.js): "Play it again" replays the hero mark's intro (OVMark.intro), and++ * "Race the curves" sends a dot along each shared curve at once, so the three can be told apart. The effects++ * themselves are openvibe-shared/ov-fx's (the navbar loads it for data-fx). Without JavaScript both buttons stay hidden.++ */++(function () {++ 'use strict';++ const replay = document.querySelector('[data-mo-replay]');++ const hero = document.querySelector('[data-mo-hero]');++ if (replay && hero) {++ replay.hidden = false;++ replay.addEventListener('click', () => { if (window.OVMark && window.OVMark.intro) window.OVMark.intro(hero); });++ }++ const run = document.querySelector('[data-mo-run]');++ const races = Array.from(document.querySelectorAll('[data-mo-race]'));++ if (run && races.length) {++ run.hidden = false;++ run.addEventListener('click', () => {++ races.forEach((r) => r.classList.remove('is-run'));++ void document.body.offsetWidth; // start from the left again++ races.forEach((r) => r.classList.add('is-run'));++ });++ }++})();@@@ -34,6 +34,7 @@ function publicPages(themes = []) {{ 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: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },{ 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.' },@@@ -26,6 +26,7 @@ const themeTokens = require('../tokens');const designView = require('../views/design');const patternsView = require('../views/patterns');const brandView = require('../views/brand');++const motionView = require('../views/motion');const { baseUrl: layoutBase } = require('../render/layout');const { asyncRouter } = require('./router');const { send } = require('../render/layout');@@@ -72,6 +73,8 @@ function createPageRoutes(ctx) {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);});++ // ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──++ r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));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) => {@@@ -29,6 +29,7 @@ const NAV = [{ 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: 'Motion', href: '/motion', icon: 'fa-wand-magic-sparkles', more: true },{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },nnew file mode 100644@@@ -0,0 +1,146 @@++'use strict';++/**++ * /motion: how OpenVibe moves (plan L45). The mark's motion (openvibe-shared 4.65.0) and the network's effects kit++ * (openvibe-shared/ov-fx, 4.66.0) in one place: every effect live, with the line of markup that turns it on; the++ * timing every site shares (the motion tokens) drawn as curves; the rules; and the code to use it anywhere.++ *++ * Server-rendered and whole without JavaScript: the demos are plain markup with data-fx, which the navbar's ov-fx.js++ * brings to life; motion.js only adds the replay, the timing races and the copy buttons' neighbours.++ *++ * motionPage({ base }) base: this site's origin, for the snippets++ * EFFECTS the effects shown, in order: { id, name, says, fx, demo(kind) }++ * CURVES the shared curves: { token, name, bezier, use }++ */++const kit = require('openvibe-shared/kit');++const mark = require('openvibe-shared/mark');++const { layout, esc } = require('../render/layout');++++let n = 0;++const art = (o = {}) => mark.svg({ id: `mo${++n}`, title: 'OpenVibe', ...o });++const live = (attrs = '', o = {}) => `<span class="ov-mark" ${attrs}>${art(o)}</span>`;++++const EFFECTS = Object.freeze([++ { id: 'glint', name: 'Glint', says: 'A band of light crosses it when you point at it. For the one thing on a page you want found.', fx: 'glint', demo: 'button' },++ { id: 'glow', name: 'Glow', says: 'The accent glows up behind it. For cards and tiles that open something.', fx: 'glow', demo: 'card' },++ { id: 'lift', name: 'Lift', says: 'It rises a little, with a softer shadow, and settles on a spring.', fx: 'lift press', demo: 'card' },++ { id: 'press', name: 'Press', says: 'It gives under your finger and springs back. Every button should.', fx: 'press', demo: 'button' },++ { id: 'ripple', name: 'Ripple', says: 'A ring spreads from the exact point you pressed, in the text colour.', fx: 'ripple press', demo: 'button' },++ { id: 'tilt', name: 'Tilt', says: 'It leans toward the pointer, like a card in the hand. Mouse only.', fx: 'tilt glint', demo: 'poster' },++ { id: 'reveal', name: 'Reveal', says: 'Its items rise in, one after another, the first time they come on screen.', fx: 'reveal', demo: 'list' },++ { id: 'count', name: 'Count', says: 'A number counts up when it is first seen, and ends on exactly its own text.', fx: 'count', demo: 'number' },++ { id: 'confetti', name: 'Confetti', says: 'For real moments only: a first stream, a tip, a goal reached.', fx: 'confetti press', demo: 'cta' },++ { id: 'shimmer', name: 'Shimmer', says: 'A placeholder that breathes until its content lands. Never a spinning ring.', fx: 'shimmer', demo: 'skeleton' },++]);++++// The curves every site shares (openvibe-shared/ov-fx's tokens), drawn from their cubic-bezier points.++const CURVES = Object.freeze([++ { token: '--ov-ease-out', name: 'Out', bezier: [0.2, 0.8, 0.2, 1], use: 'Things arriving: a panel opening, a tooltip, a reveal.' },++ { token: '--ov-ease-spring', name: 'Spring', bezier: [0.34, 1.56, 0.64, 1], use: 'Things you touched: a press springing back, a lift, the mark’s V.' },++ { token: '--ov-ease-in-out', name: 'In and out', bezier: [0.65, 0, 0.35, 1], use: 'Things crossing: a glint, a slide from one place to another.' },++]);++const DURATIONS = Object.freeze([++ ['--ov-dur-1', '120 ms', 'a press'], ['--ov-dur-2', '200 ms', 'a hover'], ['--ov-dur-3', '320 ms', 'a panel or a lift'], ['--ov-dur-4', '520 ms', 'a spring settling'],++]);++++/** One effect's live demo, as markup a reader could copy. */++function demo(e) {++ const fx = `data-fx="${esc(e.fx)}"`;++ switch (e.demo) {++ case 'button': return `<button type="button" class="mo-btn" ${fx}>${e.id === 'glint' ? 'Go live' : e.id === 'ripple' ? 'Press anywhere on me' : 'Press me'}</button>`;++ case 'card': return `<a class="mo-card" href="#mo-${esc(e.id)}" ${fx}><span class="mo-card-art">${art({})}</span><span><b>Goosely is live</b><small>Geese at dawn · 41 watching</small></span></a>`;++ case 'poster': return `<div class="mo-poster" ${fx} tabindex="0"><span class="mo-poster-art">${art({ tone: 'white' })}</span><b>OpenVibe.Live</b><small>Go live from your browser</small></div>`;++ case 'list': return `<ul class="mo-list" ${fx} data-fx-stagger="70"><li>Live</li><li>Design</li><li>Codes</li><li>Tools</li><li>Games</li></ul>`;++ case 'number': return `<p class="mo-num"><b ${fx}>12,480</b><span>people went live from a browser</span></p>`;++ case 'cta': return `<button type="button" class="mo-btn mo-btn-strong" ${fx}>Send the tip</button>`;++ case 'skeleton': return `<div class="mo-skel" aria-hidden="true"><span ${fx}> </span><span ${fx}> </span><span ${fx}> </span></div>`;++ default: return '';++ }++}++++/** A cubic-bezier as an SVG path in a 100 × 100 box (y up), padded so a spring's overshoot shows. */++function curvePath([x1, y1, x2, y2]) {++ const X = (x) => +(x * 100).toFixed(1), Y = (y) => +(100 - y * 100).toFixed(1);++ return `M0 100C${X(x1)} ${Y(y1)} ${X(x2)} ${Y(y2)} 100 0`;++}++++function motionPage({ base = 'https://openvibe.design' } = {}) {++ n = 0;++ const hero = `<header class="mo-hero">++ <div class="mo-hero-stage">${live('data-size="260" data-glow data-mo-hero')}<button type="button" class="mo-replay" data-mo-replay hidden>Play it again</button></div>++ <div class="mo-hero-words">++ <p class="mo-eye">Motion</p>++ <h1 class="mo-h1">Motion that means something.</h1>++ <p class="mo-lede">The mark draws its ring, drops its V in on a spring and catches the light once. Everything else on OpenVibe moves the same way: one timing, one set of effects, shared by every site. Nothing spins, nothing loops, and with Calm on, nothing moves at all.</p>++ <div class="mo-acts">${kit.button({ href: '#use', label: 'Use it on your site' })}${kit.button({ href: '/brand', label: 'The mark', kind: 'quiet' })}</div>++ </div>++</header>`;++++ const effects = `<section class="mo-sec" aria-labelledby="mo-fx">++ <div class="mo-sec-head"><h2 id="mo-fx">The effects</h2><p>Each one is a word in <code>data-fx</code>, and words combine: <code>data-fx="lift press"</code>. Point at them, press them, scroll to them.</p></div>++ <div class="mo-grid">${EFFECTS.map((e) => `<figure class="mo-fx" id="mo-${esc(e.id)}">++ <div class="mo-fx-stage">${demo(e)}</div>++ <figcaption><b>${esc(e.name)}</b><span>${esc(e.says)}</span><code>data-fx="${esc(e.fx)}"</code></figcaption>++ </figure>`).join('\n ')}</div>++</section>`;++++ const timing = `<section class="mo-sec" aria-labelledby="mo-time">++ <div class="mo-sec-head"><h2 id="mo-time">The timing</h2><p>Three curves and four lengths, as CSS variables on every OpenVibe page. A look can bring its own, and Calm sets every length to zero.</p></div>++ <div class="mo-curves">${CURVES.map((c) => `<div class="mo-curve">++ <svg viewBox="-8 -28 116 136" aria-hidden="true"><path class="mo-curve-grid" d="M0 0H100M0 100H100M0 0V100M100 0V100"/><path class="mo-curve-line" d="${curvePath(c.bezier)}"/></svg>++ <div class="mo-race" data-mo-race style="--mo-ease:var(${esc(c.token)})"><i></i></div>++ <p><b>${esc(c.name)}</b> <code>${esc(c.token)}</code></p>++ <p class="mo-curve-use">${esc(c.use)}</p>++ </div>`).join('')}</div>++ <ul class="mo-durs">${DURATIONS.map(([t, v, use]) => `<li><code>${esc(t)}</code><span class="mo-dur-bar" style="--w:${parseInt(v, 10) / 5.2}%"></span><b>${esc(v)}</b><small>${esc(use)}</small></li>`).join('')}</ul>++ <button type="button" class="mo-btn" data-mo-run hidden>Race the curves</button>++</section>`;++++ const rule = (title, text) => `<div class="mo-rule"><b>${title}</b><p>${text}</p></div>`;++ const rules = `<section class="mo-sec" aria-labelledby="mo-rules">++ <div class="mo-sec-head"><h2 id="mo-rules">The rules</h2><p>So that motion stays a signal, never noise.</p></div>++ <div class="mo-rules">++ ${rule('It means something.', 'Every move says one thing: this is new, this worked, this is yours to press. A move that says nothing is cut.')}++ ${rule('Nothing spins.', 'A wait is a shimmer, a bob or a ring drawing round, never a circle chasing its tail.')}++ ${rule('Once, then still.', 'Effects play on a hover, a press or the first time something is seen, then rest. Nothing runs on its own, and nothing moves off screen.')}++ ${rule('Calm wins.', 'With reduced motion or Calm on, nothing moves, nothing counts and no confetti falls. The page looks the same, only still.')}++ ${rule('Never hide to animate.', 'What is on screen when the page opens is never hidden for an entrance. Only what is further down rises in.')}++ ${rule('One kit.', 'Every site uses the same effects and the same timing, so the network feels like one place.')}++ </div>++</section>`;++++ const useHtml = `<script src="${base}/shared/ov-fx.js" defer></script>++++<a class="card" data-fx="lift press glow">…</a>++<b data-fx="count">12,480</b>++<button data-fx="confetti press">Send the tip</button>`;++ const useJs = `// From script, for a moment of your own:++OVFx.confetti({ x: 200, y: 120 });++++// Markup added later is wired by itself; or ask for it:++OVFx.scan(document.querySelector('#feed'));`;++ const useCss = `.panel {++ transition: transform var(--ov-dur-3) var(--ov-ease-spring),++ opacity var(--ov-dur-2) var(--ov-ease-out);++}`;++ const use = `<section class="mo-sec" id="use" aria-labelledby="mo-use">++ <div class="mo-sec-head"><h2 id="mo-use">Use it</h2><p>On OpenVibe sites the navbar loads the kit by itself, only on a page that asks with <code>data-fx</code>. Anywhere else, one script tag. Open source (MIT).</p></div>++ <div class="mo-use">++ <div><h3>On a web page</h3>${kit.snippet({ code: useHtml, id: 'mo-use-web', label: 'Copy the HTML' })}</div>++ <div><h3>From script</h3>${kit.snippet({ code: useJs, id: 'mo-use-js', label: 'Copy the code' })}</div>++ <div><h3>The timing in your own CSS</h3>${kit.snippet({ code: useCss, id: 'mo-use-css', label: 'Copy the CSS' })}</div>++ </div>++</section>`;++++ const body = `<article class="mo">${hero}${effects}${timing}${rules}${use}</article>`;++ return layout({++ title: 'Motion',++ description: 'How OpenVibe moves: the mark’s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules, and the code to use it anywhere.',++ path: '/motion',++ active: '/motion',++ styles: ['css/motion.css'],++ scripts: ['js/motion.js'],++ body,++ });++}++++module.exports = { motionPage, EFFECTS, CURVES, curvePath };nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++// /motion: how OpenVibe moves (plan L45). The page is whole without JavaScript: every effect is shown as plain markup++// with its data-fx words (the navbar's ov-fx.js brings them to life), with what each one says and the word to copy;++// the shared curves are drawn from their real cubic-bezier points; the rules and the code are there; and the page is++// listed in the menu and the sitemap.++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const { EFFECTS, CURVES, curvePath } = require('../server/views/motion');++++(async () => {++ const t = await boot();++ try {++ await check('every effect is live, explained, and one word to copy', async () => {++ const res = await t.get('/motion');++ assert.equal(res.status, 200);++ const html = res.text;++ assert.ok(html.includes('<h1 class="mo-h1">Motion that means something.</h1>'));++ assert.ok(html.includes('data-mo-hero') && html.includes('data-glow'), 'the hero mark, glowing, for ov-mark.js');++ for (const e of EFFECTS) {++ assert.ok(html.includes(`id="mo-${e.id}"`), `${e.name}: its card`);++ assert.ok(html.includes(`data-fx="${e.fx}"`), `${e.name}: live markup`);++ assert.ok(new RegExp(`<code>data-fx=("|"|")${e.fx}("|"|")</code>`).test(html), `${e.name}: the words to copy`);++ }++ assert.ok(/data-fx="[^"]*"/.test(html), 'the navbar loads ov-fx.js for this page');++ assert.ok(html.includes('Nothing spins.') && html.includes('Calm wins.') && html.includes('Never hide to animate.'), 'the rules');++ assert.ok(/\/shared\/ov-fx\.js("|") defer><\/script>/.test(html) && html.includes('OVFx.confetti'), 'the code');++ assert.ok(html.includes('css/motion.css') && html.includes('js/motion.js'), 'its own style and script');++ });++ await check('the curves are drawn from their points, and the timing is the kit\'s', async () => {++ const html = (await t.get('/motion')).text;++ for (const c of CURVES) {++ assert.ok(html.includes(`d="${curvePath(c.bezier)}"`), `${c.name}: its curve`);++ assert.ok(html.includes(`--mo-ease:var(${c.token})`), `${c.name}: the dot races on the shared token`);++ }++ assert.equal(curvePath([0.34, 1.56, 0.64, 1]), 'M0 100C34 -56 64 0 100 0', 'a spring overshoots above the box');++ for (const tok of ['--ov-dur-1', '--ov-dur-2', '--ov-dur-3', '--ov-dur-4']) assert.ok(html.includes(`<code>${tok}</code>`), tok);++ });++ await check('the script only adds conveniences, and the route and listing are wired', async () => {++ const fs = require('node:fs');++ const path = require('node:path');++ const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/motion.js'), 'utf8');++ assert.match(js, /replay\.hidden = false/, 'Play it again appears only with JavaScript');++ assert.match(js, /OVMark\.intro\(hero\)/, 'and replays the mark\'s own intro');++ assert.match(js, /classList\.add\('is-run'\)/, 'the curves race on a press');++ const pages = fs.readFileSync(path.join(__dirname, '..', 'server/http/pages.js'), 'utf8');++ assert.match(pages, /r\.get\('\/motion'/);++ const discovery = fs.readFileSync(path.join(__dirname, '..', 'server/http/discovery.js'), 'utf8');++ assert.match(discovery, /path: '\/motion'/);++ });++ await check('it is in the menu and the sitemap', async () => {++ const home = (await t.get('/')).text;++ assert.ok(home.includes('href="/motion"'), 'the menu');++ const map = (await t.get('/sitemap.xml')).text;++ assert.ok(map.includes('/motion</loc>'), 'the sitemap');++ });++ } finally {++ await done(t);++ }++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+/* /motion: how OpenVibe moves (server/views/motion.js). Theme variables from the Frame; the effects themselves come from
+ openvibe-shared/ov-fx (loaded by the navbar because the page has data-fx). Names in the brand face, as on /brand. */
+.mo{--mo-o:#4f7cff;--mo-v:#8b5cf6;--mo-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)}
+.mo svg{display:block;overflow:visible}
+.mo .ov-mark{--ovm-o:var(--mo-o);--ovm-v:var(--mo-v)}
+.mo .ov-mark .sh,.mo .ov-mark .sw2{display:none!important}
+.mo :not(pre)>code{font:600 .88em/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;padding:1px 6px;border-radius:6px;background:color-mix(in srgb,var(--text-primary) 7%,transparent)}
+
+/* The hero: the mark on a stage of its own two colours, the words beside it. */
+.mo-hero{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.05fr);gap:clamp(20px,4vw,56px);align-items:center;padding:clamp(8px,2vw,20px) 0 clamp(28px,5vw,56px)}
+.mo-hero-stage{position:relative;display:grid;place-items:center;aspect-ratio:1;max-height:440px;border-radius:32px;overflow:hidden;background:radial-gradient(60% 50% at 38% 52%,color-mix(in srgb,var(--mo-o) 26%,transparent),transparent 70%),radial-gradient(55% 50% at 66% 46%,color-mix(in srgb,var(--mo-v) 26%,transparent),transparent 70%),var(--bg-secondary);border:1px solid var(--border)}
+.mo-hero-stage::after{content:'';position:absolute;inset:0;background-image:radial-gradient(color-mix(in srgb,var(--text-primary) 8%,transparent) 1px,transparent 1.2px);background-size:22px 22px;-webkit-mask-image:radial-gradient(closest-side,transparent 40%,#000);mask-image:radial-gradient(closest-side,transparent 40%,#000);pointer-events:none}
+.mo-hero-stage .ov-mark{position:relative;z-index:1;width:min(260px,62%)!important;height:auto!important;aspect-ratio:1}
+.mo-replay{position:absolute;z-index:2;bottom:14px;left:50%;transform:translateX(-50%);min-height:38px;padding:0 16px;border-radius:999px;border:1px solid var(--border);background:color-mix(in srgb,var(--bg-primary) 70%,transparent);backdrop-filter:blur(8px);color:var(--text-primary);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer}
+.mo-replay:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}
+.mo-replay:focus-visible,.mo-btn:focus-visible,.mo-card:focus-visible,.mo-poster:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
+.mo-eye{margin:0 0 10px;color:var(--accent);font-size:12px;font-weight:750;letter-spacing:.12em;text-transform:uppercase}
+.mo-h1{margin:0;font-family:var(--mo-face);font-weight:900;font-size:clamp(36px,6vw,64px);line-height:1.02;letter-spacing:-.02em;text-wrap:balance}
+.mo-lede{margin:16px 0 0;max-width:54ch;color:var(--text-secondary);font-size:clamp(15px,1.6vw,17px);line-height:1.6}
+.mo-acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}
+
+/* Sections, as on /brand. */
+.mo-sec{padding:clamp(26px,4vw,44px) 0;border-top:1px solid var(--border)}
+.mo-sec-head{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:8px 32px;align-items:baseline;margin-bottom:22px}
+.mo-sec-head h2{margin:0;font-family:var(--mo-face);font-weight:900;font-size:clamp(22px,3vw,30px);line-height:1.15;letter-spacing:-.01em;text-wrap:balance}
+.mo-sec-head p{margin:0;max-width:60ch;color:var(--text-secondary);line-height:1.55}
+.mo h3{margin:0 0 10px;font-size:14px;font-weight:750}
+
+/* The effects: a stage for each, its name, what it says and the word that turns it on. */
+.mo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(196px,1fr));gap:14px}
+.mo-fx{margin:0;display:grid;grid-template-rows:auto 1fr;border-radius:22px;border:1px solid var(--border);background:var(--bg-card,var(--bg-secondary));overflow:hidden;min-width:0}
+.mo-fx-stage{display:grid;place-items:center;min-height:170px;padding:22px;background:radial-gradient(80% 70% at 50% 40%,color-mix(in srgb,var(--accent) 9%,transparent),transparent),var(--bg-secondary);border-bottom:1px solid var(--border)}
+.mo-fx figcaption{display:grid;align-content:start;gap:6px;padding:14px 16px 16px}
+.mo-fx figcaption b{font-size:15px}
+.mo-fx figcaption span{color:var(--text-secondary);font-size:13.5px;line-height:1.5}
+.mo-fx figcaption code{justify-self:start;margin-top:4px;font-size:12px}
+.mo-btn{min-height:44px;padding:0 20px;border-radius:12px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:14.5px;font-weight:700;cursor:pointer}
+.mo-btn:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}
+.mo-btn-strong{background:var(--accent-strong,var(--accent));border-color:transparent;color:var(--on-accent-strong,#fff)}
+.mo-card{display:flex;align-items:center;gap:10px;width:100%;max-width:250px;padding:10px 12px;border-radius:16px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);text-decoration:none}
+.mo-card-art{flex:none;width:40px;height:40px;display:grid;place-items:center;border-radius:12px;background:var(--bg-secondary)}
+.mo-card-art svg{width:30px;height:30px}
+.mo-card span:last-child{display:grid;gap:2px;min-width:0}
+.mo-card b{font-size:14px}
+.mo-card small{color:var(--text-muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.mo-poster{display:grid;justify-items:start;align-content:end;gap:2px;width:min(170px,100%);aspect-ratio:4/5;padding:16px;border-radius:18px;color:#fff;background:radial-gradient(90% 60% at 30% 25%,color-mix(in srgb,var(--mo-o) 85%,#fff 0%),transparent 70%),linear-gradient(160deg,var(--mo-o),var(--mo-v) 70%,#2e1065);box-shadow:0 18px 40px -18px color-mix(in srgb,var(--mo-v) 70%,transparent)}
+.mo-poster-art{margin-bottom:auto}
+.mo-poster-art svg{width:44px;height:44px}
+.mo-poster b{font-family:var(--mo-face);font-weight:900;font-size:17px}
+.mo-poster small{opacity:.85;font-size:12px}
+.mo-list{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;justify-content:center;gap:8px}
+.mo-list li{padding:8px 14px;border-radius:999px;border:1px solid var(--border);background:var(--bg-primary);font-size:13.5px;font-weight:650}
+.mo-num{margin:0;display:grid;justify-items:center;gap:4px;text-align:center}
+.mo-num b{font-family:var(--mo-face);font-weight:900;font-size:clamp(34px,4.2vw,46px);letter-spacing:-.02em;font-variant-numeric:tabular-nums;background:linear-gradient(120deg,var(--mo-o),var(--mo-v));-webkit-background-clip:text;background-clip:text;color:transparent}
+.mo-num span{color:var(--text-secondary);font-size:13px}
+.mo-skel{display:grid;gap:10px;width:100%;max-width:230px}
+.mo-skel span{display:block;height:14px;border-radius:7px;background:color-mix(in srgb,var(--text-muted) 14%,transparent)}
+.mo-skel span:first-child{height:96px;border-radius:14px}
+.mo-skel span:last-child{width:62%}
+
+/* The timing: each curve drawn, with a dot that races along it; the lengths as bars. */
+.mo-curves{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}
+.mo-curve{display:grid;gap:8px;padding:16px;border-radius:20px;border:1px solid var(--border);background:var(--bg-secondary);min-width:0}
+.mo-curve svg{width:100%;max-width:200px;height:auto;justify-self:center}
+.mo-curve-grid{fill:none;stroke:var(--border);stroke-width:1}
+.mo-curve-line{fill:none;stroke:var(--accent);stroke-width:3.5;stroke-linecap:round}
+.mo-curve p{margin:0;font-size:14px}
+.mo-curve-use{color:var(--text-secondary);font-size:13px!important;line-height:1.5}
+.mo-race{position:relative;height:10px;border-radius:5px;background:color-mix(in srgb,var(--text-muted) 14%,transparent)}
+.mo-race i{position:absolute;top:50%;left:0;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:linear-gradient(135deg,var(--mo-o),var(--mo-v));box-shadow:0 0 0 3px var(--bg-secondary)}
+.mo-race.is-run i{left:100%;transition:left 1.1s var(--mo-ease,ease)}
+.mo-durs{list-style:none;margin:16px 0 14px;padding:0;display:grid;gap:8px}
+.mo-durs li{display:grid;grid-template-columns:110px minmax(0,1fr) 64px minmax(0,150px);gap:12px;align-items:center;font-size:13.5px}
+.mo-dur-bar{height:8px;width:var(--w);border-radius:4px;background:linear-gradient(90deg,var(--mo-o),var(--mo-v))}
+.mo-durs small{color:var(--text-secondary)}
+
+/* The rules. */
+.mo-rules{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}
+.mo-rule{padding:16px 18px;border-radius:18px;border:1px solid var(--border);background:var(--bg-secondary)}
+.mo-rule b{font-size:15px}
+.mo-rule p{margin:6px 0 0;color:var(--text-secondary);font-size:13.5px;line-height:1.55}
+
+/* Use it. */
+.mo-use{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}
+.mo-use>div{min-width:0}
+
+@media (max-width:760px){
+ .mo-hero,.mo-sec-head{grid-template-columns:1fr}
+ /* On a phone the effects and the rules run sideways, every one of them there to swipe to. */
+ .mo-grid,.mo-rules,.mo-curves{grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:minmax(250px,80%);overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;padding-bottom:8px;scrollbar-width:thin}
+ .mo-grid>*,.mo-rules>*,.mo-curves>*{scroll-snap-align:start}
+ .mo-hero-stage{max-height:none}
+ .mo-durs li{grid-template-columns:96px minmax(0,1fr) 56px}
+ .mo-durs small{display:none}
+}
+@media (prefers-reduced-motion:reduce){.mo-race.is-run i{transition:none}}
+html[data-ov-motion=reduced] .mo-race.is-run i{transition:none}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+/**
+ * /motion's conveniences (server/views/motion.js): "Play it again" replays the hero mark's intro (OVMark.intro), and
+ * "Race the curves" sends a dot along each shared curve at once, so the three can be told apart. The effects
+ * themselves are openvibe-shared/ov-fx's (the navbar loads it for data-fx). Without JavaScript both buttons stay hidden.
+ */
+(function () {
+ 'use strict';
+ const replay = document.querySelector('[data-mo-replay]');
+ const hero = document.querySelector('[data-mo-hero]');
+ if (replay && hero) {
+ replay.hidden = false;
+ replay.addEventListener('click', () => { if (window.OVMark && window.OVMark.intro) window.OVMark.intro(hero); });
+ }
+ const run = document.querySelector('[data-mo-run]');
+ const races = Array.from(document.querySelectorAll('[data-mo-race]'));
+ if (run && races.length) {
+ run.hidden = false;
+ run.addEventListener('click', () => {
+ races.forEach((r) => r.classList.remove('is-run'));
+ void document.body.offsetWidth; // start from the left again
+ races.forEach((r) => r.classList.add('is-run'));
+ });
+ }
+})();
@@ -34,6 +34,7 @@ function publicPages(themes = []) {
@@ -34,6 +34,7 @@ function publicPages(themes = []) {
{ 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: '/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: '/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: '/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: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ 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.' },
{ 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.' },
@@ -26,6 +26,7 @@ const themeTokens = require('../tokens');
@@ -26,6 +26,7 @@ const themeTokens = require('../tokens');
const designView = require('../views/design');
const designView = require('../views/design');
const patternsView = require('../views/patterns');
const patternsView = require('../views/patterns');
const brandView = require('../views/brand');
const brandView = require('../views/brand');
+const motionView = require('../views/motion');
const { baseUrl: layoutBase } = require('../render/layout');
const { baseUrl: layoutBase } = require('../render/layout');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
@@ -72,6 +73,8 @@ function createPageRoutes(ctx) {
@@ -72,6 +73,8 @@ function createPageRoutes(ctx) {
if (!svg) return page(res, 404, views.notFound(), req.viewer);
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);
res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*').type('image/svg+xml').send(svg);
});
});
+ // ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──
+ r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));
r.get('/patterns', (req, res) => page(res, 200, patternsView.index(), req.viewer));
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/mark/studies', (req, res) => res.sendFile(path.join(__dirname, '../../public/mark-studies.html')));
r.get('/patterns/:slug', (req, res) => {
r.get('/patterns/:slug', (req, res) => {
@@ -29,6 +29,7 @@ const NAV = [
@@ -29,6 +29,7 @@ const NAV = [
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
+ { label: 'Motion', href: '/motion', icon: 'fa-wand-magic-sparkles', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+'use strict';
+/**
+ * /motion: how OpenVibe moves (plan L45). The mark's motion (openvibe-shared 4.65.0) and the network's effects kit
+ * (openvibe-shared/ov-fx, 4.66.0) in one place: every effect live, with the line of markup that turns it on; the
+ * timing every site shares (the motion tokens) drawn as curves; the rules; and the code to use it anywhere.
+ *
+ * Server-rendered and whole without JavaScript: the demos are plain markup with data-fx, which the navbar's ov-fx.js
+ * brings to life; motion.js only adds the replay, the timing races and the copy buttons' neighbours.
+ *
+ * motionPage({ base }) base: this site's origin, for the snippets
+ * EFFECTS the effects shown, in order: { id, name, says, fx, demo(kind) }
+ * CURVES the shared curves: { token, name, bezier, use }
+ */
+const kit = require('openvibe-shared/kit');
+const mark = require('openvibe-shared/mark');
+const { layout, esc } = require('../render/layout');
+
+let n = 0;
+const art = (o = {}) => mark.svg({ id: `mo${++n}`, title: 'OpenVibe', ...o });
+const live = (attrs = '', o = {}) => `<span class="ov-mark" ${attrs}>${art(o)}</span>`;
+
+const EFFECTS = Object.freeze([
+ { id: 'glint', name: 'Glint', says: 'A band of light crosses it when you point at it. For the one thing on a page you want found.', fx: 'glint', demo: 'button' },
+ { id: 'glow', name: 'Glow', says: 'The accent glows up behind it. For cards and tiles that open something.', fx: 'glow', demo: 'card' },
+ { id: 'lift', name: 'Lift', says: 'It rises a little, with a softer shadow, and settles on a spring.', fx: 'lift press', demo: 'card' },
+ { id: 'press', name: 'Press', says: 'It gives under your finger and springs back. Every button should.', fx: 'press', demo: 'button' },
+ { id: 'ripple', name: 'Ripple', says: 'A ring spreads from the exact point you pressed, in the text colour.', fx: 'ripple press', demo: 'button' },
+ { id: 'tilt', name: 'Tilt', says: 'It leans toward the pointer, like a card in the hand. Mouse only.', fx: 'tilt glint', demo: 'poster' },
+ { id: 'reveal', name: 'Reveal', says: 'Its items rise in, one after another, the first time they come on screen.', fx: 'reveal', demo: 'list' },
+ { id: 'count', name: 'Count', says: 'A number counts up when it is first seen, and ends on exactly its own text.', fx: 'count', demo: 'number' },
+ { id: 'confetti', name: 'Confetti', says: 'For real moments only: a first stream, a tip, a goal reached.', fx: 'confetti press', demo: 'cta' },
+ { id: 'shimmer', name: 'Shimmer', says: 'A placeholder that breathes until its content lands. Never a spinning ring.', fx: 'shimmer', demo: 'skeleton' },
+]);
+
+// The curves every site shares (openvibe-shared/ov-fx's tokens), drawn from their cubic-bezier points.
+const CURVES = Object.freeze([
+ { token: '--ov-ease-out', name: 'Out', bezier: [0.2, 0.8, 0.2, 1], use: 'Things arriving: a panel opening, a tooltip, a reveal.' },
+ { token: '--ov-ease-spring', name: 'Spring', bezier: [0.34, 1.56, 0.64, 1], use: 'Things you touched: a press springing back, a lift, the mark’s V.' },
+ { token: '--ov-ease-in-out', name: 'In and out', bezier: [0.65, 0, 0.35, 1], use: 'Things crossing: a glint, a slide from one place to another.' },
+]);
+const DURATIONS = Object.freeze([
+ ['--ov-dur-1', '120 ms', 'a press'], ['--ov-dur-2', '200 ms', 'a hover'], ['--ov-dur-3', '320 ms', 'a panel or a lift'], ['--ov-dur-4', '520 ms', 'a spring settling'],
+]);
+
+/** One effect's live demo, as markup a reader could copy. */
+function demo(e) {
+ const fx = `data-fx="${esc(e.fx)}"`;
+ switch (e.demo) {
+ case 'button': return `<button type="button" class="mo-btn" ${fx}>${e.id === 'glint' ? 'Go live' : e.id === 'ripple' ? 'Press anywhere on me' : 'Press me'}</button>`;
+ case 'card': return `<a class="mo-card" href="#mo-${esc(e.id)}" ${fx}><span class="mo-card-art">${art({})}</span><span><b>Goosely is live</b><small>Geese at dawn · 41 watching</small></span></a>`;
+ case 'poster': return `<div class="mo-poster" ${fx} tabindex="0"><span class="mo-poster-art">${art({ tone: 'white' })}</span><b>OpenVibe.Live</b><small>Go live from your browser</small></div>`;
+ case 'list': return `<ul class="mo-list" ${fx} data-fx-stagger="70"><li>Live</li><li>Design</li><li>Codes</li><li>Tools</li><li>Games</li></ul>`;
+ case 'number': return `<p class="mo-num"><b ${fx}>12,480</b><span>people went live from a browser</span></p>`;
+ case 'cta': return `<button type="button" class="mo-btn mo-btn-strong" ${fx}>Send the tip</button>`;
+ case 'skeleton': return `<div class="mo-skel" aria-hidden="true"><span ${fx}> </span><span ${fx}> </span><span ${fx}> </span></div>`;
+ default: return '';
+ }
+}
+
+/** A cubic-bezier as an SVG path in a 100 × 100 box (y up), padded so a spring's overshoot shows. */
+function curvePath([x1, y1, x2, y2]) {
+ const X = (x) => +(x * 100).toFixed(1), Y = (y) => +(100 - y * 100).toFixed(1);
+ return `M0 100C${X(x1)} ${Y(y1)} ${X(x2)} ${Y(y2)} 100 0`;
+}
+
+function motionPage({ base = 'https://openvibe.design' } = {}) {
+ n = 0;
+ const hero = `<header class="mo-hero">
+ <div class="mo-hero-stage">${live('data-size="260" data-glow data-mo-hero')}<button type="button" class="mo-replay" data-mo-replay hidden>Play it again</button></div>
+ <div class="mo-hero-words">
+ <p class="mo-eye">Motion</p>
+ <h1 class="mo-h1">Motion that means something.</h1>
+ <p class="mo-lede">The mark draws its ring, drops its V in on a spring and catches the light once. Everything else on OpenVibe moves the same way: one timing, one set of effects, shared by every site. Nothing spins, nothing loops, and with Calm on, nothing moves at all.</p>
+ <div class="mo-acts">${kit.button({ href: '#use', label: 'Use it on your site' })}${kit.button({ href: '/brand', label: 'The mark', kind: 'quiet' })}</div>
+ </div>
+</header>`;
+
+ const effects = `<section class="mo-sec" aria-labelledby="mo-fx">
+ <div class="mo-sec-head"><h2 id="mo-fx">The effects</h2><p>Each one is a word in <code>data-fx</code>, and words combine: <code>data-fx="lift press"</code>. Point at them, press them, scroll to them.</p></div>
+ <div class="mo-grid">${EFFECTS.map((e) => `<figure class="mo-fx" id="mo-${esc(e.id)}">
+ <div class="mo-fx-stage">${demo(e)}</div>
+ <figcaption><b>${esc(e.name)}</b><span>${esc(e.says)}</span><code>data-fx="${esc(e.fx)}"</code></figcaption>
+ </figure>`).join('\n ')}</div>
+</section>`;
+
+ const timing = `<section class="mo-sec" aria-labelledby="mo-time">
+ <div class="mo-sec-head"><h2 id="mo-time">The timing</h2><p>Three curves and four lengths, as CSS variables on every OpenVibe page. A look can bring its own, and Calm sets every length to zero.</p></div>
+ <div class="mo-curves">${CURVES.map((c) => `<div class="mo-curve">
+ <svg viewBox="-8 -28 116 136" aria-hidden="true"><path class="mo-curve-grid" d="M0 0H100M0 100H100M0 0V100M100 0V100"/><path class="mo-curve-line" d="${curvePath(c.bezier)}"/></svg>
+ <div class="mo-race" data-mo-race style="--mo-ease:var(${esc(c.token)})"><i></i></div>
+ <p><b>${esc(c.name)}</b> <code>${esc(c.token)}</code></p>
+ <p class="mo-curve-use">${esc(c.use)}</p>
+ </div>`).join('')}</div>
+ <ul class="mo-durs">${DURATIONS.map(([t, v, use]) => `<li><code>${esc(t)}</code><span class="mo-dur-bar" style="--w:${parseInt(v, 10) / 5.2}%"></span><b>${esc(v)}</b><small>${esc(use)}</small></li>`).join('')}</ul>
+ <button type="button" class="mo-btn" data-mo-run hidden>Race the curves</button>
+</section>`;
+
+ const rule = (title, text) => `<div class="mo-rule"><b>${title}</b><p>${text}</p></div>`;
+ const rules = `<section class="mo-sec" aria-labelledby="mo-rules">
+ <div class="mo-sec-head"><h2 id="mo-rules">The rules</h2><p>So that motion stays a signal, never noise.</p></div>
+ <div class="mo-rules">
+ ${rule('It means something.', 'Every move says one thing: this is new, this worked, this is yours to press. A move that says nothing is cut.')}
+ ${rule('Nothing spins.', 'A wait is a shimmer, a bob or a ring drawing round, never a circle chasing its tail.')}
+ ${rule('Once, then still.', 'Effects play on a hover, a press or the first time something is seen, then rest. Nothing runs on its own, and nothing moves off screen.')}
+ ${rule('Calm wins.', 'With reduced motion or Calm on, nothing moves, nothing counts and no confetti falls. The page looks the same, only still.')}
+ ${rule('Never hide to animate.', 'What is on screen when the page opens is never hidden for an entrance. Only what is further down rises in.')}
+ ${rule('One kit.', 'Every site uses the same effects and the same timing, so the network feels like one place.')}
+ </div>
+</section>`;
+
+ const useHtml = `<script src="${base}/shared/ov-fx.js" defer></script>
+
+<a class="card" data-fx="lift press glow">…</a>
+<b data-fx="count">12,480</b>
+<button data-fx="confetti press">Send the tip</button>`;
+ const useJs = `// From script, for a moment of your own:
+OVFx.confetti({ x: 200, y: 120 });
+
+// Markup added later is wired by itself; or ask for it:
+OVFx.scan(document.querySelector('#feed'));`;
+ const useCss = `.panel {
+ transition: transform var(--ov-dur-3) var(--ov-ease-spring),
+ opacity var(--ov-dur-2) var(--ov-ease-out);
+}`;
+ const use = `<section class="mo-sec" id="use" aria-labelledby="mo-use">
+ <div class="mo-sec-head"><h2 id="mo-use">Use it</h2><p>On OpenVibe sites the navbar loads the kit by itself, only on a page that asks with <code>data-fx</code>. Anywhere else, one script tag. Open source (MIT).</p></div>
+ <div class="mo-use">
+ <div><h3>On a web page</h3>${kit.snippet({ code: useHtml, id: 'mo-use-web', label: 'Copy the HTML' })}</div>
+ <div><h3>From script</h3>${kit.snippet({ code: useJs, id: 'mo-use-js', label: 'Copy the code' })}</div>
+ <div><h3>The timing in your own CSS</h3>${kit.snippet({ code: useCss, id: 'mo-use-css', label: 'Copy the CSS' })}</div>
+ </div>
+</section>`;
+
+ const body = `<article class="mo">${hero}${effects}${timing}${rules}${use}</article>`;
+ return layout({
+ title: 'Motion',
+ description: 'How OpenVibe moves: the mark’s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules, and the code to use it anywhere.',
+ path: '/motion',
+ active: '/motion',
+ styles: ['css/motion.css'],
+ scripts: ['js/motion.js'],
+ body,
+ });
+}
+
+module.exports = { motionPage, EFFECTS, CURVES, curvePath };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+// /motion: how OpenVibe moves (plan L45). The page is whole without JavaScript: every effect is shown as plain markup
+// with its data-fx words (the navbar's ov-fx.js brings them to life), with what each one says and the word to copy;
+// the shared curves are drawn from their real cubic-bezier points; the rules and the code are there; and the page is
+// listed in the menu and the sitemap.
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const { EFFECTS, CURVES, curvePath } = require('../server/views/motion');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('every effect is live, explained, and one word to copy', async () => {
+ const res = await t.get('/motion');
+ assert.equal(res.status, 200);
+ const html = res.text;
+ assert.ok(html.includes('<h1 class="mo-h1">Motion that means something.</h1>'));
+ assert.ok(html.includes('data-mo-hero') && html.includes('data-glow'), 'the hero mark, glowing, for ov-mark.js');
+ for (const e of EFFECTS) {
+ assert.ok(html.includes(`id="mo-${e.id}"`), `${e.name}: its card`);
+ assert.ok(html.includes(`data-fx="${e.fx}"`), `${e.name}: live markup`);
+ assert.ok(new RegExp(`<code>data-fx=("|"|")${e.fx}("|"|")</code>`).test(html), `${e.name}: the words to copy`);
+ }
+ assert.ok(/data-fx="[^"]*"/.test(html), 'the navbar loads ov-fx.js for this page');
+ assert.ok(html.includes('Nothing spins.') && html.includes('Calm wins.') && html.includes('Never hide to animate.'), 'the rules');
+ assert.ok(/\/shared\/ov-fx\.js("|") defer><\/script>/.test(html) && html.includes('OVFx.confetti'), 'the code');
+ assert.ok(html.includes('css/motion.css') && html.includes('js/motion.js'), 'its own style and script');
+ });
+ await check('the curves are drawn from their points, and the timing is the kit\'s', async () => {
+ const html = (await t.get('/motion')).text;
+ for (const c of CURVES) {
+ assert.ok(html.includes(`d="${curvePath(c.bezier)}"`), `${c.name}: its curve`);
+ assert.ok(html.includes(`--mo-ease:var(${c.token})`), `${c.name}: the dot races on the shared token`);
+ }
+ assert.equal(curvePath([0.34, 1.56, 0.64, 1]), 'M0 100C34 -56 64 0 100 0', 'a spring overshoots above the box');
+ for (const tok of ['--ov-dur-1', '--ov-dur-2', '--ov-dur-3', '--ov-dur-4']) assert.ok(html.includes(`<code>${tok}</code>`), tok);
+ });
+ await check('the script only adds conveniences, and the route and listing are wired', async () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/motion.js'), 'utf8');
+ assert.match(js, /replay\.hidden = false/, 'Play it again appears only with JavaScript');
+ assert.match(js, /OVMark\.intro\(hero\)/, 'and replays the mark\'s own intro');
+ assert.match(js, /classList\.add\('is-run'\)/, 'the curves race on a press');
+ const pages = fs.readFileSync(path.join(__dirname, '..', 'server/http/pages.js'), 'utf8');
+ assert.match(pages, /r\.get\('\/motion'/);
+ const discovery = fs.readFileSync(path.join(__dirname, '..', 'server/http/discovery.js'), 'utf8');
+ assert.match(discovery, /path: '\/motion'/);
+ });
+ await check('it is in the menu and the sitemap', async () => {
+ const home = (await t.get('/')).text;
+ assert.ok(home.includes('href="/motion"'), 'the menu');
+ const map = (await t.get('/sitemap.xml')).text;
+ assert.ok(map.includes('/motion</loc>'), 'the sitemap');
+ });
+ } finally {
+ await done(t);
+ }
+})();