openvibers/openvibe.shared Commits

Next-step cards in the showcase kit and the traffic-between-products ADR; release 2.10.0 (#19)

OP OpenVibers committed 5 Oct 2026 · 009229bb73 · 9 files, +201 −8

* Next-step cards in the showcase kit and the traffic-between-products ADR; release 2.10.0

D94 (plan T11): sites send people to each other on purpose.

- docs/adr/0002-traffic-between-products.md (proposed): the next-step card
  fields (to, what, text, href, from, embed), the referral tag ov_from with
  nothing about the person, shareable result pages (explicit share,
  unguessable id, noindex unless published, product retention), embeds and
  badges (live-rendered SVG plus a proof page that re-checks the fact at
  request time; a signed form only when someone needs offline checks), and
  quests (progress computed by each owning service).
- showcase.nextSteps({ id, title, lede, items }): up to four cards, each a
  real link (https or a site path only), the target's ring icon and "on
  OpenVibe.X", the referral tag before any fragment, an optional "Embed it"
  link; unusable items are left out, no items render ''. Styled in
  showcase.css (rebuilt).
- Test: escaping, unsafe and protocol-relative links refused, the tag, the
  cap of four, the embed link.

* nextSteps: a site path with a backslash, tab or newline is refused (review)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8cfc66a..bb46158 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex b62f002..ebfe8d9 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, 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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 5d6efb5..101229e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/docs/adr/0002-traffic-between-products.md b/docs/adr/0002-traffic-between-products.md
nnew file mode 100644
iindex 0000000..8cec34f
---- /dev/null
++++ b/docs/adr/0002-traffic-between-products.md
@@@ -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.
ddiff --git a/package-lock.json b/package-lock.json
iindex f6872f5..d5d152d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex d14e1d5..c1017e5 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/showcase.css b/showcase.css
iindex 4c8dc0b..adcb4ab 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -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}
ddiff --git a/showcase.js b/showcase.js
iindex be5e48d..d1808cf 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -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 };
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 429127a..c74b7f4 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -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&amp;ov_from=live#start"'), 'the referral tag goes before the fragment');
++ assert.ok(out.includes('Clip the best &lt;minute&gt;'), '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');