openvibers/openvibe.shared Commits

4.114.0: one Share sheet on every site (ov-share.js) (#295)

OP OpenVibers committed 7 days ago · cc7c79cc95 · 18 files, +1933 −10

* 4.114.0: one Share sheet on every site (ov-share.js, plan track Q)

Press Share anywhere and the same sheet opens: a sheet from the bottom on a
phone, a dialog on a computer, two parts and no tabs.

Send it, for everyone: the link and Copy (a tick and a ring when copied); on a
phone, the phone's own share sheet with its contacts and apps; 16 places with
brand logos, recent ones first and the rest behind "More (n)"; a QR code made
on the device; "Start at 1:23"; embed code with every attribute escaped.
Nothing is added to a shared link.

Who can open it, for the owner, through openvibe-sdk/share's router: Everyone,
Anyone with the link, Specific people by @name (checked as they are added, Can
view or Can edit, an end date), Only me; access requests; 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 banner
and OVShare.gate() the not-on-the-list page.

The navbar loads it on the first press of a [data-ov-share-sheet] button; the
kit's [data-ovk-share] items open it too. docs/share.md describes it, with the
native Android and iOS mapping. test/ov-share-chrome.test.js covers both parts
in headless Chrome.

* Share as an OpenVibe Design component: kit.share and its spec (web, Compose, SwiftUI)

* 4.114.0 changelog: ov-qr.js and the three new brand logos

* README status: v4.114.0

* Share on Android and iOS: OvShare in both kit renderers

The views are named after the component (OvShare), as every other is. Compose's OvShare takes onShare and the
renderer raises KitAction.Share, so the app opens Android's own chooser (with Direct Share) and the kit stays free of
platform classes; SwiftUI's is ShareLink, the system sheet itself.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex cfbe4bb..8dbf8d0 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 96d003a..aea408a 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -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
`<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
ddiff --git a/STATUS.json b/STATUS.json
iindex 06f1c3f..3dfe67f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/components.js b/components.js
iindex 8f6c6a2..b9baac1 100644
---- a/components.js
++++ b/components.js
@@@ -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 '';
}
ddiff --git a/docs/share.md b/docs/share.md
nnew file mode 100644
iindex 0000000..96c753c
---- /dev/null
++++ b/docs/share.md
@@@ -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)`.
ddiff --git a/files.js b/files.js
iindex 029969e..eb1431c 100644
---- a/files.js
++++ b/files.js
@@@ -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)
ddiff --git a/kit-web.js b/kit-web.js
iindex e1f2a7e..69e8c32 100644
---- a/kit-web.js
++++ b/kit-web.js
@@@ -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(() => {
ddiff --git a/kit.js b/kit.js
iindex 917d62c..2a40286 100644
---- a/kit.js
++++ b/kit.js
@@@ -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,
ddiff --git a/navbar.js b/navbar.js
iindex fbdf465..e6b4019 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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">
ddiff --git a/ov-share.css b/ov-share.css
nnew file mode 100644
iindex 0000000..c584916
---- /dev/null
++++ b/ov-share.css
@@@ -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; }
++}
ddiff --git a/ov-share.js b/ov-share.js
nnew file mode 100644
iindex 0000000..328887a
---- /dev/null
++++ b/ov-share.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);
ddiff --git a/package-lock.json b/package-lock.json
iindex c7de92c..ba1baee 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 29ca3ce..dc78fe3 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/native-check.js b/scripts/native-check.js
iindex 2d38c1c..f83737b 100644
---- a/scripts/native-check.js
++++ b/scripts/native-check.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',
ddiff --git a/scripts/native/compose/KitRenderer.kt b/scripts/native/compose/KitRenderer.kt
iindex 6a42cd2..e11424c 100644
---- a/scripts/native/compose/KitRenderer.kt
++++ b/scripts/native/compose/KitRenderer.kt
@@@ -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.action
val action: Pair<String, () -> Unit>? = if (link != null) {
ddiff --git a/scripts/native/swiftui/KitRenderer.swift b/scripts/native/swiftui/KitRenderer.swift
iindex 3d6ec3b..4caccfe 100644
---- a/scripts/native/swiftui/KitRenderer.swift
++++ b/scripts/native/swiftui/KitRenderer.swift
@@@ -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.props
case .notice(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 {
case .stats(let node): return node.children
case .notice(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 {
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)) }
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 901149e..98d024e 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -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(', ')} };`;
ddiff --git a/test/ov-share-chrome.test.js b/test/ov-share-chrome.test.js
nnew file mode 100644
iindex 0000000..e2e55c1
---- /dev/null
++++ b/test/ov-share-chrome.test.js
@@@ -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 &amp; 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); });