Doctrine + E1 movement strip + F9-F11 offseason hub shell, to spec
PHASE 0 — specs/ARCHETYPE-TAXONOMY-DOCTRINE.md records the ruling as
shared law: 83 designed glyphs are the full four-sport taxonomy; a glyph
renders ONLY where its archetype is modeled and proven. 39 of 83 map to a
real archetype and are wired; the 44 unmapped are DORMANT SLOTS for
WNBA/NBA/Soccer, not a wiring gap. Wiring them would mean inventing 44
archetypes to consume artwork -- decoration presented as classification,
which is forbidden. DUAL THREAT and PAINT BOSS are modeled archetypes with
no mark: the mirror gap, flagged to the design side. When a sport's
archetypes ship, activation is a MANIFEST lookup, not new art.
PHASE 1 — E1 movement strip. The spec's own line is "the movement strip is
defined once here and reused everywhere a line has a past", so it is a
primitive, not a fourth chart.
RECONCILED RATHER THAN FORKED: lib/gradeShift.js ALREADY implements E1's
colour law -- toward/against/flat, including the direction flip that makes
an UNDER's favourable move the opposite sign of an OVER's. MovementStrip
CONSUMES buildGradeTimeline instead of reimplementing it, and a test
asserts it never redefines isUnder. GradeShift stays the grade-history
view; this is the reusable strip. That is the card-fork lesson applied
before it could happen again.
Spec laws honoured: STEPS NOT CURVES (H then V, no smoothing -- a curve
invents prices that never traded, and a test rejects any C/S/Q/T command);
green only when the move FAVOURS the read; FLAT renders as a hairline plus
FLAT · [N]D because a flat market is a finding; and too little history
says NO MOVEMENT HISTORY rather than rendering blank.
PHASE 2 — F9-F11 offseason hub shell, built from Vyndr Offseason.dc.html.
The spec's load-bearing words are used verbatim: "OUTLOOKS REPRICE ON NEWS
· NOT GAME ODDS" (an offseason number is not a game line), the QUIET WIRE
empty state ("No outlook-moving news since X. We don't manufacture
movement."), WHAT CHANGED TODAY as the hero with the countdown ambient and
top-right, the tag-colour-is-meaning row anatomy, the open -> NOW -> FAIR
triplet with the movement strip embedded, and the OUTLOOK ONLY block where
every row carries NOT GRADED.
THE DESIGN FILE'S SAMPLE DATA IS NOT IN THE COMPONENT. Wembanyama +420 ->
+330, Nabers cleared 11:42 AM, the Summer League names -- all of it is a
SPEC for what a live feed renders, and copying it in would be fabrication
carrying a designer's authority. A test asserts none of those strings
appear.
The IN-SEASON information architecture is NOT invented here. The spec
covers an offseason hub; nothing specifies how content, articles, wire and
the live slate share year-round navigation. That remains the open design
gap, and the route notes it.
Two test bugs caught and fixed: my first assertions matched my own doc
comments -- the ordering check found "WHAT CHANGED TODAY" in the header
block 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; both now strip comments first.
PHASE 3 — both surfaces registered under the reachability guard. Read-only
throughout, serving fingerprint 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,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,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/);
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,13 @@ const CONTRACT = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user