Item 1 — VERB LAW: one verb, READ (never SCAN), + a lint that enforces it

The product argued with itself: FAB/nav said "Scan", Free tier "5 scans",
ticker "MLB slate scanned" — while the Ledger says "MY READS". Swept every
user-visible surface to READ:
- BottomTabBar FAB + Nav link: 'Scan' → 'Read'
- Pricing free tier: '5 scans to try the model' → '5 reads …'
- StatStrip: 'Awaiting next scan' → 'Awaiting next read'
- Ticker badge + snapshotService event: tag 'SCAN' → 'READ',
  'slate scanned' → 'slate read' (readSportOf parses BOTH old and new so
  cached ticker items dedupe cleanly through the rollover)
- upgradePitch: 'You've scanned N parlays' / 'unlimited scans' → read/reads

Internal untouched (not user-visible): /api/scan routes, scan_count column,
scanning state, DemoScan/ScanIcon, scanlines CSS, the transitional SCAN
color-map key.

tests/unit/verbLaw.test.js is the enforcement: it fails on user-visible
scan/scanned/scans copy across web/src + src/services (skips comments). Suite
270/3254 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-17 15:26:49 -04:00
parent 1776a29a99
commit 66d52a9ce0
18 changed files with 110 additions and 46 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ type TabDef = {
const TABS: TabDef[] = [
{ id: 'slate', label: 'Slate', href: '/dashboard', icon: SlateIcon },
{ id: 'explore', label: 'Explore', href: '/explore', icon: ExploreIcon },
{ id: 'scan', label: 'Scan', href: '/scan', icon: ScanIcon, primary: true },
{ id: 'scan', label: 'Read', href: '/scan', icon: ScanIcon, primary: true },
{ id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
{ id: 'more', label: 'More', icon: MoreIcon, isSheet: true },
];
+1 -1
View File
@@ -19,7 +19,7 @@ import NotificationBell from '@/components/NotificationBell';
// as brand language only. Don't re-add the link until it's fed real data.
const PRIMARY = [
{ id: 'slate', label: 'Slate', href: '/dashboard' },
{ id: 'scan', label: 'Scan', href: '/scan' },
{ id: 'scan', label: 'Read', href: '/scan' },
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
];
const MORE = [
+1 -1
View File
@@ -30,7 +30,7 @@ const TIERS: TierConfig[] = [
headline: 'Try the model. No card required.',
cta: 'Start Free',
features: [
'5 scans to try the model',
'5 reads to try the model',
'Grade letter + projection',
'Cross-book line comparison',
'Confidence indicator',
+2 -2
View File
@@ -21,7 +21,7 @@ export interface StripProp {
// Session 45 — pre-graded snapshot model: the locked grade + market movement.
gradedAt?: { line: number; odds?: number | null; timestamp?: string; ago?: string } | null;
delta?: { delta: number; direction: 'toward' | 'away'; currentLine: number } | null;
awaiting?: boolean; // not yet graded by a snapshot → "Awaiting next scan"
awaiting?: boolean; // not yet graded by a snapshot → "Awaiting next read"
// Session 55 — settled outcome from the self-learning loop (once the game is final).
outcome?: { result: 'hit' | 'miss' | 'push' | string; actual?: number | null } | null;
// Session 60 (Phase 2.5) — intraday movement + public revision.
@@ -443,7 +443,7 @@ export default function StatStrip({
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: 'var(--text-1)' }}>{p.stat} {p.line}</span>
<BestPriceDot p={p} />
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{next ? `Grades post ${next}` : 'Awaiting next scan'}</span>
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{next ? `Grades post ${next}` : 'Awaiting next read'}</span>
</div>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ type TickerProps = {
};
const TAG_COLORS: Record<string, string> = {
'A+': 'var(--g-ap)', A: 'var(--g-a)', SCAN: 'var(--g-a)',
'A+': 'var(--g-ap)', A: 'var(--g-a)', READ: 'var(--g-a)', SCAN: 'var(--g-a)',
MOVE: 'var(--amber)', CASCADE: 'var(--amber)', ALERT: 'var(--text-0)',
};
+1 -1
View File
@@ -1,6 +1,6 @@
/* ============================================================
Session 59 (work-order 2.3) — the REAL pipeline schedule, for honest
waiting states. "Awaiting next scan" told the user nothing; the truth is
waiting states. "Awaiting next read" told the user nothing; the truth is
the cron fires at fixed UTC hours, so we can say "Grades post ~6:00 PM ET".
HOURS_UTC mirrors the backend default (snapshotScheduler SNAPSHOT_HOURS_UTC
+1 -1
View File
@@ -297,7 +297,7 @@ function slateTeamsMatch(a, b) {
* Build pre-graded `playerStrips` for one game by OVERLAYING the snapshot's
* locked grades onto the game's odds-derived props (which already carry the
* correct game grouping). Each prop is either graded (grade + gradedAt + delta)
* or `awaiting:true` (no snapshot match yet → "Awaiting next scan", no Read
* or `awaiting:true` (no snapshot match yet → "Awaiting next read", no Read
* button). Archetype comes from the snapshot's per-player classification.
*
* Session 59 (work-order 1.6) — THE JOIN INVARIANT: when the snapshot knows