From 956a7455ebd6b272bd39c65b4f981eea33de6282 Mon Sep 17 00:00:00 2001 From: Kev Date: Tue, 16 Jun 2026 10:37:31 -0400 Subject: [PATCH] =?UTF-8?q?Session=2038:=20Design=20system=20Phase=20G=20?= =?UTF-8?q?=E2=80=94=20living=20layer,=20i18n/odds,=20a11y,=20paywall,=20p?= =?UTF-8?q?arlay=20math=20(1890=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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) --- BUILD-STATE.md | 82 ++++++++- CLAUDE.md | 22 +++ tests/unit/vyndrSystems.test.js | 174 +++++++++++++++++++ web/src/app/layout.tsx | 7 +- web/src/components/Nav.tsx | 28 ++- web/src/components/Slate.tsx | 2 +- web/src/components/vyndr/GlobalHosts.tsx | 208 +++++++++++++++++++++++ web/src/components/vyndr/LiveLayer.tsx | 89 ++++++++++ web/src/lib/checkout.js | 12 ++ web/src/lib/liveTick.js | 66 +++++++ web/src/lib/oddsFormat.js | 98 +++++++++++ web/src/lib/parlayMath.js | 96 +++++++++++ web/src/lib/prefs.js | 73 ++++++++ 13 files changed, 949 insertions(+), 8 deletions(-) create mode 100644 tests/unit/vyndrSystems.test.js create mode 100644 web/src/components/vyndr/GlobalHosts.tsx create mode 100644 web/src/components/vyndr/LiveLayer.tsx create mode 100644 web/src/lib/checkout.js create mode 100644 web/src/lib/liveTick.js create mode 100644 web/src/lib/oddsFormat.js create mode 100644 web/src/lib/parlayMath.js create mode 100644 web/src/lib/prefs.js diff --git a/BUILD-STATE.md b/BUILD-STATE.md index c0c8910..f678878 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,8 +4,86 @@ 2026-06-16 ## Current Phase -SHIP BUILD v37.0 — VYNDR 2.0 design system, Phase F: mobile parity — -5-tab bar, More sheet, mobile CSS, PWA manifest/viewport (Session 37) +SHIP BUILD v38.0 — VYNDR 2.0 design system, Phase G: systems — living layer, +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 `` (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 ``, 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 diff --git a/CLAUDE.md b/CLAUDE.md index 66704c8..ed81a2a 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 — 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 `` (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 - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/tests/unit/vyndrSystems.test.js b/tests/unit/vyndrSystems.test.js new file mode 100644 index 0000000..e113ae3 --- /dev/null +++ b/tests/unit/vyndrSystems.test.js @@ -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(' { + expect(read('app/layout.tsx')).toContain(''); + }); +}); diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 938ca52..7488cf2 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -7,6 +7,7 @@ import Nav from '@/components/Nav'; import Footer from '@/components/Footer'; import AuthGate from '@/components/AuthGate'; import HashRedirect from '@/components/vyndr/HashRedirect'; +import GlobalHosts from '@/components/vyndr/GlobalHosts'; import ParlayTray from '@/components/ParlayTray'; import BottomTabBar from '@/components/BottomTabBar'; import InstallPrompt from '@/components/InstallPrompt'; @@ -128,9 +129,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo