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
+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 };