openvibers/openvibe.shared Commits

4.91.0: buttons say they are working, then done or failed, with the mark (#246)

OP OpenVibers committed 11 days ago · e09ed53f25 · 7 files, +197 −6

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6d7324d..abc7a82 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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).
ddiff --git a/README.md b/README.md
iindex d87b5d9..ac00eb5 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.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
([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 a7614ce..e91207a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/navbar.js b/navbar.js
iindex feef7e0..33279ab 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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">.
ddiff --git a/ov-mark.js b/ov-mark.js
iindex 62f198f..f41dc56 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -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();
})();
ddiff --git a/package.json b/package.json
iindex 7cbc617..3b9f6a2 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/busy-buttons.test.js b/test/busy-buttons.test.js
nnew file mode 100644
iindex 0000000..27f0ee2
---- /dev/null
++++ b/test/busy-buttons.test.js
@@@ -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); });