diff --git a/tests/unit/ds5PricingStates.test.js b/tests/unit/ds5PricingStates.test.js new file mode 100644 index 0000000..51ace72 --- /dev/null +++ b/tests/unit/ds5PricingStates.test.js @@ -0,0 +1,194 @@ +// DS5 (Design v2) — Pricing (Desk-as-hero) + Motion (ticker → punctuated +// stillness) + Empty/Error unification (the 404 bar) + archetype propagation. +// Source-grep locks (plain-JS Jest, no TS transform) — same pattern as +// vyndrParityQA / ds4Billboards. Every assertion targets a DS5 artifact that +// did NOT exist on the base commit, so the suite fails before and passes after. + +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'); +const readCss = () => fs.readFileSync(path.join(WEB, 'app', 'globals.css'), 'utf8'); + +// Extract a single TIERS object block by its id. +function tierBlock(src, id) { + const m = src.match(new RegExp(`id: '${id}'[\\s\\S]*?highlight: (true|false)`)); + return m ? m[1] : null; +} + +// ── 1. Desk $44.99 is the HERO tier (#8, Part 6) ────────────────────────── +describe('Pricing — Desk is the hero, real prices, single primary CTA', () => { + const pricing = read('components/Pricing.tsx'); + const showcase = read('app/pricing/DeskShowcase.tsx'); + const page = read('app/pricing/page.tsx'); + + test('Desk is highlighted (the hero); Analyst is NOT (no two competing green CTAs, #9)', () => { + expect(tierBlock(pricing, 'desk')).toBe('true'); + expect(tierBlock(pricing, 'analyst')).toBe('false'); + }); + + test('Desk CTA is at least as strong as Analyst — Desk drives the primary button', () => { + // highlight:true → btn-primary (the strong CTA); highlight:false → btn-ghost. + expect(pricing).toContain("className={tier.highlight ? 'btn-primary' : 'btn-ghost'}"); + // Desk is the highlighted tier, so it owns the primary; Analyst is secondary. + expect(tierBlock(pricing, 'desk')).toBe('true'); + }); + + test('real prices only — Desk $34.99 founder / $44.99 regular, Analyst $14.99/$19.99, Free 5 scans', () => { + expect(pricing).toMatch(/id: 'desk'[\s\S]*?price: '\$34\.99'/); + expect(pricing).toMatch(/id: 'desk'[\s\S]*?originalPrice: '\$44\.99'/); + expect(pricing).toMatch(/id: 'analyst'[\s\S]*?price: '\$14\.99'/); + expect(pricing).toMatch(/id: 'analyst'[\s\S]*?originalPrice: '\$19\.99'/); + expect(pricing).toContain('5 scans to try the model'); + // no fabricated legacy price + expect(pricing).not.toContain("originalPrice: '$24.99'"); + expect(pricing).not.toContain("originalPrice: '$49.99'"); + }); + + test('the "$1M terminal · $44.99" story leads, above the grid, with a real feature ladder', () => { + expect(page).toContain('import DeskShowcase'); + expect(page).toContain(' { + expect(require('../../web/src/lib/checkout').checkoutUrl('desk')).toBe('/api/checkout?tier=desk'); + expect(pricing).toContain("fetch('/api/checkout'"); + }); +}); + +// ── 2. Ticker → punctuated stillness (#7, Part 4) ───────────────────────── +describe('Ticker + header motion — mostly still, meaningful pulses', () => { + const ticker = read('components/vyndr/Ticker.tsx'); + const css = readCss(); + + test('motion is tokenized — no one-off durations (--ticker-hold + --motion-*)', () => { + expect(css).toMatch(/--ticker-hold:\s*\d+ms/); + expect(css).toContain('--motion-transition:'); + expect(css).toContain('--motion-data:'); + expect(css).toContain('--motion-idle:'); + }); + + test('the ticker RESTS ≥4s on each item (hold token + JS constant both ≥4000ms, in sync)', () => { + const holdMs = Number((css.match(/--ticker-hold:\s*(\d+)ms/) || [])[1]); + const jsMs = Number((ticker.match(/TICKER_HOLD_MS\s*=\s*(\d+)/) || [])[1]); + expect(holdMs).toBeGreaterThanOrEqual(4000); + expect(jsMs).toBeGreaterThanOrEqual(4000); + expect(jsMs).toBe(holdMs); + }); + + test('the continuous marquee is retired — the ticker no longer scrolls forever', () => { + // The resting strip is static; the old infinite .ticker-track marquee is gone + // from the component (constant motion reads cheap — Part 0 law #1). + expect(ticker).not.toContain('ticker-track'); + expect(ticker).toContain('ticker-rest'); + }); + + test('ONE animated element in the header zone — the ticker no longer renders a competing live-dot', () => { + // The single idle proof-of-life is the heartbeat live-dot; the ticker drops + // its own pulsing dot so the header is not ticker + heartbeat + counter. + expect(ticker).not.toMatch(/className="[^"]*\blive-dot\b/); + const live = read('components/vyndr/LiveLayer.tsx'); + expect((live.match(/live-dot/g) || []).length).toBe(1); + }); + + test('the EKG heartbeat is a STATIC readout (no idle scroll animation)', () => { + // .ekg-track keeps its class (vyndrSystems locks the string) but no longer + // carries an infinite scroll — proof-of-life is the one live-dot. + expect(/\.ekg-track\s*\{[^}]*animation/.test(css)).toBe(false); + expect(read('components/vyndr/LiveLayer.tsx')).toContain('ekg-track'); + }); + + test('prefers-reduced-motion kills the motion entirely', () => { + expect(ticker).toContain('prefers-reduced-motion'); + // new ticker motion classes are in the global reduced-motion kill list + expect(css).toMatch(/prefers-reduced-motion[\s\S]*?\.ticker-item-enter/); + expect(css).toMatch(/prefers-reduced-motion[\s\S]*?\.ticker-pulse/); + }); + + test('the polling contract is preserved (tickerLive lock stays green)', () => { + expect(ticker).toContain("fetch('/api/ticker'"); + expect(ticker).toContain('pollMs = 30_000'); + expect(ticker).toContain('feed && feed.length > 0 ? feed : items'); + }); +}); + +// ── 3. Unify empty/error to the 404 bar (#20, Part 8) ───────────────────── +describe('EmptyState — one designed empty/error system, modeled on the 404', () => { + const es = read('components/vyndr/EmptyState.tsx'); + + test('carries the 404 north-star grammar (scanlines + glitch wordmark + amber system voice)', () => { + expect(es).toContain('scanlines'); + expect(es).toContain('crt-sweep'); + expect(es).toContain('amber-glow'); + expect(es).toContain('Wordmark'); + }); + + test('CTA hierarchy — at most one primary (color contract #9)', () => { + expect(es).toContain('primary'); + expect(es).toContain("a.primary ? 'btn-primary' : 'btn-ghost'"); + }); + + test('reused at the "Team not found" offender — no bare-red line', () => { + const hub = read('app/team/[abbr]/TeamHub.tsx'); + expect(hub).toContain(' { + const game = read('app/game/[id]/page.tsx'); + expect(game).toContain(' { + const ledger = read('app/ledger/page.tsx'); + expect(ledger).toContain(''); + }); + + test('EmptyState is exported from the vyndr barrel (one component, everywhere)', () => { + expect(read('components/vyndr/index.ts')).toContain("export { default as EmptyState }"); + }); +}); + +// ── 4. Propagate archetype glyphs everywhere (Part 5) ───────────────────── +describe('Archetype glyph+chip — the ONE component, propagated', () => { + test('the grade reveal renders the archetype (glyph+chip via ArchetypeBlend)', () => { + const card = read('components/vyndr/GradeResultCard.tsx'); + expect(card).toContain('ArchetypeBlend'); + expect(card).toContain(' { + const panel = read('components/StreaksPanel.tsx'); + expect(panel).toContain("import ArchetypeBadge"); + expect(panel).toContain(' { + const ledger = read('app/ledger/page.tsx'); + expect(ledger).toContain('ArchetypeBadge'); + expect(ledger).toContain(' { + expect(read('components/StreaksPanel.tsx')).toContain('showDesc'); + expect(read('app/ledger/page.tsx')).toContain('showDesc'); + }); +}); diff --git a/tests/unit/teamHubUI.test.js b/tests/unit/teamHubUI.test.js index 2a4c169..ae333bb 100644 --- a/tests/unit/teamHubUI.test.js +++ b/tests/unit/teamHubUI.test.js @@ -29,13 +29,16 @@ describe('Team Hub page', () => { expect(src).toContain('opacity: noProps ? 0.6 : 1'); }); it('has back-to-slate navigation', () => { - expect(src).toContain('← Back to Slate'); - expect(src).toContain('href="/dashboard"'); + // DS5 (#20) — the error surface is the unified EmptyState; its action links + // back to the Slate. + expect(src).toContain('← Back to the Slate'); + expect(src).toContain("href: '/dashboard'"); }); - it('handles loading + error states', () => { + it('handles loading + error states via the unified EmptyState (no bare-red line)', () => { expect(src).toContain("'loading'"); expect(src).toContain("'error'"); - expect(src).toContain('Team not found'); + expect(src).toContain(' { expect(src).toContain('useParlay'); diff --git a/tests/unit/vyndrDesignSystem.test.js b/tests/unit/vyndrDesignSystem.test.js index ed1c47d..7a6f24c 100644 --- a/tests/unit/vyndrDesignSystem.test.js +++ b/tests/unit/vyndrDesignSystem.test.js @@ -182,9 +182,12 @@ describe('Phase B — shared component contracts (§5)', () => { expect(src).toContain(' { + it('Ticker RESTS on each item (DS5 punctuated stillness) — no infinite marquee', () => { + // DS5 (Part 4, #7) retired the continuous .ticker-track marquee: the ticker + // now holds each ranked item ≥4s and pulses only on change. See + // ds5PricingStates.test.js for the full motion lock. const src = comp('Ticker.tsx'); - expect(src).toContain('ticker-track'); - expect(src).toMatch(/\{content\}\s*\{content\}/); + expect(src).not.toContain('ticker-track'); + expect(src).toContain('ticker-rest'); }); }); diff --git a/web/src/app/game/[id]/page.tsx b/web/src/app/game/[id]/page.tsx index 9f7cbd9..28e66e9 100644 --- a/web/src/app/game/[id]/page.tsx +++ b/web/src/app/game/[id]/page.tsx @@ -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 ( -
-

