Merge DS5 (design): pricing Desk-as-hero, ticker stillness, empty/error unify, archetype glyphs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 00:32:28 -04:00
14 changed files with 620 additions and 86 deletions
+12 -8
View File
@@ -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,
},
];
+11
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import { type Tier } from '@/lib/tierGate';
/**
@@ -31,6 +32,9 @@ interface Streak {
// Session 60 (night2/C) — THE LENS + optional snapshot grade letter.
lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null };
grade?: string | null;
// DS5 (Part 5) — the player's locked archetype from the snapshot join, when
// present. Optional + self-hiding: absent beats fabricated.
archetype?: string | null;
}
export interface StreaksPanelProps {
@@ -102,6 +106,13 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
<div style={playerName}>
{s.player}{s.team ? <span style={teamTag}> · {s.team}</span> : null}
</div>
{/* Part 5 — the archetype glyph+chip (the Rosetta stone),
propagated here; self-hides when the join has no archetype. */}
{s.archetype && (
<div style={{ margin: '3px 0' }}>
<ArchetypeBadge archetype={s.archetype} sport={sport} size="sm" showDesc />
</div>
)}
<div className="mono" style={categoryLine}>{s.description}</div>
<div style={lensLine}>{builtVs}</div>
</div>
+110
View File
@@ -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>
);
}
+89 -35
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
type TickerItem = {
tag: string;
@@ -31,20 +31,41 @@ const TAG_COLORS: Record<string, string> = {
// looping the same item reads as fake; hide the bar instead.
const MIN_ITEMS = 4;
// DS5 (DESIGN-SPEC Part 4) — the ticker RESTS this long on each item so it can
// be READ. Mirrors the --ticker-hold token in globals.css (kept in sync; a unit
// test cross-checks both are ≥4000ms). No one-off duration literals elsewhere.
const TICKER_HOLD_MS = 4200;
/**
* Scrolling marquee (§5). Continuous `ticker-scroll`; content duplicated so the
* loop is seamless. Session 45 — polls /api/ticker for live snapshot exhaust
* (top grades, line moves, slate-scanned events).
* The ranked "top moves" strip (§5) — DS5 rebuild to PUNCTUATED STILLNESS.
*
* Session 57 (Phase 0) — real items only: the hardcoded fallback feed is gone
* (callers pass []), and with fewer than MIN_ITEMS real items the bar renders
* nothing. Visibility is published as `--ticker-h` on <html> so the fixed
* header stack (layout main padding, Slate sticky top) collapses with it.
* The old continuous marquee (ticker-scroll, motion at all times) is retired:
* constant motion reads cheap (Part 0 law #1). The ticker now RESTS on one
* ranked item for --ticker-hold (≥4s), advancing with a sub-200ms cross-fade
* and a change-only green pulse — the terminal reacting, then still. It is the
* only motion in the ticker; the header's single idle proof-of-life is the
* heartbeat's one live-dot (this bar no longer renders a competing pulse).
*
* Session 45 — still polls /api/ticker for live snapshot exhaust. Session 57 —
* real items only (callers pass []); <MIN_ITEMS → renders nothing. Visibility
* is published as `--ticker-h` on <html> so the fixed header stack collapses
* with it. prefers-reduced-motion → a static ranked list, zero motion.
*/
export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
const [feed, setFeed] = useState<TickerItem[] | null>(null);
// Session 55 — flash the LIVE dot when a fresh event slides in (breaking-news feel).
const [flash, setFlash] = useState(false);
const [idx, setIdx] = useState(0);
const [pulse, setPulse] = useState(false);
const [reduced, setReduced] = useState(false);
// Respect prefers-reduced-motion: no rotation, no pulse — a static strip.
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const apply = () => setReduced(mq.matches);
apply();
mq.addEventListener?.('change', apply);
return () => mq.removeEventListener?.('change', apply);
}, []);
useEffect(() => {
if (!live) return;
@@ -58,8 +79,11 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
if (active && Array.isArray(data.items) && data.items.length > 0) {
const head = `${data.items[0]?.tag}|${data.items[0]?.text}`;
if (lastHead && head !== lastHead) {
setFlash(true);
setTimeout(() => { if (active) setFlash(false); }, 2500);
// A fresh head event — reset to the top and pulse ONCE (change-only,
// the DATA-UPDATE motion category, never idle).
setIdx(0);
setPulse(true);
setTimeout(() => { if (active) setPulse(false); }, 600);
}
lastHead = head;
setFeed(data.items.map((it) => ({ ...it, color: it.color || TAG_COLORS[it.tag] || 'var(--amber)' })));
@@ -84,14 +108,35 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
return () => { document.documentElement.style.setProperty('--ticker-h', '0px'); };
}, [visible, height]);
// The rotation: HOLD each item for TICKER_HOLD_MS, then advance. Disabled
// entirely under reduced motion or with a single item — punctuated stillness,
// not a metronome.
const len = display.length;
useEffect(() => {
if (reduced || len <= 1) return;
const id = setInterval(() => {
setIdx((i) => (i + 1) % len);
setPulse(true);
setTimeout(() => setPulse(false), 600);
}, TICKER_HOLD_MS);
return () => clearInterval(id);
}, [reduced, len]);
const heldRef = useRef(idx);
heldRef.current = idx;
if (!visible) return null;
const content = display.map((it, i) => (
const renderItem = (it: TickerItem, key: number, rank?: number) => (
<span
key={i}
key={key}
className="mono"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '0 26px', fontSize: 12, letterSpacing: '0.04em', color: 'var(--text-1)' }}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '0 22px', fontSize: 12, letterSpacing: '0.04em', color: 'var(--text-1)' }}
>
{rank != null && (
<span className="mono" style={{ color: 'var(--text-2)', fontWeight: 700, fontSize: 10, minWidth: 30 }}>
{rank}/{len}
</span>
)}
<span
style={{
color: it.color || 'var(--amber)',
@@ -105,37 +150,46 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
{it.delta && (
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>{it.delta}</span>
)}
<span style={{ color: 'var(--text-2)' }}>·</span>
</span>
));
);
return (
<div
className="scanlines"
style={{ height, overflow: 'hidden', background: 'var(--bg-1)', borderTop: '1px solid var(--border)', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', position: 'relative' }}
>
<div className="ticker-track">
{content}
{content}
{/* Static "TOP MOVES" anchor — a label, not a pulsing dot. The single
idle proof-of-life lives in the heartbeat below (one live-dot). */}
<div
className="mono"
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 6, padding: '0 14px 0 12px',
alignSelf: 'stretch', background: 'var(--bg-1)', borderRight: '1px solid var(--border)',
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)',
}}
aria-hidden
>
TOP MOVES
</div>
{/* Session 55 — anchored LIVE badge (chrome, not data → may pulse). */}
{live && (
{reduced ? (
// Reduced motion — the full ranked list, static, no animation at all.
<div className="ticker-rest" style={{ flex: 1 }}>
{display.map((it, i) => renderItem(it, i))}
</div>
) : (
// Resting rotator — ONE item held for ≥4s, change-only cross-fade + pulse.
<div
className="mono"
style={{
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 3,
display: 'flex', alignItems: 'center', gap: 6, padding: '0 14px 0 12px',
background: 'var(--bg-1)', borderRight: '1px solid var(--border)',
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em',
color: flash ? 'var(--g-ap, #00ffb8)' : 'var(--g-a, #00D4A0)',
transition: 'color 0.3s ease',
}}
key={idx}
className={`ticker-rest ticker-item-enter${pulse ? ' ticker-pulse' : ''}`}
style={{ flex: 1 }}
aria-live="polite"
>
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'currentColor', display: 'inline-block', boxShadow: flash ? '0 0 8px currentColor' : 'none' }} />
LIVE
{renderItem(display[idx % len], idx, (idx % len) + 1)}
</div>
)}
<div style={{ position: 'absolute', left: live ? 66 : 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(90deg, var(--bg-1), transparent)', zIndex: 2 }} />
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(270deg, var(--bg-1), transparent)', zIndex: 2 }} />
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 48, background: 'linear-gradient(270deg, var(--bg-1), transparent)', zIndex: 2 }} />
</div>
);
}
+2
View File
@@ -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';