openvibers/openvibe.shared Commits

openvibe-shared 4.227.0: one app icon drawn on the server too, and the feed's new kinds (#507)

OP OpenVibers committed 18 h ago · 8be6a31df6 · 22 files, +119 −28

* openvibe-shared 4.227.0: one app icon drawn on the server too, and the feed's new kinds

- **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.
- **The square keeps its size before `ov-icons.js`**: the navbar's early rules hold every `.ov-appicon` at its `data-size`, so a page's app icons no longer pop in.
- **The menu's "Across OpenVibe" sites and a quest's steps wear their app icons** instead of a small glyph in a ring.
- **The Feed's chips name the one feed's new kinds**: Rundown, Jobs, Deals and Stats, beside the media kinds.

* /updates: a filter chip's count keeps the chip's full colour

The count faded to 70% and failed axe color-contrast (serious) on every site's /updates, which the release browser check paged as critical.

* A release reaches a returning visitor within minutes

An unversioned asset kept a day of stale-while-revalidate. Sites load navbar.js without ?v=, so the first visit to each site after a release ran the old menu: the owner's phone showed the stacked You view hours after 4.223.0 made it tabs. Five minutes now; after ten quiet minutes a browser asks again and gets a 304 for an unchanged file.

* Showcase: sites and apps are their app icons

network(), apps() and the next-step list drew a small glyph in a ring for each site; they now draw the app icon (design rule 142) with appHtml(), whole on the server and the same as the menu's. One change for every site's home.

