DS1 (design): speed + trust bugs

Fixes the three DESIGN-SPEC Part 4 + #17 audit findings.

1. React #418 hydration mismatch (landing → dashboard entry). The
   `maybeSignedIn` value was computed in a useState INITIALIZER that reads
   localStorage during render: server (no window) → false → emits the
   marketing tree; a signed-in visitor's first CLIENT render → true → emits
   the loading placeholder. Whole-subtree server/client mismatch → React
   discarded and re-rendered the page. Deferred behind a mounted flag so the
   first client render matches the server; the stored-session check flips
   post-mount. SSR HTML is no longer discarded.

2. Loading walls → skeletons. New tokenized Skeleton primitive
   (.vyndr-skeleton, reduced-motion-safe via the global rule). Swapped into
   every text-wall loader: dashboard slate load ("Loading the slate…"), /desk
   ("Assembling the pack…"), /ledger ("Loading…"), scan ("Loading the model…"),
   and the landing redirect placeholder. No bare text loader remains.

3. scan→ledger persistence. Root cause: the scan page read its bearer token
   from localStorage['sb-token'] — a key written ONLY by the OAuth callback —
   so email/password users posted /api/scan anonymously and the ledger write
   (gated on an authed user) was silently skipped. Now uses the authoritative
   session.access_token (matching the ledger read path). Extracted the row
   builder to web/src/lib/ledgerRow.js (shared, testable).

