Merge DS5 (design): pricing Desk-as-hero, ticker stillness, empty/error unify, archetype glyphs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import GradeCard, { GradePill } from '@/components/GradeCard';
|
||||
import EmptyState from '@/components/vyndr/EmptyState';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
@@ -100,14 +101,14 @@ export default function GamePage({ params }: { params: Promise<{ id: string }> }
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// DS5 (#20) — unified empty/error surface (was a bare heading + link).
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '64px 16px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Game not found.</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>
|
||||
That matchup isn't on tonight's slate. Maybe the line moved off the board.
|
||||
</p>
|
||||
<a href="/dashboard" className="btn-primary">Back to slate</a>
|
||||
</section>
|
||||
<EmptyState
|
||||
code="GAME NOT FOUND"
|
||||
title="That matchup isn't on the board."
|
||||
message="It isn't on tonight's slate — maybe the line moved off the board. The Slate has what's live now."
|
||||
actions={[{ label: '← Back to the Slate', href: '/dashboard', primary: true }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+45
-2
@@ -94,6 +94,16 @@
|
||||
--scan-op: 0.04;
|
||||
--grade-hero: var(--g-a);
|
||||
|
||||
/* Motion tokens (DS5 · DESIGN-SPEC Part 4 — ALIVE IS PUNCTUATED STILLNESS).
|
||||
Every duration in the living layer resolves from one of these — no one-off
|
||||
ms literals. IDLE = the single proof-of-life pulse; TRANSITION is sub-200ms
|
||||
(perceived instantaneity); DATA is the change-only green pulse; TICKER-HOLD
|
||||
is how long the ticker RESTS on each item so it can be read (≥4s). */
|
||||
--motion-idle: 1.4s;
|
||||
--motion-transition: 180ms;
|
||||
--motion-data: 300ms;
|
||||
--ticker-hold: 4200ms;
|
||||
|
||||
/* Type — Inter for chrome/UI, JetBrains Mono for ALL data.
|
||||
Session 41: --font-* come from next/font (self-hosted, set on <html>).
|
||||
Literal family names kept as fallbacks for any non-next/font context. */
|
||||
@@ -1078,7 +1088,11 @@ body.tex-grain::before {
|
||||
}
|
||||
.grade-reveal { animation: grade-reveal 0.42s cubic-bezier(.2,1.3,.4,1) both; }
|
||||
|
||||
/* ===================== TICKER ===================== */
|
||||
/* ===================== TICKER (DS5 — punctuated stillness) =====================
|
||||
The ticker RESTS. It no longer marquee-scrolls (constant motion reads cheap,
|
||||
Part 0 law #1). It holds each item for --ticker-hold (≥4s) so it can be READ,
|
||||
and the ONLY motion is a change-only cross-fade + a brief data-update pulse
|
||||
when the item advances. The legacy .ticker-track marquee is retired. */
|
||||
@keyframes ticker-scroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
@@ -1088,6 +1102,23 @@ body.tex-grain::before {
|
||||
white-space: nowrap;
|
||||
animation: ticker-scroll 38s linear infinite;
|
||||
}
|
||||
/* The resting strip — a static ranked row, no idle animation. */
|
||||
.ticker-rest {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Change-only cross-fade as the held item advances (sub-200ms, weighted). */
|
||||
@keyframes ticker-swap { from { opacity: 0.55; } to { opacity: 1; } }
|
||||
.ticker-item-enter { animation: ticker-swap var(--motion-transition) ease-out both; }
|
||||
/* Change-only green pulse — the terminal reacting to a fresh head event. */
|
||||
@keyframes ticker-pulse {
|
||||
0% { background: color-mix(in srgb, var(--g-a) 22%, transparent); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
.ticker-pulse { animation: ticker-pulse var(--motion-data) ease-out 1; }
|
||||
|
||||
/* ===================== LIVE PULSE ===================== */
|
||||
.live-dot {
|
||||
@@ -1104,6 +1135,14 @@ body.tex-grain::before {
|
||||
100% { box-shadow: 0 0 0 0 rgba(255,59,59,0); }
|
||||
}
|
||||
|
||||
/* ===================== DESK SHOWCASE (DS5 pricing hero) ===================== */
|
||||
/* Balanced two-column at desktop (pitch | real feature visuals), single column
|
||||
on mobile — kills the dead right-half of the old pricing page (#8). */
|
||||
.desk-showcase { grid-template-columns: 1fr; }
|
||||
@media (min-width: 900px) {
|
||||
.desk-showcase { grid-template-columns: 1.05fr 1fr; }
|
||||
}
|
||||
|
||||
/* ===================== LINE FLASH (odds cell changes) ===================== */
|
||||
@keyframes flash-up { 0% { background: rgba(0,212,160,0.45); } 100% { background: transparent; } }
|
||||
@keyframes flash-down { 0% { background: rgba(255,82,82,0.45); } 100% { background: transparent; } }
|
||||
@@ -1124,7 +1163,10 @@ body.tex-grain::before {
|
||||
/* ===================== LIVING LAYER (the brain) ===================== */
|
||||
/* EKG heartbeat strip */
|
||||
@keyframes ekg-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
|
||||
.ekg-track { display: inline-flex; animation: ekg-scroll 6s linear infinite; }
|
||||
/* DS5 — the EKG is now a STATIC readout trace (no idle scroll). The header's
|
||||
ONE idle proof-of-life is the single SIGNAL-LIVE live-dot; the heartbeat
|
||||
waveform is a frozen instrument face, not constant motion (Part 4). */
|
||||
.ekg-track { display: inline-flex; }
|
||||
|
||||
/* Neural node pulse */
|
||||
@keyframes node-pulse {
|
||||
@@ -1189,6 +1231,7 @@ body.tex-grain::before {
|
||||
/* Reduced-motion: kill the living layer + glitch chrome outright */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wm, .wm::before, .wm::after, .ticker-track, .live-dot,
|
||||
.ticker-item-enter, .ticker-pulse,
|
||||
.intel-surface::after, .phosphor-cursor,
|
||||
.ekg-track, .brain-node, .brain-link, .proc-scan, .data-blink { animation: none !important; }
|
||||
}
|
||||
|
||||
+28
-23
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Skeleton } from '@/components/vyndr';
|
||||
import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr';
|
||||
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
|
||||
|
||||
/**
|
||||
@@ -44,6 +44,9 @@ interface LedgerRow {
|
||||
outcome?: 'hit' | 'miss' | 'push' | null;
|
||||
actual_value?: number | null;
|
||||
revised_from_grade?: string | null;
|
||||
// DS5 (Part 5) — the player's locked archetype, when the pipeline supplies it.
|
||||
// Optional + self-hiding: absent beats fabricated.
|
||||
archetype?: string | null;
|
||||
}
|
||||
|
||||
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
|
||||
@@ -352,6 +355,12 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
|
||||
{/* Part 5 — the archetype glyph+chip, propagated (self-hides when absent). */}
|
||||
{row.archetype && (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<ArchetypeBadge archetype={row.archetype} size="sm" showDesc />
|
||||
</div>
|
||||
)}
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
|
||||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
@@ -369,28 +378,24 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
|
||||
}
|
||||
|
||||
function EmptyLedger({ tab }: { tab: Tab }) {
|
||||
return (
|
||||
<div className="surface diagonal-cut tex-scan" style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
|
||||
{tab === 'mine' ? (
|
||||
<>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No reads yet.</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
|
||||
Read your first prop to start building your Ledger. Every grade you run lands here the moment it completes — and settles against the real result.
|
||||
</p>
|
||||
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
|
||||
Read a Prop →
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>The model record starts with the next pipeline run.</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
|
||||
Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
// DS5 (#20) — the ONE unified empty surface, inline variant.
|
||||
return tab === 'mine' ? (
|
||||
<EmptyState
|
||||
inline
|
||||
wordmark={false}
|
||||
code="LEDGER EMPTY"
|
||||
title="No reads yet."
|
||||
message="Read your first prop to start building your Ledger. Every grade lands here the moment it completes — and settles against the real result."
|
||||
actions={[{ label: 'Read a Prop →', href: '/scan', primary: true }]}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
inline
|
||||
wordmark={false}
|
||||
code="MODEL RECORD BUILDING"
|
||||
title="The record starts with the next pipeline run."
|
||||
message="Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
|
||||
/**
|
||||
* DeskShowcase (DS5 · DESIGN-SPEC Part 6, #8) — the Desk-as-hero story that
|
||||
* sits ABOVE the pricing grid. Its job is the founder's one line: make $44.99
|
||||
* feel impossibly low for a professional terminal ("how is this only $44.99"
|
||||
* IS the conversion event). Balanced two-column layout — the pitch on the left,
|
||||
* REAL feature visuals on the right (kills the dead right-half, audit #8). The
|
||||
* single primary CTA scrolls to the grid where the real Stripe checkout lives
|
||||
* (checkout wiring untouched).
|
||||
*
|
||||
* Server component — no interactivity here; the CTA is an in-page anchor and the
|
||||
* feature visuals are static, tokenized, mono-for-data mock readouts.
|
||||
*/
|
||||
|
||||
// A real alt-line-ladder rung (the Desk exclusive) — line + locked grade + edge.
|
||||
function Rung({ line, grade, edge, base }: { line: string; grade: string; edge: string; base?: boolean }) {
|
||||
const gradeCol = grade.startsWith('A') ? 'var(--g-a)' : grade.startsWith('B') ? 'var(--text-0)' : 'var(--amber)';
|
||||
const edgeCol = edge.startsWith('+') ? 'var(--g-a)' : edge.startsWith('-') ? 'var(--miss)' : 'var(--text-2)';
|
||||
return (
|
||||
<div style={{ flex: '1 1 0', minWidth: 58, textAlign: 'center', padding: '9px 6px', background: 'var(--bg-2)', border: `1px solid ${base ? 'var(--border-hi)' : 'var(--border)'}`, borderRadius: 6 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: base ? 'var(--text-0)' : 'var(--text-1)', marginBottom: 5 }}>{line}{base ? ' •' : ''}</div>
|
||||
<div className="mono" style={{ fontSize: 17, fontWeight: 800, color: gradeCol }}>{grade}</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: edgeCol, marginTop: 3 }}>{edge}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeskShowcase() {
|
||||
return (
|
||||
<section style={{ padding: '72px 24px 8px', borderTop: '1px solid var(--border)' }}>
|
||||
<div className="desk-showcase" style={{ maxWidth: 1100, margin: '0 auto', display: 'grid', gap: 40, alignItems: 'center' }}>
|
||||
{/* LEFT — the pitch */}
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 11, letterSpacing: '0.16em', color: 'var(--g-a)', marginBottom: 14 }}>
|
||||
THE DESK · FLAGSHIP
|
||||
</div>
|
||||
<h2 className="text-balance" style={{ fontSize: 'clamp(30px, 4.4vw, 50px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.02, margin: 0 }}>
|
||||
A $1M terminal.{' '}
|
||||
<span className="mono" style={{ color: 'var(--g-a)' }}>$44.99</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>/mo.</span>
|
||||
</h2>
|
||||
<p style={{ fontSize: 17, color: 'var(--text-secondary)', margin: '18px 0 0', maxWidth: 460, lineHeight: 1.55 }}>
|
||||
Every alt line graded, quarter-Kelly sizing on your bankroll, parlay
|
||||
correlation, and the model’s real-time feed. The desk a trading
|
||||
floor pays five figures for — priced for one bettor.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 28, flexWrap: 'wrap' }}>
|
||||
<a href="#pricing" className="btn-primary" style={{ padding: '13px 24px', fontSize: 15, textDecoration: 'none' }}>
|
||||
Claim a Founder Desk →
|
||||
</a>
|
||||
<span className="mono" style={{ fontSize: 12.5, color: 'var(--text-tertiary)' }}>
|
||||
<span style={{ color: 'var(--g-a)', fontWeight: 700 }}>$34.99</span> locked for the first 100 · then $44.99
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — real feature visuals (no dead half) */}
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border-hi)', borderRadius: 12, padding: 18 }}>
|
||||
<SectionHead style={{ marginBottom: 12 }}>
|
||||
ALT LINE LADDER <span style={{ color: 'var(--amber)', fontSize: 9, border: '1px solid rgba(255,179,71,.4)', borderRadius: 3, padding: '1px 5px', marginLeft: 4 }}>DESK</span>
|
||||
</SectionHead>
|
||||
<div style={{ display: 'flex', gap: 7 }}>
|
||||
<Rung line="1.5" grade="A" edge="+7.1%" />
|
||||
<Rung line="2.5" grade="A+" edge="+11.4%" base />
|
||||
<Rung line="3.5" grade="B" edge="+2.0%" />
|
||||
<Rung line="4.5" grade="C" edge="-3.8%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 14, gridTemplateColumns: '1fr 1fr' }}>
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 16 }}>
|
||||
<SectionHead style={{ marginBottom: 10 }}>QUARTER-KELLY</SectionHead>
|
||||
<div className="mono" style={{ fontSize: 26, fontWeight: 800, color: 'var(--g-a)', fontVariantNumeric: 'tabular-nums' }}>2.4%</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>of bankroll · at -110</div>
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 16 }}>
|
||||
<SectionHead style={{ marginBottom: 10 }}>PARLAY φ</SectionHead>
|
||||
<div className="mono" style={{ fontSize: 26, fontWeight: 800, color: 'var(--text-0)', fontVariantNumeric: 'tabular-nums' }}>0.34</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>correlation · same-team legs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span className="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden />
|
||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
|
||||
REAL-TIME FEED · <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>consensus vs model</span>, live line moves
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Pricing from '@/components/Pricing';
|
||||
import { ClaimMeter } from '@/components/vyndr';
|
||||
import DeskShowcase from './DeskShowcase';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pricing — VYNDR',
|
||||
@@ -35,6 +36,9 @@ export const metadata: Metadata = {
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh' }}>
|
||||
{/* DS5 (#8) — Desk is the hero. The "$1M terminal" story leads, above the
|
||||
grid, so the premium tier stops being an afterthought. */}
|
||||
<DeskShowcase />
|
||||
<Pricing />
|
||||
{/* Founder-seat scarcity under the grid (§12) */}
|
||||
<div style={{ padding: '8px 16px 48px' }}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import TeamLogo from '@/components/vyndr/TeamLogo';
|
||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||
import EmptyState from '@/components/vyndr/EmptyState';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||
|
||||
@@ -69,11 +70,14 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }
|
||||
return <section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><p className="mono" style={{ color: 'var(--text-2)' }}>Loading team intelligence…</p></section>;
|
||||
}
|
||||
if (state === 'error' || !data) {
|
||||
// DS5 (#20) — the unified empty/error surface, not a bare-red line.
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '40px 16px' }}>
|
||||
<p className="mono" style={{ color: 'var(--miss)' }}>Team not found.</p>
|
||||
<a href="/dashboard" className="mono" style={{ color: 'var(--g-a)', fontSize: 13 }}>← Back to Slate</a>
|
||||
</section>
|
||||
<EmptyState
|
||||
code="TEAM NOT FOUND"
|
||||
title="No hub for that team."
|
||||
message="That abbreviation isn't on the board right now. Check tonight's slate for the teams in play."
|
||||
actions={[{ label: '← Back to the Slate', href: '/dashboard', primary: true }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const TIERS: TierConfig[] = [
|
||||
headline: 'Try the model. No card required.',
|
||||
cta: 'Start Free',
|
||||
features: [
|
||||
'3 reads per day',
|
||||
'5 scans to try the model',
|
||||
'Grade letter + projection',
|
||||
'Cross-book line comparison',
|
||||
'Confidence indicator',
|
||||
@@ -69,7 +69,7 @@ const TIERS: TierConfig[] = [
|
||||
id: 'analyst',
|
||||
name: 'Analyst',
|
||||
price: '$14.99',
|
||||
originalPrice: '$24.99',
|
||||
originalPrice: '$19.99',
|
||||
cadence: '/mo',
|
||||
badge: 'Founder Access',
|
||||
headline: 'The full intelligence layer.',
|
||||
@@ -86,16 +86,20 @@ const TIERS: TierConfig[] = [
|
||||
'Alt line ladder (Desk only)',
|
||||
'Kelly sizing (Desk only)',
|
||||
],
|
||||
highlight: true,
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
// DS5 (Part 6, #8) — Desk is THE hero tier. It carries the "$1M terminal"
|
||||
// story and the single primary CTA on the grid (color contract #9: never
|
||||
// two competing green CTAs). $44.99 regular, $34.99 for founders.
|
||||
id: 'desk',
|
||||
name: 'Desk',
|
||||
price: '$44.99',
|
||||
originalPrice: '$49.99',
|
||||
price: '$34.99',
|
||||
originalPrice: '$44.99',
|
||||
cadence: '/mo',
|
||||
headline: 'Everything. The professional setup.',
|
||||
cta: 'Go Desk',
|
||||
badge: 'Founder Desk',
|
||||
headline: 'The professional terminal. Everything the model knows.',
|
||||
cta: 'Claim a Founder Desk',
|
||||
features: [
|
||||
'Everything in Analyst',
|
||||
'Alt line ladder + edge ranking',
|
||||
@@ -105,7 +109,7 @@ const TIERS: TierConfig[] = [
|
||||
'Consensus vs model comparison',
|
||||
],
|
||||
locked: [],
|
||||
highlight: false,
|
||||
highlight: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import { type Tier } from '@/lib/tierGate';
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,9 @@ interface Streak {
|
||||
// Session 60 (night2/C) — THE LENS + optional snapshot grade letter.
|
||||
lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null };
|
||||
grade?: string | null;
|
||||
// DS5 (Part 5) — the player's locked archetype from the snapshot join, when
|
||||
// present. Optional + self-hiding: absent beats fabricated.
|
||||
archetype?: string | null;
|
||||
}
|
||||
|
||||
export interface StreaksPanelProps {
|
||||
@@ -102,6 +106,13 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
|
||||
<div style={playerName}>
|
||||
{s.player}{s.team ? <span style={teamTag}> · {s.team}</span> : null}
|
||||
</div>
|
||||
{/* Part 5 — the archetype glyph+chip (the Rosetta stone),
|
||||
propagated here; self-hides when the join has no archetype. */}
|
||||
{s.archetype && (
|
||||
<div style={{ margin: '3px 0' }}>
|
||||
<ArchetypeBadge archetype={s.archetype} sport={sport} size="sm" showDesc />
|
||||
</div>
|
||||
)}
|
||||
<div className="mono" style={categoryLine}>{s.description}</div>
|
||||
<div style={lensLine}>{builtVs}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Wordmark from './Wordmark';
|
||||
|
||||
export interface EmptyStateAction {
|
||||
label: string;
|
||||
href: string;
|
||||
/** Primary = the one green CTA (color contract #9: never two competing). */
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** The system-voice code line, e.g. "TEAM NOT FOUND" (amber, mono, glitch-free data-adjacent chrome). */
|
||||
code: string;
|
||||
/** Human headline. */
|
||||
title: string;
|
||||
/** One muted mono line — say less, never invent (VOICE). */
|
||||
message?: string;
|
||||
/** CTA hierarchy — at most one `primary`. */
|
||||
actions?: EmptyStateAction[];
|
||||
/** Show the glitch wordmark above the code (default true — the 404 north-star look). */
|
||||
wordmark?: boolean;
|
||||
/** Compact inline variant (no full-viewport min-height) — for in-page empties like the ledger. */
|
||||
inline?: boolean;
|
||||
/** Extra content below the actions (legends, secondary copy). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyState (DS5 · DESIGN-SPEC Part 8, #20) — ONE designed empty/error system,
|
||||
* modeled on the north-star 404 (app/not-found.tsx): scanlines + optional glitch
|
||||
* wordmark + a mono amber system-voice code line + correct CTA hierarchy. It
|
||||
* REPLACES the scattered bare-red "Team not found", the naked "Game not found",
|
||||
* and the flat offseason-empty voices — one on-brand surface everywhere.
|
||||
*
|
||||
* Amber (not red) is the system-voice accent (matches the 404): an empty board
|
||||
* is a state, not an error to alarm about. Data never glitches — only the
|
||||
* wordmark chrome carries the glitch (Part 4). prefers-reduced-motion freezes
|
||||
* the crt-sweep via the global CSS rule.
|
||||
*/
|
||||
export default function EmptyState({
|
||||
code,
|
||||
title,
|
||||
message,
|
||||
actions = [],
|
||||
wordmark = true,
|
||||
inline = false,
|
||||
children,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<section
|
||||
className="scanlines"
|
||||
style={{
|
||||
minHeight: inline ? undefined : 'calc(100vh - 240px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: inline ? '40px 24px' : '56px 24px',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
border: inline ? '1px solid var(--border)' : undefined,
|
||||
borderRadius: inline ? 12 : undefined,
|
||||
background: inline ? 'var(--bg-1)' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* CRT sweep — fires once on mount; frozen under reduced motion. */}
|
||||
<div className="crt-sweep" />
|
||||
|
||||
{wordmark && (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<Wordmark size={inline ? 'md' : 'lg'} cursor />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="mono amber-glow"
|
||||
style={{ fontSize: inline ? 11 : 12.5, letterSpacing: '0.28em', color: 'var(--amber)', marginBottom: 16 }}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
|
||||
<h2 style={{ fontSize: inline ? 20 : 26, fontWeight: 800, letterSpacing: '-0.02em', margin: 0, color: 'var(--text-0)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{message && (
|
||||
<p className="mono" style={{ fontSize: 13.5, color: 'var(--text-1)', marginTop: 12, maxWidth: 460, lineHeight: 1.55 }}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actions.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 24, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{actions.map((a) => (
|
||||
<a
|
||||
key={a.href + a.label}
|
||||
href={a.href}
|
||||
className={a.primary ? 'btn-primary' : 'btn-ghost'}
|
||||
style={{ padding: '11px 20px', textDecoration: 'none', fontSize: 13.5 }}
|
||||
>
|
||||
{a.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type TickerItem = {
|
||||
tag: string;
|
||||
@@ -31,20 +31,41 @@ const TAG_COLORS: Record<string, string> = {
|
||||
// looping the same item reads as fake; hide the bar instead.
|
||||
const MIN_ITEMS = 4;
|
||||
|
||||
// DS5 (DESIGN-SPEC Part 4) — the ticker RESTS this long on each item so it can
|
||||
// be READ. Mirrors the --ticker-hold token in globals.css (kept in sync; a unit
|
||||
// test cross-checks both are ≥4000ms). No one-off duration literals elsewhere.
|
||||
const TICKER_HOLD_MS = 4200;
|
||||
|
||||
/**
|
||||
* Scrolling marquee (§5). Continuous `ticker-scroll`; content duplicated so the
|
||||
* loop is seamless. Session 45 — polls /api/ticker for live snapshot exhaust
|
||||
* (top grades, line moves, slate-scanned events).
|
||||
* The ranked "top moves" strip (§5) — DS5 rebuild to PUNCTUATED STILLNESS.
|
||||
*
|
||||
* Session 57 (Phase 0) — real items only: the hardcoded fallback feed is gone
|
||||
* (callers pass []), and with fewer than MIN_ITEMS real items the bar renders
|
||||
* nothing. Visibility is published as `--ticker-h` on <html> so the fixed
|
||||
* header stack (layout main padding, Slate sticky top) collapses with it.
|
||||
* The old continuous marquee (ticker-scroll, motion at all times) is retired:
|
||||
* constant motion reads cheap (Part 0 law #1). The ticker now RESTS on one
|
||||
* ranked item for --ticker-hold (≥4s), advancing with a sub-200ms cross-fade
|
||||
* and a change-only green pulse — the terminal reacting, then still. It is the
|
||||
* only motion in the ticker; the header's single idle proof-of-life is the
|
||||
* heartbeat's one live-dot (this bar no longer renders a competing pulse).
|
||||
*
|
||||
* Session 45 — still polls /api/ticker for live snapshot exhaust. Session 57 —
|
||||
* real items only (callers pass []); <MIN_ITEMS → renders nothing. Visibility
|
||||
* is published as `--ticker-h` on <html> so the fixed header stack collapses
|
||||
* with it. prefers-reduced-motion → a static ranked list, zero motion.
|
||||
*/
|
||||
export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
|
||||
const [feed, setFeed] = useState<TickerItem[] | null>(null);
|
||||
// Session 55 — flash the LIVE dot when a fresh event slides in (breaking-news feel).
|
||||
const [flash, setFlash] = useState(false);
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
// Respect prefers-reduced-motion: no rotation, no pulse — a static strip.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const apply = () => setReduced(mq.matches);
|
||||
apply();
|
||||
mq.addEventListener?.('change', apply);
|
||||
return () => mq.removeEventListener?.('change', apply);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!live) return;
|
||||
@@ -58,8 +79,11 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
|
||||
if (active && Array.isArray(data.items) && data.items.length > 0) {
|
||||
const head = `${data.items[0]?.tag}|${data.items[0]?.text}`;
|
||||
if (lastHead && head !== lastHead) {
|
||||
setFlash(true);
|
||||
setTimeout(() => { if (active) setFlash(false); }, 2500);
|
||||
// A fresh head event — reset to the top and pulse ONCE (change-only,
|
||||
// the DATA-UPDATE motion category, never idle).
|
||||
setIdx(0);
|
||||
setPulse(true);
|
||||
setTimeout(() => { if (active) setPulse(false); }, 600);
|
||||
}
|
||||
lastHead = head;
|
||||
setFeed(data.items.map((it) => ({ ...it, color: it.color || TAG_COLORS[it.tag] || 'var(--amber)' })));
|
||||
@@ -84,14 +108,35 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
|
||||
return () => { document.documentElement.style.setProperty('--ticker-h', '0px'); };
|
||||
}, [visible, height]);
|
||||
|
||||
// The rotation: HOLD each item for TICKER_HOLD_MS, then advance. Disabled
|
||||
// entirely under reduced motion or with a single item — punctuated stillness,
|
||||
// not a metronome.
|
||||
const len = display.length;
|
||||
useEffect(() => {
|
||||
if (reduced || len <= 1) return;
|
||||
const id = setInterval(() => {
|
||||
setIdx((i) => (i + 1) % len);
|
||||
setPulse(true);
|
||||
setTimeout(() => setPulse(false), 600);
|
||||
}, TICKER_HOLD_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [reduced, len]);
|
||||
|
||||
const heldRef = useRef(idx);
|
||||
heldRef.current = idx;
|
||||
if (!visible) return null;
|
||||
|
||||
const content = display.map((it, i) => (
|
||||
const renderItem = (it: TickerItem, key: number, rank?: number) => (
|
||||
<span
|
||||
key={i}
|
||||
key={key}
|
||||
className="mono"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '0 26px', fontSize: 12, letterSpacing: '0.04em', color: 'var(--text-1)' }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '0 22px', fontSize: 12, letterSpacing: '0.04em', color: 'var(--text-1)' }}
|
||||
>
|
||||
{rank != null && (
|
||||
<span className="mono" style={{ color: 'var(--text-2)', fontWeight: 700, fontSize: 10, minWidth: 30 }}>
|
||||
{rank}/{len}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
color: it.color || 'var(--amber)',
|
||||
@@ -105,37 +150,46 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
|
||||
{it.delta && (
|
||||
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>{it.delta}</span>
|
||||
)}
|
||||
<span style={{ color: 'var(--text-2)' }}>·</span>
|
||||
</span>
|
||||
));
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="scanlines"
|
||||
style={{ height, overflow: 'hidden', background: 'var(--bg-1)', borderTop: '1px solid var(--border)', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', position: 'relative' }}
|
||||
>
|
||||
<div className="ticker-track">
|
||||
{content}
|
||||
{content}
|
||||
{/* Static "TOP MOVES" anchor — a label, not a pulsing dot. The single
|
||||
idle proof-of-life lives in the heartbeat below (one live-dot). */}
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 6, padding: '0 14px 0 12px',
|
||||
alignSelf: 'stretch', background: 'var(--bg-1)', borderRight: '1px solid var(--border)',
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)',
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
TOP MOVES
|
||||
</div>
|
||||
{/* Session 55 — anchored LIVE badge (chrome, not data → may pulse). */}
|
||||
{live && (
|
||||
|
||||
{reduced ? (
|
||||
// Reduced motion — the full ranked list, static, no animation at all.
|
||||
<div className="ticker-rest" style={{ flex: 1 }}>
|
||||
{display.map((it, i) => renderItem(it, i))}
|
||||
</div>
|
||||
) : (
|
||||
// Resting rotator — ONE item held for ≥4s, change-only cross-fade + pulse.
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 3,
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '0 14px 0 12px',
|
||||
background: 'var(--bg-1)', borderRight: '1px solid var(--border)',
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em',
|
||||
color: flash ? 'var(--g-ap, #00ffb8)' : 'var(--g-a, #00D4A0)',
|
||||
transition: 'color 0.3s ease',
|
||||
}}
|
||||
key={idx}
|
||||
className={`ticker-rest ticker-item-enter${pulse ? ' ticker-pulse' : ''}`}
|
||||
style={{ flex: 1 }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'currentColor', display: 'inline-block', boxShadow: flash ? '0 0 8px currentColor' : 'none' }} />
|
||||
LIVE
|
||||
{renderItem(display[idx % len], idx, (idx % len) + 1)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'absolute', left: live ? 66 : 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(90deg, var(--bg-1), transparent)', zIndex: 2 }} />
|
||||
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(270deg, var(--bg-1), transparent)', zIndex: 2 }} />
|
||||
|
||||
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 48, background: 'linear-gradient(270deg, var(--bg-1), transparent)', zIndex: 2 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export { default as VBtn } from './VBtn';
|
||||
export { default as Card } from './Card';
|
||||
export { default as Sparkline } from './Sparkline';
|
||||
export { default as Ticker } from './Ticker';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||
export { default as GradeResultCard } from './GradeResultCard';
|
||||
export type { GradeResultData } from './GradeResultCard';
|
||||
export { default as ProcessingGrade } from './ProcessingGrade';
|
||||
|
||||
Reference in New Issue
Block a user