DS5 — Pricing (Desk-as-hero) + Motion + Empty/Error + archetype glyphs

Part 6 #8 — Desk $44.99 is the hero tier. New DeskShowcase leads the
pricing page with the "$1M terminal · $44.99" story + real feature
visuals (alt-line ladder, quarter-Kelly, parlay φ, real-time feed) in a
balanced two-column layout (kills the dead right-half). Desk is the sole
highlighted tier / single primary CTA (color contract #9); Analyst is
secondary. Real prices: Free 5 scans, Analyst $14.99/$19.99, Desk
$34.99/$44.99. ClaimMeter + Stripe checkout wiring untouched.

Part 4 #7 — Ticker → punctuated stillness. The continuous marquee is
retired; the ticker now RESTS ≥4s on each ranked item and pulses only on
change. The EKG heartbeat is a static readout — the header's ONE idle
proof-of-life is the single SIGNAL-LIVE live-dot (the ticker dropped its
competing pulse). All durations tokenized (--motion-*, --ticker-hold);
prefers-reduced-motion kills the motion entirely.

Part 8 #20 — new EmptyState component modeled on the north-star 404
(scanlines + glitch wordmark + amber system voice + CTA hierarchy),
reused at the bare-red "Team not found", "Game not found", and the
ledger empties — one unified voice.

Part 5 — archetype glyph+chip propagated to STREAKS rows + ledger rows
(optional + self-hiding; absent beats fabricated); grade reveal already
carries ArchetypeBlend.

Tests: new tests/unit/ds5PricingStates.test.js (22) locks all four
workstreams; updated teamHubUI + vyndrDesignSystem for the new surfaces.
237 suites / 2864 tests green; next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 00:31:35 -04:00
parent cf91c04e90
commit a18a3f33fc
14 changed files with 620 additions and 86 deletions
+8 -7
View File
@@ -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&apos;t on tonight&apos;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
View File
@@ -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
View File
@@ -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."
/>
);
}
+96
View File
@@ -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&rsquo;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>
);
}
+4
View File
@@ -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' }}>
+8 -4
View File
@@ -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 }]}
/>
);
}