openvibers/openvibe.shared Commits

What Vibe did, as chips you can undo; a timer Vibe starts shows at once (4.79.0) (#232)

OP OpenVibers committed 12 days ago · 12401243e2 · 7 files, +90 −6

Under a Vibe answer that set or stopped a reminder or a timer (Chat's results on the message,
Chat #290), the Dock's Vibe tab shows a chip with Undo for a day and Open for a reminder; an undone
one reads 'stopped'. When the answer started a timer (Chat #291, Network #486), the Dock reads its
state again so the pill counts down at once.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5831bd4..d3979e7 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ 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.79.0 — 2026-11-01
++
++- **What Vibe did, as chips you can undo** (plan L48 V2). Under a Vibe answer that set or stopped a reminder or a
++ timer (OpenVibe.Chat's `results` on the message), the Dock's Vibe tab shows a chip: "Reminder set: Stretch · Every
++ 2 hours", "Timer: 25 min focus". **Undo** for a day (Chat's `POST /api/vibe/undo`), **Open** for a reminder; an undone
++ one reads "stopped" and is not offered again.
++- **A timer Vibe starts shows at once.** When Vibe's answer started a timer ("Vibe, a 25-minute focus timer"), the Dock
++ reads its state again, so the pill counts down without waiting for the next poll. The Vibe tab's mount takes
++ `refresh`.
++
## 4.78.0 — 2026-11-01
- **`kit-layout.css`: the fluid scale, the layout primitives and a classless base** (plan L54 K1/K2). A file of its
ddiff --git a/README.md b/README.md
iindex 7cf61e6..9e6d174 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.78.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.79.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 4d62cda..a08ca9d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.78.0",
++ "version": "4.79.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-vibe.js b/dock-vibe.js
iindex f60491c..080eb23 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -26,6 +26,20 @@
.ovdk-mic { width: 40px; height: 40px; flex: none; margin-left: -8px; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }
.ovdk-mic:hover { color: var(--text-primary, #ececf1); }
.ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); }
++ .ovdk-res { display: grid; gap: 6px; margin-top: 6px; max-width: 100%; }
++ .ovdk-res-c { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 8px 10px; border-radius: 12px;
++ border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, rgba(255,255,255,.12))); background: color-mix(in srgb, var(--accent, #3b82f6) 8%, transparent); }
++ .ovdk-res-c .ovdk-i { color: var(--accent, #3b82f6); }
++ .ovdk-res-t { min-width: 0; font-size: 13px; line-height: 1.35; color: var(--text-primary, #ececf1); }
++ .ovdk-res-t b { font-weight: 750; }
++ .ovdk-res-t small { display: block; color: var(--text-muted, #8b8b96); font-size: 12px; }
++ .ovdk-res-a { display: flex; gap: 4px; }
++ .ovdk-res-a button, .ovdk-res-a a { min-height: 30px; padding: 0 10px; 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; text-decoration: none; display: inline-flex; align-items: center; }
++ .ovdk-res-a button:hover, .ovdk-res-a a:hover { border-color: var(--accent, #3b82f6); }
++ .ovdk-res-a button:disabled { opacity: .55; cursor: default; }
++ .ovdk-res-c.is-stopped { border-color: var(--border, rgba(255,255,255,.12)); background: transparent; }
++ .ovdk-res-c.is-stopped .ovdk-i, .ovdk-res-c.is-stopped .ovdk-res-t { color: var(--text-muted, #8b8b96); }
@media (prefers-reduced-motion: reduce) { .ovdk-typing i { animation: none; } }
`;
let ctx = null, G = {};
@@@ -54,11 +68,50 @@
});
return span;
}
++ const DAY = 24 * 3600 * 1000;
++ /** One thing Vibe did (Chat's results on its answer): what, when, and Undo / Open. */
++ function resultChip(m, r, i) {
++ const set = r.action === 'set';
++ const c = h('div', `ovdk-res-c${set ? '' : ' is-stopped'}`);
++ c.append(icon(r.kind === 'timer' ? G.clock : G.bell));
++ const t = h('div', 'ovdk-res-t');
++ const what = r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped');
++ t.append(h('b', '', `${what}: `), String(r.title || ''));
++ if (r.when && set) t.append(h('small', '', String(r.when)));
++ const a = h('div', 'ovdk-res-a');
++ const young = !m.at || Date.now() - Date.parse(m.at) < DAY;
++ if (set && young && Number(m.id) > 0) {
++ const undo = h('button', '', 'Undo'); undo.type = 'button';
++ undo.setAttribute('aria-label', `Undo: ${what} ${r.title || ''}`.trim());
++ undo.addEventListener('click', async () => {
++ undo.disabled = true;
++ try {
++ const out = await ctx.chatCall('/api/vibe/undo', { method: 'POST', body: JSON.stringify({ message_id: Number(m.id), index: i }) });
++ m.results = (out && out.results) || m.results.map((x, k) => (k === i ? { ...x, action: 'stopped' } : x));
++ c.replaceWith(resultChip(m, m.results[i], i));
++ if (r.kind === 'timer' && ctx.refresh) ctx.refresh();
++ } catch (e) {
++ undo.disabled = false; undo.textContent = e && e.status === 410 ? 'Too old to undo' : 'Try again';
++ if (e && (e.status === 409 || e.status === 410)) undo.disabled = true;
++ }
++ });
++ a.append(undo);
++ }
++ if (r.url && /^https:\/\//.test(r.url)) { const o = h('a', '', 'Open'); o.href = r.url; o.target = '_blank'; o.rel = 'noopener'; a.append(o); }
++ c.append(t, a);
++ return c;
++ }
++ function results(m) {
++ const box = h('div', 'ovdk-res');
++ m.results.slice(0, 4).forEach((r, i) => { if (r && (r.kind === 'reminder' || r.kind === 'timer')) box.append(resultChip(m, r, i)); });
++ return box;
++ }
function vbubble(m, prev) {
const mine = m.from === 'you';
const li = h('li', `ovdk-msg${mine ? ' is-mine' : ' is-vibe'}${prev && prev.from === m.from ? ' is-cont' : ''}`);
if (!mine && !(prev && prev.from === m.from)) li.append(h('span', 'ovdk-who', 'Vibe'));
li.append(linkified(m.text));
++ if (!mine && Array.isArray(m.results) && m.results.length) li.append(results(m));
if (m.at) li.title = new Date(m.at).toLocaleString();
return li;
}
@@@ -129,6 +182,7 @@
list.insertBefore(vbubble(m, vibe.msgs[vibe.msgs.length - 1]), typing.parentNode === list ? typing : null);
vibe.msgs.push(m); vibe.last = Math.max(vibe.last, Number(m.id) || 0);
if (m.from === 'vibe') answered = true;
++ if (!first && Array.isArray(m.results) && m.results.some((r) => r && r.kind === 'timer') && ctx.refresh) ctx.refresh();
});
if (answered || Date.now() > vibe.waitUntil) { typing.remove(); vibe.waitUntil = 0; }
if (first && !vibe.msgs.length) {
ddiff --git a/dock.js b/dock.js
iindex ab45961..1a162b5 100644
---- a/dock.js
++++ b/dock.js
@@@ -434,7 +434,7 @@
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }
-- vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send } });
++ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
return;
}
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
ddiff --git a/package.json b/package.json
iindex b644640..43556c8 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.78.0",
++ "version": "4.79.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/dock-chrome.test.js b/test/dock-chrome.test.js
iindex a8ca566..c394ff4 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -56,7 +56,7 @@ let scenario = {};
const puts = [], decided = [], said = [], reads = [];
// Chat's Vibe API for the Dock (OpenVibe.Chat server/rooms/vibe-api.js): one chat, Vibe answering a moment later.
let vibeChat = null;
--const vibeMsgs = [], asked = [], listened = [];
++const vibeMsgs = [], asked = [], listened = [], undone = [];
const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });
const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">
@@@ -106,6 +106,14 @@ const server = http.createServer((req, res) => {
const after = Number(u.searchParams.get('after')) || 0;
return json(200, { chat: vibeChat, messages: vibeMsgs.filter((m) => m.id > after) });
}
++ if (u.pathname === '/api/vibe/undo' && req.method === 'POST') {
++ const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
++ undone.push(b);
++ const m = vibeMsgs.find((x) => x.id === b.message_id);
++ if (!m || !m.results || !m.results[b.index]) return json(404, { error: 'No such result' });
++ m.results = m.results.map((r, k) => (k === b.index ? { ...r, action: 'stopped' } : r));
++ return json(200, { results: m.results, undone: { kind: 'reminder', id: m.results[b.index].id, title: m.results[b.index].title } });
++ }
if (u.pathname === '/api/vibe/listen' && req.method === 'POST') {
listened.push({ type: req.headers['content-type'], bytes: Buffer.concat(chunks).length, lang: u.searchParams.get('lang'), auth: req.headers.authorization });
return json(200, { text: 'remind me to drink water at 3' });
@@@ -116,7 +124,8 @@ const server = http.createServer((req, res) => {
vibeChat = vibeChat || { slug: 'v1', name: 'Vibe', url: 'https://openvibe.chat/vibe/v1' };
const mine = { id: 500 + vibeMsgs.length, from: 'you', text: b.message, at: iso(0) };
vibeMsgs.push(mine);
-- setTimeout(() => vibeMsgs.push({ id: 600 + vibeMsgs.length, from: 'vibe', text: 'Reminder set: Stretch, every 2 hours. See them at https://openvibe.today/reminders', at: iso(0) }), 700);
++ setTimeout(() => vibeMsgs.push({ id: 600 + vibeMsgs.length, from: 'vibe', text: 'Reminder set: Stretch, every 2 hours. See them at https://openvibe.today/reminders', at: iso(0),
++ results: [{ kind: 'reminder', action: 'set', id: 'rem_01J8Z3V9Q6N1X2Y3Z4A5B6C7E1', title: 'Stretch', when: 'Every 2 hours, 9 am–5 pm', url: 'https://openvibe.today/me#reminders' }] }), 700);
return json(201, { chat: vibeChat, message: mine });
}
if (u.pathname === '/api/notifications/unread-count') return json(200, { count: 2 });
@@@ -359,6 +368,17 @@ async function drag(p, from, to) {
const answer = await until(p, "(() => { const m = document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-vibe:not(.ovdk-typing)'); return m ? { text: m.innerText, link: (m.querySelector('a') || {}).href || null } : null; })()", 'Vibe\'s answer', 6000);
assert.match(answer.text, /Reminder set: Stretch, every 2 hours/);
assert.strictEqual(answer.link, 'https://openvibe.today/reminders', 'an https link is a link');
++ // What Vibe did, under its answer: a reminder chip with Undo and Open (plan L48 V2, result chips).
++ const chip = await until(p, "(() => { const c = document.querySelector('.ovdk [data-panel=vibe] .ovdk-res-c'); return c ? { text: c.innerText, open: (c.querySelector('a') || {}).href, undo: !!c.querySelector('button') } : null; })()", 'the result chip');
++ assert.match(chip.text, /Reminder set: Stretch/);
++ assert.match(chip.text, /Every 2 hours, 9 am–5 pm/);
++ assert.strictEqual(chip.open, 'https://openvibe.today/me#reminders');
++ assert.ok(chip.undo, 'Undo while it can be undone');
++ await p.evaluate("document.querySelector('.ovdk .ovdk-res-c button').click()");
++ await until(p, "!!document.querySelector('.ovdk .ovdk-res-c.is-stopped')", 'the chip says it is stopped');
++ assert.deepStrictEqual(undone[0], { message_id: vibeMsgs.find((m) => m.from === 'vibe').id, index: 0 }, 'undo names the answer and the result');
++ assert.match(await p.evaluate("document.querySelector('.ovdk .ovdk-res-c').innerText"), /Reminder stopped: Stretch/);
++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again');
assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers');
assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat');
await wait(900);