openvibers/openvibe.shared ov-composer.js

ov-composer.js

main 178.1 KB · 2108 lines · javascript

1/**
2 * ov-composer.js (openvibe-shared 4.201.0): one composer on every site. Ask 349, the owner: "how come on this composer
3 * there's no file attaching system and all the other features … a better composer system unified across all sites …
4 * moddable and fully customizable". ov-thread.js loads it for each thread.composer() form, and a site's own box joins
5 * with OpenVibeComposer.enhance(form). The form's markup and its submit stay the site's; this adds:
6 * - files: the + menu, a paste, a drop; each one a chip above the box (a picture's thumbnail, the name and size, ×).
7 * A long paste becomes a text file, which you can put back in the box. Files go with the form (multipart); where a
8 * mod gives upload(file, onProgress), they go ahead of it, the chip shows the progress, and the form carries the ids;
9 * - "/" for commands (the site's, its mods', your own snippets) and "@" for mentions (the mods' sources);
10 * - voice, where the browser can listen; a draft kept per box until it is sent; Ctrl/⌘+B, I, E for bold, italic and
11 * code; the count near the box's limit;
12 * - your settings (what sends, voice, compact, your snippets), from "/settings" or the slash list's last row.
13 *
14 * 4.215.0, composer v2: OpenVibeComposer.mount(host, { preset: 'post' | 'chat' | 'agent' | 'comment', … }) draws a whole
15 * composer (see mount() below); OpenVibeComposer.tool(id, spec) adds or replaces a tool module for every box.
16 *
17 * Mods (a site, or a script on it): OpenVibeComposer.mod({
18 * id, match(form) → bool, which boxes it joins (all when absent)
19 * tools: [{ id, label, path (24-unit path data), run(ctx) }],
20 * commands: [{ name, hint, insert } | { name, hint, run(ctx) }],
21 * mentions: { search(q) → Promise<[{ label, handle, sub, avatar, insert }]> },
22 * upload(file, onProgress) → Promise<{ id }>, files go to the site's store before the send
23 * beforeSend(ctx) → false to hold the send })
24 * ctx: { form, text, value, set(v), insert(s), files(), alt(file), caption(file), add(files), note(s), send() }.
25 * A box its page sends by script (a chat, an agent's run): form.__ovc.files() → the files (File objects), uploads(): the
26 * ids a mod's upload gave, clear() after the send (the text, the chips, the draft); a form 'reset' clears the same.
27 * A site without script says the same in data: data-ovt-commands='[{"name","hint","insert"}]', data-ovc-max-files,
28 * data-ovc-max-mb, data-ovc-no-draft, data-ovc-media (4.247.0: the files go to Media's composer attachments).
29 *
30 * 4.241.0, the +: one sheet of what you can add. Big tiles first (Camera, Photos, Files), then rows (Paste, Screenshot,
31 * the phone's own camera app, Share to an app, Timer, GitHub, the mods' tools, what a narrow row hides). A phone gets
32 * it as a sheet from the bottom (swipe it down, or tap outside); a wide screen as a panel under the box's row.
33 * Which ones: mount(host, { plus: { tiles: [ids], rows: [ids], only } }), the form's data-ovc-plus='{"tiles":…}',
34 * or the site's OpenVibeComposer.configure({ plus }); `only: true` leaves out the extras not named.
35 * A tool says where it fits: OpenVibeComposer.tool(id, { label, sub, tile, path, accept, capture, when(env), run })
36 * (env: files, anyFile, touch, camera, screen, share, clip). A tool with `accept` (and `capture`) is a file picker:
37 * on Android, a picker with no `accept` is the system's Choose an action (Camera, Camcorder, Files).
38 */
39(function (root) {
40 'use strict';
41 if (root.OpenVibeComposer || typeof document === 'undefined') return;
42
43 // 4.203.0: the composer carries its own look (it was in thread.css), so any box on any page gets it: the Dock's
44 // Vibe and Code boxes included. Injected once, before the first box is joined.
45 const CSS = `.ovt-composer { position: relative; display: grid; gap: 6px; max-width: 780px; margin: 0 auto; padding: 10px 12px; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 18px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); }
46.ovt-composer textarea { width: 100%; min-height: 48px; box-sizing: border-box; padding: 6px 2px; border: 0; outline: 0; resize: vertical; background: transparent; color: inherit; font: inherit; }
47.ovt-c-row { display: flex; align-items: center; gap: 8px; }
48.ovt-c-tools { flex: 1; display: flex; align-items: center; gap: 6px; }
49.ovt-send { min-height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
50.ovt-send:disabled { opacity: .5; }
51.ovt-composer .ovc-file.ovc-image { height: auto; min-height: 100px; flex-wrap: wrap; overflow: visible; }
52.ovt-composer .ovc-image input { flex: 1 0 100%; min-width: 0; min-height: 36px; box-sizing: border-box; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 8px; background: var(--bg-primary, #0b1220); color: inherit; font: inherit; }
53.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
54.ovc-files[hidden] { display: none; }
55.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;
56 border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }
57.ovc-file.is-bad { border-color: color-mix(in srgb, var(--live-red, #ef4444) 70%, transparent); }
58.ovc-file.is-up::after { content: ""; position: absolute; left: 0; bottom: 0; height: 3px; width: calc(var(--p, 0) * 100%); background: var(--ovt-accent, var(--accent, #3b82f6)); transition: width .2s; }
59.ovc-file-th { flex: none; width: 40px; height: 40px; border-radius: 10px; object-fit: cover; background: #0b1220; }
60.ovc-file-ic { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); color: var(--ovt-accent, var(--accent, #3b82f6)); font-size: 11px; font-weight: 750; letter-spacing: .02em; }
61.ovc-file-t { min-width: 0; display: grid; line-height: 1.25; }
62.ovc-file-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; color: var(--text-primary, #e6edf7); }
63.ovc-file-s { font-size: 12px; color: var(--text-muted, #7386a3); white-space: nowrap; }
64.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }
65.ovc-file-x, .ovc-file-b { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; }
66.ovc-file-b { top: auto; bottom: 4px; }
67/* 4.228.0: Caption it with AI (Network /api/frame/caption). Its corner of the chip; a gentle pulse while it writes. */
68.ovc-file-ai { position: absolute; right: 4px; bottom: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); color: var(--ovt-accent, var(--accent-light, #60a5fa)); cursor: pointer; }
69.ovc-file-ai:hover, .ovc-file-ai:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 28%, transparent); }
70.ovc-file-ai svg { width: 15px; height: 15px; }
71.ovc-file-ai.is-busy { cursor: progress; }
72.ovc-file-ai.is-busy svg { animation: ovc-spark 1.2s ease-in-out infinite; }
73@keyframes ovc-spark { 50% { transform: scale(.72) rotate(45deg); opacity: .55; } }
74html[data-ov-motion=reduced] .ovc-file-ai.is-busy svg { animation-duration: 2.4s; }
75html[data-ov-motion=off] .ovc-file-ai.is-busy svg { animation: none; opacity: .6; }
76.ovc-file-x:hover, .ovc-file-b:hover, .ovc-file-x:focus-visible, .ovc-file-b:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); color: var(--text-primary, #e6edf7); }
77.ovc-file-x svg, .ovc-file-b svg { width: 15px; height: 15px; }
78.ovc-dropping { border-color: var(--ovt-accent, var(--accent, #3b82f6)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent), 0 8px 30px rgba(0, 0, 0, .25); }
79.ovc-dropping::before { content: "Drop to attach"; position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; border-radius: inherit; background: color-mix(in srgb, var(--bg-card, #131c2e) 86%, var(--ovt-accent, var(--accent, #3b82f6))); color: var(--text-primary, #e6edf7); font-weight: 650; pointer-events: none; }
80.ovc-pop { position: absolute; z-index: 40; left: 6px; right: 6px; bottom: calc(100% + 8px); max-height: min(320px, 50vh); overflow: auto; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18)));
81 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; }
82.ovt.is-start .ovc-pop { bottom: auto; top: calc(100% + 8px); }
83.ovc-pop[hidden] { display: none; }
84.ovc-pop-i { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 6px 10px; box-sizing: border-box; border: 0; border-radius: 11px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
85.ovc-pop-i[aria-selected="true"], .ovc-pop-i:hover { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
86.ovc-pop-ic, .ovc-pop-av { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); object-fit: cover; }
87.ovc-pop-av { border-radius: 50%; }
88.ovc-pop-ic svg { width: 17px; height: 17px; }
89.ovc-pop-t { min-width: 0; display: grid; line-height: 1.3; }
90.ovc-pop-t b { font-weight: 650; font-size: 14px; color: var(--text-primary, #e6edf7); }
91.ovc-pop-t span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #7386a3); }
92.ovc-pop-tag { font-size: 11px; font-weight: 650; padding: 2px 7px; border-radius: 999px; color: var(--ovt-accent, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); }
93.ovc-mic[aria-pressed="true"] { color: var(--live-red, #ef4444); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); animation: ovc-listen 1.4s ease-in-out infinite; }
94.ovc-count { flex: none; font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }
95.ovc-count.is-low { color: var(--live-red, #ef4444); }
96.ovc-count[hidden] { display: none; }
97.ovt-composer.ovc-compact { padding: 6px 8px 6px 12px; border-radius: 18px; }
98.ovt-composer.ovc-compact .ovt-c-row { margin-top: 0; }
99.ovt-composer.ovc-compact .ovt-btn, .ovt-composer.ovc-compact .ovt-send { width: 36px; min-width: 36px; height: 36px; }
100@keyframes ovc-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }
101@keyframes ovc-listen { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-red, #ef4444) 18%, transparent); } }
102html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }
103.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
104.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }
105.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }
106.ovc-set-in .ovc-set-f { padding: 0; }
107.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }
108.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }
109.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }
110.ovc-set-g legend { padding: 0 0 6px; font-weight: 650; font-size: 14px; }
111.ovc-set label { display: flex; align-items: center; gap: 10px; min-height: 40px; font-size: 14.5px; cursor: pointer; }
112.ovc-set small, .ovc-set-p { color: var(--text-muted, #7386a3); font-size: 12.5px; margin: 0; }
113.ovc-set input[type=radio], .ovc-set input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--accent, #3b82f6); }
114.ovc-set-l { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
115.ovc-set-l li { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 4px 4px 4px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }
116.ovc-set-l span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #b4c0d4); font-size: 13px; }
117.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }
118.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }
119.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
120@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }
121/* 4.205.0: the composer's own tool buttons and row on a page without thread.css (an agent's run, a card, any box), so it
122 is the same composer everywhere. Where thread.css is loaded its rules are the same. */
123.ovc .ovt-btn { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; text-decoration: none; }
124.ovc .ovt-btn:hover, .ovc .ovt-btn:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }
125.ovc .ovt-btn .ovt-ic, .ovc .ovt-btn svg { width: 22px; height: 22px; }
126.ovc .ovt-c-row { display: flex; align-items: center; gap: 6px; }
127.ovc .ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
128.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; }
129/* 4.215.0, composer v2 (the owner, 11-12: "super elegant … some kind of social media platform … shared in harmony across
130 sites … feature rich … modular"). A mounted composer (OpenVibeComposer.mount) is .ovc-v2[data-preset=post|chat|agent|
131 comment]: the person's picture beside a box that grows, the attachments as a strip (pictures as tiles you describe),
132 one row of round tools, the option chips, the count ring and the send. */
133.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; }
134.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); }
135/* The full width is the words' (the owner, 11-12: "a big space on the left … use the full space"): the person's
136 picture, their name and who sees it are one line above the box, as Threads and Bluesky compose. */
137.ovc-v2 .ovc-head { display: flex; align-items: center; gap: 10px; min-width: 0; }
138.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; }
139.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); }
140.ovc-v2 .ovc-body { min-width: 0; display: grid; gap: 8px; }
141.ovc-v2 .ovc-body > * { min-width: 0; }
142.ovc-v2 .ovc-files { max-width: 100%; }
143.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; }
144.ovc-v2 textarea::placeholder { color: var(--text-muted, #7386a3); }
145.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); }
146.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; }
147.ovc-v2 .ovt-c-tools::-webkit-scrollbar { display: none; }
148.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; }
149.ovc-v2 .ovt-btn:hover, .ovc-v2 .ovt-btn:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
150.ovc-v2 .ovt-btn:active { transform: scale(.92); }
151.ovc-v2 .ovt-btn svg { width: 21px; height: 21px; }
152.ovc-v2 .ovt-btn input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
153.ovc-v2 .ovc-chips { display: flex; align-items: center; gap: 6px; flex: none; }
154.ovc-v2 .ovc-head .ovc-chips { margin-left: auto; }
155.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; }
156.ovc-chip svg { flex: none; width: 15px; height: 15px; }
157.ovc-chip-v { overflow: hidden; text-overflow: ellipsis; }
158.ovc-chip select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; font: inherit; }
159.ovc-chip:focus-within { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent); }
160.ovc-ring { flex: none; width: 26px; height: 26px; margin: 0 4px; }
161.ovc-ring circle { fill: none; stroke-width: 3; }
162.ovc-ring .ovc-ring-bg { stroke: color-mix(in srgb, var(--text-primary, #fff) 12%, transparent); }
163.ovc-ring .ovc-ring-fg { stroke: var(--ovt-accent, var(--accent, #3b82f6)); stroke-linecap: round; transition: stroke-dashoffset .15s, stroke .15s; }
164.ovc-ring.is-near .ovc-ring-fg { stroke: #f59e0b; }
165.ovc-ring.is-over .ovc-ring-fg { stroke: var(--live-red, #ef4444); }
166.ovc-ring[hidden] { display: none; }
167.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; }
168.ovc-v2 .ovt-send:not(:disabled):active { transform: scale(.96); }
169.ovc-v2 .ovt-send:disabled { opacity: .45; cursor: default; }
170.ovc-v2 .ovt-send--round { display: grid; place-items: center; width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 50%; }
171.ovc-v2 .ovt-send--round svg { width: 19px; height: 19px; }
172/* ── 4.248.0, one button system for every box (the owner, 11-14: "How is the centering and offset of icons still such a
173 problem?", "give them that more 3D look … the post button … completely customized … an animation"). Design rule
174 (openvibe.design/rules): an icon button is a square box, display:grid and place-items:center, its glyph a block of
175 a fixed size with no margin, never nudged; a hidden tool is gone (display:none) wherever the box sits. ── */
176.ovc-v2 [hidden], .ovt-composer [hidden] { display: none !important; }
177.ovc-v2 .ovt-c-tools .ovt-btn { box-sizing: border-box; width: 40px; min-width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center;
178 color: var(--accent-light, var(--ovt-accent, #60a5fa));
179 background: linear-gradient(180deg, color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent));
180 box-shadow: inset 0 1px 0 rgba(255,255,255,.16), inset 0 -1px 0 rgba(0,0,0,.3), 0 4px 10px -5px rgba(0,0,0,.6);
181 transition: translate .15s ease, box-shadow .15s ease, color .15s ease; }
182.ovc-v2 .ovt-c-tools .ovt-btn > svg, .ovc-v2 .ovt-c-tools .ovt-btn > .ovt-ic { display: block; width: 20px; height: 20px; margin: 0; flex: none; }
183.ovc-v2 .ovt-c-tools .ovt-btn:hover { translate: 0 -1px; color: var(--text-primary, #e6edf7); box-shadow: inset 0 1px 0 rgba(255,255,255,.22), inset 0 -1px 0 rgba(0,0,0,.3), 0 8px 16px -7px rgba(0,0,0,.65); }
184.ovc-v2 .ovt-c-tools .ovt-btn:active { translate: 0 1px; box-shadow: inset 0 2px 5px rgba(0,0,0,.4); }
185html[data-theme-mode=light] .ovc-v2 .ovt-c-tools .ovt-btn { background: linear-gradient(180deg, #fff, color-mix(in srgb, var(--text-primary, #0f172a) 6%, #fff)); box-shadow: inset 0 -1px 0 rgba(15,23,42,.08), 0 1px 2px rgba(15,23,42,.12), 0 4px 10px -6px rgba(15,23,42,.25); color: var(--accent, #2563eb); }
186/* Send: a button of its own, lit from above, a shine that crosses it on hover, a press you can feel, the arrow lifting. */
187.ovc-v2 .ovt-send { position: relative; isolation: isolate; overflow: hidden; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 0 20px;
188 color: #fff; letter-spacing: .01em;
189 background: linear-gradient(160deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 72%, #fff), var(--ovt-accent, var(--accent, #3b82f6)) 48%, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 78%, #000));
190 box-shadow: inset 0 1px 0 rgba(255,255,255,.38), inset 0 -2px 0 rgba(0,0,0,.22), 0 10px 22px -10px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 85%, transparent);
191 transition: translate .15s ease, scale .12s ease, box-shadow .2s ease, filter .2s ease; }
192.ovc-v2 .ovt-send::after { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(110deg, transparent 30%, rgba(255,255,255,.38) 50%, transparent 70%); translate: -130% 0; transition: translate .7s cubic-bezier(.2,.8,.2,1); }
193.ovc-v2 .ovt-send:not(:disabled):hover { translate: 0 -1px; box-shadow: inset 0 1px 0 rgba(255,255,255,.45), inset 0 -2px 0 rgba(0,0,0,.22), 0 14px 26px -10px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 95%, transparent); }
194.ovc-v2 .ovt-send:not(:disabled):hover::after { translate: 130% 0; }
195.ovc-v2 .ovt-send:not(:disabled):hover svg { translate: 0 -2px; }
196.ovc-v2 .ovt-send svg { transition: translate .2s cubic-bezier(.3,1.6,.4,1); }
197.ovc-v2 .ovt-send:not(:disabled):active { translate: 0 1px; scale: .97; transform: none; box-shadow: inset 0 2px 6px rgba(0,0,0,.3); }
198.ovc-v2 .ovt-send:disabled { opacity: 1; filter: saturate(.25) brightness(.72); box-shadow: inset 0 1px 0 rgba(255,255,255,.14); cursor: default; }
199.ovc-v2 .ovt-send--round { width: 40px; min-width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center; }
200.ovc-v2 .ovt-send--round svg { display: block; width: 18px; height: 18px; margin: 0; }
201.ovc-v2[data-preset="chat"] .ovt-c-row { margin-top: 0; } /* one line: the buttons sit in its middle, not 2 px low */
202html[data-ov-motion=off] .ovc-v2 .ovt-send::after { display: none; } html[data-ov-motion=off] .ovc-v2 :is(.ovt-send, .ovt-btn, .ovt-send svg) { transition: none; translate: none; }
203.ovc-cam { padding: 0; border: 0; border-radius: 22px; width: min(560px, 96vw); max-height: 94vh; overflow: hidden; background: #05070c; color: #fff; display: flex; flex-direction: column; }
204.ovc-cam::backdrop { background: rgba(2, 6, 14, .72); }
205.ovc-cam-stage { position: relative; flex: 1; min-height: 0; background: #000; }
206.ovc-cam-v { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; background: #000; }
207.ovc-cam.is-mirror .ovc-cam-v { transform: scaleX(-1); }
208.ovc-cam-msg { position: absolute; inset: auto 14px 14px; margin: 0; padding: 10px 12px; border-radius: 12px; background: rgba(0, 0, 0, .72); font-size: 14px; line-height: 1.4; }
209.ovc-cam-t { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); padding: 3px 10px; border-radius: 999px; background: #dc2626; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
210.ovc-cam-modes { display: flex; justify-content: center; gap: 4px; padding: 10px 0 0; }
211.ovc-cam-modes button { min-height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: none; color: rgba(255, 255, 255, .7); font: 600 13px/1 inherit; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; }
212.ovc-cam-modes [aria-pressed="true"] { color: #fde047; }
213.ovc-cam-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 22px max(18px, env(safe-area-inset-bottom)); }
214.ovc-cam-b { display: grid; place-items: center; width: 48px; height: 48px; border: 0; border-radius: 50%; background: rgba(255, 255, 255, .14); color: #fff; cursor: pointer; }
215.ovc-cam-b svg { width: 22px; height: 22px; }
216.ovc-cam-shutter { width: 72px; height: 72px; border-radius: 50%; border: 4px solid #fff; background: #fff; box-shadow: inset 0 0 0 4px #05070c; cursor: pointer; transition: transform var(--ov-dur-1, .12s) ease, background-color var(--ov-dur-2, .2s) ease; }
217.ovc-cam-shutter:active { transform: scale(.94); }
218.ovc-cam.is-video .ovc-cam-shutter { background: #dc2626; }
219.ovc-cam.is-recording .ovc-cam-shutter { background: #dc2626; box-shadow: inset 0 0 0 20px #05070c; }
220.ovc-cam.is-recording .ovc-cam-modes, .ovc-cam.is-recording [data-a="flip"] { visibility: hidden; }
221@media (max-width: 600px) { .ovc-cam { width: 100vw; max-width: none; height: 100dvh; max-height: none; border-radius: 0; } .ovc-cam-v { height: 100%; aspect-ratio: auto; } }
222html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
223/* A failed upload: its own picture (or icon) becomes the Try again button; a post's tile says it in words. */
224.ovc-file-r { position: absolute; left: 5px; top: 5px; width: 40px; height: 40px; display: grid; grid-auto-flow: column; place-content: center; align-items: center; gap: 6px; padding: 0; border: 0; border-radius: 10px; background: rgba(5, 7, 12, .66); color: #fff; font: 700 13px/1 inherit; cursor: pointer; }
225.ovc-file-r svg { width: 18px; height: 18px; }
226.ovc-file-r:hover, .ovc-file-r:focus-visible { background: rgba(5, 7, 12, .8); }
227.ovc-file-r-t { display: none; }
228.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r { left: 0; top: 0; width: 100%; height: 150px; border-radius: 15px 15px 0 0; }
229.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r-t, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r-t { display: inline; }
230/* Post · Ask · Do (4.218.0) */
231.ovc-v2 .ovc-verbs--head { margin-left: auto; margin-right: 0; }
232.ovc-v2 .ovc-head .ovc-chips + .ovc-verbs--head { margin-left: 6px; }
233.ovc-v2 .ovc-verbs { flex: none; display: inline-flex; gap: 2px; padding: 3px; margin-right: 4px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
234.ovc-v2 .ovc-verb { display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 10px; border: 0; border-radius: 999px; background: none; color: var(--text-muted, #93a0b4); font: 600 13px/1 inherit; cursor: pointer; }
235.ovc-v2 .ovc-verb svg { width: 15px; height: 15px; }
236.ovc-v2 .ovc-verb[aria-pressed="true"] { background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 1px 3px rgba(0, 0, 0, .3); }
237.ovc-v2 .ovc-verb[data-verb="ask"][aria-pressed="true"] svg { color: #c084fc; }
238.ovc-v2 .ovc-verb[data-verb="do"][aria-pressed="true"] svg { color: #fbbf24; }
239@media (max-width: 520px) { .ovc-v2 .ovc-verb:not([aria-pressed="true"]) span { display: none; } .ovc-v2 .ovc-verb:not([aria-pressed="true"]) { width: 34px; padding: 0; justify-content: center; } }
240.ovc-v2 .ovc-ask-chips { display: flex; gap: 6px; min-width: 0; overflow-x: auto; scrollbar-width: none; }
241.ovc-v2 .ovc-ask-chips::-webkit-scrollbar { display: none; }
242.ovc-v2 .ovc-ask-chips > * { flex: none; }
243.ovc-v2:not(.is-asking) .ovc-ask-chips { display: none; }
244.ovc-v2[data-preset=chat] .ovc-ask-chips { grid-column: 1 / -1; }
245.ovc-v2 .ovc-chip--btn { background: transparent; font-family: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }
246.ovc-v2 .ovc-chip--btn .ovc-chip-v { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
247.ovc-v2 .ovc-chip--btn.is-on svg { color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
248.ovc-v2.is-asking { border-color: color-mix(in srgb, #a855f7 45%, var(--ovt-line, transparent)); }
249.ovc-v2.is-asking[data-verb="do"] { border-color: color-mix(in srgb, #f59e0b 45%, var(--ovt-line, transparent)); }
250.ovc-sheet { box-sizing: border-box; padding: 0; border: 1px solid var(--border, rgba(148, 163, 184, .2)); border-radius: 20px; width: min(520px, 94vw); max-height: 80vh; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); overflow: hidden; display: flex; flex-direction: column; }
251.ovc-sheet::backdrop { background: rgba(2, 6, 14, .6); }
252.ovc-sheet-h { display: flex; align-items: center; justify-content: space-between; padding: 12px 10px 8px 18px; font-size: 16px; }
253.ovc-sheet-b { overflow: auto; padding: 0 10px 14px; display: grid; gap: 4px; }
254.ovc-sheet-find:focus { outline: none; border-color: var(--ovt-accent, var(--accent, #3b82f6)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 25%, transparent); }
255.ovc-sheet-find { width: 100%; box-sizing: border-box; height: 42px; margin: 0 0 6px; padding: 0 14px; border: 1px solid var(--border, rgba(148, 163, 184, .2)); border-radius: 12px; background: var(--bg-primary, #0b0f19); color: inherit; font: inherit; font-size: 16px; }
256.ovc-sheet h3 { margin: 12px 8px 4px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
257.ovc-sheet-note { margin: 8px; color: var(--text-muted, #93a0b4); font-size: 14px; }
258.ovc-models { display: grid; gap: 2px; }
259.ovc-model { display: flex; align-items: center; gap: 4px; }
260.ovc-model[hidden] { display: none; }
261.ovc-model-b { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 6px 10px; border: 0; border-radius: 12px; background: none; color: inherit; text-align: left; font: inherit; cursor: pointer; }
262.ovc-model-b:hover, .ovc-model-b:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
263.ovc-model-b[aria-pressed="true"] { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); }
264.ovc-model-m { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 20%, transparent); font-weight: 800; font-size: 14px; }
265.ovc-model-t { min-width: 0; display: grid; }
266.ovc-model-n { display: flex; align-items: center; gap: 6px; min-width: 0; }
267.ovc-model-t b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
268.ovc-model-t small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); font-size: 12.5px; }
269.ovc-model-rec { flex: none; padding: 1px 7px; border-radius: 999px; background: color-mix(in srgb, #22c55e 18%, transparent); color: #86efac; font: 700 10.5px/1.6 inherit; font-style: normal; vertical-align: 2px; }
270.ovc-model-x { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: var(--text-muted, #93a0b4); }
271.ovc-model-x:hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
272.ovc-model-x svg, .ovc-sheet-more svg { width: 16px; height: 16px; }
273.ovc-sheet-more { display: inline-flex; align-items: center; gap: 6px; margin: 10px 8px 0; color: var(--ovt-accent, var(--accent-light, #60a5fa)); font-size: 14px; font-weight: 600; text-decoration: none; }
274.ovc-src { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 6px 10px; border-radius: 12px; cursor: pointer; }
275.ovc-src:hover { background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent); }
276.ovc-src > svg { flex: none; width: 20px; height: 20px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
277.ovc-src > span { flex: 1; min-width: 0; display: grid; }
278.ovc-src small { color: var(--text-muted, #93a0b4); font-size: 12.5px; }
279.ovc-src input { flex: none; width: 20px; height: 20px; accent-color: var(--ovt-accent, var(--accent, #3b82f6)); }
280@media (max-width: 600px) { .ovc-sheet { width: 100vw; max-width: none; margin: auto 0 0; border-radius: 22px 22px 0 0; max-height: 86vh; padding-bottom: env(safe-area-inset-bottom); } }
281.ovc-answer { box-sizing: border-box; margin: 10px 0 0; padding: 12px 14px; border: 1px solid color-mix(in srgb, #a855f7 30%, var(--border, rgba(148, 163, 184, .2))); border-radius: 18px; background: color-mix(in srgb, #a855f7 6%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); }
282.ovc-answer header { display: flex; align-items: center; gap: 8px; }
283.ovc-answer header > svg { width: 17px; height: 17px; color: #c084fc; }
284.ovc-answer header small { color: var(--text-muted, #93a0b4); }
285.ovc-answer header .ovc-bar-x { margin-left: auto; }
286.ovc-answer-t { margin: 6px 0 0; white-space: pre-wrap; line-height: 1.5; font-size: 15px; }
287.ovc-answer-t.is-waiting { color: var(--text-muted, #93a0b4); }
288.ovc-answer-t.is-bad { color: #fca5a5; }
289.ovc-cites { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; }
290.ovc-cites a { display: inline-block; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); color: var(--text-primary, #e6edf7); font-size: 12.5px; text-decoration: none; }
291.ovc-answer footer { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
292.ovc-answer-a { min-height: 36px; padding: 0 14px; border: 1px solid var(--border, rgba(148, 163, 184, .22)); border-radius: 999px; background: none; color: var(--text-primary, #e6edf7); font: 600 13.5px/1 inherit; cursor: pointer; }
293.ovc-answer-a:first-child { background: var(--ovt-accent, var(--accent, #3b82f6)); border-color: transparent; color: #fff; }
294@media (pointer: coarse) { .ovc-answer-a { min-height: 44px; } }
295.ovc-v2 .ovc-bar { position: relative; display: flex; align-items: center; gap: 8px; min-width: 0; padding: 3px 4px 3px 15px; border-radius: 12px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 13%, transparent); font-size: 13px; line-height: 1.3;
296 animation: ovcBarIn .2s cubic-bezier(.2, .9, .25, 1); }
297/* 4.236.0: what the message answers, quotes or changes is a part of the box: a rail of the accent at its start. */
298.ovc-v2 .ovc-bar::before { content: ""; position: absolute; left: 6px; top: 7px; bottom: 7px; width: 3px; border-radius: 3px; background: var(--ovt-accent, var(--accent, #3b82f6)); }
299.ovc-v2 .ovc-bar[data-kind=edit]::before { background: var(--warning, #f59e0b); }
300@keyframes ovcBarIn { from { opacity: 0; transform: translateY(4px); } }
301html[data-ov-motion=off] .ovc-v2 .ovc-bar { animation: none; }
302.ovc-v2 .ovc-bar > svg { flex: none; width: 15px; height: 15px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
303.ovc-v2 .ovc-bar > b { flex: none; font-weight: 700; color: var(--text-primary, #e6edf7); }
304.ovc-v2 .ovc-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); }
305.ovc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
306.ovc-bar-x:hover, .ovc-bar-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 10%, transparent); color: var(--text-primary, #e6edf7); }
307.ovc-bar-x svg { width: 15px; height: 15px; }
308@media (pointer: coarse) { .ovc-bar-x { width: 40px; height: 40px; } }
309.ovc-v2.is-editing { box-shadow: 0 0 0 2px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent); }
310.ovc-v2.is-running .ovt-send { background: color-mix(in srgb, var(--text-primary, #fff) 14%, transparent); color: var(--text-primary, #e6edf7); }
311.ovc-v2 .ovc-files { margin: 0; padding: 0 0 2px; }
312.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; }
313.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; }
314.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; }
315.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; }
316.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-ai, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-ai { top: 6px; left: 6px; right: auto; bottom: auto; background: rgba(0, 0, 0, .6); color: #fff; border-radius: 50%; }
317.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%; }
318.ovc-v2 .ovc-note { margin: 6px 0 0; font-size: 13px; color: var(--text-muted, #7386a3); }
319.ovc-v2 .ovc-note:empty { display: none; }
320.ovc-v2.is-busy { opacity: .8; }
321/* chat: one line that grows, the tools beside it, the send round. */
322.ovc-v2[data-preset=chat].ovt-composer { padding: 6px 6px 6px 10px; border-radius: 26px; }
323.ovc-v2[data-preset=chat] .ovc-body { grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 4px; }
324.ovc-v2[data-preset=chat] .ovc-files, .ovc-v2[data-preset=chat] .ovc-bar { grid-column: 1 / -1; } /* a line of their own above the one-line box */
325.ovc-v2[data-preset=chat] textarea { font-size: 16px; padding: 9px 2px; }
326.ovc-v2[data-preset=chat] .ovt-c-row { padding: 0; border: 0; }
327.ovc-v2[data-preset=chat] .ovt-c-tools { flex: none; }
328/* agent: a roomy box, tools left, the mode chip and the round send right. */
329.ovc-v2[data-preset=agent] textarea { min-height: 52px; font-size: 16px; }
330/* comment: small, the picture smaller. */
331.ovc-v2[data-preset=comment].ovt-composer { padding: 10px 12px 8px; border-radius: 18px; }
332.ovc-v2[data-preset=comment] .ovc-av { width: 28px; height: 28px; font-size: 12px; }
333.ovc-v2[data-preset=comment] .ovc-head-n { font-size: 14px; }
334.ovc-v2[data-preset=comment] textarea { font-size: 15px; }
335/* Emoji: a small panel above the box. */
336.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; }
337.ovc-emoji[hidden] { display: none; }
338.ovc-repo { position: absolute; z-index: 41; left: 8px; right: 8px; bottom: calc(100% + 8px); display: grid; gap: 6px; max-width: 520px; max-height: min(420px, 60vh); padding: 10px; 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; }
339.ovc-repo[hidden] { display: none; }
340.ovc-repo-q { width: 100%; box-sizing: border-box; min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .22)); background: var(--bg-primary, rgba(0, 0, 0, .2)); color: inherit; font: inherit; font-size: 15px; outline: 0; }
341.ovc-repo-q:focus-visible { border-color: var(--ovt-accent, var(--accent, #3b82f6)); }
342.ovc-repo-list { display: grid; gap: 2px; overflow-y: auto; overscroll-behavior: contain; min-height: 0; }
343.ovc-repo:not([hidden]) { grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
344.ovc-repo-mono { font-weight: 700; font-size: 15px; color: hsl(var(--ovc-mono-h, 210) 70% 72%); background: hsl(var(--ovc-mono-h, 210) 45% 22%); }
345.ovc-repo-list:empty { display: none; }
346.ovc-repo-row { display: flex; align-items: flex-start; gap: 10px; width: 100%; min-height: 52px; padding: 8px; border: 0; border-radius: 12px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
347.ovc-repo-row:hover, .ovc-repo-row:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); outline: 0; }
348.ovc-repo-av { flex: none; width: 32px; height: 32px; border-radius: 9px; object-fit: cover; display: grid; place-items: center; background: color-mix(in srgb, currentColor 8%, transparent); }
349.ovc-repo-av svg { width: 18px; height: 18px; }
350.ovc-repo-t { min-width: 0; display: grid; gap: 1px; }
351.ovc-repo-t b { font-size: 14px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
352.ovc-repo-t small { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
353.ovc-repo-t i { font-style: normal; font-size: 11.5px; color: var(--text-muted, #93a0b4); }
354.ovc-repo-note { margin: 2px 4px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
355.ovc-repo-note[hidden] { display: none; }
356html[data-ov-motion=off] .ovc-repo { animation: none; }
357.ovc-emoji button { width: 38px; height: 38px; border: 0; border-radius: 10px; background: none; font-size: 22px; line-height: 1; cursor: pointer; }
358.ovc-emoji button:hover, .ovc-emoji button:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
359@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; } }
360html[data-ov-motion=off] .ovc-emoji { animation: none; }
361
362/* 4.223.0: the + sheet, the mic first, the examples' fade, the round send. */
363.ovc-v2, form.ovc[data-ovc-contain] { container: ovc / inline-size; }
364.ovc-more[aria-expanded="true"] { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 18%, transparent); }
365/* 4.242.0, the + (plan §Z.1.6): a bold rounded plus in a lit disc; arms reach and petals peek under the pointer; it
366 sinks when pressed; open, it springs into a x, the disc fills and rings once, and the petals burst. */
367.ovc-more { position: relative; isolation: isolate; overflow: visible; }
368.ovc-more::before { content: ""; position: absolute; inset: 0; border-radius: inherit; z-index: -1; background: radial-gradient(120% 120% at 30% 25%, color-mix(in srgb, #fff 14%, transparent), transparent 60%); opacity: .7; pointer-events: none; transition: opacity .25s; }
369.ovc-more::after { content: ""; position: absolute; inset: -2px; border-radius: 50%; pointer-events: none; box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 55%, transparent); opacity: 0; }
370.ovc-plus { display: block; overflow: visible; }
371.ovc-plus-x path { fill: none; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; transform-box: view-box; transform-origin: 12px 12px; transition: transform .28s cubic-bezier(.3, 1.5, .4, 1); }
372.ovc-plus-x { transform-box: view-box; transform-origin: 12px 12px; transition: transform .45s cubic-bezier(.3, 1.6, .4, 1); }
373.ovc-plus-p circle { fill: var(--ovt-accent, var(--accent-light, var(--accent, #60a5fa))); transform-box: fill-box; transform-origin: center; transform: scale(0); opacity: 0; transition: transform .3s cubic-bezier(.3, 1.6, .4, 1), opacity .2s; }
374@media (hover: hover) and (pointer: fine) {
375 .ovc-more:not([aria-expanded="true"]):hover .ovc-plus-h { transform: scaleX(1.12); }
376 .ovc-more:not([aria-expanded="true"]):hover .ovc-plus-v { transform: scaleY(1.12); }
377 .ovc-more:not([aria-expanded="true"]):hover .ovc-plus-p circle { transform: scale(.75); opacity: .9; }
378 .ovc-more:hover::before { opacity: 1; }
379}
380.ovc-more:not([aria-expanded="true"]):focus-visible .ovc-plus-p circle { transform: scale(.75); opacity: .9; }
381.ovc-more:active .ovc-plus-x { transform: scale(.82); }
382.ovc-more[aria-expanded="true"] { color: var(--on-accent, #fff); background: linear-gradient(150deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 92%, #fff), var(--ovt-accent, var(--accent-strong, var(--accent, #2563eb)))) !important; box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 75%, transparent) !important; }
383.ovc-more[aria-expanded="true"] .ovc-plus-x { transform: rotate(135deg); }
384.ovc-more[aria-expanded="true"]::after { animation: ovc-plus-ring .6s ease-out; }
385.ovc-more[aria-expanded="true"] .ovc-plus-p circle { fill: currentColor; animation: ovc-plus-burst .55s cubic-bezier(.2, .8, .3, 1) both; }
386.ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(1) { --dx: -7px; --dy: -7px; } .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(2) { --dx: 7px; --dy: -7px; }
387.ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(3) { --dx: -7px; --dy: 7px; } .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(4) { --dx: 7px; --dy: 7px; }
388@keyframes ovc-plus-burst { 0% { transform: scale(1.15); opacity: 1; } 55% { opacity: .95; } 100% { transform: translate(var(--dx, 0), var(--dy, 0)) scale(.25); opacity: 0; } }
389@keyframes ovc-plus-ring { 0% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 55%, transparent); } 100% { opacity: 0; box-shadow: 0 0 0 5px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 0%, transparent); } }
390/* Calm: the petals still burst, a third as far and slower (Calm reduces; only Off removes). */
391html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle { animation-duration: .9s; }
392html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(1) { --dx: -2px; --dy: -2px; } html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(2) { --dx: 2px; --dy: -2px; }
393html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(3) { --dx: -2px; --dy: 2px; } html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle:nth-child(4) { --dx: 2px; --dy: 2px; }
394html[data-ov-motion=reduced] :is(.ovc-plus-x, .ovc-plus-x path) { transition-timing-function: ease-out; }
395html[data-ov-motion=off] :is(.ovc-plus-x, .ovc-plus-x path, .ovc-plus-p circle) { transition: none; animation: none !important; }
396html[data-ov-motion=off] .ovc-more::after { animation: none; }
397/* 4.237.0 (the owner, 11-13: the row looked "offset"): every tool in the row is the same quiet disc, so the first one's
398 edge lines up with the words above it and an open + is that disc tinted, not the only solid shape in the row; Send
399 is the same size at the other end. */
400.ovc-v2 .ovt-c-tools { gap: 6px; }
401.ovc-v2 .ovt-c-tools > .ovt-btn { background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
402.ovc-v2 .ovt-c-tools > .ovt-btn:hover, .ovc-v2 .ovt-c-tools > .ovt-btn:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); }
403.ovc-v2 .ovt-c-tools > .ovc-more[aria-expanded="true"], .ovc-v2 .ovt-c-tools > .ovt-btn[aria-pressed="true"] { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 22%, transparent); box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent); }
404.ovc-v2 .ovt-c-tools > .ovt-btn svg { width: 20px; height: 20px; }
405@container ovc (max-width: 460px) {
406 .ovt-c-tools > .ovt-btn:not(.ovc-mic):not(.ovc-more) ~ .ovt-btn:not(.ovc-mic):not(.ovc-more):not([aria-pressed="true"]) { display: none; }
407}
408.ovc-tray { grid-column: 1 / -1; flex: 1 0 100%; width: 100%; box-sizing: border-box; margin-top: 8px; padding: 8px 0 2px; border-top: 1px solid var(--border, rgba(148,163,184,.14)); animation: ovc-tray-in .22s cubic-bezier(.2, .9, .3, 1.15) both; }
409.ovc-tray-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 4px; }
410.ovc-tray-i { all: unset; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 10px 4px 8px; border-radius: 14px; cursor: pointer; text-align: center; -webkit-tap-highlight-color: transparent; }
411.ovc-tray-ic { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 15px; color: var(--ovt-accent, var(--accent-light, var(--accent, #60a5fa)));
412 background: linear-gradient(150deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent), color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent));
413 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1); }
414.ovc-tray-ic svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
415.ovc-tray-t { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13px; font-weight: 800; line-height: 1.2; color: var(--text-primary, #e6edf7); text-wrap: balance; }
416.ovc-tray-i:active .ovc-tray-ic { transform: scale(.92); }
417.ovc-tray-i:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
418@media (hover: hover) and (pointer: fine) { .ovc-tray-i:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } .ovc-tray-i:hover .ovc-tray-ic { transform: translateY(-2px); } }
419/* 4.241.0, the +: tiles for what you add, rows for the rest; a sheet from the bottom on a phone. */
420.ovc-add-b { display: grid; gap: 12px; }
421.ovc-add-tiles { display: grid; grid-template-columns: repeat(var(--n, 3), minmax(0, 1fr)); gap: 10px; }
422.ovc-add-tile { all: unset; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; min-height: 96px; padding: 14px 6px 12px; border-radius: 20px; cursor: pointer; text-align: center; -webkit-tap-highlight-color: transparent;
423 background: linear-gradient(160deg, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent));
424 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent), 0 1px 0 color-mix(in srgb, #fff 6%, transparent) inset;
425 animation: ovc-add-in .34s cubic-bezier(.2, .9, .3, 1.15) both; animation-delay: calc(var(--k, 0) * 35ms); transition: transform .18s cubic-bezier(.3, 1.4, .4, 1), box-shadow .2s, background-color .2s; }
426.ovc-add-tile .ovc-add-ic { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 15px; color: var(--ovt-accent, var(--accent-light, var(--accent, #60a5fa)));
427 background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 32%, transparent), color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 10%, transparent)); box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 70%, transparent); }
428.ovc-add-tile .ovc-add-ic svg { width: 26px; height: 26px; }
429.ovc-add-t { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 15px; font-weight: 800; line-height: 1.15; color: var(--text-primary, #e6edf7); }
430.ovc-add-tile:active { transform: scale(.94); }
431.ovc-add-rows { display: grid; gap: 4px; }
432.ovc-add-row { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 10px 8px 8px; border-radius: 16px; cursor: pointer; -webkit-tap-highlight-color: transparent;
433 background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); animation: ovc-add-in .34s cubic-bezier(.2, .9, .3, 1.15) both; animation-delay: calc(var(--k, 0) * 30ms); transition: transform .18s cubic-bezier(.3, 1.4, .4, 1), background-color .2s; }
434.ovc-add-row .ovc-add-ic { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
435.ovc-add-row .ovc-add-ic svg { width: 21px; height: 21px; }
436.ovc-add-rt { flex: 1; min-width: 0; display: grid; gap: 1px; }
437.ovc-add-rt b { font-size: 15px; font-weight: 700; color: var(--text-primary, #e6edf7); }
438.ovc-add-rt small { font-size: 12.5px; color: var(--text-muted, #8b98ad); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
439.ovc-add-go { flex: none; width: 16px; height: 16px; color: var(--text-muted, #8b98ad); transition: transform .2s; }
440.ovc-add-row:active { transform: scale(.98); }
441.ovc-add-tile:focus-visible, .ovc-add-row:focus-visible, .ovc-add-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
442@media (hover: hover) and (pointer: fine) {
443 .ovc-add-tile:hover { transform: translateY(-2px); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent), 0 10px 24px -14px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 80%, transparent); }
444 .ovc-add-row:hover { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 10%, transparent); }
445 .ovc-add-row:hover .ovc-add-go { transform: translateX(2px); color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
446}
447@keyframes ovc-add-in { from { opacity: 0; transform: translateY(8px) scale(.97); } }
448.ovc-add-inline { padding-top: 12px; }
449.ovc-add-inline .ovc-add-tiles { grid-template-columns: repeat(var(--n, 3), minmax(0, 150px)); }
450.ovc-add-inline .ovc-add-tile { min-height: 88px; }
451.ovc-add-inline .ovc-add-rows { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
452dialog.ovc-add { padding: 0 14px calc(18px + env(safe-area-inset-bottom)); max-height: 88vh; overflow: auto; transition: transform .28s cubic-bezier(.2, .9, .3, 1); animation: ovc-add-up .32s cubic-bezier(.2, .9, .3, 1.05) both; }
453dialog.ovc-add::backdrop { background: rgba(2, 6, 14, .55); animation: ovc-fade-calm .25s ease both; }
454dialog.ovc-add.is-out { transform: translateY(110%); }
455dialog.ovc-add.is-out::backdrop { opacity: 0; transition: opacity .2s; }
456@keyframes ovc-add-up { from { transform: translateY(100%); } }
457.ovc-add-grip { width: 40px; height: 5px; margin: 8px auto 2px; border-radius: 3px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 26%, transparent); touch-action: none; }
458.ovc-add-h { position: relative; display: flex; align-items: center; justify-content: center; min-height: 48px; margin-bottom: 6px; touch-action: none; }
459.ovc-add-h b { font: 800 17px/1 'OV Brand', Nunito, Inter, sans-serif; color: var(--text-primary, #e6edf7); }
460.ovc-add-x { all: unset; position: absolute; left: 0; top: 50%; translate: 0 -50%; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; cursor: pointer; color: var(--text-secondary, #a8b3c4); }
461.ovc-add-x svg { width: 22px; height: 22px; }
462html[data-ov-motion=reduced] :is(.ovc-add-tile, .ovc-add-row) { animation-name: ovc-fade-calm; animation-delay: 0s; }
463html[data-ov-motion=reduced] dialog.ovc-add { animation-name: ovc-fade-calm; }
464html[data-ov-motion=off] :is(.ovc-add-tile, .ovc-add-row, dialog.ovc-add, dialog.ovc-add::backdrop) { animation: none; transition: none; }
465@keyframes ovc-tray-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
466html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; }
467@keyframes ovc-fade-calm { from { opacity: 0; } }
468html[data-ov-motion=off] .ovc-tray { animation: none; transition: none; }
469.ovc textarea::placeholder, .ovt-composer textarea::placeholder { transition: opacity .25s ease; }
470.ovc textarea.ovc-ph-swap::placeholder, .ovt-composer textarea.ovc-ph-swap::placeholder { opacity: 0; }
471.ovc-v2 .ovt-send--round { background: linear-gradient(150deg, color-mix(in srgb, var(--accent, #3b82f6) 85%, #fff), var(--accent-strong, var(--accent, #2563eb))); color: var(--on-accent-strong, #fff);
472 box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1), opacity .2s, box-shadow .2s; }
473.ovc-v2 .ovt-send--round svg { width: 20px; height: 20px; stroke-width: 2.4; transition: transform .18s cubic-bezier(.3, 1.4, .4, 1); }
474/* 4.236.0 (the owner, 11-13: "improve the send button … the icon's all screwed up"): with nothing to send it is a quiet
475 outline round its arrow, not a grey disc; the moment there is, it fills with the accent on a spring, and the arrow
476 lifts under a pointer and on a press. */
477.ovc-v2 .ovt-send--round:disabled { opacity: 1; filter: none; background: linear-gradient(180deg, color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent)); color: var(--text-muted, #7386a3); box-shadow: inset 0 1px 0 rgba(255,255,255,.14), inset 0 -1px 0 rgba(0,0,0,.3), 0 4px 10px -5px rgba(0,0,0,.6); } /* 4.248.0: a dim disk like its neighbours, never an outline */
478.ovc-v2 .ovt-send--round:not(:disabled) { animation: ovcSendReady .32s cubic-bezier(.34, 1.56, .64, 1); }
479@keyframes ovcSendReady { from { transform: scale(.8); } }
480@media (hover: hover) and (pointer: fine) { .ovc-v2 .ovt-send--round:not(:disabled):hover svg { transform: translateY(-2px); } }
481.ovc-v2 .ovt-send--round:not(:disabled):active { transform: scale(.9); }
482.ovc-v2 .ovt-send--round:not(:disabled):active svg { transform: translateY(-3px); }
483html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc-v2 .ovt-send--round svg { animation: none; transition: none; }
484/* 4.239.0 (the owner, 11-13: the stream's side chat wrapped "Send a message…" onto two lines beside three tools,
485 "improve composer … better responsive support and size on mobile to fit better"): the words keep one line; in a
486 narrow box the tools tighten into quiet discs, and in a very narrow one they go under the words. */
487.ovc-v2 textarea::placeholder { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
488@container ovc (max-width: 340px) {
489 .ovc-v2[data-preset=chat].ovt-composer { padding: 4px 4px 4px 10px; }
490 .ovc-v2[data-preset=chat] .ovt-c-row { gap: 2px; }
491 .ovc-v2[data-preset=chat] .ovt-c-tools { gap: 0; }
492 .ovc-v2[data-preset=chat] .ovt-c-tools > .ovt-btn, .ovc-v2[data-preset=chat] .ovt-send { width: 36px; height: 36px; min-width: 36px; }
493 .ovc-v2[data-preset=chat] .ovt-c-tools > .ovt-btn:not([aria-expanded="true"]):not([aria-pressed="true"]) { background: transparent; }
494 .ovc-v2[data-preset=chat] textarea { padding: 7px 2px; }
495}
496@container ovc (max-width: 240px) {
497 .ovc-v2[data-preset=chat] .ovc-body { grid-template-columns: minmax(0, 1fr); }
498 .ovc-v2[data-preset=chat] .ovt-c-row { justify-content: flex-end; }
499}
500`;
501 function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
502 const mods = [];
503 // 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
504 // from beside this file; without it, a plain <dialog>.
505 const COMPOSER_REV = { 'ov-window.js': '55f98e14ef27', 'ov-capture.js': '6bdc48bfd415' };
506 const selfSrc = (document.currentScript && document.currentScript.src) || '';
507 function needWindow() {
508 return new Promise((ok) => {
509 if (root.OVWindow) return ok(root.OVWindow);
510 const m = /^(https?:\/\/[^?#]*\/)ov-composer(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
511 if (!m) return ok(null);
512 const sc = document.createElement('script');
513 sc.src = m[1] + 'ov-window.js' + (COMPOSER_REV['ov-window.js'] ? '?v=' + COMPOSER_REV['ov-window.js'] : '');
514 sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
515 document.head.appendChild(sc);
516 });
517 }
518 // 4.217.0: the camera button opens the shared capture core (ov-capture.js, OVCapture), loaded from beside this file.
519 function needCapture() {
520 return new Promise((ok) => {
521 if (root.OVCapture && root.OVCapture.version >= 2) return ok(root.OVCapture);
522 const m = /^(https?:\/\/[^?#]*\/)ov-composer(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
523 if (!m) return ok(root.OVCapture || null);
524 const sc = document.createElement('script');
525 sc.src = m[1] + 'ov-capture.js' + (COMPOSER_REV['ov-capture.js'] ? '?v=' + COMPOSER_REV['ov-capture.js'] : '');
526 sc.onload = () => ok(root.OVCapture || null); sc.onerror = () => ok(null);
527 document.head.appendChild(sc);
528 });
529 }
530 // 4.217.0: a picture bigger than the box wants (data-ovc-resize, the long side in pixels; mount(): 2560, an agent's
531 // 1568 as the agents portal sent them) is made smaller here before it uploads. GIFs and SVGs are left alone.
532 async function shrink(file, max) {
533 if (!max || !/^image\/(jpeg|png|webp)$/.test(file.type || '') || typeof createImageBitmap !== 'function') return file;
534 let bmp = null;
535 try { bmp = await createImageBitmap(file); } catch { return file; }
536 const long = Math.max(bmp.width, bmp.height);
537 if (long <= max) { if (bmp.close) bmp.close(); return file; }
538 const k = max / long, w = Math.round(bmp.width * k), h = Math.round(bmp.height * k);
539 const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h;
540 canvas.getContext('2d').drawImage(bmp, 0, 0, w, h);
541 if (bmp.close) bmp.close();
542 const blob = await new Promise((r) => canvas.toBlob(r, file.type, 0.9));
543 if (!blob || blob.size >= file.size) return file;
544 try { return new File([blob], file.name, { type: file.type, lastModified: file.lastModified }); } catch { return file; }
545 }
546 // 4.241.0: the +'s glyphs, the registry's line drawings (glyphs.js), and what a site chose for its boxes' +.
547 const GL = {
548 camera: '<path d="M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z"/><circle cx="12" cy="13.3" r="3.4"/>',
549 image: '<rect x="3.5" y="5.4" width="16.7" height="13" rx="2.8"/><circle cx="9.1" cy="10" r="1.5"/><path d="M20.3 15.6l-4.7-4.7l-7.4 7.4"/>',
550 file: '<path d="M6.4 3.5h7.6l3.8 3.8v13.2H6.4Z"/><path d="M13.9 3.5v3.8h3.8"/>',
551 folder: '<path d="M3.8 7.3a1.9 1.9 0 0 1 1.9-1.9h3.6l1.9 1.9h7.4a1.9 1.9 0 0 1 1.9 1.9v7.4a1.9 1.9 0 0 1-1.9 1.9H5.8a1.9 1.9 0 0 1-1.9-1.9Z"/>',
552 clipboard: '<rect x="5.7" y="5" width="12.6" height="15.5" rx="1.9"/><path d="M9.1 5V3.5h5.8V5M9.1 10.8h5.8M9.1 14.7h3.9"/>',
553 computer: '<rect x="3.5" y="4.5" width="17" height="11.3" rx="1.9"/><path d="M8.2 19.7h7.6M12 15.9v3.8"/>',
554 share: '<path d="M12 15.2V3.3M7.4 8.1L12 3.3L16.6 8.1M4.9 12.9v6.1a1 1 0 0 0 1 1h12.2a1 1 0 0 0 1-1v-6.1"/>',
555 video: '<rect x="3.4" y="6.4" width="12.3" height="11.3" rx="1.9"/><path d="M15.6 10.2l4.7-2.8v9.4l-4.7-2.8"/>',
556 chevron: '<path d="M7.9 3.6l8.4 8.4l-8.4 8.4"/>',
557 };
558 // 4.242.0: the + as its own mark (plan §Z.1.6): two arms, four petals that peek and burst, all drawn on the 24 grid.
559 const PLUS_MARK = '<svg class="ovc-plus" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><g class="ovc-plus-p"><circle cx="6.2" cy="6.2" r="1.35"/><circle cx="17.8" cy="6.2" r="1.35"/><circle cx="6.2" cy="17.8" r="1.35"/><circle cx="17.8" cy="17.8" r="1.35"/></g><g class="ovc-plus-x"><path class="ovc-plus-h" d="M5.5 12h13"/><path class="ovc-plus-v" d="M12 5.5v13"/></g></svg>';
560 const gsvg = (inner) => `<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
561 const PLUS_DEFAULT = { tiles: ['camera', 'photo', 'file'], rows: ['paste', 'screenshot', 'camapp', 'share', 'timer', 'github'], only: false };
562 const CONFIG = { plus: null };
563 function configure(o = {}) {
564 if (o && o.plus && typeof o.plus === 'object') CONFIG.plus = cleanPlus(o.plus);
565 if (o && o.tools && typeof o.tools === 'object') Object.keys(o.tools).forEach((id) => tool(id, o.tools[id]));
566 return { plus: CONFIG.plus };
567 }
568 function cleanPlus(p) {
569 const ids = (v, n) => (Array.isArray(v) ? v.map(String).filter((x) => /^[\w-]{1,30}$/.test(x)).slice(0, n) : null);
570 const out = {};
571 const t = ids(p.tiles, 4), r = ids(p.rows, 16);
572 if (t) out.tiles = t;
573 if (r) out.rows = r;
574 if (typeof p.only === 'boolean') out.only = p.only;
575 return out;
576 }
577 const live = new Set();
578 const PREFS_KEY = 'ov:composer';
579 const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
580 const PATH_OK = /^[MmLlHhVvCcSsQqTtAaZz0-9 ,.\-]{1,2000}$/;
581 const Speech = root.SpeechRecognition || root.webkitSpeechRecognition || null;
582 const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
583 const IC = {
584 mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
585 plus: 'M12 5v14M5 12h14',
586 timer: 'M12 4.5a8.2 8.2 0 1 1 0 16.4 8.2 8.2 0 0 1 0-16.4zM12 8.6v4.2l2.7 1.7M9.6 2.6h4.8',
587 // A repository: a branch from one commit to two (the registry's git-branch; no company's mark).
588 repo: 'M6.5 4.5v15M17.5 10a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2zM6.5 21.8a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2zM17.5 10c0 4.5-4 6.2-11 6.6',
589 star: 'M12 4.2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4-3.9-3.8 5.4-.8z',
590 up: 'M12 19V5.5M6.2 11.2L12 5.4l5.8 5.8',
591 x: 'M7 7l10 10M17 7L7 17',
592 file: 'M7 3h7l5 5v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM14 3v5h5',
593 slash: 'M15 4L9 20',
594 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',
595 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',
596 back: 'M9 14l-5-5 5-5M4 9h11a5 5 0 0 1 0 10h-3',
597 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',
598 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',
599 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',
600 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',
601 spark: 'M12 3l1.9 5.1 5.1 1.9-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z',
602 send: 'M5 12h14M13 6l6 6-6 6',
603 stop: 'M7 7h10v10H7z',
604 cpu: 'M8 8h8v8H8zM9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3',
605 gauge: 'M5 18a8 8 0 1 1 14 0M12 14l3-4',
606 reply: 'M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z',
607 pen: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4',
608 check: 'M5 12.5l4.5 4.5L19 7',
609 camera: 'M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM12 10a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7z',
610 flip: 'M4 9a8 8 0 0 1 14-3l2 2M20 15a8 8 0 0 1-14 3l-2-2M20 4v4h-4M4 20v-4h4',
611 retry: 'M4 12a8 8 0 1 0 2.3-5.7L4 8.5M4 4v4.5h4.5',
612 bolt: 'M13 3L5 13.5h6L10 21l9-11h-6.2z',
613 model: 'M12 3l7.5 4.3v8.4L12 20l-7.5-4.3V7.3zM12 8v4.2M12 12.2l3.6 2.1M12 12.2l-3.6 2.1',
614 layers: 'M12 4l8.5 4.5L12 13 3.5 8.5zM3.5 13L12 17.5 20.5 13',
615 ext: 'M14 5h5v5M19 5l-8 8M10 6H6a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4',
616 };
617 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>`;
618 const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
619 // §Z.3: a person's subject (usr_…), the only key a name effect hangs on. Never invented: what the data carries is what is used.
620 const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
621 const subjectOf = (data) => {
622 if (!data || typeof data !== 'object') return null;
623 for (const key of ['subject', 'subject_id', 'user_subject', 'author_subject', 'owner_subject']) {
624 const v = data[key];
625 if (typeof v === 'string' && SUBJECT_RE.test(v)) return v;
626 if (v && typeof v === 'object' && typeof v.id === 'string' && SUBJECT_RE.test(v.id)) return v.id;
627 }
628 return null;
629 };
630 // The attribute a name wears, when the data carries a subject. Never invented.
631 const setSubject = (node, data) => { const s = subjectOf(data); if (s && node && node.setAttribute) node.setAttribute('data-ov-subject', s); return node; };
632 // Every name under the scope wears its effect, hat and badge (items.js; items.css). Never throws: a name stays plain.
633 const wearNames = (scope) => { try { const OV = root.OV || (root.window && root.window.OV); if (OV && typeof OV.names === 'function') OV.names(scope); } catch { /* names stay plain */ } };
634 const store = {
635 get(k) { try { return JSON.parse(root.localStorage.getItem(k) || 'null'); } catch { return null; } },
636 set(k, v) { try { if (v == null) root.localStorage.removeItem(k); else root.localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } },
637 };
638 const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`);
639
640 // 4.247.0 (plan §Z.9, composer v3's file queue): Media's composer attachments, where a box's files go when it says
641 // data-ovc-media (mount({ media: true })). One table of limits, Media's own (OpenVibe.Media docs/attachments.md);
642 // the bytes decide the kind there, the type or the extension guides it here. Each file goes as it is picked, with
643 // the viewer's own token, its chip shows the progress, × cancels it, and the form carries its id.
644 const MEDIA_URL = 'https://openvibe.media/api/v1/attachments';
645 const MEDIA_MB = { image: 20, video: 300, audio: 50, other: 25 };
646 const MEDIA_WORD = { image: 'Pictures', video: 'Videos', audio: 'Sound files', other: 'Files' };
647 const EXT = { video: /\.(mp4|m4v|mov|mkv|avi|webm|ogv|wmv|flv|mpe?g|3gp|3g2|ts|mts|m2ts|vob)$/i, audio: /\.(mp3|m4a|aac|wav|flac|oga|ogg|opus|wma|aiff?)$/i, image: /\.(jpe?g|png|gif|webp|bmp|heic|heif|avif|tiff?)$/i };
648 function mediaKind(file) {
649 const t = String((file && file.type) || '').toLowerCase(), n = String((file && file.name) || '');
650 const top = t.split('/')[0];
651 if (top === 'image' || top === 'video' || top === 'audio') return top;
652 for (const k of ['video', 'audio', 'image']) if (EXT[k].test(n)) return k;
653 return 'other';
654 }
655 function viewerToken() {
656 try { const N = root.OpenVibeNavbar; return N && typeof N.viewerToken === 'function' ? String(N.viewerToken() || '') : ''; } catch { return ''; }
657 }
658 function mediaUpload(file, onProgress, signal) {
659 return new Promise((resolve, reject) => {
660 const tok = viewerToken();
661 if (!tok) { reject(new Error('Sign in again to attach files.')); return; }
662 const fd = new FormData();
663 fd.append('file', file, file.name || 'file');
664 const x = new XMLHttpRequest();
665 x.open('POST', MEDIA_URL);
666 x.setRequestHeader('Authorization', `Bearer ${tok}`);
667 x.setRequestHeader('Accept', 'application/json');
668 if (x.upload && onProgress) x.upload.onprogress = (e) => { if (e.lengthComputable && e.total) onProgress(e.loaded / e.total); };
669 x.onload = () => {
670 let j = {};
671 try { j = JSON.parse(x.responseText || '{}'); } catch { j = {}; }
672 if (x.status >= 200 && x.status < 300 && j && /^att_/.test(String(j.id || ''))) { resolve({ id: j.id, attachment: j }); return; }
673 reject(new Error((j && (j.detail || j.title)) || (x.status === 413 ? 'That file is too large to attach.' : x.status === 415 ? 'That kind of file cannot be attached.' : 'The file did not upload.')));
674 };
675 x.onerror = () => reject(new Error('The file did not upload. Check the connection and try again.'));
676 if (signal) signal.addEventListener('abort', () => { try { x.abort(); } catch { /* gone */ } reject(Object.assign(new Error('Cancelled'), { cancelled: true })); }, { once: true });
677 x.send(fd);
678 });
679 }
680
681 // ── Your settings: this browser's copy, and your account's when the Network keeps them (the same on every site).
682 let prefs = Object.assign({}, DEFAULTS, store.get(PREFS_KEY) || {});
683 let synced = false, rev = null;
684 const token = () => { try { const n = root.OpenVibeNavbar; return n && typeof n.token === 'function' ? n.token() : null; } catch { return null; } };
685 const NET = 'https://openvibe.network/api/v1/me/composer';
686 async function pull() {
687 const t = token(); if (!t) return;
688 try {
689 const r = await fetch(NET, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' });
690 if (!r.ok) return;
691 const j = await r.json();
692 synced = true; rev = j.rev != null ? j.rev : null;
693 prefs = Object.assign({}, DEFAULTS, j.prefs || j);
694 store.set(PREFS_KEY, prefs);
695 live.forEach((c) => c.refresh());
696 } catch { /* local only */ }
697 }
698 async function push() {
699 const t = token(); if (!t || !synced) return;
700 try {
701 const r = await fetch(NET, { method: 'PUT', headers: { authorization: `Bearer ${t}`, 'content-type': 'application/json', accept: 'application/json' }, credentials: 'omit', body: JSON.stringify({ prefs, rev }) });
702 if (r.status === 409) { await pull(); return; }
703 if (r.ok) { const j = await r.json().catch(() => ({})); if (j.rev != null) rev = j.rev; }
704 } catch { /* kept locally */ }
705 }
706 function setPrefs(next) {
707 prefs = Object.assign({}, DEFAULTS, prefs, next);
708 prefs.snippets = (prefs.snippets || []).filter((s) => s && /^[\w-]{1,30}$/.test(s.trigger || '') && s.text).slice(0, 50);
709 store.set(PREFS_KEY, prefs);
710 live.forEach((c) => c.refresh());
711 push();
712 }
713 root.addEventListener('storage', (e) => { if (e.key === PREFS_KEY) { prefs = Object.assign({}, DEFAULTS, store.get(PREFS_KEY) || {}); live.forEach((c) => c.refresh()); } });
714
715 // 4.239.0 (the owner, 11-13: the stream's side chat cut "Send a message…" beside its tools): a box narrower than its
716 // words says their short form ("Message…", or the site's data-ovc-short), and the long one again when there is
717 // room. A site that changes the words later is followed (the long form is whatever it set last).
718 function fitPlaceholder(text) {
719 if (typeof ResizeObserver !== 'function' || text.__ovcFit) return;
720 text.__ovcFit = true;
721 let full = text.placeholder || '';
722 const shortOf = () => text.getAttribute('data-ovc-short') || (full.length > 12 ? 'Message…' : full);
723 let ctx = null;
724 const width = (s) => {
725 try {
726 ctx = ctx || document.createElement('canvas').getContext('2d');
727 const cs = getComputedStyle(text);
728 ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
729 return ctx.measureText(s).width + (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0) + 4;
730 } catch { return 0; }
731 };
732 const fit = () => {
733 const now = text.placeholder || '';
734 if (now !== full && now !== shortOf()) full = now;
735 const w = text.clientWidth;
736 if (!w || !full) return;
737 const next = width(full) > w ? shortOf() : full;
738 if (next !== now) text.placeholder = next;
739 };
740 new ResizeObserver(fit).observe(text);
741 fit();
742 }
743
744 // ── One box.
745 function enhance(form) {
746 if (!form || form.__ovc) return form && form.__ovc;
747 styles();
748 const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
749 if (!text) return null;
750 fitPlaceholder(text);
751 const row = form.querySelector('.ovt-c-row');
752 // A box drawn by its own page marks its row with data-ovc-tools (Vibe's); else the composer's own row.
753 const tools = form.querySelector('.ovt-c-tools') || form.querySelector('[data-ovc-tools]');
754 const note = form.querySelector('[data-ovt-note]');
755 const send = form.querySelector('[data-ovt-send]') || form.querySelector('[type="submit"]');
756 const inputs = () => [...form.querySelectorAll('input[type="file"]')];
757 const mine = () => mods.filter((m) => !m.match || m.match(form));
758 const media = () => form.hasAttribute('data-ovc-media') && !!viewerToken();
759 const uploader = () => { const m = mine().find((x) => typeof x.upload === 'function'); return m ? m.upload : (media() ? mediaUpload : null); };
760 // data-ovc-no-files: the box handles its own files (the Dock's Vibe box uploads a picture as it is picked).
761 const noFiles = form.hasAttribute('data-ovc-no-files');
762 const canFile = () => !noFiles && !!(inputs().length || uploader());
763 const maxFiles = Number(form.getAttribute('data-ovc-max-files')) || 10;
764 const maxBytes = (Number(form.getAttribute('data-ovc-max-mb')) || 25) * 1048576;
765 const files = [];
766 let quiet = '';
767 const wasRequired = !!text.required; // a box that was not required stays so (the Dock's Vibe box)
768
769 const say = (s) => { if (!note) return; if (!quiet) quiet = note.textContent; note.textContent = s; clearTimeout(say.t); say.t = setTimeout(() => { note.textContent = quiet; quiet = ''; }, 5000); };
770 const fire = () => text.dispatchEvent(new Event('input', { bubbles: true }));
771 const ctx = {
772 form, text,
773 get value() { return text.value; },
774 set(v) { text.value = v; fire(); },
775 insert(s) { const a = text.selectionStart, b = text.selectionEnd; text.setRangeText(s, a, b, 'end'); fire(); text.focus(); },
776 files: () => files.map((f) => f.file),
777 alt: (file) => { const f = files.find((x) => x.file === file); return f ? f.alt || '' : ''; },
778 caption: (file) => { const f = files.find((x) => x.file === file); return f ? caption(f) : Promise.resolve(); },
779 add: (list) => add(list),
780 note: say,
781 send: () => { if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit(); },
782 };
783
784 // Files: a strip of chips above the box.
785 const strip = el('div', 'ovc-files');
786 strip.hidden = true;
787 strip.setAttribute('aria-label', 'Attached files');
788 const firstLabel = form.querySelector(`label[for="${text.id}"]`);
789 const anchor = firstLabel && firstLabel.parentNode === text.parentNode ? firstLabel : text;
790 anchor.parentNode.insertBefore(strip, anchor);
791 const accepts = (file) => {
792 const list = inputs().map((i) => i.getAttribute('accept') || '');
793 if (!list.length || list.some((a) => !a)) return true;
794 const acc = list.join(',');
795 const name = (file.name || '').toLowerCase(), type = (file.type || '').toLowerCase();
796 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));
797 };
798 function syncInputs() {
799 if (noFiles) return;
800 if (uploader()) { inputs().forEach((i) => { i.value = ''; }); return; }
801 const [first, ...rest] = inputs();
802 if (!first || typeof DataTransfer === 'undefined') return;
803 const dt = new DataTransfer();
804 files.forEach((f) => dt.items.add(f.file));
805 first.files = dt.files;
806 rest.forEach((i) => { i.value = ''; });
807 }
808 function paintFile(f) {
809 const kind = (f.file.type || '').split('/')[0];
810 const chip = f.chip || el('div', 'ovc-file');
811 f.chip = chip;
812 chip.classList.toggle('is-bad', !!f.error);
813 chip.classList.toggle('is-up', f.progress != null && f.progress < 1 && !f.error);
814 chip.style.setProperty('--p', String(f.progress == null ? 1 : f.progress));
815 const ext = ((f.file.name || '').split('.').pop() || 'file').slice(0, 4).toUpperCase();
816 const thumb = f.url && kind === 'image' ? `<img class="ovc-file-th" src="${f.url}" alt="">` : f.url && kind === 'video' ? `<video class="ovc-file-th" src="${f.url}#t=0.1" muted playsinline preload="metadata"></video>` : `<span class="ovc-file-th ovc-file-ic">${f.pasted ? svg(IC.file) : ext}</span>`;
817 chip.innerHTML = `${thumb}<span class="ovc-file-t"><span class="ovc-file-n"></span><span class="ovc-file-s"></span></span>`
818 + (f.pasted ? `<button type="button" class="ovc-file-b" data-ovc-back aria-label="Put the text back in the box" title="Put it back in the box">${svg(IC.back)}</button>` : '')
819 + (f.error && uploader() ? `<button type="button" class="ovc-file-r" data-ovc-retry aria-label="Try the upload again" title="Try again">${svg(IC.retry)}<span class="ovc-file-r-t">Try again</span></button>` : '')
820 + (kind === 'image' && !f.pasted && captionOn() ? `<button type="button" class="ovc-file-ai${f.captioning ? ' is-busy' : ''}" data-ovc-caption aria-label="${f.captioning ? 'Writing a caption…' : 'Caption it with AI'}" title="${f.captioning ? 'Writing a caption…' : 'Caption it with AI'}"${f.captioning ? ' aria-busy="true"' : ''}>${svg(IC.spark)}</button>` : '')
821 + `<button type="button" class="ovc-file-x" data-ovc-x aria-label="Remove">${svg(IC.x)}</button>`;
822 chip.querySelector('.ovc-file-n').textContent = f.pasted ? 'Pasted text' : f.file.name || 'File';
823 // 4.247.0 (§Z.9): a video says its length beside its size once the browser has read it.
824 const length = f.duration > 0 ? `${Math.floor(f.duration / 60)}:${String(Math.round(f.duration % 60)).padStart(2, '0')} · ` : '';
825 chip.querySelector('.ovc-file-s').textContent = f.error || (f.pasted ? `${f.lines} lines · ${size(f.file.size)}` : f.progress != null && f.progress < 1 ? `${Math.round(f.progress * 100)}%` : length + size(f.file.size));
826 const vid = !f.duration && chip.querySelector('video.ovc-file-th');
827 if (vid) vid.addEventListener('loadedmetadata', () => { if (Number.isFinite(vid.duration) && vid.duration > 0 && files.includes(f)) { f.duration = vid.duration; paintFile(f); } }, { once: true });
828 chip.querySelector('[data-ovc-x]').setAttribute('aria-label', `Remove ${f.pasted ? 'the pasted text' : f.file.name || 'the file'}`);
829 if (!chip.isConnected) strip.appendChild(chip);
830 }
831 // 4.228.0, Caption it (the owner, 11-12: "auto caption using OpenVibe.Exchange, using the AI features … like some
832 // Instagram"): the picture, made small (768 px, JPEG), goes to the Frame's /api/frame/caption on the person's own
833 // allowance; the caption fills an empty box (theirs to change) and ovc:caption carries the alt text for a page that
834 // keeps one with the picture (the Feed's post). A tap, never on its own: each one is a model call they pay for.
835 function captionOn() { return form.getAttribute('data-ovc-caption') !== 'off'; }
836 async function smallJpeg(file, max) {
837 if (typeof createImageBitmap !== 'function') return null;
838 let bmp = null;
839 try { bmp = await createImageBitmap(file); } catch { return null; }
840 const k = Math.min(1, max / Math.max(bmp.width, bmp.height));
841 const canvas = document.createElement('canvas');
842 canvas.width = Math.max(1, Math.round(bmp.width * k)); canvas.height = Math.max(1, Math.round(bmp.height * k));
843 canvas.getContext('2d').drawImage(bmp, 0, 0, canvas.width, canvas.height);
844 if (bmp.close) bmp.close();
845 try { return canvas.toDataURL('image/jpeg', 0.82); } catch { return null; }
846 }
847 async function caption(f) {
848 if (f.captioning) return;
849 f.captioning = true; paintFile(f);
850 try {
851 const dataUrl = await smallJpeg(f.file, 768);
852 if (!dataUrl) throw new Error('This picture cannot be read here.');
853 const base = String(form.getAttribute('data-ovc-frame') || 'https://openvibe.network').replace(/\/+$/, '');
854 // The person's token when the bar has one (any site); else the Network's own cookie (on the Network).
855 const t = token();
856 const r = await fetch(`${base}/api/frame/caption`, { method: 'POST', credentials: t ? 'omit' : 'include', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) }, body: JSON.stringify({ image: { data_url: dataUrl } }) });
857 const j = await r.json().catch(() => ({}));
858 if (r.status === 401) throw new Error('Sign in to caption a picture with AI.');
859 if (!r.ok) throw new Error(j.error || 'The AI could not caption it just now.');
860 if (!files.includes(f)) return;
861 f.alt = String(j.alt || ''); f.caption = String(j.caption || '');
862 form.dispatchEvent(new CustomEvent('ovc:caption', { detail: { file: f.file, chip: f.chip, alt: f.alt, caption: f.caption, tags: Array.isArray(j.tags) ? j.tags : [] } }));
863 if (f.caption && !text.value.trim()) { ctx.set(f.caption); text.focus(); say('Caption written by AI: change it as you like.'); }
864 else if (f.alt) say('Described by AI.');
865 } catch (e) { say((e && e.message) || 'The AI could not caption it just now.'); }
866 finally { f.captioning = false; if (files.includes(f)) paintFile(f); }
867 }
868 function remove(f) {
869 const i = files.indexOf(f); if (i < 0) return;
870 files.splice(i, 1);
871 if (f.abort && f.progress != null && f.progress < 1) f.abort.abort();
872 if (f.url) URL.revokeObjectURL(f.url);
873 if (f.hidden) f.hidden.remove();
874 form.dispatchEvent(new CustomEvent('ovc:file-removed', { detail: { file: f.file, chip: f.chip } }));
875 f.chip.remove();
876 strip.hidden = !files.length;
877 syncInputs();
878 syncSend();
879 }
880 function add(list, pasted) {
881 const up = uploader();
882 for (const file of [...(list || [])]) {
883 if (files.length >= maxFiles) { say(`Up to ${maxFiles} files at a time.`); break; }
884 if (!accepts(file)) { say(`${file.name || 'That file'}: this box does not take that kind of file.`); continue; }
885 if (media()) {
886 const k = mediaKind(file), cap = MEDIA_MB[k] * 1048576;
887 if (file.size > cap) { say(`${file.name || 'That file'} is ${size(file.size)}. ${MEDIA_WORD[k]} can be up to ${MEDIA_MB[k]} MB.`); continue; }
888 } else if (file.size > maxBytes) { say(`${file.name || 'That file'} is ${size(file.size)}: the limit is ${size(maxBytes)}.`); continue; }
889 const kind = (file.type || '').split('/')[0];
890 const f = { file, url: kind === 'image' || kind === 'video' ? URL.createObjectURL(file) : null, pasted: !!pasted, lines: pasted ? pasted.lines : 0, text: pasted ? pasted.text : null };
891 files.push(f);
892 paintFile(f);
893 form.dispatchEvent(new CustomEvent('ovc:file-added', { detail: { file, chip: f.chip } }));
894 const resize = Number(form.getAttribute('data-ovc-resize')) || 0;
895 if (up || resize) {
896 if (up) { f.progress = 0; paintFile(f); }
897 shrink(file, resize).then((small) => {
898 if (!files.includes(f)) return;
899 if (small !== f.file) { f.file = small; syncInputs(); paintFile(f); }
900 if (up) sendUp(f);
901 });
902 }
903 }
904 strip.hidden = !files.length;
905 syncInputs();
906 syncSend();
907 }
908 function sendUp(f) {
909 const up = uploader(); if (!up) return;
910 f.error = null; f.progress = 0; paintFile(f); syncSend();
911 f.abort = typeof AbortController === 'function' ? new AbortController() : null;
912 Promise.resolve(up(f.file, (p) => { f.progress = Math.max(0, Math.min(0.99, Number(p) || 0)); paintFile(f); syncSend(); }, f.abort ? f.abort.signal : undefined))
913 .then((r) => {
914 if (!files.includes(f)) return;
915 f.progress = 1; f.id = r && r.id;
916 if (f.id) { f.hidden = el('input'); f.hidden.type = 'hidden'; f.hidden.name = form.getAttribute('data-ovc-ids') || 'attachments'; f.hidden.value = f.id; form.appendChild(f.hidden); }
917 paintFile(f); syncSend();
918 }, (e) => { if (!files.includes(f) || (e && e.cancelled)) return; f.error = (e && e.message) || 'Did not upload'; paintFile(f); syncSend(); });
919 }
920 strip.addEventListener('click', (e) => {
921 const chip = e.target.closest && e.target.closest('.ovc-file'); if (!chip) return;
922 const f = files.find((x) => x.chip === chip); if (!f) return;
923 if (e.target.closest('[data-ovc-x]')) { remove(f); text.focus(); }
924 else if (e.target.closest('[data-ovc-back]')) { const t = f.text; remove(f); ctx.insert(t); }
925 else if (e.target.closest('[data-ovc-retry]')) sendUp(f);
926 else if (e.target.closest('[data-ovc-caption]')) caption(f);
927 });
928 form.addEventListener('change', (e) => {
929 const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;
930 const picked = [...(i.files || [])];
931 i.__ovcSync = true; i.value = ''; i.__ovcSync = false;
932 add(picked);
933 });
934 text.addEventListener('paste', (e) => {
935 const cd = e.clipboardData; if (!cd || !canFile()) return;
936 const got = [...(cd.files || [])];
937 if (got.length) { e.preventDefault(); add(got); return; }
938 const t = cd.getData('text/plain') || '';
939 const lines = t.split('\n').length;
940 if (t.length > 6000 || lines > 120) {
941 // Only where the box takes a text file (or a mod uploads): a box for pictures keeps the paste as text.
942 const asFile = new File([t], 'pasted-text.txt', { type: 'text/plain' });
943 if (!uploader() && !accepts(asFile)) return;
944 e.preventDefault();
945 add([asFile], { lines, text: t });
946 say('The long paste went in as a file: its Put back button returns it to the box.');
947 }
948 });
949 const zone = form.closest('[data-ovt]') || form;
950 let depth = 0;
951 const hasFiles = (e) => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files');
952 zone.addEventListener('dragenter', (e) => { if (!hasFiles(e) || !canFile()) return; depth++; form.classList.add('ovc-dropping'); });
953 zone.addEventListener('dragleave', () => { if (depth && --depth === 0) form.classList.remove('ovc-dropping'); });
954 zone.addEventListener('dragover', (e) => { if (hasFiles(e) && canFile()) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } });
955 zone.addEventListener('drop', (e) => { if (!hasFiles(e) || !canFile()) return; e.preventDefault(); depth = 0; form.classList.remove('ovc-dropping'); add(e.dataTransfer.files); text.focus(); });
956
957 // Send waits for words or a file, and for uploads to finish.
958 const busy = () => files.some((f) => f.progress != null && f.progress < 1 && !f.error);
959 function syncSend() {
960 if (!send || form.classList.contains('is-busy')) return;
961 send.disabled = busy() || (!text.value.trim() && !files.length);
962 if (wasRequired && text.required && files.length) text.required = false;
963 else if (wasRequired && !files.length && !text.required && !form.classList.contains('is-busy')) text.required = true;
964 }
965 text.addEventListener('input', syncSend);
966 if (form.classList.contains('ovc-v2')) {
967 const grow = () => { text.style.height = 'auto'; text.style.height = `${Math.min(text.scrollHeight + 2, Math.round((root.innerHeight || 800) * 0.4))}px`; };
968 text.addEventListener('input', grow);
969 requestAnimationFrame(grow);
970 }
971
972 // The slash list and the mention list: one popover above the box.
973 const pop = el('div', 'ovc-pop');
974 pop.setAttribute('role', 'listbox');
975 pop.id = `ovc-pop-${Math.random().toString(36).slice(2, 8)}`;
976 pop.hidden = true;
977 form.appendChild(pop);
978 text.setAttribute('aria-controls', pop.id);
979 let items = [], at = 0, trig = null, seq = 0;
980 function siteCommands() {
981 let list = [];
982 try { list = JSON.parse(form.getAttribute('data-ovt-commands') || '[]'); } catch { /* none */ }
983 return list.filter((c) => c && /^[\w-]{1,30}$/.test(c.name || ''));
984 }
985 function commands() {
986 const own = (prefs.snippets || []).map((s) => ({ name: s.trigger, hint: s.label || s.text.slice(0, 60), insert: s.text, mine: true }));
987 const fromMods = mine().flatMap((m) => m.commands || []);
988 return [...siteCommands(), ...fromMods, ...own, { name: 'settings', hint: 'Your composer: what sends, voice, your own commands', run: () => openSettings() }];
989 }
990 function caretWord() {
991 const upto = text.value.slice(0, text.selectionStart);
992 let m = upto.match(/(?:^|\n)\/([\w-]{0,30})$/);
993 if (m) return { kind: 'slash', q: m[1], from: upto.length - m[1].length - 1 };
994 m = upto.match(/(?:^|[\s(])@([\w.-]{0,30})$/);
995 if (m && mine().some((x) => x.mentions && typeof x.mentions.search === 'function')) return { kind: 'at', q: m[1], from: upto.length - m[1].length - 1 };
996 return null;
997 }
998 function close() { pop.hidden = true; items = []; trig = null; text.removeAttribute('aria-activedescendant'); text.setAttribute('aria-expanded', 'false'); }
999 function paintPop() {
1000 if (!items.length) { close(); return; }
1001 pop.innerHTML = '';
1002 items.forEach((it, i) => {
1003 const b = el('button', 'ovc-pop-i');
1004 b.type = 'button'; b.id = `${pop.id}-${i}`;
1005 b.setAttribute('role', 'option');
1006 b.setAttribute('aria-selected', i === at ? 'true' : 'false');
1007 const lead = it.avatar ? el('img', 'ovc-pop-av') : el('span', 'ovc-pop-ic', svg(trig && trig.kind === 'at' ? IC.at : it.name === 'settings' ? IC.gear : IC.slash));
1008 if (it.avatar) { lead.src = it.avatar; lead.alt = ''; }
1009 const t = el('span', 'ovc-pop-t');
1010 const n = el('b'); n.textContent = trig && trig.kind === 'at' ? it.label : `/${it.name}`;
1011 const h = el('span'); h.textContent = trig && trig.kind === 'at' ? (it.sub || (it.handle ? `@${it.handle}` : '')) : it.hint || '';
1012 t.append(n, h);
1013 b.append(lead, t);
1014 if (it.mine) b.appendChild(el('span', 'ovc-pop-tag', 'Yours'));
1015 b.addEventListener('mousedown', (e) => e.preventDefault());
1016 b.addEventListener('click', () => choose(i));
1017 pop.appendChild(b);
1018 });
1019 pop.hidden = false;
1020 text.setAttribute('aria-expanded', 'true');
1021 text.setAttribute('aria-activedescendant', `${pop.id}-${at}`);
1022 const cur = pop.children[at]; if (cur && cur.scrollIntoView) cur.scrollIntoView({ block: 'nearest' });
1023 }
1024 async function look() {
1025 const w = caretWord();
1026 if (!w) { close(); return; }
1027 trig = w; const mineSeq = ++seq;
1028 if (w.kind === 'slash') {
1029 const q = w.q.toLowerCase();
1030 items = commands().filter((c) => !q || c.name.toLowerCase().startsWith(q) || (c.hint || '').toLowerCase().includes(q)).slice(0, 12);
1031 at = 0; paintPop(); return;
1032 }
1033 const srcs = mine().filter((m) => m.mentions && typeof m.mentions.search === 'function');
1034 const lists = await Promise.all(srcs.map((m) => Promise.resolve(m.mentions.search(w.q)).catch(() => [])));
1035 if (mineSeq !== seq) return;
1036 items = lists.flat().filter((x) => x && x.label).slice(0, 8);
1037 at = 0; paintPop();
1038 }
1039 function choose(i) {
1040 const it = items[i]; if (!it || !trig) return;
1041 const end = text.selectionStart;
1042 const from = trig.from, kind = trig.kind;
1043 close();
1044 if (kind === 'at') { text.setRangeText(it.insert || `@${it.handle || it.label} `, from, end, 'end'); fire(); text.focus(); return; }
1045 text.setRangeText('', from, end, 'end');
1046 if (typeof it.run === 'function') { fire(); try { it.run(ctx); } catch (e) { console.error(e); } return; }
1047 text.setRangeText(String(it.insert || ''), from, from, 'end'); fire(); text.focus();
1048 }
1049 let lookT = 0;
1050 text.addEventListener('input', () => { clearTimeout(lookT); lookT = setTimeout(() => { lookT = 0; look(); }, trig && trig.kind === 'at' ? 140 : 0); });
1051 text.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== text) close(); }, 120));
1052 text.setAttribute('aria-autocomplete', 'list');
1053
1054 // Keys, before the thread's own: the list's arrows and Enter, what sends, and the marks.
1055 form.addEventListener('keydown', (e) => {
1056 if (e.target !== text) return;
1057 // Enter right after the last letter: the slash list is read now, so "/settings" + Enter picks it.
1058 if (e.key === 'Enter' && lookT) { clearTimeout(lookT); lookT = 0; const w = caretWord(); if (w && w.kind === 'slash') look(); }
1059 if (!pop.hidden && items.length) {
1060 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); at = (at + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length; paintPop(); return; }
1061 if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); choose(at); return; }
1062 if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return; }
1063 }
1064 const mod = mac ? e.metaKey : e.ctrlKey;
1065 if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
1066 if (mod) { e.preventDefault(); e.stopPropagation(); if (!send || !send.disabled) ctx.send(); return; }
1067 if (prefs.send_key === 'mod-enter') { e.stopPropagation(); return; }
1068 if (busy()) { e.preventDefault(); e.stopPropagation(); say('A file is still uploading.'); return; }
1069 if (!text.value.trim() && files.length && (root.matchMedia ? root.matchMedia('(pointer: fine)').matches : true)) { e.preventDefault(); e.stopPropagation(); ctx.send(); return; }
1070 }
1071 const wrap = { b: '**', i: '_', e: '`' }[mod && !e.altKey && !e.shiftKey ? e.key.toLowerCase() : ''];
1072 if (wrap) {
1073 e.preventDefault(); e.stopPropagation();
1074 const a = text.selectionStart, z = text.selectionEnd, sel = text.value.slice(a, z);
1075 text.setRangeText(wrap + sel + wrap, a, z, 'select');
1076 if (!sel) text.setSelectionRange(a + wrap.length, a + wrap.length);
1077 else text.setSelectionRange(a + wrap.length, z + wrap.length);
1078 fire();
1079 }
1080 }, true);
1081
1082 // The send: the mods may hold it; files still uploading hold it; the draft goes.
1083 const draftKey = form.hasAttribute('data-ovc-no-draft') ? null : `ov:draft:${location.pathname}#${form.id || form.getAttribute('action') || ''}`;
1084 form.addEventListener('submit', (e) => {
1085 if (busy()) { e.preventDefault(); e.stopImmediatePropagation(); say('A file is still uploading.'); return; }
1086 for (const m of mine()) {
1087 if (typeof m.beforeSend !== 'function') continue;
1088 let ok; try { ok = m.beforeSend(ctx); } catch (err) { console.error(err); }
1089 if (ok === false) { e.preventDefault(); e.stopImmediatePropagation(); return; }
1090 }
1091 if (draftKey) store.set(draftKey, null);
1092 close();
1093 }, true);
1094 if (draftKey) {
1095 const d = store.get(draftKey);
1096 if (d && d.t && !text.value && Date.now() - (d.at || 0) < 7 * 864e5) { text.value = d.t; fire(); }
1097 let dt = 0;
1098 text.addEventListener('input', () => { clearTimeout(dt); dt = setTimeout(() => store.set(draftKey, text.value.trim() ? { t: text.value, at: Date.now() } : null), 400); });
1099 }
1100
1101 // The count, near the box's limit.
1102 let count = null;
1103 if (text.maxLength > 0 && row) {
1104 count = el('span', 'ovc-count'); count.setAttribute('aria-live', 'polite');
1105 row.insertBefore(count, send && send.parentNode === row ? send : null);
1106 // The count shows only near the limit: the last 15% of a box, at most 200 and at least 20 characters (a 120-character
1107 // search box said "120 left" while empty, 2026-11-11).
1108 const paintCount = () => { const left = text.maxLength - text.value.length; count.hidden = left > Math.min(200, Math.max(20, Math.round(text.maxLength * 0.15))); count.textContent = `${left} left`; count.classList.toggle('is-low', left < 20); };
1109 text.addEventListener('input', paintCount); paintCount();
1110 }
1111
1112 // Voice: the browser listens, the words go in at the caret as they come.
1113 let mic = null, rec = null;
1114 // 4.241.0, the +: what you can add, in one sheet (see the header). Big tiles, then rows; a sheet from the bottom on
1115 // a phone (the top layer, so no panel clips it), a panel under the row on a wide screen.
1116 let more = null, sheet = null;
1117 const motionOff = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
1118 const env = () => ({
1119 files: canFile(), anyFile: !noFiles && (!!uploader() || inputs().some((i) => !i.getAttribute('accept'))),
1120 touch: !!(root.matchMedia && root.matchMedia('(pointer: coarse)').matches),
1121 camera: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
1122 screen: !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia),
1123 share: typeof navigator.share === 'function', clip: !!(navigator.clipboard && navigator.clipboard.read),
1124 });
1125 function plusSpec() {
1126 let own = null; try { own = JSON.parse(form.dataset.ovcPlus || 'null'); } catch { own = null; }
1127 return Object.assign({}, PLUS_DEFAULT, CONFIG.plus || {}, own && typeof own === 'object' ? cleanPlus(own) : {});
1128 }
1129 const iconOf = (t) => (t.glyph && GL[t.glyph] ? gsvg(GL[t.glyph]) : svg(PATH_OK.test(t.path || '') ? t.path : IC.slash));
1130 function closeSheet(now) {
1131 if (!sheet) return;
1132 const s0 = sheet; sheet = null;
1133 if (more) more.setAttribute('aria-expanded', 'false');
1134 // A tool that runs closes it at once (a modal sheet keeps the page inert while it slides away); dismissed, it
1135 // slides down first.
1136 if (s0.tagName === 'DIALOG') { if (now === true || motionOff()) { try { s0.close(); } catch { /* */ } s0.remove(); } else { s0.classList.add('is-out'); setTimeout(() => { try { s0.close(); } catch { /* */ } s0.remove(); }, 200); } }
1137 else s0.remove();
1138 }
1139 /** A file tool: the browser's picker for its kinds; what is picked joins the box. */
1140 function pick(t) {
1141 const i = document.createElement('input'); i.type = 'file'; i.multiple = true;
1142 if (t.accept) i.accept = t.accept;
1143 if (t.capture) i.setAttribute('capture', t.capture);
1144 i.tabIndex = -1; i.setAttribute('aria-hidden', 'true');
1145 i.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0';
1146 i.addEventListener('change', () => { const list = [...(i.files || [])]; i.remove(); if (list.length) ctx.add(list); }, { once: true });
1147 document.body.appendChild(i);
1148 i.click();
1149 setTimeout(() => { if (i.isConnected) i.remove(); }, 300000);
1150 }
1151 function runTool(t) { closeSheet(true); try { if (typeof t.run === 'function') t.run(ctx, more, form); else pick(t); } catch (err) { console.error(err); } }
1152 function openSheet(ev) {
1153 if (sheet) { closeSheet(); return; }
1154 const e = env(), spec = plusSpec();
1155 const ok = (id) => { const t = TOOL[id]; return !!t && !t.builtin && !form.hasAttribute(`data-ovc-no-${id}`) && (typeof t.when !== 'function' || !!t.when(e)) && (typeof t.run === 'function' || t.accept !== undefined); };
1156 const body = el('div', 'ovc-add-b');
1157 const tileIds = spec.tiles.filter(ok).slice(0, 4);
1158 if (tileIds.length) {
1159 const g = el('div', 'ovc-add-tiles'); g.style.setProperty('--n', String(tileIds.length));
1160 tileIds.forEach((id, k) => {
1161 const t = TOOL[id];
1162 const b = el('button', 'ovc-add-tile', `<span class="ovc-add-ic">${iconOf(t)}</span><span class="ovc-add-t"></span>`);
1163 b.type = 'button'; b.dataset.tool = id; b.style.setProperty('--k', String(k));
1164 b.querySelector('.ovc-add-t').textContent = t.tile || t.label;
1165 b.setAttribute('aria-label', t.sub ? `${t.tile || t.label}: ${t.sub}` : (t.tile || t.label));
1166 b.addEventListener('click', () => runTool(t));
1167 g.appendChild(b);
1168 });
1169 body.appendChild(g);
1170 }
1171 const list = el('div', 'ovc-add-rows');
1172 const seen = new Set(tileIds.map((id) => TOOL[id].label));
1173 const row = (key, label, sub, icon, act) => {
1174 if (!label || seen.has(label)) return; seen.add(label);
1175 const b = el('button', 'ovc-add-row', `<span class="ovc-add-ic">${icon}</span><span class="ovc-add-rt"><b></b><small></small></span>${gsvg(GL.chevron).replace('<svg ', '<svg class="ovc-add-go" ')}`);
1176 b.type = 'button'; b.dataset.tool = key; b.style.setProperty('--k', String(list.children.length + tileIds.length));
1177 b.querySelector('b').textContent = label;
1178 const sm = b.querySelector('small'); if (sub) sm.textContent = sub; else sm.remove();
1179 b.addEventListener('click', act);
1180 list.appendChild(b);
1181 };
1182 spec.rows.filter(ok).forEach((id) => { const t = TOOL[id]; row(id, t.label, t.sub, iconOf(t), () => runTool(t)); });
1183 // What this box's row hides on a narrow screen, and the mods' tools, follow.
1184 [...tools.children].forEach((t) => {
1185 if (t === mic || t === more || !t.matches('.ovt-btn, button, label') || t.matches('[type="submit"], [data-ovt-send], .ovc-mic, .ovdk-mic, .ovdk-go, [hidden]')) return;
1186 const label = t.getAttribute('aria-label') || t.title || '';
1187 if (!label || (getComputedStyle(t).display !== 'none' && t.getClientRects().length)) return;
1188 const input = t.querySelector('input[type=file]');
1189 const drawn = t.querySelector('svg');
1190 row(`row-${label}`, label, '', drawn ? drawn.outerHTML : svg(IC.slash), () => { closeSheet(true); if (input) input.click(); else t.click(); });
1191 });
1192 if (!spec.only) Object.keys(TOOL).forEach((id) => { const t = TOOL[id]; if (t && t.extra && !spec.rows.includes(id) && ok(id)) row(id, t.label, t.sub, iconOf(t), () => runTool(t)); });
1193 if (list.children.length) body.appendChild(list);
1194 if (!body.children.length) return;
1195 const narrow = (root.innerWidth || 1024) <= 600 || (e.touch && (root.innerWidth || 1024) < 760);
1196 if (narrow && typeof HTMLDialogElement === 'function') {
1197 const dlg = el('dialog', 'ovc-sheet ovc-add');
1198 dlg.setAttribute('aria-label', 'Add to this');
1199 dlg.innerHTML = `<div class="ovc-add-grip" aria-hidden="true"></div><div class="ovc-add-h"><b>Add</b><button type="button" class="ovc-add-x" aria-label="Close">${svg(IC.x)}</button></div>`;
1200 dlg.appendChild(body);
1201 dlg.querySelector('.ovc-add-x').addEventListener('click', () => { closeSheet(); if (more) more.focus(); });
1202 dlg.addEventListener('click', (e2) => { if (e2.target === dlg) closeSheet(); });
1203 dlg.addEventListener('cancel', (e2) => { e2.preventDefault(); closeSheet(); });
1204 // Swipe it down to close (the grip and the header).
1205 let y0 = null, dy = 0;
1206 const head = dlg.querySelector('.ovc-add-h'), grip = dlg.querySelector('.ovc-add-grip');
1207 const down = (p) => { y0 = p.clientY; dy = 0; dlg.style.transition = 'none'; };
1208 const moveY = (p) => { if (y0 == null) return; dy = Math.max(0, p.clientY - y0); dlg.style.transform = `translateY(${dy}px)`; };
1209 const up = () => { if (y0 == null) return; y0 = null; dlg.style.transition = ''; if (dy > 80) closeSheet(); else dlg.style.transform = ''; };
1210 [head, grip].forEach((h0) => { h0.addEventListener('pointerdown', down); });
1211 dlg.addEventListener('pointermove', moveY); dlg.addEventListener('pointerup', up); dlg.addEventListener('pointercancel', up);
1212 document.body.appendChild(dlg);
1213 if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
1214 sheet = dlg;
1215 } else {
1216 sheet = el('div', 'ovc-tray ovc-add-inline'); sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Add to this');
1217 sheet.appendChild(body);
1218 form.appendChild(sheet); // inside the box, under its row: nothing can clip it
1219 sheet.addEventListener('keydown', (e2) => { if (e2.key === 'Escape') { closeSheet(); more.focus(); } });
1220 }
1221 more.setAttribute('aria-expanded', 'true');
1222 // From the keyboard (a click with no pointer) focus moves into the sheet; a tap leaves it on the +.
1223 if (ev && ev.detail === 0) { const first = sheet.querySelector('.ovc-add-tile, .ovc-add-row'); if (first) first.focus(); }
1224 }
1225 if (tools && !form.hasAttribute('data-ovc-no-more')) {
1226 more = el('button', 'ovt-btn ovc-more', PLUS_MARK);
1227 more.type = 'button'; more.setAttribute('aria-label', 'Add: camera, photos, files and more'); more.title = 'Add';
1228 more.setAttribute('aria-haspopup', 'dialog'); more.setAttribute('aria-expanded', 'false');
1229 more.addEventListener('click', openSheet);
1230 const lead = tools.querySelector(':scope > .ovdk-mic'); // a page's own mic stays first
1231 tools.insertBefore(more, lead ? lead.nextSibling : tools.firstChild);
1232 document.addEventListener('pointerdown', (e) => { if (sheet && !sheet.contains(e.target) && !more.contains(e.target)) closeSheet(); });
1233 }
1234 // What people do with this box, in turn while it is empty and you are elsewhere (Calm: slower; Off: the first stays).
1235 {
1236 let ex = null; try { ex = JSON.parse(form.dataset.ovcExamples || 'null'); } catch { ex = null; }
1237 if (Array.isArray(ex) && ex.length > 1 && typeof setInterval === 'function') {
1238 let k = 0;
1239 const motion = () => (document.documentElement.getAttribute('data-ov-motion') || '');
1240 const tick = setInterval(() => {
1241 if (!form.isConnected) { clearInterval(tick); return; }
1242 if (motion() === 'off' || text.value || document.activeElement === text) return;
1243 k = (k + 1) % ex.length;
1244 text.classList.add('ovc-ph-swap');
1245 setTimeout(() => { text.placeholder = String(ex[k]).slice(0, 80); text.classList.remove('ovc-ph-swap'); }, motion() === 'reduced' ? 120 : 260);
1246 }, motion() === 'reduced' ? 8000 : 4200);
1247 }
1248 }
1249 function micButton() {
1250 if (!Speech || !tools) return;
1251 if (!prefs.voice || form.hasAttribute('data-ovc-no-voice')) { if (mic) { mic.remove(); mic = null; } return; }
1252 if (mic) return;
1253 mic = el('button', 'ovt-btn ovc-mic', svg(IC.mic));
1254 mic.type = 'button'; mic.setAttribute('aria-label', 'Speak'); mic.title = 'Speak'; mic.setAttribute('aria-pressed', 'false');
1255 mic.addEventListener('click', () => {
1256 if (rec) { rec.stop(); return; }
1257 rec = new Speech();
1258 rec.lang = document.documentElement.lang || navigator.language || 'en-US';
1259 rec.interimResults = true; rec.continuous = true;
1260 const a = text.selectionStart, z = text.selectionEnd, head = text.value.slice(0, a), tail = text.value.slice(z);
1261 const pad = head && !/\s$/.test(head) ? ' ' : '';
1262 rec.onresult = (ev) => { let s = ''; for (let i = 0; i < ev.results.length; i++) s += ev.results[i][0].transcript; text.value = head + pad + s + tail; text.setSelectionRange((head + pad + s).length, (head + pad + s).length); fire(); };
1263 rec.onend = () => { rec = null; mic.setAttribute('aria-pressed', 'false'); mic.title = 'Speak'; text.focus(); };
1264 rec.onerror = (ev) => { if (ev.error === 'not-allowed') say('The microphone is blocked for this site.'); };
1265 try { rec.start(); mic.setAttribute('aria-pressed', 'true'); mic.title = 'Stop listening'; } catch { rec = null; }
1266 });
1267 tools.insertBefore(mic, tools.firstChild);
1268 if (more && more.parentNode === tools) tools.insertBefore(more, mic.nextSibling);
1269 }
1270
1271 // The mods' tools: buttons in the row, in the order the mods came.
1272 let modTools = [];
1273 function paintTools() {
1274 modTools.forEach((b) => b.remove()); modTools = [];
1275 if (!tools) return;
1276 for (const t of mine().flatMap((m) => m.tools || [])) {
1277 if (!t || !t.label || typeof t.run !== 'function') continue;
1278 const b = el('button', 'ovt-btn ovc-tool', svg(PATH_OK.test(t.path || '') ? t.path : IC.slash));
1279 b.type = 'button'; b.setAttribute('aria-label', t.label); b.title = t.label;
1280 b.addEventListener('click', () => { try { t.run(ctx); } catch (e) { console.error(e); } });
1281 tools.appendChild(b);
1282 modTools.push(b);
1283 }
1284 }
1285
1286 const api = {
1287 form, ctx,
1288 refresh() { form.classList.toggle('ovc-compact', prefs.density === 'compact'); micButton(); paintTools(); syncSend(); },
1289 add, files: () => files.map((f) => f.file), clearFiles: () => { [...files].forEach(remove); },
1290 uploads: () => files.map((f) => f.id).filter(Boolean),
1291 clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },
1292 destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); if (more) more.remove(); closeSheet(); modTools.forEach((b) => b.remove()); form.__ovc = null; },
1293 };
1294 form.__ovc = api;
1295 form.addEventListener('reset', () => setTimeout(() => api.clear(), 0));
1296 live.add(api);
1297 form.classList.add('ovc');
1298 api.refresh();
1299 return api;
1300 }
1301
1302
1303 // ── 4.215.0: a whole composer, mounted (composer v2). ─────────────────────────────────────────────────────────────
1304 // A site no longer draws its own box: OpenVibeComposer.mount(host, options) draws one of the presets, and everything
1305 // enhance() gives (files, paste, drop, uploads, commands, mentions, voice, drafts, keys, the count) comes with it.
1306 // options: { preset: 'post' | 'chat' | 'agent' | 'comment', id, name ('text'), placeholder, label, maxLength,
1307 // user: { display_name | username, avatar_url }, tools: ['photo', 'file', 'attach', 'emoji', 'voice'] (the
1308 // preset's own when absent), accept, maxFiles, maxMb, required (true), noDraft,
1309 // media (4.247.0: true sends each file to Media's composer attachments as it is picked, at Media's limits per kind,
1310 // and the form carries the ids in `attachments`, or idsName; a page without a viewer token keeps the plain upload),
1311 // audience / mode: { name, label, options: [[value, label]], value } (a chip each); chips: [{ name, label, icon
1312 // ('globe' | 'spark' | 'cpu' | 'gauge'), options, value, onChange }] for any more (an agent's Model and Reasoning,
1313 // as the agents portal's box has),
1314 // submitLabel, upload(file, onProgress), mentions, commands, beforeSend,
1315 // onSubmit({ text, files, uploads, audience, mode }, api) → a promise; false keeps the box as it is }
1316 // → the enhance api plus { form, text, send, note, setBusy(bool), setRunning(bool, onStop) (the send becomes Stop
1317 // while a run works, as in the agents portal), values() (every chip's value by name),
1318 // edit({ text, onSave(text) → false keeps editing, onCancel, label }) (the box takes your message's words under an
1319 // "Editing your message" bar; Enter saves, Esc or × puts back what was in the box; the send is a check),
1320 // reply({ name, text, data, onClear }) (a "Replying to …" bar; onSubmit's value carries reply: data),
1321 // bar({ icon, title, detail, kind, onClose }) (4.236.0: any context above the words, inside the box, with a rail of
1322 // the accent: a quote, a file being answered; one bar at a time), clearBar(), editing() }.
1323 // 4.218.0, Post · Ask · Do (research-2026-11-12/composer-post-ask-do.md): verbs: ['post', 'ask', 'do'] (a site
1324 // opts in; the first is where the box starts) draws one switch at the start of the row. Ask and Do add three chips:
1325 // Model (a sheet over the Frame's /choices?task=: Auto, then OpenVibe from Exchange's directory with prices, your
1326 // keys, your computers, your plans), Sources (web, OpenVibe, mine, this page) and Depth (quick, balanced, deep).
1327 // ask: { task, context: { site, url, title, kind, id } }, frameBase (default https://openvibe.network/api/frame),
1328 // onAsk(value, api) / onDo(value, api) to answer it yourself; without them the box asks the Frame (/ask, /do) and
1329 // streams the run into an answer card under the box (api.answer() gives the card to a site that answers itself).
1330 // value = { verb, text, files, uploads, model, sources, depth, context, chips }.
1331 // A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces
1332 // one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.
1333 const EXAMPLES = {
1334 agent: ['Ask for anything', 'Find a live stream about cooking', 'Start a 10-minute timer', 'Summarize today’s Rundown', 'Write a title for my stream', 'What did people post today?'],
1335 post: ["What's new?", 'Share a photo from today', 'Ask your followers a question', 'Post a clip you made'],
1336 };
1337 const PRESET = {
1338 post: { tools: ['photo', 'camera', 'file', 'emoji', 'voice'], send: 'Post', avatar: true, rows: 2, max: 2000, ring: true, resize: 2560 },
1339 chat: { tools: ['attach', 'emoji', 'voice'], send: '', avatar: false, rows: 1, max: 4000, ring: false, resize: 2560 },
1340 agent: { tools: ['attach', 'camera', 'voice'], send: '', avatar: false, rows: 2, max: 16000, ring: false, resize: 1568 },
1341 comment: { tools: ['photo', 'emoji'], send: 'Reply', avatar: true, rows: 1, max: 2000, ring: true, resize: 2560 },
1342 };
1343 const EMOJI = '😀 😂 🥹 😊 😍 🥰 😘 😎 🤩 🥳 😇 🙂 😉 😌 🤔 🤨 😐 🙄 😏 😬 😮 😱 😭 😤 😡 🤯 🥶 🥵 😴 🤤 🤗 🫡 🤝 👍 👎 👏 🙌 🙏 💪 👀 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ✨ 🔥 💯 🎉 🎊 🏆 🎮 🎧 🎵 📸 🎬 🍕 🍔 🍿 ☕ 🍻 🌈 🌙 ⭐ ⚡ 💥 💀 🤖 👻 🐐 🦆 🐸 🚀 ✅ ❌ ⚠️ ❓ 👋 🫠'.split(' ');
1344 const TOOL = {
1345 photo: { label: 'Photos and video', tile: 'Photos', sub: 'Pictures and videos', glyph: 'image', path: IC.photo, accept: 'image/*,video/*', when: (e) => e.files },
1346 // 4.223.0: in every box's + (extra: never in a row of its own). Your timers are Vibe's: one list on every site.
1347 timer: {
1348 label: 'Timer', sub: 'Counts down on every OpenVibe site', path: IC.timer, extra: true,
1349 run(ctx, button) {
1350 const D = root.OpenVibeDock;
1351 if (D && typeof D.timer === 'function') { try { D.timer(button); return; } catch (e) { console.error(e); } }
1352 const N = root.OpenVibeNavbar;
1353 if (N && typeof N.openMenuOn === 'function') { try { N.openMenuOn('vibe'); ctx.note('Say “a timer for 10 minutes” to Vibe.'); return; } catch { /* below */ } }
1354 ctx.note('Sign in to set timers: they ring on every OpenVibe site.');
1355 },
1356 },
1357 file: { label: 'Files', sub: 'Any file from this device', glyph: 'file', path: IC.file, accept: '', when: (e) => e.files && e.anyFile },
1358 attach: { label: 'Attach', path: IC.clip, accept: '' },
1359 // 4.241.0: the +'s rows. The phone's own camera app (its chooser on Android), what you copied, a picture of a
1360 // screen, and what you wrote sent to another app.
1361 camapp: { label: 'Camera app', sub: 'Your phone\u2019s own camera, or its chooser', glyph: 'video', path: IC.camera, accept: 'image/*,video/*', capture: 'environment', extra: true, when: (e) => e.files && e.touch },
1362 paste: {
1363 label: 'Paste', sub: 'A picture or words you copied', glyph: 'clipboard', path: IC.file, extra: true, when: (e) => e.clip,
1364 async run(ctx) {
1365 let items = [];
1366 try { items = await navigator.clipboard.read(); } catch { ctx.note('This browser did not share what you copied. Press and hold in the box, then Paste.'); return; }
1367 const found = [];
1368 for (const it of items) {
1369 const pic = it.types.find((t) => /^image\//.test(t));
1370 if (pic) { const b = await it.getType(pic); found.push(new File([b], `pasted.${pic.split('/')[1].replace('jpeg', 'jpg')}`, { type: pic })); continue; }
1371 if (it.types.includes('text/plain')) { const t = await (await it.getType('text/plain')).text(); if (t) ctx.insert(t); }
1372 }
1373 if (found.length) ctx.add(found); else if (!items.length) ctx.note('Nothing is copied right now.');
1374 },
1375 },
1376 screenshot: {
1377 label: 'Screenshot', sub: 'A picture of a screen, a window or a tab', glyph: 'computer', path: IC.photo, extra: true, when: (e) => e.files && e.screen && !e.touch,
1378 async run(ctx) {
1379 let stream = null;
1380 try {
1381 stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
1382 const v = document.createElement('video'); v.muted = true; v.playsInline = true; v.srcObject = stream;
1383 await v.play(); await new Promise((r) => setTimeout(r, 250));
1384 const c = document.createElement('canvas'); c.width = v.videoWidth; c.height = v.videoHeight;
1385 c.getContext('2d').drawImage(v, 0, 0);
1386 const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
1387 if (blob) ctx.add([new File([blob], `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`, { type: 'image/png' })]);
1388 } catch (err) { if (err && err.name !== 'NotAllowedError') ctx.note('The screenshot did not work in this browser.'); }
1389 finally { if (stream) stream.getTracks().forEach((t) => t.stop()); }
1390 },
1391 },
1392 share: {
1393 label: 'Share to an app', sub: 'Send what you wrote somewhere else', glyph: 'share', path: IC.ext, extra: true, when: (e) => e.share,
1394 async run(ctx) {
1395 const text = String(ctx.value || '').trim(), files = ctx.files();
1396 if (!text && !files.length) { ctx.note('Write something or add a file first, then share it.'); return; }
1397 const data = text ? { text } : {};
1398 if (files.length && navigator.canShare && navigator.canShare({ files })) data.files = files;
1399 try { await navigator.share(data); } catch { /* the person closed it */ }
1400 },
1401 },
1402 emoji: {
1403 label: 'Emoji', path: IC.smile,
1404 run(ctx, button, form) {
1405 let panel = form.querySelector('.ovc-emoji');
1406 if (panel) { panel.hidden = !panel.hidden; if (!panel.hidden) panel.querySelector('button').focus(); return; }
1407 panel = el('div', 'ovc-emoji'); panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Emoji');
1408 EMOJI.forEach((e) => {
1409 const b = el('button'); b.type = 'button'; b.textContent = e; b.setAttribute('aria-label', e);
1410 b.addEventListener('click', () => { ctx.insert(e); panel.hidden = true; });
1411 panel.appendChild(b);
1412 });
1413 panel.addEventListener('keydown', (e) => { if (e.key === 'Escape') { panel.hidden = true; button.focus(); } });
1414 document.addEventListener('pointerdown', (e) => { if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) panel.hidden = true; });
1415 form.appendChild(panel);
1416 panel.querySelector('button').focus();
1417 },
1418 },
1419 // 4.225.0: a repository from GitHub, found through OpenVibe Exchange's code search (the Frame's
1420 // /api/frame/code/search): its address goes into the box, where every site draws it as a card.
1421 github: { label: 'GitHub', sub: 'A repository, as a card', path: IC.repo, extra: true, run: (ctx, button, form) => openRepoSearch(ctx, button, form) },
1422 voice: { label: 'Speak', builtin: true }, // enhance() draws the microphone where the browser can listen
1423 camera: { label: 'Camera', sub: 'A photo or a clip, here', glyph: 'camera', path: IC.camera, run: (ctx, button) => openCamera(ctx, button), when: (e) => e.files && e.camera },
1424 };
1425 // The camera (4.217.0): a photo or a clip straight into the box, through the shared capture core. A sheet over the
1426 // page (the whole screen on a phone): the picture, Photo or Video, the shutter, flip and close. Nothing is kept or
1427 // sent until the person sends the message.
1428 async function openCamera(ctx, button) {
1429 const C = await needCapture();
1430 if (!C) { ctx.note('The camera could not start here.'); return; }
1431 styles();
1432 const dlg = el('dialog', 'ovc-cam'); dlg.setAttribute('aria-label', 'Camera');
1433 dlg.innerHTML = `<div class="ovc-cam-stage"><video class="ovc-cam-v" playsinline muted autoplay></video><p class="ovc-cam-msg" hidden></p><span class="ovc-cam-t" hidden>0:00</span></div>
1434<div class="ovc-cam-modes" role="group" aria-label="Photo or video"><button type="button" data-m="photo" aria-pressed="true">Photo</button><button type="button" data-m="video" aria-pressed="false">Video</button></div>
1435<div class="ovc-cam-bar"><button type="button" class="ovc-cam-b" data-a="close" aria-label="Close the camera">${svg(IC.x)}</button><button type="button" class="ovc-cam-shutter" data-a="shoot" aria-label="Take a photo"></button><button type="button" class="ovc-cam-b" data-a="flip" aria-label="Switch camera">${svg(IC.flip)}</button></div>`;
1436 document.body.appendChild(dlg);
1437 const video = dlg.querySelector('video'), msg = dlg.querySelector('.ovc-cam-msg'), timer = dlg.querySelector('.ovc-cam-t'), shoot = dlg.querySelector('[data-a="shoot"]');
1438 let stream = null, facing = (C.settings.load().facing) || 'user', mode = 'photo', rec = null, tick = 0;
1439 const stamp = () => new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
1440 const stop = () => { if (rec) { rec.cancel(); rec = null; } clearInterval(tick); C.stop(stream); stream = null; };
1441 const close = () => { stop(); try { dlg.close(); } catch { /* closed */ } dlg.remove(); if (button) button.focus(); };
1442 async function start() {
1443 C.stop(stream); stream = null; msg.hidden = true;
1444 try {
1445 stream = await C.open({ mode: mode === 'video' ? 'camera' : 'camera_only', facing });
1446 video.srcObject = stream; dlg.classList.toggle('is-mirror', facing === 'user');
1447 } catch (err) {
1448 const fix = C.explain(err, mode === 'video' ? 'camera' : 'camera_only');
1449 msg.textContent = `${fix.title}. ${fix.steps.join(' ')}`; msg.hidden = false;
1450 }
1451 }
1452 dlg.addEventListener('click', async (e) => {
1453 const b = e.target.closest && e.target.closest('button'); if (!b) return;
1454 if (b.dataset.m && !rec) {
1455 mode = b.dataset.m;
1456 dlg.querySelectorAll('[data-m]').forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
1457 dlg.classList.toggle('is-video', mode === 'video');
1458 shoot.setAttribute('aria-label', mode === 'video' ? 'Record' : 'Take a photo');
1459 start();
1460 } else if (b.dataset.a === 'close') close();
1461 else if (b.dataset.a === 'flip' && !rec) { facing = facing === 'user' ? 'environment' : 'user'; C.settings.save({ facing }); start(); }
1462 else if (b.dataset.a === 'shoot' && stream) {
1463 if (mode === 'photo') {
1464 try {
1465 const blob = await C.photo(stream, { mirror: facing === 'user' });
1466 ctx.add([new File([blob], `photo-${stamp()}.jpg`, { type: blob.type || 'image/jpeg' })]);
1467 close();
1468 } catch (err) { msg.textContent = 'The photo did not take. Try again.'; msg.hidden = false; }
1469 } else if (!rec) {
1470 rec = C.record(stream, { maxSeconds: 60 });
1471 dlg.classList.add('is-recording'); shoot.setAttribute('aria-label', 'Stop recording');
1472 timer.hidden = false;
1473 tick = setInterval(() => { const t = Math.floor(rec ? rec.seconds() : 0); timer.textContent = `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; }, 250);
1474 } else {
1475 const r = rec; rec = null; clearInterval(tick);
1476 dlg.classList.remove('is-recording');
1477 const blob = await r.stop();
1478 if (blob) {
1479 const ext = /mp4/.test(blob.type) ? 'mp4' : /webm/.test(blob.type) ? 'webm' : 'video';
1480 ctx.add([new File([blob], `clip-${stamp()}.${ext}`, { type: blob.type || 'video/webm' })]);
1481 }
1482 close();
1483 }
1484 }
1485 });
1486 dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(); });
1487 if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
1488 start();
1489 }
1490 /** Repository searches already answered on this page, by their words, for ten minutes (60 at most). */
1491 const REPO_CACHE = new Map();
1492 const REPO_CACHE_MS = 10 * 60 * 1000;
1493
1494 /** The GitHub tool's panel: a field, the repositories as you type (a third of a second after the last key), one press
1495 * puts the address in the box. Everything is text, and each row's mark is its owner's initial (no picture is fetched). */
1496 function openRepoSearch(ctx, button, form) {
1497 let panel = form.querySelector('.ovc-repo');
1498 if (panel) { panel.hidden = !panel.hidden; if (!panel.hidden) panel.querySelector('input').focus(); return; }
1499 styles();
1500 panel = el('div', 'ovc-repo'); panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'A repository from GitHub');
1501 const field = el('input', 'ovc-repo-q'); field.type = 'search'; field.placeholder = 'Search GitHub'; field.setAttribute('aria-label', 'Search GitHub repositories'); field.autocomplete = 'off'; field.spellcheck = false;
1502 const list = el('div', 'ovc-repo-list'); list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', 'Repositories');
1503 const note = el('p', 'ovc-repo-note'); note.textContent = 'Type what the project does: “goal bar overlay”, “markdown editor”.';
1504 panel.append(field, list, note);
1505 const frame = String(form.getAttribute('data-ovc-frame') || 'https://openvibe.network').replace(/\/+$/, '');
1506 let t = 0, gen = 0, ctl = null;
1507 const close = () => { panel.hidden = true; button.focus(); };
1508 const pick = (r) => { ctx.insert(`${r.url} `); panel.hidden = true; };
1509 const draw = (items) => {
1510 list.textContent = '';
1511 items.forEach((r) => {
1512 const b = el('button', 'ovc-repo-row'); b.type = 'button'; b.setAttribute('role', 'option');
1513 // 4.234.0: the owner's initial on a colour of its name, drawn here. GitHub's pictures were blocked by most
1514 // sites' image policy (a broken picture on every row) and asked GitHub once per row.
1515 const owner = String(r.full_name || '').split('/')[0] || '?';
1516 const av = el('span', 'ovc-repo-av ovc-repo-mono'); av.setAttribute('aria-hidden', 'true');
1517 let h = 0; for (const ch of owner) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
1518 av.style.setProperty('--ovc-mono-h', String(h % 360));
1519 av.textContent = owner.slice(0, 1).toUpperCase();
1520 const t1 = el('span', 'ovc-repo-t');
1521 const name = el('b'); name.textContent = String(r.full_name || '');
1522 const sub = el('small'); sub.textContent = String(r.description || '');
1523 const meta = el('i'); meta.textContent = [r.language, Number.isFinite(r.stars) ? `★ ${r.stars >= 1000 ? `${(r.stars / 1000).toFixed(1)}k` : r.stars}` : '', r.license].filter(Boolean).join(' · ');
1524 t1.append(name, sub, meta);
1525 b.append(av, t1);
1526 b.addEventListener('click', () => pick(r));
1527 list.appendChild(b);
1528 });
1529 };
1530 const search = async () => {
1531 const q = field.value.trim().replace(/\s+/g, ' ');
1532 const my = ++gen;
1533 if (ctl) { try { ctl.abort(); } catch { /* */ } ctl = null; }
1534 if (q.length < 2) { list.textContent = ''; note.hidden = false; note.textContent = 'Type what the project does: “goal bar overlay”, “markdown editor”.'; return; }
1535 const key = q.toLowerCase();
1536 const hit = REPO_CACHE.get(key);
1537 if (hit && Date.now() - hit.at < REPO_CACHE_MS) { draw(hit.items); note.hidden = hit.items.length > 0; note.textContent = hit.items.length ? '' : 'Nothing found. Try other words.'; return; }
1538 // The list stays while a new one is asked for; only an empty panel says it is searching.
1539 if (!list.children.length) { note.hidden = false; note.textContent = 'Searching…'; }
1540 try {
1541 ctl = typeof AbortController === 'function' ? new AbortController() : null;
1542 const r = await fetch(`${frame}/api/frame/code/search?q=${encodeURIComponent(q)}&limit=8`, { credentials: 'omit', headers: { accept: 'application/json' }, signal: ctl ? ctl.signal : undefined });
1543 const j = await r.json().catch(() => ({}));
1544 if (my !== gen) return;
1545 if (!r.ok) throw new Error(j.error || 'Search is unavailable right now');
1546 const items = Array.isArray(j.items) ? j.items : [];
1547 REPO_CACHE.set(key, { at: Date.now(), items });
1548 if (REPO_CACHE.size > 60) REPO_CACHE.delete(REPO_CACHE.keys().next().value);
1549 draw(items);
1550 note.hidden = items.length > 0;
1551 note.textContent = items.length ? '' : 'Nothing found. Try other words.';
1552 } catch (err) { if (my === gen && !(err && err.name === 'AbortError')) { list.textContent = ''; note.hidden = false; note.textContent = (err && err.message) || 'Search is unavailable right now'; } }
1553 };
1554 // A third of a second after the last key, and never the same words twice in ten minutes (REPO_CACHE).
1555 field.addEventListener('input', () => { clearTimeout(t); t = setTimeout(search, 350); });
1556 field.addEventListener('keydown', (e) => {
1557 if (e.key === 'Escape') { e.preventDefault(); close(); }
1558 else if (e.key === 'Enter') { e.preventDefault(); const first = list.querySelector('.ovc-repo-row'); if (first) first.click(); else { clearTimeout(t); search(); } }
1559 else if (e.key === 'ArrowDown') { const first = list.querySelector('.ovc-repo-row'); if (first) { e.preventDefault(); first.focus(); } }
1560 });
1561 list.addEventListener('keydown', (e) => {
1562 const rows = [...list.querySelectorAll('.ovc-repo-row')]; const k = rows.indexOf(document.activeElement);
1563 if (e.key === 'ArrowDown' && k < rows.length - 1) { e.preventDefault(); rows[k + 1].focus(); }
1564 else if (e.key === 'ArrowUp') { e.preventDefault(); (k > 0 ? rows[k - 1] : field).focus(); }
1565 else if (e.key === 'Escape') { e.preventDefault(); close(); }
1566 });
1567 document.addEventListener('pointerdown', (e) => { if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) panel.hidden = true; });
1568 form.appendChild(panel);
1569 field.focus();
1570 }
1571 function tool(id, spec) {
1572 if (!/^[\w-]{1,30}$/.test(id || '') || !spec) return;
1573 TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);
1574 }
1575 const initial = (u) => String((u && (u.display_name || u.username)) || '?').trim().charAt(0).toUpperCase() || '?';
1576 const FRAME = 'https://openvibe.network/api/frame';
1577 const VERBS = { post: { icon: 'send', hint: 'to people' }, ask: { icon: 'spark', word: 'Ask', hint: 'AI answers you' }, do: { icon: 'bolt', word: 'Do', hint: 'Actor does it for you' } };
1578 const SOURCES = [['web', 'Web', 'globe'], ['openvibe', 'OpenVibe', 'layers'], ['mine', 'Mine', 'file'], ['page', 'This page', 'photo']];
1579 const choicesCache = new Map();
1580 // The Frame's model list for a task, in one shape whatever answered (the Frame's groups, or AI's own options).
1581 async function loadChoices(frame, task) {
1582 const key = `${frame}|${task}`;
1583 if (choicesCache.has(key)) return choicesCache.get(key);
1584 const job = fetch(`${frame}/choices?task=${encodeURIComponent(task)}`, { credentials: 'include', headers: { Accept: 'application/json' } })
1585 .then((r) => (r.ok ? r.json() : Promise.reject(new Error('Models are unavailable right now.'))))
1586 .then((d) => {
1587 if (Array.isArray(d.groups)) return d;
1588 const o = d.options || {};
1589 return { recommended: d.recommended, groups: [['openvibe', 'OpenVibe'], ['keys', 'Your keys'], ['machines', 'Your computers']].map(([id, title]) => ({ id, title, rows: o[id] || [] })) };
1590 });
1591 choicesCache.set(key, job);
1592 job.catch(() => choicesCache.delete(key));
1593 return job;
1594 }
1595 const money = (row) => {
1596 if (row.free || (row.price && row.price.in === 0) || row.price_estimate_usd === 0) return 'Free';
1597 if (row.price && row.price.in != null) return `$${Number(row.price.in).toFixed(row.price.in < 1 ? 2 : 1)} / M`;
1598 if (row.price_estimate_usd != null) return `about $${Number(row.price_estimate_usd).toFixed(3)}`;
1599 return '';
1600 };
1601 function mount(host, o = {}) {
1602 if (!host) return null;
1603 styles();
1604 const name = PRESET[o.preset] ? o.preset : 'post';
1605 const p = PRESET[name];
1606 const form = el('form', 'ovt-composer ovc-v2');
1607 form.dataset.preset = name;
1608 if (o.id) form.id = o.id;
1609 form.setAttribute('data-ovc', '');
1610 form.setAttribute('data-ovc-max-files', String(o.maxFiles || 10));
1611 form.setAttribute('data-ovc-max-mb', String(o.maxMb || (o.media ? 300 : 100)));
1612 if (o.media) { form.setAttribute('data-ovc-media', '1'); form.setAttribute('data-ovc-ids', o.idsName || 'attachments'); }
1613 if (o.noDraft) form.setAttribute('data-ovc-no-draft', '');
1614 if (o.noPlus) form.setAttribute('data-ovc-no-more', '');
1615 if (o.plus && typeof o.plus === 'object') form.dataset.ovcPlus = JSON.stringify(cleanPlus(o.plus));
1616 const resize = o.resize != null ? Number(o.resize) || 0 : p.resize || 0;
1617 if (resize) form.setAttribute('data-ovc-resize', String(resize));
1618 const want = (o.tools || p.tools).filter((t) => TOOL[t]);
1619 // 4.241.0: what the + shows as a tile is not also a button in the row (the owner, 11-13: "why twice"); a file
1620 // tool keeps its input in the form, hidden, so the files still go with it.
1621 const plusTiles = o.noPlus ? [] : (Object.assign({}, PLUS_DEFAULT, CONFIG.plus || {}, o.plus && typeof o.plus === 'object' ? cleanPlus(o.plus) : {}).tiles || []);
1622 const inPlus = (id) => plusTiles.includes(id) || (id === 'attach' && (plusTiles.includes('file') || plusTiles.includes('photo')));
1623 if (!want.includes('voice')) form.setAttribute('data-ovc-no-voice', '');
1624 // A post or a reply opens with one line: the person's picture (or first letter), their name and who sees it.
1625 const body = el('div', 'ovc-body');
1626 const head = el('div', 'ovc-head');
1627 if (p.avatar && o.user) {
1628 const src = o.user.avatar_url || o.user.avatar;
1629 const av = src ? el('img', 'ovc-av') : el('span', 'ovc-av');
1630 if (src) { av.src = src; av.alt = ''; av.loading = 'lazy'; } else av.textContent = initial(o.user);
1631 av.setAttribute('aria-hidden', 'true');
1632 // §Z.3: the name wears its effect, hat and badge (the account's subject, when it carries one).
1633 const nm = setSubject(el('span', 'ovc-head-n'), o.user); nm.textContent = String(o.user.display_name || o.user.username || '');
1634 head.append(av, nm);
1635 wearNames(head);
1636 }
1637 const ta = el('textarea');
1638 ta.name = o.name || 'text'; ta.rows = p.rows; ta.maxLength = o.maxLength || p.max;
1639 ta.placeholder = o.placeholder || (name === 'post' ? "What's new?" : name === 'agent' ? 'Ask for anything' : name === 'comment' ? 'Write a reply' : 'Write a message');
1640 ta.setAttribute('aria-label', o.label || ta.placeholder);
1641 { const ex = Array.isArray(o.examples) ? o.examples : (o.placeholder ? null : EXAMPLES[name]); if (ex && ex.length > 1) form.dataset.ovcExamples = JSON.stringify(ex.slice(0, 8)); }
1642 ta.setAttribute('data-ovt-text', '');
1643 if (o.required !== false) ta.required = true;
1644 const row = el('div', 'ovt-c-row'), tools = el('div', 'ovt-c-tools'), chips = el('div', 'ovc-chips');
1645 const ctxOf = () => (form.__ovc && form.__ovc.ctx) || null;
1646 for (const id of want) {
1647 const t = TOOL[id];
1648 if (t.builtin) continue;
1649 if (id === 'camera' && !(navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function')) continue;
1650 if (typeof t.run !== 'function' && t.accept === undefined) continue;
1651 const b = el(typeof t.run === 'function' ? 'button' : 'label', `ovt-btn ovc-t-${id}`, svg(t.path || IC.slash));
1652 b.title = t.label; b.setAttribute('aria-label', t.label);
1653 if (typeof t.run === 'function') { b.type = 'button'; b.addEventListener('click', () => { const c = ctxOf(); if (c) t.run(c, b, form); }); }
1654 else {
1655 const input = el('input'); input.type = 'file'; input.multiple = true; input.tabIndex = -1;
1656 const acc = o.accept != null && id !== 'photo' ? o.accept : t.accept;
1657 if (acc) input.accept = acc;
1658 input.setAttribute('aria-label', t.label);
1659 b.tabIndex = 0;
1660 b.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });
1661 b.appendChild(input);
1662 }
1663 if (inPlus(id)) { b.hidden = true; b.tabIndex = -1; }
1664 tools.appendChild(b);
1665 }
1666 // Option chips: who sees it (a post), how the agent works (agent). A select under a pill: the browser's own list.
1667 const chip = (spec, path) => {
1668 if (!spec || !Array.isArray(spec.options) || !spec.options.length) return null;
1669 const c = el('label', 'ovc-chip', svg(path));
1670 const v = el('span', 'ovc-chip-v');
1671 const sel = el('select'); sel.name = spec.name || 'option'; sel.setAttribute('aria-label', spec.label || 'Option');
1672 spec.options.forEach(([value, label]) => { const op = el('option'); op.value = value; op.textContent = label; sel.appendChild(op); });
1673 if (spec.value != null) sel.value = spec.value;
1674 const paint = () => { v.textContent = (sel.selectedOptions[0] && sel.selectedOptions[0].textContent) || ''; };
1675 sel.addEventListener('change', () => { paint(); if (typeof spec.onChange === 'function') spec.onChange(sel.value); });
1676 paint();
1677 c.append(v, sel);
1678 return c;
1679 };
1680 const aud = chip(o.audience, IC.globe), md = chip(o.mode, IC.spark);
1681 if (aud) chips.appendChild(aud);
1682 if (md) chips.appendChild(md);
1683 (Array.isArray(o.chips) ? o.chips : []).forEach((spec) => { const c = chip(spec, IC[spec && spec.icon] || IC.spark); if (c) chips.appendChild(c); });
1684 // The ring: how much of the limit is used; amber near it, red over it (the words stay; the send waits).
1685 let ring = null;
1686 if (p.ring) {
1687 ring = el('span', 'ovc-ring');
1688 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>';
1689 ring.hidden = true;
1690 }
1691 const send = el('button', p.send ? 'ovt-send' : 'ovt-send ovt-send--round', p.send ? '' : svg(IC.up));
1692 if (p.send) send.textContent = o.submitLabel || p.send;
1693 send.type = 'submit'; send.disabled = true; send.setAttribute('data-ovt-send', '');
1694 send.setAttribute('aria-label', o.submitLabel || p.send || 'Send');
1695 // 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
1696 // box keeps its chip beside the send.
1697 const above = name === 'post' || name === 'comment';
1698 // Post · Ask · Do: one switch at the start of the row (a site opts in with verbs).
1699 const verbs = (Array.isArray(o.verbs) ? o.verbs : []).filter((v) => VERBS[v]);
1700 let verb = verbs.length ? (verbs.includes(o.verb) ? o.verb : verbs[0]) : 'post';
1701 const frame = o.frameBase || FRAME;
1702 const askTask = (o.ask && o.ask.task) || (name === 'agent' ? 'agent' : 'chat');
1703 let model = null;
1704 try { model = JSON.parse(localStorage.getItem(`ov:composer:model:${askTask}`) || 'null'); } catch { model = null; }
1705 model = model || { model: 'openvibe/auto', source: 'openvibe', name: 'Auto' };
1706 const sources = new Set((o.ask && o.ask.sources) || ['web', 'openvibe']);
1707 let switcher = null;
1708 if (verbs.length > 1) {
1709 switcher = el('div', 'ovc-verbs'); switcher.setAttribute('role', 'group'); switcher.setAttribute('aria-label', 'What this box does');
1710 verbs.forEach((v) => {
1711 const word = v === 'post' ? (o.submitLabel || p.send || 'Send') : VERBS[v].word;
1712 const b = el('button', 'ovc-verb', svg(IC[v === 'post' ? (p.send ? 'send' : 'send') : VERBS[v].icon]) + `<span>${word}</span>`);
1713 b.type = 'button'; b.dataset.verb = v; b.title = `${word}: ${VERBS[v].hint}`; b.setAttribute('aria-label', `${word}: ${VERBS[v].hint}`);
1714 b.addEventListener('click', () => setVerb(v));
1715 switcher.appendChild(b);
1716 });
1717 if (above && head) { switcher.classList.add('ovc-verbs--head'); head.appendChild(switcher); } else row.appendChild(switcher);
1718 }
1719 const askChips = el('div', 'ovc-ask-chips'); askChips.setAttribute('role', 'group'); askChips.setAttribute('aria-label', 'How it is answered');
1720 const modelChip = el('button', 'ovc-chip ovc-chip--btn', `${svg(IC.model)}<span class="ovc-chip-v"></span>`);
1721 modelChip.type = 'button'; modelChip.setAttribute('aria-haspopup', 'dialog');
1722 const paintModel = () => { modelChip.querySelector('.ovc-chip-v').textContent = model.name || model.model; modelChip.setAttribute('aria-label', `Model: ${model.name || model.model}. Change it`); };
1723 const srcChip = el('button', 'ovc-chip ovc-chip--btn', `${svg(IC.globe)}<span class="ovc-chip-v"></span>`);
1724 srcChip.type = 'button'; srcChip.setAttribute('aria-haspopup', 'dialog');
1725 const paintSources = () => {
1726 const on = SOURCES.filter(([id]) => sources.has(id) && (id !== 'page' || (o.ask && o.ask.context)));
1727 srcChip.querySelector('.ovc-chip-v').textContent = on.length ? on.map(([, w]) => w).join(' · ') : 'No sources';
1728 srcChip.classList.toggle('is-on', sources.has('web'));
1729 srcChip.setAttribute('aria-label', `Sources: ${srcChip.textContent}. Change them`);
1730 };
1731 const depthChip = chip({ name: 'depth', label: 'Depth', options: [['quick', 'Quick'], ['balanced', 'Balanced'], ['deep', 'Deep research']], value: (o.ask && o.ask.depth) || 'balanced' }, IC.gauge);
1732 askChips.append(modelChip, srcChip, depthChip);
1733 paintModel(); paintSources();
1734 modelChip.addEventListener('click', () => openModels());
1735 srcChip.addEventListener('click', () => openSources());
1736 row.appendChild(tools);
1737 if (!above) row.appendChild(chips);
1738 if (ring) row.appendChild(ring);
1739 row.appendChild(send);
1740 if (above && chips.children.length) head.appendChild(chips);
1741 if (head.children.length) body.appendChild(head);
1742 // Ask and Do's chips: a line of their own above the words (scrolls sideways on a narrow screen, never wider).
1743 if (verbs.includes('ask') || verbs.includes('do')) body.appendChild(askChips);
1744 body.append(ta, row);
1745 const note = el('p', 'ovc-note'); note.setAttribute('data-ovt-note', ''); note.setAttribute('role', 'status');
1746 form.append(body, note);
1747 host.appendChild(form);
1748 if (o.upload || o.mentions || o.commands || o.beforeSend) {
1749 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 });
1750 }
1751 const api = enhance(form);
1752 // 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
1753 // them above the line it types in.
1754 if (above) { const strip = body.querySelector('.ovc-files'); if (strip) ta.after(strip); }
1755 if (ring) {
1756 const C = 62.83, fg = ring.querySelector('.ovc-ring-fg');
1757 const paint = () => {
1758 const used = ta.value.length / ta.maxLength;
1759 ring.hidden = !ta.value.length;
1760 fg.setAttribute('stroke-dashoffset', String(C * (1 - Math.min(1, used))));
1761 ring.classList.toggle('is-near', used >= 0.85 && used < 1);
1762 ring.classList.toggle('is-over', used >= 1);
1763 };
1764 ta.addEventListener('input', paint); paint();
1765 }
1766 const setBusy = (on) => { form.classList.toggle('is-busy', !!on); send.disabled = !!on || (!ta.value.trim() && !api.files().length); };
1767 const values = () => Object.fromEntries([...chips.querySelectorAll('select')].map((sel) => [sel.name, sel.value]));
1768 // While a run works the send is Stop (the agents portal's box): a click calls onStop, nothing is sent.
1769 let stopFn = null;
1770 const sendFace = send.innerHTML, sendLabel = send.getAttribute('aria-label');
1771 send.addEventListener('click', (e) => { if (!stopFn) return; e.preventDefault(); e.stopImmediatePropagation(); const f = stopFn; try { f(); } catch (err) { console.error(err); } }, true);
1772 const setRunning = (on, onStop) => {
1773 stopFn = on ? (typeof onStop === 'function' ? onStop : () => {}) : null;
1774 form.classList.toggle('is-running', !!on);
1775 if (on) { send.innerHTML = svg(IC.stop); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Stop'); send.title = 'Stop'; send.disabled = false; }
1776 else { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; api.refresh(); }
1777 };
1778 // One bar above the box at a time: "Editing your message" or "Replying to …" (openvibe.design rule 138: a
1779 // message's actions are a cluster at its corner, and what they start is said here, in the box).
1780 let bar = null, barClose = null, editing = null, replying = null;
1781 const clearBar = () => {
1782 const f = barClose; barClose = null;
1783 if (bar) { bar.remove(); bar = null; }
1784 form.classList.remove('is-editing', 'is-replying');
1785 if (f) f();
1786 };
1787 const showBar = (icon, title, detail, onClose, kind) => {
1788 clearBar();
1789 bar = el('div', 'ovc-bar', svg(icon));
1790 if (kind) bar.dataset.kind = kind;
1791 const t = el('b'); t.textContent = title;
1792 const d = el('span'); d.textContent = detail || '';
1793 const x = el('button', 'ovc-bar-x', svg(IC.x)); x.type = 'button'; x.title = 'Cancel'; x.setAttribute('aria-label', `Cancel: ${title}`);
1794 x.addEventListener('click', () => { clearBar(); ta.focus(); });
1795 bar.append(t, d, x);
1796 body.insertBefore(bar, head.parentNode === body ? head.nextSibling : body.firstChild);
1797 barClose = onClose;
1798 };
1799 const sendFaceOf = () => { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; };
1800 const edit = (spec = {}) => {
1801 const draft = editing ? editing.draft : ta.value;
1802 if (replying) clearBar();
1803 showBar(IC.pen, spec.label || 'Editing your message', 'Enter saves · Esc cancels', () => {
1804 const was = editing; editing = null;
1805 sendFaceOf();
1806 if (was && !was.saved) { ta.value = was.draft; ta.dispatchEvent(new Event('input', { bubbles: true })); if (typeof was.onCancel === 'function') was.onCancel(); }
1807 api.refresh();
1808 }, 'edit');
1809 editing = { draft, onSave: spec.onSave, onCancel: spec.onCancel, saved: false };
1810 form.classList.add('is-editing');
1811 ta.value = String(spec.text || '');
1812 ta.dispatchEvent(new Event('input', { bubbles: true }));
1813 send.innerHTML = svg(IC.check); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Save'); send.title = 'Save';
1814 api.refresh();
1815 ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length;
1816 };
1817 const reply = (spec = {}) => {
1818 if (editing) clearBar();
1819 const who = String(spec.name || 'someone');
1820 showBar(IC.reply, `Replying to ${who}`, String(spec.text || '').slice(0, 140), () => { const was = replying; replying = null; if (was && typeof was.onClear === 'function') was.onClear(); }, 'reply');
1821 replying = { data: spec.data !== undefined ? spec.data : { name: who, text: spec.text }, onClear: spec.onClear };
1822 form.classList.add('is-replying');
1823 ta.focus();
1824 };
1825 // 4.236.0: any context above the words, inside the box (a quote, a file being answered, a poll being set up):
1826 // bar({ icon: an SVG path, title, detail, kind, onClose }). One bar at a time, as edit and reply.
1827 const contextBar = (spec = {}) => {
1828 if (editing || replying) clearBar();
1829 showBar(PATH_OK.test(spec.icon || '') ? spec.icon : IC.reply, String(spec.title || ''), String(spec.detail || '').slice(0, 140), typeof spec.onClose === 'function' ? spec.onClose : null, String(spec.kind || 'context').slice(0, 20));
1830 };
1831 ta.addEventListener('keydown', (e) => { if (e.key === 'Escape' && bar) { e.preventDefault(); e.stopPropagation(); clearBar(); } });
1832 // ── Post · Ask · Do ──
1833 const postFace = send.innerHTML, postLabel = send.getAttribute('aria-label'), postRound = send.classList.contains('ovt-send--round');
1834 function setVerb(v) {
1835 if (!VERBS[v] || (verbs.length && !verbs.includes(v))) return;
1836 verb = v;
1837 form.dataset.verb = v;
1838 form.classList.toggle('is-asking', v !== 'post');
1839 if (switcher) switcher.querySelectorAll('.ovc-verb').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.verb === v)));
1840 if (v === 'post') { send.innerHTML = postFace; send.classList.toggle('ovt-send--round', postRound); send.setAttribute('aria-label', postLabel); }
1841 else { send.innerHTML = svg(IC[VERBS[v].icon]); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', VERBS[v].word); }
1842 ta.placeholder = v === 'ask' ? 'Ask anything' : v === 'do' ? 'Tell Actor what to do' : (o.placeholder || ta.dataset.ovcPlaceholder || ta.placeholder);
1843 api.refresh();
1844 }
1845 ta.dataset.ovcPlaceholder = ta.placeholder;
1846 // A sheet (the whole width on a phone) for the model list and the sources.
1847 function sheet(title, build) {
1848 const dlg = el('dialog', 'ovc-sheet'); dlg.setAttribute('aria-label', title);
1849 dlg.innerHTML = `<div class="ovc-sheet-h"><b></b><button type="button" class="ovc-bar-x" aria-label="Close">${svg(IC.x)}</button></div><div class="ovc-sheet-b"></div>`;
1850 dlg.querySelector('b').textContent = title;
1851 const close = () => { try { dlg.close(); } catch { /* closed */ } dlg.remove(); };
1852 dlg.querySelector('.ovc-bar-x').addEventListener('click', close);
1853 dlg.addEventListener('click', (e) => { if (e.target === dlg) close(); });
1854 dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(); });
1855 document.body.appendChild(dlg);
1856 build(dlg.querySelector('.ovc-sheet-b'), close);
1857 if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
1858 return dlg;
1859 }
1860 function openModels() {
1861 sheet('Model', async (b, close) => {
1862 const find = el('input', 'ovc-sheet-find'); find.type = 'search'; find.placeholder = 'Find a model'; find.setAttribute('aria-label', 'Find a model');
1863 const list = el('div', 'ovc-models');
1864 b.append(find, list);
1865 const pick = (row) => {
1866 model = { model: row.model, source: row.source || 'openvibe', ...(row.machine ? { machine: row.machine } : {}), name: row.name || row.model };
1867 try { localStorage.setItem(`ov:composer:model:${askTask}`, JSON.stringify(model)); } catch { /* private mode */ }
1868 paintModel(); close(); ta.focus();
1869 };
1870 const rowEl = (row, rec) => {
1871 const r = el('div', 'ovc-model');
1872 const btn = el('button', 'ovc-model-b'); btn.type = 'button';
1873 btn.setAttribute('aria-pressed', String(row.model === model.model && (row.source || 'openvibe') === (model.source || 'openvibe')));
1874 const mark = el('span', 'ovc-model-m'); mark.textContent = String(row.maker || row.name || row.model || '?').trim().charAt(0).toUpperCase();
1875 const t = el('span', 'ovc-model-t'); const line = el('span', 'ovc-model-n'); const n = el('b'); n.textContent = row.name || row.model; line.appendChild(n); const d = el('small');
1876 d.textContent = [money(row), row.speed ? `${row.speed}` : '', row.maker || ''].filter(Boolean).join(' · ') || row.detail || '';
1877 if (rec) { const badge = el('em', 'ovc-model-rec'); badge.textContent = 'Recommended'; line.appendChild(badge); }
1878 t.append(line, d); btn.append(mark, t);
1879 btn.addEventListener('click', () => pick(row));
1880 r.appendChild(btn);
1881 if (row.url) { const a = el('a', 'ovc-model-x', svg(IC.ext)); a.href = row.url; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Compare on OpenVibe.Exchange'; a.setAttribute('aria-label', `${row.name || row.model} on OpenVibe.Exchange`); r.appendChild(a); }
1882 r.dataset.find = `${row.name || ''} ${row.model || ''} ${row.maker || ''}`.toLowerCase();
1883 return r;
1884 };
1885 const auto = { model: 'openvibe/auto', source: 'openvibe', name: 'Auto', detail: 'OpenVibe picks a model for each question' };
1886 list.appendChild(rowEl(auto));
1887 const wait = el('p', 'ovc-sheet-note'); wait.textContent = 'Loading the models…'; list.appendChild(wait);
1888 try {
1889 const data = await loadChoices(frame, askTask);
1890 wait.remove();
1891 const rec = data.recommended;
1892 (data.groups || []).forEach((g) => {
1893 if (!g.rows || !g.rows.length) return;
1894 const h = el('h3'); h.textContent = g.title || g.id; list.appendChild(h);
1895 g.rows.forEach((row) => list.appendChild(rowEl(row, rec && rec.model === row.model && (rec.source || 'openvibe') === (row.source || 'openvibe'))));
1896 });
1897 const more = el('a', 'ovc-sheet-more', `Every model, compared, on OpenVibe.Exchange ${svg(IC.ext)}`); more.href = 'https://openvibe.exchange/models'; more.target = '_blank'; more.rel = 'noopener';
1898 list.appendChild(more);
1899 } catch (err) { wait.textContent = (err && err.message) || 'Models are unavailable right now.'; }
1900 find.addEventListener('input', () => {
1901 const q = find.value.trim().toLowerCase();
1902 list.querySelectorAll('.ovc-model').forEach((r) => { r.hidden = !!q && !r.dataset.find.includes(q); });
1903 });
1904 find.focus();
1905 });
1906 }
1907 function openSources() {
1908 sheet('What the answer may read', (b) => {
1909 SOURCES.forEach(([id, word, icon]) => {
1910 if (id === 'page' && !(o.ask && o.ask.context)) return;
1911 const lab = el('label', 'ovc-src', `${svg(IC[icon])}<span><b></b><small></small></span>`);
1912 lab.querySelector('b').textContent = word;
1913 lab.querySelector('small').textContent = id === 'web' ? 'Search the web (OpenVibe Search, then other engines)' : id === 'openvibe' ? 'Posts, streams, videos, wiki and pastes on OpenVibe' : id === 'mine' ? 'Your own posts, files and chats' : `This ${(o.ask.context && o.ask.context.kind) || 'page'}`;
1914 const box = el('input'); box.type = 'checkbox'; box.checked = sources.has(id); box.setAttribute('role', 'switch');
1915 box.addEventListener('change', () => { if (box.checked) sources.add(id); else sources.delete(id); paintSources(); });
1916 lab.appendChild(box);
1917 b.appendChild(lab);
1918 });
1919 });
1920 }
1921 // The answer (or the run) under the box: streamed text, its sources, the model and three ways on.
1922 let card = null;
1923 function answer(question) {
1924 if (card) card.remove();
1925 card = el('section', 'ovc-answer'); card.setAttribute('aria-live', 'polite');
1926 card.innerHTML = `<header>${svg(IC[verb === 'do' ? 'bolt' : 'spark'])}<b></b><small></small><button type="button" class="ovc-bar-x" aria-label="Close the answer">${svg(IC.x)}</button></header><div class="ovc-answer-t"></div><ol class="ovc-cites" hidden></ol><footer hidden></footer>`;
1927 card.querySelector('b').textContent = verb === 'do' ? 'Actor' : 'Answer';
1928 card.querySelector('small').textContent = model.name || '';
1929 const body = card.querySelector('.ovc-answer-t'), cites = card.querySelector('.ovc-cites'), foot = card.querySelector('footer');
1930 body.textContent = verb === 'do' ? 'Planning…' : 'Thinking…'; body.classList.add('is-waiting');
1931 card.querySelector('.ovc-bar-x').addEventListener('click', () => { card.remove(); card = null; });
1932 form.after(card);
1933 let started = false;
1934 const ctl = {
1935 el: card,
1936 append(delta) { if (!started) { body.textContent = ''; body.classList.remove('is-waiting'); started = true; } body.textContent += String(delta || ''); },
1937 set(t) { started = true; body.classList.remove('is-waiting'); body.textContent = String(t || ''); },
1938 cite(list) {
1939 cites.replaceChildren();
1940 (list || []).forEach((c, i) => {
1941 const li = el('li'); const a = el('a'); a.href = c.url; a.target = '_blank'; a.rel = 'noopener nofollow';
1942 a.textContent = `${c.n || i + 1}. ${c.title || c.site || c.url}`; if (c.site) a.title = c.site;
1943 li.appendChild(a); cites.appendChild(li);
1944 });
1945 cites.hidden = !cites.children.length;
1946 },
1947 done(meta = {}) {
1948 if (meta.answer != null) ctl.set(meta.answer);
1949 if (meta.citations) ctl.cite(meta.citations);
1950 if (meta.cost != null) card.querySelector('small').textContent = `${model.name || ''} · $${Number(meta.cost).toFixed(3)}`;
1951 foot.replaceChildren();
1952 const act = (label, fn) => { const b = el('button', 'ovc-answer-a'); b.type = 'button'; b.textContent = label; b.addEventListener('click', fn); foot.appendChild(b); };
1953 if (verbs.includes('post')) act(name === 'chat' ? 'Send it' : name === 'comment' ? 'Reply with it' : 'Post it', () => { setVerb('post'); ta.value = body.textContent; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); });
1954 act('Continue in Vibe', () => { form.dispatchEvent(new CustomEvent('ovc:continue', { bubbles: true, detail: { run: meta.run, question } })); if (root.OVDock && typeof root.OVDock.open === 'function') root.OVDock.open('vibe', { run: meta.run, text: question }); });
1955 if (verb === 'ask' && verbs.includes('do')) act('Do this', () => { setVerb('do'); ta.value = question; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); });
1956 foot.hidden = false;
1957 },
1958 fail(msg) { started = true; body.classList.remove('is-waiting'); body.classList.add('is-bad'); body.textContent = String(msg || 'That did not work. Try again.'); },
1959 };
1960 return ctl;
1961 }
1962 // Ask or Do through the Frame: start the run, then follow its events (AG-UI) into the card.
1963 async function viaFrame(value, ctl) {
1964 const r = await fetch(`${frame}/${value.verb}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(value) });
1965 const d = await r.json().catch(() => ({}));
1966 if (!r.ok || !d.run) throw new Error(d.detail || d.error || (r.status === 401 ? 'Sign in to ask.' : 'Could not ask right now.'));
1967 const events = d.events || `${frame.replace(/\/api\/frame\/?$/, '')}/api/v1/runs/${encodeURIComponent(d.run)}/events`;
1968 if (typeof EventSource !== 'function') return;
1969 await new Promise((resolve) => {
1970 const es = new EventSource(events, { withCredentials: true });
1971 const end = () => { es.close(); resolve(); };
1972 es.onmessage = (m) => {
1973 let ev = null; try { ev = JSON.parse(m.data); } catch { return; }
1974 if (ev.type === 'TEXT_MESSAGE_CONTENT') ctl.append(ev.delta);
1975 else if (ev.type === 'CUSTOM' && ev.name === 'citations') ctl.cite(ev.value);
1976 else if (ev.type === 'RUN_FINISHED') { ctl.done({ run: d.run, ...(ev.result || {}) }); end(); }
1977 else if (ev.type === 'RUN_ERROR') { ctl.fail(ev.message); end(); }
1978 };
1979 es.onerror = () => { if (es.readyState === 2) { ctl.fail('The answer stopped. Try again.'); end(); } };
1980 });
1981 }
1982 form.addEventListener('submit', async (e) => {
1983 if (verb === 'post' || editing) return;
1984 e.preventDefault(); e.stopImmediatePropagation();
1985 if (form.classList.contains('is-busy')) return;
1986 const text = ta.value.trim();
1987 if (!text && !api.files().length) return;
1988 const value = { verb, text, files: api.files(), uploads: api.uploads(), model, sources: [...sources], depth: depthChip ? depthChip.querySelector('select').value : 'balanced', context: (o.ask && o.ask.context) || null, chips: values() };
1989 const ctl = answer(text);
1990 setBusy(true);
1991 try {
1992 const own = verb === 'ask' ? o.onAsk : o.onDo;
1993 if (typeof own === 'function') await own(value, Object.assign(api, { card: ctl }));
1994 else await viaFrame({ ...value, files: undefined }, ctl);
1995 api.clear();
1996 } catch (err) { ctl.fail((err && err.message) || String(err)); }
1997 setBusy(false);
1998 api.refresh();
1999 }, true);
2000 if (verbs.length) setVerb(verb);
2001
2002 // Saving an edit is not a send: it runs first and nothing else hears the submit.
2003 form.addEventListener('submit', async (e) => {
2004 if (!editing) return;
2005 e.preventDefault(); e.stopImmediatePropagation();
2006 if (form.classList.contains('is-busy')) return;
2007 const text = ta.value.trim(), ed = editing;
2008 if (!text) { api.ctx.note('A message can\'t be empty.'); return; }
2009 setBusy(true);
2010 let keep = false;
2011 try { keep = typeof ed.onSave === 'function' ? (await ed.onSave(text)) === false : false; }
2012 catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }
2013 setBusy(false);
2014 if (!keep && editing === ed) { ed.saved = true; ta.value = ed.draft; ta.dispatchEvent(new Event('input', { bubbles: true })); clearBar(); }
2015 api.refresh();
2016 }, true);
2017 if (typeof o.onSubmit === 'function') {
2018 form.addEventListener('submit', async (e) => {
2019 e.preventDefault();
2020 if (form.classList.contains('is-busy')) return;
2021 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(), reply: replying ? replying.data : null };
2022 setBusy(true);
2023 let keep = false;
2024 try { keep = (await o.onSubmit(value, api)) === false; }
2025 catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }
2026 setBusy(false);
2027 if (!keep) { api.clear(); if (replying) clearBar(); }
2028 api.refresh();
2029 });
2030 }
2031 return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, bar: contextBar, clearBar, editing: () => !!editing,
2032 verb: () => verb, setVerb, model: () => model, sources: () => [...sources], answer });
2033 }
2034
2035 // ── Your settings, in a sheet: what sends, voice, compact, your own commands.
2036 let sheet = null;
2037 function openSettings(from) {
2038 const opener = from || document.activeElement;
2039 needWindow().then((W) => settingsIn(W, opener));
2040 }
2041 function settingsIn(W, opener) {
2042 let win = null;
2043 if (!W && !sheet) {
2044 sheet = el('dialog', 'ovc-set');
2045 sheet.setAttribute('aria-label', 'Your composer');
2046 sheet.addEventListener('click', (e) => { if (e.target === sheet) sheet.close(); });
2047 document.body.appendChild(sheet);
2048 }
2049 const k = mac ? '⌘' : 'Ctrl';
2050 const host = W ? el('div', 'ovc-set ovc-set-in') : sheet;
2051 host.innerHTML = `<form method="dialog" class="ovc-set-f">${W ? '' : `<header class="ovc-set-h"><b>Your composer</b><button class="ovt-btn" value="done" aria-label="Done">${svg(IC.x)}</button></header>`}
2052<fieldset class="ovc-set-g"><legend>Send with</legend><label><input type="radio" name="send_key" value="enter"> Enter <small>(Shift+Enter for a new line)</small></label><label><input type="radio" name="send_key" value="mod-enter"> ${k}+Enter <small>(Enter for a new line)</small></label></fieldset>
2053<label class="ovc-set-c"><input type="checkbox" name="voice"> A microphone button${Speech ? '' : ' <small>(this browser cannot listen)</small>'}</label>
2054<label class="ovc-set-c"><input type="checkbox" name="compact"> A compact box</label>
2055<fieldset class="ovc-set-g"><legend>Your commands</legend><p class="ovc-set-p">Type / and the word, and its text goes in. Up to 50.</p><ul class="ovc-set-l"></ul>
2056<div class="ovc-set-add"><input name="trigger" placeholder="word" maxlength="30" pattern="[A-Za-z0-9_-]+" aria-label="The word after /"><textarea name="snippet" rows="2" maxlength="4000" placeholder="The text it puts in" aria-label="The text it puts in"></textarea><button type="button" class="ovc-set-btn" data-ovc-add>Add</button></div></fieldset>
2057<p class="ovc-set-p">${synced ? 'Saved to your account: the same on every OpenVibe site.' : 'Saved in this browser.'}</p></form>`;
2058 const f = host.querySelector('form');
2059 f.send_key.value = prefs.send_key;
2060 f.voice.checked = !!prefs.voice; f.voice.disabled = !Speech;
2061 f.compact.checked = prefs.density === 'compact';
2062 const list = host.querySelector('.ovc-set-l');
2063 const paintList = () => {
2064 list.innerHTML = '';
2065 (prefs.snippets || []).forEach((s, i) => {
2066 const li = el('li');
2067 const w = el('b'); w.textContent = `/${s.trigger}`;
2068 const t = el('span'); t.textContent = s.text.slice(0, 80);
2069 const x = el('button', 'ovt-btn', svg(IC.x)); x.type = 'button'; x.setAttribute('aria-label', `Remove /${s.trigger}`);
2070 x.addEventListener('click', () => { const next = prefs.snippets.slice(); next.splice(i, 1); setPrefs({ snippets: next }); paintList(); });
2071 li.append(w, t, x); list.appendChild(li);
2072 });
2073 };
2074 paintList();
2075 f.addEventListener('change', (e) => {
2076 if (e.target.name === 'send_key') setPrefs({ send_key: f.send_key.value });
2077 else if (e.target.name === 'voice') setPrefs({ voice: f.voice.checked });
2078 else if (e.target.name === 'compact') setPrefs({ density: f.compact.checked ? 'compact' : 'comfy' });
2079 });
2080 host.querySelector('[data-ovc-add]').addEventListener('click', () => {
2081 const w = f.trigger.value.trim().replace(/^\//, ''), t = f.snippet.value;
2082 if (!/^[\w-]{1,30}$/.test(w)) { f.trigger.focus(); return; }
2083 if (!t.trim()) { f.snippet.focus(); return; }
2084 setPrefs({ snippets: (prefs.snippets || []).filter((s) => s.trigger !== w).concat([{ trigger: w, text: t }]) });
2085 f.trigger.value = ''; f.snippet.value = '';
2086 paintList(); f.trigger.focus();
2087 });
2088 f.addEventListener('submit', (e) => e.preventDefault());
2089 if (W) win = W.open({ id: 'ov-composer-settings', title: 'Your composer', body: host, kind: 'dialog', size: 'sm', from: opener, focus: 'input[name=send_key]:checked' });
2090 else { if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', ''); const first = f.querySelector('input[name=send_key]:checked'); if (first) first.focus(); }
2091 return win;
2092 }
2093
2094 function mod(m) {
2095 if (!m || !m.id) return;
2096 const i = mods.findIndex((x) => x.id === m.id);
2097 if (i >= 0) mods[i] = m; else mods.push(m);
2098 live.forEach((c) => c.refresh());
2099 }
2100 function scan(scope) { (scope || document).querySelectorAll('[data-ovt-composer], [data-ovc]').forEach((f) => enhance(f)); }
2101
2102 root.OpenVibeComposer = { enhance, mount, tool, mod, scan, configure, presets: () => Object.keys(PRESET), tools: () => Object.keys(TOOL), settings: openSettings, get prefs() { return Object.assign({}, prefs); }, setPrefs };
2103 if (typeof module === 'object' && module.exports) module.exports = { enhance, mount, tool, mod, scan, configure };
2104 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();
2105 document.addEventListener('ov:boost:load', () => { live.forEach((c) => { if (!c.form.isConnected) c.destroy(); }); scan(); });
2106 pull();
2107})(typeof window !== 'undefined' ? window : globalThis);
2108