openvibers/openvibe.shared Commits
4.241.0: the composer's + is what you can add; the feed reads like posts by people (#525)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.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)",@@@ -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 boxform.setAttribute('data-ovc-no-files', '');@@@ -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);@@@ -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.@@@ -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] .ovcif (!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] .ovcctx.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] .ovcform.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] .ovcb.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();@@@ -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);}@@@ -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() {@@@ -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",@@@ -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": {@@@ -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');nnew file mode 100644@@@ -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); });@@@ -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;@@@ -603,8 +603,8 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...doawait 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');@@@ -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')`);@@@ -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),
@@ -1,5 +1,39 @@
@@ -1,5 +1,39 @@
# Changelog
# 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
## 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
**One agent in the menu's Vibe, and a menu button drawn from the menu** (the owner, 11-13: "all this separation of
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.240.0",
+ "version": "4.241.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -114,7 +114,7 @@
@@ -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
// 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.
// 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 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) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -84,7 +84,7 @@
@@ -84,7 +84,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// 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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -624,7 +624,7 @@
@@ -624,7 +624,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* 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.
// 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]}` : ''}`;
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.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -26,6 +26,15 @@
@@ -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.
* 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,
* 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.
* 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) {
(function (root) {
'use strict';
'use strict';
@@ -346,6 +355,52 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -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:active .ovc-tray-ic { transform: scale(.92); }
.ovc-tray-i:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.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); } }
@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); } }
@keyframes ovc-tray-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; }
html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; }
@keyframes ovc-fade-calm { from { opacity: 0; } }
@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
@@ -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;
if (!blob || blob.size >= file.size) return file;
try { return new File([blob], file.name, { type: file.type, lastModified: file.lastModified }); } catch { 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 live = new Set();
const PREFS_KEY = 'ov:composer';
const PREFS_KEY = 'ov:composer';
const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
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
@@ -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.
// Voice: the browser listens, the words go in at the caret as they come.
let mic = null, rec = null;
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;
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) {
function openSheet(ev) {
if (sheet) { closeSheet(); return; }
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) => {
[...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;
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]');
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');
more.setAttribute('aria-expanded', 'true');
// From the keyboard (a click with no pointer) focus moves into the sheet; a tap leaves it on the +.
// 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')) {
if (tools && !form.hasAttribute('data-ovc-no-more')) {
more = el('button', 'ovt-btn ovc-more', svg(IC.plus));
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.setAttribute('aria-haspopup', 'dialog'); more.setAttribute('aria-expanded', 'false');
more.addEventListener('click', openSheet);
more.addEventListener('click', openSheet);
const lead = tools.querySelector(':scope > .ovdk-mic'); // a page's own mic stays first
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
@@ -1052,10 +1211,10 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
};
};
const EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');
const EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');
const TOOL = {
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.
// 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: {
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) {
run(ctx, button) {
const D = root.OpenVibeDock;
const D = root.OpenVibeDock;
if (D && typeof D.timer === 'function') { try { D.timer(button); return; } catch (e) { console.error(e); } }
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
@@ -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.');
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: '' },
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: {
emoji: {
label: 'Emoji', path: IC.smile,
label: 'Emoji', path: IC.smile,
run(ctx, button, form) {
run(ctx, button, form) {
@@ -1085,9 +1287,9 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
@@ -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
// 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.
// /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
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
// 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
// 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
@@ -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-files', String(o.maxFiles || 10));
form.setAttribute('data-ovc-max-mb', String(o.maxMb || 100));
form.setAttribute('data-ovc-max-mb', String(o.maxMb || 100));
if (o.noDraft) form.setAttribute('data-ovc-no-draft', '');
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;
const resize = o.resize != null ? Number(o.resize) || 0 : p.resize || 0;
if (resize) form.setAttribute('data-ovc-resize', String(resize));
if (resize) form.setAttribute('data-ovc-resize', String(resize));
const want = (o.tools || p.tools).filter((t) => TOOL[t]);
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', '');
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.
// 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');
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
@@ -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.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });
b.appendChild(input);
b.appendChild(input);
}
}
+ if (inPlus(id)) { b.hidden = true; b.tabIndex = -1; }
tools.appendChild(b);
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.
// 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
@@ -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)); }
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();
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(); });
document.addEventListener('ov:boost:load', () => { live.forEach((c) => { if (!c.form.isConnected) c.destroy(); }); scan(); });
pull();
pull();
@@ -687,6 +687,12 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -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' }]],
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' }]],
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' }]],
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) {
function svgIcon(name) {
const NS = 'http://www.w3.org/2000/svg';
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; }
@@ -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
// 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).
// #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']];
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) {
function feedUrl(o, kind, cursor) {
const p = new URLSearchParams({ limit: '12' });
const p = new URLSearchParams({ limit: '12' });
if (kind === 'following') p.set('following', '1'); else if (kind) p.set('kind', kind);
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; }
@@ -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
/** 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. */
* 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 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 kind = isRundown(c) ? 'rundown' : String(c.kind || 'post');
const url = safe(c.url);
const url = safe(c.url);
if (!url) return null;
if (!url) return null;
@@ -1796,10 +1844,10 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -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)}`);
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 head = h('span', 'ovfd-head');
const face = h('span', 'ovfd-face');
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); }
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();
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
const who = h('span', 'ovfd-who');
const who = h('span', 'ovfd-who');
const meta = h('small', '');
const meta = h('small', '');
@@ -1813,9 +1861,17 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -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'));
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(' · ')}`));
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');
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 {
} 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);
who.append(meta);
head.append(face, who);
head.append(face, who);
@@ -1842,12 +1898,21 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -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 (title) a.append(h('b', 'ovfd-title', title.slice(0, 200)));
if (kind === 'report') { const st = statOf(c); if (st) a.append(st); }
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).
// 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)));
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);
const many = groupList(c);
if (many) a.append(many);
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);
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); }
if (people || counts) { const row = counts || h('span', 'ovfd-counts'); if (people) row.prepend(people); a.append(row); }
return a;
return a;
}
}
@@ -1876,17 +1941,32 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -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 list = h('span', 'ovfd-group');
const pic = (x) => { const th = x && x.media && safe(x.media.thumb); return th && /^https:/.test(th) ? th : ''; };
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));
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 row = h('span', 'ovfd-group-i');
const th = pic(x);
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); }
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'));
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);
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) 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;
return list;
}
}
/** What `counts.people` means for a kind: jobs, voters, people online; nothing where it means nothing. */
/** 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; }
@@ -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
// 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).
// per page of a Deals poll; Network now names a burst once, and this keeps any repeat off the screen).
const shownIds = new Set();
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;
let query = '', searchSeq = 0;
const loaded = [];
const loaded = [];
const feedBase = `${o.frameBase || FRAME}/feed`;
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; }
@@ -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');
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);
clear.addEventListener('click', () => { if (o.onSearch) o.onSearch(''); }); resultHead.append(clear);
list.textContent = '';
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);
if (typeof o.mountIcons === 'function') o.mountIcons(list);
}
}
const skeletons = () => { for (let k = 0; k < 3; k++) list.append(h('span', 'ovfd-skel')); };
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; }
@@ -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).
// 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);
const past = (x) => x && (x.kind === 'stream' || (x.kind === 'live' && !x.live_now)) && safe(x.url);
let run = [];
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
// 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.
// (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 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)))); };
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();
flush();
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
cursor = j.next_cursor || null;
cursor = j.next_cursor || null;
@@ -2038,7 +2120,8 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
@@ -2038,7 +2120,8 @@ html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
}
}
KINDS.forEach(([k, label]) => {
KINDS.forEach(([k, label]) => {
if (k === 'following' && !signedIn(o)) return;
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', () => {
b.addEventListener('click', () => {
if (k === kind) return;
if (k === kind) return;
kind = k; cursor = null; done = false; busy = false; lastDay = '';
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; }
@@ -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-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-media{position:relative}
.ovfd-len{font-variant-numeric:tabular-nums;z-index:1}
.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);
document.head.appendChild(st);
}
}
@@ -24,7 +24,7 @@
@@ -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.
// 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 reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
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) || '';
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.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.240.0",
+ "version": "4.241.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.240.0",
+ "version": "4.241.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -51,7 +51,7 @@ window.bigPicture = async (w, h) => { const c = document.createElement('canvas')
@@ -51,7 +51,7 @@ window.bigPicture = async (w, h) => { const c = document.createElement('canvas')
assert.ok(await until(`!!window.post`), 'the composers mount');
assert.ok(await until(`!!window.post`), 'the composers mount');
// The camera: an agent's box and a post have it.
// 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.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.ok(tools[1].includes('Camera'), 'a post: the camera beside photos');
assert.equal(await page.evaluate(`document.getElementById('g1').getAttribute('data-ovc-resize')`), '1568');
assert.equal(await page.evaluate(`document.getElementById('g1').getAttribute('data-ovc-resize')`), '1568');
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -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); });
@@ -60,17 +60,20 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
@@ -60,17 +60,20 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
// Each preset draws its own tools, chips and send.
// Each preset draws its own tools, chips and send.
const shape = await page.evaluate(`(() => [...document.querySelectorAll('.ovc-v2')].map((f) => ({ preset: f.dataset.preset,
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.
// 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'),
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) })))()`);
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.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[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.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[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[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[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');
assert.ok(shape[3].avatar && shape[3].send === 'Reply');
@@ -148,11 +151,11 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
@@ -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.
// 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.getElementById('g1').setAttribute('data-ovc-frame', location.origin), true`);
await page.evaluate(`document.querySelector('#g1 .ovc-more').click(), 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").
// 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');
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');
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 })); })()`);
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;
let rows = 0;
@@ -603,8 +603,8 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
@@ -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 p.evaluate("(document.querySelector('.ovs .ovdk-vibe-new') || { click() {} }).click()");
await until(p, "!!document.querySelector('.ovs .ovc-more')", 'the box and its +');
await until(p, "!!document.querySelector('.ovs .ovc-more')", 'the box and its +');
await p.evaluate("document.querySelector('.ovs .ovc-more').click()");
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 until(p, "!!document.querySelector('.ovdk .ovdk-tmenu')", 'the timer menu');
await p.evaluate("document.querySelector('.ovdk .ovdk-tmenu [data-min=\"5\"]').click()");
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');
const strip = await until(p, "(() => { const s = document.querySelector('.ovdk .ovdk-timer'); return s ? s.innerText : null; })()", 'the timer strip in the Dock');
@@ -51,7 +51,7 @@ const server = http.createServer((req, res) => {
@@ -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.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');
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')) }; })()`);
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');
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(); })()`);
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')`);
const landed = await until(`!!document.getElementById('posted')`);
@@ -91,9 +91,9 @@ const server = http.createServer((req, res) => {
@@ -91,9 +91,9 @@ const server = http.createServer((req, res) => {
await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
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');
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`);
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,
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],
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 })`);
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');
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) => {
@@ -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.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');
assert.ok(first.scroll <= 390, 'no sideways scroll');
await p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); l.scrollTop = l.scrollHeight; })()`);
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');
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`);
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, `(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`);
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');
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),
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),