e0a26a8055
Reconcile finding: the live tokens had DRIFTED from Design's package (HANDOFF "Tokens (exact)"). Per the standing conflict rule (Design specified exact hexes → rejected the drift → Design wins), aligned the whole palette: - Surfaces: --bg-1 #0E0E16→#0E0E14, --bg-2 #15151F→#14141E; added --bg-deep #0A0A10 + --hairline #101018 (Design's ramp). - Text: --text-0 #E8E8F0→#F0F0F0, --text-1 #7A7A8E→#B8BCC8 (Design's secondary is far brighter), --text-2 #4A4A5E→#707080, added --text-3 #4a4a58 micro. - Borders: #1E1E2E→#1E1E2A, #2A2A3E→#2A2A38. - GRADES (the big one): B blue #4A9EFF → neutral-bright WHITE #F0F0F0; C amber #FFB347 → muted GREY #B8BCC8; D #FF5252 → #FF4757. The old blue/amber actually violated DESIGN-SPEC v2's OWN "B neutral-bright, C muted" — this fixes a long-standing drift, confirmed by Design's package. Amber stays its own token (--amber); --warning decoupled to --amber; --miss → #FF4757. - vyndrTokens.js GRADE_HEX mirror + the design-system test updated to match. Big VISUAL change (grade color language), VISUALLY UNVERIFIED at 390px/desktop — on the Chrome-audit list. Every surface inherits it, so it lands first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
194 lines
7.8 KiB
JavaScript
194 lines
7.8 KiB
JavaScript
// 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: #F0F0F0');
|
||
expect(css).toContain('--g-c: #B8BCC8');
|
||
expect(css).toContain('--g-d: #FF4757');
|
||
});
|
||
|
||
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('#f0f0f0');
|
||
expect(tokens.gradeHex('C')).toBe('#b8bcc8');
|
||
expect(tokens.gradeHex('D')).toBe('#ff4757');
|
||
});
|
||
|
||
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 80–120px', () => {
|
||
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');
|
||
});
|
||
});
|