GenerAI / worldmonitor /server /_shared /brief-render.js
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 5)
20f83d9 verified
Raw
History Blame Contribute Delete
70.4 kB
// Deterministic renderer for the WorldMonitor Brief magazine.
//
// Pure function: (BriefEnvelope) -> HTML string. No I/O, no LLM calls,
// no network, no time-dependent output. The composer writes the
// envelope once; any consumer (edge route, dashboard panel preview,
// Tauri window) re-renders the same HTML at read time.
//
// The page sequence is derived from the data, not hardcoded:
// 1. Dark cover
// 2. Digest · 01 Greeting (always)
// 3. Digest · 02 At A Glance (always)
// 4. Digest · 03 On The Desk (one page if threads.length <= 6;
// else split into 03a + 03b)
// 5. Digest · 04 Signals (omitted when signals.length === 0)
// 6. Stories (one page per story, alternating
// light/dark by index parity)
// 7. Dark back cover
//
// Source references:
// - Visual prototype: .claude/worktrees/zany-chasing-boole/digest-magazine.html
// - Brainstorm: docs/brainstorms/2026-04-17-worldmonitor-brief-magazine-requirements.md
// - Plan: docs/plans/2026-04-17-003-feat-worldmonitor-brief-magazine-plan.md
import { BRIEF_ENVELOPE_VERSION, SUPPORTED_ENVELOPE_VERSIONS } from '../../shared/brief-envelope.js';
/**
* @typedef {import('../../shared/brief-envelope.js').BriefEnvelope} BriefEnvelope
* @typedef {import('../../shared/brief-envelope.js').BriefData} BriefData
* @typedef {import('../../shared/brief-envelope.js').BriefStory} BriefStory
* @typedef {import('../../shared/brief-envelope.js').BriefThread} BriefThread
* @typedef {import('../../shared/brief-envelope.js').BriefThreatLevel} BriefThreatLevel
*/
// ── Constants ────────────────────────────────────────────────────────────────
const FONTS_HREF =
'https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap';
const MAX_THREADS_PER_PAGE = 6;
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
/** @type {Record<BriefThreatLevel, string>} */
const THREAT_LABELS = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
};
/** @type {Set<BriefThreatLevel>} */
const HIGHLIGHTED_LEVELS = new Set(['critical', 'high']);
const VALID_THREAT_LEVELS = new Set(
/** @type {BriefThreatLevel[]} */ (['critical', 'high', 'medium', 'low']),
);
// ── HTML escaping ────────────────────────────────────────────────────────────
const HTML_ESCAPE_MAP = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const HTML_ESCAPE_RE = /[&<>"']/;
const HTML_ESCAPE_RE_G = /[&<>"']/g;
/**
* Text-context HTML escape. Do not use for raw attribute-value
* interpolation without extending the map.
* @param {string} str
*/
function escapeHtml(str) {
const s = String(str);
if (!HTML_ESCAPE_RE.test(s)) return s;
return s.replace(HTML_ESCAPE_RE_G, (ch) => HTML_ESCAPE_MAP[ch]);
}
/** @param {number} n */
function pad2(n) {
return String(n).padStart(2, '0');
}
// ── Envelope validation ──────────────────────────────────────────────────────
/** @param {unknown} v */
function isObject(v) {
return typeof v === 'object' && v !== null;
}
/** @param {unknown} v */
function isNonEmptyString(v) {
return typeof v === 'string' && v.length > 0;
}
/** @param {unknown} v */
function isFiniteNumber(v) {
return typeof v === 'number' && Number.isFinite(v);
}
// Closed key sets for each object in the contract. The validator
// rejects extra keys at every level — a producer cannot smuggle
// importanceScore, primaryLink, pubDate, briefModel, fetchedAt or any
// other forbidden upstream field into a persisted envelope. The
// renderer already refuses to interpolate unknown fields (and that is
// covered by the sentinel-poisoning test), but unknown fields resident
// in Redis still pollute every future consumer (edge route, dashboard
// panel preview, carousel, email teaser). Locking the contract at
// write time is the only place this invariant can live.
const ALLOWED_ENVELOPE_KEYS = new Set(['version', 'issuedAt', 'data']);
const ALLOWED_DATA_KEYS = new Set(['user', 'issue', 'date', 'dateLong', 'digest', 'stories']);
const ALLOWED_USER_KEYS = new Set(['name', 'tz']);
// publicLead / publicSignals / publicThreads: optional v3+ fields.
// Hold non-personalised content the public-share renderer uses in
// place of the personalised lead/signals/threads. v2 envelopes (no
// publicLead) still pass — the validator's optional-key pattern is
// "in the allow list, but isString/array check is skipped when
// undefined" (see validateBriefDigest below).
const ALLOWED_DIGEST_KEYS = new Set([
'greeting', 'lead', 'numbers', 'threads', 'signals',
'publicLead', 'publicSignals', 'publicThreads',
]);
const ALLOWED_NUMBERS_KEYS = new Set(['clusters', 'multiSource', 'surfaced']);
const ALLOWED_THREAD_KEYS = new Set(['tag', 'teaser']);
const ALLOWED_STORY_KEYS = new Set([
'category',
'country',
'threatLevel',
'headline',
'description',
'source',
'sourceUrl',
// v4+ stable per-story-cluster identity (see shared/brief-envelope.js
// version-history doc-block). Required on v4 envelopes — checked
// below in the per-story validator. Optional on v1-v3 envelopes
// still in TTL.
'clusterId',
'whyMatters',
]);
// Closed list of URL schemes we will interpolate into `href=`. A source
// record with an unknown scheme is a composer bug, not something to
// render — the story is dropped at envelope-validation time rather than
// shipping with an unlinked / broken source.
const ALLOWED_SOURCE_URL_SCHEMES = new Set(['https:', 'http:']);
/**
* Parses and validates a story source URL. Returns the normalised URL
* string on success; throws a descriptive error otherwise. The renderer
* validator wraps this in a per-story path-prefixed error so composer
* bugs are easy to locate.
*
* @param {unknown} raw
* @returns {string}
*/
function validateSourceUrl(raw) {
if (typeof raw !== 'string' || raw.length === 0) {
throw new Error('must be a non-empty string');
}
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new Error(`must be a parseable absolute URL (got ${JSON.stringify(raw)})`);
}
if (!ALLOWED_SOURCE_URL_SCHEMES.has(parsed.protocol)) {
throw new Error(`scheme ${JSON.stringify(parsed.protocol)} is not allowed (http/https only)`);
}
// Bar `javascript:`-style smuggling via credentials or a Unicode host
// that renders like a legitimate outlet. These aren't exploitable
// through the renderer (we only emit the URL in an href with
// rel=noopener and we escape it), but they're always a composer bug
// so flag at write time.
if (parsed.username || parsed.password) {
throw new Error('must not include userinfo credentials');
}
return parsed.toString();
}
/**
* @param {Record<string, unknown>} obj
* @param {Set<string>} allowed
* @param {string} path
*/
function assertNoExtraKeys(obj, allowed, path) {
for (const key of Object.keys(obj)) {
if (!allowed.has(key)) {
throw new Error(
`${path} has unexpected key ${JSON.stringify(key)}; allowed keys: ${[...allowed].join(', ')}`,
);
}
}
}
/**
* Throws a descriptive error on the first missing, mis-typed, or
* unexpected field. Runs before any HTML interpolation so the renderer
* can assume the typed shape after this returns. The renderer is a
* shared module with multiple independent producers (Railway composer,
* tests, future dev-only fixtures) — a strict runtime contract matters
* more than the declaration-file types alone.
*
* Also enforces the cross-field invariant that
* `digest.numbers.surfaced === stories.length`. The renderer uses both
* values (surfaced prints on the "at a glance" page; stories.length
* drives cover blurb and page count) — allowing them to disagree would
* produce a self-contradictory brief.
*
* @param {unknown} envelope
* @returns {asserts envelope is BriefEnvelope}
*/
export function assertBriefEnvelope(envelope) {
if (!isObject(envelope)) {
throw new Error('renderBriefMagazine: envelope must be an object');
}
const env = /** @type {Record<string, unknown>} */ (envelope);
assertNoExtraKeys(env, ALLOWED_ENVELOPE_KEYS, 'envelope');
// Accept any version in SUPPORTED_ENVELOPE_VERSIONS. The composer
// only ever writes the current BRIEF_ENVELOPE_VERSION; older
// versions are tolerated on READ so links issued in the 7-day TTL
// window survive a renderer rollout. Unknown versions are still
// rejected — an unexpected shape would lead the renderer to
// interpolate garbage.
if (typeof env.version !== 'number' || !SUPPORTED_ENVELOPE_VERSIONS.has(env.version)) {
throw new Error(
`renderBriefMagazine: envelope.version=${JSON.stringify(env.version)} is not in supported set [${[...SUPPORTED_ENVELOPE_VERSIONS].join(', ')}]. Deploy a matching renderer before producing envelopes at this version.`,
);
}
if (!isFiniteNumber(env.issuedAt)) {
throw new Error('renderBriefMagazine: envelope.issuedAt must be a finite number');
}
if (!isObject(env.data)) {
throw new Error('renderBriefMagazine: envelope.data is required');
}
const data = /** @type {Record<string, unknown>} */ (env.data);
assertNoExtraKeys(data, ALLOWED_DATA_KEYS, 'envelope.data');
if (!isObject(data.user)) throw new Error('envelope.data.user is required');
const user = /** @type {Record<string, unknown>} */ (data.user);
assertNoExtraKeys(user, ALLOWED_USER_KEYS, 'envelope.data.user');
if (!isNonEmptyString(user.name)) throw new Error('envelope.data.user.name must be a non-empty string');
if (!isNonEmptyString(user.tz)) throw new Error('envelope.data.user.tz must be a non-empty string');
if (!isNonEmptyString(data.issue)) throw new Error('envelope.data.issue must be a non-empty string');
if (!isNonEmptyString(data.date)) throw new Error('envelope.data.date must be a non-empty string');
if (!DATE_REGEX.test(/** @type {string} */ (data.date))) {
throw new Error('envelope.data.date must match YYYY-MM-DD');
}
if (!isNonEmptyString(data.dateLong)) throw new Error('envelope.data.dateLong must be a non-empty string');
if (!isObject(data.digest)) throw new Error('envelope.data.digest is required');
const digest = /** @type {Record<string, unknown>} */ (data.digest);
assertNoExtraKeys(digest, ALLOWED_DIGEST_KEYS, 'envelope.data.digest');
if (!isNonEmptyString(digest.greeting)) throw new Error('envelope.data.digest.greeting must be a non-empty string');
if (!isNonEmptyString(digest.lead)) throw new Error('envelope.data.digest.lead must be a non-empty string');
// publicLead: optional v3+ field. When present, MUST be a non-empty
// string (typed contract enforcement); when absent, the renderer's
// public-mode lead block omits the pull-quote entirely (per the
// "never fall back to personalised lead" rule).
if (digest.publicLead !== undefined && !isNonEmptyString(digest.publicLead)) {
throw new Error('envelope.data.digest.publicLead, when present, must be a non-empty string');
}
// publicSignals + publicThreads: optional v3+. When present, MUST
// match the signals/threads contracts (array of non-empty strings,
// array of {tag, teaser}). Absent siblings are OK — public render
// path falls back to "omit signals page" / "category-derived
// threads stub" rather than serving the personalised version.
if (digest.publicSignals !== undefined) {
if (!Array.isArray(digest.publicSignals)) {
throw new Error('envelope.data.digest.publicSignals, when present, must be an array');
}
digest.publicSignals.forEach((s, i) => {
if (!isNonEmptyString(s)) throw new Error(`envelope.data.digest.publicSignals[${i}] must be a non-empty string`);
});
}
if (digest.publicThreads !== undefined) {
if (!Array.isArray(digest.publicThreads)) {
throw new Error('envelope.data.digest.publicThreads, when present, must be an array');
}
digest.publicThreads.forEach((t, i) => {
if (!isObject(t)) throw new Error(`envelope.data.digest.publicThreads[${i}] must be an object`);
const th = /** @type {Record<string, unknown>} */ (t);
assertNoExtraKeys(th, ALLOWED_THREAD_KEYS, `envelope.data.digest.publicThreads[${i}]`);
if (!isNonEmptyString(th.tag)) throw new Error(`envelope.data.digest.publicThreads[${i}].tag must be a non-empty string`);
if (!isNonEmptyString(th.teaser)) throw new Error(`envelope.data.digest.publicThreads[${i}].teaser must be a non-empty string`);
});
}
if (!isObject(digest.numbers)) throw new Error('envelope.data.digest.numbers is required');
const numbers = /** @type {Record<string, unknown>} */ (digest.numbers);
assertNoExtraKeys(numbers, ALLOWED_NUMBERS_KEYS, 'envelope.data.digest.numbers');
for (const key of /** @type {const} */ (['clusters', 'multiSource', 'surfaced'])) {
if (!isFiniteNumber(numbers[key])) {
throw new Error(`envelope.data.digest.numbers.${key} must be a finite number`);
}
}
if (!Array.isArray(digest.threads)) {
throw new Error('envelope.data.digest.threads must be an array');
}
digest.threads.forEach((t, i) => {
if (!isObject(t)) throw new Error(`envelope.data.digest.threads[${i}] must be an object`);
const th = /** @type {Record<string, unknown>} */ (t);
assertNoExtraKeys(th, ALLOWED_THREAD_KEYS, `envelope.data.digest.threads[${i}]`);
if (!isNonEmptyString(th.tag)) throw new Error(`envelope.data.digest.threads[${i}].tag must be a non-empty string`);
if (!isNonEmptyString(th.teaser)) throw new Error(`envelope.data.digest.threads[${i}].teaser must be a non-empty string`);
});
if (!Array.isArray(digest.signals)) {
throw new Error('envelope.data.digest.signals must be an array');
}
digest.signals.forEach((s, i) => {
if (!isNonEmptyString(s)) throw new Error(`envelope.data.digest.signals[${i}] must be a non-empty string`);
});
if (!Array.isArray(data.stories) || data.stories.length === 0) {
throw new Error('envelope.data.stories must be a non-empty array');
}
data.stories.forEach((s, i) => {
if (!isObject(s)) throw new Error(`envelope.data.stories[${i}] must be an object`);
const st = /** @type {Record<string, unknown>} */ (s);
assertNoExtraKeys(st, ALLOWED_STORY_KEYS, `envelope.data.stories[${i}]`);
for (const field of /** @type {const} */ (['category', 'country', 'headline', 'description', 'source', 'whyMatters'])) {
if (!isNonEmptyString(st[field])) {
throw new Error(`envelope.data.stories[${i}].${field} must be a non-empty string`);
}
}
if (typeof st.threatLevel !== 'string' || !VALID_THREAT_LEVELS.has(/** @type {BriefThreatLevel} */ (st.threatLevel))) {
throw new Error(
`envelope.data.stories[${i}].threatLevel must be one of critical|high|medium|low (got ${JSON.stringify(st.threatLevel)})`,
);
}
// sourceUrl is required from v2 onward and absent on v1. When
// present on v1, it must still parse cleanly — a malformed URL
// would break the href. A v1 envelope that somehow carries a
// sourceUrl is still validated (cheap defence against composer
// regressions).
//
// Codex PR #3614 P2 — pre-fix used `env.version === BRIEF_ENVELOPE_VERSION`
// which only required sourceUrl on the LATEST version. Pre-U1
// (when BRIEF_ENVELOPE_VERSION === 3) v2 envelopes were already
// exempted; the v4 bump made it worse by also exempting v3. Both
// are wrong per the v2+ contract. Switched to `env.version >= 2`
// so every supported v2/v3/v4/... envelope enforces sourceUrl.
if (env.version >= 2 || st.sourceUrl !== undefined) {
try {
validateSourceUrl(st.sourceUrl);
} catch (err) {
throw new Error(
`envelope.data.stories[${i}].sourceUrl ${/** @type {Error} */ (err).message}`,
);
}
}
// clusterId is REQUIRED on v4 (the canonical-contract bump that
// wires per-cluster identity into the delivered-log + CI invariant)
// and OPTIONAL on v1-v3 envelopes still in the 7-day TTL window.
// When present on any version it must be a non-empty string —
// empty strings would silently collapse delivered-log keys across
// clusters and break the `digest.cards ⊆ brief.cards` invariant.
if (env.version === BRIEF_ENVELOPE_VERSION) {
if (!isNonEmptyString(st.clusterId)) {
throw new Error(
`envelope.data.stories[${i}].clusterId must be a non-empty string on v${BRIEF_ENVELOPE_VERSION} envelopes (got ${JSON.stringify(st.clusterId)})`,
);
}
} else if (st.clusterId !== undefined && !isNonEmptyString(st.clusterId)) {
throw new Error(
`envelope.data.stories[${i}].clusterId, when present on v${env.version}, must be a non-empty string (got ${JSON.stringify(st.clusterId)})`,
);
}
});
// Cross-field invariant: surfaced count must match the actual number
// of stories surfaced to this reader. Enforced here so cover copy
// ("N threads") and the at-a-glance stat can never disagree.
if (numbers.surfaced !== data.stories.length) {
throw new Error(
`envelope.data.digest.numbers.surfaced=${numbers.surfaced} must equal envelope.data.stories.length=${data.stories.length}`,
);
}
}
// ── Logo symbol + references ─────────────────────────────────────────────────
/**
* The full logo SVG is emitted ONCE per document inside an invisible
* <svg><defs><symbol id="wm-logo-core"> block. Every placement then
* references the symbol via `<use>` at the desired size. Saves ~7 KB on
* a 12-story brief vs. repeating the full SVG per placement.
*
* Stroke width is baked into the symbol (medium weight). Visual variance
* across placements (cover 48px vs story 28px) reads identically at
* display size; sub-pixel stroke differences are not perceptible.
*/
const LOGO_SYMBOL = (
'<svg aria-hidden="true" style="display:none;position:absolute;width:0;height:0" focusable="false">' +
'<defs>' +
'<symbol id="wm-logo-core" viewBox="0 0 64 64">' +
'<circle cx="32" cy="32" r="28"/>' +
'<ellipse cx="32" cy="32" rx="5" ry="28"/>' +
'<ellipse cx="32" cy="32" rx="14" ry="28"/>' +
'<ellipse cx="32" cy="32" rx="22" ry="28"/>' +
'<ellipse cx="32" cy="32" rx="28" ry="5"/>' +
'<ellipse cx="32" cy="32" rx="28" ry="14"/>' +
'<path class="wm-ekg" d="M 6 32 L 20 32 L 24 24 L 30 40 L 36 22 L 42 38 L 46 32 L 56 32"/>' +
'<circle class="wm-ekg-dot" cx="57" cy="32" r="1.8"/>' +
'</symbol>' +
'</defs>' +
'</svg>'
);
/**
* @param {{ size: number; color?: string }} opts
*/
function logoRef({ size, color }) {
// color is sourced ONLY from a closed enum of theme strings at the
// call sites in this file. Never interpolate envelope-derived content
// into a style= attribute via this helper.
const styleAttr = color ? ` style="color: ${color};"` : '';
return (
`<svg class="wm-logo" width="${size}" height="${size}" viewBox="0 0 64 64" ` +
`aria-label="WorldMonitor"${styleAttr}>` +
'<use href="#wm-logo-core"/>' +
'</svg>'
);
}
// ── Running head (shared across digest pages) ────────────────────────────────
/** @param {string} dateShort @param {string} label */
function digestRunningHead(dateShort, label) {
return (
'<div class="running-head">' +
'<span class="mono left">' +
logoRef({ size: 22 }) +
` · WorldMonitor Brief · ${escapeHtml(dateShort)} ·` +
'</span>' +
`<span class="mono">${escapeHtml(label)}</span>` +
'</div>'
);
}
// ── Page renderers ───────────────────────────────────────────────────────────
/**
* Strip the trailing period from envelope.data.digest.greeting
* ("Good afternoon." → "Good afternoon") so the cover's mono-cased
* salutation stays consistent with the historical no-period style.
* Defensive: if the envelope ever produces an unexpected value, fall
* back to a generic "Hello" rather than hardcoding a wrong time-of-day.
*/
function coverGreeting(greeting) {
if (typeof greeting !== 'string' || greeting.length === 0) return 'Hello';
return greeting.replace(/\.+$/, '').trim() || 'Hello';
}
/**
* @param {{ dateLong: string; issue: string; storyCount: number; pageIndex: number; totalPages: number; greeting: string }} opts
*/
function renderCover({ dateLong, issue, storyCount, pageIndex, totalPages, greeting }) {
const blurb =
storyCount === 1
? 'One thread that shaped the world today.'
: `${storyCount} threads that shaped the world today.`;
return (
'<section class="page cover">' +
'<div class="meta-top">' +
'<span class="brand">' +
logoRef({ size: 48 }) +
'<span class="mono">WorldMonitor</span>' +
'</span>' +
`<span class="mono">Issue № ${escapeHtml(issue)}</span>` +
'</div>' +
'<div class="hero">' +
`<div class="kicker">${escapeHtml(dateLong)}</div>` +
'<h1>WorldMonitor<br/>Brief.</h1>' +
`<p class="blurb">${escapeHtml(blurb)}</p>` +
'</div>' +
'<div class="meta-bottom">' +
`<span class="mono">${escapeHtml(coverGreeting(greeting))}</span>` +
'<span class="mono">Swipe / ↔ to begin</span>' +
'</div>' +
`<div class="page-number mono">${pad2(pageIndex)} / ${pad2(totalPages)}</div>` +
'</section>'
);
}
/**
* @param {{ greeting: string; lead: string; dateShort: string; pageIndex: number; totalPages: number }} opts
*/
function renderDigestGreeting({ greeting, lead, dateShort, pageIndex, totalPages }) {
// Public-share fail-safe: when `lead` is empty, omit the pull-quote
// entirely. Reached via redactForPublic when the envelope lacks a
// non-empty `publicLead` — NEVER serve the personalised lead on the
// public surface. Page still reads as a complete editorial layout
// (greeting + horizontal rule), just without the italic blockquote.
// Codex Round-2 High (security on share-URL surface).
const blockquote = typeof lead === 'string' && lead.length > 0
? `<blockquote>${escapeHtml(lead)}</blockquote>`
: '';
return (
'<section class="page digest">' +
digestRunningHead(dateShort, 'Digest / 01') +
'<div class="body">' +
'<div class="label mono">At The Top Of The Hour</div>' +
`<h2>${escapeHtml(greeting)}</h2>` +
blockquote +
'<hr class="rule" />' +
'</div>' +
`<div class="page-number mono">${pad2(pageIndex)} / ${pad2(totalPages)}</div>` +
'</section>'
);
}
/**
* @param {{ numbers: import('../../shared/brief-envelope.js').BriefNumbers; date: string; dateShort: string; pageIndex: number; totalPages: number }} opts
*/
function renderDigestNumbers({ numbers, date, dateShort, pageIndex, totalPages }) {
const rows = [
{ n: numbers.clusters, label: 'story clusters ingested in the last 24 hours' },
{ n: numbers.multiSource, label: 'multi-source confirmed events' },
{ n: numbers.surfaced, label: 'threads surfaced in this brief' },
]
.map(
(row) =>
'<div class="stat-row">' +
`<div class="stat-num">${pad2(row.n)}</div>` +
`<div class="stat-label">${escapeHtml(row.label)}</div>` +
'</div>',
)
.join('');
return (
'<section class="page digest">' +
digestRunningHead(dateShort, 'Digest / 02 — At A Glance') +
'<div class="body">' +
'<div class="label mono">The Numbers Today</div>' +
`<div class="stats">${rows}</div>` +
`<div class="footer-caption mono">Signal Window · ${escapeHtml(date)}</div>` +
'</div>' +
`<div class="page-number mono">${pad2(pageIndex)} / ${pad2(totalPages)}</div>` +
'</section>'
);
}
/**
* @param {{ threads: BriefThread[]; dateShort: string; label: string; heading: string; includeEndMarker: boolean; pageIndex: number; totalPages: number }} opts
*/
function renderDigestThreadsPage({
threads,
dateShort,
label,
heading,
includeEndMarker,
pageIndex,
totalPages,
}) {
const rows = threads
.map(
(t) =>
'<p class="thread">' +
`<span class="tag">${escapeHtml(t.tag)} —</span>` +
`${escapeHtml(t.teaser)}` +
'</p>',
)
.join('');
const endMarker = includeEndMarker
? '<div class="end-marker"><hr /><span class="mono">Stories follow →</span></div>'
: '';
return (
'<section class="page digest">' +
digestRunningHead(dateShort, label) +
'<div class="body">' +
'<div class="label mono">Today\u2019s Threads</div>' +
`<h2>${escapeHtml(heading)}</h2>` +
`<div class="threads">${rows}</div>` +
endMarker +
'</div>' +
`<div class="page-number mono">${pad2(pageIndex)} / ${pad2(totalPages)}</div>` +
'</section>'
);
}
/**
* @param {{ signals: string[]; dateShort: string; pageIndex: number; totalPages: number }} opts
*/
function renderDigestSignals({ signals, dateShort, pageIndex, totalPages }) {
const paragraphs = signals
.map((s) => `<p class="signal">${escapeHtml(s)}</p>`)
.join('');
return (
'<section class="page digest">' +
digestRunningHead(dateShort, 'Digest / 04 — Signals') +
'<div class="body">' +
'<div class="label mono">Signals To Watch</div>' +
'<h2>What would change the story.</h2>' +
`<div class="signals">${paragraphs}</div>` +
'<div class="end-marker"><hr /><span class="mono">End of digest · Stories follow →</span></div>' +
'</div>' +
`<div class="page-number mono">${pad2(pageIndex)} / ${pad2(totalPages)}</div>` +
'</section>'
);
}
/**
* Build a tracked outgoing URL for the source line. Adds utm_source /
* utm_medium / utm_campaign / utm_content only when absent — if the
* upstream feed already embeds UTM (many publisher RSS do), we keep
* their attribution intact and just append ours after.
*
* Returns the original `raw` on URL parse failure. This path is
* unreachable in practice because assertBriefEnvelope already proved
* the URL parses, but fail-safe is cheap.
*
* @param {string} raw validated absolute https URL
* @param {string} issueDate envelope.data.date (YYYY-MM-DD)
* @param {number} rank 1-indexed story rank
*/
function buildTrackedSourceUrl(raw, issueDate, rank) {
try {
const u = new URL(raw);
if (!u.searchParams.has('utm_source')) u.searchParams.set('utm_source', 'worldmonitor');
if (!u.searchParams.has('utm_medium')) u.searchParams.set('utm_medium', 'brief');
if (!u.searchParams.has('utm_campaign')) u.searchParams.set('utm_campaign', issueDate);
if (!u.searchParams.has('utm_content')) u.searchParams.set('utm_content', `story-${pad2(rank)}`);
return u.toString();
} catch {
return raw;
}
}
/**
* Extract ISO-2 country tokens from a `BriefStory.country` string.
* The contract allows composite forms like "IL / LB" and "IL/LB" —
* we split on whitespace and `/` and keep tokens that are exactly
* two ASCII letters. Used solely to compute the `data-followed`
* stamp on the magazine source-link; never affects visible content.
*
* @param {string} country
* @returns {string[]} uppercase ISO-2 tokens (may be empty)
*/
function extractIso2Tokens(country) {
if (typeof country !== 'string' || country.length === 0) return [];
/** @type {string[]} */
const out = [];
for (const raw of country.split(/[\s/]+/)) {
if (raw.length === 2 && /^[a-zA-Z]{2}$/.test(raw)) {
out.push(raw.toUpperCase());
}
}
return out;
}
/**
* @param {{ story: BriefStory; rank: number; palette: 'light' | 'dark'; pageIndex: number; totalPages: number; issueDate: string; followedSet: Set<string> }} opts
*/
function renderStoryPage({ story, rank, palette, pageIndex, totalPages, issueDate, followedSet }) {
const threatClass = HIGHLIGHTED_LEVELS.has(story.threatLevel) ? ' crit' : '';
const threatLabel = THREAT_LABELS[story.threatLevel];
// U11 telemetry stamps. Pick the first ISO-2 token as the primary
// country (composite stories like "IL / LB" still get a single
// primary-country event property; a secondary country whose only
// role is to qualify the headline is dropped to keep the analytics
// dimension cardinality bounded). `followed` is true when ANY
// token in the story's country field appears in the recipient's
// watchlist — composite stories that mention a followed country
// count as followed even if the primary token doesn't.
const iso2Tokens = extractIso2Tokens(story.country);
const primaryCountry = iso2Tokens[0] ?? '';
const followed = iso2Tokens.some((c) => followedSet.has(c));
const dataAttrs =
' data-thread-open="1"' +
(primaryCountry ? ` data-country="${escapeHtml(primaryCountry)}"` : '') +
` data-severity="${escapeHtml(story.threatLevel)}"` +
` data-followed="${followed ? '1' : '0'}"`;
// v1 envelopes don't carry sourceUrl — render the source as plain
// text (matching pre-v2 appearance). v2 envelopes always have a
// validated URL, so we wrap in a UTM-tracked anchor.
const sourceBlock = story.sourceUrl
? `<a class="source-link" href="${escapeHtml(buildTrackedSourceUrl(story.sourceUrl, issueDate, rank))}" target="_blank" rel="noopener noreferrer"${dataAttrs}>${escapeHtml(story.source)}</a>`
: escapeHtml(story.source);
return (
`<section class="page story ${palette}">` +
'<div class="left">' +
`<div class="rank-ghost">${pad2(rank)}</div>` +
'<div class="left-content">' +
'<div class="tag-row">' +
`<span class="tag">${escapeHtml(story.category)}</span>` +
`<span class="tag">${escapeHtml(story.country)}</span>` +
`<span class="tag${threatClass}">${escapeHtml(threatLabel)}</span>` +
'</div>' +
`<h3>${escapeHtml(story.headline)}</h3>` +
`<p class="desc">${escapeHtml(story.description)}</p>` +
`<div class="source">Source · ${sourceBlock}</div>` +
'</div>' +
'</div>' +
'<div class="right">' +
'<div class="callout">' +
'<div class="label">Why this is important</div>' +
`<p class="note">${escapeHtml(story.whyMatters)}</p>` +
'</div>' +
'</div>' +
'<div class="logo-chrome">' +
logoRef({ size: 28 }) +
'<span class="mono">WorldMonitor Brief</span>' +
'</div>' +
`<div class="page-number mono">${pad2(pageIndex)} / ${pad2(totalPages)}</div>` +
'</section>'
);
}
/**
* @param {{
* tz: string;
* pageIndex: number;
* totalPages: number;
* publicMode: boolean;
* refCode: string;
* }} opts
*/
function renderBackCover({ tz, pageIndex, totalPages, publicMode, refCode }) {
const ctaHref = publicMode
? `https://worldmonitor.app/pro${refCode ? `?ref=${encodeURIComponent(refCode)}` : ''}`
: 'https://worldmonitor.app';
const kicker = publicMode
? 'You\u2019re reading a shared brief'
: 'Thank you for reading';
const headline = publicMode
? 'Get your own<br/>daily brief.'
: 'End of<br/>Transmission.';
const metaLeft = publicMode
? `<a href="${escapeHtml(ctaHref)}" class="mono back-cta" target="_blank" rel="noopener">Subscribe \u2192</a>`
: '<span class="mono">worldmonitor.app</span>';
const metaRight = publicMode
? '<span class="mono">worldmonitor.app</span>'
: `<span class="mono">Next brief \u00b7 08:00 ${escapeHtml(tz)}</span>`;
return (
'<section class="page cover back">' +
'<div class="hero">' +
'<div class="centered-logo">' +
logoRef({ size: 80, color: 'var(--bone)' }) +
'</div>' +
`<div class="kicker">${kicker}</div>` +
`<h1>${headline}</h1>` +
'</div>' +
'<div class="meta-bottom">' +
metaLeft +
metaRight +
'</div>' +
`<div class="page-number mono">${pad2(pageIndex)} / ${pad2(totalPages)}</div>` +
'</section>'
);
}
// ── Shell (document + CSS + JS) ──────────────────────────────────────────────
const STYLE_BLOCK = `<style>
:root {
/* WorldMonitor brand palette — aligned with /pro landing + dashboard.
Previous sienna rust (#8b3a1f) was the only off-brand color in the
product; swapped to WM mint at two strengths so the accent harmonises
on both light and dark pages. Paper unified to a single crisp white
(#fafafa) rather than warm cream so the brief reads as a sibling of
/pro rather than a separate editorial product. */
--ink: #0a0a0a;
--bone: #f2ede4;
--cream: #fafafa; /* was #f1e9d8 — unified with --paper */
--cream-ink: #0a0a0a; /* was #1a1612 — crisper contrast on white */
/* --sienna is kept as the variable name for backwards compat (every
.digest rule below references it) but the VALUE is now a dark
mint sized for WCAG AA 4.5:1 on #fafafa. The earlier #3ab567 hit
only ~2.3:1, which failed accessibility for the mono running
heads + source lines even at their 13-18 px sizes. #1f7a3f lands
at ~4.90:1 — passes AA for normal text, still reads as mint-
family (green hue dominant), and sits close enough to the brand
#4ade80 that a reader recognises the relationship. */
--sienna: #1f7a3f; /* dark mint for light-page accents — WCAG AA on #fafafa */
--mint: #4ade80; /* bright WM brand mint for dark-page accents (AAA on #0a0a0a) */
--paper: #fafafa;
--paper-ink: #0a0a0a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100vw; height: 100vh; overflow: hidden;
background: #000;
font-family: 'Source Serif 4', Georgia, serif;
-webkit-font-smoothing: antialiased;
}
.deck {
width: 100vw; height: 100vh; display: flex;
transition: transform 620ms cubic-bezier(0.77, 0, 0.175, 1);
will-change: transform;
}
.page {
flex: 0 0 100vw; width: 100vw; height: 100vh;
padding: 6vh 6vw 10vh;
/* overflow-y: auto so pages whose content exceeds 100vh become
internally scrollable instead of silently clipping (user-reported
on desktop where vw-scaled body copy can be ~10-20% taller than
viewport; iPhone Pro Max responsive mode "worked" because narrow
viewport scaled the vw text down until it fit). overflow-x stays
hidden so the deck-level horizontal carousel isn't fought by a
per-page horizontal scrollbar. Pair with the wheel handler in
NAV_SCRIPT which now defers to native scroll when the current
page has remaining scroll in the wheel direction. */
position: relative; overflow-x: hidden; overflow-y: auto;
/* Smooth out the deck-level transform vs in-page scroll interaction
on touch + trackpad: contain scroll within the page so a fast
trackpad flick doesn't bubble to the body (body has overflow:hidden
anyway, but overscroll-behavior also disables the iOS rubber-band
effect that visually fights the deck transform). */
overscroll-behavior: contain;
display: flex; flex-direction: column;
}
.mono {
font-family: 'IBM Plex Mono', monospace;
font-weight: 500; letter-spacing: 0.18em;
text-transform: uppercase; font-size: max(11px, 0.85vw);
}
.wm-logo { display: block; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.wm-logo .wm-ekg { stroke-width: 2.4; }
.wm-logo .wm-ekg-dot { fill: currentColor; stroke: none; }
.logo-chrome {
position: absolute; bottom: 5vh; left: 6vw;
display: flex; align-items: center; gap: 0.8vw; opacity: 0.7;
}
.cover { background: var(--ink); color: var(--bone); }
.cover .meta-top, .cover .meta-bottom {
display: flex; justify-content: space-between; align-items: center; opacity: 0.75;
}
.cover .meta-top .brand { display: flex; align-items: center; gap: 1vw; }
.cover .hero {
flex: 1; display: flex; flex-direction: column; justify-content: center;
}
.cover .hero h1 {
font-family: 'Playfair Display', serif; font-weight: 900;
font-size: clamp(72px, 10vw, 156px); line-height: 0.92; letter-spacing: -0.03em;
margin-bottom: 6vh;
overflow-wrap: anywhere;
}
.cover .hero .kicker {
font-family: 'IBM Plex Mono', monospace;
font-size: max(13px, 1.1vw); letter-spacing: 0.3em;
text-transform: uppercase; opacity: 0.75; margin-bottom: 4vh;
}
.cover .hero .blurb {
font-family: 'Source Serif 4', serif; font-style: italic;
font-size: max(18px, 1.7vw); max-width: 48ch; opacity: 0.82; line-height: 1.4;
}
.cover.back { align-items: center; justify-content: center; text-align: center; }
.cover.back .hero { align-items: center; flex: 0; }
.cover.back .centered-logo { margin-bottom: 5vh; opacity: 0.9; }
.cover.back .hero h1 { font-size: clamp(64px, 8vw, 132px); }
.cover.back .meta-bottom {
width: 100%; position: absolute; bottom: 6vh; left: 0; padding: 0 6vw;
}
.digest { background: var(--cream); color: var(--cream-ink); }
.digest .running-head {
display: flex; justify-content: space-between; align-items: center;
padding-bottom: 2vh; border-bottom: 1px solid rgba(26, 22, 18, 0.18);
}
.digest .running-head .left {
display: flex; align-items: center; gap: 0.8vw;
color: var(--sienna); font-weight: 600;
}
.digest .body {
flex: 1; display: flex; flex-direction: column;
justify-content: center; padding-top: 4vh;
}
.digest .label { color: var(--sienna); margin-bottom: 5vh; }
.digest h2 {
font-family: 'Playfair Display', serif; font-weight: 900;
font-size: clamp(54px, 7vw, 112px); line-height: 0.98; letter-spacing: -0.02em;
margin-bottom: 6vh; max-width: 18ch;
overflow-wrap: anywhere;
}
.digest blockquote {
font-family: 'Source Serif 4', serif; font-style: italic;
font-size: clamp(20px, 2vw, 34px); line-height: 1.38; max-width: 32ch;
margin-bottom: 5vh; padding-left: 2vw;
border-left: 3px solid var(--sienna);
overflow-wrap: anywhere;
}
.digest .rule {
border: none; height: 2px; background: var(--sienna);
width: 8vw; margin-top: 5vh;
}
.digest .stats { display: flex; flex-direction: column; gap: 3vh; }
.digest .stat-row {
display: grid; grid-template-columns: 22vw 1fr;
align-items: baseline; gap: 3vw;
padding-bottom: 3vh; border-bottom: 1px solid rgba(26, 22, 18, 0.14);
}
.digest .stat-row:last-child { border-bottom: none; }
.digest .stat-num {
font-family: 'Playfair Display', serif; font-weight: 900;
font-size: clamp(84px, 11vw, 168px); line-height: 0.9; color: var(--cream-ink);
}
.digest .stat-label {
font-family: 'Source Serif 4', serif; font-style: italic;
font-size: max(18px, 1.7vw); line-height: 1.3;
color: var(--cream-ink); opacity: 0.85;
overflow-wrap: anywhere;
}
.digest .footer-caption { margin-top: 4vh; color: var(--sienna); opacity: 0.85; }
.digest .threads { display: flex; flex-direction: column; gap: 3.2vh; max-width: 62ch; }
.digest .thread {
font-family: 'Source Serif 4', serif;
font-size: clamp(17px, 1.55vw, 28px); line-height: 1.45;
color: var(--cream-ink);
overflow-wrap: anywhere;
}
.digest .thread .tag {
font-family: 'IBM Plex Mono', monospace; font-weight: 600;
letter-spacing: 0.2em; color: var(--sienna); margin-right: 0.6em;
}
.digest .signals { display: flex; flex-direction: column; gap: 3.5vh; max-width: 60ch; }
.digest .signal {
font-family: 'Source Serif 4', serif;
font-size: clamp(18px, 1.65vw, 30px); line-height: 1.45;
color: var(--cream-ink); padding-left: 2vw;
border-left: 2px solid var(--sienna);
overflow-wrap: anywhere;
}
.digest .end-marker {
margin-top: 5vh; display: flex; align-items: center; gap: 1.5vw;
}
.digest .end-marker hr {
flex: 0 0 10vw; border: none; height: 2px; background: var(--sienna);
}
.digest .end-marker .mono { color: var(--sienna); }
.story { display: grid; grid-template-columns: 55fr 45fr; gap: 4vw; }
.story.light { background: var(--paper); color: var(--paper-ink); }
.story.dark { background: var(--ink); color: var(--bone); }
.story .left {
display: flex; flex-direction: column; justify-content: center;
position: relative; padding-right: 2vw;
}
.story .rank-ghost {
font-family: 'Playfair Display', serif; font-weight: 900;
font-size: 38vw; line-height: 0.8;
position: absolute; top: 50%; left: -1vw;
transform: translateY(-50%); opacity: 0.07;
pointer-events: none; letter-spacing: -0.04em;
}
.story.dark .rank-ghost { opacity: 0.1; }
.story .left-content { position: relative; z-index: 2; }
.story .tag-row {
display: flex; gap: 1.2vw; margin-bottom: 4vh; flex-wrap: wrap;
}
.story .tag {
font-family: 'IBM Plex Mono', monospace;
font-size: max(11px, 0.85vw); font-weight: 600;
letter-spacing: 0.22em; text-transform: uppercase;
padding: 0.5em 1em; border: 1px solid currentColor; opacity: 0.82;
max-width: 100%; overflow-wrap: anywhere;
}
.story .tag.crit { background: currentColor; color: var(--paper); }
.story.dark .tag.crit { background: var(--bone); color: var(--ink); border-color: var(--bone); }
.story h3 {
font-family: 'Playfair Display', serif; font-weight: 900;
font-size: clamp(44px, 5vw, 86px); line-height: 0.98; letter-spacing: -0.02em;
margin-bottom: 5vh; max-width: 18ch;
overflow-wrap: anywhere;
}
.story .desc {
font-family: 'Source Serif 4', serif;
font-size: clamp(17px, 1.55vw, 28px); line-height: 1.45;
max-width: 40ch; margin-bottom: 4vh; opacity: 0.88;
overflow-wrap: anywhere;
}
.story.dark .desc { opacity: 0.85; }
/* Source line — the one editorial accent on story pages. Sits at
two-strength mint to match the brand (Option B): muted on light,
bright on dark. Opacity removed so mint reads as a deliberate
accent, not a muted bone/ink. */
.story .source {
font-family: 'IBM Plex Mono', monospace;
font-size: max(11px, 0.9vw); letter-spacing: 0.2em;
text-transform: uppercase;
overflow-wrap: anywhere;
}
.story.light .source { color: var(--sienna); }
.story.dark .source { color: var(--mint); }
/* Outgoing source anchor — inherit the palette colour from .source,
underline for affordance. rel=noopener noreferrer and target=_blank
are set in HTML; this is purely visual. */
.story .source-link {
color: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
transition: text-decoration-thickness 160ms ease;
}
.story .source-link:hover { text-decoration-thickness: 2px; }
/* Logo ekg dot: mint on every page so the brand "signal" pulse
shows across the whole magazine. Light pages use the muted mint
so it doesn't glare against #fafafa. */
/* Bright mint on DARK backgrounds only (ink cover + dark stories).
Digest pages are light (#fafafa) so they need the dark-mint
variant — bright mint would read as a neon dot on white. */
.cover .wm-logo .wm-ekg-dot,
.story.dark .wm-logo .wm-ekg-dot { fill: var(--mint); }
.digest .wm-logo .wm-ekg-dot,
.story.light .wm-logo .wm-ekg-dot { fill: var(--sienna); }
.story .right { display: flex; flex-direction: column; justify-content: center; }
.story .callout {
background: rgba(0, 0, 0, 0.05);
border-left: 4px solid currentColor;
padding: 5vh 3vw 5vh 3vw;
}
.story.dark .callout {
background: rgba(242, 237, 228, 0.06);
border-left-color: var(--bone);
}
.story .callout .label {
font-family: 'IBM Plex Mono', monospace;
font-size: max(11px, 0.85vw); font-weight: 600;
letter-spacing: 0.22em; text-transform: uppercase;
margin-bottom: 3vh; opacity: 0.75;
}
.story .callout .note {
font-family: 'Source Serif 4', serif;
font-size: clamp(17px, 1.55vw, 28px); line-height: 1.5; opacity: 0.82;
overflow-wrap: anywhere;
}
.nav-dots {
position: fixed; bottom: 3.5vh; left: 50%;
transform: translateX(-50%);
display: flex; gap: 0.9vw; z-index: 20;
padding: 0.9vh 1.4vw;
background: rgba(20, 20, 20, 0.55);
backdrop-filter: blur(8px); border-radius: 999px;
}
.nav-dots button {
width: 9px; height: 9px; border-radius: 50%; border: none;
background: rgba(255, 255, 255, 0.3);
cursor: pointer; padding: 0;
transition: all 220ms ease;
}
.nav-dots button.digest-dot { background: rgba(139, 58, 31, 0.55); }
.nav-dots button.active {
background: rgba(255, 255, 255, 0.95);
width: 26px; border-radius: 5px;
}
.nav-dots button.active.digest-dot { background: var(--sienna); }
.hint {
position: fixed; bottom: 3.5vh; right: 3vw;
font-family: 'IBM Plex Mono', monospace;
font-size: 10px; letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
z-index: 20; mix-blend-mode: difference;
}
.page-number {
position: absolute; top: 5vh; right: 4vw;
font-family: 'IBM Plex Mono', monospace;
font-size: max(11px, 0.85vw);
letter-spacing: 0.2em; opacity: 0.55;
}
@media (max-width: 640px) {
.page { padding: 5vh 6vw 8vh; }
/* padding-right must clear the absolute .page-number block on the
right. "09 / 12" in IBM Plex Mono at 11px is ~65-70px wide and
.page-number sits at right:5vw; on a 360px Android ~19px + 70px
= ~89px of occupied space. 22vw ≈ 79px at 360px AND ≈ 86px at
393px — enough headroom with a one-vw safety margin. 18vw left
~0 clearance on iPhone SE (Greptile P2). */
.digest .running-head {
flex-direction: column; align-items: flex-start;
gap: 1vh; padding-right: 22vw;
}
.page-number { top: 4vh; right: 5vw; opacity: 0.6; }
.digest h2 { font-size: 10vw; max-width: 22ch; margin-bottom: 4vh; }
.digest blockquote {
font-size: max(17px, 4.6vw); line-height: 1.35;
max-width: 40ch; padding-left: 4vw;
}
.digest .rule { width: 14vw; margin-top: 4vh; }
.digest .stat-row { grid-template-columns: 1fr; gap: 1.5vh; }
.digest .stat-num { font-size: 18vw; }
/* Keep px floors at or above the base-rule floors (17px / 18px)
so very narrow viewports (<375px) never render smaller than
desktop. vw term still scales up on typical phones (4vw ≈ 15.7px
at 393px so the max() picks the px floor). Greptile P2. */
.digest .stat-label { font-size: max(17px, 4vw); }
.digest .thread { font-size: max(17px, 4vw); line-height: 1.5; }
.digest .signal { font-size: max(18px, 4vw); padding-left: 4vw; }
.story { display: flex; flex-direction: column; gap: 4vh; }
.story .left { padding-right: 0; }
.story .rank-ghost { font-size: 62vw; left: -4vw; top: 30%; }
.story h3 { font-size: 9.5vw; max-width: none; margin-bottom: 3vh; }
.story .desc {
font-size: max(16px, 4.4vw); max-width: none;
margin-bottom: 3vh; line-height: 1.5;
}
.story .tag-row { gap: 2vw; margin-bottom: 3vh; }
.story .tag { font-size: 11px; padding: 0.4em 0.8em; }
.story .source { font-size: 11px; }
.story .right { justify-content: flex-start; }
.story .callout { padding: 3vh 4vw; border-left-width: 3px; }
.story .callout .label { font-size: 11px; margin-bottom: 1.5vh; opacity: 0.7; }
.story .callout .note { font-size: max(16px, 4.2vw); line-height: 1.5; }
}
/* ── Share button (non-public views) ─────────────────────────────
Floating action pill in the top-right chrome. Separate from the
page-number so it doesn't disappear during mobile stacking
overrides. Hidden entirely in public views because a public
reader shouldn't see a "Share" UI (the button relies on the
authenticated /api/brief/share-url endpoint). */
.wm-share {
position: fixed;
top: 3vh; right: 3vw;
z-index: 30;
display: inline-flex; align-items: center; gap: 0.5em;
padding: 0.55em 1em;
background: rgba(20, 20, 20, 0.65);
color: var(--bone);
border: 1px solid rgba(242, 237, 228, 0.25);
border-radius: 999px;
font-family: 'IBM Plex Mono', monospace;
font-size: max(11px, 0.8vw);
letter-spacing: 0.18em;
text-transform: uppercase;
cursor: pointer;
backdrop-filter: blur(8px);
transition: transform 160ms ease, background 160ms ease;
mix-blend-mode: normal;
}
.wm-share:hover { background: rgba(20, 20, 20, 0.85); transform: translateY(-1px); }
.wm-share[data-state="sharing"] { opacity: 0.6; cursor: progress; }
.wm-share[data-state="copied"]::after { content: ' \u00b7 copied'; opacity: 0.75; }
.wm-share[data-state="error"]::after { content: ' \u00b7 error'; opacity: 0.75; color: #ff9b9b; }
/* ── Public view: Subscribe banner ─────────────────────────────── */
.wm-public-strip {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 30;
display: flex; align-items: center; justify-content: center;
gap: 1em;
padding: 0.8em 1.2em;
background: var(--ink);
color: var(--bone);
border-bottom: 1px solid rgba(242, 237, 228, 0.2);
font-family: 'IBM Plex Mono', monospace;
font-size: max(11px, 0.75vw);
letter-spacing: 0.15em;
text-transform: uppercase;
}
.wm-public-strip a {
color: var(--mint, #4ade80);
text-decoration: none;
border-bottom: 1px solid currentColor;
}
@media (max-width: 640px) {
.wm-public-strip { font-size: 11px; padding: 0.7em 1em; gap: 0.6em; flex-wrap: wrap; }
}
</style>`;
/**
* Inline share-button client. The hosted magazine route has already
* derived the share URL server-side (it has the userId, issueSlot,
* and BRIEF_SHARE_SECRET — the same inputs the share-url endpoint
* uses) and embedded it as `data-share-url` on the button. At click
* time we just invoke navigator.share with a clipboard fallback.
*
* No network, no auth — the per-user magazine route's HMAC token
* check already proved this reader is authorised to share the brief
* they are viewing. Deriving the URL at render time instead of click
* time also means the button works in a fresh tab with no Clerk
* session context (common path: reader opened the magazine from an
* email link in a browser they're not signed into).
*
* Emitted only for non-public views AND only when data-share-url is
* present on the button (i.e. BRIEF_SHARE_SECRET was configured).
*/
const SHARE_SCRIPT = `<script>
(function() {
var btn = document.querySelector('.wm-share');
if (!btn) return;
var shareUrl = btn.dataset.shareUrl;
if (!shareUrl) return;
btn.addEventListener('click', async function() {
if (btn.dataset.state === 'sharing') return;
btn.dataset.state = 'sharing';
try {
var shareTitle = 'WorldMonitor Brief';
var shareText = 'My WorldMonitor Brief for today:';
if (navigator.share) {
try {
await navigator.share({ title: shareTitle, text: shareText, url: shareUrl });
btn.dataset.state = 'copied';
return;
} catch (err) {
if (err && (err.name === 'AbortError' || /abort/i.test(String(err.message)))) {
btn.dataset.state = '';
return;
}
// Fall through to clipboard on non-abort share errors.
}
}
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
btn.dataset.state = 'copied';
} else {
// Ancient browser. Show the URL so the user can copy manually.
window.prompt('Copy the link below:', shareUrl);
btn.dataset.state = 'copied';
}
} catch (err) {
btn.dataset.state = 'error';
try { console.warn('[brief] share failed:', err); } catch (_) {}
} finally {
setTimeout(function() { if (btn.dataset.state !== 'sharing') btn.dataset.state = ''; }, 2400);
}
});
})();
</script>`;
// Umami analytics loader, mirroring the production snippet in
// index.html. Hosted magazine pages are served from worldmonitor.app
// (the auth'd route) and the public-share hash mirror — both within
// `data-domains`. The `async` script never blocks rendering; if it's
// blocked by an extension, BRIEF_THREAD_OPEN_SCRIPT silently no-ops.
// Same data-website-id as the dashboard so events land in the same
// project — segmentation is via event properties, not website ids.
const UMAMI_LOADER = '<script async src="https://abacus.worldmonitor.app/script.js" data-website-id="e8800335-c853-46a8-8497-c993ed2f58bc" data-domains="worldmonitor.app,tech.worldmonitor.app,finance.worldmonitor.app,commodity.worldmonitor.app,happy.worldmonitor.app"></script>';
/**
* U11 telemetry: emit a `brief-thread-open` event whenever a story
* source-link is clicked from inside the magazine. Properties are
* baked at render time as `data-*` attributes on the anchor:
* - data-country : ISO-2 (or absent on stories without one)
* - data-severity : 'critical' | 'high' | 'medium' | 'low'
* - data-followed : '1' | '0' (renderer reads recipient watchlist)
*
* Fire-and-forget. `window.umami?.track(...)` short-circuits when
* the script blocked / hasn't loaded — the click then proceeds to
* navigation as if no tracker existed. We do NOT preventDefault
* even on transient analytics failure: the user clicked a source
* link and they get the source.
*/
const BRIEF_THREAD_OPEN_SCRIPT = `<script>
(function() {
function emit(el) {
try {
if (!window.umami || typeof window.umami.track !== 'function') return;
var country = el.dataset.country || null;
var severity = el.dataset.severity || null;
var followed = el.dataset.followed === '1';
window.umami.track('brief-thread-open', {
country: country,
followed: followed,
severity: severity,
source: 'magazine',
});
} catch (e) { /* swallow — never break navigation */ }
}
document.addEventListener('click', function(ev) {
var el = ev.target;
while (el && el.nodeType === 1) {
if (el.dataset && el.dataset.threadOpen === '1') {
emit(el);
return;
}
el = el.parentNode;
}
}, { capture: true });
})();
</script>`;
const NAV_SCRIPT = `<script>
(function() {
var deck = document.getElementById('deck');
if (!deck) return;
var pages = deck.querySelectorAll('.page');
var dotsContainer = document.getElementById('navDots');
var total = pages.length;
var current = 0;
var wheelLock = false;
var touchStartX = 0;
// digest-indexes attribute is a server-built JSON number array.
var digestIndexes = new Set(JSON.parse(deck.dataset.digestIndexes || '[]'));
for (var i = 0; i < total; i++) {
var b = document.createElement('button');
b.setAttribute('aria-label', 'Go to page ' + (i + 1));
if (digestIndexes.has(i)) b.classList.add('digest-dot');
(function(idx) { b.addEventListener('click', function() { go(idx); }); })(i);
dotsContainer.appendChild(b);
}
var dots = dotsContainer.querySelectorAll('button');
function render() {
deck.style.transform = 'translateX(-' + (current * 100) + 'vw)';
for (var i = 0; i < dots.length; i++) {
if (i === current) dots[i].classList.add('active');
else dots[i].classList.remove('active');
}
}
function go(i) { current = Math.max(0, Math.min(total - 1, i)); render(); }
function next() { go(current + 1); }
function prev() { go(current - 1); }
window.addEventListener('keydown', function(e) {
// ArrowRight/Left are the deck axis — always paginate, no scroll
// conflict. PageDown/PageUp/Space conventionally scroll a long page
// in normal browsers; defer to native page scroll when the current
// page has remaining scroll in that direction, paginate only at the
// scroll edge. Matches the wheel-handler behaviour so keyboard and
// mouse users see the same model.
if (e.key === 'ArrowRight') { e.preventDefault(); next(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); }
else if (e.key === 'PageDown' || e.key === ' ') {
if (pageCanScrollVertical(pages[current], 1)) return;
e.preventDefault(); next();
} else if (e.key === 'PageUp') {
if (pageCanScrollVertical(pages[current], -1)) return;
e.preventDefault(); prev();
}
else if (e.key === 'Home') { e.preventDefault(); go(0); }
else if (e.key === 'End') { e.preventDefault(); go(total - 1); }
});
// Wheel handler defers to per-page native scroll first. The page CSS
// is overflow-y: auto, so content longer than 100vh scrolls inside the
// page. Only advance/retreat the deck when the user is wheeling
// PAST the scroll edge in that direction — otherwise a long page is
// unreachable past 100vh because every wheel tick paginates instead
// of scrolling (user-reported: "if I try to scroll down to read it,
// it just goes to the next page instead"). Vertical wheel falls
// through to the page; horizontal wheel still paginates immediately
// (the deck axis IS horizontal, no scroll conflict to resolve).
function pageCanScrollVertical(page, deltaY) {
if (!page) return false;
var maxScroll = page.scrollHeight - page.clientHeight;
if (maxScroll <= 0) return false; // page content fits — paginate
if (deltaY > 0) return page.scrollTop < maxScroll - 1; // room to scroll down
if (deltaY < 0) return page.scrollTop > 1; // room to scroll up
return false;
}
window.addEventListener('wheel', function(e) {
if (wheelLock) return;
var isVertical = Math.abs(e.deltaY) > Math.abs(e.deltaX);
var delta = isVertical ? e.deltaY : e.deltaX;
if (Math.abs(delta) < 12) return;
if (isVertical && pageCanScrollVertical(pages[current], e.deltaY)) {
// Let the native scroll on .page take this wheel event.
return;
}
wheelLock = true;
if (delta > 0) next(); else prev();
setTimeout(function() { wheelLock = false; }, 620);
}, { passive: true });
window.addEventListener('touchstart', function(e) { touchStartX = e.touches[0].clientX; }, { passive: true });
window.addEventListener('touchend', function(e) {
var dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) < 50) return;
if (dx < 0) next(); else prev();
}, { passive: true });
render();
})();
</script>`;
// ── Main entry ───────────────────────────────────────────────────────────────
/**
* Replace per-user / personal fields with generic placeholders so a
* brief can be rendered on the unauth'd public share mirror without
* leaking the recipient's name or the LLM-generated whyMatters (which
* is framed as direct advice to that specific reader).
*
* Runs AFTER assertBriefEnvelope so the full contract is still
* enforced on the input — we never loosen validation for the public
* path, only redact the output.
*
* Lead-field handling (v3, 2026-04-25): the personalised `digest.lead`
* can carry profile context (watched assets, region preferences) and
* MUST NEVER be served on the public surface. v3 envelopes carry
* `digest.publicLead` — a non-personalised parallel synthesis from
* generateDigestProsePublic — which we substitute into the `lead`
* slot so all downstream renderers stay agnostic to the public/
* personalised distinction. When `publicLead` is absent (v2
* envelopes still in the 7-day TTL window, or v3 envelopes where
* the publicLead generation failed), we substitute an EMPTY string
* — the renderer's pull-quote block reads "no pull-quote" for empty
* leads (per renderDigestGreeting), so the page renders without
* leaking personalised content. NEVER fall through to the original
* `lead`. Codex Round-2 High (security).
*
* @param {BriefData} data
* @returns {BriefData}
*/
function redactForPublic(data) {
const safeLead = typeof data.digest?.publicLead === 'string' && data.digest.publicLead.length > 0
? data.digest.publicLead
: '';
// Public signals: substitute the publicSignals array (also produced
// by generateDigestProsePublic with profile=null) when present.
// When absent, EMPTY the signals array — the renderer's hasSignals
// gate then omits the entire "04 · Signals" page rather than
// serving the personalised forward-looking phrases (which can echo
// the user's watched assets / regions).
const safeSignals = Array.isArray(data.digest?.publicSignals) && data.digest.publicSignals.length > 0
? data.digest.publicSignals
: [];
// Public threads: substitute publicThreads when present (preferred
// — the public synthesis still produces topic clusters from story
// content). When absent, fall back to category-derived stubs so
// the threads page still renders without leaking any personalised
// phrasing the original `threads` array might carry.
const safeThreads = Array.isArray(data.digest?.publicThreads) && data.digest.publicThreads.length > 0
? data.digest.publicThreads
: derivePublicThreadsStub(data.stories);
return {
...data,
user: { ...data.user, name: 'WorldMonitor' },
digest: {
...data.digest,
lead: safeLead,
signals: safeSignals,
threads: safeThreads,
},
stories: data.stories.map((s) => ({
...s,
whyMatters: 'Subscribe to WorldMonitor Brief to see the full editorial on this story.',
})),
};
}
/**
* Category-derived threads fallback for the public surface when the
* envelope lacks `publicThreads`. Mirrors deriveThreadsFromStories
* in shared/brief-filter.js (the composer's stub path) — keeps the
* fallback shape identical to what v2 envelopes already render with.
*
* @param {Array<{ category?: unknown }>} stories
* @returns {Array<{ tag: string; teaser: string }>}
*/
function derivePublicThreadsStub(stories) {
if (!Array.isArray(stories) || stories.length === 0) {
return [{ tag: 'World', teaser: 'One thread on the desk today.' }];
}
const byCategory = new Map();
for (const s of stories) {
const tag = typeof s?.category === 'string' && s.category.length > 0 ? s.category : 'World';
byCategory.set(tag, (byCategory.get(tag) ?? 0) + 1);
}
const sorted = [...byCategory.entries()].sort((a, b) => b[1] - a[1]);
return sorted.slice(0, 6).map(([tag, count]) => ({
tag,
teaser: count === 1 ? 'One thread on the desk today.' : `${count} threads on the desk today.`,
}));
}
/**
* @param {BriefEnvelope} envelope
* @param {{ publicMode?: boolean; refCode?: string; shareUrl?: string }} [options]
* @returns {string}
*/
export function renderBriefMagazine(envelope, options = {}) {
assertBriefEnvelope(envelope);
const publicMode = options.publicMode === true;
// refCode shape is validated at the route boundary; the renderer
// still HTML-escapes it before interpolation so this is belt-and-
// suspenders against any accidental leak through that boundary.
const refCode = typeof options.refCode === 'string' ? options.refCode : '';
// shareUrl is expected to be an absolute https URL produced by
// buildPublicBriefUrl at the route level. We accept anything
// non-empty here and still escape it into the attribute; if the
// string is malformed the button's click handler simply fails open
// (prompt fallback). Suppressed entirely on publicMode.
const shareUrl = !publicMode && typeof options.shareUrl === 'string' && options.shareUrl.length > 0
? options.shareUrl
: '';
// U11 telemetry plumbing. The auth'd magazine route fetches the
// recipient's followed-countries via the relay and passes them
// here; the public-mirror route MUST NOT (no recipient identity).
// Defensive filter: each entry must be a non-empty string we can
// upper-case — anything else (null, number, the symbol-shaped
// entry an upstream regression once produced) gets dropped before
// the Set is built so a renderer crash can't leak from a relay bug.
const rawFollowed = Array.isArray(options.followedCountries)
? options.followedCountries
: [];
/** @type {Set<string>} */
const followedSet = new Set();
if (!publicMode) {
for (const entry of rawFollowed) {
if (typeof entry === 'string' && entry.length > 0) {
followedSet.add(entry.toUpperCase());
}
}
}
const rawData = publicMode ? redactForPublic(envelope.data) : envelope.data;
const { user, issue, date, dateLong, digest, stories } = rawData;
const [, month, day] = date.split('-');
const dateShort = `${day}.${month}`;
const threads = digest.threads;
const hasSignals = digest.signals.length > 0;
const splitThreads = threads.length > MAX_THREADS_PER_PAGE;
// Total page count is fully data-derived, computed up front, so every
// page renderer knows its position without a two-pass build.
const totalPages =
1 // cover
+ 1 // digest 01 greeting
+ 1 // digest 02 numbers
+ (splitThreads ? 2 : 1) // digest 03 on the desk (split if needed)
+ (hasSignals ? 1 : 0) // digest 04 signals (conditional)
+ stories.length
+ 1; // back cover
/** @type {string[]} */
const pagesHtml = [];
/** @type {number[]} */
const digestIndexes = [];
let p = 0;
pagesHtml.push(
renderCover({
dateLong,
issue,
storyCount: stories.length,
pageIndex: ++p,
totalPages,
greeting: digest.greeting,
}),
);
digestIndexes.push(p);
pagesHtml.push(
renderDigestGreeting({
greeting: digest.greeting,
lead: digest.lead,
dateShort,
pageIndex: ++p,
totalPages,
}),
);
digestIndexes.push(p);
pagesHtml.push(
renderDigestNumbers({
numbers: digest.numbers,
date,
dateShort,
pageIndex: ++p,
totalPages,
}),
);
const threadsPages = splitThreads
? [threads.slice(0, Math.ceil(threads.length / 2)), threads.slice(Math.ceil(threads.length / 2))]
: [threads];
threadsPages.forEach((slice, i) => {
const label = threadsPages.length === 1
? 'Digest / 03 — On The Desk'
: `Digest / 03${i === 0 ? 'a' : 'b'} — On The Desk`;
const heading = i === 0 ? 'What the desk is watching.' : '\u2026 continued.';
digestIndexes.push(p);
pagesHtml.push(
renderDigestThreadsPage({
threads: slice,
dateShort,
label,
heading,
includeEndMarker: i === threadsPages.length - 1 && !hasSignals,
pageIndex: ++p,
totalPages,
}),
);
});
if (hasSignals) {
digestIndexes.push(p);
pagesHtml.push(
renderDigestSignals({
signals: digest.signals,
dateShort,
pageIndex: ++p,
totalPages,
}),
);
}
stories.forEach((story, i) => {
pagesHtml.push(
renderStoryPage({
story,
rank: i + 1,
palette: i % 2 === 0 ? 'light' : 'dark',
pageIndex: ++p,
totalPages,
issueDate: date,
followedSet,
}),
);
});
pagesHtml.push(
renderBackCover({
tz: user.tz,
pageIndex: ++p,
totalPages,
publicMode,
refCode,
}),
);
const title = `WorldMonitor Brief · ${escapeHtml(dateLong)}`;
// In public view: the per-hash mirror is noindexed via the HTTP
// header AND a meta tag, and we prepend a subscribe strip pointing
// at /pro (with optional referral attribution).
const publicStripHref = `https://worldmonitor.app/pro${refCode ? `?ref=${encodeURIComponent(refCode)}` : ''}`;
const publicStripHtml = publicMode
? '<div class="wm-public-strip">'
+ '<span>WorldMonitor Brief \u00b7 shared issue</span>'
// Match renderBackCover's pattern: escapeHtml on the full href
// even though encodeURIComponent already handles HTML-special
// chars inside refCode — consistency for anyone auditing XSS
// hygiene, and a safety net if the route boundary loosens.
+ `<a href="${escapeHtml(publicStripHref)}" target="_blank" rel="noopener">`
+ 'Subscribe \u2192</a>'
+ '</div>'
: '';
// Only render the Share button on authenticated (non-public) views
// AND only when the route was able to derive a share URL (i.e.
// BRIEF_SHARE_SECRET is configured and the pointer write
// succeeded). The URL is embedded as data-share-url and read at
// click time by SHARE_SCRIPT — no fetch, no auth required
// client-side.
const shareButtonHtml = shareUrl
? `<button class="wm-share" type="button" data-share-url="${escapeHtml(shareUrl)}" data-issue-date="${escapeHtml(date)}" aria-label="Share this brief">Share</button>`
: '';
const headMeta = publicMode
? '<meta name="robots" content="noindex,nofollow">'
: '';
return (
'<!DOCTYPE html>' +
'<html lang="en">' +
'<head>' +
'<meta charset="UTF-8" />' +
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />' +
headMeta +
`<title>${title}</title>` +
'<link rel="preconnect" href="https://fonts.googleapis.com">' +
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' +
`<link href="${FONTS_HREF}" rel="stylesheet">` +
UMAMI_LOADER +
STYLE_BLOCK +
'</head>' +
'<body>' +
LOGO_SYMBOL +
publicStripHtml +
shareButtonHtml +
`<div class="deck" id="deck" data-digest-indexes='${JSON.stringify(digestIndexes)}'>` +
pagesHtml.join('') +
'</div>' +
'<div class="nav-dots" id="navDots"></div>' +
'<div class="hint">← → / swipe / scroll</div>' +
(shareUrl ? SHARE_SCRIPT : '') +
BRIEF_THREAD_OPEN_SCRIPT +
NAV_SCRIPT +
'</body>' +
'</html>'
);
}