openvibers/openvibe.shared Commits

4.164.0: Chat's two views, the page head, the upload kit (#377)

OP OpenVibers committed 4 days ago · 6f7663cfd1 · 23 files, +616 −26

* feat(shared): add reusable chat message views

* fix(shared): keep user card browser-safe

* Messages: designed (Opus): runs keep the avatar column, initials in each person's colour, room lines wrap like a stream chat

- Bubbles: the network's theme variables (dark and light), runs join on the inner corners, the avatar beside the first
  bubble and its column kept through the run (continued bubbles jumped left), a coloured initial when there is no
  picture, ticks in the accent, sticky day chips with blur.
- Room: one line per message that wraps under the name on a phone (the fixed three-column grid wasted a phone's
  width), each person's name in a steady colour of its own (light and dark tuned).
- Only the newest message animates (a repaint never replays the list); Calm fades.

* Add a shared page header with named back navigation and mobile disclosure

* Let product heroes opt into the shared page header

* Keep page header disclosures inside narrow hero grids

* Page head: designed (Opus): one whole lede, a small back chip, the icon tile of section heads, 44 px actions

The lede was in the page three times (a paragraph, a summary and a disclosure) and cut to one line behind "About this"
on a phone: now it is one paragraph, whole on every screen (readers and search see the same words). The back chip is a
32 px pill with a 44 px hit area, the icon tile matches the section heads' gradient tile, the title tightens, actions
are 44 px rounds. Rebased on 4.162.0.

* Add shared upload picker and progress UI

* Hide desktop drop hint on phones

* Support multi-file queues and drawn completion mark

* Upload kit: designed (Opus): each file's progress in its row, the total above, icons, the network's colours

- The picker: an upload icon tile in the accent (was a typed arrow on a purple gradient), "or drop them here" on
  computers, a solid border and a lift while a file is dragged over.
- Each file is one row: its picture or a file glyph, name and size, an x to remove it (was the word Remove), and its
  own compact progress inside the row (the bars floated between rows).
- The total sits between the picker and the files, only for two files or more.
- Theme variables for dark and light; done turns the bar green with a drawn tick; Calm drops the sheen.
Rebased on 4.162.0.

* 4.164.0: Chat's two views, the page head, the upload kit (designed)

* Feed: LIVE only while live now, past go-lives fold into one card, trending cards say so, video cards stay in place

With Community's feed live (#307), the menu's Feed showed LIVE on streams from days ago, a wall of identical go-live
cards, "?" on the network's trending pictures, and video cards pulled out of the flow (their kind class, ovf-video,
was the preview video's absolute-positioned class). Now: LIVE from live_now (Community #309), runs of past go-lives as
one card of faces and names, a site icon and "Trending" on cards with no person, and cards classed ovf-k-<kind>.

* Room chips: the class first, then the scroll hint (the room markup sites and tests read stays the same)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a0fa58d..86c8a5f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 3bf8c16..a92e421 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 54c5b29..bfc9f25 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/docs/kit.md b/docs/kit.md
iindex 915cf4f..fb90d32 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -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. It
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.
ddiff --git a/files.js b/files.js
iindex d36fb35..9e05488 100644
---- a/files.js
++++ b/files.js
@@@ -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)
ddiff --git a/kit.css b/kit.css
iindex 7e23ffe..2adb01d 100644
---- a/kit.css
++++ b/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}
ddiff --git a/kit.js b/kit.js
iindex 4b895a9..6c65f01 100644
---- a/kit.js
++++ b/kit.js
@@@ -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,
ddiff --git a/messages.css b/messages.css
nnew file mode 100644
iindex 0000000..5ab3164
---- /dev/null
++++ b/messages.css
@@@ -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; } }
ddiff --git a/messages.js b/messages.js
nnew file mode 100644
iindex 0000000..efb22ea
---- /dev/null
++++ b/messages.js
@@@ -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;
++}
ddiff --git a/navbar.js b/navbar.js
iindex 33cbabc..044aa4c 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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">.
ddiff --git a/ov-home.js b/ov-home.js
iindex ed4e0fa..249360a 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -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);
}
ddiff --git a/ov-upload.css b/ov-upload.css
nnew file mode 100644
iindex 0000000..e66d89e
---- /dev/null
++++ b/ov-upload.css
@@@ -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; } }
ddiff --git a/ov-upload.js b/ov-upload.js
nnew file mode 100644
iindex 0000000..591e902
---- /dev/null
++++ b/ov-upload.js
@@@ -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 };
++});
ddiff --git a/package-lock.json b/package-lock.json
iindex 1fad4d1..d251d54 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 84f853e..f33a5af 100644
---- a/package.json
++++ b/package.json
@@@ -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": [
ddiff --git a/showcase.js b/showcase.js
iindex 816a734..f669856 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -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);
ddiff --git a/test/kit.test.js b/test/kit.test.js
iindex 919fe9b..9d1afe5 100644
---- a/test/kit.test.js
++++ b/test/kit.test.js
@@@ -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>'] });
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>&lt;Record&gt;</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 &amp; all the detail.'));
++ assert.ok(!kit.pageHead({ title: 'Home' }).includes('<details'));
++}
ddiff --git a/test/menu-feed-v2-chrome.test.js b/test/menu-feed-v2-chrome.test.js
iindex e90eb41..409213a 100644
---- a/test/menu-feed-v2-chrome.test.js
++++ b/test/menu-feed-v2-chrome.test.js
@@@ -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/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); });
ddiff --git a/test/messages.test.js b/test/messages.test.js
nnew file mode 100644
iindex 0000000..247af40
---- /dev/null
++++ b/test/messages.test.js
@@@ -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');
ddiff --git a/test/ov-upload.test.js b/test/ov-upload.test.js
nnew file mode 100644
iindex 0000000..24e534a
---- /dev/null
++++ b/test/ov-upload.test.js
@@@ -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');
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 5645be7..9c14249 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -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);
++}
ddiff --git a/test/user-menu-register.test.js b/test/user-menu-register.test.js
nnew file mode 100644
iindex 0000000..64b6330
---- /dev/null
++++ b/test/user-menu-register.test.js
@@@ -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');
ddiff --git a/user-card.js b/user-card.js
iindex 143d5b5..a15b478 100644
---- a/user-card.js
++++ b/user-card.js
@@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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);