openvibers/openvibe.media Commits

Compose the media index on the shared shell with a hero and AI summary

OP OpenVibers committed 13 Oct 2026 · 126072c766 · 6 files, +117 −94

Asked: T11 step 1 — OpenVibe.Media: the last D92 home (browse onto the shared shell + showcase kit, ai-summary on every Media SSR page). ONE pull request from current origin/main (git fetch first; main is d9c46b4 or newer). The spec is file-by-file with code: /home/workstation/openvibe/agents/ds/prep/T11.…
Harness job media-t11-step-1-openvibe-media-225641 (plan T11).

Changes

ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex 1d6c3be..659b8cb 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -4,7 +4,8 @@
* Server-rendered browse UI at GET / — a mass index of every public piece of
* media this service owns, tabbed by kind (Videos / Clips / Thumbnails / Files).
* Each card carries its description / AI overview and a link to its source page
-- * in the owning app. Dependency-free: inline CSS, no client JS required.
++ * in the owning app. Composed by page-frame (the shared shell) with the showcase hero on
++ * the first page; no client JS required.
*/
'use strict';
@@@ -17,9 +18,11 @@ const PAGE_SIZE = 48;
const THUMB_DIR = path.resolve(config.thumbnails.path);
// Public base URL per app for "source" links — shared with the watch pages.
--const { appUrl, DEFAULT_THEME_CSS } = require('./page-frame');
++const pageFrame = require('./page-frame');
++const { appUrl, isoDate } = pageFrame;
const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
++const showcase = require('openvibe-shared/showcase');
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
@@@ -190,14 +193,14 @@ async function tabCounts() {
const KIND_ICON = { video: '🎬', clip: '✂️', thumbnail: '🏞️', file: '📦' };
/** JSON-LD for the index's first page: the site, and the cards it lists (ItemList). Later pages are noindex. */
--function browseJsonLd(config, tab, cards) {
++function browseJsonLd(tab, cards) {
const seo = require('openvibe-shared/seo');
const abs = (u) => { try { return new URL(u, `${config.publicUrl}/`).toString(); } catch { return null; } };
const items = cards.map((c) => ({ name: c.title || 'Untitled', url: abs(c.view) })).filter((it) => it.url);
return [
-- tab === 'all' ? seo.jsonLdTag(seo.jsonLd.website({ name: 'OpenVibe.Media', url: `${config.publicUrl}/`, description: 'Every public file on the OpenVibe network: videos, clips, files and thumbnails.' })) : '',
-- items.length ? seo.jsonLdTag(seo.jsonLd.itemList(tab === 'all' ? 'Latest public media' : `Latest public ${tab}`, items, { url: `${config.publicUrl}/${tab === 'all' ? '' : `?tab=${tab}`}` })) : '',
-- ].filter(Boolean).join('\n');
++ tab === 'all' ? seo.jsonLd.website({ name: 'OpenVibe.Media', url: `${config.publicUrl}/`, description: 'Every public file on the OpenVibe network: videos, clips, files and thumbnails.' }) : null,
++ items.length ? seo.jsonLd.itemList(tab === 'all' ? 'Latest public media' : `Latest public ${tab}`, items, { url: `${config.publicUrl}/${tab === 'all' ? '' : `?tab=${tab}`}` }) : null,
++ ].filter(Boolean);
}
function renderCard(c) {
@@@ -221,68 +224,75 @@ function renderCard(c) {
const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
--function renderPage(tab, page, data, counts) {
-- const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
-- const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;
-- return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
--<meta name="viewport" content="width=device-width, initial-scale=1">
--<title>OpenVibe.Media — Media Index</title>
--<meta name="description" content="Index of all public media on the OpenVibe network — videos, clips, files and thumbnails.">
--<link rel="canonical" href="${config.publicUrl}/${tab === 'all' && page === 1 ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}">
--<meta name="robots" content="${page > 1 ? 'noindex, follow' : 'index, follow'}">
--<meta property="og:site_name" content="OpenVibe.Media">
--<meta property="og:type" content="website">
--<meta property="og:title" content="OpenVibe.Media — Media Index">
--<meta property="og:description" content="Every public file on the OpenVibe network — videos, clips, files and thumbnails.">
--<meta property="og:url" content="${config.publicUrl}/">
--<meta property="og:image" content="${config.publicUrl}/og-image.png">
--<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
--<meta name="twitter:card" content="summary_large_image">
--<meta name="twitter:title" content="OpenVibe.Media — Media Index">
--<meta name="twitter:image" content="${config.publicUrl}/og-image.png">
--${page === 1 ? browseJsonLd(config, tab, data.cards) : ''}
--<!-- The shared theme-loader applies the user's theme to <html> before anything paints. -->
--<script src="${require('openvibe-shared/serve').url('theme-loader.js')}" defer></script>
--<style>
--${DEFAULT_THEME_CSS ? `:root{${DEFAULT_THEME_CSS}}` : ''}
--:root{--bg:var(--bg-primary,#0a0f1c);--panel:var(--bg-card,#131c2e);--border:#1f2d47;--text:var(--text-primary,#e6edf7);--muted:var(--text-secondary,#96a7c2);--accent-page:var(--accent,#3b82f6)}
--*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}
--header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:1rem 1.4rem .4rem}
++// The index's own rules; page-frame's baseCss supplies the palette (--panel, --line, --muted, --acc …) and the reset.
++// Card rules are scoped to .card: baseCss styles .meta and .desc for the watch pages.
++const BROWSE_CSS = `
++header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:0 0 .4rem}
header.intro h1{font-size:1.15rem;margin:0}
header.intro .sub{color:var(--muted);font-size:.85rem}
--nav.tabs{display:flex;gap:.25rem;padding:.2rem 1.4rem .7rem;flex-wrap:wrap}
++nav.tabs{display:flex;gap:.25rem;padding:.2rem 0 .7rem;flex-wrap:wrap}
nav.tabs a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}
nav.tabs a:hover{color:var(--text)}nav.tabs a.on{color:var(--on-accent-strong,#fff);background:var(--accent-strong,#1d4ed8)}
nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{opacity:1}
--.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;padding:0 1.4rem}
--.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
--.thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}
--.thumb img{width:100%;height:100%;object-fit:cover;display:block}
--.thumb .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2.2rem;opacity:.5}
--.thumb .dur{position:absolute;right:.5rem;bottom:.5rem;background:rgba(0,0,0,.75);padding:.1rem .45rem;border-radius:6px;font-size:.75rem}
--.thumb .kind{position:absolute;left:.5rem;top:.5rem;background:rgba(var(--accent-rgb,59,130,246),.85);color:var(--on-accent,#fff);padding:.1rem .5rem;border-radius:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}
--.meta{padding:.7rem .8rem;display:flex;flex-direction:column;gap:.35rem;flex:1}
--.title{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
--.desc{color:var(--muted);font-size:.8rem;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
--.foot{margin-top:auto;display:flex;justify-content:space-between;gap:.6rem;color:var(--muted);font-size:.74rem;flex-wrap:wrap}
--.foot a{color:var(--accent-page);text-decoration:none}.foot a:hover{text-decoration:underline}
++.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem}
++.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
++.card .thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}
++.card .thumb img{width:100%;height:100%;object-fit:cover;display:block}
++.card .thumb .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2.2rem;opacity:.5}
++.card .thumb .dur{position:absolute;right:.5rem;bottom:.5rem;background:rgba(0,0,0,.75);color:#fff;padding:.1rem .45rem;border-radius:6px;font-size:.75rem}
++.card .thumb .kind{position:absolute;left:.5rem;top:.5rem;background:rgba(var(--accent-rgb,59,130,246),.85);color:var(--on-accent,#fff);padding:.1rem .5rem;border-radius:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}
++.card .meta{margin:0;padding:.7rem .8rem;display:flex;flex-direction:column;flex-wrap:nowrap;align-items:stretch;gap:.35rem;flex:1;color:var(--text);font-size:inherit;line-height:1.5}
++.card .title{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
++.card .desc{color:var(--muted);font-size:.8rem;white-space:normal;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
++.card .foot{margin-top:auto;display:flex;justify-content:space-between;gap:.6rem;color:var(--muted);font-size:.74rem;flex-wrap:wrap}
++.card .foot a{color:var(--acc);text-decoration:none}.card .foot a:hover{text-decoration:underline}
.pager{display:flex;gap:.5rem;justify-content:center;align-items:center;padding:1.4rem;color:var(--muted);font-size:.88rem}
--.pg{color:var(--accent-page);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--border);border-radius:8px}
++.pg{color:var(--acc);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--line);border-radius:8px}
.pg.dis{color:var(--muted);opacity:.4}
.empty{padding:3rem;text-align:center;color:var(--muted)}
--</style>
--<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
--${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}
--</head><body>
--<div id="navbar-mount"></div>${frame.noscriptNav({ name: 'OpenVibe.Media' })}
--<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files &amp; thumbnails from all OpenVibe sites</span></header>
++`;
++
++const DESCRIPTION = 'Index of all public media on the OpenVibe network — videos, clips, files and thumbnails.';
++
++function renderPage(tab, page, data, counts) {
++ const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
++ const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;
++ const first = tab === 'all' && page === 1;
++ const canonical = `${config.publicUrl}/${first ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}`;
++ // The one <h1> on the page: the showcase hero on the index's front page, the plain intro elsewhere.
++ const heading = first ? showcase.hero({
++ eyebrow: 'OpenVibe.Media',
++ title: 'Every public file on the',
++ accent: 'OpenVibe network',
++ lede: 'Videos, clips, emotes, sounds, files and thumbnails from every OpenVibe site, in one index. Watch and download public media without an account.',
++ actions: [
++ { label: 'Browse videos', href: '/?tab=videos', primary: true },
++ { label: 'Watch on OpenVibe.Live', href: appUrl('live') + '/' },
++ ],
++ }) : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files &amp; thumbnails from all OpenVibe sites</span></header>';
++ const body = `${heading}
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
--${tab === 'all' && page === 1 ? `<div style="max-width:1200px;margin:0 auto;padding:0 1rem">${frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' })}</div>` : ''}
--${frame.footer({ service: 'media', variant: 'full', updates: '/updates' })}
--${require('./page-frame').frameScripts({ footer: { variant: 'full' } })}
--</body></html>`;
++${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;
++ return pageFrame.page({
++ seo: {
++ title: 'OpenVibe.Media — Media Index', description: DESCRIPTION, canonical,
++ robots: page > 1 ? 'noindex, follow' : 'index, follow', ogType: 'website',
++ jsonLd: page === 1 ? browseJsonLd(tab, data.cards) : [],
++ },
++ css: BROWSE_CSS,
++ body,
++ // Later pages are noindex and carry no structured data, the summary's WebPage included.
++ summary: page === 1 ? {
++ summary: 'Index of all public media on the OpenVibe network: videos, clips, emotes, sounds, files and thumbnails, browsable and playable without an account.',
++ url: canonical,
++ facts: [['Items', data.total.toLocaleString('en-US')]],
++ updated: isoDate(data.cards[0] && data.cards[0].date) || undefined,
++ } : undefined,
++ history: { type: 'page', title: 'OpenVibe.Media' },
++ footer: { variant: 'full' },
++ });
}
async function handle(req, res) {
ddiff --git a/server/public/page-frame.js b/server/public/page-frame.js
iindex 03418cc..17890ed 100644
---- a/server/public/page-frame.js
++++ b/server/public/page-frame.js
@@@ -82,6 +82,8 @@ function extraHead(seo, css) {
].filter(Boolean).join('\n') : '';
return [
appIcon.headTags({ site: 'media', iconBase: '/assets' }),
++ // openvibe-shared/showcase's stylesheet (the index's hero; the kit's sections on any page).
++ `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">`,
`<style>${baseCss()}${css}</style>`,
videoTags,
// The default share card is 1200×630; a media thumbnail carries its own dimensions.
@@@ -158,31 +160,6 @@ function footerBootScript(footer) {
return `<script>window.addEventListener('DOMContentLoaded', function () { try { OpenVibeFooter.init(${jsonForScript(footerOptions(footer))}); } catch (e) { /* footer optional */ } });</script>`;
}
--/**
-- * The scripts that mount the navbar + footer, after the page content so a
-- * slow Network never blocks the body. Used by the hand-written browse index
-- * (server/public/browse.js); the shell pages get the same navbar options through
-- * shell.page() plus footerBootScript() (the shell does not initialise the footer),
-- * and the footer is rendered by frame.footer.
-- */
--function frameScripts({ history, footer } = {}) {
-- const navOpts = navOptions({ history });
-- const footOpts = footerOptions(footer);
-- return `<script src="${ovServe.url('navbar.js')}" defer></script>
--<script src="${ovServe.url('footer.js')}" defer></script>
--<script>
--(function () {
-- var tries = 0;
-- function boot() {
-- if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }
-- try { OpenVibeNavbar.init(${jsonForScript(navOpts)}); } catch (e) { /* navbar optional */ }
-- try { OpenVibeFooter.init(${jsonForScript(footOpts)}); } catch (e) { /* footer optional */ }
-- }
-- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
--})();
--</script>`;
--}
--
function defaultFooterLinks() {
return [
{
@@@ -204,9 +181,11 @@ function defaultFooterLinks() {
* `body` is the <main> contents. The shell emits the theme-loader/web-runtime/navbar/footer scripts,
* the no-JavaScript nav and the shared footer; `history` and `footer` become the navbar init options
* and the server-rendered footer. The site's app icon, palette and Open Graph video tags ride in
-- * through the shell's `head`.
++ * through the shell's `head`. `summary` is the page's AI summary (openvibe-shared/seo pageSummary): a
++ * string, or { summary, url, facts: [[label, value]], updated }; the url defaults to the canonical.
*/
--function page({ seo, css = '', body, history, footer }) {
++function page({ seo, css = '', body, history, footer, summary }) {
++ const sum = summary == null ? null : typeof summary === 'object' ? summary : { summary };
const html = shell.page({
name: SITE_NAME,
lang: 'en',
@@@ -218,8 +197,9 @@ function page({ seo, css = '', body, history, footer }) {
type: seo.ogType,
robots: seo.robots || 'index, follow',
jsonLd: seo.jsonLd,
++ ...(sum ? { summary: sum.summary, facts: sum.facts, url: sum.url, updated: sum.updated } : {}),
head: extraHead(seo, css) + footerBootScript(footer),
-- body: `<div id="navbar-mount"></div>\n<main>\n${body}\n</main>`,
++ body: `<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,
navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],
navbar: navOptions({ history }),
footer: footerOptions(footer),
@@@ -236,5 +216,5 @@ function page({ seo, css = '', body, history, footer }) {
module.exports = {
NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,
esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,
-- extraHead, baseCss, frameScripts, navOptions, footerOptions, defaultFooterLinks, page, DEFAULT_THEME_CSS,
++ extraHead, baseCss, navOptions, footerOptions, defaultFooterLinks, page, DEFAULT_THEME_CSS,
};
ddiff --git a/server/public/pages.js b/server/public/pages.js
iindex cf2862c..0dcb354 100644
---- a/server/public/pages.js
++++ b/server/public/pages.js
@@@ -173,16 +173,25 @@ function renderWatchPage(kind, record, readiness = null) {
.player .state-detail { margin: 0; color: var(--muted); }
.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;
++ // Structured data and the AI summary describe public items only; unlisted and private pages carry none.
++ const described = playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public';
return pageFrame.page({
seo: {
title: `${title} — ${SITE_NAME}`, description, canonical, robots,
image: thumb || undefined, ogType: playable ? 'video.other' : 'website',
// No playable bytes: no video URL for previews and no VideoObject that names them.
video: playable ? { url: rawUrl, type: mime } : undefined,
-- // Structured data describes public items only; unlisted and private pages carry none.
-- jsonLd: playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public' ? [videoObject] : [],
++ jsonLd: described ? [videoObject] : [],
},
css, body,
++ summary: described ? {
++ summary: description, url: canonical,
++ facts: [
++ ['Duration', record.duration_seconds ? fmtDuration(record.duration_seconds) : null],
++ ['Views', record.view_count != null ? Number(record.view_count).toLocaleString('en-US') : null],
++ ].filter(([, v]) => v != null),
++ updated: uploadDate || undefined,
++ } : undefined,
history: { type: isVod ? 'vod' : 'clip', title },
footer: { variant: 'compact' },
});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex c364024..f2af071 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -18,10 +18,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
jsFiles: 4, // 3
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
// openvibe.network, so it is counted here; the page itself did not grow.
-- jsRawKB: 215, // 197.7
-- jsBrotliKB: 50, // 45.6
-- cssFiles: 2, // 1 (Font Awesome, cross-origin)
-- cssRawKB: 10, // 0 same-origin
++ // 2026-10-13 (T11): the index is composed by openvibe-shared/shell like the watch pages, which adds the
++ // shared web-runtime.js; the showcase stylesheet (same-origin) replaces Font Awesome (cross-origin).
++ jsRawKB: 240, // 230.9 (was 197.7)
++ jsBrotliKB: 56, // 53.7 (was 45.6)
++ cssFiles: 2, // 1 (showcase.css)
++ cssRawKB: 13, // 11.6 same-origin (showcase.css; was 0)
externalFiles: 3, // 2
};
ddiff --git a/test/public-pages.test.js b/test/public-pages.test.js
iindex ae3b358..99d9d35 100644
---- a/test/public-pages.test.js
++++ b/test/public-pages.test.js
@@@ -67,6 +67,11 @@ const http = require('http');
assert.ok(other.includes('<meta name="robots" content="index, follow">'));
const unlisted = pages.renderWatchPage('clip', { ...vod, id: 8, app_id: 'games', visibility: 'unlisted' });
assert.ok(unlisted.includes('content="noindex, follow"'), 'unlisted never indexes');
++ // The AI summary (openvibe-shared/seo pageSummary) on public watch pages: description, canonical, facts.
++ assert.ok(html.includes('<meta name="ai-summary" content="We built a thing &amp; it worked.">'), 'the watch page carries the AI summary');
++ assert.ok(html.includes('<li>Duration: 1:02:05</li>') && html.includes('<li>Views: 1,234</li>'), 'with its facts');
++ assert.ok(html.includes('"@type":"WebPage"') && html.includes('"url":"https://openvibe.live/vod/42"'), 'its WebPage names the canonical');
++ assert.ok(!unlisted.includes('name="ai-summary"'), 'unlisted pages carry none');
// Paste pages are Community's: there is no viewer here (public/routes.js only redirects).
assert.ok(!/\bfree\b/i.test(html + pageFrame.baseCss()), 'no cost claims in served HTML');
@@@ -90,6 +95,7 @@ const http = require('http');
assert.strictEqual(r.status, 200);
assert.ok(r.headers['content-type'].startsWith('text/html'));
assert.ok(r.body.includes('Router VOD') && r.body.includes('id="navbar-mount"'));
++ assert.ok(r.body.includes('<main id="main">'));
// No Content-Security-Policy on a page response today: the shell composes inline scripts
// (theme boot, navbar/runtime boot, footer init), so adding a policy later must allow them.
assert.ok(!('content-security-policy' in r.headers), 'watch pages carry no CSP header');
@@@ -107,8 +113,24 @@ const http = require('http');
assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the index names the site');
const list = ld.find((x) => x['@type'] === 'ItemList');
assert.ok(list && list.itemListElement.length >= 1 && list.itemListElement.every((it) => /^https?:\/\//.test(it.url)), 'the listed cards, with absolute URLs');
++ const h1s = (html) => (html.match(/<h1[\s>]/g) || []).length;
++ assert.ok(r.body.includes('class="sc-hero'), 'the index opens with the showcase hero');
++ assert.ok(/\/shared\/showcase\.css\?v=[0-9a-f]{12}/.test(r.body), 'the kit stylesheet, through ovServe');
++ assert.ok(r.body.includes('id="navbar-mount"') && r.body.includes('<main id="main">'));
++ assert.ok(r.body.includes('<meta name="ai-summary"'), 'the index carries the AI summary');
++ assert.ok(ld.some((x) => x['@type'] === 'WebPage'), 'the summary\'s WebPage');
++ assert.strictEqual(h1s(r.body), 1, 'exactly one <h1>');
++ assert.strictEqual((r.body.match(/<!doctype/gi) || []).length, 1, 'one document');
++ for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(r.body.includes(`href="/?tab=${k}"`), `tab ${k}`);
++ assert.ok(r.body.includes('class="pager"') && r.body.includes('Page 1 /'), 'the pager');
++ assert.ok(r.body.includes('<meta name="robots" content="index, follow">'));
++ r = await get('/?tab=videos', nav);
++ assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'the hero is the front page\'s only');
r = await get('/?page=2', nav);
assert.ok(!r.body.includes('application/ld+json'), 'a later (noindex) page carries none');
++ assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'no hero after page 1');
++ assert.ok(r.body.includes('<meta name="robots" content="noindex, follow">'));
++ assert.ok(r.body.includes('Page 2 /') && r.body.includes('href="/?tab=all&page=1"'), 'the pager links back');
r = await get('/og-image.png', {});
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers['content-type'], 'image/png');
ddiff --git a/test/theme-default.test.js b/test/theme-default.test.js
iindex f1a4767..cda750d 100644
---- a/test/theme-default.test.js
++++ b/test/theme-default.test.js
@@@ -13,8 +13,8 @@ const assert = require('assert');
assert.ok(css.includes(`${k}:${DEFAULT_VARS[k]}`), `the page defines ${k} as the default theme does`);
}
assert.ok(!/url\(|;\s*}/.test(css.split('\n').find((l) => l.includes('--accent:')) || ''), 'only plain token values');
-- // The index page (browse.js) writes its own <style>: it carries the same tokens.
++ // The index page (browse.js) is composed by page-frame too, so it carries the same tokens.
const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'public', 'browse.js'), 'utf8');
-- assert.ok(/<style>\n\$\{DEFAULT_THEME_CSS \?/.test(src), 'browse.js puts the default tokens first in its style');
++ assert.ok(/pageFrame\.page\(/.test(src) && !/<!DOCTYPE/i.test(src), 'browse.js renders through page-frame, not its own document');
console.log('theme default: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });