Session 38: Design system Phase G — living layer, i18n/odds, a11y, paywall, parlay math (1890 tests)

VYNDR 2.0 conversion, Phase G (the systems that make the design alive). All 5
wired. Frontend-only; zero backend changes.

- lib/parlayMath.js: correlation model (0.62/0.34/0.06/0) + parlayGrade penalty
  + grade->odds + combined odds (frontend; backend parlayService unchanged).
- lib/oddsFormat.js: fmtOdds across american/decimal/fractional/implied with the
  totals-pass-through rule (safer than the prototype's parseAm, which would
  mis-convert 228.5) + region presets.
- lib/prefs.js: applyPrefs sets <html data-*> (the S33 a11y CSS layer) + load/save.
- lib/liveTick.js: single tick engine (SSR/test-safe, no auto-start, fresh state).
- lib/checkout.js: checkoutUrl(plan).
- LiveLayer (useLive/LiveNumber/HeartbeatBar) under the Nav ticker; GlobalHosts in
  layout applies prefs + registers __prefs/__goPaywall/__checkout + hosts the
  Preferences and Paywall modals. Nav read-meter is now a paywall trigger.

Gotchas: useEffect can't return a Set.delete unsub directly (boolean != cleanup);
header grew to 124px so layout paddingTop + Slate sticky-top updated to match.