Game not found.

-

- That matchup isn't on tonight's slate. Maybe the line moved off the board. -

- Back to slate -
+ ); } diff --git a/web/src/app/globals.css b/web/src/app/globals.css index db5a884..aa810e9 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -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 ). 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; } } diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index dae5137..579bc8d 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -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 }) {

{row.player_name}

+ {/* Part 5 — the archetype glyph+chip, propagated (self-hides when absent). */} + {row.archetype && ( +
+ +
+ )}

{row.side} {row.line} {row.stat.replace(/_/g, ' ')}

@@ -369,28 +378,24 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) { } function EmptyLedger({ tab }: { tab: Tab }) { - return ( -
-

LEDGER EMPTY

- {tab === 'mine' ? ( - <> -

No reads yet.

-

- 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. -

- - Read a Prop → - - - ) : ( - <> -

The model record starts with the next pipeline run.

-

- Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted. -

- - )} -
+ // DS5 (#20) — the ONE unified empty surface, inline variant. + return tab === 'mine' ? ( + + ) : ( + ); } diff --git a/web/src/app/pricing/DeskShowcase.tsx b/web/src/app/pricing/DeskShowcase.tsx new file mode 100644 index 0000000..8f8cb0b --- /dev/null +++ b/web/src/app/pricing/DeskShowcase.tsx @@ -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 ( +
+
{line}{base ? ' •' : ''}
+
{grade}
+
{edge}
+
+ ); +} + +export default function DeskShowcase() { + return ( +
+
+ {/* LEFT — the pitch */} +
+
+ THE DESK · FLAGSHIP +
+

+ A $1M terminal.{' '} + $44.99 + /mo. +

+

+ 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. +

+ +
+ + Claim a Founder Desk → + + + $34.99 locked for the first 100 · then $44.99 + +
+
+ + {/* RIGHT — real feature visuals (no dead half) */} +
+
+ + ALT LINE LADDER DESK + +
+ + + + +
+
+ +
+
+ QUARTER-KELLY +
2.4%
+
of bankroll · at -110
+
+
+ PARLAY φ +
0.34
+
correlation · same-team legs
+
+
+ +
+ + + REAL-TIME FEED · consensus vs model, live line moves + +
+
+
+
+ ); +} diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx index 8857976..2ee04f5 100644 --- a/web/src/app/pricing/page.tsx +++ b/web/src/app/pricing/page.tsx @@ -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 (
+ {/* DS5 (#8) — Desk is the hero. The "$1M terminal" story leads, above the + grid, so the premium tier stops being an afterthought. */} + {/* Founder-seat scarcity under the grid (§12) */}
diff --git a/web/src/app/team/[abbr]/TeamHub.tsx b/web/src/app/team/[abbr]/TeamHub.tsx index 9b247ac..16e7a1f 100644 --- a/web/src/app/team/[abbr]/TeamHub.tsx +++ b/web/src/app/team/[abbr]/TeamHub.tsx @@ -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

Loading team intelligence…

; } if (state === 'error' || !data) { + // DS5 (#20) — the unified empty/error surface, not a bare-red line. return ( -
-

Team not found.

- ← Back to Slate -
+ ); } diff --git a/web/src/components/Pricing.tsx b/web/src/components/Pricing.tsx index 1b807f2..6287e00 100644 --- a/web/src/components/Pricing.tsx +++ b/web/src/components/Pricing.tsx @@ -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, }, ]; diff --git a/web/src/components/StreaksPanel.tsx b/web/src/components/StreaksPanel.tsx index 46a41ad..6c34236 100644 --- a/web/src/components/StreaksPanel.tsx +++ b/web/src/components/StreaksPanel.tsx @@ -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
{s.player}{s.team ? · {s.team} : null}
+ {/* Part 5 — the archetype glyph+chip (the Rosetta stone), + propagated here; self-hides when the join has no archetype. */} + {s.archetype && ( +
+ +
+ )}
{s.description}
{builtVs}
diff --git a/web/src/components/vyndr/EmptyState.tsx b/web/src/components/vyndr/EmptyState.tsx new file mode 100644 index 0000000..a5a48bb --- /dev/null +++ b/web/src/components/vyndr/EmptyState.tsx @@ -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 ( +
+ {/* CRT sweep — fires once on mount; frozen under reduced motion. */} +
+ + {wordmark && ( +
+ +
+ )} + +
+ {code} +
+ +

+ {title} +

+ + {message && ( +

+ {message} +

+ )} + + {actions.length > 0 && ( +
+ {actions.map((a) => ( + + {a.label} + + ))} +
+ )} + + {children} +
+ ); +} diff --git a/web/src/components/vyndr/Ticker.tsx b/web/src/components/vyndr/Ticker.tsx index da12554..c9fbb6c 100644 --- a/web/src/components/vyndr/Ticker.tsx +++ b/web/src/components/vyndr/Ticker.tsx @@ -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 = { // 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 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 []); 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(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) => ( + {rank != null && ( + + {rank}/{len} + + )} {it.delta} )} - · - )); + ); + return (
-
- {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). */} +
+ TOP MOVES
- {/* Session 55 — anchored LIVE badge (chrome, not data → may pulse). */} - {live && ( + + {reduced ? ( + // Reduced motion — the full ranked list, static, no animation at all. +
+ {display.map((it, i) => renderItem(it, i))} +
+ ) : ( + // Resting rotator — ONE item held for ≥4s, change-only cross-fade + pulse.
- - LIVE + {renderItem(display[idx % len], idx, (idx % len) + 1)}
)} -
-
+ +
); } diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 57cb2bd..082db2b 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -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';