openvibers/openvibe.shared Commits
4.114.0: one Share sheet on every site (ov-share.js) (#295)
Changes
@@@ -1,5 +1,33 @@# Changelog++## 4.114.0 — 2026-11-06++++- **One Share sheet on every site** (`ov-share.js`, `ov-share.css`; plan track Q; docs/share.md). Press Share++ anywhere and the same sheet opens: a sheet from the bottom on a phone, a dialog on a computer, no tabs.++ - **Send it** (everyone): the link with Copy, which turns into a tick; on a phone, "Send with your phone" (the++ phone's own share sheet, with its contacts and apps); 16 places (X, Bluesky, WhatsApp, Reddit, Discord, Telegram,++ Threads, Mastodon, Facebook, email, Messages, LinkedIn, Pinterest, Tumblr, Hacker News, LINE), recent ones first,++ the rest behind "More (n)"; a QR code made on the device; "Start at 1:23"; embed code. Nothing is added to a++ shared link.++ - **Who can open it** (the owner, through `openvibe-sdk/share`'s router): Everyone, Anyone with the link, Specific++ people (by @name, checked as you add them; Can view or Can edit; an end date), Only me; people who asked;++ transfer of ownership, bound to the recipient, "Remove my access" by default. Saves send If-Match. A 401, 403 or++ 404 leaves only Send it.++ - `OVShare.offer()` is the recipient's Accept/Decline banner; `OVShare.gate()` is the not-on-the-list page (Ask++ for access, or Sign in). Every name and title is text, never markup.++ - The navbar loads it on the first press of a `[data-ov-share-sheet]` button, so nothing loads before that. The++ kit's `[data-ovk-share]` items open it too.++ - Events: `ov-share:sent` `{ target, url }` and `ov-share:change`.++ - **A Share component** in OpenVibe Design (`openvibe-shared/components`, `kit.share({ title, url, kind, id })`): a++ kit document's `{ type: 'share' }` renders the button, and the spec gives its anatomy, states, keys, and the++ Android (Intent chooser with Direct Share) and iOS (`ShareLink`) views, so native apps share the same way.++ - **`ov-qr.js`**: a QR code from a link, made on the device with no dependency and no network (a Locker link's key++ stays on the device). `OVQR.matrix(text, { ecc })` and `OVQR.svg(text, { ecc, margin, dark, light, title })`;++ byte mode, versions 1 to 40, all eight masks. Ported from Nayuki's QR Code generator (MIT); checked module by++ module against bwip-js on 1,196 matrices (test/ov-qr.test.js). The sheet loads it on the first QR code.++ - **Brand logos**: Tumblr, Hacker News and LINE join brands.js (49 brands, Simple Icons 16.36.0, CC0).++ - Tests: test/ov-share-chrome.test.js (headless Chrome).++## 4.113.0 — 2026-11-06- **Bot API calls can be counted at ingest, not stored row by row.** `AnalyticsTrackerPg` takes@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.113.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.114.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -902,6 +902,19 @@ so forms, `select.value` and `change` listeners work as before. An option can saswitch; `.ov-field`, `.ov-input` and `.ov-choice` are the full field look. Turn it off with `data-ov-native` or`<html data-ov-forms="off">`. See [docs/forms.md](docs/forms.md).++### Share (`ov-share.js`, `ov-share.css`)++++One Share sheet on every site (plan track Q): a sheet from the bottom on a phone, a dialog on a computer.++- **Send it, for everyone:** the link and Copy; the phone's own share sheet (contacts, messages, apps); X, Bluesky,++ WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, email, Messages, LinkedIn, Pinterest, Tumblr,++ Hacker News and LINE; a QR code made on the device; Start at 1:23; embed code.++- **Who can open it, for the owner:** Everyone, Anyone with the link, Specific people by @name (Can view or Can edit,++ with an end date) and Only me; access requests; transfer of ownership with the recipient's say-so.++++Put `<button data-ov-share-sheet data-title="…">Share</button>` on a page: the navbar loads the sheet on the first++press. Add `data-kind` and `data-id` where the service mounts `openvibe-sdk/share`'s router. See++[docs/share.md](docs/share.md).++## Development```bash@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.113.0",++ "version": "4.114.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -505,6 +505,49 @@ fun OvNotice(text: String, kind: OvNoticeKind = OvNoticeKind.Info) {.background((isError ? OvTokens.colorStatusBadText : OvTokens.colorStatusInfoText).opacity(0.12), in: .rect(cornerRadius: OvScale.radiusSm)).accessibilityAddTraits(isError ? [.updatesFrequently] : [])}++}`,++ },++ },++ {++ id: 'share', name: 'Share', web: 'kit.share',++ summary: 'The one Share button and the sheet it opens, on every site: send the link (copy, the phone\'s own share sheet, the places people post, a QR code, embed, start at a time) and, for its owner, who can open it.',++ role: 'button that opens a modal sheet',++ children: null,++ anatomy: [++ { part: 'button', tokens: { minHeight: 'size.tap', weight: 'type.weight.bold' }, note: 'Share, with the up-from-a-tray icon' },++ { part: 'sheet', tokens: { surface: 'color.bg.raised', radius: 'radius.md', padding: 'space.lg' }, note: 'from the bottom on a phone, a 520 px dialog on a computer; Escape, the backdrop and Done close it' },++ { part: 'link', tokens: { surface: 'color.bg.hover', text: 'color.text.primary', action: 'color.accent.strong', onAction: 'color.accent.onStrong' }, note: 'the link without https:// and Copy, which turns into a tick' },++ { part: 'device', optional: true, tokens: { minHeight: 'size.tap' }, note: 'phones: "Send with your phone", the platform\'s own sheet with its contacts' },++ { part: 'places', tokens: { size: 'size.avatar', label: 'type.size.meta' }, note: 'round brand tiles, the person\'s recent ones first, the rest behind More (n)' },++ { part: 'panel', optional: true, tokens: { line: 'color.line.base' }, note: 'QR code, embed code or a Mastodon server, under the places' },++ { part: 'access', optional: true, tokens: { muted: 'color.text.muted' }, note: 'the owner only: Everyone · Anyone with the link · Specific people · Only me, the people, Transfer' },++ ],++ props: { title: { type: 'string', required: true }, url: { type: 'url' }, text: { type: 'string' }, image: { type: 'url' }, embed: { type: 'url' }, kind: { type: 'string' }, id: { type: 'string' }, label: { type: 'string' }, small: { type: 'boolean' } },++ states: ['closed', 'open', 'copied', 'panel open', 'saving', 'not the owner (send only)'],++ a11y: { name: '"Share", and the sheet is named "Share <title>"', keys: 'Enter opens; Tab moves; Escape closes (asking first when there are unsaved changes)', minTarget: '44 px' },++ layout: { phone: 'a sheet from the bottom, the home bar cleared, Send with your phone first, four places a row', computer: 'a dialog, six places a row' },++ native: {++ compose: `@Composable++fun OvShare(title: String, url: String, label: String = "Share", onShare: (title: String, url: String) -> Unit) {++ // The app hands the link to the platform's own sheet, with Direct Share's contacts. On Android:++ // context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { type = "text/plain"; putExtra(Intent.EXTRA_TEXT, url) }, title))++ OutlinedButton(onClick = { onShare(title, url) }, modifier = Modifier.heightIn(min = OvScale.SizeTap), shape = RoundedCornerShape(OvScale.RadiusPill)) {++ Icon(Icons.Default.Share, contentDescription = null)++ Spacer(Modifier.width(OvScale.SpaceSm))++ Text(label, fontWeight = FontWeight.Bold)++ }++}`,++ swiftui: `struct OvShare: View {++ let title: String++ let url: URL++ var label = "Share"++ var body: some View {++ // ShareLink is the system sheet (contacts, Messages, AirDrop, apps); the OpenVibe sheet adds QR, embed and access.++ ShareLink(item: url, subject: Text(title), message: Text(title)) {++ Label(label, systemImage: "square.and.arrow.up").fontWeight(.bold).frame(minHeight: OvScale.sizeTap)++ }++ .buttonStyle(.bordered).clipShape(RoundedRectangle(cornerRadius: OvScale.radiusMd))++ }}`,},},@@@ -634,6 +677,7 @@ enum OvKind { case primary, quiet, danger }`,case 'sidebar': return kit.sidebar({ id: p.id || 'ovk-sidebar', label: p.label || 'Navigation', action: p.action ? { href: p.action.href, label: p.action.label || 'New' } : null, nav: p.nav.map((x) => ({ href: x.href, label: x.label, icon: x.icon || 'chevron', here: !!x.here, count: x.count == null ? null : Number(x.count) })) });case 'stats': return kit.stats(p.items, { label: p.label || 'In numbers' });case 'notice': return kit.notice({ kind: p.kind || 'info', text: p.text });++ case 'share': return kit.share({ title: p.title, url: p.url || null, text: p.text || '', image: p.image || null, embed: p.embed || null, kind: p.kind || null, id: p.id || null, label: p.label || 'Share', small: !!p.small });case 'empty': return kit.empty({ title: p.title, text: p.text || '', icon: p.icon || '', action: p.action ? { href: p.action.href, label: p.action.label || 'Go' } : null });default: return '';}nnew file mode 100644@@@ -0,0 +1,97 @@++# Sharing: one Share sheet on every site (`ov-share.js`, `ov-share.css`)++++Plan track Q. In the owner's words (2026-11-06):++- share a thing with named accounts only, and give it away with the recipient's say-so;++- "for everything else too … a more universal system";++- "a unified share system … more platforms … easy copying of a link … the phone's own share thing … intuitive,++ user-friendly".++++Press Share on any OpenVibe site and the same sheet opens. It is a sheet from the bottom on a phone and a dialog on a++computer. It has two parts and no tabs:++++| Part | Who sees it | What is in it |++|---|---|---|++| **Send it** | everyone | the link and **Copy**; on a phone, **Send with your phone** (the phone's own share sheet: contacts, messages, every app); X, Bluesky, WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, Email, Messages (phones), LinkedIn, Pinterest (with a picture), Tumblr, Hacker News, LINE; **QR code**; **Embed**; **Start at 1:23** for a video or a recording |++| **Who can open it** | the owner (and staff) of a thing with a share API | Everyone · Anyone with the link · Specific people · Only me; people by @name, each Can view or Can edit, with an end date; people who asked; **Transfer** |++++The first eight places show at once, with the person's three most recent first. The rest are behind a counted fold++(**More (n)**), never hidden.++++## On a page++++```html++<!-- Send only: the page's address, or data-url -->++<button type="button" data-ov-share-sheet data-title="Building a robot that waves back"++ data-url="https://openvibe.video/v/abc123" data-time-from="#player"++ data-embed="https://openvibe.video/embed/abc123" data-image="https://openvibe.media/o/…">Share</button>++++<!-- Also "Who can open it", for its owner: the service mounts openvibe-sdk/share's router -->++<button type="button" data-ov-share-sheet data-kind="paste" data-id="abc" data-title="LOOI bridge notes">Share</button>++```++++The data attributes:++- **What to share.**++ - `data-url`: the link (else the page's address).++ - `data-title`: the title.++ - `data-text`: a line sent with it (email, the phone's sheet).++- **Extras.**++ - `data-image`: a picture (Pinterest).++ - `data-embed`: an embeddable player (the Embed tile).++ - `data-time-from`: a `<video>` or `<audio>` whose current time offers "Start at …". `data-time` gives a fixed++ time, and `data-time-param` names the parameter (default `t`).++- **Who can open it.**++ - `data-kind` and `data-id` add that part.++ - `data-base` and `data-path` say where the service's share API is, when it is not this page's++ `/api/v1/<kind>/<id>/share`.++++**Loading.** The navbar loads `ov-share.js` and `ov-share.css` the first time someone presses a++`[data-ov-share-sheet]` button, so a site gets the sheet by putting the button on its page. Nothing loads before++that. The kit's `[data-ovk-share]` menu items open the same sheet.++++## From a script++++```js++OVShare.open({ url, title, text, image, embedUrl, time, kind, id, base, path, token, headers, onChange });++OVShare.offer(el, { kind, id, transfer_id, from, expires_at, keep_access, title, onDone }); // the recipient's banner++OVShare.gate(el, { kind, id, signedInAs, signInUrl, onDone }); // not on the list++OVShare.fromButton(button); // a button's data-* as options++```++++**Events on `document`:**++- `ov-share:sent` `{ target, url, kind, id }` when the link goes somewhere: copy, x, device, qr, embed and the rest.++ A site's analytics may count these.++- `ov-share:change` `{ kind, id, state }` after a save, a transfer or an answer.++++## What it promises++++- **The service decides.** The sheet only asks. Who may open a thing is enforced by the service that holds it++ (`openvibe-sdk/share`, `canView`).++ - A 401, 403 or 404 from the share API means "not the owner": the sheet shows only **Send it**, and says nothing++ failed.++ - Saves send `If-Match`. A change made meanwhile is shown, never overwritten.++- **Text, never markup.** Every name and title is set as text. Embed code escapes every attribute it writes.++- **No tracking.** Nothing is added to a shared link. "Start at" adds only the time.++- **The QR code is made on the device** (`ov-qr.js`). A link is never sent anywhere to draw it, and a Locker link's++ key, after `#`, stays on the device.++- **Phones.**++ - Targets are at least 44 px. The name field is 16 px, so iOS does not zoom.++ - The sheet clears the home bar (`env(safe-area-inset-bottom)`).++ - Messages is offered on touch devices only.++- **Motion.** The tiles come in with a short stagger, Copy turns into a tick with a ring, and a toast says what++ happened. Nothing spins. Under `prefers-reduced-motion` all of it is still.++++## Native apps (Android, iOS)++++The sheet is a component of OpenVibe Design. Its parts and words are platform-neutral, so native apps follow the same++design rather than a web view of it:++++| | Web | Android | iOS |++|---|---|---|---|++| **Send with your phone** | `navigator.share()` (which opens the platform's own sheet on Android and iOS browsers) | `Intent.ACTION_SEND` through `Intent.createChooser`, with sharing shortcuts for Direct Share | `UIActivityViewController` (`ShareLink` in SwiftUI) |++| **Copy** | Clipboard API | `ClipboardManager`, with the system's own copy confirmation on Android 13+ | `UIPasteboard` |++| **The places** | intent URLs (X, Bluesky …) | the same intent URLs, or the app's package when installed | the same URLs (universal links open the app) |++| **QR code** | `ov-qr.js` | the same matrix, drawn natively | the same matrix, drawn natively |++| **Who can open it** | this sheet | a bottom sheet with the same sections and words | a sheet (`.presentationDetents`) with the same sections and words |++++The calls the sheet makes are the same uniform API for every client: `openvibe-sdk/share`'s router,++`GET`/`PUT /api/v1/<kind>/<id>/share` and the rest. An agent uses `sdk.share({ base, token }).for(kind, id)`.@@@ -35,6 +35,7 @@ const BROWSER = Object.freeze(['ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]'ov-forms.js', // OpenVibe's own select (a menu on a computer, a sheet on a phone); navbar.js loads it when a page has a form control'ov-qr.js', // a QR code from a link, on the device: the Share sheet's QR code (ov-share.js loads it on first use)++ 'ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)'history.js', // cross-site history@@@ -74,6 +75,7 @@ const STYLES = Object.freeze(['player.css', // player.js's controls'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)++ 'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)@@@ -12,7 +12,8 @@* sheets [data-ovk-sheet="#id"] opens dialog.ovk-sheet; the backdrop, Close and Escape close it* tabs a sideways-scrolling tab strip keeps the open tab in view* rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end-- * share [data-ovk-share]: the system share sheet, else the link copied (the menu item says "Link copied")++ * share [data-ovk-share]: the network's Share sheet (ov-share.js, beside this file); without it, the system++ * share sheet, else the link copied (the menu item says "Link copied")* sidebars kit.sidebar on a phone: [data-ovk-sidebar-open] opens the drawer; the scrim, its close button, Escape* and a page move close it (document-level, so boost-swapped pages need no wiring)* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never@@@ -22,6 +23,8 @@*/(function (root) {'use strict';++ // ov-share.js beside this file (its query, so a release's copy goes with this one's), for [data-ovk-share].++ const SHARE_SRC = (() => { try { const me = document.currentScript; return me && me.src ? me.src.replace(/kit-web\.js(\?[^#]*)?.*$/, 'ov-share.js$1') : ''; } catch (_) { return ''; } })();const CHECK = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';/** Opens or closes a sidebar drawer: its scrim, its buttons' aria-expanded, and the focus into it. */@@@ -180,6 +183,19 @@const link = new URL(a.getAttribute('href'), location.href).href;const title = a.getAttribute('data-ovk-share') || document.title;const done = () => { const d = a.closest('details'); if (d) d.open = false; };++ // The network's one Share sheet (ov-share.js, 4.114.0) when it can load; the device's own sheet, or the++ // link copied, when it cannot.++ const sheet = () => { done(); root.OVShare.open(Object.assign(root.OVShare.fromButton(a), { url: link, title })); };++ if (root.OVShare) { sheet(); return; }++ if (SHARE_SRC && !a.dataset.ovkShareTried) {++ a.dataset.ovkShareTried = '1';++ if (!document.querySelector('link[href*="ov-share.css"]')) { const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = SHARE_SRC.replace('ov-share.js', 'ov-share.css'); document.head.appendChild(ln); }++ const sc = document.createElement('script'); sc.src = SHARE_SRC; sc.async = true;++ sc.onload = () => { if (root.OVShare) sheet(); else a.click(); };++ sc.onerror = () => a.click();++ document.head.appendChild(sc);++ return;++ }if (navigator.share) { navigator.share({ title, url: link }).then(done, () => {}); return; }if (!navigator.clipboard) { location.href = link; return; }navigator.clipboard.writeText(link).then(() => {@@@ -26,7 +26,7 @@* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })-- * kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)++ * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,@@@ -161,6 +161,16 @@if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${attrs(extra)}>${inner}</a>`;return `<button class="${cls}" type="${type === 'button' ? 'button' : type === 'reset' ? 'reset' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${attrs(extra)}>${inner}</button>`;}++ /**++ * The one Share button (4.114.0, docs/share.md): it opens the network's Share sheet (ov-share.js, which the navbar++ * loads on the first press). url is what is shared (else the page's address); kind and id add "Who can open it" for++ * the owner, where the service mounts openvibe-sdk/share's router.++ */++ function share({ title = '', url: link = null, text = '', image = null, embed = null, kind = null, id = null, label = 'Share', small = false, quiet = true } = {}) {++ return button({ type: 'button', label, icon: 'upload', kind: quiet ? 'quiet' : 'primary', small,++ attrs: { 'data-ov-share-sheet': true, 'data-title': title || null, 'data-url': link ? url(link) : null, 'data-text': text || null,++ 'data-image': image ? url(image) : null, 'data-embed': embed ? url(embed) : null, 'data-kind': kind || null, 'data-id': id || null } });++ }/** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) {const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`;@@@ -882,7 +892,7 @@return {skeleton,-- esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,++ esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, iconButton, action, badge, dot, card, rail, CARD_RATIO,head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,enhance, VERSION: 2,@@@ -491,7 +491,7 @@const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68' };++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'd5bbb14b108e', 'ov-share.css': 'd6afb0f03db7' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -574,6 +574,22 @@} catch { /* */ }return true;}++ // ov-share.js and ov-share.css (4.114.0, plan track Q): the one Share sheet, fetched the first time someone presses a++ // [data-ov-share-sheet] button, so a site gets it by putting the button on its page and nothing loads before that.++ function shareClick(e) {++ const b = e.target && e.target.closest ? e.target.closest('[data-ov-share-sheet]') : null;++ if (!b || root.OVShare || b.disabled) return;++ e.preventDefault();++ if (document.getElementById('ov-share-loader')) return;++ try {++ if (!document.querySelector('link[href*="ov-share.css"]')) {++ const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = siblingV('ov-share.css'); document.head.appendChild(ln);++ }++ const sc = document.createElement('script'); sc.id = 'ov-share-loader'; sc.src = siblingV('ov-share.js'); sc.async = true;++ sc.onload = () => { if (root.OVShare) root.OVShare.open(root.OVShare.fromButton(b)); };++ document.head.appendChild(sc);++ } catch { /* */ }++ }function watchForms() {if (formWatch || !document.querySelector || typeof MutationObserver !== 'function' || !document.body) return;const go = () => {@@@ -1805,6 +1821,7 @@// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers(); if (_config.forms !== false) watchForms();++ if (!root.__ovShareClick) { root.__ovShareClick = true; document.addEventListener('click', shareClick); }nav.innerHTML = `${brandHTML(brand)}<div class="openvibe-navbar-links">nnew file mode 100644@@@ -0,0 +1,344 @@++/* openvibe-shared/ov-share.css — the look of ov-share.js: the Share sheet (a dialog in the middle on a computer, a sheet++ * from the bottom on a phone), the recipient's transfer banner and the "shared with specific people" gate.++ * Colours come from the site's theme (--accent, --bg-card, --border, --text-primary …) with system colours when a site++ * has none, the same fallbacks as ov-forms.css, so light and dark follow the site. Nothing spins: a wait is words. */++++.ov-share, .ov-share-banner, .ov-share-gate {++ --ovs-accent: var(--accent, #3b82f6);++ --ovs-on-accent: var(--on-accent, #fff);++ --ovs-surface: var(--bg-card, var(--bg-secondary, Canvas));++ --ovs-text: var(--text-primary, CanvasText);++ --ovs-muted: var(--text-muted, var(--text-secondary, color-mix(in srgb, CanvasText 60%, Canvas)));++ --ovs-line: var(--border, color-mix(in srgb, CanvasText 14%, transparent));++ --ovs-hover: var(--bg-hover, color-mix(in srgb, CanvasText 7%, transparent));++ --ovs-field: var(--bg-input, var(--bg-tertiary, Field));++ --ovs-bad: var(--danger, #ef4444);++ --ovs-warn: var(--warning, #f59e0b);++ --ovs-radius: var(--ov-radius-2, 14px);++ color: var(--ovs-text); font-family: inherit; font-size: 14px; line-height: 1.45; text-align: start;++}++.ov-share *, .ov-share *::before, .ov-share *::after, .ov-share-banner *, .ov-share-gate * { box-sizing: border-box; }++++/* ── The dialog ─────────────────────────────────────────────────────────────────────────────────────────────── */++.ov-share {++ box-sizing: border-box; width: min(520px, calc(100vw - 24px)); max-width: none; max-height: min(88dvh, 760px); max-height: min(88vh, 760px);++ margin: auto; padding: 0; overflow: hidden;++ background: var(--ovs-surface); border: 1px solid var(--ovs-line); border-radius: var(--ov-radius-3, 20px);++ box-shadow: 0 28px 70px -18px rgb(0 0 0 / .5), 0 4px 14px rgb(0 0 0 / .14);++ opacity: 0; transform: translateY(10px) scale(.985);++ transition: opacity .16s ease, transform .2s cubic-bezier(.2, .8, .2, 1);++}++.ov-share.is-open { opacity: 1; transform: none; }++.ov-share::backdrop { background: rgb(6 10 16 / .52); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); }++html.ov-share-locked, html.ov-share-locked body { overflow: hidden; overscroll-behavior: none; }++.ov-share-box { display: flex; flex-direction: column; max-height: inherit; min-height: 0; }++.ov-share-head { display: flex; align-items: center; gap: 6px; padding: 14px 10px 6px 20px; }++.ov-share-title { flex: 1; min-width: 0; margin: 0; font-size: 17px; font-weight: 700; line-height: 1.3; letter-spacing: -.005em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ov-share-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; display: grid; align-content: start; gap: 18px; padding: 8px 20px 18px; scrollbar-width: thin; }++.ov-share-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 16px 14px; border-top: 1px solid var(--ovs-line); }++.ov-share-foot.is-confirm { background: color-mix(in srgb, var(--ovs-warn) 9%, transparent); }++.ov-share-foot.is-confirm p { margin: 0; font-weight: 600; }++.ov-share-grow { flex: 1 1 auto; min-width: 0; }++++/* A phone: a sheet from the bottom, the whole width, clear of the home bar. */++@media (max-width: 640px), (pointer: coarse) and (max-width: 900px) {++ .ov-share {++ width: 100%; max-width: 100%; max-height: min(92dvh, 820px); max-height: min(92vh, 820px);++ margin: auto 0 0; border: 0; border-radius: var(--ov-radius-3, 20px) var(--ov-radius-3, 20px) 0 0;++ box-shadow: 0 -14px 44px rgb(0 0 0 / .38);++ opacity: 1; transform: translateY(100%); transition: transform .26s cubic-bezier(.2, .85, .25, 1);++ }++ .ov-share.is-open { transform: none; }++ .ov-share-head { position: relative; padding: 20px max(8px, env(safe-area-inset-right, 0px)) 6px max(18px, env(safe-area-inset-left, 0px)); }++ .ov-share-head::before { content: ""; position: absolute; top: 7px; left: 50%; width: 38px; height: 5px; margin-left: -19px; border-radius: 99px; background: color-mix(in srgb, var(--ovs-text) 22%, transparent); }++ .ov-share-body { padding: 8px max(16px, env(safe-area-inset-right, 0px)) 18px max(16px, env(safe-area-inset-left, 0px)); font-size: 15px; }++ .ov-share-foot { padding: 10px max(12px, env(safe-area-inset-right, 0px)) max(12px, env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px)); }++}++++/* ── Words ──────────────────────────────────────────────────────────────────────────────────────────────────── */++.ov-share-label { display: block; margin: 0 0 8px; padding: 0; font-size: 12px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--ovs-muted); }++.ov-share-desc, .ov-share-lead { margin: 10px 0 0; color: var(--ovs-muted); font-size: 13.5px; }++.ov-share-lead { margin: 0; font-size: 14px; }++.ov-share-desc strong { color: var(--ovs-text); font-weight: 650; }++.ov-share-muted { margin: 0; color: var(--ovs-muted); font-size: 13px; }++.ov-share-note { margin: 0; font-size: 13px; color: var(--ovs-text); padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--ovs-accent) 9%, transparent); }++.ov-share-warn { margin: 0; font-size: 13px; padding: 8px 10px; border-radius: 10px; color: var(--warning-text, var(--ovs-text)); background: color-mix(in srgb, var(--ovs-warn) 15%, transparent); }++.ov-share-error { margin: 0; font-size: 13px; color: var(--danger-text, var(--ovs-bad)); }++.ov-share-soft { font-style: italic; }++++/* ── Buttons ────────────────────────────────────────────────────────────────────────────────────────────────── */++.ov-share-btn {++ -webkit-appearance: none; appearance: none; display: inline-flex; align-items: center; justify-content: center; gap: 7px;++ min-height: 40px; padding: 0 15px; margin: 0; border: 1px solid var(--ovs-line); border-radius: 10px;++ background: transparent; color: var(--ovs-text); font: inherit; font-size: 14px; font-weight: 650; line-height: 1.2;++ text-decoration: none; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent;++ transition: background-color .14s ease, border-color .14s ease, box-shadow .14s ease;++}++.ov-share-btn:hover:not(:disabled) { background: var(--ovs-hover); }++.ov-share-btn:focus-visible, .ov-share-iconbtn:focus-visible, .ov-share-link:focus-visible, .ov-share-chip:focus-visible {++ outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 40%, transparent);++}++.ov-share-btn:disabled { opacity: .55; cursor: default; }++.ov-share-btn.is-primary { background: var(--ovs-accent); border-color: var(--ovs-accent); color: var(--ovs-on-accent); }++.ov-share-btn.is-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--ovs-accent) 88%, #000); }++.ov-share-btn.is-quiet { border-color: transparent; }++.ov-share-btn.is-danger { background: var(--ovs-bad); border-color: var(--ovs-bad); color: #fff; }++.ov-share-btn.is-small { min-height: 34px; padding: 0 12px; font-size: 13px; }++.ov-share-btn .ov-share-ico { width: 18px; height: 18px; }++.ov-share-iconbtn {++ -webkit-appearance: none; appearance: none; flex: none; display: inline-grid; place-items: center; width: 40px; height: 40px; margin: 0; padding: 0;++ border: 0; border-radius: 10px; background: none; color: var(--ovs-muted); cursor: pointer; -webkit-tap-highlight-color: transparent;++}++.ov-share-iconbtn:hover { background: var(--ovs-hover); color: var(--ovs-text); }++.ov-share-iconbtn.is-small { width: 34px; height: 34px; }++.ov-share-link {++ -webkit-appearance: none; appearance: none; display: inline; margin: 0; padding: 2px 0; border: 0; background: none;++ color: var(--ovs-accent); font: inherit; font-weight: 600; cursor: pointer; border-radius: 4px;++}++.ov-share-link:hover { text-decoration: underline; }++.ov-share-link.is-quiet { color: var(--ovs-muted); font-weight: 500; }++@media (pointer: coarse) {++ .ov-share-btn { min-height: 44px; }++ .ov-share-btn.is-small { min-height: 40px; }++ .ov-share-iconbtn { width: 44px; height: 44px; }++ .ov-share-iconbtn.is-small { width: 40px; height: 40px; }++ .ov-share-link { padding: 6px 0; }++}++++/* Icons: one stroke family, the currentColor of their place. */++.ov-share-ico { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px; }++.ov-share-ico svg { width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }++.ov-share-ico.is-big { width: 40px; height: 40px; color: var(--ovs-accent); }++++/* ── Who can open this: a segmented choice of up to four ───────────────────────────────────────────────────── */++.ov-share-aud { margin: 0; padding: 0; border: 0; min-width: 0; }++.ov-share-aud:disabled .ov-share-segopt { cursor: default; }++.ov-share-seg { display: grid; grid-template-columns: repeat(var(--n, 4), minmax(0, 1fr)); gap: 4px; padding: 4px; border-radius: var(--ovs-radius); background: var(--ovs-hover); }++.ov-share-segopt {++ position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;++ min-width: 0; min-height: 62px; padding: 8px 4px; border-radius: 11px; cursor: pointer;++ color: var(--ovs-muted); font-size: 13px; font-weight: 600; text-align: center; -webkit-tap-highlight-color: transparent;++ transition: background-color .14s ease, color .14s ease, box-shadow .14s ease;++}++.ov-share-segopt > input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; }++.ov-share-segopt:hover { color: var(--ovs-text); }++.ov-share-segopt:has(input:checked) { background: var(--ovs-surface); color: var(--ovs-accent); box-shadow: 0 1px 3px rgb(0 0 0 / .16), 0 0 0 1px color-mix(in srgb, var(--ovs-accent) 30%, transparent); }++.ov-share-segopt:has(input:focus-visible) { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 40%, transparent); }++.ov-share-seglabel { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ov-share-aud:disabled .ov-share-segopt:not(:has(input:checked)) { opacity: .55; }++++/* ── People ─────────────────────────────────────────────────────────────────────────────────────────────────── */++.ov-share-people { display: grid; gap: 10px; min-width: 0; }++.ov-share-people .ov-share-label { margin: 0; }++.ov-share-add { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; margin: 0; }++.ov-share-addfield {++ display: flex; align-items: center; min-width: 0; min-height: 44px; padding: 0 12px; gap: 2px;++ background: var(--ovs-field); border: 1px solid var(--ovs-line); border-radius: 10px;++ transition: border-color .14s ease, box-shadow .14s ease;++}++.ov-share-addfield:focus-within { border-color: var(--ovs-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 28%, transparent); }++.ov-share-at { color: var(--ovs-muted); font-weight: 650; }++.ov-share-addfield input { flex: 1; min-width: 0; height: 42px; padding: 0; border: 0; outline: 0; background: none; color: var(--ovs-text); font: inherit; }++.ov-share-addfield input::placeholder { color: var(--ovs-muted); opacity: 1; }++@media (pointer: coarse) { .ov-share-addfield input { font-size: 16px; } } /* a phone does not zoom into a 16 px field */++.ov-share-add .ov-share-btn { min-height: 44px; }++.ov-share-addmsg { grid-column: 1 / -1; margin: 0; font-size: 13px; color: var(--ovs-muted); }++.ov-share-addmsg:empty { display: none; }++.ov-share-addmsg.is-bad { color: var(--danger-text, var(--ovs-bad)); }++.ov-share-sugg { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; }++.ov-share-chip {++ -webkit-appearance: none; appearance: none; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; margin: 0; padding: 0 10px 0 4px;++ border: 1px solid var(--ovs-line); border-radius: 999px; background: none; color: var(--ovs-text); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;++}++.ov-share-chip:hover { background: var(--ovs-hover); }++.ov-share-chip .ov-share-av { width: 24px; height: 24px; font-size: 11px; }++@media (pointer: coarse) { .ov-share-chip { min-height: 40px; } }++++.ov-share-list { display: grid; margin: 0; padding: 0; list-style: none; }++.ov-share-row { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 7px 0; min-width: 0; }++.ov-share-row + .ov-share-row { border-top: 1px solid color-mix(in srgb, var(--ovs-line) 70%, transparent); }++.ov-share-row.is-dim > :not(.ov-share-iconbtn) { opacity: .5; }++.ov-share-row.is-new .ov-share-av { box-shadow: 0 0 0 2px var(--ovs-surface), 0 0 0 4px color-mix(in srgb, var(--ovs-accent) 55%, transparent); }++.ov-share-av {++ flex: none; display: inline-grid; place-items: center; width: 38px; height: 38px; overflow: hidden; border-radius: 50%;++ background: hsl(var(--ov-share-h, 210) 52% 46%); color: #fff; font-size: 15px; font-weight: 700; line-height: 1; user-select: none;++}++.ov-share-av img { width: 100%; height: 100%; object-fit: cover; display: block; }++.ov-share-words { flex: 1; min-width: 0; display: grid; gap: 1px; }++.ov-share-name { display: flex; align-items: center; gap: 6px; min-width: 0; font-weight: 650; }++.ov-share-display { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ov-share-tag { flex: none; padding: 1px 7px; border-radius: 999px; font-size: 12px; font-weight: 600; line-height: 1.4; color: var(--ovs-muted); background: var(--ovs-hover); }++.ov-share-tag.is-new { color: var(--ovs-accent); background: color-mix(in srgb, var(--ovs-accent) 14%, transparent); }++.ov-share-sub { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 0; row-gap: 2px; min-width: 0; font-size: 12.5px; color: var(--ovs-muted); }++.ov-share-sub > * + *::before { content: "\00b7"; display: inline-block; padding: 0 6px; color: var(--ovs-muted); font-style: normal; font-weight: 400; }++.ov-share-sub > * { min-width: 0; white-space: nowrap; } /* a part wraps whole, never inside "until Nov 30" */++.ov-share-sub > span:first-child { overflow: hidden; text-overflow: ellipsis; }++@media (max-width: 480px) { /* a phone: the name, then the end date under it, no dot left hanging */++ .ov-share-sub { flex-direction: column; align-items: flex-start; }++ .ov-share-sub > * + *::before { content: none; }++}++.ov-share-exp { display: inline-flex; align-items: baseline; gap: 8px; }++.ov-share-exp .ov-share-link { font-size: 12.5px; }++.ov-share-exp .ov-share-btn { min-height: 30px; padding: 0 8px; font-size: 12.5px; }++.ov-share-date { min-height: 32px; padding: 2px 8px; border: 1px solid var(--ovs-accent); border-radius: 8px; background: var(--ovs-field); color: var(--ovs-text); font: inherit; font-size: 13px; color-scheme: light dark; }++.ov-share-role { flex: none; font-size: 13px; color: var(--ovs-muted); white-space: nowrap; }++.ov-share-role.is-fixed { font-weight: 600; }++.ov-share-rolepill { flex: none; display: inline-flex; align-items: stretch; min-height: 34px; padding: 0; border: 1px solid var(--ovs-line); border-radius: 999px; font-size: 13px; font-weight: 600; overflow: hidden; }++.ov-share-rolepill:focus-within { border-color: var(--ovs-accent); }++.ov-share-select { max-width: 100%; min-height: 32px; margin: 0; padding: 0 10px; border: 0; outline: 0; background: transparent; color: var(--ovs-text); font: inherit; cursor: pointer; }++.ov-share-rolepill .ov-sel { padding: 0 10px; } /* ov-forms.js, when the site has it, draws the select as a bare button */++@media (pointer: coarse) { .ov-share-rolepill { min-height: 40px; } .ov-share-select { min-height: 38px; } .ov-share-exp .ov-share-link { padding: 9px 0; } }++.ov-share-check { display: flex; align-items: center; gap: 10px; font-size: 13.5px; cursor: pointer; }++.ov-share-check input { flex: none; width: 18px; height: 18px; margin: 0; accent-color: var(--ovs-accent); }++/* The sheet's own switch, the same as ov-forms.css draws (a site without the Forms kit still gets a switch). */++.ov-share-check input[role="switch"] {++ -webkit-appearance: none; appearance: none; width: 42px; height: 24px; border-radius: 999px; cursor: pointer;++ background-color: color-mix(in srgb, var(--ovs-text) 26%, transparent);++ background-image: radial-gradient(circle, #fff 0 8.5px, transparent 9px); background-repeat: no-repeat; background-size: 20px 20px; background-position: 2px 50%;++ transition: background-color .18s ease, background-position .2s cubic-bezier(.3, .8, .3, 1);++}++.ov-share-check input[role="switch"]:checked { background-color: var(--ovs-accent); background-position: calc(100% - 2px) 50%; }++.ov-share-check input[role="switch"]:focus-visible { outline: 2px solid var(--ovs-accent); outline-offset: 2px; }++++/* ── Send it: the link, the phone's own sheet, the places ──────────────────────────────────────────────────── */++.ov-share-box { position: relative; }++.ov-share-send { display: grid; gap: 14px; min-width: 0; }++.ov-share-linkpill {++ display: flex; align-items: center; gap: 8px; min-width: 0; padding: 5px 5px 5px 14px;++ background: var(--ovs-field); border: 1px solid var(--ovs-line); border-radius: 14px;++ transition: border-color .18s ease, box-shadow .18s ease;++}++.ov-share-linkpill:focus-within { border-color: var(--ovs-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 26%, transparent); }++.ov-share-linkpill > .ov-share-ico { color: var(--ovs-muted); width: 18px; height: 18px; }++.ov-share-linktext { flex: 1; min-width: 0; height: 40px; padding: 0; border: 0; outline: 0; background: none; color: var(--ovs-text); font: inherit; font-size: 14.5px; text-overflow: ellipsis; }++@media (pointer: coarse) { .ov-share-linktext { font-size: 16px; } }++.ov-share-copy { min-width: 96px; border-radius: 10px; }++.ov-share-linkpill.is-copied { border-color: var(--success, #22c55e); animation: ov-share-ring .7s ease-out; }++.ov-share-linkpill.is-copied .ov-share-copy { background: var(--success, #16a34a); border-color: var(--success, #16a34a); color: #fff; }++.ov-share-linkpill.is-copied .ov-share-copy .ov-share-ico { animation: ov-share-pop .38s cubic-bezier(.3, 1.6, .5, 1); }++@keyframes ov-share-ring { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--success, #22c55e) 50%, transparent); } to { box-shadow: 0 0 0 12px transparent; } }++@keyframes ov-share-pop { 0% { transform: scale(.4); } 70% { transform: scale(1.18); } 100% { transform: none; } }++.ov-share-linkfor { margin-top: -6px; }++.ov-share-rule { margin: 2px 0; border: 0; border-top: 1px solid var(--ovs-line); }++++.ov-share-device {++ -webkit-appearance: none; appearance: none; display: flex; align-items: center; gap: 14px; width: 100%; min-height: 64px; margin: 0; padding: 10px 14px;++ border: 1px solid var(--ovs-line); border-radius: 14px; background: color-mix(in srgb, var(--ovs-accent) 6%, transparent);++ color: var(--ovs-text); font: inherit; text-align: start; cursor: pointer; -webkit-tap-highlight-color: transparent;++ transition: transform .12s ease, background-color .14s ease;++}++.ov-share-device:active { transform: scale(.985); }++.ov-share-device .ov-share-tileic { background: var(--ovs-accent); color: var(--ovs-on-accent); }++.ov-share-devicewords { display: grid; gap: 1px; min-width: 0; }++.ov-share-devicetitle { font-weight: 700; font-size: 15px; }++++.ov-share-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: 4px; margin: 0 -6px; }++@media (max-width: 640px) { .ov-share-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }++.ov-share-tile {++ -webkit-appearance: none; appearance: none; display: flex; flex-direction: column; align-items: center; gap: 7px; min-width: 0; margin: 0; padding: 9px 2px 8px;++ border: 0; border-radius: 14px; background: none; color: var(--ovs-text); font: inherit; font-size: 12.5px; font-weight: 600; line-height: 1.2;++ text-decoration: none; text-align: center; cursor: pointer; -webkit-tap-highlight-color: transparent;++ transition: background-color .14s ease, transform .12s ease;++}++.ov-share-tile:hover { background: var(--ovs-hover); }++.ov-share-tile:active { transform: scale(.94); }++.ov-share-tile:focus-visible, .ov-share-device:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 40%, transparent); }++.ov-share-tile[aria-expanded="true"] { background: color-mix(in srgb, var(--ovs-accent) 12%, transparent); }++.ov-share-tilelabel { max-width: 100%; padding: 0 2px; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; }++.ov-share-tileic {++ flex: none; display: inline-grid; place-items: center; width: 48px; height: 48px; border-radius: 50%;++ background: var(--ovs-hover); color: var(--ovs-text); font-weight: 800; font-size: 18px;++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovs-text) 10%, transparent);++ transition: transform .16s cubic-bezier(.3, 1.4, .5, 1);++}++.ov-share-tile:hover .ov-share-tileic { transform: translateY(-2px); }++.ov-share-tileic svg { width: 22px; height: 22px; }++.ov-share-tileic:not(.is-brand) svg { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }++.ov-share-tileic.is-brand { background: var(--ov-brand); color: #fff; box-shadow: inset 0 0 0 1px rgb(255 255 255 / .14); }++.ov-share-tileic.is-email { background: color-mix(in srgb, var(--ovs-accent) 16%, transparent); color: var(--ovs-accent); }++.ov-share-tileic.is-sms { background: #34c759; color: #fff; }++.ov-share-tileic.is-linkedin { background: #0a66c2; color: #fff; }++.ov-share-tileic.is-qr, .ov-share-tileic.is-embed, .ov-share-tileic.is-device, .ov-share-tileic.is-more { color: var(--ovs-text); }++.ov-share-tileic.is-more svg { fill: currentColor; stroke: none; }++.ov-share-box.is-first .ov-share-tile, .ov-share-box.is-first .ov-share-device, .ov-share-box.is-first .ov-share-linkpill {++ animation: ov-share-in .34s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i, 0) * 22ms + 70ms);++}++.ov-share-box.is-first .ov-share-linkpill { --i: -2; }++.ov-share-box.is-first .ov-share-device { --i: -1; }++@keyframes ov-share-in { from { opacity: 0; transform: translateY(8px) scale(.92); } to { opacity: 1; transform: none; } }++++.ov-share-panel {++ display: grid; gap: 10px; margin: 0; padding: 14px; min-width: 0; border-radius: 14px;++ background: color-mix(in srgb, var(--ovs-text) 4%, transparent); border: 1px solid var(--ovs-line);++ animation: ov-share-unfold .22s cubic-bezier(.2, .8, .2, 1);++}++.ov-share-panel .ov-share-label { margin: 0; }++@keyframes ov-share-unfold { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }++.ov-share-qr { grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 16px; }++.ov-share-qrart { display: grid; place-items: center; width: 172px; height: 172px; padding: 8px; border-radius: 14px; background: #fff; color: #0b0f17; font-size: 12.5px; text-align: center; box-shadow: 0 6px 20px -8px rgb(0 0 0 / .4); }++.ov-share-qrart svg { display: block; width: 100%; height: 100%; shape-rendering: crispEdges; }++.ov-share-qrside { display: grid; gap: 10px; justify-items: start; min-width: 0; }++@media (max-width: 420px) { .ov-share-qr { grid-template-columns: minmax(0, 1fr); justify-items: center; text-align: center; } .ov-share-qrside { justify-items: center; } }++.ov-share-code {++ box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 10px 12px; resize: none;++ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--ovs-text);++ background: var(--ovs-field); border: 1px solid var(--ovs-line); border-radius: 10px;++}++.ov-share-panel > .ov-share-btn { justify-self: start; }++++.ov-share-toast {++ position: absolute; left: 50%; bottom: 78px; z-index: 2; max-width: calc(100% - 32px); margin: 0; padding: 9px 16px;++ border-radius: 999px; background: color-mix(in srgb, var(--ovs-text) 92%, transparent); color: var(--ovs-surface);++ font-size: 13.5px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;++ box-shadow: 0 10px 30px -8px rgb(0 0 0 / .45);++ opacity: 0; transform: translate(-50%, 8px) scale(.96); transition: opacity .18s ease, transform .22s cubic-bezier(.2, .9, .3, 1.3);++}++.ov-share-toast:empty { display: none; }++.ov-share-toast.is-quiet { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }++.ov-share-toast.is-on { opacity: 1; transform: translate(-50%, 0); }++@media (max-width: 640px), (pointer: coarse) and (max-width: 900px) { .ov-share-toast { bottom: calc(76px + env(safe-area-inset-bottom, 0px)); } }++++/* ── Transfer ───────────────────────────────────────────────────────────────────────────────────────────────── */++.ov-share-give { display: grid; gap: 10px; margin: 0; }++.ov-share-give .ov-share-label { margin: 6px 0 0; }++.ov-share-keep { display: grid; gap: 8px; margin: 6px 0 0; padding: 0; border: 0; min-width: 0; }++.ov-share-radio {++ display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; border: 1px solid var(--ovs-line); border-radius: 12px; cursor: pointer;++ transition: border-color .14s ease, background-color .14s ease;++}++.ov-share-radio:has(input:checked) { border-color: var(--ovs-accent); background: color-mix(in srgb, var(--ovs-accent) 7%, transparent); }++.ov-share-radio:has(input:focus-visible) { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 35%, transparent); }++.ov-share-radio input { flex: none; width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--ovs-accent); }++.ov-share-radiowords { display: grid; gap: 2px; min-width: 0; }++.ov-share-radiotitle { font-weight: 650; }++++/* ── Banners: the owner's pending offer, the recipient's offer ─────────────────────────────────────────────── */++.ov-share-banner {++ display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; padding: 12px 14px; border-radius: 12px;++ background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent);++}++.ov-share-banner > .ov-share-ico { color: var(--ovs-accent); }++.ov-share-banner > p { flex: 1 1 220px; min-width: 0; margin: 0; font-size: 13.5px; }++.ov-share-banner > .ov-share-error { flex-basis: 100%; }++.ov-share-banner.is-offer { margin: 0 0 16px; }++.ov-share-acts { display: flex; flex-wrap: wrap; gap: 8px; }++++/* ── The gate: a person who is not on the list ─────────────────────────────────────────────────────────────── */++.ov-share-gate {++ display: grid; justify-items: center; gap: 12px; max-width: 440px; margin: 48px auto; padding: 32px 24px; text-align: center;++ background: var(--ovs-surface); border: 1px solid var(--ovs-line); border-radius: var(--ov-radius-3, 20px);++}++.ov-share-gatetitle { margin: 4px 0 0; font-size: 19px; font-weight: 700; line-height: 1.3; text-wrap: balance; }++.ov-share-gate .ov-share-acts { justify-content: center; margin-top: 4px; }++.ov-share-gate .ov-share-muted { font-size: 14px; max-width: 34ch; }++++.ov-share-copybuf { position: fixed; top: 0; left: 0; width: 1px; height: 1px; opacity: 0; pointer-events: none; }++++@media (prefers-reduced-motion: reduce) {++ .ov-share, .ov-share-btn, .ov-share-segopt, .ov-share-addfield, .ov-share-radio, .ov-share-tile, .ov-share-tileic, .ov-share-device, .ov-share-toast, .ov-share-linkpill { transition: none !important; }++ .ov-share *, .ov-share-box.is-first * { animation: none !important; }++}++@media (forced-colors: active) {++ .ov-share { border: 1px solid CanvasText; }++ .ov-share-segopt:has(input:checked), .ov-share-radio:has(input:checked) { outline: 2px solid Highlight; }++}nnew file mode 100644@@@ -0,0 +1,1050 @@++/**++ * openvibe-shared/ov-share.js — one Share sheet for anything on OpenVibe, on every site (plan track Q; the owner,++ * 2026-11-06: share with named accounts only, give a thing away with the recipient's say-so, "for everything else too …++ * a more universal system", and "a unified share system … more platforms … easy copying of a link … the phone's own share++ * thing … intuitive, user-friendly"). The look is ov-share.css, linked from beside this file; docs/share.md has both.++ *++ * One sheet, two parts, never tabs:++ * Send it for everyone: the link and Copy; on a phone, the phone's own share sheet (contacts, messages, apps);++ * X, Bluesky, WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, email, Messages,++ * LinkedIn, Pinterest, Tumblr, Hacker News, LINE (recent ones first, the rest behind "More (n)"); a QR++ * code made on the device; "Start at 1:23"; embed code.++ * Who can open it for the owner, when the thing has kind and id: Everyone, Anyone with the link, Specific people++ * (by @name, each Can view or Can edit, with an end date), Only me; people who asked; Transfer.++ *++ * <button type="button" data-ov-share-sheet data-title="…">Share</button> the page's own address++ * data-url="https://…" the link to share (else the page's address)++ * data-text="…" a line to send with it (email, the phone's sheet)++ * data-image="https://…" a picture of it (Pinterest)++ * data-embed="https://…" an embeddable player address (the Embed tile)++ * data-time-from="#player" a <video> or <audio> whose current time offers "Start at …" (data-time-param, else t)++ * data-kind="paste" data-id="abc" also "Who can open it", for its owner (data-base, data-path as below)++ * data-base="https://…" the service's origin, when it is not this page's++ * data-path="/api/v1/…" the share URL, when it is not /api/v1/<kind>/<id>/share++ *++ * OVShare.open({ url?, title?, text?, image?, embedUrl?, time?, timeParam?, kind?, id?, base?, path?, token?, headers?, onChange? })++ * opens the sheet; resolves with the saved sharing state, or null when nothing was saved++ * OVShare.offer(el, { kind, id, transfer_id, from, expires_at, keep_access?, title?, base?, token?, onDone? })++ * the recipient's banner for a pending transfer: Accept, Decline++ * OVShare.gate(el, { kind, id, signedInAs?, signInUrl?, base?, token?, onDone? })++ * the page a person who is not on the list sees: "Ask for access", or "Sign in"++ *++ * The sheet only asks; the service decides. Who may see a thing is enforced by the service holding it (canView), never++ * by this file. Every name and title is set as text, never as markup. No tracking is added to a shared link.++ *++ * Calls (openvibe-sdk/share's router); a 401, 403 or 404 on the first GET means "not the owner": the sheet sends only.++ * GET <path> { audience, audiences, roles, you, owner, members, requests, transfer, title, link } + ETag++ * PUT <path> If-Match { audience, members: [{ subject | name, role, expires_at? }], notify } → the same++ * POST <path>/transfer { to, keep_access } POST <path>/transfer/cancel { transfer_id }++ * POST <path>/transfer/accept POST <path>/transfer/decline { transfer_id }++ * POST <path>/requests (ask for access) POST <path>/requests/<id>/dismiss++ * GET <base>/api/v1/share/people?name=x { person: { subject, username, display_name, avatar_url } | null }++ * Events on document: 'ov-share:change' { kind, id, state } after a save, a transfer or an answer;++ * 'ov-share:sent' { target, url, kind, id } when the link goes somewhere (copy, x, device, qr …).++ */++(function (root) {++ 'use strict';++ if (!root || !root.document || root.OVShare) return;++ const doc = root.document;++ const NAME_RE = /^[A-Za-z0-9_.-]{1,40}$/; // Network's username rule++ const AUDIENCES = {++ public: { label: 'Everyone', title: 'Everyone', desc: 'Listed on the site and in search. Anyone can open it.' },++ link: { label: 'Link', title: 'Anyone with the link', desc: 'Not listed and not in search. Anyone with the link can open it.' },++ people: { label: 'People', title: 'Specific people', desc: 'Only you and the people below, signed in to OpenVibe.' },++ private: { label: 'Only me', title: 'Only me', desc: 'Only you can open it.' },++ };++ const ROLE_WORDS = { viewer: 'Can view', editor: 'Can edit' };++ const svg = (d, extra) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"${extra || ''}>${d}</svg>`;++ const ICON = {++ public: svg('<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.6 2.4 3.8 5.3 3.8 8.5s-1.2 6.1-3.8 8.5c-2.6-2.4-3.8-5.3-3.8-8.5s1.2-6.1 3.8-8.5Z"/>'),++ link: svg('<path d="M10 14a4.2 4.2 0 0 0 6 0l3-3a4.2 4.2 0 0 0-6-6l-1 1"/><path d="M14 10a4.2 4.2 0 0 0-6 0l-3 3a4.2 4.2 0 0 0 6 6l1-1"/>'),++ people: svg('<circle cx="9" cy="8.5" r="3.2"/><path d="M3.5 19c.6-3.2 2.7-5 5.5-5s4.9 1.8 5.5 5"/><circle cx="16.8" cy="9.5" r="2.5"/><path d="M15.8 14.2c2.4-.3 4.2 1.2 4.7 4"/>'),++ private: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),++ close: svg('<path d="m6.5 6.5 11 11M17.5 6.5l-11 11"/>'),++ back: svg('<path d="M14.5 6 8.5 12l6 6"/>'),++ copy: svg('<rect x="8.5" y="8.5" width="11" height="11" rx="2.2"/><path d="M15.5 8.5V6.7a2.2 2.2 0 0 0-2.2-2.2H6.7a2.2 2.2 0 0 0-2.2 2.2v6.6a2.2 2.2 0 0 0 2.2 2.2h1.8"/>'),++ give: svg('<path d="M4 12h12M12 6l6 6-6 6"/><path d="M20 5v14"/>'),++ tick: svg('<path d="m5 12.5 4.5 4.5L19 7.5"/>'),++ };++++ // ── Small helpers ─────────────────────────────────────────────────────────────────────────────────────────++ const el = (tag, cls, attrs, text) => {++ const n = doc.createElement(tag);++ if (cls) n.className = cls;++ if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null && attrs[k] !== false) n.setAttribute(k, attrs[k] === true ? '' : attrs[k]);++ if (text != null) n.textContent = text;++ return n;++ };++ const iconEl = (name, cls) => { const s = el('span', cls || 'ov-share-ico'); s.innerHTML = ICON[name]; return s; }; // constants only++ const reduce = () => !!(root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches);++ const cleanName = (s) => String(s || '').trim().replace(/^@+/, '');++ const sid = (s) => (!s ? '' : typeof s === 'string' ? s : `${s.type}:${s.id}`);++ const same = (a, b) => !!a && !!b && sid(a) === sid(b);++ const handle = (p) => (p && p.username ? `@${p.username}` : '');++ const nameOf = (p) => (p && (p.display_name || p.username)) || 'Someone';++ function when(iso, withYear) {++ const d = new Date(iso);++ if (!iso || isNaN(d)) return '';++ const o = { month: 'short', day: 'numeric' };++ if (withYear || d.getFullYear() !== new Date().getFullYear()) o.year = 'numeric';++ return d.toLocaleDateString(undefined, o);++ }++ function hue(s) { let h = 0; for (const c of String(s || '?')) h = (h * 31 + c.codePointAt(0)) % 360; return h; }++ function avatar(p) {++ const s = el('span', 'ov-share-av', { 'aria-hidden': 'true' });++ const initial = (nameOf(p).trim()[0] || '?').toUpperCase();++ s.style.setProperty('--ov-share-h', String(hue(p && (p.username || sid(p.subject)))));++ const url = p && p.avatar_url;++ if (url && /^(https:\/\/|\/(?!\/))/.test(url)) {++ const img = el('img', null, { src: url, alt: '', loading: 'lazy', decoding: 'async', referrerpolicy: 'no-referrer' });++ img.addEventListener('error', () => { img.remove(); s.textContent = initial; }, { once: true });++ s.appendChild(img);++ } else s.textContent = initial;++ return s;++ }++++ // The look, once, from beside this script (a page or the navbar may have linked it already).++ function stylesheet() {++ if (doc.querySelector('link[href*="ov-share.css"]')) return;++ const me = doc.currentScript || doc.querySelector('script[src*="ov-share.js"]');++ const src = me && me.src ? me.src : '/shared/ov-share.js';++ doc.head.appendChild(el('link', null, { rel: 'stylesheet', href: src.replace(/ov-share\.js(\?[^#]*)?.*$/, 'ov-share.css$1') }));++ }++ stylesheet();++++ // ── Talking to the service ────────────────────────────────────────────────────────────────────────────────++ function endpoint(o) {++ const base = String(o.base || '').replace(/\/+$/, '');++ const path = o.path || `/api/v1/${encodeURIComponent(o.kind)}/${encodeURIComponent(o.id)}/share`;++ return { base, path: base + path };++ }++ async function call(o, method, url, body, extra) {++ const headers = Object.assign({ accept: 'application/json', 'x-requested-with': 'ov-share' }, o.headers || {}, extra || {});++ if (body !== undefined) headers['content-type'] = 'application/json';++ if (o.token) headers.authorization = `Bearer ${o.token}`;++ const r = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: o.base ? 'include' : 'same-origin' });++ let j = null;++ try { j = await r.json(); } catch (_) { /* an empty answer */ }++ if (!r.ok) {++ const e = new Error((j && (j.message || j.error_description)) || `The service answered ${r.status}`);++ e.status = r.status; e.body = j;++ throw e;++ }++ const out = j || {};++ const tag = r.headers && r.headers.get ? r.headers.get('etag') : null;++ if (tag) Object.defineProperty(out, '__etag', { value: tag, enumerable: false });++ return out;++ }++ // One @name → a person, or null. undefined means the check itself could not run (the name is checked on save).++ async function lookup(o, name) {++ try {++ const j = await call(o, 'GET', `${endpoint(o).base}/api/v1/share/people?name=${encodeURIComponent(name)}`);++ return j.person && j.person.subject ? j.person : null;++ } catch (e) {++ if (e.status === 429) { const x = new Error('Too many name checks. Wait a minute, then try again.'); x.status = 429; throw x; }++ return undefined;++ }++ }++ function announce(o, state) {++ doc.dispatchEvent(new CustomEvent('ov-share:change', { detail: { kind: o.kind, id: o.id, state } }));++ if (typeof o.onChange === 'function') { try { o.onChange(state); } catch (_) { /* the page's problem */ } }++ }++++ // ── Send: the link, the device's own share sheet, the places people post it ──────────────────────────────++ // No tracking is added to a link: the address shared is the address of the thing (with ?t= when asked).++ const E = encodeURIComponent;++ const phoneLike = () => !!(root.matchMedia && root.matchMedia('(pointer: coarse)').matches);++ // [id, label, brand slug in brands.js (null: a glyph of our own), the link it opens (null: an action here)]++ const TARGETS = [++ ['x', 'X', 'x', (u, t) => `https://x.com/intent/post?text=${E(t)}&url=${E(u)}`],++ ['bluesky', 'Bluesky', 'bluesky', (u, t) => `https://bsky.app/intent/compose?text=${E(t ? `${t} ${u}` : u)}`],++ ['whatsapp', 'WhatsApp', 'whatsapp', (u, t) => `https://wa.me/?text=${E(t ? `${t} ${u}` : u)}`],++ ['reddit', 'Reddit', 'reddit', (u, t) => `https://www.reddit.com/submit?url=${E(u)}&title=${E(t)}`],++ ['discord', 'Discord', 'discord', null],++ ['telegram', 'Telegram', 'telegram', (u, t) => `https://t.me/share/url?url=${E(u)}&text=${E(t)}`],++ ['threads', 'Threads', 'threads', (u, t) => `https://www.threads.net/intent/post?text=${E(t ? `${t} ${u}` : u)}`],++ ['mastodon', 'Mastodon', 'mastodon', null],++ ['facebook', 'Facebook', 'facebook', (u) => `https://www.facebook.com/sharer/sharer.php?u=${E(u)}`],++ ['email', 'Email', null, (u, t, o) => `mailto:?subject=${E(t)}&body=${E(`${o.text ? `${o.text}\n\n` : ''}${u}`)}`],++ ['sms', 'Messages', null, (u, t) => `sms:?&body=${E(t ? `${t} ${u}` : u)}`],++ ['linkedin', 'LinkedIn', null, (u) => `https://www.linkedin.com/sharing/share-offsite/?url=${E(u)}`],++ ['pinterest', 'Pinterest', 'pinterest', (u, t, o) => `https://www.pinterest.com/pin/create/button/?url=${E(u)}&media=${E(o.image)}&description=${E(t)}`],++ ['tumblr', 'Tumblr', 'tumblr', (u, t) => `https://www.tumblr.com/widgets/share/tool?canonicalUrl=${E(u)}&title=${E(t)}`],++ ['hn', 'Hacker News', 'ycombinator', (u, t) => `https://news.ycombinator.com/submitlink?u=${E(u)}&t=${E(t)}`],++ ['line', 'LINE', 'line', (u) => `https://social-plugins.line.me/lineit/share?url=${E(u)}`],++ ];++ const SHOWN = 8; // tiles before the counted fold ("More places (8)")++ const remembered = (k, fallback) => { try { const v = JSON.parse(root.localStorage.getItem(k)); return v == null ? fallback : v; } catch (_) { return fallback; } };++ const remember = (k, v) => { try { root.localStorage.setItem(k, JSON.stringify(v)); } catch (_) { /* private mode */ } };++ function targetsFor(st) {++ const o = st.o;++ const list = TARGETS.filter(([id]) => (id !== 'pinterest' || o.image) && (id !== 'sms' || phoneLike()));++ const recent = remembered('ov-share-recent', []).filter((id) => list.some((t) => t[0] === id)).slice(0, 3);++ return [...recent.map((id) => list.find((t) => t[0] === id)), ...list.filter((t) => !recent.includes(t[0]))];++ }++ const hms = (s) => { s = Math.max(0, Math.floor(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; };++ function theLink(st) {++ const raw = (st.server && st.server.link) || st.o.link || st.o.url || root.location.href;++ let u;++ try { u = new URL(raw, root.location.href); } catch (_) { return raw; }++ if (st.atTime && st.o.time > 0) u.searchParams.set(st.o.timeParam || 't', String(Math.floor(st.o.time)));++ return u.href;++ }++ const shownLink = (href) => href.replace(/^https?:\/\//, '').replace(/\/$/, '');++ function sent(st, target) {++ if (target !== 'copy') remember('ov-share-recent', [target, ...remembered('ov-share-recent', []).filter((x) => x !== target)].slice(0, 6));++ doc.dispatchEvent(new CustomEvent('ov-share:sent', { detail: { target, url: theLink(st), kind: st.o.kind || null, id: st.o.id || null } }));++ }++ // A short word after an action; quiet ones are only said to a screen reader (the button already shows "Copied").++ function toast(st, words, quiet) {++ st.toastWords = words; st.toastQuiet = !!quiet;++ const t = st.dlg.querySelector('.ov-share-toast');++ if (t) { t.textContent = words; t.classList.toggle('is-quiet', !!quiet); t.classList.remove('is-on'); void t.offsetWidth; t.classList.add('is-on'); }++ clearTimeout(st.toastTimer);++ st.toastTimer = setTimeout(() => { st.toastWords = ''; const n = st.dlg.querySelector('.ov-share-toast'); if (n) n.classList.remove('is-on'); }, 2200);++ }++ async function copyText(st, text) {++ try { await root.navigator.clipboard.writeText(text); return true; } catch (_) { /* fall back below */ }++ const t = el('textarea', 'ov-share-copybuf', { readonly: true, 'aria-hidden': 'true' });++ t.value = text; st.dlg.appendChild(t); t.select();++ let ok = false;++ try { ok = doc.execCommand('copy'); } catch (_) { ok = false; }++ t.remove();++ return ok;++ }++ // A file beside this one (brands.js, ov-qr.js), once; resolves when it has loaded, false when it could not.++ const loading = {};++ function loadSibling(name, global) {++ if (root[global]) return Promise.resolve(true);++ if (loading[name]) return loading[name];++ const me = doc.currentScript || doc.querySelector('script[src*="ov-share.js"]');++ const src = (me && me.src ? me.src : '/shared/ov-share.js').replace(/ov-share\.js(\?[^#]*)?.*$/, `${name}$1`);++ loading[name] = new Promise((resolve) => {++ const sc = el('script', null, { src, async: true });++ sc.onload = () => resolve(!!root[global]);++ sc.onerror = () => { loading[name] = null; resolve(false); };++ doc.head.appendChild(sc);++ });++ return loading[name];++ }++ const GLYPH = {++ email: '<rect x="3.5" y="5.5" width="17" height="13" rx="2.5"/><path d="m4.5 7.5 7.5 6 7.5-6"/>',++ sms: '<path d="M5 5.5h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-7l-4.5 3.5V17.5H5a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Z"/>',++ linkedin: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 10.5v5M8.5 8v.01M12 15.5v-5M12 13c0-1.5 1-2.5 2.2-2.5s1.8.8 1.8 2.3v2.7"/>',++ device: '<path d="M12 3.5v11M8 7.5l4-4 4 4"/><path d="M6.5 11H6a2 2 0 0 0-2 2v5.5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V13a2 2 0 0 0-2-2h-.5"/>',++ qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM18 18h2v2h-2zM14 18h2M18 14h2"/>',++ embed: '<path d="m8.5 8-4.5 4 4.5 4M15.5 8l4.5 4-4.5 4M13.5 5.5l-3 13"/>',++ more: '<circle cx="6" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="18" cy="12" r="1.4"/>',++ };++ function tileIcon(id, slug) {++ const s = el('span', `ov-share-tileic is-${id}`, { 'aria-hidden': 'true' });++ const b = slug && root.OVBrands && root.OVBrands.get(slug);++ if (b) { s.style.setProperty('--ov-brand', b.hex); s.innerHTML = root.OVBrands.svg(slug, { size: 22, color: 'currentColor' }); s.classList.add('is-brand'); } // generated, CC0++ else if (GLYPH[id]) s.innerHTML = svg(GLYPH[id]); // constants++ else s.textContent = id.slice(0, 1).toUpperCase();++ return s;++ }++ function tile(st, id, label, slug, href, onClick, i) {++ const t = href++ ? el('a', 'ov-share-tile', { href, target: /^(mailto|sms):/.test(href) ? null : '_blank', rel: 'noopener noreferrer', 'data-k': `to-${id}`, style: `--i:${i}` })++ : el('button', 'ov-share-tile', { type: 'button', 'data-k': `to-${id}`, style: `--i:${i}`, 'aria-expanded': onClick && st.panel === id ? 'true' : null });++ t.appendChild(tileIcon(id, slug));++ t.appendChild(el('span', 'ov-share-tilelabel', null, label));++ t.addEventListener('click', (e) => { if (onClick) { e.preventDefault(); onClick(); } else sent(st, id); });++ return t;++ }++++ function sendSection(st, body) {++ const o = st.o;++ const sec = el('section', 'ov-share-send', { 'aria-label': 'Send it' });++ const href = theLink(st);++ // The link, and Copy: the one thing everybody can do.++ const pill = el('div', `ov-share-linkpill${st.copied ? ' is-copied' : ''}`);++ pill.appendChild(iconEl('link'));++ const inp = el('input', 'ov-share-linktext', { type: 'text', readonly: true, 'aria-label': 'The link', 'data-k': 'link', value: shownLink(href) });++ inp.value = shownLink(href);++ inp.addEventListener('focus', () => { inp.value = href; inp.select(); });++ inp.addEventListener('blur', () => { inp.value = shownLink(href); });++ pill.appendChild(inp);++ const copy = el('button', 'ov-share-btn is-primary ov-share-copy', { type: 'button', 'data-k': 'copy' });++ copy.appendChild(iconEl(st.copied ? 'tick' : 'copy'));++ copy.appendChild(el('span', null, null, st.copied ? 'Copied' : 'Copy'));++ copy.addEventListener('click', async () => {++ const ok = await copyText(st, href);++ if (!ok) { toast(st, 'Select the link and copy it.'); inp.focus(); return; }++ st.copied = true; sent(st, 'copy'); draw(st); toast(st, 'Link copied', true);++ clearTimeout(st.copyTimer);++ st.copyTimer = setTimeout(() => { st.copied = false; if (st.dlg.isConnected) draw(st); }, 1800);++ });++ pill.appendChild(copy);++ sec.appendChild(pill);++ // Who the link opens for, when the owner is looking and it is not open to everyone.++ if (st.server && owner(st) && (st.audience === 'people' || st.audience === 'private')) {++ sec.appendChild(el('p', 'ov-share-muted ov-share-linkfor', null, st.audience === 'private' ? 'Only you can open this link. Choose who else can, below.' : 'Only you and the people below can open this link.'));++ }++ if (o.time > 0) {++ const l = el('label', 'ov-share-check');++ const sw = el('input', null, { type: 'checkbox', role: 'switch', 'data-k': 'at-time' });++ sw.checked = !!st.atTime;++ sw.addEventListener('change', () => { st.atTime = sw.checked; draw(st); });++ l.appendChild(sw);++ l.appendChild(el('span', null, null, `Start at ${hms(o.time)}`));++ sec.appendChild(l);++ }++ // The device's own share sheet: on a phone, its contacts and every app; first, because it reaches the most.++ const canDevice = !!(root.navigator && typeof root.navigator.share === 'function');++ if (canDevice && phoneLike()) {++ const d = el('button', 'ov-share-device', { type: 'button', 'data-k': 'device' });++ d.appendChild(tileIcon('device'));++ const w = el('span', 'ov-share-devicewords');++ w.appendChild(el('span', 'ov-share-devicetitle', null, 'Send with your phone'));++ w.appendChild(el('span', 'ov-share-muted', null, 'Your contacts, messages and apps'));++ d.appendChild(w);++ d.addEventListener('click', () => device(st));++ sec.appendChild(d);++ }++ // Where people post it: a grid, the person's recent ones first, the rest behind a counted fold.++ const all = targetsFor(st);++ const extra = [];++ if (canDevice && !phoneLike()) extra.push(['device', 'More apps', null, null, () => device(st)]);++ extra.push(['qr', 'QR code', null, null, () => { st.panel = st.panel === 'qr' ? '' : 'qr'; draw(st); }]);++ if (o.embedUrl) extra.push(['embed', 'Embed', null, null, () => { st.panel = st.panel === 'embed' ? '' : 'embed'; draw(st); }]);++ const tiles = all.map(([id, label, slug, fn]) => {++ if (id === 'discord') return [id, label, slug, null, async () => { const ok = await copyText(st, href); sent(st, id); toast(st, ok ? 'Link copied: paste it in Discord' : 'Copy the link, then paste it in Discord'); }];++ if (id === 'mastodon') return [id, label, slug, null, () => { st.panel = st.panel === 'mastodon' ? '' : 'mastodon'; draw(st); }];++ return [id, label, slug, fn(href, st.o.title || '', st.o), null];++ });++ const ordered = [...tiles.slice(0, SHOWN - extra.length), ...extra, ...tiles.slice(SHOWN - extra.length)];++ const grid = el('div', 'ov-share-grid', { role: 'group', 'aria-label': 'Send it to' });++ const show = st.allTargets ? ordered : ordered.slice(0, SHOWN);++ show.forEach(([id, label, slug, h, fn], i) => grid.appendChild(tile(st, id, label, slug, h, fn, i)));++ if (!st.allTargets && ordered.length > SHOWN) {++ const more = el('button', 'ov-share-tile is-more', { type: 'button', 'data-k': 'more', style: `--i:${SHOWN}`, 'aria-expanded': 'false' });++ more.appendChild(tileIcon('more'));++ more.appendChild(el('span', 'ov-share-tilelabel', null, `More (${ordered.length - SHOWN})`));++ more.addEventListener('click', () => { st.allTargets = true; draw(st); });++ grid.appendChild(more);++ }++ sec.appendChild(grid);++ if (st.panel === 'mastodon') sec.appendChild(mastodonPanel(st, href));++ if (st.panel === 'qr') sec.appendChild(qrPanel(st, href));++ if (st.panel === 'embed') sec.appendChild(embedPanel(st));++ body.appendChild(sec);++ }++++ async function device(st) {++ const o = st.o;++ const data = { title: o.title || doc.title, url: theLink(st) };++ if (o.text) data.text = o.text;++ try {++ if (root.navigator.canShare && !root.navigator.canShare(data)) throw new Error('cannot');++ await root.navigator.share(data);++ sent(st, 'device'); toast(st, 'Sent');++ } catch (e) {++ if (e && e.name === 'AbortError') return; // the person closed their phone's sheet++ toast(st, 'Your device could not share it. Copy the link instead.');++ }++ }++ function mastodonPanel(st, href) {++ const f = el('form', 'ov-share-panel', { novalidate: true, 'aria-label': 'Share on Mastodon' });++ f.appendChild(el('label', 'ov-share-label', { for: 'ov-share-masto' }, 'Your Mastodon server'));++ const row = el('div', 'ov-share-add');++ const field = el('div', 'ov-share-addfield');++ const inp = el('input', null, { id: 'ov-share-masto', type: 'text', 'data-k': 'masto', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', placeholder: 'mastodon.social', inputmode: 'url' });++ inp.value = remembered('ov-share-mastodon', '');++ field.appendChild(inp);++ row.appendChild(field);++ row.appendChild(el('button', 'ov-share-btn', { type: 'submit' }, 'Open'));++ f.appendChild(row);++ const msg = el('p', 'ov-share-addmsg', { 'aria-live': 'polite' });++ f.appendChild(msg);++ f.addEventListener('submit', (e) => {++ e.preventDefault();++ const host = inp.value.trim().replace(/^https?:\/\//i, '').replace(/^@?[^@]*@/, '').replace(/\/.*$/, '').toLowerCase();++ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) { msg.textContent = 'Type the address of your server, like mastodon.social.'; msg.classList.add('is-bad'); return; }++ remember('ov-share-mastodon', host);++ root.open(`https://${host}/share?text=${E(st.o.title ? `${st.o.title} ${href}` : href)}`, '_blank', 'noopener,noreferrer');++ sent(st, 'mastodon');++ });++ setTimeout(() => inp.focus(), 0);++ return f;++ }++ function qrPanel(st, href) {++ const p = el('div', 'ov-share-panel ov-share-qr', { 'aria-label': 'QR code' });++ const art = el('div', 'ov-share-qrart', { role: 'img', 'aria-label': `A QR code for ${shownLink(href)}` });++ p.appendChild(art);++ const side = el('div', 'ov-share-qrside');++ side.appendChild(el('p', 'ov-share-muted', null, 'Point a phone camera at it to open the link. It is made on this device: the link is not sent anywhere.'));++ const dl = el('button', 'ov-share-btn is-small', { type: 'button', 'data-k': 'qr-save' }, 'Save picture');++ side.appendChild(dl);++ p.appendChild(side);++ loadSibling('ov-qr.js', 'OVQR').then((ok) => {++ if (!ok) { art.textContent = 'The QR code could not load.'; dl.disabled = true; return; }++ let markup;++ try { markup = root.OVQR.svg(href, { ecc: 'M', margin: 2, dark: '#0b0f17', light: '#ffffff' }); } catch (_) { art.textContent = 'This link is too long for a QR code.'; dl.disabled = true; return; }++ art.innerHTML = markup; // made by ov-qr.js from the link, no markup of the link's own++ dl.addEventListener('click', () => {++ const img = new Image();++ img.onload = () => {++ const c = el('canvas'); c.width = c.height = 1024;++ const g = c.getContext('2d'); g.imageSmoothingEnabled = false; g.drawImage(img, 0, 0, 1024, 1024);++ c.toBlob((b) => { if (!b) return; const a = el('a', null, { href: URL.createObjectURL(b), download: `${(st.o.title || 'openvibe').replace(/[^\w.-]+/g, '-').slice(0, 60)}-qr.png` }); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); sent(st, 'qr'); }, 'image/png');++ };++ img.src = `data:image/svg+xml;charset=utf-8,${E(markup)}`;++ });++ });++ return p;++ }++ function embedPanel(st) {++ const o = st.o;++ const p = el('div', 'ov-share-panel', { 'aria-label': 'Embed' });++ // The code is pasted into somebody else's page: every attribute value is escaped as HTML.++ const attr = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++ const t = attr(o.title || 'OpenVibe');++ let src;++ try { src = new URL(o.embedUrl, root.location.href).href; } catch (_) { return p; }++ if (st.atTime && o.time > 0) { const u = new URL(src); u.searchParams.set(o.timeParam || 't', String(Math.floor(o.time))); src = u.href; }++ const code = `<iframe src="${attr(src)}" width="640" height="360" title="${t}" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen loading="lazy" style="border:0;max-width:100%;aspect-ratio:16/9;height:auto"></iframe>`;++ p.appendChild(el('label', 'ov-share-label', { for: 'ov-share-embed' }, 'Embed on your site'));++ const ta = el('textarea', 'ov-share-code', { id: 'ov-share-embed', readonly: true, rows: '3', 'data-k': 'embed-code', spellcheck: 'false' });++ ta.value = code;++ ta.addEventListener('focus', () => ta.select());++ p.appendChild(ta);++ const c = el('button', 'ov-share-btn is-small', { type: 'button', 'data-k': 'embed-copy' }, 'Copy code');++ c.addEventListener('click', async () => { const ok = await copyText(st, code); toast(st, ok ? 'Embed code copied' : 'Select the code and copy it.'); if (ok) sent(st, 'embed'); });++ p.appendChild(c);++ return p;++ }++++ // ── The sheet ─────────────────────────────────────────────────────────────────────────────────────────────++ let current = null; // the open sheet's state++++ function open(opts) {++ const o = Object.assign({}, opts || {});++ if (current) current.close(true);++ stylesheet();++ return new Promise((resolve) => {++ const st = {++ o, server: null, audience: 'private', members: [], notify: true, view: 'main', busy: false,++ note: '', error: '', confirmLeave: false, saved: null, resolve, giveTo: null, keepAccess: false,++ access: !!(o.kind && o.id), first: true, atTime: false, panel: '', allTargets: false,++ };++ st.dlg = el('dialog', 'ov-share', { 'aria-labelledby': 'ov-share-title' });++ st.close = (force) => close(st, force);++ st.dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(st); });++ st.dlg.addEventListener('click', (e) => { if (e.target === st.dlg) close(st); }); // the backdrop++ doc.body.appendChild(st.dlg);++ current = st;++ draw(st);++ st.dlg.showModal();++ doc.documentElement.classList.add('ov-share-locked');++ if (reduce()) st.dlg.classList.add('is-open');++ else root.requestAnimationFrame(() => root.requestAnimationFrame(() => st.dlg.classList.add('is-open')));++ if (st.access) load(st);++ if (!root.OVBrands) loadSibling('brands.js', 'OVBrands').then((ok) => { if (ok && st.dlg.isConnected) draw(st); });++ });++ }++++ async function load(st) {++ try {++ take(st, await call(st.o, 'GET', endpoint(st.o).path));++ st.error = '';++ } catch (e) {++ // Not the owner (or signed out): this person can still send it; who can open it is not theirs to see.++ if ([401, 403, 404].includes(e.status)) st.access = false;++ else st.error = `Could not load who can open this. ${e.message}`;++ }++ draw(st);++ }++ function take(st, s) {++ st.server = s;++ if (s.__etag) st.etag = s.__etag;++ st.audience = s.audience || 'private';++ st.members = (s.members || []).map((m) => Object.assign({}, m));++ st.confirmLeave = false;++ }++ const owner = (st) => !!(st.server && st.server.you && st.server.you.role === 'owner');++ const roles = (st) => (st.server && Array.isArray(st.server.roles) && st.server.roles.length ? st.server.roles : ['viewer']);++ function memberKey(m) { return m.subject ? sid(m.subject) : `name:${String(m.name || m.username).toLowerCase()}`; }++ function dirty(st) {++ if (!st.server) return false;++ if (st.audience !== (st.server.audience || 'private')) return true;++ const a = (st.server.members || []).map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');++ const b = st.members.map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');++ return a !== b;++ }++++ function close(st, force) {++ if (!st.dlg.isConnected) return;++ if (!force && dirty(st) && !st.confirmLeave) { st.confirmLeave = true; draw(st); return; }++ if (current === st) current = null;++ doc.documentElement.classList.remove('ov-share-locked');++ const done = () => { try { st.dlg.close(); } catch (_) { /* closed */ } st.dlg.remove(); st.resolve(st.saved); };++ if (reduce()) return done();++ st.dlg.classList.remove('is-open');++ let ended = false;++ const end = () => { if (!ended) { ended = true; done(); } };++ st.dlg.addEventListener('transitionend', end, { once: true });++ setTimeout(end, 260);++ }++++ // Draw the whole sheet again from its state: small enough that keeping it simple beats patching it.++ function draw(st, focus) {++ const keep = doc.activeElement && st.dlg.contains(doc.activeElement) ? doc.activeElement.getAttribute('data-k') : null;++ const box = el('div', `ov-share-box${st.first ? ' is-first' : ''}`);++ st.first = false;++ box.appendChild(head(st));++ const body = el('div', 'ov-share-body');++ if (st.view === 'give') giveView(st, body); else mainView(st, body);++ box.appendChild(body);++ box.appendChild(foot(st));++ box.appendChild(el('p', `ov-share-toast${st.toastWords ? ' is-on' : ''}${st.toastQuiet ? ' is-quiet' : ''}`, { role: 'status', 'aria-live': 'polite' }, st.toastWords || ''));++ st.dlg.replaceChildren(box);++ // A panel just opened (QR code, Embed, Mastodon) comes into view in the sheet's scroll.++ if (st.panel && st.panel !== st.shownPanel) {++ const pnl = st.dlg.querySelector('.ov-share-panel');++ if (pnl && pnl.scrollIntoView) pnl.scrollIntoView({ block: 'nearest', behavior: reduce() ? 'auto' : 'smooth' });++ }++ st.shownPanel = st.panel;++ const want = keep ? st.dlg.querySelector(`[data-k="${keep}"]`) : null;++ if (want) want.focus();++ else if (focus || !keep) {++ const first = st.dlg.querySelector('[data-k="give-name"]') || st.dlg.querySelector('[data-k="copy"]') || st.dlg.querySelector('[data-k="close"]');++ if (first) first.focus();++ }++ }++++ function head(st) {++ const h = el('div', 'ov-share-head');++ if (st.view === 'give') {++ const b = el('button', 'ov-share-iconbtn', { type: 'button', 'aria-label': 'Back', 'data-k': 'back' });++ b.appendChild(iconEl('back'));++ b.addEventListener('click', () => { st.view = 'main'; st.error = ''; draw(st, true); });++ h.appendChild(b);++ }++ const title = (st.server && st.server.title) || st.o.title || '';++ const t = el('h2', 'ov-share-title', { id: 'ov-share-title' });++ t.textContent = st.view === 'give' ? 'Transfer ownership' : (title ? `Share “${title}”` : 'Share');++ h.appendChild(t);++ const x = el('button', 'ov-share-iconbtn', { type: 'button', 'aria-label': 'Close', 'data-k': 'close' });++ x.appendChild(iconEl('close'));++ x.addEventListener('click', () => close(st));++ h.appendChild(x);++ return h;++ }++++ function mainView(st, body) {++ sendSection(st, body);++ if (!st.access) return;++ if (!st.server) {++ body.appendChild(el('p', st.error ? 'ov-share-error' : 'ov-share-muted', { role: st.error ? 'alert' : null }, st.error || 'Loading who can open this\u2026'));++ return;++ }++ const s = st.server;++ const mine = owner(st);++ body.appendChild(el('hr', 'ov-share-rule'));++ if (s.transfer && s.transfer.state === 'pending' && mine) body.appendChild(pendingBanner(st));++++ // Who can open this: four choices, one line saying what the chosen one means.++ const offered = (Array.isArray(s.audiences) && s.audiences.length ? s.audiences : Object.keys(AUDIENCES)).filter((a) => AUDIENCES[a]);++ const fs = el('fieldset', 'ov-share-aud', { disabled: !mine || st.busy });++ fs.appendChild(el('legend', 'ov-share-label', null, 'Who can open this'));++ const seg = el('div', 'ov-share-seg', { style: `--n:${offered.length}` });++ for (const a of offered) {++ const lab = el('label', 'ov-share-segopt', { title: AUDIENCES[a].title });++ const r = el('input', null, { type: 'radio', name: 'ov-share-aud', value: a, 'data-k': `aud-${a}` });++ r.checked = st.audience === a;++ r.addEventListener('change', () => { st.audience = a; st.note = ''; draw(st); });++ lab.appendChild(r);++ lab.appendChild(iconEl(a));++ lab.appendChild(el('span', 'ov-share-seglabel', null, AUDIENCES[a].label));++ seg.appendChild(lab);++ }++ fs.appendChild(seg);++ const desc = el('p', 'ov-share-desc', { 'aria-live': 'polite' });++ desc.appendChild(el('strong', null, null, `${AUDIENCES[st.audience] ? AUDIENCES[st.audience].title : st.audience}. `));++ desc.appendChild(doc.createTextNode(AUDIENCES[st.audience] ? AUDIENCES[st.audience].desc : ''));++ fs.appendChild(desc);++ body.appendChild(fs);++++ // People: who has access (or, on an open item, who may edit it).++ const open = st.audience === 'public' || st.audience === 'link';++ const canEditRole = roles(st).includes('editor');++ const listMeans = open ? (canEditRole ? 'edit' : 'nothing') : 'open';++ const sec = el('section', 'ov-share-people', { 'aria-labelledby': 'ov-share-people-h' });++ const hd = el('div', 'ov-share-sechead');++ hd.appendChild(el('h3', 'ov-share-label', { id: 'ov-share-people-h' }, open ? 'People who can edit' : 'People with access'));++ sec.appendChild(hd);++ if (mine && listMeans !== 'nothing') sec.appendChild(adder(st));++ if (st.note) sec.appendChild(el('p', 'ov-share-note', { role: 'status' }, st.note));++ if (st.audience === 'private' && st.members.length) {++ sec.appendChild(el('p', 'ov-share-warn', null, st.members.length === 1++ ? 'Only me: saving removes the person below.' : `Only me: saving removes the ${st.members.length} people below.`));++ }++ if (open && listMeans === 'nothing' && st.members.length) {++ sec.appendChild(el('p', 'ov-share-muted', null, 'Anyone can open it now, so this list does nothing. Choose People to limit it to them.'));++ }++ const ul = el('ul', 'ov-share-list');++ if (s.owner) ul.appendChild(personRow(st, s.owner, { tag: 'Owner', you: s.you && same(s.owner.subject, s.you.subject) }));++ st.members.forEach((m, i) => ul.appendChild(personRow(st, m, { member: m, index: i, dim: st.audience === 'private' })));++ sec.appendChild(ul);++ if (!st.members.length && listMeans === 'open' && mine) {++ sec.appendChild(el('p', 'ov-share-muted', null, st.audience === 'private' ? 'Add someone by name to share it with them.' : 'Nobody else yet. Add someone by their OpenVibe name.'));++ }++ if (st.members.some((m) => m.isNew)) {++ const nl = el('label', 'ov-share-check');++ const cb = el('input', null, { type: 'checkbox', 'data-k': 'notify' });++ cb.checked = st.notify;++ cb.addEventListener('change', () => { st.notify = cb.checked; });++ nl.appendChild(cb);++ nl.appendChild(el('span', null, null, 'Tell the people I add (a notification on OpenVibe)'));++ sec.appendChild(nl);++ }++ body.appendChild(sec);++++ // People who asked.++ const asks = (s.requests || []).filter((r) => !st.members.some((m) => same(m.subject, r.subject)));++ if (mine && asks.length) {++ const rs = el('section', 'ov-share-people', { 'aria-labelledby': 'ov-share-asks-h' });++ rs.appendChild(el('h3', 'ov-share-label', { id: 'ov-share-asks-h' }, `Asking for access (${asks.length})`));++ const rl = el('ul', 'ov-share-list');++ for (const r of asks) rl.appendChild(requestRow(st, r));++ rs.appendChild(rl);++ body.appendChild(rs);++ }++ if (!mine) {++ body.appendChild(el('p', 'ov-share-muted', null, s.owner && s.owner.username++ ? `Only ${handle(s.owner)} can change who can open this.` : 'Only the owner can change who can open this.'));++ }++ if (st.error) body.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error));++ }++++ // The @name field: Enter or Add checks the name with Network (through the service), then adds a chip-row.++ function adder(st) {++ const wrap = el('form', 'ov-share-add', { novalidate: true });++ const field = el('div', 'ov-share-addfield');++ field.appendChild(el('span', 'ov-share-at', { 'aria-hidden': 'true' }, '@'));++ const inp = el('input', null, {++ type: 'text', name: 'name', 'data-k': 'add', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false',++ enterkeyhint: 'done', maxlength: '41', placeholder: 'Add people by their OpenVibe name', 'aria-label': 'Add people by their OpenVibe name',++ 'aria-describedby': 'ov-share-add-msg',++ });++ if (st.draftName) inp.value = st.draftName;++ inp.addEventListener('input', () => { st.draftName = inp.value; if (st.addMsg) { st.addMsg = ''; msg.textContent = ''; msg.className = 'ov-share-addmsg'; } });++ field.appendChild(inp);++ wrap.appendChild(field);++ const btn = el('button', 'ov-share-btn', { type: 'submit', 'data-k': 'add-btn' }, st.checking ? 'Checking…' : 'Add');++ btn.disabled = !!st.checking;++ wrap.appendChild(btn);++ const msg = el('p', `ov-share-addmsg${st.addMsgBad ? ' is-bad' : ''}`, { id: 'ov-share-add-msg', 'aria-live': 'polite' }, st.addMsg || '');++ wrap.appendChild(msg);++ wrap.addEventListener('submit', (e) => { e.preventDefault(); addNames(st, inp.value); });++ const sugg = suggestionsFor(st);++ if (sugg.length) {++ const row = el('div', 'ov-share-sugg', { role: 'group', 'aria-label': 'People you have shared with' });++ for (const p of sugg) {++ const c = el('button', 'ov-share-chip', { type: 'button', 'data-k': `sugg-${p.username}` });++ c.appendChild(avatar(p));++ c.appendChild(el('span', null, null, handle(p)));++ c.addEventListener('click', () => addPerson(st, p));++ row.appendChild(c);++ }++ wrap.appendChild(row);++ }++ return wrap;++ }++ function suggestionsFor(st) {++ const have = new Set(st.members.map((m) => sid(m.subject)));++ if (st.server && st.server.owner) have.add(sid(st.server.owner.subject));++ return (st.suggested || []).filter((p) => p && p.subject && !have.has(sid(p.subject))).slice(0, 6);++ }++++ async function addNames(st, raw) {++ const names = String(raw || '').split(/[\s,;]+/).map(cleanName).filter(Boolean);++ if (!names.length) return;++ const bad = names.filter((n) => !NAME_RE.test(n));++ if (bad.length) { st.addMsg = `“${bad[0]}” is not an OpenVibe name: letters, numbers, dots, dashes and underscores only.`; st.addMsgBad = true; draw(st); return; }++ st.checking = true; st.addMsg = ''; draw(st);++ const missing = [];++ try {++ for (const n of names) {++ const p = await lookup(st.o, n);++ if (p === null) missing.push(n);++ else if (p === undefined) addPerson(st, { name: n, username: n, unchecked: true }, true);++ else addPerson(st, p, true);++ }++ st.draftName = missing.join(' ');++ st.addMsg = missing.length ? `No OpenVibe account is called ${missing.map((n) => `@${n}`).join(', ')}.` : '';++ st.addMsgBad = !!missing.length;++ } catch (e) {++ st.addMsg = e.message; st.addMsgBad = true;++ }++ st.checking = false;++ draw(st);++ }++ function addPerson(st, p, quiet) {++ const s = st.server;++ if (s.owner && p.subject && same(p.subject, s.owner.subject)) { st.addMsg = `${handle(p)} owns this already.`; st.addMsgBad = false; if (!quiet) draw(st); return; }++ if (st.members.some((m) => memberKey(m) === memberKey(p))) { st.addMsg = `${handle(p)} is on the list already.`; st.addMsgBad = false; if (!quiet) draw(st); return; }++ const open = st.audience === 'public' || st.audience === 'link';++ const role = open && roles(st).includes('editor') ? 'editor' : 'viewer';++ st.members.push({ subject: p.subject, name: p.subject ? undefined : p.name, username: p.username, display_name: p.display_name, avatar_url: p.avatar_url, role, expires_at: null, isNew: true, unchecked: !!p.unchecked });++ if (st.audience === 'private') { st.audience = 'people'; st.note = 'Switched to Specific people, so they can open it.'; }++ if (!quiet) { st.draftName = ''; draw(st); }++ }++++ function personRow(st, p, x) {++ const li = el('li', `ov-share-row${x.dim ? ' is-dim' : ''}${x.member && x.member.isNew ? ' is-new' : ''}`);++ li.appendChild(avatar(p));++ const words = el('div', 'ov-share-words');++ const top = el('div', 'ov-share-name');++ top.appendChild(el('span', 'ov-share-display', null, nameOf(p)));++ if (x.you || (st.server && st.server.you && p.subject && same(p.subject, st.server.you.subject))) top.appendChild(el('span', 'ov-share-tag', null, 'You'));++ if (x.member && x.member.isNew) top.appendChild(el('span', 'ov-share-tag is-new', null, 'New'));++ words.appendChild(top);++ const sub = el('div', 'ov-share-sub');++ if (p.username) sub.appendChild(el('span', null, null, handle(p)));++ if (x.member && x.member.unchecked) sub.appendChild(el('span', 'ov-share-soft', null, 'checked when you save'));++ if (x.member) sub.appendChild(expiry(st, x.member));++ words.appendChild(sub);++ li.appendChild(words);++ if (x.tag) { li.appendChild(el('span', 'ov-share-role is-fixed', null, x.tag)); return li; }++ const m = x.member;++ if (!owner(st)) { li.appendChild(el('span', 'ov-share-role is-fixed', null, ROLE_WORDS[m.role] || m.role)); return li; }++ const rs = roles(st);++ if (rs.length > 1) {++ const sel = el('select', 'ov-share-select', { 'aria-label': `What ${handle(m) || nameOf(m)} can do`, 'data-k': `role-${memberKey(m)}`, 'data-ov-select': 'bare' });++ for (const r of rs) { const op = el('option', null, { value: r }, ROLE_WORDS[r] || r); op.selected = m.role === r; sel.appendChild(op); }++ sel.addEventListener('change', () => { m.role = sel.value; draw(st); });++ const pill = el('span', 'ov-share-rolepill');++ pill.appendChild(sel);++ li.appendChild(pill);++ } else li.appendChild(el('span', 'ov-share-role is-fixed', null, ROLE_WORDS[m.role] || m.role));++ const rm = el('button', 'ov-share-iconbtn is-small', { type: 'button', 'aria-label': `Remove ${handle(m) || nameOf(m)}`, 'data-k': `rm-${memberKey(m)}` });++ rm.appendChild(iconEl('close'));++ rm.addEventListener('click', () => {++ st.members.splice(st.members.indexOf(m), 1);++ st.note = m.isNew ? '' : `${handle(m) || nameOf(m)} loses access when you save. They may still have a copy of what they already opened.`;++ draw(st);++ });++ li.appendChild(rm);++ return li;++ }++++ // "until Nov 30": an end date for one person's access, set with the browser's date picker.++ function expiry(st, m) {++ const wrap = el('span', 'ov-share-exp');++ if (!owner(st)) { if (m.expires_at) wrap.textContent = `until ${when(m.expires_at)}`; return wrap; }++ if (st.editingExp === memberKey(m)) {++ const d = el('input', 'ov-share-date', { type: 'date', 'aria-label': `Last day for ${handle(m) || nameOf(m)}`, 'data-k': `date-${memberKey(m)}` });++ const t = new Date(Date.now() + 86400000);++ d.min = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;++ if (m.expires_at) { const e = new Date(m.expires_at); d.value = `${e.getFullYear()}-${String(e.getMonth() + 1).padStart(2, '0')}-${String(e.getDate()).padStart(2, '0')}`; }++ const commit = () => {++ if (d.value) { const [y, mo, da] = d.value.split('-').map(Number); m.expires_at = new Date(y, mo - 1, da, 23, 59, 59).toISOString(); }++ st.editingExp = null; draw(st);++ };++ d.addEventListener('change', commit);++ d.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); st.editingExp = null; draw(st); } });++ wrap.appendChild(d);++ if (m.expires_at) {++ const none = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': `noexp-${memberKey(m)}` }, 'No end date');++ none.addEventListener('click', () => { m.expires_at = null; st.editingExp = null; draw(st); });++ wrap.appendChild(none);++ }++ setTimeout(() => { try { d.focus(); if (d.showPicker) d.showPicker(); } catch (_) { /* a picker needs a gesture in some browsers */ } }, 0);++ return wrap;++ }++ const b = el('button', 'ov-share-link', { type: 'button', 'data-k': `exp-${memberKey(m)}` }, m.expires_at ? `until ${when(m.expires_at)}` : 'Set an end date');++ b.addEventListener('click', () => { st.editingExp = memberKey(m); draw(st); });++ if (m.expires_at) b.setAttribute('aria-label', `Access for ${handle(m) || nameOf(m)} ends ${when(m.expires_at, true)}: change`);++ wrap.appendChild(b);++ return wrap;++ }++++ function requestRow(st, r) {++ const li = el('li', 'ov-share-row');++ li.appendChild(avatar(r));++ const words = el('div', 'ov-share-words');++ words.appendChild(el('div', 'ov-share-name', null, nameOf(r)));++ const sub = el('div', 'ov-share-sub');++ if (r.username) sub.appendChild(el('span', null, null, handle(r)));++ if (r.requested_at) sub.appendChild(el('span', null, null, `asked ${when(r.requested_at)}`));++ words.appendChild(sub);++ li.appendChild(words);++ const add = el('button', 'ov-share-btn is-small', { type: 'button', 'data-k': `req-add-${sid(r.subject)}` }, 'Add');++ add.addEventListener('click', () => addPerson(st, r));++ li.appendChild(add);++ const no = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': `req-no-${sid(r.subject)}` }, 'Dismiss');++ no.addEventListener('click', async () => {++ no.disabled = true;++ try {++ await call(st.o, 'POST', `${endpoint(st.o).path}/requests/${encodeURIComponent(r.id)}/dismiss`, {});++ st.server.requests = (st.server.requests || []).filter((q) => q !== r);++ } catch (e) { st.error = `Could not dismiss: ${e.message}`; }++ draw(st);++ });++ li.appendChild(no);++ return li;++ }++++ function pendingBanner(st) {++ const t = st.server.transfer;++ const b = el('div', 'ov-share-banner', { role: 'status' });++ b.appendChild(iconEl('give'));++ const p = el('p');++ p.appendChild(doc.createTextNode('Waiting for '));++ p.appendChild(el('strong', null, null, handle(t.to) || nameOf(t.to)));++ p.appendChild(doc.createTextNode(` to accept ownership${t.expires_at ? `. The offer ends ${when(t.expires_at)}` : ''}.`));++ b.appendChild(p);++ const c = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'cancel-give' }, 'Cancel offer');++ c.addEventListener('click', async () => {++ st.busy = true; draw(st);++ try { take(st, await call(st.o, 'POST', `${endpoint(st.o).path}/transfer/cancel`, { transfer_id: t.id })); st.note = 'Offer cancelled. It is still yours.'; announce(st.o, st.server); }++ catch (e) { st.error = `Could not cancel: ${e.message}`; }++ st.busy = false; draw(st);++ });++ b.appendChild(c);++ return b;++ }++++ // ── Transfer ownership: pick one person, say what happens to your own access, send ─────────────────────────++ function giveView(st, body) {++ const title = (st.server && st.server.title) || st.o.title;++ body.appendChild(el('p', 'ov-share-lead', null, `Give ${title ? `“${title}”` : 'this'} to someone else. They become the owner when they accept, and can then decide who else may open it.`));++ const f = el('form', 'ov-share-give', { novalidate: true });++ const lab = el('label', 'ov-share-label', { for: 'ov-share-give-name' }, 'New owner');++ f.appendChild(lab);++ const field = el('div', 'ov-share-addfield');++ field.appendChild(el('span', 'ov-share-at', { 'aria-hidden': 'true' }, '@'));++ const inp = el('input', null, { id: 'ov-share-give-name', type: 'text', 'data-k': 'give-name', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', maxlength: '41', placeholder: 'their OpenVibe name', 'aria-describedby': 'ov-share-give-msg' });++ if (st.giveTo) inp.value = st.giveTo.username || '';++ else if (st.giveDraft) inp.value = st.giveDraft;++ inp.addEventListener('input', () => { st.giveDraft = inp.value; st.giveTo = null; });++ field.appendChild(inp);++ f.appendChild(field);++ f.appendChild(el('p', `ov-share-addmsg${st.giveBad ? ' is-bad' : ''}`, { id: 'ov-share-give-msg', 'aria-live': 'polite' }, st.giveMsg || ''));++ const people = st.members.filter((m) => m.subject && !m.isNew);++ if (people.length && !st.giveTo) {++ const row = el('div', 'ov-share-sugg', { role: 'group', 'aria-label': 'People with access' });++ for (const p of people.slice(0, 6)) {++ const c = el('button', 'ov-share-chip', { type: 'button', 'data-k': `give-${p.username}` });++ c.appendChild(avatar(p));++ c.appendChild(el('span', null, null, handle(p)));++ c.addEventListener('click', () => { st.giveTo = p; st.giveMsg = `${nameOf(p)} (${handle(p)}) will be asked.`; st.giveBad = false; draw(st); });++ row.appendChild(c);++ }++ f.appendChild(row);++ }++ const keep = el('fieldset', 'ov-share-keep');++ keep.appendChild(el('legend', 'ov-share-label', null, 'When they accept'));++ for (const [v, t, d] of [[false, 'Remove my access', 'Recommended. It is theirs alone; they can share it back with you.'], [true, 'Keep me as a viewer', 'You can still open it, but not change it.']]) {++ const l = el('label', 'ov-share-radio');++ const r = el('input', null, { type: 'radio', name: 'ov-share-keep', 'data-k': `keep-${v}` });++ r.checked = st.keepAccess === v;++ r.addEventListener('change', () => { st.keepAccess = v; });++ l.appendChild(r);++ const w = el('span', 'ov-share-radiowords');++ w.appendChild(el('span', 'ov-share-radiotitle', null, t));++ w.appendChild(el('span', 'ov-share-muted', null, d));++ l.appendChild(w);++ keep.appendChild(l);++ }++ f.appendChild(keep);++ f.appendChild(el('p', 'ov-share-muted', null, 'They have 7 days to accept, and can open it meanwhile to look. Until they accept it stays yours, and you can cancel the offer.'));++ if (st.error) f.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error));++ f.addEventListener('submit', (e) => { e.preventDefault(); sendOffer(st, inp.value); });++ body.appendChild(f);++ }++ async function sendOffer(st, raw) {++ st.error = '';++ let to = st.giveTo;++ if (!to) {++ const n = cleanName(raw);++ if (!NAME_RE.test(n)) { st.giveMsg = 'Type their OpenVibe name.'; st.giveBad = true; draw(st); return; }++ st.busy = true; st.giveMsg = 'Checking…'; st.giveBad = false; draw(st);++ try {++ const p = await lookup(st.o, n);++ if (p === null) { st.busy = false; st.giveMsg = `No OpenVibe account is called @${n}.`; st.giveBad = true; draw(st); return; }++ to = p || { name: n, username: n };++ } catch (e) { st.busy = false; st.giveMsg = e.message; st.giveBad = true; draw(st); return; }++ }++ if (st.server.owner && to.subject && same(to.subject, st.server.owner.subject)) { st.busy = false; st.giveMsg = 'That is you.'; st.giveBad = true; draw(st); return; }++ st.busy = true; draw(st);++ try {++ const out = await call(st.o, 'POST', `${endpoint(st.o).path}/transfer`, { to: to.subject ? { subject: to.subject } : { name: to.name }, keep_access: !!st.keepAccess });++ if (out && out.audience) take(st, out); else take(st, await call(st.o, 'GET', endpoint(st.o).path));++ st.view = 'main'; st.giveTo = null; st.giveDraft = ''; st.giveMsg = '';++ st.note = `Offer sent to ${handle(to) || nameOf(to)}. It stays yours until they accept.`;++ announce(st.o, st.server);++ } catch (e) {++ st.error = e.status === 404 && e.body && /name|person|subject/i.test(JSON.stringify(e.body)) ? `No OpenVibe account is called ${handle(to)}.` : `Could not send the offer: ${e.message}`;++ }++ st.busy = false; draw(st, true);++ }++++ function foot(st) {++ const f = el('div', 'ov-share-foot');++ if (st.view === 'give') {++ const c = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': 'give-cancel' }, 'Back');++ c.addEventListener('click', () => { st.view = 'main'; st.error = ''; draw(st, true); });++ const s = el('button', 'ov-share-btn is-primary', { type: 'button', 'data-k': 'give-send', disabled: st.busy }, st.busy ? 'Sending…' : 'Send offer');++ s.addEventListener('click', () => { const i = st.dlg.querySelector('[data-k="give-name"]'); sendOffer(st, i ? i.value : ''); });++ f.appendChild(el('span', 'ov-share-grow'));++ f.appendChild(c); f.appendChild(s);++ return f;++ }++ if (st.confirmLeave) {++ f.classList.add('is-confirm');++ f.appendChild(el('p', 'ov-share-grow', { role: 'alert' }, 'Leave without saving your changes?'));++ const k = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': 'keep-editing' }, 'Keep editing');++ k.addEventListener('click', () => { st.confirmLeave = false; draw(st); });++ const d = el('button', 'ov-share-btn is-danger', { type: 'button', 'data-k': 'discard' }, 'Discard');++ d.addEventListener('click', () => close(st, true));++ f.appendChild(k); f.appendChild(d);++ return f;++ }++ const s = st.server;++ if (s && owner(st) && !(s.transfer && s.transfer.state === 'pending') && s.transferable !== false) {++ const g = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': 'give' });++ g.appendChild(iconEl('give'));++ g.appendChild(el('span', null, null, 'Transfer'));++ g.setAttribute('aria-label', 'Transfer ownership');++ g.addEventListener('click', () => { st.view = 'give'; st.error = ''; st.giveMsg = ''; draw(st, true); });++ f.appendChild(g);++ }++ f.appendChild(el('span', 'ov-share-grow'));++ const isDirty = dirty(st);++ const done = el('button', `ov-share-btn ${isDirty ? 'is-primary' : ''}`, { type: 'button', 'data-k': 'done', disabled: st.busy || (isDirty && st.checking) }, st.busy ? 'Saving…' : isDirty ? 'Save' : 'Done');++ done.addEventListener('click', () => (isDirty ? save(st) : close(st)));++ f.appendChild(done);++ return f;++ }++++ async function save(st) {++ st.busy = true; st.error = ''; draw(st);++ const members = st.audience === 'private' ? [] : st.members.map((m) => {++ const r = { role: m.role };++ if (m.expires_at) r.expires_at = m.expires_at;++ if (m.subject) r.subject = m.subject; else r.name = m.name || m.username;++ return r;++ });++ try {++ const out = await call(st.o, 'PUT', endpoint(st.o).path, { audience: st.audience, members, notify: !!st.notify }, st.etag ? { 'if-match': st.etag } : null);++ take(st, out && out.audience ? out : await call(st.o, 'GET', endpoint(st.o).path));++ st.saved = st.server; st.note = 'Saved.';++ announce(st.o, st.server);++ } catch (e) {++ if (e.status === 412) {++ try { take(st, await call(st.o, 'GET', endpoint(st.o).path)); } catch (_) { /* keep what is drawn */ }++ st.busy = false; st.error = 'Someone changed who can open this meanwhile. This is how it is now; make your change again.';++ draw(st); return;++ }++ const names = e.body && Array.isArray(e.body.names) ? e.body.names : null;++ st.error = names && names.length ? `No OpenVibe account is called ${names.map((n) => `@${n}`).join(', ')}. Remove them, then save.` : `Could not save: ${e.message}`;++ }++ st.busy = false; draw(st);++ }++++ // ── For the recipient of a transfer: a banner on the item ─────────────────────────────────────────────────++ function offer(container, opts) {++ const o = Object.assign({}, opts || {});++ stylesheet();++ const b = el('div', 'ov-share-banner is-offer', { role: 'region', 'aria-label': 'Ownership offer' });++ const drawOffer = (state, err) => {++ b.replaceChildren();++ b.appendChild(iconEl(state === 'accepted' ? 'tick' : 'give'));++ const p = el('p');++ if (state === 'accepted') p.textContent = 'It is yours now. You decide who else can open it.';++ else if (state === 'declined') p.textContent = 'You declined. Nothing changed.';++ else {++ p.appendChild(el('strong', null, null, handle(o.from) || nameOf(o.from)));++ p.appendChild(doc.createTextNode(` wants to give you ${o.title ? `“${o.title}”` : 'this'}. `));++ p.appendChild(doc.createTextNode(o.keep_access ? 'They would keep view access. ' : 'They would lose access. '));++ if (o.expires_at) p.appendChild(doc.createTextNode(`The offer ends ${when(o.expires_at)}.`));++ }++ b.appendChild(p);++ if (err) b.appendChild(el('p', 'ov-share-error', { role: 'alert' }, err));++ if (!state) {++ const acts = el('div', 'ov-share-acts');++ const yes = el('button', 'ov-share-btn is-primary is-small', { type: 'button' }, 'Accept');++ const no = el('button', 'ov-share-btn is-quiet is-small', { type: 'button' }, 'Decline');++ const answer = async (verb) => {++ yes.disabled = no.disabled = true;++ try {++ const out = await call(o, 'POST', `${endpoint(o).path}/transfer/${verb}`, { transfer_id: o.transfer_id });++ announce(o, out);++ drawOffer(verb === 'accept' ? 'accepted' : 'declined');++ if (typeof o.onDone === 'function') o.onDone(verb === 'accept' ? 'accepted' : 'declined', out);++ } catch (e) { drawOffer(null, e.status === 410 || e.status === 404 ? 'This offer is no longer open.' : `Could not answer: ${e.message}`); }++ };++ yes.addEventListener('click', () => answer('accept'));++ no.addEventListener('click', () => answer('decline'));++ acts.appendChild(yes); acts.appendChild(no);++ b.appendChild(acts);++ }++ };++ drawOffer(null);++ container.replaceChildren(b);++ return b;++ }++++ // ── For a person who is not on the list: ask, or sign in. Never the title or the content. ─────────────────++ function gate(container, opts) {++ const o = Object.assign({}, opts || {});++ stylesheet();++ const card = el('div', 'ov-share-gate', { role: 'region', 'aria-label': 'Access' });++ const drawGate = (state, err) => {++ card.replaceChildren();++ card.appendChild(iconEl('people', 'ov-share-ico is-big'));++ card.appendChild(el('h2', 'ov-share-gatetitle', null, state === 'asked' ? 'Asked' : 'This is shared with specific people'));++ if (!o.signedInAs) {++ card.appendChild(el('p', 'ov-share-muted', null, 'Sign in with the OpenVibe account it was shared with.'));++ const a = el('a', 'ov-share-btn is-primary', { href: o.signInUrl || '/login' }, 'Sign in');++ card.appendChild(a);++ } else if (state === 'asked') {++ card.appendChild(el('p', 'ov-share-muted', null, 'The owner has a notification. You will get one when they answer.'));++ } else {++ const p = el('p', 'ov-share-muted');++ p.appendChild(doc.createTextNode('You are signed in as '));++ p.appendChild(el('strong', null, null, `@${cleanName(o.signedInAs)}`));++ p.appendChild(doc.createTextNode(', who is not on the list.'));++ card.appendChild(p);++ if (err) card.appendChild(el('p', 'ov-share-error', { role: 'alert' }, err));++ const acts = el('div', 'ov-share-acts');++ const ask = el('button', 'ov-share-btn is-primary', { type: 'button' }, 'Ask for access');++ ask.addEventListener('click', async () => {++ ask.disabled = true;++ try { await call(o, 'POST', `${endpoint(o).path}/requests`, {}); drawGate('asked'); if (typeof o.onDone === 'function') o.onDone('asked'); }++ catch (e) { drawGate(null, e.status === 429 ? 'You asked recently. The owner has it.' : `Could not ask: ${e.message}`); }++ });++ acts.appendChild(ask);++ if (o.signInUrl) acts.appendChild(el('a', 'ov-share-btn is-quiet', { href: o.signInUrl }, 'Switch account'));++ card.appendChild(acts);++ }++ };++ drawGate(null);++ container.replaceChildren(card);++ return card;++ }++++ // ── Buttons in markup: [data-ov-share-sheet] ──────────────────────────────────────────────────────────────++ doc.addEventListener('click', (e) => {++ const b = e.target && e.target.closest ? e.target.closest('[data-ov-share-sheet]') : null;++ if (!b || b.disabled) return;++ e.preventDefault();++ open(fromButton(b));++ });++++ // A button's data-* as open()'s options (navbar.js and kit-web.js use it too, once this file has loaded).++ function fromButton(b) {++ const d = b.dataset || {};++ let time = 0;++ if (d.timeFrom) { const m = doc.querySelector(d.timeFrom); if (m && typeof m.currentTime === 'number' && isFinite(m.currentTime)) time = m.currentTime; }++ else if (d.time) time = Number(d.time) || 0;++ return { kind: d.kind, id: d.id, title: d.title || doc.title, text: d.text, url: d.url || d.link, image: d.image, embedUrl: d.embed,++ time: time >= 5 ? time : 0, timeParam: d.timeParam, base: d.base, path: d.path };++ }++++ root.OVShare = { open, offer, gate, fromButton, version: 2 };++})(typeof window !== 'undefined' ? window : null);@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.113.0",++ "version": "4.114.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.113.0",++ "version": "4.114.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.113.0",++ "version": "4.114.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -56,6 +56,7 @@"./ov-glyphs": "./ov-glyphs.js","./ov-forms": "./ov-forms.js","./ov-qr": "./ov-qr.js",++ "./ov-share": "./ov-share.js","./ov-labels": "./ov-labels.js","./ov-look": "./ov-look.js","./ov-fx": "./ov-fx.js",@@@ -79,6 +79,7 @@ const COMPOSE_IMPORTS = ['androidx.compose.material.icons.filled.Error','androidx.compose.material.icons.filled.ExpandMore','androidx.compose.material.icons.filled.Info',++ 'androidx.compose.material.icons.filled.Share','androidx.compose.material3.Button','androidx.compose.material3.ButtonDefaults','androidx.compose.material3.DrawerState',@@@ -41,6 +41,9 @@ sealed class KitAction {/** Run the action a button names (the app decides what that means). */data class Run(val id: String) : KitAction()++++ /** Share a link: the app opens the platform's own share sheet (Android's chooser, with Direct Share). */++ data class Share(val title: String, val url: String) : KitAction()}/** A link the kit draws: where it goes and what it says. */@@@ -226,6 +229,16 @@ sealed class KitNode {override val type: String = "notice"}++ /** The Share button: the platform's own share sheet for the link (Android's chooser, with Direct Share). */++ data class Share(++ val title: String,++ val url: String? = null,++ val label: String = "Share",++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "share"++ }++/** An empty state: what a screen shows when it has nothing to show. */data class Empty(val title: String,@@@ -340,6 +353,11 @@ sealed class KitNode {else -> OvNoticeKind.Info},)++ "share" -> Share(++ title = props.str("title") ?: "",++ url = props.str("url"),++ label = props.str("label") ?: "Share",++ )"empty" -> Empty(title = props.str("title") ?: "",text = props.str("text"),@@@ -576,6 +594,7 @@ fun KitNodeView(is KitNode.Sidebar -> OvSidebarNav(node, icons, onAction)is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)++ is KitNode.Share -> OvShare(title = node.title, url = node.url ?: "", label = node.label, onShare = { t, u -> onAction(KitAction.Share(t, u)) })is KitNode.Empty -> {val link = node.actionval action: Pair<String, () -> Unit>? = if (link != null) {@@@ -456,6 +456,21 @@ public struct KitNotice: KitComponent {}}++/// The Share button: the system share sheet for the link (ShareLink).++public struct KitShare: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ public var title: String { props.string("title") ?? "" }++ public var url: String? { props.string("url") }++ public var label: String { props.string("label") ?? "Share" }++}++/// An empty state: what a screen shows when it has nothing to show.public struct KitEmpty: KitComponent {public let props: [String: KitValue]@@@ -489,6 +504,7 @@ public enum KitNode: Codable {case stats(KitStats)case notice(KitNotice)case empty(KitEmpty)++ case share(KitShare)public init(from decoder: Decoder) throws {let container = try decoder.container(keyedBy: KitCodingKey.self)@@@ -508,6 +524,7 @@ public enum KitNode: Codable {case "stats": self = .stats(KitStats(props: props, children: children))case "notice": self = .notice(KitNotice(props: props, children: children))case "empty": self = .empty(KitEmpty(props: props, children: children))++ case "share": self = .share(KitShare(props: props, children: children))default:throw DecodingError.dataCorruptedError(forKey: KitCodingKey("type"),@@@ -541,6 +558,7 @@ public enum KitNode: Codable {case .stats: return "stats"case .notice: return "notice"case .empty: return "empty"++ case .share: return "share"}}@@@ -559,6 +577,7 @@ public enum KitNode: Codable {case .stats(let node): return node.propscase .notice(let node): return node.propscase .empty(let node): return node.props++ case .share(let node): return node.props}}@@@ -577,6 +596,7 @@ public enum KitNode: Codable {case .stats(let node): return node.childrencase .notice(let node): return node.childrencase .empty(let node): return node.children++ case .share(let node): return node.children}}}@@@ -644,6 +664,7 @@ private struct KitNodeBody: View {case .stats(let n): statsView(n)case .notice(let n): noticeView(n)case .empty(let n): emptyView(n)++ case .share(let n): shareView(n)}}@@@ -795,6 +816,14 @@ private struct KitNodeBody: View {OvEmpty(title: node.title, text: node.text)}++ /// The Share button, for a link the document names.++ @ViewBuilder++ private func shareView(_ node: KitShare) -> some View {++ if let link = node.url.flatMap(URL.init(string:)) {++ OvShare(title: node.title, url: link, label: node.label)++ }++ }++/// What the rail's "See all" does.private func moreAction(_ link: KitLink) -> () -> Void {{ self.act(.open(link.href)) }@@@ -9,7 +9,7 @@ const path = require('path');const { hashAsset } = require('../assets');const ROOT = path.join(__dirname, '..');--const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css'];++const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css'];const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;nnew file mode 100644@@@ -0,0 +1,252 @@++'use strict';++// ov-share.js and ov-share.css (4.114.0, plan track Q) in a real headless Chrome, against a stand-in for++// openvibe-sdk/share's router: the sheet loads who can open a thing, adds people by @name only once the service knows++// the name, says plainly when it does not, switches "Only me" to "Specific people" when someone is added, saves what++// changed (and only on Save), warns before leaving unsaved changes, sends a transfer offer bound to a person's subject,++// and the recipient's banner and the not-on-the-list gate call the right routes. Names and titles are text, never++// markup. A phone gets a sheet no wider than the phone. Skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('ov-share-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const PEOPLE = {++ goosely: { subject: { type: 'user', id: 'usr_g' }, username: 'goosely', display_name: 'Goosely' },++ maticus: { subject: { type: 'user', id: 'usr_m' }, username: 'maticus', display_name: 'Maticus' },++ quinn: { subject: { type: 'user', id: 'usr_q' }, username: 'Quinn', display_name: '<img src=x onerror=window.__xss=1>' },++};++const fresh = () => ({++ audience: 'private', audiences: ['public', 'link', 'people', 'private'], roles: ['viewer', 'editor'],++ title: '<b>notes</b>', link: 'https://community.test/p/abc',++ you: { subject: { type: 'user', id: 'usr_g' }, role: 'owner' }, owner: PEOPLE.goosely, members: [],++ requests: [{ id: 'r1', ...PEOPLE.maticus, requested_at: '2026-11-05T10:00:00Z' }], transfer: null,++});++let S = fresh();++const calls = [];++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Share</title><link rel="icon" href="data:,">++<link rel="stylesheet" href="/ov-share.css"></head><body style="font:15px system-ui;padding:16px">++<button type="button" id="sh" data-ov-share-sheet data-kind="paste" data-id="abc" data-title="notes">Share</button>++<button type="button" id="send" data-ov-share-sheet data-title="A robot & a wave" data-url="https://video.test/v/1" data-time="83" data-embed="https://video.test/embed/1">Send</button>++<button type="button" id="theirs" data-ov-share-sheet data-kind="paste" data-id="theirs" data-title="theirs">Theirs</button>++<div id="offer"></div><div id="gate"></div>++<script>window.__changes = 0; window.__sent = []; document.addEventListener('ov-share:change', () => { window.__changes++; }); document.addEventListener('ov-share:sent', (e) => { window.__sent.push(e.detail.target + ' ' + e.detail.url); });</script>++<script src="/ov-share.js"></script></body></html>`;++++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ const json = (code, o) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }++ if (u.pathname === '/ov-share.js' || u.pathname === '/ov-share.css') {++ res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css; charset=utf-8' : 'application/javascript; charset=utf-8' });++ return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1))));++ }++ calls.push(`${req.method} ${u.pathname}${u.search}`);++ if (u.pathname === '/api/v1/share/people') {++ const n = (u.searchParams.get('name') || '').toLowerCase();++ if (n === 'slow') return json(429, { error: 'rate_limited' });++ return json(200, { person: PEOPLE[n] || null });++ }++ const p = '/api/v1/paste/abc/share';++ const find = (sub) => Object.values(PEOPLE).find((x) => sub && x.subject.id === sub.id);++ if (u.pathname === p && req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json', etag: '"7"' }); return res.end(JSON.stringify(S)); }++ if (u.pathname === p && req.method === 'PUT') {++ const b = JSON.parse(body);++ S.lastPut = b; S.lastIfMatch = req.headers['if-match'];++ S.audience = b.audience;++ S.members = b.members.map((m) => ({ ...find(m.subject), role: m.role, expires_at: m.expires_at }));++ S.requests = S.requests.filter((r) => !S.members.some((m) => m.subject.id === r.subject.id));++ return json(200, S);++ }++ if (u.pathname === `${p}/transfer`) {++ const b = JSON.parse(body);++ S.lastTransfer = b;++ S.transfer = { id: '00000000-0000-4000-8000-0000000000aa', to: find(b.to.subject), keep_access: b.keep_access, expires_at: '2026-11-13T12:00:00Z', state: 'pending' };++ return json(200, S);++ }++ if (u.pathname === `${p}/transfer/cancel`) { S.lastCancel = JSON.parse(body); S.transfer = null; return json(200, S); }++ if (u.pathname === `${p}/transfer/accept`) { S.lastAccept = JSON.parse(body); return json(200, { ...S, you: { subject: PEOPLE.maticus.subject, role: 'owner' } }); }++ if (u.pathname === '/api/v1/paste/theirs/share') return json(404, { code: 'share.not_found', error: 'Not found' });++ if (u.pathname === `${p}/requests`) return json(201, { ok: true });++ res.writeHead(404); return res.end();++ });++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-share-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const q = (k) => `document.querySelector('dialog.ov-share [data-k="${k}"]')`;++ const typeIn = (k, v) => `(() => { const i = ${q(k)}; i.value = ${JSON.stringify(v)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`;++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 1280 });++ await p.goto(`${base}/`, { maxMs: 5000 });++ await wait(150);++++ await p.evaluate("document.getElementById('sh').click()");++ await wait(300);++ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'a [data-ov-share-sheet] button opens the sheet as a modal dialog');++ assert.strictEqual(await p.evaluate("document.getElementById('ov-share-title').textContent"), 'Share “<b>notes</b>”', 'the title is text, never markup');++ assert.strictEqual(await p.evaluate(`${q('aud-private')}.checked`), true, 'the service\'s audience is chosen');++ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share [data-k=\"done\"]').textContent"), 'Done', 'nothing changed: Done, not Save');++++ // A name nobody has: said plainly, nothing added, nothing saved.++ await p.evaluate(typeIn('add', '@nobody'));++ await p.evaluate(`${q('add-btn')}.click()`);++ await wait(150);++ assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /No OpenVibe account is called @nobody/);++ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-list').children.length"), 1, 'only the owner');++ // A name that is not a name is refused before anyone is asked.++ await p.evaluate(typeIn('add', 'a b!'));++ await p.evaluate(`${q('add-btn')}.click()`);++ await wait(80);++ assert.ok(!calls.some((c) => /name=b/.test(c)), 'an invalid name is never sent');++ // Too many checks: the service's 429 is shown, not swallowed.++ await p.evaluate(typeIn('add', 'slow'));++ await p.evaluate(`${q('add-btn')}.click()`);++ await wait(150);++ assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /Too many name checks/);++++ // A real name: added, Only me becomes Specific people, the display name stays text.++ await p.evaluate(typeIn('add', '@Quinn'));++ await p.evaluate(`${q('add-btn')}.click()`);++ await wait(150);++ assert.strictEqual(await p.evaluate(`${q('aud-people')}.checked`), true, 'adding someone to Only me switches to Specific people');++ assert.strictEqual(await p.evaluate('window.__xss'), undefined, 'a display name is never markup');++ assert.strictEqual(await p.evaluate("[...document.querySelectorAll('dialog.ov-share .ov-share-display')].map((n) => n.textContent)[1]"), PEOPLE.quinn.display_name);++ assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Save', 'a change asks to be saved');++ assert.ok(!calls.some((c) => c.startsWith('PUT')), 'nothing is saved before Save');++++ // Leaving with unsaved changes asks first; Keep editing keeps them.++ await p.evaluate(`${q('close')}.click()`);++ await wait(80);++ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'unsaved changes keep the sheet open');++ assert.ok(await p.evaluate(`!!${q('discard')}`), 'and ask: Discard or Keep editing');++ await p.evaluate(`${q('keep-editing')}.click()`);++ await wait(50);++++ // The person who asked can be added from the request.++ await p.evaluate(`${q('req-add-user:usr_m')}.click()`);++ await wait(50);++ await p.evaluate(`${q('done')}.click()`);++ await wait(200);++ assert.deepStrictEqual(S.lastPut, { audience: 'people', members: [{ role: 'viewer', subject: PEOPLE.quinn.subject }, { role: 'viewer', subject: PEOPLE.maticus.subject }], notify: true },++ 'Save sends the audience and the members by subject (never by display name), with no end date when there is none');++ assert.strictEqual(S.lastIfMatch, '"7"', 'and only over the version it was shown (If-Match)');++ assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and tells the page (ov-share:change)');++ assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Done');++++ // Transfer: to a person on the list, removing my access by default, bound to their subject.++ await p.evaluate(`${q('give')}.click()`);++ await wait(80);++ assert.strictEqual(await p.evaluate(`${q('keep-false')}.checked`), true, '"Remove my access" is the default');++ await p.evaluate(`${q('give-maticus')}.click()`);++ await wait(50);++ await p.evaluate(`${q('give-send')}.click()`);++ await wait(200);++ assert.deepStrictEqual(S.lastTransfer, { to: { subject: PEOPLE.maticus.subject }, keep_access: false });++ assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-banner').textContent"), /Waiting for @maticus to accept/);++ assert.strictEqual(await p.evaluate(`!!${q('give')}`), false, 'one offer at a time');++ await p.evaluate(`${q('cancel-give')}.click()`);++ await wait(200);++ assert.strictEqual(S.transfer, null, 'the owner can cancel');++ assert.deepStrictEqual(S.lastCancel, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'naming the offer it cancels');++ await p.evaluate(`${q('close')}.click()`);++ await wait(320);++ assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog').length + (document.documentElement.classList.contains('ov-share-locked') ? 1 : 0)"), 0, 'closing removes the sheet and frees the page');++++ // The recipient's banner and the gate.++ await p.evaluate("OVShare.offer(document.getElementById('offer'), { kind: 'paste', id: 'abc', transfer_id: '00000000-0000-4000-8000-0000000000aa', from: { username: 'goosely' }, title: 'notes' })");++ await p.evaluate("[...document.querySelectorAll('#offer button')].find((b) => b.textContent === 'Accept').click()");++ await wait(200);++ assert.ok(calls.includes('POST /api/v1/paste/abc/share/transfer/accept'));++ assert.deepStrictEqual(S.lastAccept, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'the answer names the offer');++ assert.match(await p.evaluate("document.getElementById('offer').textContent"), /It is yours now/);++ await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signedInAs: 'quinn' })");++ assert.ok(!/notes/.test(await p.evaluate("document.getElementById('gate').textContent")), 'the gate never shows the title');++ await p.evaluate("document.querySelector('#gate .ov-share-btn').click()");++ await wait(200);++ assert.ok(calls.includes('POST /api/v1/paste/abc/share/requests'));++ assert.match(await p.evaluate("document.getElementById('gate').textContent"), /Asked/);++ await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signInUrl: '/login' })");++ assert.strictEqual(await p.evaluate("document.querySelector('#gate a').getAttribute('href')"), '/login', 'signed out: sign in');++++ // Send it: for anyone, with no kind or id; the link, Copy, the places, "Start at", embed code.++ await p.evaluate("Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => { window.__clip = t; return Promise.resolve(); } }, configurable: true })"); // headless Chrome refuses the real one without a grant++ await p.evaluate("document.getElementById('send').click()");++ await wait(300);++ assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud').length"), 0, 'no "who can open it" without kind and id');++ assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1', 'the link, shown without https://');++ await p.evaluate(`${q('copy')}.click()`);++ await wait(120);++ assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link');++ assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)');++ assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied');++ const x = await p.evaluate(`${q('to-x')}.getAttribute('href')`);++ assert.strictEqual(x, 'https://x.com/intent/post?text=A%20robot%20%26%20a%20wave&url=https%3A%2F%2Fvideo.test%2Fv%2F1', 'X gets the title and the link, encoded');++ assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer');++ assert.ok(!/utm_|ref=/.test(x), 'no tracking is added to a link');++ await p.evaluate(`${q('at-time')}.click()`);++ await wait(60);++ assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1?t=83', '"Start at 1:23" adds the time');++ await p.evaluate(`${q('more')} && ${q('more')}.click()`);++ await wait(60);++ await p.evaluate(`${q('to-embed')}.click()`);++ await wait(60);++ assert.match(await p.evaluate(`${q('embed-code')}.value`), /^<iframe src="https:\/\/video\.test\/embed\/1\?t=83" [^>]*title="A robot & a wave"/, 'embed code, with the time and an escaped title');++ await p.evaluate(`${q('to-mastodon')}.click()`);++ await wait(60);++ await p.evaluate(`(() => { const i = ${q('masto')}; i.value = 'not a server'; i.form.requestSubmit(); })()`);++ assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address');++ await p.evaluate(`${q('close')}.click()`);++ await wait(320);++ // Somebody else's paste: they can send it; who can open it is not theirs to see, and nothing says it failed.++ await p.evaluate("document.getElementById('theirs').click()");++ await wait(300);++ assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud, dialog.ov-share .ov-share-error').length"), 0, 'a 404 from the share API leaves the send half alone');++ assert.ok(await p.evaluate(`!!${q('copy')}`));++ await p.evaluate(`${q('close')}.click()`);++ await wait(320);++++ // A phone: a sheet from the bottom, never wider than the phone, finger-sized controls.++ S = fresh();++ S.members = [{ ...PEOPLE.maticus, role: 'viewer', expires_at: '2026-11-30T23:59:59Z' }];++ S.audience = 'people';++ const phone = await h.openPage(browser, context, { width: 390 });++ await phone.goto(`${base}/`, { maxMs: 5000 });++ // The phone's own share sheet (navigator.share), stood in for: offered first, with the link.++ await phone.evaluate("navigator.share = (d) => { window.__shared = d; return Promise.resolve(); }; navigator.canShare = () => true; matchMedia = ((m) => (q) => (q === '(pointer: coarse)' ? { matches: true, addEventListener() {}, removeEventListener() {} } : m(q)))(matchMedia.bind(window))");++ await phone.evaluate("document.getElementById('send').click()");++ await wait(400);++ await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"device\"]').click()");++ await wait(100);++ assert.deepStrictEqual(await phone.evaluate('window.__shared'), { title: 'A robot & a wave', url: 'https://video.test/v/1' }, 'the phone\'s sheet gets the title and the link');++ assert.ok(await phone.evaluate("!!document.querySelector('dialog.ov-share [data-k=\"to-sms\"]') || !!document.querySelector('dialog.ov-share [data-k=\"more\"]')"), 'Messages is offered on a phone');++ await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"close\"]').click()");++ await wait(320);++ await phone.evaluate("document.getElementById('sh').click()");++ await wait(500);++ const r = await phone.evaluate("(() => { const d = document.querySelector('dialog.ov-share').getBoundingClientRect(); return [Math.round(d.width), Math.round(innerHeight - d.bottom), document.documentElement.scrollWidth]; })()");++ assert.deepStrictEqual(r, [390, 0, 390], 'the sheet sits on the bottom edge, the phone\'s width, and nothing is wider');++ assert.ok(await phone.evaluate("document.querySelector('dialog.ov-share .ov-share-addfield').getBoundingClientRect().height") >= 44, 'the name field is a finger tall');++ await phone.close();++ console.log('ov-share-chrome: send (copy, X, start at, embed, Mastodon, the phone sheet), not-the-owner, loading, @names (unknown, invalid, rate-limited, found), Only me → people, save by subject, leave-unsaved, transfer and cancel, the offer, the gate, the phone ok');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -1,5 +1,33 @@
@@ -1,5 +1,33 @@
# Changelog
# Changelog
+## 4.114.0 — 2026-11-06
+
+- **One Share sheet on every site** (`ov-share.js`, `ov-share.css`; plan track Q; docs/share.md). Press Share
+ anywhere and the same sheet opens: a sheet from the bottom on a phone, a dialog on a computer, no tabs.
+ - **Send it** (everyone): the link with Copy, which turns into a tick; on a phone, "Send with your phone" (the
+ phone's own share sheet, with its contacts and apps); 16 places (X, Bluesky, WhatsApp, Reddit, Discord, Telegram,
+ Threads, Mastodon, Facebook, email, Messages, LinkedIn, Pinterest, Tumblr, Hacker News, LINE), recent ones first,
+ the rest behind "More (n)"; a QR code made on the device; "Start at 1:23"; embed code. Nothing is added to a
+ shared link.
+ - **Who can open it** (the owner, through `openvibe-sdk/share`'s router): Everyone, Anyone with the link, Specific
+ people (by @name, checked as you add them; Can view or Can edit; an end date), Only me; people who asked;
+ transfer of ownership, bound to the recipient, "Remove my access" by default. Saves send If-Match. A 401, 403 or
+ 404 leaves only Send it.
+ - `OVShare.offer()` is the recipient's Accept/Decline banner; `OVShare.gate()` is the not-on-the-list page (Ask
+ for access, or Sign in). Every name and title is text, never markup.
+ - The navbar loads it on the first press of a `[data-ov-share-sheet]` button, so nothing loads before that. The
+ kit's `[data-ovk-share]` items open it too.
+ - Events: `ov-share:sent` `{ target, url }` and `ov-share:change`.
+ - **A Share component** in OpenVibe Design (`openvibe-shared/components`, `kit.share({ title, url, kind, id })`): a
+ kit document's `{ type: 'share' }` renders the button, and the spec gives its anatomy, states, keys, and the
+ Android (Intent chooser with Direct Share) and iOS (`ShareLink`) views, so native apps share the same way.
+ - **`ov-qr.js`**: a QR code from a link, made on the device with no dependency and no network (a Locker link's key
+ stays on the device). `OVQR.matrix(text, { ecc })` and `OVQR.svg(text, { ecc, margin, dark, light, title })`;
+ byte mode, versions 1 to 40, all eight masks. Ported from Nayuki's QR Code generator (MIT); checked module by
+ module against bwip-js on 1,196 matrices (test/ov-qr.test.js). The sheet loads it on the first QR code.
+ - **Brand logos**: Tumblr, Hacker News and LINE join brands.js (49 brands, Simple Icons 16.36.0, CC0).
+ - Tests: test/ov-share-chrome.test.js (headless Chrome).
+
## 4.113.0 — 2026-11-06
## 4.113.0 — 2026-11-06
- **Bot API calls can be counted at ingest, not stored row by row.** `AnalyticsTrackerPg` takes
- **Bot API calls can be counted at ingest, not stored row by row.** `AnalyticsTrackerPg` takes
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.113.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.114.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -902,6 +902,19 @@ so forms, `select.value` and `change` listeners work as before. An option can sa
@@ -902,6 +902,19 @@ so forms, `select.value` and `change` listeners work as before. An option can sa
switch; `.ov-field`, `.ov-input` and `.ov-choice` are the full field look. Turn it off with `data-ov-native` or
switch; `.ov-field`, `.ov-input` and `.ov-choice` are the full field look. Turn it off with `data-ov-native` or
`<html data-ov-forms="off">`. See [docs/forms.md](docs/forms.md).
`<html data-ov-forms="off">`. See [docs/forms.md](docs/forms.md).
+### Share (`ov-share.js`, `ov-share.css`)
+
+One Share sheet on every site (plan track Q): a sheet from the bottom on a phone, a dialog on a computer.
+- **Send it, for everyone:** the link and Copy; the phone's own share sheet (contacts, messages, apps); X, Bluesky,
+ WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, email, Messages, LinkedIn, Pinterest, Tumblr,
+ Hacker News and LINE; a QR code made on the device; Start at 1:23; embed code.
+- **Who can open it, for the owner:** Everyone, Anyone with the link, Specific people by @name (Can view or Can edit,
+ with an end date) and Only me; access requests; transfer of ownership with the recipient's say-so.
+
+Put `<button data-ov-share-sheet data-title="…">Share</button>` on a page: the navbar loads the sheet on the first
+press. Add `data-kind` and `data-id` where the service mounts `openvibe-sdk/share`'s router. See
+[docs/share.md](docs/share.md).
+
## Development
## Development
```bash
```bash
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.113.0",
+ "version": "4.114.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -505,6 +505,49 @@ fun OvNotice(text: String, kind: OvNoticeKind = OvNoticeKind.Info) {
@@ -505,6 +505,49 @@ fun OvNotice(text: String, kind: OvNoticeKind = OvNoticeKind.Info) {
.background((isError ? OvTokens.colorStatusBadText : OvTokens.colorStatusInfoText).opacity(0.12), in: .rect(cornerRadius: OvScale.radiusSm))
.background((isError ? OvTokens.colorStatusBadText : OvTokens.colorStatusInfoText).opacity(0.12), in: .rect(cornerRadius: OvScale.radiusSm))
.accessibilityAddTraits(isError ? [.updatesFrequently] : [])
.accessibilityAddTraits(isError ? [.updatesFrequently] : [])
}
}
+}`,
+ },
+ },
+ {
+ id: 'share', name: 'Share', web: 'kit.share',
+ summary: 'The one Share button and the sheet it opens, on every site: send the link (copy, the phone\'s own share sheet, the places people post, a QR code, embed, start at a time) and, for its owner, who can open it.',
+ role: 'button that opens a modal sheet',
+ children: null,
+ anatomy: [
+ { part: 'button', tokens: { minHeight: 'size.tap', weight: 'type.weight.bold' }, note: 'Share, with the up-from-a-tray icon' },
+ { part: 'sheet', tokens: { surface: 'color.bg.raised', radius: 'radius.md', padding: 'space.lg' }, note: 'from the bottom on a phone, a 520 px dialog on a computer; Escape, the backdrop and Done close it' },
+ { part: 'link', tokens: { surface: 'color.bg.hover', text: 'color.text.primary', action: 'color.accent.strong', onAction: 'color.accent.onStrong' }, note: 'the link without https:// and Copy, which turns into a tick' },
+ { part: 'device', optional: true, tokens: { minHeight: 'size.tap' }, note: 'phones: "Send with your phone", the platform\'s own sheet with its contacts' },
+ { part: 'places', tokens: { size: 'size.avatar', label: 'type.size.meta' }, note: 'round brand tiles, the person\'s recent ones first, the rest behind More (n)' },
+ { part: 'panel', optional: true, tokens: { line: 'color.line.base' }, note: 'QR code, embed code or a Mastodon server, under the places' },
+ { part: 'access', optional: true, tokens: { muted: 'color.text.muted' }, note: 'the owner only: Everyone · Anyone with the link · Specific people · Only me, the people, Transfer' },
+ ],
+ props: { title: { type: 'string', required: true }, url: { type: 'url' }, text: { type: 'string' }, image: { type: 'url' }, embed: { type: 'url' }, kind: { type: 'string' }, id: { type: 'string' }, label: { type: 'string' }, small: { type: 'boolean' } },
+ states: ['closed', 'open', 'copied', 'panel open', 'saving', 'not the owner (send only)'],
+ a11y: { name: '"Share", and the sheet is named "Share <title>"', keys: 'Enter opens; Tab moves; Escape closes (asking first when there are unsaved changes)', minTarget: '44 px' },
+ layout: { phone: 'a sheet from the bottom, the home bar cleared, Send with your phone first, four places a row', computer: 'a dialog, six places a row' },
+ native: {
+ compose: `@Composable
+fun OvShare(title: String, url: String, label: String = "Share", onShare: (title: String, url: String) -> Unit) {
+ // The app hands the link to the platform's own sheet, with Direct Share's contacts. On Android:
+ // context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { type = "text/plain"; putExtra(Intent.EXTRA_TEXT, url) }, title))
+ OutlinedButton(onClick = { onShare(title, url) }, modifier = Modifier.heightIn(min = OvScale.SizeTap), shape = RoundedCornerShape(OvScale.RadiusPill)) {
+ Icon(Icons.Default.Share, contentDescription = null)
+ Spacer(Modifier.width(OvScale.SpaceSm))
+ Text(label, fontWeight = FontWeight.Bold)
+ }
+}`,
+ swiftui: `struct OvShare: View {
+ let title: String
+ let url: URL
+ var label = "Share"
+ var body: some View {
+ // ShareLink is the system sheet (contacts, Messages, AirDrop, apps); the OpenVibe sheet adds QR, embed and access.
+ ShareLink(item: url, subject: Text(title), message: Text(title)) {
+ Label(label, systemImage: "square.and.arrow.up").fontWeight(.bold).frame(minHeight: OvScale.sizeTap)
+ }
+ .buttonStyle(.bordered).clipShape(RoundedRectangle(cornerRadius: OvScale.radiusMd))
+ }
}`,
}`,
},
},
},
},
@@ -634,6 +677,7 @@ enum OvKind { case primary, quiet, danger }`,
@@ -634,6 +677,7 @@ enum OvKind { case primary, quiet, danger }`,
case 'sidebar': return kit.sidebar({ id: p.id || 'ovk-sidebar', label: p.label || 'Navigation', action: p.action ? { href: p.action.href, label: p.action.label || 'New' } : null, nav: p.nav.map((x) => ({ href: x.href, label: x.label, icon: x.icon || 'chevron', here: !!x.here, count: x.count == null ? null : Number(x.count) })) });
case 'sidebar': return kit.sidebar({ id: p.id || 'ovk-sidebar', label: p.label || 'Navigation', action: p.action ? { href: p.action.href, label: p.action.label || 'New' } : null, nav: p.nav.map((x) => ({ href: x.href, label: x.label, icon: x.icon || 'chevron', here: !!x.here, count: x.count == null ? null : Number(x.count) })) });
case 'stats': return kit.stats(p.items, { label: p.label || 'In numbers' });
case 'stats': return kit.stats(p.items, { label: p.label || 'In numbers' });
case 'notice': return kit.notice({ kind: p.kind || 'info', text: p.text });
case 'notice': return kit.notice({ kind: p.kind || 'info', text: p.text });
+ case 'share': return kit.share({ title: p.title, url: p.url || null, text: p.text || '', image: p.image || null, embed: p.embed || null, kind: p.kind || null, id: p.id || null, label: p.label || 'Share', small: !!p.small });
case 'empty': return kit.empty({ title: p.title, text: p.text || '', icon: p.icon || '', action: p.action ? { href: p.action.href, label: p.action.label || 'Go' } : null });
case 'empty': return kit.empty({ title: p.title, text: p.text || '', icon: p.icon || '', action: p.action ? { href: p.action.href, label: p.action.label || 'Go' } : null });
default: return '';
default: return '';
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+# Sharing: one Share sheet on every site (`ov-share.js`, `ov-share.css`)
+
+Plan track Q. In the owner's words (2026-11-06):
+- share a thing with named accounts only, and give it away with the recipient's say-so;
+- "for everything else too … a more universal system";
+- "a unified share system … more platforms … easy copying of a link … the phone's own share thing … intuitive,
+ user-friendly".
+
+Press Share on any OpenVibe site and the same sheet opens. It is a sheet from the bottom on a phone and a dialog on a
+computer. It has two parts and no tabs:
+
+| Part | Who sees it | What is in it |
+|---|---|---|
+| **Send it** | everyone | the link and **Copy**; on a phone, **Send with your phone** (the phone's own share sheet: contacts, messages, every app); X, Bluesky, WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, Email, Messages (phones), LinkedIn, Pinterest (with a picture), Tumblr, Hacker News, LINE; **QR code**; **Embed**; **Start at 1:23** for a video or a recording |
+| **Who can open it** | the owner (and staff) of a thing with a share API | Everyone · Anyone with the link · Specific people · Only me; people by @name, each Can view or Can edit, with an end date; people who asked; **Transfer** |
+
+The first eight places show at once, with the person's three most recent first. The rest are behind a counted fold
+(**More (n)**), never hidden.
+
+## On a page
+
+```html
+<!-- Send only: the page's address, or data-url -->
+<button type="button" data-ov-share-sheet data-title="Building a robot that waves back"
+ data-url="https://openvibe.video/v/abc123" data-time-from="#player"
+ data-embed="https://openvibe.video/embed/abc123" data-image="https://openvibe.media/o/…">Share</button>
+
+<!-- Also "Who can open it", for its owner: the service mounts openvibe-sdk/share's router -->
+<button type="button" data-ov-share-sheet data-kind="paste" data-id="abc" data-title="LOOI bridge notes">Share</button>
+```
+
+The data attributes:
+- **What to share.**
+ - `data-url`: the link (else the page's address).
+ - `data-title`: the title.
+ - `data-text`: a line sent with it (email, the phone's sheet).
+- **Extras.**
+ - `data-image`: a picture (Pinterest).
+ - `data-embed`: an embeddable player (the Embed tile).
+ - `data-time-from`: a `<video>` or `<audio>` whose current time offers "Start at …". `data-time` gives a fixed
+ time, and `data-time-param` names the parameter (default `t`).
+- **Who can open it.**
+ - `data-kind` and `data-id` add that part.
+ - `data-base` and `data-path` say where the service's share API is, when it is not this page's
+ `/api/v1/<kind>/<id>/share`.
+
+**Loading.** The navbar loads `ov-share.js` and `ov-share.css` the first time someone presses a
+`[data-ov-share-sheet]` button, so a site gets the sheet by putting the button on its page. Nothing loads before
+that. The kit's `[data-ovk-share]` menu items open the same sheet.
+
+## From a script
+
+```js
+OVShare.open({ url, title, text, image, embedUrl, time, kind, id, base, path, token, headers, onChange });
+OVShare.offer(el, { kind, id, transfer_id, from, expires_at, keep_access, title, onDone }); // the recipient's banner
+OVShare.gate(el, { kind, id, signedInAs, signInUrl, onDone }); // not on the list
+OVShare.fromButton(button); // a button's data-* as options
+```
+
+**Events on `document`:**
+- `ov-share:sent` `{ target, url, kind, id }` when the link goes somewhere: copy, x, device, qr, embed and the rest.
+ A site's analytics may count these.
+- `ov-share:change` `{ kind, id, state }` after a save, a transfer or an answer.
+
+## What it promises
+
+- **The service decides.** The sheet only asks. Who may open a thing is enforced by the service that holds it
+ (`openvibe-sdk/share`, `canView`).
+ - A 401, 403 or 404 from the share API means "not the owner": the sheet shows only **Send it**, and says nothing
+ failed.
+ - Saves send `If-Match`. A change made meanwhile is shown, never overwritten.
+- **Text, never markup.** Every name and title is set as text. Embed code escapes every attribute it writes.
+- **No tracking.** Nothing is added to a shared link. "Start at" adds only the time.
+- **The QR code is made on the device** (`ov-qr.js`). A link is never sent anywhere to draw it, and a Locker link's
+ key, after `#`, stays on the device.
+- **Phones.**
+ - Targets are at least 44 px. The name field is 16 px, so iOS does not zoom.
+ - The sheet clears the home bar (`env(safe-area-inset-bottom)`).
+ - Messages is offered on touch devices only.
+- **Motion.** The tiles come in with a short stagger, Copy turns into a tick with a ring, and a toast says what
+ happened. Nothing spins. Under `prefers-reduced-motion` all of it is still.
+
+## Native apps (Android, iOS)
+
+The sheet is a component of OpenVibe Design. Its parts and words are platform-neutral, so native apps follow the same
+design rather than a web view of it:
+
+| | Web | Android | iOS |
+|---|---|---|---|
+| **Send with your phone** | `navigator.share()` (which opens the platform's own sheet on Android and iOS browsers) | `Intent.ACTION_SEND` through `Intent.createChooser`, with sharing shortcuts for Direct Share | `UIActivityViewController` (`ShareLink` in SwiftUI) |
+| **Copy** | Clipboard API | `ClipboardManager`, with the system's own copy confirmation on Android 13+ | `UIPasteboard` |
+| **The places** | intent URLs (X, Bluesky …) | the same intent URLs, or the app's package when installed | the same URLs (universal links open the app) |
+| **QR code** | `ov-qr.js` | the same matrix, drawn natively | the same matrix, drawn natively |
+| **Who can open it** | this sheet | a bottom sheet with the same sections and words | a sheet (`.presentationDetents`) with the same sections and words |
+
+The calls the sheet makes are the same uniform API for every client: `openvibe-sdk/share`'s router,
+`GET`/`PUT /api/v1/<kind>/<id>/share` and the rest. An agent uses `sdk.share({ base, token }).for(kind, id)`.
@@ -35,6 +35,7 @@ const BROWSER = Object.freeze([
@@ -35,6 +35,7 @@ const BROWSER = Object.freeze([
'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]
'ov-labels.js', // content labels (L1): each viewer's show / blur / hide; navbar.js loads it when a page has [data-ov-labels]
'ov-forms.js', // OpenVibe's own select (a menu on a computer, a sheet on a phone); navbar.js loads it when a page has a form control
'ov-forms.js', // OpenVibe's own select (a menu on a computer, a sheet on a phone); navbar.js loads it when a page has a form control
'ov-qr.js', // a QR code from a link, on the device: the Share sheet's QR code (ov-share.js loads it on first use)
'ov-qr.js', // a QR code from a link, on the device: the Share sheet's QR code (ov-share.js loads it on first use)
+ 'ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'history.js', // cross-site history
'history.js', // cross-site history
@@ -74,6 +75,7 @@ const STYLES = Object.freeze([
@@ -74,6 +75,7 @@ const STYLES = Object.freeze([
'player.css', // player.js's controls
'player.css', // player.js's controls
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'kit.css', // the OpenVibe Kit's look (kit.js / openvibe-shared/kit build the markup; docs/kit.md)
'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
+ 'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)
'thread.css', // the conversation UI's look (openvibe-shared/thread, ov-thread.js; plan L55 C4)
'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)
'kit-layout.css', // the fluid scale, the layout primitives (.ov-stack, .ov-switcher…) and the opt-in .ov-base (4.78.0; after kit.css)
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
* sheets [data-ovk-sheet="#id"] opens dialog.ovk-sheet; the backdrop, Close and Escape close it
* sheets [data-ovk-sheet="#id"] opens dialog.ovk-sheet; the backdrop, Close and Escape close it
* tabs a sideways-scrolling tab strip keeps the open tab in view
* tabs a sideways-scrolling tab strip keeps the open tab in view
* rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end
* rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end
- * share [data-ovk-share]: the system share sheet, else the link copied (the menu item says "Link copied")
+ * share [data-ovk-share]: the network's Share sheet (ov-share.js, beside this file); without it, the system
+ * share sheet, else the link copied (the menu item says "Link copied")
* sidebars kit.sidebar on a phone: [data-ovk-sidebar-open] opens the drawer; the scrim, its close button, Escape
* sidebars kit.sidebar on a phone: [data-ovk-sidebar-open] opens the drawer; the scrim, its close button, Escape
* and a page move close it (document-level, so boost-swapped pages need no wiring)
* and a page move close it (document-level, so boost-swapped pages need no wiring)
* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
@@ -22,6 +23,8 @@
@@ -22,6 +23,8 @@
*/
*/
(function (root) {
(function (root) {
'use strict';
'use strict';
+ // ov-share.js beside this file (its query, so a release's copy goes with this one's), for [data-ovk-share].
+ const SHARE_SRC = (() => { try { const me = document.currentScript; return me && me.src ? me.src.replace(/kit-web\.js(\?[^#]*)?.*$/, 'ov-share.js$1') : ''; } catch (_) { return ''; } })();
const CHECK = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
const CHECK = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
/** Opens or closes a sidebar drawer: its scrim, its buttons' aria-expanded, and the focus into it. */
/** Opens or closes a sidebar drawer: its scrim, its buttons' aria-expanded, and the focus into it. */
@@ -180,6 +183,19 @@
@@ -180,6 +183,19 @@
const link = new URL(a.getAttribute('href'), location.href).href;
const link = new URL(a.getAttribute('href'), location.href).href;
const title = a.getAttribute('data-ovk-share') || document.title;
const title = a.getAttribute('data-ovk-share') || document.title;
const done = () => { const d = a.closest('details'); if (d) d.open = false; };
const done = () => { const d = a.closest('details'); if (d) d.open = false; };
+ // The network's one Share sheet (ov-share.js, 4.114.0) when it can load; the device's own sheet, or the
+ // link copied, when it cannot.
+ const sheet = () => { done(); root.OVShare.open(Object.assign(root.OVShare.fromButton(a), { url: link, title })); };
+ if (root.OVShare) { sheet(); return; }
+ if (SHARE_SRC && !a.dataset.ovkShareTried) {
+ a.dataset.ovkShareTried = '1';
+ if (!document.querySelector('link[href*="ov-share.css"]')) { const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = SHARE_SRC.replace('ov-share.js', 'ov-share.css'); document.head.appendChild(ln); }
+ const sc = document.createElement('script'); sc.src = SHARE_SRC; sc.async = true;
+ sc.onload = () => { if (root.OVShare) sheet(); else a.click(); };
+ sc.onerror = () => a.click();
+ document.head.appendChild(sc);
+ return;
+ }
if (navigator.share) { navigator.share({ title, url: link }).then(done, () => {}); return; }
if (navigator.share) { navigator.share({ title, url: link }).then(done, () => {}); return; }
if (!navigator.clipboard) { location.href = link; return; }
if (!navigator.clipboard) { location.href = link; return; }
navigator.clipboard.writeText(link).then(() => {
navigator.clipboard.writeText(link).then(() => {
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom
* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom
* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)
* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)
* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
- * kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
+ * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)
* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)
* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
@@ -161,6 +161,16 @@
@@ -161,6 +161,16 @@
if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${attrs(extra)}>${inner}</a>`;
if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${attrs(extra)}>${inner}</a>`;
return `<button class="${cls}" type="${type === 'button' ? 'button' : type === 'reset' ? 'reset' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${attrs(extra)}>${inner}</button>`;
return `<button class="${cls}" type="${type === 'button' ? 'button' : type === 'reset' ? 'reset' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${attrs(extra)}>${inner}</button>`;
}
}
+ /**
+ * The one Share button (4.114.0, docs/share.md): it opens the network's Share sheet (ov-share.js, which the navbar
+ * loads on the first press). url is what is shared (else the page's address); kind and id add "Who can open it" for
+ * the owner, where the service mounts openvibe-sdk/share's router.
+ */
+ function share({ title = '', url: link = null, text = '', image = null, embed = null, kind = null, id = null, label = 'Share', small = false, quiet = true } = {}) {
+ return button({ type: 'button', label, icon: 'upload', kind: quiet ? 'quiet' : 'primary', small,
+ attrs: { 'data-ov-share-sheet': true, 'data-title': title || null, 'data-url': link ? url(link) : null, 'data-text': text || null,
+ 'data-image': image ? url(image) : null, 'data-embed': embed ? url(embed) : null, 'data-kind': kind || null, 'data-id': id || null } });
+ }
/** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */
/** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */
function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) {
function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) {
const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`;
const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`;
@@ -882,7 +892,7 @@
@@ -882,7 +892,7 @@
return {
return {
skeleton,
skeleton,
- esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
+ esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 2,
enhance, VERSION: 2,
@@ -491,7 +491,7 @@
@@ -491,7 +491,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68' };
+ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'd5bbb14b108e', 'ov-share.css': 'd6afb0f03db7' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -574,6 +574,22 @@
@@ -574,6 +574,22 @@
} catch { /* */ }
} catch { /* */ }
return true;
return true;
}
}
+ // ov-share.js and ov-share.css (4.114.0, plan track Q): the one Share sheet, fetched the first time someone presses a
+ // [data-ov-share-sheet] button, so a site gets it by putting the button on its page and nothing loads before that.
+ function shareClick(e) {
+ const b = e.target && e.target.closest ? e.target.closest('[data-ov-share-sheet]') : null;
+ if (!b || root.OVShare || b.disabled) return;
+ e.preventDefault();
+ if (document.getElementById('ov-share-loader')) return;
+ try {
+ if (!document.querySelector('link[href*="ov-share.css"]')) {
+ const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = siblingV('ov-share.css'); document.head.appendChild(ln);
+ }
+ const sc = document.createElement('script'); sc.id = 'ov-share-loader'; sc.src = siblingV('ov-share.js'); sc.async = true;
+ sc.onload = () => { if (root.OVShare) root.OVShare.open(root.OVShare.fromButton(b)); };
+ document.head.appendChild(sc);
+ } catch { /* */ }
+ }
function watchForms() {
function watchForms() {
if (formWatch || !document.querySelector || typeof MutationObserver !== 'function' || !document.body) return;
if (formWatch || !document.querySelector || typeof MutationObserver !== 'function' || !document.body) return;
const go = () => {
const go = () => {
@@ -1805,6 +1821,7 @@
@@ -1805,6 +1821,7 @@
// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers(); if (_config.forms !== false) watchForms();
if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers(); if (_config.forms !== false) watchForms();
+ if (!root.__ovShareClick) { root.__ovShareClick = true; document.addEventListener('click', shareClick); }
nav.innerHTML = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
new file mode 100644
new file mode 100644
@@ -0,0 +1,344 @@
@@ -0,0 +1,344 @@
+/* openvibe-shared/ov-share.css — the look of ov-share.js: the Share sheet (a dialog in the middle on a computer, a sheet
+ * from the bottom on a phone), the recipient's transfer banner and the "shared with specific people" gate.
+ * Colours come from the site's theme (--accent, --bg-card, --border, --text-primary …) with system colours when a site
+ * has none, the same fallbacks as ov-forms.css, so light and dark follow the site. Nothing spins: a wait is words. */
+
+.ov-share, .ov-share-banner, .ov-share-gate {
+ --ovs-accent: var(--accent, #3b82f6);
+ --ovs-on-accent: var(--on-accent, #fff);
+ --ovs-surface: var(--bg-card, var(--bg-secondary, Canvas));
+ --ovs-text: var(--text-primary, CanvasText);
+ --ovs-muted: var(--text-muted, var(--text-secondary, color-mix(in srgb, CanvasText 60%, Canvas)));
+ --ovs-line: var(--border, color-mix(in srgb, CanvasText 14%, transparent));
+ --ovs-hover: var(--bg-hover, color-mix(in srgb, CanvasText 7%, transparent));
+ --ovs-field: var(--bg-input, var(--bg-tertiary, Field));
+ --ovs-bad: var(--danger, #ef4444);
+ --ovs-warn: var(--warning, #f59e0b);
+ --ovs-radius: var(--ov-radius-2, 14px);
+ color: var(--ovs-text); font-family: inherit; font-size: 14px; line-height: 1.45; text-align: start;
+}
+.ov-share *, .ov-share *::before, .ov-share *::after, .ov-share-banner *, .ov-share-gate * { box-sizing: border-box; }
+
+/* ── The dialog ─────────────────────────────────────────────────────────────────────────────────────────────── */
+.ov-share {
+ box-sizing: border-box; width: min(520px, calc(100vw - 24px)); max-width: none; max-height: min(88dvh, 760px); max-height: min(88vh, 760px);
+ margin: auto; padding: 0; overflow: hidden;
+ background: var(--ovs-surface); border: 1px solid var(--ovs-line); border-radius: var(--ov-radius-3, 20px);
+ box-shadow: 0 28px 70px -18px rgb(0 0 0 / .5), 0 4px 14px rgb(0 0 0 / .14);
+ opacity: 0; transform: translateY(10px) scale(.985);
+ transition: opacity .16s ease, transform .2s cubic-bezier(.2, .8, .2, 1);
+}
+.ov-share.is-open { opacity: 1; transform: none; }
+.ov-share::backdrop { background: rgb(6 10 16 / .52); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); }
+html.ov-share-locked, html.ov-share-locked body { overflow: hidden; overscroll-behavior: none; }
+.ov-share-box { display: flex; flex-direction: column; max-height: inherit; min-height: 0; }
+.ov-share-head { display: flex; align-items: center; gap: 6px; padding: 14px 10px 6px 20px; }
+.ov-share-title { flex: 1; min-width: 0; margin: 0; font-size: 17px; font-weight: 700; line-height: 1.3; letter-spacing: -.005em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ov-share-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; display: grid; align-content: start; gap: 18px; padding: 8px 20px 18px; scrollbar-width: thin; }
+.ov-share-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 16px 14px; border-top: 1px solid var(--ovs-line); }
+.ov-share-foot.is-confirm { background: color-mix(in srgb, var(--ovs-warn) 9%, transparent); }
+.ov-share-foot.is-confirm p { margin: 0; font-weight: 600; }
+.ov-share-grow { flex: 1 1 auto; min-width: 0; }
+
+/* A phone: a sheet from the bottom, the whole width, clear of the home bar. */
+@media (max-width: 640px), (pointer: coarse) and (max-width: 900px) {
+ .ov-share {
+ width: 100%; max-width: 100%; max-height: min(92dvh, 820px); max-height: min(92vh, 820px);
+ margin: auto 0 0; border: 0; border-radius: var(--ov-radius-3, 20px) var(--ov-radius-3, 20px) 0 0;
+ box-shadow: 0 -14px 44px rgb(0 0 0 / .38);
+ opacity: 1; transform: translateY(100%); transition: transform .26s cubic-bezier(.2, .85, .25, 1);
+ }
+ .ov-share.is-open { transform: none; }
+ .ov-share-head { position: relative; padding: 20px max(8px, env(safe-area-inset-right, 0px)) 6px max(18px, env(safe-area-inset-left, 0px)); }
+ .ov-share-head::before { content: ""; position: absolute; top: 7px; left: 50%; width: 38px; height: 5px; margin-left: -19px; border-radius: 99px; background: color-mix(in srgb, var(--ovs-text) 22%, transparent); }
+ .ov-share-body { padding: 8px max(16px, env(safe-area-inset-right, 0px)) 18px max(16px, env(safe-area-inset-left, 0px)); font-size: 15px; }
+ .ov-share-foot { padding: 10px max(12px, env(safe-area-inset-right, 0px)) max(12px, env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px)); }
+}
+
+/* ── Words ──────────────────────────────────────────────────────────────────────────────────────────────────── */
+.ov-share-label { display: block; margin: 0 0 8px; padding: 0; font-size: 12px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--ovs-muted); }
+.ov-share-desc, .ov-share-lead { margin: 10px 0 0; color: var(--ovs-muted); font-size: 13.5px; }
+.ov-share-lead { margin: 0; font-size: 14px; }
+.ov-share-desc strong { color: var(--ovs-text); font-weight: 650; }
+.ov-share-muted { margin: 0; color: var(--ovs-muted); font-size: 13px; }
+.ov-share-note { margin: 0; font-size: 13px; color: var(--ovs-text); padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--ovs-accent) 9%, transparent); }
+.ov-share-warn { margin: 0; font-size: 13px; padding: 8px 10px; border-radius: 10px; color: var(--warning-text, var(--ovs-text)); background: color-mix(in srgb, var(--ovs-warn) 15%, transparent); }
+.ov-share-error { margin: 0; font-size: 13px; color: var(--danger-text, var(--ovs-bad)); }
+.ov-share-soft { font-style: italic; }
+
+/* ── Buttons ────────────────────────────────────────────────────────────────────────────────────────────────── */
+.ov-share-btn {
+ -webkit-appearance: none; appearance: none; display: inline-flex; align-items: center; justify-content: center; gap: 7px;
+ min-height: 40px; padding: 0 15px; margin: 0; border: 1px solid var(--ovs-line); border-radius: 10px;
+ background: transparent; color: var(--ovs-text); font: inherit; font-size: 14px; font-weight: 650; line-height: 1.2;
+ text-decoration: none; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent;
+ transition: background-color .14s ease, border-color .14s ease, box-shadow .14s ease;
+}
+.ov-share-btn:hover:not(:disabled) { background: var(--ovs-hover); }
+.ov-share-btn:focus-visible, .ov-share-iconbtn:focus-visible, .ov-share-link:focus-visible, .ov-share-chip:focus-visible {
+ outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 40%, transparent);
+}
+.ov-share-btn:disabled { opacity: .55; cursor: default; }
+.ov-share-btn.is-primary { background: var(--ovs-accent); border-color: var(--ovs-accent); color: var(--ovs-on-accent); }
+.ov-share-btn.is-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--ovs-accent) 88%, #000); }
+.ov-share-btn.is-quiet { border-color: transparent; }
+.ov-share-btn.is-danger { background: var(--ovs-bad); border-color: var(--ovs-bad); color: #fff; }
+.ov-share-btn.is-small { min-height: 34px; padding: 0 12px; font-size: 13px; }
+.ov-share-btn .ov-share-ico { width: 18px; height: 18px; }
+.ov-share-iconbtn {
+ -webkit-appearance: none; appearance: none; flex: none; display: inline-grid; place-items: center; width: 40px; height: 40px; margin: 0; padding: 0;
+ border: 0; border-radius: 10px; background: none; color: var(--ovs-muted); cursor: pointer; -webkit-tap-highlight-color: transparent;
+}
+.ov-share-iconbtn:hover { background: var(--ovs-hover); color: var(--ovs-text); }
+.ov-share-iconbtn.is-small { width: 34px; height: 34px; }
+.ov-share-link {
+ -webkit-appearance: none; appearance: none; display: inline; margin: 0; padding: 2px 0; border: 0; background: none;
+ color: var(--ovs-accent); font: inherit; font-weight: 600; cursor: pointer; border-radius: 4px;
+}
+.ov-share-link:hover { text-decoration: underline; }
+.ov-share-link.is-quiet { color: var(--ovs-muted); font-weight: 500; }
+@media (pointer: coarse) {
+ .ov-share-btn { min-height: 44px; }
+ .ov-share-btn.is-small { min-height: 40px; }
+ .ov-share-iconbtn { width: 44px; height: 44px; }
+ .ov-share-iconbtn.is-small { width: 40px; height: 40px; }
+ .ov-share-link { padding: 6px 0; }
+}
+
+/* Icons: one stroke family, the currentColor of their place. */
+.ov-share-ico { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px; }
+.ov-share-ico svg { width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
+.ov-share-ico.is-big { width: 40px; height: 40px; color: var(--ovs-accent); }
+
+/* ── Who can open this: a segmented choice of up to four ───────────────────────────────────────────────────── */
+.ov-share-aud { margin: 0; padding: 0; border: 0; min-width: 0; }
+.ov-share-aud:disabled .ov-share-segopt { cursor: default; }
+.ov-share-seg { display: grid; grid-template-columns: repeat(var(--n, 4), minmax(0, 1fr)); gap: 4px; padding: 4px; border-radius: var(--ovs-radius); background: var(--ovs-hover); }
+.ov-share-segopt {
+ position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;
+ min-width: 0; min-height: 62px; padding: 8px 4px; border-radius: 11px; cursor: pointer;
+ color: var(--ovs-muted); font-size: 13px; font-weight: 600; text-align: center; -webkit-tap-highlight-color: transparent;
+ transition: background-color .14s ease, color .14s ease, box-shadow .14s ease;
+}
+.ov-share-segopt > input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; }
+.ov-share-segopt:hover { color: var(--ovs-text); }
+.ov-share-segopt:has(input:checked) { background: var(--ovs-surface); color: var(--ovs-accent); box-shadow: 0 1px 3px rgb(0 0 0 / .16), 0 0 0 1px color-mix(in srgb, var(--ovs-accent) 30%, transparent); }
+.ov-share-segopt:has(input:focus-visible) { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 40%, transparent); }
+.ov-share-seglabel { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ov-share-aud:disabled .ov-share-segopt:not(:has(input:checked)) { opacity: .55; }
+
+/* ── People ─────────────────────────────────────────────────────────────────────────────────────────────────── */
+.ov-share-people { display: grid; gap: 10px; min-width: 0; }
+.ov-share-people .ov-share-label { margin: 0; }
+.ov-share-add { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; margin: 0; }
+.ov-share-addfield {
+ display: flex; align-items: center; min-width: 0; min-height: 44px; padding: 0 12px; gap: 2px;
+ background: var(--ovs-field); border: 1px solid var(--ovs-line); border-radius: 10px;
+ transition: border-color .14s ease, box-shadow .14s ease;
+}
+.ov-share-addfield:focus-within { border-color: var(--ovs-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 28%, transparent); }
+.ov-share-at { color: var(--ovs-muted); font-weight: 650; }
+.ov-share-addfield input { flex: 1; min-width: 0; height: 42px; padding: 0; border: 0; outline: 0; background: none; color: var(--ovs-text); font: inherit; }
+.ov-share-addfield input::placeholder { color: var(--ovs-muted); opacity: 1; }
+@media (pointer: coarse) { .ov-share-addfield input { font-size: 16px; } } /* a phone does not zoom into a 16 px field */
+.ov-share-add .ov-share-btn { min-height: 44px; }
+.ov-share-addmsg { grid-column: 1 / -1; margin: 0; font-size: 13px; color: var(--ovs-muted); }
+.ov-share-addmsg:empty { display: none; }
+.ov-share-addmsg.is-bad { color: var(--danger-text, var(--ovs-bad)); }
+.ov-share-sugg { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; }
+.ov-share-chip {
+ -webkit-appearance: none; appearance: none; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; margin: 0; padding: 0 10px 0 4px;
+ border: 1px solid var(--ovs-line); border-radius: 999px; background: none; color: var(--ovs-text); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
+}
+.ov-share-chip:hover { background: var(--ovs-hover); }
+.ov-share-chip .ov-share-av { width: 24px; height: 24px; font-size: 11px; }
+@media (pointer: coarse) { .ov-share-chip { min-height: 40px; } }
+
+.ov-share-list { display: grid; margin: 0; padding: 0; list-style: none; }
+.ov-share-row { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 7px 0; min-width: 0; }
+.ov-share-row + .ov-share-row { border-top: 1px solid color-mix(in srgb, var(--ovs-line) 70%, transparent); }
+.ov-share-row.is-dim > :not(.ov-share-iconbtn) { opacity: .5; }
+.ov-share-row.is-new .ov-share-av { box-shadow: 0 0 0 2px var(--ovs-surface), 0 0 0 4px color-mix(in srgb, var(--ovs-accent) 55%, transparent); }
+.ov-share-av {
+ flex: none; display: inline-grid; place-items: center; width: 38px; height: 38px; overflow: hidden; border-radius: 50%;
+ background: hsl(var(--ov-share-h, 210) 52% 46%); color: #fff; font-size: 15px; font-weight: 700; line-height: 1; user-select: none;
+}
+.ov-share-av img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.ov-share-words { flex: 1; min-width: 0; display: grid; gap: 1px; }
+.ov-share-name { display: flex; align-items: center; gap: 6px; min-width: 0; font-weight: 650; }
+.ov-share-display { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ov-share-tag { flex: none; padding: 1px 7px; border-radius: 999px; font-size: 12px; font-weight: 600; line-height: 1.4; color: var(--ovs-muted); background: var(--ovs-hover); }
+.ov-share-tag.is-new { color: var(--ovs-accent); background: color-mix(in srgb, var(--ovs-accent) 14%, transparent); }
+.ov-share-sub { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 0; row-gap: 2px; min-width: 0; font-size: 12.5px; color: var(--ovs-muted); }
+.ov-share-sub > * + *::before { content: "\00b7"; display: inline-block; padding: 0 6px; color: var(--ovs-muted); font-style: normal; font-weight: 400; }
+.ov-share-sub > * { min-width: 0; white-space: nowrap; } /* a part wraps whole, never inside "until Nov 30" */
+.ov-share-sub > span:first-child { overflow: hidden; text-overflow: ellipsis; }
+@media (max-width: 480px) { /* a phone: the name, then the end date under it, no dot left hanging */
+ .ov-share-sub { flex-direction: column; align-items: flex-start; }
+ .ov-share-sub > * + *::before { content: none; }
+}
+.ov-share-exp { display: inline-flex; align-items: baseline; gap: 8px; }
+.ov-share-exp .ov-share-link { font-size: 12.5px; }
+.ov-share-exp .ov-share-btn { min-height: 30px; padding: 0 8px; font-size: 12.5px; }
+.ov-share-date { min-height: 32px; padding: 2px 8px; border: 1px solid var(--ovs-accent); border-radius: 8px; background: var(--ovs-field); color: var(--ovs-text); font: inherit; font-size: 13px; color-scheme: light dark; }
+.ov-share-role { flex: none; font-size: 13px; color: var(--ovs-muted); white-space: nowrap; }
+.ov-share-role.is-fixed { font-weight: 600; }
+.ov-share-rolepill { flex: none; display: inline-flex; align-items: stretch; min-height: 34px; padding: 0; border: 1px solid var(--ovs-line); border-radius: 999px; font-size: 13px; font-weight: 600; overflow: hidden; }
+.ov-share-rolepill:focus-within { border-color: var(--ovs-accent); }
+.ov-share-select { max-width: 100%; min-height: 32px; margin: 0; padding: 0 10px; border: 0; outline: 0; background: transparent; color: var(--ovs-text); font: inherit; cursor: pointer; }
+.ov-share-rolepill .ov-sel { padding: 0 10px; } /* ov-forms.js, when the site has it, draws the select as a bare button */
+@media (pointer: coarse) { .ov-share-rolepill { min-height: 40px; } .ov-share-select { min-height: 38px; } .ov-share-exp .ov-share-link { padding: 9px 0; } }
+.ov-share-check { display: flex; align-items: center; gap: 10px; font-size: 13.5px; cursor: pointer; }
+.ov-share-check input { flex: none; width: 18px; height: 18px; margin: 0; accent-color: var(--ovs-accent); }
+/* The sheet's own switch, the same as ov-forms.css draws (a site without the Forms kit still gets a switch). */
+.ov-share-check input[role="switch"] {
+ -webkit-appearance: none; appearance: none; width: 42px; height: 24px; border-radius: 999px; cursor: pointer;
+ background-color: color-mix(in srgb, var(--ovs-text) 26%, transparent);
+ background-image: radial-gradient(circle, #fff 0 8.5px, transparent 9px); background-repeat: no-repeat; background-size: 20px 20px; background-position: 2px 50%;
+ transition: background-color .18s ease, background-position .2s cubic-bezier(.3, .8, .3, 1);
+}
+.ov-share-check input[role="switch"]:checked { background-color: var(--ovs-accent); background-position: calc(100% - 2px) 50%; }
+.ov-share-check input[role="switch"]:focus-visible { outline: 2px solid var(--ovs-accent); outline-offset: 2px; }
+
+/* ── Send it: the link, the phone's own sheet, the places ──────────────────────────────────────────────────── */
+.ov-share-box { position: relative; }
+.ov-share-send { display: grid; gap: 14px; min-width: 0; }
+.ov-share-linkpill {
+ display: flex; align-items: center; gap: 8px; min-width: 0; padding: 5px 5px 5px 14px;
+ background: var(--ovs-field); border: 1px solid var(--ovs-line); border-radius: 14px;
+ transition: border-color .18s ease, box-shadow .18s ease;
+}
+.ov-share-linkpill:focus-within { border-color: var(--ovs-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 26%, transparent); }
+.ov-share-linkpill > .ov-share-ico { color: var(--ovs-muted); width: 18px; height: 18px; }
+.ov-share-linktext { flex: 1; min-width: 0; height: 40px; padding: 0; border: 0; outline: 0; background: none; color: var(--ovs-text); font: inherit; font-size: 14.5px; text-overflow: ellipsis; }
+@media (pointer: coarse) { .ov-share-linktext { font-size: 16px; } }
+.ov-share-copy { min-width: 96px; border-radius: 10px; }
+.ov-share-linkpill.is-copied { border-color: var(--success, #22c55e); animation: ov-share-ring .7s ease-out; }
+.ov-share-linkpill.is-copied .ov-share-copy { background: var(--success, #16a34a); border-color: var(--success, #16a34a); color: #fff; }
+.ov-share-linkpill.is-copied .ov-share-copy .ov-share-ico { animation: ov-share-pop .38s cubic-bezier(.3, 1.6, .5, 1); }
+@keyframes ov-share-ring { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--success, #22c55e) 50%, transparent); } to { box-shadow: 0 0 0 12px transparent; } }
+@keyframes ov-share-pop { 0% { transform: scale(.4); } 70% { transform: scale(1.18); } 100% { transform: none; } }
+.ov-share-linkfor { margin-top: -6px; }
+.ov-share-rule { margin: 2px 0; border: 0; border-top: 1px solid var(--ovs-line); }
+
+.ov-share-device {
+ -webkit-appearance: none; appearance: none; display: flex; align-items: center; gap: 14px; width: 100%; min-height: 64px; margin: 0; padding: 10px 14px;
+ border: 1px solid var(--ovs-line); border-radius: 14px; background: color-mix(in srgb, var(--ovs-accent) 6%, transparent);
+ color: var(--ovs-text); font: inherit; text-align: start; cursor: pointer; -webkit-tap-highlight-color: transparent;
+ transition: transform .12s ease, background-color .14s ease;
+}
+.ov-share-device:active { transform: scale(.985); }
+.ov-share-device .ov-share-tileic { background: var(--ovs-accent); color: var(--ovs-on-accent); }
+.ov-share-devicewords { display: grid; gap: 1px; min-width: 0; }
+.ov-share-devicetitle { font-weight: 700; font-size: 15px; }
+
+.ov-share-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: 4px; margin: 0 -6px; }
+@media (max-width: 640px) { .ov-share-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
+.ov-share-tile {
+ -webkit-appearance: none; appearance: none; display: flex; flex-direction: column; align-items: center; gap: 7px; min-width: 0; margin: 0; padding: 9px 2px 8px;
+ border: 0; border-radius: 14px; background: none; color: var(--ovs-text); font: inherit; font-size: 12.5px; font-weight: 600; line-height: 1.2;
+ text-decoration: none; text-align: center; cursor: pointer; -webkit-tap-highlight-color: transparent;
+ transition: background-color .14s ease, transform .12s ease;
+}
+.ov-share-tile:hover { background: var(--ovs-hover); }
+.ov-share-tile:active { transform: scale(.94); }
+.ov-share-tile:focus-visible, .ov-share-device:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 40%, transparent); }
+.ov-share-tile[aria-expanded="true"] { background: color-mix(in srgb, var(--ovs-accent) 12%, transparent); }
+.ov-share-tilelabel { max-width: 100%; padding: 0 2px; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; }
+.ov-share-tileic {
+ flex: none; display: inline-grid; place-items: center; width: 48px; height: 48px; border-radius: 50%;
+ background: var(--ovs-hover); color: var(--ovs-text); font-weight: 800; font-size: 18px;
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovs-text) 10%, transparent);
+ transition: transform .16s cubic-bezier(.3, 1.4, .5, 1);
+}
+.ov-share-tile:hover .ov-share-tileic { transform: translateY(-2px); }
+.ov-share-tileic svg { width: 22px; height: 22px; }
+.ov-share-tileic:not(.is-brand) svg { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
+.ov-share-tileic.is-brand { background: var(--ov-brand); color: #fff; box-shadow: inset 0 0 0 1px rgb(255 255 255 / .14); }
+.ov-share-tileic.is-email { background: color-mix(in srgb, var(--ovs-accent) 16%, transparent); color: var(--ovs-accent); }
+.ov-share-tileic.is-sms { background: #34c759; color: #fff; }
+.ov-share-tileic.is-linkedin { background: #0a66c2; color: #fff; }
+.ov-share-tileic.is-qr, .ov-share-tileic.is-embed, .ov-share-tileic.is-device, .ov-share-tileic.is-more { color: var(--ovs-text); }
+.ov-share-tileic.is-more svg { fill: currentColor; stroke: none; }
+.ov-share-box.is-first .ov-share-tile, .ov-share-box.is-first .ov-share-device, .ov-share-box.is-first .ov-share-linkpill {
+ animation: ov-share-in .34s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i, 0) * 22ms + 70ms);
+}
+.ov-share-box.is-first .ov-share-linkpill { --i: -2; }
+.ov-share-box.is-first .ov-share-device { --i: -1; }
+@keyframes ov-share-in { from { opacity: 0; transform: translateY(8px) scale(.92); } to { opacity: 1; transform: none; } }
+
+.ov-share-panel {
+ display: grid; gap: 10px; margin: 0; padding: 14px; min-width: 0; border-radius: 14px;
+ background: color-mix(in srgb, var(--ovs-text) 4%, transparent); border: 1px solid var(--ovs-line);
+ animation: ov-share-unfold .22s cubic-bezier(.2, .8, .2, 1);
+}
+.ov-share-panel .ov-share-label { margin: 0; }
+@keyframes ov-share-unfold { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
+.ov-share-qr { grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 16px; }
+.ov-share-qrart { display: grid; place-items: center; width: 172px; height: 172px; padding: 8px; border-radius: 14px; background: #fff; color: #0b0f17; font-size: 12.5px; text-align: center; box-shadow: 0 6px 20px -8px rgb(0 0 0 / .4); }
+.ov-share-qrart svg { display: block; width: 100%; height: 100%; shape-rendering: crispEdges; }
+.ov-share-qrside { display: grid; gap: 10px; justify-items: start; min-width: 0; }
+@media (max-width: 420px) { .ov-share-qr { grid-template-columns: minmax(0, 1fr); justify-items: center; text-align: center; } .ov-share-qrside { justify-items: center; } }
+.ov-share-code {
+ box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 10px 12px; resize: none;
+ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--ovs-text);
+ background: var(--ovs-field); border: 1px solid var(--ovs-line); border-radius: 10px;
+}
+.ov-share-panel > .ov-share-btn { justify-self: start; }
+
+.ov-share-toast {
+ position: absolute; left: 50%; bottom: 78px; z-index: 2; max-width: calc(100% - 32px); margin: 0; padding: 9px 16px;
+ border-radius: 999px; background: color-mix(in srgb, var(--ovs-text) 92%, transparent); color: var(--ovs-surface);
+ font-size: 13.5px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;
+ box-shadow: 0 10px 30px -8px rgb(0 0 0 / .45);
+ opacity: 0; transform: translate(-50%, 8px) scale(.96); transition: opacity .18s ease, transform .22s cubic-bezier(.2, .9, .3, 1.3);
+}
+.ov-share-toast:empty { display: none; }
+.ov-share-toast.is-quiet { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+.ov-share-toast.is-on { opacity: 1; transform: translate(-50%, 0); }
+@media (max-width: 640px), (pointer: coarse) and (max-width: 900px) { .ov-share-toast { bottom: calc(76px + env(safe-area-inset-bottom, 0px)); } }
+
+/* ── Transfer ───────────────────────────────────────────────────────────────────────────────────────────────── */
+.ov-share-give { display: grid; gap: 10px; margin: 0; }
+.ov-share-give .ov-share-label { margin: 6px 0 0; }
+.ov-share-keep { display: grid; gap: 8px; margin: 6px 0 0; padding: 0; border: 0; min-width: 0; }
+.ov-share-radio {
+ display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; border: 1px solid var(--ovs-line); border-radius: 12px; cursor: pointer;
+ transition: border-color .14s ease, background-color .14s ease;
+}
+.ov-share-radio:has(input:checked) { border-color: var(--ovs-accent); background: color-mix(in srgb, var(--ovs-accent) 7%, transparent); }
+.ov-share-radio:has(input:focus-visible) { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovs-accent) 35%, transparent); }
+.ov-share-radio input { flex: none; width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--ovs-accent); }
+.ov-share-radiowords { display: grid; gap: 2px; min-width: 0; }
+.ov-share-radiotitle { font-weight: 650; }
+
+/* ── Banners: the owner's pending offer, the recipient's offer ─────────────────────────────────────────────── */
+.ov-share-banner {
+ display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; padding: 12px 14px; border-radius: 12px;
+ background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent);
+}
+.ov-share-banner > .ov-share-ico { color: var(--ovs-accent); }
+.ov-share-banner > p { flex: 1 1 220px; min-width: 0; margin: 0; font-size: 13.5px; }
+.ov-share-banner > .ov-share-error { flex-basis: 100%; }
+.ov-share-banner.is-offer { margin: 0 0 16px; }
+.ov-share-acts { display: flex; flex-wrap: wrap; gap: 8px; }
+
+/* ── The gate: a person who is not on the list ─────────────────────────────────────────────────────────────── */
+.ov-share-gate {
+ display: grid; justify-items: center; gap: 12px; max-width: 440px; margin: 48px auto; padding: 32px 24px; text-align: center;
+ background: var(--ovs-surface); border: 1px solid var(--ovs-line); border-radius: var(--ov-radius-3, 20px);
+}
+.ov-share-gatetitle { margin: 4px 0 0; font-size: 19px; font-weight: 700; line-height: 1.3; text-wrap: balance; }
+.ov-share-gate .ov-share-acts { justify-content: center; margin-top: 4px; }
+.ov-share-gate .ov-share-muted { font-size: 14px; max-width: 34ch; }
+
+.ov-share-copybuf { position: fixed; top: 0; left: 0; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
+
+@media (prefers-reduced-motion: reduce) {
+ .ov-share, .ov-share-btn, .ov-share-segopt, .ov-share-addfield, .ov-share-radio, .ov-share-tile, .ov-share-tileic, .ov-share-device, .ov-share-toast, .ov-share-linkpill { transition: none !important; }
+ .ov-share *, .ov-share-box.is-first * { animation: none !important; }
+}
+@media (forced-colors: active) {
+ .ov-share { border: 1px solid CanvasText; }
+ .ov-share-segopt:has(input:checked), .ov-share-radio:has(input:checked) { outline: 2px solid Highlight; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,1050 @@
@@ -0,0 +1,1050 @@
+/**
+ * openvibe-shared/ov-share.js — one Share sheet for anything on OpenVibe, on every site (plan track Q; the owner,
+ * 2026-11-06: share with named accounts only, give a thing away with the recipient's say-so, "for everything else too …
+ * a more universal system", and "a unified share system … more platforms … easy copying of a link … the phone's own share
+ * thing … intuitive, user-friendly"). The look is ov-share.css, linked from beside this file; docs/share.md has both.
+ *
+ * One sheet, two parts, never tabs:
+ * Send it for everyone: the link and Copy; on a phone, the phone's own share sheet (contacts, messages, apps);
+ * X, Bluesky, WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, email, Messages,
+ * LinkedIn, Pinterest, Tumblr, Hacker News, LINE (recent ones first, the rest behind "More (n)"); a QR
+ * code made on the device; "Start at 1:23"; embed code.
+ * Who can open it for the owner, when the thing has kind and id: Everyone, Anyone with the link, Specific people
+ * (by @name, each Can view or Can edit, with an end date), Only me; people who asked; Transfer.
+ *
+ * <button type="button" data-ov-share-sheet data-title="…">Share</button> the page's own address
+ * data-url="https://…" the link to share (else the page's address)
+ * data-text="…" a line to send with it (email, the phone's sheet)
+ * data-image="https://…" a picture of it (Pinterest)
+ * data-embed="https://…" an embeddable player address (the Embed tile)
+ * data-time-from="#player" a <video> or <audio> whose current time offers "Start at …" (data-time-param, else t)
+ * data-kind="paste" data-id="abc" also "Who can open it", for its owner (data-base, data-path as below)
+ * data-base="https://…" the service's origin, when it is not this page's
+ * data-path="/api/v1/…" the share URL, when it is not /api/v1/<kind>/<id>/share
+ *
+ * OVShare.open({ url?, title?, text?, image?, embedUrl?, time?, timeParam?, kind?, id?, base?, path?, token?, headers?, onChange? })
+ * opens the sheet; resolves with the saved sharing state, or null when nothing was saved
+ * OVShare.offer(el, { kind, id, transfer_id, from, expires_at, keep_access?, title?, base?, token?, onDone? })
+ * the recipient's banner for a pending transfer: Accept, Decline
+ * OVShare.gate(el, { kind, id, signedInAs?, signInUrl?, base?, token?, onDone? })
+ * the page a person who is not on the list sees: "Ask for access", or "Sign in"
+ *
+ * The sheet only asks; the service decides. Who may see a thing is enforced by the service holding it (canView), never
+ * by this file. Every name and title is set as text, never as markup. No tracking is added to a shared link.
+ *
+ * Calls (openvibe-sdk/share's router); a 401, 403 or 404 on the first GET means "not the owner": the sheet sends only.
+ * GET <path> { audience, audiences, roles, you, owner, members, requests, transfer, title, link } + ETag
+ * PUT <path> If-Match { audience, members: [{ subject | name, role, expires_at? }], notify } → the same
+ * POST <path>/transfer { to, keep_access } POST <path>/transfer/cancel { transfer_id }
+ * POST <path>/transfer/accept POST <path>/transfer/decline { transfer_id }
+ * POST <path>/requests (ask for access) POST <path>/requests/<id>/dismiss
+ * GET <base>/api/v1/share/people?name=x { person: { subject, username, display_name, avatar_url } | null }
+ * Events on document: 'ov-share:change' { kind, id, state } after a save, a transfer or an answer;
+ * 'ov-share:sent' { target, url, kind, id } when the link goes somewhere (copy, x, device, qr …).
+ */
+(function (root) {
+ 'use strict';
+ if (!root || !root.document || root.OVShare) return;
+ const doc = root.document;
+ const NAME_RE = /^[A-Za-z0-9_.-]{1,40}$/; // Network's username rule
+ const AUDIENCES = {
+ public: { label: 'Everyone', title: 'Everyone', desc: 'Listed on the site and in search. Anyone can open it.' },
+ link: { label: 'Link', title: 'Anyone with the link', desc: 'Not listed and not in search. Anyone with the link can open it.' },
+ people: { label: 'People', title: 'Specific people', desc: 'Only you and the people below, signed in to OpenVibe.' },
+ private: { label: 'Only me', title: 'Only me', desc: 'Only you can open it.' },
+ };
+ const ROLE_WORDS = { viewer: 'Can view', editor: 'Can edit' };
+ const svg = (d, extra) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"${extra || ''}>${d}</svg>`;
+ const ICON = {
+ public: svg('<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.6 2.4 3.8 5.3 3.8 8.5s-1.2 6.1-3.8 8.5c-2.6-2.4-3.8-5.3-3.8-8.5s1.2-6.1 3.8-8.5Z"/>'),
+ link: svg('<path d="M10 14a4.2 4.2 0 0 0 6 0l3-3a4.2 4.2 0 0 0-6-6l-1 1"/><path d="M14 10a4.2 4.2 0 0 0-6 0l-3 3a4.2 4.2 0 0 0 6 6l1-1"/>'),
+ people: svg('<circle cx="9" cy="8.5" r="3.2"/><path d="M3.5 19c.6-3.2 2.7-5 5.5-5s4.9 1.8 5.5 5"/><circle cx="16.8" cy="9.5" r="2.5"/><path d="M15.8 14.2c2.4-.3 4.2 1.2 4.7 4"/>'),
+ private: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
+ close: svg('<path d="m6.5 6.5 11 11M17.5 6.5l-11 11"/>'),
+ back: svg('<path d="M14.5 6 8.5 12l6 6"/>'),
+ copy: svg('<rect x="8.5" y="8.5" width="11" height="11" rx="2.2"/><path d="M15.5 8.5V6.7a2.2 2.2 0 0 0-2.2-2.2H6.7a2.2 2.2 0 0 0-2.2 2.2v6.6a2.2 2.2 0 0 0 2.2 2.2h1.8"/>'),
+ give: svg('<path d="M4 12h12M12 6l6 6-6 6"/><path d="M20 5v14"/>'),
+ tick: svg('<path d="m5 12.5 4.5 4.5L19 7.5"/>'),
+ };
+
+ // ── Small helpers ─────────────────────────────────────────────────────────────────────────────────────────
+ const el = (tag, cls, attrs, text) => {
+ const n = doc.createElement(tag);
+ if (cls) n.className = cls;
+ if (attrs) for (const k of Object.keys(attrs)) if (attrs[k] != null && attrs[k] !== false) n.setAttribute(k, attrs[k] === true ? '' : attrs[k]);
+ if (text != null) n.textContent = text;
+ return n;
+ };
+ const iconEl = (name, cls) => { const s = el('span', cls || 'ov-share-ico'); s.innerHTML = ICON[name]; return s; }; // constants only
+ const reduce = () => !!(root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches);
+ const cleanName = (s) => String(s || '').trim().replace(/^@+/, '');
+ const sid = (s) => (!s ? '' : typeof s === 'string' ? s : `${s.type}:${s.id}`);
+ const same = (a, b) => !!a && !!b && sid(a) === sid(b);
+ const handle = (p) => (p && p.username ? `@${p.username}` : '');
+ const nameOf = (p) => (p && (p.display_name || p.username)) || 'Someone';
+ function when(iso, withYear) {
+ const d = new Date(iso);
+ if (!iso || isNaN(d)) return '';
+ const o = { month: 'short', day: 'numeric' };
+ if (withYear || d.getFullYear() !== new Date().getFullYear()) o.year = 'numeric';
+ return d.toLocaleDateString(undefined, o);
+ }
+ function hue(s) { let h = 0; for (const c of String(s || '?')) h = (h * 31 + c.codePointAt(0)) % 360; return h; }
+ function avatar(p) {
+ const s = el('span', 'ov-share-av', { 'aria-hidden': 'true' });
+ const initial = (nameOf(p).trim()[0] || '?').toUpperCase();
+ s.style.setProperty('--ov-share-h', String(hue(p && (p.username || sid(p.subject)))));
+ const url = p && p.avatar_url;
+ if (url && /^(https:\/\/|\/(?!\/))/.test(url)) {
+ const img = el('img', null, { src: url, alt: '', loading: 'lazy', decoding: 'async', referrerpolicy: 'no-referrer' });
+ img.addEventListener('error', () => { img.remove(); s.textContent = initial; }, { once: true });
+ s.appendChild(img);
+ } else s.textContent = initial;
+ return s;
+ }
+
+ // The look, once, from beside this script (a page or the navbar may have linked it already).
+ function stylesheet() {
+ if (doc.querySelector('link[href*="ov-share.css"]')) return;
+ const me = doc.currentScript || doc.querySelector('script[src*="ov-share.js"]');
+ const src = me && me.src ? me.src : '/shared/ov-share.js';
+ doc.head.appendChild(el('link', null, { rel: 'stylesheet', href: src.replace(/ov-share\.js(\?[^#]*)?.*$/, 'ov-share.css$1') }));
+ }
+ stylesheet();
+
+ // ── Talking to the service ────────────────────────────────────────────────────────────────────────────────
+ function endpoint(o) {
+ const base = String(o.base || '').replace(/\/+$/, '');
+ const path = o.path || `/api/v1/${encodeURIComponent(o.kind)}/${encodeURIComponent(o.id)}/share`;
+ return { base, path: base + path };
+ }
+ async function call(o, method, url, body, extra) {
+ const headers = Object.assign({ accept: 'application/json', 'x-requested-with': 'ov-share' }, o.headers || {}, extra || {});
+ if (body !== undefined) headers['content-type'] = 'application/json';
+ if (o.token) headers.authorization = `Bearer ${o.token}`;
+ const r = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: o.base ? 'include' : 'same-origin' });
+ let j = null;
+ try { j = await r.json(); } catch (_) { /* an empty answer */ }
+ if (!r.ok) {
+ const e = new Error((j && (j.message || j.error_description)) || `The service answered ${r.status}`);
+ e.status = r.status; e.body = j;
+ throw e;
+ }
+ const out = j || {};
+ const tag = r.headers && r.headers.get ? r.headers.get('etag') : null;
+ if (tag) Object.defineProperty(out, '__etag', { value: tag, enumerable: false });
+ return out;
+ }
+ // One @name → a person, or null. undefined means the check itself could not run (the name is checked on save).
+ async function lookup(o, name) {
+ try {
+ const j = await call(o, 'GET', `${endpoint(o).base}/api/v1/share/people?name=${encodeURIComponent(name)}`);
+ return j.person && j.person.subject ? j.person : null;
+ } catch (e) {
+ if (e.status === 429) { const x = new Error('Too many name checks. Wait a minute, then try again.'); x.status = 429; throw x; }
+ return undefined;
+ }
+ }
+ function announce(o, state) {
+ doc.dispatchEvent(new CustomEvent('ov-share:change', { detail: { kind: o.kind, id: o.id, state } }));
+ if (typeof o.onChange === 'function') { try { o.onChange(state); } catch (_) { /* the page's problem */ } }
+ }
+
+ // ── Send: the link, the device's own share sheet, the places people post it ──────────────────────────────
+ // No tracking is added to a link: the address shared is the address of the thing (with ?t= when asked).
+ const E = encodeURIComponent;
+ const phoneLike = () => !!(root.matchMedia && root.matchMedia('(pointer: coarse)').matches);
+ // [id, label, brand slug in brands.js (null: a glyph of our own), the link it opens (null: an action here)]
+ const TARGETS = [
+ ['x', 'X', 'x', (u, t) => `https://x.com/intent/post?text=${E(t)}&url=${E(u)}`],
+ ['bluesky', 'Bluesky', 'bluesky', (u, t) => `https://bsky.app/intent/compose?text=${E(t ? `${t} ${u}` : u)}`],
+ ['whatsapp', 'WhatsApp', 'whatsapp', (u, t) => `https://wa.me/?text=${E(t ? `${t} ${u}` : u)}`],
+ ['reddit', 'Reddit', 'reddit', (u, t) => `https://www.reddit.com/submit?url=${E(u)}&title=${E(t)}`],
+ ['discord', 'Discord', 'discord', null],
+ ['telegram', 'Telegram', 'telegram', (u, t) => `https://t.me/share/url?url=${E(u)}&text=${E(t)}`],
+ ['threads', 'Threads', 'threads', (u, t) => `https://www.threads.net/intent/post?text=${E(t ? `${t} ${u}` : u)}`],
+ ['mastodon', 'Mastodon', 'mastodon', null],
+ ['facebook', 'Facebook', 'facebook', (u) => `https://www.facebook.com/sharer/sharer.php?u=${E(u)}`],
+ ['email', 'Email', null, (u, t, o) => `mailto:?subject=${E(t)}&body=${E(`${o.text ? `${o.text}\n\n` : ''}${u}`)}`],
+ ['sms', 'Messages', null, (u, t) => `sms:?&body=${E(t ? `${t} ${u}` : u)}`],
+ ['linkedin', 'LinkedIn', null, (u) => `https://www.linkedin.com/sharing/share-offsite/?url=${E(u)}`],
+ ['pinterest', 'Pinterest', 'pinterest', (u, t, o) => `https://www.pinterest.com/pin/create/button/?url=${E(u)}&media=${E(o.image)}&description=${E(t)}`],
+ ['tumblr', 'Tumblr', 'tumblr', (u, t) => `https://www.tumblr.com/widgets/share/tool?canonicalUrl=${E(u)}&title=${E(t)}`],
+ ['hn', 'Hacker News', 'ycombinator', (u, t) => `https://news.ycombinator.com/submitlink?u=${E(u)}&t=${E(t)}`],
+ ['line', 'LINE', 'line', (u) => `https://social-plugins.line.me/lineit/share?url=${E(u)}`],
+ ];
+ const SHOWN = 8; // tiles before the counted fold ("More places (8)")
+ const remembered = (k, fallback) => { try { const v = JSON.parse(root.localStorage.getItem(k)); return v == null ? fallback : v; } catch (_) { return fallback; } };
+ const remember = (k, v) => { try { root.localStorage.setItem(k, JSON.stringify(v)); } catch (_) { /* private mode */ } };
+ function targetsFor(st) {
+ const o = st.o;
+ const list = TARGETS.filter(([id]) => (id !== 'pinterest' || o.image) && (id !== 'sms' || phoneLike()));
+ const recent = remembered('ov-share-recent', []).filter((id) => list.some((t) => t[0] === id)).slice(0, 3);
+ return [...recent.map((id) => list.find((t) => t[0] === id)), ...list.filter((t) => !recent.includes(t[0]))];
+ }
+ const hms = (s) => { s = Math.max(0, Math.floor(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; };
+ function theLink(st) {
+ const raw = (st.server && st.server.link) || st.o.link || st.o.url || root.location.href;
+ let u;
+ try { u = new URL(raw, root.location.href); } catch (_) { return raw; }
+ if (st.atTime && st.o.time > 0) u.searchParams.set(st.o.timeParam || 't', String(Math.floor(st.o.time)));
+ return u.href;
+ }
+ const shownLink = (href) => href.replace(/^https?:\/\//, '').replace(/\/$/, '');
+ function sent(st, target) {
+ if (target !== 'copy') remember('ov-share-recent', [target, ...remembered('ov-share-recent', []).filter((x) => x !== target)].slice(0, 6));
+ doc.dispatchEvent(new CustomEvent('ov-share:sent', { detail: { target, url: theLink(st), kind: st.o.kind || null, id: st.o.id || null } }));
+ }
+ // A short word after an action; quiet ones are only said to a screen reader (the button already shows "Copied").
+ function toast(st, words, quiet) {
+ st.toastWords = words; st.toastQuiet = !!quiet;
+ const t = st.dlg.querySelector('.ov-share-toast');
+ if (t) { t.textContent = words; t.classList.toggle('is-quiet', !!quiet); t.classList.remove('is-on'); void t.offsetWidth; t.classList.add('is-on'); }
+ clearTimeout(st.toastTimer);
+ st.toastTimer = setTimeout(() => { st.toastWords = ''; const n = st.dlg.querySelector('.ov-share-toast'); if (n) n.classList.remove('is-on'); }, 2200);
+ }
+ async function copyText(st, text) {
+ try { await root.navigator.clipboard.writeText(text); return true; } catch (_) { /* fall back below */ }
+ const t = el('textarea', 'ov-share-copybuf', { readonly: true, 'aria-hidden': 'true' });
+ t.value = text; st.dlg.appendChild(t); t.select();
+ let ok = false;
+ try { ok = doc.execCommand('copy'); } catch (_) { ok = false; }
+ t.remove();
+ return ok;
+ }
+ // A file beside this one (brands.js, ov-qr.js), once; resolves when it has loaded, false when it could not.
+ const loading = {};
+ function loadSibling(name, global) {
+ if (root[global]) return Promise.resolve(true);
+ if (loading[name]) return loading[name];
+ const me = doc.currentScript || doc.querySelector('script[src*="ov-share.js"]');
+ const src = (me && me.src ? me.src : '/shared/ov-share.js').replace(/ov-share\.js(\?[^#]*)?.*$/, `${name}$1`);
+ loading[name] = new Promise((resolve) => {
+ const sc = el('script', null, { src, async: true });
+ sc.onload = () => resolve(!!root[global]);
+ sc.onerror = () => { loading[name] = null; resolve(false); };
+ doc.head.appendChild(sc);
+ });
+ return loading[name];
+ }
+ const GLYPH = {
+ email: '<rect x="3.5" y="5.5" width="17" height="13" rx="2.5"/><path d="m4.5 7.5 7.5 6 7.5-6"/>',
+ sms: '<path d="M5 5.5h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-7l-4.5 3.5V17.5H5a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Z"/>',
+ linkedin: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 10.5v5M8.5 8v.01M12 15.5v-5M12 13c0-1.5 1-2.5 2.2-2.5s1.8.8 1.8 2.3v2.7"/>',
+ device: '<path d="M12 3.5v11M8 7.5l4-4 4 4"/><path d="M6.5 11H6a2 2 0 0 0-2 2v5.5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V13a2 2 0 0 0-2-2h-.5"/>',
+ qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM18 18h2v2h-2zM14 18h2M18 14h2"/>',
+ embed: '<path d="m8.5 8-4.5 4 4.5 4M15.5 8l4.5 4-4.5 4M13.5 5.5l-3 13"/>',
+ more: '<circle cx="6" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="18" cy="12" r="1.4"/>',
+ };
+ function tileIcon(id, slug) {
+ const s = el('span', `ov-share-tileic is-${id}`, { 'aria-hidden': 'true' });
+ const b = slug && root.OVBrands && root.OVBrands.get(slug);
+ if (b) { s.style.setProperty('--ov-brand', b.hex); s.innerHTML = root.OVBrands.svg(slug, { size: 22, color: 'currentColor' }); s.classList.add('is-brand'); } // generated, CC0
+ else if (GLYPH[id]) s.innerHTML = svg(GLYPH[id]); // constants
+ else s.textContent = id.slice(0, 1).toUpperCase();
+ return s;
+ }
+ function tile(st, id, label, slug, href, onClick, i) {
+ const t = href
+ ? el('a', 'ov-share-tile', { href, target: /^(mailto|sms):/.test(href) ? null : '_blank', rel: 'noopener noreferrer', 'data-k': `to-${id}`, style: `--i:${i}` })
+ : el('button', 'ov-share-tile', { type: 'button', 'data-k': `to-${id}`, style: `--i:${i}`, 'aria-expanded': onClick && st.panel === id ? 'true' : null });
+ t.appendChild(tileIcon(id, slug));
+ t.appendChild(el('span', 'ov-share-tilelabel', null, label));
+ t.addEventListener('click', (e) => { if (onClick) { e.preventDefault(); onClick(); } else sent(st, id); });
+ return t;
+ }
+
+ function sendSection(st, body) {
+ const o = st.o;
+ const sec = el('section', 'ov-share-send', { 'aria-label': 'Send it' });
+ const href = theLink(st);
+ // The link, and Copy: the one thing everybody can do.
+ const pill = el('div', `ov-share-linkpill${st.copied ? ' is-copied' : ''}`);
+ pill.appendChild(iconEl('link'));
+ const inp = el('input', 'ov-share-linktext', { type: 'text', readonly: true, 'aria-label': 'The link', 'data-k': 'link', value: shownLink(href) });
+ inp.value = shownLink(href);
+ inp.addEventListener('focus', () => { inp.value = href; inp.select(); });
+ inp.addEventListener('blur', () => { inp.value = shownLink(href); });
+ pill.appendChild(inp);
+ const copy = el('button', 'ov-share-btn is-primary ov-share-copy', { type: 'button', 'data-k': 'copy' });
+ copy.appendChild(iconEl(st.copied ? 'tick' : 'copy'));
+ copy.appendChild(el('span', null, null, st.copied ? 'Copied' : 'Copy'));
+ copy.addEventListener('click', async () => {
+ const ok = await copyText(st, href);
+ if (!ok) { toast(st, 'Select the link and copy it.'); inp.focus(); return; }
+ st.copied = true; sent(st, 'copy'); draw(st); toast(st, 'Link copied', true);
+ clearTimeout(st.copyTimer);
+ st.copyTimer = setTimeout(() => { st.copied = false; if (st.dlg.isConnected) draw(st); }, 1800);
+ });
+ pill.appendChild(copy);
+ sec.appendChild(pill);
+ // Who the link opens for, when the owner is looking and it is not open to everyone.
+ if (st.server && owner(st) && (st.audience === 'people' || st.audience === 'private')) {
+ sec.appendChild(el('p', 'ov-share-muted ov-share-linkfor', null, st.audience === 'private' ? 'Only you can open this link. Choose who else can, below.' : 'Only you and the people below can open this link.'));
+ }
+ if (o.time > 0) {
+ const l = el('label', 'ov-share-check');
+ const sw = el('input', null, { type: 'checkbox', role: 'switch', 'data-k': 'at-time' });
+ sw.checked = !!st.atTime;
+ sw.addEventListener('change', () => { st.atTime = sw.checked; draw(st); });
+ l.appendChild(sw);
+ l.appendChild(el('span', null, null, `Start at ${hms(o.time)}`));
+ sec.appendChild(l);
+ }
+ // The device's own share sheet: on a phone, its contacts and every app; first, because it reaches the most.
+ const canDevice = !!(root.navigator && typeof root.navigator.share === 'function');
+ if (canDevice && phoneLike()) {
+ const d = el('button', 'ov-share-device', { type: 'button', 'data-k': 'device' });
+ d.appendChild(tileIcon('device'));
+ const w = el('span', 'ov-share-devicewords');
+ w.appendChild(el('span', 'ov-share-devicetitle', null, 'Send with your phone'));
+ w.appendChild(el('span', 'ov-share-muted', null, 'Your contacts, messages and apps'));
+ d.appendChild(w);
+ d.addEventListener('click', () => device(st));
+ sec.appendChild(d);
+ }
+ // Where people post it: a grid, the person's recent ones first, the rest behind a counted fold.
+ const all = targetsFor(st);
+ const extra = [];
+ if (canDevice && !phoneLike()) extra.push(['device', 'More apps', null, null, () => device(st)]);
+ extra.push(['qr', 'QR code', null, null, () => { st.panel = st.panel === 'qr' ? '' : 'qr'; draw(st); }]);
+ if (o.embedUrl) extra.push(['embed', 'Embed', null, null, () => { st.panel = st.panel === 'embed' ? '' : 'embed'; draw(st); }]);
+ const tiles = all.map(([id, label, slug, fn]) => {
+ if (id === 'discord') return [id, label, slug, null, async () => { const ok = await copyText(st, href); sent(st, id); toast(st, ok ? 'Link copied: paste it in Discord' : 'Copy the link, then paste it in Discord'); }];
+ if (id === 'mastodon') return [id, label, slug, null, () => { st.panel = st.panel === 'mastodon' ? '' : 'mastodon'; draw(st); }];
+ return [id, label, slug, fn(href, st.o.title || '', st.o), null];
+ });
+ const ordered = [...tiles.slice(0, SHOWN - extra.length), ...extra, ...tiles.slice(SHOWN - extra.length)];
+ const grid = el('div', 'ov-share-grid', { role: 'group', 'aria-label': 'Send it to' });
+ const show = st.allTargets ? ordered : ordered.slice(0, SHOWN);
+ show.forEach(([id, label, slug, h, fn], i) => grid.appendChild(tile(st, id, label, slug, h, fn, i)));
+ if (!st.allTargets && ordered.length > SHOWN) {
+ const more = el('button', 'ov-share-tile is-more', { type: 'button', 'data-k': 'more', style: `--i:${SHOWN}`, 'aria-expanded': 'false' });
+ more.appendChild(tileIcon('more'));
+ more.appendChild(el('span', 'ov-share-tilelabel', null, `More (${ordered.length - SHOWN})`));
+ more.addEventListener('click', () => { st.allTargets = true; draw(st); });
+ grid.appendChild(more);
+ }
+ sec.appendChild(grid);
+ if (st.panel === 'mastodon') sec.appendChild(mastodonPanel(st, href));
+ if (st.panel === 'qr') sec.appendChild(qrPanel(st, href));
+ if (st.panel === 'embed') sec.appendChild(embedPanel(st));
+ body.appendChild(sec);
+ }
+
+ async function device(st) {
+ const o = st.o;
+ const data = { title: o.title || doc.title, url: theLink(st) };
+ if (o.text) data.text = o.text;
+ try {
+ if (root.navigator.canShare && !root.navigator.canShare(data)) throw new Error('cannot');
+ await root.navigator.share(data);
+ sent(st, 'device'); toast(st, 'Sent');
+ } catch (e) {
+ if (e && e.name === 'AbortError') return; // the person closed their phone's sheet
+ toast(st, 'Your device could not share it. Copy the link instead.');
+ }
+ }
+ function mastodonPanel(st, href) {
+ const f = el('form', 'ov-share-panel', { novalidate: true, 'aria-label': 'Share on Mastodon' });
+ f.appendChild(el('label', 'ov-share-label', { for: 'ov-share-masto' }, 'Your Mastodon server'));
+ const row = el('div', 'ov-share-add');
+ const field = el('div', 'ov-share-addfield');
+ const inp = el('input', null, { id: 'ov-share-masto', type: 'text', 'data-k': 'masto', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', placeholder: 'mastodon.social', inputmode: 'url' });
+ inp.value = remembered('ov-share-mastodon', '');
+ field.appendChild(inp);
+ row.appendChild(field);
+ row.appendChild(el('button', 'ov-share-btn', { type: 'submit' }, 'Open'));
+ f.appendChild(row);
+ const msg = el('p', 'ov-share-addmsg', { 'aria-live': 'polite' });
+ f.appendChild(msg);
+ f.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const host = inp.value.trim().replace(/^https?:\/\//i, '').replace(/^@?[^@]*@/, '').replace(/\/.*$/, '').toLowerCase();
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) { msg.textContent = 'Type the address of your server, like mastodon.social.'; msg.classList.add('is-bad'); return; }
+ remember('ov-share-mastodon', host);
+ root.open(`https://${host}/share?text=${E(st.o.title ? `${st.o.title} ${href}` : href)}`, '_blank', 'noopener,noreferrer');
+ sent(st, 'mastodon');
+ });
+ setTimeout(() => inp.focus(), 0);
+ return f;
+ }
+ function qrPanel(st, href) {
+ const p = el('div', 'ov-share-panel ov-share-qr', { 'aria-label': 'QR code' });
+ const art = el('div', 'ov-share-qrart', { role: 'img', 'aria-label': `A QR code for ${shownLink(href)}` });
+ p.appendChild(art);
+ const side = el('div', 'ov-share-qrside');
+ side.appendChild(el('p', 'ov-share-muted', null, 'Point a phone camera at it to open the link. It is made on this device: the link is not sent anywhere.'));
+ const dl = el('button', 'ov-share-btn is-small', { type: 'button', 'data-k': 'qr-save' }, 'Save picture');
+ side.appendChild(dl);
+ p.appendChild(side);
+ loadSibling('ov-qr.js', 'OVQR').then((ok) => {
+ if (!ok) { art.textContent = 'The QR code could not load.'; dl.disabled = true; return; }
+ let markup;
+ try { markup = root.OVQR.svg(href, { ecc: 'M', margin: 2, dark: '#0b0f17', light: '#ffffff' }); } catch (_) { art.textContent = 'This link is too long for a QR code.'; dl.disabled = true; return; }
+ art.innerHTML = markup; // made by ov-qr.js from the link, no markup of the link's own
+ dl.addEventListener('click', () => {
+ const img = new Image();
+ img.onload = () => {
+ const c = el('canvas'); c.width = c.height = 1024;
+ const g = c.getContext('2d'); g.imageSmoothingEnabled = false; g.drawImage(img, 0, 0, 1024, 1024);
+ c.toBlob((b) => { if (!b) return; const a = el('a', null, { href: URL.createObjectURL(b), download: `${(st.o.title || 'openvibe').replace(/[^\w.-]+/g, '-').slice(0, 60)}-qr.png` }); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); sent(st, 'qr'); }, 'image/png');
+ };
+ img.src = `data:image/svg+xml;charset=utf-8,${E(markup)}`;
+ });
+ });
+ return p;
+ }
+ function embedPanel(st) {
+ const o = st.o;
+ const p = el('div', 'ov-share-panel', { 'aria-label': 'Embed' });
+ // The code is pasted into somebody else's page: every attribute value is escaped as HTML.
+ const attr = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ const t = attr(o.title || 'OpenVibe');
+ let src;
+ try { src = new URL(o.embedUrl, root.location.href).href; } catch (_) { return p; }
+ if (st.atTime && o.time > 0) { const u = new URL(src); u.searchParams.set(o.timeParam || 't', String(Math.floor(o.time))); src = u.href; }
+ const code = `<iframe src="${attr(src)}" width="640" height="360" title="${t}" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen loading="lazy" style="border:0;max-width:100%;aspect-ratio:16/9;height:auto"></iframe>`;
+ p.appendChild(el('label', 'ov-share-label', { for: 'ov-share-embed' }, 'Embed on your site'));
+ const ta = el('textarea', 'ov-share-code', { id: 'ov-share-embed', readonly: true, rows: '3', 'data-k': 'embed-code', spellcheck: 'false' });
+ ta.value = code;
+ ta.addEventListener('focus', () => ta.select());
+ p.appendChild(ta);
+ const c = el('button', 'ov-share-btn is-small', { type: 'button', 'data-k': 'embed-copy' }, 'Copy code');
+ c.addEventListener('click', async () => { const ok = await copyText(st, code); toast(st, ok ? 'Embed code copied' : 'Select the code and copy it.'); if (ok) sent(st, 'embed'); });
+ p.appendChild(c);
+ return p;
+ }
+
+ // ── The sheet ─────────────────────────────────────────────────────────────────────────────────────────────
+ let current = null; // the open sheet's state
+
+ function open(opts) {
+ const o = Object.assign({}, opts || {});
+ if (current) current.close(true);
+ stylesheet();
+ return new Promise((resolve) => {
+ const st = {
+ o, server: null, audience: 'private', members: [], notify: true, view: 'main', busy: false,
+ note: '', error: '', confirmLeave: false, saved: null, resolve, giveTo: null, keepAccess: false,
+ access: !!(o.kind && o.id), first: true, atTime: false, panel: '', allTargets: false,
+ };
+ st.dlg = el('dialog', 'ov-share', { 'aria-labelledby': 'ov-share-title' });
+ st.close = (force) => close(st, force);
+ st.dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(st); });
+ st.dlg.addEventListener('click', (e) => { if (e.target === st.dlg) close(st); }); // the backdrop
+ doc.body.appendChild(st.dlg);
+ current = st;
+ draw(st);
+ st.dlg.showModal();
+ doc.documentElement.classList.add('ov-share-locked');
+ if (reduce()) st.dlg.classList.add('is-open');
+ else root.requestAnimationFrame(() => root.requestAnimationFrame(() => st.dlg.classList.add('is-open')));
+ if (st.access) load(st);
+ if (!root.OVBrands) loadSibling('brands.js', 'OVBrands').then((ok) => { if (ok && st.dlg.isConnected) draw(st); });
+ });
+ }
+
+ async function load(st) {
+ try {
+ take(st, await call(st.o, 'GET', endpoint(st.o).path));
+ st.error = '';
+ } catch (e) {
+ // Not the owner (or signed out): this person can still send it; who can open it is not theirs to see.
+ if ([401, 403, 404].includes(e.status)) st.access = false;
+ else st.error = `Could not load who can open this. ${e.message}`;
+ }
+ draw(st);
+ }
+ function take(st, s) {
+ st.server = s;
+ if (s.__etag) st.etag = s.__etag;
+ st.audience = s.audience || 'private';
+ st.members = (s.members || []).map((m) => Object.assign({}, m));
+ st.confirmLeave = false;
+ }
+ const owner = (st) => !!(st.server && st.server.you && st.server.you.role === 'owner');
+ const roles = (st) => (st.server && Array.isArray(st.server.roles) && st.server.roles.length ? st.server.roles : ['viewer']);
+ function memberKey(m) { return m.subject ? sid(m.subject) : `name:${String(m.name || m.username).toLowerCase()}`; }
+ function dirty(st) {
+ if (!st.server) return false;
+ if (st.audience !== (st.server.audience || 'private')) return true;
+ const a = (st.server.members || []).map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');
+ const b = st.members.map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');
+ return a !== b;
+ }
+
+ function close(st, force) {
+ if (!st.dlg.isConnected) return;
+ if (!force && dirty(st) && !st.confirmLeave) { st.confirmLeave = true; draw(st); return; }
+ if (current === st) current = null;
+ doc.documentElement.classList.remove('ov-share-locked');
+ const done = () => { try { st.dlg.close(); } catch (_) { /* closed */ } st.dlg.remove(); st.resolve(st.saved); };
+ if (reduce()) return done();
+ st.dlg.classList.remove('is-open');
+ let ended = false;
+ const end = () => { if (!ended) { ended = true; done(); } };
+ st.dlg.addEventListener('transitionend', end, { once: true });
+ setTimeout(end, 260);
+ }
+
+ // Draw the whole sheet again from its state: small enough that keeping it simple beats patching it.
+ function draw(st, focus) {
+ const keep = doc.activeElement && st.dlg.contains(doc.activeElement) ? doc.activeElement.getAttribute('data-k') : null;
+ const box = el('div', `ov-share-box${st.first ? ' is-first' : ''}`);
+ st.first = false;
+ box.appendChild(head(st));
+ const body = el('div', 'ov-share-body');
+ if (st.view === 'give') giveView(st, body); else mainView(st, body);
+ box.appendChild(body);
+ box.appendChild(foot(st));
+ box.appendChild(el('p', `ov-share-toast${st.toastWords ? ' is-on' : ''}${st.toastQuiet ? ' is-quiet' : ''}`, { role: 'status', 'aria-live': 'polite' }, st.toastWords || ''));
+ st.dlg.replaceChildren(box);
+ // A panel just opened (QR code, Embed, Mastodon) comes into view in the sheet's scroll.
+ if (st.panel && st.panel !== st.shownPanel) {
+ const pnl = st.dlg.querySelector('.ov-share-panel');
+ if (pnl && pnl.scrollIntoView) pnl.scrollIntoView({ block: 'nearest', behavior: reduce() ? 'auto' : 'smooth' });
+ }
+ st.shownPanel = st.panel;
+ const want = keep ? st.dlg.querySelector(`[data-k="${keep}"]`) : null;
+ if (want) want.focus();
+ else if (focus || !keep) {
+ const first = st.dlg.querySelector('[data-k="give-name"]') || st.dlg.querySelector('[data-k="copy"]') || st.dlg.querySelector('[data-k="close"]');
+ if (first) first.focus();
+ }
+ }
+
+ function head(st) {
+ const h = el('div', 'ov-share-head');
+ if (st.view === 'give') {
+ const b = el('button', 'ov-share-iconbtn', { type: 'button', 'aria-label': 'Back', 'data-k': 'back' });
+ b.appendChild(iconEl('back'));
+ b.addEventListener('click', () => { st.view = 'main'; st.error = ''; draw(st, true); });
+ h.appendChild(b);
+ }
+ const title = (st.server && st.server.title) || st.o.title || '';
+ const t = el('h2', 'ov-share-title', { id: 'ov-share-title' });
+ t.textContent = st.view === 'give' ? 'Transfer ownership' : (title ? `Share “${title}”` : 'Share');
+ h.appendChild(t);
+ const x = el('button', 'ov-share-iconbtn', { type: 'button', 'aria-label': 'Close', 'data-k': 'close' });
+ x.appendChild(iconEl('close'));
+ x.addEventListener('click', () => close(st));
+ h.appendChild(x);
+ return h;
+ }
+
+ function mainView(st, body) {
+ sendSection(st, body);
+ if (!st.access) return;
+ if (!st.server) {
+ body.appendChild(el('p', st.error ? 'ov-share-error' : 'ov-share-muted', { role: st.error ? 'alert' : null }, st.error || 'Loading who can open this\u2026'));
+ return;
+ }
+ const s = st.server;
+ const mine = owner(st);
+ body.appendChild(el('hr', 'ov-share-rule'));
+ if (s.transfer && s.transfer.state === 'pending' && mine) body.appendChild(pendingBanner(st));
+
+ // Who can open this: four choices, one line saying what the chosen one means.
+ const offered = (Array.isArray(s.audiences) && s.audiences.length ? s.audiences : Object.keys(AUDIENCES)).filter((a) => AUDIENCES[a]);
+ const fs = el('fieldset', 'ov-share-aud', { disabled: !mine || st.busy });
+ fs.appendChild(el('legend', 'ov-share-label', null, 'Who can open this'));
+ const seg = el('div', 'ov-share-seg', { style: `--n:${offered.length}` });
+ for (const a of offered) {
+ const lab = el('label', 'ov-share-segopt', { title: AUDIENCES[a].title });
+ const r = el('input', null, { type: 'radio', name: 'ov-share-aud', value: a, 'data-k': `aud-${a}` });
+ r.checked = st.audience === a;
+ r.addEventListener('change', () => { st.audience = a; st.note = ''; draw(st); });
+ lab.appendChild(r);
+ lab.appendChild(iconEl(a));
+ lab.appendChild(el('span', 'ov-share-seglabel', null, AUDIENCES[a].label));
+ seg.appendChild(lab);
+ }
+ fs.appendChild(seg);
+ const desc = el('p', 'ov-share-desc', { 'aria-live': 'polite' });
+ desc.appendChild(el('strong', null, null, `${AUDIENCES[st.audience] ? AUDIENCES[st.audience].title : st.audience}. `));
+ desc.appendChild(doc.createTextNode(AUDIENCES[st.audience] ? AUDIENCES[st.audience].desc : ''));
+ fs.appendChild(desc);
+ body.appendChild(fs);
+
+ // People: who has access (or, on an open item, who may edit it).
+ const open = st.audience === 'public' || st.audience === 'link';
+ const canEditRole = roles(st).includes('editor');
+ const listMeans = open ? (canEditRole ? 'edit' : 'nothing') : 'open';
+ const sec = el('section', 'ov-share-people', { 'aria-labelledby': 'ov-share-people-h' });
+ const hd = el('div', 'ov-share-sechead');
+ hd.appendChild(el('h3', 'ov-share-label', { id: 'ov-share-people-h' }, open ? 'People who can edit' : 'People with access'));
+ sec.appendChild(hd);
+ if (mine && listMeans !== 'nothing') sec.appendChild(adder(st));
+ if (st.note) sec.appendChild(el('p', 'ov-share-note', { role: 'status' }, st.note));
+ if (st.audience === 'private' && st.members.length) {
+ sec.appendChild(el('p', 'ov-share-warn', null, st.members.length === 1
+ ? 'Only me: saving removes the person below.' : `Only me: saving removes the ${st.members.length} people below.`));
+ }
+ if (open && listMeans === 'nothing' && st.members.length) {
+ sec.appendChild(el('p', 'ov-share-muted', null, 'Anyone can open it now, so this list does nothing. Choose People to limit it to them.'));
+ }
+ const ul = el('ul', 'ov-share-list');
+ if (s.owner) ul.appendChild(personRow(st, s.owner, { tag: 'Owner', you: s.you && same(s.owner.subject, s.you.subject) }));
+ st.members.forEach((m, i) => ul.appendChild(personRow(st, m, { member: m, index: i, dim: st.audience === 'private' })));
+ sec.appendChild(ul);
+ if (!st.members.length && listMeans === 'open' && mine) {
+ sec.appendChild(el('p', 'ov-share-muted', null, st.audience === 'private' ? 'Add someone by name to share it with them.' : 'Nobody else yet. Add someone by their OpenVibe name.'));
+ }
+ if (st.members.some((m) => m.isNew)) {
+ const nl = el('label', 'ov-share-check');
+ const cb = el('input', null, { type: 'checkbox', 'data-k': 'notify' });
+ cb.checked = st.notify;
+ cb.addEventListener('change', () => { st.notify = cb.checked; });
+ nl.appendChild(cb);
+ nl.appendChild(el('span', null, null, 'Tell the people I add (a notification on OpenVibe)'));
+ sec.appendChild(nl);
+ }
+ body.appendChild(sec);
+
+ // People who asked.
+ const asks = (s.requests || []).filter((r) => !st.members.some((m) => same(m.subject, r.subject)));
+ if (mine && asks.length) {
+ const rs = el('section', 'ov-share-people', { 'aria-labelledby': 'ov-share-asks-h' });
+ rs.appendChild(el('h3', 'ov-share-label', { id: 'ov-share-asks-h' }, `Asking for access (${asks.length})`));
+ const rl = el('ul', 'ov-share-list');
+ for (const r of asks) rl.appendChild(requestRow(st, r));
+ rs.appendChild(rl);
+ body.appendChild(rs);
+ }
+ if (!mine) {
+ body.appendChild(el('p', 'ov-share-muted', null, s.owner && s.owner.username
+ ? `Only ${handle(s.owner)} can change who can open this.` : 'Only the owner can change who can open this.'));
+ }
+ if (st.error) body.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error));
+ }
+
+ // The @name field: Enter or Add checks the name with Network (through the service), then adds a chip-row.
+ function adder(st) {
+ const wrap = el('form', 'ov-share-add', { novalidate: true });
+ const field = el('div', 'ov-share-addfield');
+ field.appendChild(el('span', 'ov-share-at', { 'aria-hidden': 'true' }, '@'));
+ const inp = el('input', null, {
+ type: 'text', name: 'name', 'data-k': 'add', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false',
+ enterkeyhint: 'done', maxlength: '41', placeholder: 'Add people by their OpenVibe name', 'aria-label': 'Add people by their OpenVibe name',
+ 'aria-describedby': 'ov-share-add-msg',
+ });
+ if (st.draftName) inp.value = st.draftName;
+ inp.addEventListener('input', () => { st.draftName = inp.value; if (st.addMsg) { st.addMsg = ''; msg.textContent = ''; msg.className = 'ov-share-addmsg'; } });
+ field.appendChild(inp);
+ wrap.appendChild(field);
+ const btn = el('button', 'ov-share-btn', { type: 'submit', 'data-k': 'add-btn' }, st.checking ? 'Checking…' : 'Add');
+ btn.disabled = !!st.checking;
+ wrap.appendChild(btn);
+ const msg = el('p', `ov-share-addmsg${st.addMsgBad ? ' is-bad' : ''}`, { id: 'ov-share-add-msg', 'aria-live': 'polite' }, st.addMsg || '');
+ wrap.appendChild(msg);
+ wrap.addEventListener('submit', (e) => { e.preventDefault(); addNames(st, inp.value); });
+ const sugg = suggestionsFor(st);
+ if (sugg.length) {
+ const row = el('div', 'ov-share-sugg', { role: 'group', 'aria-label': 'People you have shared with' });
+ for (const p of sugg) {
+ const c = el('button', 'ov-share-chip', { type: 'button', 'data-k': `sugg-${p.username}` });
+ c.appendChild(avatar(p));
+ c.appendChild(el('span', null, null, handle(p)));
+ c.addEventListener('click', () => addPerson(st, p));
+ row.appendChild(c);
+ }
+ wrap.appendChild(row);
+ }
+ return wrap;
+ }
+ function suggestionsFor(st) {
+ const have = new Set(st.members.map((m) => sid(m.subject)));
+ if (st.server && st.server.owner) have.add(sid(st.server.owner.subject));
+ return (st.suggested || []).filter((p) => p && p.subject && !have.has(sid(p.subject))).slice(0, 6);
+ }
+
+ async function addNames(st, raw) {
+ const names = String(raw || '').split(/[\s,;]+/).map(cleanName).filter(Boolean);
+ if (!names.length) return;
+ const bad = names.filter((n) => !NAME_RE.test(n));
+ if (bad.length) { st.addMsg = `“${bad[0]}” is not an OpenVibe name: letters, numbers, dots, dashes and underscores only.`; st.addMsgBad = true; draw(st); return; }
+ st.checking = true; st.addMsg = ''; draw(st);
+ const missing = [];
+ try {
+ for (const n of names) {
+ const p = await lookup(st.o, n);
+ if (p === null) missing.push(n);
+ else if (p === undefined) addPerson(st, { name: n, username: n, unchecked: true }, true);
+ else addPerson(st, p, true);
+ }
+ st.draftName = missing.join(' ');
+ st.addMsg = missing.length ? `No OpenVibe account is called ${missing.map((n) => `@${n}`).join(', ')}.` : '';
+ st.addMsgBad = !!missing.length;
+ } catch (e) {
+ st.addMsg = e.message; st.addMsgBad = true;
+ }
+ st.checking = false;
+ draw(st);
+ }
+ function addPerson(st, p, quiet) {
+ const s = st.server;
+ if (s.owner && p.subject && same(p.subject, s.owner.subject)) { st.addMsg = `${handle(p)} owns this already.`; st.addMsgBad = false; if (!quiet) draw(st); return; }
+ if (st.members.some((m) => memberKey(m) === memberKey(p))) { st.addMsg = `${handle(p)} is on the list already.`; st.addMsgBad = false; if (!quiet) draw(st); return; }
+ const open = st.audience === 'public' || st.audience === 'link';
+ const role = open && roles(st).includes('editor') ? 'editor' : 'viewer';
+ st.members.push({ subject: p.subject, name: p.subject ? undefined : p.name, username: p.username, display_name: p.display_name, avatar_url: p.avatar_url, role, expires_at: null, isNew: true, unchecked: !!p.unchecked });
+ if (st.audience === 'private') { st.audience = 'people'; st.note = 'Switched to Specific people, so they can open it.'; }
+ if (!quiet) { st.draftName = ''; draw(st); }
+ }
+
+ function personRow(st, p, x) {
+ const li = el('li', `ov-share-row${x.dim ? ' is-dim' : ''}${x.member && x.member.isNew ? ' is-new' : ''}`);
+ li.appendChild(avatar(p));
+ const words = el('div', 'ov-share-words');
+ const top = el('div', 'ov-share-name');
+ top.appendChild(el('span', 'ov-share-display', null, nameOf(p)));
+ if (x.you || (st.server && st.server.you && p.subject && same(p.subject, st.server.you.subject))) top.appendChild(el('span', 'ov-share-tag', null, 'You'));
+ if (x.member && x.member.isNew) top.appendChild(el('span', 'ov-share-tag is-new', null, 'New'));
+ words.appendChild(top);
+ const sub = el('div', 'ov-share-sub');
+ if (p.username) sub.appendChild(el('span', null, null, handle(p)));
+ if (x.member && x.member.unchecked) sub.appendChild(el('span', 'ov-share-soft', null, 'checked when you save'));
+ if (x.member) sub.appendChild(expiry(st, x.member));
+ words.appendChild(sub);
+ li.appendChild(words);
+ if (x.tag) { li.appendChild(el('span', 'ov-share-role is-fixed', null, x.tag)); return li; }
+ const m = x.member;
+ if (!owner(st)) { li.appendChild(el('span', 'ov-share-role is-fixed', null, ROLE_WORDS[m.role] || m.role)); return li; }
+ const rs = roles(st);
+ if (rs.length > 1) {
+ const sel = el('select', 'ov-share-select', { 'aria-label': `What ${handle(m) || nameOf(m)} can do`, 'data-k': `role-${memberKey(m)}`, 'data-ov-select': 'bare' });
+ for (const r of rs) { const op = el('option', null, { value: r }, ROLE_WORDS[r] || r); op.selected = m.role === r; sel.appendChild(op); }
+ sel.addEventListener('change', () => { m.role = sel.value; draw(st); });
+ const pill = el('span', 'ov-share-rolepill');
+ pill.appendChild(sel);
+ li.appendChild(pill);
+ } else li.appendChild(el('span', 'ov-share-role is-fixed', null, ROLE_WORDS[m.role] || m.role));
+ const rm = el('button', 'ov-share-iconbtn is-small', { type: 'button', 'aria-label': `Remove ${handle(m) || nameOf(m)}`, 'data-k': `rm-${memberKey(m)}` });
+ rm.appendChild(iconEl('close'));
+ rm.addEventListener('click', () => {
+ st.members.splice(st.members.indexOf(m), 1);
+ st.note = m.isNew ? '' : `${handle(m) || nameOf(m)} loses access when you save. They may still have a copy of what they already opened.`;
+ draw(st);
+ });
+ li.appendChild(rm);
+ return li;
+ }
+
+ // "until Nov 30": an end date for one person's access, set with the browser's date picker.
+ function expiry(st, m) {
+ const wrap = el('span', 'ov-share-exp');
+ if (!owner(st)) { if (m.expires_at) wrap.textContent = `until ${when(m.expires_at)}`; return wrap; }
+ if (st.editingExp === memberKey(m)) {
+ const d = el('input', 'ov-share-date', { type: 'date', 'aria-label': `Last day for ${handle(m) || nameOf(m)}`, 'data-k': `date-${memberKey(m)}` });
+ const t = new Date(Date.now() + 86400000);
+ d.min = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
+ if (m.expires_at) { const e = new Date(m.expires_at); d.value = `${e.getFullYear()}-${String(e.getMonth() + 1).padStart(2, '0')}-${String(e.getDate()).padStart(2, '0')}`; }
+ const commit = () => {
+ if (d.value) { const [y, mo, da] = d.value.split('-').map(Number); m.expires_at = new Date(y, mo - 1, da, 23, 59, 59).toISOString(); }
+ st.editingExp = null; draw(st);
+ };
+ d.addEventListener('change', commit);
+ d.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); st.editingExp = null; draw(st); } });
+ wrap.appendChild(d);
+ if (m.expires_at) {
+ const none = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': `noexp-${memberKey(m)}` }, 'No end date');
+ none.addEventListener('click', () => { m.expires_at = null; st.editingExp = null; draw(st); });
+ wrap.appendChild(none);
+ }
+ setTimeout(() => { try { d.focus(); if (d.showPicker) d.showPicker(); } catch (_) { /* a picker needs a gesture in some browsers */ } }, 0);
+ return wrap;
+ }
+ const b = el('button', 'ov-share-link', { type: 'button', 'data-k': `exp-${memberKey(m)}` }, m.expires_at ? `until ${when(m.expires_at)}` : 'Set an end date');
+ b.addEventListener('click', () => { st.editingExp = memberKey(m); draw(st); });
+ if (m.expires_at) b.setAttribute('aria-label', `Access for ${handle(m) || nameOf(m)} ends ${when(m.expires_at, true)}: change`);
+ wrap.appendChild(b);
+ return wrap;
+ }
+
+ function requestRow(st, r) {
+ const li = el('li', 'ov-share-row');
+ li.appendChild(avatar(r));
+ const words = el('div', 'ov-share-words');
+ words.appendChild(el('div', 'ov-share-name', null, nameOf(r)));
+ const sub = el('div', 'ov-share-sub');
+ if (r.username) sub.appendChild(el('span', null, null, handle(r)));
+ if (r.requested_at) sub.appendChild(el('span', null, null, `asked ${when(r.requested_at)}`));
+ words.appendChild(sub);
+ li.appendChild(words);
+ const add = el('button', 'ov-share-btn is-small', { type: 'button', 'data-k': `req-add-${sid(r.subject)}` }, 'Add');
+ add.addEventListener('click', () => addPerson(st, r));
+ li.appendChild(add);
+ const no = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': `req-no-${sid(r.subject)}` }, 'Dismiss');
+ no.addEventListener('click', async () => {
+ no.disabled = true;
+ try {
+ await call(st.o, 'POST', `${endpoint(st.o).path}/requests/${encodeURIComponent(r.id)}/dismiss`, {});
+ st.server.requests = (st.server.requests || []).filter((q) => q !== r);
+ } catch (e) { st.error = `Could not dismiss: ${e.message}`; }
+ draw(st);
+ });
+ li.appendChild(no);
+ return li;
+ }
+
+ function pendingBanner(st) {
+ const t = st.server.transfer;
+ const b = el('div', 'ov-share-banner', { role: 'status' });
+ b.appendChild(iconEl('give'));
+ const p = el('p');
+ p.appendChild(doc.createTextNode('Waiting for '));
+ p.appendChild(el('strong', null, null, handle(t.to) || nameOf(t.to)));
+ p.appendChild(doc.createTextNode(` to accept ownership${t.expires_at ? `. The offer ends ${when(t.expires_at)}` : ''}.`));
+ b.appendChild(p);
+ const c = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'cancel-give' }, 'Cancel offer');
+ c.addEventListener('click', async () => {
+ st.busy = true; draw(st);
+ try { take(st, await call(st.o, 'POST', `${endpoint(st.o).path}/transfer/cancel`, { transfer_id: t.id })); st.note = 'Offer cancelled. It is still yours.'; announce(st.o, st.server); }
+ catch (e) { st.error = `Could not cancel: ${e.message}`; }
+ st.busy = false; draw(st);
+ });
+ b.appendChild(c);
+ return b;
+ }
+
+ // ── Transfer ownership: pick one person, say what happens to your own access, send ─────────────────────────
+ function giveView(st, body) {
+ const title = (st.server && st.server.title) || st.o.title;
+ body.appendChild(el('p', 'ov-share-lead', null, `Give ${title ? `“${title}”` : 'this'} to someone else. They become the owner when they accept, and can then decide who else may open it.`));
+ const f = el('form', 'ov-share-give', { novalidate: true });
+ const lab = el('label', 'ov-share-label', { for: 'ov-share-give-name' }, 'New owner');
+ f.appendChild(lab);
+ const field = el('div', 'ov-share-addfield');
+ field.appendChild(el('span', 'ov-share-at', { 'aria-hidden': 'true' }, '@'));
+ const inp = el('input', null, { id: 'ov-share-give-name', type: 'text', 'data-k': 'give-name', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', maxlength: '41', placeholder: 'their OpenVibe name', 'aria-describedby': 'ov-share-give-msg' });
+ if (st.giveTo) inp.value = st.giveTo.username || '';
+ else if (st.giveDraft) inp.value = st.giveDraft;
+ inp.addEventListener('input', () => { st.giveDraft = inp.value; st.giveTo = null; });
+ field.appendChild(inp);
+ f.appendChild(field);
+ f.appendChild(el('p', `ov-share-addmsg${st.giveBad ? ' is-bad' : ''}`, { id: 'ov-share-give-msg', 'aria-live': 'polite' }, st.giveMsg || ''));
+ const people = st.members.filter((m) => m.subject && !m.isNew);
+ if (people.length && !st.giveTo) {
+ const row = el('div', 'ov-share-sugg', { role: 'group', 'aria-label': 'People with access' });
+ for (const p of people.slice(0, 6)) {
+ const c = el('button', 'ov-share-chip', { type: 'button', 'data-k': `give-${p.username}` });
+ c.appendChild(avatar(p));
+ c.appendChild(el('span', null, null, handle(p)));
+ c.addEventListener('click', () => { st.giveTo = p; st.giveMsg = `${nameOf(p)} (${handle(p)}) will be asked.`; st.giveBad = false; draw(st); });
+ row.appendChild(c);
+ }
+ f.appendChild(row);
+ }
+ const keep = el('fieldset', 'ov-share-keep');
+ keep.appendChild(el('legend', 'ov-share-label', null, 'When they accept'));
+ for (const [v, t, d] of [[false, 'Remove my access', 'Recommended. It is theirs alone; they can share it back with you.'], [true, 'Keep me as a viewer', 'You can still open it, but not change it.']]) {
+ const l = el('label', 'ov-share-radio');
+ const r = el('input', null, { type: 'radio', name: 'ov-share-keep', 'data-k': `keep-${v}` });
+ r.checked = st.keepAccess === v;
+ r.addEventListener('change', () => { st.keepAccess = v; });
+ l.appendChild(r);
+ const w = el('span', 'ov-share-radiowords');
+ w.appendChild(el('span', 'ov-share-radiotitle', null, t));
+ w.appendChild(el('span', 'ov-share-muted', null, d));
+ l.appendChild(w);
+ keep.appendChild(l);
+ }
+ f.appendChild(keep);
+ f.appendChild(el('p', 'ov-share-muted', null, 'They have 7 days to accept, and can open it meanwhile to look. Until they accept it stays yours, and you can cancel the offer.'));
+ if (st.error) f.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error));
+ f.addEventListener('submit', (e) => { e.preventDefault(); sendOffer(st, inp.value); });
+ body.appendChild(f);
+ }
+ async function sendOffer(st, raw) {
+ st.error = '';
+ let to = st.giveTo;
+ if (!to) {
+ const n = cleanName(raw);
+ if (!NAME_RE.test(n)) { st.giveMsg = 'Type their OpenVibe name.'; st.giveBad = true; draw(st); return; }
+ st.busy = true; st.giveMsg = 'Checking…'; st.giveBad = false; draw(st);
+ try {
+ const p = await lookup(st.o, n);
+ if (p === null) { st.busy = false; st.giveMsg = `No OpenVibe account is called @${n}.`; st.giveBad = true; draw(st); return; }
+ to = p || { name: n, username: n };
+ } catch (e) { st.busy = false; st.giveMsg = e.message; st.giveBad = true; draw(st); return; }
+ }
+ if (st.server.owner && to.subject && same(to.subject, st.server.owner.subject)) { st.busy = false; st.giveMsg = 'That is you.'; st.giveBad = true; draw(st); return; }
+ st.busy = true; draw(st);
+ try {
+ const out = await call(st.o, 'POST', `${endpoint(st.o).path}/transfer`, { to: to.subject ? { subject: to.subject } : { name: to.name }, keep_access: !!st.keepAccess });
+ if (out && out.audience) take(st, out); else take(st, await call(st.o, 'GET', endpoint(st.o).path));
+ st.view = 'main'; st.giveTo = null; st.giveDraft = ''; st.giveMsg = '';
+ st.note = `Offer sent to ${handle(to) || nameOf(to)}. It stays yours until they accept.`;
+ announce(st.o, st.server);
+ } catch (e) {
+ st.error = e.status === 404 && e.body && /name|person|subject/i.test(JSON.stringify(e.body)) ? `No OpenVibe account is called ${handle(to)}.` : `Could not send the offer: ${e.message}`;
+ }
+ st.busy = false; draw(st, true);
+ }
+
+ function foot(st) {
+ const f = el('div', 'ov-share-foot');
+ if (st.view === 'give') {
+ const c = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': 'give-cancel' }, 'Back');
+ c.addEventListener('click', () => { st.view = 'main'; st.error = ''; draw(st, true); });
+ const s = el('button', 'ov-share-btn is-primary', { type: 'button', 'data-k': 'give-send', disabled: st.busy }, st.busy ? 'Sending…' : 'Send offer');
+ s.addEventListener('click', () => { const i = st.dlg.querySelector('[data-k="give-name"]'); sendOffer(st, i ? i.value : ''); });
+ f.appendChild(el('span', 'ov-share-grow'));
+ f.appendChild(c); f.appendChild(s);
+ return f;
+ }
+ if (st.confirmLeave) {
+ f.classList.add('is-confirm');
+ f.appendChild(el('p', 'ov-share-grow', { role: 'alert' }, 'Leave without saving your changes?'));
+ const k = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': 'keep-editing' }, 'Keep editing');
+ k.addEventListener('click', () => { st.confirmLeave = false; draw(st); });
+ const d = el('button', 'ov-share-btn is-danger', { type: 'button', 'data-k': 'discard' }, 'Discard');
+ d.addEventListener('click', () => close(st, true));
+ f.appendChild(k); f.appendChild(d);
+ return f;
+ }
+ const s = st.server;
+ if (s && owner(st) && !(s.transfer && s.transfer.state === 'pending') && s.transferable !== false) {
+ const g = el('button', 'ov-share-btn is-quiet', { type: 'button', 'data-k': 'give' });
+ g.appendChild(iconEl('give'));
+ g.appendChild(el('span', null, null, 'Transfer'));
+ g.setAttribute('aria-label', 'Transfer ownership');
+ g.addEventListener('click', () => { st.view = 'give'; st.error = ''; st.giveMsg = ''; draw(st, true); });
+ f.appendChild(g);
+ }
+ f.appendChild(el('span', 'ov-share-grow'));
+ const isDirty = dirty(st);
+ const done = el('button', `ov-share-btn ${isDirty ? 'is-primary' : ''}`, { type: 'button', 'data-k': 'done', disabled: st.busy || (isDirty && st.checking) }, st.busy ? 'Saving…' : isDirty ? 'Save' : 'Done');
+ done.addEventListener('click', () => (isDirty ? save(st) : close(st)));
+ f.appendChild(done);
+ return f;
+ }
+
+ async function save(st) {
+ st.busy = true; st.error = ''; draw(st);
+ const members = st.audience === 'private' ? [] : st.members.map((m) => {
+ const r = { role: m.role };
+ if (m.expires_at) r.expires_at = m.expires_at;
+ if (m.subject) r.subject = m.subject; else r.name = m.name || m.username;
+ return r;
+ });
+ try {
+ const out = await call(st.o, 'PUT', endpoint(st.o).path, { audience: st.audience, members, notify: !!st.notify }, st.etag ? { 'if-match': st.etag } : null);
+ take(st, out && out.audience ? out : await call(st.o, 'GET', endpoint(st.o).path));
+ st.saved = st.server; st.note = 'Saved.';
+ announce(st.o, st.server);
+ } catch (e) {
+ if (e.status === 412) {
+ try { take(st, await call(st.o, 'GET', endpoint(st.o).path)); } catch (_) { /* keep what is drawn */ }
+ st.busy = false; st.error = 'Someone changed who can open this meanwhile. This is how it is now; make your change again.';
+ draw(st); return;
+ }
+ const names = e.body && Array.isArray(e.body.names) ? e.body.names : null;
+ st.error = names && names.length ? `No OpenVibe account is called ${names.map((n) => `@${n}`).join(', ')}. Remove them, then save.` : `Could not save: ${e.message}`;
+ }
+ st.busy = false; draw(st);
+ }
+
+ // ── For the recipient of a transfer: a banner on the item ─────────────────────────────────────────────────
+ function offer(container, opts) {
+ const o = Object.assign({}, opts || {});
+ stylesheet();
+ const b = el('div', 'ov-share-banner is-offer', { role: 'region', 'aria-label': 'Ownership offer' });
+ const drawOffer = (state, err) => {
+ b.replaceChildren();
+ b.appendChild(iconEl(state === 'accepted' ? 'tick' : 'give'));
+ const p = el('p');
+ if (state === 'accepted') p.textContent = 'It is yours now. You decide who else can open it.';
+ else if (state === 'declined') p.textContent = 'You declined. Nothing changed.';
+ else {
+ p.appendChild(el('strong', null, null, handle(o.from) || nameOf(o.from)));
+ p.appendChild(doc.createTextNode(` wants to give you ${o.title ? `“${o.title}”` : 'this'}. `));
+ p.appendChild(doc.createTextNode(o.keep_access ? 'They would keep view access. ' : 'They would lose access. '));
+ if (o.expires_at) p.appendChild(doc.createTextNode(`The offer ends ${when(o.expires_at)}.`));
+ }
+ b.appendChild(p);
+ if (err) b.appendChild(el('p', 'ov-share-error', { role: 'alert' }, err));
+ if (!state) {
+ const acts = el('div', 'ov-share-acts');
+ const yes = el('button', 'ov-share-btn is-primary is-small', { type: 'button' }, 'Accept');
+ const no = el('button', 'ov-share-btn is-quiet is-small', { type: 'button' }, 'Decline');
+ const answer = async (verb) => {
+ yes.disabled = no.disabled = true;
+ try {
+ const out = await call(o, 'POST', `${endpoint(o).path}/transfer/${verb}`, { transfer_id: o.transfer_id });
+ announce(o, out);
+ drawOffer(verb === 'accept' ? 'accepted' : 'declined');
+ if (typeof o.onDone === 'function') o.onDone(verb === 'accept' ? 'accepted' : 'declined', out);
+ } catch (e) { drawOffer(null, e.status === 410 || e.status === 404 ? 'This offer is no longer open.' : `Could not answer: ${e.message}`); }
+ };
+ yes.addEventListener('click', () => answer('accept'));
+ no.addEventListener('click', () => answer('decline'));
+ acts.appendChild(yes); acts.appendChild(no);
+ b.appendChild(acts);
+ }
+ };
+ drawOffer(null);
+ container.replaceChildren(b);
+ return b;
+ }
+
+ // ── For a person who is not on the list: ask, or sign in. Never the title or the content. ─────────────────
+ function gate(container, opts) {
+ const o = Object.assign({}, opts || {});
+ stylesheet();
+ const card = el('div', 'ov-share-gate', { role: 'region', 'aria-label': 'Access' });
+ const drawGate = (state, err) => {
+ card.replaceChildren();
+ card.appendChild(iconEl('people', 'ov-share-ico is-big'));
+ card.appendChild(el('h2', 'ov-share-gatetitle', null, state === 'asked' ? 'Asked' : 'This is shared with specific people'));
+ if (!o.signedInAs) {
+ card.appendChild(el('p', 'ov-share-muted', null, 'Sign in with the OpenVibe account it was shared with.'));
+ const a = el('a', 'ov-share-btn is-primary', { href: o.signInUrl || '/login' }, 'Sign in');
+ card.appendChild(a);
+ } else if (state === 'asked') {
+ card.appendChild(el('p', 'ov-share-muted', null, 'The owner has a notification. You will get one when they answer.'));
+ } else {
+ const p = el('p', 'ov-share-muted');
+ p.appendChild(doc.createTextNode('You are signed in as '));
+ p.appendChild(el('strong', null, null, `@${cleanName(o.signedInAs)}`));
+ p.appendChild(doc.createTextNode(', who is not on the list.'));
+ card.appendChild(p);
+ if (err) card.appendChild(el('p', 'ov-share-error', { role: 'alert' }, err));
+ const acts = el('div', 'ov-share-acts');
+ const ask = el('button', 'ov-share-btn is-primary', { type: 'button' }, 'Ask for access');
+ ask.addEventListener('click', async () => {
+ ask.disabled = true;
+ try { await call(o, 'POST', `${endpoint(o).path}/requests`, {}); drawGate('asked'); if (typeof o.onDone === 'function') o.onDone('asked'); }
+ catch (e) { drawGate(null, e.status === 429 ? 'You asked recently. The owner has it.' : `Could not ask: ${e.message}`); }
+ });
+ acts.appendChild(ask);
+ if (o.signInUrl) acts.appendChild(el('a', 'ov-share-btn is-quiet', { href: o.signInUrl }, 'Switch account'));
+ card.appendChild(acts);
+ }
+ };
+ drawGate(null);
+ container.replaceChildren(card);
+ return card;
+ }
+
+ // ── Buttons in markup: [data-ov-share-sheet] ──────────────────────────────────────────────────────────────
+ doc.addEventListener('click', (e) => {
+ const b = e.target && e.target.closest ? e.target.closest('[data-ov-share-sheet]') : null;
+ if (!b || b.disabled) return;
+ e.preventDefault();
+ open(fromButton(b));
+ });
+
+ // A button's data-* as open()'s options (navbar.js and kit-web.js use it too, once this file has loaded).
+ function fromButton(b) {
+ const d = b.dataset || {};
+ let time = 0;
+ if (d.timeFrom) { const m = doc.querySelector(d.timeFrom); if (m && typeof m.currentTime === 'number' && isFinite(m.currentTime)) time = m.currentTime; }
+ else if (d.time) time = Number(d.time) || 0;
+ return { kind: d.kind, id: d.id, title: d.title || doc.title, text: d.text, url: d.url || d.link, image: d.image, embedUrl: d.embed,
+ time: time >= 5 ? time : 0, timeParam: d.timeParam, base: d.base, path: d.path };
+ }
+
+ root.OVShare = { open, offer, gate, fromButton, version: 2 };
+})(typeof window !== 'undefined' ? window : null);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.113.0",
+ "version": "4.114.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.113.0",
+ "version": "4.114.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.113.0",
+ "version": "4.114.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -56,6 +56,7 @@
@@ -56,6 +56,7 @@
"./ov-glyphs": "./ov-glyphs.js",
"./ov-glyphs": "./ov-glyphs.js",
"./ov-forms": "./ov-forms.js",
"./ov-forms": "./ov-forms.js",
"./ov-qr": "./ov-qr.js",
"./ov-qr": "./ov-qr.js",
+ "./ov-share": "./ov-share.js",
"./ov-labels": "./ov-labels.js",
"./ov-labels": "./ov-labels.js",
"./ov-look": "./ov-look.js",
"./ov-look": "./ov-look.js",
"./ov-fx": "./ov-fx.js",
"./ov-fx": "./ov-fx.js",
@@ -79,6 +79,7 @@ const COMPOSE_IMPORTS = [
@@ -79,6 +79,7 @@ const COMPOSE_IMPORTS = [
'androidx.compose.material.icons.filled.Error',
'androidx.compose.material.icons.filled.Error',
'androidx.compose.material.icons.filled.ExpandMore',
'androidx.compose.material.icons.filled.ExpandMore',
'androidx.compose.material.icons.filled.Info',
'androidx.compose.material.icons.filled.Info',
+ 'androidx.compose.material.icons.filled.Share',
'androidx.compose.material3.Button',
'androidx.compose.material3.Button',
'androidx.compose.material3.ButtonDefaults',
'androidx.compose.material3.ButtonDefaults',
'androidx.compose.material3.DrawerState',
'androidx.compose.material3.DrawerState',
@@ -41,6 +41,9 @@ sealed class KitAction {
@@ -41,6 +41,9 @@ sealed class KitAction {
/** Run the action a button names (the app decides what that means). */
/** Run the action a button names (the app decides what that means). */
data class Run(val id: String) : KitAction()
data class Run(val id: String) : KitAction()
+
+ /** Share a link: the app opens the platform's own share sheet (Android's chooser, with Direct Share). */
+ data class Share(val title: String, val url: String) : KitAction()
}
}
/** A link the kit draws: where it goes and what it says. */
/** A link the kit draws: where it goes and what it says. */
@@ -226,6 +229,16 @@ sealed class KitNode {
@@ -226,6 +229,16 @@ sealed class KitNode {
override val type: String = "notice"
override val type: String = "notice"
}
}
+ /** The Share button: the platform's own share sheet for the link (Android's chooser, with Direct Share). */
+ data class Share(
+ val title: String,
+ val url: String? = null,
+ val label: String = "Share",
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "share"
+ }
+
/** An empty state: what a screen shows when it has nothing to show. */
/** An empty state: what a screen shows when it has nothing to show. */
data class Empty(
data class Empty(
val title: String,
val title: String,
@@ -340,6 +353,11 @@ sealed class KitNode {
@@ -340,6 +353,11 @@ sealed class KitNode {
else -> OvNoticeKind.Info
else -> OvNoticeKind.Info
},
},
)
)
+ "share" -> Share(
+ title = props.str("title") ?: "",
+ url = props.str("url"),
+ label = props.str("label") ?: "Share",
+ )
"empty" -> Empty(
"empty" -> Empty(
title = props.str("title") ?: "",
title = props.str("title") ?: "",
text = props.str("text"),
text = props.str("text"),
@@ -576,6 +594,7 @@ fun KitNodeView(
@@ -576,6 +594,7 @@ fun KitNodeView(
is KitNode.Sidebar -> OvSidebarNav(node, icons, onAction)
is KitNode.Sidebar -> OvSidebarNav(node, icons, onAction)
is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })
is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })
is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)
is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)
+ is KitNode.Share -> OvShare(title = node.title, url = node.url ?: "", label = node.label, onShare = { t, u -> onAction(KitAction.Share(t, u)) })
is KitNode.Empty -> {
is KitNode.Empty -> {
val link = node.action
val link = node.action
val action: Pair<String, () -> Unit>? = if (link != null) {
val action: Pair<String, () -> Unit>? = if (link != null) {
@@ -456,6 +456,21 @@ public struct KitNotice: KitComponent {
@@ -456,6 +456,21 @@ public struct KitNotice: KitComponent {
}
}
}
}
+/// The Share button: the system share sheet for the link (ShareLink).
+public struct KitShare: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ public var title: String { props.string("title") ?? "" }
+ public var url: String? { props.string("url") }
+ public var label: String { props.string("label") ?? "Share" }
+}
+
/// An empty state: what a screen shows when it has nothing to show.
/// An empty state: what a screen shows when it has nothing to show.
public struct KitEmpty: KitComponent {
public struct KitEmpty: KitComponent {
public let props: [String: KitValue]
public let props: [String: KitValue]
@@ -489,6 +504,7 @@ public enum KitNode: Codable {
@@ -489,6 +504,7 @@ public enum KitNode: Codable {
case stats(KitStats)
case stats(KitStats)
case notice(KitNotice)
case notice(KitNotice)
case empty(KitEmpty)
case empty(KitEmpty)
+ case share(KitShare)
public init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: KitCodingKey.self)
let container = try decoder.container(keyedBy: KitCodingKey.self)
@@ -508,6 +524,7 @@ public enum KitNode: Codable {
@@ -508,6 +524,7 @@ public enum KitNode: Codable {
case "stats": self = .stats(KitStats(props: props, children: children))
case "stats": self = .stats(KitStats(props: props, children: children))
case "notice": self = .notice(KitNotice(props: props, children: children))
case "notice": self = .notice(KitNotice(props: props, children: children))
case "empty": self = .empty(KitEmpty(props: props, children: children))
case "empty": self = .empty(KitEmpty(props: props, children: children))
+ case "share": self = .share(KitShare(props: props, children: children))
default:
default:
throw DecodingError.dataCorruptedError(
throw DecodingError.dataCorruptedError(
forKey: KitCodingKey("type"),
forKey: KitCodingKey("type"),
@@ -541,6 +558,7 @@ public enum KitNode: Codable {
@@ -541,6 +558,7 @@ public enum KitNode: Codable {
case .stats: return "stats"
case .stats: return "stats"
case .notice: return "notice"
case .notice: return "notice"
case .empty: return "empty"
case .empty: return "empty"
+ case .share: return "share"
}
}
}
}
@@ -559,6 +577,7 @@ public enum KitNode: Codable {
@@ -559,6 +577,7 @@ public enum KitNode: Codable {
case .stats(let node): return node.props
case .stats(let node): return node.props
case .notice(let node): return node.props
case .notice(let node): return node.props
case .empty(let node): return node.props
case .empty(let node): return node.props
+ case .share(let node): return node.props
}
}
}
}
@@ -577,6 +596,7 @@ public enum KitNode: Codable {
@@ -577,6 +596,7 @@ public enum KitNode: Codable {
case .stats(let node): return node.children
case .stats(let node): return node.children
case .notice(let node): return node.children
case .notice(let node): return node.children
case .empty(let node): return node.children
case .empty(let node): return node.children
+ case .share(let node): return node.children
}
}
}
}
}
}
@@ -644,6 +664,7 @@ private struct KitNodeBody: View {
@@ -644,6 +664,7 @@ private struct KitNodeBody: View {
case .stats(let n): statsView(n)
case .stats(let n): statsView(n)
case .notice(let n): noticeView(n)
case .notice(let n): noticeView(n)
case .empty(let n): emptyView(n)
case .empty(let n): emptyView(n)
+ case .share(let n): shareView(n)
}
}
}
}
@@ -795,6 +816,14 @@ private struct KitNodeBody: View {
@@ -795,6 +816,14 @@ private struct KitNodeBody: View {
OvEmpty(title: node.title, text: node.text)
OvEmpty(title: node.title, text: node.text)
}
}
+ /// The Share button, for a link the document names.
+ @ViewBuilder
+ private func shareView(_ node: KitShare) -> some View {
+ if let link = node.url.flatMap(URL.init(string:)) {
+ OvShare(title: node.title, url: link, label: node.label)
+ }
+ }
+
/// What the rail's "See all" does.
/// What the rail's "See all" does.
private func moreAction(_ link: KitLink) -> () -> Void {
private func moreAction(_ link: KitLink) -> () -> Void {
{ self.act(.open(link.href)) }
{ self.act(.open(link.href)) }
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { hashAsset } = require('../assets');
const { hashAsset } = require('../assets');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css'];
+const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css'];
const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
+'use strict';
+// ov-share.js and ov-share.css (4.114.0, plan track Q) in a real headless Chrome, against a stand-in for
+// openvibe-sdk/share's router: the sheet loads who can open a thing, adds people by @name only once the service knows
+// the name, says plainly when it does not, switches "Only me" to "Specific people" when someone is added, saves what
+// changed (and only on Save), warns before leaving unsaved changes, sends a transfer offer bound to a person's subject,
+// and the recipient's banner and the not-on-the-list gate call the right routes. Names and titles are text, never
+// markup. A phone gets a sheet no wider than the phone. Skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('ov-share-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const PEOPLE = {
+ goosely: { subject: { type: 'user', id: 'usr_g' }, username: 'goosely', display_name: 'Goosely' },
+ maticus: { subject: { type: 'user', id: 'usr_m' }, username: 'maticus', display_name: 'Maticus' },
+ quinn: { subject: { type: 'user', id: 'usr_q' }, username: 'Quinn', display_name: '<img src=x onerror=window.__xss=1>' },
+};
+const fresh = () => ({
+ audience: 'private', audiences: ['public', 'link', 'people', 'private'], roles: ['viewer', 'editor'],
+ title: '<b>notes</b>', link: 'https://community.test/p/abc',
+ you: { subject: { type: 'user', id: 'usr_g' }, role: 'owner' }, owner: PEOPLE.goosely, members: [],
+ requests: [{ id: 'r1', ...PEOPLE.maticus, requested_at: '2026-11-05T10:00:00Z' }], transfer: null,
+});
+let S = fresh();
+const calls = [];
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Share</title><link rel="icon" href="data:,">
+<link rel="stylesheet" href="/ov-share.css"></head><body style="font:15px system-ui;padding:16px">
+<button type="button" id="sh" data-ov-share-sheet data-kind="paste" data-id="abc" data-title="notes">Share</button>
+<button type="button" id="send" data-ov-share-sheet data-title="A robot & a wave" data-url="https://video.test/v/1" data-time="83" data-embed="https://video.test/embed/1">Send</button>
+<button type="button" id="theirs" data-ov-share-sheet data-kind="paste" data-id="theirs" data-title="theirs">Theirs</button>
+<div id="offer"></div><div id="gate"></div>
+<script>window.__changes = 0; window.__sent = []; document.addEventListener('ov-share:change', () => { window.__changes++; }); document.addEventListener('ov-share:sent', (e) => { window.__sent.push(e.detail.target + ' ' + e.detail.url); });</script>
+<script src="/ov-share.js"></script></body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ const json = (code, o) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
+ if (u.pathname === '/ov-share.js' || u.pathname === '/ov-share.css') {
+ res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css; charset=utf-8' : 'application/javascript; charset=utf-8' });
+ return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1))));
+ }
+ calls.push(`${req.method} ${u.pathname}${u.search}`);
+ if (u.pathname === '/api/v1/share/people') {
+ const n = (u.searchParams.get('name') || '').toLowerCase();
+ if (n === 'slow') return json(429, { error: 'rate_limited' });
+ return json(200, { person: PEOPLE[n] || null });
+ }
+ const p = '/api/v1/paste/abc/share';
+ const find = (sub) => Object.values(PEOPLE).find((x) => sub && x.subject.id === sub.id);
+ if (u.pathname === p && req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json', etag: '"7"' }); return res.end(JSON.stringify(S)); }
+ if (u.pathname === p && req.method === 'PUT') {
+ const b = JSON.parse(body);
+ S.lastPut = b; S.lastIfMatch = req.headers['if-match'];
+ S.audience = b.audience;
+ S.members = b.members.map((m) => ({ ...find(m.subject), role: m.role, expires_at: m.expires_at }));
+ S.requests = S.requests.filter((r) => !S.members.some((m) => m.subject.id === r.subject.id));
+ return json(200, S);
+ }
+ if (u.pathname === `${p}/transfer`) {
+ const b = JSON.parse(body);
+ S.lastTransfer = b;
+ S.transfer = { id: '00000000-0000-4000-8000-0000000000aa', to: find(b.to.subject), keep_access: b.keep_access, expires_at: '2026-11-13T12:00:00Z', state: 'pending' };
+ return json(200, S);
+ }
+ if (u.pathname === `${p}/transfer/cancel`) { S.lastCancel = JSON.parse(body); S.transfer = null; return json(200, S); }
+ if (u.pathname === `${p}/transfer/accept`) { S.lastAccept = JSON.parse(body); return json(200, { ...S, you: { subject: PEOPLE.maticus.subject, role: 'owner' } }); }
+ if (u.pathname === '/api/v1/paste/theirs/share') return json(404, { code: 'share.not_found', error: 'Not found' });
+ if (u.pathname === `${p}/requests`) return json(201, { ok: true });
+ res.writeHead(404); return res.end();
+ });
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-share-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const q = (k) => `document.querySelector('dialog.ov-share [data-k="${k}"]')`;
+ const typeIn = (k, v) => `(() => { const i = ${q(k)}; i.value = ${JSON.stringify(v)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`;
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 1280 });
+ await p.goto(`${base}/`, { maxMs: 5000 });
+ await wait(150);
+
+ await p.evaluate("document.getElementById('sh').click()");
+ await wait(300);
+ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'a [data-ov-share-sheet] button opens the sheet as a modal dialog');
+ assert.strictEqual(await p.evaluate("document.getElementById('ov-share-title').textContent"), 'Share “<b>notes</b>”', 'the title is text, never markup');
+ assert.strictEqual(await p.evaluate(`${q('aud-private')}.checked`), true, 'the service\'s audience is chosen');
+ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share [data-k=\"done\"]').textContent"), 'Done', 'nothing changed: Done, not Save');
+
+ // A name nobody has: said plainly, nothing added, nothing saved.
+ await p.evaluate(typeIn('add', '@nobody'));
+ await p.evaluate(`${q('add-btn')}.click()`);
+ await wait(150);
+ assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /No OpenVibe account is called @nobody/);
+ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-list').children.length"), 1, 'only the owner');
+ // A name that is not a name is refused before anyone is asked.
+ await p.evaluate(typeIn('add', 'a b!'));
+ await p.evaluate(`${q('add-btn')}.click()`);
+ await wait(80);
+ assert.ok(!calls.some((c) => /name=b/.test(c)), 'an invalid name is never sent');
+ // Too many checks: the service's 429 is shown, not swallowed.
+ await p.evaluate(typeIn('add', 'slow'));
+ await p.evaluate(`${q('add-btn')}.click()`);
+ await wait(150);
+ assert.match(await p.evaluate("document.getElementById('ov-share-add-msg').textContent"), /Too many name checks/);
+
+ // A real name: added, Only me becomes Specific people, the display name stays text.
+ await p.evaluate(typeIn('add', '@Quinn'));
+ await p.evaluate(`${q('add-btn')}.click()`);
+ await wait(150);
+ assert.strictEqual(await p.evaluate(`${q('aud-people')}.checked`), true, 'adding someone to Only me switches to Specific people');
+ assert.strictEqual(await p.evaluate('window.__xss'), undefined, 'a display name is never markup');
+ assert.strictEqual(await p.evaluate("[...document.querySelectorAll('dialog.ov-share .ov-share-display')].map((n) => n.textContent)[1]"), PEOPLE.quinn.display_name);
+ assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Save', 'a change asks to be saved');
+ assert.ok(!calls.some((c) => c.startsWith('PUT')), 'nothing is saved before Save');
+
+ // Leaving with unsaved changes asks first; Keep editing keeps them.
+ await p.evaluate(`${q('close')}.click()`);
+ await wait(80);
+ assert.strictEqual(await p.evaluate("document.querySelector('dialog.ov-share').open"), true, 'unsaved changes keep the sheet open');
+ assert.ok(await p.evaluate(`!!${q('discard')}`), 'and ask: Discard or Keep editing');
+ await p.evaluate(`${q('keep-editing')}.click()`);
+ await wait(50);
+
+ // The person who asked can be added from the request.
+ await p.evaluate(`${q('req-add-user:usr_m')}.click()`);
+ await wait(50);
+ await p.evaluate(`${q('done')}.click()`);
+ await wait(200);
+ assert.deepStrictEqual(S.lastPut, { audience: 'people', members: [{ role: 'viewer', subject: PEOPLE.quinn.subject }, { role: 'viewer', subject: PEOPLE.maticus.subject }], notify: true },
+ 'Save sends the audience and the members by subject (never by display name), with no end date when there is none');
+ assert.strictEqual(S.lastIfMatch, '"7"', 'and only over the version it was shown (If-Match)');
+ assert.strictEqual(await p.evaluate('window.__changes'), 1, 'and tells the page (ov-share:change)');
+ assert.strictEqual(await p.evaluate(`${q('done')}.textContent`), 'Done');
+
+ // Transfer: to a person on the list, removing my access by default, bound to their subject.
+ await p.evaluate(`${q('give')}.click()`);
+ await wait(80);
+ assert.strictEqual(await p.evaluate(`${q('keep-false')}.checked`), true, '"Remove my access" is the default');
+ await p.evaluate(`${q('give-maticus')}.click()`);
+ await wait(50);
+ await p.evaluate(`${q('give-send')}.click()`);
+ await wait(200);
+ assert.deepStrictEqual(S.lastTransfer, { to: { subject: PEOPLE.maticus.subject }, keep_access: false });
+ assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-banner').textContent"), /Waiting for @maticus to accept/);
+ assert.strictEqual(await p.evaluate(`!!${q('give')}`), false, 'one offer at a time');
+ await p.evaluate(`${q('cancel-give')}.click()`);
+ await wait(200);
+ assert.strictEqual(S.transfer, null, 'the owner can cancel');
+ assert.deepStrictEqual(S.lastCancel, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'naming the offer it cancels');
+ await p.evaluate(`${q('close')}.click()`);
+ await wait(320);
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog').length + (document.documentElement.classList.contains('ov-share-locked') ? 1 : 0)"), 0, 'closing removes the sheet and frees the page');
+
+ // The recipient's banner and the gate.
+ await p.evaluate("OVShare.offer(document.getElementById('offer'), { kind: 'paste', id: 'abc', transfer_id: '00000000-0000-4000-8000-0000000000aa', from: { username: 'goosely' }, title: 'notes' })");
+ await p.evaluate("[...document.querySelectorAll('#offer button')].find((b) => b.textContent === 'Accept').click()");
+ await wait(200);
+ assert.ok(calls.includes('POST /api/v1/paste/abc/share/transfer/accept'));
+ assert.deepStrictEqual(S.lastAccept, { transfer_id: '00000000-0000-4000-8000-0000000000aa' }, 'the answer names the offer');
+ assert.match(await p.evaluate("document.getElementById('offer').textContent"), /It is yours now/);
+ await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signedInAs: 'quinn' })");
+ assert.ok(!/notes/.test(await p.evaluate("document.getElementById('gate').textContent")), 'the gate never shows the title');
+ await p.evaluate("document.querySelector('#gate .ov-share-btn').click()");
+ await wait(200);
+ assert.ok(calls.includes('POST /api/v1/paste/abc/share/requests'));
+ assert.match(await p.evaluate("document.getElementById('gate').textContent"), /Asked/);
+ await p.evaluate("OVShare.gate(document.getElementById('gate'), { kind: 'paste', id: 'abc', signInUrl: '/login' })");
+ assert.strictEqual(await p.evaluate("document.querySelector('#gate a').getAttribute('href')"), '/login', 'signed out: sign in');
+
+ // Send it: for anyone, with no kind or id; the link, Copy, the places, "Start at", embed code.
+ await p.evaluate("Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => { window.__clip = t; return Promise.resolve(); } }, configurable: true })"); // headless Chrome refuses the real one without a grant
+ await p.evaluate("document.getElementById('send').click()");
+ await wait(300);
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud').length"), 0, 'no "who can open it" without kind and id');
+ assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1', 'the link, shown without https://');
+ await p.evaluate(`${q('copy')}.click()`);
+ await wait(120);
+ assert.strictEqual(await p.evaluate('window.__clip'), 'https://video.test/v/1', 'Copy copies the whole link');
+ assert.ok(await p.evaluate("window.__sent.includes('copy https://video.test/v/1')"), 'and says so (ov-share:sent)');
+ assert.strictEqual(await p.evaluate(`${q('copy')}.textContent`), 'Copied');
+ const x = await p.evaluate(`${q('to-x')}.getAttribute('href')`);
+ assert.strictEqual(x, 'https://x.com/intent/post?text=A%20robot%20%26%20a%20wave&url=https%3A%2F%2Fvideo.test%2Fv%2F1', 'X gets the title and the link, encoded');
+ assert.strictEqual(await p.evaluate(`${q('to-x')}.getAttribute('rel')`), 'noopener noreferrer');
+ assert.ok(!/utm_|ref=/.test(x), 'no tracking is added to a link');
+ await p.evaluate(`${q('at-time')}.click()`);
+ await wait(60);
+ assert.strictEqual(await p.evaluate(`${q('link')}.value`), 'video.test/v/1?t=83', '"Start at 1:23" adds the time');
+ await p.evaluate(`${q('more')} && ${q('more')}.click()`);
+ await wait(60);
+ await p.evaluate(`${q('to-embed')}.click()`);
+ await wait(60);
+ assert.match(await p.evaluate(`${q('embed-code')}.value`), /^<iframe src="https:\/\/video\.test\/embed\/1\?t=83" [^>]*title="A robot & a wave"/, 'embed code, with the time and an escaped title');
+ await p.evaluate(`${q('to-mastodon')}.click()`);
+ await wait(60);
+ await p.evaluate(`(() => { const i = ${q('masto')}; i.value = 'not a server'; i.form.requestSubmit(); })()`);
+ assert.match(await p.evaluate("document.querySelector('dialog.ov-share .ov-share-panel .ov-share-addmsg').textContent"), /address of your server/, 'Mastodon asks for a real server address');
+ await p.evaluate(`${q('close')}.click()`);
+ await wait(320);
+ // Somebody else's paste: they can send it; who can open it is not theirs to see, and nothing says it failed.
+ await p.evaluate("document.getElementById('theirs').click()");
+ await wait(300);
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('dialog.ov-share .ov-share-aud, dialog.ov-share .ov-share-error').length"), 0, 'a 404 from the share API leaves the send half alone');
+ assert.ok(await p.evaluate(`!!${q('copy')}`));
+ await p.evaluate(`${q('close')}.click()`);
+ await wait(320);
+
+ // A phone: a sheet from the bottom, never wider than the phone, finger-sized controls.
+ S = fresh();
+ S.members = [{ ...PEOPLE.maticus, role: 'viewer', expires_at: '2026-11-30T23:59:59Z' }];
+ S.audience = 'people';
+ const phone = await h.openPage(browser, context, { width: 390 });
+ await phone.goto(`${base}/`, { maxMs: 5000 });
+ // The phone's own share sheet (navigator.share), stood in for: offered first, with the link.
+ await phone.evaluate("navigator.share = (d) => { window.__shared = d; return Promise.resolve(); }; navigator.canShare = () => true; matchMedia = ((m) => (q) => (q === '(pointer: coarse)' ? { matches: true, addEventListener() {}, removeEventListener() {} } : m(q)))(matchMedia.bind(window))");
+ await phone.evaluate("document.getElementById('send').click()");
+ await wait(400);
+ await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"device\"]').click()");
+ await wait(100);
+ assert.deepStrictEqual(await phone.evaluate('window.__shared'), { title: 'A robot & a wave', url: 'https://video.test/v/1' }, 'the phone\'s sheet gets the title and the link');
+ assert.ok(await phone.evaluate("!!document.querySelector('dialog.ov-share [data-k=\"to-sms\"]') || !!document.querySelector('dialog.ov-share [data-k=\"more\"]')"), 'Messages is offered on a phone');
+ await phone.evaluate("document.querySelector('dialog.ov-share [data-k=\"close\"]').click()");
+ await wait(320);
+ await phone.evaluate("document.getElementById('sh').click()");
+ await wait(500);
+ const r = await phone.evaluate("(() => { const d = document.querySelector('dialog.ov-share').getBoundingClientRect(); return [Math.round(d.width), Math.round(innerHeight - d.bottom), document.documentElement.scrollWidth]; })()");
+ assert.deepStrictEqual(r, [390, 0, 390], 'the sheet sits on the bottom edge, the phone\'s width, and nothing is wider');
+ assert.ok(await phone.evaluate("document.querySelector('dialog.ov-share .ov-share-addfield').getBoundingClientRect().height") >= 44, 'the name field is a finger tall');
+ await phone.close();
+ console.log('ov-share-chrome: send (copy, X, start at, embed, Mastodon, the phone sheet), not-the-owner, loading, @names (unknown, invalid, rate-limited, found), Only me → people, save by subject, leave-unsaved, transfer and cancel, the offer, the gate, the phone ok');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });