openvibers/openvibe.shared Commits

openvibe-shared 4.215.0: composer v2, one elegant composer every site mounts (#486)

OP OpenVibers committed 1 days ago · 4f4dde677c · 13 files, +500 −80

* openvibe-shared 4.215.0: composer v2, one elegant composer every site mounts

Composer v2: one elegant composer that every site mounts (the owner, 11-12: "this composer is completely ugly and
broken … doesn't have the file attachment … it needs to be super elegant, like some kind of social media platform … a
shared composer … interconnected through all sites with all the features we might ever need … really modular").

- **`OpenVibeComposer.mount(host, options)`** draws a whole composer from a preset, and everything `enhance()` gives
  comes with it (files by the picker, a paste or a drop, uploads with progress, `/` commands, `@` mentions, voice,
  drafts, the keys, your settings):
  - **post**: your picture, who sees it as a chip above the box, a box that grows with the words, pictures as tiles
    each with its description and files as chips under the words, photos, files, emoji and voice, the ring that fills
    toward the limit (amber near it, red past it) and Post;
  - **chat**: one line that grows, attach, emoji, voice and a round send;
  - **agent**: a roomy box, attach and voice, the mode chip and a round send (Actor, Codes);
  - **comment**: the small one, with your picture and Reply.
  - `onSubmit({ text, files, uploads, audience, mode }, api)`: the box clears after it; `false` (or an error, shown in
    the box) keeps it. `audience` and `mode` are chips over the browser's own list.
- **Tools are modules**: `OpenVibeComposer.tool(id, { label, path, accept | run(ctx, button, form) })` adds or replaces
  one for every box; a preset or a site names the ones it shows (`tools: ['photo', 'file', 'emoji', 'poll', 'voice']`).
  Built in: photo, file, attach, emoji (a panel of 90 at the caret), voice. `presets()` and `tools()` list them.
- **The Feed's composer is the post preset.** Its own + drew an icon with no stroke, so it could not be seen; the
  Feed's posting (pictures with their descriptions through the frame, other files to Media) is unchanged.
- `enhance()`: a box takes a file any of its pickers takes; a mounted box grows with its words; the file strip sits
  beside the box wherever the box is.

Test: `test/composer-v2-chrome.test.js` (the four presets at 390 px, a post's chip above and pictures under, onSubmit
and false, the ring, emoji, a site's tool); `menu-home-screen-chrome` reads the Feed's note inside the composer.

* Composer v2: the words use the full width; the picture, the name and who sees it are one line above the box

* Composer v2 for agents: any number of chips (Model, Reasoning), and the send is Stop while a run works

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 7582b08..5d5b757 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,36 @@
# Changelog
++## 4.215.0 — 2026-11-12
++
++Composer v2: one elegant composer that every site mounts (the owner, 11-12: "this composer is completely ugly and
++broken … doesn't have the file attachment … it needs to be super elegant, like some kind of social media platform … a
++shared composer … interconnected through all sites with all the features we might ever need … really modular").
++
++- **`OpenVibeComposer.mount(host, options)`** draws a whole composer from a preset, and everything `enhance()` gives
++ comes with it (files by the picker, a paste or a drop, uploads with progress, `/` commands, `@` mentions, voice,
++ drafts, the keys, your settings):
++ - **post**: one line with your picture, your name and who sees it (a chip), then the words over the full width, a box that grows with the words, pictures as tiles
++ each with its description and files as chips under the words, photos, files, emoji and voice, the ring that fills
++ toward the limit (amber near it, red past it) and Post;
++ - **chat**: one line that grows, attach, emoji, voice and a round send;
++ - **agent**: a roomy box, attach and voice, the mode chip and a round send (Actor, Codes);
++ - **comment**: the small one, with your picture and Reply.
++ - `onSubmit({ text, files, uploads, audience, mode }, api)`: the box clears after it; `false` (or an error, shown in
++ the box) keeps it. `audience` and `mode` are chips over the browser's own list.
++- **For agents** (from the agents portal's own box): `chips: [{ name, label, icon, options, value }]` for any number of
++ choices (Model and Reasoning beside the mode), all sent as `chips` by name; `setRunning(true, onStop)` turns the send
++ into Stop while a run works (a click stops, nothing is sent), `setRunning(false)` brings it back.
++- **Tools are modules**: `OpenVibeComposer.tool(id, { label, path, accept | run(ctx, button, form) })` adds or replaces
++ one for every box; a preset or a site names the ones it shows (`tools: ['photo', 'file', 'emoji', 'poll', 'voice']`).
++ Built in: photo, file, attach, emoji (a panel of 90 at the caret), voice. `presets()` and `tools()` list them.
++- **The Feed's composer is the post preset.** Its own + drew an icon with no stroke, so it could not be seen; the
++ Feed's posting (pictures with their descriptions through the frame, other files to Media) is unchanged.
++- `enhance()`: a box takes a file any of its pickers takes; a mounted box grows with its words; the file strip sits
++ beside the box wherever the box is.
++
++Test: `test/composer-v2-chrome.test.js` (the four presets at 390 px, a post's chip above and pictures under, onSubmit
++and false, the ring, emoji, a site's tool); `menu-home-screen-chrome` reads the Feed's note inside the composer.
++
## 4.214.0 — 2026-11-11
A train of four pull requests: the Arrange editor (#481), one inline comment thread for every site (#479), the bar's first paint following the sign-in hint (#482) and four redrawn glyphs (#484).
ddiff --git a/README.md b/README.md
iindex 21a496b..148ee2a 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.214.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.215.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 3c3b7c1..a40d4ca 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.214.0",
++ "version": "4.215.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex c11b429..36f17af 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -58,7 +58,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': '9b3472802903' };
++ const VIBE_REV = { 'ov-composer.js': '60c9858ff204' };
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex 18585bd..6392d45 100644
---- a/dock.js
++++ b/dock.js
@@@ -89,7 +89,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': 'ee110f2a0c1d', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
++ const DOCK_REV = { 'dock-vibe.js': '674e9cf1b2f5', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/navbar.js b/navbar.js
iindex 8983290..9d50614 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -602,7 +602,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': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'aa5ad5d80283', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0724f4913525', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'aaad15c6adb6', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 96e3abb..4f5ca8a 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -11,6 +11,9 @@
* code; the count near the box's limit;
* - your settings (what sends, voice, compact, your snippets), from "/settings" or the slash list's last row.
*
++ * 4.215.0, composer v2: OpenVibeComposer.mount(host, { preset: 'post' | 'chat' | 'agent' | 'comment', … }) draws a whole
++ * composer (see mount() below); OpenVibeComposer.tool(id, spec) adds or replaces a tool module for every box.
++ *
* Mods (a site, or a script on it): OpenVibeComposer.mod({
* id, match(form) → bool, which boxes it joins (all when absent)
* tools: [{ id, label, path (24-unit path data), run(ctx) }],
@@@ -105,6 +108,80 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
.ovc .ovt-c-row { display: flex; align-items: center; gap: 6px; }
.ovc .ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.ovc .ovt-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
++/* 4.215.0, composer v2 (the owner, 11-12: "super elegant … some kind of social media platform … shared in harmony across
++ sites … feature rich … modular"). A mounted composer (OpenVibeComposer.mount) is .ovc-v2[data-preset=post|chat|agent|
++ comment]: the person's picture beside a box that grows, the attachments as a strip (pictures as tiles you describe),
++ one row of round tools, the option chips, the count ring and the send. */
++.ovc-v2.ovt-composer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; max-width: none; padding: 14px 14px 10px; border-radius: 22px; border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: var(--bg-card, #131c2e); transition: border-color .18s, box-shadow .18s; }
++.ovc-v2.ovt-composer:focus-within { border-color: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 55%, var(--ovt-line, rgba(148, 163, 184, .18))); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
++/* The full width is the words' (the owner, 11-12: "a big space on the left … use the full space"): the person's
++ picture, their name and who sees it are one line above the box, as Threads and Bluesky compose. */
++.ovc-v2 .ovc-head { display: flex; align-items: center; gap: 10px; min-width: 0; }
++.ovc-v2 .ovc-av { flex: none; width: 36px; height: 36px; border-radius: 50%; object-fit: cover; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 22%, var(--bg-card, #131c2e)); display: grid; place-items: center; color: var(--text-primary, #e6edf7); font-weight: 700; font-size: 15px; }
++.ovc-v2 .ovc-head-n { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; font-size: 15px; color: var(--text-primary, #e6edf7); }
++.ovc-v2 .ovc-body { min-width: 0; display: grid; gap: 8px; }
++.ovc-v2 .ovc-body > * { min-width: 0; }
++.ovc-v2 .ovc-files { max-width: 100%; }
++.ovc-v2 textarea { display: block; width: 100%; min-height: 26px; max-height: 40vh; box-sizing: border-box; margin: 0; padding: 8px 2px 4px; border: 0; outline: 0; resize: none; overflow-y: auto; background: transparent; color: var(--text-primary, #e6edf7); font: inherit; font-size: 17px; line-height: 1.45; }
++.ovc-v2 textarea::placeholder { color: var(--text-muted, #7386a3); }
++.ovc-v2 .ovt-c-row { display: flex; align-items: center; gap: 4px; padding-top: 8px; border-top: 1px solid color-mix(in srgb, var(--ovt-line, rgba(148, 163, 184, .18)) 70%, transparent); }
++.ovc-v2 .ovt-c-tools { flex: 1; min-width: 0; display: flex; align-items: center; gap: 2px; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
++.ovc-v2 .ovt-c-tools::-webkit-scrollbar { display: none; }
++.ovc-v2 .ovt-btn { position: relative; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; border: 0; background: none; color: var(--ovt-accent, var(--accent-light, var(--accent, #60a5fa))); cursor: pointer; transition: background-color .15s, transform .12s; }
++.ovc-v2 .ovt-btn:hover, .ovc-v2 .ovt-btn:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
++.ovc-v2 .ovt-btn:active { transform: scale(.92); }
++.ovc-v2 .ovt-btn svg { width: 21px; height: 21px; }
++.ovc-v2 .ovt-btn input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
++.ovc-v2 .ovc-chips { display: flex; align-items: center; gap: 6px; flex: none; }
++.ovc-v2 .ovc-head .ovc-chips { margin-left: auto; }
++.ovc-chip { position: relative; display: inline-flex; align-items: center; gap: 6px; max-width: 160px; height: 32px; padding: 0 12px 0 10px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent); color: var(--ovt-accent, var(--accent-light, var(--accent, #60a5fa))); font-size: 13px; font-weight: 650; white-space: nowrap; cursor: pointer; }
++.ovc-chip svg { flex: none; width: 15px; height: 15px; }
++.ovc-chip-v { overflow: hidden; text-overflow: ellipsis; }
++.ovc-chip select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; font: inherit; }
++.ovc-chip:focus-within { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent); }
++.ovc-ring { flex: none; width: 26px; height: 26px; margin: 0 4px; }
++.ovc-ring circle { fill: none; stroke-width: 3; }
++.ovc-ring .ovc-ring-bg { stroke: color-mix(in srgb, var(--text-primary, #fff) 12%, transparent); }
++.ovc-ring .ovc-ring-fg { stroke: var(--ovt-accent, var(--accent, #3b82f6)); stroke-linecap: round; transition: stroke-dashoffset .15s, stroke .15s; }
++.ovc-ring.is-near .ovc-ring-fg { stroke: #f59e0b; }
++.ovc-ring.is-over .ovc-ring-fg { stroke: var(--live-red, #ef4444); }
++.ovc-ring[hidden] { display: none; }
++.ovc-v2 .ovt-send { flex: none; min-height: 38px; padding: 0 20px; border: 0; border-radius: 999px; background: var(--ovt-accent, var(--accent, #3b82f6)); color: var(--on-accent, #fff); font: inherit; font-weight: 700; font-size: 15px; cursor: pointer; transition: opacity .15s, transform .12s; }
++.ovc-v2 .ovt-send:not(:disabled):active { transform: scale(.96); }
++.ovc-v2 .ovt-send:disabled { opacity: .45; cursor: default; }
++.ovc-v2 .ovt-send--round { display: grid; place-items: center; width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 50%; }
++.ovc-v2 .ovt-send--round svg { width: 19px; height: 19px; }
++.ovc-v2.is-running .ovt-send { background: color-mix(in srgb, var(--text-primary, #fff) 14%, transparent); color: var(--text-primary, #e6edf7); }
++.ovc-v2 .ovc-files { margin: 0; padding: 0 0 2px; }
++.ovc-v2[data-preset=post] .ovc-file.ovc-image, .ovc-v2[data-preset=comment] .ovc-file.ovc-image { flex-direction: column; flex-wrap: nowrap; align-items: stretch; width: 150px; max-width: 150px; height: auto; padding: 0; border-radius: 16px; overflow: hidden; }
++.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-th, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-th { width: 100%; height: 150px; border-radius: 15px 15px 0 0; }
++.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-t, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-t { display: none; }
++.ovc-v2[data-preset=post] .ovc-file.ovc-image input, .ovc-v2[data-preset=comment] .ovc-file.ovc-image input { flex: none; width: 100%; min-height: 38px; margin: 0; border: 0; border-top: 1px solid var(--ovt-line, rgba(148, 163, 184, .18)); border-radius: 0 0 15px 15px; padding: 8px 10px; background: transparent; font-size: 13px; }
++.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-x, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-x { top: 6px; right: 6px; background: rgba(0, 0, 0, .6); color: #fff; border-radius: 50%; }
++.ovc-v2 .ovc-note { margin: 6px 0 0; font-size: 13px; color: var(--text-muted, #7386a3); }
++.ovc-v2 .ovc-note:empty { display: none; }
++.ovc-v2.is-busy { opacity: .8; }
++/* chat: one line that grows, the tools beside it, the send round. */
++.ovc-v2[data-preset=chat].ovt-composer { padding: 6px 6px 6px 10px; border-radius: 26px; }
++.ovc-v2[data-preset=chat] .ovc-body { grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 4px; }
++.ovc-v2[data-preset=chat] .ovc-files { grid-column: 1 / -1; }
++.ovc-v2[data-preset=chat] textarea { font-size: 16px; padding: 9px 2px; }
++.ovc-v2[data-preset=chat] .ovt-c-row { padding: 0; border: 0; }
++.ovc-v2[data-preset=chat] .ovt-c-tools { flex: none; }
++/* agent: a roomy box, tools left, the mode chip and the round send right. */
++.ovc-v2[data-preset=agent] textarea { min-height: 52px; font-size: 16px; }
++/* comment: small, the picture smaller. */
++.ovc-v2[data-preset=comment].ovt-composer { padding: 10px 12px 8px; border-radius: 18px; }
++.ovc-v2[data-preset=comment] .ovc-av { width: 28px; height: 28px; font-size: 12px; }
++.ovc-v2[data-preset=comment] .ovc-head-n { font-size: 14px; }
++.ovc-v2[data-preset=comment] textarea { font-size: 15px; }
++/* Emoji: a small panel above the box. */
++.ovc-emoji { position: absolute; z-index: 41; left: 8px; bottom: calc(100% + 8px); display: grid; grid-template-columns: repeat(8, 38px); gap: 2px; max-width: calc(100% - 16px); max-height: 232px; overflow: auto; padding: 8px; border-radius: 16px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .18)); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }
++.ovc-emoji[hidden] { display: none; }
++.ovc-emoji button { width: 38px; height: 38px; border: 0; border-radius: 10px; background: none; font-size: 22px; line-height: 1; cursor: pointer; }
++.ovc-emoji button:hover, .ovc-emoji button:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
++@media (max-width: 480px) { .ovc-v2.ovt-composer { padding: 12px 12px 8px; } .ovc-emoji { grid-template-columns: repeat(7, 38px); } .ovc-v2 .ovt-send { padding: 0 16px; } }
++html[data-ov-motion=off] .ovc-emoji { animation: none; }
`;
function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
const mods = [];
@@@ -137,6 +214,15 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
at: 'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM16 12v1.5a2.5 2.5 0 0 0 5 0V12a9 9 0 1 0-3.5 7.1',
gear: 'M12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6zM12 2.8v2.4M12 18.8v2.4M2.8 12h2.4M18.8 12h2.4M5.5 5.5l1.7 1.7M16.8 16.8l1.7 1.7M5.5 18.5l1.7-1.7M16.8 7.2l1.7-1.7',
back: 'M9 14l-5-5 5-5M4 9h11a5 5 0 0 1 0 10h-3',
++ photo: 'M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM3 16l5-5 4 4 3-3 6 6M15.5 9.5h.01',
++ clip: 'M20 11.5l-8.3 8.3a5 5 0 0 1-7.1-7.1l8.6-8.6a3.4 3.4 0 0 1 4.8 4.8l-8.6 8.6a1.7 1.7 0 0 1-2.4-2.4l7.9-7.9',
++ smile: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01',
++ globe: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM3 12h18M12 3c2.5 2.7 3.6 5.7 3.6 9s-1.1 6.3-3.6 9c-2.5-2.7-3.6-5.7-3.6-9S9.5 5.7 12 3z',
++ spark: 'M12 3l1.9 5.1 5.1 1.9-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z',
++ send: 'M5 12h14M13 6l6 6-6 6',
++ stop: 'M7 7h10v10H7z',
++ cpu: 'M8 8h8v8H8zM9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3',
++ gauge: 'M5 18a8 8 0 1 1 14 0M12 14l3-4',
};
const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
@@@ -220,10 +306,12 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
strip.hidden = true;
strip.setAttribute('aria-label', 'Attached files');
const firstLabel = form.querySelector(`label[for="${text.id}"]`);
-- form.insertBefore(strip, firstLabel || text);
++ const anchor = firstLabel && firstLabel.parentNode === text.parentNode ? firstLabel : text;
++ anchor.parentNode.insertBefore(strip, anchor);
const accepts = (file) => {
-- const acc = (inputs()[0] && inputs()[0].getAttribute('accept')) || '';
-- if (!acc) return true;
++ const list = inputs().map((i) => i.getAttribute('accept') || '');
++ if (!list.length || list.some((a) => !a)) return true;
++ const acc = list.join(',');
const name = (file.name || '').toLowerCase(), type = (file.type || '').toLowerCase();
return acc.split(',').map((x) => x.trim().toLowerCase()).some((a) => !a || (a.startsWith('.') ? name.endsWith(a) : a.endsWith('/*') ? type.startsWith(a.slice(0, -1)) : type === a));
};
@@@ -335,6 +423,11 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
else if (wasRequired && !files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;
}
text.addEventListener('input', syncSend);
++ if (form.classList.contains('ovc-v2')) {
++ const grow = () => { text.style.height = 'auto'; text.style.height = `${Math.min(text.scrollHeight + 2, Math.round((root.innerHeight || 800) * 0.4))}px`; };
++ text.addEventListener('input', grow);
++ requestAnimationFrame(grow);
++ }
// The slash list and the mention list: one popover above the box.
const pop = el('div', 'ovc-pop');
@@@ -480,7 +573,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
let mic = null, rec = null;
function micButton() {
if (!Speech || !tools) return;
-- if (!prefs.voice) { if (mic) { mic.remove(); mic = null; } return; }
++ if (!prefs.voice || form.hasAttribute('data-ovc-no-voice')) { if (mic) { mic.remove(); mic = null; } return; }
if (mic) return;
mic = el('button', 'ovt-btn ovc-mic', svg(IC.mic));
mic.type = 'button'; mic.setAttribute('aria-label', 'Speak'); mic.title = 'Speak'; mic.setAttribute('aria-pressed', 'false');
@@@ -530,6 +623,197 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
return api;
}
++
++ // ── 4.215.0: a whole composer, mounted (composer v2). ─────────────────────────────────────────────────────────────
++ // A site no longer draws its own box: OpenVibeComposer.mount(host, options) draws one of the presets, and everything
++ // enhance() gives (files, paste, drop, uploads, commands, mentions, voice, drafts, keys, the count) comes with it.
++ // options: { preset: 'post' | 'chat' | 'agent' | 'comment', id, name ('text'), placeholder, label, maxLength,
++ // user: { display_name | username, avatar_url }, tools: ['photo', 'file', 'attach', 'emoji', 'voice'] (the
++ // preset's own when absent), accept, maxFiles, maxMb, required (true), noDraft,
++ // audience / mode: { name, label, options: [[value, label]], value } (a chip each); chips: [{ name, label, icon
++ // ('globe' | 'spark' | 'cpu' | 'gauge'), options, value, onChange }] for any more (an agent's Model and Reasoning,
++ // as the agents portal's box has),
++ // submitLabel, upload(file, onProgress), mentions, commands, beforeSend,
++ // onSubmit({ text, files, uploads, audience, mode }, api) → a promise; false keeps the box as it is }
++ // → the enhance api plus { form, text, send, note, setBusy(bool), setRunning(bool, onStop) (the send becomes Stop
++ // while a run works, as in the agents portal), values() (every chip's value by name) }.
++ // A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces
++ // one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.
++ const PRESET = {
++ post: { tools: ['photo', 'file', 'emoji', 'voice'], send: 'Post', avatar: true, rows: 2, max: 2000, ring: true },
++ chat: { tools: ['attach', 'emoji', 'voice'], send: '', avatar: false, rows: 1, max: 4000, ring: false },
++ agent: { tools: ['attach', 'voice'], send: '', avatar: false, rows: 2, max: 16000, ring: false },
++ comment: { tools: ['photo', 'emoji'], send: 'Reply', avatar: true, rows: 1, max: 2000, ring: true },
++ };
++ const EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');
++ const TOOL = {
++ photo: { label: 'Photos and video', path: IC.photo, accept: 'image/*,video/*' },
++ file: { label: 'Files', path: IC.file, accept: '' },
++ attach: { label: 'Attach', path: IC.clip, accept: '' },
++ emoji: {
++ label: 'Emoji', path: IC.smile,
++ run(ctx, button, form) {
++ let panel = form.querySelector('.ovc-emoji');
++ if (panel) { panel.hidden = !panel.hidden; if (!panel.hidden) panel.querySelector('button').focus(); return; }
++ panel = el('div', 'ovc-emoji'); panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Emoji');
++ EMOJI.forEach((e) => {
++ const b = el('button'); b.type = 'button'; b.textContent = e; b.setAttribute('aria-label', e);
++ b.addEventListener('click', () => { ctx.insert(e); panel.hidden = true; });
++ panel.appendChild(b);
++ });
++ panel.addEventListener('keydown', (e) => { if (e.key === 'Escape') { panel.hidden = true; button.focus(); } });
++ document.addEventListener('pointerdown', (e) => { if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) panel.hidden = true; });
++ form.appendChild(panel);
++ panel.querySelector('button').focus();
++ },
++ },
++ voice: { label: 'Speak', builtin: true }, // enhance() draws the microphone where the browser can listen
++ };
++ function tool(id, spec) {
++ if (!/^[\w-]{1,30}$/.test(id || '') || !spec) return;
++ TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);
++ }
++ const initial = (u) => String((u && (u.display_name || u.username)) || '?').trim().charAt(0).toUpperCase() || '?';
++ function mount(host, o = {}) {
++ if (!host) return null;
++ styles();
++ const name = PRESET[o.preset] ? o.preset : 'post';
++ const p = PRESET[name];
++ const form = el('form', 'ovt-composer ovc-v2');
++ form.dataset.preset = name;
++ if (o.id) form.id = o.id;
++ form.setAttribute('data-ovc', '');
++ form.setAttribute('data-ovc-max-files', String(o.maxFiles || 10));
++ form.setAttribute('data-ovc-max-mb', String(o.maxMb || 100));
++ if (o.noDraft) form.setAttribute('data-ovc-no-draft', '');
++ const want = (o.tools || p.tools).filter((t) => TOOL[t]);
++ 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');
++ const head = el('div', 'ovc-head');
++ if (p.avatar && o.user) {
++ const src = o.user.avatar_url || o.user.avatar;
++ const av = src ? el('img', 'ovc-av') : el('span', 'ovc-av');
++ if (src) { av.src = src; av.alt = ''; av.loading = 'lazy'; } else av.textContent = initial(o.user);
++ av.setAttribute('aria-hidden', 'true');
++ const nm = el('span', 'ovc-head-n'); nm.textContent = String(o.user.display_name || o.user.username || '');
++ head.append(av, nm);
++ }
++ const ta = el('textarea');
++ ta.name = o.name || 'text'; ta.rows = p.rows; ta.maxLength = o.maxLength || p.max;
++ ta.placeholder = o.placeholder || (name === 'post' ? "What's new?" : name === 'agent' ? 'Ask for anything' : name === 'comment' ? 'Write a reply' : 'Write a message');
++ ta.setAttribute('aria-label', o.label || ta.placeholder);
++ ta.setAttribute('data-ovt-text', '');
++ if (o.required !== false) ta.required = true;
++ const row = el('div', 'ovt-c-row'), tools = el('div', 'ovt-c-tools'), chips = el('div', 'ovc-chips');
++ const ctxOf = () => (form.__ovc && form.__ovc.ctx) || null;
++ for (const id of want) {
++ const t = TOOL[id];
++ if (t.builtin) continue;
++ if (typeof t.run !== 'function' && t.accept === undefined) continue;
++ const b = el(typeof t.run === 'function' ? 'button' : 'label', `ovt-btn ovc-t-${id}`, svg(t.path || IC.slash));
++ b.title = t.label; b.setAttribute('aria-label', t.label);
++ if (typeof t.run === 'function') { b.type = 'button'; b.addEventListener('click', () => { const c = ctxOf(); if (c) t.run(c, b, form); }); }
++ else {
++ const input = el('input'); input.type = 'file'; input.multiple = true; input.tabIndex = -1;
++ const acc = o.accept != null && id !== 'photo' ? o.accept : t.accept;
++ if (acc) input.accept = acc;
++ input.setAttribute('aria-label', t.label);
++ b.tabIndex = 0;
++ b.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });
++ b.appendChild(input);
++ }
++ 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.
++ const chip = (spec, path) => {
++ if (!spec || !Array.isArray(spec.options) || !spec.options.length) return null;
++ const c = el('label', 'ovc-chip', svg(path));
++ const v = el('span', 'ovc-chip-v');
++ const sel = el('select'); sel.name = spec.name || 'option'; sel.setAttribute('aria-label', spec.label || 'Option');
++ spec.options.forEach(([value, label]) => { const op = el('option'); op.value = value; op.textContent = label; sel.appendChild(op); });
++ if (spec.value != null) sel.value = spec.value;
++ const paint = () => { v.textContent = (sel.selectedOptions[0] && sel.selectedOptions[0].textContent) || ''; };
++ sel.addEventListener('change', () => { paint(); if (typeof spec.onChange === 'function') spec.onChange(sel.value); });
++ paint();
++ c.append(v, sel);
++ return c;
++ };
++ const aud = chip(o.audience, IC.globe), md = chip(o.mode, IC.spark);
++ if (aud) chips.appendChild(aud);
++ if (md) chips.appendChild(md);
++ (Array.isArray(o.chips) ? o.chips : []).forEach((spec) => { const c = chip(spec, IC[spec && spec.icon] || IC.spark); if (c) chips.appendChild(c); });
++ // The ring: how much of the limit is used; amber near it, red over it (the words stay; the send waits).
++ let ring = null;
++ if (p.ring) {
++ ring = el('span', 'ovc-ring');
++ ring.innerHTML = '<svg viewBox="0 0 26 26" width="26" height="26" aria-hidden="true"><circle class="ovc-ring-bg" cx="13" cy="13" r="10"/><circle class="ovc-ring-fg" cx="13" cy="13" r="10" stroke-dasharray="62.83" stroke-dashoffset="62.83" transform="rotate(-90 13 13)"/></svg>';
++ ring.hidden = true;
++ }
++ const send = el('button', p.send ? 'ovt-send' : 'ovt-send ovt-send--round', p.send ? '' : svg(IC.send));
++ if (p.send) send.textContent = o.submitLabel || p.send;
++ send.type = 'submit'; send.disabled = true; send.setAttribute('data-ovt-send', '');
++ send.setAttribute('aria-label', o.submitLabel || p.send || 'Send');
++ // A post or a reply says who sees it above the box (a phone's row keeps room for the tools); a chat or an agent's
++ // box keeps its chip beside the send.
++ const above = name === 'post' || name === 'comment';
++ row.appendChild(tools);
++ if (!above) row.appendChild(chips);
++ if (ring) row.appendChild(ring);
++ row.appendChild(send);
++ if (above && chips.children.length) head.appendChild(chips);
++ if (head.children.length) body.appendChild(head);
++ body.append(ta, row);
++ const note = el('p', 'ovc-note'); note.setAttribute('data-ovt-note', ''); note.setAttribute('role', 'status');
++ form.append(body, note);
++ host.appendChild(form);
++ if (o.upload || o.mentions || o.commands || o.beforeSend) {
++ mod({ id: `mount-${o.id || Math.random().toString(36).slice(2, 8)}`, match: (f) => f === form, upload: o.upload, mentions: o.mentions, commands: o.commands, beforeSend: o.beforeSend });
++ }
++ const api = enhance(form);
++ // A post's or a reply's pictures and files go under its words, as a feed shows them; a chat or an agent's box keeps
++ // them above the line it types in.
++ if (above) { const strip = body.querySelector('.ovc-files'); if (strip) ta.after(strip); }
++ if (ring) {
++ const C = 62.83, fg = ring.querySelector('.ovc-ring-fg');
++ const paint = () => {
++ const used = ta.value.length / ta.maxLength;
++ ring.hidden = !ta.value.length;
++ fg.setAttribute('stroke-dashoffset', String(C * (1 - Math.min(1, used))));
++ ring.classList.toggle('is-near', used >= 0.85 && used < 1);
++ ring.classList.toggle('is-over', used >= 1);
++ };
++ ta.addEventListener('input', paint); paint();
++ }
++ const setBusy = (on) => { form.classList.toggle('is-busy', !!on); send.disabled = !!on || (!ta.value.trim() && !api.files().length); };
++ const values = () => Object.fromEntries([...chips.querySelectorAll('select')].map((sel) => [sel.name, sel.value]));
++ // While a run works the send is Stop (the agents portal's box): a click calls onStop, nothing is sent.
++ let stopFn = null;
++ const sendFace = send.innerHTML, sendLabel = send.getAttribute('aria-label');
++ send.addEventListener('click', (e) => { if (!stopFn) return; e.preventDefault(); e.stopImmediatePropagation(); const f = stopFn; try { f(); } catch (err) { console.error(err); } }, true);
++ const setRunning = (on, onStop) => {
++ stopFn = on ? (typeof onStop === 'function' ? onStop : () => {}) : null;
++ form.classList.toggle('is-running', !!on);
++ if (on) { send.innerHTML = svg(IC.stop); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Stop'); send.title = 'Stop'; send.disabled = false; }
++ else { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; api.refresh(); }
++ };
++ if (typeof o.onSubmit === 'function') {
++ form.addEventListener('submit', async (e) => {
++ e.preventDefault();
++ if (form.classList.contains('is-busy')) return;
++ const value = { text: ta.value.trim(), files: api.files(), uploads: api.uploads(), audience: aud ? aud.querySelector('select').value : null, mode: md ? md.querySelector('select').value : null, chips: values() };
++ setBusy(true);
++ let keep = false;
++ try { keep = (await o.onSubmit(value, api)) === false; }
++ catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }
++ setBusy(false);
++ if (!keep) api.clear();
++ api.refresh();
++ });
++ }
++ return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values });
++ }
++
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
let sheet = null;
function openSettings(from) {
@@@ -597,8 +881,8 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
}
function scan(scope) { (scope || document).querySelectorAll('[data-ovt-composer], [data-ovc]').forEach((f) => enhance(f)); }
-- root.OpenVibeComposer = { enhance, mod, scan, settings: openSettings, get prefs() { return Object.assign({}, prefs); }, setPrefs };
-- if (typeof module === 'object' && module.exports) module.exports = { enhance, mod, scan };
++ 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 };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();
document.addEventListener('ov:boost:load', () => { live.forEach((c) => { if (!c.form.isConnected) c.destroy(); }); scan(); });
pull();
ddiff --git a/ov-home.js b/ov-home.js
iindex 6f8e42d..e3c86e0 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -850,33 +850,13 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
} catch { /* the original goes as it is */ }
return out;
}
++ // The Feed's composer is the shared one (ov-composer.js 4.215.0, composer v2, preset post): the person's picture, a
++ // box that grows, photos as tiles each with its description, files, emoji, voice, the ring and Post. The 11-12
++ // owner: "this composer is completely ugly and broken … doesn't have the file attachment" (its own + drew an icon
++ // with no stroke, so it could not be seen). What it posts is still the Feed's: pictures through the frame with their
++ // descriptions, other files to Media, then the post or the status.
function composer(box, o) {
-- const form = h('form', 'ovt-composer'); form.id = 'ovh-feed-composer'; form.setAttribute('data-ovc', ''); form.setAttribute('data-ovc-max-files', '10'); form.setAttribute('data-ovc-max-mb', '300');
-- const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 2000; ta.rows = 2; ta.required = true; ta.setAttribute('data-ovt-text', '');
const first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];
-- ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");
-- const row = h('div', 'ovt-c-row'), tools = h('div', 'ovt-c-tools');
-- // The composer's own + (its contract: label.ovt-btn.ovt-attach holding the file input), drawn by ov-composer.js's
-- // styles wherever the menu opens; a kit button had no styles here and drew bare ("Attach files").
-- const attach = h('label', 'ovt-btn ovt-attach'); attach.title = 'Attach files'; attach.setAttribute('aria-label', 'Attach files');
-- attach.append(svgIcon('attach'));
-- const file = h('input'); file.type = 'file'; file.multiple = true; file.hidden = true; file.setAttribute('data-ovt-files', ''); file.setAttribute('aria-label', 'Attach files');
-- attach.append(file); tools.append(attach);
-- const post = h('button', 'ovt-send', 'Post'); post.type = 'submit'; post.disabled = true; post.setAttribute('data-ovt-send', '');
-- const note = h('p', 'ovh-note'); note.hidden = true; note.setAttribute('data-ovt-note', '');
-- row.append(tools, post); form.append(ta, row); box.append(form, note);
-- const pics = new Map();
-- const ready = () => { post.disabled = !ta.value.trim() || [...pics.values()].some((alt) => !alt.value.trim()); };
-- form.addEventListener('input', ready);
-- form.addEventListener('ovc:file-added', (e) => {
-- const { file: f, chip } = e.detail;
-- if (!/^image\//.test(f.type)) return;
-- chip.classList.add('ovc-image');
-- const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?';
-- alt.setAttribute('aria-label', `What ${f.name || 'the picture'} shows`); alt.required = true;
-- chip.append(alt); pics.set(f, alt); queueMicrotask(ready);
-- });
-- form.addEventListener('ovc:file-removed', (e) => { pics.delete(e.detail.file); queueMicrotask(ready); });
const auth = { Authorization: `Bearer ${o.token}` };
const upload = async (url, f) => {
const fd = new FormData(); fd.append('file', f, f.name || 'file');
@@@ -886,47 +866,56 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
if (!r.ok || !id) throw new Error(j.detail || `${f.name || 'File'} did not upload.`);
return id;
};
-- form.addEventListener('submit', async (e) => {
-- e.preventDefault();
-- const text = ta.value.trim(), files = form.__ovc ? form.__ovc.files() : [...file.files];
-- if (!text) { ta.focus(); return; }
-- const images = files.filter((f) => /^image\//.test(f.type));
-- if (images.length > 4) { note.hidden = false; note.textContent = 'Choose up to four pictures.'; return; }
-- const missing = images.find((f) => !pics.get(f) || !pics.get(f).value.trim());
-- if (missing) { note.hidden = false; note.textContent = 'Describe each picture.'; pics.get(missing)?.focus(); return; }
-- post.disabled = true; note.hidden = true;
-- const label = post.textContent;
-- try {
-- const pictures = [], attachments = [];
-- for (const f of files) {
-- post.textContent = `Uploading ${f.name || 'file'}`;
-- if (/^image\//.test(f.type)) {
-- const prepared = await preparePicture(f);
-- const media_id = await upload(`${o.frameBase || FRAME}/attachments`, prepared.file);
-- pictures.push({ media_id, alt: pics.get(f).value.trim() });
-- } else {
-- const media_id = await upload('https://openvibe.media/api/v1/attachments', f);
-- attachments.push(media_id);
++ const start = () => {
++ if (!root.OpenVibeComposer || !root.OpenVibeComposer.mount || !box.isConnected || box.querySelector('#ovh-feed-composer')) return;
++ const pics = new Map();
++ const c = root.OpenVibeComposer.mount(box, {
++ preset: 'post', id: 'ovh-feed-composer', user: o.user, maxLength: 2000, maxFiles: 10, maxMb: 300,
++ placeholder: first ? `What's new, ${first}?` : "What's new?", label: "What's new",
++ async onSubmit(v, api) {
++ const images = v.files.filter((f) => /^image\//.test(f.type));
++ if (images.length > 4) { api.ctx.note('Choose up to four pictures.'); return false; }
++ const missing = images.find((f) => !pics.get(f) || !pics.get(f).value.trim());
++ if (missing) { api.ctx.note('Describe each picture: what does it show?'); pics.get(missing)?.focus(); return false; }
++ const pictures = [], attachments = [];
++ for (const f of v.files) {
++ c.send.textContent = `Uploading ${f.name || 'file'}`;
++ if (/^image\//.test(f.type)) {
++ const prepared = await preparePicture(f);
++ const media_id = await upload(`${o.frameBase || FRAME}/attachments`, prepared.file);
++ pictures.push({ media_id, alt: pics.get(f).value.trim() });
++ } else attachments.push(await upload('https://openvibe.media/api/v1/attachments', f));
}
-- }
-- post.textContent = 'Posting';
-- const url = files.length ? `${o.frameBase || FRAME}/posts` : `${o.frameBase || FRAME}/status`;
-- const body = files.length ? { text, pictures, attachments } : { text };
-- const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify(body) });
-- const j = await r.json().catch(() => ({}));
-- if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');
-- ta.value = ''; ta.dispatchEvent(new Event('input', { bubbles: true })); form.__ovc?.clearFiles(); pics.clear();
-- note.hidden = false; note.textContent = 'Posted. It is on your profile and in the Feed.';
-- if (o.onPosted) o.onPosted(j);
-- } catch (err) { note.hidden = false; note.textContent = String(err.message || err); ta.dispatchEvent(new Event('input', { bubbles: true })); }
-- post.textContent = label; ready();
-- });
-- const join = () => { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); };
-- if (root.OpenVibeComposer) join();
++ c.send.textContent = 'Posting';
++ const url = v.files.length ? `${o.frameBase || FRAME}/posts` : `${o.frameBase || FRAME}/status`;
++ const body = v.files.length ? { text: v.text, pictures, attachments } : { text: v.text };
++ try {
++ const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify(body) });
++ const j = await r.json().catch(() => ({}));
++ if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');
++ pics.clear();
++ api.ctx.note('Posted. It is on your profile and in the Feed.');
++ if (o.onPosted) o.onPosted(j);
++ } finally { c.send.textContent = 'Post'; }
++ return true;
++ },
++ });
++ // Each picture asks what it shows (the Feed shows pictures with their descriptions).
++ c.form.addEventListener('ovc:file-added', (e) => {
++ const { file: f, chip } = e.detail;
++ if (!/^image\//.test(f.type)) return;
++ chip.classList.add('ovc-image');
++ const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?';
++ alt.setAttribute('aria-label', `What ${f.name || 'the picture'} shows`); alt.required = true;
++ chip.append(alt); pics.set(f, alt);
++ });
++ c.form.addEventListener('ovc:file-removed', (e) => { pics.delete(e.detail.file); });
++ };
++ if (root.OpenVibeComposer && root.OpenVibeComposer.mount) start();
else if (typeof o.sibling === 'function') {
let script = document.getElementById('ov-composer-script');
-- if (!script) { script = document.createElement('script'); script.id = 'ov-composer-script'; script.src = o.sibling('ov-composer.js'); script.onload = join; document.head.append(script); }
-- else script.addEventListener('load', join, { once: true });
++ if (!script) { script = document.createElement('script'); script.id = 'ov-composer-script'; script.src = o.sibling('ov-composer.js'); script.onload = start; document.head.append(script); }
++ else script.addEventListener('load', start, { once: true });
}
}
function spark(series) {
ddiff --git a/ov-thread.js b/ov-thread.js
iindex d4494d2..06314fc 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
-- const THREAD_REV = { 'ov-composer.js': '9b3472802903', 'ov-combo.js': '3e426e56fc75' };
++ const THREAD_REV = { 'ov-composer.js': '60c9858ff204', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
ddiff --git a/package-lock.json b/package-lock.json
iindex 99d1a5e..8d11424 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.214.0",
++ "version": "4.215.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.214.0",
++ "version": "4.215.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 7ad2c62..2615010 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.214.0",
++ "version": "4.215.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/composer-v2-chrome.test.js b/test/composer-v2-chrome.test.js
nnew file mode 100644
iindex 0000000..d58b4ee
---- /dev/null
++++ b/test/composer-v2-chrome.test.js
@@@ -0,0 +1,116 @@
++'use strict';
++// Composer v2 (ov-composer.js 4.215.0, OpenVibeComposer.mount) in a real headless Chrome at 390 px: the four presets
++// draw their own tools; a post says who sees it above the box and puts its pictures under the words; onSubmit gets the
++// words, the files and the chips' choices and the box clears after; false keeps it; the ring fills toward the limit;
++// emoji go in at the caret; a site's tool module (tool()) joins every box that names it; nothing is wider than a phone.
++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');
++assert.doesNotThrow(() => require('../ov-composer'), 'ov-composer.js loads (browser-only: Node takes the early return)');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer-v2-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') { 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><div id="d"></div>
++<script src="/ov-composer.js"></script>
++<script>
++window.got = [];
++const C = OpenVibeComposer;
++C.tool('poll', { label: 'Poll', path: 'M5 20V10M12 20V4M19 20v-7', run: (ctx) => { ctx.insert('[poll] '); } });
++window.post = C.mount(document.getElementById('a'), { preset: 'post', id: 'p1', user: { display_name: 'goosely' }, maxLength: 100,
++ tools: ['photo', 'file', 'emoji', 'poll', 'voice'],
++ audience: { name: 'audience', label: 'Who sees it', options: [['public', 'Everyone'], ['followers', 'Followers']], value: 'public' },
++ onSubmit: async (v) => { window.got.push({ text: v.text, files: v.files.map((f) => f.name), audience: v.audience }); return window.keepNext ? false : true; } });
++window.chat = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', onSubmit: async () => true });
++window.agent = C.mount(document.getElementById('c'), { preset: 'agent', id: 'g1', mode: { name: 'mode', label: 'Mode', options: [['balanced', 'Balanced'], ['best', 'Best']], value: 'balanced' },
++ chips: [{ name: 'effort', label: 'Reasoning', icon: 'gauge', options: [['medium', 'Medium'], ['high', 'High']], value: 'high' }],
++ onSubmit: async (v) => { window.got.push({ mode: v.mode, effort: v.chips.effort }); } });
++window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r1', user: { display_name: 'Maticus', avatar_url: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' }, onSubmit: async () => true });
++</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-v2-'));
++ 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: 900 });
++ 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.reply`), 'the four composers mount');
++
++ // Each preset draws its own tools, chips and send.
++ const shape = await page.evaluate(`(() => [...document.querySelectorAll('.ovc-v2')].map((f) => ({ preset: f.dataset.preset,
++ tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')),
++ 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.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[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.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');
++
++ // A post: who sees it is above the box; a picture goes under the words.
++ let order = await page.evaluate(`(() => { const b = document.querySelector('#p1 .ovc-body'); return [...b.children].map((n) => n.className || n.tagName.toLowerCase()); })()`);
++ assert.ok(order.indexOf('ovc-head') === 0 && order.indexOf('ovc-head') < order.indexOf('textarea'), `the picture, the name and who sees it are above the box (${order})`);
++ assert.ok(await page.evaluate(`!!document.querySelector('#p1 .ovc-head .ovc-chips .ovc-chip')`), 'the audience chip is in that line');
++ await page.evaluate(`post.add([new File([new Uint8Array([71, 73, 70])], 'cat.gif', { type: 'image/gif' })])`);
++ order = await page.evaluate(`(() => [...document.querySelector('#p1 .ovc-body').children].map((n) => n.className || n.tagName.toLowerCase()))()`);
++ assert.ok(order.indexOf('textarea') < order.findIndex((c) => String(c).includes('ovc-files')), `pictures go under the words (${order})`);
++
++ // The site's tool runs with the box's context; emoji go in at the caret.
++ await page.evaluate(`document.querySelector('#p1 .ovc-t-poll').click()`);
++ await page.evaluate(`document.querySelector('#p1 .ovc-t-emoji').click()`);
++ assert.ok(await until(`!!document.querySelector('#p1 .ovc-emoji:not([hidden])')`), 'the emoji panel opens');
++ await page.evaluate(`document.querySelector('#p1 .ovc-emoji button').click()`);
++ assert.match(await page.evaluate(`document.querySelector('#p1 textarea').value`), /^\[poll\] 😀$/);
++
++ // The ring fills toward the limit and turns red past it.
++ await page.evaluate(`post.ctx.set('x'.repeat(90))`);
++ assert.ok(await page.evaluate(`document.querySelector('#p1 .ovc-ring').classList.contains('is-near')`), '90 of 100 is near the limit');
++
++ // Send: the words, the files and the audience; then the box clears. false keeps it.
++ await page.evaluate(`(() => { post.ctx.set('Hello'); document.querySelector('#p1 select').value = 'followers'; document.querySelector('#p1 select').dispatchEvent(new Event('change', { bubbles: true })); })()`);
++ assert.equal(await page.evaluate(`document.querySelector('#p1 .ovc-chip-v').textContent`), 'Followers', 'the chip shows the choice');
++ await page.evaluate(`document.querySelector('#p1 [data-ovt-send]').click()`);
++ assert.ok(await until(`window.got.length === 1`));
++ assert.deepEqual(await page.evaluate(`window.got[0]`), { text: 'Hello', files: ['cat.gif'], audience: 'followers' });
++ assert.ok(await until(`document.querySelector('#p1 textarea').value === '' && !document.querySelector('#p1 .ovc-file')`), 'the box clears after the send');
++ await page.evaluate(`(() => { window.keepNext = true; post.ctx.set('Keep me'); document.querySelector('#p1 [data-ovt-send]').click(); })()`);
++ assert.ok(await until(`window.got.length === 2`));
++ assert.equal(await page.evaluate(`document.querySelector('#p1 textarea').value`), 'Keep me', 'onSubmit false keeps the box');
++
++ // An agent's mode goes with the send.
++ await page.evaluate(`(() => { agent.ctx.set('Plan my week'); document.querySelector('#g1 [data-ovt-send]').click(); })()`);
++ assert.ok(await until(`window.got.length === 3`));
++ assert.deepEqual(await page.evaluate(`window.got[2]`), { mode: 'balanced', effort: 'high' }, 'the mode and any more chips go with the send');
++ // While a run works, the send is Stop.
++ await page.evaluate(`(() => { window.stopped = 0; agent.setRunning(true, () => { window.stopped++; }); })()`);
++ assert.equal(await page.evaluate(`document.querySelector('#g1 [data-ovt-send]').getAttribute('aria-label')`), 'Stop');
++ await page.evaluate(`document.querySelector('#g1 [data-ovt-send]').click()`);
++ assert.equal(await page.evaluate(`window.stopped`), 1, 'Stop calls onStop');
++ assert.equal(await page.evaluate(`window.got.length`), 3, 'and sends nothing');
++ await page.evaluate(`agent.setRunning(false)`);
++ assert.equal(await page.evaluate(`document.querySelector('#g1 [data-ovt-send]').getAttribute('aria-label')`), 'Send');
++
++ // Nothing is wider than the phone.
++ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);
++ await page.close();
++ console.log('composer-v2-chrome: four presets, a post\'s chip above and pictures under, onSubmit and false, the ring, emoji, a site\'s tool, 390 px');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex 0e7abc4..5483d01 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -232,8 +232,8 @@ const server = http.createServer((req, res) => {
await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('#ovh-feed-composer').checkValidity()`), 'the picture needs a description'));
await s.evaluate(`(() => { const a = document.querySelector('#ovh-feed-composer .ovc-image input'); a.value = 'A dark sky'; a.dispatchEvent(new Event('input', { bubbles: true })); })()`);
await s.evaluate(`document.querySelector('#ovh-feed-composer .ovt-send').click(), true`);
-- const posted = await until(s, `/Posted/.test(document.querySelector('#ovh-feed-composer + .ovh-note').textContent)`);
-- const postState = await s.evaluate(`({ note: document.querySelector('#ovh-feed-composer + .ovh-note').textContent, valid: document.querySelector('#ovh-feed-composer').checkValidity(), disabled: document.querySelector('#ovh-feed-composer .ovt-send').disabled, calls: window.__calls.filter((c) => /attachments|posts/.test(c.u)).map((c) => c.u) })`);
++ const posted = await until(s, `/Posted/.test(document.querySelector('#ovh-feed-composer .ovc-note').textContent)`);
++ const postState = await s.evaluate(`({ note: document.querySelector('#ovh-feed-composer .ovc-note').textContent, valid: document.querySelector('#ovh-feed-composer').checkValidity(), disabled: document.querySelector('#ovh-feed-composer .ovt-send').disabled, calls: window.__calls.filter((c) => /attachments|posts/.test(c.u)).map((c) => c.u) })`);
assert.ok(posted, `it posts: ${JSON.stringify(postState)}`);
const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/v1/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u, auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);
assert.deepStrictEqual(sent.map((c) => c.u.replace(/^https:\/\/(?:openvibe.network\/api\/frame|openvibe.media\/api\/v1)\//, '')), ['attachments', 'attachments', 'posts'], 'picture and PDF upload before the post');