Files
worldmonitor/server/_shared/brief-render.js
Elie Habib 2f5445284b fix(brief): single canonical synthesis brain — eliminate email/brief lead divergence (#3396)
* feat(brief-llm): canonical synthesis prompt + v3 cache key

Extends generateDigestProse to be the single source of truth for
brief executive-summary synthesis (canonicalises what was previously
split between brief-llm's generateDigestProse and seed-digest-
notifications.mjs's generateAISummary). Ports Brain B's prompt
features into buildDigestPrompt:

- ctx={profile, greeting, isPublic} parameter (back-compat: 4-arg
  callers behave like today)
- per-story severity uppercased + short-hash prefix [h:XXXX] so the
  model can emit rankedStoryHashes for stable re-ranking
- profile lines + greeting opener appear only when ctx.isPublic !== true

validateDigestProseShape gains optional rankedStoryHashes (≥4-char
strings, capped to MAX_STORIES_PER_USER × 2). v2-shaped rows still
pass — field defaults to [].

hashDigestInput v3:
- material includes profile-SHA, greeting bucket, isPublic flag,
  per-story hash
- isPublic=true substitutes literal 'public' for userId in the cache
  key so all share-URL readers of the same (date, sensitivity, pool)
  hit ONE cache row (no PII in public cache key)

Adds generateDigestProsePublic(stories, sensitivity, deps) wrapper —
no userId param by design — for the share-URL surface.

Cache prefix bumped brief:llm:digest:v2 → v3. v2 rows expire on TTL.
Per the v1→v2 precedent (see hashDigestInput comment), one-tick cost
on rollout is acceptable for cache-key correctness.

Tests: 72/72 passing in tests/brief-llm.test.mjs (8 new for the v3
behaviors), full data suite 6952/6952.

Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Step 1, Codex-approved (5 rounds).

* feat(brief): envelope v3 — adds digest.publicLead for share-URL surface

Bumps BRIEF_ENVELOPE_VERSION 2 → 3. Adds optional
BriefDigest.publicLead — non-personalised executive lead generated
by generateDigestProsePublic (already in this branch from the
previous commit) for the public share-URL surface. Personalised
`lead` is the canonical synthesis for authenticated channels;
publicLead is its profile-stripped sibling so api/brief/public/*
never serves user-specific content (watched assets/regions).

SUPPORTED_ENVELOPE_VERSIONS = [1, 2, 3] keeps v1 + v2 envelopes
in the 7-day TTL window readable through the rollout — the
composer only ever writes the current version, but readers must
tolerate older shapes that haven't expired yet. Same rollout
pattern used at the v1 → v2 bump.

Renderer changes (server/_shared/brief-render.js):
- ALLOWED_DIGEST_KEYS gains 'publicLead' (closed-key-set still
  enforced; v2 envelopes pass because publicLead === undefined is
  the v2 shape).
- assertBriefEnvelope: new isNonEmptyString check on publicLead
  when present. Type contract enforced; absence is OK.

Tests (tests/brief-magazine-render.test.mjs):
- New describe block "v3 publicLead field": v3 envelope renders;
  malformed publicLead rejected; v2 envelope still passes; ad-hoc
  digest keys (e.g. synthesisLevel) still rejected — confirming
  the closed-key-set defense holds for the cron-local-only fields
  the orchestrator must NOT persist.
- BRIEF_ENVELOPE_VERSION pin updated 2 → 3 with rollout-rationale
  comment.

Test results: 182 brief-related tests pass; full data suite
6956/6956.

Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Step 2, Codex Round-3 Medium #2.

* feat(brief): synthesis splice + rankedStoryHashes pre-cap re-order

Plumbs the canonical synthesis output (lead, threads, signals,
publicLead, rankedStoryHashes from generateDigestProse) through the
pure composer so the orchestration layer can hand pre-resolved data
into envelope.digest. Composer stays sync / no I/O — Codex Round-2
High #2 honored.

Changes:

scripts/lib/brief-compose.mjs:
- digestStoryToUpstreamTopStory now emits `hash` (the digest story's
  stable identifier, falls back to titleHash when absent). Without
  this, rankedStoryHashes from the LLM has nothing to match against.
- composeBriefFromDigestStories accepts opts.synthesis = {lead,
  threads, signals, rankedStoryHashes?, publicLead?}. When passed,
  splices into envelope.digest after the stub is built. Partial
  synthesis (e.g. only `lead` populated) keeps stub defaults for the
  other fields — graceful degradation when L2 fallback fires.

shared/brief-filter.js:
- filterTopStories accepts optional rankedStoryHashes. New helper
  applyRankedOrder re-orders stories by short-hash prefix match
  BEFORE the cap is applied, so the model's editorial judgment of
  importance survives MAX_STORIES_PER_USER. Stable for ties; stories
  not in the ranking come after in original order. Empty/missing
  ranking is a no-op (legacy callers unchanged).

shared/brief-filter.d.ts:
- filterTopStories signature gains rankedStoryHashes?: string[].
- UpstreamTopStory gains hash?: unknown (carried through from
  digestStoryToUpstreamTopStory).

Tests added (tests/brief-from-digest-stories.test.mjs):
- synthesis substitutes lead/threads/signals/publicLead.
- legacy 4-arg callers (no synthesis) keep stub lead.
- partial synthesis (only lead) keeps stub threads/signals.
- rankedStoryHashes re-orders pool before cap.
- short-hash prefix match (model emits 8 chars; story carries full).
- unranked stories go after in original order.

Test results: 33/33 in brief-from-digest-stories; 182/182 across all
brief tests; full data suite 6956/6956.

Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Step 3, Codex Round-2 Low + Round-2 High #2.

* feat(brief): single canonical synthesis per user; rewire all channels

Restructures the digest cron's per-user compose + send loops to
produce ONE canonical synthesis per user per issueSlot — the lead
text every channel (email HTML, plain-text, Telegram, Slack,
Discord, webhook) and the magazine show is byte-identical. This
eliminates the "two-brain" divergence that was producing different
exec summaries on different surfaces (observed 2026-04-25 0802).

Architecture:

composeBriefsForRun (orchestration):
- Pre-annotates every eligible rule with lastSentAt + isDue once,
  before the per-user pass. Same getLastSentAt helper the send loop
  uses so compose + send agree on lastSentAt for every rule.

composeAndStoreBriefForUser (per-user):
- Two-pass winner walk: try DUE rules first (sortedDue), fall back
  to ALL eligible rules (sortedAll) for compose-only ticks.
  Preserves today's dashboard refresh contract for weekly /
  twice_daily users on non-due ticks (Codex Round-4 High #1).
- Within each pass, walk by compareRules priority and pick the
  FIRST candidate with a non-empty pool — mirrors today's behavior
  at scripts/seed-digest-notifications.mjs:1044 and prevents the
  "highest-priority but empty pool" edge case (Codex Round-4
  Medium #2).
- Three-level synthesis fallback chain:
    L1: generateDigestProse(fullPool, ctx={profile,greeting,!public})
    L2: generateDigestProse(envelope-sized slice, ctx={})
    L3: stub from assembleStubbedBriefEnvelope
  Distinct log lines per fallback level so ops can quantify
  failure-mode distribution.
- Generates publicLead in parallel via generateDigestProsePublic
  (no userId param; cache-shared across all share-URL readers).
- Splices synthesis into envelope via composer's optional
  `synthesis` arg (Step 3); rankedStoryHashes re-orders the pool
  BEFORE the cap so editorial importance survives MAX_STORIES.
- synthesisLevel stored in the cron-local briefByUser entry — NOT
  persisted in the envelope (renderer's assertNoExtraKeys would
  reject; Codex Round-2 Medium #5).

Send loop:
- Reads lastSentAt via shared getLastSentAt helper (single source
  of truth with compose flow).
- briefLead = brief?.envelope?.data?.digest?.lead — the canonical
  lead. Passed to buildChannelBodies (text/Telegram/Slack/Discord),
  injectEmailSummary (HTML email), and sendWebhook (webhook
  payload's `summary` field). All-channel parity (Codex Round-1
  Medium #6).
- Subject ternary reads cron-local synthesisLevel: 1 or 2 →
  "Intelligence Brief", 3 → "Digest" (preserves today's UX for
  fallback paths; Codex Round-1 Missing #5).

Removed:
- generateAISummary() — the second LLM call that produced the
  divergent email lead. ~85 lines.
- AI_SUMMARY_CACHE_TTL constant — no longer referenced. The
  digest:ai-summary:v1:* cache rows expire on their existing 1h
  TTL (no cleanup pass).

Helpers added:
- getLastSentAt(rule) — extracted Upstash GET for digest:last-sent
  so compose + send both call one source of truth.
- buildSynthesisCtx(rule, nowMs) — formats profile + greeting for
  the canonical synthesis call. Preserves all today's prefs-fetch
  failure-mode behavior.

Composer:
- compareRules now exported from scripts/lib/brief-compose.mjs so
  the cron can sort each pass identically to groupEligibleRulesByUser.

Test results: full data suite 6962/6962 (was 6956 pre-Step 4; +6
new compose-synthesis tests from Step 3).

Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Steps 4 + 4b. Codex-approved (5 rounds).

* fix(brief-render): public-share lead fail-safe — never leak personalised lead

Public-share render path (api/brief/public/[hash].ts → renderer
publicMode=true) MUST NEVER serve the personalised digest.lead
because that string can carry profile context — watched assets,
saved-region names, etc. — written by generateDigestProse with
ctx.profile populated.

Previously: redactForPublic redacted user.name and stories.whyMatters
but passed digest.lead through unchanged. Codex Round-2 High
(security finding).

Now (v3 envelope contract):
- redactForPublic substitutes digest.lead = digest.publicLead when
  the v3 envelope carries one (generated by generateDigestProsePublic
  with profile=null, cache-shared across all public readers).
- When publicLead is absent (v2 envelope still in TTL window OR v3
  envelope where publicLead generation failed), redactForPublic sets
  digest.lead to empty string.
- renderDigestGreeting: when lead is empty, OMIT the <blockquote>
  pull-quote entirely. Page still renders complete (greeting +
  horizontal rule), just without the italic lead block.
- NEVER falls back to the original personalised lead.

assertBriefEnvelope still validates publicLead's contract (when
present, must be a non-empty string) BEFORE redactForPublic runs,
so a malformed publicLead throws before any leak risk.

Tests added (tests/brief-magazine-render.test.mjs):
- v3 envelope renders publicLead in pull-quote, personalised lead
  text never appears.
- v2 envelope (no publicLead) omits pull-quote; rest of page
  intact.
- empty-string publicLead rejected by validator (defensive).
- private render still uses personalised lead.

Test results: 68 brief-magazine-render tests pass; full data suite
remains green from prior commit.

Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Step 5, Codex Round-2 High (security).

* feat(digest): brief lead parity log + extra acceptance tests

Adds the parity-contract observability line and supplementary
acceptance tests for the canonical synthesis path.

Parity log (per send, after successful delivery):
  [digest] brief lead parity user=<id> rule=<v>:<s>:<lang>
    synthesis_level=<1|2|3> exec_len=<n> brief_lead_len=<n>
    channels_equal=<bool> public_lead_len=<n>

When channels_equal=false an extra WARN line fires —
"PARITY REGRESSION user=… — email lead != envelope lead." Sentry's
existing console-breadcrumb hook lifts this without an explicit
captureMessage call. Plan acceptance criterion A5.

Tests added (tests/brief-llm.test.mjs, +9):
- generateDigestProsePublic: two distinct callers with identical
  (sensitivity, story-pool) hit the SAME cache row (per Codex
  Round-2 Medium #4 — "no PII in public cache key").
- public + private writes never collide on cache key (defensive).
- greeting bucket change re-keys the personalised cache (Brain B
  parity).
- profile change re-keys the personalised cache.
- v3 cache prefix used (no v2 writes).

Test results: 77/77 in brief-llm; full data suite 6971/6971
(was 6962 pre-Step-7; +9 new public-cache tests).

Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Steps 6 (partial) + 7. Acceptance A5, A6.g, A6.f.

* test(digest): backfill A6.h/i/l/m acceptance tests via helper extraction

* fix(brief): close two correctness regressions on multi-rule + public surface

Two findings from human review of the canonical-synthesis PR:

1. Public-share redaction leaked personalised signals + threads.
   The new prompt explicitly personalises both `lead` and `signals`
   ("personalise lead and signals"), but redactForPublic only
   substituted `lead` — leaving `signals` and `threads` intact.
   Public renderer's hasSignals gate would emit the signals page
   whenever `digest.signals.length > 0`, exposing watched-asset /
   region phrasing to anonymous readers. Same privacy bug class
   the original PR was meant to close, just on different fields.

2. Multi-rule users got cross-pool lead/storyList mismatch.
   composeAndStoreBriefForUser picks ONE winning rule for the
   canonical envelope. The send loop then injected that ONE
   `briefLead` into every due rule's channel body — even though
   each rule's storyList came from its own (per-rule) digest pool.
   Multi-rule users (e.g. `full` + `finance`) ended up with email
   bodies leading on geopolitics while listing finance stories.
   Cross-rule editorial mismatch reintroduced after the cross-
   surface fix.

Fix 1 — public signals + threads:
- Envelope shape: BriefDigest gains `publicSignals?: string[]` +
  `publicThreads?: BriefThread[]` (sibling fields to publicLead).
  Renderer's ALLOWED_DIGEST_KEYS extended; assertBriefEnvelope
  validates them when present.
- generateDigestProsePublic already returned a full prose object
  (lead + signals + threads) — orchestration now captures all
  three instead of just `.lead`. Composer splices each into its
  envelope slot.
- redactForPublic substitutes:
    digest.lead    ← publicLead (or empty → omits pull-quote)
    digest.signals ← publicSignals (or empty → omits signals page)
    digest.threads ← publicThreads (or category-derived stub via
                     new derivePublicThreadsStub helper — never
                     falls back to the personalised threads)
- New tests cover all three substitutions + their fail-safes.

Fix 2 — per-rule synthesis in send loop:
- Each due rule independently calls runSynthesisWithFallback over
  ITS OWN pool + ctx. Channel body lead is internally consistent
  with the storyList (both from the same pool).
- Cache absorbs the cost: when this is the winner rule, the
  synthesis hits the cache row written during the compose pass
  (same userId/sensitivity/pool/ctx) — no extra LLM call. Only
  multi-rule users with non-overlapping pools incur additional
  LLM calls.
- magazineUrl still points at the winner's envelope (single brief
  per user per slot — `(userId, issueSlot)` URL contract). Channel
  lead vs magazine lead may differ for non-winner rule sends;
  documented as acceptable trade-off (URL/key shape change to
  support per-rule magazines is out of scope for this PR).
- Parity log refined: adds `winner_match=<bool>` field. The
  PARITY REGRESSION warning now fires only when winner_match=true
  AND the channel lead differs from the envelope lead (the actual
  contract regression). Non-winner sends with legitimately
  different leads no longer spam the alert.

Test results:
- tests/brief-magazine-render.test.mjs: 75/75 (+7 new for public
  signals/threads + validator + private-mode-ignores-public-fields)
- Full data suite: 6995/6995 (was 6988; +7 net)
- typecheck + typecheck:api: clean

Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md
Addresses 2 review findings on PR #3396 not anticipated in the
5-round Codex review.

* fix(brief): unify compose+send window, fall through filter-rejection

Address two residual risks in PR #3396 (single-canonical-brain refactor):

Risk 1 — canonical lead synthesized from a fixed 24h pool while the
send loop ships stories from `lastSentAt ?? 24h`. For weekly users
that meant a 24h-pool lead bolted onto a 7d email body — the same
cross-surface divergence the refactor was meant to eliminate, just in
a different shape. Twice-daily users hit a 12h-vs-24h variant.

Fix: extract the window formula to `digestWindowStartMs(lastSentAt,
nowMs, defaultLookbackMs)` in digest-orchestration-helpers.mjs and
call it from BOTH the compose path's digestFor closure AND the send
loop. The compose path now derives windowStart per-candidate from
`cand.lastSentAt`, identical to what the send loop will use for that
rule. Removed the now-unused BRIEF_STORY_WINDOW_MS constant.

Side-effect: digestFor now receives the full annotated candidate
(`cand`) instead of just the rule, so it can reach `cand.lastSentAt`.
Backwards-compatible at the helper level — pickWinningCandidateWithPool
forwards `cand` instead of `cand.rule`.

Cache memo hit rate drops since lastSentAt varies per-rule, but
correctness > a few extra Upstash GETs.

Risk 2 — pickWinningCandidateWithPool returned the first candidate
with a non-empty raw pool as winner. If composeBriefFromDigestStories
then dropped every story (URL/headline/shape filters), the caller
bailed without trying lower-priority candidates. Pre-PR behaviour was
to keep walking. This regressed multi-rule users whose top-priority
rule's pool happens to be entirely filter-rejected.

Fix: optional `tryCompose(cand, stories)` callback on
pickWinningCandidateWithPool. When provided, the helper calls it after
the non-empty pool check; falsy return → log filter-rejected and walk
to the next candidate; truthy → returns `{winner, stories,
composeResult}` so the caller can reuse the result. Without the
callback, legacy semantics preserved (existing tests + callers
unaffected).

Caller composeAndStoreBriefForUser passes a no-synthesis compose call
as tryCompose — cheap pure-JS, no I/O. Synthesis only runs once after
the winner is locked in, so the perf cost is one extra compose per
filter-rejected candidate, no extra LLM round-trips.

Tests:
- 10 new cases in tests/digest-orchestration-helpers.test.mjs
  covering: digestFor receiving full candidate; tryCompose
  fall-through to lower-priority; all-rejected returns null;
  composeResult forwarded; legacy semantics without tryCompose;
  digestWindowStartMs lastSentAt-vs-default branches; weekly +
  twice-daily window parity assertions; epoch-zero ?? guard.
- Updated tests/digest-cache-key-sensitivity.test.mjs static-shape
  regex to match the new `cand.rule.sensitivity` cache-key shape
  (intent unchanged: cache key MUST include sensitivity).

Stacked on PR #3396 — targets feat/brief-two-brain-divergence.
2026-04-25 16:22:31 +04:00

1473 lines
59 KiB
JavaScript

// 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',
'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 on v2 and absent on v1. When present on
// either version, it must parse cleanly — a malformed URL would
// break the href. On v1 it's expected to be absent; a v1 envelope
// that somehow carries a sourceUrl is still validated (cheap
// defence against composer regressions).
if (env.version === BRIEF_ENVELOPE_VERSION || st.sourceUrl !== undefined) {
try {
validateSourceUrl(st.sourceUrl);
} catch (err) {
throw new Error(
`envelope.data.stories[${i}].sourceUrl ${/** @type {Error} */ (err).message}`,
);
}
}
});
// 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;
}
}
/**
* @param {{ story: BriefStory; rank: number; palette: 'light' | 'dark'; pageIndex: number; totalPages: number; issueDate: string }} opts
*/
function renderStoryPage({ story, rank, palette, pageIndex, totalPages, issueDate }) {
const threatClass = HIGHLIGHTED_LEVELS.has(story.threatLevel) ? ' crit' : '';
const threatLabel = THREAT_LABELS[story.threatLevel];
// 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">${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;
position: relative; overflow: hidden;
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: 10vw; line-height: 0.92; letter-spacing: -0.03em;
margin-bottom: 6vh;
}
.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: 8vw; }
.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: 7vw; line-height: 0.98; letter-spacing: -0.02em;
margin-bottom: 6vh; max-width: 18ch;
}
.digest blockquote {
font-family: 'Source Serif 4', serif; font-style: italic;
font-size: 2vw; line-height: 1.38; max-width: 32ch;
margin-bottom: 5vh; padding-left: 2vw;
border-left: 3px solid var(--sienna);
}
.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: 11vw; 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;
}
.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: max(17px, 1.55vw); line-height: 1.45;
color: var(--cream-ink);
}
.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: max(18px, 1.65vw); line-height: 1.45;
color: var(--cream-ink); padding-left: 2vw;
border-left: 2px solid var(--sienna);
}
.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;
}
.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: 5vw; line-height: 0.98; letter-spacing: -0.02em;
margin-bottom: 5vh; max-width: 18ch;
}
.story .desc {
font-family: 'Source Serif 4', serif;
font-size: max(17px, 1.55vw); line-height: 1.45;
max-width: 40ch; margin-bottom: 4vh; opacity: 0.88;
}
.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;
}
.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: max(17px, 1.55vw); line-height: 1.5; opacity: 0.82;
}
.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, issueDate,
* 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>`;
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) {
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); next(); }
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); prev(); }
else if (e.key === 'Home') { e.preventDefault(); go(0); }
else if (e.key === 'End') { e.preventDefault(); go(total - 1); }
});
window.addEventListener('wheel', function(e) {
if (wheelLock) return;
var delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
if (Math.abs(delta) < 12) 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
: '';
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,
}),
);
});
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">` +
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 : '') +
NAV_SCRIPT +
'</body>' +
'</html>'
);
}