openvibers/openvibe.shared Commits

openvibe-shared 4.228.0: a page's sections as tabs on a phone, Caption it with AI, a picture alone is a post (#510)

OP OpenVibers committed 12 h ago · 7e8a3c8dc4 · 20 files, +457 −17

* Showcase parts(): a page's sections as tabs on a phone

The owner (11-11): "consolidate these into tabs, self-contained tabs, so the page isn't so freaking huge … at least on mobile". parts() shows one section at a time with a tab bar (name and count) at 900 px and under (600 for a front page); every part stays in the HTML; radios and :has(), no script; wide screens and browsers without :has() stack them.

* ov-parts.css: the phone tabs alone, for a page without showcase.css

* Composer: Caption it with AI on a picture; the Feed's picture descriptions are optional, written by AI when left

* openvibe-shared 4.228.0: a page's sections as tabs on a phone, Caption it with AI, a picture alone is a post

* parts(): a link into a part opens it (CSS :target, no script)

* showcase.pick(): the pick block twenty-five sites copied, once, on parts()

* pick(): headId and bodyClass, inside .pick-body as the copies had it

* OpenVibeNavbar.token(); Caption it sends it as the Bearer on any site (the composer's settings sync reads it too)

* CHANGELOG: OpenVibeNavbar.token()

* Test: a Feed picture without a description can post; it offers Caption it

* Caption it: Calm slows its pulse, only Off stops it (motion rule)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bdab19f..71c6d99 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,13 @@
# Changelog
++## 4.228.0 — 2026-11-12
++
++- **A page's sections as tabs on a phone** (the owner, 11-11: "consolidate these into tabs, self-contained tabs, so the page isn't so freaking huge … at least on mobile"). `showcase.parts({ name, items: [{ id, label, count, html }], front })` shows one part at a time with a tab bar (its name and count) at 900 px and under, 600 for a front page; every part stays in the HTML (crawlers, AI readers); radios and `:has()`, no script; the arrow keys move between parts; wide screens and browsers without `:has()` stack them. `ov-parts.css` is the same look alone, for a page that does not load showcase.css (Live's home).
++- **`showcase.pick()`: the pick block, once.** Twenty-five sites kept a copy of a "pick block" (server/render/short.js) whose chips only jumped to a pane, so a phone still showed them all. `pick({ key, label, head, panes, checked })` is that block on `parts()`: a heading, then tabs on a phone, every pane in the HTML with its id, `ov-parts.css` linked by its hash. `parts()` takes `checked` too, and a link to something inside a part opens it (CSS `:target`, no script).
++- **Caption it with AI.** A picture in any composer has ✨: one tap sends it, made small (JPEG, 768 px), to the Frame's `/api/frame/caption` on the person's own allowance; the caption fills an empty box (theirs to change) and `ovc:caption` carries the alt text. `ctx.alt(file)` and `ctx.caption(file)` for a page; `data-ovc-caption="off"` leaves it out.
++- **`OpenVibeNavbar.token()`** reads the person's Network token the site gave `init()`. Caption it sends it as the Bearer on any site (the Network's session cookie is not sent cross-site), and the composer's settings sync, which asked for it all along, now finds it.
++- **A picture alone is a post.** The menu's Feed composer no longer asks for a description of every picture: one left empty is written by AI as it posts, and a post goes without one if that cannot be done.
++
## 4.227.0 — 2026-11-12
- **One app icon, drawn on the server too** (design rule 142). `OpenVibeIcons.appHtml(site, { size, label, hue })` returns the app icon as markup (no DOM needed): its hue and size inline and its glyph already drawn, so `ov-icons.js` finds nothing to fill and nothing moves when it arrives. `OpenVibeIcons.APP_CSS` is the app icon's own rules, sizes 20 · 26 · 32 · 44 · 56 · 64 included: a server-drawn page puts it in its stylesheet once and its icons are whole before any script runs.
ddiff --git a/README.md b/README.md
iindex d33399a..92885fa 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.227.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.228.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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 772c017..a55261d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.227.0",
++ "version": "4.228.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/dock-vibe.js b/dock-vibe.js
iindex 5e07848..820792f 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -114,7 +114,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
-- const VIBE_REV = { 'ov-composer.js': '293808b32ad7' };
++ const VIBE_REV = { 'ov-composer.js': 'ac7107f5d733' };
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex 7ad9b8e..cb6c240 100644
---- a/dock.js
++++ b/dock.js
@@@ -84,7 +84,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': 'a918b45c0dae', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
++ const DOCK_REV = { 'dock-vibe.js': 'c5fbc40a2cce', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/files.js b/files.js
iindex 425e68f..3ab5218 100644
---- a/files.js
++++ b/files.js
@@@ -104,6 +104,7 @@ const BROWSER = Object.freeze([
// Stylesheets sites serve at /shared/<file> beside the scripts.
const STYLES = Object.freeze([
'showcase.css', // openvibe-shared/showcase's sections + the ring icons (generated: scripts/build-styles.js)
++ 'ov-parts.css', // a page's sections as tabs on a phone, alone (showcase.parts(); generated: scripts/build-styles.js; 4.228.0)
'items.css', // Live's name effects for items.js (live.name_effect.css@1)
'profile-panel.css', // the panel profile-panel.js draws
'player.css', // player.js's controls
ddiff --git a/navbar.js b/navbar.js
iindex 94da1f8..3b0cfcf 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -610,7 +610,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '146282739c06', 'dock.js': '1ae182836461', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '3a88a5ba2565', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': 'afd4d1deefc6', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': '293808b32ad7' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '146282739c06', 'dock.js': '9f15df28050c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '3a88a5ba2565', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '69a80757ed04', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': 'ac7107f5d733' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
@@@ -2643,6 +2643,9 @@
}
const OpenVibeNavbar = {
++ /** 4.228.0: the person's Network token the site gave init() (or null), for Shared's own calls to the Network
++ * (the composer's settings sync and Caption it). The site already put it in the page; this only reads it. */
++ token() { return _config.token || null; },
/** The mark works while something loads (an SPA's route, a long action): busy(true), then busy(false). */
busy(on) { busy(!!on); },
/** A floating card (overlay.js): OpenVibeNavbar.overlay({ id, src, title, href, live, side }) → Promise<handle|null>. */
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 6dbe883..9d2fa5e 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -21,7 +21,7 @@
* mentions: { search(q) → Promise<[{ label, handle, sub, avatar, insert }]> },
* upload(file, onProgress) → Promise<{ id }>, files go to the site's store before the send
* beforeSend(ctx) → false to hold the send })
-- * ctx: { form, text, value, set(v), insert(s), files(), add(files), note(s), send() }.
++ * ctx: { form, text, value, set(v), insert(s), files(), alt(file), caption(file), add(files), note(s), send() }.
* A box its page sends by script (a chat, an agent's run): form.__ovc.files() → the files (File objects), uploads(): the
* ids a mod's upload gave, clear() after the send (the text, the chips, the draft); a form 'reset' clears the same.
* A site without script says the same in data: data-ovt-commands='[{"name","hint","insert"}]', data-ovc-max-files,
@@@ -55,6 +55,15 @@
.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }
.ovc-file-x, .ovc-file-b { position: absolute; top: 4px; right: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; }
.ovc-file-b { top: auto; bottom: 4px; }
++/* 4.228.0: Caption it with AI (Network /api/frame/caption). Its corner of the chip; a gentle pulse while it writes. */
++.ovc-file-ai { position: absolute; right: 4px; bottom: 4px; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 8px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); color: var(--ovt-accent, var(--accent-light, #60a5fa)); cursor: pointer; }
++.ovc-file-ai:hover, .ovc-file-ai:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 28%, transparent); }
++.ovc-file-ai svg { width: 15px; height: 15px; }
++.ovc-file-ai.is-busy { cursor: progress; }
++.ovc-file-ai.is-busy svg { animation: ovc-spark 1.2s ease-in-out infinite; }
++@keyframes ovc-spark { 50% { transform: scale(.72) rotate(45deg); opacity: .55; } }
++html[data-ov-motion=reduced] .ovc-file-ai.is-busy svg { animation-duration: 2.4s; }
++html[data-ov-motion=off] .ovc-file-ai.is-busy svg { animation: none; opacity: .6; }
.ovc-file-x:hover, .ovc-file-b:hover, .ovc-file-x:focus-visible, .ovc-file-b:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); color: var(--text-primary, #e6edf7); }
.ovc-file-x svg, .ovc-file-b svg { width: 15px; height: 15px; }
.ovc-dropping { border-color: var(--ovt-accent, var(--accent, #3b82f6)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 30%, transparent), 0 8px 30px rgba(0, 0, 0, .25); }
@@@ -258,6 +267,7 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-th, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-th { width: 100%; height: 150px; border-radius: 15px 15px 0 0; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-t, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-t { display: none; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image input, .ovc-v2[data-preset=comment] .ovc-file.ovc-image input { flex: none; width: 100%; min-height: 38px; margin: 0; border: 0; border-top: 1px solid var(--ovt-line, rgba(148, 163, 184, .18)); border-radius: 0 0 15px 15px; padding: 8px 10px; background: transparent; font-size: 13px; }
++.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-ai, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-ai { top: 6px; left: 6px; right: auto; bottom: auto; background: rgba(0, 0, 0, .6); color: #fff; border-radius: 50%; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-x, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-x { top: 6px; right: 6px; background: rgba(0, 0, 0, .6); color: #fff; border-radius: 50%; }
.ovc-v2 .ovc-note { margin: 6px 0 0; font-size: 13px; color: var(--text-muted, #7386a3); }
.ovc-v2 .ovc-note:empty { display: none; }
@@@ -490,6 +500,8 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
set(v) { text.value = v; fire(); },
insert(s) { const a = text.selectionStart, b = text.selectionEnd; text.setRangeText(s, a, b, 'end'); fire(); text.focus(); },
files: () => files.map((f) => f.file),
++ alt: (file) => { const f = files.find((x) => x.file === file); return f ? f.alt || '' : ''; },
++ caption: (file) => { const f = files.find((x) => x.file === file); return f ? caption(f) : Promise.resolve(); },
add: (list) => add(list),
note: say,
send: () => { if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit(); },
@@@ -531,12 +543,50 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
chip.innerHTML = `${thumb}<span class="ovc-file-t"><span class="ovc-file-n"></span><span class="ovc-file-s"></span></span>`
+ (f.pasted ? `<button type="button" class="ovc-file-b" data-ovc-back aria-label="Put the text back in the box" title="Put it back in the box">${svg(IC.back)}</button>` : '')
+ (f.error && uploader() ? `<button type="button" class="ovc-file-r" data-ovc-retry aria-label="Try the upload again" title="Try again">${svg(IC.retry)}<span class="ovc-file-r-t">Try again</span></button>` : '')
++ + (kind === 'image' && !f.pasted && captionOn() ? `<button type="button" class="ovc-file-ai${f.captioning ? ' is-busy' : ''}" data-ovc-caption aria-label="${f.captioning ? 'Writing a caption…' : 'Caption it with AI'}" title="${f.captioning ? 'Writing a caption…' : 'Caption it with AI'}"${f.captioning ? ' aria-busy="true"' : ''}>${svg(IC.spark)}</button>` : '')
+ `<button type="button" class="ovc-file-x" data-ovc-x aria-label="Remove">${svg(IC.x)}</button>`;
chip.querySelector('.ovc-file-n').textContent = f.pasted ? 'Pasted text' : f.file.name || 'File';
chip.querySelector('.ovc-file-s').textContent = f.error || (f.pasted ? `${f.lines} lines · ${size(f.file.size)}` : f.progress != null && f.progress < 1 ? `${Math.round(f.progress * 100)}%` : size(f.file.size));
chip.querySelector('[data-ovc-x]').setAttribute('aria-label', `Remove ${f.pasted ? 'the pasted text' : f.file.name || 'the file'}`);
if (!chip.isConnected) strip.appendChild(chip);
}
++ // 4.228.0, Caption it (the owner, 11-12: "auto caption using OpenVibe.Exchange, using the AI features … like some
++ // Instagram"): the picture, made small (768 px, JPEG), goes to the Frame's /api/frame/caption on the person's own
++ // allowance; the caption fills an empty box (theirs to change) and ovc:caption carries the alt text for a page that
++ // keeps one with the picture (the Feed's post). A tap, never on its own: each one is a model call they pay for.
++ function captionOn() { return form.getAttribute('data-ovc-caption') !== 'off'; }
++ async function smallJpeg(file, max) {
++ if (typeof createImageBitmap !== 'function') return null;
++ let bmp = null;
++ try { bmp = await createImageBitmap(file); } catch { return null; }
++ const k = Math.min(1, max / Math.max(bmp.width, bmp.height));
++ const canvas = document.createElement('canvas');
++ canvas.width = Math.max(1, Math.round(bmp.width * k)); canvas.height = Math.max(1, Math.round(bmp.height * k));
++ canvas.getContext('2d').drawImage(bmp, 0, 0, canvas.width, canvas.height);
++ if (bmp.close) bmp.close();
++ try { return canvas.toDataURL('image/jpeg', 0.82); } catch { return null; }
++ }
++ async function caption(f) {
++ if (f.captioning) return;
++ f.captioning = true; paintFile(f);
++ try {
++ const dataUrl = await smallJpeg(f.file, 768);
++ if (!dataUrl) throw new Error('This picture cannot be read here.');
++ const base = String(form.getAttribute('data-ovc-frame') || 'https://openvibe.network').replace(/\/+$/, '');
++ // The person's token when the bar has one (any site); else the Network's own cookie (on the Network).
++ const t = token();
++ const r = await fetch(`${base}/api/frame/caption`, { method: 'POST', credentials: t ? 'omit' : 'include', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) }, body: JSON.stringify({ image: { data_url: dataUrl } }) });
++ const j = await r.json().catch(() => ({}));
++ if (r.status === 401) throw new Error('Sign in to caption a picture with AI.');
++ if (!r.ok) throw new Error(j.error || 'The AI could not caption it just now.');
++ if (!files.includes(f)) return;
++ f.alt = String(j.alt || ''); f.caption = String(j.caption || '');
++ form.dispatchEvent(new CustomEvent('ovc:caption', { detail: { file: f.file, chip: f.chip, alt: f.alt, caption: f.caption, tags: Array.isArray(j.tags) ? j.tags : [] } }));
++ if (f.caption && !text.value.trim()) { ctx.set(f.caption); text.focus(); say('Caption written by AI: change it as you like.'); }
++ else if (f.alt) say('Described by AI.');
++ } catch (e) { say((e && e.message) || 'The AI could not caption it just now.'); }
++ finally { f.captioning = false; if (files.includes(f)) paintFile(f); }
++ }
function remove(f) {
const i = files.indexOf(f); if (i < 0) return;
files.splice(i, 1);
@@@ -590,6 +640,7 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
if (e.target.closest('[data-ovc-x]')) { remove(f); text.focus(); }
else if (e.target.closest('[data-ovc-back]')) { const t = f.text; remove(f); ctx.insert(t); }
else if (e.target.closest('[data-ovc-retry]')) sendUp(f);
++ else if (e.target.closest('[data-ovc-caption]')) caption(f);
});
form.addEventListener('change', (e) => {
const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;
ddiff --git a/ov-home.js b/ov-home.js
iindex ce37ce7..75ba16c 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -1296,15 +1296,23 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
async onSubmit(v, api) {
const images = v.files.filter((f) => /^image\//.test(f.type));
if (images.length > 4) { api.ctx.note('Choose up to four pictures.'); return false; }
-- const missing = images.find((f) => !pics.get(f) || !pics.get(f).value.trim());
-- if (missing) { api.ctx.note('Describe each picture: what does it show?'); pics.get(missing)?.focus(); return false; }
++ // 4.228.0: words and descriptions are optional (the owner, 11-12: a picture alone, as on any social
++ // app). A picture nobody described is described by AI as it posts (Network /api/frame/caption, the
++ // person's own allowance); if that cannot be done it posts without, never stops the post.
++ for (const f of images) {
++ const field = pics.get(f);
++ if (!field || field.value.trim()) continue;
++ c.send.textContent = 'Describing your picture';
++ try { await api.ctx.caption(f); } catch { /* posts without */ }
++ if (!field.value.trim() && api.ctx.alt(f)) field.value = api.ctx.alt(f);
++ }
const pictures = [], attachments = [];
for (const f of v.files) {
c.send.textContent = `Uploading ${f.name || 'file'}`;
if (/^image\//.test(f.type)) {
const prepared = await preparePicture(f);
const media_id = await upload(`${o.frameBase || FRAME}/attachments`, prepared.file);
-- pictures.push({ media_id, alt: pics.get(f).value.trim() });
++ pictures.push({ media_id, alt: ((pics.get(f) && pics.get(f).value) || '').trim().slice(0, 300) });
} else attachments.push(await upload('https://openvibe.media/api/v1/attachments', f));
}
c.send.textContent = 'Posting';
@@@ -1326,10 +1334,15 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
const { file: f, chip } = e.detail;
if (!/^image\//.test(f.type)) return;
chip.classList.add('ovc-image');
-- const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?';
-- alt.setAttribute('aria-label', `What ${f.name || 'the picture'} shows`); alt.required = true;
++ const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What it shows (or ✨ AI)';
++ alt.setAttribute('aria-label', `What ${f.name || 'the picture'} shows (optional: AI writes it if you leave it)`);
chip.append(alt); pics.set(f, alt);
});
++ // ✨ on a picture wrote its description: it goes in the picture's field unless the person wrote one.
++ c.form.addEventListener('ovc:caption', (e) => {
++ const field = pics.get(e.detail.file);
++ if (field && !field.value.trim() && e.detail.alt) field.value = e.detail.alt;
++ });
c.form.addEventListener('ovc:file-removed', (e) => { pics.delete(e.detail.file); });
};
if (root.OpenVibeComposer && root.OpenVibeComposer.mount) start();
ddiff --git a/ov-parts.css b/ov-parts.css
nnew file mode 100644
iindex 0000000..e78c30b
---- /dev/null
++++ b/ov-parts.css
@@@ -0,0 +1,57 @@
++/* Generated by scripts/build-styles.js from showcase.js PARTS_CSS. Do not edit. */
++.ov-parts-r,.ov-parts-bar{display:none}
++.ov-parts-bar{gap:4px;margin:18px 0 14px;padding:4px;border-radius:14px;background:color-mix(in srgb,var(--text-primary,#e6edf7) 5%,transparent);overflow-x:auto;scrollbar-width:none;overscroll-behavior-x:contain}.ov-parts-bar::-webkit-scrollbar{display:none}
++.ov-parts-bar label{flex:1 0 auto;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:42px;padding:0 14px;border-radius:10px;font-size:13.5px;font-weight:700;color:var(--text-secondary,#a8b3c4);white-space:nowrap;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:background-color .2s,color .2s}
++.ov-parts-bar label:active{transform:scale(.97)}
++.pick .ov-part+.ov-part{margin-top:26px}.pick .ov-parts-bar{margin-top:10px}
++.ov-parts-n{min-width:18px;padding:0 6px;border-radius:999px;font-size:11.5px;line-height:18px;text-align:center;font-variant-numeric:tabular-nums;background:color-mix(in srgb,var(--text-primary,#e6edf7) 9%,transparent)}
++@supports selector(:has(*)){@media (max-width:900px){
++.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-parts-r{display:block;position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-parts-bar{display:flex}
++.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-part>.sc-sec>h2:first-child,.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-part>.sc-sec>.sc-fold>summary>h2{display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-part>.sc-sec{margin-top:0;padding-top:0;border-top:0}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(1):checked)>.ov-parts-bar>label:nth-of-type(1){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(2):checked)>.ov-parts-bar>label:nth-of-type(2){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(3):checked)>.ov-parts-bar>label:nth-of-type(3){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(4):checked)>.ov-parts-bar>label:nth-of-type(4){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(5):checked)>.ov-parts-bar>label:nth-of-type(5){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(6):checked)>.ov-parts-bar>label:nth-of-type(6){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(7):checked)>.ov-parts-bar>label:nth-of-type(7){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(8):checked)>.ov-parts-bar>label:nth-of-type(8){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(1):checked)>.ov-part:not(:nth-of-type(1)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(2):checked)>.ov-part:not(:nth-of-type(2)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(3):checked)>.ov-part:not(:nth-of-type(3)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(4):checked)>.ov-part:not(:nth-of-type(4)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(5):checked)>.ov-part:not(:nth-of-type(5)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(6):checked)>.ov-part:not(:nth-of-type(6)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(7):checked)>.ov-part:not(:nth-of-type(7)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(8):checked)>.ov-part:not(:nth-of-type(8)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:focus-visible)>.ov-parts-bar{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-part:target,>.ov-part :target)>.ov-part:not(:target):not(:has(:target)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-part:target,>.ov-part :target)>.ov-part:is(:target,:has(:target)){display:block!important}
++}@media (max-width:600px){
++.ov-parts.ov-parts--front>.ov-parts-r{display:block;position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
++.ov-parts.ov-parts--front>.ov-parts-bar{display:flex}
++.ov-parts.ov-parts--front>.ov-part>.sc-sec>h2:first-child,.ov-parts.ov-parts--front>.ov-part>.sc-sec>.sc-fold>summary>h2{display:none}
++.ov-parts.ov-parts--front>.ov-part>.sc-sec{margin-top:0;padding-top:0;border-top:0}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(1):checked)>.ov-parts-bar>label:nth-of-type(1){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(2):checked)>.ov-parts-bar>label:nth-of-type(2){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(3):checked)>.ov-parts-bar>label:nth-of-type(3){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(4):checked)>.ov-parts-bar>label:nth-of-type(4){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(5):checked)>.ov-parts-bar>label:nth-of-type(5){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(6):checked)>.ov-parts-bar>label:nth-of-type(6){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(7):checked)>.ov-parts-bar>label:nth-of-type(7){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(8):checked)>.ov-parts-bar>label:nth-of-type(8){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(1):checked)>.ov-part:not(:nth-of-type(1)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(2):checked)>.ov-part:not(:nth-of-type(2)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(3):checked)>.ov-part:not(:nth-of-type(3)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(4):checked)>.ov-part:not(:nth-of-type(4)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(5):checked)>.ov-part:not(:nth-of-type(5)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(6):checked)>.ov-part:not(:nth-of-type(6)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(7):checked)>.ov-part:not(:nth-of-type(7)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(8):checked)>.ov-part:not(:nth-of-type(8)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:focus-visible)>.ov-parts-bar{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
++.ov-parts.ov-parts--front:has(>.ov-part:target,>.ov-part :target)>.ov-part:not(:target):not(:has(:target)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-part:target,>.ov-part :target)>.ov-part:is(:target,:has(:target)){display:block!important}
++}}
++html[data-ov-motion=off] .ov-parts-bar label{transition:none}
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 4a802d6..b68b476 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
-- const THREAD_REV = { 'ov-composer.js': '293808b32ad7', 'ov-combo.js': '3e426e56fc75' };
++ const THREAD_REV = { 'ov-composer.js': 'ac7107f5d733', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
ddiff --git a/package-lock.json b/package-lock.json
iindex 28434ca..f363207 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.227.0",
++ "version": "4.228.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.227.0",
++ "version": "4.228.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 1c6c8b9..f6ab147 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.227.0",
++ "version": "4.228.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/scripts/build-styles.js b/scripts/build-styles.js
iindex e19ab30..14a6561 100644
---- a/scripts/build-styles.js
++++ b/scripts/build-styles.js
@@@ -7,6 +7,8 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const OUT = {
'showcase.css': () => `/* Generated by scripts/build-styles.js from showcase.js and ov-icons.js. Do not edit. */\n${require('../showcase').CSS.trim()}\n${require('../ov-icons').CSS.trim()}\n`,
++ // 4.228.0: the phone tabs alone, for a page that does not load showcase.css (Live's home is a fragment of its own).
++ 'ov-parts.css': () => `/* Generated by scripts/build-styles.js from showcase.js PARTS_CSS. Do not edit. */\n${require('../showcase').PARTS_CSS.trim()}\n`,
};
const check = process.argv.includes('--check');
let stale = 0;
ddiff --git a/showcase.css b/showcase.css
iindex c5f24fe..7b783f8 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -1,4 +1,60 @@
/* Generated by scripts/build-styles.js from showcase.js and ov-icons.js. Do not edit. */
++.ov-parts-r,.ov-parts-bar{display:none}
++.ov-parts-bar{gap:4px;margin:18px 0 14px;padding:4px;border-radius:14px;background:color-mix(in srgb,var(--text-primary,#e6edf7) 5%,transparent);overflow-x:auto;scrollbar-width:none;overscroll-behavior-x:contain}.ov-parts-bar::-webkit-scrollbar{display:none}
++.ov-parts-bar label{flex:1 0 auto;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:42px;padding:0 14px;border-radius:10px;font-size:13.5px;font-weight:700;color:var(--text-secondary,#a8b3c4);white-space:nowrap;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:background-color .2s,color .2s}
++.ov-parts-bar label:active{transform:scale(.97)}
++.pick .ov-part+.ov-part{margin-top:26px}.pick .ov-parts-bar{margin-top:10px}
++.ov-parts-n{min-width:18px;padding:0 6px;border-radius:999px;font-size:11.5px;line-height:18px;text-align:center;font-variant-numeric:tabular-nums;background:color-mix(in srgb,var(--text-primary,#e6edf7) 9%,transparent)}
++@supports selector(:has(*)){@media (max-width:900px){
++.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-parts-r{display:block;position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-parts-bar{display:flex}
++.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-part>.sc-sec>h2:first-child,.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-part>.sc-sec>.sc-fold>summary>h2{display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front)>.ov-part>.sc-sec{margin-top:0;padding-top:0;border-top:0}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(1):checked)>.ov-parts-bar>label:nth-of-type(1){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(2):checked)>.ov-parts-bar>label:nth-of-type(2){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(3):checked)>.ov-parts-bar>label:nth-of-type(3){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(4):checked)>.ov-parts-bar>label:nth-of-type(4){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(5):checked)>.ov-parts-bar>label:nth-of-type(5){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(6):checked)>.ov-parts-bar>label:nth-of-type(6){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(7):checked)>.ov-parts-bar>label:nth-of-type(7){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(8):checked)>.ov-parts-bar>label:nth-of-type(8){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(1):checked)>.ov-part:not(:nth-of-type(1)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(2):checked)>.ov-part:not(:nth-of-type(2)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(3):checked)>.ov-part:not(:nth-of-type(3)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(4):checked)>.ov-part:not(:nth-of-type(4)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(5):checked)>.ov-part:not(:nth-of-type(5)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(6):checked)>.ov-part:not(:nth-of-type(6)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(7):checked)>.ov-part:not(:nth-of-type(7)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:nth-of-type(8):checked)>.ov-part:not(:nth-of-type(8)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-parts-r:focus-visible)>.ov-parts-bar{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-part:target,>.ov-part :target)>.ov-part:not(:target):not(:has(:target)){display:none}
++.ov-parts:not(.is-stacked):not(.ov-parts--front):has(>.ov-part:target,>.ov-part :target)>.ov-part:is(:target,:has(:target)){display:block!important}
++}@media (max-width:600px){
++.ov-parts.ov-parts--front>.ov-parts-r{display:block;position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
++.ov-parts.ov-parts--front>.ov-parts-bar{display:flex}
++.ov-parts.ov-parts--front>.ov-part>.sc-sec>h2:first-child,.ov-parts.ov-parts--front>.ov-part>.sc-sec>.sc-fold>summary>h2{display:none}
++.ov-parts.ov-parts--front>.ov-part>.sc-sec{margin-top:0;padding-top:0;border-top:0}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(1):checked)>.ov-parts-bar>label:nth-of-type(1){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(2):checked)>.ov-parts-bar>label:nth-of-type(2){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(3):checked)>.ov-parts-bar>label:nth-of-type(3){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(4):checked)>.ov-parts-bar>label:nth-of-type(4){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(5):checked)>.ov-parts-bar>label:nth-of-type(5){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(6):checked)>.ov-parts-bar>label:nth-of-type(6){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(7):checked)>.ov-parts-bar>label:nth-of-type(7){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(8):checked)>.ov-parts-bar>label:nth-of-type(8){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(1):checked)>.ov-part:not(:nth-of-type(1)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(2):checked)>.ov-part:not(:nth-of-type(2)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(3):checked)>.ov-part:not(:nth-of-type(3)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(4):checked)>.ov-part:not(:nth-of-type(4)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(5):checked)>.ov-part:not(:nth-of-type(5)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(6):checked)>.ov-part:not(:nth-of-type(6)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(7):checked)>.ov-part:not(:nth-of-type(7)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:nth-of-type(8):checked)>.ov-part:not(:nth-of-type(8)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-parts-r:focus-visible)>.ov-parts-bar{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
++.ov-parts.ov-parts--front:has(>.ov-part:target,>.ov-part :target)>.ov-part:not(:target):not(:has(:target)){display:none}
++.ov-parts.ov-parts--front:has(>.ov-part:target,>.ov-part :target)>.ov-part:is(:target,:has(:target)){display:block!important}
++}}
++html[data-ov-motion=off] .ov-parts-bar label{transition:none}
/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */
.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
.sc-aura-glow{position:absolute;inset:28%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent),transparent);filter:blur(8px);animation:sc-breathe 6s ease-in-out infinite}
ddiff --git a/showcase.js b/showcase.js
iindex 02f172e..c784085 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -179,6 +179,74 @@ function fold({ id: sid, title, hint, html, open = false } = {}) {
return `<section class="sc-sec sc-fold-sec"${id(sid)} aria-labelledby="${h}"><details class="sc-fold"${open ? ' open' : ''}><summary><h2 id="${h}">${esc(title)}</h2>${hint ? `<span class="sc-fold-hint">${esc(hint)}</span>` : ''}</summary><div class="sc-fold-body">${html}</div></details></section>`;
}
++/**
++ * parts({ name, items: [{ id, label, count, html }], label, front }) — 4.228.0: a page of sections shown one at a time as
++ * tabs on a phone (the owner, 11-11: "consolidate these into tabs, self-contained tabs, so the page isn't so freaking
++ * huge … at least on mobile"; design rule short-pages-tabs). Every part stays in the HTML (crawlers, AI readers, no
++ * script): radios and :has(), so a browser without :has() simply stacks them. A tab names its part and its count, so a
++ * part's own showcase heading steps aside on a phone (it still names the section for screen readers). Wide screens keep
++ * the parts stacked. Phones are 900 px and under; a front page (front: true) tabs only at 600 px and under. Two to eight
++ * parts get tabs; one, or more than eight, are stacked as they are. Parts with no html are left out, never an empty tab.
++ */
++function parts({ name = 'parts', items = [], label = 'Sections', front = false, checked = '' } = {}) {
++ const list = (items || []).filter((x) => x && x.id != null && x.label && x.html != null && x.html !== '');
++ if (!list.length) return '';
++ const n = slug(name);
++ const key = (x) => String(x.id).replace(/[^a-z0-9_-]/gi, '-');
++ const body = list.map((x) => `<section class="ov-part" data-part="${esc(key(x))}">${x.html}</section>`).join('');
++ if (list.length < 2 || list.length > 8) return `<div class="ov-parts is-stacked">${body}</div>`;
++ const open = checked && list.some((x) => String(x.id) === String(checked)) ? String(checked) : String(list[0].id);
++ const radios = list.map((x) => `<input type="radio" class="ov-parts-r" name="ovp-${n}" id="ovp-${n}-${key(x)}" value="${esc(key(x))}"${String(x.id) === open ? ' checked' : ''}>`).join('');
++ const bar = list.map((x) => `<label for="ovp-${n}-${key(x)}">${esc(x.label)}${x.count != null && x.count !== '' ? ` <span class="ov-parts-n">${esc(x.count)}</span>` : ''}</label>`).join('');
++ return `<div class="ov-parts${front ? ' ov-parts--front' : ''}" data-parts="${list.length}">${radios}<div class="ov-parts-bar" role="group" aria-label="${esc(label)}">${bar}</div>${body}</div>`;
++}
++
++/**
++ * pick({ key, label, head, headId, bodyClass, panes: [{ id, label, html }], checked, front = true }) — 4.228.0: the "pick block" twenty-five
++ * sites each kept a copy of (server/render/short.js), now one: a heading over the page's own blocks, one at a time as
++ * tabs on a phone (parts()), every block in the HTML. Each pane keeps its id, so a link to it opens its tab. The copies
++ * drew a row of chips that only jumped to a pane, so a phone still showed them all (the owner, 11-12: "You also never
++ * released the new GUI with the tabs"). checked: the pane open first (else the first). A pane with no html is left out.
++ * It links ov-parts.css itself (by its content hash), so no site has to.
++ */
++function pick({ key = 'pick', label = 'Sections', head = '', headId = '', bodyClass = '', panes = [], checked = '', front = true } = {}) {
++ const list = (panes || []).filter((p) => p && p.id && p.label && p.html != null && p.html !== '');
++ if (!list.length) return '';
++ serveMod = serveMod || require('./serve');
++ const items = list.map((p) => ({ id: p.id, label: p.label, html: `<div class="pick-pane" id="${esc(p.id)}">${p.html}</div>` }));
++ return `<link rel="stylesheet" href="${esc(serveMod.url('ov-parts.css'))}"><div class="pick" id="pick-${esc(String(key).replace(/[^a-z0-9_-]/gi, '-'))}"><div class="pick-bar"><div class="pick-head"><h2${headId ? ` id="${esc(headId)}"` : ''}>${esc(head || label)}</h2></div></div><div class="pick-body${bodyClass ? ` ${esc(bodyClass)}` : ''}">${parts({ name: key, label, front, items, checked: list.some((p) => p.id === checked) ? checked : '' })}</div></div>`;
++}
++let serveMod = null;
++
++/** parts()' look: phones see one part and a tab bar (generated for one to eight parts, at 900 px or, for a front page, 600). */
++const PARTS_CSS = (() => {
++ const on = (scope) => {
++ const k = [1, 2, 3, 4, 5, 6, 7, 8];
++ return [
++ `${scope}>.ov-parts-r{display:block;position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}`,
++ `${scope}>.ov-parts-bar{display:flex}`,
++ `${scope}>.ov-part>.sc-sec>h2:first-child,${scope}>.ov-part>.sc-sec>.sc-fold>summary>h2{display:none}`,
++ `${scope}>.ov-part>.sc-sec{margin-top:0;padding-top:0;border-top:0}`,
++ ...k.map((i) => `${scope}:has(>.ov-parts-r:nth-of-type(${i}):checked)>.ov-parts-bar>label:nth-of-type(${i}){background:var(--bg-card,#16213a);color:var(--text-primary,#e6edf7);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent)}`),
++ ...k.map((i) => `${scope}:has(>.ov-parts-r:nth-of-type(${i}):checked)>.ov-part:not(:nth-of-type(${i})){display:none}`),
++ `${scope}:has(>.ov-parts-r:focus-visible)>.ov-parts-bar{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}`,
++ // A link to something inside a part (#faq, a hero's anchor) opens that part, whichever tab was chosen.
++ `${scope}:has(>.ov-part:target,>.ov-part :target)>.ov-part:not(:target):not(:has(:target)){display:none}`,
++ `${scope}:has(>.ov-part:target,>.ov-part :target)>.ov-part:is(:target,:has(:target)){display:block!important}`,
++ ].join('\n');
++ };
++ return [
++ '.ov-parts-r,.ov-parts-bar{display:none}',
++ '.ov-parts-bar{gap:4px;margin:18px 0 14px;padding:4px;border-radius:14px;background:color-mix(in srgb,var(--text-primary,#e6edf7) 5%,transparent);overflow-x:auto;scrollbar-width:none;overscroll-behavior-x:contain}.ov-parts-bar::-webkit-scrollbar{display:none}',
++ '.ov-parts-bar label{flex:1 0 auto;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:42px;padding:0 14px;border-radius:10px;font-size:13.5px;font-weight:700;color:var(--text-secondary,#a8b3c4);white-space:nowrap;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:background-color .2s,color .2s}',
++ '.ov-parts-bar label:active{transform:scale(.97)}',
++ '.pick .ov-part+.ov-part{margin-top:26px}.pick .ov-parts-bar{margin-top:10px}',
++ '.ov-parts-n{min-width:18px;padding:0 6px;border-radius:999px;font-size:11.5px;line-height:18px;text-align:center;font-variant-numeric:tabular-nums;background:color-mix(in srgb,var(--text-primary,#e6edf7) 9%,transparent)}',
++ `@supports selector(:has(*)){@media (max-width:900px){\n${on('.ov-parts:not(.is-stacked):not(.ov-parts--front)')}\n}@media (max-width:600px){\n${on('.ov-parts.ov-parts--front')}\n}}`,
++ 'html[data-ov-motion=off] .ov-parts-bar label{transition:none}',
++ ].join('\n');
++})();
++
const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));
// A table wider than a phone scrolls inside its wrapper, so the wrapper is a region the keyboard can reach and name
@@@ -519,6 +587,7 @@ const NETWORK_JS = "(function(){try{var box=document.querySelector('[data-ov-now
+ "if(n)box.hidden=false})}catch(e){}})();";
const CSS = `
++${PARTS_CSS}
/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */
.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
.sc-aura-glow{position:absolute;inset:28%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent),transparent);filter:blur(8px);animation:sc-breathe 6s ease-in-out infinite}
@@@ -746,4 +815,4 @@ html{overflow-x:clip}
}
`;
--module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, DEFAULT_RELATED, CSS, STYLESHEET: 'showcase.css', esc };
++module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, parts, pick, PARTS_CSS, cta, jsonLd, faqJsonLd, RELATED, DEFAULT_RELATED, CSS, STYLESHEET: 'showcase.css', esc };
ddiff --git a/test/composer-caption-chrome.test.js b/test/composer-caption-chrome.test.js
nnew file mode 100644
iindex 0000000..0ed6639
---- /dev/null
++++ b/test/composer-caption-chrome.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++// "Caption it" (4.228.0; the owner, 11-12: "auto caption using OpenVibe.Exchange, using the AI features … like some
++// Instagram"): in a real headless Chrome, on thread.composer()'s own markup, a picture's chip has ✨; a tap sends the
++// picture, made small (JPEG, 768 px at most), to the Frame's /api/frame/caption with the person's session, the caption
++// fills an empty box and ovc:caption carries the alt text; a box that has words keeps them; a text file has no ✨;
++// data-ovc-caption="off" leaves it out; a refusal is said in the box's note.
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const http = require('node:http');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer-caption-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const thread = require('../thread');
++const box = (id) => thread.composer({ action: '/send', id, placeholder: 'Add a comment', maxlength: 600, attach: { menu: true, accept: 'image/*,text/plain,.md' } });
++const asked = [];
++let refuse = false;
++const server = http.createServer((req, res) => {
++ const u = req.url.split('?')[0];
++ const file = { '/ov-composer.js': 'application/javascript', '/ov-window.js': 'application/javascript', '/thread.css': 'text/css' }[u];
++ if (file) { res.writeHead(200, { 'content-type': file }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }
++ if (u === '/api/frame/caption' && req.method === 'POST') {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ asked.push({ body: JSON.parse(body), cookie: req.headers.cookie || '', auth: req.headers.authorization || '' });
++ res.writeHead(refuse ? 402 : 200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify(refuse ? { error: 'Your free AI allowance for today is used up' } : { alt: 'A small blue square on white', caption: 'Minimalism, but make it blue.', tags: ['blue', 'square'] }));
++ });
++ return;
++ }
++ res.writeHead(200, { 'content-type': 'text/html', 'set-cookie': 'ov_token=person; Path=/' });
++ const frame = `http://127.0.0.1:${server.address().port}`;
++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/thread.css">
++<body style="margin:0;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div class="ovt" data-ovt style="padding:40px 16px">${box('c1').replace('<form ', `<form data-ovc-frame="${frame}" `)}${box('c2').replace('<form ', `<form data-ovc-caption="off" data-ovc-frame="${frame}" `)}</div>
++<script>window.captions = []; document.getElementById('c1').addEventListener('ovc:caption', (e) => window.captions.push({ alt: e.detail.alt, caption: e.detail.caption, tags: e.detail.tags }));</script>
++<script src="/ov-composer.js"></script>`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-caption-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800 });
++ await page.goto(base + '/', { maxMs: 5000 });
++ await h.eventually(async () => assert.equal(await page.evaluate(`!!(document.getElementById('c1').__ovc && document.getElementById('c2').__ovc)`), true));
++ const paste = (id, name, type, b64) => page.evaluate(`(async () => { const t = document.querySelector('#${id} textarea'); const dt = new DataTransfer();
++ let blob;
++ if ('${type}' === 'image/png') { const c = document.createElement('canvas'); c.width = 40; c.height = 30; const g = c.getContext('2d'); g.fillStyle = '#2563eb'; g.fillRect(0, 0, 40, 30); blob = await new Promise((r) => c.toBlob(r, 'image/png')); }
++ else blob = new Blob([Uint8Array.from(atob('${b64}'), (ch) => ch.charCodeAt(0))], { type: '${type}' });
++ dt.items.add(new File([blob], '${name}', { type: '${type}' }));
++ t.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); return true; })()`);
++ await paste('c1', 'square.png', 'image/png', '');
++ await paste('c1', 'notes.md', 'text/markdown', Buffer.from('# notes').toString('base64'));
++ await paste('c2', 'square.png', 'image/png', '');
++ await h.eventually(async () => assert.equal(await page.evaluate(`document.querySelectorAll('#c1 .ovc-file').length`), 2));
++ const buttons = await page.evaluate(`[...document.querySelectorAll('.ovc-file')].map((c) => [c.closest('form').id, c.querySelector('.ovc-file-n').textContent, !!c.querySelector('[data-ovc-caption]')])`);
++ assert.deepEqual(buttons, [['c1', 'square.png', true], ['c1', 'notes.md', false], ['c2', 'square.png', false]], 'a picture has ✨; a text file and a box with captions off do not');
++ assert.equal(await page.evaluate(`document.querySelector('#c1 [data-ovc-caption]').getAttribute('aria-label')`), 'Caption it with AI');
++
++ await page.evaluate(`document.querySelector('#c1 [data-ovc-caption]').click(), true`);
++ await h.eventually(async () => assert.equal(await page.evaluate(`document.querySelector('#c1 textarea').value`), 'Minimalism, but make it blue.', `asked ${asked.length}, note: ${await page.evaluate(`(document.querySelector('#c1 [data-ovt-note]') || {}).textContent || '(no note)'`)}`), { timeout: 8000 });
++ assert.equal(asked.length, 1);
++ assert.match(asked[0].body.image.data_url, /^data:image\/jpeg;base64,/, 'a small JPEG goes, not the original file');
++ assert.ok(asked[0].cookie.includes('ov_token=person'), 'with the person\'s session');
++ assert.deepEqual(await page.evaluate(`window.captions`), [{ alt: 'A small blue square on white', caption: 'Minimalism, but make it blue.', tags: ['blue', 'square'] }], 'ovc:caption carries the alt text');
++ assert.equal(await page.evaluate(`document.getElementById('c1').__ovc.ctx.alt(document.getElementById('c1').__ovc.files()[0])`), 'A small blue square on white', 'ctx.alt(file)');
++ assert.equal(await page.evaluate(`!!document.querySelector('#c1 [data-ovc-caption]:not(.is-busy)')`), true, 'the button is ready again');
++
++ // With the bar's token (any OpenVibe site), it goes as the Bearer, not a cookie.
++ await page.evaluate(`window.OpenVibeNavbar = { token: () => 'tok_person_abcdefghijklmnop' }; document.querySelector('#c1 textarea').value = ''; true`);
++ await page.evaluate(`document.querySelector('#c1 [data-ovc-caption]').click(), true`);
++ await h.eventually(async () => assert.equal(asked.length, 2));
++ assert.equal(asked[1].auth, 'Bearer tok_person_abcdefghijklmnop', 'the bar\'s token as the Bearer');
++ await h.eventually(async () => assert.equal(await page.evaluate(`!!document.querySelector('#c1 [data-ovc-caption]:not(.is-busy)')`), true));
++ // Words already in the box stay; a refusal is said in the note.
++ await page.evaluate(`(() => { const t = document.querySelector('#c1 textarea'); t.value = 'mine'; t.dispatchEvent(new Event('input', { bubbles: true })); })()`);
++ refuse = true;
++ await page.evaluate(`document.querySelector('#c1 [data-ovc-caption]').click(), true`);
++ await h.eventually(async () => assert.match(await page.evaluate(`document.querySelector('#c1 [data-ovt-note]') ? document.querySelector('#c1 [data-ovt-note]').textContent : ''`), /allowance/));
++ assert.equal(await page.evaluate(`document.querySelector('#c1 textarea').value`), 'mine', 'the person\'s words stay');
++ await page.close();
++ console.log('composer-caption-chrome: ✨ on a picture, a small JPEG with the session, the caption in an empty box, the alt text in ovc:caption, off where asked, a refusal said');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex c192174..9efeb74 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -260,7 +260,9 @@ const server = http.createServer((req, res) => {
const input = document.querySelector('#ovh-feed-composer input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change', { bubbles: true }));
})(), true`);
assert.ok(await until(s, `document.querySelectorAll('#ovh-feed-composer .ovc-file').length === 2`), 'the picture and PDF become chips');
-- await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('#ovh-feed-composer').checkValidity()`), 'the picture needs a description'));
++ // 4.228.0 (the owner, 11-12: a picture alone, as on any social app): a description is optional, and ✨ can write it.
++ await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('#ovh-feed-composer').checkValidity()`), 'a picture without a description can still post'));
++ assert.ok(await s.evaluate(`!!document.querySelector('#ovh-feed-composer .ovc-image [data-ovc-caption]')`), 'the picture offers Caption it with AI');
await s.evaluate(`(() => { const a = document.querySelector('#ovh-feed-composer .ovc-image input'); a.value = 'A dark sky'; a.dispatchEvent(new Event('input', { bubbles: true })); })()`);
await s.evaluate(`document.querySelector('#ovh-feed-composer .ovt-send').click(), true`);
const posted = await until(s, `/Posted/.test(document.querySelector('#ovh-feed-composer .ovc-note').textContent)`);
ddiff --git a/test/showcase-parts-chrome.test.js b/test/showcase-parts-chrome.test.js
nnew file mode 100644
iindex 0000000..2632b31
---- /dev/null
++++ b/test/showcase-parts-chrome.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++// showcase.parts() in a real headless Chrome (4.228.0, design rule short-pages-tabs): on a phone one part at a time
++// with a tab bar, its own heading stepping aside; a tap or the arrow keys change the part; every part stays in the
++// HTML; wide screens stack them with no bar; a front page tabs only at 600 px and under; no script on the page at all.
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const http = require('node:http');
++const h = require('../browser-harness');
++const sc = require('../showcase');
++const sec = (t, body) => `<section class="sc-sec"><h2>${t}</h2><p class="sc-lede">${t} lede</p>${body}</section>`;
++const tall = (t) => `<div style="height:600px">${t} body</div>`;
++const page = (front) => sc.parts({ name: 'Channel', front, items: [
++ { id: 'clips', label: 'Clips', count: 12, html: sec('Clips', tall('clips')) },
++ { id: 'videos', label: 'Videos', count: 3, html: sec('Videos', tall('videos') + '<p id="deep-in-videos">a target</p>') },
++ { id: 'about', label: 'About', html: sec('About', tall('about')) },
++ { id: 'none', label: 'Nothing', html: '' },
++] });
++const html = page(false);
++assert.ok(!html.includes('Nothing'), 'an empty part is left out, never an empty tab');
++assert.equal((html.match(/class="ov-part"/g) || []).length, 3, 'every part is in the HTML');
++assert.ok(html.includes('<label for="ovp-channel-clips">Clips <span class="ov-parts-n">12</span></label>'), 'a tab names its part and its count');
++assert.ok(sc.parts({ items: [{ id: 'a', label: 'A', html: '<p>a</p>' }] }).startsWith('<div class="ov-parts is-stacked">'), 'one part: stacked, no tabs');
++assert.equal(sc.parts({ items: [] }), '');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('showcase-parts-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const server = http.createServer((req, res) => {
++ res.writeHead(200, { 'content-type': 'text/html' });
++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${sc.CSS}</style><body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7">${req.url.includes('front') ? page(true) : html}`);
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-parts-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const state = (p) => p.evaluate(`({ shown: [...document.querySelectorAll('.ov-part')].filter((e) => e.offsetParent !== null).map((e) => e.dataset.part),
++ bar: !!document.querySelector('.ov-parts-bar') && document.querySelector('.ov-parts-bar').offsetParent !== null,
++ h2: [...document.querySelectorAll('.ov-part h2')].filter((e) => e.offsetParent !== null).length,
++ lede: [...document.querySelectorAll('.ov-part .sc-lede')].filter((e) => e.offsetParent !== null).length,
++ wide: document.documentElement.scrollWidth > window.innerWidth + 1, scripts: document.scripts.length })`);
++ try {
++ const url = `http://127.0.0.1:${server.address().port}/`;
++ const phone = await h.openPage(browser, await browser.newContext(), { width: 390, height: 844 });
++ await phone.goto(url, { maxMs: 5000 });
++ await wait(100);
++ let s = await state(phone);
++ assert.deepEqual([s.shown, s.bar, s.h2, s.lede, s.wide, s.scripts], [['clips'], true, 0, 1, false, 0], 'a phone: the first part, the bar, its heading aside (its lede kept), no overflow, no script');
++ await phone.evaluate(`document.querySelector('label[for="ovp-channel-videos"]').click(), true`);
++ await wait(50);
++ assert.deepEqual((await state(phone)).shown, ['videos'], 'a tap shows that part alone');
++ await phone.evaluate(`document.getElementById('ovp-channel-videos').focus(), true`);
++ await phone.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
++ await phone.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
++ await wait(50);
++ assert.deepEqual((await state(phone)).shown, ['about'], 'the arrow keys move between parts');
++ await phone.evaluate(`location.hash = '#deep-in-videos', true`);
++ await h.eventually(async () => assert.deepEqual((await state(phone)).shown, ['videos'], 'a link into a part opens it'));
++ await phone.evaluate(`history.replaceState(null, '', location.pathname), true`);
++ await phone.close();
++ const desk = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 900 });
++ await desk.goto(url, { maxMs: 5000 });
++ await wait(100);
++ s = await state(desk);
++ assert.deepEqual([s.shown, s.bar, s.h2], [['clips', 'videos', 'about'], false, 3], 'a wide screen: every part stacked with its heading, no bar');
++ await desk.close();
++ const mid = await h.openPage(browser, await browser.newContext(), { width: 768, height: 900 });
++ await mid.goto(`${url}front`, { maxMs: 5000 });
++ await wait(100);
++ assert.deepEqual((await state(mid)).shown, ['clips', 'videos', 'about'], 'a front page stacks at 768 px');
++ await mid.goto(url, { maxMs: 5000 });
++ await wait(100);
++ assert.deepEqual((await state(mid)).shown, ['clips'], 'any other page tabs at 768 px');
++ await mid.close();
++ console.log('showcase-parts-chrome: one part on a phone, tap and arrow keys, all in the HTML, stacked wide, the front-page width');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex f4d4b75..2087855 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -183,6 +183,13 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
assert.ok(!h.includes('javascript:') && !h.includes('>Bad<'), 'a tile with an unsafe link is dropped');
assert.ok(h.includes('<span class="ov-appicon" data-icon="live" data-ova="live" data-size="56"'), 'an app is its app icon (design rule 142), 56 px on a phone');
assert.ok(sc.CSS.includes('.sc-apps .ov-appicon{--ova-size:44px!important}'), 'and the row size from 600 px');
++ // pick() (4.228.0): the pick block twenty-five sites copied, one in Shared: a heading, then tabs on a phone.
++ const pk = sc.pick({ key: 'parts', label: 'What it does', head: 'Inside', checked: 'b', panes: [{ id: 'a', label: 'A', html: '<p>a</p>' }, { id: 'b', label: 'B', html: '<p>b</p>' }, { id: 'c', label: 'C', html: '' }] });
++ assert.match(pk, /^<link rel="stylesheet" href="\/shared\/ov-parts\.css\?v=[0-9a-f]+"><div class="pick" id="pick-parts"><div class="pick-bar"><div class="pick-head"><h2>Inside<\/h2><\/div><\/div><div class="pick-body"><div class="ov-parts ov-parts--front" data-parts="2">/, 'its look linked by hash, its heading, then the tabs (a front page by default)');
++ assert.ok(pk.includes('id="ovp-parts-b" value="b" checked>') && !pk.includes('id="ovp-parts-a" value="a" checked'), 'checked opens that pane, the order kept');
++ assert.ok(pk.includes('<div class="pick-pane" id="a"><p>a</p></div>') && !pk.includes('>C<'), 'each pane keeps its id; an empty one is left out');
++ assert.strictEqual(sc.pick({ panes: [] }), '');
++ assert.match(sc.pick({ key: 'k', headId: 'what', bodyClass: 'grid', panes: [{ id: 'x', label: 'X', html: 'x' }] }), /<h2 id="what">Sections<\/h2><\/div><\/div><div class="pick-body grid">/, 'headId and bodyClass as the copies had them');
assert.ok(sc.apps({ items: [{ icon: 'fa-star', name: 'S', href: '/s' }] }).includes('<span class="sc-ic">'), 'a non-OpenVibe glyph keeps its tile');
assert.ok(h.includes('<p class="sc-apps-more"><a href="/sites">All 48 sites →</a></p>'), 'See all');
assert.strictEqual(sc.apps({ items: [] }), '', 'nothing to show, no section');