diff --git a/src/app.js b/src/app.js
index 8c41c4b..0027e39 100644
--- a/src/app.js
+++ b/src/app.js
@@ -128,6 +128,8 @@ app.use('/api/preferences', require('./routes/preferences'));
app.use('/api/stripe', stripeRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/props', propsRoutes);
+// Session 60 (night2/E) — the scan search box's canonical player resolver.
+app.use('/api/players', require('./routes/players'));
app.use('/api/waitlist', waitlistRoutes);
app.use('/api/pipeline', pipelineRoutes);
app.use('/api/share-card', shareCardRoutes);
diff --git a/src/routes/ledger.js b/src/routes/ledger.js
index 204b244..9ce7a83 100644
--- a/src/routes/ledger.js
+++ b/src/routes/ledger.js
@@ -92,6 +92,8 @@ router.get('/model', async (req, res) => {
.select(ROW_COLUMNS)
.is('user_id', null);
q = applyFilters(q, req);
+ // Session 60 (night2/E) — PRIOR READS: a player's own public history.
+ if (req.query.player) q = q.eq('player_key', nameKey(String(req.query.player).slice(0, 60)));
const { data, error } = await q
.order('graded_at', { ascending: false })
.limit(limit);
diff --git a/src/routes/players.js b/src/routes/players.js
new file mode 100644
index 0000000..7c76045
--- /dev/null
+++ b/src/routes/players.js
@@ -0,0 +1,74 @@
+'use strict';
+
+/**
+ * GET /api/players/search (Session 60, night2/E — audit fix 4.1).
+ *
+ * The scan search box's canonical resolver. Before this route existed, MLB
+ * search 404'd at Express and NBA/WNBA hit the (usually offline) Python
+ * service — "Ohtani" returned nothing while his tile sat on the page.
+ *
+ * MLB: fuzzy match against the cached statsapi player list (free, 24h
+ * cache) — case/diacritic-insensitive, nickname/suffix-aware via nameKey.
+ * Other sports: cache-only match against the names the platform already
+ * knows (rosterlogs blob + tonight's graded slate). Never an upstream call
+ * for non-MLB; empty is a valid answer.
+ */
+
+const express = require('express');
+const { createRateLimit } = require('../middleware/rateLimit');
+const { nameKey } = require('../utils/playerName');
+
+const router = express.Router();
+router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
+
+async function cachedNames(sport) {
+ const { cacheGet } = require('../utils/redis');
+ const out = new Map();
+ try {
+ const blob = await cacheGet(`rosterlogs:${sport}`);
+ for (const p of Array.isArray(blob) ? blob : []) {
+ if (p && p.name) out.set(nameKey(p.name), { full_name: p.name, team: p.team || null });
+ }
+ } catch { /* cache-only, degrade */ }
+ try {
+ const env = await cacheGet(`grades:${sport}`);
+ for (const g of (env && env.grades) || []) {
+ const n = g.player || g.player_name;
+ if (n && !out.has(nameKey(n))) out.set(nameKey(n), { full_name: n, team: g.team || null });
+ }
+ } catch { /* cache-only, degrade */ }
+ return [...out.values()];
+}
+
+router.get('/search', async (req, res) => {
+ const sport = String(req.query.sport || 'NBA').toUpperCase();
+ const q = String(req.query.q || '').trim();
+ if (q.length < 2) return res.json({ players: [] });
+
+ try {
+ if (sport === 'MLB') {
+ const { searchPlayers } = require('../services/adapters/mlbStatsAdapter');
+ const hits = await searchPlayers(q, { limit: 12 });
+ return res.json({
+ players: hits.map((h) => ({ id: String(h.id), full_name: h.fullName, team: h.team || undefined, position: h.position || undefined })),
+ });
+ }
+ // NBA/WNBA/soccer — the names the platform already carries (cache-only).
+ const names = await cachedNames(sport.toLowerCase());
+ const qKey = nameKey(q);
+ const qLast = qKey.split(' ').pop();
+ const players = names
+ .filter((p) => {
+ const k = nameKey(p.full_name);
+ return k === qKey || k.includes(qKey) || (qLast.length >= 3 && k.split(' ').some((w) => w.startsWith(qLast)));
+ })
+ .slice(0, 12)
+ .map((p, i) => ({ id: `${sport}-${i}-${nameKey(p.full_name)}`, full_name: p.full_name, team: p.team || undefined }));
+ return res.json({ players });
+ } catch (err) {
+ console.error('[players/search]', err.message);
+ return res.json({ players: [] });
+ }
+});
+
+module.exports = router;
diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js
index 2bc2d44..200b399 100644
--- a/src/services/adapters/mlbStatsAdapter.js
+++ b/src/services/adapters/mlbStatsAdapter.js
@@ -144,6 +144,45 @@ const { nameKey } = require('../../utils/playerName');
* requires a UNIQUE same-last-name + same-first-initial candidate; anything
* ambiguous returns null (a missing profile beats another player's log).
*/
+/**
+ * Session 60 (night2/E, audit fix 4.1) — fuzzy MULTI-match against the
+ * canonical list. Pure: rank = exact nameKey > last-name prefix > folded
+ * substring. Case/diacritic-insensitive via nameKey's folding, so
+ * "ohtani", "Sánchez", "sanchez", "Chisholm Jr" all resolve.
+ */
+function matchPlayers(people, query, limit = 12) {
+ const qKey = nameKey(query);
+ if (!qKey) return [];
+ const qLast = qKey.split(' ').pop();
+ const scored = [];
+ for (const p of people || []) {
+ const k = nameKey(p.fullName);
+ if (!k) continue;
+ let score = null;
+ if (k === qKey) score = 0;
+ else if (k.split(' ').some((w) => w.startsWith(qLast)) && qLast.length >= 3) score = 1;
+ else if (k.includes(qKey)) score = 2;
+ if (score == null) continue;
+ scored.push({ score, p });
+ }
+ scored.sort((a, b) => a.score - b.score);
+ return scored.slice(0, limit).map(({ p }) => ({
+ id: p.id,
+ fullName: p.fullName,
+ team: p.currentTeam?.name ?? null,
+ position: p.primaryPosition?.abbreviation ?? null,
+ }));
+}
+
+/** Multi-result fuzzy search (scan search box). Cached list, free API. */
+async function searchPlayers(query, opts = {}) {
+ const season = opts.season || DEFAULT_SEASON;
+ const url = `${BASE}/sports/1/players?season=${season}`;
+ const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
+ const people = (data && Array.isArray(data.people)) ? data.people : [];
+ return matchPlayers(people, query, opts.limit || 12);
+}
+
async function searchPlayer(name, season = DEFAULT_SEASON) {
const targetKey = nameKey(name);
if (!targetKey) return null;
@@ -248,6 +287,8 @@ module.exports = {
getSeasonAverages,
getBatterVsPitcher,
searchPlayer,
+ searchPlayers,
+ matchPlayers,
getPlayerStats,
getTeams,
resolveTeam,
diff --git a/tests/unit/mlbStatsAdapter.test.js b/tests/unit/mlbStatsAdapter.test.js
index 00d32c6..1c29158 100644
--- a/tests/unit/mlbStatsAdapter.test.js
+++ b/tests/unit/mlbStatsAdapter.test.js
@@ -176,3 +176,26 @@ describe('searchPlayer — canonical nameKey resolution', () => {
expect(hit).toBeNull();
});
});
+
+// Session 60 (night2/E, audit 4.1) — the scan box fuzzy matcher.
+describe('matchPlayers — fuzzy search (pure)', () => {
+ const LIST = [
+ { id: 1, fullName: 'Shohei Ohtani', currentTeam: { name: 'Los Angeles Dodgers' }, primaryPosition: { abbreviation: 'DH' } },
+ { id: 2, fullName: 'Aaron Judge', currentTeam: { name: 'New York Yankees' }, primaryPosition: { abbreviation: 'RF' } },
+ { id: 3, fullName: 'Cristopher Sanchez', currentTeam: { name: 'Philadelphia Phillies' }, primaryPosition: { abbreviation: 'P' } },
+ { id: 4, fullName: 'Jazz Chisholm Jr.', currentTeam: { name: 'New York Yankees' }, primaryPosition: { abbreviation: '3B' } },
+ ];
+ const { matchPlayers } = adapter;
+
+ test.each([
+ ['ohtani', 1], ['Aaron Judge', 2], ['Sánchez', 3], ['sanchez', 3], ['Chisholm Jr', 4], ['jazz chisholm', 4],
+ ])('"%s" resolves', (q, id) => {
+ const hits = matchPlayers(LIST, q);
+ expect(hits.length).toBeGreaterThan(0);
+ expect(hits[0].id).toBe(id);
+ });
+
+ test('sub-2-char garbage resolves nothing', () => {
+ expect(matchPlayers(LIST, '')).toEqual([]);
+ });
+});
diff --git a/web/src/app/api/players/search/route.ts b/web/src/app/api/players/search/route.ts
index 2e1e879..9a9360d 100644
--- a/web/src/app/api/players/search/route.ts
+++ b/web/src/app/api/players/search/route.ts
@@ -20,16 +20,19 @@ export async function GET(req: NextRequest) {
if (q.length < 2) return NextResponse.json({ players: [] });
try {
- // NBA/WNBA use the nba_api wrapper service; MLB falls back to the main backend.
- const url =
- sport === 'MLB'
- ? `${BACKEND_URL}/api/players/search?sport=MLB&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`
- : `${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`;
+ // Session 60 (night2/E, audit 4.1) — Express is the canonical resolver
+ // for EVERY sport now (nameKey fuzzy match; MLB = full statsapi list,
+ // others = the platform's cached names). The Python NBA service is a
+ // best-effort second try only when the canonical index has nothing.
+ const url = `${BACKEND_URL}/api/players/search?sport=${encodeURIComponent(sport)}&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`;
- const res = await fetch(url, { headers: { Accept: 'application/json' } });
- if (!res.ok) return NextResponse.json({ players: [] });
-
- const data = await res.json().catch(() => ({}));
+ let res = await fetch(url, { headers: { Accept: 'application/json' } });
+ let data = res.ok ? await res.json().catch(() => ({})) : {};
+ const canonical = Array.isArray((data as { players?: unknown[] }).players) ? (data as { players: unknown[] }).players : [];
+ if (canonical.length === 0 && sport !== 'MLB') {
+ res = await fetch(`${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' } }).catch(() => new Response(null, { status: 502 }));
+ data = res.ok ? await res.json().catch(() => ({})) : {};
+ }
const rawPlayers: unknown[] = Array.isArray((data as { results?: unknown[] }).results)
? (data as { results: unknown[] }).results
: Array.isArray((data as { players?: unknown[] }).players)
diff --git a/web/src/app/globals.css b/web/src/app/globals.css
index 9f9ddd6..0dc18c5 100644
--- a/web/src/app/globals.css
+++ b/web/src/app/globals.css
@@ -1293,3 +1293,33 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
.gl-full:not(.gl-expanded) { display: none !important; }
.gl-full.gl-expanded { margin-top: 10px; }
}
+
+/* ============================================================
+ Session 60 (night2/E) — §7 reveal choreography.
+ Stamp slam + 90ms staggered context panels. Entrance keyframes floor at
+ a VISIBLE state (Phase-0 rule: a paused frame is never invisible), and
+ reduced-motion kills the choreography entirely (ProcessingGrade also
+ skips straight to the card).
+ ============================================================ */
+@keyframes stamp-in {
+ 0% { opacity: .5; transform: rotate(-7deg) scale(1.7); }
+ 60% { opacity: 1; transform: rotate(-7deg) scale(0.96); }
+ 100% { opacity: 1; transform: rotate(-7deg) scale(1); }
+}
+.stamp-in { animation: stamp-in .34s cubic-bezier(.2,1.6,.4,1) both; }
+
+@keyframes panel-in {
+ from { opacity: .55; transform: translateY(7px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.grade-reveal > div > * { animation: panel-in .3s ease-out both; }
+.grade-reveal > div > *:nth-child(2) { animation-delay: 90ms; }
+.grade-reveal > div > *:nth-child(3) { animation-delay: 180ms; }
+.grade-reveal > div > *:nth-child(4) { animation-delay: 270ms; }
+.grade-reveal > div > *:nth-child(5) { animation-delay: 360ms; }
+.grade-reveal > div > *:nth-child(6) { animation-delay: 450ms; }
+.grade-reveal > div > *:nth-child(7) { animation-delay: 540ms; }
+.grade-reveal > div > *:nth-child(n+8) { animation-delay: 630ms; }
+@media (prefers-reduced-motion: reduce) {
+ .stamp-in, .grade-reveal > div > * { animation: none !important; }
+}
diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx
index 62050e5..c98f596 100644
--- a/web/src/app/scan/page.tsx
+++ b/web/src/app/scan/page.tsx
@@ -3,6 +3,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 type { GradeResultData } from '@/components/vyndr/GradeResultCard';
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
@@ -762,6 +763,10 @@ export default function ScanPage() {
onReadAnother={reset}
/>
+ {/* Session 60 (4.3) — the model's public history on this player.
+ Deferred-render: shows only when real ledger rows exist. */}
+