openvibers/openvibe.news Commits

The News home opens with what OpenVibe.News is for (plan T11, openvibe-shared/showcase) (#9)

OP OpenVibers committed 5 Oct 2026 · a89a510fc4 · 6 files, +48 −11

Page 1 of / now starts with the showcase kit, as Blog's home does: a hero
("News you can check": Read the stories, Browse topics) and four feature
cards for what a story really has today (cited paragraphs, revisions on
the record, source checks, Community discussion). The stories, pager and
topics follow unchanged. Later pages and topic pages have no showcase.

- The hero is the page's one h1; the list header on page 1 is "Latest
  stories" (h2). Page 2+ keep the OpenVibe.News h1 and lede.
- showcase.css is linked only on page 1 (layout now forwards `styles` to
  openvibe-publishing's renderDocument).
- Home budgets raised as a decision: HTML 27 KB / 7.5 KB br, CSS 17.5 KB /
  4.5 KB br (measured 24.4 / 6.3 and 15.5 / 3.9).
- test/stories.test.js asserts the hero, the stylesheet link, a single h1,
  the hero above the first headline, and no showcase on a topic page.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 040c24b..798fcb2 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/server/http/public.js b/server/http/public.js
iindex e5d65bb..d3e9c73 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -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 });
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 0ef611c..76396ad 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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,
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex ea4a88b..c52bb17 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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>` : ''}`);
}
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 74c8179..1836ae1 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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
};
ddiff --git a/test/stories.test.js b/test/stories.test.js
iindex 79dba65..453f48c 100644
---- a/test/stories.test.js
++++ b/test/stories.test.js
@@@ -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');