* Tests: wait for ov-icons.js, not a fixed sleep; navbar budget 50.5 KB for the app icon's early squares

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b6ef5d5..bdab19f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,15 @@
# Changelog
++## 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.
++- **Every site's "More on OpenVibe", app launcher and next steps draw sites as their app icons** (showcase `network()`, `apps()`, the next-step list), whole on the server and the same as the menu's: 44 px in a row, 56 px as a launcher tile on a phone. A non-OpenVibe glyph (`fa-…`) keeps its tile.
++- **The square keeps its size before `ov-icons.js`**: the navbar's early rules hold every `.ov-appicon` at its `data-size`, so a page's app icons no longer pop in.
++- **The menu's "Across OpenVibe" sites and a quest's steps wear their app icons** instead of a small glyph in a ring.
++- **The Feed's chips name the one feed's new kinds**: Rundown, Jobs, Deals and Stats, beside the media kinds.
++- **A release reaches a returning visitor within minutes**: an unversioned asset (`cache-policy` `assetHeaders`) keeps five minutes of stale-while-revalidate, not a day. Sites load `navbar.js` without `?v=`, so the first visit to each site after a release ran the old menu.
++- **/updates passes its contrast check again**: a filter chip's count no longer fades to 70% (axe color-contrast, serious, on every site's /updates; the release browser check had paged as critical).
++
## 4.226.0 — 2026-11-12
- **The menu's feed draws the one feed's new kinds (feed.item@1)** (the one-feed job's finding: they fell through to a
ddiff --git a/README.md b/README.md
iindex 239a339..d33399a 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.226.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.227.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 0144203..772c017 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.226.0",
++ "version": "4.227.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/cache-policy.js b/cache-policy.js
iindex 4c2b10d..fae9fb2 100644
---- a/cache-policy.js
++++ b/cache-policy.js
@@@ -2,7 +2,7 @@
/**
* openvibe-shared/cache-policy — the one cache rule every OpenVibe site uses, so no site has to
* remember it and no site invents its own numbers: a content-addressed asset is immutable for a
-- * year, everything else is a short public window with a long stale-while-revalidate, and a page
++ * year, everything else is a short public window with a short stale-while-revalidate, and a page
* that is personalised for the person reading it is never cached by anybody.
*
* const cache = require('openvibe-shared/cache-policy');
@@@ -13,12 +13,15 @@
* A URL is content-addressed when it carries `?v=<hash>` (what serve.url() hands out) or a hashed
* filename (`app.6f2a91c3.js`, `styles-a1b2c3d4e5.css`). Immutable is only ever right for those:
* the bytes cannot change under the same URL. Everything else may change at any moment, so it gets
-- * five minutes plus a day of stale-while-revalidate — a visitor is served the old copy for a
-- * second while the new one is fetched, and nobody waits on the network twice.
++ * five minutes plus five more of stale-while-revalidate — a visitor is served the old copy for a
++ * second while the new one is fetched, and nobody waits on the network twice. 4.227.0: it was a day,
++ * and the first visit to each site after a release ran the old navbar.js (sites load it without ?v=),
++ * so the owner's phone showed an old menu hours after the new one shipped (11-12). After ten quiet
++ * minutes a browser asks again: a 304 for an unchanged file.
*/
const IMMUTABLE = 'public, max-age=31536000, immutable';
const ASSET_MAX_AGE = 300;
--const ASSET_SWR = 86400;
++const ASSET_SWR = 300;
/** True when `path` is content-addressed: `?v=<hash>` or a hash in the filename. */
function isHashed(path) {
ddiff --git a/footer.js b/footer.js
iindex afedd71..13fac3d 100644
---- a/footer.js
++++ b/footer.js
@@@ -753,7 +753,7 @@ html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity
let _shippedLoading = false;
// shipped.js's content hash (scripts/sibling-revs.js writes it), and the footer's "shipped" line is fetched only when the
// footer comes near the screen (4.138.0): most visits never scroll there, and it is 12 KB.
-- const FOOTER_REV = { 'shipped.js': 'f00b4bbc56a4' };
++ const FOOTER_REV = { 'shipped.js': 'f1513e2d434f' };
let _shippedSeen = null;
function shippedWhenNear(el) {
if (typeof IntersectionObserver !== 'function' || !el || !el.getBoundingClientRect) { loadShipped(); return; }
ddiff --git a/navbar.js b/navbar.js
iindex d5cb6bd..94da1f8 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -190,6 +190,12 @@
/* Icons have their size and a faint ring before ov-icons.js draws them: nothing moves or pops when they arrive. */
.ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px; border-radius: 50%; }
.ovnav-drawer .ov-icon[data-size="22"] { width: 22px; height: 22px; }
++ /* An app icon (design rule 142) holds its square at its size before ov-icons.js draws it, on every page. */
++ .ov-appicon { display: inline-block; flex: none; width: var(--ova-size, 56px); height: var(--ova-size, 56px); border-radius: 30%; vertical-align: middle; }
++ .ov-appicon[data-size="20"] { --ova-size: 20px; } .ov-appicon[data-size="26"] { --ova-size: 26px; } .ov-appicon[data-size="32"] { --ova-size: 32px; }
++ .ov-appicon[data-size="44"] { --ova-size: 44px; } .ov-appicon[data-size="56"] { --ova-size: 56px; } .ov-appicon[data-size="64"] { --ova-size: 64px; }
++ .ov-appicon:empty { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++ .ovnav-drawer .ov-appicon > .ov-icon { width: 100%; height: 100%; border-radius: 0; }
.ovnav-drawer .ov-icon:empty { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
/* Several balance chips fold into one on a phone (owner, 2026-10-27: the two currencies pushed out the site
switcher next to the logo); a tap lists each in full. */
@@@ -604,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': '437395e64eda', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '660e68d4f243', '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': '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 siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
@@@ -1313,7 +1319,7 @@
if (!pick.length) return '';
if (inDrawer) {
return `<div class="ovnav-net-head"><span class="ov-icon" data-icon="ov" data-size="22" data-fx="none"></span><span class="ud-label">Across OpenVibe</span>${_config.launcher === false ? '' : '<button type="button" class="ovnav-net-all" data-open-launcher>All sites</button>'}</div>`
-- + `<div class="ovnav-net-grid">${pick.map(n => `<a class="ovnav-net" data-ov-net-link href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}"><span class="ov-icon" data-icon="${escapeAttr(n.icon || n.id)}" data-size="22" data-fx="none"></span><span>${escapeAttr(n.name)}</span></a>`).join('')}</div>`;
++ + `<div class="ovnav-net-grid">${pick.map(n => `<a class="ovnav-net" data-ov-net-link href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}"><span class="ov-appicon" data-icon="${escapeAttr(n.icon || n.id)}" data-size="26"></span><span>${escapeAttr(n.name)}</span></a>`).join('')}</div>`;
}
return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" data-ov-net-link href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
}
ddiff --git a/ov-home.js b/ov-home.js
iindex 47f5d4e..ce37ce7 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -1436,7 +1436,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
// you near the end. When the API is not there yet, the pulse wall above stands (timeline()).
// Following needs a signed-in person (the feed reads their Network follows); the rest are the feed's kinds (Community
// #310: wiki pages, quest badges, busy chat rooms, calendar events and the people who joined, beside the media kinds).
-- const KINDS = [['', 'For you'], ['following', 'Following'], ['live', 'Live'], ['video', 'Videos'], ['clip', 'Clips'], ['pic', 'Pics'], ['post', 'Posts'], ['meme', 'Memes'], ['game', 'Games'], ['chat', 'Chat'], ['event', 'Events'], ['wiki', 'Wiki'], ['quest', 'Quests'], ['update', 'Updates']];
++ const KINDS = [['', 'For you'], ['following', 'Following'], ['live', 'Live'], ['video', 'Videos'], ['clip', 'Clips'], ['pic', 'Pics'], ['post', 'Posts'], ['rundown', 'Rundown'], ['meme', 'Memes'], ['game', 'Games'], ['chat', 'Chat'], ['event', 'Events'], ['work', 'Jobs'], ['deal', 'Deals'], ['report', 'Stats'], ['wiki', 'Wiki'], ['quest', 'Quests'], ['update', 'Updates']];
function feedUrl(o, kind, cursor) {
const p = new URLSearchParams({ limit: '12' });
if (kind === 'following') p.set('following', '1'); else if (kind) p.set('kind', kind);
ddiff --git a/ov-icons.js b/ov-icons.js
iindex 66baec5..d6c0805 100644
---- a/ov-icons.js
++++ b/ov-icons.js
@@@ -151,12 +151,21 @@
const registry = Object.create(null);
for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };
-- const CSS = `
++ /** The app icon's own rules (design rule 142). A page drawn on the server puts these in its stylesheet once
++ * (OpenVibeIcons.APP_CSS) and its appHtml() icons are whole before any script runs. */
++ const APP_CSS = `
.ov-appicon{--ova-h:215;--ova-size:56px;position:relative;display:inline-block;flex:none;width:var(--ova-size);height:var(--ova-size);border-radius:30%;overflow:hidden;vertical-align:middle;background:linear-gradient(150deg,hsl(var(--ova-h) 72% 58% / .36),hsl(var(--ova-h) 70% 38% / .2));box-shadow:inset 0 0 0 1px hsl(var(--ova-h) 70% 64% / .3)}
.ov-appicon>.ov-icon{position:absolute;inset:0;width:100%;height:100%;display:block;--ovi-glyph:hsl(var(--ova-h) 92% 84%);color:hsl(var(--ova-h) 92% 84%)}
.ov-appicon>.ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;margin:0;transform:none!important}
.ov-appicon .ovi-ring,.ov-appicon .ovi-bg,.ov-appicon .ovi-comet,.ov-appicon .ovi-prog{display:none}
:root[data-theme="light"] .ov-appicon>.ov-icon{--ovi-glyph:hsl(var(--ova-h) 70% 30%);color:hsl(var(--ova-h) 70% 30%)}
++.ov-appicon[data-size="20"]{--ova-size:20px}
++.ov-appicon[data-size="26"]{--ova-size:26px}
++.ov-appicon[data-size="32"]{--ova-size:32px}
++.ov-appicon[data-size="44"]{--ova-size:44px}
++.ov-appicon[data-size="56"]{--ova-size:56px}
++.ov-appicon[data-size="64"]{--ova-size:64px}`;
++ const CSS = `${APP_CSS}
.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;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
@@@ -374,6 +383,18 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;
el.appendChild(g);
mountOne(g);
}
++ /** 4.227.0: the same app icon as markup, for a page drawn on the server (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. */
++ function appHtml(site, { size = 56, label = '', hue: h = null } = {}) {
++ const name = String(site || 'ov'); const key = resolve(name);
++ const n = Math.min(Math.max(parseInt(size, 10) || 56, 12), 256);
++ const e = (t) => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
++ const given = h != null && h !== '' && Number.isFinite(Number(h));
++ const hh = given ? Math.abs(Math.round(Number(h))) % 360 : hue(name);
++ const a11y = label ? ` data-label="${e(label)}" role="img" aria-label="${e(label)}"` : ' aria-hidden="true"';
++ return `<span class="ov-appicon" data-icon="${e(name)}" data-ova="${e(name)}" data-size="${n}"${given ? ` data-hue="${hh}"` : ''} style="--ova-h:${hh};--ova-size:${n}px"${a11y}>`
++ + `<span class="ov-icon" data-icon="${key}" data-ovi="${key}" data-fx="none" aria-hidden="true">${svg(key)}</span></span>`;
++ }
function app(site, { size = 56, label = '', hue: h = null } = {}) {
const el = document.createElement('span'); el.className = 'ov-appicon';
el.setAttribute('data-icon', String(site || 'ov'));
@@@ -398,7 +419,7 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;
if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });
}
-- const api = { names: () => Object.keys(registry), register, mount, set, svg, sprite, use, resolve, app, hue, CSS };
++ const api = { names: () => Object.keys(registry), register, mount, set, svg, sprite, use, resolve, app, appHtml, hue, CSS, APP_CSS };
root.OpenVibeIcons = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document === 'undefined') return;
ddiff --git a/package-lock.json b/package-lock.json
iindex 09a3ccf..28434ca 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.226.0",
++ "version": "4.227.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.226.0",
++ "version": "4.227.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 80378ab..1c6c8b9 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.226.0",
++ "version": "4.227.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/quest.js b/quest.js
iindex 389bd3c..cfc4ee9 100644
---- a/quest.js
++++ b/quest.js
@@@ -150,8 +150,9 @@
const row = el('li');
const a = el('a');
a.href = href(step.href);
-- const siteIcon = el('span', 'ovq-icon ovq-site-icon ov-icon');
++ const siteIcon = el('span', 'ovq-site-icon ov-appicon');
siteIcon.dataset.icon = String(step.site || '').split('.')[1] || 'quest';
++ siteIcon.dataset.size = '32';
siteIcon.setAttribute('aria-hidden', 'true');
const text = el('span', 'ovq-step');
const sentence = el('span', null, step.sentence || '');
ddiff --git a/shipped.js b/shipped.js
iindex 09858f4..0b94fb5 100644
---- a/shipped.js
++++ b/shipped.js
@@@ -59,7 +59,7 @@
`.ov-shipped-latest b{color:${TEXT};font-weight:600;overflow:hidden;text-overflow:ellipsis;min-width:0}`,
'[data-ov-shipped][hidden],.ov-shipped[hidden],.ov-shipped-latest[hidden]{display:none!important}',
'.ov-shipped-home{display:grid;gap:12px;justify-items:start;margin:16px 0;min-width:0}.ov-shipped-home>.ov-shipped{justify-self:stretch}',
-- `.ov-shipped-n{margin-left:2px;white-space:pre;font-size:.78em;font-weight:600;opacity:.7;font-variant-numeric:tabular-nums}`,
++ `.ov-shipped-n{margin-left:2px;white-space:pre;font-size:.78em;font-weight:600;font-variant-numeric:tabular-nums}`,
`.ov-shipped-kinds{margin-top:-4px}.ov-shipped-house{margin-top:6px;border-top:1px dashed var(--border,rgba(255,255,255,.12));padding-top:6px}.ov-shipped-house>summary{cursor:pointer;color:${MUTED};font-size:13px;padding:6px 0}.ov-shipped-house .ov-shipped-entry{opacity:.85}`,
`.ov-updates{min-width:0}.ov-updates h1{margin:.2em 0 .3em}.ov-updates-lede{color:${MUTED};line-height:1.55;margin:0 0 18px}`,
'.ov-shipped{font-size:.92rem;min-width:0}.ov-shipped h3{font-size:1rem;margin:0 0 .5rem;display:flex;gap:.4rem;align-items:center}',
ddiff --git a/showcase.css b/showcase.css
iindex db230c5..c5f24fe 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -177,6 +177,7 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@media (min-width:600px){
.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
++.sc-apps .ov-appicon{--ova-size:44px!important}
.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}
.sc-apps b{font-size:15px}
.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}
@@@ -228,6 +229,12 @@ html{overflow-x:clip}
.ov-appicon>.ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;margin:0;transform:none!important}
.ov-appicon .ovi-ring,.ov-appicon .ovi-bg,.ov-appicon .ovi-comet,.ov-appicon .ovi-prog{display:none}
:root[data-theme="light"] .ov-appicon>.ov-icon{--ovi-glyph:hsl(var(--ova-h) 70% 30%);color:hsl(var(--ova-h) 70% 30%)}
++.ov-appicon[data-size="20"]{--ova-size:20px}
++.ov-appicon[data-size="26"]{--ova-size:26px}
++.ov-appicon[data-size="32"]{--ova-size:32px}
++.ov-appicon[data-size="44"]{--ova-size:44px}
++.ov-appicon[data-size="56"]{--ova-size:56px}
++.ov-appicon[data-size="64"]{--ova-size:64px}
.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;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
ddiff --git a/showcase.js b/showcase.js
iindex e9ac4d1..02f172e 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -34,6 +34,11 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&':
const has = (a) => Array.isArray(a) && a.length > 0;
let ovIcons = null;
let glyphSet = null;
++/** 4.227.0 (design rule 142): a site or an app is drawn as its app icon, the menu's and Home's, whole on the server. */
++function appIcon(key, size) {
++ ovIcons = ovIcons || require('./ov-icons');
++ return ovIcons.appHtml(String(key || 'ov').toLowerCase(), { size });
++}
function icon(name, size = 22) {
if (!name) return '';
const n = String(name);
@@@ -246,7 +251,7 @@ function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {
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>`;
++ return `<li class="sc-next-item"><a class="sc-next-main" href="${esc(href)}">${s.to && to ? appIcon(s.to, 44) : ''}<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>`;
}
@@@ -418,7 +423,7 @@ function network({ id: sid, service, title = 'More on OpenVibe', lede = 'One acc
.filter((x) => x && x.what && safeHref(x.href) && x.to !== service).slice(0, 4);
if (!list.length) return '';
const h = `sc-${slug(title)}`;
-- return `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'spark')}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}"><span class="sc-ic sc-ic-ov">${icon(`ov:${String(x.to).toLowerCase()}`, 40)}</span><span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul><div class="sc-net-now" data-ov-now hidden></div><p class="sc-net-all"><a href="https://openvibe.network/sites">Every OpenVibe site →</a></p></section>`;
++ return `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'spark')}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}">${appIcon(x.to, 44)}<span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul><div class="sc-net-now" data-ov-now hidden></div><p class="sc-net-all"><a href="https://openvibe.network/sites">Every OpenVibe site →</a></p></section>`;
}
/**
@@@ -434,7 +439,8 @@ function apps({ id: sid, title = '', lede = '', groups = null, items = null, mor
const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);
if (!sets.length) return '';
const h = `sc-${slug(title || 'apps')}`;
-- const tile = (x) => `<li><a href="${esc(safeHref(x.href))}"><span class="sc-ic sc-ic-ov">${icon(x.icon || 'ov:spark', 40)}</span><span class="sc-app-t"><b>${esc(x.name)}</b>${x.text ? `<small>${esc(x.text)}</small>` : ''}</span></a></li>`;
++ const glyph = (x) => { const k = String(x.icon || 'ov:spark'); return k.startsWith('ov:') ? appIcon(k.slice(3), 56) : `<span class="sc-ic">${icon(k, 22)}</span>`; };
++ const tile = (x) => `<li><a href="${esc(safeHref(x.href))}">${glyph(x)}<span class="sc-app-t"><b>${esc(x.name)}</b>${x.text ? `<small>${esc(x.text)}</small>` : ''}</span></a></li>`;
// Six or nine are three a row (4.46.1): four columns left one tile alone on the last row.
const cols = (n) => (n >= 2 && n <= 4 ? n : n % 4 === 0 ? 4 : n % 3 === 0 ? 3 : 0);
const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps${cols(g.items.length) ? ` sc-apps--c${cols(g.items.length)}` : ''}">${g.items.map(tile).join('')}</ul>`).join('');
@@@ -691,6 +697,7 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@media (min-width:600px){
.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
++.sc-apps .ov-appicon{--ova-size:44px!important}
.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}
.sc-apps b{font-size:15px}
.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}
ddiff --git a/test/cache-policy.test.js b/test/cache-policy.test.js
iindex a823622..4bd39e4 100644
---- a/test/cache-policy.test.js
++++ b/test/cache-policy.test.js
@@@ -17,9 +17,9 @@ const serve = require('../serve');
// Assets: the hash means immutable for a year; anything else is short plus a long stale window.
assert.strictEqual(cache.assetHeaders('/shared/navbar.js?v=6f2a91c3d4e5'), 'public, max-age=31536000, immutable');
-- assert.strictEqual(cache.assetHeaders('/shared/navbar.js'), 'public, max-age=300, stale-while-revalidate=86400');
++ assert.strictEqual(cache.assetHeaders('/shared/navbar.js'), 'public, max-age=300, stale-while-revalidate=300', 'a release reaches a returning visitor within minutes, not a day');
assert.strictEqual(cache.assetHeaders('/logo.png', { hashed: true }), cache.IMMUTABLE, 'a caller that knows can say so');
-- assert.strictEqual(cache.assetHeaders('/logo.png', { hashed: false }), 'public, max-age=300, stale-while-revalidate=86400');
++ assert.strictEqual(cache.assetHeaders('/logo.png', { hashed: false }), 'public, max-age=300, stale-while-revalidate=300');
// HTML: stale-while-revalidate at the edge, or nothing stored at all when personalised.
assert.strictEqual(cache.htmlHeaders(), 'public, max-age=120, stale-while-revalidate=3600');
ddiff --git a/test/menu-feed-v2-chrome.test.js b/test/menu-feed-v2-chrome.test.js
iindex 209f092..f3e0f07 100644
---- a/test/menu-feed-v2-chrome.test.js
++++ b/test/menu-feed-v2-chrome.test.js
@@@ -96,7 +96,7 @@ const server = http.createServer((req, res) => {
kinds: [...new Set([...document.querySelectorAll('.ovfd-list > a')].map((a) => a.className))], live: !!document.querySelector('.ovfd-livechip'), media: document.querySelectorAll('.ovfd-media img').length,
bolds: document.querySelectorAll('.ovfd-list b b').length, posts: [...document.querySelectorAll('.ovfd-k-post .ovfd-title')].map((b) => b.textContent)[0],
sections: [...document.querySelectorAll('.ovs-group')].map((g) => g.textContent.trim()).filter(Boolean), scroll: document.documentElement.scrollWidth })`);
-- assert.deepStrictEqual(first.chips, ['For you', 'Live', 'Videos', 'Clips', 'Pics', 'Posts', 'Memes', 'Games', 'Chat', 'Events', 'Wiki', 'Quests', 'Updates'], 'signed out: no Following chip');
++ assert.deepStrictEqual(first.chips, ['For you', 'Live', 'Videos', 'Clips', 'Pics', 'Posts', 'Rundown', 'Memes', 'Games', 'Chat', 'Events', 'Jobs', 'Deals', 'Stats', 'Wiki', 'Quests', 'Updates'], 'signed out: no Following chip');
assert.strictEqual(first.selected, 'For you');
assert.ok(first.kinds.some((c) => /\bovfd-k-live\b/.test(c)) && first.kinds.some((c) => /\bovfd-act\b/.test(c) && /\bovfd-k-update\b/.test(c)) && first.kinds.some((c) => /\bovfd-k-pic\b/.test(c)), `cards by kind (${first.kinds})`);
assert.ok(first.live, 'a channel live now says LIVE (live_now)');
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 6222d8e..bf02d6a 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
--const NAVBAR_BROTLI_BUDGET = 50 * 1024; // 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
++const NAVBAR_BROTLI_BUDGET = 50.5 * 1024; // 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex 0111ef9..b75cfb2 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -245,7 +245,7 @@ function load(hostname, opts = {}) {
{
const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');
assert.ok(src.includes('<span class="ud-label">Across OpenVibe</span>') && src.includes('data-open-launcher>All sites</button>'), 'a header with All sites');
-- assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" data-ov-net-link href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');
++ assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" data-ov-net-link href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-appicon" data-icon=/.test(src), 'each site with its app icon (design rule 142)');
assert.ok(src.includes('.ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');
assert.strictEqual((src.match(/function loadIcons\(/g) || []).length, 1, 'one loadIcons (the nav glyphs); the ov-icons loader has its own name');
assert.ok(/function queueIcons\(\)[\s\S]*?requestIdleCallback\(go/.test(src), 'ov-icons.js loads when the page settles');
ddiff --git a/test/ov-appicon-chrome.test.js b/test/ov-appicon-chrome.test.js
iindex 3f3f1e6..3424126 100644
---- a/test/ov-appicon-chrome.test.js
++++ b/test/ov-appicon-chrome.test.js
@@@ -13,10 +13,28 @@ const icons = require('../ov-icons');
assert.equal(typeof icons.app, 'function', 'OpenVibeIcons.app is exported');
assert.equal(icons.hue('live'), (() => { let x = 7; for (const c of 'live') x = (x * 31 + c.charCodeAt(0)) % 360; return x; })(), 'the same hue as the menu and Home');
assert.match(icons.CSS, /\.ov-appicon\{/, 'its look ships with the icons');
++assert.ok(icons.CSS.startsWith(icons.APP_CSS) && /\.ov-appicon\[data-size="44"\]\{--ova-size:44px\}/.test(icons.APP_CSS), 'APP_CSS is the app icon alone, sizes included');
++{
++ const m = icons.appHtml('live', { size: 44 });
++ assert.match(m, /^<span class="ov-appicon" data-icon="live" data-ova="live" data-size="44" style="--ova-h:\d+;--ova-size:44px" aria-hidden="true"><span class="ov-icon" data-icon="live" data-ovi="live"/, 'appHtml: filled, sized and hued inline, decoration');
++ assert.ok(m.includes(`--ova-h:${icons.hue('live')};`), 'appHtml: the same hue as the menu');
++ const l = icons.appHtml('tools', { label: 'A <b> & "c"' });
++ assert.ok(l.includes('aria-label="A &lt;b&gt; &amp; &quot;c&quot;"') && !l.includes('<b>'), 'appHtml: a label is escaped');
++ assert.match(icons.appHtml('x', { size: 9999, hue: -30 }), /data-size="256" data-hue="30" style="--ova-h:30;--ova-size:256px"/, 'appHtml: size clamped, a given hue kept');
++ const nav = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');
++ for (const n of [20, 26, 32, 44, 56, 64]) assert.ok(icons.APP_CSS.includes(`[data-size="${n}"]{--ova-size:${n}px}`) && nav.includes(`.ov-appicon[data-size="${n}"] { --ova-size: ${n}px; }`), `size ${n}: the navbar holds the square before ov-icons.js, at the same size`);
++ assert.ok(/class="ovnav-net"[^`]*<span class="ov-appicon" data-icon=/.test(nav), 'the menu\'s "Across OpenVibe" sites are app icons');
++ assert.ok(/'ovq-site-icon ov-appicon'/.test(fs.readFileSync(path.join(__dirname, '..', 'quest.js'), 'utf8')), 'a quest step\'s site is its app icon');
++}
if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-appicon-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
const root = path.join(__dirname, '..');
const server = http.createServer((req, res) => {
const u = req.url.split('?')[0];
++ if (u === '/ssr') {
++ // 4.227.0: drawn on the server: APP_CSS in the page's stylesheet, appHtml() markup, no script at all.
++ res.writeHead(200, { 'content-type': 'text/html' });
++ return res.end(`<!doctype html><meta charset="utf-8"><style>${icons.APP_CSS}.ov-icon svg{width:100%;height:100%}</style><body style="margin:0;padding:16px;background:#0b1220">${icons.appHtml('live', { size: 44 })}${icons.appHtml('tools', { label: 'Tools & "more"' })}${icons.appHtml('chat', { size: 26, hue: 30 })}`);
++ }
if (u === '/ov-icons.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-icons.js'))); }
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"><body style="margin:0;padding:16px;background:#0b1220">
@@@ -51,13 +69,26 @@ const server = http.createServer((req, res) => {
await page.evaluate(`document.getElementById('late').setAttribute('data-icon', 'video'), true`);
await wait(80);
assert.equal((await look('#late')).hue, String(icons.hue('video')), 'a new app, a new hue');
++ // Drawn on the server: whole with no script, and ov-icons.js arriving later changes nothing.
++ const ssr = await h.openPage(browser, await browser.newContext(), { width: 390, height: 700 });
++ await ssr.goto(`http://127.0.0.1:${server.address().port}/ssr`, { maxMs: 5000 });
++ const box = (i) => ssr.evaluate(`(() => { const e = document.querySelectorAll('.ov-appicon')[${i}]; const r = e.getBoundingClientRect(); const cs = getComputedStyle(e); const ring = e.querySelector('.ovi-ring');
++ return { w: Math.round(r.width), h: Math.round(r.height), bg: cs.backgroundImage.startsWith('linear-gradient'), radius: cs.borderRadius, ring: ring ? getComputedStyle(ring).display : 'none', label: e.getAttribute('aria-label'), svg: e.querySelector('svg') }; })()`);
++ const s0 = await box(0);
++ assert.deepEqual([s0.w, s0.h, s0.bg, s0.radius, s0.ring], [44, 44, true, '30%', 'none'], 'server markup: its size, its look and no ring, with no script');
++ assert.deepEqual([(await box(1)).w, (await box(1)).label], [56, 'Tools & "more"'], 'server markup: the default size and its name');
++ assert.equal((await box(2)).w, 26);
++ await ssr.evaluate(`window.__svg = document.querySelector('.ov-appicon svg'); const sc = document.createElement('script'); sc.src = '/ov-icons.js'; document.head.append(sc); true`);
++ await h.eventually(async () => assert.equal(await ssr.evaluate(`!!(window.OpenVibeIcons && document.getElementById('ov-icons-css'))`), true, 'ov-icons.js arrived'));
++ assert.equal(await ssr.evaluate(`document.querySelector('.ov-appicon svg') === window.__svg && document.querySelectorAll('.ov-appicon svg').length === 3`), true, 'ov-icons.js finds them already filled and leaves them');
++ await ssr.close();
// A light theme draws the glyph dark.
const darkGlyph = a.glyph;
await page.evaluate(`document.documentElement.setAttribute('data-theme', 'light'), true`);
await wait(50);
assert.notEqual((await look('#a')).glyph, darkGlyph, 'the glyph changes with the theme');
await page.close();
-- console.log('ov-appicon-chrome: markup and script, size, hue, label, late markup, a changed app, the light theme');
++ console.log('ov-appicon-chrome: markup and script, size, hue, label, late markup, a changed app, server markup with no script, the light theme');
} finally {
await browser.close();
server.close();
ddiff --git a/test/quest.test.js b/test/quest.test.js
iindex 6424685..214b58b 100644
---- a/test/quest.test.js
++++ b/test/quest.test.js
@@@ -54,8 +54,8 @@ function host() {
assert.deepEqual(outSteps.map((li) => li.querySelector('.ovq-step span').textContent), ['Upload a picture', 'Send a public chat message', 'Go live for a stream']);
assert.deepEqual(outSteps.map((li) => li.querySelector('.ovq-step small').textContent), ['OpenVibe.Pics', 'OpenVibe.Chat', 'OpenVibe.Live']);
assert.equal(out.el.querySelectorAll('.is-next, .is-done').length, 0);
-- assert.equal(out.el.querySelectorAll('.ovq-steps .ovq-icon').length, 3);
-- assert.deepEqual([...out.el.querySelectorAll('.ovq-steps .ovq-icon')].map((i) => i.dataset.icon), ['pics', 'chat', 'live']);
++ assert.equal(out.el.querySelectorAll('.ovq-steps .ov-appicon').length, 3, 'each step wears its site\'s app icon (design rule 142)');
++ assert.deepEqual([...out.el.querySelectorAll('.ovq-steps .ov-appicon')].map((i) => [i.dataset.icon, i.dataset.size]), [['pics', '32'], ['chat', '32'], ['live', '32']]);
assert.equal(out.el.querySelector('.ovq-chip').hidden, true);
assert.equal(out.el.querySelector('.ovq-more-line, .ovq-more-text span').textContent, '24 quests on 8 sites, each with a badge');
assert.equal(out.el.querySelector('.ovq-more').href, 'https://openvibe.quest/');
ddiff --git a/test/shipped.test.js b/test/shipped.test.js
iindex 241323a..4444943 100644
---- a/test/shipped.test.js
++++ b/test/shipped.test.js
@@@ -35,6 +35,7 @@ const data = {
assert.strictEqual(node.querySelector('.ov-shipped-notes').getAttribute('href'), data.latest_post.url);
// 4.53.1: the patch-notes link under the list wraps on a narrow screen (a whole post title ran past 320 px).
assert.ok(require('fs').readFileSync(require.resolve('../shipped'), 'utf8').includes("'.ov-shipped-links .ov-shipped-notes{white-space:normal;min-width:0;overflow-wrap:anywhere}'"), 'the long notes link may wrap');
++ assert.ok(/`\.ov-shipped-n\{(?![^}]*opacity)[^}]*\}`/.test(require('fs').readFileSync(require.resolve('../shipped'), 'utf8')), 'a chip\'s count keeps the chip\'s full colour (axe color-contrast on /updates, 4.227.0)');
const one = document.getElementById('n');
shipped.render(one, data, { service: 'live', title: '' });
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 709e7d9..f4d4b75 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -128,6 +128,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
const net = sc.network({ service: 'live' });
assert.ok(net.includes('More on OpenVibe') && net.includes('data-ov-now') && net.includes('https://openvibe.network/sites') && net.includes('https://openre.stream/?ov_from=live') && net.includes('data-icon="openre"'), 'related products, with the referral tag and ring icons');
assert.ok(!net.includes('https://openvibe.live/'), 'never links a product to itself');
++ assert.ok(net.includes('<span class="ov-appicon" data-icon="openre" data-ova="openre" data-size="44"') && !net.includes('sc-ic-ov'), 'each related site is its app icon, whole on the server (design rule 142)');
assert.ok(sc.network({ service: 'unknown-thing' }).includes('OpenVibe.Live'), 'a product without its own list gets the core ones');
assert.ok(sc.network({ service: 'x', items: [['tools', 'Tools', 'https://openvibe.tools/'], ['bad', 'Bad', 'ftp://x']] }).includes('OpenVibe.Tools'));
for (const [svc, list] of Object.entries(sc.RELATED)) for (const [to, what, href] of list) assert.ok(to && what && /^https:\/\//.test(href) && to !== svc, `${svc} → ${to}`);
@@@ -180,6 +181,9 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
assert.ok(h.includes('<h3 class="sc-apps-group">Watch &lt;and&gt; stream</h3>') && !h.includes('>Empty<'), 'groups are escaped; an empty one is left out');
assert.ok(h.includes('<b>Live &lt;x&gt;</b><small>Go live from your browser</small>') && h.includes('href="https://openvibe.live/"'), 'a tile: icon, name, text');
assert.ok(!h.includes('javascript:') && !h.includes('>Bad<'), 'a tile with an unsafe link is dropped');
++ assert.ok(h.includes('<span class="ov-appicon" data-icon="live" data-ova="live" data-size="56"'), 'an app is its app icon (design rule 142), 56 px on a phone');
++ assert.ok(sc.CSS.includes('.sc-apps .ov-appicon{--ova-size:44px!important}'), 'and the row size from 600 px');
++ 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');
assert.ok(sc.apps({ items: [{ name: 'A', href: '/a' }] }).includes('<ul class="sc-apps">'), 'items without groups is one grid');