openvibers/openvibe.news Commits
The News home opens with what OpenVibe.News is for (plan T11, openvibe-shared/showcase) (#9)
Changes
@@@ -2,6 +2,7 @@## Unreleased++- The home page opens with what OpenVibe.News is for (plan T11, `openvibe-shared/showcase`, as Blog's home does): a hero (News you can check; Read the stories, Browse topics) and the four things a story really has today (cited paragraphs, revisions on the record, source checks, Community discussion). Page 1 only: later pages and topic pages are unchanged. The hero is the page's one h1, so the list header there is "Latest stories"; `showcase.css` is linked only on page 1. Home budgets raised as a decision (HTML 27 KB / 7.5 KB brotli, CSS 17.5 KB / 4.5 KB brotli).- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli.- The `openvibe-publishing` v1.2.0 lock entry carries its sha512 integrity again (the bump had dropped it), and a test fails if any codeload pin loses its hash.- `openvibe-publishing` v1.3.0 (from v1.2.0): `layout.renderDocument` now forwards `summary`, `facts`, `updated` and `url` to the shell's AI-summary block, so the home page carries the same one-line summary as `<meta name="ai-summary">` and a WebPage JSON-LD twin.@@@ -20,6 +20,7 @@const express = require('express');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');++const showcase = require('openvibe-shared/showcase');const rateLimit = require('express-rate-limit');const seo = require('openvibe-publishing/seo');const ssr = require('openvibe-publishing/ssr');@@@ -68,6 +69,24 @@ function createPublicRoutes(ctx) {const FEEDS = [{ type: 'rss', label: 'RSS', file: 'feed.xml' }, { type: 'atom', label: 'Atom', file: 'atom.xml' }, { type: 'json', label: 'JSON Feed', file: 'feed.json' }];const feedsFor = (base) => FEEDS.map((f) => ({ type: f.type, label: f.label, href: `${base === '/' ? '' : base}/${f.file}`, title: f.label }));++ /** The product's own words above the front page's stories: only what OpenVibe.News does today. */++ function newsShowcase() {++ return showcase.hero({++ eyebrow: 'OpenVibe.News',++ title: 'News you can', accent: 'check',++ lede: 'Every paragraph links the reports it rests on. Corrections and retractions stay on the record, and anyone can read without an account.',++ actions: [{ label: 'Read the stories', href: '#stories', primary: true }, { label: 'Browse topics', href: '/topics' }],++ }) + showcase.features({++ title: 'How a story is built',++ items: [++ { icon: 'ov:news', title: 'Cited paragraphs', text: 'Each paragraph names the sources behind it, with outlet, authors and date.' },++ { icon: 'ov:history', title: 'Revisions on the record', text: 'Corrections and retractions are kept and shown, never quietly edited away.' },++ { icon: 'ov:search', title: 'Source checks', text: 'When a source changes or is removed upstream, the story says so while editors check it.' },++ { icon: 'ov:community', title: 'Discussion', text: 'Talk about a story on OpenVibe.Community with your OpenVibe account.' },++ ],++ });++ }++async function listing(req, res, { topic = null }) {const page = pageNumber(req);const path = topic ? publication.topicPath(topic) : '/';@@@ -88,7 +107,10 @@ function createPublicRoutes(ctx) {jsonLd: topic? [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))]: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.News', url: publication.abs('/') }],-- body: topic ? pages.topicPage({ topic, items, pager, feeds, breadcrumbs: crumbs }) : pages.home({ items, pager, topics: await topics.list(), feeds }),++ // The front page opens with what OpenVibe.News is for (openvibe-shared/showcase), then the stories.++ ...(!topic && pager.page === 1 ? { styles: [showcase.STYLESHEET] } : {}),++ body: topic ? pages.topicPage({ topic, items, pager, feeds, breadcrumbs: crumbs })++ : (pager.page === 1 ? newsShowcase() : '') + pages.home({ items, pager, topics: await topics.list(), feeds, showcase: pager.page === 1 }),}, { cacheable: true });}@@@ -42,7 +42,8 @@ function setRelease(id) { if (id) RELEASE = String(id); }* o: title, description, decision (required), canonical, type ('website'|'article'), image,* jsonLd [], feeds [{ type, href, title }], body (HTML), viewer, config, csrf,* published, modified, author, prev, next, bodyClass,-- * summary, facts, updated, url (the shell's ai-summary inputs; openvibe-publishing v1.3.0)++ * summary, facts, updated, url (the shell's ai-summary inputs; openvibe-publishing v1.3.0),++ * styles [openvibe-shared stylesheet names]*/function renderPage(o) {if (!o.decision) throw new TypeError('renderPage needs the gate decision');@@@ -95,6 +96,7 @@ function renderPage(o) {navLinks: NAV_LINKS,home: '/',css: asset('css/news.css'),++ styles: o.styles, // openvibe-shared stylesheet names (the home page's showcase.css)release: RELEASE,account,body: o.body,@@@ -33,13 +33,17 @@ function feedLinksHtml(feeds) {return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>`;}--function home({ items, pager, topics, feeds }) {-- return String(h`<header class="site-header">++// With the showcase above it (page 1), the showcase hero is the page's h1 and says what the site is,++// so the list header is a plain "Latest stories" h2; later pages keep the site's own h1 and lede.++function home({ items, pager, topics, feeds, showcase = false }) {++ return String(h`${showcase ? h`<header class="site-header"><h2>Latest stories</h2>++${raw(feedLinksHtml(feeds))}++</header>` : h`<header class="site-header"><h1>OpenVibe.News</h1><p class="lede">Source-backed stories. Every paragraph cites the reports it rests on; corrections and retractions stay on the record.</p>${raw(feedLinksHtml(feeds))}--</header>--<section aria-label="Stories">${storyList(items, 'No stories have been published yet. OpenVibe.News publishes only what an editor has written from its sources.')}</section>++</header>`}++<section id="stories" aria-label="Stories">${storyList(items, 'No stories have been published yet. OpenVibe.News publishes only what an editor has written from its sources.')}</section>${raw(ssr.paginationHtml(pager))}${topics.length ? h`<section class="side" aria-label="Topics"><h2>Topics</h2><ul class="topic-list">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a></li>`)}</ul></section>` : ''}`);}@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 22, // measured 17.4 (fresh database)-- htmlBrotliKB: 5.5, // 4.5++ htmlRawKB: 27, // measured 24.4 with the home showcase (17.4 before it; fresh database): raised as a decision, as Blog did++ htmlBrotliKB: 7.5, // 6.3 (4.5 before the showcase)jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)jsRawKB: 245, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)-- cssFiles: 2, // 1-- cssRawKB: 6.5, // 5.2-- cssBrotliKB: 2, // 1.4++ cssFiles: 2, // 2 (news.css + showcase.css, home page 1 only)++ cssRawKB: 17.5, // 15.5 (5.2 before showcase.css)++ cssBrotliKB: 4.5, // 3.9 (1.4 before showcase.css)externalFiles: 1, // 0};@@@ -141,8 +141,16 @@ const BODY = [await check('home, topic page, feeds and sitemap list the published story; its text reaches feeds, source summaries do not', async () => {const home = await t.get('/');assert.match(home.text, /Europa Clipper launches for Jupiter/);++ // Page 1 opens with the showcase (openvibe-shared/showcase): its hero is the one h1, its stylesheet is linked,++ // and the stories still follow under "Latest stories".++ assert.ok(home.text.includes('class="sc-hero'), 'the home page opens with the showcase hero');++ assert.match(home.text, /<link rel="stylesheet" href="[^"]*showcase\.css[^"]*">/);++ assert.strictEqual((home.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on the home page');++ assert.ok(home.text.indexOf('class="sc-hero') < home.text.indexOf('Europa Clipper launches for Jupiter'), 'the showcase sits above the stories');++ assert.match(home.text, /<h2>Latest stories<\/h2>/);const topic = await t.get('/topics/space');assert.match(topic.text, /Europa Clipper launches for Jupiter/);++ assert.ok(!topic.text.includes('sc-hero') && !topic.text.includes('showcase.css'), 'a topic page has no showcase');const rss = await t.get('/feed.xml');assert.match(rss.text, /<guid isPermaLink="false">tag:openvibe.news,2026:story\/sty_/);assert.doesNotMatch(rss.text, /spacecraft lifted off/, 'a source summary is never fed');
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
## Unreleased
## Unreleased
+- The home page opens with what OpenVibe.News is for (plan T11, `openvibe-shared/showcase`, as Blog's home does): a hero (News you can check; Read the stories, Browse topics) and the four things a story really has today (cited paragraphs, revisions on the record, source checks, Community discussion). Page 1 only: later pages and topic pages are unchanged. The hero is the page's one h1, so the list header there is "Latest stories"; `showcase.css` is linked only on page 1. Home budgets raised as a decision (HTML 27 KB / 7.5 KB brotli, CSS 17.5 KB / 4.5 KB brotli).
- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli.
- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli.
- The `openvibe-publishing` v1.2.0 lock entry carries its sha512 integrity again (the bump had dropped it), and a test fails if any codeload pin loses its hash.
- The `openvibe-publishing` v1.2.0 lock entry carries its sha512 integrity again (the bump had dropped it), and a test fails if any codeload pin loses its hash.
- `openvibe-publishing` v1.3.0 (from v1.2.0): `layout.renderDocument` now forwards `summary`, `facts`, `updated` and `url` to the shell's AI-summary block, so the home page carries the same one-line summary as `<meta name="ai-summary">` and a WebPage JSON-LD twin.
- `openvibe-publishing` v1.3.0 (from v1.2.0): `layout.renderDocument` now forwards `summary`, `facts`, `updated` and `url` to the shell's AI-summary block, so the home page carries the same one-line summary as `<meta name="ai-summary">` and a WebPage JSON-LD twin.
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
const express = require('express');
const express = require('express');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const showcase = require('openvibe-shared/showcase');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
@@ -68,6 +69,24 @@ function createPublicRoutes(ctx) {
@@ -68,6 +69,24 @@ function createPublicRoutes(ctx) {
const FEEDS = [{ type: 'rss', label: 'RSS', file: 'feed.xml' }, { type: 'atom', label: 'Atom', file: 'atom.xml' }, { type: 'json', label: 'JSON Feed', file: 'feed.json' }];
const FEEDS = [{ type: 'rss', label: 'RSS', file: 'feed.xml' }, { type: 'atom', label: 'Atom', file: 'atom.xml' }, { type: 'json', label: 'JSON Feed', file: 'feed.json' }];
const feedsFor = (base) => FEEDS.map((f) => ({ type: f.type, label: f.label, href: `${base === '/' ? '' : base}/${f.file}`, title: f.label }));
const feedsFor = (base) => FEEDS.map((f) => ({ type: f.type, label: f.label, href: `${base === '/' ? '' : base}/${f.file}`, title: f.label }));
+ /** The product's own words above the front page's stories: only what OpenVibe.News does today. */
+ function newsShowcase() {
+ return showcase.hero({
+ eyebrow: 'OpenVibe.News',
+ title: 'News you can', accent: 'check',
+ lede: 'Every paragraph links the reports it rests on. Corrections and retractions stay on the record, and anyone can read without an account.',
+ actions: [{ label: 'Read the stories', href: '#stories', primary: true }, { label: 'Browse topics', href: '/topics' }],
+ }) + showcase.features({
+ title: 'How a story is built',
+ items: [
+ { icon: 'ov:news', title: 'Cited paragraphs', text: 'Each paragraph names the sources behind it, with outlet, authors and date.' },
+ { icon: 'ov:history', title: 'Revisions on the record', text: 'Corrections and retractions are kept and shown, never quietly edited away.' },
+ { icon: 'ov:search', title: 'Source checks', text: 'When a source changes or is removed upstream, the story says so while editors check it.' },
+ { icon: 'ov:community', title: 'Discussion', text: 'Talk about a story on OpenVibe.Community with your OpenVibe account.' },
+ ],
+ });
+ }
+
async function listing(req, res, { topic = null }) {
async function listing(req, res, { topic = null }) {
const page = pageNumber(req);
const page = pageNumber(req);
const path = topic ? publication.topicPath(topic) : '/';
const path = topic ? publication.topicPath(topic) : '/';
@@ -88,7 +107,10 @@ function createPublicRoutes(ctx) {
@@ -88,7 +107,10 @@ function createPublicRoutes(ctx) {
jsonLd: topic
jsonLd: topic
? [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))]
? [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))]
: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.News', url: publication.abs('/') }],
: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.News', url: publication.abs('/') }],
- body: topic ? pages.topicPage({ topic, items, pager, feeds, breadcrumbs: crumbs }) : pages.home({ items, pager, topics: await topics.list(), feeds }),
+ // The front page opens with what OpenVibe.News is for (openvibe-shared/showcase), then the stories.
+ ...(!topic && pager.page === 1 ? { styles: [showcase.STYLESHEET] } : {}),
+ body: topic ? pages.topicPage({ topic, items, pager, feeds, breadcrumbs: crumbs })
+ : (pager.page === 1 ? newsShowcase() : '') + pages.home({ items, pager, topics: await topics.list(), feeds, showcase: pager.page === 1 }),
}, { cacheable: true });
}, { cacheable: true });
}
}
@@ -42,7 +42,8 @@ function setRelease(id) { if (id) RELEASE = String(id); }
@@ -42,7 +42,8 @@ function setRelease(id) { if (id) RELEASE = String(id); }
* o: title, description, decision (required), canonical, type ('website'|'article'), image,
* o: title, description, decision (required), canonical, type ('website'|'article'), image,
* jsonLd [], feeds [{ type, href, title }], body (HTML), viewer, config, csrf,
* jsonLd [], feeds [{ type, href, title }], body (HTML), viewer, config, csrf,
* published, modified, author, prev, next, bodyClass,
* published, modified, author, prev, next, bodyClass,
- * summary, facts, updated, url (the shell's ai-summary inputs; openvibe-publishing v1.3.0)
+ * summary, facts, updated, url (the shell's ai-summary inputs; openvibe-publishing v1.3.0),
+ * styles [openvibe-shared stylesheet names]
*/
*/
function renderPage(o) {
function renderPage(o) {
if (!o.decision) throw new TypeError('renderPage needs the gate decision');
if (!o.decision) throw new TypeError('renderPage needs the gate decision');
@@ -95,6 +96,7 @@ function renderPage(o) {
@@ -95,6 +96,7 @@ function renderPage(o) {
navLinks: NAV_LINKS,
navLinks: NAV_LINKS,
home: '/',
home: '/',
css: asset('css/news.css'),
css: asset('css/news.css'),
+ styles: o.styles, // openvibe-shared stylesheet names (the home page's showcase.css)
release: RELEASE,
release: RELEASE,
account,
account,
body: o.body,
body: o.body,
@@ -33,13 +33,17 @@ function feedLinksHtml(feeds) {
@@ -33,13 +33,17 @@ function feedLinksHtml(feeds) {
return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>`;
return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}">${f.label}</a> `)}</p>`;
}
}
-function home({ items, pager, topics, feeds }) {
- return String(h`<header class="site-header">
+// With the showcase above it (page 1), the showcase hero is the page's h1 and says what the site is,
+// so the list header is a plain "Latest stories" h2; later pages keep the site's own h1 and lede.
+function home({ items, pager, topics, feeds, showcase = false }) {
+ return String(h`${showcase ? h`<header class="site-header"><h2>Latest stories</h2>
+${raw(feedLinksHtml(feeds))}
+</header>` : h`<header class="site-header">
<h1>OpenVibe.News</h1>
<h1>OpenVibe.News</h1>
<p class="lede">Source-backed stories. Every paragraph cites the reports it rests on; corrections and retractions stay on the record.</p>
<p class="lede">Source-backed stories. Every paragraph cites the reports it rests on; corrections and retractions stay on the record.</p>
${raw(feedLinksHtml(feeds))}
${raw(feedLinksHtml(feeds))}
-</header>
-<section aria-label="Stories">${storyList(items, 'No stories have been published yet. OpenVibe.News publishes only what an editor has written from its sources.')}</section>
+</header>`}
+<section id="stories" aria-label="Stories">${storyList(items, 'No stories have been published yet. OpenVibe.News publishes only what an editor has written from its sources.')}</section>
${raw(ssr.paginationHtml(pager))}
${raw(ssr.paginationHtml(pager))}
${topics.length ? h`<section class="side" aria-label="Topics"><h2>Topics</h2><ul class="topic-list">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a></li>`)}</ul></section>` : ''}`);
${topics.length ? h`<section class="side" aria-label="Topics"><h2>Topics</h2><ul class="topic-list">${topics.map((t) => h`<li><a href="/topics/${t.slug}">${t.name}</a></li>`)}</ul></section>` : ''}`);
}
}
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 22, // measured 17.4 (fresh database)
- htmlBrotliKB: 5.5, // 4.5
+ htmlRawKB: 27, // measured 24.4 with the home showcase (17.4 before it; fresh database): raised as a decision, as Blog did
+ htmlBrotliKB: 7.5, // 6.3 (4.5 before the showcase)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 245, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 245, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
- cssFiles: 2, // 1
- cssRawKB: 6.5, // 5.2
- cssBrotliKB: 2, // 1.4
+ cssFiles: 2, // 2 (news.css + showcase.css, home page 1 only)
+ cssRawKB: 17.5, // 15.5 (5.2 before showcase.css)
+ cssBrotliKB: 4.5, // 3.9 (1.4 before showcase.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};
@@ -141,8 +141,16 @@ const BODY = [
@@ -141,8 +141,16 @@ const BODY = [
await check('home, topic page, feeds and sitemap list the published story; its text reaches feeds, source summaries do not', async () => {
await check('home, topic page, feeds and sitemap list the published story; its text reaches feeds, source summaries do not', async () => {
const home = await t.get('/');
const home = await t.get('/');
assert.match(home.text, /Europa Clipper launches for Jupiter/);
assert.match(home.text, /Europa Clipper launches for Jupiter/);
+ // Page 1 opens with the showcase (openvibe-shared/showcase): its hero is the one h1, its stylesheet is linked,
+ // and the stories still follow under "Latest stories".
+ assert.ok(home.text.includes('class="sc-hero'), 'the home page opens with the showcase hero');
+ assert.match(home.text, /<link rel="stylesheet" href="[^"]*showcase\.css[^"]*">/);
+ assert.strictEqual((home.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on the home page');
+ assert.ok(home.text.indexOf('class="sc-hero') < home.text.indexOf('Europa Clipper launches for Jupiter'), 'the showcase sits above the stories');
+ assert.match(home.text, /<h2>Latest stories<\/h2>/);
const topic = await t.get('/topics/space');
const topic = await t.get('/topics/space');
assert.match(topic.text, /Europa Clipper launches for Jupiter/);
assert.match(topic.text, /Europa Clipper launches for Jupiter/);
+ assert.ok(!topic.text.includes('sc-hero') && !topic.text.includes('showcase.css'), 'a topic page has no showcase');
const rss = await t.get('/feed.xml');
const rss = await t.get('/feed.xml');
assert.match(rss.text, /<guid isPermaLink="false">tag:openvibe.news,2026:story\/sty_/);
assert.match(rss.text, /<guid isPermaLink="false">tag:openvibe.news,2026:story\/sty_/);
assert.doesNotMatch(rss.text, /spacecraft lifted off/, 'a source summary is never fed');
assert.doesNotMatch(rss.text, /spacecraft lifted off/, 'a source summary is never fed');