diff --git a/frontend/package.json b/frontend/package.json
index b406604..8e14a9a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
- "postbuild": "node scripts/patch-play-head.mjs",
+ "postbuild": "node scripts/patch-static-heads.mjs",
"preview": "vite preview",
"test": "vitest run"
},
diff --git a/frontend/scripts/patch-play-head.mjs b/frontend/scripts/patch-play-head.mjs
deleted file mode 100644
index 6ac14e6..0000000
--- a/frontend/scripts/patch-play-head.mjs
+++ /dev/null
@@ -1,41 +0,0 @@
-// Post-build: give build/play.html its own social/canonical metadata.
-//
-// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
-// HOMEPAGE
— meaning a shared /play link previews as the news homepage. Client
-// svelte:head can't fix that for non-JS social scrapers (Twitter/Slack/iMessage/etc.).
-// So we rewrite the static head of play.html here, at build time. Deep-linked variants
-// (/play?game=…) are served the same file, so they inherit this games-hub preview.
-import { readFile, writeFile } from 'node:fs/promises';
-
-const FILE = new URL('../build/play.html', import.meta.url);
-const URL_PLAY = 'https://upbeatbytes.com/play';
-const TITLE = 'Play · Upbeat Bytes — calm daily games';
-const DESC =
- 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
- 'A friendly little break from the doomscroll.';
-
-const subs = [
- [/[\s\S]*?<\/title>/, `${TITLE}`],
- [//, ``],
- [//, ``],
- [//, ``],
- [//, ``],
- [//, ``],
- [//, ``],
- [//, ``],
-];
-
-let html = await readFile(FILE, 'utf8');
-const missed = [];
-for (const [re, repl] of subs) {
- if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
- html = html.replace(re, repl);
-}
-// Fail loudly if the homepage head drifted — better a broken build than silently
-// shipping the wrong /play preview again.
-if (missed.length) {
- console.error('patch-play-head: these head tags were not found (app.html changed?):\n ' + missed.join('\n '));
- process.exit(1);
-}
-await writeFile(FILE, html);
-console.log('patch-play-head: rewrote build/play.html head → /play metadata');
diff --git a/frontend/scripts/patch-static-heads.mjs b/frontend/scripts/patch-static-heads.mjs
new file mode 100644
index 0000000..857d334
--- /dev/null
+++ b/frontend/scripts/patch-static-heads.mjs
@@ -0,0 +1,72 @@
+// Post-build: give specific prerendered shells their OWN social/canonical metadata.
+//
+// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
+// HOMEPAGE — meaning a shared /play or /word link previews as the news homepage,
+// and its canonical points at "/". Client svelte:head can't fix that for non-JS scrapers
+// (Twitter/Slack/iMessage) or for canonical dedup. So we rewrite each page's static head
+// here, at build time. Deep-linked variants (e.g. /play?game=…) inherit the same file.
+import { readFile, writeFile } from 'node:fs/promises';
+
+const BASE = 'https://upbeatbytes.com';
+
+// Per-page overrides. Keep titles/descriptions in sync with each page's intent.
+const PAGES = [
+ {
+ file: 'play.html', path: '/play',
+ title: 'Play · Upbeat Bytes — calm daily games',
+ desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
+ 'A friendly little break from the doomscroll.',
+ },
+ {
+ file: 'word.html', path: '/word',
+ title: 'Word of the Day · upbeatBytes',
+ desc: 'A new uplifting word every day — its meaning in plain language, how to say it, and how to use it.',
+ },
+ {
+ file: 'quote.html', path: '/quote',
+ title: 'Quote of the Day · upbeatBytes',
+ desc: 'A hopeful, hand-picked quote each day, with a short note on what it means.',
+ },
+ {
+ file: 'onthisday.html', path: '/onthisday',
+ title: 'On This Day · upbeatBytes',
+ desc: 'One genuinely good thing that happened on this day in history.',
+ },
+];
+
+function subsFor(url, title, desc) {
+ return [
+ [/[\s\S]*?<\/title>/, `${title}`],
+ [//, ``],
+ [//, ``],
+ [//, ``],
+ [//, ``],
+ [//, ``],
+ [//, ``],
+ [//, ``],
+ ];
+}
+
+for (const { file, path, title, desc } of PAGES) {
+ const fileUrl = new URL(`../build/${file}`, import.meta.url);
+ let html;
+ try {
+ html = await readFile(fileUrl, 'utf8');
+ } catch {
+ console.error(`patch-static-heads: build/${file} is missing (route renamed/removed?)`);
+ process.exit(1);
+ }
+ const missed = [];
+ for (const [re, repl] of subsFor(BASE + path, title, desc)) {
+ if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
+ html = html.replace(re, repl);
+ }
+ // Fail loudly if the homepage head drifted — better a broken build than silently
+ // shipping the wrong preview/canonical again.
+ if (missed.length) {
+ console.error(`patch-static-heads: ${file} — these head tags were not found (app.html changed?):\n ` + missed.join('\n '));
+ process.exit(1);
+ }
+ await writeFile(fileUrl, html);
+ console.log(`patch-static-heads: rewrote build/${file} head → ${path}`);
+}
diff --git a/frontend/src/routes/home3/+page.svelte b/frontend/src/routes/home3/+page.svelte
index 86d1ec7..a41bb74 100644
--- a/frontend/src/routes/home3/+page.svelte
+++ b/frontend/src/routes/home3/+page.svelte
@@ -26,6 +26,11 @@
// truncation handled by CSS (-webkit-line-clamp:2) — breaks on whole words, fills 2 full lines
let headline = $derived(news?.title ?? 'What went right this week: the good news that actually matters');
+ // Honest read-time from our own gist (~200 wpm, floor 1). We summarize, so this is
+ // usually "1 min read" — a feature, not a bug: the good news in about a minute.
+ const readMins = (t) => Math.max(1, Math.round((t || '').trim().split(/\s+/).filter(Boolean).length / 200));
+ let readTime = $derived(`${readMins(news?.summary)} min read`);
+
// small-joys shelf: 3 cells shown two at a time, rotated by the reader (no auto-motion)
const JOY_ACCENTS = ['#4f7da8', '#b06a86', '#b06a45'];
let joyIdx = $state(0);
@@ -125,7 +130,7 @@
{news?.summary || "We read the week so you don't have to doomscroll it. Five quietly hopeful stories, summarised to the gist."}