openvibers/openvibe.shared Commits
4.91.0: buttons say they are working, then done or failed, with the mark (#246)
Changes
@@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.91.0 — 2026-11-01++++- **Buttons say they are working, then done or failed, with the mark** (`ov-mark.js`, which the navbar loads on every++ site; the plan's "buttons' busy, then done or failed, from the mark's states").++ - `OVMark.busy(button)` → `{ done(text?), fail(text?), reset() }`: the button shows the mark busy beside its label,++ keeps its width and takes no second press; done turns the V into the green tick and says "Done" (or the text),++ fail into the red "!" and a word; then the label comes back.++ - A form posted the old way (POST) does this by itself until the next page arrives, and a second submit does nothing.++ Its submitter is never disabled, so its name and value still post. A GET form, a form whose own script took the++ submit, and `data-ov-busy="off"` are left alone; the back/forward cache resets it.++ - `navbar.js` loads the new `ov-mark.js` by its content hash (`SIBLING_REV`).++## 4.90.0 — 2026-11-01- **An audio-only recording shows the streamer, not a black box** (`player.js`, `player.css`; plan R4).@@@ -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.90.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.91.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.90.0",++ "version": "4.91.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)",@@@ -478,7 +478,7 @@const NAV_ICONS_REV = 'a571e2967b16'; // 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': 'bb9d0a92e0f2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db' };++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': 'bb9d0a92e0f2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', '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">.@@@ -8,6 +8,8 @@* <span class="ov-mark" data-form="heavy"> → force a form (default: heavy up to 22px wide, the pair above)* data-state="busy|ok|error|no|notify|live|sleep" → what it shows (island.js sets busy/ok/error on the navbar's)* data-progress="0.4" with style="--ovm-p:.4" → a known progress: the ring draws round to that much++ * OVMark.busy(button) → { done, fail, reset }: a button at work shows the mark busy, then the tick or the "!". A form++ * posted the old way (POST) does this by itself until the next page; data-ov-busy="off" on a form opts out.* Self-contained: injects its own CSS once, works on any page (Live, Network, static pages). The same mark as static* SVG, for servers and icons: openvibe-shared/mark (mark.js), whose numbers this file draws.*@@@ -185,7 +187,10 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark *,.ov-mark::before{transition:none!important}.ovm-fall{display:none}}--html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;++html[data-ov-motion=reduced] .ov-mark *{animation:none!important}++.ov-busy{cursor:progress;pointer-events:none}++.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none}++`;const seg = (p, i) => `M${f(p[i])} ${f(p[i + 1])}L${f(p[i + 2])} ${f(p[i + 3])}`;function svg(id, G, wide) {const p = G.shapes.v, oc = `transform-origin:${G.cx}px ${G.cy}px`, vc = `transform-origin:${G.T[0]}px ${G.T[1]}px`;@@@ -465,7 +470,73 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;try { void el.getBoundingClientRect(); } catch { /* */ }st.introEnd = Date.now() + 2000; update(el);}-- window.OVMark = { mount, navStart, navEnd, usual, intro, FORMS };++ // ── Busy buttons (the plan: "buttons' busy, then done or failed, from the mark's states", 2026-11-01) ──++ // A button that starts work shows the mark at work beside its label, keeps its width and takes no second press.++ // Script work ends it with done (the tick, and "Done" or a word) or fail (the "!", and a word), then the label++ // comes back; a form posted the old way stays busy until the next page arrives. The button is never disabled:++ // a disabled submitter's name and value would drop out of the form it posts.++ // OVMark.busy(button) → { done(text?), fail(text?), reset() }++ const BUSY = new Map();++ function busy(btn) {++ if (!btn || !btn.isConnected) return null;++ if (BUSY.has(btn)) return BUSY.get(btn);++ const isInput = btn.tagName === 'INPUT';++ const before = isInput ? btn.value : btn.innerHTML;++ const width = btn.offsetWidth;++ if (width) btn.style.minWidth = width + 'px';++ btn.setAttribute('aria-busy', 'true');++ btn.classList.add('ov-busy');++ let mark = null;++ if (!isInput) {++ mark = document.createElement('span');++ mark.className = 'ov-mark ov-busy-mark';++ mark.setAttribute('data-size', '18'); mark.setAttribute('data-tone', 'ink'); mark.setAttribute('data-state', 'busy'); mark.setAttribute('aria-hidden', 'true');++ btn.insertBefore(mark, btn.firstChild);++ mount(btn);++ }++ let timer = 0;++ const reset = () => {++ clearTimeout(timer);++ if (!BUSY.has(btn)) return;++ BUSY.delete(btn);++ if (isInput) btn.value = before; else btn.innerHTML = before;++ btn.style.minWidth = '';++ btn.removeAttribute('aria-busy');++ btn.classList.remove('ov-busy');++ const form = btn.form || (btn.closest && btn.closest('form'));++ if (form) delete form.__ovBusy;++ };++ const end = (st, text, ms) => {++ if (!BUSY.has(btn)) return;++ if (mark) {++ mark.setAttribute('data-state', st);++ // The label after the mark says what happened, in the button's own place.++ while (mark.nextSibling) btn.removeChild(mark.nextSibling);++ btn.appendChild(document.createTextNode(text));++ } else btn.value = text;++ btn.setAttribute('aria-busy', 'false');++ clearTimeout(timer); timer = setTimeout(reset, ms);++ };++ const api = { done: (text) => end('ok', text || 'Done', 1400), fail: (text) => end('error', text || 'Failed', 2600), reset };++ BUSY.set(btn, api);++ return api;++ }++ // A form posted the old way (POST, no script of the page's own took the submit): its button shows the mark at work++ // until the next page arrives, and a second submit does nothing. data-ov-busy="off" on a form opts out.++ document.addEventListener('submit', (e) => {++ const form = e.target;++ if (!form || form.tagName !== 'FORM' || form.getAttribute('data-ov-busy') === 'off') return;++ if (String(form.getAttribute('method') || 'get').toLowerCase() !== 'post') return;++ if (form.__ovBusy) { e.preventDefault(); return; }++ if (e.defaultPrevented) return;++ const btn = e.submitter || form.querySelector('button[type=submit],button:not([type]),input[type=submit]');++ if (!btn) return;++ form.__ovBusy = true;++ busy(btn);++ });++ // Back to a page from the back/forward cache: nothing is busy any more.++ window.addEventListener('pageshow', (e) => { if (e.persisted) [...BUSY.values()].forEach((b) => b.reset()); });++ window.OVMark = { mount, navStart, navEnd, usual, intro, busy, FORMS };window.ovMarkMount = mount;if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();})();@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.90.0",++ "version": "4.91.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++// Busy buttons (ov-mark.js, 4.91.0; the plan: "buttons' busy, then done or failed, from the mark's states"). A button at++// work shows the mark busy beside its label, keeps its width and takes no second press; done shows the tick, fail the++// "!", then the label comes back. A form posted the old way (POST) does this by itself, never disables its submitter++// (its name and value must still post), ignores a second submit, and leaves alone a GET form, a form whose own script++// took the submit, and a form that opts out.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { parseHTML } = require('linkedom');++++const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-mark.js'), 'utf8');++++function setup(html) {++ const { window, document, MutationObserver, Event } = parseHTML(`<!doctype html><html><head></head><body>${html}</body></html>`);++ let now = 1000000, timers = [];++ const clock = {++ advance(ms) {++ now += ms;++ for (;;) {++ const due = timers.filter((t) => t.at <= now).sort((a, b) => a.at - b.at)[0];++ if (!due) break;++ timers = timers.filter((t) => t !== due); due.fn();++ }++ },++ };++ const FakeDate = class extends Date { constructor(...a) { super(...(a.length ? a : [now])); } static now() { return now; } };++ const setT = (fn, ms) => { const t = { fn, at: now + ms }; timers.push(t); return t; };++ const clearT = (t) => { timers = timers.filter((x) => x !== t); };++ class IO { constructor() {} observe() {} unobserve() {} }++ window.matchMedia = () => ({ matches: false, addEventListener() {} });++ window.__ovMark = false;++ const session = { getItem: () => null, setItem() {}, removeItem() {} };++ new Function('window', 'document', 'IntersectionObserver', 'MutationObserver', 'setTimeout', 'clearTimeout', 'Date', 'sessionStorage', SRC)(++ window, document, IO, MutationObserver, setT, clearT, FakeDate, session);++ const submit = (form, submitter, { prevent = false } = {}) => {++ const e = new Event('submit', { bubbles: true, cancelable: true });++ e.submitter = submitter;++ if (prevent) form.addEventListener('submit', (ev) => ev.preventDefault(), { once: true });++ form.dispatchEvent(e);++ return e;++ };++ return { window, document, clock, submit };++}++++(async () => {++ // ── OVMark.busy: busy, then done or fail, then the label is back ─────++ {++ const t = setup('<button id="b" type="button">Save <b>now</b></button>');++ const b = t.document.getElementById('b');++ const api = t.window.OVMark.busy(b);++ assert.ok(api && typeof api.done === 'function' && typeof api.fail === 'function', 'busy() answers done and fail');++ assert.strictEqual(t.window.OVMark.busy(b), api, 'a second busy() on the same button is the same one');++ assert.strictEqual(b.getAttribute('aria-busy'), 'true');++ assert.ok(b.classList.contains('ov-busy'), 'the button takes no second press while busy');++ assert.strictEqual(b.disabled, false, 'never disabled');++ const mark = b.querySelector('.ov-busy-mark');++ assert.ok(mark && mark.getAttribute('data-state') === 'busy' && mark.getAttribute('aria-hidden') === 'true', 'the mark at work, beside the label');++ assert.ok(mark.querySelector('svg'), 'the mark is drawn');++ api.done();++ assert.strictEqual(mark.getAttribute('data-state'), 'ok', 'done: the tick');++ assert.match(b.textContent, /Done/);++ t.clock.advance(1500);++ assert.strictEqual(b.innerHTML, 'Save <b>now</b>', 'the label comes back exactly');++ assert.ok(!b.hasAttribute('aria-busy') && !b.classList.contains('ov-busy'));++ const again = t.window.OVMark.busy(b);++ again.fail('Not saved');++ assert.strictEqual(b.querySelector('.ov-busy-mark').getAttribute('data-state'), 'error', 'fail: the "!"');++ assert.match(b.textContent, /Not saved/);++ t.clock.advance(2700);++ assert.strictEqual(b.innerHTML, 'Save <b>now</b>');++ }++++ // ── a form posted the old way ─────++ {++ const t = setup(`<form id="post" method="post" action="/report"><button id="worked" name="result" value="worked">It worked</button><button id="failed" name="result" value="failed">It didn't work</button></form>++<form id="get" method="get" action="/search"><button id="go">Search</button></form>++<form id="mine" method="post" action="/x"><button id="mineb">Send</button></form>++<form id="off" method="post" action="/x" data-ov-busy="off"><button id="offb">Send</button></form>`);++ const $ = (id) => t.document.getElementById(id);++ const first = t.submit($('post'), $('failed'));++ assert.strictEqual(first.defaultPrevented, false, 'the post goes through');++ assert.strictEqual($('failed').getAttribute('aria-busy'), 'true', 'the button pressed is the busy one');++ assert.strictEqual($('worked').hasAttribute('aria-busy'), false, 'not the other');++ assert.strictEqual($('failed').disabled, false, 'its name and value still post');++ const second = t.submit($('post'), $('worked'));++ assert.strictEqual(second.defaultPrevented, true, 'a second submit does nothing');++ t.submit($('get'), $('go'));++ assert.strictEqual($('go').hasAttribute('aria-busy'), false, 'a GET form is left alone');++ t.submit($('mine'), $('mineb'), { prevent: true });++ assert.strictEqual($('mineb').hasAttribute('aria-busy'), false, 'a form whose own script took the submit is left alone');++ t.submit($('off'), $('offb'));++ assert.strictEqual($('offb').hasAttribute('aria-busy'), false, 'data-ov-busy="off" opts out');++ // Back from the back/forward cache: nothing is busy.++ const e = new t.window.Event('pageshow'); e.persisted = true; t.window.dispatchEvent(e);++ assert.strictEqual($('failed').hasAttribute('aria-busy'), false);++ assert.strictEqual(t.submit($('post'), $('worked')).defaultPrevented, false, 'and the form posts again');++ }++++ // ── the CSS ─────++ {++ const t = setup('');++ const css = t.document.getElementById('ov-mark-css').textContent;++ assert.match(css, /\.ov-busy\{cursor:progress;pointer-events:none\}/);++ assert.match(css, /\.ov-busy-mark\{/);++ }++ console.log('busy buttons: busy, done, fail, the label back; POST forms by themselves; GET, taken and opted-out forms left alone');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.91.0 — 2026-11-01
+
+- **Buttons say they are working, then done or failed, with the mark** (`ov-mark.js`, which the navbar loads on every
+ site; the plan's "buttons' busy, then done or failed, from the mark's states").
+ - `OVMark.busy(button)` → `{ done(text?), fail(text?), reset() }`: the button shows the mark busy beside its label,
+ keeps its width and takes no second press; done turns the V into the green tick and says "Done" (or the text),
+ fail into the red "!" and a word; then the label comes back.
+ - A form posted the old way (POST) does this by itself until the next page arrives, and a second submit does nothing.
+ Its submitter is never disabled, so its name and value still post. A GET form, a form whose own script took the
+ submit, and `data-ov-busy="off"` are left alone; the back/forward cache resets it.
+ - `navbar.js` loads the new `ov-mark.js` by its content hash (`SIBLING_REV`).
+
## 4.90.0 — 2026-11-01
## 4.90.0 — 2026-11-01
- **An audio-only recording shows the streamer, not a black box** (`player.js`, `player.css`; plan R4).
- **An audio-only recording shows the streamer, not a black box** (`player.js`, `player.css`; plan R4).
@@ -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.90.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.91.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.90.0",
+ "version": "4.91.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)",
@@ -478,7 +478,7 @@
@@ -478,7 +478,7 @@
const NAV_ICONS_REV = 'a571e2967b16'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'a571e2967b16'; // 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': 'bb9d0a92e0f2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db' };
+ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': 'bb9d0a92e0f2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', '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">.
@@ -8,6 +8,8 @@
@@ -8,6 +8,8 @@
* <span class="ov-mark" data-form="heavy"> → force a form (default: heavy up to 22px wide, the pair above)
* <span class="ov-mark" data-form="heavy"> → force a form (default: heavy up to 22px wide, the pair above)
* data-state="busy|ok|error|no|notify|live|sleep" → what it shows (island.js sets busy/ok/error on the navbar's)
* data-state="busy|ok|error|no|notify|live|sleep" → what it shows (island.js sets busy/ok/error on the navbar's)
* data-progress="0.4" with style="--ovm-p:.4" → a known progress: the ring draws round to that much
* data-progress="0.4" with style="--ovm-p:.4" → a known progress: the ring draws round to that much
+ * OVMark.busy(button) → { done, fail, reset }: a button at work shows the mark busy, then the tick or the "!". A form
+ * posted the old way (POST) does this by itself until the next page; data-ov-busy="off" on a form opts out.
* Self-contained: injects its own CSS once, works on any page (Live, Network, static pages). The same mark as static
* Self-contained: injects its own CSS once, works on any page (Live, Network, static pages). The same mark as static
* SVG, for servers and icons: openvibe-shared/mark (mark.js), whose numbers this file draws.
* SVG, for servers and icons: openvibe-shared/mark (mark.js), whose numbers this file draws.
*
*
@@ -185,7 +187,10 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@@ -185,7 +187,10 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark *,.ov-mark::before{transition:none!important}.ovm-fall{display:none}}
@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark *,.ov-mark::before{transition:none!important}.ovm-fall{display:none}}
-html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
+html[data-ov-motion=reduced] .ov-mark *{animation:none!important}
+.ov-busy{cursor:progress;pointer-events:none}
+.ov-busy-mark{display:inline-block;vertical-align:-.2em;margin-right:.45em;flex:none}
+`;
const seg = (p, i) => `M${f(p[i])} ${f(p[i + 1])}L${f(p[i + 2])} ${f(p[i + 3])}`;
const seg = (p, i) => `M${f(p[i])} ${f(p[i + 1])}L${f(p[i + 2])} ${f(p[i + 3])}`;
function svg(id, G, wide) {
function svg(id, G, wide) {
const p = G.shapes.v, oc = `transform-origin:${G.cx}px ${G.cy}px`, vc = `transform-origin:${G.T[0]}px ${G.T[1]}px`;
const p = G.shapes.v, oc = `transform-origin:${G.cx}px ${G.cy}px`, vc = `transform-origin:${G.T[0]}px ${G.T[1]}px`;
@@ -465,7 +470,73 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
@@ -465,7 +470,73 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
try { void el.getBoundingClientRect(); } catch { /* */ }
try { void el.getBoundingClientRect(); } catch { /* */ }
st.introEnd = Date.now() + 2000; update(el);
st.introEnd = Date.now() + 2000; update(el);
}
}
- window.OVMark = { mount, navStart, navEnd, usual, intro, FORMS };
+ // ── Busy buttons (the plan: "buttons' busy, then done or failed, from the mark's states", 2026-11-01) ──
+ // A button that starts work shows the mark at work beside its label, keeps its width and takes no second press.
+ // Script work ends it with done (the tick, and "Done" or a word) or fail (the "!", and a word), then the label
+ // comes back; a form posted the old way stays busy until the next page arrives. The button is never disabled:
+ // a disabled submitter's name and value would drop out of the form it posts.
+ // OVMark.busy(button) → { done(text?), fail(text?), reset() }
+ const BUSY = new Map();
+ function busy(btn) {
+ if (!btn || !btn.isConnected) return null;
+ if (BUSY.has(btn)) return BUSY.get(btn);
+ const isInput = btn.tagName === 'INPUT';
+ const before = isInput ? btn.value : btn.innerHTML;
+ const width = btn.offsetWidth;
+ if (width) btn.style.minWidth = width + 'px';
+ btn.setAttribute('aria-busy', 'true');
+ btn.classList.add('ov-busy');
+ let mark = null;
+ if (!isInput) {
+ mark = document.createElement('span');
+ mark.className = 'ov-mark ov-busy-mark';
+ mark.setAttribute('data-size', '18'); mark.setAttribute('data-tone', 'ink'); mark.setAttribute('data-state', 'busy'); mark.setAttribute('aria-hidden', 'true');
+ btn.insertBefore(mark, btn.firstChild);
+ mount(btn);
+ }
+ let timer = 0;
+ const reset = () => {
+ clearTimeout(timer);
+ if (!BUSY.has(btn)) return;
+ BUSY.delete(btn);
+ if (isInput) btn.value = before; else btn.innerHTML = before;
+ btn.style.minWidth = '';
+ btn.removeAttribute('aria-busy');
+ btn.classList.remove('ov-busy');
+ const form = btn.form || (btn.closest && btn.closest('form'));
+ if (form) delete form.__ovBusy;
+ };
+ const end = (st, text, ms) => {
+ if (!BUSY.has(btn)) return;
+ if (mark) {
+ mark.setAttribute('data-state', st);
+ // The label after the mark says what happened, in the button's own place.
+ while (mark.nextSibling) btn.removeChild(mark.nextSibling);
+ btn.appendChild(document.createTextNode(text));
+ } else btn.value = text;
+ btn.setAttribute('aria-busy', 'false');
+ clearTimeout(timer); timer = setTimeout(reset, ms);
+ };
+ const api = { done: (text) => end('ok', text || 'Done', 1400), fail: (text) => end('error', text || 'Failed', 2600), reset };
+ BUSY.set(btn, api);
+ return api;
+ }
+ // A form posted the old way (POST, no script of the page's own took the submit): its button shows the mark at work
+ // until the next page arrives, and a second submit does nothing. data-ov-busy="off" on a form opts out.
+ document.addEventListener('submit', (e) => {
+ const form = e.target;
+ if (!form || form.tagName !== 'FORM' || form.getAttribute('data-ov-busy') === 'off') return;
+ if (String(form.getAttribute('method') || 'get').toLowerCase() !== 'post') return;
+ if (form.__ovBusy) { e.preventDefault(); return; }
+ if (e.defaultPrevented) return;
+ const btn = e.submitter || form.querySelector('button[type=submit],button:not([type]),input[type=submit]');
+ if (!btn) return;
+ form.__ovBusy = true;
+ busy(btn);
+ });
+ // Back to a page from the back/forward cache: nothing is busy any more.
+ window.addEventListener('pageshow', (e) => { if (e.persisted) [...BUSY.values()].forEach((b) => b.reset()); });
+ window.OVMark = { mount, navStart, navEnd, usual, intro, busy, FORMS };
window.ovMarkMount = mount;
window.ovMarkMount = mount;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})();
})();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.90.0",
+ "version": "4.91.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": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+// Busy buttons (ov-mark.js, 4.91.0; the plan: "buttons' busy, then done or failed, from the mark's states"). A button at
+// work shows the mark busy beside its label, keeps its width and takes no second press; done shows the tick, fail the
+// "!", then the label comes back. A form posted the old way (POST) does this by itself, never disables its submitter
+// (its name and value must still post), ignores a second submit, and leaves alone a GET form, a form whose own script
+// took the submit, and a form that opts out.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { parseHTML } = require('linkedom');
+
+const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-mark.js'), 'utf8');
+
+function setup(html) {
+ const { window, document, MutationObserver, Event } = parseHTML(`<!doctype html><html><head></head><body>${html}</body></html>`);
+ let now = 1000000, timers = [];
+ const clock = {
+ advance(ms) {
+ now += ms;
+ for (;;) {
+ const due = timers.filter((t) => t.at <= now).sort((a, b) => a.at - b.at)[0];
+ if (!due) break;
+ timers = timers.filter((t) => t !== due); due.fn();
+ }
+ },
+ };
+ const FakeDate = class extends Date { constructor(...a) { super(...(a.length ? a : [now])); } static now() { return now; } };
+ const setT = (fn, ms) => { const t = { fn, at: now + ms }; timers.push(t); return t; };
+ const clearT = (t) => { timers = timers.filter((x) => x !== t); };
+ class IO { constructor() {} observe() {} unobserve() {} }
+ window.matchMedia = () => ({ matches: false, addEventListener() {} });
+ window.__ovMark = false;
+ const session = { getItem: () => null, setItem() {}, removeItem() {} };
+ new Function('window', 'document', 'IntersectionObserver', 'MutationObserver', 'setTimeout', 'clearTimeout', 'Date', 'sessionStorage', SRC)(
+ window, document, IO, MutationObserver, setT, clearT, FakeDate, session);
+ const submit = (form, submitter, { prevent = false } = {}) => {
+ const e = new Event('submit', { bubbles: true, cancelable: true });
+ e.submitter = submitter;
+ if (prevent) form.addEventListener('submit', (ev) => ev.preventDefault(), { once: true });
+ form.dispatchEvent(e);
+ return e;
+ };
+ return { window, document, clock, submit };
+}
+
+(async () => {
+ // ── OVMark.busy: busy, then done or fail, then the label is back ─────
+ {
+ const t = setup('<button id="b" type="button">Save <b>now</b></button>');
+ const b = t.document.getElementById('b');
+ const api = t.window.OVMark.busy(b);
+ assert.ok(api && typeof api.done === 'function' && typeof api.fail === 'function', 'busy() answers done and fail');
+ assert.strictEqual(t.window.OVMark.busy(b), api, 'a second busy() on the same button is the same one');
+ assert.strictEqual(b.getAttribute('aria-busy'), 'true');
+ assert.ok(b.classList.contains('ov-busy'), 'the button takes no second press while busy');
+ assert.strictEqual(b.disabled, false, 'never disabled');
+ const mark = b.querySelector('.ov-busy-mark');
+ assert.ok(mark && mark.getAttribute('data-state') === 'busy' && mark.getAttribute('aria-hidden') === 'true', 'the mark at work, beside the label');
+ assert.ok(mark.querySelector('svg'), 'the mark is drawn');
+ api.done();
+ assert.strictEqual(mark.getAttribute('data-state'), 'ok', 'done: the tick');
+ assert.match(b.textContent, /Done/);
+ t.clock.advance(1500);
+ assert.strictEqual(b.innerHTML, 'Save <b>now</b>', 'the label comes back exactly');
+ assert.ok(!b.hasAttribute('aria-busy') && !b.classList.contains('ov-busy'));
+ const again = t.window.OVMark.busy(b);
+ again.fail('Not saved');
+ assert.strictEqual(b.querySelector('.ov-busy-mark').getAttribute('data-state'), 'error', 'fail: the "!"');
+ assert.match(b.textContent, /Not saved/);
+ t.clock.advance(2700);
+ assert.strictEqual(b.innerHTML, 'Save <b>now</b>');
+ }
+
+ // ── a form posted the old way ─────
+ {
+ const t = setup(`<form id="post" method="post" action="/report"><button id="worked" name="result" value="worked">It worked</button><button id="failed" name="result" value="failed">It didn't work</button></form>
+<form id="get" method="get" action="/search"><button id="go">Search</button></form>
+<form id="mine" method="post" action="/x"><button id="mineb">Send</button></form>
+<form id="off" method="post" action="/x" data-ov-busy="off"><button id="offb">Send</button></form>`);
+ const $ = (id) => t.document.getElementById(id);
+ const first = t.submit($('post'), $('failed'));
+ assert.strictEqual(first.defaultPrevented, false, 'the post goes through');
+ assert.strictEqual($('failed').getAttribute('aria-busy'), 'true', 'the button pressed is the busy one');
+ assert.strictEqual($('worked').hasAttribute('aria-busy'), false, 'not the other');
+ assert.strictEqual($('failed').disabled, false, 'its name and value still post');
+ const second = t.submit($('post'), $('worked'));
+ assert.strictEqual(second.defaultPrevented, true, 'a second submit does nothing');
+ t.submit($('get'), $('go'));
+ assert.strictEqual($('go').hasAttribute('aria-busy'), false, 'a GET form is left alone');
+ t.submit($('mine'), $('mineb'), { prevent: true });
+ assert.strictEqual($('mineb').hasAttribute('aria-busy'), false, 'a form whose own script took the submit is left alone');
+ t.submit($('off'), $('offb'));
+ assert.strictEqual($('offb').hasAttribute('aria-busy'), false, 'data-ov-busy="off" opts out');
+ // Back from the back/forward cache: nothing is busy.
+ const e = new t.window.Event('pageshow'); e.persisted = true; t.window.dispatchEvent(e);
+ assert.strictEqual($('failed').hasAttribute('aria-busy'), false);
+ assert.strictEqual(t.submit($('post'), $('worked')).defaultPrevented, false, 'and the form posts again');
+ }
+
+ // ── the CSS ─────
+ {
+ const t = setup('');
+ const css = t.document.getElementById('ov-mark-css').textContent;
+ assert.match(css, /\.ov-busy\{cursor:progress;pointer-events:none\}/);
+ assert.match(css, /\.ov-busy-mark\{/);
+ }
+ console.log('busy buttons: busy, done, fail, the label back; POST forms by themselves; GET, taken and opted-out forms left alone');
+})().catch((e) => { console.error(e); process.exit(1); });