Tests: +17 (scanLedgerPersistence write→mine round-trip + scope + idempotency;
ds1SpeedTrust hydration/skeleton/persistence source invariants). Full suite
233 suites / 2793 green; web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-12 19:25:32 -04:00
parent 24af247b29
commit 1c681df5d3
12 changed files with 498 additions and 45 deletions
+24 -29
View File
@@ -193,12 +193,10 @@ async function writeLedgerEntry(
data: { grade?: string; projection?: number; confidence?: number; edge_pct?: number },
) {
try {
const { nameKey, normalizeName } = await import('@/lib/playerName');
const { nameKey } = await import('@/lib/playerName');
const { buildManualLedgerRow, LEDGER_CONFLICT_COLS } = await import('@/lib/ledgerRow');
const sport = body.sport.toLowerCase();
const playerKey = nameKey(body.player);
const gameDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
// Cache-only snapshot read (never triggers an odds fetch → no quota).
let lockedOdds: string | null = null;
@@ -220,31 +218,28 @@ async function writeLedgerEntry(
if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds);
} catch { /* absent beats wrong */ }
await sb.from('ledger_entries').upsert(
{
user_id: userId,
player_key: playerKey,
player_name: normalizeName(body.player).display || body.player,
sport,
stat: body.stat.toLowerCase(),
line: body.line,
side: body.direction,
locked_odds: lockedOdds,
book: body.book ?? 'draftkings',
// Session 59 — team from the snapshot's stats resolve (real feed);
// opponent stays null on manual scans (no game context — never guessed).
team,
opponent: null,
grade: data.grade,
edge: typeof data.edge_pct === 'number' ? data.edge_pct : null,
confidence: typeof data.confidence === 'number' ? data.confidence : null,
model_value: typeof data.projection === 'number' ? data.projection : null,
graded_at: new Date().toISOString(),
game_id: `manual:${sport}:${gameDate}:${playerKey}`,
game_date: gameDate,
},
{ onConflict: 'user_id,player_key,stat,line,side,game_id', ignoreDuplicates: true },
);
// Session 59 — team from the snapshot's stats resolve (real feed); opponent
// stays null on manual scans (no game context — never guessed).
const row = buildManualLedgerRow({
userId,
sport: body.sport,
player: body.player,
stat: body.stat,
line: body.line,
side: body.direction,
book: body.book ?? 'draftkings',
team,
lockedOdds,
grade: data.grade,
edge: data.edge_pct,
confidence: data.confidence,
projection: data.projection,
});
await sb.from('ledger_entries').upsert(row, {
onConflict: LEDGER_CONFLICT_COLS,
ignoreDuplicates: true,
});
} catch (err) {
console.warn('[scan] ledger write failed', err);
}
+9 -3
View File
@@ -10,7 +10,7 @@ import { GradePill } from '@/components/GradeCard';
// below as intelligence layers on top of the raw odds.
import Slate from '@/components/Slate';
// Session 55 — the self-learning loop's track record, live in the header.
import { AccuracyBadge } from '@/components/vyndr';
import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr';
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
import { emptyStateCopy } from '@/lib/emptyState';
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
@@ -181,9 +181,15 @@ export default function DashboardPage() {
}, [user]);
if (authLoading || !user) {
// DS1 (§4) — layout-matched skeleton, never a text wall. Mirrors the real
// dashboard: header strip + a stack of slate cards.
return (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the slate</p>
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '24px 16px 120px' }} aria-busy="true">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, gap: 16 }}>
<Skeleton height={30} width={220} radius={10} />
<Skeleton height={30} width={120} radius={999} />
</div>
<SkeletonList count={5} height={92} />
</section>
);
}
+13 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { Skeleton, SkeletonList } from '@/components/vyndr';
/**
* /desk (Session 63 / A1-S4c) — the founder's daily copy-paste arsenal.
@@ -103,10 +104,21 @@ export default function DeskPage() {
</section>
);
}
if (status === 'loading') {
// DS1 (§4) — the pack is assembling; layout-matched skeleton, not a text
// wall. Mirrors the header + stack of FormatCards below.
return (
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 120px' }} aria-busy="true">
<Skeleton height={14} width={90} radius={4} style={{ marginBottom: 12 }} />
<Skeleton height={30} width={260} radius={8} style={{ marginBottom: 24 }} />
<SkeletonList count={4} height={120} />
</section>
);
}
if (status !== 'ready' || !pack) {
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>{status === 'loading' ? 'Assembling the pack…' : 'Pack unavailable. Check back after the first pipeline run.'}</p>
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>Pack unavailable. Check back after the first pipeline run.</p>
</section>
);
}
+16
View File
@@ -401,6 +401,22 @@ h1, h2, h3, h4, h5, h6 {
animation: shimmer 1.5s linear infinite;
}
/* DS1 (§4 — kill loading walls) — the ONE skeleton primitive. Layout-matched
dark shimmer blocks replace every text-wall loader. Tokenized (no one-off
CSS); the global prefers-reduced-motion rule freezes the sweep to a static,
still-visible surface — arrives-then-rests, honored globally. */
.vyndr-skeleton {
background: linear-gradient(
90deg,
var(--bg-surface) 0%,
var(--bg-surface-hover) 50%,
var(--bg-surface) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
border-radius: 8px;
}
/* S6 (A1 board, LCP) — floored at a VISIBLE state (.6), matching the S33
entrance-keyframe rule (a paused frame is never invisible). Starting at
opacity 0 also delayed the hero heading's first contentful paint — an
+7 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard';
import { useAuth } from '@/contexts/AuthContext';
import { Skeleton } from '@/components/vyndr';
/**
* The Ledger (Session 58, Phase 1) — the truth surface, now backed by the
@@ -153,7 +154,12 @@ export default function LedgerPage() {
</div>
{rows === null ? (
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 32, textAlign: 'center' }}>Loading</p>
// DS1 (§4) — grid of card skeletons matching the ledger's real layout.
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }} aria-busy="true">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height={132} radius={14} />
))}
</div>
) : rows.length === 0 ? (
<EmptyLedger tab={tab} />
) : (
+18 -6
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import Hero from '@/components/Hero';
import { ClaimMeter, ModelRecord } from '@/components/vyndr';
import { ClaimMeter, ModelRecord, Skeleton, SkeletonList } from '@/components/vyndr';
// Session 55 — live top A-rated grades pulled from tonight's real snapshot,
// with the self-learning loop's accuracy line. The product shown, not described.
import TopSignals from '@/components/TopSignals';
@@ -47,7 +47,18 @@ function hasStoredSession(): boolean {
export default function Home() {
const { user, loading } = useAuth();
const router = useRouter();
const [maybeSignedIn] = useState<boolean>(() => hasStoredSession());
// DS1 (DESIGN-SPEC §4, audit #5) — React #418 hydration fix. Reading
// localStorage inside a useState INITIALIZER runs during render: the server
// (no `window`) computes `false` and emits the marketing tree, but the first
// CLIENT render of a signed-in visitor reads the stored session, computes
// `true`, and emits the "Loading" placeholder instead — a server/client HTML
// mismatch that made React discard and re-render the whole page (#418). The
// client-only value is now deferred behind a mounted flag: the first client
// render matches the server (both treat it as `false`), then the stored-
// session check flips it post-mount. SSR HTML is never discarded.
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const maybeSignedIn = mounted && hasStoredSession();
useEffect(() => {
if (!loading && user) router.replace('/dashboard');
@@ -56,11 +67,12 @@ export default function Home() {
// Suppress the marketing render ONLY for visitors with a stored session
// (they're about to redirect) — anonymous first paint is instant.
if (user || (loading && maybeSignedIn)) {
// Signed-in visitor mid-redirect to /dashboard — a layout-matched skeleton,
// never a text wall (§4).
return (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
Loading the slate
</p>
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px' }} aria-busy="true">
<Skeleton height={40} width={280} radius={10} style={{ marginBottom: 24 }} />
<SkeletonList count={4} height={96} />
</section>
);
}
+18 -5
View File
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
import PriorReads from '@/components/vyndr/PriorReads';
import { AccuracyBadge } from '@/components/vyndr';
import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr';
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
import { normalizeName, nameKey } from '@/lib/playerName';
@@ -110,7 +110,7 @@ const SPORT_ACCENT: Record<Sport, string> = {
export default function ScanPage() {
const router = useRouter();
const { user, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth();
const { user, session, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth();
const { addLeg, legCount, open } = useParlay();
const [sport, setSport] = useState<Sport>('NBA');
@@ -241,7 +241,16 @@ export default function ScanPage() {
setError('');
setResult(null);
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
// DS1 (§17 — scan→ledger persistence). The authoritative bearer token is
// the live Supabase session's access_token (set for EVERY sign-in method).
// The legacy `localStorage['sb-token']` key is written ONLY by the OAuth
// callback — so email/password users sent NO Authorization header, the
// /api/scan route saw an anonymous request, and the completed read was
// silently dropped from the ledger (the write is gated on an authed user).
// Prefer the session token; keep the legacy key as a fallback.
const token =
session?.access_token ||
(typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null);
const res = await fetch('/api/scan', {
method: 'POST',
headers: {
@@ -301,9 +310,13 @@ export default function ScanPage() {
};
if (authLoading || !user) {
// DS1 (§4) — layout-matched skeleton of the scan form, not a text wall.
return (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the model</p>
<section style={{ maxWidth: 720, margin: '0 auto', padding: '32px 16px 96px' }} aria-busy="true">
<Skeleton height={30} width={200} radius={8} style={{ marginBottom: 8 }} />
<Skeleton height={16} width={320} radius={6} style={{ marginBottom: 28 }} />
<SkeletonList count={3} height={64} />
<Skeleton height={52} radius={12} style={{ marginTop: 20 }} />
</section>
);
}