openvibers/openvibe.shared Commits
4.66.0: ov-fx.js, the network's effects in one kit; the static mark, app icons and share cards with depth (#218)
Changes
@@@ -6,6 +6,30 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.66.0 — 2026-10-31++++- **`ov-fx.js`, the network's effects in one kit** (plan L45; the owner: "reusable modular sharable systems and++ interface"). The mark's motion is now available for anything on any OpenVibe page:++ - **API:** `data-fx="glint glow lift press ripple tilt reveal count confetti shimmer"`, and++ `OVFx.confetti({ x, y, count, colors })` from script;++ - **reveal:** children rise in as they come on screen (`data-fx-stagger`). Anything already on screen when it is++ wired is never hidden;++ - **count:** a number counts up the first time it is seen and ends exactly on its own text;++ - **tilt:** mouse only;++ - **confetti:** one canvas, about 1.6 s, then gone;++ - **motion tokens:** `--ov-dur-1…4`, `--ov-ease-out`, `--ov-ease-spring` and `--ov-ease-in-out`. Calm sets the++ durations to 0;++ - **rules:** nothing runs by itself. Under reduced motion or Calm, nothing is hidden, nothing counts and no confetti++ falls;++ - **loading:** navbar.js loads it from beside itself once the page settles, and only on a page with `data-fx`++ (about 2.6 KB brotli). Also exported as `openvibe-shared/ov-fx`.++- **The static mark has the new depth** (plan L44):++ - `openvibe-shared/mark` takes `depth`, on by default for the brand tone. Each stroke runs from a lighter to a deeper++ tone of its colour, the same mixes `ov-mark.js` uses (`tones(o, v)`), in user space so a straight "!" still paints.++ One-colour tones stay flat;++ - `app-icon.js` draws with it, so the installable icons (`icons/`) and the share cards (`social/`) are rebuilt with++ the deeper mark.++## 4.65.0 — 2026-10-31- **The mark, with depth and motion** (plan L44; the owner: "the stylistic choices of the logo image need more@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.65.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.66.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.65.0",++ "version": "4.66.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -29,14 +29,14 @@ const MARK = require('./mark');// The mark centred on 0,0 (openvibe-shared/mark draws it centred on 50,50 in a 100-unit box), scaled.function mark(site, scale, form = 'pair') {-- const c = of(site), g = MARK.geo(form), { body } = MARK.art({ form, id: 'ov' });++ const c = of(site), g = MARK.geo(form), { body } = MARK.art({ form, id: 'ov', depth: true });const light = c.dot && c.dot.toLowerCase() !== '#ffffff' ? `<circle cx="${g.cx}" cy="${g.cy}" r="${+((g.r - g.sw / 2) * 0.62).toFixed(2)}" fill="${c.dot}"/>` : '';return `<g transform="scale(${scale}) translate(-50 -50)"><ellipse cx="50" cy="50" rx="52" ry="40" fill="url(#glow)"/>${body}${light}</g>`;}function defs(site, form = 'pair') {const [a] = of(site).accent;-- return `<defs>${MARK.art({ form, id: 'ov' }).defs}++ return `<defs>${MARK.art({ form, id: 'ov', depth: true }).defs}<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".3"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient><radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;}@@@ -27,6 +27,7 @@ const BROWSER = Object.freeze(['ov-mark.js', // the OV brand mark drop-in'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)'ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)++ 'ov-fx.js', // the network's effects (data-fx): glint, glow, lift, press, ripple, tilt, reveal, count, confetti'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)'history.js', // cross-site history'sso-client.js', // link hand-off, FedCMBBinary files a/icons/ai-192.png and b/icons/ai-192.png differBBinary files a/icons/ai-512.png and b/icons/ai-512.png differBBinary files a/icons/ai-maskable-192.png and b/icons/ai-maskable-192.png differBBinary files a/icons/ai-maskable-512.png and b/icons/ai-maskable-512.png differBBinary files a/icons/community-192.png and b/icons/community-192.png differBBinary files a/icons/community-512.png and b/icons/community-512.png differBBinary files a/icons/community-maskable-192.png and b/icons/community-maskable-192.png differBBinary files a/icons/community-maskable-512.png and b/icons/community-maskable-512.png differBBinary files a/icons/games-192.png and b/icons/games-192.png differBBinary files a/icons/games-512.png and b/icons/games-512.png differBBinary files a/icons/games-maskable-192.png and b/icons/games-maskable-192.png differBBinary files a/icons/games-maskable-512.png and b/icons/games-maskable-512.png differBBinary files a/icons/live-192.png and b/icons/live-192.png differBBinary files a/icons/live-512.png and b/icons/live-512.png differBBinary files a/icons/live-maskable-192.png and b/icons/live-maskable-192.png differBBinary files a/icons/live-maskable-512.png and b/icons/live-maskable-512.png differBBinary files a/icons/media-192.png and b/icons/media-192.png differBBinary files a/icons/media-512.png and b/icons/media-512.png differBBinary files a/icons/media-maskable-192.png and b/icons/media-maskable-192.png differBBinary files a/icons/media-maskable-512.png and b/icons/media-maskable-512.png differBBinary files a/icons/network-192.png and b/icons/network-192.png differBBinary files a/icons/network-512.png and b/icons/network-512.png differBBinary files a/icons/network-maskable-192.png and b/icons/network-maskable-192.png differBBinary files a/icons/network-maskable-512.png and b/icons/network-maskable-512.png differBBinary files a/icons/space-192.png and b/icons/space-192.png differBBinary files a/icons/space-512.png and b/icons/space-512.png differBBinary files a/icons/space-maskable-192.png and b/icons/space-maskable-192.png differBBinary files a/icons/space-maskable-512.png and b/icons/space-maskable-512.png differBBinary files a/icons/tools-192.png and b/icons/tools-192.png differBBinary files a/icons/tools-512.png and b/icons/tools-512.png differBBinary files a/icons/tools-maskable-192.png and b/icons/tools-maskable-192.png differBBinary files a/icons/tools-maskable-512.png and b/icons/tools-maskable-512.png differ@@@ -15,10 +15,14 @@// geo(form) → { cx, cy, r, sw, R, vsw, halo, A, T, B, ring, box, wideBox }// shapes(form) → the V's two strokes for each shape it takes: v, ok (a tick), no (a cross),// err (an exclamation mark), tip (folded into its point, for the intro)--// art({ form, o, v, id, shape }) → { defs, body }: the mark's <defs> and drawing, to place inside another SVG--// svg({ form, tone, wide, size, title, id })++// art({ form, o, v, id, shape, depth })++// → { defs, body }: the mark's <defs> and drawing, to place inside another SVG++// svg({ form, tone, wide, size, title, id, depth })// → a standalone SVG. tone: 'brand' (blue O, violet V), 'ink' (currentColor),// 'white', or { o, v }. wide: the box hugs the mark (92 × 56) instead of a square.++// depth (4.66.0; on by default for the brand tone): each stroke runs from a lighter++// to a deeper tone of its colour, as ov-mark.js draws it. Hex colours only; the++// one-colour tones stay flat.// COLORS → { o: '#4f7cff', v: '#8b5cf6' }// ═══════════════════════════════════════════════════════════════@@@ -58,29 +62,45 @@ function shapes(form) {}const stroke = (p, i) => `M${p[i]} ${p[i + 1]}L${p[i + 2]} ${p[i + 3]}`;++const HEX = /^#[0-9a-f]{6}$/i;++/** hex mixed toward `to` by t (0..1): ov-mark.js's color-mix, worked out for files that cannot run CSS. */++function mix(hex, to, t) {++ const a = parseInt(hex.slice(1), 16), b = parseInt(to.slice(1), 16);++ return `#${[16, 8, 0].map((sh) => Math.round(((a >> sh) & 255) * (1 - t) + ((b >> sh) & 255) * t).toString(16).padStart(2, '0')).join('')}`;++}++/** The light-to-deep tones of each stroke: the same mixes as ov-mark.js (O 68% / 84%, V 70% / 86%). */++const tones = (o, v) => ({ o: [mix(o, '#ffffff', 0.32), mix(o, '#000000', 0.16)], v: [mix(v, '#ffffff', 0.30), mix(v, '#000000', 0.14)] });/** The mark's defs and drawing, to place inside another SVG. ids are prefixed with `id` (unique per document). */--function art({ form = 'pair', o = COLORS.o, v = COLORS.v, id = 'ovm', shape = 'v' } = {}) {++function art({ form = 'pair', o = COLORS.o, v = COLORS.v, id = 'ovm', shape = 'v', depth = false } = {}) {const g = geo(form), p = shapes(form)[shape] || shapes(form).v;-- const defs = `<mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/>`++ let defs = `<mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/>`+ `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="#000" stroke-width="${g.halo}" stroke-linecap="round"/></mask>`;-- const body = `<circle cx="${g.cx}" cy="${g.cy}" r="${g.r}" fill="none" stroke="${o}" stroke-width="${g.sw}" mask="url(#${id}h)"/>`-- + `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="${v}" stroke-width="${g.vsw}" stroke-linecap="round" stroke-linejoin="round"/>`;++ let os = o, vs = v;++ if (depth && HEX.test(o) && HEX.test(v)) {++ // In user space: a straight stroke (the "!" shape) has no box for a bounding-box gradient to span.++ const t = tones(o, v);++ defs += `<linearGradient id="${id}go" gradientUnits="userSpaceOnUse" x1="${f(g.cx - g.R)}" y1="${f(g.cy - g.R)}" x2="${f(g.cx + g.R)}" y2="${f(g.cy + g.R)}"><stop offset="0" stop-color="${t.o[0]}"/><stop offset="1" stop-color="${t.o[1]}"/></linearGradient>`++ + `<linearGradient id="${id}gv" gradientUnits="userSpaceOnUse" x1="0" y1="${g.A[1]}" x2="0" y2="${g.T[1]}"><stop offset="0" stop-color="${t.v[0]}"/><stop offset="1" stop-color="${t.v[1]}"/></linearGradient>`;++ os = `url(#${id}go)`; vs = `url(#${id}gv)`;++ }++ const body = `<circle cx="${g.cx}" cy="${g.cy}" r="${g.r}" fill="none" stroke="${os}" stroke-width="${g.sw}" mask="url(#${id}h)"/>`++ + `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="${vs}" stroke-width="${g.vsw}" stroke-linecap="round" stroke-linejoin="round"/>`;return { defs, body };}const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));/** A standalone SVG of the mark. */--function svg({ form = 'pair', tone = 'brand', wide = false, size = null, title = 'OpenVibe', id = 'ovm' } = {}) {++function svg({ form = 'pair', tone = 'brand', wide = false, size = null, title = 'OpenVibe', id = 'ovm', depth = tone === 'brand' } = {}) {const c = typeof tone === 'object' && tone ? tone: tone === 'ink' ? { o: 'currentColor', v: 'currentColor' }: tone === 'white' ? { o: '#fff', v: '#fff' } : COLORS;const g = geo(form), box = wide ? g.wideBox : g.box;const [, , bw, bh] = box.split(' ').map(Number);const dims = size ? ` width="${f(wide ? size * bw / bh : size)}" height="${size}"` : '';-- const { defs, body } = art({ form, o: c.o, v: c.v, id });++ const { defs, body } = art({ form, o: c.o, v: c.v, id, depth });return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box}"${dims} role="img" aria-label="${esc(title)}"><title>${esc(title)}</title><defs>${defs}</defs>${body}</svg>`;}--module.exports = { COLORS, FORMS, geo, shapes, art, svg };++module.exports = { COLORS, FORMS, geo, shapes, art, svg, tones };@@@ -476,7 +476,7 @@const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '82afb8a8cb4d', 'ov-glyphs.js': '2b97058350a5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'f234846e81db' };++ const SIBLING_REV = { 'ov-mark.js': '82afb8a8cb4d', 'ov-glyphs.js': '2b97058350a5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'f234846e81db', 'ov-fx.js': 'de375a22cf17' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -820,6 +820,15 @@if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 4000 });else { const t = setTimeout(go, 2500); if (t && typeof t.unref === 'function') t.unref(); }}++ /** ov-fx.js (4.66.0): the network's effects, loaded once the page settles, only for a page that has data-fx. */++ function queueFx() {++ const go = () => {++ if (root.OVFx || typeof document.querySelector !== 'function' || document.getElementById('ov-fx-loader') || !document.querySelector('[data-fx]')) return;++ try { const sc = document.createElement('script'); sc.id = 'ov-fx-loader'; sc.src = siblingV('ov-fx.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }++ };++ if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 3000 });++ else { const t = setTimeout(go, 1500); if (t && typeof t.unref === 'function') t.unref(); }++ }function bindChips(scope) {(_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {if (!c || typeof c.onClick !== 'function') return;@@@ -1685,6 +1694,7 @@bindChips(nav);bindChipsAll(nav);queueIcons();++ queueFx();countView();// Pages that wire the bell themselves run right after init(); give them the first go.setTimeout(() => { mountBell(nav, u); mountDock(u); }, 0);nnew file mode 100644@@@ -0,0 +1,187 @@++/**++ * openvibe-shared/ov-fx.js — the network's effects, one kit (plan L45, 4.66.0). The mark's motion (4.65.0: a glint,++ * a glow, a press) for anything on any OpenVibe page, plus reveals, counting numbers, a ripple, a tilt and confetti.++ * navbar.js loads it from beside itself once the page settles, and only when the page has an element with data-fx.++ *++ * <a class="card" data-fx="glint lift press">…</a> effects by name, any number of them++ * <ul data-fx="reveal" data-fx-stagger="60">…</ul> its children rise in as they come on screen, 60 ms apart++ * <b data-fx="count">12,480</b> the number counts up the first time it is seen++ * <button data-fx="confetti press">Send tip</button> a burst of confetti from the button when it is pressed++ * OVFx.confetti({ x, y, count, colors }) the same burst from anywhere (a first stream, a goal)++ * OVFx.scan(root) wire effects in markup added later (scanned by itself too)++ *++ * Effects: glint (a band of light crosses on hover), glow (the accent glows behind on hover), lift (rises a little),++ * press (gives on press and springs back), ripple (from the point pressed), tilt (leans toward the pointer; mouse++ * only), reveal, count, confetti, shimmer (a placeholder that shimmers until data-fx is removed).++ *++ * Motion tokens (a look can set them; theme-loader.js passes a theme's own): --ov-dur-1…4, --ov-ease-out,++ * --ov-ease-spring, --ov-ease-in-out. Nothing here runs by itself: every effect waits for a hover, a press, or the++ * element coming on screen, plays once, and stops. Under prefers-reduced-motion or Calm (html[data-ov-motion=reduced])++ * nothing moves: reveals and numbers are simply there, and no confetti falls.++ */++(function (root) {++ 'use strict';++ if (!root || root.OVFx) return;++ const doc = root.document;++ const CSS = ':root{--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s;--ov-ease-out:cubic-bezier(.2,.8,.2,1);--ov-ease-spring:cubic-bezier(.34,1.56,.64,1);--ov-ease-in-out:cubic-bezier(.65,0,.35,1)}'++ + 'html[data-ov-motion=reduced]{--ov-dur-1:0s;--ov-dur-2:0s;--ov-dur-3:0s;--ov-dur-4:0s}'++ + '.ovfx-glint{position:relative;overflow:hidden;isolation:isolate}'++ + '.ovfx-glint::after{content:"";position:absolute;inset:-2px;pointer-events:none;background:linear-gradient(105deg,transparent 35%,rgba(255,255,255,.28) 50%,transparent 65%);transform:translateX(-120%);z-index:1}'++ + '.ovfx-glint:hover::after,.ovfx-glint:focus-visible::after{transform:translateX(120%);transition:transform .75s var(--ov-ease-in-out)}'++ + '.ovfx-glow{transition:box-shadow var(--ov-dur-3) var(--ov-ease-out)}'++ + '.ovfx-glow:hover,.ovfx-glow:focus-visible{box-shadow:0 0 0 1px color-mix(in srgb,var(--accent,#4f7cff) 45%,transparent),0 10px 34px -10px color-mix(in srgb,var(--accent,#4f7cff) 70%,transparent)}'++ + '.ovfx-lift{transition:transform var(--ov-dur-3) var(--ov-ease-spring),box-shadow var(--ov-dur-3) var(--ov-ease-out)}'++ + '.ovfx-lift:hover{transform:translateY(-3px);box-shadow:0 12px 28px -14px rgba(0,0,0,.45)}'++ + '.ovfx-press{transition:transform var(--ov-dur-4) var(--ov-ease-spring)}'++ + '.ovfx-press:active{transform:scale(.96);transition-duration:var(--ov-dur-1)}'++ + '.ovfx-lift.ovfx-press:active{transform:translateY(-1px) scale(.97)}'++ + '.ovfx-ripple{position:relative;overflow:hidden;isolation:isolate}'++ + '.ovfx-rip{position:absolute;border-radius:50%;pointer-events:none;background:currentColor;opacity:.22;transform:scale(0);animation:ovfxRip .6s var(--ov-ease-out) forwards}'++ + '@keyframes ovfxRip{to{transform:scale(1);opacity:0}}'++ + '.ovfx-tilt{transition:transform var(--ov-dur-3) var(--ov-ease-out);transform-style:preserve-3d;will-change:transform}'++ + '.ovfx-hide{opacity:0;transform:translateY(14px)}'++ + '.ovfx-in{opacity:1;transform:none;transition:opacity .5s var(--ov-ease-out),transform .6s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}'++ + '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'++ + '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'++ + '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'++ + '@media (prefers-reduced-motion:reduce){.ovfx-glint::after{display:none}.ovfx-lift,.ovfx-press,.ovfx-tilt,.ovfx-in{transition:none!important}.ovfx-shimmer{animation:none}}'++ + 'html[data-ov-motion=reduced] .ovfx-glint::after{display:none}html[data-ov-motion=reduced] .ovfx-shimmer{animation:none}html[data-ov-motion=reduced] .ovfx-in{transition:none}';++++ const reduced = () => (root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches) || doc.documentElement.getAttribute('data-ov-motion') === 'reduced';++ const fine = () => !!(root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches);++ const done = new WeakSet();++ let io = null;++++ function css() {++ if (doc.getElementById('ov-fx-css')) return;++ const st = doc.createElement('style'); st.id = 'ov-fx-css'; st.textContent = CSS; (doc.head || doc.documentElement).appendChild(st);++ }++++ // ── Seen: reveal and count, once each, when they come on screen ──++ function seen(el) {++ if (el.__ovfxReveal) {++ const kids = el.__ovfxReveal;++ kids.forEach((k) => { k.classList.add('ovfx-in'); k.classList.remove('ovfx-hide'); });++ root.setTimeout(() => kids.forEach((k) => { k.classList.remove('ovfx-in'); k.style.removeProperty('--ovfx-d'); }), 1400 + kids.length * 80);++ el.__ovfxReveal = null;++ }++ if (el.__ovfxCount) { const c = el.__ovfxCount; el.__ovfxCount = null; countUp(el, c); }++ }++ function observe(el) {++ if (!('IntersectionObserver' in root)) return seen(el);++ io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); seen(e.target); } }), { rootMargin: '0px 0px -8% 0px' });++ io.observe(el);++ }++ const below = (el) => { try { return el.getBoundingClientRect().top > (root.innerHeight || 0); } catch (e) { return false; } };++++ function countUp(el, c) {++ const t0 = root.performance ? root.performance.now() : Date.now(), ms = Math.min(1600, 500 + Math.log10(Math.max(10, c.to)) * 220);++ const step = (now) => {++ const p = Math.min(1, ((now || Date.now()) - t0) / ms), e = 1 - Math.pow(1 - p, 3);++ el.textContent = p < 1 ? c.fmt(c.to * e) : c.text;++ if (p < 1) root.requestAnimationFrame(step);++ };++ root.requestAnimationFrame(step);++ }++ function countOf(el) {++ const text = el.textContent;++ const m = /^(\D*?)(\d(?:[\d.,\s ]*\d)?)(\D*)$/.exec(text.trim());++ if (!m) return null;++ const digits = m[2].replace(/[\s ]/g, '');++ const dec = /[.,]\d{1,2}$/.test(digits) && !/^\d{1,3}([.,]\d{3})+$/.test(digits) ? digits.slice(-3).replace(/^\D/, '').length : 0;++ const to = Number(dec ? digits.replace(/[.,](?=\d{3}(\D|$))/g, '').replace(',', '.') : digits.replace(/[.,]/g, ''));++ if (!Number.isFinite(to) || to <= 0) return null;++ const nf = new Intl.NumberFormat(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec });++ return { to, text, fmt: (v) => m[1] + nf.format(v) + m[3] };++ }++++ // ── Pointer effects ──++ function ripple(el, ev) {++ if (reduced()) return;++ const r = el.getBoundingClientRect(), d = Math.max(r.width, r.height) * 2.2;++ const s = doc.createElement('span'); s.className = 'ovfx-rip';++ s.style.cssText = `width:${d}px;height:${d}px;left:${(ev.clientX || r.left + r.width / 2) - r.left - d / 2}px;top:${(ev.clientY || r.top + r.height / 2) - r.top - d / 2}px`;++ el.appendChild(s); root.setTimeout(() => s.remove(), 650);++ }++ function tilt(el) {++ let raf = 0;++ el.addEventListener('pointermove', (ev) => {++ if (ev.pointerType !== 'mouse' || reduced() || raf) return;++ raf = root.requestAnimationFrame(() => {++ raf = 0;++ const r = el.getBoundingClientRect(), x = (ev.clientX - r.left) / r.width - 0.5, y = (ev.clientY - r.top) / r.height - 0.5;++ el.style.transform = `perspective(900px) rotateX(${(-y * 6).toFixed(2)}deg) rotateY(${(x * 7).toFixed(2)}deg)`;++ });++ });++ el.addEventListener('pointerleave', () => { if (raf) { root.cancelAnimationFrame(raf); raf = 0; } el.style.transform = ''; });++ }++++ // ── Confetti: one canvas, about 1.6 s, then gone ──++ function confetti(opts) {++ if (reduced() || !doc.body) return false;++ const o = opts || {}, cs = root.getComputedStyle(doc.documentElement);++ const accent = (cs.getPropertyValue('--accent') || '').trim() || '#4f7cff';++ const colors = o.colors || [accent, '#4f7cff', '#8b5cf6', '#22c55e', '#facc15', '#f472b6'];++ const cv = doc.createElement('canvas'); cv.className = 'ovfx-confetti'; cv.setAttribute('aria-hidden', 'true');++ const dpr = Math.min(2, root.devicePixelRatio || 1), W = root.innerWidth, H = root.innerHeight;++ cv.width = W * dpr; cv.height = H * dpr; doc.body.appendChild(cv);++ const ctx = cv.getContext('2d'); if (!ctx) { cv.remove(); return false; }++ ctx.scale(dpr, dpr);++ const x0 = o.x == null ? W / 2 : o.x, y0 = o.y == null ? H / 3 : o.y, n = Math.min(160, o.count || 80);++ const ps = Array.from({ length: n }, (_, i) => {++ const a = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9, v = 6 + Math.random() * 7;++ return { x: x0, y: y0, vx: Math.cos(a) * v, vy: Math.sin(a) * v, r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.3, w: 5 + Math.random() * 5, h: 3 + Math.random() * 4, c: colors[i % colors.length], round: Math.random() < 0.3 };++ });++ const t0 = Date.now();++ const frame = () => {++ const t = (Date.now() - t0) / 1600;++ ctx.clearRect(0, 0, W, H);++ ps.forEach((p) => {++ p.vy += 0.28; p.vx *= 0.985; p.vy *= 0.985; p.x += p.vx; p.y += p.vy; p.r += p.vr;++ ctx.save(); ctx.globalAlpha = Math.max(0, 1 - Math.max(0, t - 0.6) / 0.4); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.fillStyle = p.c;++ if (p.round) { ctx.beginPath(); ctx.arc(0, 0, p.h / 1.6, 0, Math.PI * 2); ctx.fill(); } else ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);++ ctx.restore();++ });++ if (t < 1 && !doc.hidden) root.requestAnimationFrame(frame); else cv.remove();++ };++ root.requestAnimationFrame(frame);++ return true;++ }++++ // ── Wiring ──++ function wire(el) {++ if (done.has(el)) return;++ done.add(el);++ const names = String(el.getAttribute('data-fx') || '').toLowerCase().split(/\s+/).filter(Boolean);++ for (const n of names) {++ if (n === 'glint' || n === 'glow' || n === 'lift' || n === 'press' || n === 'ripple' || n === 'shimmer') el.classList.add(`ovfx-${n}`);++ if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));++ if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }++ if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70 }); });++ if (n === 'reveal' && !reduced()) {++ const kids = el.children.length && el.getAttribute('data-fx-self') == null ? Array.from(el.children) : [el];++ if (!below(el)) continue; // already on screen when wired: never hide what someone can see++ const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 50));++ kids.forEach((k, i) => { k.classList.add('ovfx-hide'); k.style.setProperty('--ovfx-d', `${Math.min(i, 12) * gap}ms`); });++ el.__ovfxReveal = kids; observe(el);++ }++ if (n === 'count' && !reduced()) {++ const c = countOf(el);++ if (c && below(el)) { el.__ovfxCount = c; observe(el); } else if (c) { el.__ovfxCount = c; seen(el); }++ }++ }++ }++ function scan(scope) {++ const base = scope && scope.querySelectorAll ? scope : doc;++ if (base.matches && base.matches('[data-fx]')) wire(base);++ base.querySelectorAll('[data-fx]').forEach(wire);++ }++ function init() {++ css();++ scan(doc);++ if ('MutationObserver' in root) new root.MutationObserver((rs) => rs.forEach((r) => r.addedNodes.forEach((n) => { if (n.nodeType === 1) scan(n); }))).observe(doc.documentElement, { childList: true, subtree: true });++ }++++ root.OVFx = { scan, confetti, wire };++ if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', init); else init();++})(typeof window !== 'undefined' ? window : this);@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.65.0",++ "version": "4.66.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -52,6 +52,7 @@"./ov-icons": "./ov-icons.js","./ov-glyphs": "./ov-glyphs.js","./ov-look": "./ov-look.js",++ "./ov-fx": "./ov-fx.js","./look-boot": "./look-boot.js","./ov-mark": "./ov-mark.js","./package.json": "./package.json",@@@ -9,7 +9,7 @@ const path = require('path');const { hashAsset } = require('../assets');const ROOT = path.join(__dirname, '..');--const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js'];++const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js'];const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;BBinary files a/social/actor.jpg and b/social/actor.jpg differBBinary files a/social/ai.jpg and b/social/ai.jpg differBBinary files a/social/blog.jpg and b/social/blog.jpg differBBinary files a/social/bot.jpg and b/social/bot.jpg differBBinary files a/social/chat.jpg and b/social/chat.jpg differBBinary files a/social/codes.jpg and b/social/codes.jpg differBBinary files a/social/community.jpg and b/social/community.jpg differBBinary files a/social/coupons.jpg and b/social/coupons.jpg differBBinary files a/social/deals.jpg and b/social/deals.jpg differBBinary files a/social/events.jpg and b/social/events.jpg differBBinary files a/social/food.jpg and b/social/food.jpg differBBinary files a/social/games.jpg and b/social/games.jpg differBBinary files a/social/help.jpg and b/social/help.jpg differBBinary files a/social/host.jpg and b/social/host.jpg differBBinary files a/social/inventory.jpg and b/social/inventory.jpg differBBinary files a/social/live-chat.jpg and b/social/live-chat.jpg differBBinary files a/social/live-content.jpg and b/social/live-content.jpg differBBinary files a/social/live-moments.jpg and b/social/live-moments.jpg differBBinary files a/social/live-rundown.jpg and b/social/live-rundown.jpg differBBinary files a/social/live.jpg and b/social/live.jpg differBBinary files a/social/media-hub.jpg and b/social/media-hub.jpg differBBinary files a/social/media.jpg and b/social/media.jpg differBBinary files a/social/network.jpg and b/social/network.jpg differBBinary files a/social/news.jpg and b/social/news.jpg differBBinary files a/social/openre.jpg and b/social/openre.jpg differBBinary files a/social/play.jpg and b/social/play.jpg differBBinary files a/social/quest.jpg and b/social/quest.jpg differBBinary files a/social/rent.jpg and b/social/rent.jpg differBBinary files a/social/reviews.jpg and b/social/reviews.jpg differBBinary files a/social/run.jpg and b/social/run.jpg differBBinary files a/social/search.jpg and b/social/search.jpg differBBinary files a/social/services.jpg and b/social/services.jpg differBBinary files a/social/space.jpg and b/social/space.jpg differBBinary files a/social/tips.jpg and b/social/tips.jpg differBBinary files a/social/tools.jpg and b/social/tools.jpg differBBinary files a/social/trade.jpg and b/social/trade.jpg differBBinary files a/social/vip.jpg and b/social/vip.jpg differBBinary files a/social/watch.jpg and b/social/watch.jpg differBBinary files a/social/wiki.jpg and b/social/wiki.jpg differBBinary files a/social/work.jpg and b/social/work.jpg differBBinary files a/social/zone.jpg and b/social/zone.jpg differnnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++// ov-fx.js (4.66.0, plan L45): the network's effects by data-fx. Each name wires its class or listener; a reveal hides++// only what is below the fold and shows it when it comes on screen; a number counts up and ends on its own text;++// under reduced motion or Calm nothing is hidden, nothing counts and no confetti falls; markup added later is wired;++// the navbar loads it only for a page that asks. linkedom with a fake IntersectionObserver and matchMedia.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { parseHTML } = require('linkedom');++++const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-fx.js'), 'utf8');++++function setup({ reduce = false, calm = false, html = '' } = {}) {++ const { window, document, MutationObserver } = parseHTML(`<!doctype html><html${calm ? ' data-ov-motion="reduced"' : ''}><head></head><body>${html}</body></html>`);++ const observed = [];++ class IO { constructor(cb) { this.cb = cb; } observe(el) { observed.push(el); } unobserve() {} fire(el) { this.cb([{ target: el, isIntersecting: true }]); } }++ let io = null;++ const frames = [];++ window.IntersectionObserver = class extends IO { constructor(cb) { super(cb); io = this; } };++ window.MutationObserver = MutationObserver;++ window.matchMedia = (q) => ({ matches: /reduce/.test(q) ? reduce : /hover/.test(q) });++ window.innerHeight = 800;++ window.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };++ window.cancelAnimationFrame = () => {};++ window.setTimeout = (fn) => { fn(); return 0; };++ window.performance = { now: () => 0 };++ for (const el of document.querySelectorAll('[data-top]')) el.getBoundingClientRect = () => ({ top: Number(el.getAttribute('data-top')), left: 0, width: 100, height: 40 });++ window.OVFx = undefined; // linkedom windows share their expando properties++ new Function('window', SRC)(window);++ const run = (t) => { let n = 0; while (frames.length && n++ < 500) frames.shift()(t); };++ return { window, document, io: () => io, observed, run };++}++++(async () => {++ // ── names wire their classes; the CSS carries the motion tokens and the reduced-motion guards ──++ {++ const t = setup({ html: '<a id="a" data-fx="glint lift press glow">x</a><span id="s" data-fx="shimmer"></span><b id="r" data-fx="ripple">r</b>' });++ const a = t.document.getElementById('a');++ assert.deepStrictEqual(['glint', 'lift', 'press', 'glow'].map((n) => a.classList.contains(`ovfx-${n}`)), [true, true, true, true]);++ assert.ok(t.document.getElementById('s').classList.contains('ovfx-shimmer') && t.document.getElementById('r').classList.contains('ovfx-ripple'));++ const css = t.document.getElementById('ov-fx-css').textContent;++ assert.match(css, /--ov-dur-1:\.12s;--ov-dur-2:\.2s;--ov-dur-3:\.32s;--ov-dur-4:\.52s/, 'the motion tokens');++ assert.match(css, /html\[data-ov-motion=reduced\]\{--ov-dur-1:0s/, 'Calm zeroes them');++ assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{\.ovfx-glint::after\{display:none\}/);++ assert.ok(!/infinite/.test(css.replace(/\.ovfx-shimmer\{[^}]*\}/, '')), 'nothing loops but a placeholder\'s shimmer');++ assert.strictEqual(typeof t.window.OVFx.confetti, 'function');++ }++++ // ── reveal: below the fold it waits for the screen; on screen it is never hidden ──++ {++ const t = setup({ html: '<ul id="low" data-fx="reveal" data-fx-stagger="40" data-top="1400"><li>a</li><li>b</li><li>c</li></ul><ul id="top" data-fx="reveal" data-top="100"><li>x</li></ul>' });++ const low = t.document.getElementById('low'), top = t.document.getElementById('top');++ assert.ok([...low.children].every((k) => k.classList.contains('ovfx-hide')), 'below the fold: hidden until seen');++ assert.strictEqual(low.children[2].style.getPropertyValue('--ovfx-d'), '80ms', 'staggered');++ assert.ok(![...top.children].some((k) => k.classList.contains('ovfx-hide')), 'on screen when wired: never hidden');++ assert.ok(t.observed.includes(low) && !t.observed.includes(top));++ t.io().fire(low);++ assert.ok([...low.children].every((k) => !k.classList.contains('ovfx-hide')), 'seen: shown');++ }++++ // ── count: from 0 up to the number, ending on its own text ──++ {++ const t = setup({ html: '<b id="n" data-fx="count" data-top="1200">12,480 viewers</b><b id="m" data-fx="count" data-top="10">€3.50</b><b id="w" data-fx="count" data-top="1200">soon</b>' });++ const n = t.document.getElementById('n');++ t.io().fire(n);++ t.run(100);++ assert.notStrictEqual(n.textContent, '12,480 viewers', 'counting');++ assert.match(n.textContent, / viewers$/, 'keeps its words');++ t.run(5000);++ assert.strictEqual(n.textContent, '12,480 viewers', 'ends exactly on its own text');++ t.run(5000);++ assert.strictEqual(t.document.getElementById('m').textContent, '€3.50', 'decimals end exact too');++ assert.strictEqual(t.document.getElementById('w').textContent, 'soon', 'not a number: left alone');++ }++++ // ── reduced motion and Calm: nothing hidden, nothing counted, no confetti ──++ for (const opts of [{ reduce: true }, { calm: true }]) {++ const t = setup({ ...opts, html: '<ul data-fx="reveal" data-top="1400"><li>a</li></ul><b id="n" data-fx="count" data-top="1400">900</b>' });++ assert.strictEqual(t.document.querySelectorAll('.ovfx-hide').length, 0, 'nothing hidden');++ assert.strictEqual(t.observed.length, 0, 'nothing waits to count');++ assert.strictEqual(t.document.getElementById('n').textContent, '900');++ assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'no confetti');++ }++++ // ── markup added later is wired ──++ {++ const t = setup();++ const el = t.document.createElement('a'); el.setAttribute('data-fx', 'press'); t.document.body.appendChild(el);++ await new Promise((r) => setImmediate(r));++ t.window.OVFx.scan(t.document.body);++ assert.ok(el.classList.contains('ovfx-press'));++ }++++ // ── the navbar loads it once the page settles, only when the page asks ──++ {++ const nav = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');++ assert.match(nav, /function queueFx\(\)/);++ assert.match(nav, /!document\.querySelector\('\[data-fx\]'\)\) return;/, 'only for a page with data-fx');++ assert.match(nav, /siblingV\('ov-fx\.js'\)/, 'from beside the navbar, by content hash');++ assert.ok(require('../files').BROWSER.includes('ov-fx.js'), 'served');++ }++++ console.log('fx: names wire their effects; reveal waits below the fold and never hides what is on screen; numbers end on their own text; reduced motion and Calm keep still; later markup wired; loaded only on request');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -36,7 +36,7 @@ for (const form of ['pair', 'heavy']) {}// The standalone SVG: the gap is a mask, so it holds on any ground; the tones; the wide box.--const svg = mark.svg({ size: 64, title: 'OpenVibe' });++const svg = mark.svg({ size: 64, title: 'OpenVibe', depth: false }); // the flat colours (depth is checked below)assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="[\d. ]+" width="64" height="64" role="img" aria-label="OpenVibe"><title>OpenVibe<\/title>/);assert.match(svg, /<mask id="ovmh"[\s\S]*stroke="#000" stroke-width="20"[\s\S]*<\/mask>[\s\S]*<circle [^>]*stroke="#4f7cff"[^>]*mask="url\(#ovmh\)"\/><path [^>]*stroke="#8b5cf6"/, 'blue O cut by a mask, violet V on top');assert.match(mark.svg({ tone: 'ink' }), /stroke="currentColor"[\s\S]*stroke="currentColor"/, 'ink: the text colour');@@@ -67,4 +67,20 @@ assert.ok(!appIcon.svg({ site: 'network' }).includes('fill="#ffffff"'), 'a whiteassert.ok(src.includes(`viewBox="${mark.geo('pair').box}"`) && src.includes(`cx="${mark.geo('pair').cx}" cy="50" r="20"`), 'and the same O in the same box');}++// ── Depth (4.66.0, plan L44): the static mark and the app icons wear ov-mark.js's light-to-deep strokes ──++{++ const t = mark.tones(mark.COLORS.o, mark.COLORS.v);++ assert.deepStrictEqual(t, { o: ['#87a6ff', '#4268d6'], v: ['#ae8df9', '#784fd4'] }, 'the same mixes as ov-mark.js: O 68 % / 84 %, V 70 % / 86 %');++ const brand = mark.svg({ size: 64 });++ assert.strictEqual((brand.match(/<linearGradient id="ovmg[ov]" gradientUnits="userSpaceOnUse"/g) || []).length, 2, 'the brand mark has depth by default, in user space');++ assert.ok(brand.includes('stroke="url(#ovmgo)"') && brand.includes('stroke="url(#ovmgv)"'));++ assert.ok(!mark.svg({ tone: 'ink' }).includes('linearGradient') && !mark.svg({ tone: 'white' }).includes('linearGradient'), 'one-colour tones stay flat');++ assert.ok(!mark.svg({ depth: false }).includes('linearGradient'), 'depth can be turned off');++ assert.ok(!mark.svg({ tone: { o: 'red', v: 'blue' }, depth: true }).includes('linearGradient'), 'only hex colours get tones');++ const err = mark.art({ shape: 'err', depth: true, id: 'e' });++ assert.ok(err.defs.includes('id="ego"') && err.body.includes('url(#egv)'), 'the "!" (a straight stroke) still paints: user space');++ const ai = require('../app-icon');++ assert.ok(ai.svg({ site: 'live' }).includes('url(#ovgo)') && ai.favicon({ site: 'network' }).includes('url(#ovgv)'), 'the app icons and favicons have depth');++}++console.log('mark: one geometry in mark.js, ov-mark.js, the icons and the loader; centred, level, the gap a mask; tones, wide box, maskable safe zone');
@@ -6,6 +6,30 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,30 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.66.0 — 2026-10-31
+
+- **`ov-fx.js`, the network's effects in one kit** (plan L45; the owner: "reusable modular sharable systems and
+ interface"). The mark's motion is now available for anything on any OpenVibe page:
+ - **API:** `data-fx="glint glow lift press ripple tilt reveal count confetti shimmer"`, and
+ `OVFx.confetti({ x, y, count, colors })` from script;
+ - **reveal:** children rise in as they come on screen (`data-fx-stagger`). Anything already on screen when it is
+ wired is never hidden;
+ - **count:** a number counts up the first time it is seen and ends exactly on its own text;
+ - **tilt:** mouse only;
+ - **confetti:** one canvas, about 1.6 s, then gone;
+ - **motion tokens:** `--ov-dur-1…4`, `--ov-ease-out`, `--ov-ease-spring` and `--ov-ease-in-out`. Calm sets the
+ durations to 0;
+ - **rules:** nothing runs by itself. Under reduced motion or Calm, nothing is hidden, nothing counts and no confetti
+ falls;
+ - **loading:** navbar.js loads it from beside itself once the page settles, and only on a page with `data-fx`
+ (about 2.6 KB brotli). Also exported as `openvibe-shared/ov-fx`.
+- **The static mark has the new depth** (plan L44):
+ - `openvibe-shared/mark` takes `depth`, on by default for the brand tone. Each stroke runs from a lighter to a deeper
+ tone of its colour, the same mixes `ov-mark.js` uses (`tones(o, v)`), in user space so a straight "!" still paints.
+ One-colour tones stay flat;
+ - `app-icon.js` draws with it, so the installable icons (`icons/`) and the share cards (`social/`) are rebuilt with
+ the deeper mark.
+
## 4.65.0 — 2026-10-31
## 4.65.0 — 2026-10-31
- **The mark, with depth and motion** (plan L44; the owner: "the stylistic choices of the logo image need more
- **The mark, with depth and motion** (plan L44; the owner: "the stylistic choices of the logo image need more
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.65.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.66.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.65.0",
+ "version": "4.66.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -29,14 +29,14 @@ const MARK = require('./mark');
@@ -29,14 +29,14 @@ const MARK = require('./mark');
// The mark centred on 0,0 (openvibe-shared/mark draws it centred on 50,50 in a 100-unit box), scaled.
// The mark centred on 0,0 (openvibe-shared/mark draws it centred on 50,50 in a 100-unit box), scaled.
function mark(site, scale, form = 'pair') {
function mark(site, scale, form = 'pair') {
- const c = of(site), g = MARK.geo(form), { body } = MARK.art({ form, id: 'ov' });
+ const c = of(site), g = MARK.geo(form), { body } = MARK.art({ form, id: 'ov', depth: true });
const light = c.dot && c.dot.toLowerCase() !== '#ffffff' ? `<circle cx="${g.cx}" cy="${g.cy}" r="${+((g.r - g.sw / 2) * 0.62).toFixed(2)}" fill="${c.dot}"/>` : '';
const light = c.dot && c.dot.toLowerCase() !== '#ffffff' ? `<circle cx="${g.cx}" cy="${g.cy}" r="${+((g.r - g.sw / 2) * 0.62).toFixed(2)}" fill="${c.dot}"/>` : '';
return `<g transform="scale(${scale}) translate(-50 -50)"><ellipse cx="50" cy="50" rx="52" ry="40" fill="url(#glow)"/>${body}${light}</g>`;
return `<g transform="scale(${scale}) translate(-50 -50)"><ellipse cx="50" cy="50" rx="52" ry="40" fill="url(#glow)"/>${body}${light}</g>`;
}
}
function defs(site, form = 'pair') {
function defs(site, form = 'pair') {
const [a] = of(site).accent;
const [a] = of(site).accent;
- return `<defs>${MARK.art({ form, id: 'ov' }).defs}
+ return `<defs>${MARK.art({ form, id: 'ov', depth: true }).defs}
<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".3"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient>
<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".3"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient>
<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;
<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;
}
}
@@ -27,6 +27,7 @@ const BROWSER = Object.freeze([
@@ -27,6 +27,7 @@ const BROWSER = Object.freeze([
'ov-mark.js', // the OV brand mark drop-in
'ov-mark.js', // the OV brand mark drop-in
'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)
'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)
'ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)
'ov-look.js', // the Look panel: themes, the season, more on openvibe.design (navbar.js loads it on first open)
+ 'ov-fx.js', // the network's effects (data-fx): glint, glow, lift, press, ripple, tilt, reveal, count, confetti
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'history.js', // cross-site history
'history.js', // cross-site history
'sso-client.js', // link hand-off, FedCM
'sso-client.js', // link hand-off, FedCM
Binary files a/icons/ai-192.png and b/icons/ai-192.png differ
Binary files a/icons/ai-192.png and b/icons/ai-192.png differ
Binary files a/icons/ai-512.png and b/icons/ai-512.png differ
Binary files a/icons/ai-512.png and b/icons/ai-512.png differ
Binary files a/icons/ai-maskable-192.png and b/icons/ai-maskable-192.png differ
Binary files a/icons/ai-maskable-192.png and b/icons/ai-maskable-192.png differ
Binary files a/icons/ai-maskable-512.png and b/icons/ai-maskable-512.png differ
Binary files a/icons/ai-maskable-512.png and b/icons/ai-maskable-512.png differ
Binary files a/icons/community-192.png and b/icons/community-192.png differ
Binary files a/icons/community-192.png and b/icons/community-192.png differ
Binary files a/icons/community-512.png and b/icons/community-512.png differ
Binary files a/icons/community-512.png and b/icons/community-512.png differ
Binary files a/icons/community-maskable-192.png and b/icons/community-maskable-192.png differ
Binary files a/icons/community-maskable-192.png and b/icons/community-maskable-192.png differ
Binary files a/icons/community-maskable-512.png and b/icons/community-maskable-512.png differ
Binary files a/icons/community-maskable-512.png and b/icons/community-maskable-512.png differ
Binary files a/icons/games-192.png and b/icons/games-192.png differ
Binary files a/icons/games-192.png and b/icons/games-192.png differ
Binary files a/icons/games-512.png and b/icons/games-512.png differ
Binary files a/icons/games-512.png and b/icons/games-512.png differ
Binary files a/icons/games-maskable-192.png and b/icons/games-maskable-192.png differ
Binary files a/icons/games-maskable-192.png and b/icons/games-maskable-192.png differ
Binary files a/icons/games-maskable-512.png and b/icons/games-maskable-512.png differ
Binary files a/icons/games-maskable-512.png and b/icons/games-maskable-512.png differ
Binary files a/icons/live-192.png and b/icons/live-192.png differ
Binary files a/icons/live-192.png and b/icons/live-192.png differ
Binary files a/icons/live-512.png and b/icons/live-512.png differ
Binary files a/icons/live-512.png and b/icons/live-512.png differ
Binary files a/icons/live-maskable-192.png and b/icons/live-maskable-192.png differ
Binary files a/icons/live-maskable-192.png and b/icons/live-maskable-192.png differ
Binary files a/icons/live-maskable-512.png and b/icons/live-maskable-512.png differ
Binary files a/icons/live-maskable-512.png and b/icons/live-maskable-512.png differ
Binary files a/icons/media-192.png and b/icons/media-192.png differ
Binary files a/icons/media-192.png and b/icons/media-192.png differ
Binary files a/icons/media-512.png and b/icons/media-512.png differ
Binary files a/icons/media-512.png and b/icons/media-512.png differ
Binary files a/icons/media-maskable-192.png and b/icons/media-maskable-192.png differ
Binary files a/icons/media-maskable-192.png and b/icons/media-maskable-192.png differ
Binary files a/icons/media-maskable-512.png and b/icons/media-maskable-512.png differ
Binary files a/icons/media-maskable-512.png and b/icons/media-maskable-512.png differ
Binary files a/icons/network-192.png and b/icons/network-192.png differ
Binary files a/icons/network-192.png and b/icons/network-192.png differ
Binary files a/icons/network-512.png and b/icons/network-512.png differ
Binary files a/icons/network-512.png and b/icons/network-512.png differ
Binary files a/icons/network-maskable-192.png and b/icons/network-maskable-192.png differ
Binary files a/icons/network-maskable-192.png and b/icons/network-maskable-192.png differ
Binary files a/icons/network-maskable-512.png and b/icons/network-maskable-512.png differ
Binary files a/icons/network-maskable-512.png and b/icons/network-maskable-512.png differ
Binary files a/icons/space-192.png and b/icons/space-192.png differ
Binary files a/icons/space-192.png and b/icons/space-192.png differ
Binary files a/icons/space-512.png and b/icons/space-512.png differ
Binary files a/icons/space-512.png and b/icons/space-512.png differ
Binary files a/icons/space-maskable-192.png and b/icons/space-maskable-192.png differ
Binary files a/icons/space-maskable-192.png and b/icons/space-maskable-192.png differ
Binary files a/icons/space-maskable-512.png and b/icons/space-maskable-512.png differ
Binary files a/icons/space-maskable-512.png and b/icons/space-maskable-512.png differ
Binary files a/icons/tools-192.png and b/icons/tools-192.png differ
Binary files a/icons/tools-192.png and b/icons/tools-192.png differ
Binary files a/icons/tools-512.png and b/icons/tools-512.png differ
Binary files a/icons/tools-512.png and b/icons/tools-512.png differ
Binary files a/icons/tools-maskable-192.png and b/icons/tools-maskable-192.png differ
Binary files a/icons/tools-maskable-192.png and b/icons/tools-maskable-192.png differ
Binary files a/icons/tools-maskable-512.png and b/icons/tools-maskable-512.png differ
Binary files a/icons/tools-maskable-512.png and b/icons/tools-maskable-512.png differ
@@ -15,10 +15,14 @@
@@ -15,10 +15,14 @@
// geo(form) → { cx, cy, r, sw, R, vsw, halo, A, T, B, ring, box, wideBox }
// geo(form) → { cx, cy, r, sw, R, vsw, halo, A, T, B, ring, box, wideBox }
// shapes(form) → the V's two strokes for each shape it takes: v, ok (a tick), no (a cross),
// shapes(form) → the V's two strokes for each shape it takes: v, ok (a tick), no (a cross),
// err (an exclamation mark), tip (folded into its point, for the intro)
// err (an exclamation mark), tip (folded into its point, for the intro)
-// art({ form, o, v, id, shape }) → { defs, body }: the mark's <defs> and drawing, to place inside another SVG
-// svg({ form, tone, wide, size, title, id })
+// art({ form, o, v, id, shape, depth })
+// → { defs, body }: the mark's <defs> and drawing, to place inside another SVG
+// svg({ form, tone, wide, size, title, id, depth })
// → a standalone SVG. tone: 'brand' (blue O, violet V), 'ink' (currentColor),
// → a standalone SVG. tone: 'brand' (blue O, violet V), 'ink' (currentColor),
// 'white', or { o, v }. wide: the box hugs the mark (92 × 56) instead of a square.
// 'white', or { o, v }. wide: the box hugs the mark (92 × 56) instead of a square.
+// depth (4.66.0; on by default for the brand tone): each stroke runs from a lighter
+// to a deeper tone of its colour, as ov-mark.js draws it. Hex colours only; the
+// one-colour tones stay flat.
// COLORS → { o: '#4f7cff', v: '#8b5cf6' }
// COLORS → { o: '#4f7cff', v: '#8b5cf6' }
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -58,29 +62,45 @@ function shapes(form) {
@@ -58,29 +62,45 @@ function shapes(form) {
}
}
const stroke = (p, i) => `M${p[i]} ${p[i + 1]}L${p[i + 2]} ${p[i + 3]}`;
const stroke = (p, i) => `M${p[i]} ${p[i + 1]}L${p[i + 2]} ${p[i + 3]}`;
+const HEX = /^#[0-9a-f]{6}$/i;
+/** hex mixed toward `to` by t (0..1): ov-mark.js's color-mix, worked out for files that cannot run CSS. */
+function mix(hex, to, t) {
+ const a = parseInt(hex.slice(1), 16), b = parseInt(to.slice(1), 16);
+ return `#${[16, 8, 0].map((sh) => Math.round(((a >> sh) & 255) * (1 - t) + ((b >> sh) & 255) * t).toString(16).padStart(2, '0')).join('')}`;
+}
+/** The light-to-deep tones of each stroke: the same mixes as ov-mark.js (O 68% / 84%, V 70% / 86%). */
+const tones = (o, v) => ({ o: [mix(o, '#ffffff', 0.32), mix(o, '#000000', 0.16)], v: [mix(v, '#ffffff', 0.30), mix(v, '#000000', 0.14)] });
/** The mark's defs and drawing, to place inside another SVG. ids are prefixed with `id` (unique per document). */
/** The mark's defs and drawing, to place inside another SVG. ids are prefixed with `id` (unique per document). */
-function art({ form = 'pair', o = COLORS.o, v = COLORS.v, id = 'ovm', shape = 'v' } = {}) {
+function art({ form = 'pair', o = COLORS.o, v = COLORS.v, id = 'ovm', shape = 'v', depth = false } = {}) {
const g = geo(form), p = shapes(form)[shape] || shapes(form).v;
const g = geo(form), p = shapes(form)[shape] || shapes(form).v;
- const defs = `<mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/>`
+ let defs = `<mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/>`
+ `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="#000" stroke-width="${g.halo}" stroke-linecap="round"/></mask>`;
+ `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="#000" stroke-width="${g.halo}" stroke-linecap="round"/></mask>`;
- const body = `<circle cx="${g.cx}" cy="${g.cy}" r="${g.r}" fill="none" stroke="${o}" stroke-width="${g.sw}" mask="url(#${id}h)"/>`
- + `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="${v}" stroke-width="${g.vsw}" stroke-linecap="round" stroke-linejoin="round"/>`;
+ let os = o, vs = v;
+ if (depth && HEX.test(o) && HEX.test(v)) {
+ // In user space: a straight stroke (the "!" shape) has no box for a bounding-box gradient to span.
+ const t = tones(o, v);
+ defs += `<linearGradient id="${id}go" gradientUnits="userSpaceOnUse" x1="${f(g.cx - g.R)}" y1="${f(g.cy - g.R)}" x2="${f(g.cx + g.R)}" y2="${f(g.cy + g.R)}"><stop offset="0" stop-color="${t.o[0]}"/><stop offset="1" stop-color="${t.o[1]}"/></linearGradient>`
+ + `<linearGradient id="${id}gv" gradientUnits="userSpaceOnUse" x1="0" y1="${g.A[1]}" x2="0" y2="${g.T[1]}"><stop offset="0" stop-color="${t.v[0]}"/><stop offset="1" stop-color="${t.v[1]}"/></linearGradient>`;
+ os = `url(#${id}go)`; vs = `url(#${id}gv)`;
+ }
+ const body = `<circle cx="${g.cx}" cy="${g.cy}" r="${g.r}" fill="none" stroke="${os}" stroke-width="${g.sw}" mask="url(#${id}h)"/>`
+ + `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="${vs}" stroke-width="${g.vsw}" stroke-linecap="round" stroke-linejoin="round"/>`;
return { defs, body };
return { defs, body };
}
}
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
/** A standalone SVG of the mark. */
/** A standalone SVG of the mark. */
-function svg({ form = 'pair', tone = 'brand', wide = false, size = null, title = 'OpenVibe', id = 'ovm' } = {}) {
+function svg({ form = 'pair', tone = 'brand', wide = false, size = null, title = 'OpenVibe', id = 'ovm', depth = tone === 'brand' } = {}) {
const c = typeof tone === 'object' && tone ? tone
const c = typeof tone === 'object' && tone ? tone
: tone === 'ink' ? { o: 'currentColor', v: 'currentColor' }
: tone === 'ink' ? { o: 'currentColor', v: 'currentColor' }
: tone === 'white' ? { o: '#fff', v: '#fff' } : COLORS;
: tone === 'white' ? { o: '#fff', v: '#fff' } : COLORS;
const g = geo(form), box = wide ? g.wideBox : g.box;
const g = geo(form), box = wide ? g.wideBox : g.box;
const [, , bw, bh] = box.split(' ').map(Number);
const [, , bw, bh] = box.split(' ').map(Number);
const dims = size ? ` width="${f(wide ? size * bw / bh : size)}" height="${size}"` : '';
const dims = size ? ` width="${f(wide ? size * bw / bh : size)}" height="${size}"` : '';
- const { defs, body } = art({ form, o: c.o, v: c.v, id });
+ const { defs, body } = art({ form, o: c.o, v: c.v, id, depth });
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box}"${dims} role="img" aria-label="${esc(title)}"><title>${esc(title)}</title><defs>${defs}</defs>${body}</svg>`;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box}"${dims} role="img" aria-label="${esc(title)}"><title>${esc(title)}</title><defs>${defs}</defs>${body}</svg>`;
}
}
-module.exports = { COLORS, FORMS, geo, shapes, art, svg };
+module.exports = { COLORS, FORMS, geo, shapes, art, svg, tones };
@@ -476,7 +476,7 @@
@@ -476,7 +476,7 @@
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '82afb8a8cb4d', 'ov-glyphs.js': '2b97058350a5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'f234846e81db' };
+ const SIBLING_REV = { 'ov-mark.js': '82afb8a8cb4d', 'ov-glyphs.js': '2b97058350a5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'f234846e81db', 'ov-fx.js': 'de375a22cf17' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -820,6 +820,15 @@
@@ -820,6 +820,15 @@
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 4000 });
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 4000 });
else { const t = setTimeout(go, 2500); if (t && typeof t.unref === 'function') t.unref(); }
else { const t = setTimeout(go, 2500); if (t && typeof t.unref === 'function') t.unref(); }
}
}
+ /** ov-fx.js (4.66.0): the network's effects, loaded once the page settles, only for a page that has data-fx. */
+ function queueFx() {
+ const go = () => {
+ if (root.OVFx || typeof document.querySelector !== 'function' || document.getElementById('ov-fx-loader') || !document.querySelector('[data-fx]')) return;
+ try { const sc = document.createElement('script'); sc.id = 'ov-fx-loader'; sc.src = siblingV('ov-fx.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
+ };
+ if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 3000 });
+ else { const t = setTimeout(go, 1500); if (t && typeof t.unref === 'function') t.unref(); }
+ }
function bindChips(scope) {
function bindChips(scope) {
(_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {
(_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {
if (!c || typeof c.onClick !== 'function') return;
if (!c || typeof c.onClick !== 'function') return;
@@ -1685,6 +1694,7 @@
@@ -1685,6 +1694,7 @@
bindChips(nav);
bindChips(nav);
bindChipsAll(nav);
bindChipsAll(nav);
queueIcons();
queueIcons();
+ queueFx();
countView();
countView();
// Pages that wire the bell themselves run right after init(); give them the first go.
// Pages that wire the bell themselves run right after init(); give them the first go.
setTimeout(() => { mountBell(nav, u); mountDock(u); }, 0);
setTimeout(() => { mountBell(nav, u); mountDock(u); }, 0);
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+/**
+ * openvibe-shared/ov-fx.js — the network's effects, one kit (plan L45, 4.66.0). The mark's motion (4.65.0: a glint,
+ * a glow, a press) for anything on any OpenVibe page, plus reveals, counting numbers, a ripple, a tilt and confetti.
+ * navbar.js loads it from beside itself once the page settles, and only when the page has an element with data-fx.
+ *
+ * <a class="card" data-fx="glint lift press">…</a> effects by name, any number of them
+ * <ul data-fx="reveal" data-fx-stagger="60">…</ul> its children rise in as they come on screen, 60 ms apart
+ * <b data-fx="count">12,480</b> the number counts up the first time it is seen
+ * <button data-fx="confetti press">Send tip</button> a burst of confetti from the button when it is pressed
+ * OVFx.confetti({ x, y, count, colors }) the same burst from anywhere (a first stream, a goal)
+ * OVFx.scan(root) wire effects in markup added later (scanned by itself too)
+ *
+ * Effects: glint (a band of light crosses on hover), glow (the accent glows behind on hover), lift (rises a little),
+ * press (gives on press and springs back), ripple (from the point pressed), tilt (leans toward the pointer; mouse
+ * only), reveal, count, confetti, shimmer (a placeholder that shimmers until data-fx is removed).
+ *
+ * Motion tokens (a look can set them; theme-loader.js passes a theme's own): --ov-dur-1…4, --ov-ease-out,
+ * --ov-ease-spring, --ov-ease-in-out. Nothing here runs by itself: every effect waits for a hover, a press, or the
+ * element coming on screen, plays once, and stops. Under prefers-reduced-motion or Calm (html[data-ov-motion=reduced])
+ * nothing moves: reveals and numbers are simply there, and no confetti falls.
+ */
+(function (root) {
+ 'use strict';
+ if (!root || root.OVFx) return;
+ const doc = root.document;
+ const CSS = ':root{--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s;--ov-ease-out:cubic-bezier(.2,.8,.2,1);--ov-ease-spring:cubic-bezier(.34,1.56,.64,1);--ov-ease-in-out:cubic-bezier(.65,0,.35,1)}'
+ + 'html[data-ov-motion=reduced]{--ov-dur-1:0s;--ov-dur-2:0s;--ov-dur-3:0s;--ov-dur-4:0s}'
+ + '.ovfx-glint{position:relative;overflow:hidden;isolation:isolate}'
+ + '.ovfx-glint::after{content:"";position:absolute;inset:-2px;pointer-events:none;background:linear-gradient(105deg,transparent 35%,rgba(255,255,255,.28) 50%,transparent 65%);transform:translateX(-120%);z-index:1}'
+ + '.ovfx-glint:hover::after,.ovfx-glint:focus-visible::after{transform:translateX(120%);transition:transform .75s var(--ov-ease-in-out)}'
+ + '.ovfx-glow{transition:box-shadow var(--ov-dur-3) var(--ov-ease-out)}'
+ + '.ovfx-glow:hover,.ovfx-glow:focus-visible{box-shadow:0 0 0 1px color-mix(in srgb,var(--accent,#4f7cff) 45%,transparent),0 10px 34px -10px color-mix(in srgb,var(--accent,#4f7cff) 70%,transparent)}'
+ + '.ovfx-lift{transition:transform var(--ov-dur-3) var(--ov-ease-spring),box-shadow var(--ov-dur-3) var(--ov-ease-out)}'
+ + '.ovfx-lift:hover{transform:translateY(-3px);box-shadow:0 12px 28px -14px rgba(0,0,0,.45)}'
+ + '.ovfx-press{transition:transform var(--ov-dur-4) var(--ov-ease-spring)}'
+ + '.ovfx-press:active{transform:scale(.96);transition-duration:var(--ov-dur-1)}'
+ + '.ovfx-lift.ovfx-press:active{transform:translateY(-1px) scale(.97)}'
+ + '.ovfx-ripple{position:relative;overflow:hidden;isolation:isolate}'
+ + '.ovfx-rip{position:absolute;border-radius:50%;pointer-events:none;background:currentColor;opacity:.22;transform:scale(0);animation:ovfxRip .6s var(--ov-ease-out) forwards}'
+ + '@keyframes ovfxRip{to{transform:scale(1);opacity:0}}'
+ + '.ovfx-tilt{transition:transform var(--ov-dur-3) var(--ov-ease-out);transform-style:preserve-3d;will-change:transform}'
+ + '.ovfx-hide{opacity:0;transform:translateY(14px)}'
+ + '.ovfx-in{opacity:1;transform:none;transition:opacity .5s var(--ov-ease-out),transform .6s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}'
+ + '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'
+ + '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'
+ + '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'
+ + '@media (prefers-reduced-motion:reduce){.ovfx-glint::after{display:none}.ovfx-lift,.ovfx-press,.ovfx-tilt,.ovfx-in{transition:none!important}.ovfx-shimmer{animation:none}}'
+ + 'html[data-ov-motion=reduced] .ovfx-glint::after{display:none}html[data-ov-motion=reduced] .ovfx-shimmer{animation:none}html[data-ov-motion=reduced] .ovfx-in{transition:none}';
+
+ const reduced = () => (root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches) || doc.documentElement.getAttribute('data-ov-motion') === 'reduced';
+ const fine = () => !!(root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches);
+ const done = new WeakSet();
+ let io = null;
+
+ function css() {
+ if (doc.getElementById('ov-fx-css')) return;
+ const st = doc.createElement('style'); st.id = 'ov-fx-css'; st.textContent = CSS; (doc.head || doc.documentElement).appendChild(st);
+ }
+
+ // ── Seen: reveal and count, once each, when they come on screen ──
+ function seen(el) {
+ if (el.__ovfxReveal) {
+ const kids = el.__ovfxReveal;
+ kids.forEach((k) => { k.classList.add('ovfx-in'); k.classList.remove('ovfx-hide'); });
+ root.setTimeout(() => kids.forEach((k) => { k.classList.remove('ovfx-in'); k.style.removeProperty('--ovfx-d'); }), 1400 + kids.length * 80);
+ el.__ovfxReveal = null;
+ }
+ if (el.__ovfxCount) { const c = el.__ovfxCount; el.__ovfxCount = null; countUp(el, c); }
+ }
+ function observe(el) {
+ if (!('IntersectionObserver' in root)) return seen(el);
+ io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); seen(e.target); } }), { rootMargin: '0px 0px -8% 0px' });
+ io.observe(el);
+ }
+ const below = (el) => { try { return el.getBoundingClientRect().top > (root.innerHeight || 0); } catch (e) { return false; } };
+
+ function countUp(el, c) {
+ const t0 = root.performance ? root.performance.now() : Date.now(), ms = Math.min(1600, 500 + Math.log10(Math.max(10, c.to)) * 220);
+ const step = (now) => {
+ const p = Math.min(1, ((now || Date.now()) - t0) / ms), e = 1 - Math.pow(1 - p, 3);
+ el.textContent = p < 1 ? c.fmt(c.to * e) : c.text;
+ if (p < 1) root.requestAnimationFrame(step);
+ };
+ root.requestAnimationFrame(step);
+ }
+ function countOf(el) {
+ const text = el.textContent;
+ const m = /^(\D*?)(\d(?:[\d.,\s ]*\d)?)(\D*)$/.exec(text.trim());
+ if (!m) return null;
+ const digits = m[2].replace(/[\s ]/g, '');
+ const dec = /[.,]\d{1,2}$/.test(digits) && !/^\d{1,3}([.,]\d{3})+$/.test(digits) ? digits.slice(-3).replace(/^\D/, '').length : 0;
+ const to = Number(dec ? digits.replace(/[.,](?=\d{3}(\D|$))/g, '').replace(',', '.') : digits.replace(/[.,]/g, ''));
+ if (!Number.isFinite(to) || to <= 0) return null;
+ const nf = new Intl.NumberFormat(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec });
+ return { to, text, fmt: (v) => m[1] + nf.format(v) + m[3] };
+ }
+
+ // ── Pointer effects ──
+ function ripple(el, ev) {
+ if (reduced()) return;
+ const r = el.getBoundingClientRect(), d = Math.max(r.width, r.height) * 2.2;
+ const s = doc.createElement('span'); s.className = 'ovfx-rip';
+ s.style.cssText = `width:${d}px;height:${d}px;left:${(ev.clientX || r.left + r.width / 2) - r.left - d / 2}px;top:${(ev.clientY || r.top + r.height / 2) - r.top - d / 2}px`;
+ el.appendChild(s); root.setTimeout(() => s.remove(), 650);
+ }
+ function tilt(el) {
+ let raf = 0;
+ el.addEventListener('pointermove', (ev) => {
+ if (ev.pointerType !== 'mouse' || reduced() || raf) return;
+ raf = root.requestAnimationFrame(() => {
+ raf = 0;
+ const r = el.getBoundingClientRect(), x = (ev.clientX - r.left) / r.width - 0.5, y = (ev.clientY - r.top) / r.height - 0.5;
+ el.style.transform = `perspective(900px) rotateX(${(-y * 6).toFixed(2)}deg) rotateY(${(x * 7).toFixed(2)}deg)`;
+ });
+ });
+ el.addEventListener('pointerleave', () => { if (raf) { root.cancelAnimationFrame(raf); raf = 0; } el.style.transform = ''; });
+ }
+
+ // ── Confetti: one canvas, about 1.6 s, then gone ──
+ function confetti(opts) {
+ if (reduced() || !doc.body) return false;
+ const o = opts || {}, cs = root.getComputedStyle(doc.documentElement);
+ const accent = (cs.getPropertyValue('--accent') || '').trim() || '#4f7cff';
+ const colors = o.colors || [accent, '#4f7cff', '#8b5cf6', '#22c55e', '#facc15', '#f472b6'];
+ const cv = doc.createElement('canvas'); cv.className = 'ovfx-confetti'; cv.setAttribute('aria-hidden', 'true');
+ const dpr = Math.min(2, root.devicePixelRatio || 1), W = root.innerWidth, H = root.innerHeight;
+ cv.width = W * dpr; cv.height = H * dpr; doc.body.appendChild(cv);
+ const ctx = cv.getContext('2d'); if (!ctx) { cv.remove(); return false; }
+ ctx.scale(dpr, dpr);
+ const x0 = o.x == null ? W / 2 : o.x, y0 = o.y == null ? H / 3 : o.y, n = Math.min(160, o.count || 80);
+ const ps = Array.from({ length: n }, (_, i) => {
+ const a = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9, v = 6 + Math.random() * 7;
+ return { x: x0, y: y0, vx: Math.cos(a) * v, vy: Math.sin(a) * v, r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.3, w: 5 + Math.random() * 5, h: 3 + Math.random() * 4, c: colors[i % colors.length], round: Math.random() < 0.3 };
+ });
+ const t0 = Date.now();
+ const frame = () => {
+ const t = (Date.now() - t0) / 1600;
+ ctx.clearRect(0, 0, W, H);
+ ps.forEach((p) => {
+ p.vy += 0.28; p.vx *= 0.985; p.vy *= 0.985; p.x += p.vx; p.y += p.vy; p.r += p.vr;
+ ctx.save(); ctx.globalAlpha = Math.max(0, 1 - Math.max(0, t - 0.6) / 0.4); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.fillStyle = p.c;
+ if (p.round) { ctx.beginPath(); ctx.arc(0, 0, p.h / 1.6, 0, Math.PI * 2); ctx.fill(); } else ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
+ ctx.restore();
+ });
+ if (t < 1 && !doc.hidden) root.requestAnimationFrame(frame); else cv.remove();
+ };
+ root.requestAnimationFrame(frame);
+ return true;
+ }
+
+ // ── Wiring ──
+ function wire(el) {
+ if (done.has(el)) return;
+ done.add(el);
+ const names = String(el.getAttribute('data-fx') || '').toLowerCase().split(/\s+/).filter(Boolean);
+ for (const n of names) {
+ if (n === 'glint' || n === 'glow' || n === 'lift' || n === 'press' || n === 'ripple' || n === 'shimmer') el.classList.add(`ovfx-${n}`);
+ if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));
+ if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }
+ if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70 }); });
+ if (n === 'reveal' && !reduced()) {
+ const kids = el.children.length && el.getAttribute('data-fx-self') == null ? Array.from(el.children) : [el];
+ if (!below(el)) continue; // already on screen when wired: never hide what someone can see
+ const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 50));
+ kids.forEach((k, i) => { k.classList.add('ovfx-hide'); k.style.setProperty('--ovfx-d', `${Math.min(i, 12) * gap}ms`); });
+ el.__ovfxReveal = kids; observe(el);
+ }
+ if (n === 'count' && !reduced()) {
+ const c = countOf(el);
+ if (c && below(el)) { el.__ovfxCount = c; observe(el); } else if (c) { el.__ovfxCount = c; seen(el); }
+ }
+ }
+ }
+ function scan(scope) {
+ const base = scope && scope.querySelectorAll ? scope : doc;
+ if (base.matches && base.matches('[data-fx]')) wire(base);
+ base.querySelectorAll('[data-fx]').forEach(wire);
+ }
+ function init() {
+ css();
+ scan(doc);
+ if ('MutationObserver' in root) new root.MutationObserver((rs) => rs.forEach((r) => r.addedNodes.forEach((n) => { if (n.nodeType === 1) scan(n); }))).observe(doc.documentElement, { childList: true, subtree: true });
+ }
+
+ root.OVFx = { scan, confetti, wire };
+ if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', init); else init();
+})(typeof window !== 'undefined' ? window : this);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.65.0",
+ "version": "4.66.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -52,6 +52,7 @@
@@ -52,6 +52,7 @@
"./ov-icons": "./ov-icons.js",
"./ov-icons": "./ov-icons.js",
"./ov-glyphs": "./ov-glyphs.js",
"./ov-glyphs": "./ov-glyphs.js",
"./ov-look": "./ov-look.js",
"./ov-look": "./ov-look.js",
+ "./ov-fx": "./ov-fx.js",
"./look-boot": "./look-boot.js",
"./look-boot": "./look-boot.js",
"./ov-mark": "./ov-mark.js",
"./ov-mark": "./ov-mark.js",
"./package.json": "./package.json",
"./package.json": "./package.json",
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { hashAsset } = require('../assets');
const { hashAsset } = require('../assets');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js'];
+const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js'];
const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
Binary files a/social/actor.jpg and b/social/actor.jpg differ
Binary files a/social/actor.jpg and b/social/actor.jpg differ
Binary files a/social/ai.jpg and b/social/ai.jpg differ
Binary files a/social/ai.jpg and b/social/ai.jpg differ
Binary files a/social/blog.jpg and b/social/blog.jpg differ
Binary files a/social/blog.jpg and b/social/blog.jpg differ
Binary files a/social/bot.jpg and b/social/bot.jpg differ
Binary files a/social/bot.jpg and b/social/bot.jpg differ
Binary files a/social/chat.jpg and b/social/chat.jpg differ
Binary files a/social/chat.jpg and b/social/chat.jpg differ
Binary files a/social/codes.jpg and b/social/codes.jpg differ
Binary files a/social/codes.jpg and b/social/codes.jpg differ
Binary files a/social/community.jpg and b/social/community.jpg differ
Binary files a/social/community.jpg and b/social/community.jpg differ
Binary files a/social/coupons.jpg and b/social/coupons.jpg differ
Binary files a/social/coupons.jpg and b/social/coupons.jpg differ
Binary files a/social/deals.jpg and b/social/deals.jpg differ
Binary files a/social/deals.jpg and b/social/deals.jpg differ
Binary files a/social/events.jpg and b/social/events.jpg differ
Binary files a/social/events.jpg and b/social/events.jpg differ
Binary files a/social/food.jpg and b/social/food.jpg differ
Binary files a/social/food.jpg and b/social/food.jpg differ
Binary files a/social/games.jpg and b/social/games.jpg differ
Binary files a/social/games.jpg and b/social/games.jpg differ
Binary files a/social/help.jpg and b/social/help.jpg differ
Binary files a/social/help.jpg and b/social/help.jpg differ
Binary files a/social/host.jpg and b/social/host.jpg differ
Binary files a/social/host.jpg and b/social/host.jpg differ
Binary files a/social/inventory.jpg and b/social/inventory.jpg differ
Binary files a/social/inventory.jpg and b/social/inventory.jpg differ
Binary files a/social/live-chat.jpg and b/social/live-chat.jpg differ
Binary files a/social/live-chat.jpg and b/social/live-chat.jpg differ
Binary files a/social/live-content.jpg and b/social/live-content.jpg differ
Binary files a/social/live-content.jpg and b/social/live-content.jpg differ
Binary files a/social/live-moments.jpg and b/social/live-moments.jpg differ
Binary files a/social/live-moments.jpg and b/social/live-moments.jpg differ
Binary files a/social/live-rundown.jpg and b/social/live-rundown.jpg differ
Binary files a/social/live-rundown.jpg and b/social/live-rundown.jpg differ
Binary files a/social/live.jpg and b/social/live.jpg differ
Binary files a/social/live.jpg and b/social/live.jpg differ
Binary files a/social/media-hub.jpg and b/social/media-hub.jpg differ
Binary files a/social/media-hub.jpg and b/social/media-hub.jpg differ
Binary files a/social/media.jpg and b/social/media.jpg differ
Binary files a/social/media.jpg and b/social/media.jpg differ
Binary files a/social/network.jpg and b/social/network.jpg differ
Binary files a/social/network.jpg and b/social/network.jpg differ
Binary files a/social/news.jpg and b/social/news.jpg differ
Binary files a/social/news.jpg and b/social/news.jpg differ
Binary files a/social/openre.jpg and b/social/openre.jpg differ
Binary files a/social/openre.jpg and b/social/openre.jpg differ
Binary files a/social/play.jpg and b/social/play.jpg differ
Binary files a/social/play.jpg and b/social/play.jpg differ
Binary files a/social/quest.jpg and b/social/quest.jpg differ
Binary files a/social/quest.jpg and b/social/quest.jpg differ
Binary files a/social/rent.jpg and b/social/rent.jpg differ
Binary files a/social/rent.jpg and b/social/rent.jpg differ
Binary files a/social/reviews.jpg and b/social/reviews.jpg differ
Binary files a/social/reviews.jpg and b/social/reviews.jpg differ
Binary files a/social/run.jpg and b/social/run.jpg differ
Binary files a/social/run.jpg and b/social/run.jpg differ
Binary files a/social/search.jpg and b/social/search.jpg differ
Binary files a/social/search.jpg and b/social/search.jpg differ
Binary files a/social/services.jpg and b/social/services.jpg differ
Binary files a/social/services.jpg and b/social/services.jpg differ
Binary files a/social/space.jpg and b/social/space.jpg differ
Binary files a/social/space.jpg and b/social/space.jpg differ
Binary files a/social/tips.jpg and b/social/tips.jpg differ
Binary files a/social/tips.jpg and b/social/tips.jpg differ
Binary files a/social/tools.jpg and b/social/tools.jpg differ
Binary files a/social/tools.jpg and b/social/tools.jpg differ
Binary files a/social/trade.jpg and b/social/trade.jpg differ
Binary files a/social/trade.jpg and b/social/trade.jpg differ
Binary files a/social/vip.jpg and b/social/vip.jpg differ
Binary files a/social/vip.jpg and b/social/vip.jpg differ
Binary files a/social/watch.jpg and b/social/watch.jpg differ
Binary files a/social/watch.jpg and b/social/watch.jpg differ
Binary files a/social/wiki.jpg and b/social/wiki.jpg differ
Binary files a/social/wiki.jpg and b/social/wiki.jpg differ
Binary files a/social/work.jpg and b/social/work.jpg differ
Binary files a/social/work.jpg and b/social/work.jpg differ
Binary files a/social/zone.jpg and b/social/zone.jpg differ
Binary files a/social/zone.jpg and b/social/zone.jpg differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+// ov-fx.js (4.66.0, plan L45): the network's effects by data-fx. Each name wires its class or listener; a reveal hides
+// only what is below the fold and shows it when it comes on screen; a number counts up and ends on its own text;
+// under reduced motion or Calm nothing is hidden, nothing counts and no confetti falls; markup added later is wired;
+// the navbar loads it only for a page that asks. linkedom with a fake IntersectionObserver and matchMedia.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { parseHTML } = require('linkedom');
+
+const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-fx.js'), 'utf8');
+
+function setup({ reduce = false, calm = false, html = '' } = {}) {
+ const { window, document, MutationObserver } = parseHTML(`<!doctype html><html${calm ? ' data-ov-motion="reduced"' : ''}><head></head><body>${html}</body></html>`);
+ const observed = [];
+ class IO { constructor(cb) { this.cb = cb; } observe(el) { observed.push(el); } unobserve() {} fire(el) { this.cb([{ target: el, isIntersecting: true }]); } }
+ let io = null;
+ const frames = [];
+ window.IntersectionObserver = class extends IO { constructor(cb) { super(cb); io = this; } };
+ window.MutationObserver = MutationObserver;
+ window.matchMedia = (q) => ({ matches: /reduce/.test(q) ? reduce : /hover/.test(q) });
+ window.innerHeight = 800;
+ window.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
+ window.cancelAnimationFrame = () => {};
+ window.setTimeout = (fn) => { fn(); return 0; };
+ window.performance = { now: () => 0 };
+ for (const el of document.querySelectorAll('[data-top]')) el.getBoundingClientRect = () => ({ top: Number(el.getAttribute('data-top')), left: 0, width: 100, height: 40 });
+ window.OVFx = undefined; // linkedom windows share their expando properties
+ new Function('window', SRC)(window);
+ const run = (t) => { let n = 0; while (frames.length && n++ < 500) frames.shift()(t); };
+ return { window, document, io: () => io, observed, run };
+}
+
+(async () => {
+ // ── names wire their classes; the CSS carries the motion tokens and the reduced-motion guards ──
+ {
+ const t = setup({ html: '<a id="a" data-fx="glint lift press glow">x</a><span id="s" data-fx="shimmer"></span><b id="r" data-fx="ripple">r</b>' });
+ const a = t.document.getElementById('a');
+ assert.deepStrictEqual(['glint', 'lift', 'press', 'glow'].map((n) => a.classList.contains(`ovfx-${n}`)), [true, true, true, true]);
+ assert.ok(t.document.getElementById('s').classList.contains('ovfx-shimmer') && t.document.getElementById('r').classList.contains('ovfx-ripple'));
+ const css = t.document.getElementById('ov-fx-css').textContent;
+ assert.match(css, /--ov-dur-1:\.12s;--ov-dur-2:\.2s;--ov-dur-3:\.32s;--ov-dur-4:\.52s/, 'the motion tokens');
+ assert.match(css, /html\[data-ov-motion=reduced\]\{--ov-dur-1:0s/, 'Calm zeroes them');
+ assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{\.ovfx-glint::after\{display:none\}/);
+ assert.ok(!/infinite/.test(css.replace(/\.ovfx-shimmer\{[^}]*\}/, '')), 'nothing loops but a placeholder\'s shimmer');
+ assert.strictEqual(typeof t.window.OVFx.confetti, 'function');
+ }
+
+ // ── reveal: below the fold it waits for the screen; on screen it is never hidden ──
+ {
+ const t = setup({ html: '<ul id="low" data-fx="reveal" data-fx-stagger="40" data-top="1400"><li>a</li><li>b</li><li>c</li></ul><ul id="top" data-fx="reveal" data-top="100"><li>x</li></ul>' });
+ const low = t.document.getElementById('low'), top = t.document.getElementById('top');
+ assert.ok([...low.children].every((k) => k.classList.contains('ovfx-hide')), 'below the fold: hidden until seen');
+ assert.strictEqual(low.children[2].style.getPropertyValue('--ovfx-d'), '80ms', 'staggered');
+ assert.ok(![...top.children].some((k) => k.classList.contains('ovfx-hide')), 'on screen when wired: never hidden');
+ assert.ok(t.observed.includes(low) && !t.observed.includes(top));
+ t.io().fire(low);
+ assert.ok([...low.children].every((k) => !k.classList.contains('ovfx-hide')), 'seen: shown');
+ }
+
+ // ── count: from 0 up to the number, ending on its own text ──
+ {
+ const t = setup({ html: '<b id="n" data-fx="count" data-top="1200">12,480 viewers</b><b id="m" data-fx="count" data-top="10">€3.50</b><b id="w" data-fx="count" data-top="1200">soon</b>' });
+ const n = t.document.getElementById('n');
+ t.io().fire(n);
+ t.run(100);
+ assert.notStrictEqual(n.textContent, '12,480 viewers', 'counting');
+ assert.match(n.textContent, / viewers$/, 'keeps its words');
+ t.run(5000);
+ assert.strictEqual(n.textContent, '12,480 viewers', 'ends exactly on its own text');
+ t.run(5000);
+ assert.strictEqual(t.document.getElementById('m').textContent, '€3.50', 'decimals end exact too');
+ assert.strictEqual(t.document.getElementById('w').textContent, 'soon', 'not a number: left alone');
+ }
+
+ // ── reduced motion and Calm: nothing hidden, nothing counted, no confetti ──
+ for (const opts of [{ reduce: true }, { calm: true }]) {
+ const t = setup({ ...opts, html: '<ul data-fx="reveal" data-top="1400"><li>a</li></ul><b id="n" data-fx="count" data-top="1400">900</b>' });
+ assert.strictEqual(t.document.querySelectorAll('.ovfx-hide').length, 0, 'nothing hidden');
+ assert.strictEqual(t.observed.length, 0, 'nothing waits to count');
+ assert.strictEqual(t.document.getElementById('n').textContent, '900');
+ assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'no confetti');
+ }
+
+ // ── markup added later is wired ──
+ {
+ const t = setup();
+ const el = t.document.createElement('a'); el.setAttribute('data-fx', 'press'); t.document.body.appendChild(el);
+ await new Promise((r) => setImmediate(r));
+ t.window.OVFx.scan(t.document.body);
+ assert.ok(el.classList.contains('ovfx-press'));
+ }
+
+ // ── the navbar loads it once the page settles, only when the page asks ──
+ {
+ const nav = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');
+ assert.match(nav, /function queueFx\(\)/);
+ assert.match(nav, /!document\.querySelector\('\[data-fx\]'\)\) return;/, 'only for a page with data-fx');
+ assert.match(nav, /siblingV\('ov-fx\.js'\)/, 'from beside the navbar, by content hash');
+ assert.ok(require('../files').BROWSER.includes('ov-fx.js'), 'served');
+ }
+
+ console.log('fx: names wire their effects; reveal waits below the fold and never hides what is on screen; numbers end on their own text; reduced motion and Calm keep still; later markup wired; loaded only on request');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -36,7 +36,7 @@ for (const form of ['pair', 'heavy']) {
@@ -36,7 +36,7 @@ for (const form of ['pair', 'heavy']) {
}
}
// The standalone SVG: the gap is a mask, so it holds on any ground; the tones; the wide box.
// The standalone SVG: the gap is a mask, so it holds on any ground; the tones; the wide box.
-const svg = mark.svg({ size: 64, title: 'OpenVibe' });
+const svg = mark.svg({ size: 64, title: 'OpenVibe', depth: false }); // the flat colours (depth is checked below)
assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="[\d. ]+" width="64" height="64" role="img" aria-label="OpenVibe"><title>OpenVibe<\/title>/);
assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="[\d. ]+" width="64" height="64" role="img" aria-label="OpenVibe"><title>OpenVibe<\/title>/);
assert.match(svg, /<mask id="ovmh"[\s\S]*stroke="#000" stroke-width="20"[\s\S]*<\/mask>[\s\S]*<circle [^>]*stroke="#4f7cff"[^>]*mask="url\(#ovmh\)"\/><path [^>]*stroke="#8b5cf6"/, 'blue O cut by a mask, violet V on top');
assert.match(svg, /<mask id="ovmh"[\s\S]*stroke="#000" stroke-width="20"[\s\S]*<\/mask>[\s\S]*<circle [^>]*stroke="#4f7cff"[^>]*mask="url\(#ovmh\)"\/><path [^>]*stroke="#8b5cf6"/, 'blue O cut by a mask, violet V on top');
assert.match(mark.svg({ tone: 'ink' }), /stroke="currentColor"[\s\S]*stroke="currentColor"/, 'ink: the text colour');
assert.match(mark.svg({ tone: 'ink' }), /stroke="currentColor"[\s\S]*stroke="currentColor"/, 'ink: the text colour');
@@ -67,4 +67,20 @@ assert.ok(!appIcon.svg({ site: 'network' }).includes('fill="#ffffff"'), 'a white
@@ -67,4 +67,20 @@ assert.ok(!appIcon.svg({ site: 'network' }).includes('fill="#ffffff"'), 'a white
assert.ok(src.includes(`viewBox="${mark.geo('pair').box}"`) && src.includes(`cx="${mark.geo('pair').cx}" cy="50" r="20"`), 'and the same O in the same box');
assert.ok(src.includes(`viewBox="${mark.geo('pair').box}"`) && src.includes(`cx="${mark.geo('pair').cx}" cy="50" r="20"`), 'and the same O in the same box');
}
}
+// ── Depth (4.66.0, plan L44): the static mark and the app icons wear ov-mark.js's light-to-deep strokes ──
+{
+ const t = mark.tones(mark.COLORS.o, mark.COLORS.v);
+ assert.deepStrictEqual(t, { o: ['#87a6ff', '#4268d6'], v: ['#ae8df9', '#784fd4'] }, 'the same mixes as ov-mark.js: O 68 % / 84 %, V 70 % / 86 %');
+ const brand = mark.svg({ size: 64 });
+ assert.strictEqual((brand.match(/<linearGradient id="ovmg[ov]" gradientUnits="userSpaceOnUse"/g) || []).length, 2, 'the brand mark has depth by default, in user space');
+ assert.ok(brand.includes('stroke="url(#ovmgo)"') && brand.includes('stroke="url(#ovmgv)"'));
+ assert.ok(!mark.svg({ tone: 'ink' }).includes('linearGradient') && !mark.svg({ tone: 'white' }).includes('linearGradient'), 'one-colour tones stay flat');
+ assert.ok(!mark.svg({ depth: false }).includes('linearGradient'), 'depth can be turned off');
+ assert.ok(!mark.svg({ tone: { o: 'red', v: 'blue' }, depth: true }).includes('linearGradient'), 'only hex colours get tones');
+ const err = mark.art({ shape: 'err', depth: true, id: 'e' });
+ assert.ok(err.defs.includes('id="ego"') && err.body.includes('url(#egv)'), 'the "!" (a straight stroke) still paints: user space');
+ const ai = require('../app-icon');
+ assert.ok(ai.svg({ site: 'live' }).includes('url(#ovgo)') && ai.favicon({ site: 'network' }).includes('url(#ovgv)'), 'the app icons and favicons have depth');
+}
+
console.log('mark: one geometry in mark.js, ov-mark.js, the icons and the loader; centred, level, the gap a mask; tones, wide box, maskable safe zone');
console.log('mark: one geometry in mark.js, ov-mark.js, the icons and the loader; centred, level, the gap a mask; tones, wide box, maskable safe zone');