openvibers/openvibe.shared island.js

island.js

main 13.7 KB · 163 lines · javascript

1/**
2 * openvibe-shared/island.js — the activity island.
3 *
4 * A page tells the navbar what it is doing. The brand mark becomes the indicator (progress ring,
5 * state colour) and a pill grows out of the brand; hover, focus or tap expands it into a rich card
6 * (image, title, subtitle, progress bar, detail line, actions).
7 *
8 * OpenVibeIsland.start({ id, title, subtitle, icon, image, progress, state, detail, actions })
9 * OpenVibeIsland.update(id, patch) progress: 0–1 | null (indeterminate)
10 * OpenVibeIsland.finish(id, { state: 'ok', title, actions, ttl })
11 * OpenVibeIsland.fail(id, { title, detail })
12 * OpenVibeIsland.remove(id) · .list()
13 *
14 * Works without the navbar (fixed pill, top centre). All text goes through textContent; links are
15 * http(s) or same-origin paths only. Motion follows the Motion setting (Calm slows its loops, Off stops them).
16 */
17(function (root) {
18 'use strict';
19 if (root.OpenVibeIsland) return;
20 if (typeof document === 'undefined') { const n = () => {}; const stub = { start: () => ({ update: n, finish: n, fail: n, remove: n }), update: n, finish: n, fail: n, remove: n, list: () => [] }; if (typeof module !== 'undefined') module.exports = stub; return; }
21
22 const CSS = `
23.ov-island{position:relative;display:inline-flex;align-items:center;margin-left:10px;z-index:60;font:500 12.5px/1.2 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--text-primary,#e6edf7)}
24.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}
25.ov-island[hidden]{display:none}
26.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}
27.ov-island{--ovis-c:var(--accent,#3b82f6);--ovis-t:var(--ovis-c)}
28.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e);--ovis-t:var(--success-text,var(--success,#22c55e))}
29.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444);--ovis-t:var(--danger-text,var(--danger,#ef4444))}
30.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}
31.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}
32.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}
33.ovi-count{background:var(--ovis-c);color:var(--on-accent,#fff);border-radius:999px;font-size:10.5px;font-weight:800;padding:1px 6px}
34.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}
35.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}
36.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}
37.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6);--ovis-t:var(--ovis-c)}
38.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e);--ovis-t:var(--success-text,var(--success,#22c55e))}
39.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444);--ovis-t:var(--danger-text,var(--danger,#ef4444))}
40.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}
41.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}
42.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}
43.ovi-t{font-weight:700;font-size:13.5px}
44.ovi-s{color:var(--text-secondary,#a8b3c4);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
45.ovi-body{min-width:0}
46.ovi-bar{height:4px;border-radius:4px;background:color-mix(in srgb,var(--ovis-c) 18%,transparent);margin-top:8px;overflow:hidden}
47.ovi-bar i{display:block;height:100%;width:0;background:var(--ovis-c);border-radius:4px;transition:width .35s cubic-bezier(.2,.8,.2,1)}
48.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}
49.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
50.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
51.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-t,var(--ovis-c));background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}
52.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}
53.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}
54@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}
55@keyframes ovisInd{0%{transform:translateX(-110%)}100%{transform:translateX(300%)}}
56@media (max-width:640px){.ov-island{margin-left:6px}.ovi-pill{max-width:40vw;padding-right:9px}.ovi-pill-pct{display:none}.ovi-card{position:fixed;top:56px;left:12px;right:12px;width:auto}}
57`;
58
59 const items = new Map(); // id → activity (insertion order = age)
60 const timers = new Map();
61 let host = null, pill = null, card = null, seq = 0;
62
63 const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };
64 const safeUrl = (u) => { try { const x = new URL(u, location.href); return /^(https?|blob):$/.test(x.protocol) ? x.href : null; } catch { return null; } };
65 const clamp = (p) => (typeof p === 'number' && isFinite(p) ? Math.max(0, Math.min(1, p)) : null);
66
67 function ensure() {
68 if (host && host.isConnected) return;
69 if (!document.getElementById('ov-island-css')) { const s = el('style'); s.id = 'ov-island-css'; s.textContent = CSS; document.head.appendChild(s); }
70 if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = el('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
71 host = el('div', 'ov-island'); host.hidden = true;
72 pill = el('button', 'ovi-pill'); pill.type = 'button'; pill.setAttribute('aria-haspopup', 'true'); pill.setAttribute('aria-expanded', 'false');
73 card = el('div', 'ovi-card'); card.setAttribute('role', 'region'); card.setAttribute('aria-label', 'Activity');
74 host.append(pill, card);
75 // Dock after the brand (and its launcher button); sites with their own navbar mark the spot with [data-ov-island].
76 const brand = document.querySelector('[data-ov-island], .openvibe-navbar #openvibe-launcher-btn, .openvibe-navbar .openvibe-navbar-brand, .navbar .nav-brand');
77 if (brand && brand.parentNode) brand.parentNode.insertBefore(host, brand.nextSibling);
78 else { host.classList.add('is-floating'); document.body.appendChild(host); }
79 const open = (v) => { host.classList.toggle('is-open', v); pill.setAttribute('aria-expanded', String(v)); };
80 pill.addEventListener('click', () => open(!host.classList.contains('is-open')));
81 if (matchMedia('(hover:hover)').matches) { host.addEventListener('mouseenter', () => open(true)); host.addEventListener('mouseleave', () => open(false)); }
82 document.addEventListener('click', (e) => { if (!host.contains(e.target)) open(false); });
83 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') open(false); });
84 }
85
86 function top() { let t = null; for (const a of items.values()) t = a; return t; }
87
88 function brandMark(a) {
89 // The navbar's own mark mirrors the top activity: progress ring + state colour.
90 document.querySelectorAll('.openvibe-navbar-brand .ov-mark, .navbar .nav-brand .ov-mark').forEach((m) => {
91 if (m.closest('.openvibe-network-badge')) return;
92 if (!a) { m.removeAttribute('data-state'); m.removeAttribute('data-progress'); m.style.removeProperty('--ovm-p'); return; }
93 m.setAttribute('data-state', a.state === 'info' ? 'idle' : a.state);
94 if (a.progress == null || a.state !== 'busy') m.removeAttribute('data-progress'); else { m.setAttribute('data-progress', String(a.progress)); m.style.setProperty('--ovm-p', String(a.progress)); }
95 });
96 try { root.dispatchEvent(new CustomEvent('ov:activity', { detail: a ? { id: a.id, state: a.state, progress: a.progress, title: a.title } : null })); } catch { /* */ }
97 }
98
99 function iconEl(a, size) { const i = el('span', 'ov-icon'); i.dataset.icon = a.icon || 'ov'; i.dataset.size = String(size); i.dataset.state = a.state === 'info' ? 'idle' : a.state; if (a.progress != null && a.state === 'busy') i.dataset.progress = String(a.progress); i.dataset.fx = a.state === 'busy' ? 'orbit' : 'none'; return i; }
100
101 function render() {
102 ensure();
103 const a = top();
104 brandMark(a);
105 if (!a) { host.hidden = true; host.classList.remove('is-open'); return; }
106 host.hidden = false; host.dataset.state = a.state;
107 pill.replaceChildren(iconEl(a, 22), el('span', 'ovi-pill-text', a.title || 'Working'));
108 if (a.state === 'busy' && a.progress != null) pill.appendChild(el('span', 'ovi-pill-pct', Math.round(a.progress * 100) + '%'));
109 if (items.size > 1) pill.appendChild(el('span', 'ovi-count', items.size));
110 pill.setAttribute('aria-label', `${a.title || 'Activity'}${a.progress != null ? ', ' + Math.round(a.progress * 100) + ' percent' : ''}`);
111
112 card.replaceChildren(...[...items.values()].reverse().map((it) => {
113 const row = el('div', 'ovi-item'); row.dataset.state = it.state;
114 const img = it.image && safeUrl(it.image);
115 if (img) { const im = el('img', 'ovi-thumb'); im.src = img; im.alt = ''; im.loading = 'lazy'; im.referrerPolicy = 'no-referrer'; row.appendChild(im); } else row.appendChild(iconEl(it, 40));
116 const body = el('div', 'ovi-body'); body.appendChild(el('div', 'ovi-t', it.title || ''));
117 if (it.subtitle) body.appendChild(el('div', 'ovi-s', it.subtitle));
118 if (it.state === 'busy') { const bar = el('div', 'ovi-bar' + (it.progress == null ? ' is-ind' : '')); const fill = el('i'); if (it.progress != null) fill.style.width = (it.progress * 100).toFixed(1) + '%'; bar.appendChild(fill); bar.setAttribute('role', 'progressbar'); if (it.progress != null) bar.setAttribute('aria-valuenow', String(Math.round(it.progress * 100))); body.appendChild(bar); }
119 if (it.detail) body.appendChild(el('div', 'ovi-d', it.detail));
120 if (it.actions && it.actions.length) {
121 const acts = el('div', 'ovi-acts');
122 it.actions.slice(0, 3).forEach((ac) => {
123 if (!ac || !ac.label) return;
124 const href = ac.href && safeUrl(ac.href);
125 const b = el(href ? 'a' : 'button', 'ovi-act', ac.label);
126 if (href) { b.href = href; if (ac.download) b.setAttribute('download', typeof ac.download === 'string' ? ac.download : ''); } else b.type = 'button';
127 if (ac.onClick) b.addEventListener('click', (ev) => { try { ac.onClick(ev); } catch { /* */ } });
128 acts.appendChild(b);
129 });
130 body.appendChild(acts);
131 }
132 row.appendChild(body);
133 return row;
134 }));
135 }
136
137 function put(id, patch, defaults) {
138 const prev = items.get(id) || Object.assign({ id, title: '', subtitle: '', icon: 'ov', image: '', progress: null, state: 'busy', detail: '', actions: [] }, defaults);
139 const next = Object.assign({}, prev, patch || {});
140 if (patch && 'progress' in patch) next.progress = clamp(patch.progress);
141 if (!['busy', 'ok', 'error', 'info'].includes(next.state)) next.state = 'busy';
142 items.set(id, next);
143 clearTimeout(timers.get(id));
144 if (next.ttl) timers.set(id, setTimeout(() => remove(id), next.ttl));
145 render();
146 return next;
147 }
148
149 function handle(id) { return { id, update: (p) => update(id, p), finish: (p) => finish(id, p), fail: (p) => fail(id, p), remove: () => remove(id) }; }
150 function start(o) { const id = (o && o.id) || 'act-' + (++seq); items.delete(id); put(id, Object.assign({ state: 'busy' }, o, { id })); return handle(id); }
151 function update(id, patch) { if (!items.has(id)) return; const p = Object.assign({}, patch); delete p.id; put(id, p); }
152 function finish(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'ok', progress: 1, actions: [], ttl: 8000 }, patch)); if (host && top() && top().id === id && (patch && patch.actions && patch.actions.length)) { host.classList.add('is-open'); pill.setAttribute('aria-expanded', 'true'); setTimeout(() => { if (host && !host.matches(':hover')) host.classList.remove('is-open'); }, 5000); } }
153 function fail(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'error', progress: null, actions: [], ttl: 12000 }, patch)); }
154 function remove(id) { clearTimeout(timers.get(id)); timers.delete(id); if (items.delete(id)) render(); }
155
156 const api = { start, update, finish, fail, remove, list: () => [...items.values()] };
157 root.OpenVibeIsland = api;
158 // The navbar exposes the same thing as OpenVibeNavbar.activity, whichever loads first.
159 const bind = () => { if (root.OpenVibeNavbar && !root.OpenVibeNavbar.activity) { try { root.OpenVibeNavbar.activity = api; } catch { /* */ } } };
160 bind(); root.addEventListener('load', bind);
161 if (typeof module !== 'undefined' && module.exports) module.exports = api;
162})(typeof window !== 'undefined' ? window : globalThis);
163