Content studio API + preview page; widen the reachability guard; correct
two inventory errors INVENTORY CORRECTION, and it was mine. Phase 2's two "orphans" are NOT orphans -- my board grepped only web/src/app and missed component-level mounting. The transitive check says both are already mounted: BookComparisonPanel -> GradeResultCard -> app/scan/page.tsx NewsWire -> ExploreHub -> app/explore/page.tsx So book comparison is DONE (wired to /api/books, rendering on the grade card) and THE WIRE is DONE-BY-DESIGN, mounted in ExploreHub. Its header names an "Offseason Hub" as its home, and that hub genuinely does not exist -- but that is board item #8, not a mounting bug, and inventing a surface to satisfy a comment would be the wrong fix. The lesson is the same one this session keeps teaching: I checked one directory and reported a conclusion the check could not support. ALSO CAUGHT: I overwrote src/routes/content.js, which was the Session-29 content-templates route, by picking a filename without looking. Restored from git with no work lost; the new surface lives at /api/content-studio and both now coexist. PHASE 0/1 — /api/content-studio serves finished posts (copy, branded card, card_svg, the fact_contract each was REQUIRED to have, and the facts that actually backed it) plus a POST for editorial status in Redis. Private via internal key; the Next proxy holds the key server-side so the browser never does. /studio renders it as a thin client -- copy and card side by side with the fact contract visible, because reviewing copy by reading it is exactly how a wrong number ships. Never-blank: a night with nothing generated says so. API-FIRST is the point: the endpoint an autonomous poster will call is the one the page already renders, so the agent handoff is a pointer change, not a rebuild. Contract documented at docs/CONTENT-STUDIO-API.md. EXPRESS 5 BROKE 23 SUITES at first: `router.get('/:date?')` throws at mount time in Express 5, taking down everything that imports app.js. Two explicit routes instead. PHASE 3 — the reachability guard is widened from grade-fields-only to a general built-but-unread check. Book comparison, THE WIRE and the content studio are now registered surfaces; a page counts as its own entry point (Next mounts it by convention) while everything else must trace to one. 22 checks green; a registered-but-unimported surface still goes red. FULLY ISOLATED: read-only on model/slate/ledger, serving fingerprint verified unchanged, accrual clock unchanged at 0 eligible dates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
@@ -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.
|
||||
@@ -212,6 +212,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 };
|
||||
@@ -69,6 +69,20 @@ const CONTRACT = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
const read = (rel) => {
|
||||
@@ -152,6 +166,12 @@ describe('every promised honest field reaches a rendered pixel', () => {
|
||||
);
|
||||
|
||||
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.
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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