Session 33: Design system Phase A+B — tokens, fonts, glitch CSS, shared components (1792 tests)
VYNDR 2.0 design-system conversion, foundation only (pages/mobile/systems are Sessions 34+). Frontend-only; zero backend changes. Phase A: §2 token set + token-wired typography + Inter/JetBrains Mono fonts + §4 glitch keyframes (ported verbatim from the prototype's vyndr.css) + scanline texture + §10 a11y data-* layer in globals.css. Legacy alias block kept. Phase B: web/src/lib/vyndrTokens.js (CommonJS helpers) + 9 shared components under web/src/components/vyndr/ (Wordmark, GradeBadge, SportBadge, TerminalInput, SectionHead, VBtn, Card, Sparkline, Ticker). 74 new design-system tests. Backend 1718 -> 1792, 141 suites, zero regressions. Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+80
-2
@@ -4,8 +4,86 @@
|
||||
2026-06-15
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v32.0 — Close audit gaps: grades pipeline + NFL/NHL wiring +
|
||||
rate limiting + test-artifact cleanup (Session 32)
|
||||
SHIP BUILD v33.0 — VYNDR 2.0 design system, Phase A+B: tokens + global CSS +
|
||||
fonts + glitch keyframes + shared components (Session 33)
|
||||
|
||||
## Session 33 (2026-06-15) — SHIPPED
|
||||
|
||||
First session of the multi-session VYNDR 2.0 design-system conversion. Fetched
|
||||
the design handoff bundle (claude.ai/design share → gzipped tar of the
|
||||
prototype: `vyndr.css` + ~35 `.jsx` + `VYNDR_HANDOFF.md`), read the README +
|
||||
handoff spec, and implemented **Phase A (foundation)** and **Phase B (shared
|
||||
components)** only — pages, mobile, and systems are Sessions 34+. Frontend-only;
|
||||
ZERO backend changes. Backend 1718 → **1792 tests** (+74), 141 suites, zero
|
||||
regressions. Web build clean (exit 0).
|
||||
|
||||
### PHASE A — Tokens + global CSS + fonts + glitch keyframes
|
||||
- **Tokens (§2, exact):** added the canonical short-name token set to
|
||||
`web/src/app/globals.css` `:root` — `--g-ap/--g-a/--g-b/--g-c/--g-d`,
|
||||
`--s-nba/--s-mlb/--s-wnba/--s-soccer`, `--amber/--amber-glow`,
|
||||
`--live/--hit/--miss`, `--scan-op: 0.04`, `--grade-hero`, `--sans` (Inter),
|
||||
`--mono` (JetBrains Mono). Set `--glitch: 1` (§2 baseline, was 0.5). Kept the
|
||||
full legacy alias block so every existing component still resolves during the
|
||||
migration — additive, not a rip-out.
|
||||
- **Typography wired to tokens:** base `body` / `h1–h6` / `.mono` / `.num` /
|
||||
`.lbl` now resolve `var(--sans)` / `var(--mono)` instead of hardcoding
|
||||
Instrument Sans / IBM Plex Mono → the whole app picks up Inter + JetBrains
|
||||
Mono from the foundation (the §2 monospace-for-data rule).
|
||||
- **Fonts (§2):** `layout.tsx` Google Fonts link now loads Inter (400–900) +
|
||||
JetBrains Mono (400–800); IBM Plex Mono + Instrument Sans kept loaded so
|
||||
un-converted pages don't lose their type before Sessions 34+.
|
||||
- **Glitch system (§4, ported verbatim from `vyndr.css`):** appended a
|
||||
"VYNDR 2.0 DESIGN SYSTEM" block with every keyframe — `wm-tear`,
|
||||
`glitch-shift-r/-b`, `wm-caret`, `head-tear`, `crt-sweep`, `crt-sweep-local`,
|
||||
`phosphor-pulse`, `grade-reveal`, `ticker-scroll`, `live-pulse`,
|
||||
`flash-up/-down`, `ekg-scroll`, `node-pulse`, `synapse-travel`, `count-tick`,
|
||||
`factor-ignite`, `signal-in`, `proc-scan`, `data-blink`, `toast-in`,
|
||||
`sheet-up`, `cmd-in`, `fade-in`, `scan-drift` — plus `.scanlines::after`
|
||||
texture, `.intel-surface` (Level-3 "VYNDR is speaking" green), `.wm*`
|
||||
wordmark, `.glitch-hover`, living-layer classes, and the §10 a11y `data-*`
|
||||
layer (contrast/text/colorblind/readable-font/reduced-motion). Where
|
||||
keyframe/class names collide with the legacy block, the new (appended)
|
||||
definitions are authoritative (later-wins). **Entrance keyframes floor at the
|
||||
visible state** (`fade-in` from opacity .6) per the §4 hard-won note.
|
||||
|
||||
### PHASE B — Shared components (§5)
|
||||
- `web/src/lib/vyndrTokens.js` — plain CommonJS helpers (`GRADE_COLORS`,
|
||||
`GRADE_HEX`, `SPORT`, `GRADE_BADGE_SIZES`, `gradeColor`, `gradeHex`,
|
||||
`gradeBadgeSize`). CommonJS on purpose: the `.tsx` components import it
|
||||
(allowJs) AND the Jest suite requires it directly (no TS/Babel transform in
|
||||
the plain-JS test config) → the helper LOGIC is genuinely unit-tested, not
|
||||
just text-asserted.
|
||||
- `web/src/components/vyndr/` — `Wordmark`, `GradeBadge`, `SportBadge`,
|
||||
`TerminalInput`, `SectionHead`, `VBtn`, `Card`, `Sparkline`, `Ticker` +
|
||||
barrel `index.ts`. Ported faithfully from `vyndr-core.jsx` → TS, token-only
|
||||
colors. `'use client'` only where needed (`TerminalInput`, `VBtn`).
|
||||
GradeBadge `hero` size maps to 100px (within the §5 80–120px rule).
|
||||
NOTE: the existing root `components/Wordmark.tsx` (legacy `.wordmark` markup,
|
||||
used by Nav) is untouched — the new one lives at `@/components/vyndr/Wordmark`
|
||||
(`.wm` markup) and supersedes it as pages convert in Sessions 34+.
|
||||
|
||||
### Files created
|
||||
- `web/src/lib/vyndrTokens.js`
|
||||
- `web/src/components/vyndr/{Wordmark,GradeBadge,SportBadge,TerminalInput,
|
||||
SectionHead,VBtn,Card,Sparkline,Ticker}.tsx`, `web/src/components/vyndr/index.ts`
|
||||
- `tests/unit/vyndrDesignSystem.test.js` (74 tests via parameterized `it.each`:
|
||||
token presence, exact §2 hex, font loading, all keyframes, scanline + a11y +
|
||||
reduced-motion, helper logic, component contracts)
|
||||
|
||||
### Files modified
|
||||
- `web/src/app/globals.css` (§2 tokens, token-wired typography, full §4 glitch
|
||||
+ §10 a11y block appended)
|
||||
- `web/src/app/layout.tsx` (Inter + JetBrains Mono font link)
|
||||
|
||||
### Deliberately deferred (per the session spec — Sessions 34+)
|
||||
- A.7 hardcoded-hex sweep across the 40+ existing components is page-conversion
|
||||
work (Sessions 34–37), NOT Phase A. New vyndr components are token-only.
|
||||
- Phases C–H: app shell/routing, core + remaining screens, mobile parity,
|
||||
living layer / i18n / a11y wiring / PWA / paywall / parlay math, QA vs §13.
|
||||
- The prompt's RYAN MONTGOMERY note (sessionStorage auth → httpOnly cookies in
|
||||
prod) is a systems concern for Phase G, not Phase A/B.
|
||||
|
||||
---
|
||||
|
||||
## Session 32 (2026-06-15) — SHIPPED
|
||||
|
||||
|
||||
@@ -135,6 +135,36 @@ Tank01 betting-odds real shape: sportsbooks are TOP-LEVEL keys on each
|
||||
game object (`{ awayTeam, homeTeam, bet365:{...} }`), not a `sportsBooks`
|
||||
array. Filter `NON_BOOK_KEYS` to extract books (see `gameLines.js`).
|
||||
|
||||
## VYNDR 2.0 Design System (Session 33 — Phase A+B)
|
||||
Multi-session frontend conversion of the claude.ai/design "VYNDR 2.0" handoff
|
||||
(NOT a backend change). Foundation shipped; pages/mobile/systems are Sessions
|
||||
34+. Source of truth = the prototype's `vyndr.css` + `VYNDR_HANDOFF.md`.
|
||||
- **Tokens** live in `web/src/app/globals.css` `:root`. The NEW canonical set
|
||||
is the short names: grades `--g-ap/--g-a/--g-b/--g-c/--g-d`, sports
|
||||
`--s-nba/--s-mlb/--s-wnba/--s-soccer`, `--amber`, `--live/--hit/--miss`,
|
||||
`--scan-op` (0.04), `--glitch` (1), `--sans` (Inter), `--mono` (JetBrains
|
||||
Mono). The legacy alias block (`--grade-a`, `--nba`, `--accent`, …) is KEPT —
|
||||
do not delete it until every consumer is migrated.
|
||||
- **Two hard brand rules:** (1) JetBrains Mono (`var(--mono)` / `.mono`) for ALL
|
||||
data — odds, %, timestamps, book names, stat lines, grades; Inter
|
||||
(`var(--sans)`) for everything else. (2) Glitch animations apply ONLY to
|
||||
chrome (wordmark, headers-on-hover, dividers, loaders, living layer). DATA
|
||||
NEVER GLITCHES.
|
||||
- **Glitch keyframes** (§4) are appended to globals.css after the legacy block —
|
||||
later-wins where names collide, so the appended VYNDR-2.0 definitions are
|
||||
authoritative. Entrance keyframes floor at the visible state (`fade-in` from
|
||||
opacity .6) so a paused frame is never invisible.
|
||||
- **Shared components**: `@/components/vyndr/*` (Wordmark, GradeBadge,
|
||||
SportBadge, TerminalInput, SectionHead, VBtn, Card, Sparkline, Ticker), helpers
|
||||
in `web/src/lib/vyndrTokens.js`. The NEW Wordmark is `.wm` markup at
|
||||
`@/components/vyndr/Wordmark`; the legacy `@/components/Wordmark` (`.wordmark`)
|
||||
is still used by Nav until pages convert. `vyndrTokens.js` is CommonJS so it's
|
||||
importable by `.tsx` (allowJs) AND requireable by the plain-JS Jest suite —
|
||||
keep helper logic there so it stays genuinely unit-testable.
|
||||
- **a11y layer** (§10): `<html data-contrast|data-text|data-cb|data-font|
|
||||
data-motion>` overrides in globals.css. Wiring the toggles to these attrs is
|
||||
Phase G (Session 38).
|
||||
|
||||
## Active Skills
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// 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', () => {
|
||||
expect(css).toMatch(/--sans:\s*'Inter'/);
|
||||
expect(css).toMatch(/--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('loads Inter (400–900) and JetBrains Mono (400–800) from Google Fonts', () => {
|
||||
expect(layout).toContain('Inter:wght@400;500;600;700;800;900');
|
||||
expect(layout).toContain('JetBrains+Mono:wght@400;500;600;700;800');
|
||||
});
|
||||
});
|
||||
|
||||
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 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 uses the ticker-track marquee and duplicates content for a seamless loop', () => {
|
||||
const src = comp('Ticker.tsx');
|
||||
expect(src).toContain('ticker-track');
|
||||
expect(src).toMatch(/\{content\}\s*\{content\}/);
|
||||
});
|
||||
});
|
||||
+426
-7
@@ -54,14 +54,50 @@
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
/* Glitch intensity — 0..1, can be tuned per-section */
|
||||
--glitch: 0.5;
|
||||
/* Glitch intensity — 0..1, can be tuned per-section.
|
||||
VYNDR 2.0 (§2) sets this to 1 — full intensity is the design baseline. */
|
||||
--glitch: 1;
|
||||
--scan-opacity: calc(0.04 + 0.06 * var(--glitch));
|
||||
--grain-opacity: calc(0.02 + 0.02 * var(--glitch));
|
||||
--rgb-shift: calc(0.5px + 1.5px * var(--glitch));
|
||||
--slash-opacity: calc(0.06 + 0.08 * var(--glitch));
|
||||
--sweep-opacity: calc(0.08 + 0.12 * var(--glitch));
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
VYNDR 2.0 DESIGN TOKENS (§2 — exact values from vyndr.css).
|
||||
The new canonical short-name token set. Legacy aliases below
|
||||
keep every existing component resolving during the migration.
|
||||
───────────────────────────────────────────────────────── */
|
||||
/* Grade tokens (the product's core color language) */
|
||||
--g-ap: #00ffb8; /* A+ rarest, max glow */
|
||||
--g-a: #00d4a0; /* A workhorse green / primary CTA */
|
||||
--g-b: #4a9eff; /* B blue */
|
||||
--g-c: #ffb347; /* C amber */
|
||||
--g-d: #ff5252; /* D red */
|
||||
|
||||
/* Sport tokens */
|
||||
--s-nba: #e94b3c;
|
||||
--s-mlb: #1e90ff;
|
||||
--s-wnba: #f7944a;
|
||||
--s-soccer: #3ddc84;
|
||||
|
||||
/* Amber / system glow (LOADING, LIVE, 404, warnings) */
|
||||
--amber: #ffb347;
|
||||
--amber-glow: 0 0 12px rgba(255, 179, 71, 0.6);
|
||||
|
||||
/* Semantic */
|
||||
--live: #ff3b3b;
|
||||
--hit: #00d4a0;
|
||||
--miss: #ff5252;
|
||||
|
||||
/* Scanline base opacity + hero grade color */
|
||||
--scan-op: 0.04;
|
||||
--grade-hero: var(--g-a);
|
||||
|
||||
/* Type — Inter for chrome/UI, JetBrains Mono for ALL data */
|
||||
--sans: 'Inter', system-ui, sans-serif;
|
||||
--mono: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
|
||||
/* ── Legacy aliases — every existing component still resolves ── */
|
||||
--bg-primary: var(--bg-0);
|
||||
--bg-surface: var(--bg-1);
|
||||
@@ -106,7 +142,7 @@ html {
|
||||
body {
|
||||
background: var(--bg-0);
|
||||
color: var(--text-0);
|
||||
font-family: 'Instrument Sans', 'SF Pro Display', system-ui, -apple-system, sans-serif;
|
||||
font-family: var(--sans);
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.01em;
|
||||
@@ -117,7 +153,7 @@ body {
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Instrument Sans', 'SF Pro Display', system-ui, sans-serif;
|
||||
font-family: var(--sans);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
@@ -126,17 +162,17 @@ h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
.font-mono,
|
||||
.mono {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-family: var(--mono);
|
||||
font-feature-settings: 'tnum' 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.num {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lbl {
|
||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
@@ -802,3 +838,386 @@ body.tex-grain::before {
|
||||
0% { background-position: -100% 0; }
|
||||
100% { background-position: 100% 0; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
VYNDR 2.0 DESIGN SYSTEM (§4 — ported verbatim from vyndr.css)
|
||||
Glitch + animation system. Applied ONLY to chrome (wordmark,
|
||||
headers, dividers, loaders, the living layer). DATA NEVER
|
||||
GLITCHES. Where keyframe/class names collide with the legacy
|
||||
block above, these definitions are authoritative (later wins).
|
||||
============================================================ */
|
||||
|
||||
/* ===================== SCANLINE TEXTURE ===================== */
|
||||
.scanlines, .scanlines-bg { position: relative; }
|
||||
.scanlines::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(0deg, transparent 50%, rgba(0,0,0,1) 50%);
|
||||
background-size: 100% 2px;
|
||||
opacity: var(--scan-op);
|
||||
z-index: 1;
|
||||
}
|
||||
/* Intelligence surface (Level 3) — green + drifting scanlines: "VYNDR is speaking" */
|
||||
.intel-surface {
|
||||
position: relative;
|
||||
background: linear-gradient(160deg, var(--acc-0), var(--acc-1));
|
||||
overflow: hidden;
|
||||
}
|
||||
.intel-surface::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -4px 0 -4px 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(0deg, transparent 0 2px, rgba(0,0,0,0.85) 2px 4px);
|
||||
opacity: calc(var(--scan-op) * 1.25 + 0.01);
|
||||
animation: scan-drift 4s linear infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
@keyframes scan-drift {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(4px); }
|
||||
}
|
||||
|
||||
/* ===================== WORDMARK GLITCH ===================== */
|
||||
.wm {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
font-family: var(--sans);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-0);
|
||||
text-shadow: 0 0 1px rgba(232,232,240,.25);
|
||||
animation: wm-tear 5s steps(1, end) infinite;
|
||||
}
|
||||
.wm::before, .wm::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
opacity: calc(0.92 * var(--glitch));
|
||||
}
|
||||
.wm::before {
|
||||
color: #ff2d6f;
|
||||
transform: translateX(calc(-1.9px * var(--glitch)));
|
||||
animation: glitch-shift-r 5s steps(1, end) infinite;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.wm::after {
|
||||
color: #2dffd5;
|
||||
transform: translateX(calc(1.9px * var(--glitch)));
|
||||
animation: glitch-shift-b 5s steps(1, end) infinite;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
/* the locked green R — permanent phosphor highlight */
|
||||
.wm-r {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
color: var(--g-a);
|
||||
text-shadow: 0 0 9px rgba(0,212,160,.7), 0 0 22px rgba(0,212,160,.38);
|
||||
}
|
||||
/* live terminal caret after the R — alive, mid-type flicker */
|
||||
.wm-cursor {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: inline-block;
|
||||
width: 0.11em;
|
||||
height: 0.82em;
|
||||
margin-left: 0.14em;
|
||||
transform: translateY(0.04em);
|
||||
background: var(--g-a);
|
||||
box-shadow: 0 0 9px rgba(0,212,160,.9), 0 0 18px rgba(0,212,160,.4);
|
||||
animation: wm-caret 1.15s steps(1, end) infinite;
|
||||
}
|
||||
@keyframes wm-caret {
|
||||
0%, 44% { opacity: 1; }
|
||||
45%, 55% { opacity: 0.12; }
|
||||
56%, 70% { opacity: 1; }
|
||||
71%, 100% { opacity: 0.12; }
|
||||
}
|
||||
/* BETA clearance pill */
|
||||
.wm-beta {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--g-a);
|
||||
border: 1px solid color-mix(in srgb, var(--g-a) 50%, transparent);
|
||||
background: rgba(0,212,160,.08);
|
||||
padding: 3px 7px 3px 9px;
|
||||
border-radius: 5px;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 16px -5px rgba(0,212,160,.6);
|
||||
}
|
||||
@keyframes wm-tear {
|
||||
0%, 4%, 100% { transform: translate(0); }
|
||||
5% { filter: hue-rotate(40deg) brightness(1.15); transform: translate(calc(2.4px*var(--glitch))) skew(calc(-3deg*var(--glitch))); }
|
||||
6% { filter: brightness(1.05); transform: translate(calc(-1.4px*var(--glitch))) skew(calc(1deg*var(--glitch))); }
|
||||
7% { filter: none; transform: translate(0); }
|
||||
47% { transform: translate(0); }
|
||||
48% { filter: hue-rotate(-30deg) brightness(1.12); transform: translate(calc(-2.4px*var(--glitch))) skew(calc(1.5deg*var(--glitch))); }
|
||||
49% { transform: translate(calc(1.4px*var(--glitch))); }
|
||||
50% { filter: none; transform: translate(0); }
|
||||
}
|
||||
@keyframes glitch-shift-r {
|
||||
0%,4%,8%,46%,52%,100% { clip-path: inset(0 0 0 0); transform: translateX(calc(-1.5px*var(--glitch))); }
|
||||
5% { clip-path: inset(10% 0 60% 0); transform: translateX(calc(-4px*var(--glitch))); }
|
||||
6% { clip-path: inset(40% 0 20% 0); transform: translateX(calc(3px*var(--glitch))); }
|
||||
48% { clip-path: inset(20% 0 55% 0); transform: translateX(calc(-3px*var(--glitch))); }
|
||||
49% { clip-path: inset(60% 0 10% 0); transform: translateX(calc(2px*var(--glitch))); }
|
||||
}
|
||||
@keyframes glitch-shift-b {
|
||||
0%,4%,8%,46%,52%,100% { clip-path: inset(0 0 0 0); transform: translateX(calc(1.5px*var(--glitch))); }
|
||||
5% { clip-path: inset(55% 0 15% 0); transform: translateX(calc(4px*var(--glitch))); }
|
||||
6% { clip-path: inset(15% 0 50% 0); transform: translateX(calc(-3px*var(--glitch))); }
|
||||
48% { clip-path: inset(50% 0 25% 0); transform: translateX(calc(3px*var(--glitch))); }
|
||||
49% { clip-path: inset(5% 0 70% 0); transform: translateX(calc(-2px*var(--glitch))); }
|
||||
}
|
||||
|
||||
/* Section-header glitch on hover */
|
||||
.glitch-hover { position: relative; display: inline-block; }
|
||||
.glitch-hover:hover { animation: head-tear 0.32s steps(1,end) 1; }
|
||||
@keyframes head-tear {
|
||||
0% { transform: translate(0); text-shadow: none; }
|
||||
25% { transform: translate(calc(2px*var(--glitch))) skew(calc(-3deg*var(--glitch)));
|
||||
text-shadow: calc(-2px*var(--glitch)) 0 #ff2d6f, calc(2px*var(--glitch)) 0 #2dffd5; }
|
||||
50% { transform: translate(calc(-1px*var(--glitch))); }
|
||||
75% { transform: translate(calc(1px*var(--glitch))); text-shadow: calc(1px*var(--glitch)) 0 #2dffd5; }
|
||||
100% { transform: translate(0); text-shadow: none; }
|
||||
}
|
||||
|
||||
/* ===================== CRT SWEEP ===================== */
|
||||
.crt-sweep {
|
||||
position: fixed;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 38vh;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
background: linear-gradient(180deg,
|
||||
transparent 0%,
|
||||
rgba(0,212,160,0.04) 40%,
|
||||
rgba(0,255,184,0.14) 50%,
|
||||
rgba(255,255,255,0.10) 51%,
|
||||
rgba(0,212,160,0.04) 60%,
|
||||
transparent 100%);
|
||||
animation: crt-sweep 0.7s ease-in 1;
|
||||
}
|
||||
@keyframes crt-sweep {
|
||||
0% { opacity: 0; transform: translateY(-40vh); }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(120vh); }
|
||||
}
|
||||
/* Local sweep within a card (grade-card reveal) */
|
||||
.crt-sweep-local {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 60%;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
background: linear-gradient(180deg,
|
||||
transparent, rgba(0,255,184,0.12) 48%, rgba(255,255,255,0.10) 50%,
|
||||
rgba(0,255,184,0.08) 52%, transparent);
|
||||
animation: crt-sweep-local 0.65s ease-in 1;
|
||||
}
|
||||
@keyframes crt-sweep-local {
|
||||
0% { opacity: 0; transform: translateY(-60%); }
|
||||
15% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(220%); }
|
||||
}
|
||||
|
||||
/* ===================== PHOSPHOR PULSE ===================== */
|
||||
.phosphor-cursor {
|
||||
display: inline-block;
|
||||
width: 9px; height: 1.05em;
|
||||
background: var(--g-a);
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
box-shadow: 0 0 8px rgba(0,212,160,0.8);
|
||||
animation: phosphor-pulse 1.05s ease-in-out infinite;
|
||||
}
|
||||
.phosphor-cursor.amber { background: var(--amber); box-shadow: var(--amber-glow); }
|
||||
@keyframes phosphor-pulse {
|
||||
0%,100% { opacity: 0.5; transform: scaleX(0.6); }
|
||||
50% { opacity: 1; transform: scaleX(1); }
|
||||
}
|
||||
|
||||
/* ===================== GRADE REVEAL ===================== */
|
||||
@keyframes grade-reveal {
|
||||
0% { transform: scale(0.82); }
|
||||
60% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.grade-reveal { animation: grade-reveal 0.42s cubic-bezier(.2,1.3,.4,1) both; }
|
||||
|
||||
/* ===================== TICKER ===================== */
|
||||
@keyframes ticker-scroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
.ticker-track {
|
||||
display: inline-flex;
|
||||
white-space: nowrap;
|
||||
animation: ticker-scroll 38s linear infinite;
|
||||
}
|
||||
|
||||
/* ===================== LIVE PULSE ===================== */
|
||||
.live-dot {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--live);
|
||||
box-shadow: 0 0 0 0 rgba(255,59,59,0.7);
|
||||
animation: live-pulse 1.4s ease-out infinite;
|
||||
}
|
||||
@keyframes live-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255,59,59,0.6); }
|
||||
70% { box-shadow: 0 0 0 7px rgba(255,59,59,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255,59,59,0); }
|
||||
}
|
||||
|
||||
/* ===================== 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; } }
|
||||
.flash-up { animation: flash-up 0.6s ease-out 1; }
|
||||
.flash-down { animation: flash-down 0.6s ease-out 1; }
|
||||
|
||||
/* ===================== UTILITY ===================== */
|
||||
.mono { font-family: var(--mono); }
|
||||
.label {
|
||||
font-family: var(--mono);
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.amber-glow { color: var(--amber); text-shadow: var(--amber-glow); }
|
||||
|
||||
/* ===================== 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; }
|
||||
|
||||
/* Neural node pulse */
|
||||
@keyframes node-pulse {
|
||||
0%, 100% { opacity: 0.35; r: 2.4; }
|
||||
50% { opacity: 1; r: 3.4; }
|
||||
}
|
||||
.brain-node { animation: node-pulse 2.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
|
||||
/* Synapse traveling pulse along connections */
|
||||
@keyframes synapse-travel { from { stroke-dashoffset: 26; } to { stroke-dashoffset: 0; } }
|
||||
.brain-link { stroke-dasharray: 3 23; animation: synapse-travel 2.2s linear infinite; }
|
||||
|
||||
/* Number tick pop when a counter increments */
|
||||
@keyframes count-tick {
|
||||
0% { transform: translateY(0); color: var(--text-0); }
|
||||
30% { transform: translateY(-2px); color: var(--g-a); text-shadow: 0 0 10px rgba(0,212,160,.6); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
.count-tick { animation: count-tick 0.5s ease-out 1; }
|
||||
|
||||
/* Factor ignite — sequential light-up during grade processing */
|
||||
@keyframes factor-ignite {
|
||||
0% { opacity: 0; transform: translateX(-6px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.factor-ignite { animation: factor-ignite 0.34s ease-out both; }
|
||||
|
||||
/* Signal feed item entering */
|
||||
@keyframes signal-in {
|
||||
0% { opacity: 0; transform: translateY(-8px); background: rgba(0,212,160,.16); }
|
||||
100% { opacity: 1; transform: translateY(0); background: transparent; }
|
||||
}
|
||||
.signal-in { animation: signal-in 0.6s ease-out 1; }
|
||||
|
||||
/* Processing scan bar (grade thinking) */
|
||||
@keyframes proc-scan {
|
||||
0% { transform: translateY(-100%); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { transform: translateY(280%); opacity: 0; }
|
||||
}
|
||||
.proc-scan { animation: proc-scan 1.1s ease-in-out infinite; }
|
||||
|
||||
/* Soft data-stream blink */
|
||||
@keyframes data-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
|
||||
.data-blink { animation: data-blink 1.6s ease-in-out infinite; }
|
||||
|
||||
/* ── Entrance keyframes — each FLOORS at the visible state so a paused/
|
||||
backgrounded frame never renders invisible (learned the hard way, §4). ── */
|
||||
@keyframes toast-in { 0% { transform: translateX(24px); } 100% { transform: translateX(0); } }
|
||||
.toast-in { animation: toast-in 0.26s cubic-bezier(.2,1,.4,1) both; }
|
||||
|
||||
@keyframes sheet-up { 0% { transform: translateY(40px); } 100% { transform: translateY(0); } }
|
||||
.sheet-up { animation: sheet-up 0.24s ease-out both; }
|
||||
|
||||
@keyframes cmd-in { 0% { transform: translateY(-10px); } 100% { transform: translateY(0); } }
|
||||
.cmd-in { animation: cmd-in 0.18s ease-out both; }
|
||||
|
||||
@keyframes fade-in { from { opacity: 0.6; } to { opacity: 1; } }
|
||||
.fade-in { animation: fade-in 0.2s ease-out both; }
|
||||
|
||||
/* Reduced-motion: kill the living layer + glitch chrome outright */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wm, .wm::before, .wm::after, .ticker-track, .live-dot,
|
||||
.intel-surface::after, .phosphor-cursor,
|
||||
.ekg-track, .brain-node, .brain-link, .proc-scan, .data-blink { animation: none !important; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ACCESSIBILITY + PREFERENCES (§10 — driven by <html data-*>)
|
||||
============================================================ */
|
||||
:focus-visible { outline: 2px solid var(--g-a); outline-offset: 2px; border-radius: 3px; }
|
||||
|
||||
/* Reduce motion (manual toggle, independent of OS setting) */
|
||||
html[data-motion="reduced"] *,
|
||||
html[data-motion="reduced"] *::before,
|
||||
html[data-motion="reduced"] *::after {
|
||||
animation-duration: 0.001s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001s !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
/* High contrast */
|
||||
html[data-contrast="high"] {
|
||||
--text-0: #ffffff;
|
||||
--text-1: #cdcde0;
|
||||
--text-2: #a0a0bc;
|
||||
--border: #4c4c6a;
|
||||
--border-hi: #70709a;
|
||||
--bg-0: #000005;
|
||||
--bg-1: #0c0c16;
|
||||
--bg-2: #16161f;
|
||||
}
|
||||
html[data-contrast="high"] .scanlines::after,
|
||||
html[data-contrast="high"] .intel-surface::after { opacity: 0.015 !important; }
|
||||
|
||||
/* Text size — zoom scales the whole UI predictably */
|
||||
html[data-text="sm"] body { zoom: 0.92; }
|
||||
html[data-text="lg"] body { zoom: 1.12; }
|
||||
html[data-text="xl"] body { zoom: 1.25; }
|
||||
|
||||
/* Colorblind-safe — retunes red→orange; meaning never rests on color alone */
|
||||
html[data-cb="1"] {
|
||||
--miss: #fe6100;
|
||||
--g-d: #fe6100;
|
||||
--live: #fe6100;
|
||||
--s-nba: #ff6f3c;
|
||||
}
|
||||
html[data-cb="1"] .cb-neg { text-decoration: underline; text-underline-offset: 2px; }
|
||||
html[data-cb="1"] .cb-pos { text-decoration: underline; text-underline-offset: 2px; }
|
||||
|
||||
/* Readable font — high-legibility stack for dyslexia/low-vision */
|
||||
html[data-font="readable"] {
|
||||
--sans: "Atkinson Hyperlegible", "Verdana", "Trebuchet MS", system-ui, sans-serif;
|
||||
--mono: "Atkinson Hyperlegible Mono", "DejaVu Sans Mono", ui-monospace, monospace;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
html[data-font="readable"] .wm::before,
|
||||
html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
|
||||
|
||||
@@ -107,8 +107,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
{/* VYNDR 2.0 (§2): Inter for chrome/UI, JetBrains Mono for ALL data.
|
||||
IBM Plex Mono + Instrument Sans kept while pages migrate session-by-session. */}
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700;800&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700;800&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600;700;800&family=Instrument+Sans:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
type CardProps = {
|
||||
children: ReactNode;
|
||||
/** Apply the scanline texture overlay (default true). */
|
||||
hoverScan?: boolean;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Level-1 surface card (§5). bg-1 panel, border, scanline skin. */
|
||||
export default function Card({ children, hoverScan = true, style = {}, className = '' }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={(hoverScan ? 'scanlines ' : '') + className}
|
||||
style={{
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
position: 'relative',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { gradeColor, gradeBadgeSize } from '@/lib/vyndrTokens';
|
||||
|
||||
type Grade = 'A+' | 'A' | 'A-' | 'B+' | 'B' | 'B-' | 'C' | 'D';
|
||||
|
||||
type GradeBadgeProps = {
|
||||
grade: Grade | string;
|
||||
/** Pixel height or a variant: sm 16 (inline) / md 30 / lg 48 (card) / hero 100 (80–120). */
|
||||
size?: number | 'sm' | 'md' | 'lg' | 'hero';
|
||||
/** A/A+ get a phosphor glow when true. */
|
||||
glow?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The grade chip (§5) — the product's core visual. The grade letter is ALWAYS
|
||||
* the largest, first-read element on a card; on hero surfaces it's 80–120px.
|
||||
* Colored strictly by grade token. JetBrains Mono. Data — never glitches.
|
||||
*/
|
||||
export default function GradeBadge({ grade, size = 'md', glow = false }: GradeBadgeProps) {
|
||||
const c = gradeColor(grade);
|
||||
const px = gradeBadgeSize(size);
|
||||
const isA = grade === 'A+' || grade === 'A';
|
||||
return (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: px + 6,
|
||||
height: px,
|
||||
padding: '0 6px',
|
||||
fontSize: px * 0.52,
|
||||
fontWeight: 800,
|
||||
color: c,
|
||||
border: `1.5px solid ${c}`,
|
||||
borderRadius: 4,
|
||||
background: `color-mix(in srgb, ${c} 12%, transparent)`,
|
||||
boxShadow: glow && isA ? `0 0 14px color-mix(in srgb, ${c} 55%, transparent)` : 'none',
|
||||
letterSpacing: grade.length > 1 ? '-0.02em' : '0',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{grade}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
type SectionHeadProps = {
|
||||
children: ReactNode;
|
||||
/** Label color token (default muted text). */
|
||||
accent?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* All-caps 11px mono section label with a one-shot glitch tear on hover (§5).
|
||||
* Chrome — glitches; never wrap data in this.
|
||||
*/
|
||||
export default function SectionHead({ children, accent = 'var(--text-1)', style = {} }: SectionHeadProps) {
|
||||
return (
|
||||
<div
|
||||
className="label glitch-hover"
|
||||
style={{
|
||||
color: accent,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'default',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
type SparklineProps = {
|
||||
data: number[];
|
||||
/** Direction tint: green when up, red when down. */
|
||||
up?: boolean;
|
||||
w?: number;
|
||||
h?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tiny SVG line for line movement (§5). Green up / red down, dot on the last
|
||||
* point. Dependency-free. Data viz — the line itself never glitches.
|
||||
*/
|
||||
export default function Sparkline({ data, up = true, w = 92, h = 26 }: SparklineProps) {
|
||||
const safe = data && data.length ? data : [0, 0];
|
||||
const min = Math.min(...safe);
|
||||
const max = Math.max(...safe);
|
||||
const rng = max - min || 1;
|
||||
const pts = safe.map((v, i) => {
|
||||
const x = (i / Math.max(safe.length - 1, 1)) * w;
|
||||
const y = h - ((v - min) / rng) * (h - 4) - 2;
|
||||
return [x, y] as const;
|
||||
});
|
||||
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
|
||||
const c = up ? 'var(--g-a)' : 'var(--miss)';
|
||||
const last = pts[pts.length - 1];
|
||||
return (
|
||||
<svg width={w} height={h} style={{ display: 'block', overflow: 'visible' }} aria-hidden>
|
||||
<path d={d} fill="none" stroke={c} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={last[0]} cy={last[1]} r="2.2" fill={c} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SPORT } from '@/lib/vyndrTokens';
|
||||
|
||||
type SportBadgeProps = {
|
||||
sport: 'nba' | 'mlb' | 'wnba' | 'soccer' | string;
|
||||
size?: 'sm' | 'md';
|
||||
};
|
||||
|
||||
/** Colored sport chip from the sport token (§5). JetBrains Mono caps. */
|
||||
export default function SportBadge({ sport, size = 'md' }: SportBadgeProps) {
|
||||
const s = SPORT[sport as keyof typeof SPORT] || SPORT.nba;
|
||||
const pad = size === 'sm' ? '2px 6px' : '3px 8px';
|
||||
const fs = size === 'sm' ? 10 : 11;
|
||||
return (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: pad,
|
||||
fontSize: fs,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
color: s.hex,
|
||||
border: `1px solid ${s.hex}`,
|
||||
borderRadius: 3,
|
||||
background: `${s.hex}1a`,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
|
||||
type TerminalInputProps = {
|
||||
prompt?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
/** Amber prompt + caret (system-state) instead of grade-green. */
|
||||
amber?: boolean;
|
||||
/** Monospace input text (default true — terminal copy is data). */
|
||||
mono?: boolean;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminal field with a `›` prompt and a phosphor cursor that pulses while empty (§5).
|
||||
* The focus ring is grade-green. Monospace by default — what you type is data.
|
||||
*/
|
||||
export default function TerminalInput({
|
||||
prompt = '›',
|
||||
placeholder = '',
|
||||
value,
|
||||
onChange,
|
||||
amber = false,
|
||||
mono = true,
|
||||
style = {},
|
||||
}: TerminalInputProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const empty = !value;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'var(--bg-2)',
|
||||
border: `1px solid ${focused ? 'var(--border-hi)' : 'var(--border)'}`,
|
||||
borderRadius: 6,
|
||||
padding: '11px 14px',
|
||||
transition: 'border-color .15s',
|
||||
boxShadow: focused ? '0 0 0 1px rgba(0,212,160,.25)' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mono"
|
||||
style={{ color: amber ? 'var(--amber)' : 'var(--g-a)', fontWeight: 700, fontSize: 14 }}
|
||||
>
|
||||
{prompt}
|
||||
</span>
|
||||
<div style={{ position: 'relative', flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<input
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange && onChange(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder={placeholder}
|
||||
className={mono ? 'mono' : ''}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: 'var(--text-0)',
|
||||
fontSize: 14,
|
||||
letterSpacing: '0.02em',
|
||||
fontFamily: mono ? 'var(--mono)' : 'var(--sans)',
|
||||
}}
|
||||
/>
|
||||
{empty && (
|
||||
<span
|
||||
className={'phosphor-cursor' + (amber ? ' amber' : '')}
|
||||
style={{ position: 'absolute', left: 1 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
type TickerItem = {
|
||||
tag: string;
|
||||
text: string;
|
||||
/** Tag color (default amber). */
|
||||
color?: string;
|
||||
/** Amber glow on the tag. */
|
||||
glow?: boolean;
|
||||
/** Movement delta, e.g. "▲+1.5" (green) or "▼-0.5" (red). */
|
||||
delta?: string;
|
||||
};
|
||||
|
||||
type TickerProps = {
|
||||
items: TickerItem[];
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrolling marquee (§5). Continuous `ticker-scroll`; content duplicated so the
|
||||
* loop is seamless. Edge fades mask the wrap. Tags glow; values stay crisp.
|
||||
*/
|
||||
export default function Ticker({ items, height = 34 }: TickerProps) {
|
||||
const content = items.map((it, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '0 26px',
|
||||
fontSize: 12,
|
||||
letterSpacing: '0.04em',
|
||||
color: 'var(--text-1)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: it.color || 'var(--amber)',
|
||||
textShadow: it.glow ? 'var(--amber-glow)' : 'none',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{it.tag}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-0)' }}>{it.text}</span>
|
||||
{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}
|
||||
</div>
|
||||
<div style={{ position: 'absolute', left: 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties, ReactNode, MouseEvent } from 'react';
|
||||
|
||||
type Variant = 'primary' | 'outline' | 'ghost' | 'amber' | 'danger';
|
||||
|
||||
type VBtnProps = {
|
||||
children: ReactNode;
|
||||
variant?: Variant;
|
||||
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
small?: boolean;
|
||||
style?: CSSProperties;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const VARIANTS: Record<Variant, CSSProperties> = {
|
||||
primary: { background: 'var(--g-a)', color: '#04140f', borderColor: 'var(--g-a)' },
|
||||
outline: { background: 'transparent', color: 'var(--text-0)', borderColor: 'var(--border-hi)' },
|
||||
ghost: { background: 'transparent', color: 'var(--text-1)', borderColor: 'transparent' },
|
||||
amber: { background: 'transparent', color: 'var(--amber)', borderColor: 'var(--amber)' },
|
||||
danger: { background: 'transparent', color: 'var(--miss)', borderColor: 'var(--miss)' },
|
||||
};
|
||||
|
||||
/** Button (§5). primary = grade-green filled. Inter, never glitches. */
|
||||
export default function VBtn({
|
||||
children,
|
||||
variant = 'primary',
|
||||
onClick,
|
||||
small = false,
|
||||
style = {},
|
||||
type = 'button',
|
||||
disabled = false,
|
||||
}: VBtnProps) {
|
||||
const base: CSSProperties = {
|
||||
fontFamily: 'var(--sans)',
|
||||
fontWeight: 700,
|
||||
fontSize: small ? 12.5 : 14,
|
||||
padding: small ? '8px 14px' : '11px 20px',
|
||||
borderRadius: 6,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
border: '1px solid transparent',
|
||||
transition: 'all .15s',
|
||||
letterSpacing: '0.01em',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
style={{ ...base, ...VARIANTS[variant], ...style }}
|
||||
onMouseEnter={(e) => {
|
||||
if (variant === 'outline') e.currentTarget.style.borderColor = 'var(--g-a)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (variant === 'outline') e.currentTarget.style.borderColor = 'var(--border-hi)';
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const SIZES: Record<string, number> = { sm: 18, md: 22, lg: 34 };
|
||||
|
||||
type WordmarkProps = {
|
||||
/** Pixel size or a named variant (sm 18 / md 22 / lg 34). */
|
||||
size?: number | 'sm' | 'md' | 'lg';
|
||||
/** Render the live blinking phosphor caret after the R. */
|
||||
cursor?: boolean;
|
||||
/** Render the BETA clearance pill. */
|
||||
beta?: boolean;
|
||||
style?: CSSProperties;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* VYNDR 2.0 signature wordmark (§4). Inter 800 with RGB chromatic-aberration
|
||||
* (::before #ff2d6f / ::after #2dffd5), a permanently grade-green glowing R,
|
||||
* and a blinking caret. The `wm-tear` glitch fires twice per 5s cycle — restraint
|
||||
* is the premium. Appears EVERYWHERE the name does. Data never glitches; this does.
|
||||
*/
|
||||
export default function Wordmark({
|
||||
size = 'md',
|
||||
cursor = true,
|
||||
beta = false,
|
||||
style = {},
|
||||
ariaLabel = 'VYNDR',
|
||||
}: WordmarkProps) {
|
||||
const px = typeof size === 'number' ? size : SIZES[size] ?? SIZES.md;
|
||||
return (
|
||||
<span
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: Math.round(px * 0.55) }}
|
||||
role="img"
|
||||
aria-label={beta ? `${ariaLabel} beta` : ariaLabel}
|
||||
>
|
||||
<span className="wm" data-text="VYNDR" style={{ fontSize: px, ...style }} aria-hidden>
|
||||
VYND<span className="wm-r">R</span>
|
||||
{cursor && <span className="wm-cursor" />}
|
||||
</span>
|
||||
{beta && <span className="wm-beta" aria-hidden>BETA</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* VYNDR 2.0 shared components (§5). Import from '@/components/vyndr'. */
|
||||
export { default as Wordmark } from './Wordmark';
|
||||
export { default as GradeBadge } from './GradeBadge';
|
||||
export { default as SportBadge } from './SportBadge';
|
||||
export { default as TerminalInput } from './TerminalInput';
|
||||
export { default as SectionHead } from './SectionHead';
|
||||
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 {
|
||||
GRADE_COLORS,
|
||||
GRADE_HEX,
|
||||
SPORT,
|
||||
GRADE_BADGE_SIZES,
|
||||
gradeColor,
|
||||
gradeHex,
|
||||
gradeBadgeSize,
|
||||
} from '@/lib/vyndrTokens';
|
||||
@@ -0,0 +1,53 @@
|
||||
/* ============================================================
|
||||
VYNDR 2.0 — design-system token helpers (§5).
|
||||
Plain CommonJS so the .tsx components import it AND the Jest
|
||||
suite can require it directly (no TS/Babel transform needed).
|
||||
Every value resolves to a CSS custom property from globals.css.
|
||||
============================================================ */
|
||||
|
||||
/* Grade → CSS variable (the product's core color language). */
|
||||
const GRADE_COLORS = {
|
||||
'A+': 'var(--g-ap)', A: 'var(--g-a)', 'A-': 'var(--g-a)',
|
||||
'B+': 'var(--g-b)', B: 'var(--g-b)', 'B-': 'var(--g-b)',
|
||||
C: 'var(--g-c)', D: 'var(--g-d)',
|
||||
};
|
||||
|
||||
/* Grade → raw hex (for SVG fills / contexts where a var won't resolve). */
|
||||
const GRADE_HEX = {
|
||||
'A+': '#00ffb8', A: '#00d4a0', 'A-': '#00d4a0',
|
||||
'B+': '#4a9eff', B: '#4a9eff', 'B-': '#4a9eff',
|
||||
C: '#ffb347', D: '#ff5252',
|
||||
};
|
||||
|
||||
/* Sport config map. */
|
||||
const SPORT = {
|
||||
nba: { label: 'NBA', color: 'var(--s-nba)', hex: '#e94b3c' },
|
||||
mlb: { label: 'MLB', color: 'var(--s-mlb)', hex: '#1e90ff' },
|
||||
wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' },
|
||||
soccer: { label: 'SOC', color: 'var(--s-soccer)', hex: '#3ddc84' },
|
||||
};
|
||||
|
||||
/* GradeBadge size variants — hero stays 80–120px (§5: grade letter is
|
||||
always the largest, first-read element on a card). Numbers pass through. */
|
||||
const GRADE_BADGE_SIZES = { sm: 16, md: 30, lg: 48, hero: 100 };
|
||||
|
||||
function gradeColor(g) {
|
||||
return GRADE_COLORS[g] || 'var(--text-0)';
|
||||
}
|
||||
function gradeHex(g) {
|
||||
return GRADE_HEX[g] || '#e8e8f0';
|
||||
}
|
||||
function gradeBadgeSize(size) {
|
||||
if (typeof size === 'number') return size;
|
||||
return GRADE_BADGE_SIZES[size] || GRADE_BADGE_SIZES.md;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GRADE_COLORS,
|
||||
GRADE_HEX,
|
||||
SPORT,
|
||||
GRADE_BADGE_SIZES,
|
||||
gradeColor,
|
||||
gradeHex,
|
||||
gradeBadgeSize,
|
||||
};
|
||||
Reference in New Issue
Block a user