From a74b5dd1edd8e8103ec493ba65695251dfdf0af3 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 15 Jun 2026 22:58:57 -0400 Subject: [PATCH] =?UTF-8?q?Session=2033:=20Design=20system=20Phase=20A+B?= =?UTF-8?q?=20=E2=80=94=20tokens,=20fonts,=20glitch=20CSS,=20shared=20comp?= =?UTF-8?q?onents=20(1792=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BUILD-STATE.md | 82 +++- CLAUDE.md | 30 ++ tests/unit/vyndrDesignSystem.test.js | 185 +++++++++ web/src/app/globals.css | 433 ++++++++++++++++++++- web/src/app/layout.tsx | 4 +- web/src/components/vyndr/Card.tsx | 27 ++ web/src/components/vyndr/GradeBadge.tsx | 46 +++ web/src/components/vyndr/SectionHead.tsx | 32 ++ web/src/components/vyndr/Sparkline.tsx | 32 ++ web/src/components/vyndr/SportBadge.tsx | 32 ++ web/src/components/vyndr/TerminalInput.tsx | 81 ++++ web/src/components/vyndr/Ticker.tsx | 76 ++++ web/src/components/vyndr/VBtn.tsx | 68 ++++ web/src/components/vyndr/Wordmark.tsx | 43 ++ web/src/components/vyndr/index.ts | 20 + web/src/lib/vyndrTokens.js | 53 +++ 16 files changed, 1234 insertions(+), 10 deletions(-) create mode 100644 tests/unit/vyndrDesignSystem.test.js create mode 100644 web/src/components/vyndr/Card.tsx create mode 100644 web/src/components/vyndr/GradeBadge.tsx create mode 100644 web/src/components/vyndr/SectionHead.tsx create mode 100644 web/src/components/vyndr/Sparkline.tsx create mode 100644 web/src/components/vyndr/SportBadge.tsx create mode 100644 web/src/components/vyndr/TerminalInput.tsx create mode 100644 web/src/components/vyndr/Ticker.tsx create mode 100644 web/src/components/vyndr/VBtn.tsx create mode 100644 web/src/components/vyndr/Wordmark.tsx create mode 100644 web/src/components/vyndr/index.ts create mode 100644 web/src/lib/vyndrTokens.js diff --git a/BUILD-STATE.md b/BUILD-STATE.md index d99ebbb..10e9280 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 7f3b5d3..bb7fe44 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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): `` 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) diff --git a/tests/unit/vyndrDesignSystem.test.js b/tests/unit/vyndrDesignSystem.test.js new file mode 100644 index 0000000..f1efada --- /dev/null +++ b/tests/unit/vyndrDesignSystem.test.js @@ -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(' { + const src = comp('Ticker.tsx'); + expect(src).toContain('ticker-track'); + expect(src).toMatch(/\{content\}\s*\{content\}/); + }); +}); diff --git a/web/src/app/globals.css b/web/src/app/globals.css index db7e698..805d702 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -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 ) + ============================================================ */ +: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; } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 1e7d0fa..9f49046 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -107,8 +107,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo + {/* 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. */} diff --git a/web/src/components/vyndr/Card.tsx b/web/src/components/vyndr/Card.tsx new file mode 100644 index 0000000..99dc7fc --- /dev/null +++ b/web/src/components/vyndr/Card.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/web/src/components/vyndr/GradeBadge.tsx b/web/src/components/vyndr/GradeBadge.tsx new file mode 100644 index 0000000..59aaa4d --- /dev/null +++ b/web/src/components/vyndr/GradeBadge.tsx @@ -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 ( + 1 ? '-0.02em' : '0', + lineHeight: 1, + }} + > + {grade} + + ); +} diff --git a/web/src/components/vyndr/SectionHead.tsx b/web/src/components/vyndr/SectionHead.tsx new file mode 100644 index 0000000..290b44c --- /dev/null +++ b/web/src/components/vyndr/SectionHead.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/web/src/components/vyndr/Sparkline.tsx b/web/src/components/vyndr/Sparkline.tsx new file mode 100644 index 0000000..eb63366 --- /dev/null +++ b/web/src/components/vyndr/Sparkline.tsx @@ -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 ( + + + + + ); +} diff --git a/web/src/components/vyndr/SportBadge.tsx b/web/src/components/vyndr/SportBadge.tsx new file mode 100644 index 0000000..2683d7d --- /dev/null +++ b/web/src/components/vyndr/SportBadge.tsx @@ -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 ( + + {s.label} + + ); +} diff --git a/web/src/components/vyndr/TerminalInput.tsx b/web/src/components/vyndr/TerminalInput.tsx new file mode 100644 index 0000000..65765d9 --- /dev/null +++ b/web/src/components/vyndr/TerminalInput.tsx @@ -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 ( +
+ + {prompt} + +
+ 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 && ( + + )} +
+
+ ); +} diff --git a/web/src/components/vyndr/Ticker.tsx b/web/src/components/vyndr/Ticker.tsx new file mode 100644 index 0000000..4ac1945 --- /dev/null +++ b/web/src/components/vyndr/Ticker.tsx @@ -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) => ( + + + {it.tag} + + {it.text} + {it.delta && ( + + {it.delta} + + )} + · + + )); + return ( +
+
+ {content} + {content} +
+
+
+
+ ); +} diff --git a/web/src/components/vyndr/VBtn.tsx b/web/src/components/vyndr/VBtn.tsx new file mode 100644 index 0000000..d4905f4 --- /dev/null +++ b/web/src/components/vyndr/VBtn.tsx @@ -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) => void; + small?: boolean; + style?: CSSProperties; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; +}; + +const VARIANTS: Record = { + 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 ( + + ); +} diff --git a/web/src/components/vyndr/Wordmark.tsx b/web/src/components/vyndr/Wordmark.tsx new file mode 100644 index 0000000..bcac4ac --- /dev/null +++ b/web/src/components/vyndr/Wordmark.tsx @@ -0,0 +1,43 @@ +import type { CSSProperties } from 'react'; + +const SIZES: Record = { 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 ( + + + VYNDR + {cursor && } + + {beta && BETA} + + ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts new file mode 100644 index 0000000..423729a --- /dev/null +++ b/web/src/components/vyndr/index.ts @@ -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'; diff --git a/web/src/lib/vyndrTokens.js b/web/src/lib/vyndrTokens.js new file mode 100644 index 0000000..a506bee --- /dev/null +++ b/web/src/lib/vyndrTokens.js @@ -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, +};