Files
vyndr/tests/unit/vyndrDesignSystem.test.js
T
builtbykev a18a3f33fc 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>
2026-07-13 00:31:35 -04:00

194 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// VYNDR 2.0 design system — Phase A (tokens/CSS/fonts/glitch) + Phase B
// (shared components). The .tsx components can't run under the plain-JS Jest
// config (no Babel/TS transform), so component contracts are asserted against
// their source text; the helper LOGIC is exercised directly via the CommonJS
// vyndrTokens module that the components actually import.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const css = read('app/globals.css');
const layout = read('app/layout.tsx');
const tokens = require('../../web/src/lib/vyndrTokens');
describe('Phase A — design tokens (§2)', () => {
const REQUIRED_TOKENS = [
'--bg-0', '--bg-1', '--bg-2', '--bg-3', '--border', '--border-hi',
'--text-0', '--text-1', '--text-2',
'--g-ap', '--g-a', '--g-b', '--g-c', '--g-d',
'--s-nba', '--s-mlb', '--s-wnba', '--s-soccer',
'--acc-0', '--acc-1', '--amber', '--amber-glow',
'--live', '--hit', '--miss',
'--glitch', '--scan-op', '--grade-hero', '--sans', '--mono',
];
it.each(REQUIRED_TOKENS)('defines %s in :root', (token) => {
expect(css).toMatch(new RegExp(`${token.replace(/[-]/g, '\\-')}\\s*:`));
});
it('uses the exact §2 grade hex values', () => {
expect(css).toContain('--g-ap: #00ffb8');
expect(css).toContain('--g-a: #00d4a0');
expect(css).toContain('--g-b: #4a9eff');
expect(css).toContain('--g-c: #ffb347');
expect(css).toContain('--g-d: #ff5252');
});
it('sets --sans to Inter and --mono to JetBrains Mono', () => {
// Session 41 — fonts are self-hosted via next/font, so the families now
// come through the --font-* variables (with the literal names kept as
// fallbacks for any non-next/font context).
expect(css).toMatch(/--sans:\s*var\(--font-sans\),\s*'Inter'/);
expect(css).toMatch(/--mono:\s*var\(--font-mono\),\s*'JetBrains Mono'/);
});
it('sets glitch intensity to the §2 baseline of 1 and scan-op to 0.04', () => {
expect(css).toMatch(/--glitch:\s*1;/);
expect(css).toMatch(/--scan-op:\s*0\.04;/);
});
});
describe('Phase A — fonts (§2)', () => {
it('self-hosts Inter + JetBrains Mono via next/font (Session 41 — no CDN <link>)', () => {
expect(layout).toContain("from 'next/font/google'");
expect(layout).toContain('Inter(');
expect(layout).toContain('JetBrains_Mono(');
expect(layout).not.toMatch(/href=["'][^"']*fonts\.googleapis\.com/);
});
});
describe('Phase A — glitch + animation keyframes (§4, ported verbatim)', () => {
const KEYFRAMES = [
'wm-tear', 'glitch-shift-r', 'glitch-shift-b', 'wm-caret', 'head-tear',
'crt-sweep', 'crt-sweep-local', 'phosphor-pulse', 'grade-reveal',
'ticker-scroll', 'live-pulse', 'flash-up', 'flash-down', 'ekg-scroll',
'node-pulse', 'synapse-travel', 'count-tick', 'factor-ignite',
'toast-in', 'sheet-up', 'cmd-in', 'fade-in', 'scan-drift',
];
it.each(KEYFRAMES)('defines @keyframes %s', (name) => {
expect(css).toContain(`@keyframes ${name}`);
});
it('floors fade-in at the visible state (opacity .6, never 0)', () => {
expect(css).toMatch(/@keyframes fade-in\s*\{\s*from\s*\{\s*opacity:\s*0\.6;/);
});
});
describe('Phase A — scanline texture + reduced motion (§4, §10)', () => {
it('has the .scanlines::after CRT texture class', () => {
expect(css).toContain('.scanlines::after');
expect(css).toMatch(/repeating-linear-gradient\(0deg, transparent 50%, rgba\(0,0,0,1\) 50%\)/);
expect(css).toMatch(/opacity: var\(--scan-op\)/);
});
it('respects prefers-reduced-motion and the in-app data-motion toggle', () => {
expect(css).toContain('@media (prefers-reduced-motion: reduce)');
expect(css).toContain('html[data-motion="reduced"]');
});
it('ships the a11y preference layer (contrast / text / colorblind / readable font)', () => {
expect(css).toContain('html[data-contrast="high"]');
expect(css).toContain('html[data-text="xl"]');
expect(css).toContain('html[data-cb="1"]');
expect(css).toContain('html[data-font="readable"]');
});
});
describe('Phase B — token helpers (vyndrTokens)', () => {
it('gradeColor returns the correct CSS variable for each grade tier', () => {
expect(tokens.gradeColor('A+')).toBe('var(--g-ap)');
expect(tokens.gradeColor('A')).toBe('var(--g-a)');
expect(tokens.gradeColor('A-')).toBe('var(--g-a)');
expect(tokens.gradeColor('B+')).toBe('var(--g-b)');
expect(tokens.gradeColor('B')).toBe('var(--g-b)');
expect(tokens.gradeColor('C')).toBe('var(--g-c)');
expect(tokens.gradeColor('D')).toBe('var(--g-d)');
});
it('gradeColor falls back to primary text for unknown grades', () => {
expect(tokens.gradeColor('Z')).toBe('var(--text-0)');
});
it('gradeHex returns the exact §2 hex for each grade', () => {
expect(tokens.gradeHex('A+')).toBe('#00ffb8');
expect(tokens.gradeHex('A')).toBe('#00d4a0');
expect(tokens.gradeHex('B')).toBe('#4a9eff');
expect(tokens.gradeHex('C')).toBe('#ffb347');
expect(tokens.gradeHex('D')).toBe('#ff5252');
});
it('SPORT map carries every sport with its token color and label', () => {
expect(tokens.SPORT.nba).toEqual({ label: 'NBA', color: 'var(--s-nba)', hex: '#e94b3c' });
expect(tokens.SPORT.mlb.hex).toBe('#1e90ff');
expect(tokens.SPORT.wnba.hex).toBe('#f7944a');
expect(tokens.SPORT.soccer.hex).toBe('#3ddc84');
});
it('gradeBadgeSize maps variants and passes raw numbers through; hero is 80120px', () => {
expect(tokens.gradeBadgeSize('sm')).toBe(16);
expect(tokens.gradeBadgeSize('lg')).toBe(48);
expect(tokens.gradeBadgeSize(72)).toBe(72);
const hero = tokens.gradeBadgeSize('hero');
expect(hero).toBeGreaterThanOrEqual(80);
expect(hero).toBeLessThanOrEqual(120);
});
});
describe('Phase B — shared component contracts (§5)', () => {
const comp = (name) => read(path.join('components', 'vyndr', name));
it('Wordmark renders the green R, caret, VYNDR data-text and BETA pill', () => {
const src = comp('Wordmark.tsx');
expect(src).toContain('data-text="VYNDR"');
expect(src).toContain('className="wm-r"');
expect(src).toContain('wm-cursor');
expect(src).toContain('wm-beta');
});
it('GradeBadge colors by grade token and supports the hero size', () => {
const src = comp('GradeBadge.tsx');
expect(src).toContain('gradeColor(grade)');
expect(src).toContain('gradeBadgeSize(size)');
expect(src).toMatch(/'hero'/);
});
it('SportBadge derives its color from the SPORT token map', () => {
expect(comp('SportBadge.tsx')).toContain('SPORT[sport');
});
it('VBtn defines all five variants', () => {
const src = comp('VBtn.tsx');
['primary', 'outline', 'ghost', 'amber', 'danger'].forEach((v) => {
expect(src).toContain(`${v}:`);
});
expect(src).toContain("background: 'var(--g-a)'"); // primary = grade-green filled
});
it('Card is a Level-1 (bg-1) surface with the scanline skin', () => {
const src = comp('Card.tsx');
expect(src).toContain("background: 'var(--bg-1)'");
expect(src).toContain('scanlines');
});
it('TerminalInput uses a phosphor cursor while empty', () => {
expect(comp('TerminalInput.tsx')).toContain('phosphor-cursor');
});
it('Sparkline renders an SVG path from its data points', () => {
const src = comp('Sparkline.tsx');
expect(src).toContain('<svg');
expect(src).toContain('<path');
});
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).not.toContain('ticker-track');
expect(src).toContain('ticker-rest');
});
});