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:
@@ -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('<DeskShowcase');
|
||||||
|
expect(showcase).toContain('$1M terminal');
|
||||||
|
expect(showcase).toContain('$44.99'); // the conversion hook figure
|
||||||
|
expect(showcase).toContain('$34.99'); // the founder price
|
||||||
|
// real feature visuals fill the right half (kills the dead half, #8)
|
||||||
|
expect(showcase).toContain('ALT LINE LADDER');
|
||||||
|
expect(showcase).toContain('QUARTER-KELLY');
|
||||||
|
// the primary CTA scrolls to the grid where the real checkout lives
|
||||||
|
expect(showcase).toContain('href="#pricing"');
|
||||||
|
expect(showcase).toContain('btn-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stripe checkout wiring is untouched (checkoutUrl + POST /api/checkout)', () => {
|
||||||
|
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('<EmptyState');
|
||||||
|
expect(hub).toContain('code="TEAM NOT FOUND"');
|
||||||
|
// the bare-red string is gone
|
||||||
|
expect(hub).not.toContain('Team not found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reused at the "Game not found" offender', () => {
|
||||||
|
const game = read('app/game/[id]/page.tsx');
|
||||||
|
expect(game).toContain('<EmptyState');
|
||||||
|
expect(game).toContain('code="GAME NOT FOUND"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reused for the ledger empty states (one voice, not a bespoke box)', () => {
|
||||||
|
const ledger = read('app/ledger/page.tsx');
|
||||||
|
expect(ledger).toContain('<EmptyState');
|
||||||
|
// the old bespoke "surface diagonal-cut tex-scan" empty box is replaced
|
||||||
|
expect(ledger).not.toContain('LEDGER EMPTY</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<ArchetypeBlend');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STREAKS rows render the archetype badge (was plain text) — self-hiding', () => {
|
||||||
|
const panel = read('components/StreaksPanel.tsx');
|
||||||
|
expect(panel).toContain("import ArchetypeBadge");
|
||||||
|
expect(panel).toContain('<ArchetypeBadge');
|
||||||
|
expect(panel).toContain('s.archetype &&'); // optional + self-hiding
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the ledger rows render the archetype badge (was plain text) — self-hiding', () => {
|
||||||
|
const ledger = read('app/ledger/page.tsx');
|
||||||
|
expect(ledger).toContain('ArchetypeBadge');
|
||||||
|
expect(ledger).toContain('<ArchetypeBadge');
|
||||||
|
expect(ledger).toContain('row.archetype &&');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the badge shows its one-line meaning where it leads (showDesc)', () => {
|
||||||
|
expect(read('components/StreaksPanel.tsx')).toContain('showDesc');
|
||||||
|
expect(read('app/ledger/page.tsx')).toContain('showDesc');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,13 +29,16 @@ describe('Team Hub page', () => {
|
|||||||
expect(src).toContain('opacity: noProps ? 0.6 : 1');
|
expect(src).toContain('opacity: noProps ? 0.6 : 1');
|
||||||
});
|
});
|
||||||
it('has back-to-slate navigation', () => {
|
it('has back-to-slate navigation', () => {
|
||||||
expect(src).toContain('← Back to Slate');
|
// DS5 (#20) — the error surface is the unified EmptyState; its action links
|
||||||
expect(src).toContain('href="/dashboard"');
|
// 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("'loading'");
|
||||||
expect(src).toContain("'error'");
|
expect(src).toContain("'error'");
|
||||||
expect(src).toContain('Team not found');
|
expect(src).toContain('<EmptyState');
|
||||||
|
expect(src).toContain('code="TEAM NOT FOUND"');
|
||||||
});
|
});
|
||||||
it('wires the parlay "+" on graded props', () => {
|
it('wires the parlay "+" on graded props', () => {
|
||||||
expect(src).toContain('useParlay');
|
expect(src).toContain('useParlay');
|
||||||
|
|||||||
@@ -182,9 +182,12 @@ describe('Phase B — shared component contracts (§5)', () => {
|
|||||||
expect(src).toContain('<path');
|
expect(src).toContain('<path');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Ticker uses the ticker-track marquee and duplicates content for a seamless loop', () => {
|
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');
|
const src = comp('Ticker.tsx');
|
||||||
expect(src).toContain('ticker-track');
|
expect(src).not.toContain('ticker-track');
|
||||||
expect(src).toMatch(/\{content\}\s*\{content\}/);
|
expect(src).toContain('ticker-rest');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useParlay } from '@/contexts/ParlayContext';
|
import { useParlay } from '@/contexts/ParlayContext';
|
||||||
import GradeCard, { GradePill } from '@/components/GradeCard';
|
import GradeCard, { GradePill } from '@/components/GradeCard';
|
||||||
|
import EmptyState from '@/components/vyndr/EmptyState';
|
||||||
|
|
||||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||||
|
|
||||||
@@ -100,14 +101,14 @@ export default function GamePage({ params }: { params: Promise<{ id: string }> }
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
// DS5 (#20) — unified empty/error surface (was a bare heading + link).
|
||||||
return (
|
return (
|
||||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '64px 16px', textAlign: 'center' }}>
|
<EmptyState
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Game not found.</h1>
|
code="GAME NOT FOUND"
|
||||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>
|
title="That matchup isn't on the board."
|
||||||
That matchup isn't on tonight's slate. Maybe the line moved off the board.
|
message="It isn't on tonight's slate — maybe the line moved off the board. The Slate has what's live now."
|
||||||
</p>
|
actions={[{ label: '← Back to the Slate', href: '/dashboard', primary: true }]}
|
||||||
<a href="/dashboard" className="btn-primary">Back to slate</a>
|
/>
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-2
@@ -94,6 +94,16 @@
|
|||||||
--scan-op: 0.04;
|
--scan-op: 0.04;
|
||||||
--grade-hero: var(--g-a);
|
--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.
|
/* Type — Inter for chrome/UI, JetBrains Mono for ALL data.
|
||||||
Session 41: --font-* come from next/font (self-hosted, set on <html>).
|
Session 41: --font-* come from next/font (self-hosted, set on <html>).
|
||||||
Literal family names kept as fallbacks for any non-next/font context. */
|
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; }
|
.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 {
|
@keyframes ticker-scroll {
|
||||||
0% { transform: translateX(0); }
|
0% { transform: translateX(0); }
|
||||||
100% { transform: translateX(-50%); }
|
100% { transform: translateX(-50%); }
|
||||||
@@ -1088,6 +1102,23 @@ body.tex-grain::before {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
animation: ticker-scroll 38s linear infinite;
|
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 PULSE ===================== */
|
||||||
.live-dot {
|
.live-dot {
|
||||||
@@ -1104,6 +1135,14 @@ body.tex-grain::before {
|
|||||||
100% { box-shadow: 0 0 0 0 rgba(255,59,59,0); }
|
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) ===================== */
|
/* ===================== LINE FLASH (odds cell changes) ===================== */
|
||||||
@keyframes flash-up { 0% { background: rgba(0,212,160,0.45); } 100% { background: transparent; } }
|
@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; } }
|
@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) ===================== */
|
/* ===================== LIVING LAYER (the brain) ===================== */
|
||||||
/* EKG heartbeat strip */
|
/* EKG heartbeat strip */
|
||||||
@keyframes ekg-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
|
@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 */
|
/* Neural node pulse */
|
||||||
@keyframes node-pulse {
|
@keyframes node-pulse {
|
||||||
@@ -1189,6 +1231,7 @@ body.tex-grain::before {
|
|||||||
/* Reduced-motion: kill the living layer + glitch chrome outright */
|
/* Reduced-motion: kill the living layer + glitch chrome outright */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.wm, .wm::before, .wm::after, .ticker-track, .live-dot,
|
.wm, .wm::before, .wm::after, .ticker-track, .live-dot,
|
||||||
|
.ticker-item-enter, .ticker-pulse,
|
||||||
.intel-surface::after, .phosphor-cursor,
|
.intel-surface::after, .phosphor-cursor,
|
||||||
.ekg-track, .brain-node, .brain-link, .proc-scan, .data-blink { animation: none !important; }
|
.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 { 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 { Skeleton } from '@/components/vyndr';
|
import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr';
|
||||||
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
|
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +44,9 @@ interface LedgerRow {
|
|||||||
outcome?: 'hit' | 'miss' | 'push' | null;
|
outcome?: 'hit' | 'miss' | 'push' | null;
|
||||||
actual_value?: number | null;
|
actual_value?: number | null;
|
||||||
revised_from_grade?: string | 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 }
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
|
<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 }}>
|
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
|
||||||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||||||
</p>
|
</p>
|
||||||
@@ -369,28 +378,24 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function EmptyLedger({ tab }: { tab: Tab }) {
|
function EmptyLedger({ tab }: { tab: Tab }) {
|
||||||
return (
|
// DS5 (#20) — the ONE unified empty surface, inline variant.
|
||||||
<div className="surface diagonal-cut tex-scan" style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}>
|
return tab === 'mine' ? (
|
||||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
|
<EmptyState
|
||||||
{tab === 'mine' ? (
|
inline
|
||||||
<>
|
wordmark={false}
|
||||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No reads yet.</h3>
|
code="LEDGER EMPTY"
|
||||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
|
title="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.
|
message="Read your first prop to start building your Ledger. Every grade lands here the moment it completes — and settles against the real result."
|
||||||
</p>
|
actions={[{ label: 'Read a Prop →', href: '/scan', primary: true }]}
|
||||||
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
|
/>
|
||||||
Read a Prop →
|
) : (
|
||||||
</a>
|
<EmptyState
|
||||||
</>
|
inline
|
||||||
) : (
|
wordmark={false}
|
||||||
<>
|
code="MODEL RECORD BUILDING"
|
||||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>The model record starts with the next pipeline run.</h3>
|
title="The record starts with the next pipeline run."
|
||||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
|
message="Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted."
|
||||||
Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted.
|
/>
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { Metadata } from 'next';
|
||||||
import Pricing from '@/components/Pricing';
|
import Pricing from '@/components/Pricing';
|
||||||
import { ClaimMeter } from '@/components/vyndr';
|
import { ClaimMeter } from '@/components/vyndr';
|
||||||
|
import DeskShowcase from './DeskShowcase';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Pricing — VYNDR',
|
title: 'Pricing — VYNDR',
|
||||||
@@ -35,6 +36,9 @@ export const metadata: Metadata = {
|
|||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
return (
|
return (
|
||||||
<main style={{ minHeight: '100vh' }}>
|
<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 />
|
<Pricing />
|
||||||
{/* Founder-seat scarcity under the grid (§12) */}
|
{/* Founder-seat scarcity under the grid (§12) */}
|
||||||
<div style={{ padding: '8px 16px 48px' }}>
|
<div style={{ padding: '8px 16px 48px' }}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import GradeBadge from '@/components/vyndr/GradeBadge';
|
|||||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
import TeamLogo from '@/components/vyndr/TeamLogo';
|
import TeamLogo from '@/components/vyndr/TeamLogo';
|
||||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||||
|
import EmptyState from '@/components/vyndr/EmptyState';
|
||||||
import { playerHref } from '@/lib/playerHref';
|
import { playerHref } from '@/lib/playerHref';
|
||||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
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>;
|
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) {
|
if (state === 'error' || !data) {
|
||||||
|
// DS5 (#20) — the unified empty/error surface, not a bare-red line.
|
||||||
return (
|
return (
|
||||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '40px 16px' }}>
|
<EmptyState
|
||||||
<p className="mono" style={{ color: 'var(--miss)' }}>Team not found.</p>
|
code="TEAM NOT FOUND"
|
||||||
<a href="/dashboard" className="mono" style={{ color: 'var(--g-a)', fontSize: 13 }}>← Back to Slate</a>
|
title="No hub for that team."
|
||||||
</section>
|
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.',
|
headline: 'Try the model. No card required.',
|
||||||
cta: 'Start Free',
|
cta: 'Start Free',
|
||||||
features: [
|
features: [
|
||||||
'3 reads per day',
|
'5 scans to try the model',
|
||||||
'Grade letter + projection',
|
'Grade letter + projection',
|
||||||
'Cross-book line comparison',
|
'Cross-book line comparison',
|
||||||
'Confidence indicator',
|
'Confidence indicator',
|
||||||
@@ -69,7 +69,7 @@ const TIERS: TierConfig[] = [
|
|||||||
id: 'analyst',
|
id: 'analyst',
|
||||||
name: 'Analyst',
|
name: 'Analyst',
|
||||||
price: '$14.99',
|
price: '$14.99',
|
||||||
originalPrice: '$24.99',
|
originalPrice: '$19.99',
|
||||||
cadence: '/mo',
|
cadence: '/mo',
|
||||||
badge: 'Founder Access',
|
badge: 'Founder Access',
|
||||||
headline: 'The full intelligence layer.',
|
headline: 'The full intelligence layer.',
|
||||||
@@ -86,16 +86,20 @@ const TIERS: TierConfig[] = [
|
|||||||
'Alt line ladder (Desk only)',
|
'Alt line ladder (Desk only)',
|
||||||
'Kelly sizing (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',
|
id: 'desk',
|
||||||
name: 'Desk',
|
name: 'Desk',
|
||||||
price: '$44.99',
|
price: '$34.99',
|
||||||
originalPrice: '$49.99',
|
originalPrice: '$44.99',
|
||||||
cadence: '/mo',
|
cadence: '/mo',
|
||||||
headline: 'Everything. The professional setup.',
|
badge: 'Founder Desk',
|
||||||
cta: 'Go Desk',
|
headline: 'The professional terminal. Everything the model knows.',
|
||||||
|
cta: 'Claim a Founder Desk',
|
||||||
features: [
|
features: [
|
||||||
'Everything in Analyst',
|
'Everything in Analyst',
|
||||||
'Alt line ladder + edge ranking',
|
'Alt line ladder + edge ranking',
|
||||||
@@ -105,7 +109,7 @@ const TIERS: TierConfig[] = [
|
|||||||
'Consensus vs model comparison',
|
'Consensus vs model comparison',
|
||||||
],
|
],
|
||||||
locked: [],
|
locked: [],
|
||||||
highlight: false,
|
highlight: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
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 GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
import { type Tier } from '@/lib/tierGate';
|
import { type Tier } from '@/lib/tierGate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +32,9 @@ interface Streak {
|
|||||||
// Session 60 (night2/C) — THE LENS + optional snapshot grade letter.
|
// Session 60 (night2/C) — THE LENS + optional snapshot grade letter.
|
||||||
lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null };
|
lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null };
|
||||||
grade?: 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 {
|
export interface StreaksPanelProps {
|
||||||
@@ -102,6 +106,13 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
|
|||||||
<div style={playerName}>
|
<div style={playerName}>
|
||||||
{s.player}{s.team ? <span style={teamTag}> · {s.team}</span> : null}
|
{s.player}{s.team ? <span style={teamTag}> · {s.team}</span> : null}
|
||||||
</div>
|
</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 className="mono" style={categoryLine}>{s.description}</div>
|
||||||
<div style={lensLine}>{builtVs}</div>
|
<div style={lensLine}>{builtVs}</div>
|
||||||
</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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
type TickerItem = {
|
type TickerItem = {
|
||||||
tag: string;
|
tag: string;
|
||||||
@@ -31,20 +31,41 @@ const TAG_COLORS: Record<string, string> = {
|
|||||||
// looping the same item reads as fake; hide the bar instead.
|
// looping the same item reads as fake; hide the bar instead.
|
||||||
const MIN_ITEMS = 4;
|
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
|
* The ranked "top moves" strip (§5) — DS5 rebuild to PUNCTUATED STILLNESS.
|
||||||
* loop is seamless. Session 45 — polls /api/ticker for live snapshot exhaust
|
|
||||||
* (top grades, line moves, slate-scanned events).
|
|
||||||
*
|
*
|
||||||
* Session 57 (Phase 0) — real items only: the hardcoded fallback feed is gone
|
* The old continuous marquee (ticker-scroll, motion at all times) is retired:
|
||||||
* (callers pass []), and with fewer than MIN_ITEMS real items the bar renders
|
* constant motion reads cheap (Part 0 law #1). The ticker now RESTS on one
|
||||||
* nothing. Visibility is published as `--ticker-h` on <html> so the fixed
|
* ranked item for --ticker-hold (≥4s), advancing with a sub-200ms cross-fade
|
||||||
* header stack (layout main padding, Slate sticky top) collapses with it.
|
* 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) {
|
export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
|
||||||
const [feed, setFeed] = useState<TickerItem[] | null>(null);
|
const [feed, setFeed] = useState<TickerItem[] | null>(null);
|
||||||
// Session 55 — flash the LIVE dot when a fresh event slides in (breaking-news feel).
|
const [idx, setIdx] = useState(0);
|
||||||
const [flash, setFlash] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!live) return;
|
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) {
|
if (active && Array.isArray(data.items) && data.items.length > 0) {
|
||||||
const head = `${data.items[0]?.tag}|${data.items[0]?.text}`;
|
const head = `${data.items[0]?.tag}|${data.items[0]?.text}`;
|
||||||
if (lastHead && head !== lastHead) {
|
if (lastHead && head !== lastHead) {
|
||||||
setFlash(true);
|
// A fresh head event — reset to the top and pulse ONCE (change-only,
|
||||||
setTimeout(() => { if (active) setFlash(false); }, 2500);
|
// the DATA-UPDATE motion category, never idle).
|
||||||
|
setIdx(0);
|
||||||
|
setPulse(true);
|
||||||
|
setTimeout(() => { if (active) setPulse(false); }, 600);
|
||||||
}
|
}
|
||||||
lastHead = head;
|
lastHead = head;
|
||||||
setFeed(data.items.map((it) => ({ ...it, color: it.color || TAG_COLORS[it.tag] || 'var(--amber)' })));
|
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'); };
|
return () => { document.documentElement.style.setProperty('--ticker-h', '0px'); };
|
||||||
}, [visible, height]);
|
}, [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;
|
if (!visible) return null;
|
||||||
|
|
||||||
const content = display.map((it, i) => (
|
const renderItem = (it: TickerItem, key: number, rank?: number) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={key}
|
||||||
className="mono"
|
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
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: it.color || 'var(--amber)',
|
color: it.color || 'var(--amber)',
|
||||||
@@ -105,37 +150,46 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
|
|||||||
{it.delta && (
|
{it.delta && (
|
||||||
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>{it.delta}</span>
|
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>{it.delta}</span>
|
||||||
)}
|
)}
|
||||||
<span style={{ color: 'var(--text-2)' }}>·</span>
|
|
||||||
</span>
|
</span>
|
||||||
));
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="scanlines"
|
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' }}
|
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">
|
{/* Static "TOP MOVES" anchor — a label, not a pulsing dot. The single
|
||||||
{content}
|
idle proof-of-life lives in the heartbeat below (one live-dot). */}
|
||||||
{content}
|
<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>
|
</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
|
<div
|
||||||
className="mono"
|
key={idx}
|
||||||
style={{
|
className={`ticker-rest ticker-item-enter${pulse ? ' ticker-pulse' : ''}`}
|
||||||
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 3,
|
style={{ flex: 1 }}
|
||||||
display: 'flex', alignItems: 'center', gap: 6, padding: '0 14px 0 12px',
|
aria-live="polite"
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<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' }} />
|
{renderItem(display[idx % len], idx, (idx % len) + 1)}
|
||||||
LIVE
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export { default as VBtn } from './VBtn';
|
|||||||
export { default as Card } from './Card';
|
export { default as Card } from './Card';
|
||||||
export { default as Sparkline } from './Sparkline';
|
export { default as Sparkline } from './Sparkline';
|
||||||
export { default as Ticker } from './Ticker';
|
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 { default as GradeResultCard } from './GradeResultCard';
|
||||||
export type { GradeResultData } from './GradeResultCard';
|
export type { GradeResultData } from './GradeResultCard';
|
||||||
export { default as ProcessingGrade } from './ProcessingGrade';
|
export { default as ProcessingGrade } from './ProcessingGrade';
|
||||||
|
|||||||
Reference in New Issue
Block a user