diff --git a/tests/unit/edgeBoard.test.js b/tests/unit/edgeBoard.test.js
new file mode 100644
index 0000000..d80c449
--- /dev/null
+++ b/tests/unit/edgeBoard.test.js
@@ -0,0 +1,73 @@
+'use strict';
+
+// Rev 3 — flat EDGE BOARD (mobile screen 01). Design's mobile board is a flat,
+// edge-ranked list across every game, not game-grouped cards. The flatten is a
+// pure transform of the assembled GameCardData[]; lock its ranking + honesty.
+
+const { flattenToEdgeBoard, edgeBoardBreadth, edgeGradeRank } = require('../../web/src/lib/slateAdapter');
+
+const card = (over) => ({
+ sport: 'nba', live: false,
+ away: { abbr: 'DEN', name: 'Denver' }, home: { abbr: 'MIN', name: 'Minnesota' },
+ playerStrips: [], ...over,
+});
+const strip = (player, props, extra = {}) => ({ player, team: extra.team || '', props });
+
+describe('flattenToEdgeBoard', () => {
+ const cards = [
+ card({ playerStrips: [
+ strip('Jokic', [{ stat: 'PTS', line: 27.5, side: 'O', grade: 'A+', edge: 8.4 }]),
+ strip('Gordon', [{ stat: 'REB', line: 7.5, side: 'O', grade: 'B', edge: 3.4 }]),
+ ] }),
+ card({ live: true, away: { abbr: 'CHI', name: 'Chi' }, home: { abbr: 'MIL', name: 'Mil' }, playerStrips: [
+ strip('Edwards', [{ stat: 'AST', line: 5.5, side: 'O', grade: 'A', edge: 5.2 }]),
+ strip('LaVine', [{ stat: 'PTS', line: 22.5, side: 'O', grade: 'D', edge: -1.2 }]),
+ ] }),
+ ];
+
+ test('flattens every graded prop across games into ranked rows, edge desc', () => {
+ const rows = flattenToEdgeBoard(cards);
+ expect(rows.map((r) => r.player)).toEqual(['Jokic', 'Edwards', 'Gordon', 'LaVine']); // 8.4, 5.2, 3.4, -1.2
+ expect(rows[0].rank).toBe(1);
+ expect(rows[3].rank).toBe(4);
+ expect(rows[1].live).toBe(true); // Edwards' game is live → row carries it
+ expect(rows[0].away).toBe('DEN'); expect(rows[0].home).toBe('MIN');
+ });
+
+ test('a null edge sorts LAST, never 0-coerced to the top', () => {
+ const rows = flattenToEdgeBoard([card({ playerStrips: [
+ strip('NoEdge', [{ stat: 'PTS', line: 20, side: 'O', grade: 'A', edge: null }]),
+ strip('HasEdge', [{ stat: 'PTS', line: 20, side: 'O', grade: 'C', edge: 2.0 }]),
+ ] })]);
+ expect(rows[0].player).toBe('HasEdge'); // +2.0 beats a null (not 0)
+ expect(rows[1].player).toBe('NoEdge');
+ });
+
+ test('awaiting / dead / ungraded rows are excluded from the board', () => {
+ const rows = flattenToEdgeBoard([card({ playerStrips: [
+ strip('Graded', [{ stat: 'PTS', line: 20, side: 'O', grade: 'A', edge: 5 }]),
+ strip('Awaiting', [{ stat: 'PTS', line: 20, side: 'O', grade: null, awaiting: true }]),
+ strip('Dead', [{ stat: 'PTS', line: 20, side: 'O', grade: 'B', edge: 4, dead: true }]),
+ ] })]);
+ expect(rows.map((r) => r.player)).toEqual(['Graded']);
+ });
+
+ test('breadth counts A/B edges + games, never fabricates CLV', () => {
+ const rows = flattenToEdgeBoard(cards);
+ const b = edgeBoardBreadth(rows, cards);
+ expect(b.edges).toBe(3); // A+, B, A (not the D)
+ expect(b.games).toBe(2);
+ expect(b).not.toHaveProperty('clv'); // CLV comes from /api/accuracy, never invented here
+ });
+
+ test('edgeGradeRank orders tiers for the tiebreak', () => {
+ expect(edgeGradeRank('A+')).toBeLessThan(edgeGradeRank('A'));
+ expect(edgeGradeRank('A')).toBeLessThan(edgeGradeRank('B'));
+ expect(edgeGradeRank('F')).toBeLessThan(edgeGradeRank('ZZZ')); // unknown → last
+ });
+
+ test('empty / malformed input → empty board, no throw', () => {
+ expect(flattenToEdgeBoard(null)).toEqual([]);
+ expect(flattenToEdgeBoard([{}])).toEqual([]);
+ });
+});
diff --git a/web/src/app/globals.css b/web/src/app/globals.css
index 75cd4b5..624431d 100644
--- a/web/src/app/globals.css
+++ b/web/src/app/globals.css
@@ -1414,6 +1414,15 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
SPECIFIC hero sizes, not one generic clamp: 74px grade tier, 40px live
tier, 24px /u stats. Those land per-surface in M1b, not as a global class.) */
+
+/* Rev 3 — flat EDGE BOARD is the mobile board (Design screen 01); desktop keeps
+ the game-grouped cards. */
+.edge-board-mobile { display: none; }
+@media (max-width: 767px) {
+ .edge-board-mobile { display: block; }
+ .edge-board-desktop { display: none !important; }
+}
+
/* ============================================================
Session 60 (night2/E) — §7 reveal choreography.
Stamp slam + 90ms staggered context panels. Entrance keyframes floor at
diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx
index b3192f4..94c5a83 100644
--- a/web/src/components/Slate.tsx
+++ b/web/src/components/Slate.tsx
@@ -7,7 +7,8 @@ import { useRouter } from 'next/navigation';
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, Tier } from '@/components/PropRow';
-import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey } from '@/lib/slateAdapter';
+import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey, flattenToEdgeBoard, edgeBoardBreadth } from '@/lib/slateAdapter';
+import MobileEdgeBoard from '@/components/vyndr/MobileEdgeBoard';
// Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no
// live games (and it's not a fetch failure) the grid shows REAL data —
// yesterday's proven receipts + tomorrow's date-pinned schedule.
@@ -1181,16 +1182,37 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
graded prop has a real ≥2-book median to compare the model against. */}
{dateOffset === 0 && }
-
- {orderedGames.map((g, i) => (
- router.push('/scan')}
- />
- ))}
-
+ {(() => {
+ // Build each game's card once, then present two ways (Rev 3, screen 01):
+ // MOBILE = Design's FLAT edge-ranked board (all props, one list, sorted
+ // by edge); DESKTOP = the game-grouped cards. Same data, different IA —
+ // toggled by width in globals.css (.edge-board-mobile / -desktop).
+ const cards = orderedGames.map((g) => slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex));
+ const edgeRows = flattenToEdgeBoard(cards);
+ const breadth = edgeBoardBreadth(edgeRows, cards);
+ const hasBoard = edgeRows.length > 0;
+ return (
+ <>
+ {hasBoard && (
+
+
+
+ )}
+ {/* Cards: on mobile they hide ONLY when the flat board is present
+ (an ungraded slate still shows the game cards on phones). */}
+
+ {cards.map((card, i) => (
+ router.push('/scan')}
+ />
+ ))}
+
+ >
+ );
+ })()}
{/* Session 23 — intelligence layer. These coexist WITH the odds
above; they never replace games. Both self-hide when empty, so
diff --git a/web/src/components/vyndr/MobileEdgeBoard.tsx b/web/src/components/vyndr/MobileEdgeBoard.tsx
new file mode 100644
index 0000000..6c829a9
--- /dev/null
+++ b/web/src/components/vyndr/MobileEdgeBoard.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import GradeBadge from '@/components/vyndr/GradeBadge';
+import TeamChip from '@/components/vyndr/TeamChip';
+import { playerHref } from '@/lib/playerHref';
+
+/**
+ * MobileEdgeBoard (Rev 3, mobile screen 01) — Design's FLAT edge-ranked board.
+ * All graded props across every game on one ranked list, sorted by edge, with
+ * the ranked opacity ramp (1 → .55 down the board), a green inset border on the
+ * top reads, matchup chips, and the edge% as the one bold mono hero per row
+ * (green +, red −). Fed by slateAdapter.flattenToEdgeBoard — this is pure
+ * presentation. Renders on phones (<768px); desktop keeps the game-grouped cards.
+ */
+
+export interface EdgeBoardRow {
+ rank: number;
+ player: string;
+ team?: string;
+ stat: string;
+ line: number | string;
+ side: string; // 'O' | 'U'
+ grade: string;
+ edge: number | null;
+ sport: string;
+ away: string;
+ home: string;
+ live?: boolean;
+ outcome?: { result?: string } | null;
+}
+
+// Design's ranked opacity ramp: top two full, then progressively dim.
+function rampOpacity(rank: number): number {
+ if (rank <= 2) return 1;
+ if (rank === 3) return 1; // live row reads full in Design
+ if (rank === 4) return 0.85;
+ if (rank === 5) return 0.7;
+ return Math.max(0.5, 0.7 - (rank - 5) * 0.06);
+}
+
+function EdgeCell({ edge }: { edge: number | null }) {
+ if (edge == null) {
+ return —;
+ }
+ const pos = edge >= 0;
+ return (
+
+ {pos ? '+' : ''}{edge.toFixed(1)}%
+
+ );
+}
+
+export default function MobileEdgeBoard({
+ rows,
+ breadth,
+ clv = null,
+}: {
+ rows: EdgeBoardRow[];
+ breadth: { edges: number; games: number };
+ clv?: string | null;
+}) {
+ if (!Array.isArray(rows) || rows.length === 0) return null;
+ return (
+
+ );
+}
diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js
index effa9f1..bd71305 100644
--- a/web/src/lib/slateAdapter.js
+++ b/web/src/lib/slateAdapter.js
@@ -382,6 +382,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
line: rec.line,
side,
grade: rec.grade,
+ // Rev 3 flat edge-board (mobile screen 01) — the ranked hero figure.
+ // Strict null (never 0-coerced) so an absent edge sorts last, not top.
+ edge: (rec.edge_pct != null && Number.isFinite(Number(rec.edge_pct))) ? Number(rec.edge_pct) : null,
// A1 S3 — the prop's own book + the best available price across the
// game's book rows for the graded side (null unless ≥2 books at the
// same current line disagree — see detectBestBook).
@@ -595,6 +598,69 @@ function pitchersForGameTeams(awayTeam, homeTeam, pitcherMap) {
return { away: one(a), home: one(h) };
}
+// ── Rev 3 — flat EDGE BOARD (mobile screen 01) ──────────────────────────
+// Design's mobile board is a FLAT, edge-ranked list (all graded props across
+// every game, sorted by edge), not game-grouped cards. This flattens the
+// GameCardData[] the Slate already assembled (grade→game join already done)
+// into ranked rows. Pure + testable. Sort: edge desc (STRICT null sorts LAST —
+// never 0-coerced to the top), grade rank as tiebreak.
+const EDGE_GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, C: 6, D: 7, F: 8 };
+function edgeGradeRank(g) {
+ return EDGE_GRADE_RANK[g] != null ? EDGE_GRADE_RANK[g] : 9;
+}
+function flattenToEdgeBoard(cards) {
+ const rows = [];
+ for (const c of Array.isArray(cards) ? cards : []) {
+ const away = (c.away && c.away.abbr) || '';
+ const home = (c.home && c.home.abbr) || '';
+ for (const strip of (c.playerStrips || [])) {
+ for (const p of (strip.props || [])) {
+ if (!p || !p.grade || p.awaiting || p.dead) continue; // only live graded rows
+ rows.push({
+ player: strip.player,
+ team: strip.team || '',
+ stat: p.stat,
+ statType: p.statType,
+ line: p.line,
+ side: p.side || 'O',
+ grade: p.grade,
+ edge: (p.edge != null && Number.isFinite(p.edge)) ? p.edge : null,
+ sport: c.sport || '',
+ away,
+ home,
+ live: !!c.live,
+ outcome: p.outcome || null,
+ movement: p.movement || null,
+ playerId: strip.playerId,
+ espnId: strip.espnId,
+ headshotUrl: strip.headshotUrl,
+ });
+ }
+ }
+ }
+ rows.sort((a, b) => {
+ const ea = a.edge == null ? -Infinity : a.edge;
+ const eb = b.edge == null ? -Infinity : b.edge;
+ if (eb !== ea) return eb - ea;
+ return edgeGradeRank(a.grade) - edgeGradeRank(b.grade);
+ });
+ return rows.map((r, i) => ({ ...r, rank: i + 1 }));
+}
+
+/** Breadth-strip numbers for the mobile board header (Design screen 01):
+ * EDGES = count of A/B-tier graded rows · GAMES = games with ≥1 graded row.
+ * AVG CLV is NOT computed here (comes from /api/accuracy; honest '—' when
+ * absent — Design draws it but the row-level model doesn't produce per-slate
+ * CLV). Never fabricates. */
+function edgeBoardBreadth(rows, games) {
+ const list = Array.isArray(rows) ? rows : [];
+ const edges = list.filter((r) => {
+ const t = String(r.grade || '')[0];
+ return t === 'A' || t === 'B';
+ }).length;
+ return { edges, games: Array.isArray(games) ? games.length : 0 };
+}
+
module.exports = {
parseAmericanOdds,
detectBestLines,
@@ -611,6 +677,9 @@ module.exports = {
statShort,
gradedAgo,
buildPlayerStripsFromProps,
+ flattenToEdgeBoard,
+ edgeBoardBreadth,
+ edgeGradeRank,
buildPitcherMap,
pitchersForGameTeams,
// DS2 — dashboard rebuild engine.