Files
vyndr/src/services/content/cardRenderer.js
T
builtbykev 74aa75945e Content engine: posts that structurally cannot lie
PHASE 0 — contentEngine makes Truth Law structural, not careful. Copy is
token-substituted and an unbacked {token} REFUSES to render -- there is no
code path that produces a plausible default. The fact contract is asserted
before any string is built. Card and copy render from ONE fact object, so
a caption and a card cannot disagree. No live model writes factual claims:
the voice is in the template, the facts are pulled, and the voice-polish
port is deliberately unwired, because an LLM that can rewrite a sentence
can rewrite a number.

18 tests carry the proof. The one that matters most: ZERO IS PRESENT.
"0 cleared B+" is our most honest possible post, and treating 0 as missing
would be the Number(null)===0 breach wearing its opposite coat -- it would
silently delete exactly the post the brand is built on.

PHASE 1 — three templates, generating real posts from tonight's data:
hot hitters off the repaired full-season log, the honesty flex off the
real servedGrade distribution (2,140 graded / 70 cleared B+ / 42% not
separable / A unissuable), and streaks verified from settled outcomes only.

THE ENGINE CAUGHT A BUG IN ITSELF, and it is the sharpest lesson here. The
first run published "No hitter is meaningfully hot tonight -- we could
dress up a middling week as a streak. We don't." That was FALSE: the
box-score cache spans only the settled window, every player had under 20
games, and the pool was empty. A broken pull was publishing as considered
editorial judgement -- the fourth appearance of this class tonight and the
first where our OWN HONESTY COPY was the disguise.

Fixed structurally rather than by patching the number: an absent() variant
may now DECLINE to speak, and the template separates "no candidates at
all" (SKIP with a reason) from "candidates judged, none hot" (honest
absence). Both locked by test. Source corrected to mlbStatsAdapter.fullLog,
the same log the repaired champion reads.

PHASE 2 — cardRenderer emits SVG rather than canvas: it is text, so it
diffs in review and its numbers are greppable, which matters when the
whole claim is that the numbers are real. VYND white + R green, slashed-Y,
scanlines, mono. The card never formats its own facts -- every string
arrives pre-rendered and gate-checked.

PHASE 3 — scripts/generate-content.js writes copy + card per template to
.content-out/<date>/. Template N+1 is a registry entry: requires, pull,
copy, card, absent. Queued as stubs, not built: hot takes, daily reads,
"grades we DIDN'T give", cross-sport streak variants (the streak template
is already sport-agnostic -- settled outcomes and a noun).

FULLY ISOLATED: read-only on every source, zero writes to serving, model
or ledger tables. Serving fingerprint verified unchanged. The accrual clock
is untouched at 0 eligible dates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 16:34:57 -04:00

84 lines
3.8 KiB
JavaScript

'use strict';
/**
* cardRenderer — one card engine, per-template layouts.
*
* SVG rather than canvas: it is text, so it has no native dependency, it
* diffs in review, and the numbers inside it are greppable — which matters when
* the whole claim is that the numbers are real. A card whose contents cannot be
* inspected without opening an image is a bad fit for a Truth-Law product.
*
* The card never formats its own facts. Every string arrives already rendered
* and already gate-checked by contentEngine, so a caption and a card physically
* cannot disagree.
*/
const BRAND = Object.freeze({
bg: '#05070A',
panel: '#0A0E14',
line: '#1A222E',
green: '#00D4A0', // the R
white: '#FFFFFF', // VYND
dim: '#6B7A8D',
amber: '#FFB347',
mono: "ui-monospace, 'JetBrains Mono', 'SFMono-Regular', Menlo, monospace",
});
const W = 1080;
const H = 1350;
const esc = (s) => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
/** The slashed-Y wordmark: VYND white, R green. */
function wordmark(x, y, size = 34) {
return `
<g font-family="${BRAND.mono}" font-size="${size}" font-weight="800" letter-spacing="${size * 0.09}">
<text x="${x}" y="${y}" fill="${BRAND.white}">VYND</text>
<text x="${x + size * 2.92}" y="${y}" fill="${BRAND.green}">R</text>
<line x1="${x + size * 1.02}" y1="${y - size * 0.78}" x2="${x + size * 1.42}" y2="${y + size * 0.22}"
stroke="${BRAND.green}" stroke-width="${Math.max(2, size * 0.07)}" opacity=".85"/>
</g>`;
}
/**
* Render a card model to SVG.
* @param {object} card { kind, title, subtitle, lines[], footer }
*/
function toSvg(card = {}) {
const lines = card.lines || [];
let y = 300;
const body = lines.map((l) => {
const text = typeof l === 'string' ? l : l.text;
const style = (typeof l === 'object' && l.style) || 'body';
let out = '';
if (style === 'rule') {
out = `<line x1="72" y1="${y - 18}" x2="${W - 72}" y2="${y - 18}" stroke="${BRAND.line}" stroke-width="1"/>`;
y += 26;
return out;
}
const size = style === 'lead' ? 46 : style === 'stat' ? 40 : 30;
const fill = style === 'stat' ? BRAND.green : style === 'dim' ? BRAND.dim : BRAND.white;
const weight = style === 'body' ? 500 : 800;
out = `<text x="72" y="${y}" font-family="${BRAND.mono}" font-size="${size}" font-weight="${weight}" fill="${fill}">${esc(text)}</text>`;
y += size + (style === 'lead' ? 26 : 18);
return out;
}).join('\n');
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<rect width="${W}" height="${H}" fill="${BRAND.bg}"/>
<rect x="40" y="40" width="${W - 80}" height="${H - 80}" fill="${BRAND.panel}" stroke="${BRAND.line}"/>
${Array.from({ length: 26 }, (_, i) => `<line x1="40" y1="${52 * i + 40}" x2="${W - 40}" y2="${52 * i + 40}" stroke="${BRAND.white}" stroke-width="1" opacity=".02"/>`).join('')}
${wordmark(72, 128)}
${card.title ? `<text x="72" y="212" font-family="${BRAND.mono}" font-size="54" font-weight="800" fill="${BRAND.white}" letter-spacing="1">${esc(card.title)}</text>` : ''}
${card.subtitle ? `<text x="72" y="256" font-family="${BRAND.mono}" font-size="26" font-weight="600" fill="${BRAND.dim}" letter-spacing="2">${esc(card.subtitle)}</text>` : ''}
<line x1="72" y1="272" x2="${W - 72}" y2="272" stroke="${BRAND.green}" stroke-width="2" opacity=".55"/>
${body}
<text x="72" y="${H - 96}" font-family="${BRAND.mono}" font-size="22" font-weight="600" fill="${BRAND.dim}">${esc(card.footer || 'Every number here is measured. Nothing is projected.')}</text>
<text x="72" y="${H - 62}" font-family="${BRAND.mono}" font-size="20" font-weight="500" fill="${BRAND.line}">vyndr · built by Kevon Butler · Detroit</text>
</svg>`;
}
module.exports = { toSvg, BRAND, W, H };