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)

OP OpenVibers committed 13 days ago · 03ad3918b5 · 85 files, +380 −17

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex fc96e27..7a94edf 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 774d0ec..dfd56f7 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex fa8182b..1669891 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/app-icon.js b/app-icon.js
iindex 821cfb0..584fd62 100644
---- a/app-icon.js
++++ b/app-icon.js
@@@ -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>`;
}
ddiff --git a/files.js b/files.js
iindex 8af9ac7..8ab8ec0 100644
---- a/files.js
++++ b/files.js
@@@ -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, FedCM
ddiff --git a/icons/ai-192.png b/icons/ai-192.png
iindex 0bf8180..4cdb839 100644
BBinary files a/icons/ai-192.png and b/icons/ai-192.png differ
ddiff --git a/icons/ai-512.png b/icons/ai-512.png
iindex 638de87..7d592a2 100644
BBinary files a/icons/ai-512.png and b/icons/ai-512.png differ
ddiff --git a/icons/ai-maskable-192.png b/icons/ai-maskable-192.png
iindex ba0bbd8..9bdd934 100644
BBinary files a/icons/ai-maskable-192.png and b/icons/ai-maskable-192.png differ
ddiff --git a/icons/ai-maskable-512.png b/icons/ai-maskable-512.png
iindex 62d42a8..289b78f 100644
BBinary files a/icons/ai-maskable-512.png and b/icons/ai-maskable-512.png differ
ddiff --git a/icons/community-192.png b/icons/community-192.png
iindex d2154e6..99b84e6 100644
BBinary files a/icons/community-192.png and b/icons/community-192.png differ
ddiff --git a/icons/community-512.png b/icons/community-512.png
iindex 1957052..636ea69 100644
BBinary files a/icons/community-512.png and b/icons/community-512.png differ
ddiff --git a/icons/community-maskable-192.png b/icons/community-maskable-192.png
iindex f1e4cf3..f41fb6e 100644
BBinary files a/icons/community-maskable-192.png and b/icons/community-maskable-192.png differ
ddiff --git a/icons/community-maskable-512.png b/icons/community-maskable-512.png
iindex 7ef2ffc..ed7eade 100644
BBinary files a/icons/community-maskable-512.png and b/icons/community-maskable-512.png differ
ddiff --git a/icons/games-192.png b/icons/games-192.png
iindex 6ff26ea..79036bb 100644
BBinary files a/icons/games-192.png and b/icons/games-192.png differ
ddiff --git a/icons/games-512.png b/icons/games-512.png
iindex 2a3cd99..f717587 100644
BBinary files a/icons/games-512.png and b/icons/games-512.png differ
ddiff --git a/icons/games-maskable-192.png b/icons/games-maskable-192.png
iindex 6e85942..3404d37 100644
BBinary files a/icons/games-maskable-192.png and b/icons/games-maskable-192.png differ
ddiff --git a/icons/games-maskable-512.png b/icons/games-maskable-512.png
iindex 7f91a18..d5b66c7 100644
BBinary files a/icons/games-maskable-512.png and b/icons/games-maskable-512.png differ
ddiff --git a/icons/live-192.png b/icons/live-192.png
iindex 82d72c8..eeea24d 100644
BBinary files a/icons/live-192.png and b/icons/live-192.png differ
ddiff --git a/icons/live-512.png b/icons/live-512.png
iindex 25b82ed..a13c66e 100644
BBinary files a/icons/live-512.png and b/icons/live-512.png differ
ddiff --git a/icons/live-maskable-192.png b/icons/live-maskable-192.png
iindex 54bf86d..cd89a01 100644
BBinary files a/icons/live-maskable-192.png and b/icons/live-maskable-192.png differ
ddiff --git a/icons/live-maskable-512.png b/icons/live-maskable-512.png
iindex f232c6d..1d1b968 100644
BBinary files a/icons/live-maskable-512.png and b/icons/live-maskable-512.png differ
ddiff --git a/icons/media-192.png b/icons/media-192.png
iindex b53300e..9cf8cec 100644
BBinary files a/icons/media-192.png and b/icons/media-192.png differ
ddiff --git a/icons/media-512.png b/icons/media-512.png
iindex a6ee745..464b884 100644
BBinary files a/icons/media-512.png and b/icons/media-512.png differ
ddiff --git a/icons/media-maskable-192.png b/icons/media-maskable-192.png
iindex 5b42428..4880425 100644
BBinary files a/icons/media-maskable-192.png and b/icons/media-maskable-192.png differ
ddiff --git a/icons/media-maskable-512.png b/icons/media-maskable-512.png
iindex 3f632b1..337608d 100644
BBinary files a/icons/media-maskable-512.png and b/icons/media-maskable-512.png differ
ddiff --git a/icons/network-192.png b/icons/network-192.png
iindex 488bd33..99de24d 100644
BBinary files a/icons/network-192.png and b/icons/network-192.png differ
ddiff --git a/icons/network-512.png b/icons/network-512.png
iindex 633ed8e..1633c01 100644
BBinary files a/icons/network-512.png and b/icons/network-512.png differ
ddiff --git a/icons/network-maskable-192.png b/icons/network-maskable-192.png
iindex 1decfe1..fdc7fca 100644
BBinary files a/icons/network-maskable-192.png and b/icons/network-maskable-192.png differ
ddiff --git a/icons/network-maskable-512.png b/icons/network-maskable-512.png
iindex ea2b674..3eaac84 100644
BBinary files a/icons/network-maskable-512.png and b/icons/network-maskable-512.png differ
ddiff --git a/icons/space-192.png b/icons/space-192.png
iindex 8564370..e42c434 100644
BBinary files a/icons/space-192.png and b/icons/space-192.png differ
ddiff --git a/icons/space-512.png b/icons/space-512.png
iindex 48a07ab..0d3ae39 100644
BBinary files a/icons/space-512.png and b/icons/space-512.png differ
ddiff --git a/icons/space-maskable-192.png b/icons/space-maskable-192.png
iindex e4e1c2e..4991d0c 100644
BBinary files a/icons/space-maskable-192.png and b/icons/space-maskable-192.png differ
ddiff --git a/icons/space-maskable-512.png b/icons/space-maskable-512.png
iindex 774de56..c3ba54f 100644
BBinary files a/icons/space-maskable-512.png and b/icons/space-maskable-512.png differ
ddiff --git a/icons/tools-192.png b/icons/tools-192.png
iindex f43ed9c..d24ddcb 100644
BBinary files a/icons/tools-192.png and b/icons/tools-192.png differ
ddiff --git a/icons/tools-512.png b/icons/tools-512.png
iindex aaaa2da..ef06b54 100644
BBinary files a/icons/tools-512.png and b/icons/tools-512.png differ
ddiff --git a/icons/tools-maskable-192.png b/icons/tools-maskable-192.png
iindex df0c819..1ae4f6d 100644
BBinary files a/icons/tools-maskable-192.png and b/icons/tools-maskable-192.png differ
ddiff --git a/icons/tools-maskable-512.png b/icons/tools-maskable-512.png
iindex dba5195..40dbd3d 100644
BBinary files a/icons/tools-maskable-512.png and b/icons/tools-maskable-512.png differ
ddiff --git a/mark.js b/mark.js
iindex 2b7d251..a4094fb 100644
---- a/mark.js
++++ b/mark.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 };
ddiff --git a/navbar.js b/navbar.js
iindex ba34785..8ab25bb 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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);
ddiff --git a/ov-fx.js b/ov-fx.js
nnew file mode 100644
iindex 0000000..9235ecb
---- /dev/null
++++ b/ov-fx.js
@@@ -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);
ddiff --git a/package.json b/package.json
iindex f62a79c..7127ab6 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 76a8b99..51b4ce0 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -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(', ')} };`;
ddiff --git a/social/actor.jpg b/social/actor.jpg
iindex 2f3a3d6..6f1403b 100644
BBinary files a/social/actor.jpg and b/social/actor.jpg differ
ddiff --git a/social/ai.jpg b/social/ai.jpg
iindex de1b3d6..38d5ec2 100644
BBinary files a/social/ai.jpg and b/social/ai.jpg differ
ddiff --git a/social/blog.jpg b/social/blog.jpg
iindex 1b69a16..450acf0 100644
BBinary files a/social/blog.jpg and b/social/blog.jpg differ
ddiff --git a/social/bot.jpg b/social/bot.jpg
iindex 4fe7102..6c13d10 100644
BBinary files a/social/bot.jpg and b/social/bot.jpg differ
ddiff --git a/social/chat.jpg b/social/chat.jpg
iindex 1be65e8..e74043c 100644
BBinary files a/social/chat.jpg and b/social/chat.jpg differ
ddiff --git a/social/codes.jpg b/social/codes.jpg
iindex 37ca341..f334d1b 100644
BBinary files a/social/codes.jpg and b/social/codes.jpg differ
ddiff --git a/social/community.jpg b/social/community.jpg
iindex cec7aab..f048c67 100644
BBinary files a/social/community.jpg and b/social/community.jpg differ
ddiff --git a/social/coupons.jpg b/social/coupons.jpg
iindex 425c144..0f899b8 100644
BBinary files a/social/coupons.jpg and b/social/coupons.jpg differ
ddiff --git a/social/deals.jpg b/social/deals.jpg
iindex 21ad35c..4c1c18e 100644
BBinary files a/social/deals.jpg and b/social/deals.jpg differ
ddiff --git a/social/events.jpg b/social/events.jpg
iindex e660f75..403405a 100644
BBinary files a/social/events.jpg and b/social/events.jpg differ
ddiff --git a/social/food.jpg b/social/food.jpg
iindex 96fcec6..8be2378 100644
BBinary files a/social/food.jpg and b/social/food.jpg differ
ddiff --git a/social/games.jpg b/social/games.jpg
iindex a47af0a..1fb1997 100644
BBinary files a/social/games.jpg and b/social/games.jpg differ
ddiff --git a/social/help.jpg b/social/help.jpg
iindex f0b6ee3..b750c97 100644
BBinary files a/social/help.jpg and b/social/help.jpg differ
ddiff --git a/social/host.jpg b/social/host.jpg
iindex 3a29b28..25c7daa 100644
BBinary files a/social/host.jpg and b/social/host.jpg differ
ddiff --git a/social/inventory.jpg b/social/inventory.jpg
iindex d01e263..a4ad3fe 100644
BBinary files a/social/inventory.jpg and b/social/inventory.jpg differ
ddiff --git a/social/live-chat.jpg b/social/live-chat.jpg
iindex d08983f..a6c33dc 100644
BBinary files a/social/live-chat.jpg and b/social/live-chat.jpg differ
ddiff --git a/social/live-content.jpg b/social/live-content.jpg
iindex 0cd480d..de5b8f0 100644
BBinary files a/social/live-content.jpg and b/social/live-content.jpg differ
ddiff --git a/social/live-moments.jpg b/social/live-moments.jpg
iindex 03955cd..19201f0 100644
BBinary files a/social/live-moments.jpg and b/social/live-moments.jpg differ
ddiff --git a/social/live-rundown.jpg b/social/live-rundown.jpg
iindex 2d8a154..3b2b2d2 100644
BBinary files a/social/live-rundown.jpg and b/social/live-rundown.jpg differ
ddiff --git a/social/live.jpg b/social/live.jpg
iindex 06fed75..6f46cf0 100644
BBinary files a/social/live.jpg and b/social/live.jpg differ
ddiff --git a/social/media-hub.jpg b/social/media-hub.jpg
iindex 613d8c2..f58f32a 100644
BBinary files a/social/media-hub.jpg and b/social/media-hub.jpg differ
ddiff --git a/social/media.jpg b/social/media.jpg
iindex f4bb857..1cf19e9 100644
BBinary files a/social/media.jpg and b/social/media.jpg differ
ddiff --git a/social/network.jpg b/social/network.jpg
iindex 3cd41ae..fdc63cb 100644
BBinary files a/social/network.jpg and b/social/network.jpg differ
ddiff --git a/social/news.jpg b/social/news.jpg
iindex 6ff1fb9..8fbe3f9 100644
BBinary files a/social/news.jpg and b/social/news.jpg differ
ddiff --git a/social/openre.jpg b/social/openre.jpg
iindex 06fd2d0..7419491 100644
BBinary files a/social/openre.jpg and b/social/openre.jpg differ
ddiff --git a/social/play.jpg b/social/play.jpg
iindex d2d5306..338c46d 100644
BBinary files a/social/play.jpg and b/social/play.jpg differ
ddiff --git a/social/quest.jpg b/social/quest.jpg
iindex 840b12f..88989c8 100644
BBinary files a/social/quest.jpg and b/social/quest.jpg differ
ddiff --git a/social/rent.jpg b/social/rent.jpg
iindex 5ca16d1..879d4f8 100644
BBinary files a/social/rent.jpg and b/social/rent.jpg differ
ddiff --git a/social/reviews.jpg b/social/reviews.jpg
iindex fbcd434..1ddbb20 100644
BBinary files a/social/reviews.jpg and b/social/reviews.jpg differ
ddiff --git a/social/run.jpg b/social/run.jpg
iindex 2bc7f8a..114ebb4 100644
BBinary files a/social/run.jpg and b/social/run.jpg differ
ddiff --git a/social/search.jpg b/social/search.jpg
iindex c55f6af..b42c691 100644
BBinary files a/social/search.jpg and b/social/search.jpg differ
ddiff --git a/social/services.jpg b/social/services.jpg
iindex 092fe1e..9946f9e 100644
BBinary files a/social/services.jpg and b/social/services.jpg differ
ddiff --git a/social/space.jpg b/social/space.jpg
iindex d83df2d..452450d 100644
BBinary files a/social/space.jpg and b/social/space.jpg differ
ddiff --git a/social/tips.jpg b/social/tips.jpg
iindex 370577b..654c6d4 100644
BBinary files a/social/tips.jpg and b/social/tips.jpg differ
ddiff --git a/social/tools.jpg b/social/tools.jpg
iindex 02e3743..0af088e 100644
BBinary files a/social/tools.jpg and b/social/tools.jpg differ
ddiff --git a/social/trade.jpg b/social/trade.jpg
iindex 7541829..8c4d957 100644
BBinary files a/social/trade.jpg and b/social/trade.jpg differ
ddiff --git a/social/vip.jpg b/social/vip.jpg
iindex deb1a9e..9542061 100644
BBinary files a/social/vip.jpg and b/social/vip.jpg differ
ddiff --git a/social/watch.jpg b/social/watch.jpg
iindex 203e7b1..c700b0b 100644
BBinary files a/social/watch.jpg and b/social/watch.jpg differ
ddiff --git a/social/wiki.jpg b/social/wiki.jpg
iindex 18b8b67..3ddbe06 100644
BBinary files a/social/wiki.jpg and b/social/wiki.jpg differ
ddiff --git a/social/work.jpg b/social/work.jpg
iindex b34692e..eeb3f60 100644
BBinary files a/social/work.jpg and b/social/work.jpg differ
ddiff --git a/social/zone.jpg b/social/zone.jpg
iindex 7caf2bd..ea508e7 100644
BBinary files a/social/zone.jpg and b/social/zone.jpg differ
ddiff --git a/test/fx.test.js b/test/fx.test.js
nnew file mode 100644
iindex 0000000..941b6ad
---- /dev/null
++++ b/test/fx.test.js
@@@ -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); });
ddiff --git a/test/mark.test.js b/test/mark.test.js
iindex e16f6da..2de1dac 100644
---- a/test/mark.test.js
++++ b/test/mark.test.js
@@@ -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 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');
}
++// ── 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');