DS4 (design): billboards — STREAKS row, grade reveal, CLV reframe, /u profile

P0 billboards (the timeline is customer #1). Pixel-level craft: one bold hero
among muted context, real entities from DS0.

- STREAKS row: rebuilt from a log line into a Bloomberg alert. Streak LENGTH is
  now the mono/tabular HERO (38px, the largest figure in the row); real
  PlayerAvatar identity; muted "built vs [opponents]" lens; ONE severity accent
  (step-up amber / step-down green); grade badge tier-gated (the READ is paid).
- Grade reveal: edge is now sign-colored — negative edge uses var(--miss),
  never green (color contract #3). Fixed in BOTH the confidence strip and the
  MODEL/LINE/EDGE row; that row is now mono + tabular. Letter stays the hero.
- CLV reframe: new pure lib/clvDisplay.js (clvMode flat/spread/none). A near-
  flat distribution (73/74) now renders a confident VOICE line — "CLV flat — we
  grade the outcome, not the close" — instead of a broken-looking histogram;
  bars show only on real spread.
- /u profile: hit% is the bold record hero (56px mono tabular) + beat-close
  secondary; honest CLV-VERIFIED badge (only when closing value is tracked);
  real PlayerAvatar identity on cards; OG image elevated to carry the real
  record at 1200x630 social crop (graceful tagline fallback).

Tests: +23 (tests/unit/ds4Billboards.test.js). Full suite green (2732).
web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-12 19:25:57 -04:00
parent 24af247b29
commit 45bafbc01a
7 changed files with 452 additions and 74 deletions
+180
View File
@@ -0,0 +1,180 @@
// DS4 (Design v2) — the BILLBOARDS. Screenshot-first surfaces (the timeline is
// customer #1): the STREAKS row, the grade reveal, the CLV reframe, and the /u
// public profile. The .tsx surfaces are asserted against source (plain-JS Jest,
// no TS transform) — same pattern as publicProfilePage / vyndrParityQA. The
// CLV flat-vs-spread decision is a pure fn and is exercised directly.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const WEB = path.join(ROOT, 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
// ── 1. CLV reframe — the pure flat/spread decision (#16) ──────────────────
describe('clvDisplay — flat CLV says so, spread shows bars', () => {
const { clvMode, isFlatClv, CLV_FLAT_LINE } = require('../../web/src/lib/clvDisplay');
test('near-flat (73/74 at the close) is FLAT, not a broken histogram', () => {
const dist = [
{ label: '[-.5,0)', side: 'faded', count: 0 },
{ label: '0', side: 'flat', count: 73 },
{ label: '(0,.5]', side: 'beat', count: 1 },
];
expect(clvMode(dist).mode).toBe('flat');
expect(isFlatClv(dist)).toBe(true);
});
test('real dispersion is SPREAD (the bars are meaningful)', () => {
const dist = [
{ label: '[-1,-.5)', side: 'faded', count: 8 },
{ label: '[-.5,0)', side: 'faded', count: 6 },
{ label: '0', side: 'flat', count: 5 },
{ label: '(0,.5]', side: 'beat', count: 9 },
{ label: '(.5,1]', side: 'beat', count: 12 },
];
expect(clvMode(dist).mode).toBe('spread');
expect(isFlatClv(dist)).toBe(false);
});
test('null / empty / all-zero → none (renders nothing, never errored)', () => {
expect(clvMode(null).mode).toBe('none');
expect(clvMode([]).mode).toBe('none');
expect(clvMode([{ side: 'flat', count: 0 }, { side: 'beat', count: 0 }]).mode).toBe('none');
});
test('the flat line is confident + VOICE-compliant (no jargon, no hype)', () => {
expect(CLV_FLAT_LINE).toMatch(/grade the outcome, not the close/);
expect(CLV_FLAT_LINE).not.toMatch(/!/);
expect(CLV_FLAT_LINE.toLowerCase()).not.toMatch(/bayesian|regression|guarantee/);
});
test('the ledger MODEL tab renders the flat line, not a histogram, when flat', () => {
const src = read('app/ledger/page.tsx');
expect(src).toContain("import { clvMode, CLV_FLAT_LINE }");
expect(src).toMatch(/mode === 'flat'/);
expect(src).toContain('CLV_FLAT_LINE');
// bars still exist for the spread branch
expect(src).toContain('CLV DISTRIBUTION');
});
});
// ── 2. STREAKS row — the Bloomberg alert (#2) ─────────────────────────────
describe('StreaksPanel — one hero figure, real identity, one accent', () => {
const src = read('components/StreaksPanel.tsx');
test('uses the real PlayerAvatar entity (not a bare <img>)', () => {
expect(src).toContain('import PlayerAvatar');
expect(src).toContain('<PlayerAvatar');
expect(src).not.toMatch(/<img\b/);
});
test('the streak LENGTH is the hero — the largest font in the row', () => {
// heroNum renders currentStreak and is the biggest fontSize; the player
// name is demoted well below it.
expect(src).toMatch(/heroNum[\s\S]*fontSize:\s*38/);
expect(src).toContain('{s.currentStreak}');
const heroSize = Number((src.match(/heroNum:[^}]*fontSize:\s*(\d+)/) || [])[1]);
const nameSize = Number((src.match(/playerName:[^}]*fontSize:\s*(\d+)/) || [])[1]);
expect(heroSize).toBeGreaterThan(nameSize);
expect(heroSize).toBeGreaterThanOrEqual(32);
});
test('the hero number is mono + tabular (data grammar)', () => {
expect(src).toMatch(/heroNum:[\s\S]*fontVariantNumeric:\s*'tabular-nums'/);
});
test('carries the muted "built vs" lens context', () => {
expect(src).toContain('built vs');
expect(src).toContain('builtVs');
});
test('ONE severity accent — step-up amber / step-down green', () => {
expect(src).toContain("diff === 'step up'");
expect(src).toContain('var(--amber');
expect(src).toContain("diff === 'step down'");
expect(src).toContain('var(--g-a');
});
test('the grade badge is tier-gated (the READ is the paid layer)', () => {
expect(src).toMatch(/tier === 'analyst' \|\| tier === 'desk'/);
expect(src).toContain('<GradeBadge');
});
});
// ── 3. Grade reveal — sign-colored edge (#3, the color contract) ──────────
describe('GradeResultCard — the letter is hero, edge is sign-colored', () => {
const src = read('components/vyndr/GradeResultCard.tsx');
test('a negative edge NEVER renders green — negative = var(--miss)', () => {
expect(src).toMatch(/edgeColor\s*=\s*d\.edge != null && d\.edge < 0 \? 'var\(--miss\)' : 'var\(--g-a\)'/);
// both the confidence strip and the projection row use edgeColor, not a
// hardcoded green.
expect(src).toContain('color: edgeColor');
expect(src).toMatch(/d\.edge != null \? edgeColor : 'var\(--text-2\)'/);
});
test('the grade LETTER is still the hero (largest element)', () => {
expect(src).toMatch(/grade-hero[\s\S]*fontSize: compact \? 92 : 116/);
});
test('the MODEL/LINE/EDGE row is mono + tabular (demoted data)', () => {
expect(src).toMatch(/fontSize: 19[\s\S]*fontVariantNumeric: 'tabular-nums'/);
});
test('reveal choreography is reduced-motion safe', () => {
const proc = read('components/vyndr/ProcessingGrade.tsx');
expect(proc).toContain("prefers-reduced-motion: reduce");
expect(proc).toMatch(/setPhase\('card'\)/);
});
});
// ── 4. /u public profile — the shareable record billboard (#4) ────────────
describe('PublicProfile — the record hero + CLV-verified badge', () => {
const src = read('app/u/[handle]/PublicProfile.tsx');
test('the hit rate is the bold hero figure (large mono tabular)', () => {
expect(src).toMatch(/fontSize: 56[\s\S]*fontVariantNumeric: 'tabular-nums'/);
expect(src).toContain('{agg.hit_pct}%');
expect(src).toContain('HIT RATE');
});
test('beat-close is the secondary bold figure', () => {
expect(src).toContain('{agg.beat_close_pct}%');
expect(src).toContain('BEAT CLOSE');
});
test('CLV-VERIFIED badge is HONEST — only when closing value is tracked', () => {
expect(src).toContain('✓ CLV-VERIFIED');
expect(src).toMatch(/clvVerified = Boolean\(ready && agg && agg\.beat_close_pct != null\)/);
});
test('still honors the n-gate: RECORD BUILDING under min_sample', () => {
expect(src).toContain('RECORD BUILDING');
expect(src).toMatch(/settled >= minSample/);
});
test('renders real player identity (PlayerAvatar entity)', () => {
expect(src).toContain('import PlayerAvatar');
expect(src).toContain('<PlayerAvatar');
});
});
// ── 5. /u OG image — the record at social crop ────────────────────────────
describe('/u opengraph-image — real record at 1200x630 social crop', () => {
const src = read('app/u/[handle]/opengraph-image.tsx');
test('social crop size is preserved (1200x630)', () => {
expect(src).toMatch(/size = \{ width: 1200, height: 630 \}/);
});
test('renders the real hit% / beat-close% when the record is published', () => {
expect(src).toContain('{rec.hit_pct}%');
expect(src).toContain('{rec.beat_close_pct}%');
expect(src).toContain('CLV-VERIFIED RECORD');
});
test('falls back gracefully to the tagline when there is no record', () => {
expect(src).toContain('every settled read · misses included');
});
});
+29 -7
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard'; import { GradePill } from '@/components/GradeCard';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
/** /**
* The Ledger (Session 58, Phase 1) — the truth surface, now backed by the * The Ledger (Session 58, Phase 1) — the truth surface, now backed by the
@@ -191,15 +192,36 @@ function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: n
); );
} }
/** S6 (A1 board) — compact settled-CLV distribution strip. Bars are DATA /** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives
* (mono, tabular): green = beat-the-close side, red = faded side, dim = flat. * in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat
* Renders only when the server passed the n≥20 gate (clv_distribution set). */ * distribution renders as one giant bar + six empty stubs — it reads as
* BROKEN. When CLV is flat we SAY so in one confident VOICE line (the record
* is the best data on the site; it must look the most premium, never errored)
* and show the bars only when there is real spread. */
function ClvDistribution({ agg }: { agg: ModelAggregate }) { function ClvDistribution({ agg }: { agg: ModelAggregate }) {
const dist = agg.clv_distribution; const dist = agg.clv_distribution;
if (!Array.isArray(dist) || dist.length === 0) return null; const { mode } = clvMode(dist as Parameters<typeof clvMode>[0]);
const total = dist.reduce((n, b) => n + b.count, 0); if (mode === 'none') return null;
if (mode === 'flat') {
// Flat CLV is a FEATURE, not an error: the model grades outcomes, not the
// close. State it plainly — premium, confident, no broken histogram.
return (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 6 }}>
CLOSING-LINE VALUE
</div>
<p className="mono" style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', margin: 0, letterSpacing: '0.01em' }}>
{CLV_FLAT_LINE}
</p>
</div>
);
}
const distArr = dist as ClvBucket[];
const total = distArr.reduce((n, b) => n + b.count, 0);
if (total === 0) return null; if (total === 0) return null;
const max = Math.max(...dist.map((b) => b.count)); const max = Math.max(...distArr.map((b) => b.count));
const color = (side: ClvBucket['side']) => const color = (side: ClvBucket['side']) =>
side === 'beat' ? 'var(--g-a, #00D4A0)' : side === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)'; side === 'beat' ? 'var(--g-a, #00D4A0)' : side === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
return ( return (
@@ -208,7 +230,7 @@ function ClvDistribution({ agg }: { agg: ModelAggregate }) {
CLV DISTRIBUTION · {total} SETTLED CLV DISTRIBUTION · {total} SETTLED
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', maxWidth: 420 }}> <div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', maxWidth: 420 }}>
{dist.map((b) => ( {distArr.map((b) => (
<div key={b.label} title={`${b.label}: ${b.count}`} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 0 }}> <div key={b.label} title={`${b.label}: ${b.count}`} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 0 }}>
<span className="mono" style={{ fontSize: 10, fontVariantNumeric: 'tabular-nums', color: b.count > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}> <span className="mono" style={{ fontSize: 10, fontVariantNumeric: 'tabular-nums', color: b.count > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}>
{b.count} {b.count}
+50 -13
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard'; import { GradePill } from '@/components/GradeCard';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
/** /**
* PublicProfile (A1 Session 10) — the public ledger record for one handle. * PublicProfile (A1 Session 10) — the public ledger record for one handle.
@@ -144,30 +145,60 @@ export default function PublicProfile({ handle }: { handle: string }) {
); );
} }
/**
* The record HERO (DS4 · billboard — DESIGN-SPEC Part 3 + Part 6 #4). This is
* the shareable surface Kev pitches partners with. One bold figure among muted
* context: the HIT RATE as a large mono tabular number, beat-close as the
* secondary bold figure, the raw record demoted. CLV-VERIFIED badge only when
* closing-line value is actually tracked (honest — never a decorative badge).
*/
function RecordHeader({ agg, minSample }: { agg: ProfileAggregate | null; minSample: number }) { function RecordHeader({ agg, minSample }: { agg: ProfileAggregate | null; minSample: number }) {
const ready = Boolean(agg && agg.settled >= minSample && agg.hit_pct != null); const ready = Boolean(agg && agg.settled >= minSample && agg.hit_pct != null);
const clvVerified = Boolean(ready && agg && agg.beat_close_pct != null);
return ( return (
<div <div
className="surface diagonal-cut" className="surface diagonal-cut"
style={{ padding: 20, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }} style={{ padding: 24, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
> >
{ready && agg ? ( {ready && agg ? (
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'baseline' }}> <>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}> <span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
RECORD · LAST 30D RECORD · LAST 30D
</span> </span>
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--g-a, #00D4A0)' }}> {clvVerified && (
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT <span className="mono" style={{ fontSize: 10.5, fontWeight: 800, letterSpacing: '0.08em', color: 'var(--g-a, #00D4A0)', border: '1px solid var(--g-a, #00D4A0)', borderRadius: 999, padding: '3px 10px', background: 'color-mix(in srgb, var(--g-a, #00D4A0) 12%, transparent)' }}>
</span> CLV-VERIFIED
{agg.beat_close_pct != null && (
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>
{agg.beat_close_pct}% BEAT CLOSE
</span> </span>
)} )}
<span className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{agg.pending} pending
</span>
</div> </div>
{/* HERO figures — hit rate is the largest thing on the page. */}
<div style={{ display: 'flex', gap: 40, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<div className="mono" style={{ fontSize: 56, fontWeight: 800, lineHeight: 0.95, letterSpacing: '-0.03em', color: 'var(--g-a, #00D4A0)', fontVariantNumeric: 'tabular-nums' }}>
{agg.hit_pct}%
</div>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
HIT RATE · {agg.hits}-{agg.misses}
</div>
</div>
{agg.beat_close_pct != null && (
<div>
<div className="mono" style={{ fontSize: 40, fontWeight: 800, lineHeight: 0.95, letterSpacing: '-0.02em', color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
{agg.beat_close_pct}%
</div>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
BEAT CLOSE
</div>
</div>
)}
<div style={{ marginLeft: 'auto' }}>
<div className="mono" style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-tertiary)', fontVariantNumeric: 'tabular-nums' }}>
{agg.pending} pending
</div>
</div>
</div>
</>
) : ( ) : (
<div> <div>
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}> <p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
@@ -230,10 +261,16 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
<GradePill grade={row.grade} /> <GradePill grade={row.grade} />
</span> </span>
</div> </div>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3> {/* Real entity identity (DS0) — headshot / team-colored monogram. */}
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<PlayerAvatar name={row.player_name} sport={row.sport} size={34} />
<div style={{ minWidth: 0 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.player_name}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', margin: 0 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')} {row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p> </p>
</div>
</div>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}> <p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{row.model_value != null && <span> · MODEL {row.model_value}</span>} {row.model_value != null && <span> · MODEL {row.model_value}</span>}
+41 -5
View File
@@ -1,15 +1,36 @@
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
// A1 Session 10 — every shared /u/:handle link unfurls as a record card. // A1 Session 10 · elevated DS4 — every shared /u/:handle link unfurls as a
// Self-hosted standalone build → Node runtime (NOT edge; Session-53 rule — // record card at social crop (1200×630). The billboard now carries the REAL
// next/og breaks under edge off Vercel). // record (hit% + beat-close%) when the handle is published + past the n-gate,
// so the timeline (customer #1) sees the proof, not just a tagline. Self-hosted
// standalone build → Node runtime (NOT edge; Session-53 rule).
export const alt = 'VYNDR Public Ledger'; export const alt = 'VYNDR Public Ledger';
export const size = { width: 1200, height: 630 }; export const size = { width: 1200, height: 630 };
export const contentType = 'image/png'; export const contentType = 'image/png';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number } | null> {
try {
const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!r.ok) return null;
const d = await r.json();
const agg = d && d.aggregate;
if (!agg || agg.hit_pct == null) return null;
return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses };
} catch {
return null;
}
}
export default async function Image({ params }: { params: Promise<{ handle: string }> }) { export default async function Image({ params }: { params: Promise<{ handle: string }> }) {
const { handle } = await params; const { handle } = await params;
const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle'; const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle';
const rec = await fetchRecord(h);
return new ImageResponse( return new ImageResponse(
( (
<div <div
@@ -28,14 +49,29 @@ export default async function Image({ params }: { params: Promise<{ handle: stri
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} /> <div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} />
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}> <div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
PUBLIC LEDGER {rec ? 'CLV-VERIFIED RECORD · 30D' : 'PUBLIC LEDGER'}
</div> </div>
<div style={{ display: 'flex', fontSize: 84, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 18, color: '#FFFFFF' }}> <div style={{ display: 'flex', fontSize: 72, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 14, color: '#FFFFFF' }}>
@{h} @{h}
</div> </div>
{rec ? (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 56, marginTop: 26 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 128, fontWeight: 900, lineHeight: 1, color: '#00D4A0' }}>{rec.hit_pct}%</div>
<div style={{ display: 'flex', fontSize: 22, letterSpacing: '0.14em', color: '#7A7A8E', marginTop: 8 }}>HIT RATE · {rec.hits}-{rec.misses}</div>
</div>
{rec.beat_close_pct != null && (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', fontSize: 92, fontWeight: 900, lineHeight: 1, color: '#FFFFFF' }}>{rec.beat_close_pct}%</div>
<div style={{ display: 'flex', fontSize: 22, letterSpacing: '0.14em', color: '#7A7A8E', marginTop: 8 }}>BEAT CLOSE</div>
</div>
)}
</div>
) : (
<div style={{ display: 'flex', fontSize: 28, color: '#7A7A8E', marginTop: 20 }}> <div style={{ display: 'flex', fontSize: 28, color: '#7A7A8E', marginTop: 20 }}>
CLV-verified record · every settled read · misses included CLV-verified record · every settled read · misses included
</div> </div>
)}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', fontSize: 44, fontWeight: 900, letterSpacing: '0.08em' }}> <div style={{ display: 'flex', fontSize: 44, fontWeight: 900, letterSpacing: '0.08em' }}>
+86 -31
View File
@@ -2,15 +2,18 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import GradeBadge from '@/components/vyndr/GradeBadge';
import { type Tier } from '@/lib/tierGate'; import { type Tier } from '@/lib/tierGate';
/** /**
* StreaksPanel (Session 23). * StreaksPanel (Session 23 · rebuilt DS4 — the STREAKS BILLBOARD).
* *
* Surfaces computed player streaks for a sport, narrowed by the active * DESIGN-SPEC Part 7 (#2): the STREAKS row is a P0 billboard — the timeline
* stat filter. Everything through VYNDR's lens — "4-game 28+ scoring * is customer #1. Not a log line: a Bloomberg alert. ONE bold figure (the
* streak", not "31.2 PPG". Free users see the top 3 with an upgrade * streak LENGTH, mono + tabular, the largest thing in the row) among muted
* nudge; paid users see the full list. * context (real player identity + "built vs" lens), with a SINGLE severity
* accent — tonight's matchup difficulty (step-up amber / step-down green).
* The grade badge is tier-gated (the READ is the paid layer).
* *
* Self-hides when there are no streaks so the landing page never shows an * Self-hides when there are no streaks so the landing page never shows an
* empty box — the other layers (schedule, game lines, props) carry the * empty box — the other layers (schedule, game lines, props) carry the
@@ -61,39 +64,60 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
// Session 60 (product def) — the PICTURE is free: every streak shows for // Session 60 (product def) — the PICTURE is free: every streak shows for
// every tier. Only an explicit `limit` (landing teaser) caps the list. // every tier. Only an explicit `limit` (landing teaser) caps the list.
// The paid layer is the GRADE, not the data. tierGate still governs the
// hot LISTS (top-3 free) — not streaks.
void tier;
const visible = limit && limit > 0 ? streaks.slice(0, limit) : streaks; const visible = limit && limit > 0 ? streaks.slice(0, limit) : streaks;
const hidden = streaks.length - visible.length; const hidden = streaks.length - visible.length;
// DS4 — the GRADE (the READ) is the paid layer; only analyst/desk see it.
const canSeeGrade = tier === 'analyst' || tier === 'desk';
return ( return (
<section className="streaks-panel" style={{ margin: '16px 0' }}> <section className="streaks-panel" style={{ margin: '16px 0' }}>
<h3 style={panelHeading}>🔥 STREAKS</h3> <h3 style={panelHeading}>🔥 STREAKS</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{visible.map((s) => ( {visible.map((s) => {
const diff = s.lens?.difficulty;
// ONE severity accent — tonight's matchup difficulty.
const sev = diff === 'step up'
? { color: 'var(--amber, #FFB347)', label: '▲ STEP-UP SPOT TONIGHT' }
: diff === 'step down'
? { color: 'var(--g-a, #00D4A0)', label: '▼ SOFTER SPOT TONIGHT' }
: null;
// The LENS — muted "built vs" context; never the raw streak alone.
const builtVs = Array.isArray(s.lens?.builtVs) && s.lens!.builtVs!.length > 0
? `built vs ${s.lens!.builtVs!.slice(0, 3).join(', ')}`
: (s.lens?.read || s.description);
return (
<div key={`${s.player}-${s.type}`} style={rowStyle}> <div key={`${s.player}-${s.type}`} style={rowStyle}>
{/* DS0 — real headshot / team-colored monogram (kills the gray {/* Real identity — headshot / team-colored monogram (DS0). */}
silhouette). The STREAKS row is a P0 billboard. */} <PlayerAvatar name={s.player} sport={sport} playerId={s.playerId} team={s.team} size={46} />
<PlayerAvatar name={s.player} sport={sport} playerId={s.playerId} team={s.team} size={38} />
{/* THE HERO — streak length, mono + tabular, the largest figure
in the row. One bold number; everything else demoted. */}
<div style={heroBlock}>
<span className="mono" style={heroNum}>{s.currentStreak}</span>
<span className="mono" style={heroUnit}>GAME<br />STREAK</span>
</div>
{/* Identity + the LENS — demoted, muted context. */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}> <div style={playerName}>
{s.player}{s.team ? ` · ${s.team}` : ''} {s.player}{s.team ? <span style={teamTag}> · {s.team}</span> : null}
{/* Grade letter when the snapshot graded this player (the paid
READ layer; the letter itself is public on the slate). */}
{s.grade && <span className="mono" style={{ marginLeft: 8, fontSize: 10.5, fontWeight: 800, color: 'var(--g-a, #00D4A0)', border: '1px solid rgba(0,212,160,.35)', borderRadius: 4, padding: '1px 5px' }}>{s.grade}</span>}
</div> </div>
{/* THE LENS — the interpreted read, never the raw streak alone. */} <div className="mono" style={categoryLine}>{s.description}</div>
<div style={streakDesc}>{s.lens?.read || s.description}</div> <div style={lensLine}>{builtVs}</div>
{s.lens?.difficulty && (
<div className="mono" style={{ fontSize: 10.5, marginTop: 2, color: s.lens.difficulty === 'step up' ? 'var(--amber, #FFB347)' : s.lens.difficulty === 'step down' ? 'var(--g-a, #00D4A0)' : 'var(--text-tertiary, #6A6A78)' }}>
{s.lens.difficulty === 'step up' ? '▲ TOUGHER SPOT TONIGHT' : s.lens.difficulty === 'step down' ? '▼ SOFTER SPOT TONIGHT' : ''}
</div> </div>
{/* The single severity accent + tier-gated grade badge. */}
<div style={rightRail}>
{sev && <span className="mono" style={{ ...sevChip, color: sev.color, borderColor: sev.color }}>{sev.label}</span>}
{s.grade && (
canSeeGrade
? <GradeBadge grade={s.grade} size="md" glow />
: <a href="/pricing" className="mono" style={gradeLock} title="Unlock the grade">GRADE </a>
)} )}
</div> </div>
<span style={badgeStyle}>{s.currentStreak} G</span>
</div> </div>
))} );
})}
</div> </div>
{hidden > 0 && ( {hidden > 0 && (
<a href="/explore" style={upsellStyle}> <a href="/explore" style={upsellStyle}>
@@ -109,20 +133,51 @@ const panelHeading: React.CSSProperties = {
color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px', color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px',
}; };
const rowStyle: React.CSSProperties = { const rowStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 10, display: 'flex', alignItems: 'center', gap: 14,
padding: '8px 10px', borderRadius: 10, padding: '12px 14px', borderRadius: 12,
background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)', background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)',
}; };
// THE HERO NUMBER — deliberately the largest font in the row (mono, tabular).
const heroBlock: React.CSSProperties = {
flex: '0 0 auto', display: 'flex', alignItems: 'baseline', gap: 6,
minWidth: 62,
};
const heroNum: React.CSSProperties = {
fontSize: 38, fontWeight: 800, lineHeight: 1,
color: 'var(--text-primary, #F0F0F4)', fontVariantNumeric: 'tabular-nums',
letterSpacing: '-0.03em',
};
const heroUnit: React.CSSProperties = {
fontSize: 8.5, fontWeight: 700, lineHeight: 1.05, letterSpacing: '0.1em',
color: 'var(--text-tertiary, #6A6A78)',
};
const playerName: React.CSSProperties = { const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)', fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}; };
const streakDesc: React.CSSProperties = { const teamTag: React.CSSProperties = {
fontSize: 12, color: 'var(--text-secondary, #9A9AA8)', fontSize: 12, fontWeight: 500, color: 'var(--text-tertiary, #6A6A78)',
}; };
const badgeStyle: React.CSSProperties = { const categoryLine: React.CSSProperties = {
flex: '0 0 auto', fontSize: 12, fontWeight: 800, padding: '3px 8px', fontSize: 11, color: 'var(--text-secondary, #9A9AA8)', marginTop: 1,
borderRadius: 6, background: 'rgba(233,75,60,0.15)', color: 'var(--accent, #E94B3C)', };
const lensLine: React.CSSProperties = {
fontSize: 11, color: 'var(--text-tertiary, #6A6A78)', marginTop: 2,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const rightRail: React.CSSProperties = {
flex: '0 0 auto', display: 'flex', flexDirection: 'column',
alignItems: 'flex-end', gap: 6,
};
const sevChip: React.CSSProperties = {
fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em',
padding: '2px 7px', borderRadius: 5, border: '1px solid transparent',
background: 'transparent', whiteSpace: 'nowrap',
};
const gradeLock: React.CSSProperties = {
fontSize: 10.5, fontWeight: 800, letterSpacing: '0.06em',
color: 'var(--text-tertiary, #6A6A78)', textDecoration: 'none',
border: '1px solid var(--border, #2A2A36)', borderRadius: 4, padding: '2px 6px',
}; };
const upsellStyle: React.CSSProperties = { const upsellStyle: React.CSSProperties = {
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600, display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
+6 -3
View File
@@ -69,6 +69,9 @@ export default function GradeResultCard({
}, [replayKey]); }, [replayKey]);
const sideColor = d.side === 'Over' ? 'var(--g-a)' : 'var(--miss)'; const sideColor = d.side === 'Over' ? 'var(--g-a)' : 'var(--miss)';
// COLOR CONTRACT (Part 1 #3): edge/CLV colored by SIGN. A negative edge must
// NEVER render green — negative = var(--miss), positive/zero = signal-green.
const edgeColor = d.edge != null && d.edge < 0 ? 'var(--miss)' : 'var(--g-a)';
const hasKill = !!d.killConditions && d.killConditions.length > 0; const hasKill = !!d.killConditions && d.killConditions.length > 0;
const hasBooks = Array.isArray(d.books) && d.books.length > 0; const hasBooks = Array.isArray(d.books) && d.books.length > 0;
const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0; const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0;
@@ -150,7 +153,7 @@ export default function GradeResultCard({
{d.edge != null && ( {d.edge != null && (
<> <>
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span> <span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
<span style={{ color: 'var(--g-a)' }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span> <span style={{ color: edgeColor }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
</> </>
)} )}
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span> <span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
@@ -173,11 +176,11 @@ export default function GradeResultCard({
{[ {[
{ l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' }, { l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' },
{ l: 'LINE', v: d.line, col: 'var(--text-0)' }, { l: 'LINE', v: d.line, col: 'var(--text-0)' },
{ l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? 'var(--g-a)' : 'var(--text-2)' }, { l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? edgeColor : 'var(--text-2)' },
].map((x, i) => ( ].map((x, i) => (
<div key={i} style={{ padding: '14px 16px', textAlign: 'center', borderRight: i < 2 ? '1px solid var(--border)' : 'none' }}> <div key={i} style={{ padding: '14px 16px', textAlign: 'center', borderRight: i < 2 ? '1px solid var(--border)' : 'none' }}>
<div className="label" style={{ fontSize: 10, marginBottom: 5 }}>{x.l}</div> <div className="label" style={{ fontSize: 10, marginBottom: 5 }}>{x.l}</div>
<div className="mono" style={{ fontSize: 19, fontWeight: 700, color: x.col }}>{x.v}</div> <div className="mono" style={{ fontSize: 19, fontWeight: 700, color: x.col, fontVariantNumeric: 'tabular-nums' }}>{x.v}</div>
</div> </div>
))} ))}
</div> </div>
+45
View File
@@ -0,0 +1,45 @@
/**
* clvDisplay (DS4 · Billboards — CLV reframe, DESIGN-SPEC Part 3 #16).
*
* "Charts that are flat should SAY so." When settled CLV is near-flat (e.g.
* 73 of 74 reads landed at the close), a 7-bar histogram is one giant bar and
* six empty stubs — it reads as BROKEN. The record (55-19, 74% hit) is the
* best data on the site; it must look the MOST premium, never errored.
*
* This is the single pure decision: given the server's `clv_distribution`
* (already n≥20 gated in ledgerService), decide FLAT vs SPREAD. Flat → a
* confident VOICE line. Spread → the real bars. Pure + CommonJS so the .tsx
* consumers (ledger, profile) and Jest share one tested rule.
*/
// The flat bucket must hold this share of settled CLV for the histogram to be
// declared "flat." 73/74 → 0.986; a genuinely dispersed book → well under.
const FLAT_SHARE_THRESHOLD = 0.6;
// VOICE-compliant one-liner. No jargon; states the model's actual claim.
const CLV_FLAT_LINE = 'CLV flat — we grade the outcome, not the close.';
/**
* @param {Array<{label:string,count:number,side:'beat'|'faded'|'flat'}>|null} dist
* @returns {{ mode:'flat'|'spread'|'none', total:number, flat:number, flatShare:number }}
*/
function clvMode(dist) {
if (!Array.isArray(dist) || dist.length === 0) {
return { mode: 'none', total: 0, flat: 0, flatShare: 0 };
}
const total = dist.reduce((n, b) => n + (Number(b && b.count) || 0), 0);
if (total <= 0) return { mode: 'none', total: 0, flat: 0, flatShare: 0 };
const flat = dist
.filter((b) => b && b.side === 'flat')
.reduce((n, b) => n + (Number(b.count) || 0), 0);
const flatShare = flat / total;
const mode = flatShare >= FLAT_SHARE_THRESHOLD ? 'flat' : 'spread';
return { mode, total, flat, flatShare };
}
/** Convenience predicate for the flat branch. */
function isFlatClv(dist) {
return clvMode(dist).mode === 'flat';
}
module.exports = { clvMode, isFlatClv, CLV_FLAT_LINE, FLAT_SHARE_THRESHOLD };