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)
Changes
@@@ -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.@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -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)",@@@ -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 boxform.setAttribute('data-ovc-no-files', '');@@@ -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);@@@ -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@@@ -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>. */@@@ -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 { aniset(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 { anichip.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 { aniif (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;@@@ -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();nnew file mode 100644@@@ -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}@@@ -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() {@@@ -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",@@@ -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": {@@@ -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;@@@ -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}@@@ -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 };nnew file mode 100644@@@ -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); });@@@ -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)`);nnew file mode 100644@@@ -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); });@@@ -183,6 +183,13 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:haassert.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');
@@ -1,5 +1,13 @@
@@ -1,5 +1,13 @@
# Changelog
# 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
## 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.
- **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.
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.227.0",
+ "version": "4.228.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -114,7 +114,7 @@
@@ -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
// 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.
// 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 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) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -84,7 +84,7 @@
@@ -84,7 +84,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// 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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -104,6 +104,7 @@ const BROWSER = Object.freeze([
@@ -104,6 +104,7 @@ const BROWSER = Object.freeze([
// Stylesheets sites serve at /shared/<file> beside the scripts.
// Stylesheets sites serve at /shared/<file> beside the scripts.
const STYLES = Object.freeze([
const STYLES = Object.freeze([
'showcase.css', // openvibe-shared/showcase's sections + the ring icons (generated: scripts/build-styles.js)
'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)
'items.css', // Live's name effects for items.js (live.name_effect.css@1)
'profile-panel.css', // the panel profile-panel.js draws
'profile-panel.css', // the panel profile-panel.js draws
'player.css', // player.js's controls
'player.css', // player.js's controls
@@ -610,7 +610,7 @@
@@ -610,7 +610,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* 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.
// 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]}` : ''}`;
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.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -2643,6 +2643,9 @@
@@ -2643,6 +2643,9 @@
}
}
const OpenVibeNavbar = {
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). */
/** The mark works while something loads (an SPA's route, a long action): busy(true), then busy(false). */
busy(on) { busy(!!on); },
busy(on) { busy(!!on); },
/** A floating card (overlay.js): OpenVibeNavbar.overlay({ id, src, title, href, live, side }) → Promise<handle|null>. */
/** A floating card (overlay.js): OpenVibeNavbar.overlay({ id, src, title, href, live, side }) → Promise<handle|null>. */
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
* mentions: { search(q) → Promise<[{ label, handle, sub, avatar, insert }]> },
* mentions: { search(q) → Promise<[{ label, handle, sub, avatar, insert }]> },
* upload(file, onProgress) → Promise<{ id }>, files go to the site's store before the send
* upload(file, onProgress) → Promise<{ id }>, files go to the site's store before the send
* beforeSend(ctx) → false to hold 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
* 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.
* 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,
* A site without script says the same in data: data-ovt-commands='[{"name","hint","insert"}]', data-ovc-max-files,
@@ -55,6 +55,15 @@
@@ -55,6 +55,15 @@
.ovc-file.is-bad .ovc-file-s { color: var(--live-red, #ef4444); }
.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-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; }
.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: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-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); }
.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; }
@@ -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-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 .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 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[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 { margin: 6px 0 0; font-size: 13px; color: var(--text-muted, #7386a3); }
.ovc-v2 .ovc-note:empty { display: none; }
.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
@@ -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(); },
set(v) { text.value = v; fire(); },
insert(s) { const a = text.selectionStart, b = text.selectionEnd; text.setRangeText(s, a, b, 'end'); fire(); text.focus(); },
insert(s) { const a = text.selectionStart, b = text.selectionEnd; text.setRangeText(s, a, b, 'end'); fire(); text.focus(); },
files: () => files.map((f) => f.file),
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),
add: (list) => add(list),
note: say,
note: say,
send: () => { if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit(); },
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
@@ -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>`
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.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>` : '')
+ (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>`;
+ `<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-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('.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'}`);
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);
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) {
function remove(f) {
const i = files.indexOf(f); if (i < 0) return;
const i = files.indexOf(f); if (i < 0) return;
files.splice(i, 1);
files.splice(i, 1);
@@ -590,6 +640,7 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
@@ -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(); }
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-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-retry]')) sendUp(f);
+ else if (e.target.closest('[data-ovc-caption]')) caption(f);
});
});
form.addEventListener('change', (e) => {
form.addEventListener('change', (e) => {
const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;
const i = e.target; if (noFiles || !i || i.type !== 'file' || i.__ovcSync) return;
@@ -1296,15 +1296,23 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1296,15 +1296,23 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
async onSubmit(v, api) {
async onSubmit(v, api) {
const images = v.files.filter((f) => /^image\//.test(f.type));
const images = v.files.filter((f) => /^image\//.test(f.type));
if (images.length > 4) { api.ctx.note('Choose up to four pictures.'); return false; }
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 = [];
const pictures = [], attachments = [];
for (const f of v.files) {
for (const f of v.files) {
c.send.textContent = `Uploading ${f.name || 'file'}`;
c.send.textContent = `Uploading ${f.name || 'file'}`;
if (/^image\//.test(f.type)) {
if (/^image\//.test(f.type)) {
const prepared = await preparePicture(f);
const prepared = await preparePicture(f);
const media_id = await upload(`${o.frameBase || FRAME}/attachments`, prepared.file);
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));
} else attachments.push(await upload('https://openvibe.media/api/v1/attachments', f));
}
}
c.send.textContent = 'Posting';
c.send.textContent = 'Posting';
@@ -1326,10 +1334,15 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1326,10 +1334,15 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
const { file: f, chip } = e.detail;
const { file: f, chip } = e.detail;
if (!/^image\//.test(f.type)) return;
if (!/^image\//.test(f.type)) return;
chip.classList.add('ovc-image');
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);
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); });
c.form.addEventListener('ovc:file-removed', (e) => { pics.delete(e.detail.file); });
};
};
if (root.OpenVibeComposer && root.OpenVibeComposer.mount) start();
if (root.OpenVibeComposer && root.OpenVibeComposer.mount) start();
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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}
@@ -24,7 +24,7 @@
@@ -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.
// 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 reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
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) || '';
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.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.227.0",
+ "version": "4.228.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.227.0",
+ "version": "4.228.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -7,6 +7,8 @@ const path = require('path');
@@ -7,6 +7,8 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const OUT = {
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`,
'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');
const check = process.argv.includes('--check');
let stale = 0;
let stale = 0;
@@ -1,4 +1,60 @@
@@ -1,4 +1,60 @@
/* Generated by scripts/build-styles.js from showcase.js and ov-icons.js. Do not edit. */
/* 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. */
/* 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-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}
.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}
@@ -179,6 +179,74 @@ function fold({ id: sid, title, hint, html, open = false } = {}) {
@@ -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>`;
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));
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
// 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
@@ -519,6 +587,7 @@ const NETWORK_JS = "(function(){try{var box=document.querySelector('[data-ov-now
+ "if(n)box.hidden=false})}catch(e){}})();";
+ "if(n)box.hidden=false})}catch(e){}})();";
const CSS = `
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. */
/* 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-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}
.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}
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -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); });
@@ -260,7 +260,9 @@ const server = http.createServer((req, res) => {
@@ -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 }));
const input = document.querySelector('#ovh-feed-composer input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change', { bubbles: true }));
})(), true`);
})(), true`);
assert.ok(await until(s, `document.querySelectorAll('#ovh-feed-composer .ovc-file').length === 2`), 'the picture and PDF become chips');
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(`(() => { 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`);
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)`);
const posted = await until(s, `/Posted/.test(document.querySelector('#ovh-feed-composer .ovc-note').textContent)`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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); });
@@ -183,6 +183,13 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
@@ -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('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(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');
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(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.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');
assert.strictEqual(sc.apps({ items: [] }), '', 'nothing to show, no section');