Session 41: P0 audit fixes — MLB stat_types, broken routes, tier mismatch, self-hosted fonts (1940 tests)
- Backend: whitelist MLB stat_types in analyze.js + scan.js gates (mirrors python validation.py); fixes MLB scans 400ing. - Routes: /settings -> /profile, /report -> /blog redirect pages. - Profile: read tier from useAuth().tier (nav's source) to kill the Free-vs-DESK mismatch. - Fonts: self-host Inter/JetBrains Mono/IBM Plex Mono via next/font, drop the 503ing fonts.googleapis.com <link>; rewire literal font-family refs to vars. - Kept /settings/security (real MFA page) intact — NOT clobbered to a redirect. - +33 tests (1907 -> 1940), 149 suites; web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+47
-3
@@ -1,11 +1,55 @@
|
|||||||
# VYNDR — Build State
|
# VYNDR — Build State
|
||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
2026-06-16
|
2026-06-17
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v39.0 — VYNDR 2.0 design system, Phase H: QA pass — §13 parity
|
SHIP BUILD v41.0 — P0 audit fixes (MLB stat gate, broken routes, profile tier,
|
||||||
verified, conversion COMPLETE (Session 39)
|
self-hosted fonts). Design conversion (33–39) remains COMPLETE.
|
||||||
|
|
||||||
|
## Session 41 (2026-06-17) — SHIPPED ✅ P0 AUDIT FIXES
|
||||||
|
|
||||||
|
The Chrome audit's P0 list only. No features. Backend 1907 → **1940 tests**
|
||||||
|
(+33), 149 suites, all green. Web build clean (compiled successfully, exit 0).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
1. **MLB stat_type gate (BACKEND)** — the Scan frontend sends MLB IDs (`hits`,
|
||||||
|
`strikeouts`, `total_bases`, `rbi`, `home_runs`, `earned_runs`, `hits_allowed`,
|
||||||
|
`innings_pitched`, `runs`, `walks`, `stolen_bases`) but `VALID_STAT_TYPES` in
|
||||||
|
`src/routes/analyze.js` + `src/routes/scan.js` only whitelisted the NBA/soccer
|
||||||
|
set, so every MLB scan 400'd. Added the MLB set to both gates, mirroring the
|
||||||
|
already-correct `python/utils/validation.py` `VALID_STAT_TYPES.mlb`. The live
|
||||||
|
grade path is the generic engine1 feature pipeline (note: `mlbGrader.js` is
|
||||||
|
dead code — required nowhere), which keys off these exact stat names.
|
||||||
|
2. **/settings → /profile, /report → /blog** — new server-component `redirect()`
|
||||||
|
pages (`app/settings/page.tsx`, `app/report/page.tsx`). `/profile` is the
|
||||||
|
canonical account surface; `/blog` is where "The Report" lives.
|
||||||
|
3. **Profile tier mismatch** — `app/profile/page.tsx` showed "Free" (from the
|
||||||
|
`/api/user/profile` fetch) while the nav showed the real tier (from
|
||||||
|
`useAuth().tier`). Profile now derives the displayed tier from
|
||||||
|
`useAuth().tier` (`authTier || profile.tier || 'free'`); the fetch still
|
||||||
|
supplies scan_count/subscription_*/founder.
|
||||||
|
4. **Self-hosted fonts** — replaced the runtime `fonts.googleapis.com`
|
||||||
|
stylesheet `<link>` (503 in prod, ~4s page load) with `next/font/google`:
|
||||||
|
Inter→`--font-sans`, JetBrains_Mono→`--font-mono`, IBM_Plex_Mono→`--font-ibm`,
|
||||||
|
variables set on `<html>`. globals.css `:root` maps `--sans`/`--mono`/
|
||||||
|
`--ibm-mono` onto them. Because next/font obfuscates family names, literal
|
||||||
|
`'JetBrains Mono'`/`'IBM Plex Mono'` references in CSS + inline component
|
||||||
|
styles were rewired to the variables (Hero, upgrade/desk, NotificationBell,
|
||||||
|
responsible-gambling, globals.css wordmark/.lines).
|
||||||
|
|
||||||
|
### Deviations from the spec (flagged)
|
||||||
|
- **Did NOT overwrite `/settings/security`.** The spec said redirect it to
|
||||||
|
`/profile` too, but that route is a working MFA enrollment flow — clobbering it
|
||||||
|
would be a security-feature regression. Left intact; only the bare `/settings`
|
||||||
|
404 was fixed.
|
||||||
|
- **`/report` link doesn't exist in current code** — Nav's "The Report" already
|
||||||
|
points to `/blog`. The redirect is harmless defensive coverage (PWA shortcut /
|
||||||
|
external / stale share links).
|
||||||
|
- **ShareCard canvas** still uses literal `"JetBrains Mono"`/`"Instrument Sans"`
|
||||||
|
(canvas `ctx.font` can't read CSS vars). Out of P0 scope; pre-existing.
|
||||||
|
|
||||||
|
## Session 39 (2026-06-16) — SHIPPED ✅ DESIGN CONVERSION COMPLETE
|
||||||
|
|
||||||
## Session 39 (2026-06-16) — SHIPPED ✅ DESIGN CONVERSION COMPLETE
|
## Session 39 (2026-06-16) — SHIPPED ✅ DESIGN CONVERSION COMPLETE
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,34 @@ The 7-session design conversion (33–39) is done and parity-verified against §
|
|||||||
it falls through to live adapters on cache miss and flaked at Jest's 5s default
|
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).
|
under full-suite load (same family as the S32 pipeline test).
|
||||||
|
|
||||||
|
## P0 Audit Fixes (Session 41 — non-obvious)
|
||||||
|
- **THREE stat_type whitelists must stay in sync.** A prop's `stat_type` is
|
||||||
|
gated in `src/routes/analyze.js` (`/prop` + `/batch`), `src/routes/scan.js`
|
||||||
|
(parlay legs), AND `src/services/python/utils/validation.py`. Adding a sport's
|
||||||
|
stats to one without the others silently 400s. The S41 MLB bug was exactly
|
||||||
|
this: Python had the MLB set; both Node gates didn't. `mlbGrader.js` is DEAD
|
||||||
|
CODE (required nowhere) — the live MLB grade path is the generic engine1
|
||||||
|
feature pipeline, which keys off the Python validator's exact stat names
|
||||||
|
(`rbi`/`runs`/`innings_pitched`, NOT `rbis`/`runs_scored`/`outs_recorded`).
|
||||||
|
- **`tests/integration/analyze.test.js` sits exactly at the 10-req/min IP rate
|
||||||
|
limit** (`createRateLimit max:10`, no reset hook). Adding any HTTP test there
|
||||||
|
429s the later cases. Put new analyze-route HTTP tests in a SEPARATE file
|
||||||
|
(jest isolates module state per file → fresh limiter): see
|
||||||
|
`tests/integration/analyzeMlbStats.test.js`.
|
||||||
|
- **Fonts are self-hosted via `next/font/google`** (layout.tsx): Inter→
|
||||||
|
`--font-sans`, JetBrains_Mono→`--font-mono`, IBM_Plex_Mono→`--font-ibm`, all
|
||||||
|
set on `<html className>`. globals.css `:root` maps `--sans`/`--mono`/
|
||||||
|
`--ibm-mono` onto them. CRITICAL: next/font OBFUSCATES family names, so literal
|
||||||
|
`'JetBrains Mono'`/`'IBM Plex Mono'` in CSS or inline `fontFamily` no longer
|
||||||
|
resolve — always reference the variable. The old `fonts.googleapis.com` CDN
|
||||||
|
`<link>` is GONE (it 503'd in prod). ShareCard canvas still uses literal names
|
||||||
|
(canvas can't read CSS vars) — knowingly left.
|
||||||
|
- **Redirect routes** use server-component `redirect()` from `next/navigation`
|
||||||
|
(`/settings`→`/profile`, `/report`→`/blog`). `/settings/security` is a REAL
|
||||||
|
MFA enrollment page — do NOT clobber it into a redirect.
|
||||||
|
- **Profile tier** reads from `useAuth().tier` (the nav's source), not the
|
||||||
|
`/api/user/profile` fetch, so the two can't disagree.
|
||||||
|
|
||||||
## Active Skills
|
## Active Skills
|
||||||
- vyndr-voice (all user-facing output)
|
- vyndr-voice (all user-facing output)
|
||||||
- prop-analysis (grading methodology)
|
- prop-analysis (grading methodology)
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ const VALID_STAT_TYPES = new Set([
|
|||||||
// discriminates downstream).
|
// discriminates downstream).
|
||||||
'goals', 'shots_on_target', 'shots', 'tackles', 'cards',
|
'goals', 'shots_on_target', 'shots', 'tackles', 'cards',
|
||||||
'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
|
'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
|
||||||
|
// MLB (Session 41) — mirrors src/services/python/utils/validation.py
|
||||||
|
// VALID_STAT_TYPES.mlb. The Scan frontend sends these IDs; the grading
|
||||||
|
// engine already processes them — this gate was the only thing rejecting.
|
||||||
|
'strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases',
|
||||||
|
'walks', 'runs', 'earned_runs', 'innings_pitched',
|
||||||
|
'hits_allowed', 'stolen_bases',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const VALID_DIRECTIONS = new Set(['over', 'under']);
|
const VALID_DIRECTIONS = new Set(['over', 'under']);
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const VALID_STAT_TYPES = new Set([
|
|||||||
// Soccer (Session 7j)
|
// Soccer (Session 7j)
|
||||||
'goals', 'shots_on_target', 'shots', 'tackles', 'cards',
|
'goals', 'shots_on_target', 'shots', 'tackles', 'cards',
|
||||||
'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
|
'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
|
||||||
|
// MLB (Session 41) — mirrors src/routes/analyze.js + python validation.
|
||||||
|
'strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases',
|
||||||
|
'walks', 'runs', 'earned_runs', 'innings_pitched',
|
||||||
|
'hits_allowed', 'stolen_bases',
|
||||||
]);
|
]);
|
||||||
const VALID_DIRECTIONS = new Set(['over', 'under']);
|
const VALID_DIRECTIONS = new Set(['over', 'under']);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Session 41 — MLB stat_type acceptance. The Scan frontend sends MLB IDs
|
||||||
|
// (hits, strikeouts, total_bases, rbi, home_runs, earned_runs, hits_allowed,
|
||||||
|
// innings_pitched) but the /api/analyze validation gate only whitelisted the
|
||||||
|
// NBA/soccer set, so every MLB scan 400'd. This file lives apart from
|
||||||
|
// analyze.test.js because that suite sits exactly at the 10/min IP rate-limit
|
||||||
|
// boundary; a separate file gets a fresh limiter (jest isolates module state
|
||||||
|
// per file) so we can exercise all eight MLB stats under the cap.
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() };
|
||||||
|
jest.mock('../../src/utils/redis', () => ({
|
||||||
|
getRedisClient: () => mockRedis,
|
||||||
|
cacheGet: async () => null,
|
||||||
|
cacheSet: async () => true,
|
||||||
|
cacheDel: async () => true,
|
||||||
|
isDegraded: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAnalyzeViaEngine1 = jest.fn();
|
||||||
|
jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
|
||||||
|
analyzeViaEngine1: (...args) => mockAnalyzeViaEngine1(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../src/utils/tierGating', () => ({ applyTierGating: (result) => result }));
|
||||||
|
|
||||||
|
const { __internals: scanLimitInternals } = require('../../src/middleware/scanLimit');
|
||||||
|
const app = require('../../src/app');
|
||||||
|
|
||||||
|
const MLB_STATS = ['hits', 'strikeouts', 'total_bases', 'rbi', 'home_runs', 'earned_runs', 'hits_allowed', 'innings_pitched'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue('OK');
|
||||||
|
mockAnalyzeViaEngine1.mockReset();
|
||||||
|
if (scanLimitInternals && scanLimitInternals.resetForTests) scanLimitInternals.resetForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/analyze/prop — MLB stat_types', () => {
|
||||||
|
it.each(MLB_STATS)('accepts %s (no longer 400s at the validation gate)', async (stat) => {
|
||||||
|
mockAnalyzeViaEngine1.mockResolvedValueOnce({
|
||||||
|
player: 'Aaron Judge',
|
||||||
|
stat_type: stat,
|
||||||
|
line: 1.5,
|
||||||
|
direction: 'over',
|
||||||
|
grade: 'B',
|
||||||
|
confidence: 70,
|
||||||
|
kill_conditions_triggered: [],
|
||||||
|
reasoning: { summary: 'ok', steps: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/analyze/prop')
|
||||||
|
.send({ player: 'Aaron Judge', sport: 'mlb', stat_type: stat, line: 1.5, direction: 'over' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.error).toBeUndefined();
|
||||||
|
expect(mockAnalyzeViaEngine1).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects a genuinely invalid stat_type', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/analyze/prop')
|
||||||
|
.send({ player: 'Aaron Judge', sport: 'mlb', stat_type: 'vibes', line: 1.5, direction: 'over' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain('Invalid stat_type');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Session 41 — P0 audit fixes. Frontend assertions read page source as text
|
||||||
|
// (same pattern as the Phase D–H suites); backend stat-gate acceptance is
|
||||||
|
// covered in tests/integration/analyze.test.js.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const WEB = path.join(ROOT, 'web', 'src');
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||||
|
|
||||||
|
describe('Session 41 — backend MLB stat_type whitelist', () => {
|
||||||
|
const analyze = fs.readFileSync(path.join(ROOT, 'src', 'routes', 'analyze.js'), 'utf8');
|
||||||
|
const scan = fs.readFileSync(path.join(ROOT, 'src', 'routes', 'scan.js'), 'utf8');
|
||||||
|
const mlbStats = ['hits', 'strikeouts', 'total_bases', 'rbi', 'home_runs', 'earned_runs', 'hits_allowed', 'innings_pitched'];
|
||||||
|
|
||||||
|
it.each(mlbStats)('/api/analyze gate whitelists MLB stat %s', (stat) => {
|
||||||
|
expect(analyze).toContain(`'${stat}'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(mlbStats)('/api/scan (parlay) gate whitelists MLB stat %s', (stat) => {
|
||||||
|
expect(scan).toContain(`'${stat}'`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session 41 — broken-route redirects', () => {
|
||||||
|
it('/settings redirects to /profile', () => {
|
||||||
|
const src = read('app/settings/page.tsx');
|
||||||
|
expect(src).toContain("from 'next/navigation'");
|
||||||
|
expect(src).toContain("redirect('/profile')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/report redirects to /blog (THE REPORT link target)', () => {
|
||||||
|
const src = read('app/report/page.tsx');
|
||||||
|
expect(src).toContain("redirect('/blog')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/settings/security stays the real MFA page (NOT clobbered into a redirect)', () => {
|
||||||
|
// The audit spec wanted this redirected too, but it is a working MFA
|
||||||
|
// enrollment flow — overwriting it would be a security-feature regression.
|
||||||
|
const src = read('app/settings/security/page.tsx');
|
||||||
|
expect(src).toContain('mfa');
|
||||||
|
expect(src).not.toContain("redirect('/profile')");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session 41 — profile reads tier from useAuth', () => {
|
||||||
|
const src = read('app/profile/page.tsx');
|
||||||
|
|
||||||
|
it('destructures tier from useAuth (same source as the nav)', () => {
|
||||||
|
expect(src).toMatch(/tier:\s*authTier/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the displayed tier from the auth session', () => {
|
||||||
|
expect(src).toContain('authTier || profile.tier');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session 41 — self-hosted fonts (no Google Fonts CDN)', () => {
|
||||||
|
const layout = read('app/layout.tsx');
|
||||||
|
const globals = read('app/globals.css');
|
||||||
|
|
||||||
|
it('layout uses next/font instead of a runtime <link>', () => {
|
||||||
|
expect(layout).toContain("from 'next/font/google'");
|
||||||
|
expect(layout).toContain('Inter(');
|
||||||
|
expect(layout).toContain('JetBrains_Mono(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removed the runtime Google Fonts stylesheet <link>', () => {
|
||||||
|
// The historical reference survives in a code comment; what must be gone
|
||||||
|
// is the actual CDN stylesheet href that caused the 503.
|
||||||
|
expect(layout).not.toMatch(/href=["'][^"']*fonts\.googleapis\.com/);
|
||||||
|
expect(layout).not.toContain('rel="stylesheet"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('globals.css :root maps --sans/--mono onto the next/font variables', () => {
|
||||||
|
expect(globals).toContain('--sans: var(--font-sans)');
|
||||||
|
expect(globals).toContain('--mono: var(--font-mono)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,8 +37,11 @@ describe('Phase A — design tokens (§2)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sets --sans to Inter and --mono to JetBrains Mono', () => {
|
it('sets --sans to Inter and --mono to JetBrains Mono', () => {
|
||||||
expect(css).toMatch(/--sans:\s*'Inter'/);
|
// Session 41 — fonts are self-hosted via next/font, so the families now
|
||||||
expect(css).toMatch(/--mono:\s*'JetBrains Mono'/);
|
// come through the --font-* variables (with the literal names kept as
|
||||||
|
// fallbacks for any non-next/font context).
|
||||||
|
expect(css).toMatch(/--sans:\s*var\(--font-sans\),\s*'Inter'/);
|
||||||
|
expect(css).toMatch(/--mono:\s*var\(--font-mono\),\s*'JetBrains Mono'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets glitch intensity to the §2 baseline of 1 and scan-op to 0.04', () => {
|
it('sets glitch intensity to the §2 baseline of 1 and scan-op to 0.04', () => {
|
||||||
@@ -48,9 +51,11 @@ describe('Phase A — design tokens (§2)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Phase A — fonts (§2)', () => {
|
describe('Phase A — fonts (§2)', () => {
|
||||||
it('loads Inter (400–900) and JetBrains Mono (400–800) from Google Fonts', () => {
|
it('self-hosts Inter + JetBrains Mono via next/font (Session 41 — no CDN <link>)', () => {
|
||||||
expect(layout).toContain('Inter:wght@400;500;600;700;800;900');
|
expect(layout).toContain("from 'next/font/google'");
|
||||||
expect(layout).toContain('JetBrains+Mono:wght@400;500;600;700;800');
|
expect(layout).toContain('Inter(');
|
||||||
|
expect(layout).toContain('JetBrains_Mono(');
|
||||||
|
expect(layout).not.toMatch(/href=["'][^"']*fonts\.googleapis\.com/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+10
-7
@@ -94,9 +94,12 @@
|
|||||||
--scan-op: 0.04;
|
--scan-op: 0.04;
|
||||||
--grade-hero: var(--g-a);
|
--grade-hero: var(--g-a);
|
||||||
|
|
||||||
/* Type — Inter for chrome/UI, JetBrains Mono for ALL data */
|
/* Type — Inter for chrome/UI, JetBrains Mono for ALL data.
|
||||||
--sans: 'Inter', system-ui, sans-serif;
|
Session 41: --font-* come from next/font (self-hosted, set on <html>).
|
||||||
--mono: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
Literal family names kept as fallbacks for any non-next/font context. */
|
||||||
|
--sans: var(--font-sans), 'Inter', system-ui, sans-serif;
|
||||||
|
--mono: var(--font-mono), 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||||
|
--ibm-mono: var(--font-ibm), var(--font-mono), 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
||||||
|
|
||||||
/* ── Legacy aliases — every existing component still resolves ── */
|
/* ── Legacy aliases — every existing component still resolves ── */
|
||||||
--bg-primary: var(--bg-0);
|
--bg-primary: var(--bg-0);
|
||||||
@@ -605,7 +608,7 @@ body.tex-grain::before {
|
|||||||
───────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.wordmark {
|
.wordmark {
|
||||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
font-family: var(--ibm-mono);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.10em;
|
letter-spacing: 0.10em;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -717,7 +720,7 @@ body.tex-grain::before {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 10px 4px 4px;
|
padding: 4px 10px 4px 4px;
|
||||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
font-family: var(--ibm-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
@@ -743,7 +746,7 @@ body.tex-grain::before {
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: var(--bg-1);
|
background: var(--bg-1);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
font-family: var(--ibm-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -761,7 +764,7 @@ body.tex-grain::before {
|
|||||||
/* The V watermark — Vendetta nod, hero background */
|
/* The V watermark — Vendetta nod, hero background */
|
||||||
.v-watermark {
|
.v-watermark {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-family: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
font-family: var(--ibm-mono);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 70vmin;
|
font-size: 70vmin;
|
||||||
line-height: 0.8;
|
line-height: 0.8;
|
||||||
|
|||||||
+18
-11
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { Inter, JetBrains_Mono, IBM_Plex_Mono } from 'next/font/google';
|
||||||
import PostHogProvider from '@/components/PostHogProvider';
|
import PostHogProvider from '@/components/PostHogProvider';
|
||||||
import AuthProvider from '@/contexts/AuthContext';
|
import AuthProvider from '@/contexts/AuthContext';
|
||||||
import ParlayProvider from '@/contexts/ParlayContext';
|
import ParlayProvider from '@/contexts/ParlayContext';
|
||||||
@@ -21,6 +22,22 @@ import { headers } from 'next/headers';
|
|||||||
import { LOCALE_HEADER, COUNTRY_HEADER, isLocale, DEFAULT_LOCALE, LOCALE_META } from '@/lib/locales';
|
import { LOCALE_HEADER, COUNTRY_HEADER, isLocale, DEFAULT_LOCALE, LOCALE_META } from '@/lib/locales';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
|
// Session 41 — self-hosted via next/font (downloaded at build time, served
|
||||||
|
// from our origin). Replaces the runtime fonts.googleapis.com <link>, which
|
||||||
|
// returned 503 in production and added ~4s to page load. §2 brand fonts:
|
||||||
|
// Inter for chrome/UI (--font-sans), JetBrains Mono for ALL data (--font-mono).
|
||||||
|
// IBM Plex Mono (--font-ibm) kept for the wordmark + legacy mono surfaces while
|
||||||
|
// pages migrate. globals.css :root maps --sans/--mono onto these variables.
|
||||||
|
const inter = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap' });
|
||||||
|
const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono', display: 'swap' });
|
||||||
|
const ibmPlexMono = IBM_Plex_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['400', '500', '600', '700'],
|
||||||
|
variable: '--font-ibm',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
const fontVars = `${inter.variable} ${jetbrainsMono.variable} ${ibmPlexMono.variable}`;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'),
|
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'),
|
||||||
title: {
|
title: {
|
||||||
@@ -110,17 +127,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
const country = hdrs.get(COUNTRY_HEADER) || '';
|
const country = hdrs.get(COUNTRY_HEADER) || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} dir={dir} className="dark">
|
<html lang={locale} dir={dir} className={`dark ${fontVars}`}>
|
||||||
<head>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
||||||
{/* 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. */}
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600;700;800&family=Instrument+Sans:wght@400;500;600;700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="antialiased tex-grain">
|
<body className="antialiased tex-grain">
|
||||||
<LocaleProvider locale={locale} country={country}>
|
<LocaleProvider locale={locale} country={country}>
|
||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface FullProfile {
|
|||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, signOut, loading: authLoading } = useAuth();
|
const { user, tier: authTier, signOut, loading: authLoading } = useAuth();
|
||||||
const [profile, setProfile] = useState<FullProfile | null>(null);
|
const [profile, setProfile] = useState<FullProfile | null>(null);
|
||||||
const [working, setWorking] = useState(false);
|
const [working, setWorking] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -69,6 +69,12 @@ export default function ProfilePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session 41 — tier is read from useAuth (the same live-session source the
|
||||||
|
// nav uses) so the two can't disagree. The /api/user/profile fetch can lag
|
||||||
|
// or return a stale/null tier; the auth context is authoritative. Profile
|
||||||
|
// still supplies the other fields (scan_count, subscription_*, founder).
|
||||||
|
const tier = authTier || profile.tier || 'free';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '24px 16px 120px' }}>
|
<section style={{ maxWidth: 600, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||||
<header style={{ marginBottom: 24 }}>
|
<header style={{ marginBottom: 24 }}>
|
||||||
@@ -88,8 +94,8 @@ export default function ProfilePage() {
|
|||||||
{/* Session 27 — always render a tier label. When the profile
|
{/* Session 27 — always render a tier label. When the profile
|
||||||
API returns null/undefined tier (free users sometimes do),
|
API returns null/undefined tier (free users sometimes do),
|
||||||
fall back to 'free' so the field is never blank. */}
|
fall back to 'free' so the field is never blank. */}
|
||||||
<h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(profile.tier || 'free') }}>
|
<h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(tier) }}>
|
||||||
{profile.tier || 'free'}
|
{tier}
|
||||||
{profile.founder_pricing && (
|
{profile.founder_pricing && (
|
||||||
<span
|
<span
|
||||||
className="mono"
|
className="mono"
|
||||||
@@ -108,27 +114,27 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{profile.tier === 'free' && (
|
{tier === 'free' && (
|
||||||
<a href="/api/checkout?tier=analyst" className="btn-primary" style={{ padding: '10px 18px', fontSize: 13 }}>
|
<a href="/api/checkout?tier=analyst" className="btn-primary" style={{ padding: '10px 18px', fontSize: 13 }}>
|
||||||
Upgrade
|
Upgrade
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{profile.tier !== 'free' && (
|
{tier !== 'free' && (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
|
<Stat label="Status" value={profile.subscription_status} tone={profile.subscription_status === 'active' ? 'good' : 'warn'} />
|
||||||
<Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} />
|
<Stat label="Renews" value={profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : '—'} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{profile.tier === 'free' && (
|
{tier === 'free' && (
|
||||||
<Stat label="Reads this month" value={`${profile.scan_count} of 5`} />
|
<Stat label="Reads this month" value={`${profile.scan_count} of 5`} />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Founder pricing promo for free users */}
|
{/* Founder pricing promo for free users */}
|
||||||
{profile.tier === 'free' && (
|
{tier === 'free' && (
|
||||||
<section className="surface diagonal-cut-strong" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-a)' }}>
|
<section className="surface diagonal-cut-strong" style={{ padding: 20, marginBottom: 16, borderColor: 'var(--grade-a)' }}>
|
||||||
<p className="mono" style={{ fontSize: 11, color: 'var(--grade-a)', letterSpacing: '0.08em', marginBottom: 8 }}>
|
<p className="mono" style={{ fontSize: 11, color: 'var(--grade-a)', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||||
FOUNDER ACCESS
|
FOUNDER ACCESS
|
||||||
@@ -146,7 +152,7 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subscription actions */}
|
{/* Subscription actions */}
|
||||||
{profile.tier !== 'free' && !profile.cancel_at_period_end && (
|
{tier !== 'free' && !profile.cancel_at_period_end && (
|
||||||
<section className="surface" style={{ padding: 20, marginBottom: 16 }}>
|
<section className="surface" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Cancel subscription</h3>
|
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Cancel subscription</h3>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /report (Session 41 — P0 audit fix).
|
||||||
|
*
|
||||||
|
* The MORE dropdown links "THE REPORT" to /report, but the blog lives at
|
||||||
|
* /blog. Forward there so the link resolves instead of 404ing.
|
||||||
|
*/
|
||||||
|
export default function ReportPage() {
|
||||||
|
redirect('/blog');
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export default function ResponsibleGamblingPage() {
|
|||||||
<li>
|
<li>
|
||||||
<strong style={{ color: 'var(--text-primary)' }}>National Council on Problem Gambling Helpline</strong>
|
<strong style={{ color: 'var(--text-primary)' }}>National Council on Problem Gambling Helpline</strong>
|
||||||
<br />
|
<br />
|
||||||
<a href="tel:18005224700" style={{ color: 'var(--grade-a)', fontSize: 24, fontWeight: 700, fontFamily: 'JetBrains Mono, monospace' }}>
|
<a href="tel:18005224700" style={{ color: 'var(--grade-a)', fontSize: 24, fontWeight: 700, fontFamily: 'var(--mono)' }}>
|
||||||
1-800-522-4700
|
1-800-522-4700
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /settings (Session 41 — P0 audit fix).
|
||||||
|
*
|
||||||
|
* The audit found /settings 404'd. Account/preferences already live on
|
||||||
|
* /profile (the canonical surface), so forward there rather than build a
|
||||||
|
* second screen that would drift out of sync. Server-side redirect — no
|
||||||
|
* flash of an empty page.
|
||||||
|
*/
|
||||||
|
export default function SettingsPage() {
|
||||||
|
redirect('/profile');
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ export default function DeskUpgradePage() {
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
background: cadence === c ? 'var(--bg-3)' : 'transparent',
|
background: cadence === c ? 'var(--bg-3)' : 'transparent',
|
||||||
color: cadence === c ? 'var(--text-0)' : 'var(--text-1)',
|
color: cadence === c ? 'var(--text-0)' : 'var(--text-1)',
|
||||||
fontFamily: 'IBM Plex Mono, monospace',
|
fontFamily: 'var(--ibm-mono)',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.08em',
|
||||||
@@ -241,7 +241,7 @@ function TierCard({
|
|||||||
<ul style={{ listStyle: 'none', padding: 0, margin: '14px 0 0', display: 'grid', gap: 8 }}>
|
<ul style={{ listStyle: 'none', padding: 0, margin: '14px 0 0', display: 'grid', gap: 8 }}>
|
||||||
{features.map((f) => (
|
{features.map((f) => (
|
||||||
<li key={f} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 14, color: 'var(--text-0)' }}>
|
<li key={f} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 14, color: 'var(--text-0)' }}>
|
||||||
<span aria-hidden style={{ color: 'var(--grade-a)', fontFamily: 'IBM Plex Mono, monospace' }}>›</span>
|
<span aria-hidden style={{ color: 'var(--grade-a)', fontFamily: 'var(--ibm-mono)' }}>›</span>
|
||||||
<span>{f}</span>
|
<span>{f}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function SportBadgeStrip() {
|
|||||||
>
|
>
|
||||||
{SPORTS_DISPLAY.map((s) => {
|
{SPORTS_DISPLAY.map((s) => {
|
||||||
const base: React.CSSProperties = {
|
const base: React.CSSProperties = {
|
||||||
fontFamily: 'IBM Plex Mono, JetBrains Mono, monospace',
|
fontFamily: 'var(--ibm-mono)',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.08em',
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export default function NotificationBell() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
boxShadow: '0 0 8px rgba(0, 212, 160, 0.7)',
|
boxShadow: '0 0 8px rgba(0, 212, 160, 0.7)',
|
||||||
fontFamily: 'IBM Plex Mono, monospace',
|
fontFamily: 'var(--ibm-mono)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{unread > 9 ? '9+' : unread}
|
{unread > 9 ? '9+' : unread}
|
||||||
@@ -236,7 +236,7 @@ export default function NotificationBell() {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'IBM Plex Mono, monospace',
|
fontFamily: 'var(--ibm-mono)',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.08em',
|
||||||
color: TYPE_TINT[n.type],
|
color: TYPE_TINT[n.type],
|
||||||
|
|||||||
Reference in New Issue
Block a user