openvibers/openvibe.shared Commits

4.241.0: the composer's + is what you can add; the feed reads like posts by people (#525)

OP OpenVibers committed 28 min ago · b63b600efa · 17 files, +566 −74

* 4.240.0: one agent in the menu's Vibe, and a menu button drawn from the menu

The owner, 11-13: "all this separation of tasks and codes and chats … It's just like filters … your actor is just
your agent … it can orchestrate to openvibe.codes, or timer, task, desktop, browser"; "I tried typing test in and
just like nothing happens"; "regenerate this icon … based on what the menu actually looks like … glow".

- ov-run.js: your agent heads the list (face, state, today's spend, Pause or Resume); filters are chips over one list;
  one box starts everything and the thread opens at once with your words and your agent typing; a handed-on run says
  where it went; a failed start returns the words with why; Now shows on every filter; words carry into the shared
  composer.
- search.js: the menu's Vibe no longer lends the Dock's Chat, Agents and Code panels or its Actor card (only Now and
  the live agent view, dock.js part 'live'); Ask your agent fills the one box.
- navbar.js: the menu button is the menu, small (ask bar with your face, words and spark over the place tabs, one
  lit), with a walking light, glow, twinkle and the fold into the close mark.
- Tests: run-thread-chrome, dock-chrome (both halves).

* 4.240.0: Calm slows the menu button's light and spark (only Off stills them); navbar budget 52.6 KB; the slot shows on every filter

* WIP 4.241.0: the composer's + as tiles and rows

* 4.241.0: the feed as posts by people (who, level, name effect, Comment and Share, deal listings), chips with glyphs; version and changelog

* 4.241.0: the composer's new hash in its loaders

* wallet-chrome: measure the buttons after the opening animation settles (it failed about one run in four)

* Merge branch 'ui-4240' into ui-4241

* Merge remote-tracking branch 'origin/main' into ui-4241

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	navbar.js
#	package-lock.json
#	package.json

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4d317f0..479eb91 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,39 @@
# Changelog
++## 4.241.0 — 2026-11-13
++
++**The composer's + is what you can add, and the feed reads like posts by people** (the owner, 11-13: "the plus button
++on the composer … camera, photos, files … our own custom style … a modular system … it can still open this choose an
++action … share … each site can choose … highly configurable"; on openvibe.community's feed: "add icons to those [tags]
++and make those buttons nicer … make it more like a social media … it should say who posted it … their level … their
++username effects … update the listings for deals … way better on mobile, but also great on desktop").
++
++- **The + (ov-composer.js).**
++ - Big tiles for what you add: Camera (the shared capture core), Photos (pictures and videos), Files (any file; on
++ Android the system's Choose an action). Then rows: Paste (a picture or words you copied), Screenshot (a screen,
++ window or tab; desktop), Camera app (the phone's own camera), Share to an app (what you wrote, with its files),
++ Timer, GitHub, a site's own tools, and what a narrow row hides. Each row says what it does in a second line.
++ - A phone gets a sheet from the bottom (the top layer: no panel clips it; swipe it down, tap outside, or the ×); a
++ wide screen a panel under the row with the rows in columns. Tiles rise in turn; a press sinks them.
++ - What the + holds as a tile is not also a button in the row (rule: never twice); a file tool's input stays in the
++ form so the files still go with it.
++ - Configurable: `mount(host, { plus: { tiles, rows, only } })`, a form's `data-ovc-plus`, the site's
++ `OpenVibeComposer.configure({ plus, tools })`; a tool says where it fits with `sub`, `tile`, `accept`, `capture`
++ and `when(env)` (env: files, anyFile, touch, camera, screen, share, clip). `noPlus` leaves the + out.
++- **The feed (ov-home.js).**
++ - Each chip has its glyph (For you, Following, Live, Videos, Clips, Pics, Posts, Rundown, Memes, Games, Chat,
++ Events, Jobs, Deals, Stats, Wiki, Quests, Updates); the chosen one is tinted, not a white block.
++ - A person's card names them: their face, their name wearing its effect (`actor.fx`, drawn by items.css; Inventory
++ by `subject` where the page has items.js), their level badge, @handle, the site and when (feed.item@1 1.1.0,
++ Contracts 0.425.0). "System" is nobody: a site's own card is the site's name and what it is, never
++ "Community · Community".
++ - A post, a picture, a clip, a video, a meme or the Rundown is its card and, under it (outside its link), Comment
++ with the count, likes, and Share (the phone's share sheet, or the link copied).
++ - A run of deals is a listing: each with its picture (or a tag), its price, the old price struck through with the
++ saving, and the store; two columns on a wide screen; "See all 37 deals".
++- Tests: `composer-plus-chrome` (new: tiles and rows, the sheet and the panel, per box and per site, a site's tool,
++ picked files in the box); `composer-v2-chrome`, `dock-chrome` and `menu-feed-v2-chrome` follow the new markup.
++
## 4.240.0 — 2026-11-13
**One agent in the menu's Vibe, and a menu button drawn from the menu** (the owner, 11-13: "all this separation of
ddiff --git a/README.md b/README.md
iindex 1010d28..13703da 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.240.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.241.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 47e7b11..b462598 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.240.0",
++ "version": "4.241.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 9801860..20d8d4e 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -114,7 +114,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
-- const VIBE_REV = { 'ov-composer.js': '79b34aff4452' };
++ const VIBE_REV = { 'ov-composer.js': '8a4f9f30bb39' };
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex 2b9d22a..0d7b7a5 100644
---- a/dock.js
++++ b/dock.js
@@@ -84,7 +84,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': '654261348535', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
++ const DOCK_REV = { 'dock-vibe.js': 'ca316ecbc9c3', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/navbar.js b/navbar.js
iindex cfefe0f..874454d 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -624,7 +624,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '73c7387a6ec5', 'dock.js': '8de9086a2cfd', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '4e7d405117de', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '34f19495bc12', 'ov-composer.js': '79b34aff4452' };
++ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '73c7387a6ec5', 'dock.js': '9cf40600859c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '39decf9a5ce5', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '34f19495bc12', 'ov-composer.js': '8a4f9f30bb39' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 0859dfd..39dd147 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -26,6 +26,15 @@
* ids a mod's upload gave, clear() after the send (the text, the chips, the draft); a form 'reset' clears the same.
* A site without script says the same in data: data-ovt-commands='[{"name","hint","insert"}]', data-ovc-max-files,
* data-ovc-max-mb, data-ovc-no-draft.
++ *
++ * 4.241.0, the +: one sheet of what you can add. Big tiles first (Camera, Photos, Files), then rows (Paste, Screenshot,
++ * the phone's own camera app, Share to an app, Timer, GitHub, the mods' tools, what a narrow row hides). A phone gets
++ * it as a sheet from the bottom (swipe it down, or tap outside); a wide screen as a panel under the box's row.
++ * Which ones: mount(host, { plus: { tiles: [ids], rows: [ids], only } }), the form's data-ovc-plus='{"tiles":…}',
++ * or the site's OpenVibeComposer.configure({ plus }); `only: true` leaves out the extras not named.
++ * A tool says where it fits: OpenVibeComposer.tool(id, { label, sub, tile, path, accept, capture, when(env), run })
++ * (env: files, anyFile, touch, camera, screen, share, clip). A tool with `accept` (and `capture`) is a file picker:
++ * on Android, a picker with no `accept` is the system's Choose an action (Camera, Camcorder, Files).
*/
(function (root) {
'use strict';
@@@ -346,6 +355,52 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
.ovc-tray-i:active .ovc-tray-ic { transform: scale(.92); }
.ovc-tray-i:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
@media (hover: hover) and (pointer: fine) { .ovc-tray-i:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } .ovc-tray-i:hover .ovc-tray-ic { transform: translateY(-2px); } }
++/* 4.241.0, the +: tiles for what you add, rows for the rest; a sheet from the bottom on a phone. */
++.ovc-add-b { display: grid; gap: 12px; }
++.ovc-add-tiles { display: grid; grid-template-columns: repeat(var(--n, 3), minmax(0, 1fr)); gap: 10px; }
++.ovc-add-tile { all: unset; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; min-height: 96px; padding: 14px 6px 12px; border-radius: 20px; cursor: pointer; text-align: center; -webkit-tap-highlight-color: transparent;
++ background: linear-gradient(160deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent));
++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent), 0 1px 0 color-mix(in srgb, #fff 6%, transparent) inset;
++ animation: ovc-add-in .34s cubic-bezier(.2, .9, .3, 1.15) both; animation-delay: calc(var(--k, 0) * 35ms); transition: transform .18s cubic-bezier(.3, 1.4, .4, 1), box-shadow .2s, background-color .2s; }
++.ovc-add-tile .ovc-add-ic { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 15px; color: var(--ovt-accent, var(--accent-light, var(--accent, #60a5fa)));
++ background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 32%, transparent), color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 10%, transparent)); box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 70%, transparent); }
++.ovc-add-tile .ovc-add-ic svg { width: 26px; height: 26px; }
++.ovc-add-t { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 15px; font-weight: 800; line-height: 1.15; color: var(--text-primary, #e6edf7); }
++.ovc-add-tile:active { transform: scale(.94); }
++.ovc-add-rows { display: grid; gap: 4px; }
++.ovc-add-row { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 10px 8px 8px; border-radius: 16px; cursor: pointer; -webkit-tap-highlight-color: transparent;
++ background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); animation: ovc-add-in .34s cubic-bezier(.2, .9, .3, 1.15) both; animation-delay: calc(var(--k, 0) * 30ms); transition: transform .18s cubic-bezier(.3, 1.4, .4, 1), background-color .2s; }
++.ovc-add-row .ovc-add-ic { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
++.ovc-add-row .ovc-add-ic svg { width: 21px; height: 21px; }
++.ovc-add-rt { flex: 1; min-width: 0; display: grid; gap: 1px; }
++.ovc-add-rt b { font-size: 15px; font-weight: 700; color: var(--text-primary, #e6edf7); }
++.ovc-add-rt small { font-size: 12.5px; color: var(--text-muted, #8b98ad); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovc-add-go { flex: none; width: 16px; height: 16px; color: var(--text-muted, #8b98ad); transition: transform .2s; }
++.ovc-add-row:active { transform: scale(.98); }
++.ovc-add-tile:focus-visible, .ovc-add-row:focus-visible, .ovc-add-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++@media (hover: hover) and (pointer: fine) {
++ .ovc-add-tile:hover { transform: translateY(-2px); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent), 0 10px 24px -14px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 80%, transparent); }
++ .ovc-add-row:hover { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 10%, transparent); }
++ .ovc-add-row:hover .ovc-add-go { transform: translateX(2px); color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
++}
++@keyframes ovc-add-in { from { opacity: 0; transform: translateY(8px) scale(.97); } }
++.ovc-add-inline { padding-top: 12px; }
++.ovc-add-inline .ovc-add-tiles { grid-template-columns: repeat(var(--n, 3), minmax(0, 150px)); }
++.ovc-add-inline .ovc-add-tile { min-height: 88px; }
++.ovc-add-inline .ovc-add-rows { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
++dialog.ovc-add { padding: 0 14px calc(18px + env(safe-area-inset-bottom)); max-height: 88vh; overflow: auto; transition: transform .28s cubic-bezier(.2, .9, .3, 1); animation: ovc-add-up .32s cubic-bezier(.2, .9, .3, 1.05) both; }
++dialog.ovc-add::backdrop { background: rgba(2, 6, 14, .55); animation: ovc-fade-calm .25s ease both; }
++dialog.ovc-add.is-out { transform: translateY(110%); }
++dialog.ovc-add.is-out::backdrop { opacity: 0; transition: opacity .2s; }
++@keyframes ovc-add-up { from { transform: translateY(100%); } }
++.ovc-add-grip { width: 40px; height: 5px; margin: 8px auto 2px; border-radius: 3px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 26%, transparent); touch-action: none; }
++.ovc-add-h { position: relative; display: flex; align-items: center; justify-content: center; min-height: 48px; margin-bottom: 6px; touch-action: none; }
++.ovc-add-h b { font: 800 17px/1 'OV Brand', Nunito, Inter, sans-serif; color: var(--text-primary, #e6edf7); }
++.ovc-add-x { all: unset; position: absolute; left: 0; top: 50%; translate: 0 -50%; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; cursor: pointer; color: var(--text-secondary, #a8b3c4); }
++.ovc-add-x svg { width: 22px; height: 22px; }
++html[data-ov-motion=reduced] :is(.ovc-add-tile, .ovc-add-row) { animation-name: ovc-fade-calm; animation-delay: 0s; }
++html[data-ov-motion=reduced] dialog.ovc-add { animation-name: ovc-fade-calm; }
++html[data-ov-motion=off] :is(.ovc-add-tile, .ovc-add-row, dialog.ovc-add, dialog.ovc-add::backdrop) { animation: none; transition: none; }
@keyframes ovc-tray-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; }
@keyframes ovc-fade-calm { from { opacity: 0; } }
@@@ -427,6 +482,35 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
if (!blob || blob.size >= file.size) return file;
try { return new File([blob], file.name, { type: file.type, lastModified: file.lastModified }); } catch { return file; }
}
++ // 4.241.0: the +'s glyphs, the registry's line drawings (glyphs.js), and what a site chose for its boxes' +.
++ const GL = {
++ camera: '<path d="M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z"/><circle cx="12" cy="13.3" r="3.4"/>',
++ image: '<rect x="3.5" y="5.4" width="16.7" height="13" rx="2.8"/><circle cx="9.1" cy="10" r="1.5"/><path d="M20.3 15.6l-4.7-4.7l-7.4 7.4"/>',
++ file: '<path d="M6.4 3.5h7.6l3.8 3.8v13.2H6.4Z"/><path d="M13.9 3.5v3.8h3.8"/>',
++ folder: '<path d="M3.8 7.3a1.9 1.9 0 0 1 1.9-1.9h3.6l1.9 1.9h7.4a1.9 1.9 0 0 1 1.9 1.9v7.4a1.9 1.9 0 0 1-1.9 1.9H5.8a1.9 1.9 0 0 1-1.9-1.9Z"/>',
++ clipboard: '<rect x="5.7" y="5" width="12.6" height="15.5" rx="1.9"/><path d="M9.1 5V3.5h5.8V5M9.1 10.8h5.8M9.1 14.7h3.9"/>',
++ computer: '<rect x="3.5" y="4.5" width="17" height="11.3" rx="1.9"/><path d="M8.2 19.7h7.6M12 15.9v3.8"/>',
++ share: '<path d="M12 15.2V3.3M7.4 8.1L12 3.3L16.6 8.1M4.9 12.9v6.1a1 1 0 0 0 1 1h12.2a1 1 0 0 0 1-1v-6.1"/>',
++ video: '<rect x="3.4" y="6.4" width="12.3" height="11.3" rx="1.9"/><path d="M15.6 10.2l4.7-2.8v9.4l-4.7-2.8"/>',
++ chevron: '<path d="M7.9 3.6l8.4 8.4l-8.4 8.4"/>',
++ };
++ const gsvg = (inner) => `<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
++ const PLUS_DEFAULT = { tiles: ['camera', 'photo', 'file'], rows: ['paste', 'screenshot', 'camapp', 'share', 'timer', 'github'], only: false };
++ const CONFIG = { plus: null };
++ function configure(o = {}) {
++ if (o && o.plus && typeof o.plus === 'object') CONFIG.plus = cleanPlus(o.plus);
++ if (o && o.tools && typeof o.tools === 'object') Object.keys(o.tools).forEach((id) => tool(id, o.tools[id]));
++ return { plus: CONFIG.plus };
++ }
++ function cleanPlus(p) {
++ const ids = (v, n) => (Array.isArray(v) ? v.map(String).filter((x) => /^[\w-]{1,30}$/.test(x)).slice(0, n) : null);
++ const out = {};
++ const t = ids(p.tiles, 4), r = ids(p.rows, 16);
++ if (t) out.tiles = t;
++ if (r) out.rows = r;
++ if (typeof p.only === 'boolean') out.only = p.only;
++ return out;
++ }
const live = new Set();
const PREFS_KEY = 'ov:composer';
const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
@@@ -898,45 +982,120 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
// Voice: the browser listens, the words go in at the caret as they come.
let mic = null, rec = null;
-- // 4.223.0, the +: every tool of this box in one sheet (its row's own, the mods', and the extras such as Timer), big
-- // and named. On a narrow box the row keeps the mic, the + and the first tool; the sheet has the rest.
++ // 4.241.0, the +: what you can add, in one sheet (see the header). Big tiles, then rows; a sheet from the bottom on
++ // a phone (the top layer, so no panel clips it), a panel under the row on a wide screen.
let more = null, sheet = null;
-- function closeSheet() { if (sheet) { sheet.remove(); sheet = null; } if (more) more.setAttribute('aria-expanded', 'false'); }
++ const motionOff = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
++ const env = () => ({
++ files: canFile(), anyFile: !noFiles && (!!uploader() || inputs().some((i) => !i.getAttribute('accept'))),
++ touch: !!(root.matchMedia && root.matchMedia('(pointer: coarse)').matches),
++ camera: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
++ screen: !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia),
++ share: typeof navigator.share === 'function', clip: !!(navigator.clipboard && navigator.clipboard.read),
++ });
++ function plusSpec() {
++ let own = null; try { own = JSON.parse(form.dataset.ovcPlus || 'null'); } catch { own = null; }
++ return Object.assign({}, PLUS_DEFAULT, CONFIG.plus || {}, own && typeof own === 'object' ? cleanPlus(own) : {});
++ }
++ const iconOf = (t) => (t.glyph && GL[t.glyph] ? gsvg(GL[t.glyph]) : svg(PATH_OK.test(t.path || '') ? t.path : IC.slash));
++ function closeSheet(now) {
++ if (!sheet) return;
++ const s0 = sheet; sheet = null;
++ if (more) more.setAttribute('aria-expanded', 'false');
++ // A tool that runs closes it at once (a modal sheet keeps the page inert while it slides away); dismissed, it
++ // slides down first.
++ if (s0.tagName === 'DIALOG') { if (now === true || motionOff()) { try { s0.close(); } catch { /* */ } s0.remove(); } else { s0.classList.add('is-out'); setTimeout(() => { try { s0.close(); } catch { /* */ } s0.remove(); }, 200); } }
++ else s0.remove();
++ }
++ /** A file tool: the browser's picker for its kinds; what is picked joins the box. */
++ function pick(t) {
++ const i = document.createElement('input'); i.type = 'file'; i.multiple = true;
++ if (t.accept) i.accept = t.accept;
++ if (t.capture) i.setAttribute('capture', t.capture);
++ i.tabIndex = -1; i.setAttribute('aria-hidden', 'true');
++ i.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0';
++ i.addEventListener('change', () => { const list = [...(i.files || [])]; i.remove(); if (list.length) ctx.add(list); }, { once: true });
++ document.body.appendChild(i);
++ i.click();
++ setTimeout(() => { if (i.isConnected) i.remove(); }, 300000);
++ }
++ function runTool(t) { closeSheet(true); try { if (typeof t.run === 'function') t.run(ctx, more, form); else pick(t); } catch (err) { console.error(err); } }
function openSheet(ev) {
if (sheet) { closeSheet(); return; }
-- sheet = el('div', 'ovc-tray'); sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Tools');
-- const grid = el('div', 'ovc-tray-grid');
-- const add = (label, path, act, drawn) => {
-- const b = el('button', 'ovc-tray-i', `<span class="ovc-tray-ic">${drawn ? '' : svg(PATH_OK.test(path || '') ? path : IC.slash)}</span><span class="ovc-tray-t"></span>`);
-- if (drawn) { const c = drawn.cloneNode(true); c.removeAttribute('width'); c.removeAttribute('height'); c.removeAttribute('class'); b.firstChild.appendChild(c); } // the button's own whole drawing
-- b.type = 'button'; b.querySelector('.ovc-tray-t').textContent = label;
-- b.addEventListener('click', () => { closeSheet(); try { act(); } catch (e) { console.error(e); } });
-- grid.appendChild(b);
++ const e = env(), spec = plusSpec();
++ const ok = (id) => { const t = TOOL[id]; return !!t && !t.builtin && !form.hasAttribute(`data-ovc-no-${id}`) && (typeof t.when !== 'function' || !!t.when(e)) && (typeof t.run === 'function' || t.accept !== undefined); };
++ const body = el('div', 'ovc-add-b');
++ const tileIds = spec.tiles.filter(ok).slice(0, 4);
++ if (tileIds.length) {
++ const g = el('div', 'ovc-add-tiles'); g.style.setProperty('--n', String(tileIds.length));
++ tileIds.forEach((id, k) => {
++ const t = TOOL[id];
++ const b = el('button', 'ovc-add-tile', `<span class="ovc-add-ic">${iconOf(t)}</span><span class="ovc-add-t"></span>`);
++ b.type = 'button'; b.dataset.tool = id; b.style.setProperty('--k', String(k));
++ b.querySelector('.ovc-add-t').textContent = t.tile || t.label;
++ b.setAttribute('aria-label', t.sub ? `${t.tile || t.label}: ${t.sub}` : (t.tile || t.label));
++ b.addEventListener('click', () => runTool(t));
++ g.appendChild(b);
++ });
++ body.appendChild(g);
++ }
++ const list = el('div', 'ovc-add-rows');
++ const seen = new Set(tileIds.map((id) => TOOL[id].label));
++ const row = (key, label, sub, icon, act) => {
++ if (!label || seen.has(label)) return; seen.add(label);
++ const b = el('button', 'ovc-add-row', `<span class="ovc-add-ic">${icon}</span><span class="ovc-add-rt"><b></b><small></small></span>${gsvg(GL.chevron).replace('<svg ', '<svg class="ovc-add-go" ')}`);
++ b.type = 'button'; b.dataset.tool = key; b.style.setProperty('--k', String(list.children.length + tileIds.length));
++ b.querySelector('b').textContent = label;
++ const sm = b.querySelector('small'); if (sub) sm.textContent = sub; else sm.remove();
++ b.addEventListener('click', act);
++ list.appendChild(b);
};
-- const seen = new Set();
++ spec.rows.filter(ok).forEach((id) => { const t = TOOL[id]; row(id, t.label, t.sub, iconOf(t), () => runTool(t)); });
++ // What this box's row hides on a narrow screen, and the mods' tools, follow.
[...tools.children].forEach((t) => {
if (t === mic || t === more || !t.matches('.ovt-btn, button, label') || t.matches('[type="submit"], [data-ovt-send], .ovc-mic, .ovdk-mic, .ovdk-go, [hidden]')) return;
-- const label = t.getAttribute('aria-label') || t.title || ''; if (!label || seen.has(label)) return; seen.add(label);
-- // 4.234.0: a tool the row shows is not repeated in the sheet (the owner, 11-13: "why twice"); the sheet has
-- // what a narrow row hides, and the extras.
-- if (getComputedStyle(t).display !== 'none' && t.getClientRects().length) return;
++ const label = t.getAttribute('aria-label') || t.title || '';
++ if (!label || (getComputedStyle(t).display !== 'none' && t.getClientRects().length)) return;
const input = t.querySelector('input[type=file]');
-- add(label, '', () => { if (input) input.click(); else t.click(); }, t.querySelector('svg'));
-- });
-- Object.keys(TOOL).forEach((id) => {
-- const t = TOOL[id]; if (!t || !t.extra || seen.has(t.label) || form.hasAttribute(`data-ovc-no-${id}`)) return; seen.add(t.label);
-- add(t.label, t.path, () => t.run(ctx, more, form));
++ const drawn = t.querySelector('svg');
++ row(`row-${label}`, label, '', drawn ? drawn.outerHTML : svg(IC.slash), () => { closeSheet(true); if (input) input.click(); else t.click(); });
});
-- sheet.appendChild(grid);
-- form.appendChild(sheet); // inside the box, under its row: nothing can clip it
++ if (!spec.only) Object.keys(TOOL).forEach((id) => { const t = TOOL[id]; if (t && t.extra && !spec.rows.includes(id) && ok(id)) row(id, t.label, t.sub, iconOf(t), () => runTool(t)); });
++ if (list.children.length) body.appendChild(list);
++ if (!body.children.length) return;
++ const narrow = (root.innerWidth || 1024) <= 600 || (e.touch && (root.innerWidth || 1024) < 760);
++ if (narrow && typeof HTMLDialogElement === 'function') {
++ const dlg = el('dialog', 'ovc-sheet ovc-add');
++ dlg.setAttribute('aria-label', 'Add to this');
++ dlg.innerHTML = `<div class="ovc-add-grip" aria-hidden="true"></div><div class="ovc-add-h"><b>Add</b><button type="button" class="ovc-add-x" aria-label="Close">${svg(IC.x)}</button></div>`;
++ dlg.appendChild(body);
++ dlg.querySelector('.ovc-add-x').addEventListener('click', () => { closeSheet(); if (more) more.focus(); });
++ dlg.addEventListener('click', (e2) => { if (e2.target === dlg) closeSheet(); });
++ dlg.addEventListener('cancel', (e2) => { e2.preventDefault(); closeSheet(); });
++ // Swipe it down to close (the grip and the header).
++ let y0 = null, dy = 0;
++ const head = dlg.querySelector('.ovc-add-h'), grip = dlg.querySelector('.ovc-add-grip');
++ const down = (p) => { y0 = p.clientY; dy = 0; dlg.style.transition = 'none'; };
++ const moveY = (p) => { if (y0 == null) return; dy = Math.max(0, p.clientY - y0); dlg.style.transform = `translateY(${dy}px)`; };
++ const up = () => { if (y0 == null) return; y0 = null; dlg.style.transition = ''; if (dy > 80) closeSheet(); else dlg.style.transform = ''; };
++ [head, grip].forEach((h0) => { h0.addEventListener('pointerdown', down); });
++ dlg.addEventListener('pointermove', moveY); dlg.addEventListener('pointerup', up); dlg.addEventListener('pointercancel', up);
++ document.body.appendChild(dlg);
++ if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
++ sheet = dlg;
++ } else {
++ sheet = el('div', 'ovc-tray ovc-add-inline'); sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Add to this');
++ sheet.appendChild(body);
++ form.appendChild(sheet); // inside the box, under its row: nothing can clip it
++ sheet.addEventListener('keydown', (e2) => { if (e2.key === 'Escape') { closeSheet(); more.focus(); } });
++ }
more.setAttribute('aria-expanded', 'true');
// From the keyboard (a click with no pointer) focus moves into the sheet; a tap leaves it on the +.
-- if (ev && ev.detail === 0) { const first = sheet.querySelector('button'); if (first) first.focus(); }
-- sheet.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeSheet(); more.focus(); } });
++ if (ev && ev.detail === 0) { const first = sheet.querySelector('.ovc-add-tile, .ovc-add-row'); if (first) first.focus(); }
}
if (tools && !form.hasAttribute('data-ovc-no-more')) {
more = el('button', 'ovt-btn ovc-more', svg(IC.plus));
-- more.type = 'button'; more.setAttribute('aria-label', 'More: attach, timer and tools'); more.title = 'More';
++ more.type = 'button'; more.setAttribute('aria-label', 'Add: camera, photos, files and more'); more.title = 'Add';
more.setAttribute('aria-haspopup', 'dialog'); more.setAttribute('aria-expanded', 'false');
more.addEventListener('click', openSheet);
const lead = tools.querySelector(':scope > .ovdk-mic'); // a page's own mic stays first
@@@ -1052,10 +1211,10 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
};
const EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');
const TOOL = {
-- photo: { label: 'Photos and video', path: IC.photo, accept: 'image/*,video/*' },
++ photo: { label: 'Photos and video', tile: 'Photos', sub: 'Pictures and videos', glyph: 'image', path: IC.photo, accept: 'image/*,video/*', when: (e) => e.files },
// 4.223.0: in every box's + (extra: never in a row of its own). Your timers are Vibe's: one list on every site.
timer: {
-- label: 'Timer', path: IC.timer, extra: true,
++ label: 'Timer', sub: 'Counts down on every OpenVibe site', path: IC.timer, extra: true,
run(ctx, button) {
const D = root.OpenVibeDock;
if (D && typeof D.timer === 'function') { try { D.timer(button); return; } catch (e) { console.error(e); } }
@@@ -1064,8 +1223,51 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
ctx.note('Sign in to set timers: they ring on every OpenVibe site.');
},
},
-- file: { label: 'Files', path: IC.file, accept: '' },
++ file: { label: 'Files', sub: 'Any file from this device', glyph: 'file', path: IC.file, accept: '', when: (e) => e.files && e.anyFile },
attach: { label: 'Attach', path: IC.clip, accept: '' },
++ // 4.241.0: the +'s rows. The phone's own camera app (its chooser on Android), what you copied, a picture of a
++ // screen, and what you wrote sent to another app.
++ camapp: { label: 'Camera app', sub: 'Your phone\u2019s own camera, or its chooser', glyph: 'video', path: IC.camera, accept: 'image/*,video/*', capture: 'environment', extra: true, when: (e) => e.files && e.touch },
++ paste: {
++ label: 'Paste', sub: 'A picture or words you copied', glyph: 'clipboard', path: IC.file, extra: true, when: (e) => e.clip,
++ async run(ctx) {
++ let items = [];
++ try { items = await navigator.clipboard.read(); } catch { ctx.note('This browser did not share what you copied. Press and hold in the box, then Paste.'); return; }
++ const found = [];
++ for (const it of items) {
++ const pic = it.types.find((t) => /^image\//.test(t));
++ if (pic) { const b = await it.getType(pic); found.push(new File([b], `pasted.${pic.split('/')[1].replace('jpeg', 'jpg')}`, { type: pic })); continue; }
++ if (it.types.includes('text/plain')) { const t = await (await it.getType('text/plain')).text(); if (t) ctx.insert(t); }
++ }
++ if (found.length) ctx.add(found); else if (!items.length) ctx.note('Nothing is copied right now.');
++ },
++ },
++ screenshot: {
++ label: 'Screenshot', sub: 'A picture of a screen, a window or a tab', glyph: 'computer', path: IC.photo, extra: true, when: (e) => e.files && e.screen && !e.touch,
++ async run(ctx) {
++ let stream = null;
++ try {
++ stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
++ const v = document.createElement('video'); v.muted = true; v.playsInline = true; v.srcObject = stream;
++ await v.play(); await new Promise((r) => setTimeout(r, 250));
++ const c = document.createElement('canvas'); c.width = v.videoWidth; c.height = v.videoHeight;
++ c.getContext('2d').drawImage(v, 0, 0);
++ const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
++ if (blob) ctx.add([new File([blob], `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`, { type: 'image/png' })]);
++ } catch (err) { if (err && err.name !== 'NotAllowedError') ctx.note('The screenshot did not work in this browser.'); }
++ finally { if (stream) stream.getTracks().forEach((t) => t.stop()); }
++ },
++ },
++ share: {
++ label: 'Share to an app', sub: 'Send what you wrote somewhere else', glyph: 'share', path: IC.ext, extra: true, when: (e) => e.share,
++ async run(ctx) {
++ const text = String(ctx.value || '').trim(), files = ctx.files();
++ if (!text && !files.length) { ctx.note('Write something or add a file first, then share it.'); return; }
++ const data = text ? { text } : {};
++ if (files.length && navigator.canShare && navigator.canShare({ files })) data.files = files;
++ try { await navigator.share(data); } catch { /* the person closed it */ }
++ },
++ },
emoji: {
label: 'Emoji', path: IC.smile,
run(ctx, button, form) {
@@@ -1085,9 +1287,9 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
},
// 4.225.0: a repository from GitHub, found through OpenVibe Exchange's code search (the Frame's
// /api/frame/code/search): its address goes into the box, where every site draws it as a card.
-- github: { label: 'GitHub', path: IC.repo, extra: true, run: (ctx, button, form) => openRepoSearch(ctx, button, form) },
++ github: { label: 'GitHub', sub: 'A repository, as a card', path: IC.repo, extra: true, run: (ctx, button, form) => openRepoSearch(ctx, button, form) },
voice: { label: 'Speak', builtin: true }, // enhance() draws the microphone where the browser can listen
-- camera: { label: 'Camera', path: IC.camera, run: (ctx, button) => openCamera(ctx, button) },
++ camera: { label: 'Camera', sub: 'A photo or a clip, here', glyph: 'camera', path: IC.camera, run: (ctx, button) => openCamera(ctx, button), when: (e) => e.files && e.camera },
};
// The camera (4.217.0): a photo or a clip straight into the box, through the shared capture core. A sheet over the
// page (the whole screen on a phone): the picture, Photo or Video, the shutter, flip and close. Nothing is kept or
@@@ -1277,9 +1479,15 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
form.setAttribute('data-ovc-max-files', String(o.maxFiles || 10));
form.setAttribute('data-ovc-max-mb', String(o.maxMb || 100));
if (o.noDraft) form.setAttribute('data-ovc-no-draft', '');
++ if (o.noPlus) form.setAttribute('data-ovc-no-more', '');
++ if (o.plus && typeof o.plus === 'object') form.dataset.ovcPlus = JSON.stringify(cleanPlus(o.plus));
const resize = o.resize != null ? Number(o.resize) || 0 : p.resize || 0;
if (resize) form.setAttribute('data-ovc-resize', String(resize));
const want = (o.tools || p.tools).filter((t) => TOOL[t]);
++ // 4.241.0: what the + shows as a tile is not also a button in the row (the owner, 11-13: "why twice"); a file
++ // tool keeps its input in the form, hidden, so the files still go with it.
++ const plusTiles = o.noPlus ? [] : (Object.assign({}, PLUS_DEFAULT, CONFIG.plus || {}, o.plus && typeof o.plus === 'object' ? cleanPlus(o.plus) : {}).tiles || []);
++ const inPlus = (id) => plusTiles.includes(id) || (id === 'attach' && (plusTiles.includes('file') || plusTiles.includes('photo')));
if (!want.includes('voice')) form.setAttribute('data-ovc-no-voice', '');
// A post or a reply opens with one line: the person's picture (or first letter), their name and who sees it.
const body = el('div', 'ovc-body');
@@@ -1318,6 +1526,7 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
b.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });
b.appendChild(input);
}
++ if (inPlus(id)) { b.hidden = true; b.tabIndex = -1; }
tools.appendChild(b);
}
// Option chips: who sees it (a post), how the agent works (agent). A select under a pill: the browser's own list.
@@@ -1756,8 +1965,8 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
}
function scan(scope) { (scope || document).querySelectorAll('[data-ovt-composer], [data-ovc]').forEach((f) => enhance(f)); }
-- root.OpenVibeComposer = { enhance, mount, tool, mod, scan, presets: () => Object.keys(PRESET), tools: () => Object.keys(TOOL), settings: openSettings, get prefs() { return Object.assign({}, prefs); }, setPrefs };
-- if (typeof module === 'object' && module.exports) module.exports = { enhance, mount, tool, mod, scan };
++ root.OpenVibeComposer = { enhance, mount, tool, mod, scan, configure, presets: () => Object.keys(PRESET), tools: () => Object.keys(TOOL), settings: openSettings, get prefs() { return Object.assign({}, prefs); }, setPrefs };
++ if (typeof module === 'object' && module.exports) module.exports = { enhance, mount, tool, mod, scan, configure };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();
document.addEventListener('ov:boost:load', () => { live.forEach((c) => { if (!c.form.isConnected) c.destroy(); }); scan(); });
pull();
ddiff --git a/ov-home.js b/ov-home.js
iindex 14b9676..1f64184 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -687,6 +687,12 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
home: [['path', { d: 'M3.6 10.6L12 3.3l8.4 7.3v8.4a1 1 0 0 1-1 1h-4.2v-6.3h-6.3v6.3H4.7a1 1 0 0 1-1-1Z' }]],
live: [['circle', { cx: 12, cy: 11.8, r: 2 }], ['path', { d: 'M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9' }]],
shield: [['path', { d: 'M12 3.7L5.5 6.6v5.6c0 4.1 2.8 7.1 6.5 8.4c3.7-1.3 6.5-4.2 6.5-8.4V6.6Z' }]],
++ // 4.241.0: the feed's chips and actions.
++ tag: [['path', { d: 'M3.8 11.2V3.8h7.4l9.3 9.3l-7.4 7.4Z' }], ['circle', { cx: 8, cy: 8, r: 1.2 }]],
++ chart: [['path', { d: 'M3.9 3.4v16.7h16.7' }], ['path', { d: 'M7.7 14.5l3.6-4.1l3.2 2.7l5.2-6.3' }]],
++ scissors: [['circle', { cx: 6, cy: 7, r: 2.6 }], ['circle', { cx: 6, cy: 17, r: 2.6 }], ['path', { d: 'M8.2 8.6 20 18M8.2 15.4 20 6' }]],
++ smile: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'M8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01' }]],
++ share: [['path', { d: 'M12 15.2V3.3M7.4 8.1L12 3.3L16.6 8.1M4.9 12.9v6.1a1 1 0 0 0 1 1h12.2a1 1 0 0 0 1-1v-6.1' }]],
};
function svgIcon(name) {
const NS = 'http://www.w3.org/2000/svg';
@@@ -1652,6 +1658,8 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
// Following needs a signed-in person (the feed reads their Network follows); the rest are the feed's kinds (Community
// #310: wiki pages, quest badges, busy chat rooms, calendar events and the people who joined, beside the media kinds).
const KINDS = [['', 'For you'], ['following', 'Following'], ['live', 'Live'], ['video', 'Videos'], ['clip', 'Clips'], ['pic', 'Pics'], ['post', 'Posts'], ['rundown', 'Rundown'], ['meme', 'Memes'], ['game', 'Games'], ['chat', 'Chat'], ['event', 'Events'], ['work', 'Jobs'], ['deal', 'Deals'], ['report', 'Stats'], ['wiki', 'Wiki'], ['quest', 'Quests'], ['update', 'Updates']];
++ // 4.241.0: each kind's glyph on its chip.
++ const KIND_GL = { '': 'spark', following: 'people', live: 'live', video: 'video', clip: 'scissors', pic: 'image', post: 'pencil', rundown: 'page', meme: 'smile', game: 'game', chat: 'chat', event: 'calendar', work: 'briefcase', deal: 'tag', report: 'chart', wiki: 'book', quest: 'trophy', update: 'bell' };
function feedUrl(o, kind, cursor) {
const p = new URLSearchParams({ limit: '12' });
if (kind === 'following') p.set('following', '1'); else if (kind) p.set('kind', kind);
@@@ -1781,7 +1789,47 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
/** The Rundown's episodes (Live writes them from what streamers said): Network's kind `rundown`, or the older items
* that came as "System" with a "Rundown:" title. */
function isRundown(c) { return c.kind === 'rundown' || (/^Rundown:\s*/i.test(String(c.title || '')) && (!c.actor || /^system$/i.test(String(c.actor.name || '')))); }
-- function feedCard(c) {
++ /** The person behind a card (4.241.0): their name wears its effect (items.css; Inventory by subject where the page
++ * has items.js), their level and @handle beside it. "System" is nobody: the site speaks for itself. */
++ const personOf = (c) => (c.actor && c.actor.name && !/^system$/i.test(String(c.actor.name)) ? c.actor : null);
++ const FX = /^name-fx-[a-z]{2,24}$/;
++ let itemsCss = false;
++ function nameOf(c, actor, o) {
++ const name = h('b', 'ovfd-name', String(actor.name || '').slice(0, 60));
++ if (c.subject && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(c.subject))) name.setAttribute('data-ov-subject', String(c.subject));
++ if (actor.fx && FX.test(String(actor.fx))) {
++ name.classList.add('ov-fx', String(actor.fx));
++ if (!itemsCss && typeof document !== 'undefined' && o && typeof o.sibling === 'function' && !document.querySelector('link[data-ov-items-css]')) {
++ itemsCss = true;
++ const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = o.sibling('items.css'); l.setAttribute('data-ov-items-css', ''); document.head.append(l);
++ }
++ }
++ const line = h('span', 'ovfd-nameline'); line.append(name);
++ const lv = Number(actor.level);
++ if (Number.isInteger(lv) && lv > 0) { const b = h('span', 'ovfd-lvl', `Lv ${lv}`); b.setAttribute('aria-label', `level ${lv}`); line.append(b); }
++ return line;
++ }
++ const KIND_WORD = { post: 'Post', pic: 'A picture', clip: 'Clip', video: 'Video', live: 'Live', meme: 'Meme', deal: 'Deals', work: 'Jobs', report: 'Report', game: 'Game', wiki: 'Wiki', news: 'News', poll: 'Poll' };
++ /** Under a post, a picture, a clip or a video: reply (with the count), likes, and share. Never inside the card's link. */
++ function actsOf(c, url) {
++ const n = c.counts || {};
++ const row = h('div', 'ovfd-acts');
++ const reply = h('a', 'ovfd-act-b'); reply.href = `${url}#comments`; reply.append(glyph('reply'), h('span', '', Number(n.comments) > 0 ? count(n.comments) : 'Comment'));
++ reply.setAttribute('aria-label', Number(n.comments) > 0 ? `${num(n.comments)} comments` : 'Comment');
++ row.append(reply);
++ if (Number(n.likes) > 0) { const lk = h('span', 'ovfd-act-b is-static'); lk.append(glyph('heart'), h('span', '', count(n.likes))); lk.setAttribute('aria-label', `${num(n.likes)} likes`); row.append(lk); }
++ const sh = h('button', 'ovfd-act-b'); sh.type = 'button'; sh.append(glyph('share'), h('span', '', 'Share'));
++ sh.setAttribute('aria-label', 'Share');
++ sh.addEventListener('click', async () => {
++ const data = { title: String(c.title || '').slice(0, 120), url };
++ try { if (navigator.share) { await navigator.share(data); return; } } catch { return; }
++ try { await navigator.clipboard.writeText(url); sh.lastChild.textContent = 'Link copied'; setTimeout(() => { sh.lastChild.textContent = 'Share'; }, 1800); } catch { /* */ }
++ });
++ row.append(sh);
++ return row;
++ }
++ const SOCIAL = new Set(['post', 'pic', 'clip', 'video', 'meme', 'rundown']);
++ function feedCard(c, o) {
const kind = isRundown(c) ? 'rundown' : String(c.kind || 'post');
const url = safe(c.url);
if (!url) return null;
@@@ -1796,10 +1844,10 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
if (kind === 'rundown') a.setAttribute('aria-label', `The Rundown: ${String(c.title || '').replace(/^Rundown:\s*/i, '').slice(0, 160)}`);
const head = h('span', 'ovfd-head');
const face = h('span', 'ovfd-face');
-- const av = c.actor && safe(c.actor.avatar);
-- // No person behind it (the network's trending pictures): the site's icon and the card's label, not a "?".
++ const av = personOf(c) && safe(c.actor.avatar);
++ // No person behind it (the network's trending pictures, a report): the site's icon, 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.append(appFace(c.site || 'ov')); }
++ else if (!personOf(c)) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
const who = h('span', 'ovfd-who');
const meta = h('small', '');
@@@ -1813,9 +1861,17 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
who.append(h('b', '', 'The Rundown'));
meta.append(siteIcon('live', 18), document.createTextNode(` ${[about ? `about ${about}` : 'from today’s streams', when(c.at)].filter(Boolean).join(' · ')}`));
a.classList.add('ovfd-k-rundown');
++ } else if (personOf(c)) {
++ const p = personOf(c);
++ who.append(nameOf(c, p, o));
++ meta.append(siteIcon(c.site || 'ov', 18), document.createTextNode(` ${[p.handle ? `@${String(p.handle).slice(0, 40)}` : '', c.site_name || '', when(c.at)].filter(Boolean).join(' · ')}`));
} else {
-- who.append(h('b', '', String((c.actor && c.actor.name) || c.label || c.site_name || '').slice(0, 60)));
-- meta.append(siteIcon(c.site || 'ov', 18), document.createTextNode(` ${[c.site_name || '', c.actor ? when(c.at) : String(c.text || when(c.at) || '').slice(0, 60)].filter(Boolean).join(' · ')}`));
++ // Nobody behind it (a site's own card, or a picture whose author the feed does not know yet): the site's name,
++ // then what it is and when, never "Community · Community".
++ who.append(h('b', '', String(c.label && !/^system$/i.test(String(c.label)) ? c.label : c.site_name || '').slice(0, 60)));
++ // A report says its sentence beside its number (statOf); a member card says it here.
++ const what = kind === 'member' ? String(c.text || '').slice(0, 60) : kind === 'report' ? '' : KIND_WORD[kind] || '';
++ meta.append(siteIcon(c.site || 'ov', 18), document.createTextNode(` ${[what, when(c.at)].filter(Boolean).join(' · ')}`));
}
who.append(meta);
head.append(face, who);
@@@ -1842,12 +1898,21 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
if (title) a.append(h('b', 'ovfd-title', title.slice(0, 200)));
if (kind === 'report') { const st = statOf(c); if (st) a.append(st); }
// A card without a person says its text in the line under its name already (not twice).
-- const textInMeta = !c.actor && kind !== 'rundown' && !!c.text;
++ const textInMeta = !personOf(c) && kind === 'member' && !!c.text;
if (c.text && (kind === 'post' || !thumb) && kind !== 'report' && !textInMeta) a.append(h('p', 'ovfd-text', String(c.text).slice(0, 500)));
const many = groupList(c);
if (many) a.append(many);
-- const people = peopleOf(kind, c);
++ // A run of deals says its count in its own link ("See all 37 deals"), not again under it.
++ const people = many && kind === 'deal' ? null : peopleOf(kind, c);
const counts = countsRow(c.counts);
++ if (SOCIAL.has(kind)) {
++ // 4.241.0: a post reads like a post: the card, then reply, likes and share under it (outside its link).
++ if (people) { const row = h('span', 'ovfd-counts'); row.append(people); a.append(row); }
++ else if (counts && Number((c.counts || {}).views) > 0) { const row = h('span', 'ovfd-counts'); const v = counts.querySelector('[aria-label$="views"], [aria-label$="watched"]'); if (v) { row.append(v); a.append(row); } }
++ const post = h('article', `ovfd-post ovfd-k-${kind.replace(/[^a-z]/g, '')}`);
++ post.append(a, actsOf(c, url));
++ return post;
++ }
if (people || counts) { const row = counts || h('span', 'ovfd-counts'); if (people) row.prepend(people); a.append(row); }
return a;
}
@@@ -1876,17 +1941,32 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
const list = h('span', 'ovfd-group');
const pic = (x) => { const th = x && x.media && safe(x.media.thumb); return th && /^https:/.test(th) ? th : ''; };
const anyPic = g.slice(0, 3).some((x) => !!pic(x));
-- g.slice(0, 3).forEach((x) => {
++ const money = (p) => { try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: p.currency }).format(Number(p.amount)); } catch { return `${Number(p.amount).toFixed(2)} ${p.currency}`; } };
++ const isDeal = c.kind === 'deal';
++ if (isDeal) list.classList.add('is-deals');
++ g.slice(0, isDeal ? 4 : 3).forEach((x) => {
const row = h('span', 'ovfd-group-i');
const th = pic(x);
-- // Rows line up: where one has a picture, the others hold its place.
++ // Rows line up: where one has a picture, the others hold its place (a deal's: its store's first letter).
if (th) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = th; row.append(img); }
++ else if (isDeal) { const ph = h('i', 'ovfd-group-ph is-deal'); ph.append(glyph('tag')); row.append(ph); }
else if (anyPic) row.append(h('i', 'ovfd-group-ph'));
-- row.append(h('span', '', String(x.title || '').slice(0, 120)));
++ const t = h('span', 'ovfd-group-t'); t.append(h('span', 'ovfd-group-n', String(x.title || '').replace(/\s+[—-]\s+[^—-]{2,40}$/, '').slice(0, 120)));
++ if (isDeal) {
++ const line = h('span', 'ovfd-price');
++ const p = x.price && Number.isFinite(Number(x.price.amount)) && /^[A-Z]{3}$/.test(String(x.price.currency || '')) ? x.price : null;
++ if (p) line.append(h('b', '', Number(p.amount) === 0 ? 'Free' : money(p)));
++ if (p && Number(x.was) > Number(p.amount)) { line.append(h('s', '', money({ amount: x.was, currency: p.currency }))); line.append(h('em', '', `-${Math.round((1 - Number(p.amount) / Number(x.was)) * 100)}%`)); }
++ const store = x.store || (/\s[—-]\s([^—-]{2,40})$/.exec(String(x.title || '')) || [])[1];
++ if (store) line.append(h('small', '', String(store).slice(0, 40)));
++ if (line.childNodes.length) t.append(line);
++ }
++ row.append(t);
list.append(row);
});
-- const more = Number(c.group.count) - Math.min(3, g.length);
++ const more = Number(c.group.count) - Math.min(c.kind === 'deal' ? 4 : 3, g.length);
if (more > 0) list.append(h('small', 'ovfd-group-more', `and ${num(more)} more`));
++ if (more > 0 && isDeal) list.lastChild.textContent = `See all ${num(Number(c.group.count))} deals`;
return list;
}
/** What `counts.people` means for a kind: jobs, voters, people online; nothing where it means nothing. */
@@@ -1950,6 +2030,8 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
// 4.229.0: a card already on the list is never drawn again (the owner, 11-12: "112 new deals" three times, one
// per page of a Deals poll; Network now names a burst once, and this keeps any repeat off the screen).
const shownIds = new Set();
++ // Names wear what their people wear (OpenVibe.Inventory through items.js, where the page has it).
++ const decorate = () => { try { if (root.OpenVibeItems && typeof root.OpenVibeItems.decorate === 'function') root.OpenVibeItems.decorate(list); } catch { /* names stay plain */ } };
let query = '', searchSeq = 0;
const loaded = [];
const feedBase = `${o.frameBase || FRAME}/feed`;
@@@ -1998,7 +2080,7 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
const clear = h('button', 'ovk-btn ovk-btn--quiet', '×'); clear.type = 'button'; clear.setAttribute('aria-label', 'Clear feed search');
clear.addEventListener('click', () => { if (o.onSearch) o.onSearch(''); }); resultHead.append(clear);
list.textContent = '';
-- found.forEach((x) => { const card = feedCard(x); if (card) list.append(card); });
++ found.forEach((x) => { const card = feedCard(x, o); if (card) list.append(card); }); decorate();
if (typeof o.mountIcons === 'function') o.mountIcons(list);
}
const skeletons = () => { for (let k = 0; k < 3; k++) list.append(h('span', 'ovfd-skel')); };
@@@ -2021,12 +2103,12 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
// 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 === 'stream' || (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 = []; };
++ const flush = () => { if (run.length >= 2) list.append(livesCard(run)); else run.forEach((x) => { const c = feedCard(x, o); if (c) list.append(c); }); run = []; decorate(); };
// Date by date (the owner, 11-09): a quiet day line where the day changes. The network's trending cards
// (no time of their own) sit between, under the day they were woven into.
const day = (x) => { const t = Date.parse(String((x && x.at) || '')); return Number.isFinite(t) ? new Date(t).toDateString() : ''; };
const mark = (x) => { const d = day(x); if (!d || d === lastDay) return; flush(); lastDay = d; list.append(dayLine(Date.parse(String(x.at)))); };
-- j.items.filter((x) => x && x.id && !shownIds.has(x.id) && shownIds.add(x.id)).forEach((x) => { mark(x); if (past(x) && !kind) { run.push(x); return; } flush(); const c = feedCard(x); if (c) list.append(c); });
++ j.items.filter((x) => x && x.id && !shownIds.has(x.id) && shownIds.add(x.id)).forEach((x) => { mark(x); if (past(x) && !kind) { run.push(x); return; } flush(); const c = feedCard(x, o); if (c) list.append(c); });
flush();
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
cursor = j.next_cursor || null;
@@@ -2038,7 +2120,8 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
}
KINDS.forEach(([k, label]) => {
if (k === 'following' && !signedIn(o)) return;
-- const b = h('button', 'ovfd-chip', label); b.type = 'button'; b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', k === kind ? 'true' : 'false');
++ const b = h('button', 'ovfd-chip'); b.type = 'button'; b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', k === kind ? 'true' : 'false');
++ { const g = svgIcon(KIND_GL[k] || 'spark'); g.setAttribute('class', 'ovfd-chip-g'); b.append(g, h('span', '', label)); }
b.addEventListener('click', () => {
if (k === kind) return;
kind = k; cursor = null; done = false; busy = false; lastDay = '';
@@@ -2159,6 +2242,48 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
.ovfd-ribbon svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}
.ovfd-media{position:relative}
.ovfd-len{font-variant-numeric:tabular-nums;z-index:1}
++
++/* 4.241.0: the feed as social posts. Chips with glyphs; a person's line (face, name with its effect, level, @handle);
++ a post is its card and its reply, likes and share; a run of deals is a listing (picture, price, old price, store). */
++.ovfd-chip{display:inline-flex;align-items:center;gap:7px;min-height:38px;padding:0 15px 0 11px;transition:background-color .2s,color .2s,border-color .2s,transform .18s cubic-bezier(.3,1.4,.4,1)}
++.ovfd-chip-g{flex:none;width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;opacity:.85}
++.ovfd-chip[aria-selected=true]{background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent);color:var(--accent-light,#93c5fd);border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent)}
++.ovfd-chip[aria-selected=true] .ovfd-chip-g{opacity:1}
++.ovfd-chip:active{transform:scale(.94)}
++@media (hover:hover){.ovfd-chip:not([aria-selected=true]):hover{color:var(--text-primary,#e8ecf3);border-color:color-mix(in srgb,var(--text-primary,#e8ecf3) 28%,transparent)}}
++.ovfd-nameline{display:flex;align-items:center;gap:6px;min-width:0}
++.ovfd-nameline .ovfd-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14.5px;font-weight:800}
++.ovfd-lvl{flex:none;display:inline-flex;align-items:center;height:18px;padding:0 6px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.02em;color:#fde68a;background:linear-gradient(135deg,color-mix(in srgb,#f59e0b 32%,transparent),color-mix(in srgb,#f59e0b 14%,transparent));box-shadow:inset 0 0 0 1px color-mix(in srgb,#f59e0b 40%,transparent)}
++.ovfd-post{display:grid;border-radius:18px;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));min-width:0;overflow:hidden}
++.ovfd-post>.ovfd-card{border-radius:18px 18px 0 0;background:none;box-shadow:none;padding:12px 12px 8px}
++.ovfd-post .ovfd-face{width:42px;height:42px}
++.ovfd-post .ovfd-media{border-radius:14px}
++.ovfd-acts{display:flex;align-items:center;gap:4px;padding:2px 6px 6px;border-top:1px solid color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent)}
++.ovfd-act-b{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;gap:7px;min-height:40px;padding:0 12px;border-radius:999px;cursor:pointer;font-size:13.5px;font-weight:650;color:var(--text-secondary,#a8b3c4);text-decoration:none;transition:background-color .2s,color .2s,transform .18s cubic-bezier(.3,1.4,.4,1)}
++.ovfd-act-b .ovfd-gl{width:19px;height:19px}
++.ovfd-act-b:last-child{margin-left:auto}
++.ovfd-act-b.is-static{cursor:default}
++.ovfd-act-b:not(.is-static):active{transform:scale(.94)}
++.ovfd-act-b:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
++@media (hover:hover){.ovfd-act-b:not(.is-static):hover{color:var(--accent-light,#93c5fd);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}.ovfd-post:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent)}}
++.ovfd-group.is-deals{gap:6px}
++.ovfd-group.is-deals .ovfd-group-i{align-items:center;gap:10px;padding:6px;border-radius:12px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent)}
++.ovfd-group.is-deals .ovfd-group-i::before{display:none}
++.ovfd-group.is-deals .ovfd-group-i img,.ovfd-group-ph.is-deal{width:48px;height:48px;border-radius:10px}
++.ovfd-group-ph.is-deal{display:grid;place-items:center;color:#facc15;background:color-mix(in srgb,#facc15 12%,transparent)}
++.ovfd-group-ph.is-deal .ovfd-gl{width:22px;height:22px}
++.ovfd-group-t{min-width:0;display:grid;gap:2px}
++.ovfd-group-n{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:14px;font-weight:650;color:var(--text-primary,#e8ecf3)}
++.ovfd-price{display:flex;align-items:baseline;gap:7px;min-width:0;font-size:13px;white-space:nowrap}
++.ovfd-price b{font-size:14.5px;font-weight:800;color:#4ade80;font-variant-numeric:tabular-nums}
++.ovfd-price s{color:var(--text-muted,#93a0b4);font-variant-numeric:tabular-nums}
++.ovfd-price em{font-style:normal;font-weight:800;font-size:11.5px;padding:1px 6px;border-radius:6px;color:#4ade80;background:color-mix(in srgb,#22c55e 14%,transparent)}
++.ovfd-price small{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--text-muted,#93a0b4)}
++.ovfd-group.is-deals .ovfd-group-more{justify-self:start;padding:4px 2px;font-size:13px;font-weight:700;color:var(--accent-light,#93c5fd)}
++@media (min-width:900px){.ovfd-group.is-deals{grid-template-columns:repeat(2,minmax(0,1fr))}.ovfd-group.is-deals .ovfd-group-more{grid-column:1/-1}}
++@media (prefers-color-scheme:light){.ovfd-lvl{color:#92400e}.ovfd-price b,.ovfd-price em{color:#15803d}.ovfd-group-ph.is-deal{color:#a16207}}
++:root[data-theme=light] .ovfd-lvl{color:#92400e} :root[data-theme=light] .ovfd-price b,:root[data-theme=light] .ovfd-price em{color:#15803d}
++html[data-ov-motion=off] :is(.ovfd-chip,.ovfd-act-b){transition:none}
`;
document.head.appendChild(st);
}
ddiff --git a/ov-thread.js b/ov-thread.js
iindex b0a6d9f..a6213ed 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
-- const THREAD_REV = { 'ov-composer.js': '79b34aff4452', 'ov-combo.js': '8001a3b19c2f' };
++ const THREAD_REV = { 'ov-composer.js': '8a4f9f30bb39', 'ov-combo.js': '8001a3b19c2f' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
ddiff --git a/package-lock.json b/package-lock.json
iindex 12a3f3d..eb56c15 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.240.0",
++ "version": "4.241.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.240.0",
++ "version": "4.241.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex bbc7007..7a9c6ce 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.240.0",
++ "version": "4.241.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/composer-camera-chrome.test.js b/test/composer-camera-chrome.test.js
iindex 862bcbb..2be9643 100644
---- a/test/composer-camera-chrome.test.js
++++ b/test/composer-camera-chrome.test.js
@@@ -51,7 +51,7 @@ window.bigPicture = async (w, h) => { const c = document.createElement('canvas')
assert.ok(await until(`!!window.post`), 'the composers mount');
// The camera: an agent's box and a post have it.
-- const tools = await page.evaluate(`[...document.querySelectorAll('.ovc-v2')].map((f) => [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l !== 'Speak' && l !== 'More: attach, timer and tools'))`);
++ const tools = await page.evaluate(`[...document.querySelectorAll('.ovc-v2')].map((f) => [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l !== 'Speak' && l !== 'Add: camera, photos, files and more'))`);
assert.deepEqual(tools[0].slice(0, 2), ['Attach', 'Camera'], 'an agent: attach and the camera');
assert.ok(tools[1].includes('Camera'), 'a post: the camera beside photos');
assert.equal(await page.evaluate(`document.getElementById('g1').getAttribute('data-ovc-resize')`), '1568');
ddiff --git a/test/composer-plus-chrome.test.js b/test/composer-plus-chrome.test.js
nnew file mode 100644
iindex 0000000..812214b
---- /dev/null
++++ b/test/composer-plus-chrome.test.js
@@@ -0,0 +1,121 @@
++'use strict';
++// The composer's + (ov-composer.js 4.241.0) in a real headless Chrome: at 390 px it is a sheet from the bottom with big
++// tiles (Camera, Photos, Files) and rows (the extras, a site's own tool); what the + holds is not also in the row; a
++// tile opens the browser's picker for its kinds and what is picked joins the box; a box (data-ovc-plus) and a site
++// (OpenVibeComposer.configure) choose what it shows; the close button and the backdrop close it. At 1280 px it is a
++// panel under the box's row. Nothing is wider than the screen.
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const http = require('node:http');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer-plus-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const server = http.createServer((req, res) => {
++ const u = req.url.split('?')[0];
++ if (u === '/ov-composer.js' || u === '/ov-window.js' || u === '/ov-capture.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }
++ res.writeHead(200, { 'content-type': 'text/html' });
++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++<body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div id="a"></div><div id="b"></div><div id="c"></div>
++<script src="/ov-composer.js"></script>
++<script>
++const C = OpenVibeComposer;
++window.ran = [];
++C.tool('poll', { label: 'Poll', sub: 'Ask people to vote', path: 'M5 19V9M12 19V5M19 19v-7', extra: true, run: () => window.ran.push('poll') });
++window.post = C.mount(document.getElementById('a'), { preset: 'post', id: 'p1', user: { display_name: 'goosely' } });
++window.only = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', plus: { tiles: [], rows: ['timer'], only: true } });
++window.site = C.mount(document.getElementById('c'), { preset: 'agent', id: 'g1' });
++// The browser's picker, as a stand-in: what it was asked for, and two files "picked".
++window.picked = [];
++HTMLInputElement.prototype.click = function () {
++ if (this.type !== 'file') return;
++ window.picked.push({ accept: this.accept, capture: this.getAttribute('capture') });
++ const dt = new DataTransfer();
++ dt.items.add(new File([new Uint8Array([137, 80, 78, 71])], 'shot.png', { type: 'image/png' }));
++ this.files = dt.files;
++ setTimeout(() => this.dispatchEvent(new Event('change', { bubbles: true })), 10);
++};
++</script>`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'composer-plus-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 860 });
++ await page.goto(base + '/', { maxMs: 5000 });
++ const until = async (expr) => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(40); } return false; };
++ assert.ok(await until('!!window.site'), 'the composers mount');
++
++ // What the + holds is not also in the row; the file inputs stay in the form.
++ const row = await page.evaluate(`[...document.querySelectorAll('#p1 .ovt-c-tools .ovt-btn:not([hidden])')].map((b) => b.getAttribute('aria-label'))`);
++ assert.deepEqual(row.filter((l) => /Photos|Files|Camera/.test(l)), [], `no photo, file or camera button in the row (${row})`);
++ assert.ok(await page.evaluate(`document.querySelectorAll('#p1 input[type=file]').length >= 1`), 'the file inputs stay in the form');
++
++ // At 390 px the + is a sheet from the bottom: tiles, then rows, a site's own tool among them.
++ await page.evaluate(`document.querySelector('#p1 .ovc-more').click()`);
++ const sheet = await until(`(() => { const d = document.querySelector('dialog.ovc-add[open]'); return d ? { tiles: [...d.querySelectorAll('.ovc-add-tile')].map((t) => t.dataset.tool), rows: [...d.querySelectorAll('.ovc-add-row')].map((r) => r.dataset.tool), title: d.querySelector('.ovc-add-h b').textContent } : null; })()`);
++ assert.ok(sheet, 'a sheet from the bottom');
++ assert.equal(sheet.title, 'Add');
++ assert.deepEqual(sheet.tiles.filter((t) => t !== 'camera'), ['photo', 'file'], `Photos and Files as tiles (Camera where the browser has one) (${sheet.tiles})`);
++ assert.ok(sheet.rows.includes('timer') && sheet.rows.includes('poll'), `the extras and the site's tool as rows (${sheet.rows})`);
++ assert.equal(await page.evaluate(`document.querySelector('#p1 .ovc-more').getAttribute('aria-expanded')`), 'true');
++ assert.equal(await page.evaluate('document.documentElement.scrollWidth'), 390, 'nothing wider than the phone');
++
++ // Photos: the picker for pictures and videos; what is picked joins the box, and the sheet is gone.
++ await page.evaluate(`document.querySelector('dialog.ovc-add .ovc-add-tile[data-tool=photo]').click()`);
++ assert.ok(await until(`document.querySelectorAll('#p1 .ovc-file').length === 1`), 'the picked picture is a chip in the box');
++ assert.deepEqual(await page.evaluate('window.picked[0]'), { accept: 'image/*,video/*', capture: null });
++ assert.equal(await page.evaluate(`!!document.querySelector('dialog.ovc-add')`), false, 'the sheet closed as the tool ran');
++
++ // A site's tool runs from its row.
++ await page.evaluate(`document.querySelector('#p1 .ovc-more').click()`);
++ await until(`!!document.querySelector('dialog.ovc-add[open] .ovc-add-row[data-tool=poll]')`);
++ await page.evaluate(`document.querySelector('dialog.ovc-add .ovc-add-row[data-tool=poll]').click()`);
++ assert.deepEqual(await page.evaluate('window.ran'), ['poll']);
++
++ // The close button closes it; so does the backdrop.
++ await page.evaluate(`document.querySelector('#p1 .ovc-more').click()`);
++ await until(`!!document.querySelector('dialog.ovc-add[open]')`);
++ await page.evaluate(`document.querySelector('dialog.ovc-add .ovc-add-x').click()`);
++ assert.ok(await until(`!document.querySelector('dialog.ovc-add')`), 'the close button closes it');
++ await page.evaluate(`document.querySelector('#p1 .ovc-more').click()`);
++ await until(`!!document.querySelector('dialog.ovc-add[open]')`);
++ await page.evaluate(`(() => { const d = document.querySelector('dialog.ovc-add'); d.dispatchEvent(new MouseEvent('click', { bubbles: true })); })()`);
++ assert.ok(await until(`!document.querySelector('dialog.ovc-add')`), 'the backdrop closes it');
++
++ // A box that chose its own +: no tiles, only Timer.
++ await page.evaluate(`document.querySelector('#c1 .ovc-more').click()`);
++ const own = await until(`(() => { const d = document.querySelector('dialog.ovc-add[open]'); return d ? { tiles: d.querySelectorAll('.ovc-add-tile').length, rows: [...d.querySelectorAll('.ovc-add-row')].map((r) => r.dataset.tool).filter((t) => !/^row-/.test(t)) } : null; })()`);
++ assert.deepEqual(own, { tiles: 0, rows: ['timer'] }, 'data-ovc-plus: only what the box named');
++ await page.evaluate(`document.querySelector('dialog.ovc-add .ovc-add-x').click()`);
++ await until(`!document.querySelector('dialog.ovc-add')`);
++
++ // The site's choice (configure): every box's + follows it from then on; here, Files only as a tile.
++ await page.evaluate(`OpenVibeComposer.configure({ plus: { tiles: ['file'] } }), true`);
++ await page.evaluate(`document.querySelector('#g1 .ovc-more').click()`);
++ const siteTiles = await until(`(() => { const d = document.querySelector('dialog.ovc-add[open]'); return d ? [...d.querySelectorAll('.ovc-add-tile')].map((t) => t.dataset.tool) : null; })()`);
++ assert.deepEqual(siteTiles, ['file'], 'OpenVibeComposer.configure({ plus }) sets the site\'s choice');
++ await page.evaluate(`document.querySelector('dialog.ovc-add .ovc-add-tile[data-tool=file]').click()`);
++ assert.ok(await until(`window.picked.length === 2`), 'Files opens the picker');
++ assert.deepEqual(await page.evaluate('window.picked[1]'), { accept: '', capture: null }, 'any file (on Android: the system\'s Choose an action)');
++
++ // At 1280 px: a panel under the row, inside the box.
++ const wide = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 860 });
++ await wide.goto(base + '/', { maxMs: 5000 });
++ const untilW = async (expr) => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await wide.evaluate(expr); if (v) return v; await wait(40); } return false; };
++ await untilW('!!window.site');
++ await wide.evaluate(`document.querySelector('#p1 .ovc-more').click()`);
++ assert.ok(await untilW(`!!document.querySelector('#p1 .ovc-add-inline .ovc-add-tile[data-tool=photo]') && !document.querySelector('dialog.ovc-add')`), 'a panel inside the box, not a sheet');
++ await wide.evaluate(`document.querySelector('#p1 .ovc-add-inline').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);
++ assert.ok(await untilW(`!document.querySelector('#p1 .ovc-add-inline')`), 'Escape closes the panel');
++ console.log('composer-plus-chrome: the + as tiles and rows, a sheet on a phone and a panel on a wide screen, per box and per site, picked files in the box');
++ } 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/composer-v2-chrome.test.js b/test/composer-v2-chrome.test.js
iindex 8f0e889..16f73fa 100644
---- a/test/composer-v2-chrome.test.js
++++ b/test/composer-v2-chrome.test.js
@@@ -60,17 +60,20 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
// Each preset draws its own tools, chips and send.
const shape = await page.evaluate(`(() => [...document.querySelectorAll('.ovc-v2')].map((f) => ({ preset: f.dataset.preset,
// 4.223.0: the mic and the + lead the row; the box's own tools follow.
-- lead: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l === 'Speak' || l === 'More: attach, timer and tools'),
-- tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')).filter((l) => l !== 'Speak' && l !== 'More: attach, timer and tools'),
++ lead: [...f.querySelectorAll('.ovt-c-tools .ovt-btn:not([hidden])')].map((b) => b.getAttribute('aria-label')).filter((l) => l === 'Speak' || l === 'Add: camera, photos, files and more'),
++ // 4.241.0: what the + shows as a tile (camera, photos, files; attach) is not a button in the row too.
++ tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn:not([hidden])')].map((b) => b.getAttribute('aria-label')).filter((l) => l !== 'Speak' && l !== 'Add: camera, photos, files and more'),
++ kept: f.querySelectorAll('.ovt-c-tools input[type=file]').length,
avatar: !!f.querySelector('.ovc-head .ovc-av'), full: Math.abs(f.querySelector('textarea').getBoundingClientRect().left - f.querySelector('.ovc-body').getBoundingClientRect().left) < 4, round: f.querySelector('[data-ovt-send]').classList.contains('ovt-send--round'),
send: f.querySelector('[data-ovt-send]').textContent.trim(), chips: [...f.querySelectorAll('.ovc-chip-v')].map((c) => c.textContent) })))()`);
assert.deepEqual(shape.map((s) => s.preset), ['post', 'chat', 'agent', 'comment']);
-- assert.ok(shape.every((s) => s.lead[s.lead.length - 1] === 'More: attach, timer and tools' && (s.lead.length === 1 || s.lead[0] === 'Speak')), `the mic, then the +, lead every row (${JSON.stringify(shape.map((s) => s.lead))})`);
-- assert.deepEqual(shape[0].tools.slice(0, 4), ['Photos and video', 'Files', 'Emoji', 'Poll'], 'a post: photos, files, emoji and the site\'s own tool');
++ assert.ok(shape.every((s) => s.lead[s.lead.length - 1] === 'Add: camera, photos, files and more' && (s.lead.length === 1 || s.lead[0] === 'Speak')), `the mic, then the +, lead every row (${JSON.stringify(shape.map((s) => s.lead))})`);
++ assert.deepEqual(shape[0].tools.slice(0, 2), ['Emoji', 'Poll'], 'a post: emoji and the site\'s own tool in the row; photos and files in the +');
++ assert.ok(shape.every((x) => x.preset === 'agent' || x.kept > 0), 'the file inputs stay in the form, so files still go with it');
assert.ok(shape[0].avatar && shape[0].send === 'Post' && !shape[0].round);
assert.ok(shape.every((x) => x.full), 'the words use the full width: no column kept for the picture');
assert.deepEqual(shape[0].chips, ['Everyone']);
-- assert.deepEqual(shape[1].tools.slice(0, 2), ['Attach', 'Emoji']);
++ assert.deepEqual(shape[1].tools.slice(0, 1), ['Emoji'], 'a chat: emoji in the row; attaching is the +\'s');
assert.ok(shape[1].round && !shape[1].avatar, 'a chat: a round send, no picture');
assert.ok(shape[2].round && shape[2].chips.join() === 'Balanced,High', 'an agent: its mode and reasoning chips and a round send');
assert.ok(shape[3].avatar && shape[3].send === 'Reply');
@@@ -148,11 +151,11 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
// 4.225.0: the + has GitHub. A repository found through the Frame's code search goes into the box as its address.
await page.evaluate(`document.getElementById('g1').setAttribute('data-ovc-frame', location.origin), true`);
await page.evaluate(`document.querySelector('#g1 .ovc-more').click(), true`);
-- assert.ok((await page.evaluate(`[...document.querySelectorAll('#g1 .ovc-tray-t')].map((t) => t.textContent)`)).includes('GitHub'), 'GitHub is in the +');
++ assert.ok((await page.evaluate(`[...document.querySelectorAll('#g1 .ovc-add-t, #g1 .ovc-add-rt b, dialog.ovc-add[open] .ovc-add-t, dialog.ovc-add[open] .ovc-add-rt b')].map((t) => t.textContent)`)).includes('GitHub'), 'GitHub is in the +');
// 4.234.0: the sheet does not repeat a tool the row already shows (the owner, 11-13: "why twice").
-- const repeat = await page.evaluate(`(() => { const row = [...document.querySelectorAll('#g1 .ovt-c-tools > .ovt-btn')].filter((b) => getComputedStyle(b).display !== 'none' && b.getClientRects().length).map((b) => b.getAttribute('aria-label')); const sheet = [...document.querySelectorAll('#g1 .ovc-tray-t')].map((t) => t.textContent); return row.filter((l) => l && sheet.includes(l)); })()`);
++ const repeat = await page.evaluate(`(() => { const row = [...document.querySelectorAll('#g1 .ovt-c-tools > .ovt-btn')].filter((b) => getComputedStyle(b).display !== 'none' && b.getClientRects().length).map((b) => b.getAttribute('aria-label')); const sheet = [...document.querySelectorAll('#g1 .ovc-add-t, #g1 .ovc-add-rt b, dialog.ovc-add[open] .ovc-add-t, dialog.ovc-add[open] .ovc-add-rt b')].map((t) => t.textContent); return row.filter((l) => l && sheet.includes(l)); })()`);
assert.deepEqual(repeat, [], 'nothing the row shows is in the + again');
-- await page.evaluate(`[...document.querySelectorAll('#g1 .ovc-tray-i')].find((b) => b.textContent === 'GitHub').click(), true`);
++ await page.evaluate(`document.querySelector('#g1 .ovc-add-row[data-tool=github], dialog.ovc-add[open] .ovc-add-row[data-tool=github]').click(), true`);
assert.equal(await page.evaluate(`document.activeElement === document.querySelector('#g1 .ovc-repo-q')`), true, 'the search field has the focus');
await page.evaluate(`(() => { const f = document.querySelector('#g1 .ovc-repo-q'); f.value = 'goal bar'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);
let rows = 0;
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 15829dc..3436db2 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -603,8 +603,8 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
await p.evaluate("(document.querySelector('.ovs .ovdk-vibe-new') || { click() {} }).click()");
await until(p, "!!document.querySelector('.ovs .ovc-more')", 'the box and its +');
await p.evaluate("document.querySelector('.ovs .ovc-more').click()");
-- await until(p, "[...document.querySelectorAll('.ovs .ovc-tray-i')].some((b) => b.textContent === 'Timer')", 'Timer in the +');
-- await p.evaluate("[...document.querySelectorAll('.ovs .ovc-tray-i')].find((b) => b.textContent === 'Timer').click()");
++ await until(p, "!!document.querySelector('.ovs .ovc-add-row[data-tool=timer]')", 'Timer in the +');
++ await p.evaluate("document.querySelector('.ovs .ovc-add-row[data-tool=timer]').click()");
await until(p, "!!document.querySelector('.ovdk .ovdk-tmenu')", 'the timer menu');
await p.evaluate("document.querySelector('.ovdk .ovdk-tmenu [data-min=\"5\"]').click()");
const strip = await until(p, "(() => { const s = document.querySelector('.ovdk .ovdk-timer'); return s ? s.innerText : null; })()", 'the timer strip in the Dock');
ddiff --git a/test/inline-composer-chrome.test.js b/test/inline-composer-chrome.test.js
iindex 9403e46..56b95c2 100644
---- a/test/inline-composer-chrome.test.js
++++ b/test/inline-composer-chrome.test.js
@@@ -51,7 +51,7 @@ const server = http.createServer((req, res) => {
assert.ok(await until(`!!document.querySelector('form.ovc-v2[data-preset="comment"]')`), 'the reply is the shared comment composer');
assert.equal(await page.evaluate(`document.querySelectorAll('[data-ovt-composer]').length`), 0, 'the server form is replaced, not doubled');
const shape = await page.evaluate(`(() => { const f = document.querySelector('form.ovc-v2[data-preset="comment"]'); return { action: f.getAttribute('action'), method: f.method, parent: (f.querySelector('input[name=parent_id]') || {}).value, send: f.querySelector('[data-ovt-send]').textContent.trim(), tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')) }; })()`);
-- assert.deepEqual(shape, { action: '/p/x/comments', method: 'post', parent: '1', send: 'Reply', tools: ['More: attach, timer and tools', 'Photos and video', 'Emoji'] });
++ assert.deepEqual(shape, { action: '/p/x/comments', method: 'post', parent: '1', send: 'Reply', tools: ['Add: camera, photos, files and more', 'Photos and video', 'Emoji'] });
assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');
await page.evaluate(`(() => { const t = document.querySelector('form.ovc-v2 textarea'); t.value = 'Hello from the shared box'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('form.ovc-v2 [data-ovt-send]').click(); })()`);
const landed = await until(`!!document.getElementById('posted')`);
ddiff --git a/test/menu-feed-v2-chrome.test.js b/test/menu-feed-v2-chrome.test.js
iindex f3e0f07..2ce7681 100644
---- a/test/menu-feed-v2-chrome.test.js
++++ b/test/menu-feed-v2-chrome.test.js
@@@ -91,9 +91,9 @@ const server = http.createServer((req, res) => {
await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open [data-tab="feed"]')`), 'the menu opened');
await p.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
-- assert.ok(await until(p, `document.querySelectorAll('.ovfd-list > a').length >= 12`), 'the feed painted');
++ assert.ok(await until(p, `document.querySelectorAll('.ovfd-list > a, .ovfd-list > .ovfd-post > a').length >= 12`), 'the feed painted');
const first = await p.evaluate(`({ chips: [...document.querySelectorAll('.ovfd-chip')].map((b) => b.textContent), selected: (document.querySelector('.ovfd-chip[aria-selected=true]') || {}).textContent,
-- kinds: [...new Set([...document.querySelectorAll('.ovfd-list > a')].map((a) => a.className))], live: !!document.querySelector('.ovfd-livechip'), media: document.querySelectorAll('.ovfd-media img').length,
++ kinds: [...new Set([...document.querySelectorAll('.ovfd-list > a, .ovfd-list > .ovfd-post > a')].map((a) => a.className))], live: !!document.querySelector('.ovfd-livechip'), media: document.querySelectorAll('.ovfd-media img').length,
bolds: document.querySelectorAll('.ovfd-list b b').length, posts: [...document.querySelectorAll('.ovfd-k-post .ovfd-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', 'Videos', 'Clips', 'Pics', 'Posts', 'Rundown', 'Memes', 'Games', 'Chat', 'Events', 'Jobs', 'Deals', 'Stats', 'Wiki', 'Quests', 'Updates'], 'signed out: no Following chip');
@@@ -106,11 +106,11 @@ const server = http.createServer((req, res) => {
assert.ok(!first.sections.some((t) => /New to you|Next from|What's new/.test(t)), `the Feed tab is the stream when the feed answers (${first.sections})`);
assert.ok(first.scroll <= 390, 'no sideways scroll');
await p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); l.scrollTop = l.scrollHeight; })()`);
-- assert.ok(await until(p, `document.querySelectorAll('.ovfd-list > a').length >= 24`), 'the next page loads as the end comes near');
++ assert.ok(await until(p, `document.querySelectorAll('.ovfd-list > a, .ovfd-list > .ovfd-post > a').length >= 24`), 'the next page loads as the end comes near');
assert.ok(await until(p, `!!document.querySelector('.ovfd-caught')`), 'the end says so');
await p.evaluate(`[...document.querySelectorAll('.ovfd-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('.ovfd-list > a')].every((a) => a.className === 'ovfd-card ovfd-k-clip') && document.querySelectorAll('.ovfd-list > a').length === 12`), 'only clips, from the first page');
++ assert.ok(await until(p, `[...document.querySelectorAll('.ovfd-list > a, .ovfd-list > .ovfd-post > a')].every((a) => a.className === 'ovfd-card ovfd-k-clip') && document.querySelectorAll('.ovfd-list > a, .ovfd-list > .ovfd-post > a').length === 12`), 'only clips, from the first page');
await p.evaluate(`[...document.querySelectorAll('.ovfd-chip')].find((b) => b.textContent === 'Events').click(), true`);
assert.ok(await until(p, `document.querySelectorAll('.ovfd-act').length === 5`), 'every activity kind is one row');
const acts = await p.evaluate(`({ titles: [...document.querySelectorAll('.ovfd-act-txt > b')].map((b) => b.textContent), days: [...document.querySelectorAll('.ovfd-day')].map((d) => d.textContent),