openvibers/openvibe.shared Commits
4.164.0: Chat's two views, the page head, the upload kit (#377)
Changes
@@@ -1,5 +1,24 @@# Changelog++## 4.164.0 — 2026-11-09++++- **Chat's two views** (`openvibe-shared/messages`, `OVMessages.render(el, { messages, view: 'room' | 'bubbles', me })`;++ the owner, 11-09: "texting bubbles like Android/iOS Messages, and the live chat room… switchable"). Bubbles: runs join++ on the inner corners, the avatar beside the first with its column kept through the run, a coloured initial when there++ is no picture, the time under the last, read ticks, sticky day chips. Room: one line per message wrapping under the++ name on a phone, each person's name in a steady colour (tuned for dark and light). Only the newest message animates.++ `OpenVibeUserMenu.register()` lets a site add actions to the shared user card.++- **Page head** (`kit.pageHead({ back, icon, title, lede, actions })`; ask 64: "less websitey… an icon system"): a small++ named back chip (44 px to the finger), the page's icon tile and name, 44 px actions, and the lede once and whole on++ every screen. Product heroes can opt in (`showcase.hero({ pageHead })`).++- **Upload kit** (`ov-upload.js`/`.css`: `OVUpload.picker`, `.queue`, `.progress`): drag and drop or choose, each file a++ row with its picture or a file glyph, an × and its own progress (speed and time left smoothed); the total above for two++ files or more; done draws a tick; Calm drops the sheen. Sites bring the transport (`upload(file, { onProgress, signal })`).++- **The Feed, fixed for real data** (Community's /api/v1/feed is live): LIVE shows only while a channel is live now++ (`live_now`, with a breathing chip and a red edge); back-to-back go-lives that are over fold into one card of faces and++ names; a trending picture shows its site's icon and "Trending" (not a "?"); video cards no longer leave the flow (their++ class collided with the preview video's, `.ovf-video`; cards are `.ovf-k-<kind>` now).++## 4.163.0 — 2026-11-09- **You, redone** (the owner, 11-09: "the U tab still sucks, and you didn't do all the improvements"). You on top: your@@@ -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.163.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.164.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer 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` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.163.0",++ "version": "4.164.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)",@@@ -727,3 +727,7 @@ set the `--bg-*`, `--text-*` and `--accent` variables yourself, or keep the fall`node scripts/kit-gallery.js > gallery.html` writes one page with every piece and the app bar on.`node scripts/kit-gallery.js --shots <dir>` serves the package folder and screenshots it at 390, 768 and 1280 px. Itprints any sideways scroll, so the gallery doubles as the visual test.++++### Page identity++++`kit.pageHead({ back: { label: 'Home', href: '/' }, icon: 'video', title: 'Record a video', lede: 'Record, review and share your video.', actions: [{ href: '/videos', icon: 'video', label: 'Your videos' }] })` renders the named back chip, page icon tile, H1 and accessible icon actions. On phones the lede is one line with a native **About this** disclosure; all its words remain server-rendered and work without JavaScript. Keep breadcrumb structured data in the site's BreadcrumbList JSON-LD. `head()` remains available for sticky application toolbars.@@@ -45,6 +45,8 @@ const BROWSER = Object.freeze(['ov-locker-core.js', // openvibe-sdk/locker for the browser, generated (scripts/build-locker-core.js): WebCrypto only, the SDK's own vectors'ov-locker.js', // Locker in the browser (plan Q2): a locked item's card, making and unlocking your locker; crypto in ov-locker-core.js'ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button++ 'ov-upload.js', // shared file picker, upload queue and ov-progress++ 'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)'ov-home.js', // the menu's home screen and wall (search.js loads it for You and Feed)@@@ -89,6 +91,8 @@ const STYLES = Object.freeze(['ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)'ov-locker.css', // ov-locker.js's look: the locked card, the locker sheets, the recovery kit (docs/locker.md)'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)++ 'ov-upload.css', // picker, queue and ov-progress states++ 'messages.css', // the room and bubble views' look'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)@@@ -452,3 +452,26 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }.ovk-sidebar { overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }.ovk-sidebar-scrim { touch-action: none; overscroll-behavior: contain; }}++/* A page's head (kit.pageHead; designed 11-09, the owner: "less websitey… modern… an icon system"): a small named way++ back, the page's icon tile and name with its actions, and one line about it, whole (one copy: readers and search see++ the same words; nothing behind an "About this"). */++.ovk-page-head { margin: 0 0 20px; min-width: 0; }++.ovk-page-back { position: relative; display: inline-flex; align-items: center; gap: 2px; height: 32px; margin: 0 0 12px; padding: 0 12px 0 8px;++ border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); color: var(--text-secondary, #a8b3c4);++ font-size: 13.5px; font-weight: 650; text-decoration: none; transition: background .15s, color .15s; }++.ovk-page-back::after { content: ""; position: absolute; inset: -6px -4px; } /* 44 px under the finger */++.ovk-page-back .ovk-i { width: 18px; height: 18px; }++@media (hover: hover) { .ovk-page-back:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 11%, transparent); color: var(--text-primary, #e8ecf3); } }++.ovk-page-head-row { display: flex; align-items: center; gap: 12px; min-width: 0; }++.ovk-page-head-row > .ovk-heading-icon { flex-basis: 44px; width: 44px; height: 44px; border-radius: 13px;++ background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent), color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent));++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }++.ovk-page-head-row > .ovk-heading-icon > .ovk-i { width: 24px; height: 24px; }++.ovk-page-head-row h1 { flex: 1 1 auto; min-width: 0; margin: 0; font-size: clamp(1.45rem, 2.4vw + .7rem, 2.2rem); line-height: 1.15; letter-spacing: -.015em; overflow-wrap: anywhere; }++.ovk-page-actions { display: flex; align-items: center; gap: 6px; flex: none; margin-left: auto; }++.ovk-page-actions .ovk-icon-btn { width: 44px; height: 44px; border-radius: 50%; }++.ovk-page-lede { max-width: 68ch; margin: 10px 0 0; color: var(--text-secondary, #a8b3c4); font-size: 15.5px; line-height: 1.5; }++.ovk-page-back:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; }++@media (max-width: 760px) { .ovk-page-head { margin-bottom: 16px; } .ovk-page-lede { margin-top: 8px; font-size: 14.5px; } }++/* A phone hero's 1fr track shrinks around the head. */++.sc-hero-main:has(>.ovk-page-head),.sc-room-main:has(>.ovk-page-head){min-width:0}@@@ -211,6 +211,17 @@+ (actions && actions.length ? `<div class="ovk-head-tools">${actions.join('')}</div>` : '')+ `</div>${tabs || ''}</header>`;}++ /** A page identity, with a named parent and a native, script-free mobile description disclosure.++ * actions accepts iconButton options or trusted control markup, like head(). */++ function pageHead({ back = null, icon: ic = 'home', title = '', lede = '', actions = [] } = {}) {++ const tools = (actions || []).map(a => typeof a === 'string' ? a : iconButton(a)).join('');++ return `<header class="ovk-page-head">`++ + (back ? `<a class="ovk-page-back" href="${esc(url(back.href))}">${icon('back')}<span>${esc(back.label || 'Back')}</span></a>` : '')++ + `<div class="ovk-page-head-row"><span class="ovk-heading-icon">${icon(ic)}</span><h1>${esc(title)}</h1>`++ + (tools ? `<div class="ovk-page-actions">${tools}</div>` : '') + `</div>`++ + (lede ? `<p class="ovk-page-lede">${esc(lede)}</p>` : '') // one copy, whole, on every screen++ + `</header>`;++ }// ── The left sidebar (L35) ──/*** An app's left sidebar, one for every OpenVibe product (the owner, 2026-10-31: "the navigation makes more sense@@@ -903,7 +914,7 @@return {skeleton,esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,-- head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,++ head, pageHead, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,enhance, VERSION: 2,num, steps, table, chart, rank, meter, drop, receipt, page,nnew file mode 100644@@@ -0,0 +1,52 @@++/* openvibe-shared/messages — Chat's two views (the owner, 11-09): a live room (lines like a stream's chat, wrapping++ under the name on a phone) and texting bubbles (Android/iOS Messages: runs of bubbles with joined corners, the++ avatar beside the first, the time under the last). The network's theme variables; dark and light; Calm fades. */++.ovm-messages { list-style: none; margin: 0; padding: 12px clamp(10px, 3vw, 24px) 16px; display: flex; flex-direction: column; gap: 2px;++ overflow: auto; min-height: 0; overscroll-behavior: contain; font-size: 15px; line-height: 1.42; color: var(--text-primary, #e8ecf3); }++.ovm-day { position: sticky; top: 6px; z-index: 1; align-self: center; margin: 14px 0 6px; padding: 4px 12px; border-radius: 999px;++ background: color-mix(in srgb, var(--bg-secondary, #141a24) 84%, transparent); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);++ box-shadow: inset 0 0 0 1px var(--border, rgba(255, 255, 255, .08)); font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }++++/* Bubbles */++.ovm-group { display: flex; gap: 8px; align-items: flex-start; max-width: min(82%, 640px); margin: 8px 0 0; }++.ovm-group[data-continued="true"] { margin-top: 2px; }++.ovm-group[data-mine="true"] { align-self: flex-end; flex-direction: row-reverse; }++.ovm-avatar { flex: none; display: block; width: 30px; height: 30px; border-radius: 50%; object-fit: cover; background: var(--bg-tertiary, #2a3140); }++.ovm-group:not([data-continued="true"]) .ovm-avatar { margin-top: 19px; } /* level with the first bubble, under the name */++.ovm-avatar.is-gap { height: 1px; background: none; } /* a run keeps the avatar's column */++.ovm-avatar.is-initial { display: grid; place-items: center; background: hsl(var(--ovm-h, 215) 55% 46%); color: #fff; font-size: 13px; font-weight: 800; }++.ovm-group-body { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }++.ovm-group[data-mine="true"] .ovm-group-body { align-items: flex-end; }++.ovm-author { margin: 0 0 3px 12px; font-size: 12px; font-weight: 700; color: var(--text-muted, #93a0b4); }++.ovm-bubble { position: relative; max-width: 100%; padding: 8px 13px; border-radius: 20px; background: var(--bg-tertiary, #2a3140); overflow-wrap: anywhere; }++.ovm-group[data-mine="true"] .ovm-bubble { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }++/* A run of bubbles from one person joins on the inner side. */++.ovm-group:not([data-mine="true"])[data-continued="true"] .ovm-bubble { border-top-left-radius: 6px; }++.ovm-group:not([data-mine="true"])[data-tail="false"] .ovm-bubble { border-bottom-left-radius: 6px; }++.ovm-group[data-mine="true"][data-continued="true"] .ovm-bubble { border-top-right-radius: 6px; }++.ovm-group[data-mine="true"][data-tail="false"] .ovm-bubble { border-bottom-right-radius: 6px; }++.ovm-text { white-space: pre-wrap; }++.ovm-meta { display: flex; align-items: center; gap: 4px; margin: 3px 8px 0; font-size: 11px; color: var(--text-muted, #93a0b4); }++.ovm-meta time { font-variant-numeric: tabular-nums; }++.ovm-tick { letter-spacing: -.2em; color: var(--accent-light, var(--accent, #60a5fa)); }++.ovm-emoji-only { padding: 0 2px; background: transparent !important; font-size: 2.2rem; line-height: 1.15; }++.ovm-reply { display: block; margin-bottom: 6px; padding-left: 8px; border-left: 2px solid currentColor; font-size: .86em; opacity: .75; }++.ovm-link { color: inherit; text-decoration: underline; text-underline-offset: 2px; }++++/* Room: one line per message, the name in its colour, the words wrapping under it on a phone. */++.ovm-messages[data-view="room"] { gap: 0; }++.ovm-room { display: block; padding: 3px 8px; border-radius: 8px; overflow-wrap: anywhere; }++@media (hover: hover) { .ovm-room:hover { background: var(--bg-hover, rgba(255, 255, 255, .05)); } }++.ovm-room time { margin-right: 7px; font-size: 11.5px; color: var(--text-muted, #93a0b4); font-variant-numeric: tabular-nums; }++.ovm-room-author { font-weight: 750; color: var(--ov-author-color, hsl(var(--ovm-h, 215) 80% 68%)); }++:root[data-theme="light"] .ovm-room-author { color: var(--ov-author-color, hsl(var(--ovm-h, 215) 72% 36%)); }++.ovm-room-author::after { content: ":"; margin-right: 6px; font-weight: 600; color: var(--text-muted, #93a0b4); }++.ovm-room .ovm-avatar { display: none; }++++/* The newest message arrives (only the last: a repaint never replays the whole list). */++.ovm-group:last-child { animation: ovm-in .24s cubic-bezier(.2, .8, .2, 1) both; }++.ovm-room:last-child { animation: ovm-fade .2s ease-out both; }++@keyframes ovm-in { from { opacity: 0; transform: translateY(8px) scale(.97); } }++@keyframes ovm-fade { from { opacity: 0; } }++html[data-ov-motion=reduced] .ovm-group:last-child { animation-name: ovm-fade; }++@media (prefers-reduced-motion: reduce) { .ovm-group:last-child { animation-name: ovm-fade; animation-duration: .15s; } }nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++/** Render Chat messages as a compact room or grouped conversation bubbles. */++const text = (parent, value) => parent.appendChild(document.createTextNode(String(value == null ? '' : value)));++function safeLinkify(node, value) {++ const raw = String(value || ''), re = /https?:\/\/[^\s<>"']+/g;++ let last = 0, match;++ while ((match = re.exec(raw))) {++ if (match.index > last) text(node, raw.slice(last, match.index));++ const a = document.createElement('a'); a.className = 'ovm-link'; a.href = match[0]; a.rel = 'nofollow noopener noreferrer'; a.target = '_blank'; text(a, match[0]); node.appendChild(a);++ last = match.index + match[0].length;++ }++ if (last < raw.length) text(node, raw.slice(last));++}++function timeValue(message) { let raw = String(message.created_at || message.at || '').replace(' ', 'T'); if (raw && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) raw += 'Z'; const d = new Date(raw); return Number.isNaN(d.getTime()) ? null : d; }++function safeAvatar(value) { try { const u = new URL(String(value || '')); return u.protocol === 'https:' && !u.port && u.hostname.toLowerCase() === 'openvibe.media' ? u.href : ''; } catch { return ''; } }++/** A steady hue per person: their name in the room and their initial's circle keep one colour everywhere. */++function hueOf(id) { let x = 7; for (const c of String(id || '')) x = (x * 31 + c.codePointAt(0)) % 360; return x; }++function dayLabel(date, now) {++ const days = Math.floor((Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) / 86400000);++ if (days === 0) return 'Today'; if (days === 1) return 'Yesterday';++ return new Intl.DateTimeFormat(undefined, { weekday: days < 7 ? 'short' : undefined, day: 'numeric', month: 'short' }).format(date);++}++function render(root, { messages = [], view = 'room', me = null, now = new Date() } = {}) {++ if (!root || !root.ownerDocument) throw new TypeError('A message list element is required');++ if (view !== 'room' && view !== 'bubbles') throw new TypeError('view must be "room" or "bubbles"');++ root.replaceChildren(); root.classList.add('ovm-messages'); root.dataset.view = view;++ let previous = null, activeDay = '';++ messages.forEach((message, index) => {++ const date = timeValue(message), stamp = date ? date.getTime() : 0;++ const day = date ? dayLabel(date, now) : '';++ if (view === 'bubbles' && day && day !== activeDay) { const separator = document.createElement('li'); separator.className = 'ovm-day'; text(separator, day); root.appendChild(separator); activeDay = day; }++ const sender = String(message.sender_id || message.user_id || message.from || message.username || '');++ const mine = me != null && String(message.sender_id || message.user_id || '') === String(me);++ const same = previous && sender === previous.sender && stamp && previous.stamp && stamp - previous.stamp >= 0 && stamp - previous.stamp <= 180000;++ if (view === 'room') {++ const row = document.createElement('li'); row.className = 'ovm-room'; row.dataset.id = String(message.id || '');++ const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time);++ const author = document.createElement('span'); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone'); row.appendChild(author);++ const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row);++ } else {++ const group = document.createElement('li'); group.className = 'ovm-group'; group.dataset.id = String(message.id || ''); group.dataset.mine = String(mine); group.dataset.tail = String(!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender);++ if (same) group.dataset.continued = 'true';++ if (!mine && !same && safeAvatar(message.avatar_url)) { const avatar = document.createElement('img'); avatar.className = 'ovm-avatar'; avatar.alt = ''; avatar.loading = 'lazy'; avatar.referrerPolicy = 'no-referrer'; avatar.src = safeAvatar(message.avatar_url); group.appendChild(avatar); }++ else if (!mine && !same) { const spacer = document.createElement('span'); spacer.className = 'ovm-avatar is-initial'; spacer.setAttribute('aria-hidden', 'true'); spacer.style.setProperty('--ovm-h', String(hueOf(sender))); text(spacer, String(message.display_name || message.username || '?').trim().charAt(0).toUpperCase()); group.appendChild(spacer); }++ else if (!mine) { const gap = document.createElement('span'); gap.className = 'ovm-avatar is-gap'; gap.setAttribute('aria-hidden', 'true'); group.appendChild(gap); } // a run keeps the avatar's column++ const body = document.createElement('div'); body.className = 'ovm-group-body';++ if (!mine && !same) { const author = document.createElement('div'); author.className = 'ovm-author'; text(author, message.display_name || message.username || 'Someone'); body.appendChild(author); }++ const bubble = document.createElement('div'); bubble.className = 'ovm-bubble';++ if (message.reply_to) { const quote = document.createElement('span'); quote.className = 'ovm-reply'; text(quote, message.reply_to.message || message.reply_to.text || 'Reply'); bubble.appendChild(quote); }++ const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || '');++ if (/^(?:\p{Extended_Pictographic}|\s)+$/u.test(value) && value.trim()) bubble.classList.add('ovm-emoji-only');++ safeLinkify(content, value); bubble.appendChild(content); body.appendChild(bubble);++ if (!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender || !stamp || !timeValue(messages[index + 1]) || timeValue(messages[index + 1]).getTime() - stamp > 180000) {++ const meta = document.createElement('div'); meta.className = 'ovm-meta'; const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date)); } meta.appendChild(time);++ if (mine && message.read) { const tick = document.createElement('span'); tick.className = 'ovm-tick'; tick.setAttribute('aria-label', 'Read'); text(tick, '✓✓'); meta.appendChild(tick); }++ body.appendChild(meta);++ }++ group.appendChild(body); root.appendChild(group);++ }++ previous = { sender, stamp };++ });++ return root;++}++const api = { render };++if (typeof module !== 'undefined' && module.exports) module.exports = api;++if (typeof window !== 'undefined') {++ window.OVMessages = api;++ const ov = window.ov = window.ov || {};++ ov.chat = ov.chat || {};++ ov.chat.messages = api;++}@@@ -572,7 +572,7 @@const NAV_ICONS_REV = '1091daee9c7e'; // 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': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': 'd0baedb2ce11', 'nav-slots.js': 'a5af310d62e0' };++ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '184115c2aa7f', 'nav-slots.js': 'a5af310d62e0' };// 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">.@@@ -876,18 +876,21 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;const t = h('span', 'ovf-update-txt'); t.append(h('b', '', String(c.title || '').slice(0, 160)), h('small', '', [c.site_name || c.site, ago(c.at)].filter(Boolean).join(' · ')));a.append(ic, t); return a;}-- const a = h('a', `ovf-card ovf-${kind.replace(/[^a-z]/g, '')}`); a.href = url;++ const a = h('a', `ovf-card ovf-k-${kind.replace(/[^a-z]/g, '')}`); a.href = url;const head = h('span', 'ovf-head');const face = h('span', 'ovf-face');const av = c.actor && safe(c.actor.avatar);-- if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); } else face.textContent = String((c.actor && c.actor.name) || '?').charAt(0).toUpperCase();++ // No person behind it (the network's trending pictures): the site's icon and the card's label, not a "?".++ if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }++ else if (!c.actor) { face.classList.add('is-site'); face.style.setProperty('--ovh-h', String(hueOf(c.site || 'ov'))); face.append(siteIcon(c.site || 'ov', 20)); }++ else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();const who = h('span', 'ovf-who');-- who.append(h('b', '', String((c.actor && c.actor.name) || c.site_name || '').slice(0, 60)));++ who.append(h('b', '', String((c.actor && c.actor.name) || c.label || c.site_name || '').slice(0, 60)));const meta = h('small', '');-- meta.append(siteIcon(c.site || 'ov', 14), document.createTextNode(` ${[c.site_name || '', ago(c.at)].filter(Boolean).join(' · ')}`));++ meta.append(siteIcon(c.site || 'ov', 14), document.createTextNode(` ${[c.site_name || '', c.actor ? ago(c.at) : String(c.text || ago(c.at) || '').slice(0, 60)].filter(Boolean).join(' · ')}`));who.append(meta);head.append(face, who);-- if (kind === 'live') head.append(h('span', 'ovf-livechip', 'LIVE'));++ if (kind === 'live' && c.live_now) { a.classList.add('is-on'); head.append(h('span', 'ovf-livechip', 'LIVE')); }a.append(head);const media = c.media || {};const thumb = safe(media.thumb);@@@ -908,6 +911,34 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;if (bits.length) a.append(h('small', 'ovf-counts', bits.join(' · ')));return a;}++ /** Several streamers who went live (and are not live now), as one card: their faces, then each name to their channel. */++ function livesCard(items) {++ const box = h('div', 'ovf-card ovf-lives');++ const head = h('div', 'ovf-head');++ const faces = h('span', 'ovf-faces');++ items.slice(0, 4).forEach((x) => {++ const f = h('span', 'ovf-face');++ const av = x.actor && safe(x.actor.avatar);++ if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; f.append(img); } else f.textContent = String((x.actor && x.actor.name) || '?').charAt(0).toUpperCase();++ faces.append(f);++ });++ const names = items.map((x) => String((x.actor && x.actor.name) || '').slice(0, 40)).filter(Boolean);++ const who = h('span', 'ovf-who');++ who.append(h('b', '', names.length > 2 ? `${names.slice(0, 2).join(', ')} and ${names.length - 2} more went live` : `${names.join(' and ')} went live`));++ const meta = h('small'); meta.append(siteIcon('live', 14), document.createTextNode(` Live · ${[ago(items[0].at), items.length > 1 ? ago(items[items.length - 1].at) : ''].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' – ')}`));++ who.append(meta);++ head.append(faces, who);++ const row = h('div', 'ovf-lives-row');++ items.forEach((x) => {++ const l = h('a', 'ovf-pill'); l.href = safe(x.url);++ const t = String(x.title || '').replace(/^.*?went live:\s*/i, '');++ l.append(h('b', '', String((x.actor && x.actor.name) || '').slice(0, 40)));++ if (t) l.append(h('span', '', t.slice(0, 60)));++ row.append(l);++ });++ box.append(head, row);++ return box;++ }function feed(el, o) {styles(); feedStyles();el.classList.add('ovh', 'ovf');@@@ -932,7 +963,12 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;done = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;}if (first && o.feedLive) o.feedLive();-- j.items.map(feedCard).filter(Boolean).forEach((c) => list.append(c));++ // Back-to-back go-lives that are over fold into one card of faces (a wall of "X went live" was the whole feed).++ const past = (x) => x && x.kind === 'live' && !x.live_now && safe(x.url);++ let run = [];++ const flush = () => { if (run.length >= 2) list.append(livesCard(run)); else run.forEach((x) => { const c = feedCard(x); if (c) list.append(c); }); run = []; };++ j.items.forEach((x) => { if (past(x) && !kind) { run.push(x); return; } flush(); const c = feedCard(x); if (c) list.append(c); });++ flush();if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }cursor = j.next_cursor || null;done = !cursor;@@@ -971,10 +1007,19 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;.ovf-head{display:flex;align-items:center;gap:8px;min-width:0}.ovf-face{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:13px}.ovf-face img{width:100%;height:100%;object-fit:cover}++.ovf-face.is-site{border-radius:10px;background:linear-gradient(150deg,hsl(var(--ovh-h,215) 72% 58% / .34),hsl(var(--ovh-h,215) 70% 38% / .18))}.ovf-face.is-site .ov-icon{color:hsl(var(--ovh-h,215) 90% 82%)}.ovf-who{min-width:0;display:grid}.ovf-who b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ovf-who small{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted,#93a0b4)}--.ovf-livechip{margin-left:auto;padding:2px 8px;border-radius:6px;background:var(--live,#ef4444);color:#fff;font-size:11px;font-weight:800;letter-spacing:.06em}++.ovf-livechip{margin-left:auto;padding:2px 8px;border-radius:6px;background:var(--live,#ef4444);color:#fff;font-size:11px;font-weight:800;letter-spacing:.06em;animation:ovf-onair 2.4s ease-in-out infinite}++.ovf-card.is-on{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--live,#ef4444) 45%,transparent);background:color-mix(in srgb,var(--live,#ef4444) 7%,transparent)}++@keyframes ovf-onair{50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--live,#ef4444) 25%,transparent)}}++.ovf-faces{display:flex;flex:none}.ovf-faces .ovf-face{width:30px;height:30px;box-shadow:0 0 0 2px var(--bg-secondary,#141a24)}.ovf-faces .ovf-face+.ovf-face{margin-left:-9px}++.ovf-lives-row{display:flex;flex-wrap:wrap;gap:6px}++.ovf-lives .ovf-who b{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.3}++.ovf-pill{box-sizing:border-box;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-height:36px;padding:6px 12px;border-radius:999px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent);color:inherit;text-decoration:none;font-size:13px;line-height:1.2}++.ovf-pill b{flex:none;font-weight:700}.ovf-pill span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted,#93a0b4)}++@media (hover:hover){.ovf-pill:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 11%,transparent)}}.ovf-media{position:relative;display:block;border-radius:12px;overflow:hidden;background:var(--bg-tertiary,#1f2633)}.ovf-media img,.ovf-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.ovf-len{position:absolute;right:8px;bottom:8px;padding:2px 7px;border-radius:6px;background:rgba(0,0,0,.72);color:#fff;font-size:12px;font-weight:700}@@@ -988,7 +1033,7 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;.ovf-skel{display:block;height:180px;border-radius:16px;background:linear-gradient(90deg,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 0,color-mix(in srgb,var(--text-primary,#e8ecf3) 9%,transparent) 50%,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 100%);background-size:200% 100%;animation:ovf-shimmer 1.4s ease-in-out infinite}.ovf-more{min-height:1px}.ovf-caught{text-align:center;padding:10px 0}@keyframes ovf-shimmer{to{background-position:-200% 0}}--@media (prefers-reduced-motion:reduce){.ovf-skel{animation:none}}++@media (prefers-reduced-motion:reduce){.ovf-skel{animation:none}.ovf-livechip{animation-duration:4s}}`;document.head.appendChild(st);}nnew file mode 100644@@@ -0,0 +1,64 @@++/* openvibe-shared ov-upload (designed 11-09): a picker that says what to do, each file a row with its own progress,++ the total above them for two or more. The network's theme variables; dark and light; Calm keeps the fill, not the sheen. */++.ov-upload { display: grid; gap: 12px; color: var(--text-primary, #e8ecf3); }++.ov-upload__input { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }++.ov-upload__picker { display: grid; place-items: center; align-content: center; gap: 6px; width: 100%; min-height: 148px; padding: 22px; border: 1.5px dashed color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, rgba(255,255,255,.14)));++ border-radius: 18px; background: color-mix(in srgb, var(--accent, #3b82f6) 5%, transparent); color: inherit; font: inherit; cursor: pointer; transition: background .18s, border-color .18s, transform .18s; }++.ov-upload__picker strong { font-size: 15.5px; }++.ov-upload__picker:hover, .ov-upload.is-dragging .ov-upload__picker { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 11%, transparent); }++.ov-upload.is-dragging .ov-upload__picker { transform: scale(1.01); border-style: solid; }++.ov-upload__picker:active { transform: scale(.99); }++.ov-upload__picker:focus-visible, .ov-upload__remove:focus-visible, .ov-progress__retry:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; }++.ov-upload__icon { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 4px; border-radius: 16px; color: var(--on-accent-strong, #fff);++ background: linear-gradient(150deg, var(--accent, #3b82f6), var(--accent-strong, #2563eb)); box-shadow: 0 8px 22px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }++.ov-upload__icon svg, .ov-upload__preview svg, .ov-upload__remove svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++.ov-upload__icon svg { width: 26px; height: 26px; }++.ov-upload.is-dragging .ov-upload__icon { animation: ov-upload-bob .9s ease-in-out infinite; }++.ov-upload__drop { font-size: 13px; color: var(--text-muted, #93a0b4); }++.ov-upload__total { padding: 0 2px; }++.ov-upload__total[hidden] { display: none; }++.ov-upload__rows { display: grid; gap: 8px; }++.ov-upload__row { display: grid; grid-template-columns: 52px minmax(0, 1fr) 40px; align-items: center; gap: 4px 12px; min-width: 0; padding: 10px;++ border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.08));++ animation: ov-upload-in .26s cubic-bezier(.2, .8, .2, 1) both; }++.ov-upload__preview { display: grid; place-items: center; width: 52px; height: 52px; overflow: hidden; border-radius: 10px; color: var(--accent-light, var(--accent, #60a5fa));++ background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }++.ov-upload__preview svg { width: 24px; height: 24px; }++.ov-upload__preview img, .ov-upload__preview video { width: 100%; height: 100%; object-fit: cover; }++.ov-upload__file { display: grid; gap: 1px; min-width: 0; }++.ov-upload__file strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 14.5px; }++.ov-upload__file small { font-size: 12.5px; color: var(--text-muted, #93a0b4); }++.ov-upload__remove { display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-secondary, #a8b3c4); cursor: pointer; }++.ov-upload__remove svg { width: 18px; height: 18px; }++@media (hover: hover) { .ov-upload__remove:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 9%, transparent); color: var(--text-primary, #e8ecf3); } }++.ov-upload__meter { grid-column: 1 / -1; }++/* Progress */++.ov-progress { display: grid; gap: 7px; }++.ov-progress__heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 14px; font-weight: 650; }++.ov-progress__percent { font-variant-numeric: tabular-nums; }++.ov-progress__track { height: 8px; overflow: hidden; border-radius: 99px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 10%, transparent); }++.ov-progress__fill { position: relative; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-strong, #2563eb), var(--accent, #3b82f6)); transition: width .65s cubic-bezier(.2, .7, .2, 1); }++.ov-progress[data-state="sending"] .ov-progress__fill::after { content: ''; position: absolute; inset: 0; background: linear-gradient(110deg, transparent 25%, rgba(255,255,255,.3) 45%, transparent 65%); animation: ov-upload-sheen 1.8s linear infinite; }++.ov-progress[data-state="processing"] .ov-progress__fill { width: 100% !important; animation: ov-upload-pulse 1.8s ease-in-out infinite; }++.ov-progress[data-state="done"] .ov-progress__fill { background: var(--success, #22c55e); }++.ov-progress[data-state="done"] .ov-progress__percent { color: var(--success, #22c55e); animation: ov-upload-check .38s ease-out both; }++.ov-progress__check { display: block; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }++.ov-progress__check path { stroke-dasharray: 24; stroke-dashoffset: 24; animation: ov-upload-draw .45s ease-out forwards; }++.ov-progress[data-state="failed"] .ov-progress__fill { background: var(--danger, #ef4444); }++.ov-progress[data-state="failed"] .ov-progress__label { color: var(--danger, #ef4444); }++.ov-progress__detail { min-height: 1.4em; margin: 0; font-size: 12.5px; color: var(--text-muted, #93a0b4); font-variant-numeric: tabular-nums; }++.ov-progress__retry { justify-self: start; min-height: 36px; padding: 0 14px; border: 0; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 9%, transparent); color: inherit; font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }++/* Inside a file's row: one line of state and percent, a thin bar, the numbers. */++.ov-progress--row { gap: 5px; margin-top: 6px; }++.ov-progress--row .ov-progress__heading { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }++.ov-progress--row .ov-progress__track { height: 5px; }++.ov-progress--row .ov-progress__detail { font-size: 12px; }++@keyframes ov-upload-sheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }++@keyframes ov-upload-pulse { 50% { opacity: .5; } }++@keyframes ov-upload-check { from { transform: scale(.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }++@keyframes ov-upload-draw { to { stroke-dashoffset: 0; } }++@keyframes ov-upload-in { from { opacity: 0; transform: translateY(6px); } }++@keyframes ov-upload-bob { 50% { transform: translateY(-3px); } }++@media (pointer: coarse), (max-width: 700px) { .ov-upload__picker { min-height: 120px; } .ov-upload__drop { display: none; } }++html[data-ov-motion=reduced] .ov-progress[data-state="sending"] .ov-progress__fill::after { animation: none; }++@media (prefers-reduced-motion: reduce) { .ov-progress__fill, .ov-progress[data-state="processing"] .ov-progress__fill, .ov-progress[data-state="done"] .ov-progress__percent, .ov-progress__check path, .ov-upload__row, .ov-upload.is-dragging .ov-upload__icon { transition: none; animation: none; } .ov-progress__check path { stroke-dashoffset: 0; } .ov-progress[data-state="sending"] .ov-progress__fill::after { display: none; } }nnew file mode 100644@@@ -0,0 +1,201 @@++/* Shared OpenVibe upload UI. Sites supply their upload(file, {onProgress}) transport. */++(function (root, factory) {++ var api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ if (root && root.document) root.OVUpload = api;++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {++ 'use strict';++ function size(bytes) {++ if (!Number.isFinite(bytes)) return '0 B';++ if (bytes < 1024) return Math.round(bytes) + ' B';++ var unit = Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024)));++ return (bytes / Math.pow(1024, unit)).toFixed(1) + ' ' + ['B', 'KB', 'MB', 'GB'][unit];++ }++ function timeLeft(seconds) {++ if (!Number.isFinite(seconds)) return 'Estimating time left';++ if (seconds < 5) return 'Almost done';++ if (seconds < 60) return 'About ' + Math.ceil(seconds / 5) * 5 + ' sec left';++ if (seconds < 3600) return 'About ' + Math.ceil(seconds / 60) + ' min left';++ return 'About ' + Math.ceil(seconds / 3600) + ' hr left';++ }++ // Seconds are smoothed by measured bytes per second, not the last network burst.++ function rate(previous, deltaBytes, deltaSeconds, alpha) {++ if (deltaBytes <= 0 || deltaSeconds <= 0) return previous || 0;++ var sample = deltaBytes / deltaSeconds;++ return previous > 0 ? (alpha || 0.25) * sample + (1 - (alpha || 0.25)) * previous : sample;++ }++ // Line icons (constants, never data): upload, a file, close.++ var ICON_UP = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V4m0 0-4.5 4.5M12 4l4.5 4.5"/><path d="M4 14v3a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-3"/></svg>';++ var ICON_FILE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>';++ var ICON_X = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';++ function node(tag, className, text) {++ var el = document.createElement(tag);++ if (className) el.className = className;++ if (text != null) el.textContent = text;++ return el;++ }++ function progress(el, options) {++ options = options || {};++ el.classList.add('ov-progress');++ if (options.compact) el.classList.add('ov-progress--row');++ var label = node('span', 'ov-progress__label', 'Waiting');++ var percent = node('strong', 'ov-progress__percent', '0%');++ var heading = node('div', 'ov-progress__heading');++ heading.append(label, percent);++ var track = node('div', 'ov-progress__track');++ track.setAttribute('role', 'progressbar');++ track.setAttribute('aria-valuemin', '0');++ track.setAttribute('aria-valuemax', '100');++ var fill = node('div', 'ov-progress__fill');++ track.append(fill);++ var detail = node('p', 'ov-progress__detail', 'Ready when you are');++ var retry = node('button', 'ov-progress__retry', 'Retry');++ retry.type = 'button'; retry.hidden = true;++ retry.addEventListener('click', function () { if (options.onRetry) options.onRetry(); });++ el.replaceChildren(heading, track, detail, retry);++ var current = { state: 'waiting', done: 0, total: 0, rate: 0, at: 0, lastBytes: 0, message: '' };++ function render() {++ var state = current.state;++ var pct = current.total ? Math.min(100, Math.floor(current.done / current.total * 100)) : 0;++ el.dataset.state = state;++ label.textContent = ({ waiting: 'Waiting', sending: 'Uploading', paused: 'Paused: no connection', processing: 'Processing: making it playable', done: 'Uploaded', failed: 'Upload failed' })[state] || state;++ if (state === 'done') percent.innerHTML = '<svg class="ov-progress__check" viewBox="0 0 24 24" role="img" aria-label="Done"><path d="M4 12l5 5L20 6"/></svg>';++ else percent.textContent = pct + '%';++ fill.style.width = (state === 'done' ? 100 : pct) + '%';++ track.setAttribute('aria-valuenow', String(pct));++ track.setAttribute('aria-valuetext', label.textContent + ', ' + pct + '%');++ var meta = current.total ? size(current.done) + ' of ' + size(current.total) : '';++ if (state === 'sending') detail.textContent = [meta, current.rate ? size(current.rate) + '/s' : '', timeLeft((current.total - current.done) / current.rate)].filter(Boolean).join(' · ');++ else if (state === 'paused') detail.textContent = 'Your upload resumes when the connection returns.';++ else if (state === 'processing') detail.textContent = current.message || 'Finishing your file';++ else if (state === 'failed') detail.textContent = current.message || 'The upload did not go through.';++ else if (state === 'done') detail.textContent = current.message || 'Your file is ready.';++ else detail.textContent = meta || 'Ready when you are';++ retry.hidden = state !== 'failed';++ }++ function update(next) {++ next = next || {};++ var now = Date.now();++ var done = Math.max(0, Number(next.done == null ? current.done : next.done) || 0);++ var total = Math.max(0, Number(next.total == null ? current.total : next.total) || 0);++ if (done > current.lastBytes && current.at) current.rate = rate(current.rate, done - current.lastBytes, (now - current.at) / 1000);++ if (done !== current.lastBytes) { current.lastBytes = done; current.at = now; }++ current.done = done; current.total = total;++ if (next.state) current.state = next.state;++ if (next.message !== undefined) current.message = next.message;++ render();++ }++ var timer = typeof window === 'undefined' ? null : window.setInterval(function () {++ if (current.state !== 'sending') return;++ if (Date.now() - current.at > 5000) current.rate *= 0.65;++ render();++ }, 1000);++ render();++ return { update: update, destroy: function () { if (timer) clearInterval(timer); } };++ }++ function picker(root, options) {++ options = options || {};++ var input = node('input', 'ov-upload__input');++ input.type = 'file'; input.accept = options.accept || ''; input.multiple = !!options.multiple;++ input.setAttribute('aria-label', options.label || 'Choose a file');++ var card = node('button', 'ov-upload__picker');++ card.type = 'button';++ card.innerHTML = '<span class="ov-upload__icon" aria-hidden="true">' + ICON_UP + '</span><strong></strong><span class="ov-upload__drop">or drop ' + (options.multiple ? 'them' : 'it') + ' here</span>';++ card.querySelector('strong').textContent = options.label || 'Choose a file';++ card.addEventListener('click', function () { input.click(); });++ var rows = node('div', 'ov-upload__rows');++ root.classList.add('ov-upload'); root.append(input, card, rows);++ function add(files) {++ Array.prototype.forEach.call(options.multiple ? files : Array.prototype.slice.call(files, 0, 1), function (file) {++ var row = node('div', 'ov-upload__row');++ var preview = node('div', 'ov-upload__preview'); preview.innerHTML = ICON_FILE;++ if (file.type.indexOf('image/') === 0 || file.type.indexOf('video/') === 0) {++ var url = URL.createObjectURL(file);++ var media = node(file.type.indexOf('image/') === 0 ? 'img' : 'video');++ media.src = url; media.muted = true; media.preload = 'metadata'; media.setAttribute('aria-hidden', 'true');++ media.addEventListener('loadeddata', function () { if (media.tagName === 'VIDEO') media.currentTime = Math.min(0.1, media.duration || 0); }, { once: true });++ row.addEventListener('remove', function () { URL.revokeObjectURL(url); });++ preview.replaceChildren(media);++ }++ var text = node('div', 'ov-upload__file');++ text.append(node('strong', '', file.name), node('small', '', size(file.size)));++ var remove = node('button', 'ov-upload__remove'); remove.type = 'button'; remove.innerHTML = ICON_X;++ remove.setAttribute('aria-label', 'Remove ' + file.name); remove.title = 'Remove';++ remove.addEventListener('click', function () { row.dispatchEvent(new Event('remove')); row.remove(); if (options.onRemove) options.onRemove(file); });++ row.append(preview, text, remove); rows.append(row);++ if (options.onFile) options.onFile(file, row);++ });++ }++ input.addEventListener('change', function () { add(input.files); input.value = ''; });++ root.addEventListener('dragover', function (e) { e.preventDefault(); root.classList.add('is-dragging'); });++ root.addEventListener('dragleave', function () { root.classList.remove('is-dragging'); });++ root.addEventListener('drop', function (e) { e.preventDefault(); root.classList.remove('is-dragging'); if (e.dataTransfer && e.dataTransfer.files) add(e.dataTransfer.files); });++ return { input: input, add: add, rows: rows };++ }++ function queue(root, options) {++ options = options || {};++ if (typeof options.upload !== 'function') throw new TypeError('OVUpload.queue: upload(file, {onProgress, signal}) is required');++ var pickRoot = node('div');++ var totalRoot = node('div', 'ov-upload__total');++ totalRoot.hidden = true;++ root.append(pickRoot);++ var items = [], active = 0;++ var total = progress(totalRoot);++ function refresh() {++ var bytes = items.reduce(function (n, item) { return n + item.file.size; }, 0);++ var done = items.reduce(function (n, item) { return n + item.done; }, 0);++ var state = items.some(function (item) { return item.state === 'sending'; }) ? 'sending'++ : items.some(function (item) { return item.state === 'failed'; }) ? 'failed'++ : items.length && items.every(function (item) { return item.state === 'done'; }) ? 'done' : 'waiting';++ total.update({ done: done, total: bytes, state: state, message: state === 'failed' ? 'A file needs a retry.' : '' });++ totalRoot.hidden = items.length < 2;++ }++ function pump() {++ while (active < (options.concurrency || 3)) {++ var item = items.find(function (x) { return x.state === 'waiting'; });++ if (!item) break;++ item.state = 'sending'; active++;++ item.meter.update({ state: 'sending', done: item.done, total: item.file.size }); refresh();++ (function (entry) {++ entry.controller = new AbortController();++ Promise.resolve().then(function () { return options.upload(entry.file, {++ signal: entry.controller.signal,++ onProgress: function (value) {++ entry.done = Math.max(0, Math.min(entry.file.size, Number(value.uploadedBytes == null ? value.done : value.uploadedBytes) || 0));++ entry.meter.update({ state: 'sending', done: entry.done, total: entry.file.size }); refresh();++ }++ }); }).then(function (result) {++ if (entry.removed) return;++ entry.done = entry.file.size; entry.state = 'done';++ entry.meter.update({ state: 'done', done: entry.done, total: entry.file.size });++ if (options.onDone) options.onDone(entry.file, result);++ }).catch(function (error) {++ if (entry.removed) return;++ entry.state = 'failed';++ entry.meter.update({ state: 'failed', message: error && error.message || 'The upload did not go through.' });++ }).finally(function () { active--; refresh(); pump(); });++ })(item);++ }++ }++ var pick = picker(pickRoot, {++ label: options.label, accept: options.accept, multiple: options.multiple !== false,++ onFile: function (file, row) {++ var meterRoot = node('div', 'ov-upload__meter'); row.append(meterRoot); row.classList.add('has-meter');++ var item = { file: file, row: row, meterRoot: meterRoot, done: 0, state: 'waiting' };++ item.meter = progress(meterRoot, { compact: true, onRetry: function () { item.state = 'waiting'; pump(); } });++ items.push(item); refresh(); pump();++ },++ onRemove: function (file) {++ var item = items.find(function (x) { return x.file === file; });++ if (!item) return;++ item.removed = true;++ if (item.controller) item.controller.abort();++ item.meter.destroy(); item.meterRoot.remove();++ items.splice(items.indexOf(item), 1); refresh();++ }++ });++ pick.rows.before(totalRoot); // the total sits between the picker and the files++ return { picker: pick, total: total, items: items };++ }++ return { progress: progress, picker: picker, queue: queue, rate: rate, timeLeft: timeLeft, size: size };++});@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.163.0",++ "version": "4.164.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.163.0",++ "version": "4.164.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.163.0",++ "version": "4.164.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -67,6 +67,7 @@"./ov-forms": "./ov-forms.js","./ov-qr": "./ov-qr.js","./ov-share": "./ov-share.js",++ "./ov-upload": "./ov-upload.js","./ov-sync": "./ov-sync.js","./ov-locker": "./ov-locker.js","./ov-locker-core": "./ov-locker-core.js",@@@ -121,6 +122,7 @@"./motion": "./motion.js","./agent-layer": "./agent-layer.js","./thread": "./thread.js",++ "./messages": "./messages.js","./ov-thread": "./ov-thread.js"},"files": [@@@ -118,12 +118,12 @@ function visualHTML(orbit) {+ `${coreHref ? `<a class="sc-orb-core" href="${esc(coreHref)}">${core}</a>` : `<span class="sc-orb-core">${core}</span>`}</div>`;}--function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html, orbit, visual } = {}) {++function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html, orbit, visual, pageHead: ph = null } = {}) {// Without an aside of its own the hero gets a visual (4.26.0): an orbit of the site's places when it names them, else// the aura. visual: false keeps it plain.const vis = !aside && html == null && visual !== false;const hasOrbit = vis && orbit && (has(orbit.inner) || has(orbit.outer));-- return `<section class="sc-hero${aside || html || vis ? ' sc-split' : ''}${vis ? ` sc-has-vis${hasOrbit ? ' sc-has-orbit' : ''}` : ''}"><div class="sc-hero-main"><h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : vis ? `<div class="sc-hero-aside sc-hero-vis">${visualHTML(hasOrbit ? orbit : null)}</div>` : ''}</section>`;++ return `<section class="sc-hero${aside || html || vis ? ' sc-split' : ''}${vis ? ` sc-has-vis${hasOrbit ? ' sc-has-orbit' : ''}` : ''}"><div class="sc-hero-main">${ph ? require('./kit').pageHead({ ...ph, title: [title, accent].filter(Boolean).join(' '), lede }) : `<h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}`}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : vis ? `<div class="sc-hero-aside sc-hero-vis">${visualHTML(hasOrbit ? orbit : null)}</div>` : ''}</section>`;}/** A grid of what the product does; each card may link to where it starts. */@@@ -424,8 +424,8 @@ function room({ room: key, hero: h = {}, proof, rails, scenes, explore, dev, faq+ `<b${typeof p.value === 'number' ? ` data-ov-ticker="${p.value}"` : ''}>${esc(fmtNum(p.value))}</b> <span>${esc(p.label)}</span>`+ `${p.live ? '<em class="sc-room-live">live</em>' : p.asOf ? `<em>${esc(p.asOf)}</em>` : ''}</li>`).join('')}</ul>` : '';const heroHTML = h.title ? `<section class="sc-room-hero" aria-labelledby="sc-room-h">`-- + `<div class="sc-room-main"><h1 id="sc-room-h">${esc(h.title)}</h1>${h.lede ? `<p class="sc-room-lede">${esc(h.lede)}</p>` : ''}${box}${start}`-- + `${chips.length ? `<nav class="sc-room-chips" aria-label="Try">${chips.map((c) => `<a href="${esc(safeHref(c.href))}">${esc(c.label)}</a>`).join('')}</nav>` : ''}${proofHTML}</div>`++ + `<div class="sc-room-main">${h.pageHead ? kitLib.pageHead({ ...h.pageHead, title: h.title, lede: h.lede }).replace('<h1>', '<h1 id="sc-room-h">') : `<h1 id="sc-room-h">${esc(h.title)}</h1>${h.lede ? `<p class="sc-room-lede">${esc(h.lede)}</p>` : ''}`}${box}${start}`++ + `${chips.length ? `<nav class="sc-room-chips" data-ovk-scroll aria-label="Try">${chips.map((c) => `<a href="${esc(safeHref(c.href))}">${esc(c.label)}</a>`).join('')}</nav>` : ''}${proofHTML}</div>`+ `<div class="sc-room-pic">${h.motif != null ? h.motif : roomMotif(r)}</div></section>` : '';const railsHTML = (rails || []).map((x) => kitLib.rail(x)).join('');const sceneList = (scenes || []).filter((x) => x && x.title && x.html != null);@@@ -316,3 +316,13 @@ console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, ticonst h = kit.rail({ id: 'recent', title: 'Recent', icon: 'clock', more: { href: '/all', label: 'See all' }, items: ['<p>One</p>'] });assert.ok(h.includes('ovk-heading-icon') && h.includes('ovk-btn--quiet') && h.includes('id="recent-h"'));}++++// A page head keeps its description available without JS and refuses unsafe parent/action URLs.++{++ const h = kit.pageHead({ title: '<Record>', icon: 'video', lede: 'One line & all the detail.', back: { label: 'Home', href: 'javascript:bad' }, actions: [{ icon: 'plus', label: 'Create', href: '/new' }] });++ assert.ok(h.includes('<h1><Record></h1>'));++ assert.ok(h.includes('href="#"') && !h.includes('javascript:'));++ assert.ok(!h.includes('<details') && (h.match(/class="ovk-page-lede"/g) || []).length === 1, 'the lede once, whole (no About-this copy)');++ assert.ok(h.includes('aria-label="Create"') && h.includes('One line & all the detail.'));++ assert.ok(!kit.pageHead({ title: 'Home' }).includes('<details'));++}@@@ -41,7 +41,7 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="utif (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);if (s.startsWith('https://openvibe.community/api/v1/feed')) { window.__feedCalls = (window.__feedCalls || []).concat([s]); const u = new URL(s); const kind = u.searchParams.get('kind') || ''; const page = u.searchParams.get('cursor') ? 2 : 1;-- const mk = (i, k) => ({ id: k + i + '-' + page, kind: k, url: 'https://openvibe.video/c/' + i, title: (k === 'post' ? '<b>bold</b> ' : '') + k + ' ' + i + ' page ' + page, text: 'words', at: new Date(Date.now() - i * 60000).toISOString(), site: k === 'pic' ? 'pics' : 'video', site_name: 'Video', actor: { name: 'ada' }, media: k === 'post' || k === 'update' ? {} : { thumb: 'https://openvibe.media/t/x.webp', ratio: 1.7778, length: '0:30' }, counts: { views: 1200, likes: 3 } });++ const mk = (i, k) => ({ id: k + i + '-' + page, kind: k, live_now: k === 'live', url: 'https://openvibe.video/c/' + i, title: (k === 'post' ? '<b>bold</b> ' : '') + k + ' ' + i + ' page ' + page, text: 'words', at: new Date(Date.now() - i * 60000).toISOString(), site: k === 'pic' ? 'pics' : 'video', site_name: 'Video', actor: { name: 'ada' }, media: k === 'post' || k === 'update' ? {} : { thumb: 'https://openvibe.media/t/x.webp', ratio: 1.7778, length: '0:30' }, counts: { views: 1200, likes: 3 } });const kinds = kind ? [kind] : ['live', 'clip', 'pic', 'post', 'update'];return J({ items: Array.from({ length: 12 }, (_, i) => mk(i, kinds[i % kinds.length])), next_cursor: page === 1 ? 'c2' : null }); }if (s.startsWith('https://openvibe.community/api/v1/pulse')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));@@@ -82,12 +82,12 @@ const server = http.createServer((req, res) => {assert.ok(await until(p, `document.querySelectorAll('.ovf-list > a').length >= 12`), 'the feed painted');const first = await p.evaluate(`({ chips: [...document.querySelectorAll('.ovf-chip')].map((b) => b.textContent), selected: (document.querySelector('.ovf-chip[aria-selected=true]') || {}).textContent,kinds: [...new Set([...document.querySelectorAll('.ovf-list > a')].map((a) => a.className))], live: !!document.querySelector('.ovf-livechip'), media: document.querySelectorAll('.ovf-media img').length,-- bolds: document.querySelectorAll('.ovf-list b b').length, posts: [...document.querySelectorAll('.ovf-post .ovf-title')].map((b) => b.textContent)[0],++ bolds: document.querySelectorAll('.ovf-list b b').length, posts: [...document.querySelectorAll('.ovf-k-post .ovf-title')].map((b) => b.textContent)[0],sections: [...document.querySelectorAll('.ovs-group')].map((g) => g.textContent.trim()).filter(Boolean), scroll: document.documentElement.scrollWidth })`);assert.deepStrictEqual(first.chips, ['For you', 'Live', 'Clips', 'Videos', 'Pics', 'Posts', 'Memes', 'Games', 'Updates']);assert.strictEqual(first.selected, 'For you');-- assert.ok(first.kinds.includes('ovf-card ovf-live') && first.kinds.includes('ovf-update') && first.kinds.includes('ovf-card ovf-pic'), `cards by kind (${first.kinds})`);-- assert.ok(first.live, 'a live card says LIVE');++ assert.ok(first.kinds.some((c) => /\bovf-k-live\b/.test(c)) && first.kinds.includes('ovf-update') && first.kinds.some((c) => /\bovf-k-pic\b/.test(c)), `cards by kind (${first.kinds})`);++ assert.ok(first.live, 'a channel live now says LIVE (live_now)');assert.ok(first.media >= 6, 'media cards show their picture');assert.strictEqual(first.bolds, 0, 'what people wrote is text');assert.ok(/^<b>bold<\/b> post/.test(first.posts), 'the markup shows as words');@@@ -98,7 +98,7 @@ const server = http.createServer((req, res) => {assert.ok(await until(p, `!!document.querySelector('.ovf-caught')`), 'the end says so');await p.evaluate(`[...document.querySelectorAll('.ovf-chip')].find((b) => b.textContent === 'Clips').click(), true`);assert.ok(await until(p, `(window.__feedCalls || []).some((u) => u.includes('kind=clip'))`), 'the chip asks for its kind');-- assert.ok(await until(p, `[...document.querySelectorAll('.ovf-list > a')].every((a) => a.className === 'ovf-card ovf-clip') && document.querySelectorAll('.ovf-list > a').length === 12`), 'only clips, from the first page');++ assert.ok(await until(p, `[...document.querySelectorAll('.ovf-list > a')].every((a) => a.className === 'ovf-card ovf-k-clip') && document.querySelectorAll('.ovf-list > a').length === 12`), 'only clips, from the first page');console.log('menu-feed-v2-chrome: chips, cards by kind, text only, the next page, caught up, a chip by kind, the stream alone ok');} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++const assert = require('assert');++const { parseHTML } = require('linkedom');++const { window } = parseHTML('<!doctype html><html><body><ol id="messages"></ol></body></html>');++global.document = window.document;++const { render } = require('../messages');++const root = document.getElementById('messages');++render(root, { view: 'bubbles', me: 1, now: new Date('2026-11-09T12:00:00Z'), messages: [++ { id: 1, sender_id: 2, username: 'bob', display_name: 'Bob', avatar_url: 'https://openvibe.media/fixture/bob.png', created_at: '2026-11-09 11:58:00', message: 'Hi https://openvibe.today/' },++ { id: 2, sender_id: 2, username: 'bob', display_name: 'Bob', created_at: '2026-11-09 11:59:00', message: '👋' },++ { id: 3, sender_id: 1, username: 'alice', created_at: '2026-11-09 11:59:30', message: 'Hello' },++] });++assert.strictEqual(root.querySelectorAll('.ovm-day').length, 1);++assert.strictEqual(root.querySelectorAll('.ovm-group').length, 3);++assert.strictEqual(root.querySelector('.ovm-avatar').getAttribute('src'), 'https://openvibe.media/fixture/bob.png');++assert.strictEqual(root.querySelector('.ovm-link').getAttribute('href'), 'https://openvibe.today/');++assert.strictEqual(root.querySelector('.ovm-group[data-mine="true"]').dataset.tail, 'true');++render(root, { view: 'room', messages: [{ id: 4, username: 'bob', message: 'room line' }] });++assert.strictEqual(root.querySelector('.ovm-room-text').textContent, 'room line');++console.log('messages: room and bubbles, groups, dates, links and profile picture ok');nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const upload = require('../ov-upload');++const files = require('../files');++++let steady = 0;++for (let i = 0; i < 5; i++) steady = upload.rate(steady, 2 * 1024 * 1024, 1);++assert.strictEqual(steady, 2 * 1024 * 1024);++assert.strictEqual(upload.timeLeft(120), 'About 2 min left');++assert.strictEqual(upload.timeLeft(4), 'Almost done');++++const burst = upload.rate(steady, 20 * 1024 * 1024, 1);++assert.ok(burst > steady && burst < 20 * 1024 * 1024, 'one burst is smoothed');++assert.strictEqual(upload.rate(burst, 0, 1), burst, 'a stalled sample does not become a false zero rate');++assert.strictEqual(upload.timeLeft(Infinity), 'Estimating time left');++++assert.ok(files.isBrowserFile('ov-upload.js') && files.isBrowserFile('ov-upload.css'));++const css = fs.readFileSync(path.join(__dirname, '../ov-upload.css'), 'utf8');++assert.match(css, /prefers-reduced-motion: reduce/);++assert.match(css, /\.ov-upload__input\s*\{[^}]*clip:/s, 'native input is visually hidden');@@@ -203,3 +203,8 @@ console.log('showcase: all checks passed');assert.ok(/border-top:1px solid/.test(sc.CSS) && /\.sc-hero\+\.sc-sec/.test(sc.CSS), 'sections stand apart, the first after the hero without a rule');assert.ok(/api\/frame\/pulse/.test(sc.NETWORK_JS) && /textContent/.test(sc.NETWORK_JS) && !/innerHTML/.test(sc.NETWORK_JS), 'the live strip reads the Frame and writes text only');}++// A product can use the shared page identity while retaining the hero's visual and calls to action.++{++ const html = require('../showcase').hero({ title: 'Record a video', lede: 'Review it before sharing.', pageHead: { icon: 'video', back: { href: '/', label: 'Home' } }, visual: false });++ require('assert').ok(html.includes('ovk-page-head') && html.split('Review it before sharing.').length === 2);++}nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++const assert = require('assert');++const card = require('../user-card');++const menu = card.OpenVibeUserMenu;++assert.ok(menu && typeof menu.register === 'function');++const release = menu.register({ id: 'chat-message', label: 'Message', icon: '✉', onSelect() {} });++assert.strictEqual(typeof release, 'function');++assert.throws(() => menu.register({ id: '../unsafe', label: '<img>', onSelect() {} }), TypeError);++release();++console.log('user menu: site actions register and validate safely');@@@ -12,6 +12,8 @@let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };let _cardEl = null;let _cache = new Map();++ const _menuItems = new Map();++ const escapeHtml = value => String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);function injectStyles() {if (document.getElementById('openvibe-usercard-styles')) return;@@@ -140,6 +142,9 @@actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;}++ for (const item of _menuItems.values()) {++ actions += `<button data-act="site" data-site-id="${escapeHtml(item.id)}"><span class="icon">${escapeHtml(item.icon || '↗')}</span> ${escapeHtml(item.label)}</button>`;++ }const card = document.createElement('div');card.className = 'openvibe-usercard';@@@ -180,8 +185,11 @@card.querySelectorAll('[data-act]').forEach(btn => {btn.addEventListener('click', () => {const act = btn.dataset.act;++ const siteItem = act === 'site' ? _menuItems.get(btn.dataset.siteId) : null;closeCard();-- if (act === 'profile') {++ if (siteItem) {++ try { siteItem.onSelect(user); } catch (err) { console.error('[OpenVibeUserMenu] item failed:', err); }++ } else if (act === 'profile') {const url = isSelf ? `${_config.apiBase}/my`: `${_config.apiBase}/user/${user.username}`;window.open(url, '_blank');@@@ -248,7 +256,26 @@},};++ const OpenVibeUserMenu = {++ /** Add a host site's safe action to the shared user card. Returns an unregister function. */++ register({ id, label, icon, onSelect } = {}) {++ const key = String(id || '');++ if (!/^[a-zA-Z0-9_-]{1,48}$/.test(key) || typeof label !== 'string' || !label.trim() || typeof onSelect !== 'function') {++ throw new TypeError('register requires an id, label and onSelect function');++ }++ const item = { id: key, label: label.trim().slice(0, 80), icon: typeof icon === 'string' ? icon.slice(0, 8) : '', onSelect };++ _menuItems.set(key, item);++ return () => { if (_menuItems.get(key) === item) _menuItems.delete(key); };++ },++ unregister(id) { return _menuItems.delete(String(id || '')); },++ attach: (...args) => OpenVibeUserCard.attach(...args),++ show: (...args) => OpenVibeUserCard.show(...args),++ close: () => OpenVibeUserCard.close(),++ };++if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;-- else root.OpenVibeUserCard = OpenVibeUserCard;++ if (typeof module !== 'undefined' && module.exports) module.exports.OpenVibeUserMenu = OpenVibeUserMenu;++ root.OpenVibeUserCard = OpenVibeUserCard;++ root.OpenVibeUserMenu = OpenVibeUserMenu;})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
@@ -1,5 +1,24 @@
@@ -1,5 +1,24 @@
# Changelog
# Changelog
+## 4.164.0 — 2026-11-09
+
+- **Chat's two views** (`openvibe-shared/messages`, `OVMessages.render(el, { messages, view: 'room' | 'bubbles', me })`;
+ the owner, 11-09: "texting bubbles like Android/iOS Messages, and the live chat room… switchable"). Bubbles: runs join
+ on the inner corners, the avatar beside the first with its column kept through the run, a coloured initial when there
+ is no picture, the time under the last, read ticks, sticky day chips. Room: one line per message wrapping under the
+ name on a phone, each person's name in a steady colour (tuned for dark and light). Only the newest message animates.
+ `OpenVibeUserMenu.register()` lets a site add actions to the shared user card.
+- **Page head** (`kit.pageHead({ back, icon, title, lede, actions })`; ask 64: "less websitey… an icon system"): a small
+ named back chip (44 px to the finger), the page's icon tile and name, 44 px actions, and the lede once and whole on
+ every screen. Product heroes can opt in (`showcase.hero({ pageHead })`).
+- **Upload kit** (`ov-upload.js`/`.css`: `OVUpload.picker`, `.queue`, `.progress`): drag and drop or choose, each file a
+ row with its picture or a file glyph, an × and its own progress (speed and time left smoothed); the total above for two
+ files or more; done draws a tick; Calm drops the sheen. Sites bring the transport (`upload(file, { onProgress, signal })`).
+- **The Feed, fixed for real data** (Community's /api/v1/feed is live): LIVE shows only while a channel is live now
+ (`live_now`, with a breathing chip and a red edge); back-to-back go-lives that are over fold into one card of faces and
+ names; a trending picture shows its site's icon and "Trending" (not a "?"); video cards no longer leave the flow (their
+ class collided with the preview video's, `.ovf-video`; cards are `.ovf-k-<kind>` now).
+
## 4.163.0 — 2026-11-09
## 4.163.0 — 2026-11-09
- **You, redone** (the owner, 11-09: "the U tab still sucks, and you didn't do all the improvements"). You on top: your
- **You, redone** (the owner, 11-09: "the U tab still sucks, and you didn't do all the improvements"). You on top: your
@@ -2,7 +2,7 @@
@@ -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.
> 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.163.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.164.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.163.0",
+ "version": "4.164.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -727,3 +727,7 @@ set the `--bg-*`, `--text-*` and `--accent` variables yourself, or keep the fall
@@ -727,3 +727,7 @@ set the `--bg-*`, `--text-*` and `--accent` variables yourself, or keep the fall
`node scripts/kit-gallery.js > gallery.html` writes one page with every piece and the app bar on.
`node scripts/kit-gallery.js > gallery.html` writes one page with every piece and the app bar on.
`node scripts/kit-gallery.js --shots <dir>` serves the package folder and screenshots it at 390, 768 and 1280 px. It
`node scripts/kit-gallery.js --shots <dir>` serves the package folder and screenshots it at 390, 768 and 1280 px. It
prints any sideways scroll, so the gallery doubles as the visual test.
prints any sideways scroll, so the gallery doubles as the visual test.
+
+### Page identity
+
+`kit.pageHead({ back: { label: 'Home', href: '/' }, icon: 'video', title: 'Record a video', lede: 'Record, review and share your video.', actions: [{ href: '/videos', icon: 'video', label: 'Your videos' }] })` renders the named back chip, page icon tile, H1 and accessible icon actions. On phones the lede is one line with a native **About this** disclosure; all its words remain server-rendered and work without JavaScript. Keep breadcrumb structured data in the site's BreadcrumbList JSON-LD. `head()` remains available for sticky application toolbars.
@@ -45,6 +45,8 @@ const BROWSER = Object.freeze([
@@ -45,6 +45,8 @@ const BROWSER = Object.freeze([
'ov-locker-core.js', // openvibe-sdk/locker for the browser, generated (scripts/build-locker-core.js): WebCrypto only, the SDK's own vectors
'ov-locker-core.js', // openvibe-sdk/locker for the browser, generated (scripts/build-locker-core.js): WebCrypto only, the SDK's own vectors
'ov-locker.js', // Locker in the browser (plan Q2): a locked item's card, making and unlocking your locker; crypto in ov-locker-core.js
'ov-locker.js', // Locker in the browser (plan Q2): a locked item's card, making and unlocking your locker; crypto in ov-locker-core.js
'ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button
'ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button
+ 'ov-upload.js', // shared file picker, upload queue and ov-progress
+ 'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-home.js', // the menu's home screen and wall (search.js loads it for You and Feed)
'ov-home.js', // the menu's home screen and wall (search.js loads it for You and Feed)
@@ -89,6 +91,8 @@ const STYLES = Object.freeze([
@@ -89,6 +91,8 @@ const STYLES = Object.freeze([
'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
'ov-locker.css', // ov-locker.js's look: the locked card, the locker sheets, the recovery kit (docs/locker.md)
'ov-locker.css', // ov-locker.js's look: the locked card, the locker sheets, the recovery kit (docs/locker.md)
'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)
'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)
+ 'ov-upload.css', // picker, queue and ov-progress states
+ 'messages.css', // the room and bubble views' look
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)
'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)
'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)
'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)
@@ -452,3 +452,26 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
@@ -452,3 +452,26 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
.ovk-sidebar { overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
.ovk-sidebar { overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
.ovk-sidebar-scrim { touch-action: none; overscroll-behavior: contain; }
.ovk-sidebar-scrim { touch-action: none; overscroll-behavior: contain; }
}
}
+/* A page's head (kit.pageHead; designed 11-09, the owner: "less websitey… modern… an icon system"): a small named way
+ back, the page's icon tile and name with its actions, and one line about it, whole (one copy: readers and search see
+ the same words; nothing behind an "About this"). */
+.ovk-page-head { margin: 0 0 20px; min-width: 0; }
+.ovk-page-back { position: relative; display: inline-flex; align-items: center; gap: 2px; height: 32px; margin: 0 0 12px; padding: 0 12px 0 8px;
+ border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); color: var(--text-secondary, #a8b3c4);
+ font-size: 13.5px; font-weight: 650; text-decoration: none; transition: background .15s, color .15s; }
+.ovk-page-back::after { content: ""; position: absolute; inset: -6px -4px; } /* 44 px under the finger */
+.ovk-page-back .ovk-i { width: 18px; height: 18px; }
+@media (hover: hover) { .ovk-page-back:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 11%, transparent); color: var(--text-primary, #e8ecf3); } }
+.ovk-page-head-row { display: flex; align-items: center; gap: 12px; min-width: 0; }
+.ovk-page-head-row > .ovk-heading-icon { flex-basis: 44px; width: 44px; height: 44px; border-radius: 13px;
+ background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent), color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent));
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }
+.ovk-page-head-row > .ovk-heading-icon > .ovk-i { width: 24px; height: 24px; }
+.ovk-page-head-row h1 { flex: 1 1 auto; min-width: 0; margin: 0; font-size: clamp(1.45rem, 2.4vw + .7rem, 2.2rem); line-height: 1.15; letter-spacing: -.015em; overflow-wrap: anywhere; }
+.ovk-page-actions { display: flex; align-items: center; gap: 6px; flex: none; margin-left: auto; }
+.ovk-page-actions .ovk-icon-btn { width: 44px; height: 44px; border-radius: 50%; }
+.ovk-page-lede { max-width: 68ch; margin: 10px 0 0; color: var(--text-secondary, #a8b3c4); font-size: 15.5px; line-height: 1.5; }
+.ovk-page-back:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; }
+@media (max-width: 760px) { .ovk-page-head { margin-bottom: 16px; } .ovk-page-lede { margin-top: 8px; font-size: 14.5px; } }
+/* A phone hero's 1fr track shrinks around the head. */
+.sc-hero-main:has(>.ovk-page-head),.sc-room-main:has(>.ovk-page-head){min-width:0}
@@ -211,6 +211,17 @@
@@ -211,6 +211,17 @@
+ (actions && actions.length ? `<div class="ovk-head-tools">${actions.join('')}</div>` : '')
+ (actions && actions.length ? `<div class="ovk-head-tools">${actions.join('')}</div>` : '')
+ `</div>${tabs || ''}</header>`;
+ `</div>${tabs || ''}</header>`;
}
}
+ /** A page identity, with a named parent and a native, script-free mobile description disclosure.
+ * actions accepts iconButton options or trusted control markup, like head(). */
+ function pageHead({ back = null, icon: ic = 'home', title = '', lede = '', actions = [] } = {}) {
+ const tools = (actions || []).map(a => typeof a === 'string' ? a : iconButton(a)).join('');
+ return `<header class="ovk-page-head">`
+ + (back ? `<a class="ovk-page-back" href="${esc(url(back.href))}">${icon('back')}<span>${esc(back.label || 'Back')}</span></a>` : '')
+ + `<div class="ovk-page-head-row"><span class="ovk-heading-icon">${icon(ic)}</span><h1>${esc(title)}</h1>`
+ + (tools ? `<div class="ovk-page-actions">${tools}</div>` : '') + `</div>`
+ + (lede ? `<p class="ovk-page-lede">${esc(lede)}</p>` : '') // one copy, whole, on every screen
+ + `</header>`;
+ }
// ── The left sidebar (L35) ──
// ── The left sidebar (L35) ──
/**
/**
* An app's left sidebar, one for every OpenVibe product (the owner, 2026-10-31: "the navigation makes more sense
* An app's left sidebar, one for every OpenVibe product (the owner, 2026-10-31: "the navigation makes more sense
@@ -903,7 +914,7 @@
@@ -903,7 +914,7 @@
return {
return {
skeleton,
skeleton,
esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,
esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,
- head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
+ head, pageHead, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 2,
enhance, VERSION: 2,
num, steps, table, chart, rank, meter, drop, receipt, page,
num, steps, table, chart, rank, meter, drop, receipt, page,
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+/* openvibe-shared/messages — Chat's two views (the owner, 11-09): a live room (lines like a stream's chat, wrapping
+ under the name on a phone) and texting bubbles (Android/iOS Messages: runs of bubbles with joined corners, the
+ avatar beside the first, the time under the last). The network's theme variables; dark and light; Calm fades. */
+.ovm-messages { list-style: none; margin: 0; padding: 12px clamp(10px, 3vw, 24px) 16px; display: flex; flex-direction: column; gap: 2px;
+ overflow: auto; min-height: 0; overscroll-behavior: contain; font-size: 15px; line-height: 1.42; color: var(--text-primary, #e8ecf3); }
+.ovm-day { position: sticky; top: 6px; z-index: 1; align-self: center; margin: 14px 0 6px; padding: 4px 12px; border-radius: 999px;
+ background: color-mix(in srgb, var(--bg-secondary, #141a24) 84%, transparent); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
+ box-shadow: inset 0 0 0 1px var(--border, rgba(255, 255, 255, .08)); font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
+
+/* Bubbles */
+.ovm-group { display: flex; gap: 8px; align-items: flex-start; max-width: min(82%, 640px); margin: 8px 0 0; }
+.ovm-group[data-continued="true"] { margin-top: 2px; }
+.ovm-group[data-mine="true"] { align-self: flex-end; flex-direction: row-reverse; }
+.ovm-avatar { flex: none; display: block; width: 30px; height: 30px; border-radius: 50%; object-fit: cover; background: var(--bg-tertiary, #2a3140); }
+.ovm-group:not([data-continued="true"]) .ovm-avatar { margin-top: 19px; } /* level with the first bubble, under the name */
+.ovm-avatar.is-gap { height: 1px; background: none; } /* a run keeps the avatar's column */
+.ovm-avatar.is-initial { display: grid; place-items: center; background: hsl(var(--ovm-h, 215) 55% 46%); color: #fff; font-size: 13px; font-weight: 800; }
+.ovm-group-body { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
+.ovm-group[data-mine="true"] .ovm-group-body { align-items: flex-end; }
+.ovm-author { margin: 0 0 3px 12px; font-size: 12px; font-weight: 700; color: var(--text-muted, #93a0b4); }
+.ovm-bubble { position: relative; max-width: 100%; padding: 8px 13px; border-radius: 20px; background: var(--bg-tertiary, #2a3140); overflow-wrap: anywhere; }
+.ovm-group[data-mine="true"] .ovm-bubble { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
+/* A run of bubbles from one person joins on the inner side. */
+.ovm-group:not([data-mine="true"])[data-continued="true"] .ovm-bubble { border-top-left-radius: 6px; }
+.ovm-group:not([data-mine="true"])[data-tail="false"] .ovm-bubble { border-bottom-left-radius: 6px; }
+.ovm-group[data-mine="true"][data-continued="true"] .ovm-bubble { border-top-right-radius: 6px; }
+.ovm-group[data-mine="true"][data-tail="false"] .ovm-bubble { border-bottom-right-radius: 6px; }
+.ovm-text { white-space: pre-wrap; }
+.ovm-meta { display: flex; align-items: center; gap: 4px; margin: 3px 8px 0; font-size: 11px; color: var(--text-muted, #93a0b4); }
+.ovm-meta time { font-variant-numeric: tabular-nums; }
+.ovm-tick { letter-spacing: -.2em; color: var(--accent-light, var(--accent, #60a5fa)); }
+.ovm-emoji-only { padding: 0 2px; background: transparent !important; font-size: 2.2rem; line-height: 1.15; }
+.ovm-reply { display: block; margin-bottom: 6px; padding-left: 8px; border-left: 2px solid currentColor; font-size: .86em; opacity: .75; }
+.ovm-link { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
+
+/* Room: one line per message, the name in its colour, the words wrapping under it on a phone. */
+.ovm-messages[data-view="room"] { gap: 0; }
+.ovm-room { display: block; padding: 3px 8px; border-radius: 8px; overflow-wrap: anywhere; }
+@media (hover: hover) { .ovm-room:hover { background: var(--bg-hover, rgba(255, 255, 255, .05)); } }
+.ovm-room time { margin-right: 7px; font-size: 11.5px; color: var(--text-muted, #93a0b4); font-variant-numeric: tabular-nums; }
+.ovm-room-author { font-weight: 750; color: var(--ov-author-color, hsl(var(--ovm-h, 215) 80% 68%)); }
+:root[data-theme="light"] .ovm-room-author { color: var(--ov-author-color, hsl(var(--ovm-h, 215) 72% 36%)); }
+.ovm-room-author::after { content: ":"; margin-right: 6px; font-weight: 600; color: var(--text-muted, #93a0b4); }
+.ovm-room .ovm-avatar { display: none; }
+
+/* The newest message arrives (only the last: a repaint never replays the whole list). */
+.ovm-group:last-child { animation: ovm-in .24s cubic-bezier(.2, .8, .2, 1) both; }
+.ovm-room:last-child { animation: ovm-fade .2s ease-out both; }
+@keyframes ovm-in { from { opacity: 0; transform: translateY(8px) scale(.97); } }
+@keyframes ovm-fade { from { opacity: 0; } }
+html[data-ov-motion=reduced] .ovm-group:last-child { animation-name: ovm-fade; }
+@media (prefers-reduced-motion: reduce) { .ovm-group:last-child { animation-name: ovm-fade; animation-duration: .15s; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/** Render Chat messages as a compact room or grouped conversation bubbles. */
+const text = (parent, value) => parent.appendChild(document.createTextNode(String(value == null ? '' : value)));
+function safeLinkify(node, value) {
+ const raw = String(value || ''), re = /https?:\/\/[^\s<>"']+/g;
+ let last = 0, match;
+ while ((match = re.exec(raw))) {
+ if (match.index > last) text(node, raw.slice(last, match.index));
+ const a = document.createElement('a'); a.className = 'ovm-link'; a.href = match[0]; a.rel = 'nofollow noopener noreferrer'; a.target = '_blank'; text(a, match[0]); node.appendChild(a);
+ last = match.index + match[0].length;
+ }
+ if (last < raw.length) text(node, raw.slice(last));
+}
+function timeValue(message) { let raw = String(message.created_at || message.at || '').replace(' ', 'T'); if (raw && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) raw += 'Z'; const d = new Date(raw); return Number.isNaN(d.getTime()) ? null : d; }
+function safeAvatar(value) { try { const u = new URL(String(value || '')); return u.protocol === 'https:' && !u.port && u.hostname.toLowerCase() === 'openvibe.media' ? u.href : ''; } catch { return ''; } }
+/** A steady hue per person: their name in the room and their initial's circle keep one colour everywhere. */
+function hueOf(id) { let x = 7; for (const c of String(id || '')) x = (x * 31 + c.codePointAt(0)) % 360; return x; }
+function dayLabel(date, now) {
+ const days = Math.floor((Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) / 86400000);
+ if (days === 0) return 'Today'; if (days === 1) return 'Yesterday';
+ return new Intl.DateTimeFormat(undefined, { weekday: days < 7 ? 'short' : undefined, day: 'numeric', month: 'short' }).format(date);
+}
+function render(root, { messages = [], view = 'room', me = null, now = new Date() } = {}) {
+ if (!root || !root.ownerDocument) throw new TypeError('A message list element is required');
+ if (view !== 'room' && view !== 'bubbles') throw new TypeError('view must be "room" or "bubbles"');
+ root.replaceChildren(); root.classList.add('ovm-messages'); root.dataset.view = view;
+ let previous = null, activeDay = '';
+ messages.forEach((message, index) => {
+ const date = timeValue(message), stamp = date ? date.getTime() : 0;
+ const day = date ? dayLabel(date, now) : '';
+ if (view === 'bubbles' && day && day !== activeDay) { const separator = document.createElement('li'); separator.className = 'ovm-day'; text(separator, day); root.appendChild(separator); activeDay = day; }
+ const sender = String(message.sender_id || message.user_id || message.from || message.username || '');
+ const mine = me != null && String(message.sender_id || message.user_id || '') === String(me);
+ const same = previous && sender === previous.sender && stamp && previous.stamp && stamp - previous.stamp >= 0 && stamp - previous.stamp <= 180000;
+ if (view === 'room') {
+ const row = document.createElement('li'); row.className = 'ovm-room'; row.dataset.id = String(message.id || '');
+ const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time);
+ const author = document.createElement('span'); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone'); row.appendChild(author);
+ const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row);
+ } else {
+ const group = document.createElement('li'); group.className = 'ovm-group'; group.dataset.id = String(message.id || ''); group.dataset.mine = String(mine); group.dataset.tail = String(!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender);
+ if (same) group.dataset.continued = 'true';
+ if (!mine && !same && safeAvatar(message.avatar_url)) { const avatar = document.createElement('img'); avatar.className = 'ovm-avatar'; avatar.alt = ''; avatar.loading = 'lazy'; avatar.referrerPolicy = 'no-referrer'; avatar.src = safeAvatar(message.avatar_url); group.appendChild(avatar); }
+ else if (!mine && !same) { const spacer = document.createElement('span'); spacer.className = 'ovm-avatar is-initial'; spacer.setAttribute('aria-hidden', 'true'); spacer.style.setProperty('--ovm-h', String(hueOf(sender))); text(spacer, String(message.display_name || message.username || '?').trim().charAt(0).toUpperCase()); group.appendChild(spacer); }
+ else if (!mine) { const gap = document.createElement('span'); gap.className = 'ovm-avatar is-gap'; gap.setAttribute('aria-hidden', 'true'); group.appendChild(gap); } // a run keeps the avatar's column
+ const body = document.createElement('div'); body.className = 'ovm-group-body';
+ if (!mine && !same) { const author = document.createElement('div'); author.className = 'ovm-author'; text(author, message.display_name || message.username || 'Someone'); body.appendChild(author); }
+ const bubble = document.createElement('div'); bubble.className = 'ovm-bubble';
+ if (message.reply_to) { const quote = document.createElement('span'); quote.className = 'ovm-reply'; text(quote, message.reply_to.message || message.reply_to.text || 'Reply'); bubble.appendChild(quote); }
+ const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || '');
+ if (/^(?:\p{Extended_Pictographic}|\s)+$/u.test(value) && value.trim()) bubble.classList.add('ovm-emoji-only');
+ safeLinkify(content, value); bubble.appendChild(content); body.appendChild(bubble);
+ if (!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender || !stamp || !timeValue(messages[index + 1]) || timeValue(messages[index + 1]).getTime() - stamp > 180000) {
+ const meta = document.createElement('div'); meta.className = 'ovm-meta'; const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date)); } meta.appendChild(time);
+ if (mine && message.read) { const tick = document.createElement('span'); tick.className = 'ovm-tick'; tick.setAttribute('aria-label', 'Read'); text(tick, '✓✓'); meta.appendChild(tick); }
+ body.appendChild(meta);
+ }
+ group.appendChild(body); root.appendChild(group);
+ }
+ previous = { sender, stamp };
+ });
+ return root;
+}
+const api = { render };
+if (typeof module !== 'undefined' && module.exports) module.exports = api;
+if (typeof window !== 'undefined') {
+ window.OVMessages = api;
+ const ov = window.ov = window.ov || {};
+ ov.chat = ov.chat || {};
+ ov.chat.messages = api;
+}
@@ -572,7 +572,7 @@
@@ -572,7 +572,7 @@
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': 'd0baedb2ce11', 'nav-slots.js': 'a5af310d62e0' };
+ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '184115c2aa7f', 'nav-slots.js': 'a5af310d62e0' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// 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
// (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">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -876,18 +876,21 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
@@ -876,18 +876,21 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
const t = h('span', 'ovf-update-txt'); t.append(h('b', '', String(c.title || '').slice(0, 160)), h('small', '', [c.site_name || c.site, ago(c.at)].filter(Boolean).join(' · ')));
const t = h('span', 'ovf-update-txt'); t.append(h('b', '', String(c.title || '').slice(0, 160)), h('small', '', [c.site_name || c.site, ago(c.at)].filter(Boolean).join(' · ')));
a.append(ic, t); return a;
a.append(ic, t); return a;
}
}
- const a = h('a', `ovf-card ovf-${kind.replace(/[^a-z]/g, '')}`); a.href = url;
+ const a = h('a', `ovf-card ovf-k-${kind.replace(/[^a-z]/g, '')}`); a.href = url;
const head = h('span', 'ovf-head');
const head = h('span', 'ovf-head');
const face = h('span', 'ovf-face');
const face = h('span', 'ovf-face');
const av = c.actor && safe(c.actor.avatar);
const av = c.actor && safe(c.actor.avatar);
- if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); } else face.textContent = String((c.actor && c.actor.name) || '?').charAt(0).toUpperCase();
+ // No person behind it (the network's trending pictures): the site's icon and the card's label, not a "?".
+ if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }
+ else if (!c.actor) { face.classList.add('is-site'); face.style.setProperty('--ovh-h', String(hueOf(c.site || 'ov'))); face.append(siteIcon(c.site || 'ov', 20)); }
+ else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
const who = h('span', 'ovf-who');
const who = h('span', 'ovf-who');
- who.append(h('b', '', String((c.actor && c.actor.name) || c.site_name || '').slice(0, 60)));
+ who.append(h('b', '', String((c.actor && c.actor.name) || c.label || c.site_name || '').slice(0, 60)));
const meta = h('small', '');
const meta = h('small', '');
- meta.append(siteIcon(c.site || 'ov', 14), document.createTextNode(` ${[c.site_name || '', ago(c.at)].filter(Boolean).join(' · ')}`));
+ meta.append(siteIcon(c.site || 'ov', 14), document.createTextNode(` ${[c.site_name || '', c.actor ? ago(c.at) : String(c.text || ago(c.at) || '').slice(0, 60)].filter(Boolean).join(' · ')}`));
who.append(meta);
who.append(meta);
head.append(face, who);
head.append(face, who);
- if (kind === 'live') head.append(h('span', 'ovf-livechip', 'LIVE'));
+ if (kind === 'live' && c.live_now) { a.classList.add('is-on'); head.append(h('span', 'ovf-livechip', 'LIVE')); }
a.append(head);
a.append(head);
const media = c.media || {};
const media = c.media || {};
const thumb = safe(media.thumb);
const thumb = safe(media.thumb);
@@ -908,6 +911,34 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
@@ -908,6 +911,34 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
if (bits.length) a.append(h('small', 'ovf-counts', bits.join(' · ')));
if (bits.length) a.append(h('small', 'ovf-counts', bits.join(' · ')));
return a;
return a;
}
}
+ /** Several streamers who went live (and are not live now), as one card: their faces, then each name to their channel. */
+ function livesCard(items) {
+ const box = h('div', 'ovf-card ovf-lives');
+ const head = h('div', 'ovf-head');
+ const faces = h('span', 'ovf-faces');
+ items.slice(0, 4).forEach((x) => {
+ const f = h('span', 'ovf-face');
+ const av = x.actor && safe(x.actor.avatar);
+ if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; f.append(img); } else f.textContent = String((x.actor && x.actor.name) || '?').charAt(0).toUpperCase();
+ faces.append(f);
+ });
+ const names = items.map((x) => String((x.actor && x.actor.name) || '').slice(0, 40)).filter(Boolean);
+ const who = h('span', 'ovf-who');
+ who.append(h('b', '', names.length > 2 ? `${names.slice(0, 2).join(', ')} and ${names.length - 2} more went live` : `${names.join(' and ')} went live`));
+ const meta = h('small'); meta.append(siteIcon('live', 14), document.createTextNode(` Live · ${[ago(items[0].at), items.length > 1 ? ago(items[items.length - 1].at) : ''].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' – ')}`));
+ who.append(meta);
+ head.append(faces, who);
+ const row = h('div', 'ovf-lives-row');
+ items.forEach((x) => {
+ const l = h('a', 'ovf-pill'); l.href = safe(x.url);
+ const t = String(x.title || '').replace(/^.*?went live:\s*/i, '');
+ l.append(h('b', '', String((x.actor && x.actor.name) || '').slice(0, 40)));
+ if (t) l.append(h('span', '', t.slice(0, 60)));
+ row.append(l);
+ });
+ box.append(head, row);
+ return box;
+ }
function feed(el, o) {
function feed(el, o) {
styles(); feedStyles();
styles(); feedStyles();
el.classList.add('ovh', 'ovf');
el.classList.add('ovh', 'ovf');
@@ -932,7 +963,12 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
@@ -932,7 +963,12 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
done = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;
done = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;
}
}
if (first && o.feedLive) o.feedLive();
if (first && o.feedLive) o.feedLive();
- j.items.map(feedCard).filter(Boolean).forEach((c) => list.append(c));
+ // Back-to-back go-lives that are over fold into one card of faces (a wall of "X went live" was the whole feed).
+ const past = (x) => x && x.kind === 'live' && !x.live_now && safe(x.url);
+ let run = [];
+ const flush = () => { if (run.length >= 2) list.append(livesCard(run)); else run.forEach((x) => { const c = feedCard(x); if (c) list.append(c); }); run = []; };
+ j.items.forEach((x) => { if (past(x) && !kind) { run.push(x); return; } flush(); const c = feedCard(x); if (c) list.append(c); });
+ flush();
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
cursor = j.next_cursor || null;
cursor = j.next_cursor || null;
done = !cursor;
done = !cursor;
@@ -971,10 +1007,19 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
@@ -971,10 +1007,19 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
.ovf-head{display:flex;align-items:center;gap:8px;min-width:0}
.ovf-head{display:flex;align-items:center;gap:8px;min-width:0}
.ovf-face{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:13px}
.ovf-face{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:13px}
.ovf-face img{width:100%;height:100%;object-fit:cover}
.ovf-face img{width:100%;height:100%;object-fit:cover}
+.ovf-face.is-site{border-radius:10px;background:linear-gradient(150deg,hsl(var(--ovh-h,215) 72% 58% / .34),hsl(var(--ovh-h,215) 70% 38% / .18))}.ovf-face.is-site .ov-icon{color:hsl(var(--ovh-h,215) 90% 82%)}
.ovf-who{min-width:0;display:grid}
.ovf-who{min-width:0;display:grid}
.ovf-who b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ovf-who b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ovf-who small{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted,#93a0b4)}
.ovf-who small{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted,#93a0b4)}
-.ovf-livechip{margin-left:auto;padding:2px 8px;border-radius:6px;background:var(--live,#ef4444);color:#fff;font-size:11px;font-weight:800;letter-spacing:.06em}
+.ovf-livechip{margin-left:auto;padding:2px 8px;border-radius:6px;background:var(--live,#ef4444);color:#fff;font-size:11px;font-weight:800;letter-spacing:.06em;animation:ovf-onair 2.4s ease-in-out infinite}
+.ovf-card.is-on{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--live,#ef4444) 45%,transparent);background:color-mix(in srgb,var(--live,#ef4444) 7%,transparent)}
+@keyframes ovf-onair{50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--live,#ef4444) 25%,transparent)}}
+.ovf-faces{display:flex;flex:none}.ovf-faces .ovf-face{width:30px;height:30px;box-shadow:0 0 0 2px var(--bg-secondary,#141a24)}.ovf-faces .ovf-face+.ovf-face{margin-left:-9px}
+.ovf-lives-row{display:flex;flex-wrap:wrap;gap:6px}
+.ovf-lives .ovf-who b{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.3}
+.ovf-pill{box-sizing:border-box;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-height:36px;padding:6px 12px;border-radius:999px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent);color:inherit;text-decoration:none;font-size:13px;line-height:1.2}
+.ovf-pill b{flex:none;font-weight:700}.ovf-pill span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted,#93a0b4)}
+@media (hover:hover){.ovf-pill:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 11%,transparent)}}
.ovf-media{position:relative;display:block;border-radius:12px;overflow:hidden;background:var(--bg-tertiary,#1f2633)}
.ovf-media{position:relative;display:block;border-radius:12px;overflow:hidden;background:var(--bg-tertiary,#1f2633)}
.ovf-media img,.ovf-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.ovf-media img,.ovf-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.ovf-len{position:absolute;right:8px;bottom:8px;padding:2px 7px;border-radius:6px;background:rgba(0,0,0,.72);color:#fff;font-size:12px;font-weight:700}
.ovf-len{position:absolute;right:8px;bottom:8px;padding:2px 7px;border-radius:6px;background:rgba(0,0,0,.72);color:#fff;font-size:12px;font-weight:700}
@@ -988,7 +1033,7 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
@@ -988,7 +1033,7 @@ html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s;
.ovf-skel{display:block;height:180px;border-radius:16px;background:linear-gradient(90deg,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 0,color-mix(in srgb,var(--text-primary,#e8ecf3) 9%,transparent) 50%,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 100%);background-size:200% 100%;animation:ovf-shimmer 1.4s ease-in-out infinite}
.ovf-skel{display:block;height:180px;border-radius:16px;background:linear-gradient(90deg,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 0,color-mix(in srgb,var(--text-primary,#e8ecf3) 9%,transparent) 50%,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 100%);background-size:200% 100%;animation:ovf-shimmer 1.4s ease-in-out infinite}
.ovf-more{min-height:1px}.ovf-caught{text-align:center;padding:10px 0}
.ovf-more{min-height:1px}.ovf-caught{text-align:center;padding:10px 0}
@keyframes ovf-shimmer{to{background-position:-200% 0}}
@keyframes ovf-shimmer{to{background-position:-200% 0}}
-@media (prefers-reduced-motion:reduce){.ovf-skel{animation:none}}
+@media (prefers-reduced-motion:reduce){.ovf-skel{animation:none}.ovf-livechip{animation-duration:4s}}
`;
`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+/* openvibe-shared ov-upload (designed 11-09): a picker that says what to do, each file a row with its own progress,
+ the total above them for two or more. The network's theme variables; dark and light; Calm keeps the fill, not the sheen. */
+.ov-upload { display: grid; gap: 12px; color: var(--text-primary, #e8ecf3); }
+.ov-upload__input { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+.ov-upload__picker { display: grid; place-items: center; align-content: center; gap: 6px; width: 100%; min-height: 148px; padding: 22px; border: 1.5px dashed color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, rgba(255,255,255,.14)));
+ border-radius: 18px; background: color-mix(in srgb, var(--accent, #3b82f6) 5%, transparent); color: inherit; font: inherit; cursor: pointer; transition: background .18s, border-color .18s, transform .18s; }
+.ov-upload__picker strong { font-size: 15.5px; }
+.ov-upload__picker:hover, .ov-upload.is-dragging .ov-upload__picker { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 11%, transparent); }
+.ov-upload.is-dragging .ov-upload__picker { transform: scale(1.01); border-style: solid; }
+.ov-upload__picker:active { transform: scale(.99); }
+.ov-upload__picker:focus-visible, .ov-upload__remove:focus-visible, .ov-progress__retry:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; }
+.ov-upload__icon { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 4px; border-radius: 16px; color: var(--on-accent-strong, #fff);
+ background: linear-gradient(150deg, var(--accent, #3b82f6), var(--accent-strong, #2563eb)); box-shadow: 0 8px 22px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
+.ov-upload__icon svg, .ov-upload__preview svg, .ov-upload__remove svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.ov-upload__icon svg { width: 26px; height: 26px; }
+.ov-upload.is-dragging .ov-upload__icon { animation: ov-upload-bob .9s ease-in-out infinite; }
+.ov-upload__drop { font-size: 13px; color: var(--text-muted, #93a0b4); }
+.ov-upload__total { padding: 0 2px; }
+.ov-upload__total[hidden] { display: none; }
+.ov-upload__rows { display: grid; gap: 8px; }
+.ov-upload__row { display: grid; grid-template-columns: 52px minmax(0, 1fr) 40px; align-items: center; gap: 4px 12px; min-width: 0; padding: 10px;
+ border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.08));
+ animation: ov-upload-in .26s cubic-bezier(.2, .8, .2, 1) both; }
+.ov-upload__preview { display: grid; place-items: center; width: 52px; height: 52px; overflow: hidden; border-radius: 10px; color: var(--accent-light, var(--accent, #60a5fa));
+ background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
+.ov-upload__preview svg { width: 24px; height: 24px; }
+.ov-upload__preview img, .ov-upload__preview video { width: 100%; height: 100%; object-fit: cover; }
+.ov-upload__file { display: grid; gap: 1px; min-width: 0; }
+.ov-upload__file strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 14.5px; }
+.ov-upload__file small { font-size: 12.5px; color: var(--text-muted, #93a0b4); }
+.ov-upload__remove { display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-secondary, #a8b3c4); cursor: pointer; }
+.ov-upload__remove svg { width: 18px; height: 18px; }
+@media (hover: hover) { .ov-upload__remove:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 9%, transparent); color: var(--text-primary, #e8ecf3); } }
+.ov-upload__meter { grid-column: 1 / -1; }
+/* Progress */
+.ov-progress { display: grid; gap: 7px; }
+.ov-progress__heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 14px; font-weight: 650; }
+.ov-progress__percent { font-variant-numeric: tabular-nums; }
+.ov-progress__track { height: 8px; overflow: hidden; border-radius: 99px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 10%, transparent); }
+.ov-progress__fill { position: relative; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-strong, #2563eb), var(--accent, #3b82f6)); transition: width .65s cubic-bezier(.2, .7, .2, 1); }
+.ov-progress[data-state="sending"] .ov-progress__fill::after { content: ''; position: absolute; inset: 0; background: linear-gradient(110deg, transparent 25%, rgba(255,255,255,.3) 45%, transparent 65%); animation: ov-upload-sheen 1.8s linear infinite; }
+.ov-progress[data-state="processing"] .ov-progress__fill { width: 100% !important; animation: ov-upload-pulse 1.8s ease-in-out infinite; }
+.ov-progress[data-state="done"] .ov-progress__fill { background: var(--success, #22c55e); }
+.ov-progress[data-state="done"] .ov-progress__percent { color: var(--success, #22c55e); animation: ov-upload-check .38s ease-out both; }
+.ov-progress__check { display: block; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
+.ov-progress__check path { stroke-dasharray: 24; stroke-dashoffset: 24; animation: ov-upload-draw .45s ease-out forwards; }
+.ov-progress[data-state="failed"] .ov-progress__fill { background: var(--danger, #ef4444); }
+.ov-progress[data-state="failed"] .ov-progress__label { color: var(--danger, #ef4444); }
+.ov-progress__detail { min-height: 1.4em; margin: 0; font-size: 12.5px; color: var(--text-muted, #93a0b4); font-variant-numeric: tabular-nums; }
+.ov-progress__retry { justify-self: start; min-height: 36px; padding: 0 14px; border: 0; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 9%, transparent); color: inherit; font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }
+/* Inside a file's row: one line of state and percent, a thin bar, the numbers. */
+.ov-progress--row { gap: 5px; margin-top: 6px; }
+.ov-progress--row .ov-progress__heading { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
+.ov-progress--row .ov-progress__track { height: 5px; }
+.ov-progress--row .ov-progress__detail { font-size: 12px; }
+@keyframes ov-upload-sheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
+@keyframes ov-upload-pulse { 50% { opacity: .5; } }
+@keyframes ov-upload-check { from { transform: scale(.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
+@keyframes ov-upload-draw { to { stroke-dashoffset: 0; } }
+@keyframes ov-upload-in { from { opacity: 0; transform: translateY(6px); } }
+@keyframes ov-upload-bob { 50% { transform: translateY(-3px); } }
+@media (pointer: coarse), (max-width: 700px) { .ov-upload__picker { min-height: 120px; } .ov-upload__drop { display: none; } }
+html[data-ov-motion=reduced] .ov-progress[data-state="sending"] .ov-progress__fill::after { animation: none; }
+@media (prefers-reduced-motion: reduce) { .ov-progress__fill, .ov-progress[data-state="processing"] .ov-progress__fill, .ov-progress[data-state="done"] .ov-progress__percent, .ov-progress__check path, .ov-upload__row, .ov-upload.is-dragging .ov-upload__icon { transition: none; animation: none; } .ov-progress__check path { stroke-dashoffset: 0; } .ov-progress[data-state="sending"] .ov-progress__fill::after { display: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -0,0 +1,201 @@
+/* Shared OpenVibe upload UI. Sites supply their upload(file, {onProgress}) transport. */
+(function (root, factory) {
+ var api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ if (root && root.document) root.OVUpload = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+ function size(bytes) {
+ if (!Number.isFinite(bytes)) return '0 B';
+ if (bytes < 1024) return Math.round(bytes) + ' B';
+ var unit = Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024)));
+ return (bytes / Math.pow(1024, unit)).toFixed(1) + ' ' + ['B', 'KB', 'MB', 'GB'][unit];
+ }
+ function timeLeft(seconds) {
+ if (!Number.isFinite(seconds)) return 'Estimating time left';
+ if (seconds < 5) return 'Almost done';
+ if (seconds < 60) return 'About ' + Math.ceil(seconds / 5) * 5 + ' sec left';
+ if (seconds < 3600) return 'About ' + Math.ceil(seconds / 60) + ' min left';
+ return 'About ' + Math.ceil(seconds / 3600) + ' hr left';
+ }
+ // Seconds are smoothed by measured bytes per second, not the last network burst.
+ function rate(previous, deltaBytes, deltaSeconds, alpha) {
+ if (deltaBytes <= 0 || deltaSeconds <= 0) return previous || 0;
+ var sample = deltaBytes / deltaSeconds;
+ return previous > 0 ? (alpha || 0.25) * sample + (1 - (alpha || 0.25)) * previous : sample;
+ }
+ // Line icons (constants, never data): upload, a file, close.
+ var ICON_UP = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V4m0 0-4.5 4.5M12 4l4.5 4.5"/><path d="M4 14v3a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-3"/></svg>';
+ var ICON_FILE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>';
+ var ICON_X = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
+ function node(tag, className, text) {
+ var el = document.createElement(tag);
+ if (className) el.className = className;
+ if (text != null) el.textContent = text;
+ return el;
+ }
+ function progress(el, options) {
+ options = options || {};
+ el.classList.add('ov-progress');
+ if (options.compact) el.classList.add('ov-progress--row');
+ var label = node('span', 'ov-progress__label', 'Waiting');
+ var percent = node('strong', 'ov-progress__percent', '0%');
+ var heading = node('div', 'ov-progress__heading');
+ heading.append(label, percent);
+ var track = node('div', 'ov-progress__track');
+ track.setAttribute('role', 'progressbar');
+ track.setAttribute('aria-valuemin', '0');
+ track.setAttribute('aria-valuemax', '100');
+ var fill = node('div', 'ov-progress__fill');
+ track.append(fill);
+ var detail = node('p', 'ov-progress__detail', 'Ready when you are');
+ var retry = node('button', 'ov-progress__retry', 'Retry');
+ retry.type = 'button'; retry.hidden = true;
+ retry.addEventListener('click', function () { if (options.onRetry) options.onRetry(); });
+ el.replaceChildren(heading, track, detail, retry);
+ var current = { state: 'waiting', done: 0, total: 0, rate: 0, at: 0, lastBytes: 0, message: '' };
+ function render() {
+ var state = current.state;
+ var pct = current.total ? Math.min(100, Math.floor(current.done / current.total * 100)) : 0;
+ el.dataset.state = state;
+ label.textContent = ({ waiting: 'Waiting', sending: 'Uploading', paused: 'Paused: no connection', processing: 'Processing: making it playable', done: 'Uploaded', failed: 'Upload failed' })[state] || state;
+ if (state === 'done') percent.innerHTML = '<svg class="ov-progress__check" viewBox="0 0 24 24" role="img" aria-label="Done"><path d="M4 12l5 5L20 6"/></svg>';
+ else percent.textContent = pct + '%';
+ fill.style.width = (state === 'done' ? 100 : pct) + '%';
+ track.setAttribute('aria-valuenow', String(pct));
+ track.setAttribute('aria-valuetext', label.textContent + ', ' + pct + '%');
+ var meta = current.total ? size(current.done) + ' of ' + size(current.total) : '';
+ if (state === 'sending') detail.textContent = [meta, current.rate ? size(current.rate) + '/s' : '', timeLeft((current.total - current.done) / current.rate)].filter(Boolean).join(' · ');
+ else if (state === 'paused') detail.textContent = 'Your upload resumes when the connection returns.';
+ else if (state === 'processing') detail.textContent = current.message || 'Finishing your file';
+ else if (state === 'failed') detail.textContent = current.message || 'The upload did not go through.';
+ else if (state === 'done') detail.textContent = current.message || 'Your file is ready.';
+ else detail.textContent = meta || 'Ready when you are';
+ retry.hidden = state !== 'failed';
+ }
+ function update(next) {
+ next = next || {};
+ var now = Date.now();
+ var done = Math.max(0, Number(next.done == null ? current.done : next.done) || 0);
+ var total = Math.max(0, Number(next.total == null ? current.total : next.total) || 0);
+ if (done > current.lastBytes && current.at) current.rate = rate(current.rate, done - current.lastBytes, (now - current.at) / 1000);
+ if (done !== current.lastBytes) { current.lastBytes = done; current.at = now; }
+ current.done = done; current.total = total;
+ if (next.state) current.state = next.state;
+ if (next.message !== undefined) current.message = next.message;
+ render();
+ }
+ var timer = typeof window === 'undefined' ? null : window.setInterval(function () {
+ if (current.state !== 'sending') return;
+ if (Date.now() - current.at > 5000) current.rate *= 0.65;
+ render();
+ }, 1000);
+ render();
+ return { update: update, destroy: function () { if (timer) clearInterval(timer); } };
+ }
+ function picker(root, options) {
+ options = options || {};
+ var input = node('input', 'ov-upload__input');
+ input.type = 'file'; input.accept = options.accept || ''; input.multiple = !!options.multiple;
+ input.setAttribute('aria-label', options.label || 'Choose a file');
+ var card = node('button', 'ov-upload__picker');
+ card.type = 'button';
+ card.innerHTML = '<span class="ov-upload__icon" aria-hidden="true">' + ICON_UP + '</span><strong></strong><span class="ov-upload__drop">or drop ' + (options.multiple ? 'them' : 'it') + ' here</span>';
+ card.querySelector('strong').textContent = options.label || 'Choose a file';
+ card.addEventListener('click', function () { input.click(); });
+ var rows = node('div', 'ov-upload__rows');
+ root.classList.add('ov-upload'); root.append(input, card, rows);
+ function add(files) {
+ Array.prototype.forEach.call(options.multiple ? files : Array.prototype.slice.call(files, 0, 1), function (file) {
+ var row = node('div', 'ov-upload__row');
+ var preview = node('div', 'ov-upload__preview'); preview.innerHTML = ICON_FILE;
+ if (file.type.indexOf('image/') === 0 || file.type.indexOf('video/') === 0) {
+ var url = URL.createObjectURL(file);
+ var media = node(file.type.indexOf('image/') === 0 ? 'img' : 'video');
+ media.src = url; media.muted = true; media.preload = 'metadata'; media.setAttribute('aria-hidden', 'true');
+ media.addEventListener('loadeddata', function () { if (media.tagName === 'VIDEO') media.currentTime = Math.min(0.1, media.duration || 0); }, { once: true });
+ row.addEventListener('remove', function () { URL.revokeObjectURL(url); });
+ preview.replaceChildren(media);
+ }
+ var text = node('div', 'ov-upload__file');
+ text.append(node('strong', '', file.name), node('small', '', size(file.size)));
+ var remove = node('button', 'ov-upload__remove'); remove.type = 'button'; remove.innerHTML = ICON_X;
+ remove.setAttribute('aria-label', 'Remove ' + file.name); remove.title = 'Remove';
+ remove.addEventListener('click', function () { row.dispatchEvent(new Event('remove')); row.remove(); if (options.onRemove) options.onRemove(file); });
+ row.append(preview, text, remove); rows.append(row);
+ if (options.onFile) options.onFile(file, row);
+ });
+ }
+ input.addEventListener('change', function () { add(input.files); input.value = ''; });
+ root.addEventListener('dragover', function (e) { e.preventDefault(); root.classList.add('is-dragging'); });
+ root.addEventListener('dragleave', function () { root.classList.remove('is-dragging'); });
+ root.addEventListener('drop', function (e) { e.preventDefault(); root.classList.remove('is-dragging'); if (e.dataTransfer && e.dataTransfer.files) add(e.dataTransfer.files); });
+ return { input: input, add: add, rows: rows };
+ }
+ function queue(root, options) {
+ options = options || {};
+ if (typeof options.upload !== 'function') throw new TypeError('OVUpload.queue: upload(file, {onProgress, signal}) is required');
+ var pickRoot = node('div');
+ var totalRoot = node('div', 'ov-upload__total');
+ totalRoot.hidden = true;
+ root.append(pickRoot);
+ var items = [], active = 0;
+ var total = progress(totalRoot);
+ function refresh() {
+ var bytes = items.reduce(function (n, item) { return n + item.file.size; }, 0);
+ var done = items.reduce(function (n, item) { return n + item.done; }, 0);
+ var state = items.some(function (item) { return item.state === 'sending'; }) ? 'sending'
+ : items.some(function (item) { return item.state === 'failed'; }) ? 'failed'
+ : items.length && items.every(function (item) { return item.state === 'done'; }) ? 'done' : 'waiting';
+ total.update({ done: done, total: bytes, state: state, message: state === 'failed' ? 'A file needs a retry.' : '' });
+ totalRoot.hidden = items.length < 2;
+ }
+ function pump() {
+ while (active < (options.concurrency || 3)) {
+ var item = items.find(function (x) { return x.state === 'waiting'; });
+ if (!item) break;
+ item.state = 'sending'; active++;
+ item.meter.update({ state: 'sending', done: item.done, total: item.file.size }); refresh();
+ (function (entry) {
+ entry.controller = new AbortController();
+ Promise.resolve().then(function () { return options.upload(entry.file, {
+ signal: entry.controller.signal,
+ onProgress: function (value) {
+ entry.done = Math.max(0, Math.min(entry.file.size, Number(value.uploadedBytes == null ? value.done : value.uploadedBytes) || 0));
+ entry.meter.update({ state: 'sending', done: entry.done, total: entry.file.size }); refresh();
+ }
+ }); }).then(function (result) {
+ if (entry.removed) return;
+ entry.done = entry.file.size; entry.state = 'done';
+ entry.meter.update({ state: 'done', done: entry.done, total: entry.file.size });
+ if (options.onDone) options.onDone(entry.file, result);
+ }).catch(function (error) {
+ if (entry.removed) return;
+ entry.state = 'failed';
+ entry.meter.update({ state: 'failed', message: error && error.message || 'The upload did not go through.' });
+ }).finally(function () { active--; refresh(); pump(); });
+ })(item);
+ }
+ }
+ var pick = picker(pickRoot, {
+ label: options.label, accept: options.accept, multiple: options.multiple !== false,
+ onFile: function (file, row) {
+ var meterRoot = node('div', 'ov-upload__meter'); row.append(meterRoot); row.classList.add('has-meter');
+ var item = { file: file, row: row, meterRoot: meterRoot, done: 0, state: 'waiting' };
+ item.meter = progress(meterRoot, { compact: true, onRetry: function () { item.state = 'waiting'; pump(); } });
+ items.push(item); refresh(); pump();
+ },
+ onRemove: function (file) {
+ var item = items.find(function (x) { return x.file === file; });
+ if (!item) return;
+ item.removed = true;
+ if (item.controller) item.controller.abort();
+ item.meter.destroy(); item.meterRoot.remove();
+ items.splice(items.indexOf(item), 1); refresh();
+ }
+ });
+ pick.rows.before(totalRoot); // the total sits between the picker and the files
+ return { picker: pick, total: total, items: items };
+ }
+ return { progress: progress, picker: picker, queue: queue, rate: rate, timeLeft: timeLeft, size: size };
+});
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.163.0",
+ "version": "4.164.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.163.0",
+ "version": "4.164.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.163.0",
+ "version": "4.164.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -67,6 +67,7 @@
@@ -67,6 +67,7 @@
"./ov-forms": "./ov-forms.js",
"./ov-forms": "./ov-forms.js",
"./ov-qr": "./ov-qr.js",
"./ov-qr": "./ov-qr.js",
"./ov-share": "./ov-share.js",
"./ov-share": "./ov-share.js",
+ "./ov-upload": "./ov-upload.js",
"./ov-sync": "./ov-sync.js",
"./ov-sync": "./ov-sync.js",
"./ov-locker": "./ov-locker.js",
"./ov-locker": "./ov-locker.js",
"./ov-locker-core": "./ov-locker-core.js",
"./ov-locker-core": "./ov-locker-core.js",
@@ -121,6 +122,7 @@
@@ -121,6 +122,7 @@
"./motion": "./motion.js",
"./motion": "./motion.js",
"./agent-layer": "./agent-layer.js",
"./agent-layer": "./agent-layer.js",
"./thread": "./thread.js",
"./thread": "./thread.js",
+ "./messages": "./messages.js",
"./ov-thread": "./ov-thread.js"
"./ov-thread": "./ov-thread.js"
},
},
"files": [
"files": [
@@ -118,12 +118,12 @@ function visualHTML(orbit) {
@@ -118,12 +118,12 @@ function visualHTML(orbit) {
+ `${coreHref ? `<a class="sc-orb-core" href="${esc(coreHref)}">${core}</a>` : `<span class="sc-orb-core">${core}</span>`}</div>`;
+ `${coreHref ? `<a class="sc-orb-core" href="${esc(coreHref)}">${core}</a>` : `<span class="sc-orb-core">${core}</span>`}</div>`;
}
}
-function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html, orbit, visual } = {}) {
+function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html, orbit, visual, pageHead: ph = null } = {}) {
// Without an aside of its own the hero gets a visual (4.26.0): an orbit of the site's places when it names them, else
// Without an aside of its own the hero gets a visual (4.26.0): an orbit of the site's places when it names them, else
// the aura. visual: false keeps it plain.
// the aura. visual: false keeps it plain.
const vis = !aside && html == null && visual !== false;
const vis = !aside && html == null && visual !== false;
const hasOrbit = vis && orbit && (has(orbit.inner) || has(orbit.outer));
const hasOrbit = vis && orbit && (has(orbit.inner) || has(orbit.outer));
- return `<section class="sc-hero${aside || html || vis ? ' sc-split' : ''}${vis ? ` sc-has-vis${hasOrbit ? ' sc-has-orbit' : ''}` : ''}"><div class="sc-hero-main"><h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : vis ? `<div class="sc-hero-aside sc-hero-vis">${visualHTML(hasOrbit ? orbit : null)}</div>` : ''}</section>`;
+ return `<section class="sc-hero${aside || html || vis ? ' sc-split' : ''}${vis ? ` sc-has-vis${hasOrbit ? ' sc-has-orbit' : ''}` : ''}"><div class="sc-hero-main">${ph ? require('./kit').pageHead({ ...ph, title: [title, accent].filter(Boolean).join(' '), lede }) : `<h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}`}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : vis ? `<div class="sc-hero-aside sc-hero-vis">${visualHTML(hasOrbit ? orbit : null)}</div>` : ''}</section>`;
}
}
/** A grid of what the product does; each card may link to where it starts. */
/** A grid of what the product does; each card may link to where it starts. */
@@ -424,8 +424,8 @@ function room({ room: key, hero: h = {}, proof, rails, scenes, explore, dev, faq
@@ -424,8 +424,8 @@ function room({ room: key, hero: h = {}, proof, rails, scenes, explore, dev, faq
+ `<b${typeof p.value === 'number' ? ` data-ov-ticker="${p.value}"` : ''}>${esc(fmtNum(p.value))}</b> <span>${esc(p.label)}</span>`
+ `<b${typeof p.value === 'number' ? ` data-ov-ticker="${p.value}"` : ''}>${esc(fmtNum(p.value))}</b> <span>${esc(p.label)}</span>`
+ `${p.live ? '<em class="sc-room-live">live</em>' : p.asOf ? `<em>${esc(p.asOf)}</em>` : ''}</li>`).join('')}</ul>` : '';
+ `${p.live ? '<em class="sc-room-live">live</em>' : p.asOf ? `<em>${esc(p.asOf)}</em>` : ''}</li>`).join('')}</ul>` : '';
const heroHTML = h.title ? `<section class="sc-room-hero" aria-labelledby="sc-room-h">`
const heroHTML = h.title ? `<section class="sc-room-hero" aria-labelledby="sc-room-h">`
- + `<div class="sc-room-main"><h1 id="sc-room-h">${esc(h.title)}</h1>${h.lede ? `<p class="sc-room-lede">${esc(h.lede)}</p>` : ''}${box}${start}`
- + `${chips.length ? `<nav class="sc-room-chips" aria-label="Try">${chips.map((c) => `<a href="${esc(safeHref(c.href))}">${esc(c.label)}</a>`).join('')}</nav>` : ''}${proofHTML}</div>`
+ + `<div class="sc-room-main">${h.pageHead ? kitLib.pageHead({ ...h.pageHead, title: h.title, lede: h.lede }).replace('<h1>', '<h1 id="sc-room-h">') : `<h1 id="sc-room-h">${esc(h.title)}</h1>${h.lede ? `<p class="sc-room-lede">${esc(h.lede)}</p>` : ''}`}${box}${start}`
+ + `${chips.length ? `<nav class="sc-room-chips" data-ovk-scroll aria-label="Try">${chips.map((c) => `<a href="${esc(safeHref(c.href))}">${esc(c.label)}</a>`).join('')}</nav>` : ''}${proofHTML}</div>`
+ `<div class="sc-room-pic">${h.motif != null ? h.motif : roomMotif(r)}</div></section>` : '';
+ `<div class="sc-room-pic">${h.motif != null ? h.motif : roomMotif(r)}</div></section>` : '';
const railsHTML = (rails || []).map((x) => kitLib.rail(x)).join('');
const railsHTML = (rails || []).map((x) => kitLib.rail(x)).join('');
const sceneList = (scenes || []).filter((x) => x && x.title && x.html != null);
const sceneList = (scenes || []).filter((x) => x && x.title && x.html != null);
@@ -316,3 +316,13 @@ console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, ti
@@ -316,3 +316,13 @@ console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, ti
const h = kit.rail({ id: 'recent', title: 'Recent', icon: 'clock', more: { href: '/all', label: 'See all' }, items: ['<p>One</p>'] });
const h = kit.rail({ id: 'recent', title: 'Recent', icon: 'clock', more: { href: '/all', label: 'See all' }, items: ['<p>One</p>'] });
assert.ok(h.includes('ovk-heading-icon') && h.includes('ovk-btn--quiet') && h.includes('id="recent-h"'));
assert.ok(h.includes('ovk-heading-icon') && h.includes('ovk-btn--quiet') && h.includes('id="recent-h"'));
}
}
+
+// A page head keeps its description available without JS and refuses unsafe parent/action URLs.
+{
+ const h = kit.pageHead({ title: '<Record>', icon: 'video', lede: 'One line & all the detail.', back: { label: 'Home', href: 'javascript:bad' }, actions: [{ icon: 'plus', label: 'Create', href: '/new' }] });
+ assert.ok(h.includes('<h1><Record></h1>'));
+ assert.ok(h.includes('href="#"') && !h.includes('javascript:'));
+ assert.ok(!h.includes('<details') && (h.match(/class="ovk-page-lede"/g) || []).length === 1, 'the lede once, whole (no About-this copy)');
+ assert.ok(h.includes('aria-label="Create"') && h.includes('One line & all the detail.'));
+ assert.ok(!kit.pageHead({ title: 'Home' }).includes('<details'));
+}
@@ -41,7 +41,7 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut
@@ -41,7 +41,7 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut
if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });
if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });
if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);
if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);
if (s.startsWith('https://openvibe.community/api/v1/feed')) { window.__feedCalls = (window.__feedCalls || []).concat([s]); const u = new URL(s); const kind = u.searchParams.get('kind') || ''; const page = u.searchParams.get('cursor') ? 2 : 1;
if (s.startsWith('https://openvibe.community/api/v1/feed')) { window.__feedCalls = (window.__feedCalls || []).concat([s]); const u = new URL(s); const kind = u.searchParams.get('kind') || ''; const page = u.searchParams.get('cursor') ? 2 : 1;
- const mk = (i, k) => ({ id: k + i + '-' + page, kind: k, url: 'https://openvibe.video/c/' + i, title: (k === 'post' ? '<b>bold</b> ' : '') + k + ' ' + i + ' page ' + page, text: 'words', at: new Date(Date.now() - i * 60000).toISOString(), site: k === 'pic' ? 'pics' : 'video', site_name: 'Video', actor: { name: 'ada' }, media: k === 'post' || k === 'update' ? {} : { thumb: 'https://openvibe.media/t/x.webp', ratio: 1.7778, length: '0:30' }, counts: { views: 1200, likes: 3 } });
+ const mk = (i, k) => ({ id: k + i + '-' + page, kind: k, live_now: k === 'live', url: 'https://openvibe.video/c/' + i, title: (k === 'post' ? '<b>bold</b> ' : '') + k + ' ' + i + ' page ' + page, text: 'words', at: new Date(Date.now() - i * 60000).toISOString(), site: k === 'pic' ? 'pics' : 'video', site_name: 'Video', actor: { name: 'ada' }, media: k === 'post' || k === 'update' ? {} : { thumb: 'https://openvibe.media/t/x.webp', ratio: 1.7778, length: '0:30' }, counts: { views: 1200, likes: 3 } });
const kinds = kind ? [kind] : ['live', 'clip', 'pic', 'post', 'update'];
const kinds = kind ? [kind] : ['live', 'clip', 'pic', 'post', 'update'];
return J({ items: Array.from({ length: 12 }, (_, i) => mk(i, kinds[i % kinds.length])), next_cursor: page === 1 ? 'c2' : null }); }
return J({ items: Array.from({ length: 12 }, (_, i) => mk(i, kinds[i % kinds.length])), next_cursor: page === 1 ? 'c2' : null }); }
if (s.startsWith('https://openvibe.community/api/v1/pulse')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));
if (s.startsWith('https://openvibe.community/api/v1/pulse')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));
@@ -82,12 +82,12 @@ const server = http.createServer((req, res) => {
@@ -82,12 +82,12 @@ const server = http.createServer((req, res) => {
assert.ok(await until(p, `document.querySelectorAll('.ovf-list > a').length >= 12`), 'the feed painted');
assert.ok(await until(p, `document.querySelectorAll('.ovf-list > a').length >= 12`), 'the feed painted');
const first = await p.evaluate(`({ chips: [...document.querySelectorAll('.ovf-chip')].map((b) => b.textContent), selected: (document.querySelector('.ovf-chip[aria-selected=true]') || {}).textContent,
const first = await p.evaluate(`({ chips: [...document.querySelectorAll('.ovf-chip')].map((b) => b.textContent), selected: (document.querySelector('.ovf-chip[aria-selected=true]') || {}).textContent,
kinds: [...new Set([...document.querySelectorAll('.ovf-list > a')].map((a) => a.className))], live: !!document.querySelector('.ovf-livechip'), media: document.querySelectorAll('.ovf-media img').length,
kinds: [...new Set([...document.querySelectorAll('.ovf-list > a')].map((a) => a.className))], live: !!document.querySelector('.ovf-livechip'), media: document.querySelectorAll('.ovf-media img').length,
- bolds: document.querySelectorAll('.ovf-list b b').length, posts: [...document.querySelectorAll('.ovf-post .ovf-title')].map((b) => b.textContent)[0],
+ bolds: document.querySelectorAll('.ovf-list b b').length, posts: [...document.querySelectorAll('.ovf-k-post .ovf-title')].map((b) => b.textContent)[0],
sections: [...document.querySelectorAll('.ovs-group')].map((g) => g.textContent.trim()).filter(Boolean), scroll: document.documentElement.scrollWidth })`);
sections: [...document.querySelectorAll('.ovs-group')].map((g) => g.textContent.trim()).filter(Boolean), scroll: document.documentElement.scrollWidth })`);
assert.deepStrictEqual(first.chips, ['For you', 'Live', 'Clips', 'Videos', 'Pics', 'Posts', 'Memes', 'Games', 'Updates']);
assert.deepStrictEqual(first.chips, ['For you', 'Live', 'Clips', 'Videos', 'Pics', 'Posts', 'Memes', 'Games', 'Updates']);
assert.strictEqual(first.selected, 'For you');
assert.strictEqual(first.selected, 'For you');
- assert.ok(first.kinds.includes('ovf-card ovf-live') && first.kinds.includes('ovf-update') && first.kinds.includes('ovf-card ovf-pic'), `cards by kind (${first.kinds})`);
- assert.ok(first.live, 'a live card says LIVE');
+ assert.ok(first.kinds.some((c) => /\bovf-k-live\b/.test(c)) && first.kinds.includes('ovf-update') && first.kinds.some((c) => /\bovf-k-pic\b/.test(c)), `cards by kind (${first.kinds})`);
+ assert.ok(first.live, 'a channel live now says LIVE (live_now)');
assert.ok(first.media >= 6, 'media cards show their picture');
assert.ok(first.media >= 6, 'media cards show their picture');
assert.strictEqual(first.bolds, 0, 'what people wrote is text');
assert.strictEqual(first.bolds, 0, 'what people wrote is text');
assert.ok(/^<b>bold<\/b> post/.test(first.posts), 'the markup shows as words');
assert.ok(/^<b>bold<\/b> post/.test(first.posts), 'the markup shows as words');
@@ -98,7 +98,7 @@ const server = http.createServer((req, res) => {
@@ -98,7 +98,7 @@ const server = http.createServer((req, res) => {
assert.ok(await until(p, `!!document.querySelector('.ovf-caught')`), 'the end says so');
assert.ok(await until(p, `!!document.querySelector('.ovf-caught')`), 'the end says so');
await p.evaluate(`[...document.querySelectorAll('.ovf-chip')].find((b) => b.textContent === 'Clips').click(), true`);
await p.evaluate(`[...document.querySelectorAll('.ovf-chip')].find((b) => b.textContent === 'Clips').click(), true`);
assert.ok(await until(p, `(window.__feedCalls || []).some((u) => u.includes('kind=clip'))`), 'the chip asks for its kind');
assert.ok(await until(p, `(window.__feedCalls || []).some((u) => u.includes('kind=clip'))`), 'the chip asks for its kind');
- assert.ok(await until(p, `[...document.querySelectorAll('.ovf-list > a')].every((a) => a.className === 'ovf-card ovf-clip') && document.querySelectorAll('.ovf-list > a').length === 12`), 'only clips, from the first page');
+ assert.ok(await until(p, `[...document.querySelectorAll('.ovf-list > a')].every((a) => a.className === 'ovf-card ovf-k-clip') && document.querySelectorAll('.ovf-list > a').length === 12`), 'only clips, from the first page');
console.log('menu-feed-v2-chrome: chips, cards by kind, text only, the next page, caught up, a chip by kind, the stream alone ok');
console.log('menu-feed-v2-chrome: chips, cards by kind, text only, the next page, caught up, a chip by kind, the stream alone ok');
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+const assert = require('assert');
+const { parseHTML } = require('linkedom');
+const { window } = parseHTML('<!doctype html><html><body><ol id="messages"></ol></body></html>');
+global.document = window.document;
+const { render } = require('../messages');
+const root = document.getElementById('messages');
+render(root, { view: 'bubbles', me: 1, now: new Date('2026-11-09T12:00:00Z'), messages: [
+ { id: 1, sender_id: 2, username: 'bob', display_name: 'Bob', avatar_url: 'https://openvibe.media/fixture/bob.png', created_at: '2026-11-09 11:58:00', message: 'Hi https://openvibe.today/' },
+ { id: 2, sender_id: 2, username: 'bob', display_name: 'Bob', created_at: '2026-11-09 11:59:00', message: '👋' },
+ { id: 3, sender_id: 1, username: 'alice', created_at: '2026-11-09 11:59:30', message: 'Hello' },
+] });
+assert.strictEqual(root.querySelectorAll('.ovm-day').length, 1);
+assert.strictEqual(root.querySelectorAll('.ovm-group').length, 3);
+assert.strictEqual(root.querySelector('.ovm-avatar').getAttribute('src'), 'https://openvibe.media/fixture/bob.png');
+assert.strictEqual(root.querySelector('.ovm-link').getAttribute('href'), 'https://openvibe.today/');
+assert.strictEqual(root.querySelector('.ovm-group[data-mine="true"]').dataset.tail, 'true');
+render(root, { view: 'room', messages: [{ id: 4, username: 'bob', message: 'room line' }] });
+assert.strictEqual(root.querySelector('.ovm-room-text').textContent, 'room line');
+console.log('messages: room and bubbles, groups, dates, links and profile picture ok');
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const upload = require('../ov-upload');
+const files = require('../files');
+
+let steady = 0;
+for (let i = 0; i < 5; i++) steady = upload.rate(steady, 2 * 1024 * 1024, 1);
+assert.strictEqual(steady, 2 * 1024 * 1024);
+assert.strictEqual(upload.timeLeft(120), 'About 2 min left');
+assert.strictEqual(upload.timeLeft(4), 'Almost done');
+
+const burst = upload.rate(steady, 20 * 1024 * 1024, 1);
+assert.ok(burst > steady && burst < 20 * 1024 * 1024, 'one burst is smoothed');
+assert.strictEqual(upload.rate(burst, 0, 1), burst, 'a stalled sample does not become a false zero rate');
+assert.strictEqual(upload.timeLeft(Infinity), 'Estimating time left');
+
+assert.ok(files.isBrowserFile('ov-upload.js') && files.isBrowserFile('ov-upload.css'));
+const css = fs.readFileSync(path.join(__dirname, '../ov-upload.css'), 'utf8');
+assert.match(css, /prefers-reduced-motion: reduce/);
+assert.match(css, /\.ov-upload__input\s*\{[^}]*clip:/s, 'native input is visually hidden');
@@ -203,3 +203,8 @@ console.log('showcase: all checks passed');
@@ -203,3 +203,8 @@ console.log('showcase: all checks passed');
assert.ok(/border-top:1px solid/.test(sc.CSS) && /\.sc-hero\+\.sc-sec/.test(sc.CSS), 'sections stand apart, the first after the hero without a rule');
assert.ok(/border-top:1px solid/.test(sc.CSS) && /\.sc-hero\+\.sc-sec/.test(sc.CSS), 'sections stand apart, the first after the hero without a rule');
assert.ok(/api\/frame\/pulse/.test(sc.NETWORK_JS) && /textContent/.test(sc.NETWORK_JS) && !/innerHTML/.test(sc.NETWORK_JS), 'the live strip reads the Frame and writes text only');
assert.ok(/api\/frame\/pulse/.test(sc.NETWORK_JS) && /textContent/.test(sc.NETWORK_JS) && !/innerHTML/.test(sc.NETWORK_JS), 'the live strip reads the Frame and writes text only');
}
}
+// A product can use the shared page identity while retaining the hero's visual and calls to action.
+{
+ const html = require('../showcase').hero({ title: 'Record a video', lede: 'Review it before sharing.', pageHead: { icon: 'video', back: { href: '/', label: 'Home' } }, visual: false });
+ require('assert').ok(html.includes('ovk-page-head') && html.split('Review it before sharing.').length === 2);
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+const assert = require('assert');
+const card = require('../user-card');
+const menu = card.OpenVibeUserMenu;
+assert.ok(menu && typeof menu.register === 'function');
+const release = menu.register({ id: 'chat-message', label: 'Message', icon: '✉', onSelect() {} });
+assert.strictEqual(typeof release, 'function');
+assert.throws(() => menu.register({ id: '../unsafe', label: '<img>', onSelect() {} }), TypeError);
+release();
+console.log('user menu: site actions register and validate safely');
@@ -12,6 +12,8 @@
@@ -12,6 +12,8 @@
let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };
let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };
let _cardEl = null;
let _cardEl = null;
let _cache = new Map();
let _cache = new Map();
+ const _menuItems = new Map();
+ const escapeHtml = value => String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
function injectStyles() {
function injectStyles() {
if (document.getElementById('openvibe-usercard-styles')) return;
if (document.getElementById('openvibe-usercard-styles')) return;
@@ -140,6 +142,9 @@
@@ -140,6 +142,9 @@
actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;
actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;
actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;
actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;
}
}
+ for (const item of _menuItems.values()) {
+ actions += `<button data-act="site" data-site-id="${escapeHtml(item.id)}"><span class="icon">${escapeHtml(item.icon || '↗')}</span> ${escapeHtml(item.label)}</button>`;
+ }
const card = document.createElement('div');
const card = document.createElement('div');
card.className = 'openvibe-usercard';
card.className = 'openvibe-usercard';
@@ -180,8 +185,11 @@
@@ -180,8 +185,11 @@
card.querySelectorAll('[data-act]').forEach(btn => {
card.querySelectorAll('[data-act]').forEach(btn => {
btn.addEventListener('click', () => {
btn.addEventListener('click', () => {
const act = btn.dataset.act;
const act = btn.dataset.act;
+ const siteItem = act === 'site' ? _menuItems.get(btn.dataset.siteId) : null;
closeCard();
closeCard();
- if (act === 'profile') {
+ if (siteItem) {
+ try { siteItem.onSelect(user); } catch (err) { console.error('[OpenVibeUserMenu] item failed:', err); }
+ } else if (act === 'profile') {
const url = isSelf ? `${_config.apiBase}/my`
const url = isSelf ? `${_config.apiBase}/my`
: `${_config.apiBase}/user/${user.username}`;
: `${_config.apiBase}/user/${user.username}`;
window.open(url, '_blank');
window.open(url, '_blank');
@@ -248,7 +256,26 @@
@@ -248,7 +256,26 @@
},
},
};
};
+ const OpenVibeUserMenu = {
+ /** Add a host site's safe action to the shared user card. Returns an unregister function. */
+ register({ id, label, icon, onSelect } = {}) {
+ const key = String(id || '');
+ if (!/^[a-zA-Z0-9_-]{1,48}$/.test(key) || typeof label !== 'string' || !label.trim() || typeof onSelect !== 'function') {
+ throw new TypeError('register requires an id, label and onSelect function');
+ }
+ const item = { id: key, label: label.trim().slice(0, 80), icon: typeof icon === 'string' ? icon.slice(0, 8) : '', onSelect };
+ _menuItems.set(key, item);
+ return () => { if (_menuItems.get(key) === item) _menuItems.delete(key); };
+ },
+ unregister(id) { return _menuItems.delete(String(id || '')); },
+ attach: (...args) => OpenVibeUserCard.attach(...args),
+ show: (...args) => OpenVibeUserCard.show(...args),
+ close: () => OpenVibeUserCard.close(),
+ };
+
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;
- else root.OpenVibeUserCard = OpenVibeUserCard;
+ if (typeof module !== 'undefined' && module.exports) module.exports.OpenVibeUserMenu = OpenVibeUserMenu;
+ root.OpenVibeUserCard = OpenVibeUserCard;
+ root.OpenVibeUserMenu = OpenVibeUserMenu;
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);