openvibers/openvibe.shared Commits

4.67.0: share your look by link; the theme's save is dated; each look moves with its own feel (#219)

OP OpenVibers committed 12 days ago · a38b720217 · 11 files, +72 −13

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 7a94edf..674f432 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,20 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.67.0 — 2026-10-31
++
++- **Share this look** (plan L45 step 4). The Look panel has a Share button beside "saved to your account". It copies
++ a link to this page with `?ov-try=<theme>` (the phone's share sheet on Android and iOS). Anyone who opens it sees the
++ page in that theme with theme-loader's Use it / Stop bar, and nothing is saved until they choose. Built-in themes
++ only: custom colours never ride a link. `OVLook.shareLink(id)`.
++- **The theme's save is dated**, as the display settings are since 4.64.0. The PUT carries `at`, and Network (since
++ #458) keeps the newest copy.
++- **A feel for each look** (plan L45 step 1). ov-fx.js's motion tokens follow the theme:
++ - the quiet paper and light themes move calmly: a little slower, and the spring settles without overshoot;
++ - the neon and terminal ones snap: quicker, with a livelier spring;
++ - the rest stay lively;
++ - Calm still sets every length to zero. `OVFx.FEEL` lists the two groups.
++
## 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
ddiff --git a/README.md b/README.md
iindex dfd56f7..d8586c9 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.66.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.67.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 1669891..01c8cca 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.66.0",
++ "version": "4.67.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/navbar.js b/navbar.js
iindex 8ab25bb..cd45dd4 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', 'ov-fx.js': 'de375a22cf17' };
++ const SIBLING_REV = { 'ov-mark.js': '82afb8a8cb4d', 'ov-glyphs.js': '2b97058350a5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '220c2bf637aa', 'ov-fx.js': '46fce1e2b8db' };
// 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">.
ddiff --git a/ov-fx.js b/ov-fx.js
iindex 9235ecb..36e909f 100644
---- a/ov-fx.js
++++ b/ov-fx.js
@@@ -15,7 +15,8 @@
* 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
++ * --ov-ease-spring, --ov-ease-in-out. Each look has a feel (4.67.0): the quiet paper and light themes move calmly (a
++ * little slower, no overshoot), the neon and terminal ones snap (quicker, a livelier spring); the rest are lively. 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.
*/
@@@ -23,7 +24,12 @@
'use strict';
if (!root || root.OVFx) return;
const doc = root.document;
++ const feel = (ids) => ids.map((id) => `[data-theme=${id}]`).join(',');
++ const CALM = feel(['paper', 'daylight', 'cloud', 'snow', 'sand', 'rose', 'meadow', 'peach', 'lavender', 'dawn', 'solarized-light', 'catppuccin-latte', 'nord', 'nord-light']);
++ const SNAPPY = feel(['neon-tokyo', 'vapor', 'matrix', 'hacker', 'high-contrast', 'dracula', 'monokai', 'abyss']);
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:is(${CALM}){--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}`
++ + `html:is(${SNAPPY}){--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s;--ov-ease-spring:cubic-bezier(.3,1.8,.6,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}'
@@@ -182,6 +188,6 @@
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 };
++ root.OVFx = { scan, confetti, wire, FEEL: { CALM, SNAPPY } };
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', init); else init();
})(typeof window !== 'undefined' ? window : this);
ddiff --git a/ov-look.js b/ov-look.js
iindex 05a564d..b5bd170 100644
---- a/ov-look.js
++++ b/ov-look.js
@@@ -6,8 +6,9 @@
* OVLook.mount(box, { signedIn, season }) draws the panel into `box` and keeps it current (ov:theme, ov:display);
* season: false leaves out the season switch where the host has its own
*
-- * Theme swatches (the season's own while there is one), the season switch, where this look comes from, and
-- * "More looks" on openvibe.design, the home of every theme. State is theme-loader.js's: a pick applies at once, is
++ * Theme swatches (the season's own while there is one), the season switch, where this look comes from, Share (4.67.0:
++ * a link that opens this page in your theme for anyone, with theme-loader's Use it / Stop bar; built-in themes only),
++ * and "More looks" on openvibe.design, the home of every theme. State is theme-loader.js's: a pick applies at once, is
* kept in this browser, saved to the account when signed in, and travels with links to the other OpenVibe sites.
*/
(function (root) {
@@@ -28,7 +29,9 @@
+ '.ovlk-season b{color:var(--text-primary,#e6edf7);font-weight:650}'
+ '.ovlk .ovlk-switch{all:unset;position:relative;display:block;width:38px!important;height:22px!important;padding:0!important;flex:none;border-radius:999px;background:var(--border,#2c3d5c);cursor:pointer}.ovlk-switch::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;transition:transform .15s}'
+ '.ovlk .ovlk-switch[aria-checked=true]{background:var(--accent-strong,var(--accent,#3472d8))}.ovlk .ovlk-switch[aria-checked=true]::after{transform:translateX(16px)}.ovlk .ovlk-switch:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}'
-- + '.ovlk-why{font-size:11.5px;color:var(--text-muted,#7d8aa0)}'
++ + '.ovlk-foot{display:flex;align-items:center;justify-content:space-between;gap:10px}.ovlk-why{min-width:0;font-size:11.5px;color:var(--text-muted,#7d8aa0)}'
++ + '.ovlk .ovlk-share{all:unset;flex:none;display:inline-flex;align-items:center;min-height:30px;padding:0 12px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-primary,#e6edf7);font-size:12px;font-weight:650;cursor:pointer}'
++ + '.ovlk .ovlk-share:hover{border-color:var(--accent,#3b82f6)}.ovlk .ovlk-share:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}'
+ '.ovlk a.ovlk-more{display:flex!important;align-items:center;justify-content:space-between;gap:8px;padding:9px 10px!important;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.1));color:var(--text-primary,#e6edf7)!important;text-decoration:none;font-size:13px!important;font-weight:600!important;line-height:1.35}'
+ '.ovlk-more:hover{border-color:var(--accent,#3b82f6)}.ovlk-more span{color:var(--accent-light,var(--accent,#60a5fa))}'
+ '@media (prefers-reduced-motion:reduce){.ovlk-switch::after{transition:none}}';
@@@ -70,11 +73,30 @@
}
const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());
const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);
-- panel.append(el('div', { class: 'ovlk-why' }, [`${named} \u00b7 ${signedIn ? 'saved to your account' : 'saved in this browser'}`]));
++ const why = el('div', { class: 'ovlk-why' }, [`${named} \u00b7 ${signedIn ? 'saved to your account' : 'saved in this browser'}`]);
++ const shareId = showing ? showing.theme : cur;
++ panel.append(el('div', { class: 'ovlk-foot' }, [why].concat(L.THEMES[shareId] ? [el('button', { type: 'button', class: 'ovlk-share', 'data-look-share': shareId, 'aria-label': `Share the ${named} look` }, ['Share'])] : [])));
panel.append(el('a', { class: 'ovlk-more', href: `${DESIGN}/` }, ['More looks on openvibe.design', el('span', {}, ['\u2192'])]));
box.append(panel);
}
++ /** A link to this page in that theme: anyone who opens it tries it (theme-loader's bar: Use it, Stop). */
++ function shareLink(id) {
++ const u = new URL(root.location.href);
++ u.hash = '';
++ ['ov_look', 'ov-try'].forEach((k) => u.searchParams.delete(k));
++ u.searchParams.set('ov-try', id);
++ return u.toString();
++ }
++ function share(btn) {
++ const url = shareLink(btn.getAttribute('data-look-share'));
++ const said = (t) => { btn.textContent = t; root.setTimeout(() => { if (btn.isConnected) btn.textContent = 'Share'; }, 1800); };
++ const nav = root.navigator || {};
++ if (nav.share && /Android|iPhone|iPad/i.test(nav.userAgent || '')) return nav.share({ title: 'My OpenVibe look', url }).catch(() => {});
++ if (nav.clipboard && nav.clipboard.writeText) return nav.clipboard.writeText(url).then(() => said('Link copied'), () => { root.prompt && root.prompt('Copy this link', url); });
++ if (root.prompt) root.prompt('Copy this link', url);
++ }
++
function mount(box, opts) {
if (!box || box.__ovlk) return;
box.__ovlk = true;
@@@ -83,6 +105,8 @@
draw(box, opts);
box.addEventListener('click', (e) => {
const L = root.OpenVibeThemeLoader; if (!L) return;
++ const sh = e.target.closest && e.target.closest('[data-look-share]');
++ if (sh) { e.stopPropagation(); return share(sh); }
const s = e.target.closest && e.target.closest('[data-look-season]');
if (s) { e.stopPropagation(); L.display.set({ seasonal: s.getAttribute('aria-checked') === 'true' ? 'off' : 'on' }); return draw(box, opts); }
const b = e.target.closest && e.target.closest('[data-look]'); if (!b) return;
@@@ -95,5 +119,5 @@
['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, () => { if (box.isConnected) draw(box, opts); }));
}
-- root.OVLook = { mount, PICKS };
++ root.OVLook = { mount, PICKS, shareLink };
})(typeof window !== 'undefined' ? window : this);
ddiff --git a/package.json b/package.json
iindex 7127ab6..a48b886 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.66.0",
++ "version": "4.67.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/fx.test.js b/test/fx.test.js
iindex 941b6ad..0365639 100644
---- a/test/fx.test.js
++++ b/test/fx.test.js
@@@ -44,6 +44,10 @@ function setup({ reduce = false, calm = false, html = '' } = {}) {
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');
++ // A feel per look (4.67.0): quiet themes move calmly (no overshoot), neon ones snap; Calm still wins.
++ assert.match(css, /html:is\(\[data-theme=paper\],[^)]*\)\{--ov-dur-1:\.15s;[^}]*--ov-ease-spring:var\(--ov-ease-out\)\}/, 'calm looks: slower, no overshoot');
++ assert.match(css, /html:is\(\[data-theme=neon-tokyo\],[^)]*\)\{--ov-dur-1:\.09s;/, 'neon looks snap');
++ assert.ok(css.indexOf('html[data-ov-motion=reduced]{--ov-dur-1:0s') > css.indexOf('html:is([data-theme=neon-tokyo]'), 'Calm comes last, so it wins over any feel');
}
// ── reveal: below the fold it waits for the screen; on screen it is never hidden ──
ddiff --git a/test/look-boot.test.js b/test/look-boot.test.js
iindex 91a5491..ad8b939 100644
---- a/test/look-boot.test.js
++++ b/test/look-boot.test.js
@@@ -78,7 +78,7 @@ assert.ok(tag({ nonce: 'abc123' }).startsWith('<script nonce="abc123">'));
assert.ok(!tag({ nonce: '"><x' }).includes('"><x'), 'a nonce cannot break out of its attribute');
// ── The Look panel offers only real themes, and the navbar opens it in place ──
--const sandbox = { window: {} };
++const sandbox = { window: { location: { href: 'https://openvibe.live/@goosely?tab=clips&ov_look=abc&ov-try=old#chat' } }, URL };
sandbox.window.window = sandbox.window;
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'ov-look.js'), 'utf8'), sandbox);
const { PICKS } = sandbox.window.OVLook;
@@@ -90,5 +90,10 @@ const nav = fs.readFileSync(path.join(ROOT, 'navbar.js'), 'utf8');
assert.ok(/data-ov-look-open/.test(nav) && nav.includes("siblingV('ov-look.js')"), 'the navbar loads the panel from beside itself');
assert.ok(!nav.includes('openvibe.network/themes"'), 'the menu no longer sends people to another site to change their look');
assert.ok(require('../files').BROWSER.includes('ov-look.js'), 'served');
++// Share (4.67.0): this page in that theme, for anyone; the tried theme's own bar offers Use it / Stop.
++const shared = new URL(sandbox.window.OVLook.shareLink('nord'));
++assert.deepStrictEqual([shared.origin + shared.pathname, shared.searchParams.get('tab'), shared.searchParams.get('ov-try'), shared.searchParams.has('ov_look'), shared.hash], ['https://openvibe.live/@goosely', 'clips', 'nord', false, ''], 'the page, its own query, the theme to try; never a carried look or an anchor');
++const lookSrc = fs.readFileSync(path.join(__dirname, '..', 'ov-look.js'), 'utf8');
++assert.match(lookSrc, /L\.THEMES\[shareId\] \? \[el\('button'/, 'Share only for a built-in theme (custom colours cannot ride a link)');
console.log(`look boot: paints ov_paint or a carried look before first paint (${BOOT.length} chars), colours only, ended seasons left; the Look panel's ${PICKS.length} picks are real themes`);
ddiff --git a/test/look-travel.test.js b/test/look-travel.test.js
iindex 35bc460..4d118b9 100644
---- a/test/look-travel.test.js
++++ b/test/look-travel.test.js
@@@ -63,6 +63,12 @@ const display = (store) => JSON.parse(store.get('ov_display') || 'null');
assert.strictEqual(display(p.store).seasonal, 'off');
assert.strictEqual(display(p.store).at, NOW, 'dated now');
++ // ── A theme saved here carries its date too (4.67.0): the account keeps the newest ──
++ p = load();
++ p.L.save('nord');
++ const themePut = p.puts.find((x) => /\/api\/themes\/me$/.test(x.url));
++ assert.ok(themePut && themePut.body.theme_id === 'nord' && themePut.body.at === NOW, 'the theme\'s PUT is dated');
++
// ── The account's answer: newest wins ──
const older = new Map([['ov_display', JSON.stringify({ seasonal: 'off', at: NOW - 5000 })]]);
p = load({ store: older, active: { theme_id: 'vibe', display: { seasonal: 'on' }, display_at: NOW - 60000 } });
ddiff --git a/theme-loader.js b/theme-loader.js
iindex ae7a5eb..255b694 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -322,7 +322,7 @@
if (token) headers['Authorization'] = 'Bearer ' + token;
fetch(_config.apiBase + '/api/themes/me', {
method: 'PUT', headers: headers, credentials: 'include', keepalive: true,
-- body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),
++ body: JSON.stringify({ theme_id: themeId, custom_variables: custom, at: themeAt() }),
}).catch(function () { /* offline / signed out — local cache still holds */ });
}