Session 39: Design system Phase H — QA pass, §13 parity verified, conversion COMPLETE (1907 tests)

Final phase of the VYNDR 2.0 conversion (Sessions 33-39). Verify -> fix -> lock.
Frontend-only; zero backend changes.

§13 automated checklist: all PASS or FIXED.
- QA.1 token resolution FIXED: ProcessingGrade #00ffb8 -> var(--g-ap);
  game/[id] sport literals -> var(--s-*). Remaining hex documented as intentional
  (var-with-fallback, Next metadata, bespoke intel-surface shades).
- QA.6 glitch discipline: ZERO glitch on data components.
- QA.4/5/8/11/16/18 verified; QA.9 (cmd palette) documented deferred;
  QA.17 (AI slop) flagged for Kev's manual browser review.

De-flake: soccerFeatureExtractorCascade hit Jest's 5s default under full-suite
load (falls through to live adapters on cache miss) -> jest.setTimeout(20000),
same family as the S32 pipeline test. Verified stable across 3 full-suite runs.

New: tests/unit/vyndrParityQA.test.js (17 tests locking the parity invariants).

Backend 1890 -> 1907, 146 suites, zero regressions (stable x3). Web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-16 14:37:07 -04:00
parent 956a7455eb
commit e453c24d2c
6 changed files with 200 additions and 11 deletions
+74 -2
View File
@@ -4,8 +4,80 @@
2026-06-16 2026-06-16
## Current Phase ## Current Phase
SHIP BUILD v38.0 — VYNDR 2.0 design system, Phase G: systems — living layer, SHIP BUILD v39.0 — VYNDR 2.0 design system, Phase H: QA pass — §13 parity
i18n/odds, a11y toggles, paywall/checkout, parlay correlation math (Session 38) verified, conversion COMPLETE (Session 39)
## Session 39 (2026-06-16) — SHIPPED ✅ DESIGN CONVERSION COMPLETE
Phase H: the QA pass against the §13 parity checklist. Verify → fix → lock.
Frontend-only; ZERO backend changes. Backend 1890 → **1907 tests** (+17), 146
suites, **stable across 3 consecutive full-suite runs** (the S38 flaky test is
fixed). Web build clean (compiled successfully, exit 0).
This completes the 7-session VYNDR 2.0 conversion (Sessions 3339): tokens →
components → shell → screens → mobile → systems → QA.
### §13 AUTOMATED CHECKLIST — RESULTS
- **QA.1 Token resolution** — FIXED. `#00ffb8` (= the A+ token) → `var(--g-ap)`
in ProcessingGrade (7 sites); `game/[id]` sport literals (`#E94B3C/#1E90FF/
#FFB347`) → `var(--s-nba/mlb/wnba)`. Remaining hex are INTENTIONAL +
documented: `var(--token, #fallback)` belt-and-suspenders (soccer/offline/
admin), Next metadata `themeColor` (can't be a var), and bespoke intel-surface
/ red-tint text shades (`#e8fff4`,`#bdf5e2`,`#ff8a8a`,`#ff8b7a`,`#ffb0a4`,
`#ffd9a8`,`#04140f`) ported verbatim from the prototype — no token equivalent;
retokenizing would visibly deviate from the design.
- **QA.2 Typography** — PASS. Data rows/chips use `var(--mono)`; the GradeResult
hero LETTER is `var(--sans)` (display) faithful to the prototype's grade-card.
- **QA.3 Grade prominence** — PASS. Hero 92116px (80px mobile), GradeBadge hero=100.
- **QA.4 Best/worst lines** — PASS. Legacy GameCard (live slate) + vyndr/GameCard
tint best green (`rgba(0,212,160,.13)` + green left border) / worst red.
- **QA.5 Wordmark everywhere** — PASS. Nav, Footer, login, about, 404 (+ social cards).
- **QA.6 Glitch discipline** — PASS. ZERO glitch classes in GradeResultCard /
GameCard(s) / ProcessingGrade. Glitch stays on chrome only.
- **QA.7 Heartbeat/ticker/live counters** — PASS (Phase G; under the nav).
- **QA.8 Scan reveal** — PASS. ProcessingGrade factor-ignite → CRT-sweep → card.
- **QA.9 Command palette (⌘K)** — DEFERRED (documented). Nav `` Query links to
/scan; a true ⌘K palette was never in scope for 3339.
- **QA.10 Parlay correlation** — PASS (Phase G `lib/parlayMath.js`, tested).
- **QA.11 Mobile parity** — PASS. 5-tab bar (Slate/Terminal/Scan/Ledger/More).
- **QA.12 Auth gate / deep-links / paywall** — PASS (AuthGate, HashRedirect, read-meter→paywall).
- **QA.13 i18n / odds** — PASS. `fmtOdds` converts ML; totals pass through (tested).
- **QA.14 Accessibility** — PASS. Prefs modal sets `<html data-*>`, persists.
- **QA.15 PWA** — PASS (S27 SW + S37 manifest/shortcuts/viewport-fit). Not touched.
- **QA.16 No dead buttons** — PASS. Zero `onClick={}` (asserted by a tree-walk test).
- **QA.17 No AI slop** — MANUAL (flagged for Kev's browser review — see below).
- **QA.18 Auth-gate integration** — PASS. Gates via lib/routes → `/login?next=`.
### De-flake (completion-quality)
The S38-flagged flaky test (`soccerFeatureExtractorCascade nextMatch cascade`)
intermittently hit Jest's 5s default under full-suite concurrency (the extractor
falls through to live adapters on a cache miss). Added `jest.setTimeout(20000)`
to that test file — same fix family as S32's CPU-bound pipeline test. Test-only,
no service change. Verified: 3 consecutive clean full-suite runs (1907/1907).
### Files created
- `tests/unit/vyndrParityQA.test.js` (17 tests locking QA.1/4/5/6/11/16/18 —
glitch-free data, token resolution, best-line tint, wordmark presence, 5 tabs,
no dead buttons, auth-gate list)
### Files modified
- `web/src/components/vyndr/ProcessingGrade.tsx` (#00ffb8 → --g-ap)
- `web/src/app/game/[id]/page.tsx` (sport literals → tokens)
- `tests/unit/soccerFeatureExtractorCascade.test.js` (de-flake timeout)
### ⚠️ MANUAL CHECKS for Kev (browser, post-deploy)
- [ ] No AI slop (gradient bg / rounded-pill SaaS cards / stray emoji / exposed-
algorithm copy). NOTE: 🔥 STREAKS + ◎ scan glyph are deliberate data markers.
- [ ] North-star energy — every page belongs with the 404.
- [ ] Auth gate end-to-end: incognito → /ledger → /login?next=/ledger → sign in → /ledger.
- [ ] Mobile: tab bar native feel, More sheet slide, touch targets.
- [ ] Scan grade reveal plays; ticker scrolls; best/worst line tints; PWA installs.
- [ ] Still-open operator item (since S31): rotate the leaked GitHub PAT in the
`origin` remote URL and scrub it from `.git/config`.
---
## Session 38 (2026-06-16) — SHIPPED
## Session 38 (2026-06-16) — SHIPPED ## Session 38 (2026-06-16) — SHIPPED
+17
View File
@@ -276,6 +276,23 @@ Testable CommonJS modules in `lib/` + thin React glue:
- GOTCHA: don't return a `Set.delete`-based unsub directly from `useEffect` - GOTCHA: don't return a `Set.delete`-based unsub directly from `useEffect`
(returns boolean ≠ valid cleanup) — wrap as `() => { unsub(); }`. (returns boolean ≠ valid cleanup) — wrap as `() => { unsub(); }`.
## VYNDR 2.0 Conversion COMPLETE (Session 39 — Phase H QA)
The 7-session design conversion (3339) is done and parity-verified against §13.
- Parity invariants are locked by `tests/unit/vyndrParityQA.test.js` — if you
later add a glitch class to a data component, a raw `#00ffb8`/sport hex, a dead
`onClick={}`, or break the gated-route list, that suite fails. Keep it green.
- INTENTIONAL hex (do NOT "fix" to tokens): `var(--token, #fallback)` fallbacks,
Next metadata `themeColor`, and the bespoke intel-surface/red-tint text shades
(#e8fff4/#bdf5e2/#ff8a8a/#ff8b7a/#ffb0a4/#ffd9a8/#04140f) ported from the
prototype — no token equivalent.
- The GradeResultCard hero LETTER is intentionally `var(--sans)` (display),
matching the prototype; all grade DATA rows + the GradeBadge chip are mono.
- DEFERRED (never in 3339 scope): a true ⌘K command palette (Nav `` Query
currently links to /scan).
- TEST DE-FLAKE: `soccerFeatureExtractorCascade` gets `jest.setTimeout(20000)` —
it falls through to live adapters on cache miss and flaked at Jest's 5s default
under full-suite load (same family as the S32 pipeline test).
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
@@ -19,6 +19,13 @@ jest.mock('../../src/utils/redis', () => ({
const { normalizeName } = require('../../src/utils/normalize'); const { normalizeName } = require('../../src/utils/normalize');
const extractor = require('../../src/services/intelligence/soccerFeatureExtractor'); const extractor = require('../../src/services/intelligence/soccerFeatureExtractor');
// Session 39 (QA) — de-flake. When a cache key misses, the extractor falls
// through to live network adapters; under full-suite concurrency that async
// chain occasionally exceeds Jest's 5s default (intermittent timeout on the
// nextMatch/referee cascade tests). Same fix family as Session 32's CPU-bound
// pipeline test — give the suite headroom. Test-only; no service change.
jest.setTimeout(20000);
beforeEach(() => { mockCacheStore.clear(); }); beforeEach(() => { mockCacheStore.clear(); });
describe('soccerFeatureExtractor — source cascade (Session 9)', () => { describe('soccerFeatureExtractor — source cascade (Session 9)', () => {
+92
View File
@@ -0,0 +1,92 @@
// VYNDR 2.0 — Phase H parity QA (Session 39). Locks the automated §13
// checklist invariants so the design conversion can't silently regress.
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');
describe('QA.6 — glitch discipline (data NEVER glitches)', () => {
const GLITCH = /wm-tear|glitch-shift|head-tear|glitch-hover/;
it.each([
'components/vyndr/GradeResultCard.tsx',
'components/vyndr/GameCard.tsx',
'components/GameCard.tsx',
'components/vyndr/ProcessingGrade.tsx',
])('%s contains no glitch classes', (rel) => {
expect(GLITCH.test(read(rel))).toBe(false);
});
});
describe('QA.1 — token resolution (no literals that duplicate a token)', () => {
it('ProcessingGrade uses the A+ token, not the raw #00ffb8 literal', () => {
const src = read('components/vyndr/ProcessingGrade.tsx');
expect(src).not.toContain('#00ffb8');
expect(src).toContain('var(--g-ap)');
});
it('game detail uses sport tokens, not duplicated sport hex', () => {
const src = read('app/game/[id]/page.tsx');
expect(src).toContain('var(--s-nba)');
expect(src).not.toMatch(/#E94B3C/);
});
});
describe('QA.4 — Bloomberg best/worst line pattern', () => {
it('legacy GameCard (live slate) tints best green / worst red', () => {
const src = read('components/GameCard.tsx');
expect(src).toContain('rgba(0,212,160,.13)');
expect(src).toContain('rgba(255,82,82,.07)');
expect(src).toMatch(/bestAway|bestHome/);
});
});
describe('QA.5 — wordmark everywhere', () => {
it.each([
'components/Nav.tsx',
'components/Footer.tsx',
'app/login/page.tsx',
'app/about/page.tsx',
'app/not-found.tsx',
])('%s renders the Wordmark', (rel) => {
expect(read(rel)).toContain('Wordmark');
});
});
describe('QA.11 — mobile parity (5-tab bar)', () => {
it('BottomTabBar declares Slate/Terminal/Scan/Ledger/More', () => {
const src = read('components/BottomTabBar.tsx');
['Slate', 'Terminal', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`));
});
});
describe('QA.16 — no dead buttons', () => {
it.each(['components', 'app'])('no empty onClick handlers under web/src/%s', (dir) => {
const root = path.join(WEB, dir);
const offenders = [];
const walk = (d) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const fp = path.join(d, e.name);
if (e.isDirectory()) walk(fp);
else if (e.name.endsWith('.tsx') && /onClick=\{\}/.test(fs.readFileSync(fp, 'utf8'))) offenders.push(fp);
}
};
walk(root);
expect(offenders).toEqual([]);
});
});
describe('QA.18 — auth gate integration', () => {
it('AuthGate gates via lib/routes and bounces to /login?next=', () => {
const src = read('components/AuthGate.tsx');
expect(src).toContain('isGatedRoute');
expect(src).toContain('/login?next=');
});
it('gated route list covers the personal surfaces', () => {
const routes = require('../../web/src/lib/routes');
['/ledger', '/tracker', '/account', '/notifications'].forEach((r) =>
expect(routes.isGatedRoute(r)).toBe(true),
);
expect(routes.isGatedRoute('/dashboard')).toBe(false); // free funnel stays open
});
});
+4 -3
View File
@@ -49,10 +49,11 @@ interface PropEntry {
alt_lines?: { line: number; grade: string; hit_rate?: number }[]; alt_lines?: { line: number; grade: string; hit_rate?: number }[];
} }
// Session 39 (QA.1) — resolve to sport tokens instead of duplicate literals.
const SPORT_COLOR: Record<Sport, string> = { const SPORT_COLOR: Record<Sport, string> = {
NBA: '#E94B3C', NBA: 'var(--s-nba)',
MLB: '#1E90FF', MLB: 'var(--s-mlb)',
WNBA: '#FFB347', WNBA: 'var(--s-wnba)',
}; };
export default function GamePage({ params }: { params: Promise<{ id: string }> }) { export default function GamePage({ params }: { params: Promise<{ id: string }> }) {
+6 -6
View File
@@ -13,10 +13,10 @@ function MiniBrain({ size = 56 }: { size?: number }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 60 56" aria-hidden style={{ flexShrink: 0 }}> <svg width={size} height={size} viewBox="0 0 60 56" aria-hidden style={{ flexShrink: 0 }}>
{links.map(([a, b], i) => ( {links.map(([a, b], i) => (
<line key={i} className="brain-link" x1={nodes[a][0]} y1={nodes[a][1]} x2={nodes[b][0]} y2={nodes[b][1]} stroke="#00ffb8" strokeWidth="1" opacity="0.5" /> <line key={i} className="brain-link" x1={nodes[a][0]} y1={nodes[a][1]} x2={nodes[b][0]} y2={nodes[b][1]} stroke="var(--g-ap)" strokeWidth="1" opacity="0.5" />
))} ))}
{nodes.map(([x, y], i) => ( {nodes.map(([x, y], i) => (
<circle key={i} className="brain-node" cx={x} cy={y} r="2.6" fill="#00ffb8" style={{ animationDelay: `${(i % 5) * 0.2}s` }} /> <circle key={i} className="brain-node" cx={x} cy={y} r="2.6" fill="var(--g-ap)" style={{ animationDelay: `${(i % 5) * 0.2}s` }} />
))} ))}
</svg> </svg>
); );
@@ -84,7 +84,7 @@ export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToP
</div> </div>
</div> </div>
<div style={{ marginLeft: 'auto', textAlign: 'right' }}> <div style={{ marginLeft: 'auto', textAlign: 'right' }}>
<div className="mono" style={{ fontSize: 30, fontWeight: 800, color: '#00ffb8', textShadow: '0 0 16px rgba(0,255,184,.5)' }}>{pct}%</div> <div className="mono" style={{ fontSize: 30, fontWeight: 800, color: 'var(--g-ap)', textShadow: '0 0 16px rgba(0,255,184,.5)' }}>{pct}%</div>
<div className="mono" style={{ fontSize: 9.5, letterSpacing: '0.12em', color: 'rgba(232,255,244,.5)' }}>WEIGHING 40+ FACTORS</div> <div className="mono" style={{ fontSize: 9.5, letterSpacing: '0.12em', color: 'rgba(232,255,244,.5)' }}>WEIGHING 40+ FACTORS</div>
</div> </div>
</div> </div>
@@ -94,16 +94,16 @@ export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToP
const on = i < lit; const on = i < lit;
return ( return (
<div key={i} className={on ? 'factor-ignite' : ''} style={{ display: 'flex', alignItems: 'center', gap: 12, opacity: on ? 1 : 0.18, transition: 'opacity .2s' }}> <div key={i} className={on ? 'factor-ignite' : ''} style={{ display: 'flex', alignItems: 'center', gap: 12, opacity: on ? 1 : 0.18, transition: 'opacity .2s' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, background: on ? '#00ffb8' : 'rgba(232,255,244,.3)', boxShadow: on ? '0 0 10px rgba(0,255,184,.8)' : 'none' }} /> <span style={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, background: on ? 'var(--g-ap)' : 'rgba(232,255,244,.3)', boxShadow: on ? '0 0 10px rgba(0,255,184,.8)' : 'none' }} />
<span className="mono" style={{ fontSize: 13, color: on ? '#e8fff4' : 'rgba(232,255,244,.4)' }}>{f}</span> <span className="mono" style={{ fontSize: 13, color: on ? '#e8fff4' : 'rgba(232,255,244,.4)' }}>{f}</span>
{on && <span className="mono" style={{ marginLeft: 'auto', fontSize: 10.5, color: '#00ffb8', letterSpacing: '0.08em' }}> WEIGHED</span>} {on && <span className="mono" style={{ marginLeft: 'auto', fontSize: 10.5, color: 'var(--g-ap)', letterSpacing: '0.08em' }}> WEIGHED</span>}
</div> </div>
); );
})} })}
</div> </div>
<div style={{ marginTop: 24, height: 4, background: 'rgba(0,0,0,.35)', borderRadius: 3, overflow: 'hidden' }}> <div style={{ marginTop: 24, height: 4, background: 'rgba(0,0,0,.35)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'linear-gradient(90deg, var(--acc-1), #00ffb8)', borderRadius: 3, transition: 'width .18s ease-out', boxShadow: '0 0 10px rgba(0,255,184,.6)' }} /> <div style={{ width: `${pct}%`, height: '100%', background: 'linear-gradient(90deg, var(--acc-1), var(--g-ap))', borderRadius: 3, transition: 'width .18s ease-out', boxShadow: '0 0 10px rgba(0,255,184,.6)' }} />
</div> </div>
</div> </div>
</div> </div>