From 1c681df5d33b6618ac202e3f5270da3f787f67d2 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 19:25:32 -0400 Subject: [PATCH] DS1 (design): speed + trust bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tests/unit/ds1SpeedTrust.test.js | 96 ++++++++++++++ tests/unit/scanLedgerPersistence.test.js | 153 +++++++++++++++++++++++ web/src/app/api/scan/route.ts | 53 ++++---- web/src/app/dashboard/page.tsx | 12 +- web/src/app/desk/page.tsx | 14 ++- web/src/app/globals.css | 16 +++ web/src/app/ledger/page.tsx | 8 +- web/src/app/page.tsx | 24 +++- web/src/app/scan/page.tsx | 23 +++- web/src/components/vyndr/Skeleton.tsx | 63 ++++++++++ web/src/components/vyndr/index.ts | 3 + web/src/lib/ledgerRow.js | 78 ++++++++++++ 12 files changed, 498 insertions(+), 45 deletions(-) create mode 100644 tests/unit/ds1SpeedTrust.test.js create mode 100644 tests/unit/scanLedgerPersistence.test.js create mode 100644 web/src/components/vyndr/Skeleton.tsx create mode 100644 web/src/lib/ledgerRow.js diff --git a/tests/unit/ds1SpeedTrust.test.js b/tests/unit/ds1SpeedTrust.test.js new file mode 100644 index 0000000..64396a2 --- /dev/null +++ b/tests/unit/ds1SpeedTrust.test.js @@ -0,0 +1,96 @@ +/** + * DS1 (DESIGN-SPEC §4 + §17) — source invariants for the Speed + Trust bugs. + * - React #418 hydration: no client-only value rendered in the SSR path + * without a mounted guard. + * - Loading walls: every named loader is a skeleton, not a text wall. + * - Persistence: the scan write uses the authoritative session token. + */ + +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('DS1 — React #418 hydration safety (§4, audit #5)', () => { + const landing = read('app/page.tsx'); + + test('landing no longer reads localStorage inside a useState initializer', () => { + // The exact antipattern that caused #418: the client-only stored-session + // check ran during render, so server (false) and client (true) diverged. + expect(landing).not.toMatch(/useState\(\s*\(\)\s*=>\s*hasStoredSession\(\)\s*\)/); + expect(landing).not.toMatch(/useState\(\s*\(\)\s*=>\s*hasStoredSession/); + }); + + test('the client-only value is deferred behind a mounted flag', () => { + // Server + first client render both compute maybeSignedIn from mounted=false. + expect(landing).toMatch(/const\s+\[mounted,\s*setMounted\]\s*=\s*useState\(false\)/); + expect(landing).toMatch(/setMounted\(true\)/); + expect(landing).toMatch(/maybeSignedIn\s*=\s*mounted\s*&&\s*hasStoredSession\(\)/); + }); + + test('hasStoredSession still guards against server-side window access', () => { + expect(landing).toMatch(/typeof window === 'undefined'/); + }); +}); + +describe('DS1 — loading walls are skeletons, never text (§4, audit #5)', () => { + const files = { + 'dashboard slate load': 'app/dashboard/page.tsx', + '/desk assembling': 'app/desk/page.tsx', + '/ledger loading': 'app/ledger/page.tsx', + 'scan loading': 'app/scan/page.tsx', + 'landing redirect': 'app/page.tsx', + }; + const BANNED = [ + 'Loading the slate', + 'Assembling the pack', + 'Loading the model', + /]*>\s*Loading…\s*<\/p>/, // the bare ledger text loader + ]; + + for (const [name, rel] of Object.entries(files)) { + test(`${name} contains no text-wall loader`, () => { + const src = read(rel); + for (const b of BANNED) { + if (typeof b === 'string') expect(src).not.toContain(b); + else expect(src).not.toMatch(b); + } + }); + } + + test('each loader path renders the shared Skeleton', () => { + for (const rel of ['app/dashboard/page.tsx', 'app/desk/page.tsx', 'app/ledger/page.tsx', 'app/scan/page.tsx', 'app/page.tsx']) { + const src = read(rel); + expect(src).toMatch(/Skeleton/); + expect(src).toMatch(/from '@\/components\/vyndr'/); + } + }); + + test('the Skeleton primitive is tokenized (no raw hex) and reduced-motion-safe', () => { + const skel = read('components/vyndr/Skeleton.tsx'); + // Uses the CSS class, no inline colors. + expect(skel).toMatch(/vyndr-skeleton/); + expect(skel).not.toMatch(/#[0-9a-fA-F]{3,8}/); + const css = fs.readFileSync(path.join(WEB, 'app', 'globals.css'), 'utf8'); + expect(css).toMatch(/\.vyndr-skeleton\s*\{/); + expect(css).toMatch(/var\(--bg-surface\)/); + // The global prefers-reduced-motion rule freezes every animation. + expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/); + }); +}); + +describe('DS1 — scan→ledger persistence uses the authoritative token (§17)', () => { + const scan = read('app/scan/page.tsx'); + + test('scan pulls the bearer token from the live Supabase session', () => { + expect(scan).toMatch(/session,/); // destructured from useAuth + expect(scan).toMatch(/session\?\.access_token/); + }); + + test('scan no longer relies solely on the OAuth-only legacy localStorage key', () => { + // The stale key may remain only as a FALLBACK after the session token. + const m = scan.match(/session\?\.access_token\s*\|\|[\s\S]{0,120}sb-token/); + expect(m).toBeTruthy(); + }); +}); diff --git a/tests/unit/scanLedgerPersistence.test.js b/tests/unit/scanLedgerPersistence.test.js new file mode 100644 index 0000000..d29df48 --- /dev/null +++ b/tests/unit/scanLedgerPersistence.test.js @@ -0,0 +1,153 @@ +/** + * DS1 (DESIGN-SPEC §17) — scan→ledger persistence. + * + * Proves a completed scan's ledger row (built by the SAME builder the + * /api/scan route uses) is readable by the /api/ledger/mine query: scoped by + * user_id, keyed by player_key = nameKey, deduped idempotently. This is the + * "every grade, no hiding" guarantee the audit found broken. + */ + +const { buildManualLedgerRow, LEDGER_CONFLICT_COLS, gameDateET } = require('../../web/src/lib/ledgerRow'); +const { nameKey, normalizeName } = require('../../web/src/lib/playerName'); + +// A minimal in-memory stand-in for the service-role Supabase client, modelling +// exactly the two operations that matter: the route's upsert(row, {onConflict, +// ignoreDuplicates}) and the /api/ledger/mine read +// (.select().eq('user_id',id).order('graded_at',desc).limit(n)). +function makeFakeSupabase() { + const rows = []; + const conflictCols = LEDGER_CONFLICT_COLS.split(','); + const keyOf = (r) => conflictCols.map((c) => String(r[c])).join('|'); + + return { + _rows: rows, + from() { + return { + async upsert(row, opts = {}) { + const k = keyOf(row); + const existing = rows.find((r) => keyOf(r) === k); + if (existing) { + if (!opts.ignoreDuplicates) Object.assign(existing, row); + return { data: null, error: null }; + } + rows.push({ ...row, id: `row-${rows.length + 1}` }); + return { data: null, error: null }; + }, + // Chainable read that mirrors the mine route. + select() { + const filters = {}; + let orderCol = null; let asc = true; let lim = Infinity; + const q = { + eq(col, val) { filters[col] = val; return q; }, + order(col, o = {}) { orderCol = col; asc = o.ascending !== false; return q; }, + limit(n) { lim = n; return q; }, + then(resolve) { + let out = rows.filter((r) => Object.entries(filters).every(([c, v]) => r[c] === v)); + if (orderCol) { + out = [...out].sort((a, b) => (a[orderCol] < b[orderCol] ? -1 : a[orderCol] > b[orderCol] ? 1 : 0)); + if (!asc) out.reverse(); + } + out = out.slice(0, lim); + resolve({ data: out, error: null }); + }, + }; + return q; + }, + }; + }, + }; +} + +// The mine query, byte-for-byte in spirit with routes/ledger.js GET /mine. +async function mineQuery(sb, userId, limit = 100) { + const { data } = await sb.from('ledger_entries') + .select('*') + .eq('user_id', userId) + .order('graded_at', { ascending: false }) + .limit(limit); + return data; +} + +describe('scan→ledger persistence (DS1 §17)', () => { + const USER_A = 'user-aaaa'; + const USER_B = 'user-bbbb'; + + test('a completed scan write is immediately readable by the mine query', async () => { + const sb = makeFakeSupabase(); + const row = buildManualLedgerRow({ + userId: USER_A, + sport: 'MLB', + player: 'Aaron Judge', + stat: 'home_runs', + line: 1.5, + side: 'over', + book: 'draftkings', + grade: 'A', + confidence: 71, + projection: 1.9, + edge: 12.4, + }); + + await sb.from('ledger_entries').upsert(row, { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); + + const mine = await mineQuery(sb, USER_A); + expect(mine).toHaveLength(1); + const got = mine[0]; + expect(got.user_id).toBe(USER_A); + // The join key the ledger/model reads on is player_key = nameKey. + expect(got.player_key).toBe(nameKey('Aaron Judge')); + expect(got.player_name).toBe(normalizeName('Aaron Judge').display); + expect(got.sport).toBe('mlb'); + expect(got.stat).toBe('home_runs'); + expect(got.side).toBe('over'); + expect(got.line).toBe(1.5); + expect(got.grade).toBe('A'); + // game_id embeds the ET game_date so the settle pass can find it. + expect(got.game_id).toBe(`manual:mlb:${gameDateET()}:${nameKey('Aaron Judge')}`); + expect(got.game_date).toBe(gameDateET()); + }); + + test('the mine query is scoped: another user cannot read the row', async () => { + const sb = makeFakeSupabase(); + const row = buildManualLedgerRow({ + userId: USER_A, sport: 'MLB', player: 'Shohei Ohtani', stat: 'total_bases', + line: 1.5, side: 'over', book: 'fanduel', grade: 'B', + }); + await sb.from('ledger_entries').upsert(row, { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); + + expect(await mineQuery(sb, USER_A)).toHaveLength(1); + expect(await mineQuery(sb, USER_B)).toHaveLength(0); + }); + + test('re-scanning the same prop is idempotent (never duplicates the row)', async () => { + const sb = makeFakeSupabase(); + const mk = () => buildManualLedgerRow({ + userId: USER_A, sport: 'MLB', player: 'Mookie Betts', stat: 'hits', + line: 0.5, side: 'over', book: 'draftkings', grade: 'A', + }); + await sb.from('ledger_entries').upsert(mk(), { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); + await sb.from('ledger_entries').upsert(mk(), { onConflict: LEDGER_CONFLICT_COLS, ignoreDuplicates: true }); + + expect(await mineQuery(sb, USER_A)).toHaveLength(1); + }); + + test('DATA SEMANTICS: absent beats wrong — no snapshot ⇒ locked_odds/team null, model fields carried', () => { + const row = buildManualLedgerRow({ + userId: USER_A, sport: 'WNBA', player: "A'ja Wilson", stat: 'points', + line: 22.5, side: 'over', grade: 'B', confidence: 60, projection: 24, + // no team / lockedOdds supplied (no snapshot match) + }); + expect(row.locked_odds).toBeNull(); + expect(row.team).toBeNull(); + expect(row.opponent).toBeNull(); + expect(row.model_value).toBe(24); + expect(row.confidence).toBe(60); + // Non-numeric model fields degrade to null, never 0 (Number(null)===0 bug). + expect(buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'X', stat: 'hits', line: 1, side: 'over' }).confidence).toBeNull(); + expect(buildManualLedgerRow({ userId: USER_A, sport: 'MLB', player: 'X', stat: 'hits', line: 1, side: 'over' }).model_value).toBeNull(); + }); + + test('the dedupe key matches the migration-019 constraint the route upserts on', () => { + expect(LEDGER_CONFLICT_COLS).toBe('user_id,player_key,stat,line,side,game_id'); + }); +}); diff --git a/web/src/app/api/scan/route.ts b/web/src/app/api/scan/route.ts index c506f47..19757a0 100644 --- a/web/src/app/api/scan/route.ts +++ b/web/src/app/api/scan/route.ts @@ -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); } diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index df05037..c278038 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -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 ( -
-

Loading the slate…

+
+
+ + +
+
); } diff --git a/web/src/app/desk/page.tsx b/web/src/app/desk/page.tsx index ec397b3..0f45c28 100644 --- a/web/src/app/desk/page.tsx +++ b/web/src/app/desk/page.tsx @@ -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() {
); } + if (status === 'loading') { + // DS1 (§4) — the pack is assembling; layout-matched skeleton, not a text + // wall. Mirrors the header + stack of FormatCards below. + return ( +
+ + + +
+ ); + } if (status !== 'ready' || !pack) { return (
-

{status === 'loading' ? 'Assembling the pack…' : 'Pack unavailable. Check back after the first pipeline run.'}

+

Pack unavailable. Check back after the first pipeline run.

); } diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 4cd96c4..db5a884 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -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 diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 1d4ded0..6772563 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -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() { {rows === null ? ( -

Loading…

+ // DS1 (§4) — grid of card skeletons matching the ledger's real layout. +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
) : rows.length === 0 ? ( ) : ( diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index c7fdb97..cbe7130 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -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(() => 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 ( -
-

- Loading the slate -

+
+ +
); } diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index c4799c6..56e4244 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -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 = { 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('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 ( -
-

Loading the model…

+
+ + + +
); } diff --git a/web/src/components/vyndr/Skeleton.tsx b/web/src/components/vyndr/Skeleton.tsx new file mode 100644 index 0000000..9321195 --- /dev/null +++ b/web/src/components/vyndr/Skeleton.tsx @@ -0,0 +1,63 @@ +'use client'; + +import React from 'react'; + +/** + * DS1 (DESIGN-SPEC §4 — "kill loading walls") — the single skeleton primitive. + * + * Governing law: premium = SPEED + RESTRAINT. A text-wall loader ("Loading…") + * reads cheap; a layout-matched skeleton reads instant. This is a tokenized + * dark shimmer block (`.vyndr-skeleton` in globals.css) — the sweep is chrome, + * never data, and the global prefers-reduced-motion rule freezes it to a + * still, visible surface. + */ +export function Skeleton({ + height = 16, + width = '100%', + radius = 8, + style, + className = '', +}: { + height?: number | string; + width?: number | string; + radius?: number | string; + style?: React.CSSProperties; + className?: string; +}) { + return ( +