openvibers/openvibe.shared Commits
openvibe-shared 4.215.0: composer v2, one elegant composer every site mounts (#486)
Changes
@@@ -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-11A 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).@@@ -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 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.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)",@@@ -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 boxform.setAttribute('data-ovc-no-files', '');@@@ -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);@@@ -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.@@@ -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[dataat: '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[datastrip.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[dataelse 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[datalet 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[datareturn 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();@@@ -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-durif (!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) {@@@ -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() {@@@ -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",@@@ -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": {nnew file mode 100644@@@ -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); });@@@ -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');
@@ -1,5 +1,36 @@
@@ -1,5 +1,36 @@
# Changelog
# 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
## 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).
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).
@@ -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.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
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.214.0",
+ "version": "4.215.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)",
@@ -58,7 +58,7 @@
@@ -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
// 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': '9b3472802903' };
+ const VIBE_REV = { 'ov-composer.js': '60c9858ff204' };
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', '');
@@ -89,7 +89,7 @@
@@ -89,7 +89,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': '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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -602,7 +602,7 @@
@@ -602,7 +602,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': '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]}` : ''}`;
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.
@@ -11,6 +11,9 @@
@@ -11,6 +11,9 @@
* code; the count near the box's limit;
* 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.
* - 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({
* Mods (a site, or a script on it): OpenVibeComposer.mod({
* id, match(form) → bool, which boxes it joins (all when absent)
* id, match(form) → bool, which boxes it joins (all when absent)
* tools: [{ id, label, path (24-unit path data), run(ctx) }],
* 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
@@ -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-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-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; }
.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); }
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 = [];
const mods = [];
@@ -137,6 +214,15 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -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',
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',
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',
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 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; };
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
@@ -220,10 +306,12 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
strip.hidden = true;
strip.hidden = true;
strip.setAttribute('aria-label', 'Attached files');
strip.setAttribute('aria-label', 'Attached files');
const firstLabel = form.querySelector(`label[for="${text.id}"]`);
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 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();
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));
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
@@ -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;
else if (wasRequired && !files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;
}
}
text.addEventListener('input', syncSend);
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.
// The slash list and the mention list: one popover above the box.
const pop = el('div', 'ovc-pop');
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
@@ -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;
let mic = null, rec = null;
function micButton() {
function micButton() {
if (!Speech || !tools) return;
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;
if (mic) return;
mic = el('button', 'ovt-btn ovc-mic', svg(IC.mic));
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');
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
@@ -530,6 +623,197 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
return api;
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.
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
let sheet = null;
let sheet = null;
function openSettings(from) {
function openSettings(from) {
@@ -597,8 +881,8 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -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)); }
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();
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();
@@ -850,33 +850,13 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -850,33 +850,13 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
} catch { /* the original goes as it is */ }
} catch { /* the original goes as it is */ }
return out;
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) {
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];
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 auth = { Authorization: `Bearer ${o.token}` };
const upload = async (url, f) => {
const upload = async (url, f) => {
const fd = new FormData(); fd.append('file', f, f.name || 'file');
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
@@ -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.`);
if (!r.ok || !id) throw new Error(j.detail || `${f.name || 'File'} did not upload.`);
return id;
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') {
else if (typeof o.sibling === 'function') {
let script = document.getElementById('ov-composer-script');
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) {
function spark(series) {
@@ -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': '9b3472802903', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': '60c9858ff204', 'ov-combo.js': '3e426e56fc75' };
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.214.0",
+ "version": "4.215.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.214.0",
+ "version": "4.215.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.214.0",
+ "version": "4.215.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": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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); });
@@ -232,8 +232,8 @@ const server = http.createServer((req, res) => {
@@ -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 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(`(() => { 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`);
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)}`);
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 : '' }))`);
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');
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');