openvibers/openvibe.shared island.js
island.js
| 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 | ; |
| 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 |