openvibers/openvibe.shared Commits
Next-step cards in the showcase kit and the traffic-between-products ADR; release 2.10.0 (#19)
Changes
@@@ -6,6 +6,15 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 2.10.0 — 2026-10-05++++- `openvibe-shared/showcase`: `nextSteps({ id, title, lede, items })` renders up to four next steps on other OpenVibe++ products (Shared ADR 0002, plan D94, `docs/adr/0002-traffic-between-products.md`). An item is++ `{ to, what, text?, href, from?, embed? }`: `to` picks the ring icon and the "on OpenVibe.X" label, `from` appends++ the referral tag `ov_from=<from>` (nothing about the person), `embed` adds an "Embed it" link. Only `https://` URLs++ or site paths are rendered; an unusable item is left out and no items render ''. Styled in `showcase.css`.++- `docs/adr/0002-traffic-between-products.md` (proposed): next-step cards, shareable result pages, embeds and badges++ (live-rendered SVG + a proof page that re-checks the fact), and quests.## 2.9.0 — 2026-10-04- `openvibe-shared/showcase`: a feature card's ring icon (`icon: 'ov:…'`) now fills the 40 px icon slot@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v2.9.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v2.10.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": "2.9.0",++ "version": "2.10.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)",nnew file mode 100644@@@ -0,0 +1,101 @@++# Shared ADR 0002: traffic between products++++**Status:** Proposed 2026-10-05 by the build plan (plan T11, decision D94), revisable by the owner. A++Shared-local decision: it fixes the shape of the presentation pieces Shared ships and the rules a++service follows when it links to another one. No contract changes; nothing here moves data between++services.++++## Context++++D94 asks the open sites to send people to each other on purpose: "next-step cards, shareable result++pages, embeds and badges, cross-product quests". Today each site's home ends where its own product++ends. A person who just finished something (cut a clip, converted a file, read a story) gets no++honest suggestion of the obvious next thing on another OpenVibe site, and nothing a person makes can++be shown elsewhere with proof that it is real.++++Constraints from the rest of the network:++++- **Every site works signed out.** A suggestion cannot depend on knowing who the person is.++- **No invented claims.** The showcase rule applies: a card names only what the target does today.++- **Privacy.** A link between sites must not carry a person's identity or activity (the++ analytics source of truth is each service's own events, ADR-021).++- **No build step, no shared runtime state.** Pieces are server-rendered strings, like the rest of++ `openvibe-shared/showcase`.++++## Decision++++### 1. Next-step cards++++A next-step card is a server-rendered suggestion of one concrete thing to do on another product,++placed after a person finishes something (a result page, a finished upload, the end of a story) or++in a site's home. Shared ships the renderer, `showcase.nextSteps({ id, title, lede, items })`; each++site passes its own items and Shared holds no product data.++++An item carries:++++| field | required | meaning |++|---|---|---|++| `to` | yes | the target service id (`live`, `tools`, `media`, …): picks the ring icon and the "on OpenVibe.X" label |++| `what` | yes | the action, imperative, one line ("Clip the best minute") |++| `text` | no | one sentence on why it fits what the person just did |++| `href` | yes | an absolute `https://` URL on an OpenVibe zone, or a site-relative path for the same site |++| `from` | no | the service rendering the card; with `to` it forms the referral tag below |++| `embed` | no | the URL of an embeddable view of the target (section 3), shown as a secondary link |++++Rules:++++- **Real targets only.** `href` must open a page that exists and works signed out (sign-in may be++ asked for at the target, never before the click).++- **Referral without identity.** A card may append `ov_from=<from>` to `href` so the target counts++ arrivals by source. Nothing else rides in the URL: no user id, session, subject or result id the++ person did not choose to share.++- **At most four per placement.** A list of everything is not a next step.++++### 2. Shareable result pages++++A result page is shareable when a person explicitly shares it: the result gets a stable URL that++renders without the person's session (`/r/<id>` or the product's own path). Such a page:++++- is created only by an explicit share action, never by producing the result;++- has an id that cannot be guessed from other ids (an opaque random or content-addressed id);++- states who shared it only if the person chose to show their name;++- carries its own Open Graph and canonical tags, and is `noindex` unless the owner publishes it;++- expires on the product's own retention, and says so on the page.++++### 3. Embeds and badges++++An **embed** is a framed, read-only view of a public thing on its own product (a robot's video and++readouts, a stream, a paste). Frame-ancestors lists are per product, as Bot's `BOT_EMBED_ORIGINS`++does; an embed never shows controls or anything the anonymous public could not see on the product++itself.++++A **badge** is a small image that states one public fact ("live now", "member of <creator>'s plan",++"a robot on OpenVibe.Bot") and links to a page that proves it. Badges are issued and verified by the++service that owns the fact:++++- **Image:** `GET https://<service>/badge/<kind>/<resource>.svg`, rendered from current state, cached++ briefly (`max-age` of a minute or less), never a static file that can go stale.++- **Proof:** the badge always links to `https://<service>/badge/<kind>/<resource>`, an HTML page that++ re-checks the fact at request time and shows what it found. A badge copied elsewhere is therefore++ only as good as its link: anyone can click through and see the truth.++- **No secrets, no personal data:** `<resource>` is a public handle or id the owner already shows++ publicly. A badge for a private fact is not issued.++- A signed form (an HMAC over service, kind, resource and issue time) is added only when a++ consumer needs to verify offline; until then the live proof page is the verification.++++### 4. Cross-product quests++++A quest is a short list of next steps across products with progress the person can see ("go live++once, clip a moment, share it"). Progress is computed by each owning service from its own events and++read by the quest page; Shared ships no quest state. Quests are out of scope for the first release of++the cards; when built they reuse the next-step item shape for each step.++++## Consequences++++- Shared gains `showcase.nextSteps` (presentation only) and this document; product data stays in++ each site.++- Sites adopt cards one placement at a time, starting where a result already exists (Tools results,++ finished Media clips, Live VODs).++- Badge and share endpoints are per service and follow sections 2 and 3 when each service builds++ them; nothing is centralised in Network.++- The referral tag gives each target a count of arrivals by source without any cross-site identity.@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "2.9.0",++ "version": "2.10.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "2.9.0",++ "version": "2.10.0","license": "MIT","bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "2.9.0",++ "version": "2.10.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -52,12 +52,21 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}.sc-story blockquote{margin:0;font-size:15px;line-height:1.55}.sc-story figcaption{margin-top:10px;font-weight:700;font-size:13.5px}.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}++.sc-next-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}++.sc-next-item{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}++.sc-next-main{flex:1 1 320px;display:flex;align-items:center;gap:14px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}++.sc-next-main:hover,.sc-next-main:focus-visible{border-color:var(--accent,#3b82f6);transform:translateX(2px);outline:0}++.sc-next-body{flex:1;min-width:0}.sc-next-body b{display:block;font-size:15.5px}++.sc-next-body small{display:block;margin-top:2px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}++.sc-next-on{display:block;margin-top:4px;font-size:12px;font-weight:700;letter-spacing:.3px;color:var(--accent-light,var(--accent,#60a5fa))}++.sc-next-go{font-size:20px;color:var(--text-muted,#7386a3);transition:transform .15s}.sc-next-main:hover .sc-next-go{transform:translateX(3px);color:var(--accent-light,#60a5fa)}++.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}--@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn{transition:none}}++@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go{transition:none}}.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}@@@ -119,6 +119,45 @@ function stories({ id: sid, title, lede, items } = {}) {return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-stories">${items.map((s) => `<figure class="sc-story"><blockquote>${esc(s.text)}</blockquote><figcaption>${esc(s.who)}${s.outcome ? `<span>${esc(s.outcome)}</span>` : ''}</figcaption></figure>`).join('')}</div></section>`;}++// A next step's target names: the service id → the product name the card says it opens ("on OpenVibe.Media").++const SERVICE_NAME = (to) => {++ const t = String(to || '').toLowerCase();++ if (!/^[a-z][a-z0-9-]{0,31}$/.test(t)) return '';++ if (t === 'openre') return 'OpenRe.Stream';++ return `OpenVibe.${t.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('')}`;++};++// Only links a person can follow safely: https anywhere, or a path on this site. A path may hold no backslash and no++// tab or newline: the URL parser turns \ into / and drops tab/LF/CR, so "/\evil.example" would leave the site.++const safeHref = (h) => {++ const s = String(h || '');++ if (/^https:\/\//i.test(s)) return s;++ return /^\/(?!\/)[^\t\n\r\\]*$/.test(s) ? s : '';++};++const withFrom = (href, from) => {++ if (!from || !/^[a-z][a-z0-9-]{0,31}$/.test(String(from))) return href;++ const [base, hash = ''] = href.split('#');++ return `${base}${base.includes('?') ? '&' : '?'}ov_from=${encodeURIComponent(from)}${hash ? `#${hash}` : ''}`;++};++++/**++ * Next steps on other OpenVibe products (Shared ADR 0002, plan D94): after a person finishes something, up to four++ * concrete things to do next, each a real link that works signed out. Items: `{ to, what, text?, href, from?, embed? }`++ * — `to` picks the ring icon and the "on OpenVibe.X" label, `from` adds the referral tag `ov_from=<from>` (never anything++ * about the person), `embed` adds a secondary link to an embeddable view. Presentation only: each site passes its own++ * items. An item without `what` or a safe `href` is left out; no items → ''.++ */++function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {++ const usable = (items || []).filter((s) => s && s.what && safeHref(s.href)).slice(0, 4);++ if (!usable.length) return '';++ const h = `sc-${slug(title)}`;++ return `<section class="sc-sec sc-next"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ol class="sc-next-list">${usable.map((s) => {++ const to = SERVICE_NAME(s.to);++ const href = withFrom(safeHref(s.href), s.from);++ const embed = safeHref(s.embed);++ return `<li class="sc-next-item"><a class="sc-next-main" href="${esc(href)}">${s.to && to ? `<span class="sc-ic sc-ic-ov">${icon(`ov:${String(s.to).toLowerCase()}`, 40)}</span>` : ''}<span class="sc-next-body"><b>${esc(s.what)}</b>${s.text ? `<small>${esc(s.text)}</small>` : ''}${to ? `<span class="sc-next-on">on ${esc(to)}</span>` : ''}</span><span class="sc-next-go" aria-hidden="true">→</span></a>${embed ? `<a class="sc-next-embed" href="${esc(embed)}">Embed it</a>` : ''}</li>`;++ }).join('')}</ol></section>`;++}++/** The closing call to action. */function cta({ title, text, actions: acts } = {}) {if (!title) return '';@@@ -179,12 +218,21 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}.sc-story blockquote{margin:0;font-size:15px;line-height:1.55}.sc-story figcaption{margin-top:10px;font-weight:700;font-size:13.5px}.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}++.sc-next-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}++.sc-next-item{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}++.sc-next-main{flex:1 1 320px;display:flex;align-items:center;gap:14px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}++.sc-next-main:hover,.sc-next-main:focus-visible{border-color:var(--accent,#3b82f6);transform:translateX(2px);outline:0}++.sc-next-body{flex:1;min-width:0}.sc-next-body b{display:block;font-size:15.5px}++.sc-next-body small{display:block;margin-top:2px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}++.sc-next-on{display:block;margin-top:4px;font-size:12px;font-weight:700;letter-spacing:.3px;color:var(--accent-light,var(--accent,#60a5fa))}++.sc-next-go{font-size:20px;color:var(--text-muted,#7386a3);transition:transform .15s}.sc-next-main:hover .sc-next-go{transform:translateX(3px);color:var(--accent-light,#60a5fa)}++.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}--@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn{transition:none}}++@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go{transition:none}}`;--module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, cta, CSS, STYLESHEET: 'showcase.css', esc };++module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, nextSteps, cta, CSS, STYLESHEET: 'showcase.css', esc };@@@ -44,4 +44,30 @@ assert.strictEqual(sc.STYLESHEET, 'showcase.css');assert.ok(sc.CSS.includes('.sc-ic.sc-ic-ov{background:none'), 'the slot drops its tile for a ring icon');}++// Next steps (Shared ADR 0002): safe links only, the referral tag, the target's ring icon and name, escaping, at most four.++{++ assert.strictEqual(sc.nextSteps({ items: [] }), '', 'no items, no section');++ assert.strictEqual(sc.nextSteps({ items: [{ to: 'media', what: 'x', href: 'javascript:alert(1)' }] }), '', 'an unsafe link is left out');++ const out = sc.nextSteps({ title: 'What next', items: [++ { to: 'media', from: 'live', what: 'Clip the best <minute>', text: 'From the VOD you just watched.', href: 'https://openvibe.media/clips/new?vod=1#start', embed: 'https://openvibe.media/embed/1' },++ { to: 'tools', what: 'Convert it', href: '/tools/convert' },++ { to: 'chat', what: 'Tell people', href: '//evil.example/' },++ { to: 'blog', what: 'Write about it', href: 'https://openvibe.blog/write' },++ { to: 'news', what: 'Read more', href: 'https://openvibe.news/' },++ { to: 'wiki', what: 'Look it up', href: 'https://openvibe.wiki/' },++ ] });++ assert.ok(out.includes('<section class="sc-sec sc-next"'));++ assert.ok(out.includes('href="https://openvibe.media/clips/new?vod=1&ov_from=live#start"'), 'the referral tag goes before the fragment');++ assert.ok(out.includes('Clip the best <minute>'), 'text is escaped');++ assert.ok(out.includes('on OpenVibe.Media') && out.includes('data-icon="media"'), 'the target product and its ring icon');++ assert.ok(out.includes('href="/tools/convert"'), 'a path on this site is allowed');++ assert.ok(!out.includes('evil.example'), 'a protocol-relative link is left out');++ for (const sneaky of ['/\\evil.example', '/\t/evil.example', '/\n/evil.example', '/\r/evil.example']) {++ assert.strictEqual(sc.nextSteps({ items: [{ to: 'tools', what: 'x', href: sneaky }] }), '', `${JSON.stringify(sneaky)} would leave the site`);++ }++ assert.strictEqual((out.match(/class="sc-next-item"/g) || []).length, 4, 'at most four');++ assert.ok(out.includes('class="sc-next-embed" href="https://openvibe.media/embed/1"'));++ assert.ok(sc.CSS.includes('.sc-next-main{'), 'styled by the kit');++}++console.log('showcase: all checks passed');
@@ -6,6 +6,15 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,15 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 2.10.0 — 2026-10-05
+
+- `openvibe-shared/showcase`: `nextSteps({ id, title, lede, items })` renders up to four next steps on other OpenVibe
+ products (Shared ADR 0002, plan D94, `docs/adr/0002-traffic-between-products.md`). An item is
+ `{ to, what, text?, href, from?, embed? }`: `to` picks the ring icon and the "on OpenVibe.X" label, `from` appends
+ the referral tag `ov_from=<from>` (nothing about the person), `embed` adds an "Embed it" link. Only `https://` URLs
+ or site paths are rendered; an unusable item is left out and no items render ''. Styled in `showcase.css`.
+- `docs/adr/0002-traffic-between-products.md` (proposed): next-step cards, shareable result pages, embeds and badges
+ (live-rendered SVG + a proof page that re-checks the fact), and quests.
## 2.9.0 — 2026-10-04
## 2.9.0 — 2026-10-04
- `openvibe-shared/showcase`: a feature card's ring icon (`icon: 'ov:…'`) now fills the 40 px icon slot
- `openvibe-shared/showcase`: a feature card's ring icon (`icon: 'ov:…'`) now fills the 40 px icon slot
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v2.9.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v2.10.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": "2.9.0",
+ "version": "2.10.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)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+# Shared ADR 0002: traffic between products
+
+**Status:** Proposed 2026-10-05 by the build plan (plan T11, decision D94), revisable by the owner. A
+Shared-local decision: it fixes the shape of the presentation pieces Shared ships and the rules a
+service follows when it links to another one. No contract changes; nothing here moves data between
+services.
+
+## Context
+
+D94 asks the open sites to send people to each other on purpose: "next-step cards, shareable result
+pages, embeds and badges, cross-product quests". Today each site's home ends where its own product
+ends. A person who just finished something (cut a clip, converted a file, read a story) gets no
+honest suggestion of the obvious next thing on another OpenVibe site, and nothing a person makes can
+be shown elsewhere with proof that it is real.
+
+Constraints from the rest of the network:
+
+- **Every site works signed out.** A suggestion cannot depend on knowing who the person is.
+- **No invented claims.** The showcase rule applies: a card names only what the target does today.
+- **Privacy.** A link between sites must not carry a person's identity or activity (the
+ analytics source of truth is each service's own events, ADR-021).
+- **No build step, no shared runtime state.** Pieces are server-rendered strings, like the rest of
+ `openvibe-shared/showcase`.
+
+## Decision
+
+### 1. Next-step cards
+
+A next-step card is a server-rendered suggestion of one concrete thing to do on another product,
+placed after a person finishes something (a result page, a finished upload, the end of a story) or
+in a site's home. Shared ships the renderer, `showcase.nextSteps({ id, title, lede, items })`; each
+site passes its own items and Shared holds no product data.
+
+An item carries:
+
+| field | required | meaning |
+|---|---|---|
+| `to` | yes | the target service id (`live`, `tools`, `media`, …): picks the ring icon and the "on OpenVibe.X" label |
+| `what` | yes | the action, imperative, one line ("Clip the best minute") |
+| `text` | no | one sentence on why it fits what the person just did |
+| `href` | yes | an absolute `https://` URL on an OpenVibe zone, or a site-relative path for the same site |
+| `from` | no | the service rendering the card; with `to` it forms the referral tag below |
+| `embed` | no | the URL of an embeddable view of the target (section 3), shown as a secondary link |
+
+Rules:
+
+- **Real targets only.** `href` must open a page that exists and works signed out (sign-in may be
+ asked for at the target, never before the click).
+- **Referral without identity.** A card may append `ov_from=<from>` to `href` so the target counts
+ arrivals by source. Nothing else rides in the URL: no user id, session, subject or result id the
+ person did not choose to share.
+- **At most four per placement.** A list of everything is not a next step.
+
+### 2. Shareable result pages
+
+A result page is shareable when a person explicitly shares it: the result gets a stable URL that
+renders without the person's session (`/r/<id>` or the product's own path). Such a page:
+
+- is created only by an explicit share action, never by producing the result;
+- has an id that cannot be guessed from other ids (an opaque random or content-addressed id);
+- states who shared it only if the person chose to show their name;
+- carries its own Open Graph and canonical tags, and is `noindex` unless the owner publishes it;
+- expires on the product's own retention, and says so on the page.
+
+### 3. Embeds and badges
+
+An **embed** is a framed, read-only view of a public thing on its own product (a robot's video and
+readouts, a stream, a paste). Frame-ancestors lists are per product, as Bot's `BOT_EMBED_ORIGINS`
+does; an embed never shows controls or anything the anonymous public could not see on the product
+itself.
+
+A **badge** is a small image that states one public fact ("live now", "member of <creator>'s plan",
+"a robot on OpenVibe.Bot") and links to a page that proves it. Badges are issued and verified by the
+service that owns the fact:
+
+- **Image:** `GET https://<service>/badge/<kind>/<resource>.svg`, rendered from current state, cached
+ briefly (`max-age` of a minute or less), never a static file that can go stale.
+- **Proof:** the badge always links to `https://<service>/badge/<kind>/<resource>`, an HTML page that
+ re-checks the fact at request time and shows what it found. A badge copied elsewhere is therefore
+ only as good as its link: anyone can click through and see the truth.
+- **No secrets, no personal data:** `<resource>` is a public handle or id the owner already shows
+ publicly. A badge for a private fact is not issued.
+- A signed form (an HMAC over service, kind, resource and issue time) is added only when a
+ consumer needs to verify offline; until then the live proof page is the verification.
+
+### 4. Cross-product quests
+
+A quest is a short list of next steps across products with progress the person can see ("go live
+once, clip a moment, share it"). Progress is computed by each owning service from its own events and
+read by the quest page; Shared ships no quest state. Quests are out of scope for the first release of
+the cards; when built they reuse the next-step item shape for each step.
+
+## Consequences
+
+- Shared gains `showcase.nextSteps` (presentation only) and this document; product data stays in
+ each site.
+- Sites adopt cards one placement at a time, starting where a result already exists (Tools results,
+ finished Media clips, Live VODs).
+- Badge and share endpoints are per service and follow sections 2 and 3 when each service builds
+ them; nothing is centralised in Network.
+- The referral tag gives each target a count of arrivals by source without any cross-site identity.
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.9.0",
+ "version": "2.10.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.9.0",
+ "version": "2.10.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.9.0",
+ "version": "2.10.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": {
@@ -52,12 +52,21 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
@@ -52,12 +52,21 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-story blockquote{margin:0;font-size:15px;line-height:1.55}.sc-story figcaption{margin-top:10px;font-weight:700;font-size:13.5px}
.sc-story blockquote{margin:0;font-size:15px;line-height:1.55}.sc-story figcaption{margin-top:10px;font-weight:700;font-size:13.5px}
.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}
.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}
+.sc-next-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
+.sc-next-item{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}
+.sc-next-main{flex:1 1 320px;display:flex;align-items:center;gap:14px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
+.sc-next-main:hover,.sc-next-main:focus-visible{border-color:var(--accent,#3b82f6);transform:translateX(2px);outline:0}
+.sc-next-body{flex:1;min-width:0}.sc-next-body b{display:block;font-size:15.5px}
+.sc-next-body small{display:block;margin-top:2px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}
+.sc-next-on{display:block;margin-top:4px;font-size:12px;font-weight:700;letter-spacing:.3px;color:var(--accent-light,var(--accent,#60a5fa))}
+.sc-next-go{font-size:20px;color:var(--text-muted,#7386a3);transition:transform .15s}.sc-next-main:hover .sc-next-go{transform:translateX(3px);color:var(--accent-light,#60a5fa)}
+.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}
.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}
.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}
@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
-@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn{transition:none}}
+@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go{transition:none}}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
@@ -119,6 +119,45 @@ function stories({ id: sid, title, lede, items } = {}) {
@@ -119,6 +119,45 @@ function stories({ id: sid, title, lede, items } = {}) {
return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-stories">${items.map((s) => `<figure class="sc-story"><blockquote>${esc(s.text)}</blockquote><figcaption>${esc(s.who)}${s.outcome ? `<span>${esc(s.outcome)}</span>` : ''}</figcaption></figure>`).join('')}</div></section>`;
return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-stories">${items.map((s) => `<figure class="sc-story"><blockquote>${esc(s.text)}</blockquote><figcaption>${esc(s.who)}${s.outcome ? `<span>${esc(s.outcome)}</span>` : ''}</figcaption></figure>`).join('')}</div></section>`;
}
}
+// A next step's target names: the service id → the product name the card says it opens ("on OpenVibe.Media").
+const SERVICE_NAME = (to) => {
+ const t = String(to || '').toLowerCase();
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(t)) return '';
+ if (t === 'openre') return 'OpenRe.Stream';
+ return `OpenVibe.${t.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('')}`;
+};
+// Only links a person can follow safely: https anywhere, or a path on this site. A path may hold no backslash and no
+// tab or newline: the URL parser turns \ into / and drops tab/LF/CR, so "/\evil.example" would leave the site.
+const safeHref = (h) => {
+ const s = String(h || '');
+ if (/^https:\/\//i.test(s)) return s;
+ return /^\/(?!\/)[^\t\n\r\\]*$/.test(s) ? s : '';
+};
+const withFrom = (href, from) => {
+ if (!from || !/^[a-z][a-z0-9-]{0,31}$/.test(String(from))) return href;
+ const [base, hash = ''] = href.split('#');
+ return `${base}${base.includes('?') ? '&' : '?'}ov_from=${encodeURIComponent(from)}${hash ? `#${hash}` : ''}`;
+};
+
+/**
+ * Next steps on other OpenVibe products (Shared ADR 0002, plan D94): after a person finishes something, up to four
+ * concrete things to do next, each a real link that works signed out. Items: `{ to, what, text?, href, from?, embed? }`
+ * — `to` picks the ring icon and the "on OpenVibe.X" label, `from` adds the referral tag `ov_from=<from>` (never anything
+ * about the person), `embed` adds a secondary link to an embeddable view. Presentation only: each site passes its own
+ * items. An item without `what` or a safe `href` is left out; no items → ''.
+ */
+function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {
+ const usable = (items || []).filter((s) => s && s.what && safeHref(s.href)).slice(0, 4);
+ if (!usable.length) return '';
+ const h = `sc-${slug(title)}`;
+ return `<section class="sc-sec sc-next"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ol class="sc-next-list">${usable.map((s) => {
+ const to = SERVICE_NAME(s.to);
+ const href = withFrom(safeHref(s.href), s.from);
+ const embed = safeHref(s.embed);
+ return `<li class="sc-next-item"><a class="sc-next-main" href="${esc(href)}">${s.to && to ? `<span class="sc-ic sc-ic-ov">${icon(`ov:${String(s.to).toLowerCase()}`, 40)}</span>` : ''}<span class="sc-next-body"><b>${esc(s.what)}</b>${s.text ? `<small>${esc(s.text)}</small>` : ''}${to ? `<span class="sc-next-on">on ${esc(to)}</span>` : ''}</span><span class="sc-next-go" aria-hidden="true">→</span></a>${embed ? `<a class="sc-next-embed" href="${esc(embed)}">Embed it</a>` : ''}</li>`;
+ }).join('')}</ol></section>`;
+}
+
/** The closing call to action. */
/** The closing call to action. */
function cta({ title, text, actions: acts } = {}) {
function cta({ title, text, actions: acts } = {}) {
if (!title) return '';
if (!title) return '';
@@ -179,12 +218,21 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
@@ -179,12 +218,21 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-story{margin:0;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-story blockquote{margin:0;font-size:15px;line-height:1.55}.sc-story figcaption{margin-top:10px;font-weight:700;font-size:13.5px}
.sc-story blockquote{margin:0;font-size:15px;line-height:1.55}.sc-story figcaption{margin-top:10px;font-weight:700;font-size:13.5px}
.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}
.sc-story figcaption span{display:block;font-weight:500;color:var(--text-secondary,#96a7c2)}
+.sc-next-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
+.sc-next-item{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}
+.sc-next-main{flex:1 1 320px;display:flex;align-items:center;gap:14px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
+.sc-next-main:hover,.sc-next-main:focus-visible{border-color:var(--accent,#3b82f6);transform:translateX(2px);outline:0}
+.sc-next-body{flex:1;min-width:0}.sc-next-body b{display:block;font-size:15.5px}
+.sc-next-body small{display:block;margin-top:2px;color:var(--text-secondary,#96a7c2);font-size:13.5px;line-height:1.45}
+.sc-next-on{display:block;margin-top:4px;font-size:12px;font-weight:700;letter-spacing:.3px;color:var(--accent-light,var(--accent,#60a5fa))}
+.sc-next-go{font-size:20px;color:var(--text-muted,#7386a3);transition:transform .15s}.sc-next-main:hover .sc-next-go{transform:translateX(3px);color:var(--accent-light,#60a5fa)}
+.sc-next-embed{font-size:13px;font-weight:600;color:var(--accent-light,#60a5fa)}
.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
.sc-cta{margin-top:64px;margin-bottom:24px;text-align:center}
.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}
.sc-cta>div,.sc-cta .sc-actions{justify-content:center}.sc-cta p{color:var(--text-secondary,#96a7c2);margin:6px auto 0;max-width:620px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}
@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
-@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn{transition:none}}
+@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go{transition:none}}
`;
`;
-module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, cta, CSS, STYLESHEET: 'showcase.css', esc };
+module.exports = { hero, features, steps, code, demo, compare, pricing, limits, stories, nextSteps, cta, CSS, STYLESHEET: 'showcase.css', esc };
@@ -44,4 +44,30 @@ assert.strictEqual(sc.STYLESHEET, 'showcase.css');
@@ -44,4 +44,30 @@ assert.strictEqual(sc.STYLESHEET, 'showcase.css');
assert.ok(sc.CSS.includes('.sc-ic.sc-ic-ov{background:none'), 'the slot drops its tile for a ring icon');
assert.ok(sc.CSS.includes('.sc-ic.sc-ic-ov{background:none'), 'the slot drops its tile for a ring icon');
}
}
+// Next steps (Shared ADR 0002): safe links only, the referral tag, the target's ring icon and name, escaping, at most four.
+{
+ assert.strictEqual(sc.nextSteps({ items: [] }), '', 'no items, no section');
+ assert.strictEqual(sc.nextSteps({ items: [{ to: 'media', what: 'x', href: 'javascript:alert(1)' }] }), '', 'an unsafe link is left out');
+ const out = sc.nextSteps({ title: 'What next', items: [
+ { to: 'media', from: 'live', what: 'Clip the best <minute>', text: 'From the VOD you just watched.', href: 'https://openvibe.media/clips/new?vod=1#start', embed: 'https://openvibe.media/embed/1' },
+ { to: 'tools', what: 'Convert it', href: '/tools/convert' },
+ { to: 'chat', what: 'Tell people', href: '//evil.example/' },
+ { to: 'blog', what: 'Write about it', href: 'https://openvibe.blog/write' },
+ { to: 'news', what: 'Read more', href: 'https://openvibe.news/' },
+ { to: 'wiki', what: 'Look it up', href: 'https://openvibe.wiki/' },
+ ] });
+ assert.ok(out.includes('<section class="sc-sec sc-next"'));
+ assert.ok(out.includes('href="https://openvibe.media/clips/new?vod=1&ov_from=live#start"'), 'the referral tag goes before the fragment');
+ assert.ok(out.includes('Clip the best <minute>'), 'text is escaped');
+ assert.ok(out.includes('on OpenVibe.Media') && out.includes('data-icon="media"'), 'the target product and its ring icon');
+ assert.ok(out.includes('href="/tools/convert"'), 'a path on this site is allowed');
+ assert.ok(!out.includes('evil.example'), 'a protocol-relative link is left out');
+ for (const sneaky of ['/\\evil.example', '/\t/evil.example', '/\n/evil.example', '/\r/evil.example']) {
+ assert.strictEqual(sc.nextSteps({ items: [{ to: 'tools', what: 'x', href: sneaky }] }), '', `${JSON.stringify(sneaky)} would leave the site`);
+ }
+ assert.strictEqual((out.match(/class="sc-next-item"/g) || []).length, 4, 'at most four');
+ assert.ok(out.includes('class="sc-next-embed" href="https://openvibe.media/embed/1"'));
+ assert.ok(sc.CSS.includes('.sc-next-main{'), 'styled by the kit');
+}
+
console.log('showcase: all checks passed');
console.log('showcase: all checks passed');