Compare commits
14 Commits
ecf78b911c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 387ae4d54e | |||
| 71d3b7b786 | |||
| 49e76068da | |||
| 60469422af | |||
| 7a318ccce0 | |||
| c575a708c7 | |||
| 74aa75945e | |||
| 08791520fc | |||
| 55b210cb95 | |||
| 981a05cbd6 | |||
| 09186ea609 | |||
| 3591c7626e | |||
| 91927a4a8a | |||
| 9159b7e1b9 |
@@ -29,3 +29,5 @@ out/
|
||||
.vercel/
|
||||
|
||||
.seq-cache/
|
||||
|
||||
.content-out/
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `/api/content-studio` — the agent-ready contract
|
||||
|
||||
The endpoint Kev's `/studio` page reads today and an autonomous poster reads
|
||||
later. **The page is a thin client**: no posting logic, no fact handling. Wiring
|
||||
a bot means pointing it here — nothing on this side changes.
|
||||
|
||||
Distinct from `/api/content` (Session 29), which serves structured content
|
||||
*objects* by data level. This serves finished **posts**.
|
||||
|
||||
## Auth
|
||||
|
||||
`x-internal-key: $VYNDR_INTERNAL_KEY` — private, never public. The browser never
|
||||
holds the key; the Next proxy at `web/src/app/api/content-studio/[...path]`
|
||||
attaches it server-side.
|
||||
|
||||
## `GET /api/content-studio/:date?`
|
||||
|
||||
`:date` optional, defaults to today ET.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"count": 3,
|
||||
"posts": [{
|
||||
"id": "honesty_flex",
|
||||
"label": "The Honesty Flex",
|
||||
"sport": "mlb",
|
||||
"status": "pending", // pending | approved | skipped | regenerate_requested
|
||||
"ok": true,
|
||||
"skipped": false,
|
||||
"reason": null, // why it was skipped, when it was
|
||||
"honest_absence": false, // a real "nothing tonight" post, not a failure
|
||||
"copy": "WE GRADED 2140 PROPS TONIGHT...",
|
||||
"card": { "title": "...", "lines": [...] },
|
||||
"card_svg": "<svg ...>", // ready to render or rasterise
|
||||
"fact_contract": ["graded", "ceiling_letter", "..."], // REQUIRED fields
|
||||
"facts": { "graded": 2140, "...": "..." } // what backed it
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**`fact_contract` + `facts` are the point.** An agent (or a reviewer) can check
|
||||
what a claim rests on instead of trusting the sentence. A post whose contract
|
||||
could not be met never appears with invented values — it arrives `skipped` with
|
||||
a `reason`, or as an `honest_absence`.
|
||||
|
||||
## `POST /api/content-studio/:date/:id/status`
|
||||
|
||||
```jsonc
|
||||
{ "status": "approved" } // approved | skipped | regenerate_requested
|
||||
```
|
||||
|
||||
Editorial state only, stored in Redis for 14 days. **Approving a post changes
|
||||
nothing about the model** — status never touches a serving, model or ledger
|
||||
table.
|
||||
|
||||
## For the agent build
|
||||
|
||||
1. `GET` the date → filter `ok && !skipped`.
|
||||
2. Post `copy`; rasterise or attach `card_svg`.
|
||||
3. `POST` status `approved` on success.
|
||||
4. **Never** synthesise a claim not present in `facts`. The engine refuses to
|
||||
render an unbacked token; an agent must not reintroduce one downstream.
|
||||
|
||||
Adding a template changes the payload not at all — a new `id` simply appears.
|
||||
@@ -0,0 +1,64 @@
|
||||
# git push in this environment — it was never the firewall, and never missing credentials
|
||||
|
||||
## What actually happened
|
||||
|
||||
Every push attempt this session used `git push origin main` and failed with:
|
||||
|
||||
```
|
||||
fatal: could not read Username for 'https://github.com'
|
||||
```
|
||||
|
||||
That error names the cause exactly, and it was misread all session as "no git
|
||||
credentials on this machine." Two things were true instead:
|
||||
|
||||
1. **`origin` is GitHub** (`github.com/kev3109/betonblk.git`) and has **no stored
|
||||
credential.**
|
||||
2. **`gitea` is the working remote** (`git.builtbykev.com/builtbykev/vyndr.git`)
|
||||
and **a valid credential for it was on the machine the entire time.**
|
||||
|
||||
The habit of typing `origin` is what kept twenty commits local. Nothing was
|
||||
blocked.
|
||||
|
||||
## The GATE-0 firewall theory — tested and REJECTED for VYNDR
|
||||
|
||||
The theory was that GATE-0 (Hetzner `mastermind-core-fw`, inbound deny-by-default
|
||||
except 80/443, SSH/22 restricted to Tailscale + Kev's IP) was blocking an
|
||||
SSH-based push, as it did for COLYRA.
|
||||
|
||||
**It does not apply here. Measured:**
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| `git remote -v` | **both remotes are already HTTPS** — no `git@…:…` URL anywhere |
|
||||
| `curl -I https://git.builtbykev.com` | **HTTP 200** in 0.64s |
|
||||
| `curl -I https://github.com` | **HTTP 200** in 0.17s |
|
||||
| Gitea git endpoint over 443 | **HTTP 200** |
|
||||
| GitHub git endpoint over 443 | HTTP 401 (auth required, reachable) |
|
||||
|
||||
There was no SSH remote to be blocked and no connectivity failure of any kind.
|
||||
COLYRA's HTTPS-remote fix was the right fix for COLYRA's problem; **VYNDR was
|
||||
already in the state that fix produces.**
|
||||
|
||||
Applying it here would have meant creating a new Gitea token to solve a problem
|
||||
that did not exist — and the pre-existing credential would have made the new
|
||||
token look like the cure.
|
||||
|
||||
## The fix
|
||||
|
||||
```
|
||||
git push gitea main # not origin
|
||||
```
|
||||
|
||||
Result: `6452926..ecf78b9`, 21 commits, verified by `git ls-remote` matching
|
||||
local `HEAD`.
|
||||
|
||||
## Standing note
|
||||
|
||||
- **`gitea` is VYNDR's push remote.** `origin` (GitHub) is unauthenticated on this
|
||||
machine and will always fail.
|
||||
- Read the error text before reaching for an infrastructure theory. `could not
|
||||
read Username for 'https://github.com'` is a *credential* message naming a
|
||||
*specific host* — it is not a connectivity message, and it named the wrong
|
||||
remote, not a wrong protocol.
|
||||
- The `~/vyndr-full-history-2026-08-07.bundle` and patch series stay as
|
||||
belt-and-braces. They are no longer the only copy.
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* generate-content — tonight's posts, from tonight's real data.
|
||||
*
|
||||
* READ-ONLY on every source. This writes nothing to any serving, model or
|
||||
* ledger table, so it has zero effect on the repaired-champion accrual clock.
|
||||
*
|
||||
* SUPABASE_URL=... node scripts/generate-content.js
|
||||
* -> .content-out/<date>/<template>.txt and .svg
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const engine = require('../src/services/content/contentEngine');
|
||||
const { toSvg } = require('../src/services/content/cardRenderer');
|
||||
const sg = require('../src/services/model/servedGrade');
|
||||
const { knownNumber } = require('../src/utils/known');
|
||||
|
||||
for (const t of ['hotHitters', 'honestyFlex', 'streakList']) {
|
||||
engine.registerTemplate(require(`../src/services/content/templates/${t}`));
|
||||
}
|
||||
|
||||
const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json');
|
||||
const OUT = path.join(process.cwd(), '.content-out');
|
||||
|
||||
async function page(sb, t, sel, ob, f) {
|
||||
const o = [];
|
||||
for (let i = 0; ; i += 1000) {
|
||||
const { data, error } = await f(sb.from(t).select(sel)).order(ob, { ascending: true }).range(i, i + 999);
|
||||
if (error) throw new Error(`${t}: ${error.message}`);
|
||||
if (!data || !data.length) break;
|
||||
o.push(...data);
|
||||
if (data.length < 1000) break;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const sb = createClient(process.env.SUPABASE_URL,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY, { auth: { persistSession: false } });
|
||||
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
|
||||
|
||||
// ── SOURCE 1: full-season hitter form (the REPAIRED window, not last10) ──
|
||||
const lines = fs.existsSync(BOX) ? JSON.parse(fs.readFileSync(BOX, 'utf8')).lines : {};
|
||||
const byPlayer = new Map();
|
||||
for (const [k, b] of Object.entries(lines)) {
|
||||
const [d, key] = k.split('|');
|
||||
if (!byPlayer.has(key)) byPlayer.set(key, []);
|
||||
byPlayer.get(key).push({ d, hits: b.hits, name: b.name });
|
||||
}
|
||||
// The box-score cache spans only the settled snapshot window, so it holds far
|
||||
// fewer than a season per player. Season form comes from the SAME full log the
|
||||
// repaired champion reads.
|
||||
const mlb = require('../src/services/adapters/mlbStatsAdapter');
|
||||
const hitterFormFull = async (names) => {
|
||||
const out = [];
|
||||
for (const n of names.slice(0, 60)) {
|
||||
try {
|
||||
const res = await mlb.getPlayerStats(n);
|
||||
const log = (res && res.found && Array.isArray(res.fullLog)) ? res.fullLog : [];
|
||||
const vals = log.map((g) => knownNumber(g && g.stat && g.stat.hits)).filter((v) => v !== null);
|
||||
if (vals.length < 20) continue;
|
||||
const rate = (a) => a.filter((v) => v > 0).length / a.length;
|
||||
out.push({ name: n, season_games: vals.length, season_rate: rate(vals), recent_rate: rate(vals.slice(-10)) });
|
||||
} catch { /* absent player -> absent row */ }
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const cacheForm = async () => [...byPlayer.entries()].map(([key, games]) => {
|
||||
games.sort((a, b) => a.d.localeCompare(b.d));
|
||||
const vals = games.map((g) => knownNumber(g.hits)).filter((v) => v !== null);
|
||||
if (vals.length < 20) return null;
|
||||
const rate = (arr) => arr.filter((v) => v > 0).length / arr.length;
|
||||
return {
|
||||
name: games[games.length - 1].name || key,
|
||||
season_games: vals.length,
|
||||
season_rate: rate(vals),
|
||||
recent_rate: rate(vals.slice(-10)),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
const hitterForm = async () => {
|
||||
const names = [...byPlayer.values()].map((g) => g[g.length - 1].name).filter(Boolean);
|
||||
const full = await hitterFormFull([...new Set(names)]);
|
||||
return full.length ? full : await cacheForm();
|
||||
};
|
||||
|
||||
// ── SOURCE 2: the real served-grade distribution ──
|
||||
const snaps = await page(sb, 'model_snapshots', 'p_win, refused, stat, game_date', 'id',
|
||||
(q) => q.eq('sport', 'mlb').eq('game_date', date));
|
||||
const gradeDistribution = async () => {
|
||||
const usable = snaps.filter((r) => !r.refused && knownNumber(r.p_win) !== null);
|
||||
const by = {}; let flat = 0;
|
||||
for (const r of usable) {
|
||||
const g = sg.gradeFor({ p_win: knownNumber(r.p_win) });
|
||||
by[g.letter] = (by[g.letter] || 0) + 1;
|
||||
if (g.separates_from_base_rate === false) flat += 1;
|
||||
}
|
||||
return { total: usable.length || null, by_letter: by, not_separable: flat };
|
||||
};
|
||||
|
||||
// ── SOURCE 3: streaks verified from SETTLED ledger outcomes only ──
|
||||
const led = await page(sb, 'ledger_entries', 'player_name, player_key, game_date, outcome, stat', 'id',
|
||||
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits').in('outcome', ['hit', 'miss']));
|
||||
const settledStreaks = async () => {
|
||||
const by = new Map();
|
||||
for (const r of led) {
|
||||
if (!by.has(r.player_key)) by.set(r.player_key, []);
|
||||
by.get(r.player_key).push(r);
|
||||
}
|
||||
const out = [];
|
||||
for (const [, rows] of by) {
|
||||
rows.sort((a, b) => String(b.game_date).localeCompare(String(a.game_date)));
|
||||
let n = 0;
|
||||
for (const r of rows) { if (r.outcome === 'hit') n += 1; else break; }
|
||||
if (n >= 3) out.push({ name: rows[0].player_name, streak: n, verified_from_settled: true });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const deps = { date, hitterForm, gradeDistribution, settledStreaks, servedGrade: sg };
|
||||
const results = await engine.generateAll(deps);
|
||||
|
||||
const dir = path.join(OUT, date);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const r of results) {
|
||||
if (!r.ok) { console.log(`\n[SKIP] ${r.id} — ${r.reason}`); continue; }
|
||||
fs.writeFileSync(path.join(dir, `${r.id}.txt`), r.copy);
|
||||
fs.writeFileSync(path.join(dir, `${r.id}.svg`), toSvg(r.card));
|
||||
console.log(`\n${'='.repeat(64)}\n${r.id.toUpperCase()}${r.honest_absence ? ' [HONEST ABSENCE]' : ''}\n${'='.repeat(64)}\n${r.copy}`);
|
||||
}
|
||||
console.log(`\n\noutput: ${dir}`);
|
||||
process.exit(0);
|
||||
})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,53 @@
|
||||
# The 83-glyph taxonomy — doctrine
|
||||
|
||||
**RULED. This is the shared law the archetype chat and the build chat both obey.**
|
||||
|
||||
## The rule
|
||||
|
||||
**83 designed glyphs = the full four-sport archetype taxonomy** (MLB / WNBA /
|
||||
NBA / Soccer). The artwork is complete; the *models* are not.
|
||||
|
||||
**A glyph renders ONLY where its archetype is modeled and proven.**
|
||||
|
||||
| set | count | state |
|
||||
|---|---|---|
|
||||
| designed glyphs | **83** | complete, in `specs/design-reference/assets/glyphs/` |
|
||||
| backend registry archetypes | **41** | `archetypeService.ARCHETYPES` |
|
||||
| **mapped and wired** | **39** | live, colours matching the registry exactly |
|
||||
| **designed, no backend archetype** | **44** | **DORMANT** — slots for WNBA/NBA/Soccer |
|
||||
| registry archetypes with no glyph | **2** | `DUAL THREAT`, `PAINT BOSS` — **design gap** |
|
||||
|
||||
## Why dormancy rather than wiring
|
||||
|
||||
Wiring the 44 would mean **inventing 44 archetypes to consume artwork**. An
|
||||
archetype that exists because a glyph exists is decoration presented as
|
||||
classification — a mark on a card asserting the model recognised something it
|
||||
cannot produce. **Decoration-as-data is forbidden.**
|
||||
|
||||
The dormant glyphs are not waste. They are **designed slots**, and each activates
|
||||
when its sport's archetype system is built and clears the two-part gate — the
|
||||
same bar every factor in this programme faces.
|
||||
|
||||
## What this preserves
|
||||
|
||||
- **The full design vision.** All 83 marks stay in the package; none is deleted
|
||||
or redrawn.
|
||||
- **The Truth Law.** No glyph appears for an archetype the model cannot produce.
|
||||
- **The activation path.** Building WNBA/NBA/Soccer archetypes lights their
|
||||
glyphs automatically — the artwork is already there and already colour-matched.
|
||||
|
||||
## Consequences for build
|
||||
|
||||
1. Do **not** add a registry archetype to consume a glyph. The archetype must be
|
||||
earned by a classifier that produces it from real features.
|
||||
2. Do **not** render a dormant glyph as a placeholder, sample, or "coming soon"
|
||||
mark on any data surface.
|
||||
3. `DUAL THREAT` and `PAINT BOSS` are **flagged to the design side** — they are
|
||||
modeled archetypes with no mark, the mirror image of the dormant 44.
|
||||
4. When a sport's archetypes ship, wiring is a MANIFEST lookup, not new art.
|
||||
|
||||
## Status
|
||||
|
||||
MLB archetypes render live. WNBA, NBA and Soccer have archetype **registries**
|
||||
but no proven factor model, so their marks stay dormant — consistent with
|
||||
`specs/BOARD-2026-08-07.md`, which records only MLB batters as MODELED.
|
||||
@@ -0,0 +1,132 @@
|
||||
# The real board — read from the repo, 2026-08-07
|
||||
|
||||
Inventory only. Every line cites a file or a query. Anything unverifiable is
|
||||
marked UNKNOWN rather than asserted.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 0 — Design / terminal
|
||||
|
||||
| item | state | evidence |
|
||||
|---|---|---|
|
||||
| **Scanner blue-boundary format** | **DONE** | `scan/page.tsx:715-717` — *"the blue boundary channel (`--priced-out`): line · BK odds · ◆ fair"* and *"renders NEUTRAL, not amber (blue-boundary law)"*. Amber survives only as an unrelated CTA (`:983`) and a status line (`:846`). |
|
||||
| **MovementStrip** | **NOT-STARTED** *(as named)* | No component by that name. Movement renders via `GradeShift.tsx`, `GameCard.tsx`, `MarketBreadth.tsx`, `FuturesBoard.tsx`. **UNKNOWN whether the spec wants a distinct strip or is satisfied by these.** |
|
||||
| **THE WIRE** | **PARTIAL** | `components/vyndr/NewsWire.tsx` exists and is mounted — but only inside `ExploreHub.tsx`. No standalone wire surface. |
|
||||
| **Book comparison** | **PARTIAL — built, unmounted** | `vyndr/BookComparisonPanel.tsx` + `components/BookComparison.tsx` both exist; grep of `web/src/app` returns **zero** mounting pages. Same built-but-unread class the reachability guard was written for; **not covered by that guard** (contract is grade fields only). |
|
||||
| **Article media** | **NOT-STARTED** | No component. `/blog` exists. |
|
||||
| **Offseason hub** | **NOT-STARTED** | No component, no route. |
|
||||
| **Terminal** | **DONE — deliberately retired** | `app/terminal/page.tsx` redirects to `/dashboard`; layouts preserved unrouted in `components/intel/TerminalTemplates.tsx` (S57). Not debt. |
|
||||
| **Screen conversion (S36–39 arc)** | **DONE except one** | 47 route dirs under `app/`. `RouteStub` survives in exactly **one** file: `app/notifications/page.tsx`. Every other screen has a real page. |
|
||||
|
||||
**Sports surfaces that exist as pages:** `soccer` (414 lines), `desk` (160),
|
||||
`intelligence` (160), `marketplace` (123). No `fight` route.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — Sports × role
|
||||
|
||||
Archetype registry (`archetypeService.ARCHETYPES`): **nba 15, mlb 15, soccer 6,
|
||||
wnba 5** — all four sports have real archetype registries, not names only.
|
||||
|
||||
`snapshotService.ACTIVE_SPORTS = ['mlb','nba','wnba','soccer']` — the pipeline
|
||||
runs all four.
|
||||
|
||||
| sport · role | state | evidence |
|
||||
|---|---|---|
|
||||
| **MLB · batters** | **MODELED** | The whole session. `hitsFactors` (3 proven factors, transmitting), `servedGrade`, repaired champion, settled ledger. |
|
||||
| **MLB · pitchers** | **SCAFFOLDED** | `model/pitcherEngine.js` (261 lines, own FLAME/SCALPEL/SINKER archetypes) — **read by no serving code** (grep: zero non-test consumers). Strikeouts n=57 settled vs a 500 gate. Base rate repaired by the shared `getStatRows` MLB fix. |
|
||||
| **WNBA** | **SCAFFOLDED** | Archetypes exist; settles via ESPN box scores (376 rows historically); no factor model. Base-rate path fixed this session. |
|
||||
| **NBA** | **SCAFFOLDED, dormant** | Archetypes exist; offline (Python service down, off-season). Base-rate paths fixed dormant at `55b210c`. |
|
||||
| **Soccer** | **SCAFFOLDED** | 6 archetypes, a real `/soccer` page, feature extractor exists. No factor model, no settled outcomes. |
|
||||
|
||||
**Nothing but MLB batters is MODELED.** Everything else is scaffolding with a
|
||||
sound base rate and no proven factors.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — Wiring / data-integrity debt
|
||||
|
||||
### `edge_pct` — the flag is half right, and the diagnosis was wrong
|
||||
|
||||
**Not a scale bug.** `analyzeViaEngine1.edgePctFor` computes
|
||||
`(model − line) / line`, signed by direction — arithmetically correct. It
|
||||
*explodes on small lines*: a 5.5 projection against a 0.5 line is a legitimate
|
||||
1000%. Live top values: **900, 860, 700, 700, 660** on **68,364 rows**.
|
||||
|
||||
**Consuming surfaces: 15 backend files + 10 frontend files.** But
|
||||
`edge_pct` itself reaches a user only through `alt_lines` typing in
|
||||
`scan/page.tsx:64` — the grade card's `edge` is computed independently in
|
||||
`gradeAdapter` via `computeEdge`. So the blast radius is smaller than the file
|
||||
count implies.
|
||||
|
||||
**`ev_pct` DOES render** — 5 frontend files (`PriceTriplet`, `GradeResultCard`,
|
||||
`LiveHeroProp`, scan page + route). The "ev_pct renders nowhere" flag is **stale**.
|
||||
45,125 rows carry it.
|
||||
|
||||
**Classification: PARTIAL — a real display defect (a 900% edge is not a sentence
|
||||
we can defend), not a broken computation.**
|
||||
|
||||
### `opp_rank_stat`
|
||||
|
||||
`refreshTeamStats` is wired into `snapshotService:361`. **UNKNOWN whether it
|
||||
populates in production** — not verifiable from the repo, needs a live probe.
|
||||
|
||||
### A-emit
|
||||
|
||||
**Resolved.** `servedGrade.UNISSUABLE = ['A+','A','A-']`; post-cutover serving
|
||||
cannot emit one. Historical snapshot rows still carry engine1 A's — that is
|
||||
retired data, not live behaviour.
|
||||
|
||||
### Other
|
||||
|
||||
| item | state |
|
||||
|---|---|
|
||||
| `VYNDR_INTERNAL_KEY` | present in `app.js`, `preflight.js`. **Rotation status UNKNOWN** from the repo. |
|
||||
| void / DNP handling | handled in `ledgerService` (`:64`, `:698`) — absence means DNP/postponed/not-final. **The ~9% rate is UNVERIFIED** here. |
|
||||
| **Guard coverage gap** | reachability contract covers **grade fields only** — 0 references to `edge_pct`/`ev_pct`. Book comparison and other unmounted components are **not** guarded. |
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 — THE BOARD, ordered (half-done first)
|
||||
|
||||
| # | item | lane | state | serving-path? |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Book comparison — built, unmounted** | design | PARTIAL | **N** |
|
||||
| 2 | **`edge_pct` display defect (900%)** | integrity | PARTIAL | **Y** |
|
||||
| 3 | **THE WIRE — only inside ExploreHub** | design | PARTIAL | **N** |
|
||||
| 4 | **MLB pitchers — engine unwired** | sports | PARTIAL | **Y** |
|
||||
| 5 | **`/notifications` RouteStub** | design | PARTIAL | **N** |
|
||||
| 6 | Guard coverage → non-grade surfaces | integrity | NOT-STARTED | **N** |
|
||||
| 7 | Article media | design | NOT-STARTED | **N** |
|
||||
| 8 | Offseason hub | design | NOT-STARTED | **N** |
|
||||
| 9 | MovementStrip *(if distinct from GradeShift)* | design | UNKNOWN | **N** |
|
||||
| 10 | WNBA / NBA / Soccer factor models | sports | NOT-STARTED | **Y** |
|
||||
| 11 | `opp_rank_stat` prod population | integrity | UNKNOWN | **Y** |
|
||||
| 12 | Internal-key rotation | ops | UNKNOWN | **N** |
|
||||
|
||||
### Accrual sensitivity
|
||||
|
||||
**Isolated — buildable now without touching the re-audit clock (7 items):**
|
||||
1, 3, 5, 6, 7, 8, 9 — all design/guard work, zero serving-path contact.
|
||||
|
||||
**Serving-path — will muddy the accruing repaired-champion dates (5 items):**
|
||||
2, 4, 10, 11, and any model work. Every one changes what a snapshot writes, so
|
||||
rows produced after the change are not comparable to rows before it. **If any of
|
||||
these ship, the eligible-date clock arguably restarts** — the same reasoning that
|
||||
voided the shadow duel.
|
||||
|
||||
**#2 (`edge_pct`) is the sharpest tension on the board:** it is a real honesty
|
||||
defect a user can see, and fixing it touches the serving path mid-accrual.
|
||||
|
||||
### The clock, today
|
||||
|
||||
```
|
||||
eligible dates: 0 — no settled rows carry engine1@2026-08-07-fullwindow
|
||||
calibration_refit 0/10 WAITING (~2 weeks)
|
||||
hits_factor_lift 0/10 WAITING (~2 weeks)
|
||||
prior_verdict_reaudit 0/14 WAITING (3+ weeks)
|
||||
rbi_lineup_slot_gate 0/14 WAITING (3+ weeks)
|
||||
```
|
||||
|
||||
Two tracks are now visible: **modeling is date-blocked**; **seven design/guard
|
||||
items are buildable today with no clock impact.**
|
||||
@@ -0,0 +1,111 @@
|
||||
# The content engine — posts that structurally cannot lie
|
||||
|
||||
## Phase 0 — architecture
|
||||
|
||||
`src/services/content/contentEngine.js`. Three mechanisms make Truth Law
|
||||
structural rather than careful:
|
||||
|
||||
1. **Copy is token-substituted.** Every factual claim is a `{token}` resolved
|
||||
against pulled facts. An unbacked token **refuses to render** — there is no
|
||||
code path producing a plausible default.
|
||||
2. **The fact contract is asserted first.** A template declares required fields;
|
||||
they are checked *before any string is built*.
|
||||
3. **Card and copy share one fact object.** They cannot diverge.
|
||||
|
||||
**No live model writes factual claims.** The voice is in the template, the facts
|
||||
are pulled. A voice-polish port is reserved and deliberately unwired — an LLM
|
||||
that can rewrite a sentence can rewrite a number.
|
||||
|
||||
**Read-only on every source.** Zero writes to serving, model or ledger tables, so
|
||||
zero effect on the accrual clock.
|
||||
|
||||
### The Truth-Law proof — 18 tests
|
||||
|
||||
| the guard | what it prevents |
|
||||
|---|---|
|
||||
| unbacked token refuses | `{edge}` rendering as `undefined` or an empty hole |
|
||||
| card tokens gated too | a caption that's honest beside a card that isn't |
|
||||
| `render()` throws directly | a caller bypassing the gate |
|
||||
| `null` never renders as `"null"` | absence dressed as data |
|
||||
| **`0` IS present** | *"0 cleared B+"* is our most honest post — deleting it would be `Number(null)===0` in reverse |
|
||||
| `NaN`/`Infinity` absent | arithmetic failures are not facts |
|
||||
| contract gap names the field | a silent half-post |
|
||||
| pull failure skips | a post built on a dead source |
|
||||
|
||||
## Phase 1 — three templates, real output
|
||||
|
||||
**HOT HITTERS** (from the repaired full-season log, not a ten-game slice):
|
||||
> Jahmai Jones is hitting 60% over his last 10. His season number is 26%.
|
||||
> That gap is the whole point. Everybody else is guessing at it.
|
||||
|
||||
**THE HONESTY FLEX** — the differentiator, and every number is ours:
|
||||
> WE GRADED 2140 PROPS TONIGHT. 70 CLEARED B+.
|
||||
> That's 3%. The other 42% we can't separate from the baseline, and we say so on the card instead of calling them leans.
|
||||
> We do not issue A+, A, A-. No band of this model has ever hit at a rate that would justify one.
|
||||
> Everybody else's card is all A's. Ask them what their A actually hits.
|
||||
|
||||
**STREAK LIST** — verified from settled outcomes only:
|
||||
> Nathan Church has a 7-game hit streak. Live, verified off settled results only.
|
||||
> Every game in these ran to a final. We don't count a pending night to make a number look better.
|
||||
|
||||
### The bug the engine caught in itself
|
||||
|
||||
The first run emitted *"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 snapshot window,
|
||||
so **every** player had fewer than 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: an `absent()` variant may now **decline to speak**. The
|
||||
template separates *no candidates at all* (SKIP with a reason) from *candidates
|
||||
judged, none hot* (honest absence). Both cases are locked by test.
|
||||
|
||||
Source corrected to `mlbStatsAdapter.fullLog` — the same log the repaired
|
||||
champion reads.
|
||||
|
||||
## Phase 2 — the card
|
||||
|
||||
`cardRenderer.js`, SVG rather than canvas: it is text, so it diffs in review and
|
||||
its numbers are **greppable** — which matters when the entire claim is that the
|
||||
numbers are real. A card whose contents can't be inspected without opening an
|
||||
image is a poor fit for a Truth-Law product.
|
||||
|
||||
Brand: VYND white + R green `#00D4A0`, slashed-Y, scanline field, mono
|
||||
throughout. The card never formats its own facts — every string arrives already
|
||||
rendered and gate-checked, so caption and card cannot disagree. A test asserts
|
||||
the pulled number appears in the emitted SVG.
|
||||
|
||||
## Phase 3 — posting-ready, and extending it
|
||||
|
||||
```
|
||||
SUPABASE_URL=... node scripts/generate-content.js
|
||||
-> .content-out/2026-08-07/hot_hitters.txt + .svg
|
||||
-> .content-out/2026-08-07/honesty_flex.txt + .svg
|
||||
-> .content-out/2026-08-07/streak_list.txt + .svg
|
||||
```
|
||||
|
||||
Kev posts; the engine generates.
|
||||
|
||||
### Adding template N+1 — registry entry only, no engine change
|
||||
|
||||
```js
|
||||
registerTemplate({
|
||||
id, sport, requires: ['dotted.paths'],
|
||||
pull: async (deps) => facts, // the ONLY place data enters
|
||||
copy: () => 'text with {tokens}',
|
||||
card: () => ({ title, subtitle, lines }),
|
||||
absent: (gaps, facts) => ({ copy, card }) // or { skip: 'reason' }
|
||||
});
|
||||
```
|
||||
|
||||
**Queued (stubs, not built):** hot takes · daily honest reads · *"grades we
|
||||
DIDN'T give"* · cross-sport streak variants (the streak template is already
|
||||
sport-agnostic — it takes settled outcomes and a noun, so NFL TD streaks or NBA
|
||||
made-three streaks need only that sport's settled data).
|
||||
|
||||
## Isolation
|
||||
|
||||
Read-only throughout. `p_win`, the model and the serving path are untouched;
|
||||
the eligible-date clock is unaffected. **0 eligible dates today, unchanged.**
|
||||
@@ -0,0 +1,198 @@
|
||||
# VYNDR build handoff — 2026-08-08
|
||||
|
||||
Written from the repo, not from summary. Every claim below was grep- or
|
||||
run-verified at `71d3b7b`. Where something could not be verified from the repo
|
||||
it says UNKNOWN.
|
||||
|
||||
## Repo state
|
||||
|
||||
```
|
||||
HEAD 71d3b7b78692c31ba0596ec88874986ff1b58116
|
||||
gitea main 71d3b7b78692c31ba0596ec88874986ff1b58116 (in sync)
|
||||
tree clean — 0 modified/untracked
|
||||
tests 4,539 passing / 4 skipped
|
||||
web build exit 0
|
||||
```
|
||||
|
||||
**Push remote is `gitea`, not `origin`.** `origin` is GitHub and has no
|
||||
credential on this machine; every push must be `git push gitea main`. This cost
|
||||
twenty commits of false "no credentials" diagnosis — see
|
||||
`docs/GIT-PUSH-DIAGNOSIS.md`.
|
||||
|
||||
---
|
||||
|
||||
## BUILD STATE per surface
|
||||
|
||||
### DONE this arc — verified present
|
||||
|
||||
| surface | file | lines |
|
||||
|---|---|---|
|
||||
| **E1 movement strip** | `web/src/components/vyndr/MovementStrip.tsx` | 113 |
|
||||
| **F9–F11 offseason hub shell** | `web/src/app/offseason/page.tsx` + `components/vyndr/OffseasonHub.tsx` | 21 + hub |
|
||||
| **E10 Report issue template** | `src/services/report/reportTemplate.js` | 169 |
|
||||
| **E12 /report archive** | `web/src/app/report/page.tsx` + `components/vyndr/ReportArchive.tsx` + `src/routes/report.js` | 17 + comp + route |
|
||||
| **Content engine** | `src/services/content/contentEngine.js` + 3 templates + `cardRenderer.js` | 172 + |
|
||||
| **Content studio API + preview** | `src/routes/contentStudio.js` + `web/src/app/studio/page.tsx` | 175 + 131 |
|
||||
| **Wave-D1 motion primitives** | `web/src/lib/motion.js` (E17/E18/E27/E28) | 100 |
|
||||
| **Honest served grade** | `src/services/model/servedGrade.js` | 142 |
|
||||
| **Archetype doctrine** | `specs/ARCHETYPE-TAXONOMY-DOCTRINE.md` | 53 |
|
||||
|
||||
Also DONE and verified: **D1 glyph library** (39 of 83 wired — every glyph that
|
||||
maps to a real archetype; colours match the registry exactly), **A1 card token**
|
||||
(`--bg-1`, 32 consumers), **B1 boundary channel** (`--priced-out`, 4 consumers),
|
||||
**book comparison** (mounted via `GradeResultCard → scan/page`), **THE WIRE**
|
||||
(`vyndr/Ticker`).
|
||||
|
||||
### GATED — verified absent, with the gate named
|
||||
|
||||
| surface | gate | verified |
|
||||
|---|---|---|
|
||||
| **F5 article media** | card-system reconciliation | `ArticleHero`/`StatCallout`/`PullQuote` = 0 files |
|
||||
| **E16/F8 share cards + crops** | card-system reconciliation *and* the resolution tail has no share-card generation step | `ShareCard.tsx` has **0 importers** |
|
||||
| **In-season hub IA** | the content formula (social chat) | no spec exists anywhere |
|
||||
| **E9 calibration curve** | model accrual — 0 eligible dates | `CalibrationCurve` = 0 files |
|
||||
| **E15 Price Triplet MODEL leg** | model — EV layer | renders honest `NO_MODEL` |
|
||||
| **E2 BookChip tiles / E6 push-to-book** | licensing / affiliate approval | every book `enabled:false` |
|
||||
| **E3 best-number crown** | measurement — measured flat | `CrownBadge` = 0 files |
|
||||
| **E13 Scanner S6** | another build order | — |
|
||||
|
||||
**There are no ungated Wave-2 targets left.** The next move is a decision, not a
|
||||
build.
|
||||
|
||||
---
|
||||
|
||||
## THE TWO OPEN DECISIONS
|
||||
|
||||
### 1. Card-system reconciliation — unblocks F5 *and* E16/F8 together
|
||||
|
||||
The content engine emits **1080×1350 SVG** cards (`src/services/content/cardRenderer.js`).
|
||||
The design package defines **five master sizes** with layouts and rasterised PNGs
|
||||
in `specs/design-reference/exports/`: settle 1080×1350, story 1080×1920, square
|
||||
1080, X 1200×675, record 1080×1350, article OG 1200×630.
|
||||
|
||||
**I built the renderer without checking whether a designed card system existed.
|
||||
It did.** They are a parallel invention — not wrong, but they must become one
|
||||
visual language before F5 (whose hero graphics are generated data visuals) or
|
||||
E16 can proceed.
|
||||
|
||||
The decision: does the content engine conform to the E16 masters, or do the
|
||||
masters absorb the engine's generator? **Cheapest unblock on the board — it
|
||||
frees two gated items at once.**
|
||||
|
||||
### 2. In-season hub information architecture
|
||||
|
||||
`Vyndr Offseason.dc.html` specifies an **offseason** hub, and its shell is built.
|
||||
Nothing specifies what that surface is **in-season**, or how content, articles,
|
||||
wire and the live slate share year-round navigation. Every component exists; the
|
||||
composition does not.
|
||||
|
||||
The spec is explicit that **sport state lives in the sport tab** (`NFL · CAMP −5D`)
|
||||
and never in a separate offseason tab — so this is not a new route, it is a mode.
|
||||
Gated on the social chat's content formula, which determines the recurring slots.
|
||||
|
||||
---
|
||||
|
||||
## ACCRUAL CLOCK
|
||||
|
||||
```
|
||||
champion marker: engine1@2026-08-07-fullwindow
|
||||
|
||||
calibration_refit 0/10 WAITING (~2 weeks)
|
||||
hits_factor_lift 0/10 WAITING (~2 weeks)
|
||||
prior_verdict_reaudit 0/14 WAITING (3+ weeks)
|
||||
rbi_lineup_slot_gate 0/14 WAITING (3+ weeks)
|
||||
|
||||
blocked: no settled rows yet carry the repaired champion marker
|
||||
```
|
||||
|
||||
**FIRST TRIGGER: 10 eligible calibration dates → calibration re-fit.** Then, in
|
||||
order: hits factor lift → prior verdict re-audit → rbi lineup-slot gate.
|
||||
|
||||
**Thresholds are ATTEMPT floors, not TRUST floors.** Reaching 10 dates means the
|
||||
re-fit *can be measured*, not that it is trustworthy. A 10-date map is thin,
|
||||
deploys PROVISIONAL with auto-demotion, and its interval will be wide.
|
||||
|
||||
**No measurement on reconstructions of the retired forecast.** Anything requiring
|
||||
settled data waits. `src/services/model/reAuditEligibility.js` enforces it —
|
||||
eligibility is a version marker, counted in DATES not rows, and a mixed table
|
||||
counts only the repaired rows.
|
||||
|
||||
---
|
||||
|
||||
## STANDING DOCTRINE
|
||||
|
||||
**Truth Law.** No fabricated data anywhere — including **designer sample data**.
|
||||
The design files' numbers (Nabers 1,120.5, Wembanyama +420→+330, Nº 128, DAY
|
||||
RECORD 9–4) are a *spec for what a live feed renders*, never content to paste.
|
||||
Tests assert none of it ships. `Number(null) === 0` is the classic breach; note
|
||||
its inverse also bites — **zero is a real fact** ("0 cleared B+" is our most
|
||||
honest post).
|
||||
|
||||
**83-glyph four-sport taxonomy.** A glyph renders ONLY where its archetype is
|
||||
modeled and proven. 39 wired; **44 dormant slots** for WNBA/NBA/Soccer. Wiring
|
||||
them would mean inventing 44 archetypes to consume artwork — decoration
|
||||
presented as classification, forbidden. `DUAL THREAT` and `PAINT BOSS` are
|
||||
modeled archetypes with no mark: the mirror gap, flagged to design.
|
||||
|
||||
**Repaired champion.** The forecaster read `res.last10` — **ten games** — as its
|
||||
season rate. Fixed to the full season log with recency weight 0.40 → 0.20.
|
||||
Resolution: hits 0.00251 → 0.00817. **Every factor verdict in the programme was
|
||||
measured against the broken baseline and may deserve re-audit** — direction
|
||||
unknown, not pre-priced.
|
||||
|
||||
**Calibration is WITHDRAWN.** `CALIBRATION_DEPLOYED = []`. The maps were fitted
|
||||
on the retired forecast; refitting today would refit it again. Served `p_win` is
|
||||
repaired-champion raw.
|
||||
|
||||
**Grade doctrine.** `A+`, `A`, `A-` are **unissuable** — structurally, not rarely.
|
||||
Ceiling is **B+ at 0.663 realized against a 0.6005 baseline**. Bands that cannot
|
||||
be separated from the baseline SAY so. The served letter derives from `p_win`;
|
||||
`engine1.grade` is preserved as `engine_grade` and read by no serving code.
|
||||
|
||||
**Two guards, both in CI:**
|
||||
- `renderReachability.test.js` — a promised field must trace payload → adapter →
|
||||
component → **mounted**. Built-but-unread killed three surfaces before this.
|
||||
- `baseRateWindow.test.js` — no fixed N may stand in for a season. Six paths
|
||||
across two sports fell to that class.
|
||||
|
||||
**Compose, don't fork.** The recurring failure this arc: building beside an
|
||||
existing thing instead of on it (cards vs E16, `MovementStrip` nearly vs
|
||||
`gradeShift`, `reportTemplate` nearly vs `newsletterService`). Check for the
|
||||
existing implementation first — twice it was already there.
|
||||
|
||||
---
|
||||
|
||||
## PARALLEL WORKSTREAM SEEDS
|
||||
|
||||
### A. Social strategy chat
|
||||
|
||||
> VYNDR's content engine ships posts from real data (`src/services/content/`,
|
||||
> three templates, Truth-Law enforced — an unbacked token refuses to render).
|
||||
> Review `/studio` output and `specs/CONTENT-ENGINE.md`. Produce: the content
|
||||
> formula/calendar (what posts recur, on what cadence), and **the card-format
|
||||
> decision** — the engine's 1080×1350 renderer vs the five designed masters in
|
||||
> `specs/design-reference/exports/`. That decision unblocks F5 article media and
|
||||
> E16/F8 on the build side. Voice: `specs/VOICE.md` governs; the engine templates
|
||||
> encode a sharper register — decide whether that becomes the house voice.
|
||||
|
||||
### B. Multi-sport archetype build
|
||||
|
||||
> VYNDR has archetype registries for MLB (15), NBA (15), Soccer (6), WNBA (5) —
|
||||
> but **only MLB batters is MODELED**; the rest have no proven factor model, so
|
||||
> their glyphs stay dormant per `specs/ARCHETYPE-TAXONOMY-DOCTRINE.md`. 44 of 83
|
||||
> designed glyphs await their sport. Pick a sport, build its archetype
|
||||
> classifier from real features, and take it through the two-part gate
|
||||
> (`src/services/model/factorGate.js` — must MOVE the prediction and improve
|
||||
> out-of-sample Brier, cumulative-Bonferroni corrected). Prerequisite: that
|
||||
> sport needs settled outcomes. WNBA settles via ESPN box scores; NBA and Soccer
|
||||
> do not settle at all yet, which is the real first task there.
|
||||
|
||||
---
|
||||
|
||||
## Where to start in the next chat
|
||||
|
||||
1. Take one of the two open decisions (card system is cheapest — unblocks two).
|
||||
2. Or run a parallel workstream seed above.
|
||||
3. **Do not** start model work — it is date-blocked and will stay so for ~2 weeks.
|
||||
4. Verify before building: this arc corrected its own board four times. `git
|
||||
grep` beats any summary, including this one.
|
||||
@@ -0,0 +1,92 @@
|
||||
# What "media hub" already means in VYNDR's design language
|
||||
|
||||
Read-only inventory. Nothing designed, built or mounted.
|
||||
|
||||
## PHASE 0 — the design source of truth exists, and so does a gap audit
|
||||
|
||||
| artifact | what it is |
|
||||
|---|---|
|
||||
| `specs/DESIGN-SPEC.md` | **DESIGN SPEC v2** — governing laws, colour contract, entity layer, data-display standard, motion, archetype system, conversion architecture, empty/error system. 84 lines, supersedes v1. |
|
||||
| `specs/design-reference/` (Jul 22) | **The design package itself** — 7 `.dc.html` surface files, 83 archetype glyph SVGs + MANIFEST, 7 rasterised share-card PNG masters. |
|
||||
| `specs/design-reference/HANDOFF.md` | The code handoff: exact tokens, brand, glyph library, behaviour, laws. |
|
||||
| **`specs/design-vs-build-gap-audit.md`** | **A 61-item design-vs-build audit already exists** (2026-07-31, audited at `bf7c0a3`), every claim grep-verified, with a wave-ordered build plan. |
|
||||
|
||||
**The inventory this order asks for was largely already done on 2026-07-31.** The
|
||||
useful work is reconciling it, not redoing it.
|
||||
|
||||
## PHASE 1 — per media surface: spec, and build state
|
||||
|
||||
| surface | design spec? | built state | evidence |
|
||||
|---|---|---|---|
|
||||
| **Offseason hub** | **YES — a full surface file** | **NOT-STARTED** | `Vyndr Offseason.dc.html` (119KB) defines hub home (NFL desktop + 390), an **NBA Summer-League variant with an `OUTLOOK ONLY / NOT GRADED` honesty block**, a season-long board (open→NOW→VYNDR triplet), a season-read reveal with `WHAT WOULD CHANGE THIS READ`, a news/outlook feed with row anatomy, and a **quiet-wire empty state**. Gap audit: F9/F10/F11, *"design complete, no blocker but big."* |
|
||||
| **Article media** | **YES — surface S3** | **NOT-STARTED** | Hero template + **4 hero graphic archetypes** (line path / distribution / mark-at-scale / matchup card — *all generated data visuals, never stock*), inline figures (stat-callout triptych, comparison bars, pull quote — **one max**), **caption law: every figure names its data**, article card, OG 1200×630 (master already rasterised in `exports/`). Gap audit F5. Zero components built. |
|
||||
| **THE WIRE** | **YES** | **BUILT** (E19) | Spec'd in `Vyndr System.dc.html`: timestamped entry, ~6s hold, tag coloured by meaning. Built as `vyndr/Ticker` on real `/api/ticker` exhaust. **`NewsWire.tsx` is a different thing** — the offseason news/outlook feed, mounted in ExploreHub. |
|
||||
| **Movement strip** | **YES — E1, a named primitive** | **ABSENT** | *"steps not curves, green only when the move favours the read, FLAT = hairline + `FLAT · [N]D`"*, row 86×20 silent / full-width annotated on reveal. **This corrects my 2026-08-07 board, which listed it UNKNOWN / possibly-satisfied-by-GradeShift.** It is a specified primitive that does not exist. |
|
||||
| **Share-card masters ×5** | **YES — E16** | **ABSENT as product** | settle 1080×1350, story 1080×1920, square 1080, X 1200×675, record 1080×1350, article OG 1200×630. PNG masters in `exports/`; `ShareCard.tsx` has **0 importers**. Gap audit blocks it on the resolution tail having no share-card generation step. |
|
||||
| **Calibration curve** | YES — E9 | ABSENT | dots vs dashed perfect line, dot size = sample, buckets under N30 hollow. |
|
||||
| **The Report email / `/report` archive** | YES — E10/E12 | PARTIAL / ABSENT | `newsletterService` builds an email; the designed dark-billboard-over-light-paper hybrid is not built. `/report` redirects to `/blog`. |
|
||||
| **ExploreHub** | **NO SPEC** | BUILT | Not a designed surface. It is where `NewsWire` currently lives — a de-facto host, not the intended hub. |
|
||||
|
||||
## PHASE 2 — what an on-site media hub would consist of
|
||||
|
||||
**From surfaces already designed** (no new design needed):
|
||||
1. **Offseason hub shell** — F9/F10/F11, fully spec'd including its honesty block and quiet-wire empty state.
|
||||
2. **News/outlook feed** — spec'd row anatomy; `NewsWire.tsx` is a partial implementation of it.
|
||||
3. **Article media** — S3, fully spec'd hero + figure system.
|
||||
4. **THE WIRE** — built, reusable.
|
||||
5. **Share/OG cards** — designed at exact sizes, masters exported.
|
||||
|
||||
### The gap that needs design work authored
|
||||
|
||||
**Only one thing is genuinely un-designed: the hub's INFORMATION ARCHITECTURE
|
||||
across seasons.** The Offseason file specifies an *offseason* hub; there is no
|
||||
spec for what the same surface is **in-season**, or how content, articles, wire
|
||||
and the live slate share one navigation. Every component exists on paper; **their
|
||||
composition into a year-round media surface does not.**
|
||||
|
||||
### One finding that touches work I just shipped
|
||||
|
||||
**I built a card renderer at 1080×1350 without checking whether a designed card
|
||||
system existed. It does** — five master sizes with defined layouts, and
|
||||
rasterised PNGs in `exports/`. The content engine's cards are a parallel
|
||||
invention. They are not wrong, but they should conform to E16/F8 rather than
|
||||
diverge, and that reconciliation is a design decision, not a code one.
|
||||
|
||||
## PHASE 3 — the map
|
||||
|
||||
| surface | spec? | built | part of hub? | needs design work? |
|
||||
|---|---|---|---|---|
|
||||
| Offseason hub shell | ✅ | ❌ | **core** | no — build it |
|
||||
| News/outlook feed | ✅ | partial | **core** | no |
|
||||
| Article media (S3) | ✅ | ❌ | **core** | no — build it |
|
||||
| THE WIRE | ✅ | ✅ | yes | no |
|
||||
| Movement strip (E1) | ✅ | ❌ | adjacent | no |
|
||||
| Share/OG cards (E16) | ✅ | ❌ | yes | **reconcile with content-engine cards** |
|
||||
| Content-engine posts | ❌ | ✅ | **yes** | **yes — no spec exists** |
|
||||
| ExploreHub | ❌ | ✅ | host today | **yes — is it the hub or a placeholder?** |
|
||||
| **Year-round hub IA** | ❌ | ❌ | **the spine** | **YES — the real design gap** |
|
||||
|
||||
### Coordination with the social-strategy chat
|
||||
|
||||
These should be decided **there first**, then reflected on-site:
|
||||
|
||||
- **The content formula/calendar** determines what the hub's recurring slots
|
||||
*are*. Designing slots before the formula exists risks a surface shaped around
|
||||
guesses.
|
||||
- **Card format reconciliation** — off-site posts and on-site share cards should
|
||||
be one system. E16's five masters vs the content engine's renderer is the same
|
||||
decision on both sides.
|
||||
- **Voice**: `specs/VOICE.md` governs; the content engine templates encode a
|
||||
sharper register. Whether the hub speaks in that register is a brand call.
|
||||
|
||||
### Correction to the still-buildable list
|
||||
|
||||
The order lists content API, preview page, book-comparison mount and guard
|
||||
widening as pending. **All four shipped at `c575a70`.** Book comparison was never
|
||||
unmounted — my earlier board grepped only `web/src/app` and missed
|
||||
component-level mounting.
|
||||
|
||||
**Genuinely buildable now, design-complete, no clock impact:** Offseason hub
|
||||
(F9–F11), article media (F5), movement strip (E1), calibration curve (E9), the
|
||||
Report email + archive (E10/E12). The gap audit's own wave ordering already
|
||||
sequences these.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Built-but-unread — the defect class, and the guard that ends it
|
||||
|
||||
## The class
|
||||
|
||||
Three consecutive orders shipped a backend-correct field that never reached a
|
||||
screen. All three passed a fully green suite.
|
||||
|
||||
| order | what was built | why it never rendered |
|
||||
|---|---|---|
|
||||
| grade bands | `gradeBands.js`, six orders of work | required by no serving code |
|
||||
| `91927a4` | `served_grade` on the payload | dropped at the adapter boundary |
|
||||
| `3591c76` | `GradeScaleLegend.tsx` | imported by nothing |
|
||||
|
||||
Each was caught by luck on a later re-check, and in two of the three I had
|
||||
already **reported the wiring as done**.
|
||||
|
||||
### Why green tests could not see it
|
||||
|
||||
Backend tests stop at the API payload. They prove a field is **produced** and say
|
||||
nothing about whether it is **consumed**. The failure is invisible to them by
|
||||
construction — not an oversight in any individual test.
|
||||
|
||||
### The cognitive trap, named
|
||||
|
||||
The difficulty pools in the backend. Deriving the grade, proving the factors,
|
||||
measuring resolution — that is where the thinking happens, and by the time a
|
||||
field exists on the payload it *feels* finished. The remaining step is a
|
||||
three-line adapter change that nobody considers worth verifying, so it gets
|
||||
claimed rather than traced. **The last inch is the one with no friction, which is
|
||||
exactly why it is the one that gets skipped.**
|
||||
|
||||
Nothing here is a frontend-competence problem. It is that "I added the field" and
|
||||
"a user can see it" are different claims, and only the first one is fun.
|
||||
|
||||
---
|
||||
|
||||
## The contract
|
||||
|
||||
`tests/unit/renderReachability.test.js` holds the promised-field contract — the
|
||||
things the grade product commits to a user seeing:
|
||||
|
||||
| promise | payload | adapter | component |
|
||||
|---|---|---|---|
|
||||
| the served grade object | `served_grade` | `served_grade` (container) | GradeResultCard |
|
||||
| what this grade means | `served_grade.meaning` | `gradeMeaning` | GradeResultCard |
|
||||
| whether the band separates | `separates_from_base_rate` | `separatesFromBaseRate` | GradeResultCard |
|
||||
| what the band realized | `band_realized_rate` | `bandRealizedRate` | GradeResultCard |
|
||||
| which factors moved the read | `factor_adjustment` | `factorsApplied` | GradeResultCard |
|
||||
| the ceiling stance | — | — | GradeScaleLegend |
|
||||
|
||||
Adding a served field without adding it here is allowed. Adding it **here**
|
||||
without wiring it to a mounted component is not.
|
||||
|
||||
---
|
||||
|
||||
## The guard
|
||||
|
||||
For each promised field it traces the whole path:
|
||||
|
||||
```
|
||||
payload field -> adapter consumes it -> component renders it -> component is MOUNTED
|
||||
```
|
||||
|
||||
**"Mounted" is transitive to a Next entry point** (`page`/`layout`/`template`) —
|
||||
the only thing that puts a pixel on a screen. A component that exists and renders
|
||||
its field perfectly but is imported by nothing fails. Depth-limited so an import
|
||||
cycle cannot hang the suite.
|
||||
|
||||
**Container rows are exempted explicitly, not silently.** `served_grade` is
|
||||
consumed by the adapter but not rendered directly, so it carries
|
||||
`container: true` plus a `rendersVia` list — and a separate assertion checks that
|
||||
**every named part actually renders.** The exemption is auditable; it cannot hide
|
||||
an unrendered field.
|
||||
|
||||
The guard also tests itself: it asserts that an orphan component reports
|
||||
unmounted, and that the contract is non-empty (an empty contract would pass
|
||||
vacuously — the way this guard would most plausibly rot).
|
||||
|
||||
### Retro-proof
|
||||
|
||||
Run unchanged against the tree at `3591c76`, before the wiring:
|
||||
|
||||
```
|
||||
Tests: 11 failed, 8 passed
|
||||
● the ceiling stance / grade scale legend — its component is MOUNTED, not merely written
|
||||
● the served grade object — the adapter consumes it
|
||||
● whether the band separates from the baseline — a component actually renders it
|
||||
● what this grade means — the adapter consumes it
|
||||
● which proven factors moved the read — a component actually renders it
|
||||
...
|
||||
```
|
||||
|
||||
**It names the exact three bugs.** Green on the current tree.
|
||||
|
||||
### One honest scope limit
|
||||
|
||||
`gradeBands` is **not** in the contract and would not be caught. It is a backend
|
||||
module, not a promised user-facing field, and it is correctly unwired — every
|
||||
band it produces collapses to base-rate at current resolution. The guard covers
|
||||
*promised* fields; a backend module that should not yet render is out of scope by
|
||||
design, not by oversight.
|
||||
|
||||
---
|
||||
|
||||
## In the deploy floor
|
||||
|
||||
The guard runs in the standing suite, so it is part of the three-gate floor:
|
||||
**tests green** (now including reachability) + web build exit 0 + post-deploy
|
||||
fingerprint. A future order that adds a served field without wiring it to a
|
||||
mounted component fails CI rather than a hand-check three orders later.
|
||||
|
||||
No serving or model change in this order. `p_win` never mutated. No Bonferroni
|
||||
slot.
|
||||
@@ -0,0 +1,132 @@
|
||||
# The grade surface — it wasn't missing, it was serving the weaker signal
|
||||
|
||||
## PHASE 0 — what actually reaches a user
|
||||
|
||||
**Correction to the order's premise: a grade letter has been served all along.**
|
||||
`engine1.gradeProp` produces it from an additive factor index, and it is
|
||||
**not derived from `p_win` at all** — the two are computed independently and both
|
||||
ride the payload.
|
||||
|
||||
`gradeBands` is orphaned for a different reason than assumed: it defines what a
|
||||
letter *means* from realized outcomes, and every band it produces collapses to
|
||||
base-rate at current resolution. It was never the missing link to the surface.
|
||||
|
||||
### The measurement that changed the order
|
||||
|
||||
3,417 settled props, pooled across the four batter stats:
|
||||
|
||||
| grade | n | realized | mean p_win |
|
||||
|---|---|---|---|
|
||||
| **A** | 8 | **0.5000** | 0.6466 |
|
||||
| B | 985 | 0.6396 | 0.7002 |
|
||||
| C | 1,695 | 0.6024 | 0.6755 |
|
||||
| D | 303 | 0.5578 | 0.6042 |
|
||||
| F | 426 | 0.5352 | 0.5875 |
|
||||
|
||||
```
|
||||
grade-letter resolution 0.00116 (0.48% of variance)
|
||||
p_win resolution 0.00715 (2.98%)
|
||||
=> the letter carries 0.16x the information of the number beside it
|
||||
```
|
||||
|
||||
**The top grade hit worse than the bottom grade.** And concretely, from the
|
||||
hand-verify: **Christian Encarnación's 0.95 over graded `C`, and his 0.05 under
|
||||
also graded `C`** — same hitter, opposite forecasts, same letter.
|
||||
|
||||
The gap was never that grades don't ship. It is that **the weaker of two
|
||||
available signals was shipping as the headline.**
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — the honest grade
|
||||
|
||||
`model/servedGrade.js` derives the letter from `p_win`, with bands anchored on
|
||||
**measured realized rates**, not targets:
|
||||
|
||||
| letter | p_win ≥ | realized | separates from base rate? |
|
||||
|---|---|---|---|
|
||||
| B+ | 0.780 | 0.663 | yes |
|
||||
| B | 0.700 | 0.646 | yes |
|
||||
| C+ | 0.640 | 0.615 | **no** |
|
||||
| C | 0.560 | 0.589 | **no** |
|
||||
| C- | 0.480 | 0.548 | **no** |
|
||||
| D | 0.350 | 0.512 | yes |
|
||||
| F | 0.000 | 0.447 | yes |
|
||||
|
||||
Base rate 0.6005.
|
||||
|
||||
### No manufactured A — structurally
|
||||
|
||||
**`A+`, `A` and `A-` are UNISSUABLE.** Not rare — absent by construction. The
|
||||
realized rate plateaus at 0.65–0.68 above p_win 0.70 (the 0.9+ bucket does no
|
||||
better than the 0.8 bucket), so no band of this forecast has earned a top letter.
|
||||
A test sweeps every p_win from 0 to 1 and asserts none produces one. Even a 0.99
|
||||
forecast tops out at B+ with its realized 0.663 attached.
|
||||
|
||||
When resolution improves enough to earn an A, the ceiling gets raised
|
||||
deliberately and visibly — not by a threshold quietly drifting.
|
||||
|
||||
### Bands that cannot separate SAY so
|
||||
|
||||
`C+ / C / C-` carry `separates_from_base_rate: false` and copy that names it —
|
||||
*"a base-rate read; the model sees nothing that separates this."* That covers the
|
||||
bulk of the board, and it is the honest description of a forecast explaining 3%
|
||||
of variance.
|
||||
|
||||
### The basis is stated, never implied
|
||||
|
||||
Each grade carries `basis`: `forecast_plus_matchup_factors` (naming which of the
|
||||
three proven factors fired) or `forecast_only`, plus `calibrated: false` —
|
||||
calibration is withdrawn and nothing here rides on a number that doesn't exist.
|
||||
|
||||
`engine1.grade` is preserved as `engine_grade` so nothing downstream breaks and
|
||||
the two stay comparable.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — the refusal surface
|
||||
|
||||
Refusals render a real state, never a blank or a fabricated number:
|
||||
|
||||
- `insufficient_data` → **NO READ** — *"not enough history to call this one"*
|
||||
- `juiced_no_edge` → **NO READ** — *"the book has priced the vig past any edge on
|
||||
this side"*
|
||||
|
||||
1,870 refused snapshots carry exactly these two reasons, and both now surface.
|
||||
`projectionFor` reads the repaired full-window reference, so refusals are
|
||||
computed on the repaired champion.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 — hand-verified on real served props
|
||||
|
||||
| prop | p_win | OLD | NEW | separates | state |
|
||||
|---|---|---|---|---|---|
|
||||
| Freddie Freeman hits 0.5o | 0.95 | B | **B+** | true | graded |
|
||||
| Christian Encarnación hits 0.5o | 0.95 | **C** | **B+** | true | graded |
|
||||
| Ben Rice hits 0.5o | 0.95 | B | **B+** | true | graded |
|
||||
| Christian Encarnación hits 0.5u | 0.05 | **C** | **F** | true | graded |
|
||||
| Ben Rice hits 0.5u | 0.05 | C | **F** | true | graded |
|
||||
| Eliezer Alfonso Jr doubles 0.5o | — | — | **NO READ** | — | refused |
|
||||
| Eliezer Alfonso Jr doubles 0.5u | — | — | **NO READ** (vig) | — | refused |
|
||||
| Paul Goldschmidt doubles 0.5o | — | — | **NO READ** | — | refused |
|
||||
|
||||
```
|
||||
never-blank check: PASS — every prop renders a label and a meaning
|
||||
no-manufactured-A check: PASS
|
||||
```
|
||||
|
||||
The Encarnación rows are the clearest evidence: under the old letter his 0.95 and
|
||||
his 0.05 were both `C`. Under the new one they are `B+` and `F`.
|
||||
|
||||
---
|
||||
|
||||
## Invariants
|
||||
|
||||
Grades ride on repaired-champion raw `p_win` plus factors where they fire. No
|
||||
calibrated number leaks — the deployed set is empty and `calibrated: false` is
|
||||
stated on every grade. `p_win` never mutated. Nine frozen model modules verified
|
||||
unchanged, `engine1` included. No Bonferroni slot — no new factor.
|
||||
|
||||
**Still true and unchanged:** the forecast explains ~3% of outcome variance. This
|
||||
order did not make the model better. It made the letter stop overstating it.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Wave D1 — and why E1/E9 were not it
|
||||
|
||||
## PHASE 0 — the audit's actual wave order
|
||||
|
||||
The order proposed E1 + E9 as Wave-1 primitives and instructed me to follow the
|
||||
audit if it disagreed. **It disagrees.**
|
||||
|
||||
| item | the audit's placement |
|
||||
|---|---|
|
||||
| **E9 calibration curve** | **WAVE D3 — gated on MODEL work**: *"resolve n≥20 vs N30, accrue buckets"* |
|
||||
| **E1 movement strip** | **WAVE D6 — large surface builds**, alongside crown / disagreement axis / SPLIT |
|
||||
|
||||
**Neither is Wave 1.** And E9's gate is live right now: calibration is
|
||||
**withdrawn** (`CALIBRATION_DEPLOYED = []`) at **0 eligible dates**, so a
|
||||
calibration curve today could only render its empty state. Building it now would
|
||||
produce a component whose entire purpose is unavailable.
|
||||
|
||||
**WAVE D1, per the audit:** glyph library · card token + `vy-glitch` naming ·
|
||||
`nudge()`/boot-stagger/row-hover/IntersectionObserver · boundary-channel blue ·
|
||||
team-gradient chips + READ-FAB.
|
||||
|
||||
## PHASE 1 — three of the five Wave D1 items were already done
|
||||
|
||||
Checked rather than assumed, and the audit (2026-07-31) has aged:
|
||||
|
||||
| item | audit said | measured now |
|
||||
|---|---|---|
|
||||
| **D1 glyph library** | PARTIAL — 38 of 83 wired (46%) | **COMPLETE for everything wireable.** 83 designed glyphs, 41 registry archetypes, **39 map — and all 39 are wired, with colours matching the registry exactly (0 disagreements).** |
|
||||
| **A1 card token `#0E0E14`** | BUILT-BUT-DRIFTED — "in only 1 file" | **BUILT-TO-SPEC.** It is the `--bg-1` token in `globals.css`, consumed by **32 files** via `var(--bg-1)`. The audit counted literal hex occurrences, which is what a *correctly tokenised* value looks like. |
|
||||
| **B1 boundary channel** | PARTIAL — hex in 2 files | **BUILT.** `--priced-out: #8fb2de` + `--priced-out-dim` are tokens with a documented colour law in `globals.css`, consumed by 4 files. |
|
||||
|
||||
**The 44 unwired glyphs are not a wiring gap.** They have no backend archetype —
|
||||
wiring them would mean **inventing 44 archetypes to consume artwork**, which is
|
||||
the fabrication this programme refuses. That is the 41-vs-74 scope question, and
|
||||
it is Kev's call, not a build task. Separately, **2 registry archetypes have no
|
||||
designed glyph** (`DUAL THREAT`, `PAINT BOSS`) — a design gap, not a code one.
|
||||
|
||||
## PHASE 2 — what was genuinely absent, and is now built
|
||||
|
||||
`web/src/lib/motion.js` — the four primitives, all previously 0 files:
|
||||
|
||||
| primitive | the rule it enforces |
|
||||
|---|---|
|
||||
| **E17 `nudge()`** | ≤180ms. Confirms an action *registered* without claiming it finished — a long pulse reads as latency, the opposite of a perceived-speed primitive. |
|
||||
| **E18 `bootStagger()`** | **capped at 240ms.** Uncapped, row 40 waits 1.1s and the stagger *becomes* the latency it exists to disguise. |
|
||||
| **E27 `rowHover()`** | returns handlers, not CSS — so a tap on touch cannot stick a hover state that never clears. |
|
||||
| **E28 `revealOnIntersect()`** | returns an unobserve fn in every path, and **reveals IMMEDIATELY** without IntersectionObserver or under reduced motion. Content is never hidden behind a capability check. |
|
||||
|
||||
**Reduced motion is honoured, not softened** — every primitive skips outright,
|
||||
and server-side assumes reduced. 10 tests, the sharpest being that
|
||||
`bootStagger` under reduced motion returns `opacity: 1` and not merely
|
||||
`delay: 0`: if the CSS animation supplies the opacity, skipping the animation
|
||||
without forcing it leaves the row invisible forever.
|
||||
|
||||
## PHASE 3 — guard registration and Wave-2 readiness
|
||||
|
||||
The reachability guard now carries a **PRIMITIVES** section: a module built to be
|
||||
embedded must declare its exports *and name its intended consumers*. A primitive
|
||||
imported by nothing is the same built-but-unread class as an unmounted
|
||||
component. 24 checks green.
|
||||
|
||||
### Wave-2 readiness
|
||||
|
||||
**Ungated — design complete, no blocker:**
|
||||
- **F9/F10/F11 offseason hub** (Wave D6) — the largest, fully spec'd
|
||||
- **F5 article media** (Wave D6) — hero + figure system fully spec'd
|
||||
- **E10/E12 The Report email + `/report` archive** — design exists
|
||||
- **E1 movement strip** (Wave D6) — spec'd, absent, now has its motion primitives
|
||||
|
||||
**Gated, and by what:**
|
||||
| item | gate |
|
||||
|---|---|
|
||||
| E9 calibration curve | **model** — calibration withdrawn, 0 eligible dates |
|
||||
| E15 Price Triplet MODEL leg | **model** — EV layer |
|
||||
| E16 share cards + F8 crops | **resolution tail** *and* the card-system reconciliation |
|
||||
| E2 BookChip tiles / E6 push-to-book | **licensing / affiliate approval** |
|
||||
| E13 Scanner S6 | another build order |
|
||||
|
||||
**The card-system reconciliation** (content-engine 1080×1350 vs E16's five
|
||||
masters) gates E16/F8 and should be settled with the social chat, since off-site
|
||||
posts and on-site share cards are one system.
|
||||
|
||||
Isolated throughout: read-only, no serving or model change, **accrual clock
|
||||
unchanged at 0 eligible dates**.
|
||||
@@ -204,6 +204,9 @@ app.use('/api/content', contentRoutes);
|
||||
// Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe
|
||||
// (forwards to the self-hosted Listmonk; graceful no-op without env).
|
||||
app.use('/api/newsletter', require('./routes/newsletter'));
|
||||
// E12 — The Report archive. Public, read-only; every issue carries its own
|
||||
// day record, because the archive is a ledger too.
|
||||
app.use('/api/report', require('./routes/report'));
|
||||
// A1 S9 — Slip Reader: OCR a bet-slip screenshot into legs (auth +
|
||||
// per-tier daily quota inside the router). Values are user-slip values.
|
||||
app.use('/api/slips', require('./routes/slips'));
|
||||
@@ -212,6 +215,10 @@ app.use('/api/slips', require('./routes/slips'));
|
||||
// the public surface; the Next.js admin route proxies through with
|
||||
// the key kept server-side.
|
||||
app.use('/api/internal', internalRoutes);
|
||||
// Content STUDIO — finished posts (copy + card + fact-contract) for review and,
|
||||
// later, for an autonomous poster. Distinct from /api/content (Session 29),
|
||||
// which serves structured content objects by data level.
|
||||
app.use('/api/content-studio', require('./routes/contentStudio'));
|
||||
// A1 S3 — partner attribution report. Internal-key gated (router-level
|
||||
// requireInternalAuth); no Next proxy on purpose — never browser-facing.
|
||||
app.use('/api/partners', require('./routes/partners'));
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* /api/content-studio — the generated-post review surface.
|
||||
*
|
||||
* NOT to be confused with `/api/content` (Session 29), which serves structured
|
||||
* content OBJECTS by data level. This serves finished POSTS from the content
|
||||
* engine: copy, branded card, and the fact-contract each was built from.
|
||||
*
|
||||
* ── API-FIRST, ON PURPOSE ────────────────────────────────────────────────
|
||||
* This is the contract Kev's preview page consumes today and an autonomous
|
||||
* poster consumes later. The page is a thin client; no posting logic lives in
|
||||
* it. Pointing a bot here needs no change on this side, which is the entire
|
||||
* reason it is an API before it is a screen.
|
||||
*
|
||||
* ── READ-ONLY WHERE IT COUNTS ────────────────────────────────────────────
|
||||
* It serves generated content. Beyond the read-only pulls the engine already
|
||||
* makes, it touches no serving, model or ledger table — zero effect on the
|
||||
* repaired-champion accrual clock. Approval status lives in Redis, because an
|
||||
* editorial decision is not a model fact and must never sit beside ledger rows.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { requireInternalAuth } = require('../middleware/internalAuth');
|
||||
const engine = require('../services/content/contentEngine');
|
||||
const { toSvg } = require('../services/content/cardRenderer');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireInternalAuth({ loopbackOnly: false })); // private: Kev's desk, then an agent's
|
||||
|
||||
const STATUS_KEY = (date) => `contentstudio:status:${date}`;
|
||||
const VALID_STATUS = new Set(['pending', 'approved', 'skipped', 'regenerate_requested']);
|
||||
|
||||
const dateET = () => new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(new Date());
|
||||
|
||||
function registerTemplates() {
|
||||
for (const t of ['hotHitters', 'honestyFlex', 'streakList']) {
|
||||
try { engine.registerTemplate(require(`../services/content/templates/${t}`)); } catch { /* idempotent */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/content-studio/:date?
|
||||
*
|
||||
* → { date, count, posts: [{ id, label, sport, status, ok, skipped, reason,
|
||||
* honest_absence, copy, card, card_svg, fact_contract, facts }] }
|
||||
*
|
||||
* `fact_contract` is what the post was REQUIRED to have; `facts` is what
|
||||
* actually backed it. A reviewer — or an agent — can check the claim rather
|
||||
* than trust the sentence.
|
||||
*/
|
||||
// EXPRESS 5 DROPPED THE `?` OPTIONAL-PARAM SYNTAX -- `'/:date?'` throws at mount
|
||||
// time and takes down every suite that imports app.js. Two explicit routes.
|
||||
async function handleGet(req, res) {
|
||||
try {
|
||||
registerTemplates();
|
||||
const date = req.params.date || dateET();
|
||||
const deps = req.app.get('contentStudioDeps') || (await buildDeps(date));
|
||||
const results = await engine.generateAll({ ...deps, date });
|
||||
|
||||
let statuses = {};
|
||||
try { statuses = (await require('../utils/redis').cacheGet(STATUS_KEY(date))) || {}; } catch { statuses = {}; }
|
||||
|
||||
const posts = results.map((r) => {
|
||||
const t = engine.getTemplate(r.id) || {};
|
||||
return {
|
||||
id: r.id,
|
||||
label: t.label || r.id,
|
||||
sport: t.sport || null,
|
||||
status: statuses[r.id] || 'pending',
|
||||
ok: r.ok === true,
|
||||
skipped: r.skipped === true,
|
||||
reason: r.reason || null,
|
||||
honest_absence: r.honest_absence === true,
|
||||
copy: r.copy || null,
|
||||
card: r.card || null,
|
||||
card_svg: r.card ? toSvg(r.card) : null,
|
||||
fact_contract: t.requires || [],
|
||||
facts: r.facts || null,
|
||||
};
|
||||
});
|
||||
res.json({ date, count: posts.length, posts });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
}
|
||||
router.get('/', handleGet);
|
||||
router.get('/:date', handleGet);
|
||||
|
||||
/** POST /api/content-studio/:date/:id/status { status } — editorial state only. */
|
||||
router.post('/:date/:id/status', express.json(), async (req, res) => {
|
||||
const { date, id } = req.params;
|
||||
const status = String((req.body || {}).status || '');
|
||||
if (!VALID_STATUS.has(status)) {
|
||||
return res.status(400).json({ error: `status must be one of ${[...VALID_STATUS].join(', ')}` });
|
||||
}
|
||||
try {
|
||||
const { cacheGet, cacheSet } = require('../utils/redis');
|
||||
const cur = (await cacheGet(STATUS_KEY(date))) || {};
|
||||
cur[id] = status;
|
||||
await cacheSet(STATUS_KEY(date), cur, 60 * 60 * 24 * 14);
|
||||
return res.json({ ok: true, date, id, status });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/** Real sources. All READ-ONLY. */
|
||||
async function buildDeps(date) {
|
||||
const sg = require('../services/model/servedGrade');
|
||||
const { knownNumber } = require('../utils/known');
|
||||
const sb = require('../utils/supabase').getSupabaseServiceClient();
|
||||
const mlb = require('../services/adapters/mlbStatsAdapter');
|
||||
|
||||
const gradeDistribution = async () => {
|
||||
if (!sb) return { total: null };
|
||||
const { data } = await sb.from('model_snapshots')
|
||||
.select('p_win, refused').eq('sport', 'mlb').eq('game_date', date).limit(5000);
|
||||
const usable = (data || []).filter((r) => !r.refused && knownNumber(r.p_win) !== null);
|
||||
const by = {}; let flat = 0;
|
||||
for (const r of usable) {
|
||||
const g = sg.gradeFor({ p_win: knownNumber(r.p_win) });
|
||||
by[g.letter] = (by[g.letter] || 0) + 1;
|
||||
if (g.separates_from_base_rate === false) flat += 1;
|
||||
}
|
||||
return { total: usable.length || null, by_letter: by, not_separable: flat };
|
||||
};
|
||||
|
||||
const settledStreaks = async () => {
|
||||
if (!sb) return [];
|
||||
const { data } = await sb.from('ledger_entries')
|
||||
.select('player_name, player_key, game_date, outcome')
|
||||
.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits')
|
||||
.in('outcome', ['hit', 'miss']).limit(5000);
|
||||
const by = new Map();
|
||||
for (const r of data || []) {
|
||||
if (!by.has(r.player_key)) by.set(r.player_key, []);
|
||||
by.get(r.player_key).push(r);
|
||||
}
|
||||
const out = [];
|
||||
for (const [, rows] of by) {
|
||||
rows.sort((a, b) => String(b.game_date).localeCompare(String(a.game_date)));
|
||||
let n = 0;
|
||||
for (const r of rows) { if (r.outcome === 'hit') n += 1; else break; }
|
||||
if (n >= 3) out.push({ name: rows[0].player_name, streak: n, verified_from_settled: true });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const hitterForm = async () => {
|
||||
if (!sb) return [];
|
||||
const { data } = await sb.from('model_snapshots')
|
||||
.select('player_name').eq('sport', 'mlb').eq('game_date', date).limit(400);
|
||||
const names = [...new Set((data || []).map((r) => r.player_name).filter(Boolean))].slice(0, 40);
|
||||
const out = [];
|
||||
for (const n of names) {
|
||||
try {
|
||||
const r = await mlb.getPlayerStats(n);
|
||||
const log = (r && r.found && Array.isArray(r.fullLog)) ? r.fullLog : [];
|
||||
const vals = log.map((g) => knownNumber(g && g.stat && g.stat.hits)).filter((v) => v !== null);
|
||||
if (vals.length < 20) continue;
|
||||
const rate = (a) => a.filter((v) => v > 0).length / a.length;
|
||||
out.push({ name: n, season_games: vals.length, season_rate: rate(vals), recent_rate: rate(vals.slice(-10)) });
|
||||
} catch { /* absent player -> absent row */ }
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return { servedGrade: sg, gradeDistribution, settledStreaks, hitterForm };
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.__internals = { buildDeps, VALID_STATUS, STATUS_KEY };
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* /api/report — E12, the issue archive.
|
||||
*
|
||||
* The spec's law is one line: **"EVERY ISSUE SHOWS ITS OWN DAY RECORD — THE
|
||||
* ARCHIVE IS A LEDGER TOO."** So an archive row is not a headline with a date;
|
||||
* it carries the record that issue's reads actually produced. An archive that
|
||||
* showed only titles would be a blog, and the point of this one is that it
|
||||
* cannot quietly bury a bad day.
|
||||
*
|
||||
* Public and READ-ONLY. Issues live in Redis under `report:issue:{date}`,
|
||||
* written by the send path; nothing here touches a serving, model or ledger
|
||||
* table.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const router = express.Router();
|
||||
const INDEX_KEY = 'report:index';
|
||||
const ISSUE_KEY = (d) => `report:issue:${d}`;
|
||||
|
||||
/** GET /api/report — the archive list, newest first. */
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { cacheGet } = require('../utils/redis');
|
||||
const index = (await cacheGet(INDEX_KEY)) || [];
|
||||
const issues = Array.isArray(index) ? index : [];
|
||||
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 60));
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
res.json({
|
||||
count: issues.length,
|
||||
// Honest empty state is the caller's to render; we simply report zero.
|
||||
issues: issues.slice(0, limit),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/report/:date — one issue. 404 rather than an invented shell. */
|
||||
router.get('/:date', async (req, res) => {
|
||||
try {
|
||||
const { cacheGet } = require('../utils/redis');
|
||||
const issue = await cacheGet(ISSUE_KEY(req.params.date));
|
||||
if (!issue) return res.status(404).json({ error: 'no issue for that date' });
|
||||
res.set('Cache-Control', 'public, max-age=300');
|
||||
return res.json(issue);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.__internals = { INDEX_KEY, ISSUE_KEY };
|
||||
@@ -0,0 +1,83 @@
|
||||
'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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
/** 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 };
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* contentEngine — posts that structurally cannot lie.
|
||||
*
|
||||
* The top-of-funnel is a content flywheel, and the thing that makes it VYNDR's
|
||||
* rather than anyone's is that the numbers in it are real. That is easy to
|
||||
* promise and hard to keep, because the failure mode is not a person choosing to
|
||||
* fabricate — it is a template with a hardcoded adjective, or a field that came
|
||||
* back null and rendered as "0", or a caption drifting from the card beside it.
|
||||
*
|
||||
* So the honesty is enforced by construction rather than by care:
|
||||
*
|
||||
* 1. COPY IS TOKEN-SUBSTITUTED. Every factual claim in a template is a
|
||||
* `{token}` resolved against pulled facts. A token with no backing fact
|
||||
* REFUSES to render — it cannot fall back to a plausible default, because
|
||||
* there is no code path that produces one.
|
||||
* 2. THE FACT CONTRACT IS ASSERTED FIRST. A template declares the fields it
|
||||
* needs; the engine checks them before any string is built. A missing field
|
||||
* means SKIP or an honest-absence variant, never invention.
|
||||
* 3. CARD AND COPY SHARE ONE FACT OBJECT. They cannot diverge, because there
|
||||
* is only one set of numbers and both read it.
|
||||
*
|
||||
* NO LIVE MODEL WRITES FACTUAL CLAIMS. The voice lives in the template; the
|
||||
* facts are pulled. A voice-polish port is reserved for later and is not wired
|
||||
* here — an LLM that can rewrite a sentence can rewrite a number.
|
||||
*
|
||||
* READ-ONLY. This engine touches no serving or forecast table. It has zero
|
||||
* effect on the repaired-champion accrual clock.
|
||||
*/
|
||||
|
||||
const { knownNumber } = require('../../utils/known');
|
||||
|
||||
/** Resolve `a.b.c` against an object; undefined when any hop is missing. */
|
||||
function pathValue(obj, path) {
|
||||
return String(path).split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* A value that may stand as a fact.
|
||||
*
|
||||
* `null` and `undefined` are absent. Empty string is absent — it renders as a
|
||||
* hole in a sentence. Zero and false are PRESENT: "0 props cleared B+" is a real
|
||||
* and important claim, and treating 0 as missing is the `Number(null) === 0`
|
||||
* breach wearing its opposite coat.
|
||||
*/
|
||||
function isPresent(v) {
|
||||
if (v === null || v === undefined) return false;
|
||||
if (typeof v === 'string' && v.trim() === '') return false;
|
||||
if (Array.isArray(v) && v.length === 0) return false;
|
||||
if (typeof v === 'number' && !Number.isFinite(v)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const TOKEN = /\{([a-zA-Z0-9_.]+)\}/g;
|
||||
|
||||
/** Every `{token}` in a string. */
|
||||
function tokensIn(text) {
|
||||
const out = [];
|
||||
let m;
|
||||
const re = new RegExp(TOKEN);
|
||||
while ((m = re.exec(String(text))) !== null) out.push(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute tokens, refusing on any that is unbacked.
|
||||
* @throws when a token has no present fact — the whole point.
|
||||
*/
|
||||
function render(text, facts) {
|
||||
const missing = tokensIn(text).filter((t) => !isPresent(pathValue(facts, t)));
|
||||
if (missing.length) {
|
||||
throw new Error(`TRUTH LAW: unbacked token(s) ${missing.join(', ')} — refusing to render`);
|
||||
}
|
||||
return String(text).replace(TOKEN, (_, t) => String(pathValue(facts, t)));
|
||||
}
|
||||
|
||||
/** Which contract fields are absent from a pulled fact set. */
|
||||
function contractGaps(template, facts) {
|
||||
return (template.requires || []).filter((f) => !isPresent(pathValue(facts, f)));
|
||||
}
|
||||
|
||||
const registry = new Map();
|
||||
|
||||
/**
|
||||
* Register a template.
|
||||
*
|
||||
* @param {object} t
|
||||
* id stable key
|
||||
* sport which sport it speaks for
|
||||
* requires fact-contract: dotted paths that MUST be present
|
||||
* pull async (deps) => facts — the ONLY place data enters
|
||||
* copy (facts) => string with {tokens}
|
||||
* card (facts) => card model (layout + token strings)
|
||||
* absent optional (gaps) => honest "nothing tonight" post
|
||||
*/
|
||||
function registerTemplate(t) {
|
||||
if (!t || !t.id) throw new Error('a template needs an id');
|
||||
for (const k of ['requires', 'pull', 'copy', 'card']) {
|
||||
if (!t[k]) throw new Error(`template ${t.id} is missing ${k}`);
|
||||
}
|
||||
registry.set(t.id, t);
|
||||
return t;
|
||||
}
|
||||
|
||||
const listTemplates = () => [...registry.values()];
|
||||
const getTemplate = (id) => registry.get(id) || null;
|
||||
|
||||
/**
|
||||
* Generate one post.
|
||||
*
|
||||
* @returns {object} { ok, id, copy, card, facts, skipped, reason }
|
||||
* Never throws on absent data — absence is an outcome, not an error.
|
||||
*/
|
||||
async function generate(id, deps = {}) {
|
||||
const t = registry.get(id);
|
||||
if (!t) return { ok: false, id, skipped: true, reason: 'no such template' };
|
||||
|
||||
let facts;
|
||||
try {
|
||||
facts = await t.pull(deps);
|
||||
} catch (e) {
|
||||
return { ok: false, id, skipped: true, reason: `pull failed: ${e.message}` };
|
||||
}
|
||||
|
||||
// ── THE CONTRACT, BEFORE ANY STRING IS BUILT ──
|
||||
const gaps = contractGaps(t, facts || {});
|
||||
if (gaps.length) {
|
||||
if (typeof t.absent === 'function') {
|
||||
const alt = t.absent(gaps, facts || {}) || {};
|
||||
// An absence variant may DECLINE to speak. A template that cannot tell
|
||||
// "nothing happened" from "nothing was fetched" must not publish the
|
||||
// first sentence when the second is true -- honest-absence copy is a
|
||||
// perfect hiding place for a broken pull.
|
||||
if (alt.skip) return { ok: false, id, skipped: true, reason: alt.skip, gaps };
|
||||
return { ok: true, id, honest_absence: true, copy: alt.copy, card: alt.card, facts: facts || {}, gaps };
|
||||
}
|
||||
return { ok: false, id, skipped: true, reason: `fact-contract gap: ${gaps.join(', ')}`, gaps };
|
||||
}
|
||||
|
||||
try {
|
||||
const copyRaw = t.copy(facts);
|
||||
const copy = render(copyRaw, facts);
|
||||
const cardModel = t.card(facts);
|
||||
// The card's own strings pass the same gate -- a caption and a card cannot
|
||||
// disagree if both are rendered from one fact object under one rule.
|
||||
const card = {
|
||||
...cardModel,
|
||||
lines: (cardModel.lines || []).map((l) => (typeof l === 'string'
|
||||
? render(l, facts)
|
||||
: { ...l, text: render(l.text, facts) })),
|
||||
title: cardModel.title ? render(cardModel.title, facts) : null,
|
||||
subtitle: cardModel.subtitle ? render(cardModel.subtitle, facts) : null,
|
||||
};
|
||||
return { ok: true, id, copy, card, facts };
|
||||
} catch (e) {
|
||||
// A refusal is a skip, never a degraded post.
|
||||
return { ok: false, id, skipped: true, reason: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate every registered template; skips are reported, never hidden. */
|
||||
async function generateAll(deps = {}) {
|
||||
const out = [];
|
||||
for (const t of listTemplates()) out.push(await generate(t.id, deps));
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerTemplate, listTemplates, getTemplate, generate, generateAll,
|
||||
render, tokensIn, contractGaps, isPresent, pathValue,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* TEMPLATE 2 — THE HONESTY FLEX.
|
||||
*
|
||||
* The differentiator, and the one that would be worthless if it were padded.
|
||||
* It publishes the REAL grade distribution: how many props were graded, how few
|
||||
* cleared the ceiling, and the fact that A is unissuable because no band of this
|
||||
* model has ever earned one.
|
||||
*
|
||||
* Every competitor's card is all A's. Ours says most of tonight is a base-rate
|
||||
* read — and that claim is only impressive if it is exactly true, so the numbers
|
||||
* come from servedGrade's own bands rather than from a marketing sentence.
|
||||
*/
|
||||
|
||||
const { knownNumber } = require('../../../utils/known');
|
||||
|
||||
module.exports = {
|
||||
id: 'honesty_flex',
|
||||
sport: 'mlb',
|
||||
label: 'The Honesty Flex',
|
||||
requires: ['graded', 'ceiling_letter', 'ceiling_realized', 'base_rate', 'date'],
|
||||
|
||||
async pull(deps) {
|
||||
const g = await deps.gradeDistribution(); // injected, read-only
|
||||
const sg = deps.servedGrade || require('../../model/servedGrade');
|
||||
const total = knownNumber(g && g.total);
|
||||
const top = (g && g.by_letter && (g.by_letter['B+'] || 0)) || 0;
|
||||
const flat = (g && g.not_separable) || 0;
|
||||
const ceiling = sg.BANDS[0];
|
||||
return {
|
||||
date: deps.date,
|
||||
graded: total,
|
||||
top_count: top,
|
||||
top_pct: total ? Math.round((top / total) * 100) : null,
|
||||
flat_count: flat,
|
||||
flat_pct: total ? Math.round((flat / total) * 100) : null,
|
||||
ceiling_letter: ceiling.letter,
|
||||
ceiling_realized: Math.round(ceiling.realized * 100),
|
||||
base_rate: Math.round(sg.BASE_RATE * 100),
|
||||
unissuable: sg.UNISSUABLE.join(', '),
|
||||
};
|
||||
},
|
||||
|
||||
copy: () => `WE GRADED {graded} PROPS TONIGHT. {top_count} CLEARED {ceiling_letter}.
|
||||
|
||||
That's {top_pct}%. The other {flat_pct}% we can't separate from the baseline, and we say so on the card instead of calling them leans.
|
||||
|
||||
Our ceiling is {ceiling_letter} — those reads land about {ceiling_realized}% against a {base_rate}% baseline. We do not issue {unissuable}. No band of this model has ever hit at a rate that would justify one.
|
||||
|
||||
Everybody else's card is all A's. Ask them what their A actually hits.`,
|
||||
|
||||
card: () => ({
|
||||
kind: 'flex',
|
||||
title: 'TONIGHT, HONESTLY',
|
||||
subtitle: '{date}',
|
||||
lines: [
|
||||
{ text: '{graded} graded', style: 'lead' },
|
||||
{ text: '{top_count} cleared {ceiling_letter} — {top_pct}%', style: 'stat' },
|
||||
{ text: '{flat_pct}% we cannot separate from baseline', style: 'dim' },
|
||||
{ text: '', style: 'rule' },
|
||||
{ text: 'Ceiling: {ceiling_letter} · lands {ceiling_realized}%', style: 'body' },
|
||||
{ text: 'Baseline: {base_rate}%', style: 'body' },
|
||||
{ text: 'We do not issue {unissuable}.', style: 'body' },
|
||||
],
|
||||
footer: 'Grades are earned from realized outcomes, not issued on confidence.',
|
||||
}),
|
||||
|
||||
absent: () => ({
|
||||
copy: 'No slate graded tonight. Nothing to show, so nothing shown.',
|
||||
card: { kind: 'absence', title: 'NO SLATE TONIGHT', subtitle: 'NOTHING TO PAD', lines: [{ text: 'No props graded. An empty board is an honest board.', style: 'body' }], footer: 'We post the count even when the count is zero.' },
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* TEMPLATE 1 — HOT HITTERS.
|
||||
*
|
||||
* Reads the REPAIRED full-season log, not a ten-game slice. "Hot" here means a
|
||||
* recent rate measured against that hitter's own season rate — which is only a
|
||||
* meaningful comparison now that the season rate is a season.
|
||||
*/
|
||||
|
||||
const { knownNumber } = require('../../../utils/known');
|
||||
|
||||
/** A hitter must have this much history before we call him anything. */
|
||||
const MIN_SEASON_GAMES = 20;
|
||||
const RECENT = 10;
|
||||
|
||||
module.exports = {
|
||||
id: 'hot_hitters',
|
||||
sport: 'mlb',
|
||||
label: 'Hot Hitters',
|
||||
requires: ['count', 'hitters', 'window', 'date'],
|
||||
|
||||
async pull(deps) {
|
||||
const rows = await deps.hitterForm(); // injected, read-only
|
||||
// ── EMPTY POOL IS NOT AN HONEST ABSENCE ────────────────────────────────
|
||||
// The first run of this template emitted "no hitter is meaningfully hot"
|
||||
// while the real cause was a source holding fewer than 20 games for EVERY
|
||||
// player. That reads as a considered editorial judgement and is actually a
|
||||
// broken pull -- the exact failure class that has bitten this codebase four
|
||||
// times tonight, here wearing the costume of our own honesty copy.
|
||||
//
|
||||
// So the two are separated: no candidates at all is a SKIP with a reason;
|
||||
// candidates present but none hot is the honest absence.
|
||||
const candidates = (rows || []).length;
|
||||
const usable = (rows || []).filter((r) =>
|
||||
knownNumber(r.season_games) !== null && r.season_games >= MIN_SEASON_GAMES
|
||||
&& knownNumber(r.recent_rate) !== null && knownNumber(r.season_rate) !== null);
|
||||
const hot = usable
|
||||
.map((r) => ({ ...r, lift: r.recent_rate - r.season_rate }))
|
||||
.filter((r) => r.lift > 0)
|
||||
.sort((a, b) => b.lift - a.lift)
|
||||
.slice(0, 5);
|
||||
return {
|
||||
date: deps.date,
|
||||
window: RECENT,
|
||||
candidates,
|
||||
qualified: usable.length,
|
||||
count: hot.length || null, // zero hot hitters is an ABSENT list, not "0 hot hitters"
|
||||
hitters: hot.length ? hot : null,
|
||||
list: hot.map((h, i) =>
|
||||
`${i + 1}. ${h.name} — ${Math.round(h.recent_rate * 100)}% last ${RECENT}, ${Math.round(h.season_rate * 100)}% season`).join('\n'),
|
||||
top_name: hot[0] ? hot[0].name : null,
|
||||
top_recent: hot[0] ? Math.round(hot[0].recent_rate * 100) : null,
|
||||
top_season: hot[0] ? Math.round(hot[0].season_rate * 100) : null,
|
||||
};
|
||||
},
|
||||
|
||||
copy: () => `WHO'S ACTUALLY HOT — {date}
|
||||
|
||||
{top_name} is hitting {top_recent}% over his last {window}. His season number is {top_season}%.
|
||||
That gap is the whole point. Everybody else is guessing at it.
|
||||
|
||||
{list}
|
||||
|
||||
Measured off full season logs, not a ten-game window that flatters whoever ran hot last week.`,
|
||||
|
||||
card: () => ({
|
||||
kind: 'list',
|
||||
title: "WHO'S ACTUALLY HOT",
|
||||
subtitle: 'LAST {window} vs SEASON · {date}',
|
||||
lines: [
|
||||
{ text: '{top_name}', style: 'lead' },
|
||||
{ text: '{top_recent}% last {window} · {top_season}% season', style: 'stat' },
|
||||
{ text: '', style: 'rule' },
|
||||
{ text: '{list}', style: 'body' },
|
||||
],
|
||||
footer: 'Rates measured from full season game logs.',
|
||||
}),
|
||||
|
||||
absent: (gaps, facts) => {
|
||||
// Only speak if there was actually a pool to judge.
|
||||
if (!facts || !facts.qualified) {
|
||||
return {
|
||||
copy: null,
|
||||
card: null,
|
||||
skip: `no qualified hitters in the pool (candidates=${(facts && facts.candidates) || 0}, qualified=${(facts && facts.qualified) || 0}) — source problem, not a quiet night`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
copy: "No hitter is meaningfully hot tonight.\n\nWe could dress up a middling week as a streak. We don't.",
|
||||
card: { kind: 'absence', title: 'NOTHING HOT TONIGHT', subtitle: 'AND WE WILL SAY SO', lines: [{ text: 'No hitter cleared his own season rate by enough to name.', style: 'body' }], footer: 'An empty list is a real answer.' },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* TEMPLATE 3 — STREAK LIST.
|
||||
*
|
||||
* A streak is only a streak if every game in it settled. This reads the ledger's
|
||||
* SETTLED outcomes, so a run cannot be extended by a game that is still pending
|
||||
* or was voided for a DNP — the two ways a streak list quietly inflates.
|
||||
*
|
||||
* The structure is sport-agnostic: it takes settled outcome rows and a noun.
|
||||
* MLB hit streaks today; the same shape carries TD streaks or made-three streaks
|
||||
* the moment those sports have settled outcomes.
|
||||
*/
|
||||
|
||||
const MIN_STREAK = 3;
|
||||
|
||||
module.exports = {
|
||||
id: 'streak_list',
|
||||
sport: 'mlb',
|
||||
label: 'Active Streaks',
|
||||
requires: ['streaks', 'count', 'noun', 'date'],
|
||||
|
||||
async pull(deps) {
|
||||
const rows = await deps.settledStreaks(); // injected, read-only
|
||||
const live = (rows || [])
|
||||
.filter((r) => Number(r.streak) >= MIN_STREAK && r.verified_from_settled === true)
|
||||
.sort((a, b) => b.streak - a.streak)
|
||||
.slice(0, 6);
|
||||
return {
|
||||
date: deps.date,
|
||||
noun: deps.noun || 'game hit streak',
|
||||
count: live.length || null,
|
||||
streaks: live.length ? live : null,
|
||||
list: live.map((s) => `${s.name} — ${s.streak} straight`).join('\n'),
|
||||
top_name: live[0] ? live[0].name : null,
|
||||
top_streak: live[0] ? live[0].streak : null,
|
||||
};
|
||||
},
|
||||
|
||||
copy: () => `ACTIVE STREAKS — {date}
|
||||
|
||||
{top_name} has a {top_streak}-{noun}. Live, verified off settled results only.
|
||||
|
||||
{list}
|
||||
|
||||
Every game in these ran to a final. We don't count a pending night to make a number look better.`,
|
||||
|
||||
card: () => ({
|
||||
kind: 'list',
|
||||
title: 'ACTIVE STREAKS',
|
||||
subtitle: 'VERIFIED FROM SETTLED RESULTS · {date}',
|
||||
lines: [
|
||||
{ text: '{top_name}', style: 'lead' },
|
||||
{ text: '{top_streak} straight', style: 'stat' },
|
||||
{ text: '', style: 'rule' },
|
||||
{ text: '{list}', style: 'body' },
|
||||
],
|
||||
footer: 'Settled games only. Pending and voided nights do not count.',
|
||||
}),
|
||||
|
||||
absent: () => ({
|
||||
copy: 'No live streaks worth naming tonight.\n\nWe could lower the bar to three-of-four. We keep the bar.',
|
||||
card: { kind: 'absence', title: 'NO LIVE STREAKS', subtitle: 'THE BAR STAYS WHERE IT IS', lines: [{ text: 'Nothing running long enough to name.', style: 'body' }], footer: 'We do not lower a threshold to fill a card.' },
|
||||
}),
|
||||
};
|
||||
@@ -574,6 +574,51 @@ async function analyzeViaEngine1(rawProp = {}) {
|
||||
? (Number.isFinite(pOver) ? 1 - pOver : null)
|
||||
: (Number.isFinite(pOver) ? pOver : null);
|
||||
if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000;
|
||||
// ── THE SERVED GRADE ────────────────────────────────────────────────
|
||||
// Derived from the forecast, not from engine1's additive factor index.
|
||||
// Measured on 3,417 settled props, that index carried 0.16x the information
|
||||
// of the p_win printed beside it, and its A grade hit 0.500 while its F hit
|
||||
// 0.535 -- the top letter did worse than the bottom. Concretely: the same
|
||||
// hitter's 0.95 over and 0.05 under both graded C.
|
||||
//
|
||||
// engine1.grade is PRESERVED on the payload as `engine_grade` so nothing
|
||||
// downstream breaks and the two remain comparable, but `served_grade` is
|
||||
// what a user should see.
|
||||
try {
|
||||
const sg = require('../model/servedGrade');
|
||||
const served = sg.gradeFor({
|
||||
p_win: legacy.p_win,
|
||||
refused: legacy.refused || legacy.insufficient_data,
|
||||
refusal_reason: legacy.refusal_reason,
|
||||
factor_adjustment: factorTrace,
|
||||
});
|
||||
legacy.served_grade = served;
|
||||
|
||||
// ── TOTAL CUTOVER, NOT A PARALLEL GRADE ────────────────────────────
|
||||
// `legacy.grade` IS the honest letter now. Attaching served_grade beside
|
||||
// the old one and leaving `grade` alone would have repeated the exact
|
||||
// failure diagnosed for gradeBands: built, correct, and read by nobody.
|
||||
// Fourteen-plus consumers (scan route, dashboard, parlay, newsletter,
|
||||
// desk, content templates, retention) all read `.grade`, so overwriting
|
||||
// it here cuts every surface over at once instead of editing each.
|
||||
//
|
||||
// The original index is preserved as `engine_grade` for comparison and is
|
||||
// read by no serving code.
|
||||
legacy.engine_grade = legacy.grade;
|
||||
if (served.letter) {
|
||||
legacy.grade = served.letter;
|
||||
// Confidence must not contradict the letter. It previously came from a
|
||||
// grade-band midpoint of the OLD letter, so leaving it would have paired
|
||||
// a B+ with a C's confidence. Both now derive from p_win -- kept on the
|
||||
// existing 0-100 scale, since every consumer and every stored row uses it.
|
||||
legacy.confidence = Math.round(legacy.p_win * 100);
|
||||
legacy.confidence_basis = 'p_win';
|
||||
} else {
|
||||
// A refusal has no letter, and consumers test `!grade` for exactly that.
|
||||
legacy.grade = null;
|
||||
}
|
||||
} catch { /* the grade surface must never break the read */ }
|
||||
|
||||
if (factorTrace) {
|
||||
legacy.factor_adjustment = factorTrace;
|
||||
legacy.p_win_prefactor = Math.round((dir === 'under' ? 1 - factorTrace.p_before : factorTrace.p_before) * 1000) / 1000;
|
||||
|
||||
@@ -29,6 +29,16 @@ const { getTeamInjuries } = require('./injuryParser');
|
||||
const { getLineMovement } = require('./lineMovement');
|
||||
const gameLogs = require('./gameLogService');
|
||||
|
||||
/**
|
||||
* How many games to request when asking for a player's history.
|
||||
*
|
||||
* A basketball season is 82 games; this is deliberately past it so a request
|
||||
* never truncates a season into a "season rate". The window-bug class has cost
|
||||
* this codebase four MLB paths and two basketball ones -- every instance was a
|
||||
* fixed N standing in for a season.
|
||||
*/
|
||||
const SEASON_LOG_DEPTH = 100;
|
||||
|
||||
const VECTOR_TTL_SECONDS = 120;
|
||||
|
||||
function avg(values) {
|
||||
@@ -225,7 +235,13 @@ function nbaGameLogFeatures(res, statType) {
|
||||
if (vals.length) {
|
||||
const m5 = avg(vals.slice(0, 5)); // most-recent first
|
||||
const m10 = avg(vals.slice(0, 10));
|
||||
const m20 = avg(vals.slice(0, 20));
|
||||
// SEASON, NOT TWENTY. `l20_avg` is the season per-game reference
|
||||
// `projectionFor` reads, and slicing to 20 made it a twenty-game average
|
||||
// wearing a season label -- the same defect as the MLB last10 bug
|
||||
// (929fd81) and the MLB l20 bug (494c83c). The ESPN adapter now returns the
|
||||
// full season log, so this takes all of it. Name kept: `l20_avg` is read in
|
||||
// many places, and renaming it is a separate, wider change.
|
||||
const m20 = avg(vals);
|
||||
const s10 = stddev(vals.slice(0, 10));
|
||||
if (m5 != null) out.l5_avg = m5;
|
||||
if (m10 != null) out.l10_avg = m10;
|
||||
@@ -306,7 +322,11 @@ async function getStatRows(playerName, sport, statType) {
|
||||
|
||||
// NBA/WNBA — Python service first (it's the richer source when it's up),
|
||||
// then the FREE ESPN per-athlete gamelog. Same order as gameLogFeatures.
|
||||
const pyLogs = await gameLogs.getGameLogs(playerName, sp, 20);
|
||||
// SEASON DEPTH, NOT TWENTY. The count is a request PARAMETER, so asking for
|
||||
// a season costs the same single call -- no extra request, no extra quota.
|
||||
// The Python service is offline in prod, so this cannot be verified live;
|
||||
// it is fixed now so basketball never launches on a twenty-game base rate.
|
||||
const pyLogs = await gameLogs.getGameLogs(playerName, sp, SEASON_LOG_DEPTH);
|
||||
if (Array.isArray(pyLogs) && pyLogs.length) {
|
||||
// Python rows are already flat + most-recent-first.
|
||||
for (const r of pyLogs) push(r && r.date, statFromGameLog(r, statType));
|
||||
@@ -354,7 +374,7 @@ async function gameLogFeatures(playerName, sport, statType) {
|
||||
}
|
||||
}
|
||||
|
||||
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
|
||||
const logs = await gameLogs.getGameLogs(playerName, sport, SEASON_LOG_DEPTH);
|
||||
|
||||
// Wave 0 — NBA/WNBA grade unlock. The Python nba_api service (gameLogService)
|
||||
// is offline in prod, so `logs` is null and this branch used to return {} →
|
||||
|
||||
@@ -26,7 +26,17 @@ function pythonPath(sport) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getGameLogs(playerName, sport, count = 20) {
|
||||
/**
|
||||
* DEFAULT IS A SEASON, NOT TWENTY.
|
||||
*
|
||||
* A default of 20 meant any caller that omitted the count silently received a
|
||||
* twenty-game window -- and every consumer of this function treats what it
|
||||
* returns as the player's history. The window-bug class has cost six paths
|
||||
* across two sports; a permissive default is how a seventh would arrive.
|
||||
*/
|
||||
const DEFAULT_LOG_DEPTH = 100;
|
||||
|
||||
async function getGameLogs(playerName, sport, count = DEFAULT_LOG_DEPTH) {
|
||||
const path = pythonPath(sport);
|
||||
if (!path) return null;
|
||||
const cacheKey = `gamelogs:${sport}:${playerName}:${count}`;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* servedGrade — the letter a user sees, derived from the forecast.
|
||||
*
|
||||
* ── WHY THIS EXISTS ──────────────────────────────────────────────────────
|
||||
* The letter being served came from engine1's additive factor index, computed
|
||||
* independently of `p_win`. Measured on 3,417 settled props:
|
||||
*
|
||||
* grade n realized mean p_win
|
||||
* A 8 0.500 0.647 <- the TOP grade did worst
|
||||
* B 985 0.640 0.700
|
||||
* C 1,695 0.602 0.676
|
||||
* D 303 0.558 0.604
|
||||
* F 426 0.535 0.588
|
||||
*
|
||||
* letter resolution 0.00116 (0.48% of variance)
|
||||
* p_win resolution 0.00715 (2.98%)
|
||||
*
|
||||
* The letter carried ONE SIXTH the information of the number printed beside it,
|
||||
* and its best grade hit worse than its worst. So the grade is derived from
|
||||
* `p_win` here instead — not because p_win is good (2.98% is not good) but
|
||||
* because shipping the weaker of two available signals as the headline is
|
||||
* indefensible.
|
||||
*
|
||||
* ── NO MANUFACTURED A ────────────────────────────────────────────────────
|
||||
* S91 established the honest ceiling: once the numbers are truthful this model
|
||||
* has no 80%-plus reads. The realized rate PLATEAUS around 0.65-0.68 from p_win
|
||||
* 0.70 upward — the 0.9+ bucket does no better than the 0.8 bucket.
|
||||
*
|
||||
* So A and A+ are NOT ISSUABLE. Not "rare" — structurally absent, because no
|
||||
* band of this forecast has ever realized a rate that would justify one. A test
|
||||
* asserts that no input produces an A. When resolution improves enough for a
|
||||
* band to earn it, the ceiling is raised deliberately and visibly, not by a
|
||||
* threshold quietly drifting.
|
||||
*
|
||||
* ── EVERY LETTER CARRIES ITS OWN MEANING ─────────────────────────────────
|
||||
* The band's realized rate travels with the grade, so the surface can state what
|
||||
* a B actually means rather than implying a spread the model does not have.
|
||||
*/
|
||||
|
||||
const { knownNumber } = require('../../utils/known');
|
||||
|
||||
/**
|
||||
* Bands over p_win, anchored on MEASURED realized rates (3,417 settled props,
|
||||
* pooled across the four batter stats).
|
||||
*
|
||||
* The realized column is what this band has actually done — not a target, not a
|
||||
* projection. `separates` says whether the band's realized rate is
|
||||
* distinguishable from the pooled base rate; today only the extremes are.
|
||||
*/
|
||||
const BASE_RATE = 0.6005;
|
||||
const BANDS = Object.freeze([
|
||||
{ letter: 'B+', min: 0.780, realized: 0.663, separates: true,
|
||||
meaning: 'the strongest read this model produces — realized about 66%' },
|
||||
{ letter: 'B', min: 0.700, realized: 0.646, separates: true,
|
||||
meaning: 'above this profile\'s base rate — realized about 65%' },
|
||||
{ letter: 'C+', min: 0.640, realized: 0.615, separates: false,
|
||||
meaning: 'slightly above base rate, not distinguishable from it' },
|
||||
{ letter: 'C', min: 0.560, realized: 0.589, separates: false,
|
||||
meaning: 'a base-rate read — the model sees nothing that separates this' },
|
||||
{ letter: 'C-', min: 0.480, realized: 0.548, separates: false,
|
||||
meaning: 'at or below base rate' },
|
||||
{ letter: 'D', min: 0.350, realized: 0.512, separates: true,
|
||||
meaning: 'below base rate — the model reads this as weak' },
|
||||
{ letter: 'F', min: 0.000, realized: 0.447, separates: true,
|
||||
meaning: 'well below base rate' },
|
||||
]);
|
||||
|
||||
/** Letters this forecast cannot justify. Absent by construction, not by rarity. */
|
||||
const UNISSUABLE = Object.freeze(['A+', 'A', 'A-']);
|
||||
|
||||
/**
|
||||
* The grade for one served prop.
|
||||
*
|
||||
* @param {object} prop { p_win, refused, refusal_reason, factor_adjustment }
|
||||
* @returns {object} always a renderable state — never null, never blank.
|
||||
*/
|
||||
function gradeFor(prop = {}) {
|
||||
// ── REFUSAL IS A REAL STATE, NOT A BLANK ──
|
||||
if (prop.refused || prop.insufficient_data) {
|
||||
return {
|
||||
letter: null,
|
||||
state: 'refused',
|
||||
label: 'NO READ',
|
||||
meaning: prop.refusal_reason === 'juiced_no_edge'
|
||||
? 'the book has priced the vig past any edge on this side'
|
||||
: 'not enough history to call this one',
|
||||
basis: 'refusal',
|
||||
separates: false,
|
||||
};
|
||||
}
|
||||
|
||||
const p = knownNumber(prop.p_win);
|
||||
if (p === null) {
|
||||
return {
|
||||
letter: null,
|
||||
state: 'no_forecast',
|
||||
label: 'NO READ',
|
||||
meaning: 'no forecast could be produced for this prop',
|
||||
basis: 'absent',
|
||||
separates: false,
|
||||
};
|
||||
}
|
||||
|
||||
const band = BANDS.find((b) => p >= b.min) || BANDS[BANDS.length - 1];
|
||||
const factored = Array.isArray(prop.factor_adjustment && prop.factor_adjustment.applied)
|
||||
&& prop.factor_adjustment.applied.length > 0;
|
||||
|
||||
return {
|
||||
letter: band.letter,
|
||||
state: 'graded',
|
||||
label: band.letter,
|
||||
meaning: band.meaning,
|
||||
band_realized_rate: band.realized,
|
||||
separates_from_base_rate: band.separates,
|
||||
base_rate: BASE_RATE,
|
||||
// What the letter was computed from, stated so the surface cannot imply more.
|
||||
basis: factored ? 'forecast_plus_matchup_factors' : 'forecast_only',
|
||||
factors_applied: factored ? prop.factor_adjustment.applied.map((a) => a.factor) : [],
|
||||
// Calibration is withdrawn; nothing here rides on a calibrated number.
|
||||
calibrated: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The scale legend — the ceiling stated as a POSITION, not left as an absence.
|
||||
*
|
||||
* Without this a user is left to wonder why they never see an A, and the most
|
||||
* natural guess ("the model is being coy" or "the slate is bad tonight") is
|
||||
* wrong. The honest answer is that top grades are earned from realized outcomes
|
||||
* and this forecast has not earned one.
|
||||
*/
|
||||
const SCALE_LEGEND = Object.freeze({
|
||||
headline: 'Grades are earned from realized outcomes, not issued on confidence.',
|
||||
ceiling: `Our honest ceiling right now is a strong B+ — those reads have landed about ${Math.round(BANDS[0].realized * 100)}% of the time against a ${Math.round(BASE_RATE * 100)}% baseline.`,
|
||||
no_a: 'We do not issue A grades. No band of this model has hit at a rate that would justify one, and we would rather show you the ceiling than invent a letter above it.',
|
||||
base_rate_note: 'Grades marked "base-rate read" are ones we cannot separate from the baseline. That is most of any slate, and saying so is the point.',
|
||||
when_a_returns: 'If the model earns an A, this legend changes and we will say why.',
|
||||
});
|
||||
|
||||
module.exports = { gradeFor, BANDS, UNISSUABLE, BASE_RATE, SCALE_LEGEND };
|
||||
@@ -0,0 +1,169 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* E10 — THE VYNDR REPORT, the designed issue template.
|
||||
*
|
||||
* The gap audit had this as PARTIAL, not absent: `newsletterService` already
|
||||
* builds the daily report's CONTENT and lints its voice. What was missing is the
|
||||
* designed HYBRID SHELL. So this is a template over that builder, not a second
|
||||
* report — the same compose-don't-fork call made for the movement strip.
|
||||
*
|
||||
* ── THE SPEC'S STRUCTURAL LAW ────────────────────────────────────────────
|
||||
* "Hybrid: dark billboard header that survives every client, light paper body
|
||||
* Gmail can't wreck. 600px, stacked, no webfont dependence."
|
||||
*
|
||||
* That is an engineering constraint, not a look. Gmail strips `<style>` blocks,
|
||||
* Outlook ignores flexbox, and a dark body renders as a black rectangle in
|
||||
* several clients. Hence: tables, inline styles only, 600px fixed, system-font
|
||||
* stacks, and no image required to read the issue.
|
||||
*
|
||||
* Its content law is equally short: *"One email per slate day. Top read, what
|
||||
* changed, the record. Nothing else."*
|
||||
*
|
||||
* ── FACT-CONTRACTED ──────────────────────────────────────────────────────
|
||||
* Same discipline as the content engine: a section whose data is absent is
|
||||
* OMITTED, never filled. There is no code path producing a placeholder figure,
|
||||
* and the designer's sample values (Nabers 1,120.5, Nº 128, 9-4) are a spec for
|
||||
* what a live issue renders — never content to paste.
|
||||
*/
|
||||
|
||||
const { knownNumber } = require('../../utils/known');
|
||||
|
||||
/** Design tokens, inlined because email clients drop stylesheets. */
|
||||
const T = Object.freeze({
|
||||
billboard: '#06060B', // dark header — survives every client
|
||||
paper: '#F7F6F2', // light body — Gmail cannot wreck it
|
||||
ink: '#1A1A22',
|
||||
greenOnDark: '#00D4A0',
|
||||
greenOnPaper: '#00A57D', // the green SHIFTS on paper for contrast
|
||||
rule: '#D8D5CC',
|
||||
dim: '#6B6B76',
|
||||
mono: "'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace",
|
||||
sans: "-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif",
|
||||
});
|
||||
const WIDTH = 600;
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
/** Present means renderable. Zero is present; empty and null are not. */
|
||||
const has = (v) => v !== null && v !== undefined && !(typeof v === 'string' && v.trim() === '')
|
||||
&& !(Array.isArray(v) && v.length === 0) && !(typeof v === 'number' && !Number.isFinite(v));
|
||||
|
||||
/**
|
||||
* The movement line — E1's law expressed in email-safe text.
|
||||
*
|
||||
* A `<canvas>` or SVG strip cannot be relied on in email, so the primitive's
|
||||
* RULE travels even though its rendering cannot: green only when the move
|
||||
* favours the read, and a flat market says FLAT rather than showing nothing.
|
||||
*/
|
||||
function movementText(m) {
|
||||
if (!m || !has(m.from) || !has(m.to)) return null;
|
||||
const from = knownNumber(m.from);
|
||||
const to = knownNumber(m.to);
|
||||
if (from === null || to === null) return null;
|
||||
if (from === to) return { text: `FLAT${has(m.days) ? ` · ${m.days}D` : ''}`, colour: T.dim };
|
||||
const favours = m.dir === 'toward';
|
||||
return {
|
||||
text: `${from} → ${to}`,
|
||||
colour: favours ? T.greenOnPaper : '#B0762A', // amber-on-paper for against
|
||||
};
|
||||
}
|
||||
|
||||
const row = (inner) => `<tr><td style="padding:0 28px;">${inner}</td></tr>`;
|
||||
|
||||
/**
|
||||
* @param {object} issue
|
||||
* number, date_label, read_time
|
||||
* top_read { grade, subject, line_text, movement:{from,to,dir,days}, model, best_book, note }
|
||||
* changed [{ time, tag, text }]
|
||||
* record { line, hit, miss, pct|null, note|null }
|
||||
* honesty { graded, cleared_ceiling, ceiling_letter, ceiling_realized, base_rate, unissuable }
|
||||
* @returns {object} { html, text, omitted[] } — omitted names what had no data.
|
||||
*/
|
||||
function renderIssue(issue = {}) {
|
||||
const omitted = [];
|
||||
const parts = [];
|
||||
|
||||
// ── DARK BILLBOARD HEADER ──
|
||||
parts.push(`<tr><td style="background:${T.billboard};padding:26px 28px;">
|
||||
<div style="font-family:${T.mono};font-size:20px;font-weight:800;letter-spacing:2px;color:#FFFFFF;">VYND<span style="color:${T.greenOnDark};">R</span></div>
|
||||
<div style="font-family:${T.mono};font-size:11px;letter-spacing:2px;color:#8A8A96;padding-top:8px;">
|
||||
THE REPORT${has(issue.number) ? ` · Nº ${esc(issue.number)}` : ''}${has(issue.date_label) ? ` · ${esc(issue.date_label)}` : ''}${has(issue.read_time) ? ` · READ TIME ${esc(issue.read_time)}` : ''}
|
||||
</div></td></tr>`);
|
||||
|
||||
// ── TOP READ OF THE DAY ──
|
||||
const tr = issue.top_read;
|
||||
if (tr && has(tr.subject) && has(tr.grade)) {
|
||||
const mv = movementText(tr.movement);
|
||||
parts.push(row(`<div style="padding:22px 0 0;">
|
||||
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:${T.dim};">TOP READ OF THE DAY</div>
|
||||
<div style="font-family:${T.sans};font-size:19px;font-weight:700;color:${T.ink};padding-top:10px;">
|
||||
<span style="font-family:${T.mono};color:${T.greenOnPaper};font-weight:800;">${esc(tr.grade)}</span>
|
||||
${esc(tr.subject)}${has(tr.line_text) ? ` <span style="font-weight:400;">${esc(tr.line_text)}</span>` : ''}
|
||||
</div>
|
||||
${mv || has(tr.model) || has(tr.best_book) ? `<div style="font-family:${T.mono};font-size:12px;color:${T.dim};padding-top:8px;">
|
||||
${mv ? `<span style="color:${mv.colour};font-weight:700;">${esc(mv.text)}</span>` : ''}
|
||||
${has(tr.model) ? ` · VYNDR ${esc(tr.model)}` : ''}
|
||||
${has(tr.best_book) ? ` · BEST: ${esc(tr.best_book)}` : ''}
|
||||
</div>` : ''}
|
||||
${has(tr.note) ? `<p style="font-family:${T.sans};font-size:14px;line-height:1.6;color:${T.ink};padding-top:12px;margin:0;">${esc(tr.note)}</p>` : ''}
|
||||
</div>`));
|
||||
} else omitted.push('top_read');
|
||||
|
||||
// ── WHAT CHANGED ──
|
||||
if (Array.isArray(issue.changed) && issue.changed.length) {
|
||||
const rows = issue.changed.map((c) => `<div style="padding:7px 0;border-top:1px solid ${T.rule};">
|
||||
<span style="font-family:${T.mono};font-size:11px;color:${T.dim};">${esc(c.time)}</span>
|
||||
<span style="font-family:${T.mono};font-size:11px;font-weight:800;color:${T.ink};"> ${esc(String(c.tag).toUpperCase())}</span>
|
||||
<span style="font-family:${T.sans};font-size:13px;color:${T.ink};"> ${esc(c.text)}</span></div>`).join('');
|
||||
parts.push(row(`<div style="padding:26px 0 0;">
|
||||
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:${T.dim};padding-bottom:6px;">WHAT CHANGED</div>${rows}</div>`));
|
||||
} else omitted.push('changed');
|
||||
|
||||
// ── THE RECORD ── (dark band: survives every client, per spec)
|
||||
const rec = issue.record;
|
||||
if (rec && has(rec.line)) {
|
||||
parts.push(`<tr><td style="background:${T.billboard};padding:18px 28px;margin-top:20px;">
|
||||
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:#8A8A96;">THE RECORD</div>
|
||||
<div style="font-family:${T.mono};font-size:16px;font-weight:800;color:#FFFFFF;padding-top:6px;">${esc(rec.line)}</div>
|
||||
${has(rec.note) ? `<div style="font-family:${T.mono};font-size:11px;color:#8A8A96;padding-top:6px;">${esc(rec.note)}</div>` : ''}
|
||||
</td></tr>`);
|
||||
} else omitted.push('record');
|
||||
|
||||
// ── THE HONESTY BLOCK ── real figures or nothing.
|
||||
const h = issue.honesty;
|
||||
if (h && has(h.graded) && has(h.ceiling_letter)) {
|
||||
parts.push(row(`<div style="padding:22px 0 0;">
|
||||
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:${T.dim};">HONESTLY</div>
|
||||
<p style="font-family:${T.sans};font-size:13px;line-height:1.65;color:${T.ink};padding-top:8px;margin:0;">
|
||||
We graded ${esc(h.graded)} props${has(h.cleared_ceiling) ? ` and ${esc(h.cleared_ceiling)} cleared ${esc(h.ceiling_letter)}` : ''}.
|
||||
${has(h.ceiling_realized) && has(h.base_rate) ? `Those reads land about ${esc(h.ceiling_realized)}% against a ${esc(h.base_rate)}% baseline. ` : ''}
|
||||
${has(h.unissuable) ? `We do not issue ${esc(h.unissuable)} — no band of this model has hit at a rate that would justify one.` : ''}
|
||||
</p></div>`));
|
||||
} else omitted.push('honesty');
|
||||
|
||||
const html = `<!doctype html><html><body style="margin:0;padding:0;background:${T.paper};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${T.paper};">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="${WIDTH}" cellpadding="0" cellspacing="0" style="width:${WIDTH}px;max-width:${WIDTH}px;background:${T.paper};">
|
||||
${parts.join('\n')}
|
||||
<tr><td style="padding:22px 28px 30px;">
|
||||
<div style="font-family:${T.mono};font-size:10px;color:${T.dim};line-height:1.7;">
|
||||
One email per slate day. Top read, what changed, the record. Nothing else.<br>
|
||||
No outcome is promised. 21+. <a href="{{ UnsubscribeURL }}" style="color:${T.dim};">Unsubscribe</a>.
|
||||
</div></td></tr>
|
||||
</table></td></tr></table></body></html>`;
|
||||
|
||||
const text = [
|
||||
`THE REPORT${has(issue.number) ? ` No ${issue.number}` : ''}${has(issue.date_label) ? ` — ${issue.date_label}` : ''}`,
|
||||
tr && has(tr.subject) ? `\nTOP READ: ${tr.grade} ${tr.subject}${has(tr.line_text) ? ` ${tr.line_text}` : ''}` : '',
|
||||
tr && has(tr.note) ? tr.note : '',
|
||||
rec && has(rec.line) ? `\nRECORD: ${rec.line}` : '',
|
||||
'\nOne email per slate day. Top read, what changed, the record. Nothing else.',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return { html, text, omitted, width: WIDTH };
|
||||
}
|
||||
|
||||
module.exports = { renderIssue, movementText, T, WIDTH, has };
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* NOTE ON `engine_grade` (2026-08-07 grade cutover).
|
||||
*
|
||||
* The user-facing `grade` now derives from `p_win`, not engine1's additive
|
||||
* factor index -- that index carried 0.16x the information of p_win on 3,417
|
||||
* settled props and its A hit worse than its F. The engine's OWN decision
|
||||
* (graded vs suppressed) is preserved as `engine_grade`, so behaviour
|
||||
* assertions read that; suppression assertions still read `grade`, since a
|
||||
* suppressed prop has no letter either way.
|
||||
*/
|
||||
// Fix 2 (Session 7f) — verifies the end-to-end shape from
|
||||
// computeFeaturesForProp → engine1 → adapter → concrete reasoning.
|
||||
|
||||
@@ -46,8 +56,13 @@ describe('analyzeViaEngine1 — happy path', () => {
|
||||
});
|
||||
|
||||
// Adapter-collapsed grade.
|
||||
expect(out.grade).toBe('A');
|
||||
expect(out.confidence).toBe(78);
|
||||
expect(out.engine_grade).toBe('A');
|
||||
// Confidence now derives from p_win (0.95 -> 95), not from a grade-band
|
||||
// midpoint of the old letter. The old 78 was the midpoint for engine1's "A"
|
||||
// -- a number that carried no information beyond the letter it was looked up
|
||||
// from, and which would now contradict the served B+.
|
||||
expect(out.confidence).toBe(95);
|
||||
expect(out.confidence_basis).toBe('p_win');
|
||||
expect(out.player).toBe('Jalen Brunson');
|
||||
expect(out.stat_type).toBe('points');
|
||||
expect(out.line).toBe(25.5);
|
||||
@@ -104,7 +119,7 @@ describe('analyzeViaEngine1 — happy path', () => {
|
||||
player: 'P', stat_type: 'points', line: 24.5, direction: 'over', sport: 'nba',
|
||||
});
|
||||
|
||||
expect(out.grade).toBe('D');
|
||||
expect(out.engine_grade).toBe('D');
|
||||
expect(out.reasoning.summary).toContain('Playing on the road');
|
||||
expect(out.reasoning.summary).toContain('OKC');
|
||||
expect(out.reasoning.summary).toContain('top-tier defense');
|
||||
@@ -185,7 +200,7 @@ describe('analyzeViaEngine1 — graceful degradation', () => {
|
||||
player: 'P', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
|
||||
});
|
||||
|
||||
expect(out.grade).toBe('B');
|
||||
expect(out.engine_grade).toBe('B');
|
||||
expect(out.insufficient_data).toBeUndefined();
|
||||
expect(out.projection).toBe(28.4); // the REAL model reference, never the line
|
||||
});
|
||||
@@ -205,7 +220,7 @@ describe('analyzeViaEngine1 — interface verifications', () => {
|
||||
player: 'X', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
|
||||
});
|
||||
expect(out).toBeDefined();
|
||||
expect(out.grade).toBeDefined();
|
||||
expect(out.engine_grade).toBeDefined();
|
||||
});
|
||||
|
||||
test('every legacy field DemoScan reads is present', async () => {
|
||||
@@ -222,7 +237,7 @@ describe('analyzeViaEngine1 — interface verifications', () => {
|
||||
});
|
||||
// DemoScan reads: grade, confidence, reasoning.summary,
|
||||
// kill_conditions_triggered[].code, edge_pct, line, player, stat_type.
|
||||
expect(out.grade).toBeDefined();
|
||||
expect(out.engine_grade).toBeDefined();
|
||||
expect(typeof out.confidence).toBe('number');
|
||||
expect(typeof out.reasoning.summary).toBe('string');
|
||||
expect(Array.isArray(out.kill_conditions_triggered)).toBe(true);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* THE WINDOW GUARD — a fixed N must never stand in for a season.
|
||||
*
|
||||
* This class has now cost six paths across two sports:
|
||||
*
|
||||
* MLB getStatRows res.last10 -> a 10-game "season rate"
|
||||
* MLB mlbGameLogFeatures res.last10 -> l20_avg was 10 games
|
||||
* NBA espnStatsAdapter rows.slice(0, 20) -> every downstream rate capped at 20
|
||||
* NBA nbaGameLogFeatures vals.slice(0, 20) -> l20_avg was 20 games
|
||||
* NBA getStatRows (python) getGameLogs(.., 20) -> a 20-game base rate
|
||||
* NBA gameLogFeatures getGameLogs(.., 20) -> same
|
||||
*
|
||||
* Every one looked correct in isolation. `slice(0, 20)` is unremarkable code;
|
||||
* what made it a defect was the QUESTION it was answering — "what is this
|
||||
* player's season rate?" — and no test could see that mismatch, because the
|
||||
* value it produced was always a plausible number.
|
||||
*
|
||||
* So this asserts the property directly on the source: the base-rate and
|
||||
* season-reference paths must not narrow to a fixed window. Basketball is
|
||||
* OFFLINE, which is exactly why it is guarded now — a regression there would
|
||||
* otherwise surface only when it had already served grades.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
|
||||
/** Body of a named function, for targeted assertions. */
|
||||
function fnBody(src, name) {
|
||||
const i = src.indexOf(`function ${name}(`);
|
||||
if (i < 0) return null;
|
||||
let depth = 0; let started = false;
|
||||
for (let j = i; j < src.length; j += 1) {
|
||||
if (src[j] === '{') { depth += 1; started = true; }
|
||||
else if (src[j] === '}') { depth -= 1; if (started && depth === 0) return src.slice(i, j + 1); }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('no fixed window may stand in for a season', () => {
|
||||
const featureCache = read('src/services/intelligence/featureCache.js');
|
||||
|
||||
it('the season log depth is a named constant past a full season', () => {
|
||||
// A literal 20 buried in a call is how every instance of this bug looked.
|
||||
expect(featureCache).toMatch(/const SEASON_LOG_DEPTH = (\d+);/);
|
||||
const depth = Number(/const SEASON_LOG_DEPTH = (\d+);/.exec(featureCache)[1]);
|
||||
expect(depth).toBeGreaterThanOrEqual(82); // a basketball season
|
||||
});
|
||||
|
||||
it('no game-log request asks for a hardcoded small count', () => {
|
||||
const calls = [...featureCache.matchAll(/getGameLogs\([^)]*\)/g)].map((m) => m[0]);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
for (const c of calls) {
|
||||
const literal = /,\s*(\d+)\s*\)/.exec(c);
|
||||
if (literal) expect(Number(literal[1])).toBeGreaterThanOrEqual(82);
|
||||
}
|
||||
});
|
||||
|
||||
it('the BASKETBALL season reference (l20_avg) is not a 20-game slice', () => {
|
||||
const body = fnBody(featureCache, 'nbaGameLogFeatures');
|
||||
expect(body).not.toBeNull();
|
||||
// The exact pre-fix line. It read as ordinary code and was the defect.
|
||||
expect(body).not.toMatch(/m20\s*=\s*avg\(vals\.slice\(0,\s*20\)\)/);
|
||||
expect(body).toMatch(/m20\s*=\s*avg\(vals\)/);
|
||||
});
|
||||
|
||||
it('the MLB season reference is a real season aggregate, not a slice', () => {
|
||||
const body = fnBody(featureCache, 'mlbGameLogFeatures');
|
||||
expect(body).not.toBeNull();
|
||||
expect(body).toMatch(/l20_avg\s*=\s*seasonTotal\s*\/\s*games/);
|
||||
});
|
||||
|
||||
it('the MLB base-rate path reads the full log, not last10', () => {
|
||||
const body = fnBody(featureCache, 'getStatRows');
|
||||
expect(body).toMatch(/res\.fullLog/);
|
||||
});
|
||||
|
||||
it('the ESPN adapter does not cap the parsed game log', () => {
|
||||
const adapter = read('src/services/adapters/espnStatsAdapter.js');
|
||||
const body = fnBody(adapter, 'parseGameLog');
|
||||
expect(body).not.toBeNull();
|
||||
// `return rows.slice(0, 20)` capped every downstream basketball rate.
|
||||
expect(body).not.toMatch(/return\s+rows\.slice\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the guard would have caught the real defects', () => {
|
||||
it('flags the exact pre-fix basketball slice', () => {
|
||||
const preFix = 'const m20 = avg(vals.slice(0, 20));';
|
||||
expect(/m20\s*=\s*avg\(vals\.slice\(0,\s*20\)\)/.test(preFix)).toBe(true);
|
||||
});
|
||||
|
||||
it('flags the exact pre-fix ESPN cap', () => {
|
||||
expect(/return\s+rows\.slice\(/.test(' return rows.slice(0, 20);')).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a hardcoded small game-log request', () => {
|
||||
const preFix = 'await gameLogs.getGameLogs(playerName, sp, 20);';
|
||||
const literal = /,\s*(\d+)\s*\)/.exec(/getGameLogs\([^)]*\)/.exec(preFix)[0]);
|
||||
expect(Number(literal[1])).toBeLessThan(82); // i.e. would fail the guard
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* THE TRUTH-LAW PROOF.
|
||||
*
|
||||
* The claim this engine makes is that a post cannot contain a number that was
|
||||
* not pulled. These tests are that claim, made falsifiable. If any of them can
|
||||
* be made to pass while a fabricated string escapes, the moat is decorative.
|
||||
*/
|
||||
|
||||
const engine = require('../../src/services/content/contentEngine');
|
||||
const { toSvg } = require('../../src/services/content/cardRenderer');
|
||||
|
||||
const base = {
|
||||
id: 'test_tpl', sport: 'mlb', requires: ['n'],
|
||||
pull: async () => ({ n: 3, name: 'Real Player' }),
|
||||
copy: () => 'we graded {n} props',
|
||||
card: () => ({ title: 'T', lines: [{ text: '{n} props', style: 'stat' }] }),
|
||||
};
|
||||
const reg = (over = {}) => engine.registerTemplate({ ...base, ...over, id: over.id || `t_${Math.random()}` });
|
||||
|
||||
describe('a template CANNOT render an unbacked claim', () => {
|
||||
it('refuses a token with no pulled fact', async () => {
|
||||
// The failure this prevents: a template author writes {edge} and the engine
|
||||
// helpfully renders "undefined" or, worse, an empty string that reads fine.
|
||||
const t = reg({ copy: () => 'edge is {edge_that_was_never_pulled}%' });
|
||||
const out = await engine.generate(t.id, {});
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.skipped).toBe(true);
|
||||
expect(out.reason).toMatch(/TRUTH LAW: unbacked token/);
|
||||
});
|
||||
|
||||
it('refuses an unbacked token on the CARD too, not just the copy', async () => {
|
||||
const t = reg({ card: () => ({ title: 'T', lines: [{ text: '{ghost_stat}', style: 'stat' }] }) });
|
||||
const out = await engine.generate(t.id, {});
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.reason).toMatch(/unbacked token/);
|
||||
});
|
||||
|
||||
it('render() throws directly — the gate is not bypassable by a caller', () => {
|
||||
expect(() => engine.render('{nope}', { yes: 1 })).toThrow(/TRUTH LAW/);
|
||||
});
|
||||
|
||||
it('a null fact is ABSENT, not rendered as "null"', async () => {
|
||||
const t = reg({ pull: async () => ({ n: null }), copy: () => '{n} props' });
|
||||
const out = await engine.generate(t.id, {});
|
||||
expect(out.ok).toBe(false);
|
||||
expect(String(out.copy || '')).not.toMatch(/null/);
|
||||
});
|
||||
|
||||
it('an empty string is absent — a hole in a sentence is a lie by omission', () => {
|
||||
expect(engine.isPresent('')).toBe(false);
|
||||
expect(engine.isPresent(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('ZERO is PRESENT — "0 cleared B+" is a real and important claim', () => {
|
||||
// Treating 0 as missing is the Number(null) === 0 breach wearing its
|
||||
// opposite coat, and it would silently delete our most honest post.
|
||||
expect(engine.isPresent(0)).toBe(true);
|
||||
expect(engine.render('{n} cleared', { n: 0 })).toBe('0 cleared');
|
||||
});
|
||||
|
||||
it('NaN and Infinity are absent — they are arithmetic failures, not facts', () => {
|
||||
expect(engine.isPresent(NaN)).toBe(false);
|
||||
expect(engine.isPresent(Infinity)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the fact contract is checked BEFORE any string is built', () => {
|
||||
it('a contract gap skips with the missing field named', async () => {
|
||||
const t = reg({ requires: ['n', 'missing_field'] });
|
||||
const out = await engine.generate(t.id, {});
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.reason).toMatch(/fact-contract gap: missing_field/);
|
||||
});
|
||||
|
||||
it('a gap can emit an HONEST ABSENCE instead of nothing', async () => {
|
||||
const t = reg({
|
||||
requires: ['n', 'absent_thing'],
|
||||
absent: () => ({ copy: 'nothing tonight, and we say so', card: { title: 'NONE' } }),
|
||||
});
|
||||
const out = await engine.generate(t.id, {});
|
||||
expect(out.ok).toBe(true);
|
||||
expect(out.honest_absence).toBe(true);
|
||||
expect(out.copy).toMatch(/nothing tonight/);
|
||||
});
|
||||
|
||||
it('a failing pull skips rather than rendering a half-post', async () => {
|
||||
const t = reg({ pull: async () => { throw new Error('source down'); } });
|
||||
const out = await engine.generate(t.id, {});
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.reason).toMatch(/pull failed: source down/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy and card cannot disagree', () => {
|
||||
it('both render from ONE fact object', async () => {
|
||||
const t = reg({
|
||||
pull: async () => ({ n: 7 }),
|
||||
copy: () => 'we graded {n}',
|
||||
card: () => ({ title: 'T', lines: [{ text: '{n} graded', style: 'stat' }] }),
|
||||
});
|
||||
const out = await engine.generate(t.id, {});
|
||||
expect(out.copy).toMatch(/7/);
|
||||
expect(out.card.lines[0].text).toMatch(/7/);
|
||||
});
|
||||
|
||||
it('the card SVG contains the same pulled number', async () => {
|
||||
const t = reg({ pull: async () => ({ n: 42 }), copy: () => '{n}', card: () => ({ title: 'T', lines: [{ text: '{n} graded', style: 'stat' }] }) });
|
||||
const out = await engine.generate(t.id, {});
|
||||
expect(toSvg(out.card)).toMatch(/42 graded/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the real templates obey the contract', () => {
|
||||
const hot = require('../../src/services/content/templates/hotHitters');
|
||||
const flex = require('../../src/services/content/templates/honestyFlex');
|
||||
const streak = require('../../src/services/content/templates/streakList');
|
||||
|
||||
it.each([[hot], [flex], [streak]])('every token in %s is declarable', (t) => {
|
||||
const tokens = new Set([
|
||||
...engine.tokensIn(t.copy({})),
|
||||
...engine.tokensIn(JSON.stringify(t.card({}))),
|
||||
]);
|
||||
expect(tokens.size).toBeGreaterThan(0);
|
||||
// Each template must ship an absent-variant, or a thin night silently
|
||||
// produces nothing and the flywheel stops without anyone noticing.
|
||||
expect(typeof t.absent).toBe('function');
|
||||
});
|
||||
|
||||
it('hot hitters refuses a hitter with too little history', async () => {
|
||||
engine.registerTemplate(hot);
|
||||
const out = await engine.generate('hot_hitters', {
|
||||
date: '2026-08-07',
|
||||
hitterForm: async () => ([{ name: 'Rookie', season_games: 4, recent_rate: 0.9, season_rate: 0.2 }]),
|
||||
});
|
||||
// 4 games is not a season, so nobody QUALIFIES -- which is a source problem,
|
||||
// not a quiet night. It must SKIP rather than publish honest-absence copy.
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.reason).toMatch(/source problem, not a quiet night/);
|
||||
});
|
||||
|
||||
it('hot hitters DOES emit honest absence when a real pool has nobody hot', async () => {
|
||||
// The distinction that matters: candidates existed, were judged, none hot.
|
||||
engine.registerTemplate(hot);
|
||||
const out = await engine.generate('hot_hitters', {
|
||||
date: '2026-08-07',
|
||||
hitterForm: async () => Array.from({ length: 30 }, (_, i) => ({
|
||||
name: `P${i}`, season_games: 90, recent_rate: 0.30, season_rate: 0.40,
|
||||
})),
|
||||
});
|
||||
expect(out.honest_absence).toBe(true);
|
||||
expect(out.copy).toMatch(/No hitter is meaningfully hot/);
|
||||
});
|
||||
|
||||
it('streaks refuse a run not verified from settled results', async () => {
|
||||
engine.registerTemplate(streak);
|
||||
const out = await engine.generate('streak_list', {
|
||||
date: '2026-08-07',
|
||||
settledStreaks: async () => ([{ name: 'X', streak: 9, verified_from_settled: false }]),
|
||||
});
|
||||
expect(out.honest_absence).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* WAVE D1 motion primitives.
|
||||
*
|
||||
* The property that matters most here is not how they look — it is that they
|
||||
* never withhold content. A reveal primitive that hides a row until an observer
|
||||
* fires will, on any browser or preference where the observer never fires, hide
|
||||
* the row forever.
|
||||
*/
|
||||
|
||||
const m = require('../../web/src/lib/motion');
|
||||
|
||||
describe('reduced motion is honoured, not softened', () => {
|
||||
it('assumes reduced motion server-side', () => {
|
||||
expect(m.prefersReducedMotion()).toBe(true);
|
||||
});
|
||||
|
||||
it('nudge does nothing and returns a no-op cleanup', () => {
|
||||
const el = { style: {} };
|
||||
const stop = m.nudge(el);
|
||||
expect(el.style.transform).toBeUndefined();
|
||||
expect(typeof stop).toBe('function');
|
||||
expect(() => stop()).not.toThrow();
|
||||
});
|
||||
|
||||
it('boot stagger collapses to zero delay AND forces opacity', () => {
|
||||
// Not just delay 0: if a CSS animation supplies the opacity, skipping the
|
||||
// animation without forcing opacity leaves the row invisible.
|
||||
expect(m.bootStagger(9)).toEqual({ animationDelay: '0ms', opacity: 1 });
|
||||
});
|
||||
|
||||
it('reveal fires IMMEDIATELY rather than waiting for an observer', () => {
|
||||
const el = {}; let revealed = null;
|
||||
m.revealOnIntersect(el, (x) => { revealed = x; });
|
||||
expect(revealed).toBe(el);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the stagger cannot make content late', () => {
|
||||
it('is capped, so a long board does not delay its tail', () => {
|
||||
// Uncapped, row 40 would wait 1.1s and the stagger becomes the latency it
|
||||
// exists to disguise.
|
||||
const orig = m.prefersReducedMotion;
|
||||
expect(m.STAGGER_CAP_MS).toBeLessThanOrEqual(300);
|
||||
expect(m.STAGGER_STEP_MS * 40).toBeGreaterThan(m.STAGGER_CAP_MS);
|
||||
});
|
||||
|
||||
it('the nudge is short enough to read as acknowledgement, not latency', () => {
|
||||
expect(m.NUDGE_MS).toBeLessThanOrEqual(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe('row hover is pointer-only by construction', () => {
|
||||
it('exposes handlers rather than CSS, so touch cannot stick a hover state', () => {
|
||||
const h = m.rowHover();
|
||||
expect(typeof h.onMouseEnter).toBe('function');
|
||||
expect(typeof h.onMouseLeave).toBe('function');
|
||||
});
|
||||
|
||||
it('clears the tint it set, never leaving a row highlighted', () => {
|
||||
const el = { style: { background: '' } };
|
||||
const h = m.rowHover();
|
||||
h.onMouseEnter({ currentTarget: el });
|
||||
expect(el.style.background).toBeTruthy();
|
||||
h.onMouseLeave({ currentTarget: el });
|
||||
expect(el.style.background).toBe('');
|
||||
});
|
||||
|
||||
it('survives a missing currentTarget rather than throwing mid-render', () => {
|
||||
const h = m.rowHover();
|
||||
expect(() => h.onMouseEnter({})).not.toThrow();
|
||||
expect(() => h.onMouseLeave({})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('no observer leak', () => {
|
||||
it('reveal returns an unobserve function in every path', () => {
|
||||
expect(typeof m.revealOnIntersect(null, () => {})).toBe('function');
|
||||
expect(typeof m.revealOnIntersect({}, () => {})).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* F9-F11 offseason hub + E1 movement strip — the laws, not the pixels.
|
||||
*
|
||||
* What these protect is that the surface cannot show a number it does not have.
|
||||
* The design file is full of sample values (Wembanyama +420 → +330, Nabers
|
||||
* cleared at 11:42 AM); those are a SPEC for what a live feed renders, and
|
||||
* copying them into the component would be fabrication carrying a designer's
|
||||
* authority.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const read = (r) => fs.readFileSync(path.join(ROOT, r), 'utf8');
|
||||
|
||||
/**
|
||||
* Strip comments before matching. My first version of these assertions matched
|
||||
* my own doc blocks -- the ordering check found "WHAT CHANGED TODAY" in the
|
||||
* header comment, and the no-curves check caught the word "curve" in the
|
||||
* sentence explaining why curves are wrong. A guard that reads its own
|
||||
* explanation is not reading the render.
|
||||
*/
|
||||
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
|
||||
const HUB_RAW = read('web/src/components/vyndr/OffseasonHub.tsx');
|
||||
const STRIP_RAW = read('web/src/components/vyndr/MovementStrip.tsx');
|
||||
const HUB = stripComments(HUB_RAW);
|
||||
const STRIP = stripComments(STRIP_RAW);
|
||||
|
||||
describe('the hub cannot ship the design file\'s sample data', () => {
|
||||
it('contains none of the spec\'s sample subjects or prices', () => {
|
||||
for (const sample of ['Wembanyama', 'Nabers', 'Dybantsa', 'Boozer', 'Kincaid', '+420', '+330']) {
|
||||
expect(HUB).not.toContain(sample);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the QUIET WIRE empty state verbatim, not a generic blank', () => {
|
||||
expect(HUB).toMatch(/QUIET WIRE/);
|
||||
expect(HUB).toMatch(/We don't manufacture movement\./);
|
||||
});
|
||||
|
||||
it('carries the spec\'s load-bearing subhead', () => {
|
||||
// An offseason number is not a game line; saying so is the difference
|
||||
// between a read and a bet.
|
||||
expect(HUB).toMatch(/OUTLOOKS REPRICE ON NEWS · NOT GAME ODDS/);
|
||||
});
|
||||
|
||||
it('leads with what changed today — the countdown is ambient, not the hero', () => {
|
||||
// In the RENDER the countdown sits in the header block above; the hero
|
||||
// section is WHAT CHANGED TODAY. Both must be present and ordered.
|
||||
const iCountdown = HUB.indexOf('DAYS TO');
|
||||
const iHero = HUB.indexOf('WHAT CHANGED TODAY');
|
||||
expect(iCountdown).toBeGreaterThan(-1);
|
||||
expect(iHero).toBeGreaterThan(iCountdown);
|
||||
expect(HUB_RAW).toMatch(/never the hero/); // the law, stated in the source
|
||||
});
|
||||
|
||||
it('every OUTLOOK-ONLY row carries NOT GRADED', () => {
|
||||
// The block exists in order to say it.
|
||||
expect(HUB).toMatch(/NOT GRADED/);
|
||||
});
|
||||
|
||||
it('empty board says nothing rather than showing placeholders', () => {
|
||||
expect(HUB).toMatch(/Nothing shown rather than a board of placeholders/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E1 movement strip obeys its spec laws', () => {
|
||||
it('does NOT reimplement the colour law — it consumes gradeShift', () => {
|
||||
// Forking the toward/against rule is how two surfaces drift apart.
|
||||
expect(STRIP).toMatch(/buildGradeTimeline/);
|
||||
expect(STRIP).not.toMatch(/isUnder\s*\(/);
|
||||
});
|
||||
|
||||
it('draws STEPS, not curves', () => {
|
||||
// H then V — a hold, then a jump. No smoothing, which would invent prices
|
||||
// that never traded.
|
||||
expect(STRIP).toMatch(/H\$\{x1\} V\$\{y1\}/);
|
||||
// No SVG curve commands in the emitted path -- C, S, Q or T.
|
||||
expect(STRIP).not.toMatch(/[CSQT]\s*\$\{/);
|
||||
});
|
||||
|
||||
it('renders FLAT as a hairline plus the day count', () => {
|
||||
expect(STRIP).toMatch(/FLAT\{typeof days === 'number'/);
|
||||
});
|
||||
|
||||
it('is never blank — too little history says so', () => {
|
||||
expect(STRIP).toMatch(/NO MOVEMENT HISTORY/);
|
||||
});
|
||||
|
||||
it('green is reserved for a move that FAVOURS the read', () => {
|
||||
expect(STRIP).toMatch(/dir === 'toward' \? TOWARD/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the archetype doctrine is recorded', () => {
|
||||
it('the 83-taxonomy doctrine exists and forbids decoration-as-data', () => {
|
||||
const d = read('specs/ARCHETYPE-TAXONOMY-DOCTRINE.md');
|
||||
expect(d).toMatch(/Decoration-as-data is forbidden/);
|
||||
expect(d).toMatch(/A glyph renders ONLY where its archetype is modeled and proven/);
|
||||
expect(d).toMatch(/DUAL THREAT/);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,22 @@
|
||||
'use strict';
|
||||
/**
|
||||
* NOTE ON `engine_grade` (2026-08-07 grade cutover).
|
||||
*
|
||||
* The user-facing `grade` is now derived from `p_win` rather than from engine1's
|
||||
* additive factor index: measured on 3,417 settled props that index carried
|
||||
* 0.16x the information of p_win, and its A hit worse than its F. The engine's
|
||||
* OWN decision -- graded vs suppressed -- is preserved unchanged as
|
||||
* `engine_grade`, so assertions about engine BEHAVIOUR read that.
|
||||
*
|
||||
* Assertions that a prop was SUPPRESSED still read `grade`, because a suppressed
|
||||
* prop has no letter under either scheme.
|
||||
*
|
||||
* Fixtures here produce no game logs, so `p_win` is null and the served `grade`
|
||||
* is null -- the surface renders NO READ with a reason. That is deliberate and
|
||||
* measured: 0.6% of live non-refused props (303 of 47,991) have no p_win, and
|
||||
* their old letter came from the retired index, i.e. noise.
|
||||
*/
|
||||
|
||||
|
||||
// Betting-logic audit (2026-07-19) — rare-event 0.5 markets. The UNDER is
|
||||
// always suppressed (juiced); the OVER grades only when the model genuinely
|
||||
@@ -102,7 +120,7 @@ describe('analyzeViaEngine1 — rare-event suppression', () => {
|
||||
test('doubles OVER 0.5 with projection 0.7 (> line) GRADES — genuine event read', async () => {
|
||||
mockComputeReturn.current = feat(0.7, 0.5, 'over');
|
||||
const out = await analyzeViaEngine1({ player: 'X', stat_type: 'doubles', line: 0.5, direction: 'over' });
|
||||
expect(out.grade).toBe('B'); // real grade, not suppressed
|
||||
expect(out.engine_grade).toBe('B'); // real grade, not suppressed
|
||||
expect(out.suppressed).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -117,14 +135,14 @@ describe('analyzeViaEngine1 — rare-event suppression', () => {
|
||||
test('a NON-rare under (hits) is unaffected — still grades', async () => {
|
||||
mockComputeReturn.current = feat(1.2, 0.5, 'under');
|
||||
const out = await analyzeViaEngine1({ player: 'X', stat_type: 'hits', line: 0.5, direction: 'under' });
|
||||
expect(out.grade).toBe('B');
|
||||
expect(out.engine_grade).toBe('B');
|
||||
expect(out.suppressed).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a rare stat at a 1.5 line (not 0.5) is unaffected', async () => {
|
||||
mockComputeReturn.current = feat(1.2, 1.5, 'under');
|
||||
const out = await analyzeViaEngine1({ player: 'X', stat_type: 'home_runs', line: 1.5, direction: 'under' });
|
||||
expect(out.grade).toBe('B');
|
||||
expect(out.engine_grade).toBe('B');
|
||||
});
|
||||
|
||||
test('JUICE GUARD — a heavily-juiced side is refused for ANY stat, via price', async () => {
|
||||
@@ -137,7 +155,7 @@ describe('analyzeViaEngine1 — rare-event suppression', () => {
|
||||
test('JUICE GUARD — a normally-priced play still grades', async () => {
|
||||
mockComputeReturn.current = feat(1.4, 0.5, 'over');
|
||||
const out = await analyzeViaEngine1({ player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', over_odds: -130 });
|
||||
expect(out.grade).toBe('B'); // -130 is fine → real read
|
||||
expect(out.engine_grade).toBe('B'); // -130 is fine → real read
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* RENDER REACHABILITY — the guard against "built, correct, and read by nobody."
|
||||
*
|
||||
* Three consecutive orders shipped a backend-correct field that never reached a
|
||||
* screen, and all three passed a green suite:
|
||||
*
|
||||
* gradeBands built over six orders, required by NO serving code
|
||||
* served_grade attached to the payload, dropped at the adapter boundary
|
||||
* GradeScaleLegend component written, imported by nothing
|
||||
*
|
||||
* Every one was caught by luck on a later re-check, because backend tests stop
|
||||
* at the API payload — they prove a field is PRODUCED and say nothing about
|
||||
* whether it is CONSUMED. The failure is invisible to them by construction.
|
||||
*
|
||||
* So this test traces each promised field the whole way:
|
||||
*
|
||||
* payload field -> adapter consumes it -> component renders it
|
||||
* -> component is MOUNTED
|
||||
*
|
||||
* "Mounted" means transitively imported by a Next entry point (a page or
|
||||
* layout), which is the only thing that puts a pixel on a screen. A component
|
||||
* that exists and renders the field perfectly but is imported by nothing is
|
||||
* exactly the GradeScaleLegend bug, and it fails here.
|
||||
*
|
||||
* Scope is deliberately narrow: the honest-grade fields the product PROMISES a
|
||||
* user sees. This is not a frontend test harness.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const WEB = path.join(ROOT, 'web', 'src');
|
||||
|
||||
/**
|
||||
* THE PROMISED-FIELD CONTRACT.
|
||||
*
|
||||
* Each row is a commitment: this is on the payload, and a user can see it.
|
||||
* Adding a served field without adding it here is allowed; adding it here
|
||||
* without wiring it to a mounted component is not.
|
||||
*/
|
||||
const CONTRACT = [
|
||||
// A CONTAINER row: the adapter must consume it, but it is not rendered
|
||||
// directly -- its parts are, and each part has its own row below. Marked
|
||||
// explicitly rather than silently skipped, so the exemption is auditable.
|
||||
{ promise: 'the served grade object',
|
||||
payload: 'served_grade', backend: 'src/services/intelligence/analyzeViaEngine1.js',
|
||||
adapter: 'served_grade', container: true,
|
||||
rendersVia: ['gradeMeaning', 'separatesFromBaseRate', 'bandRealizedRate'],
|
||||
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
|
||||
{ promise: 'what this grade means',
|
||||
payload: 'served_grade.meaning', adapterField: 'gradeMeaning',
|
||||
backend: 'src/services/model/servedGrade.js', adapter: 'gradeMeaning',
|
||||
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
|
||||
{ promise: 'whether the band separates from the baseline',
|
||||
payload: 'separates_from_base_rate', adapterField: 'separatesFromBaseRate',
|
||||
backend: 'src/services/model/servedGrade.js', adapter: 'separatesFromBaseRate',
|
||||
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
|
||||
{ promise: 'what the band has actually realized',
|
||||
payload: 'band_realized_rate', adapterField: 'bandRealizedRate',
|
||||
backend: 'src/services/model/servedGrade.js', adapter: 'bandRealizedRate',
|
||||
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
|
||||
{ promise: 'which proven factors moved the read',
|
||||
payload: 'factor_adjustment', adapterField: 'factorsApplied',
|
||||
backend: 'src/services/intelligence/analyzeViaEngine1.js', adapter: 'factorsApplied',
|
||||
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
|
||||
{ promise: 'the ceiling stance / grade scale legend',
|
||||
payload: null, backend: 'src/services/model/servedGrade.js',
|
||||
adapter: null, component: 'web/src/components/vyndr/GradeScaleLegend.tsx' },
|
||||
|
||||
// ── WIDENED BEYOND GRADE FIELDS ────────────────────────────────────────
|
||||
// The contract was grade-only, so it could not have caught a built-but-
|
||||
// unmounted surface elsewhere. Any user-facing SURFACE now registers here and
|
||||
// must trace to a Next entry point, which is the general form of the class.
|
||||
{ promise: 'book comparison (per-book prices)',
|
||||
payload: null, backend: 'src/routes/bookComparison.js', adapter: null,
|
||||
component: 'web/src/components/vyndr/BookComparisonPanel.tsx' },
|
||||
{ promise: 'the league wire (news + injuries)',
|
||||
payload: null, backend: null, adapter: null,
|
||||
component: 'web/src/components/vyndr/NewsWire.tsx' },
|
||||
{ promise: 'the content studio (daily post review)',
|
||||
payload: null, backend: 'src/routes/contentStudio.js', adapter: null,
|
||||
component: 'web/src/app/studio/page.tsx' },
|
||||
// WAVE 2 — E1 and the F9-F11 hub shell.
|
||||
{ promise: 'E1 movement strip (line history primitive)',
|
||||
payload: null, backend: null, adapter: null,
|
||||
component: 'web/src/components/vyndr/MovementStrip.tsx' },
|
||||
{ promise: 'F9-F11 the offseason desk',
|
||||
payload: null, backend: null, adapter: null,
|
||||
component: 'web/src/app/offseason/page.tsx' },
|
||||
{ promise: 'E12 the Report archive',
|
||||
payload: null, backend: 'src/routes/report.js', adapter: null,
|
||||
component: 'web/src/app/report/page.tsx' },
|
||||
];
|
||||
|
||||
/**
|
||||
* CONSUMABLE PRIMITIVES — built to be embedded, with no surface of their own.
|
||||
*
|
||||
* A primitive imported by nothing is the same built-but-unread class as an
|
||||
* unmounted component, so each names its intended consumers. Wave D1 built
|
||||
* these BEFORE the surfaces that embed them, precisely so they are not built
|
||||
* twice and allowed to diverge -- which is what happened when the content
|
||||
* engine invented a card system beside the designed one.
|
||||
*/
|
||||
const PRIMITIVES = [
|
||||
{ promise: 'motion primitives (E17 nudge, E18 stagger, E27 row-hover, E28 reveal)',
|
||||
module: 'web/src/lib/motion.js',
|
||||
exports: ['nudge', 'bootStagger', 'rowHover', 'revealOnIntersect'],
|
||||
intended_consumers: ['F9-F11 offseason hub rows', 'F5 article media reveal', 'slate board rows'] },
|
||||
];
|
||||
|
||||
const read = (rel) => {
|
||||
const p = path.join(ROOT, rel);
|
||||
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
|
||||
};
|
||||
|
||||
/** Every .ts/.tsx file under web/src. */
|
||||
function webFiles(dir = WEB, out = []) {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) webFiles(p, out);
|
||||
else if (/\.tsx?$/.test(e.name)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ALL = webFiles();
|
||||
|
||||
/** Files importing this component, by basename. */
|
||||
function importersOf(componentRel) {
|
||||
const base = path.basename(componentRel).replace(/\.tsx?$/, '');
|
||||
return ALL.filter((f) => {
|
||||
if (f.endsWith(path.basename(componentRel))) return false;
|
||||
const src = fs.readFileSync(f, 'utf8');
|
||||
return new RegExp(`import[^;]*\\b${base}\\b[^;]*from`).test(src)
|
||||
|| new RegExp(`from\\s+['"][^'"]*/${base}['"]`).test(src);
|
||||
});
|
||||
}
|
||||
|
||||
/** Is a Next entry point — the only thing that mounts anything. */
|
||||
const isEntry = (f) => /(^|\/)(page|layout|template)\.tsx?$/.test(f.replace(/\\/g, '/'));
|
||||
|
||||
/**
|
||||
* Transitively: does an entry point reach this component?
|
||||
* Depth-limited because an import cycle would otherwise hang the suite.
|
||||
*/
|
||||
function reachesEntry(componentRel, seen = new Set(), depth = 0) {
|
||||
if (depth > 8) return false;
|
||||
const importers = importersOf(componentRel);
|
||||
for (const imp of importers) {
|
||||
if (isEntry(imp)) return { mounted: true, via: path.relative(ROOT, imp) };
|
||||
const rel = path.relative(ROOT, imp);
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
const up = reachesEntry(rel, seen, depth + 1);
|
||||
if (up && up.mounted) return { mounted: true, via: `${rel} -> ${up.via}` };
|
||||
}
|
||||
return { mounted: false, via: null };
|
||||
}
|
||||
|
||||
describe('every promised honest field reaches a rendered pixel', () => {
|
||||
it.each(CONTRACT.filter((c) => c.adapter))(
|
||||
'$promise — the adapter consumes it',
|
||||
({ payload, adapter, adapterField }) => {
|
||||
const src = read('web/src/lib/gradeAdapter.js');
|
||||
expect(src).not.toBeNull();
|
||||
// The adapter must both READ the payload field and EMIT the card field.
|
||||
const payloadKey = String(payload).split('.')[0];
|
||||
expect(src.includes(payloadKey)).toBe(true);
|
||||
expect(src.includes(adapterField || adapter)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(CONTRACT.filter((c) => c.component && c.adapter && !c.container))(
|
||||
'$promise — a component actually renders it',
|
||||
({ adapter, adapterField, component }) => {
|
||||
const src = read(component);
|
||||
expect(src).not.toBeNull();
|
||||
// This is the check that all three bugs would have failed.
|
||||
expect(src.includes(adapterField || adapter)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(CONTRACT.filter((c) => c.container))(
|
||||
'$promise — every part of the container is rendered somewhere',
|
||||
({ rendersVia, component }) => {
|
||||
const src = read(component);
|
||||
// A container earns its exemption only if all of its parts render.
|
||||
for (const part of rendersVia) expect(src.includes(part)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(CONTRACT)('$promise — its component is MOUNTED, not merely written', ({ component }) => {
|
||||
// A page IS an entry point -- Next mounts it by convention, so it needs no
|
||||
// importer. Everything else must be reachable FROM one.
|
||||
if (isEntry(component)) {
|
||||
expect(fs.existsSync(path.join(ROOT, component))).toBe(true);
|
||||
return;
|
||||
}
|
||||
const r = reachesEntry(component);
|
||||
// GradeScaleLegend existed, rendered its content correctly, and was imported
|
||||
// by nothing. That is what this catches.
|
||||
expect(r.mounted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumable primitives exist and are complete', () => {
|
||||
it.each(PRIMITIVES)('$promise — every declared export is real', ({ module, exports: ex }) => {
|
||||
const mod = require(path.join(ROOT, module));
|
||||
for (const name of ex) expect(typeof mod[name]).toBe('function');
|
||||
});
|
||||
|
||||
it.each(PRIMITIVES)('$promise — names its intended consumers', ({ intended_consumers }) => {
|
||||
// A primitive with no named consumer is a guess about the future, and this
|
||||
// is where it gets recorded rather than assumed.
|
||||
expect(Array.isArray(intended_consumers)).toBe(true);
|
||||
expect(intended_consumers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the guard itself is honest', () => {
|
||||
it('fails when a promised field is produced but never consumed', () => {
|
||||
// Simulate the served_grade bug: present in the payload, absent from the
|
||||
// adapter. The check must go red, or it is decoration.
|
||||
const fakeAdapter = 'module.exports = { map: (i) => ({ grade: i.grade }) };';
|
||||
expect(fakeAdapter.includes('separatesFromBaseRate')).toBe(false);
|
||||
});
|
||||
|
||||
it('fails when a component exists but is imported by nothing', () => {
|
||||
const orphan = 'web/src/components/vyndr/__DefinitelyNotImported.tsx';
|
||||
expect(reachesEntry(orphan).mounted).toBe(false);
|
||||
});
|
||||
|
||||
it('the contract is non-empty — an empty contract would pass vacuously', () => {
|
||||
expect(CONTRACT.length).toBeGreaterThanOrEqual(5);
|
||||
for (const c of CONTRACT) expect(c.component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* E10 issue template + E12 archive.
|
||||
*
|
||||
* The failure mode being guarded is the one the hub taught: a design file full
|
||||
* of sample data (Nabers 1,120.5, Nº 128, DAY RECORD 9-4) is a SPEC for what a
|
||||
* live issue renders. Pasting it in would be fabrication carrying a designer's
|
||||
* authority, and it would look completely correct.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const t = require('../../src/services/report/reportTemplate');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
/**
|
||||
* Strip comments first. The template's own doc block NAMES the forbidden sample
|
||||
* values ("Nabers 1,120.5 ... never content to paste") -- documentation worth
|
||||
* keeping, and a check that reads it is reading the warning rather than the
|
||||
* code. Same bug I made on the offseason hub.
|
||||
*/
|
||||
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const ARCHIVE = stripComments(fs.readFileSync(path.join(ROOT, 'web/src/components/vyndr/ReportArchive.tsx'), 'utf8'));
|
||||
const TPL = stripComments(fs.readFileSync(path.join(ROOT, 'src/services/report/reportTemplate.js'), 'utf8'));
|
||||
const ARCHIVE_RAW = fs.readFileSync(path.join(ROOT, 'web/src/components/vyndr/ReportArchive.tsx'), 'utf8');
|
||||
|
||||
const full = {
|
||||
number: 3, date_label: 'FRI · AUG 07, 2026', read_time: '3 MIN',
|
||||
top_read: { grade: 'B+', subject: 'Real Player', line_text: 'Over 0.5 Hits',
|
||||
movement: { from: 0.5, to: 0.5, days: 2 }, model: '0.61', best_book: 'DK', note: 'A real note.' },
|
||||
changed: [{ time: '11:42 AM', tag: 'injury', text: 'Something real happened.' }],
|
||||
record: { line: '9–4', note: 'settled' },
|
||||
honesty: { graded: 2140, cleared_ceiling: 70, ceiling_letter: 'B+', ceiling_realized: 66, base_rate: 60, unissuable: 'A+, A, A-' },
|
||||
};
|
||||
|
||||
describe('no designer sample data ships', () => {
|
||||
it('the template source contains none of the spec\'s sample values', () => {
|
||||
for (const sample of ['Nabers', '1,120.5', '1,188', 'Skenes', 'Wemby', '12,408']) {
|
||||
expect(TPL).not.toContain(sample);
|
||||
}
|
||||
});
|
||||
|
||||
it('the archive contains no sample issues', () => {
|
||||
for (const sample of ['Nabers', 'SKENES', '9–4', 'Nº 128']) {
|
||||
expect(ARCHIVE).not.toContain(sample);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a section with no data is OMITTED, never filled', () => {
|
||||
it('names every omitted section rather than hiding the gap', () => {
|
||||
const out = t.renderIssue({ number: 1, date_label: 'X' });
|
||||
expect(out.omitted).toEqual(expect.arrayContaining(['top_read', 'changed', 'record', 'honesty']));
|
||||
expect(out.html).not.toMatch(/TOP READ OF THE DAY/);
|
||||
});
|
||||
|
||||
it('an empty issue still renders a valid, readable shell', () => {
|
||||
const out = t.renderIssue({});
|
||||
expect(out.html).toMatch(/VYND/);
|
||||
expect(out.html).toMatch(/One email per slate day/);
|
||||
});
|
||||
|
||||
it('renders every section when the data is there', () => {
|
||||
const out = t.renderIssue(full);
|
||||
expect(out.omitted).toEqual([]);
|
||||
for (const s of ['TOP READ OF THE DAY', 'WHAT CHANGED', 'THE RECORD', 'HONESTLY']) {
|
||||
expect(out.html).toContain(s);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the E1 movement law travels into email', () => {
|
||||
it('a flat market says FLAT with its day count, not nothing', () => {
|
||||
// A strip cannot render in email, but its RULE still applies.
|
||||
expect(t.movementText({ from: 1.5, to: 1.5, days: 4 })).toMatchObject({ text: 'FLAT · 4D' });
|
||||
});
|
||||
|
||||
it('green is reserved for a move that FAVOURS the read', () => {
|
||||
const toward = t.movementText({ from: 1.5, to: 1.2, dir: 'toward' });
|
||||
const against = t.movementText({ from: 1.5, to: 1.8, dir: 'against' });
|
||||
expect(toward.colour).toBe(t.T.greenOnPaper);
|
||||
expect(against.colour).not.toBe(t.T.greenOnPaper);
|
||||
});
|
||||
|
||||
it('an unreadable movement returns null rather than a guess', () => {
|
||||
expect(t.movementText({ from: null, to: 1.2 })).toBeNull();
|
||||
expect(t.movementText(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the hybrid shell is an engineering constraint, not a look', () => {
|
||||
it('is 600px and table-based — Gmail strips style blocks, Outlook ignores flex', () => {
|
||||
const out = t.renderIssue(full);
|
||||
expect(out.width).toBe(600);
|
||||
expect(out.html).toMatch(/<table role="presentation"/);
|
||||
expect(out.html).not.toMatch(/<style/);
|
||||
expect(out.html).not.toMatch(/display:\s*flex/);
|
||||
});
|
||||
|
||||
it('the green SHIFTS on paper — the dark-mode green is unreadable there', () => {
|
||||
expect(t.T.greenOnPaper).not.toBe(t.T.greenOnDark);
|
||||
});
|
||||
|
||||
it('depends on no webfont and no image to be readable', () => {
|
||||
const out = t.renderIssue(full);
|
||||
expect(out.html).not.toMatch(/@font-face|fonts\.googleapis/);
|
||||
expect(out.html).not.toMatch(/<img/);
|
||||
});
|
||||
|
||||
it('keeps the LITERAL unsubscribe token for Listmonk to substitute', () => {
|
||||
expect(t.renderIssue(full).html).toContain('{{ UnsubscribeURL }}');
|
||||
});
|
||||
|
||||
it('ships a plain-text alternative', () => {
|
||||
expect(t.renderIssue(full).text).toMatch(/TOP READ/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('E12 — the archive is a ledger too', () => {
|
||||
it('every row renders its own day record', () => {
|
||||
expect(ARCHIVE).toMatch(/DAY RECORD/);
|
||||
});
|
||||
|
||||
it('an unknown record says UNSETTLED, not a dash that reads as zero', () => {
|
||||
expect(ARCHIVE).toMatch(/UNSETTLED/);
|
||||
expect(ARCHIVE_RAW).toMatch(/never a dash that reads as zero/);
|
||||
});
|
||||
|
||||
it('an empty archive is an honest state, not a broken page', () => {
|
||||
expect(ARCHIVE).toMatch(/No issues published yet/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The letter a user sees.
|
||||
*
|
||||
* What these protect: that no input manufactures an A, that a band which cannot
|
||||
* be distinguished from the base rate SAYS so, and that nothing ever renders
|
||||
* blank.
|
||||
*/
|
||||
|
||||
const sg = require('../../src/services/model/servedGrade');
|
||||
|
||||
describe('no manufactured A — structurally, not by rarity', () => {
|
||||
it('no p_win produces an A, A- or A+', () => {
|
||||
// The realized rate plateaus around 0.65-0.68 above p_win 0.70, so no band
|
||||
// of this forecast has earned a top letter. This is the ceiling made real.
|
||||
for (let p = 0; p <= 1.0001; p += 0.01) {
|
||||
const g = sg.gradeFor({ p_win: p });
|
||||
expect(sg.UNISSUABLE).not.toContain(g.letter);
|
||||
}
|
||||
expect(sg.BANDS.some((b) => sg.UNISSUABLE.includes(b.letter))).toBe(false);
|
||||
});
|
||||
|
||||
it('even a 0.99 forecast tops out at the strongest honest band', () => {
|
||||
const g = sg.gradeFor({ p_win: 0.99 });
|
||||
expect(g.letter).toBe('B+');
|
||||
expect(g.band_realized_rate).toBeLessThan(0.70);
|
||||
expect(g.meaning).toMatch(/strongest read/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a band that cannot separate SAYS so', () => {
|
||||
it('mid bands are flagged as not distinguishable from base rate', () => {
|
||||
for (const p of [0.50, 0.58, 0.66]) {
|
||||
const g = sg.gradeFor({ p_win: p });
|
||||
expect(g.separates_from_base_rate).toBe(false);
|
||||
// The copy must not imply a separation the band does not have.
|
||||
expect(g.meaning).toMatch(/base rate|base-rate/);
|
||||
}
|
||||
});
|
||||
|
||||
it('the extremes do separate, and are marked so', () => {
|
||||
expect(sg.gradeFor({ p_win: 0.85 }).separates_from_base_rate).toBe(true);
|
||||
expect(sg.gradeFor({ p_win: 0.20 }).separates_from_base_rate).toBe(true);
|
||||
});
|
||||
|
||||
it('every band carries its own realized rate, not a target', () => {
|
||||
for (const b of sg.BANDS) {
|
||||
expect(b.realized).toBeGreaterThan(0);
|
||||
expect(b.realized).toBeLessThan(0.70); // the honest ceiling
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('never blank', () => {
|
||||
it('a refusal renders a real state with a reason', () => {
|
||||
const g = sg.gradeFor({ refused: true, refusal_reason: 'insufficient_data' });
|
||||
expect(g.letter).toBeNull();
|
||||
expect(g.state).toBe('refused');
|
||||
expect(g.label).toBe('NO READ');
|
||||
expect(g.meaning).toMatch(/not enough history/);
|
||||
});
|
||||
|
||||
it('a juiced-out side says the book ate the edge', () => {
|
||||
expect(sg.gradeFor({ refused: true, refusal_reason: 'juiced_no_edge' }).meaning).toMatch(/vig/);
|
||||
});
|
||||
|
||||
it('a missing forecast renders, rather than returning nothing', () => {
|
||||
const g = sg.gradeFor({ p_win: null });
|
||||
expect(g.state).toBe('no_forecast');
|
||||
expect(g.label).toBe('NO READ');
|
||||
});
|
||||
|
||||
it('an empty prop still renders', () => {
|
||||
expect(sg.gradeFor({}).label).toBe('NO READ');
|
||||
expect(sg.gradeFor().label).toBe('NO READ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the basis is stated, never implied', () => {
|
||||
it('names when matchup factors moved the forecast', () => {
|
||||
const g = sg.gradeFor({ p_win: 0.72, factor_adjustment: { applied: [{ factor: 'platoon_severity' }] } });
|
||||
expect(g.basis).toBe('forecast_plus_matchup_factors');
|
||||
expect(g.factors_applied).toEqual(['platoon_severity']);
|
||||
});
|
||||
|
||||
it('says forecast-only when nothing fired', () => {
|
||||
const g = sg.gradeFor({ p_win: 0.72 });
|
||||
expect(g.basis).toBe('forecast_only');
|
||||
expect(g.factors_applied).toEqual([]);
|
||||
});
|
||||
|
||||
it('never claims calibration — the deployed set is empty', () => {
|
||||
expect(sg.gradeFor({ p_win: 0.72 }).calibrated).toBe(false);
|
||||
});
|
||||
|
||||
it('is monotone: a higher forecast never grades lower', () => {
|
||||
const order = ['F', 'D', 'C-', 'C', 'C+', 'B', 'B+'];
|
||||
let prev = -1;
|
||||
for (let p = 0.02; p <= 0.98; p += 0.02) {
|
||||
const i = order.indexOf(sg.gradeFor({ p_win: p }).letter);
|
||||
expect(i).toBeGreaterThanOrEqual(prev);
|
||||
prev = i;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,16 @@ describe('Session 41 — broken-route redirects', () => {
|
||||
expect(src).toContain('DANGER ZONE');
|
||||
});
|
||||
|
||||
it('/report redirects to /blog (THE REPORT link target)', () => {
|
||||
it('/report is now the REAL Report archive, no longer a redirect to /blog', () => {
|
||||
// The S41 redirect existed BECAUSE the surface did not. E12 built it, so
|
||||
// the placeholder is correctly gone: /report is the archive, and every row
|
||||
// carries its own day record.
|
||||
const page = read('app/report/page.tsx');
|
||||
expect(page).toContain('ReportArchive');
|
||||
expect(page).not.toContain("redirect('/blog')");
|
||||
});
|
||||
|
||||
it.skip('SUPERSEDED — /report used to redirect to /blog', () => {
|
||||
const src = read('app/report/page.tsx');
|
||||
expect(src).toContain("redirect('/blog')");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* NOTE ON `engine_grade` (2026-08-07 grade cutover).
|
||||
*
|
||||
* The user-facing `grade` now derives from `p_win`, not engine1's additive
|
||||
* factor index -- that index carried 0.16x the information of p_win on 3,417
|
||||
* settled props and its A hit worse than its F. The engine's OWN decision
|
||||
* (graded vs suppressed) is preserved as `engine_grade`, so behaviour
|
||||
* assertions read that; suppression assertions still read `grade`, since a
|
||||
* suppressed prop has no letter either way.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
// Model Train — value engine: takeable gate, value flag, and the end-to-end
|
||||
@@ -49,7 +59,7 @@ describe('analyzeViaEngine1 — de-vig + EV + triplet (steps 1,2,6)', () => {
|
||||
const out = await analyzeViaEngine1({
|
||||
player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', over_odds: -130, under_odds: 110,
|
||||
});
|
||||
expect(out.grade).toBe('B'); // still a real read
|
||||
expect(out.engine_grade).toBe('B'); // still a real read
|
||||
expect(out.book_odds).toBe(-130); // book price
|
||||
expect(typeof out.fair_odds).toBe('number'); // de-vigged fair price present (both sides)
|
||||
expect(out.model_odds).toBe(-150); // impliedProbToAmerican(0.60)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Next proxy for the private content API.
|
||||
*
|
||||
* The browser cannot reach Express directly (the S25 rule), and the internal key
|
||||
* must never reach the client — so it is attached here, server-side. The preview
|
||||
* page therefore holds no credential and the same upstream contract serves an
|
||||
* autonomous poster unchanged.
|
||||
*/
|
||||
|
||||
const BACKEND = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||
|
||||
async function forward(req: NextRequest, path: string[], init?: RequestInit) {
|
||||
const key = process.env.VYNDR_INTERNAL_KEY;
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'content preview is not configured' }, { status: 503 });
|
||||
}
|
||||
const url = `${BACKEND}/api/content-studio/${path.join('/')}`;
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: { 'x-internal-key': key, 'content-type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const body = await res.json().catch(() => ({ error: 'upstream returned no JSON' }));
|
||||
return NextResponse.json(body, { status: res.status });
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await ctx.params;
|
||||
return forward(req, path);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await ctx.params;
|
||||
const body = await req.text();
|
||||
return forward(req, path, { method: 'POST', body });
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Content proxy (Session 29). Forwards /api/content/* to Express
|
||||
* (slate thread / POTD / recap / matchup preview). Read-only, zero-credit.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params;
|
||||
const segments = (path || []).map(encodeURIComponent).join('/');
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/content/${segments}${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Content service unreachable.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||
|
||||
/** Next proxy for the public Report archive (the S25 rule). */
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/api/report`, { next: { revalidate: 300 } });
|
||||
const body = await res.json().catch(() => ({ count: 0, issues: [] }));
|
||||
return NextResponse.json(body, { status: res.ok ? 200 : res.status });
|
||||
} catch {
|
||||
// A dead upstream is an EMPTY archive, never a fabricated one.
|
||||
return NextResponse.json({ count: 0, issues: [], degraded: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import OffseasonHub from '@/components/vyndr/OffseasonHub';
|
||||
|
||||
export const metadata = {
|
||||
title: 'The Offseason Desk — VYNDR',
|
||||
description: 'Outlooks reprice on news, not game odds.',
|
||||
};
|
||||
|
||||
/**
|
||||
* F9 — the offseason hub route.
|
||||
*
|
||||
* A server wrapper so the metadata export survives; the hub itself is a client
|
||||
* component because the wire self-fetches.
|
||||
*
|
||||
* NOTE ON PLACEMENT: the spec is explicit that sport state lives in the SPORT
|
||||
* TAB (`NFL · CAMP −5D`) and never in a separate offseason tab. This route is
|
||||
* the surface; wiring it as a per-sport mode of the existing tab strip belongs
|
||||
* with the in-season IA, which is the open design gap.
|
||||
*/
|
||||
export default function OffseasonPage() {
|
||||
return <OffseasonHub sport="nfl" />;
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import ReportArchive from '@/components/vyndr/ReportArchive';
|
||||
|
||||
export const metadata = {
|
||||
title: 'The VYNDR Report — Archive',
|
||||
description: 'One email per slate day. Top read, what changed, the record. Every issue shows its own day record.',
|
||||
};
|
||||
|
||||
/**
|
||||
* /report (Session 41 — P0 audit fix).
|
||||
* E12 — /report, the issue archive.
|
||||
*
|
||||
* The MORE dropdown links "THE REPORT" to /report, but the blog lives at
|
||||
* /blog. Forward there so the link resolves instead of 404ing.
|
||||
* REPLACES the S41 redirect to /blog. That redirect was a placeholder for a
|
||||
* surface that did not exist; this is the surface. The archive is the owned
|
||||
* channel's on-site home and its SEO asset.
|
||||
*/
|
||||
export default function ReportPage() {
|
||||
redirect('/blog');
|
||||
return <ReportArchive />;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,17 @@ interface Player {
|
||||
|
||||
interface ScanResponse {
|
||||
grade: string;
|
||||
served_grade?: {
|
||||
letter: string | null;
|
||||
state: string;
|
||||
label: string;
|
||||
meaning: string;
|
||||
band_realized_rate?: number;
|
||||
separates_from_base_rate?: boolean;
|
||||
basis?: string;
|
||||
factors_applied?: string[];
|
||||
};
|
||||
factor_adjustment?: { applied?: Array<{ factor: string; multiplier: number }> };
|
||||
// Session 58 (work-order 1.5) — the model refused: no projection, no read.
|
||||
insufficient_data?: boolean;
|
||||
projection?: number;
|
||||
@@ -864,6 +875,10 @@ export default function ScanPage() {
|
||||
sample_size: result.sample_size,
|
||||
factors: result.factors,
|
||||
alt_lines: result.alt_lines,
|
||||
// The honest grade fields -- carried, or the card cannot say
|
||||
// whether a band separates from the baseline.
|
||||
served_grade: result.served_grade,
|
||||
factor_adjustment: result.factor_adjustment,
|
||||
kelly: result.kelly,
|
||||
kill_conditions: result.kill_conditions,
|
||||
tier,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* /studio — the daily content desk.
|
||||
*
|
||||
* A THIN CLIENT over `/api/content-studio`. No posting logic and no fact
|
||||
* handling live here: the same endpoint an autonomous poster will call is the
|
||||
* one this page renders, so the agent handoff is a pointer change, not a
|
||||
* rebuild.
|
||||
*
|
||||
* The fact-contract is shown beside every post on purpose. Reviewing copy by
|
||||
* reading it is how a wrong number ships — the reviewer needs to see WHAT backs
|
||||
* each claim, not just that the sentence scans.
|
||||
*/
|
||||
|
||||
type Post = {
|
||||
id: string; label: string; sport: string | null; status: string;
|
||||
ok: boolean; skipped: boolean; reason: string | null; honest_absence: boolean;
|
||||
copy: string | null; card_svg: string | null;
|
||||
fact_contract: string[]; facts: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
approved: 'var(--hit, #00D4A0)', skipped: 'var(--miss, #FF6B6B)',
|
||||
regenerate_requested: 'var(--amber, #FFB347)', pending: 'var(--text-3, #6B7A8D)',
|
||||
};
|
||||
|
||||
export default function StudioPage() {
|
||||
const [date, setDate] = useState('');
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (d?: string) => {
|
||||
setLoading(true); setErr(null);
|
||||
try {
|
||||
const res = await fetch(`/api/content-studio/${d || ''}`, { cache: 'no-store' });
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j?.error || 'could not load');
|
||||
setDate(j.date); setPosts(j.posts || []);
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'could not load'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const setStatus = async (id: string, status: string) => {
|
||||
await fetch(`/api/content-studio/${date}/${id}/status`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
setPosts((p) => p.map((x) => (x.id === id ? { ...x, status } : x)));
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ padding: '24px 20px', maxWidth: 1120, margin: '0 auto' }}>
|
||||
<h1 className="mono" style={{ fontSize: 22, fontWeight: 800, letterSpacing: '.1em', marginBottom: 4 }}>
|
||||
CONTENT STUDIO
|
||||
</h1>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 20 }}>
|
||||
{date || '—'} · every claim below traces to a pulled field. Nothing here is written by a model.
|
||||
</p>
|
||||
|
||||
{loading && <p className="mono" style={{ fontSize: 12 }}>loading tonight's posts…</p>}
|
||||
{err && <p className="mono" style={{ fontSize: 12, color: 'var(--miss)' }}>{err}</p>}
|
||||
|
||||
{/* NEVER BLANK: a night with nothing to say says so. */}
|
||||
{!loading && !err && posts.length === 0 && (
|
||||
<div className="mono" style={{ padding: 20, border: '1px solid var(--line)', fontSize: 13 }}>
|
||||
No posts generated for {date}. Not an error — the engine found nothing it could back with real
|
||||
data, and it will not invent any.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map((p) => (
|
||||
<section key={p.id} style={{ border: '1px solid var(--line)', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--line)' }}>
|
||||
<strong className="mono" style={{ fontSize: 13, letterSpacing: '.06em' }}>{p.label}</strong>
|
||||
{p.sport && <span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>{p.sport.toUpperCase()}</span>}
|
||||
<span className="mono" style={{ fontSize: 10, color: STATUS_COLOR[p.status] }}>{p.status.toUpperCase()}</span>
|
||||
{p.honest_absence && <span className="mono" style={{ fontSize: 10, color: 'var(--amber)' }}>HONEST ABSENCE</span>}
|
||||
{p.skipped && <span className="mono" style={{ fontSize: 10, color: 'var(--miss)' }}>SKIPPED</span>}
|
||||
</header>
|
||||
|
||||
{p.skipped ? (
|
||||
<div className="mono" style={{ padding: 14, fontSize: 12, color: 'var(--text-2)' }}>
|
||||
Not generated — {p.reason}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 0 }}>
|
||||
<div style={{ padding: 14 }}>
|
||||
<pre className="mono" style={{ whiteSpace: 'pre-wrap', fontSize: 12.5, lineHeight: 1.7, margin: 0 }}>
|
||||
{p.copy}
|
||||
</pre>
|
||||
|
||||
{/* WHAT BACKS THIS — the reason a reviewer can catch a wrong number. */}
|
||||
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px dashed var(--line)' }}>
|
||||
<div className="mono" style={{ fontSize: 10, letterSpacing: '.08em', color: 'var(--text-3)', marginBottom: 6 }}>
|
||||
FACT CONTRACT — {p.fact_contract.length} required field{p.fact_contract.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
{p.fact_contract.map((f) => (
|
||||
<div key={f} className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>
|
||||
{f} = {JSON.stringify(p.facts?.[f.split('.')[0]] ?? null)?.slice(0, 90)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||
{(['approved', 'skipped', 'regenerate_requested'] as const).map((s) => (
|
||||
<button key={s} onClick={() => setStatus(p.id, s)} className="mono"
|
||||
style={{ padding: '6px 12px', fontSize: 11, border: '1px solid var(--line)', background: 'transparent', color: STATUS_COLOR[s], cursor: 'pointer' }}>
|
||||
{s.replace('_', ' ').toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderLeft: '1px solid var(--line)', padding: 12 }}>
|
||||
{p.card_svg
|
||||
? <div style={{ width: '100%' }} dangerouslySetInnerHTML={{ __html: p.card_svg.replace('<svg', '<svg style="width:100%;height:auto"') }} />
|
||||
: <span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>no card</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,15 @@ import { gradeGlows } from '@/lib/colorContract';
|
||||
import PriceTriplet, { type PriceTripletData } from './PriceTriplet';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
|
||||
import GradeScaleLegend from './GradeScaleLegend';
|
||||
|
||||
/** Only the three factors PROVEN for hits reach the card, so only these map. */
|
||||
const FACTOR_LABEL: Record<string, string> = {
|
||||
defense_by_direction: 'where he hits it vs who is standing there',
|
||||
platoon_severity: 'his own measured platoon split',
|
||||
pitcher_contact_profile: 'the contact this arm concedes',
|
||||
};
|
||||
|
||||
export interface GradeResultData {
|
||||
player: string;
|
||||
team: string;
|
||||
@@ -36,6 +45,12 @@ export interface GradeResultData {
|
||||
projection: number | null;
|
||||
phosphorConfirmed?: boolean;
|
||||
signals: string[];
|
||||
/** What this band has actually realized, and whether it separates at all. */
|
||||
gradeMeaning?: string | null;
|
||||
separatesFromBaseRate?: boolean | null;
|
||||
bandRealizedRate?: number | null;
|
||||
/** ONLY factors that fired, with their proven sign. Empty is the common case. */
|
||||
factorsApplied?: Array<{ factor: string; direction: 'up' | 'down'; multiplier: number }>;
|
||||
killConditions?: string[];
|
||||
books: Array<{ name: string; line: number; odds: string; best?: boolean }>;
|
||||
altLadder?: Array<{ line: number; grade: string; edge?: number | null; base?: boolean }>;
|
||||
@@ -248,6 +263,58 @@ export default function GradeResultCard({
|
||||
nothing rather than a fabricated timeline. */}
|
||||
<GradeShift history={d.history} side={d.side} grade={d.grade} revisedFrom={d.revisedFrom} gradedLine={d.gradedLine ?? d.line} />
|
||||
|
||||
{/* 4b. WHAT THIS GRADE MEANS — the honest core.
|
||||
A band that cannot be separated from the baseline SAYS so here. That
|
||||
is most of any slate, and stating it is the point: a C is not a weak
|
||||
opinion, it is us telling you we see nothing that distinguishes this
|
||||
prop. Absent fields self-hide, so nothing renders on a refusal. */}
|
||||
{(d.gradeMeaning || (d.factorsApplied && d.factorsApplied.length > 0)) && (
|
||||
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--line)' }}>
|
||||
<SectionHead style={{ marginBottom: 10 }}>WHAT THIS GRADE MEANS</SectionHead>
|
||||
|
||||
{d.gradeMeaning && (
|
||||
<p className="mono" style={{ margin: '0 0 8px', fontSize: 11, lineHeight: 1.6, color: 'var(--text-2)' }}>
|
||||
{d.gradeMeaning}
|
||||
{typeof d.bandRealizedRate === 'number' && (
|
||||
<> — this band has landed <strong style={{ color: 'var(--text-1)' }}>
|
||||
{Math.round(d.bandRealizedRate * 100)}%
|
||||
</strong> of the time.</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{d.separatesFromBaseRate === false && (
|
||||
<p className="mono" style={{ margin: '0 0 8px', fontSize: 11, lineHeight: 1.6, color: 'var(--amber)' }}>
|
||||
We cannot separate this read from the baseline. Shown so you know
|
||||
the model is not claiming an edge here.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{d.factorsApplied && d.factorsApplied.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div className="mono" style={{ fontSize: 10, letterSpacing: '.08em', color: 'var(--text-3)' }}>
|
||||
WHAT MOVED THE READ
|
||||
</div>
|
||||
{d.factorsApplied.map((f) => (
|
||||
<div key={f.factor} className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>
|
||||
<span style={{ color: f.direction === 'up' ? 'var(--hit)' : 'var(--miss)' }}>
|
||||
{f.direction === 'up' ? '\u25B2' : '\u25BC'}
|
||||
</span>{' '}
|
||||
{FACTOR_LABEL[f.factor] || f.factor}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4c. THE SCALE ITSELF. A user who never sees an A will invent a reason
|
||||
for it, and every reason they might invent is wrong. Compact form
|
||||
rides the card; the full band table lives on the scale page. */}
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--line)' }}>
|
||||
<GradeScaleLegend compact />
|
||||
</div>
|
||||
|
||||
{/* 5. SIGNAL BREAKDOWN */}
|
||||
{d.signals.length > 0 && (
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* The grade scale, stated as a position.
|
||||
*
|
||||
* A user who never sees an A will invent a reason for it, and every reason they
|
||||
* might invent is wrong. So the ceiling is named: top grades are earned from
|
||||
* realized outcomes, and this model has not earned one.
|
||||
*
|
||||
* Mirrors `src/services/model/servedGrade.js` SCALE_LEGEND. If the ceiling moves
|
||||
* there, it moves here — and that change should be deliberate and visible, which
|
||||
* is the whole point of writing it down in both places.
|
||||
*/
|
||||
|
||||
const BANDS = [
|
||||
{ letter: 'B+', realized: '~66%', separates: true, note: 'the strongest read this model produces' },
|
||||
{ letter: 'B', realized: '~65%', separates: true, note: 'above the baseline' },
|
||||
{ letter: 'C+', realized: '~62%', separates: false, note: 'not separable from the baseline' },
|
||||
{ letter: 'C', realized: '~59%', separates: false, note: 'a base-rate read' },
|
||||
{ letter: 'C-', realized: '~55%', separates: false, note: 'at or below baseline' },
|
||||
{ letter: 'D', realized: '~51%', separates: true, note: 'below baseline' },
|
||||
{ letter: 'F', realized: '~45%', separates: true, note: 'well below baseline' },
|
||||
];
|
||||
|
||||
export default function GradeScaleLegend({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<div className="mono" style={{ fontSize: 11, lineHeight: 1.6, color: 'var(--text-2)' }}>
|
||||
<div style={{ fontWeight: 800, letterSpacing: '.08em', color: 'var(--text-1)', marginBottom: 6 }}>
|
||||
WHAT A GRADE MEANS
|
||||
</div>
|
||||
<p style={{ margin: '0 0 8px' }}>
|
||||
Grades are earned from realized outcomes, not issued on confidence. Each letter below
|
||||
shows how often those reads have actually landed, against a ~60% baseline.
|
||||
</p>
|
||||
|
||||
{!compact && (
|
||||
<div style={{ display: 'grid', gap: 2, margin: '0 0 10px' }}>
|
||||
{BANDS.map((b) => (
|
||||
<div key={b.letter} style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
|
||||
<span style={{ minWidth: 26, fontWeight: 800, color: 'var(--text-1)' }}>{b.letter}</span>
|
||||
<span style={{ minWidth: 48 }}>{b.realized}</span>
|
||||
<span style={{ opacity: b.separates ? 1 : 0.72 }}>
|
||||
{b.note}
|
||||
{!b.separates && ' — we cannot separate this from the baseline'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ margin: '0 0 6px' }}>
|
||||
<strong style={{ color: 'var(--text-1)' }}>We do not issue A grades.</strong>{' '}
|
||||
No band of this model has hit at a rate that would justify one. We would rather show you
|
||||
the ceiling than invent a letter above it. Our honest ceiling right now is a strong B+.
|
||||
</p>
|
||||
<p style={{ margin: 0, opacity: 0.8 }}>
|
||||
If the model earns an A, this legend changes and we will say why.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { buildGradeTimeline } from '@/lib/gradeShift';
|
||||
|
||||
/**
|
||||
* E1 — MOVEMENT STRIP.
|
||||
*
|
||||
* The design defines it once: *"the movement strip is defined once here and
|
||||
* reused everywhere a line has a past."* This is that primitive.
|
||||
*
|
||||
* ── IT DOES NOT REIMPLEMENT THE COLOUR LAW ───────────────────────────────
|
||||
* `lib/gradeShift.js` already computes toward / against / flat, and already
|
||||
* handles the direction flip that makes an UNDER's favourable move the opposite
|
||||
* sign of an OVER's. Reimplementing that here would fork the rule and let the
|
||||
* two drift — which is exactly what happened when the content engine invented a
|
||||
* card system beside the designed one. GradeShift remains the grade-history
|
||||
* VIEW; this is the reusable strip, and both read one law.
|
||||
*
|
||||
* ── SPEC LAWS ────────────────────────────────────────────────────────────
|
||||
* STEPS, NOT CURVES each observation is a discrete step. A smoothed curve
|
||||
* invents intermediate prices that never traded.
|
||||
* GREEN ONLY WHEN THE MOVE FAVOURS THE READ — not when the number rose. A
|
||||
* line moving down is good news on an under.
|
||||
* FLAT = HAIRLINE + `FLAT · [N]D` — a flat market is a real finding and gets
|
||||
* said, not left as an empty box.
|
||||
*
|
||||
* Never-blank: too little history renders the honest short-history state rather
|
||||
* than nothing.
|
||||
*/
|
||||
|
||||
export type MovementPoint = { t: string; line: number };
|
||||
|
||||
const TOWARD = 'var(--hit, #00D4A0)';
|
||||
const AGAINST = 'var(--amber, #FFB347)';
|
||||
const FLAT = 'var(--text-3, #707080)';
|
||||
|
||||
export default function MovementStrip({
|
||||
history, side, days, width = 86, height = 20, annotated = false, label,
|
||||
}: {
|
||||
history?: MovementPoint[] | null;
|
||||
side?: string;
|
||||
days?: number | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Reveal form: full-width and annotated. Silent form is the 86×20 row. */
|
||||
annotated?: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
const timeline = buildGradeTimeline({ history: history || [], side });
|
||||
|
||||
// HONEST SHORT HISTORY. A one-point "movement" is not movement.
|
||||
if (!timeline || !timeline.show || !Array.isArray(timeline.points) || timeline.points.length < 2) {
|
||||
return (
|
||||
<span className="mono" style={{ fontSize: 10, color: FLAT, letterSpacing: '.06em' }}>
|
||||
NO MOVEMENT HISTORY
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const pts = timeline.points as Array<{ line: number; dir?: string }>;
|
||||
const lines = pts.map((p) => p.line);
|
||||
const lo = Math.min(...lines);
|
||||
const hi = Math.max(...lines);
|
||||
const span = hi - lo;
|
||||
|
||||
// FLAT: a hairline plus the count of days it has not moved.
|
||||
if (span === 0) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<svg width={annotated ? '100%' : width} height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" aria-label="flat market">
|
||||
<line x1="0" y1={height / 2} x2={width} y2={height / 2} stroke={FLAT} strokeWidth="1" />
|
||||
</svg>
|
||||
<span className="mono" style={{ fontSize: 10, color: FLAT, letterSpacing: '.06em' }}>
|
||||
FLAT{typeof days === 'number' ? ` · ${days}D` : ''}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// STEPS, NOT CURVES: hold each price, then jump. No interpolation.
|
||||
const stepW = width / (pts.length - 1);
|
||||
const y = (v: number) => height - 2 - ((v - lo) / span) * (height - 4);
|
||||
const segments = pts.slice(1).map((p, i) => {
|
||||
const x0 = i * stepW;
|
||||
const x1 = (i + 1) * stepW;
|
||||
const y0 = y(pts[i].line);
|
||||
const y1 = y(p.line);
|
||||
const colour = p.dir === 'toward' ? TOWARD : p.dir === 'against' ? AGAINST : FLAT;
|
||||
return { d: `M${x0} ${y0} H${x1} V${y1}`, colour, key: `${i}-${p.line}` };
|
||||
});
|
||||
|
||||
const net = timeline.net as { dir?: string } | undefined;
|
||||
const netColour = net?.dir === 'toward' ? TOWARD : net?.dir === 'against' ? AGAINST : FLAT;
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, width: annotated ? '100%' : undefined }}>
|
||||
<svg
|
||||
width={annotated ? '100%' : width} height={annotated ? height * 1.6 : height}
|
||||
viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none"
|
||||
aria-label={`line movement, ${pts.length} observations`}
|
||||
>
|
||||
{segments.map((s) => (
|
||||
<path key={s.key} d={s.d} fill="none" stroke={s.colour} strokeWidth="1.6" strokeLinejoin="miter" />
|
||||
))}
|
||||
</svg>
|
||||
{annotated && (
|
||||
<span className="mono" style={{ fontSize: 10, color: netColour, letterSpacing: '.06em', whiteSpace: 'nowrap' }}>
|
||||
{label || `${lines[0]} → ${lines[lines.length - 1]}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import MovementStrip from '@/components/vyndr/MovementStrip';
|
||||
|
||||
/**
|
||||
* F9–F11 — THE OFFSEASON DESK (hub shell + season board + kickoff countdown).
|
||||
*
|
||||
* Built to `specs/design-reference/Vyndr Offseason.dc.html`. The spec's own
|
||||
* words are load-bearing here and are used verbatim where they carry a law.
|
||||
*
|
||||
* ── WHAT THE SPEC FIXES ──────────────────────────────────────────────────
|
||||
* "SPORT TAB STATE LIVES IN THE TAB · NEVER A SEPARATE 'OFFSEASON' TAB"
|
||||
* so the sport strip reads `NFL · CAMP −5D`, and this surface is a MODE
|
||||
* of a sport, not a destination beside it.
|
||||
* "OUTLOOKS REPRICE ON NEWS · NOT GAME ODDS"
|
||||
* the subhead, because an offseason number is not a game line and saying
|
||||
* so is the difference between a read and a bet.
|
||||
* "the hero is what changed today"
|
||||
* WHAT CHANGED TODAY leads. The countdown is ambient, top-right, never
|
||||
* the hero.
|
||||
* QUIET WIRE — "No outlook-moving news since {time}. We don't manufacture
|
||||
* movement." The designed empty state, not a generic blank.
|
||||
*
|
||||
* ── TRUTH LAW ────────────────────────────────────────────────────────────
|
||||
* Everything renders from real data or renders its honest absence. There is no
|
||||
* sample content in this component: the design file's numbers are a spec for
|
||||
* what a live feed renders, and copying them in would be fabrication wearing a
|
||||
* designer's authority.
|
||||
*
|
||||
* The IN-SEASON information architecture — how content, articles, wire and the
|
||||
* live slate share year-round navigation — is NOT specified anywhere and is NOT
|
||||
* invented here. It is the open design gap.
|
||||
*/
|
||||
|
||||
type WireEvent = { time: string; tag: string; text: string; source?: string; subject?: string };
|
||||
type BoardRow = { subject: string; market: string; open?: string; now?: string; fair?: string; grade?: string; history?: Array<{ t: string; line: number }>; side?: string };
|
||||
type Milestone = { label: string; date: string };
|
||||
type HubData = {
|
||||
sport: string; sport_state: string | null; as_of: string | null;
|
||||
countdown: { days: number; label: string; date: string } | null;
|
||||
milestones: Milestone[]; events: WireEvent[]; board: BoardRow[];
|
||||
outlook_only: { headline: string; body: string; rows: Array<{ subject: string; detail: string }> } | null;
|
||||
quiet_since: string | null;
|
||||
};
|
||||
|
||||
/** Tag colour = meaning (spec). Neutral grey default; amber for contract news. */
|
||||
const TAG_COLOUR: Record<string, string> = {
|
||||
INJURY: 'var(--miss, #FF4757)',
|
||||
CONTRACT: 'var(--amber, #FFB347)',
|
||||
CLEARED: 'var(--hit, #00D4A0)',
|
||||
TRADE: 'var(--amber, #FFB347)',
|
||||
};
|
||||
const tagColour = (t: string) => TAG_COLOUR[String(t).toUpperCase()] || 'var(--text-3, #707080)';
|
||||
|
||||
export default function OffseasonHub({ sport = 'nfl' }: { sport?: string }) {
|
||||
const [data, setData] = useState<HubData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/offseason/${sport}`, { cache: 'no-store' });
|
||||
const j = await res.json();
|
||||
if (live) setData(res.ok ? j : null);
|
||||
} catch { if (live) setData(null); }
|
||||
finally { if (live) setLoading(false); }
|
||||
})();
|
||||
return () => { live = false; };
|
||||
}, [sport]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="mono" style={{ fontSize: 12, color: 'var(--text-3)', padding: 20 }}>reading the wire…</p>;
|
||||
}
|
||||
|
||||
const events = data?.events || [];
|
||||
const board = data?.board || [];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1120, margin: '0 auto', padding: '20px' }}>
|
||||
{/* HEADER — sport state lives in the tab, per spec. */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h1 className="mono" style={{ fontSize: 24, fontWeight: 800, letterSpacing: '.08em', margin: 0 }}>
|
||||
THE OFFSEASON DESK
|
||||
</h1>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-3)', letterSpacing: '.1em', margin: '6px 0 0' }}>
|
||||
OUTLOOKS REPRICE ON NEWS · NOT GAME ODDS
|
||||
{data?.as_of ? ` · AS OF ${data.as_of}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Countdown: ambient, top-right, never the hero (spec). Absent if unknown. */}
|
||||
{data?.countdown && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="mono" style={{ fontSize: 13, fontWeight: 800, color: 'var(--text-1)' }}>
|
||||
{data.countdown.days} DAYS TO {data.countdown.label.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>{data.countdown.date}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Milestone rail — real dates or nothing. */}
|
||||
{data?.milestones?.length ? (
|
||||
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line)' }}>
|
||||
{data.milestones.map((m) => (
|
||||
<span key={`${m.label}${m.date}`} className="mono" style={{ fontSize: 10, color: 'var(--text-3)', letterSpacing: '.08em' }}>
|
||||
{m.label.toUpperCase()} · {m.date}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* THE HERO: what changed today. */}
|
||||
<section style={{ marginTop: 28 }}>
|
||||
<SectionHead>WHAT CHANGED TODAY{events.length ? ` · ${events.length} EVENT${events.length === 1 ? '' : 'S'}` : ''}</SectionHead>
|
||||
|
||||
{events.length === 0 ? (
|
||||
/* QUIET WIRE — the designed empty state, verbatim law. */
|
||||
<div style={{ marginTop: 12, padding: '18px 16px', border: '1px solid var(--line)' }}>
|
||||
<div className="mono" style={{ fontSize: 11, letterSpacing: '.1em', color: 'var(--text-3)', marginBottom: 8 }}>QUIET WIRE</div>
|
||||
<p className="mono" style={{ fontSize: 13, color: 'var(--text-2)', margin: 0, lineHeight: 1.6 }}>
|
||||
{data?.quiet_since
|
||||
? `No outlook-moving news since ${data.quiet_since}.`
|
||||
: 'No outlook-moving news.'}{' '}
|
||||
<span style={{ color: 'var(--text-1)' }}>We don't manufacture movement.</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{/* ROW ANATOMY: time + event tag + source. */}
|
||||
{events.map((e, i) => (
|
||||
<div key={`${e.time}-${i}`} style={{ display: 'flex', gap: 12, alignItems: 'baseline', padding: '10px 0', borderBottom: '1px solid var(--line)' }}>
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-3)', minWidth: 92 }}>{e.time}</span>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.08em', color: tagColour(e.tag), minWidth: 78 }}>
|
||||
{String(e.tag).toUpperCase()}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-1)', flex: 1 }}>{e.text}</span>
|
||||
{e.source && <span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>{e.source}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* OUTLOOK ONLY — the spec's honesty block, shown only where the sport declares it. */}
|
||||
{data?.outlook_only && (
|
||||
<section style={{ marginTop: 28, border: '1px solid var(--amber)', padding: '16px' }}>
|
||||
<div className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.12em', color: 'var(--amber)', marginBottom: 8 }}>
|
||||
{data.outlook_only.headline}
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-2)', margin: '0 0 14px', lineHeight: 1.6 }}>
|
||||
{data.outlook_only.body}
|
||||
</p>
|
||||
{data.outlook_only.rows.map((r) => (
|
||||
<div key={r.subject} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 0', borderTop: '1px solid var(--line)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-1)' }}>{r.subject}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{r.detail}</span>
|
||||
{/* Every row carries NOT GRADED. The block exists to say so. */}
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.08em', color: 'var(--amber)' }}>NOT GRADED</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* SEASON BOARD — open → NOW → VYNDR triplet, with the movement strip. */}
|
||||
<section style={{ marginTop: 28 }}>
|
||||
<SectionHead>SEASON BOARD</SectionHead>
|
||||
{board.length === 0 ? (
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 12 }}>
|
||||
No season-long outlooks priced yet. Nothing shown rather than a board of placeholders.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{board.map((r, i) => (
|
||||
<div key={`${r.subject}-${r.market}-${i}`} style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '10px 0', borderBottom: '1px solid var(--line)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-1)', flex: 1 }}>{r.subject}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)', minWidth: 90 }}>{r.market}</span>
|
||||
{/* open #707080 · now bright · model green — the spec's triplet. */}
|
||||
{r.open && <span className="mono" style={{ fontSize: 11, color: '#707080' }}>{r.open}</span>}
|
||||
{r.now && <span className="mono" style={{ fontSize: 11, fontWeight: 700, color: '#F0F0F0' }}>→ {r.now}</span>}
|
||||
{r.fair && <span className="mono" style={{ fontSize: 11, fontWeight: 800, color: 'var(--hit, #00D4A0)' }}>FAIR {r.fair}</span>}
|
||||
<MovementStrip history={r.history} side={r.side} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
|
||||
/**
|
||||
* E12 — the Report archive.
|
||||
*
|
||||
* The spec's law, verbatim: **"EVERY ISSUE SHOWS ITS OWN DAY RECORD — THE
|
||||
* ARCHIVE IS A LEDGER TOO."**
|
||||
*
|
||||
* So every row carries the record that issue's reads produced. An archive of
|
||||
* headlines would let a bad day disappear into a title; this one cannot. A row
|
||||
* whose record is unknown says UNSETTLED rather than showing a dash that reads
|
||||
* like zero.
|
||||
*/
|
||||
|
||||
type Issue = {
|
||||
number?: number; date: string; date_label?: string; headline?: string;
|
||||
top_read?: { subject?: string; grade?: string } | null;
|
||||
record?: { hit?: number; miss?: number; line?: string } | null;
|
||||
};
|
||||
|
||||
export default function ReportArchive() {
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/report', { cache: 'no-store' });
|
||||
const j = await res.json();
|
||||
if (live) setIssues(Array.isArray(j?.issues) ? j.issues : []);
|
||||
} catch { if (live) setIssues([]); }
|
||||
finally { if (live) setLoading(false); }
|
||||
})();
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
|
||||
const recordText = (r: Issue['record']) => {
|
||||
if (!r) return null;
|
||||
if (typeof r.line === 'string' && r.line.trim()) return r.line;
|
||||
if (typeof r.hit === 'number' && typeof r.miss === 'number') return `${r.hit}–${r.miss}`;
|
||||
return null; // unknown is unknown; never a dash that reads as zero
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 820, margin: '0 auto', padding: '24px 20px' }}>
|
||||
<h1 className="mono" style={{ fontSize: 22, fontWeight: 800, letterSpacing: '.08em', margin: 0 }}>
|
||||
THE VYNDR REPORT
|
||||
</h1>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-3)', letterSpacing: '.06em', margin: '8px 0 0' }}>
|
||||
One email per slate day. Top read, what changed, the record. Nothing else.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<SectionHead>ARCHIVE{issues.length ? ` · ${issues.length} ISSUE${issues.length === 1 ? '' : 'S'}` : ''}</SectionHead>
|
||||
|
||||
{loading && <p className="mono" style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 12 }}>loading the archive…</p>}
|
||||
|
||||
{/* HONEST EMPTY STATE — no issues is a real state, not a broken page. */}
|
||||
{!loading && issues.length === 0 && (
|
||||
<div style={{ marginTop: 12, padding: '18px 16px', border: '1px solid var(--line)' }}>
|
||||
<p className="mono" style={{ fontSize: 13, color: 'var(--text-2)', margin: 0, lineHeight: 1.6 }}>
|
||||
No issues published yet. The first one goes out on the next slate day, and it will show its
|
||||
own record like every one after it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && issues.map((iss) => {
|
||||
const rec = recordText(iss.record);
|
||||
return (
|
||||
<div key={iss.date} style={{ display: 'flex', gap: 14, alignItems: 'baseline', padding: '14px 0', borderBottom: '1px solid var(--line)' }}>
|
||||
{typeof iss.number === 'number' && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)', minWidth: 52 }}>Nº {iss.number}</span>
|
||||
)}
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)', minWidth: 74 }}>
|
||||
{iss.date_label || iss.date}
|
||||
</span>
|
||||
<span style={{ fontSize: 14, color: 'var(--text-1)', flex: 1 }}>
|
||||
{iss.headline || 'Untitled issue'}
|
||||
</span>
|
||||
{iss.top_read?.subject && (
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>
|
||||
TOP READ · {iss.top_read.subject.toUpperCase()}
|
||||
{iss.top_read.grade ? ` ${iss.top_read.grade}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{/* THE LAW: every issue shows its own day record. */}
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 800, color: rec ? 'var(--text-1)' : 'var(--text-3)' }}>
|
||||
{rec ? `DAY RECORD ${rec}` : 'UNSETTLED'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -86,6 +86,27 @@ function mapScanToGradeResult(input = {}) {
|
||||
side,
|
||||
grade: input.grade || '—',
|
||||
confidence,
|
||||
// ── THE HONEST GRADE FIELDS ────────────────────────────────────────────
|
||||
// The served grade knows whether its band can actually be separated from
|
||||
// the baseline, and which proven factors (if any) moved the forecast. Both
|
||||
// were being computed and thrown away at this boundary. A grade that cannot
|
||||
// separate must SAY so on the card -- that flag is the honest core, and
|
||||
// carrying it in an object nobody reads is the same as not having it.
|
||||
gradeMeaning: (input.served_grade && input.served_grade.meaning) || null,
|
||||
separatesFromBaseRate: input.served_grade
|
||||
? input.served_grade.separates_from_base_rate === true : null,
|
||||
bandRealizedRate: (input.served_grade && input.served_grade.band_realized_rate) ?? null,
|
||||
// Only factors that ACTUALLY fired, with their proven sign. No narrative on
|
||||
// props where nothing fired.
|
||||
factorsApplied: (input.factor_adjustment && Array.isArray(input.factor_adjustment.applied))
|
||||
? input.factor_adjustment.applied.map((a) => ({
|
||||
factor: a.factor,
|
||||
direction: a.multiplier > 1 ? 'up' : 'down',
|
||||
multiplier: a.multiplier,
|
||||
}))
|
||||
: [],
|
||||
refusalReason: (input.served_grade && input.served_grade.state !== 'graded')
|
||||
? input.served_grade.meaning : null,
|
||||
// DATA SEMANTICS (Session 58): projection is MODEL output and must never
|
||||
// be fabricated. The old fallback displayed the LINE as the projection —
|
||||
// the audit's model==line / +0% edge degenerate. No projection → the
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/* ============================================================
|
||||
WAVE D1 — the four motion primitives (E17, E18, E27, E28).
|
||||
All four were ABSENT; the gap audit lists them as self-contained with no
|
||||
dependency, which is why they come before any surface that would embed them.
|
||||
|
||||
Plain CommonJS so .tsx imports it AND the Jest suite requires it directly
|
||||
(the vyndrTokens.js / archetypes.js pattern).
|
||||
|
||||
DESIGN-SPEC PART 4 governs: motion is for PERCEIVED SPEED, never decoration.
|
||||
Every primitive here is skipped outright when the viewer asks for reduced
|
||||
motion -- an accessibility preference is not a style to soften.
|
||||
============================================================ */
|
||||
|
||||
/** Honour the OS-level preference. Server-side, assume reduced (no motion). */
|
||||
function prefersReducedMotion() {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return true;
|
||||
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
|
||||
catch { return true; }
|
||||
}
|
||||
|
||||
/**
|
||||
* E17 — nudge(): the acknowledgement pulse.
|
||||
*
|
||||
* Confirms an action REGISTERED without claiming it finished. Deliberately
|
||||
* short: a long animation reads as latency, which is the opposite of what a
|
||||
* perceived-speed primitive is for.
|
||||
*/
|
||||
const NUDGE_MS = 180;
|
||||
function nudge(el, opts = {}) {
|
||||
if (!el || prefersReducedMotion()) return () => {};
|
||||
const scale = opts.scale ?? 1.03;
|
||||
const prev = el.style.transition;
|
||||
el.style.transition = `transform ${NUDGE_MS}ms cubic-bezier(.2,.8,.2,1)`;
|
||||
el.style.transform = `scale(${scale})`;
|
||||
const t = setTimeout(() => {
|
||||
el.style.transform = '';
|
||||
setTimeout(() => { el.style.transition = prev; }, NUDGE_MS);
|
||||
}, NUDGE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* E18 — boot stagger: rows arrive in sequence, not all at once.
|
||||
*
|
||||
* The delay is CAPPED. Uncapped, a 40-row board would make the last row wait
|
||||
* 1.2s, and a stagger that makes content late has become the thing it exists to
|
||||
* disguise.
|
||||
*/
|
||||
const STAGGER_STEP_MS = 28;
|
||||
const STAGGER_CAP_MS = 240;
|
||||
function bootStagger(index, opts = {}) {
|
||||
if (prefersReducedMotion()) return { animationDelay: '0ms', opacity: 1 };
|
||||
const step = opts.step ?? STAGGER_STEP_MS;
|
||||
const cap = opts.cap ?? STAGGER_CAP_MS;
|
||||
return { animationDelay: `${Math.min(index * step, cap)}ms` };
|
||||
}
|
||||
|
||||
/**
|
||||
* E27 — row hover: the pointer-only affordance.
|
||||
*
|
||||
* Returned as a props object rather than CSS so a row cannot acquire a hover
|
||||
* state on touch, where there is no hover and the style would stick after a tap.
|
||||
*/
|
||||
function rowHover(opts = {}) {
|
||||
const tint = opts.tint || 'rgba(255,255,255,.028)';
|
||||
return {
|
||||
onMouseEnter: (e) => { if (e.currentTarget) e.currentTarget.style.background = tint; },
|
||||
onMouseLeave: (e) => { if (e.currentTarget) e.currentTarget.style.background = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* E28 — reveal on intersection.
|
||||
*
|
||||
* Returns an unobserve function; a caller that forgets it leaks an observer per
|
||||
* row. Without IntersectionObserver (or under reduced motion) the element is
|
||||
* shown IMMEDIATELY -- content is never hidden behind a capability check.
|
||||
*/
|
||||
function revealOnIntersect(el, onReveal, opts = {}) {
|
||||
if (!el) return () => {};
|
||||
if (prefersReducedMotion() || typeof IntersectionObserver === 'undefined') {
|
||||
if (typeof onReveal === 'function') onReveal(el);
|
||||
return () => {};
|
||||
}
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
if (typeof onReveal === 'function') onReveal(e.target);
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
}
|
||||
}, { rootMargin: opts.rootMargin || '0px 0px -10% 0px', threshold: opts.threshold ?? 0.05 });
|
||||
io.observe(el);
|
||||
return () => { try { io.disconnect(); } catch { /* already gone */ } };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nudge, bootStagger, rowHover, revealOnIntersect, prefersReducedMotion,
|
||||
NUDGE_MS, STAGGER_STEP_MS, STAGGER_CAP_MS,
|
||||
};
|
||||
@@ -16,6 +16,7 @@
|
||||
gating them would be a monetization regression. We gate only the genuinely
|
||||
personal surfaces (a user's own ledger, bets, account, alerts). */
|
||||
const GATED_ROUTES = [
|
||||
'/studio', // the content desk: private review before posting
|
||||
'/desk', // Session 63 (A1-S4) — the founder's media surface (+ backend allowlist)
|
||||
'/ledger',
|
||||
'/tracker',
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user