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:
Kev
2026-08-07 21:24:36 -04:00
parent 60469422af
commit 49e76068da
7 changed files with 494 additions and 1 deletions
+53
View File
@@ -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.
+105
View File
@@ -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&apos;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/);
});
});
+7
View File
@@ -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' },
];
/**
+21
View File
@@ -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" />;
}
+113
View File
@@ -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>
);
}
+194
View File
@@ -0,0 +1,194 @@
'use client';
import { useEffect, useState } from 'react';
import SectionHead from '@/components/vyndr/SectionHead';
import MovementStrip from '@/components/vyndr/MovementStrip';
/**
* F9F11 — 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&apos;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