openvibers/openvibe.shared Commits

A timer in the Dock, on every site (4.76.0) (#228)

OP OpenVibers committed 12 days ago · 0304fc1ba1 · 9 files, +288 −13

Plan L48 R2. The header's timer button: 5/10/25 minutes, focus rounds, or your own. The timer lives in the Network's
Dock state (contracts 0.271.0), counts down in the pill on every site, shows a strip in the open Dock (+5, Stop),
chimes on a showing page and notifies when allowed at its end, and moves focus rounds on by themselves (computed from
the same end time on every page). dock-timer.js loads only while a timer runs or its menu opens. The header's tabs
are a sideways rail that never runs under the buttons.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f840ca8..967cc3b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,24 @@ 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.76.0 — 2026-11-01
++
++- **A timer in the Dock, on every site** (plan L48 R2; the owner, 2026-11-01: "a reminder system… timer or however
++ often they want").
++ - The Dock's header has a timer button with 5, 10 and 25 minutes, focus rounds (25/5 ×4, 50/5 ×2), or your own
++ minutes and what it is for.
++ - The timer lives in the Network's Dock state (`timer`, openvibe-contracts 0.271.0, Network #482), so it counts
++ down on every OpenVibe site the person has open and follows them to the next.
++ - **The pill** shows the minutes left (an agent that needs you still comes first). **The open Dock** shows a strip
++ with the time, what it is for, a line that fills, +5 min and Stop.
++ - **At its end:** a soft chime on a page that is showing, a notification when the person allowed them (asked once,
++ when they start one), and "Time's up" in the pill. A focus round moves on to its break and its next round by
++ itself; every page computes that from the same end time, so two pages writing it agree.
++ - `dock-timer.js`, a new served file (3.7 KB brotli), loads only while a timer runs or its menu opens. `dock.js`
++ grows by 0.45 KB (budget noted).
++- **The Dock's header fits four tabs and four buttons.** The tabs are a sideways rail that never runs under the
++ buttons, and a narrow Dock tightens both.
++
## 4.75.0 — 2026-11-01
- **The Dock keeps the Vibe tab across sites.** The Network's Dock state knows `vibe` since openvibe-contracts 0.270.0
ddiff --git a/README.md b/README.md
iindex d58c30a..3ab1154 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.75.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.76.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 1efc369..635a815 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.75.0",
++ "version": "4.76.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/dock-timer.js b/dock-timer.js
nnew file mode 100644
iindex 0000000..cfd3616
---- /dev/null
++++ b/dock-timer.js
@@@ -0,0 +1,186 @@
++/**
++ * openvibe-shared/dock-timer.js: the Dock's timer (plan L48 R2; the owner, 11-01: "a reminder system… timer or however
++ * often they want"). dock.js loads it when the person's Dock state carries a timer, or when they open the timer menu.
++ *
++ * OpenVibeDockTimer.attach({ state, save, host, icon }) → { sync(), pillText(), menu(anchor), stop() }
++ *
++ * The timer lives in the Network's Dock state (network.dock-state@1 `timer`, contracts 0.271.0), so it counts down on
++ * every OpenVibe site the person has open and follows them to the next. Each page counts from the same end time; the
++ * page that sees the end rings (a soft chime, only on a page that is showing, and a notification when the person
++ * allowed them) and moves a focus timer on to its break or its next round. Every page computes that next phase the same
++ * way from the same end time, so two pages writing it at once write the same thing. Nothing spins: the pill shows the
++ * minutes, and a line fills as the time goes.
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OpenVibeDockTimer) return;
++
++ const PRESETS = [[5, 'timer'], [10, 'timer'], [25, 'timer']];
++ const CSS = `
++.ovdk-timer { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 10px 12px 12px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
++.ovdk-timer-t { min-width: 0; display: grid; gap: 1px; }
++.ovdk-timer-t b { font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: .01em; line-height: 1.1; }
++.ovdk-timer-t span { color: var(--text-muted, #8b8b96); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovdk-timer-acts { display: flex; gap: 6px; }
++.ovdk-timer-acts button, .ovdk-tmenu button { min-height: 32px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
++.ovdk-timer-acts button:hover, .ovdk-tmenu button:hover { border-color: var(--accent, #3b82f6); }
++.ovdk-timer-bar { grid-column: 1 / -1; height: 4px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #ececf1) 9%, transparent); overflow: hidden; }
++.ovdk-timer-bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--accent, #3b82f6); transition: width 1s linear; }
++.ovdk-timer[data-kind="break"] .ovdk-timer-bar i { background: #22c55e; }
++.ovdk-timer.is-done b { color: var(--accent, #3b82f6); }
++.ovdk-tmenu { position: absolute; z-index: 5; right: 8px; top: 52px; width: min(300px, calc(100% - 16px)); display: grid; gap: 10px; padding: 12px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-card, var(--bg-secondary, #16161d)); box-shadow: 0 16px 40px rgba(0,0,0,.4); }
++.ovdk-tmenu p { margin: 0; color: var(--text-muted, #8b8b96); font-size: 11px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
++.ovdk-tmenu .row { display: flex; flex-wrap: wrap; gap: 6px; }
++.ovdk-tmenu .own { display: grid; grid-template-columns: 70px minmax(0, 1fr) auto; gap: 6px; }
++.ovdk-tmenu input { min-height: 32px; min-width: 0; box-sizing: border-box; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: inherit; font: inherit; font-size: 13px; }
++.ovdk-tmenu .go { background: var(--accent, #3b82f6); border-color: transparent; color: var(--on-accent, #fff); }
++.ovdk-pill[data-tone="timer"] .ovdk-t { font-variant-numeric: tabular-nums; }
++.ovdk-pill[data-tone="timer-done"] { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
++@media (prefers-reduced-motion: reduce) { .ovdk-timer-bar i { transition: none; } }
++`;
++ const pad = (n) => String(n).padStart(2, '0');
++ const left = (ms) => {
++ const s = Math.max(0, Math.ceil(ms / 1000));
++ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
++ return h ? `${h}:${pad(m)}:${pad(r)}` : `${m}:${pad(r)}`;
++ };
++ const iso = (ms) => new Date(ms).toISOString();
++
++ /** What comes after a timer that has ended: the break after a focus round, the next round, or nothing. */
++ function nextOf(t) {
++ if (!t || !t.rounds || t.kind === 'timer') return null;
++ const end = Date.parse(t.ends_at);
++ const round = t.round || 1;
++ if (t.kind === 'focus' && round < t.rounds) return { ...t, kind: 'break', started_at: iso(end), ends_at: iso(end + (t.break_min || 5) * 60000) };
++ if (t.kind === 'break') return { ...t, kind: 'focus', round: round + 1, started_at: iso(end), ends_at: iso(end + (t.focus_min || 25) * 60000) };
++ return null;
++ }
++ const titleOf = (t) => (t.kind === 'break' ? 'Break' : t.kind === 'focus' ? `Focus${t.rounds > 1 ? ` ${t.round || 1} of ${t.rounds}` : ''}` : 'Timer');
++
++ function chime() {
++ try {
++ const A = root.AudioContext || root.webkitAudioContext;
++ if (!A) return;
++ const ctx = new A();
++ [[660, 0], [880, 0.22], [990, 0.44]].forEach(([f, at]) => {
++ const o = ctx.createOscillator(), g = ctx.createGain();
++ o.type = 'sine'; o.frequency.value = f;
++ g.gain.setValueAtTime(0.0001, ctx.currentTime + at);
++ g.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + at + 0.02);
++ g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + 0.5);
++ o.connect(g).connect(ctx.destination);
++ o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + 0.55);
++ });
++ setTimeout(() => { try { ctx.close(); } catch { /* */ } }, 1500);
++ } catch { /* no sound here */ }
++ }
++
++ function attach(c) {
++ const ctx = c || {};
++ let tick = 0, rang = '', doneUntil = 0, doneLabel = '';
++ const now = () => Date.now();
++ const timer = () => { const t = (ctx.state() || {}).timer; return t && Date.parse(t.ends_at) ? t : null; };
++ const style = () => {
++ const st = ctx.host && ctx.host() && ctx.host().ownerDocument.getElementById('ovdk-styles') || document.getElementById('ovdk-styles');
++ if (st && !st.textContent.includes('.ovdk-timer {')) st.textContent += CSS;
++ };
++
++ function ended(t) {
++ if (rang === t.ends_at) return;
++ rang = t.ends_at;
++ const next = nextOf(t);
++ const said = next ? (next.kind === 'break' ? `Focus done: a ${next.break_min || 5}-minute break` : `Break over: focus ${next.round} of ${next.rounds}`) : `${t.label || titleOf(t)}: time's up`;
++ if (!document.hidden) chime();
++ try { if (root.Notification && Notification.permission === 'granted' && !document.hidden) new Notification('OpenVibe', { body: said, tag: `ov-timer-${t.ends_at}` }); } catch { /* */ }
++ doneUntil = now() + 60000; doneLabel = said;
++ ctx.save({ timer: next });
++ }
++
++ function paintStrip() {
++ const host = ctx.host && ctx.host();
++ if (!host) return;
++ const body = host.querySelector('.ovdk-body');
++ let strip = host.querySelector('.ovdk-timer');
++ const t = timer();
++ const done = !t && now() < doneUntil;
++ if (!t && !done) { if (strip) strip.remove(); return; }
++ if (!strip) {
++ strip = document.createElement('div'); strip.className = 'ovdk-timer'; strip.setAttribute('role', 'timer'); strip.setAttribute('aria-live', 'off');
++ strip.innerHTML = '<div class="ovdk-timer-t"><b></b><span></span></div><div class="ovdk-timer-acts"><button type="button" data-t="more">+5 min</button><button type="button" data-t="stop">Stop</button></div><div class="ovdk-timer-bar"><i></i></div>';
++ strip.addEventListener('click', (e) => {
++ const b = e.target.closest('[data-t]'); if (!b) return;
++ const cur = timer();
++ if (b.dataset.t === 'stop') { doneUntil = 0; ctx.save({ timer: null }); }
++ if (b.dataset.t === 'more' && cur) ctx.save({ timer: { ...cur, ends_at: iso(Math.max(Date.parse(cur.ends_at), now()) + 5 * 60000) } });
++ if (b.dataset.t === 'ok') { doneUntil = 0; }
++ paintStrip();
++ });
++ if (body) body.parentNode.insertBefore(strip, body); else host.appendChild(strip);
++ }
++ strip.classList.toggle('is-done', done);
++ strip.dataset.kind = t ? t.kind : 'done';
++ strip.querySelector('b').textContent = t ? left(Date.parse(t.ends_at) - now()) : 'Done';
++ strip.querySelector('span').textContent = t ? `${titleOf(t)}${t.label ? ` · ${t.label}` : ''}` : doneLabel;
++ const total = t ? Date.parse(t.ends_at) - Date.parse(t.started_at) : 1;
++ strip.querySelector('.ovdk-timer-bar i').style.width = t ? `${Math.min(100, Math.max(0, 100 * (now() - Date.parse(t.started_at)) / Math.max(1, total)))}%` : '100%';
++ const acts = strip.querySelector('.ovdk-timer-acts');
++ acts.innerHTML = t ? '<button type="button" data-t="more">+5 min</button><button type="button" data-t="stop">Stop</button>' : '<button type="button" data-t="ok">OK</button>';
++ }
++
++ function step() {
++ const t = timer();
++ if (t && now() >= Date.parse(t.ends_at)) ended(t);
++ paintStrip();
++ if (ctx.repaint) ctx.repaint();
++ if (!timer() && now() >= doneUntil) { clearInterval(tick); tick = 0; }
++ }
++ function sync() {
++ style();
++ if ((timer() || now() < doneUntil) && !tick) tick = setInterval(step, 1000);
++ step();
++ }
++ /** The pill's words while a timer runs (null when there is none): minutes left, or what just ended. */
++ function pillText() {
++ const t = timer();
++ if (t) return { text: left(Date.parse(t.ends_at) - now()), tone: 'timer', label: `${titleOf(t)}${t.label ? `, ${t.label}` : ''}: ${left(Date.parse(t.ends_at) - now())} left` };
++ if (now() < doneUntil) return { text: "Time's up", tone: 'timer-done', label: doneLabel };
++ return null;
++ }
++ function start(minutes, kind, label, rounds) {
++ const m = Math.max(1, Math.min(180, Math.round(Number(minutes) || 0)));
++ const at = now();
++ const t = { kind, started_at: iso(at), ends_at: iso(at + m * 60000), ...(label ? { label: String(label).slice(0, 60) } : {}) };
++ if (kind === 'focus') Object.assign(t, { round: 1, rounds: rounds || 4, focus_min: m, break_min: 5 });
++ rang = ''; doneUntil = 0;
++ // Asked once, at the moment the person starts one (a click): may the end show as a notification?
++ try { if (root.Notification && Notification.permission === 'default') Notification.requestPermission().catch(() => {}); } catch { /* */ }
++ ctx.save({ timer: t });
++ sync();
++ }
++ function menu(anchor) {
++ style();
++ const host = ctx.host && ctx.host();
++ if (!host) return;
++ let m = host.querySelector('.ovdk-tmenu');
++ if (m) { m.remove(); return; }
++ m = document.createElement('div'); m.className = 'ovdk-tmenu'; m.setAttribute('role', 'dialog'); m.setAttribute('aria-label', 'Start a timer');
++ m.innerHTML = `<p>Timer</p><div class="row">${PRESETS.map(([n]) => `<button type="button" data-min="${n}">${n} min</button>`).join('')}</div>
++<p>Focus</p><div class="row"><button type="button" data-focus="25">25 min, 5 min break, ×4</button><button type="button" data-focus="50">50 / 5, ×2</button></div>
++<p>Your own</p><div class="own"><input type="number" min="1" max="180" value="15" aria-label="Minutes" data-own-min><input type="text" maxlength="60" placeholder="What for (optional)" aria-label="What it is for" data-own-label><button type="button" class="go" data-own>Start</button></div>`;
++ m.addEventListener('click', (e) => {
++ const b = e.target.closest('button'); if (!b) return;
++ if (b.dataset.min) start(b.dataset.min, 'timer');
++ else if (b.dataset.focus) start(b.dataset.focus, 'focus', '', b.dataset.focus === '50' ? 2 : 4);
++ else if (b.hasAttribute('data-own')) start(m.querySelector('[data-own-min]').value, 'timer', m.querySelector('[data-own-label]').value.trim());
++ else return;
++ m.remove();
++ });
++ m.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); m.remove(); if (anchor) anchor.focus(); } });
++ host.appendChild(m);
++ const first = m.querySelector('button'); if (first) first.focus();
++ }
++ return { sync, pillText, menu, start, stop: () => { clearInterval(tick); tick = 0; } };
++ }
++
++ root.OpenVibeDockTimer = { attach, nextOf };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/dock.js b/dock.js
iindex 5f48b6f..f6d59d3 100644
---- a/dock.js
++++ b/dock.js
@@@ -39,6 +39,7 @@
back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',
send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',
vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',
++ clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',
};
// Vibe first (L48 V2): the person's AI, on every site.
const TABS = [['vibe', 'Vibe', G.vibe], ['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];
@@@ -144,7 +145,9 @@
@keyframes ovdk-rise { from { transform: translateY(100%); } }
.ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.ovdk-head { position: relative; flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 8px 8px 10px; cursor: grab; touch-action: none; user-select: none; }
-- .ovdk-tabs { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #ececf1) 6%, transparent); min-width: 0; }
++ .ovdk-tabs { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #ececf1) 6%, transparent); min-width: 0; flex: 0 1 auto; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
++ .ovdk-tabs::-webkit-scrollbar { display: none; }
++ .ovdk-tabs button { flex: none; }
.ovdk-tabs button { position: relative; display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px 0 11px; border: 0; border-radius: 999px; background: transparent;
color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s, color .15s; }
.ovdk-tabs button svg { width: 17px; height: 17px; flex: none; }
@@@ -188,7 +191,7 @@
.ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }
@media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }
.ovdk { container-type: inline-size; }
-- @container (max-width: 440px) { .ovdk-tabs button { padding: 0 11px; gap: 6px; } .ovdk-tabs button svg { display: none; } }
++ @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-tabs button svg { display: none; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }
.ovdk-muted { color: var(--text-muted, #8b8b96); }
.ovdk-i { display: inline-grid; place-items: center; }
.ovdk-i svg { width: 18px; height: 18px; }
@@@ -263,6 +266,7 @@
<header class="ovdk-head">
<div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div>
<div class="ovdk-acts">
++ <button type="button" data-act="timer" title="A timer" aria-label="Start a timer">${svg(G.clock)}</button>
<button type="button" data-act="pop" hidden title="Open the Dock in a window of its own" aria-label="Open the Dock in a window of its own">${svg(G.open)}</button>
<button type="button" data-act="pin" aria-pressed="false" title="Keep the Dock's button on every page" aria-label="Keep the Dock's button on every page">${svg(G.pin)}</button>
<button type="button" data-act="close" title="Close" aria-label="Close the Dock">${svg(G.close)}</button>
@@@ -282,6 +286,7 @@
el.querySelector('[data-act="close"]').addEventListener('click', () => close());
el.querySelector('[data-act="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
++ el.querySelector('[data-act="timer"]').addEventListener('click', (e) => { const a = e.currentTarget; withTimer((t) => t.menu(a)); });
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });
// A tap outside closes the phone sheet. The path is the one at dispatch: a tap that redraws its own panel (opening a
// chat) leaves its target detached, and must not read as outside.
@@@ -634,6 +639,17 @@
if (!isOpen() || tab !== 'vibe') { open({ tab: 'vibe' }); setTimeout(talk, 600); } else talk();
});
++ // ── The timer (plan L48 R2): dock-timer.js, loaded when the state carries one or the person opens its menu ──
++ let timerMod = null, timerLoading = null;
++ function withTimer(fn) {
++ if (timerMod) return fn(timerMod);
++ timerLoading = timerLoading || load('dock-timer.js', () => !!(root.OpenVibeDockTimer && root.OpenVibeDockTimer.attach)).then((ok) => {
++ if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => el, repaint: () => paintPill() });
++ return timerMod;
++ });
++ return timerLoading.then((m) => (m ? fn(m) : null));
++ }
++
// ── The pill ─────────────────────────────────────────────
let pill = null, readT = 0, bound = false;
function store(v) {
@@@ -653,13 +669,16 @@
pill.setAttribute('aria-label', 'The Dock is in a window of its own \u2014 click to bring it back');
return;
}
-- const show = !isOpen() && !!(w || r || st.pinned);
++ // A running timer: its minutes, unless an agent needs the person (that comes first).
++ if (st.timer && !timerMod) withTimer((m) => m.sync());
++ const tm = timerMod && !w ? timerMod.pillText() : null;
++ const show = !isOpen() && !!(w || r || st.pinned || tm);
pill.hidden = !show;
pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
-- pill.dataset.tone = w ? 'waiting' : r ? 'running' : 'idle';
++ pill.dataset.tone = w ? 'waiting' : tm ? tm.tone : r ? 'running' : 'idle';
pill.querySelector('.ov-mark').setAttribute('data-state', r && !w ? 'busy' : '');
-- pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : r ? (r > 1 ? `${r} working` : 'Working') : '';
-- pill.setAttribute('aria-label', [w ? `${w} need${w > 1 ? '' : 's'} you` : '', r ? `${r} working` : ''].filter(Boolean).join(', ') || 'Open the Dock');
++ pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : tm ? tm.text : r ? (r > 1 ? `${r} working` : 'Working') : '';
++ pill.setAttribute('aria-label', tm ? tm.label : [w ? `${w} need${w > 1 ? '' : 's'} you` : '', r ? `${r} working` : ''].filter(Boolean).join(', ') || 'Open the Dock');
// Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.
if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);
}
@@@ -676,7 +695,7 @@
update(next);
if (!isOpen() && next.state.open && !phone()) open();
const c = next.counts || {};
-- if (c.running || c.waiting) readT = setTimeout(read, 30000);
++ if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000);
} catch { /* offline: the copy stands */ }
}
/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
@@@ -823,6 +842,7 @@
if (!next || !next.state) return;
doc = Object.keys(pending).length ? { ...next, state: state() } : next;
store(doc);
++ if (timerMod) timerMod.sync();
paintPill();
if (!el) return;
badges();
ddiff --git a/files.js b/files.js
iindex 80d9bcc..b4dadce 100644
---- a/files.js
++++ b/files.js
@@@ -23,6 +23,7 @@ const BROWSER = Object.freeze([
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
++ 'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'account-switcher.js',
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
ddiff --git a/package.json b/package.json
iindex 9beeed4..595bd81 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.75.0",
++ "version": "4.76.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -48,6 +48,7 @@
"./action-center": "./action-center.js",
"./dock": "./dock.js",
"./dock-vibe": "./dock-vibe.js",
++ "./dock-timer": "./dock-timer.js",
"./notification-ui": "./notification-ui.js",
"./notifications": "./notifications.js",
"./openvibe-sw": "./openvibe-sw.js",
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 396f8d4..a317ff0 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -13,6 +13,8 @@
// never reads as a tap outside the sheet;
// • Vibe (L48 V2): the person's Vibe from Chat's /api/vibe, starters before the first message, a question posted and
// Vibe's answer arriving in place (an https link as a link), and the Vibe tab kept in the Network's state;
++// • the timer (L48 R2): the header's menu starts one, the pill counts it down when the Dock is closed, its end
++// clears it and says so, and a focus round's end writes its break from that same end (any page, the same result);
// • Pop out (K-D4): the button only where the browser can open a window of its own; the panel moves into it with
// its tabs and its styles, the page keeps the pill saying where the Dock went, and closing the window brings the
// panel back; the Network is told.
@@@ -360,6 +362,50 @@ async function drag(p, from, to) {
await p.close();
}
++ // ── The timer (L48 R2): start from the menu, count down in the pill, end, and a focus round moves on by itself. ──
++ {
++ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState() } };
++ puts.length = 0;
++ const p = await fresh(1280, 860);
++ await p.goto(`${base}/page`, { maxMs: 5000 });
++ await p.evaluate("OpenVibeDock.open({ tab: 'activity' })");
++ await until(p, CARD, 'the card');
++ await p.evaluate("document.querySelector('.ovdk [data-act=timer]').click()");
++ await until(p, "!!document.querySelector('.ovdk .ovdk-tmenu')", 'the timer menu');
++ await p.evaluate("document.querySelector('.ovdk .ovdk-tmenu [data-min=\"5\"]').click()");
++ const strip = await until(p, "(() => { const s = document.querySelector('.ovdk .ovdk-timer'); return s ? s.innerText : null; })()", 'the timer strip in the Dock');
++ assert.match(strip, /^[45]:\d\d/, `about five minutes left (${strip})`);
++ await wait(900);
++ const started = puts.find((b) => b.timer);
++ assert.ok(started && started.timer.kind === 'timer' && Date.parse(started.timer.ends_at) - Date.parse(started.timer.started_at) === 300000, `the Network keeps the timer (${JSON.stringify(puts)})`);
++ await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");
++ const pill = await until(p, `(() => { const q = ${PILL}; return q && /^\\d+:\\d\\d$/.test(q.text) ? q : null; })()`, 'the pill counts it down');
++ assert.strictEqual(pill.tone, 'timer');
++ assert.match(pill.label, /Timer: \d+:\d\d left/);
++ // Another page's view: a timer two seconds from its end.
++ const soon = (ms, extra = {}) => ({ kind: 'timer', started_at: new Date(Date.now() - 60000).toISOString(), ends_at: new Date(Date.now() + ms).toISOString(), ...extra });
++ scenario.state = { ...defaultState(), timer: soon(1500) };
++ puts.length = 0;
++ await p.goto(`${base}/page`, { maxMs: 5000 });
++ const up = await until(p, `(() => { const q = ${PILL}; return q && q.text === "Time's up" ? q : null; })()`, 'the end, said', 8000);
++ assert.match(up.label, /time's up/i);
++ await wait(900);
++ assert.ok(puts.some((b) => 'timer' in b && b.timer === null), `a plain timer clears itself at its end (${JSON.stringify(puts)})`);
++ // A focus round ending: its break starts at that same end, whatever page writes it.
++ const end = Date.now() + 1200;
++ scenario.state = { ...defaultState(), timer: { kind: 'focus', label: 'Write', started_at: new Date(end - 25 * 60000).toISOString(), ends_at: new Date(end).toISOString(), round: 1, rounds: 2, focus_min: 25, break_min: 5 } };
++ puts.length = 0;
++ await p.goto(`${base}/page`, { maxMs: 5000 });
++ await until(p, `(() => { const q = ${PILL}; return q && q.tone === 'timer' ? q : null; })()`, 'the focus round in the pill');
++ await wait(2600);
++ const brk = puts.map((b) => b.timer).find((t) => t && t.kind === 'break');
++ assert.ok(brk, `the break follows the focus round (${JSON.stringify(puts)})`);
++ assert.strictEqual(Date.parse(brk.started_at), end, 'it starts where the round ended');
++ assert.strictEqual(Date.parse(brk.ends_at), end + 5 * 60000, 'five minutes');
++ assert.deepStrictEqual([brk.round, brk.rounds, brk.label], [1, 2, 'Write']);
++ await p.close();
++ }
++
// ── Network without the route yet (404): no pill, and the site stops asking for a day. Signed out: none. ──
{
scenario = { missing: true };
@@@ -376,7 +422,7 @@ async function drag(p, from, to) {
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
await p.close();
}
-- console.log('dock-chrome: the pill only when it matters, Vibe, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
++ console.log('dock-chrome: the pill only when it matters, Vibe, Agents, Chats and Activity, the timer, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
} finally {
await browser.close();
server.close();
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 2194243..b226f8b 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -14,8 +14,11 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// budget is 14.0 KB, a 0.25 KB increase, so both still have to stay inside it.
// 4.74.0 (2026-11-01, L48 V2) adds the Vibe tab's entry, its loader and Alt+V: +568 bytes (14757). The conversation
// and the mic are dock-vibe.js (4.1 KB brotli), loaded the first time the tab opens, not on every page.
--const DOCK_BROTLI_BUDGET = 14848; // 14.50 KB (14757 after 4.74.0 + 91 bytes of headroom)
--const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js'];
++// 4.76.0 (2026-11-01, L48 R2) adds the timer's button, its loader and the pill's countdown hook: +456 bytes (15213).
++// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
++// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
++const DOCK_BROTLI_BUDGET = 15360; // 15.00 KB (15213 after 4.76.0 + 147 bytes of headroom)
++const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js'];
// ── the budget ──────────────────────────────────────────────
{