openvibers/openvibe.shared ov-composer.js
ov-composer.js
| 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 | ; |
| 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; } } |
| 74 | html[data-ov-motion=reduced] .ovc-file-ai.is-busy svg { animation-duration: 2.4s; } |
| 75 | html[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); } } |
| 102 | html[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); } |
| 185 | html[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 */ |
| 202 | html[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; } } |
| 222 | html[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); } } |
| 301 | html[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; } |
| 356 | html[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; } } |
| 360 | html[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). */ |
| 391 | html[data-ov-motion=reduced] .ovc-more[aria-expanded="true"] .ovc-plus-p circle { animation-duration: .9s; } |
| 392 | html[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; } |
| 393 | html[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; } |
| 394 | html[data-ov-motion=reduced] :is(.ovc-plus-x, .ovc-plus-x path) { transition-timing-function: ease-out; } |
| 395 | html[data-ov-motion=off] :is(.ovc-plus-x, .ovc-plus-x path, .ovc-plus-p circle) { transition: none; animation: none !important; } |
| 396 | html[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)); } |
| 452 | dialog.ovc-add { padding: 0 14px calc(18px + env(safe-area-inset-bottom)); max-height: 88vh; overflow: auto; transition: transform .28s cubic-bezier(.2, .9, .3, 1); animation: ovc-add-up .32s cubic-bezier(.2, .9, .3, 1.05) both; } |
| 453 | dialog.ovc-add::backdrop { background: rgba(2, 6, 14, .55); animation: ovc-fade-calm .25s ease both; } |
| 454 | dialog.ovc-add.is-out { transform: translateY(110%); } |
| 455 | dialog.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; } |
| 462 | html[data-ov-motion=reduced] :is(.ovc-add-tile, .ovc-add-row) { animation-name: ovc-fade-calm; animation-delay: 0s; } |
| 463 | html[data-ov-motion=reduced] dialog.ovc-add { animation-name: ovc-fade-calm; } |
| 464 | html[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); } } |
| 466 | html[data-ov-motion=reduced] .ovc-tray { animation-name: ovc-fade-calm; } |
| 467 | @keyframes ovc-fade-calm { from { opacity: 0; } } |
| 468 | html[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); } |
| 483 | html[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 |