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)
Changes
@@@ -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@@@ -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 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.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)",@@@ -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">.@@@ -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);@@@ -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);@@@ -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": {@@@ -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 ──@@@ -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`);@@@ -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 } });@@@ -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 */ });}
@@ -6,6 +6,20 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,20 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 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
- **`ov-fx.js`, the network's effects in one kit** (plan L45; the owner: "reusable modular sharable systems and
@@ -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.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
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.66.0",
+ "version": "4.67.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)",
@@ -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', '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
// 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">.
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
* only), reveal, count, confetti, shimmer (a placeholder that shimmers until data-fx is removed).
* 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,
* 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])
* 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.
* nothing moves: reveals and numbers are simply there, and no confetti falls.
*/
*/
@@ -23,7 +24,12 @@
@@ -23,7 +24,12 @@
'use strict';
'use strict';
if (!root || root.OVFx) return;
if (!root || root.OVFx) return;
const doc = root.document;
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)}'
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}'
+ '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{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::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 @@
@@ -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 });
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();
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', init); else init();
})(typeof window !== 'undefined' ? window : this);
})(typeof window !== 'undefined' ? window : this);
@@ -6,8 +6,9 @@
@@ -6,8 +6,9 @@
* OVLook.mount(box, { signedIn, season }) draws the panel into `box` and keeps it current (ov:theme, ov:display);
* 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
* 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.
* kept in this browser, saved to the account when signed in, and travels with links to the other OpenVibe sites.
*/
*/
(function (root) {
(function (root) {
@@ -28,7 +29,9 @@
@@ -28,7 +29,9 @@
+ '.ovlk-season b{color:var(--text-primary,#e6edf7);font-weight:650}'
+ '.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{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 .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 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))}'
+ '.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}}';
+ '@media (prefers-reduced-motion:reduce){.ovlk-switch::after{transition:none}}';
@@ -70,11 +73,30 @@
@@ -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 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]);
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'])]));
panel.append(el('a', { class: 'ovlk-more', href: `${DESIGN}/` }, ['More looks on openvibe.design', el('span', {}, ['\u2192'])]));
box.append(panel);
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) {
function mount(box, opts) {
if (!box || box.__ovlk) return;
if (!box || box.__ovlk) return;
box.__ovlk = true;
box.__ovlk = true;
@@ -83,6 +105,8 @@
@@ -83,6 +105,8 @@
draw(box, opts);
draw(box, opts);
box.addEventListener('click', (e) => {
box.addEventListener('click', (e) => {
const L = root.OpenVibeThemeLoader; if (!L) return;
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]');
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); }
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;
const b = e.target.closest && e.target.closest('[data-look]'); if (!b) return;
@@ -95,5 +119,5 @@
@@ -95,5 +119,5 @@
['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, () => { if (box.isConnected) draw(box, opts); }));
['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);
})(typeof window !== 'undefined' ? window : this);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -44,6 +44,10 @@ function setup({ reduce = false, calm = false, html = '' } = {}) {
@@ -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.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.ok(!/infinite/.test(css.replace(/\.ovfx-shimmer\{[^}]*\}/, '')), 'nothing loops but a placeholder\'s shimmer');
assert.strictEqual(typeof t.window.OVFx.confetti, 'function');
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 ──
// ── reveal: below the fold it waits for the screen; on screen it is never hidden ──
@@ -78,7 +78,7 @@ assert.ok(tag({ nonce: 'abc123' }).startsWith('<script nonce="abc123">'));
@@ -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');
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 ──
// ── 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;
sandbox.window.window = sandbox.window;
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'ov-look.js'), 'utf8'), sandbox);
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'ov-look.js'), 'utf8'), sandbox);
const { PICKS } = sandbox.window.OVLook;
const { PICKS } = sandbox.window.OVLook;
@@ -90,5 +90,10 @@ const nav = fs.readFileSync(path.join(ROOT, 'navbar.js'), 'utf8');
@@ -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(/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(!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');
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`);
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`);
@@ -63,6 +63,12 @@ const display = (store) => JSON.parse(store.get('ov_display') || 'null');
@@ -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).seasonal, 'off');
assert.strictEqual(display(p.store).at, NOW, 'dated now');
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 ──
// ── The account's answer: newest wins ──
const older = new Map([['ov_display', JSON.stringify({ seasonal: 'off', at: NOW - 5000 })]]);
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 } });
p = load({ store: older, active: { theme_id: 'vibe', display: { seasonal: 'on' }, display_at: NOW - 60000 } });
@@ -322,7 +322,7 @@
@@ -322,7 +322,7 @@
if (token) headers['Authorization'] = 'Bearer ' + token;
if (token) headers['Authorization'] = 'Bearer ' + token;
fetch(_config.apiBase + '/api/themes/me', {
fetch(_config.apiBase + '/api/themes/me', {
method: 'PUT', headers: headers, credentials: 'include', keepalive: true,
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 */ });
}).catch(function () { /* offline / signed out — local cache still holds */ });
}
}