18 new tests. Backend 1872 -> 1890, 146 suites, zero regressions. Web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-16 10:37:31 -04:00
parent f88961885c
commit 956a7455eb
13 changed files with 949 additions and 8 deletions
+80 -2
View File
@@ -4,8 +4,86 @@
2026-06-16 2026-06-16
## Current Phase ## Current Phase
SHIP BUILD v37.0 — VYNDR 2.0 design system, Phase F: mobile parity — SHIP BUILD v38.0 — VYNDR 2.0 design system, Phase G: systems — living layer,
5-tab bar, More sheet, mobile CSS, PWA manifest/viewport (Session 37) i18n/odds, a11y toggles, paywall/checkout, parlay correlation math (Session 38)
## Session 38 (2026-06-16) — SHIPPED
Phase G: the systems that make the design alive. All 5 wired (minimal-wire path
where a full path risked regressions, per the scope guidance). Frontend-only;
ZERO backend changes. Backend 1872 → **1890 tests** (+18), 146 suites, zero
regressions. Web build clean (exit 0).
### Testable CommonJS lib modules (the reliable surface)
- **`lib/parlayMath.js`** (§12) — `getCorrelation` (same player 0.62 / same team
0.34 / same league 0.06 / cross-sport 0), `parlayGrade` (averages leg grades +
bumps the slip down a tier when any pair correlates >0.4), `GRADE_ODDS`,
amToDec/decToAm, `combinedDecimal`/`combinedAmerican`, `correlationPairs`.
Frontend model that powers the Parlay Lab matrix; the BACKEND parlayService
(Session 28) still owns server combined odds + suggestions.
- **`lib/oddsFormat.js`** (§9) — `fmtOdds(value, format)` (american/decimal/
fractional/implied), `REGIONS`/`CURRENCIES`/`regionPreset`. **Deliberately
SAFER than the prototype's `parseAm`**, whose regex accepted decimals and
would mis-convert O/U totals (228.5 → fake implied %). Our `parseMoneyline`
only treats explicitly-signed integer STRINGS (+150/-110) and integer NUMBERS
(the grade→odds map) as odds; totals/lines/spreads ("228.5","229","-7.5") pass
through unchanged. (Honest spec-over-prototype correction, like the S32 NFL keys.)
- **`lib/prefs.js`** (§10) — `applyPrefs` sets `<html data-motion|contrast|text|
cb|font>` (the CSS layer from Session 33 keys off these), `loadPrefs`/
`savePrefs` to `localStorage('vyndr_prefs')`. Injectable element + storage →
fully unit-tested.
- **`lib/liveTick.js`** (§8) — single tick store, fan-out subscribers. Does NOT
auto-start on import (SSR/test-safe); `start()` begins the 1s interval (unref'd),
`tick()` advances + emits a FRESH state object (so React re-renders); `reset()`
for tests.
- **`lib/checkout.js`** (§12) — `checkoutUrl(plan)` → `/api/checkout?tier=…`.
### React glue + wiring
- **`components/vyndr/LiveLayer.tsx`** — `useLive()` hook (starts the shared tick,
one interval many subscribers), `LiveNumber` (count-tick pop on change),
`HeartbeatBar` (scrolling EKG + SIGNAL LIVE + live graded count + breathing
neural % + sync clock). Mounted under the Ticker in the Nav.
- **`components/vyndr/GlobalHosts.tsx`** — mounted once in layout. On mount:
applies stored prefs to `<html>`, registers `window.__prefs` / `__goPaywall` /
`__checkout`. Hosts the **Preferences modal** (region→odds+currency cascade,
odds format, text size, reduce-motion, high-contrast, colorblind, readable
font — persisted) and the **Paywall modal** (Analyst/Desk tiers → `__checkout`).
- **Nav** — mounts HeartbeatBar; added a globe **prefs trigger** (`__prefs`); the
free-tier read meter is now a button → `__goPaywall` (the §12 live paywall
trigger). Header is now nav 60 + ticker 32 + heartbeat 30 → layout `main`
paddingTop 96 → **124**; Slate sticky header `top` 64 → 122 to match.
### Animations gated behind reduced-motion
The living-layer classes (ekg-track, live-dot, count-tick, etc.) are already in
the Session-33 reduced-motion kill list (both `prefers-reduced-motion` and
`html[data-motion="reduced"]`), which the prefs toggle now sets. The tick still
fires (data updates); only the animation is killed.
### Files created
- `web/src/lib/{parlayMath,oddsFormat,prefs,liveTick,checkout}.js`
- `web/src/components/vyndr/{LiveLayer,GlobalHosts}.tsx`
- `tests/unit/vyndrSystems.test.js` (18 tests: correlation tiers + grade penalty
+ odds combine, odds formats + totals-pass-through + region presets, applyPrefs
+ storage round-trip, tick fan-out/increment, checkout URL, React-glue wiring)
### Files modified
- `web/src/app/layout.tsx` (GlobalHosts mount + paddingTop), `web/src/components/
Nav.tsx` (HeartbeatBar + prefs + paywall meter), `web/src/components/Slate.tsx`
(sticky top)
### Notes / gotchas
- BUILD GOTCHA (caught + fixed): a `useEffect` returning `liveTick.subscribe(...)`
directly failed type-check — `Set.delete` returns boolean, not a valid effect
cleanup. Wrapped in `() => { unsub(); }`.
- FLAKY (pre-existing, NOT this session): one full-suite run showed 1 failing
backend test (computeFeatures/oddsService async-timing warnings); it passed on
re-run (1890/1890 twice). Unrelated to these frontend changes — flagged for a
future stabilization pass.
- Did NOT touch the PWA/service worker (Session 27/37 own it) per the no-overlap rule.
---
## Session 37 (2026-06-16) — SHIPPED
## Session 37 (2026-06-16) — SHIPPED ## Session 37 (2026-06-16) — SHIPPED
+22
View File
@@ -254,6 +254,28 @@ The frame every page sits in. Frontend-only.
Use a shared interface. And the build worker exits code 1 on type errors — Use a shared interface. And the build worker exits code 1 on type errors —
check the build EXIT CODE, not just a `| tail` of its output. check the build EXIT CODE, not just a `| tail` of its output.
## VYNDR 2.0 Systems (Session 38 — Phase G)
Testable CommonJS modules in `lib/` + thin React glue:
- **`lib/parlayMath.js`** — frontend correlation model (player 0.62 / team 0.34 /
league 0.06 / cross-sport 0), `parlayGrade` penalty, grade→odds. Backend
parlayService (S28) still owns server combined odds.
- **`lib/oddsFormat.js`** — `fmtOdds(value, format)`. CRITICAL: only signed-int
strings + integer numbers are odds; totals/lines/spreads pass through UNCHANGED
(`parseMoneyline`). This is intentionally stricter than the prototype's parseAm
(which would mis-convert 228.5) — keep it that way.
- **`lib/prefs.js`** — `applyPrefs` sets `<html data-*>` (the S33 CSS layer keys
off these), load/save to `localStorage('vyndr_prefs')`.
- **`lib/liveTick.js`** — single tick store; never auto-starts (SSR/test-safe),
`start()` runs the unref'd 1s interval, `tick()` emits a fresh state object.
- **`lib/checkout.js`** — `checkoutUrl(plan)`.
- **`components/vyndr/LiveLayer.tsx`** (`useLive`/`LiveNumber`/`HeartbeatBar`) +
**`GlobalHosts.tsx`** (mounted in layout — applies prefs, registers
`window.__prefs`/`__goPaywall`/`__checkout`, hosts Prefs + Paywall modals).
- Header is now 124px tall (nav 60 + ticker 32 + heartbeat 30): layout `main`
paddingTop = 124, Slate sticky `top` = 122. Keep them in sync if header changes.
- GOTCHA: don't return a `Set.delete`-based unsub directly from `useEffect`
(returns boolean ≠ valid cleanup) — wrap as `() => { unsub(); }`.
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+174
View File
@@ -0,0 +1,174 @@
// VYNDR 2.0 — Phase G systems (Session 38): living layer, i18n/odds, a11y
// prefs, paywall/checkout, parlay correlation math. Pure logic runs directly
// via the CommonJS modules; the .tsx glue is asserted against source text.
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 parlay = require('../../web/src/lib/parlayMath');
const odds = require('../../web/src/lib/oddsFormat');
const prefs = require('../../web/src/lib/prefs');
const { liveTick } = require('../../web/src/lib/liveTick');
const { checkoutUrl } = require('../../web/src/lib/checkout');
describe('Phase G.5 — parlay correlation math (§12)', () => {
const jokicPts = { player: 'Jokic', team: 'DEN', sport: 'nba', grade: 'A' };
const jokicReb = { player: 'Jokic', team: 'DEN', sport: 'nba', grade: 'A' };
const murray = { player: 'Murray', team: 'DEN', sport: 'nba', grade: 'A' };
const judge = { player: 'Judge', team: 'NYY', sport: 'mlb', grade: 'A' };
const tatum = { player: 'Tatum', team: 'BOS', sport: 'nba', grade: 'A' };
it('encodes the correlation tiers exactly', () => {
expect(parlay.getCorrelation(jokicPts, jokicReb)).toBe(0.62); // same player
expect(parlay.getCorrelation(jokicPts, murray)).toBe(0.34); // same team+sport
expect(parlay.getCorrelation(jokicPts, tatum)).toBe(0.06); // same league
expect(parlay.getCorrelation(jokicPts, judge)).toBe(0); // cross-sport
});
it('parlayGrade penalizes a correlated stack vs an independent one', () => {
const correlated = parlay.parlayGrade([jokicPts, jokicReb]); // 0.62 → penalty
const independent = parlay.parlayGrade([judge, tatum]); // cross-sport, no penalty
const order = parlay.GRADE_ORDER;
expect(order.indexOf(correlated)).toBeGreaterThan(order.indexOf(independent));
expect(independent).toBe('A');
});
it('converts American↔decimal correctly', () => {
expect(parlay.amToDec(110)).toBeCloseTo(2.1, 3);
expect(parlay.amToDec(-135)).toBeCloseTo(1.741, 2);
expect(parlay.decToAm(2.1)).toBe(110);
});
it('maps grades to representative odds and combines a slip', () => {
expect(parlay.GRADE_ODDS['A+']).toBe(-135);
const dec = parlay.combinedDecimal([jokicPts, judge]); // 110 × 110
expect(dec).toBeCloseTo(2.1 * 2.1, 3);
expect(parlay.combinedAmerican([])).toBe('—');
expect(parlay.combinedAmerican([jokicPts])).toMatch(/^[+-]\d+$/);
});
});
describe('Phase G.2 — odds formatting (§9, the totals-pass-through rule)', () => {
it('converts moneylines across formats', () => {
expect(odds.fmtOdds('+150', 'decimal')).toBe('2.50');
expect(odds.fmtOdds('-110', 'decimal')).toBe('1.91');
expect(odds.fmtOdds('+150', 'fractional')).toBe('3/2');
expect(odds.fmtOdds('-110', 'implied')).toBe('52%');
expect(odds.fmtOdds('+150', 'american')).toBe('+150');
});
it('treats integer NUMBERS (the grade→odds map) as moneylines', () => {
expect(odds.fmtOdds(110, 'american')).toBe('+110');
expect(odds.fmtOdds(-135, 'decimal')).toBe('1.74');
});
it('CRITICAL: totals / lines pass through UNCHANGED', () => {
expect(odds.fmtOdds('228.5', 'decimal')).toBe('228.5'); // half-point total
expect(odds.fmtOdds('229', 'implied')).toBe('229'); // unsigned integer total
expect(odds.fmtOdds('-7.5', 'american')).toBe('-7.5'); // spread
expect(odds.fmtOdds(228.5, 'decimal')).toBe(228.5); // non-integer number
});
it('region presets set odds format + currency', () => {
expect(odds.regionPreset('UK')).toMatchObject({ odds: 'fractional', currency: 'GBP', currencySymbol: '£' });
expect(odds.regionPreset('EU').odds).toBe('decimal');
expect(odds.regionPreset('US').odds).toBe('american');
});
});
describe('Phase G.3 — accessibility prefs (§10)', () => {
function fakeEl() {
const attrs = {};
return {
attrs,
setAttribute: (k, v) => { attrs[k] = v; },
removeAttribute: (k) => { delete attrs[k]; },
};
}
function fakeStore() {
const m = {};
return { getItem: (k) => (k in m ? m[k] : null), setItem: (k, v) => { m[k] = String(v); } };
}
it('applyPrefs sets the data-* attributes the CSS layer keys off', () => {
const el = fakeEl();
prefs.applyPrefs({ contrast: 'high', motion: 'reduced', text: 'xl', cb: 'on', font: 'readable' }, el);
expect(el.attrs['data-contrast']).toBe('high');
expect(el.attrs['data-motion']).toBe('reduced');
expect(el.attrs['data-text']).toBe('xl');
expect(el.attrs['data-cb']).toBe('1');
expect(el.attrs['data-font']).toBe('readable');
});
it('applyPrefs removes attributes for default values', () => {
const el = fakeEl();
el.setAttribute('data-contrast', 'high');
prefs.applyPrefs({ contrast: 'off', text: 'base' }, el);
expect(el.attrs['data-contrast']).toBeUndefined();
expect(el.attrs['data-text']).toBeUndefined();
});
it('prefs round-trip through storage', () => {
const store = fakeStore();
prefs.savePrefs({ contrast: 'high', region: 'UK' }, store);
const loaded = prefs.loadPrefs(store);
expect(loaded.contrast).toBe('high');
expect(loaded.region).toBe('UK');
expect(loaded.lang).toBe('en'); // defaults merged
});
});
describe('Phase G.1 — living-layer tick engine (§8)', () => {
beforeEach(() => liveTick.reset());
afterEach(() => liveTick.reset());
it('fans out one tick to all subscribers', () => {
let calls = 0;
const unsub = liveTick.subscribe(() => { calls += 1; });
liveTick.tick();
liveTick.tick();
expect(calls).toBe(2);
unsub();
liveTick.tick();
expect(calls).toBe(2); // unsubscribed
});
it('advances tick and creeps the graded count, with a fresh state object', () => {
const first = liveTick.state;
for (let i = 0; i < 7; i++) liveTick.tick();
expect(liveTick.state.tick).toBe(7);
expect(liveTick.state.graded).toBeGreaterThan(first.graded);
expect(liveTick.state).not.toBe(first); // new object → React re-renders
});
});
describe('Phase G.4 — checkout (§12)', () => {
it('maps a plan tier to the checkout URL', () => {
expect(checkoutUrl('analyst')).toBe('/api/checkout?tier=analyst');
expect(checkoutUrl('desk')).toBe('/api/checkout?tier=desk');
expect(checkoutUrl('garbage')).toBe('/api/checkout?tier=analyst'); // safe default
});
});
describe('Phase G — React glue wired correctly', () => {
it('HeartbeatBar consumes the live tick + renders SIGNAL LIVE', () => {
const src = read('components/vyndr/LiveLayer.tsx');
expect(src).toContain('useLive');
expect(src).toContain('SIGNAL LIVE');
expect(src).toContain('ekg-track');
expect(src).toContain('count-tick'); // LiveNumber pop
});
it('GlobalHosts registers the design globals + applies prefs on mount', () => {
const src = read('components/vyndr/GlobalHosts.tsx');
expect(src).toContain('window.__prefs');
expect(src).toContain('window.__goPaywall');
expect(src).toContain('window.__checkout');
expect(src).toContain('applyPrefs');
});
it('Nav mounts the heartbeat, a prefs trigger, and a paywall read-meter', () => {
const src = read('components/Nav.tsx');
expect(src).toContain('<HeartbeatBar');
expect(src).toContain('window.__prefs');
expect(src).toContain('window.__goPaywall');
});
it('layout mounts GlobalHosts', () => {
expect(read('app/layout.tsx')).toContain('<GlobalHosts />');
});
});
+5 -2
View File
@@ -7,6 +7,7 @@ import Nav from '@/components/Nav';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import AuthGate from '@/components/AuthGate'; import AuthGate from '@/components/AuthGate';
import HashRedirect from '@/components/vyndr/HashRedirect'; import HashRedirect from '@/components/vyndr/HashRedirect';
import GlobalHosts from '@/components/vyndr/GlobalHosts';
import ParlayTray from '@/components/ParlayTray'; import ParlayTray from '@/components/ParlayTray';
import BottomTabBar from '@/components/BottomTabBar'; import BottomTabBar from '@/components/BottomTabBar';
import InstallPrompt from '@/components/InstallPrompt'; import InstallPrompt from '@/components/InstallPrompt';
@@ -128,9 +129,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<ParlayProvider> <ParlayProvider>
<HashRedirect /> <HashRedirect />
<Nav /> <Nav />
{/* Header = 60px nav + 32px ticker; offset main so content clears it. */} {/* Header = 60px nav + 32px ticker + 30px heartbeat (§8); offset main. */}
<AuthGate> <AuthGate>
<main style={{ paddingTop: 96, minHeight: '100vh', paddingBottom: 80 }}>{children}</main> <main style={{ paddingTop: 124, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
</AuthGate> </AuthGate>
<Footer /> <Footer />
<ParlayTray /> <ParlayTray />
@@ -141,6 +142,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<MFAChallenge /> <MFAChallenge />
<CookieConsent /> <CookieConsent />
<SentryInit /> <SentryInit />
{/* Session 38 — prefs apply + paywall/checkout/prefs globals (§9/§10/§12) */}
<GlobalHosts />
</ParlayProvider> </ParlayProvider>
</ExplainModeProvider> </ExplainModeProvider>
</AuthProvider> </AuthProvider>
+25 -3
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Wordmark, Ticker } from '@/components/vyndr'; import { Wordmark, Ticker } from '@/components/vyndr';
import { HeartbeatBar } from '@/components/vyndr/LiveLayer';
import NotificationBell from '@/components/NotificationBell'; import NotificationBell from '@/components/NotificationBell';
// Nav labels are English literals for now; nav-string i18n lands in Phase G // Nav labels are English literals for now; nav-string i18n lands in Phase G
// (Session 38) once the locale dictionaries carry slate/terminal/etc. keys. // (Session 38) once the locale dictionaries carry slate/terminal/etc. keys.
@@ -190,21 +191,41 @@ export default function Nav() {
<span>Query</span> <span>Query</span>
</a> </a>
{/* Preferences (language / odds format / accessibility) — §9/§10 */}
<button
onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()}
aria-label="Language & accessibility preferences"
title="Language & accessibility"
style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid var(--border-hi)', background: 'var(--bg-2)', color: 'var(--text-1)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</svg>
</button>
{user && <NotificationBell />} {user && <NotificationBell />}
{user ? ( {user ? (
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}> <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{showReadCounter && scansRemaining != null && tier === 'free' && ( {showReadCounter && scansRemaining != null && tier === 'free' && (
<span // Read meter is a live paywall trigger (§12): tapping it (or
// hitting 0) opens the paywall.
<button
onClick={() => typeof window !== 'undefined' && window.__goPaywall && window.__goPaywall()}
className="mono" className="mono"
aria-label={`${scansRemaining} of 5 reads remaining — upgrade`}
style={{ style={{
fontSize: 11, fontSize: 11,
fontWeight: 700, fontWeight: 700,
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
color: scansRemaining <= 1 ? 'var(--g-c)' : 'var(--text-1)', color: scansRemaining <= 1 ? 'var(--g-c)' : 'var(--text-1)',
}} }}
> >
{scansRemaining}/5 · MO {scansRemaining}/5 · MO
</span> </button>
)} )}
{tier !== 'free' && ( {tier !== 'free' && (
<span <span
@@ -376,8 +397,9 @@ export default function Nav() {
)} )}
</nav> </nav>
{/* Ticker under the bar — sample data; real feed wires in Session 38 */} {/* Ticker + heartbeat under the bar (§8 living layer) */}
<Ticker items={TICKER_ITEMS} height={32} /> <Ticker items={TICKER_ITEMS} height={32} />
<HeartbeatBar />
<style jsx>{` <style jsx>{`
@media (min-width: 768px) { @media (min-width: 768px) {
+1 -1
View File
@@ -555,7 +555,7 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
<div <div
style={{ style={{
position: 'sticky', position: 'sticky',
top: 64, // matches Nav height top: 122, // clears nav (60) + ticker (32) + heartbeat (30) — Session 38
zIndex: 5, zIndex: 5,
background: 'var(--bg-0, #0A0A0F)', background: 'var(--bg-0, #0A0A0F)',
paddingTop: 12, paddingTop: 12,
+208
View File
@@ -0,0 +1,208 @@
'use client';
import { useEffect, useState } from 'react';
import { applyPrefs, loadPrefs, savePrefs, DEFAULTS } from '@/lib/prefs';
import { REGIONS, ODDS_FORMATS, regionPreset } from '@/lib/oddsFormat';
import { checkoutUrl, PLAN_PRICES } from '@/lib/checkout';
import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn';
// Augment the window with the design's global hosts (§12).
declare global {
interface Window {
__prefs?: () => void;
__goPaywall?: () => void;
__checkout?: (plan: string) => void;
}
}
type Prefs = typeof DEFAULTS;
/**
* GlobalHosts (§9/§10/§12) — mounted once in the layout. On mount it:
* - applies stored a11y/i18n prefs to <html> (the CSS layer keys off data-*),
* - registers window.__prefs / __goPaywall / __checkout,
* - hosts the Preferences modal and the Paywall modal.
*/
export default function GlobalHosts() {
const [prefs, setPrefs] = useState<Prefs>(DEFAULTS);
const [prefsOpen, setPrefsOpen] = useState(false);
const [paywallOpen, setPaywallOpen] = useState(false);
useEffect(() => {
const loaded = loadPrefs();
setPrefs(loaded);
applyPrefs(loaded);
window.__prefs = () => setPrefsOpen(true);
window.__goPaywall = () => setPaywallOpen(true);
window.__checkout = (plan: string) => {
window.location.href = checkoutUrl(plan);
};
return () => {
delete window.__prefs;
delete window.__goPaywall;
delete window.__checkout;
};
}, []);
const update = (patch: Partial<Prefs>) => {
setPrefs((prev) => {
let next = { ...prev, ...patch };
// Region preset cascades odds + currency.
if (patch.region) {
const r = regionPreset(patch.region);
next = { ...next, odds: r.odds, currency: r.currency };
}
savePrefs(next);
applyPrefs(next);
return next;
});
};
return (
<>
{prefsOpen && <PrefsModal prefs={prefs} update={update} onClose={() => setPrefsOpen(false)} />}
{paywallOpen && <PaywallModal onClose={() => setPaywallOpen(false)} />}
</>
);
}
const overlay: React.CSSProperties = {
position: 'fixed',
inset: 0,
zIndex: 80,
background: 'rgba(6,6,11,.7)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
};
const panel: React.CSSProperties = {
width: '100%',
maxWidth: 460,
maxHeight: '88vh',
overflowY: 'auto',
background: 'var(--bg-1)',
border: '1px solid var(--border-hi)',
borderRadius: 14,
position: 'relative',
};
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 18px', borderBottom: '1px solid var(--border)' }}>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-0)', letterSpacing: '0.04em' }}>{label}</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>{children}</div>
</div>
);
}
function Seg({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
aria-pressed={active}
className="mono"
style={{
minHeight: 30,
padding: '5px 10px',
fontSize: 11,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.04em',
borderRadius: 6,
cursor: 'pointer',
border: `1px solid ${active ? 'var(--g-a)' : 'var(--border-hi)'}`,
background: active ? 'color-mix(in srgb, var(--g-a) 16%, transparent)' : 'transparent',
color: active ? 'var(--g-a)' : 'var(--text-1)',
}}
>
{children}
</button>
);
}
function PrefsModal({ prefs, update, onClose }: { prefs: Prefs; update: (p: Partial<Prefs>) => void; onClose: () => void }) {
return (
<div className="fade-in" role="dialog" aria-modal="true" aria-label="Preferences" style={overlay} onClick={onClose}>
<div className="scanlines" style={panel} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid var(--border)' }}>
<SectionHead accent="var(--g-a)">PREFERENCES</SectionHead>
<button onClick={onClose} aria-label="Close" style={{ background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, color: 'var(--text-1)', width: 30, height: 30, cursor: 'pointer' }}>×</button>
</div>
<Row label="Region">
{Object.keys(REGIONS).map((r) => (
<Seg key={r} active={prefs.region === r} onClick={() => update({ region: r })}>{r}</Seg>
))}
</Row>
<Row label="Odds format">
{ODDS_FORMATS.map((f) => (
<Seg key={f} active={prefs.odds === f} onClick={() => update({ odds: f })}>{f.slice(0, 4)}</Seg>
))}
</Row>
<Row label="Text size">
{(['sm', 'base', 'lg', 'xl'] as const).map((t) => (
<Seg key={t} active={prefs.text === t} onClick={() => update({ text: t })}>{t === 'base' ? 'M' : t.toUpperCase()}</Seg>
))}
</Row>
<Row label="Reduce motion">
<Seg active={prefs.motion === 'reduced'} onClick={() => update({ motion: prefs.motion === 'reduced' ? 'on' : 'reduced' })}>{prefs.motion === 'reduced' ? 'ON' : 'OFF'}</Seg>
</Row>
<Row label="High contrast">
<Seg active={prefs.contrast === 'high'} onClick={() => update({ contrast: prefs.contrast === 'high' ? 'off' : 'high' })}>{prefs.contrast === 'high' ? 'ON' : 'OFF'}</Seg>
</Row>
<Row label="Colorblind-safe">
<Seg active={prefs.cb === 'on'} onClick={() => update({ cb: prefs.cb === 'on' ? 'off' : 'on' })}>{prefs.cb === 'on' ? 'ON' : 'OFF'}</Seg>
</Row>
<Row label="Readable font">
<Seg active={prefs.font === 'readable'} onClick={() => update({ font: prefs.font === 'readable' ? 'default' : 'readable' })}>{prefs.font === 'readable' ? 'ON' : 'OFF'}</Seg>
</Row>
<div className="mono" style={{ padding: '12px 18px', fontSize: 10.5, color: 'var(--text-2)' }}>
Saved to this device. Totals &amp; stat lines never reformat only odds.
</div>
</div>
</div>
);
}
function PaywallModal({ onClose }: { onClose: () => void }) {
const tiers = [
{ id: 'analyst', name: 'Analyst', price: PLAN_PRICES.analyst, lines: ['Unlimited reads', 'Every signal', 'Kill conditions'] },
{ id: 'desk', name: 'Desk', price: PLAN_PRICES.desk, lines: ['Everything in Analyst', 'Alt-line ladder', 'Full intelligence layer'] },
];
return (
<div className="fade-in" role="dialog" aria-modal="true" aria-label="Upgrade" style={overlay} onClick={onClose}>
<div className="scanlines" style={panel} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid var(--border)' }}>
<SectionHead accent="var(--amber)"> SIGNAL EXHAUSTED</SectionHead>
<button onClick={onClose} aria-label="Close" style={{ background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, color: 'var(--text-1)', width: 30, height: 30, cursor: 'pointer' }}>×</button>
</div>
<div style={{ padding: 18 }}>
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)', lineHeight: 1.6, marginBottom: 16 }}>
You&apos;ve used your free reads. Unlock the full intelligence layer founder pricing locks for life.
</p>
<div style={{ display: 'grid', gap: 12 }}>
{tiers.map((t) => (
<div key={t.id} style={{ border: '1px solid var(--border-hi)', borderRadius: 10, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ fontSize: 16, fontWeight: 800 }}>{t.name}</span>
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: 'var(--g-a)' }}>{t.price}<span style={{ fontSize: 11, color: 'var(--text-2)' }}>/mo</span></span>
</div>
{t.lines.map((l) => (
<div key={l} className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginBottom: 4 }}>· {l}</div>
))}
<VBtn variant={t.id === 'analyst' ? 'primary' : 'outline'} style={{ width: '100%', marginTop: 12 }} onClick={() => window.__checkout && window.__checkout(t.id)}>
Go {t.name}
</VBtn>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { liveTick } from '@/lib/liveTick';
/** Subscribe to the single living-layer tick (§8). Starts the shared interval
* on first mount; many components can call this — one interval, fan-out. */
export function useLive() {
const [s, setS] = useState(liveTick.state);
useEffect(() => {
liveTick.start();
const unsub = liveTick.subscribe(setS);
return () => {
unsub();
};
}, []);
return s;
}
/** A number that pops (count-tick) when its value changes. Data, not chrome —
* the pop is a one-shot transform, killed under reduced-motion by the CSS. */
export function LiveNumber({ value, className = '', style = {} }: { value: number | string; className?: string; style?: CSSProperties }) {
const [pop, setPop] = useState(false);
const prev = useRef(value);
useEffect(() => {
if (prev.current !== value) {
prev.current = value;
setPop(true);
const t = setTimeout(() => setPop(false), 500);
return () => clearTimeout(t);
}
}, [value]);
return (
<span className={`mono ${pop ? 'count-tick' : ''} ${className}`} style={{ display: 'inline-block', ...style }}>
{value}
</span>
);
}
const EKG = 'M0 12 H10 L13 4 L16 20 L19 12 H30 L33 9 L36 15 L39 12 H50';
/** The system heartbeat (§8): scrolling EKG + SIGNAL LIVE + graded count +
* breathing neural % + sync clock. Sits under the ticker. */
export function HeartbeatBar() {
const live = useLive();
const clock = `${String(Math.floor(live.tick / 60) % 60).padStart(2, '0')}:${String(live.tick % 60).padStart(2, '0')}`;
return (
<div
className="scanlines"
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
height: 30,
padding: '0 16px',
background: 'var(--bg-0)',
borderBottom: '1px solid var(--border)',
overflow: 'hidden',
fontSize: 10.5,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span className="live-dot" style={{ background: 'var(--g-a)' }} />
<span className="mono" style={{ color: 'var(--g-a)', fontWeight: 700, letterSpacing: '0.12em' }}>SIGNAL LIVE</span>
</span>
{/* EKG strip */}
<div style={{ flex: 1, overflow: 'hidden', height: 22, maskImage: 'linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent)' }}>
<div className="ekg-track" style={{ height: 22 }}>
{[0, 1].map((k) => (
<svg key={k} width="200" height="22" viewBox="0 0 200 22" style={{ display: 'block' }} aria-hidden>
{[0, 50, 100, 150].map((x) => (
<path key={x} d={EKG} transform={`translate(${x},0)`} fill="none" stroke="var(--g-a)" strokeWidth="1.2" opacity="0.55" />
))}
</svg>
))}
</div>
</div>
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
<LiveNumber value={live.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
</span>
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
neural <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{live.neural}%</span>
</span>
<span className="mono" style={{ color: 'var(--text-2)', flexShrink: 0 }}>SYNC {clock}</span>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
/* VYNDR 2.0 — checkout helper (§12). CommonJS so the paywall imports it
AND Jest verifies the tier→URL mapping. */
const PLAN_PRICES = { analyst: '$14.99', desk: '$44.99' };
/** Build the Stripe checkout URL for a plan tier. */
function checkoutUrl(plan) {
const tier = plan === 'desk' ? 'desk' : 'analyst';
return `/api/checkout?tier=${tier}`;
}
module.exports = { checkoutUrl, PLAN_PRICES };
+66
View File
@@ -0,0 +1,66 @@
/* ============================================================
VYNDR 2.0 — living-layer tick engine (§8).
ONE interval, many subscribers (fan-out). Drives HeartbeatBar,
LiveNumber, NeuralBrain. Plain CommonJS so the React hook imports it
AND Jest drives it manually via tick().
Does NOT auto-start on import (would run in SSR/tests). The React hook
calls start() on mount; tests call tick() directly. Animations are gated
behind reduced-motion in the CSS layer (Session 33), not here — the tick
only updates data.
============================================================ */
const INITIAL = { tick: 0, graded: 247, neural: 96, aPlus: 6, cascades: 11 };
const liveTick = {
listeners: new Set(),
state: { ...INITIAL },
_timer: null,
subscribe(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
},
_notify() {
for (const fn of this.listeners) fn(this.state);
},
/** Advance one tick. New state object so React subscribers re-render. */
tick() {
const prev = this.state;
const t = prev.tick + 1;
this.state = {
tick: t,
// graded count creeps up roughly every ~7s
graded: prev.graded + (t % 7 === 0 ? 1 : 0),
// neural % "breathes" 9098 deterministically (sin-driven, test-safe)
neural: 94 + Math.round(Math.sin(t / 3) * 4),
aPlus: prev.aPlus + (t % 23 === 0 ? 1 : 0),
cascades: prev.cascades,
};
this._notify();
return this.state;
},
start(intervalMs = 1000) {
if (this._timer || typeof setInterval === 'undefined') return;
this._timer = setInterval(() => this.tick(), intervalMs);
if (this._timer && typeof this._timer.unref === 'function') this._timer.unref();
},
stop() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
},
reset() {
this.stop();
this.state = { ...INITIAL };
this.listeners.clear();
},
};
module.exports = { liveTick, INITIAL };
+98
View File
@@ -0,0 +1,98 @@
/* ============================================================
VYNDR 2.0 — odds formatting + region presets (§9).
Plain CommonJS so components import it (allowJs) AND Jest tests it.
CRITICAL (the #1 i18n footgun): fmtOdds only converts MONEYLINES —
bare signed-integer strings (+150 / -110) or integer numbers (the
grade→odds map). Totals/lines/spreads like 228.5, 229, -7.5 PASS
THROUGH UNCHANGED. Apply to moneylines/book odds/parlay legs; NEVER
to O/U totals or stat lines.
This deliberately DEVIATES from the prototype's parseAm, whose regex
accepted decimals (`228.5` would have been mis-converted to a fake
implied % on O/U cells). The prompt's spec is authoritative here.
============================================================ */
const REGIONS = {
US: { label: 'United States', odds: 'american', currency: 'USD' },
UK: { label: 'United Kingdom', odds: 'fractional', currency: 'GBP' },
EU: { label: 'Europe', odds: 'decimal', currency: 'EUR' },
BR: { label: 'Brazil', odds: 'decimal', currency: 'BRL' },
AU: { label: 'Australia', odds: 'decimal', currency: 'AUD' },
};
const CURRENCIES = { USD: '$', GBP: '£', EUR: '€', BRL: 'R$', AUD: 'A$' };
const ODDS_FORMATS = ['american', 'decimal', 'fractional', 'implied'];
function gcd(a, b) {
a = Math.abs(a);
b = Math.abs(b);
while (b) {
[a, b] = [b, a % b];
}
return a || 1;
}
/**
* Return the American-odds integer for a moneyline input, or null if the
* value is NOT a moneyline (and must pass through unchanged).
* - number → only an integer counts (the grade→odds map); 228.5 → null
* - string → only an explicitly SIGNED integer (+150 / -110); "228.5",
* "229", "-7.5" → null
*/
function parseMoneyline(value) {
if (typeof value === 'number') return Number.isInteger(value) ? value : null;
if (typeof value !== 'string') return null;
return /^[+-]\d+$/.test(value.trim()) ? parseInt(value, 10) : null;
}
function americanToDecimal(am) {
return am > 0 ? am / 100 + 1 : 100 / -am + 1;
}
/**
* Format an odds value into the requested style. Non-moneyline inputs are
* returned verbatim (the pass-through guarantee).
*/
function fmtOdds(value, format = 'american') {
const am = parseMoneyline(value);
if (am === null) return value;
if (format === 'american') return (am > 0 ? '+' : '') + Math.round(am);
const dec = americanToDecimal(am);
if (format === 'decimal') return dec.toFixed(2);
if (format === 'implied') return Math.round((1 / dec) * 100) + '%';
// fractional
let n;
let d;
if (am > 0) {
n = Math.round(am);
d = 100;
} else {
n = 100;
d = Math.round(-am);
}
const g = gcd(n, d);
return `${n / g}/${d / g}`;
}
/** Region → { odds, currency, currencySymbol } preset (§9). */
function regionPreset(region) {
const r = REGIONS[region] || REGIONS.US;
return { odds: r.odds, currency: r.currency, currencySymbol: CURRENCIES[r.currency] || '$' };
}
function currencySymbol(code) {
return CURRENCIES[code] || '$';
}
module.exports = {
REGIONS,
CURRENCIES,
ODDS_FORMATS,
parseMoneyline,
americanToDecimal,
fmtOdds,
regionPreset,
currencySymbol,
};
+96
View File
@@ -0,0 +1,96 @@
/* ============================================================
VYNDR 2.0 — parlay correlation math (§12).
Ported from the prototype's vyndr-parlay.jsx. Plain CommonJS so the
Parlay Lab imports it (allowJs) AND Jest exercises it directly.
NOTE: the BACKEND parlayService (Session 28) owns server-side combined
odds + suggestions. This module is the FRONTEND correlation model that
powers the Parlay Lab's live matrix + grade penalty.
============================================================ */
// Grade → representative American odds (the prototype's GRADE_ODDS).
const GRADE_ODDS = { 'A+': -135, A: 110, 'A-': 115, 'B+': 125, B: 135, 'B-': 150, C: 175, D: 240 };
function amToDec(am) {
return am > 0 ? am / 100 + 1 : 100 / -am + 1;
}
function decToAm(dec) {
return dec >= 2 ? Math.round((dec - 1) * 100) : Math.round(-100 / (dec - 1));
}
function fmtAmerican(am) {
return (am > 0 ? '+' : '') + am;
}
/**
* Pairwise correlation (§12): same player legs move together hardest,
* then same team, then same league, then ~independent across sports.
*/
function getCorrelation(legA, legB) {
if (!legA || !legB) return 0;
if (legA.player && legA.player === legB.player) return 0.62;
if (legA.team && legA.team === legB.team && legA.sport === legB.sport) return 0.34;
if (legA.sport && legA.sport === legB.sport) return 0.06;
return 0;
}
/** Highest pairwise correlation across the slip (the worst hidden drag). */
function maxCorrelation(legs) {
let max = 0;
for (let i = 0; i < legs.length; i++) {
for (let j = i + 1; j < legs.length; j++) {
max = Math.max(max, getCorrelation(legs[i], legs[j]));
}
}
return max;
}
/** All correlated pairs as [i, j, correlation]. */
function correlationPairs(legs) {
const pairs = [];
for (let i = 0; i < legs.length; i++) {
for (let j = i + 1; j < legs.length; j++) {
pairs.push([i, j, getCorrelation(legs[i], legs[j])]);
}
}
return pairs;
}
const GRADE_ORDER = ['A+', 'A', 'B+', 'B', 'C', 'D'];
/**
* Combined parlay grade. Averages the leg grades, then bumps the slip DOWN
* one tier when any pair is strongly correlated (>0.4) — correlated stacks
* are riskier than the books' independent pricing implies.
*/
function parlayGrade(legs) {
if (!legs || !legs.length) return '—';
const avg = legs.reduce((s, l) => s + Math.max(0, GRADE_ORDER.indexOf(String(l.grade || 'B').replace('-', ''))), 0) / legs.length;
const penalty = maxCorrelation(legs) > 0.4 ? 1 : 0;
const idx = Math.min(GRADE_ORDER.length - 1, Math.round(avg + penalty));
return GRADE_ORDER[idx];
}
/** Combined decimal odds (product of per-leg decimal odds). */
function combinedDecimal(legs) {
return legs.reduce((d, l) => d * amToDec(GRADE_ODDS[l.grade] != null ? GRADE_ODDS[l.grade] : 110), 1);
}
/** Combined odds as an American string, or "—" for an empty slip. */
function combinedAmerican(legs) {
if (!legs || !legs.length) return '—';
return fmtAmerican(decToAm(combinedDecimal(legs)));
}
module.exports = {
GRADE_ODDS,
GRADE_ORDER,
amToDec,
decToAm,
fmtAmerican,
getCorrelation,
maxCorrelation,
correlationPairs,
parlayGrade,
combinedDecimal,
combinedAmerican,
};
+73
View File
@@ -0,0 +1,73 @@
/* ============================================================
VYNDR 2.0 — preferences store (§9 i18n + §10 a11y).
Plain CommonJS so the modal/applier import it (allowJs) AND Jest tests
the apply/load/save logic with a fake element + fake storage.
The CSS layer already exists (Session 33 added the <html data-*>
overrides to globals.css). This module SETS those attributes.
============================================================ */
const PREFS_KEY = 'vyndr_prefs';
const DEFAULTS = {
lang: 'en',
region: 'US',
odds: 'american',
currency: 'USD',
motion: 'on', // 'on' | 'reduced'
contrast: 'off', // 'off' | 'high'
text: 'base', // 'sm' | 'base' | 'lg' | 'xl'
cb: 'off', // 'off' | 'on' (colorblind-safe)
font: 'default', // 'default' | 'readable'
};
/**
* Apply preferences to a document element by setting/removing the data-*
* attributes the CSS layer keys off. `el` defaults to document.documentElement;
* tests pass a fake element exposing setAttribute/removeAttribute.
*/
function applyPrefs(prefs, el) {
const target = el || (typeof document !== 'undefined' ? document.documentElement : null);
if (!target) return;
const p = { ...DEFAULTS, ...(prefs || {}) };
const setOrRemove = (attr, value) => {
if (value == null) target.removeAttribute(attr);
else target.setAttribute(attr, value);
};
setOrRemove('data-motion', p.motion === 'reduced' ? 'reduced' : null);
setOrRemove('data-contrast', p.contrast === 'high' ? 'high' : null);
setOrRemove('data-text', p.text && p.text !== 'base' ? p.text : null);
setOrRemove('data-cb', p.cb === 'on' || p.cb === '1' ? '1' : null);
setOrRemove('data-font', p.font === 'readable' ? 'readable' : null);
}
/** Read persisted prefs merged over defaults. `storage` defaults to localStorage. */
function loadPrefs(storage) {
const store = storage || (typeof localStorage !== 'undefined' ? localStorage : null);
if (!store) return { ...DEFAULTS };
try {
const raw = store.getItem(PREFS_KEY);
if (!raw) return { ...DEFAULTS };
return { ...DEFAULTS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULTS };
}
}
/** Persist prefs (merged over defaults). Returns the merged object. */
function savePrefs(prefs, storage) {
const store = storage || (typeof localStorage !== 'undefined' ? localStorage : null);
const merged = { ...DEFAULTS, ...(prefs || {}) };
if (store) {
try {
store.setItem(PREFS_KEY, JSON.stringify(merged));
} catch {
/* ignore quota / privacy-mode failures */
}
}
return merged;
}
module.exports = { PREFS_KEY, DEFAULTS, applyPrefs, loadPrefs, savePrefs };