From 66d52a9ce0a5a91b5cedb5a5cd2e75cddbad5dba Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 17 Jul 2026 15:26:49 -0400 Subject: [PATCH] =?UTF-8?q?Item=201=20=E2=80=94=20VERB=20LAW:=20one=20verb?= =?UTF-8?q?,=20READ=20(never=20SCAN),=20+=20a=20lint=20that=20enforces=20i?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/services/snapshotService.js | 27 ++++++----- src/services/upgradePitch.js | 6 +-- tests/integration/snapshotRoutes.test.js | 6 +-- tests/integration/snapshotStatus.test.js | 2 +- tests/unit/ds5PricingStates.test.js | 4 +- tests/unit/snapshotOverlay.test.js | 4 +- tests/unit/snapshotService.test.js | 4 +- tests/unit/tickerDedup.test.js | 22 ++++----- tests/unit/verbLaw.test.js | 61 ++++++++++++++++++++++++ tests/unit/vyndrMobile.test.js | 2 +- tests/unit/vyndrParityQA.test.js | 2 +- web/src/components/BottomTabBar.tsx | 2 +- web/src/components/Nav.tsx | 2 +- web/src/components/Pricing.tsx | 2 +- web/src/components/vyndr/StatStrip.tsx | 4 +- web/src/components/vyndr/Ticker.tsx | 2 +- web/src/lib/pipelineSchedule.js | 2 +- web/src/lib/slateAdapter.js | 2 +- 18 files changed, 110 insertions(+), 46 deletions(-) create mode 100644 tests/unit/verbLaw.test.js diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 7e7af25..a6d4e71 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -128,8 +128,9 @@ const isTopGrade = (g) => g === 'A+' || g === 'A'; function generateTickerEvents(sport, grades, deltas, ts) { const events = []; events.push({ - tag: 'SCAN', color: 'var(--g-a)', ts, sport, // sport tag → dedupe one SCAN per sport - text: `${sport.toUpperCase()} slate scanned · ${grades.length} props graded`, + // VERB LAW — the verb is READ, never SCAN. One READ event per sport (deduped). + tag: 'READ', color: 'var(--g-a)', ts, sport, + text: `${sport.toUpperCase()} slate read · ${grades.length} props graded`, }); for (const g of grades.filter((x) => isTopGrade(x.grade)).slice(0, 6)) { const arch = g.archetype ? `${g.archetype} ` : ''; @@ -149,12 +150,14 @@ function generateTickerEvents(sport, grades, deltas, ts) { return events; } -// Session 47 — a SCAN event's sport, from the event field or its text prefix -// (defends ticker items written before the `sport` field existed). -function scanSportOf(e) { - if (e.tag !== 'SCAN') return null; +// Session 47 — a READ event's sport, from the event field or its text prefix +// (defends ticker items written before the `sport` field existed). Accepts the +// legacy 'SCAN'/'slate scanned' shape too so cached items dedupe cleanly through +// the verb-law rollover (the ticker regenerates as READ on the next snapshot). +function readSportOf(e) { + if (e.tag !== 'READ' && e.tag !== 'SCAN') return null; if (e.sport) return String(e.sport).toLowerCase(); - const m = String(e.text || '').match(/^([a-z]+)\s+slate scanned/i); + const m = String(e.text || '').match(/^([a-z]+)\s+slate (?:read|scanned)/i); return m ? m[1].toLowerCase() : null; } @@ -162,13 +165,13 @@ async function pushTickerItems(events, deps) { if (!events || events.length === 0) return; const existing = await deps.cacheGet('ticker:items'); const arr = Array.isArray(existing) ? existing : []; - // Keep only the LATEST SCAN per sport: drop existing SCAN events for any sport - // that has a fresh SCAN in this batch. MOVE/GRADE events are time-specific and + // Keep only the LATEST READ per sport: drop existing READ events for any sport + // that has a fresh READ in this batch. MOVE/GRADE events are time-specific and // preserved. - const freshScanSports = new Set(events.map(scanSportOf).filter(Boolean)); + const freshReadSports = new Set(events.map(readSportOf).filter(Boolean)); const pruned = arr.filter((e) => { - const sp = scanSportOf(e); - return !(sp && freshScanSports.has(sp)); + const sp = readSportOf(e); + return !(sp && freshReadSports.has(sp)); }); const merged = [...events, ...pruned].slice(0, TICKER_CAP); await deps.cacheSet('ticker:items', merged, TICKER_TTL); diff --git a/src/services/upgradePitch.js b/src/services/upgradePitch.js index 700b683..5d49988 100644 --- a/src/services/upgradePitch.js +++ b/src/services/upgradePitch.js @@ -68,15 +68,15 @@ async function generateUpgradePitch(supabase, userId, currentScanResults) { const tierBenefit = tierRecommended === 'desk' ? 'Desk tier adds full bet tracking, ROI analytics, and priority cascade alerts.' - : 'Analyst tier gives you unlimited scans plus line movement alerts so you never miss a soft number.'; + : 'Analyst tier gives you unlimited reads plus line movement alerts so you never miss a soft number.'; const founderPrice = tierRecommended === 'desk' ? '$34.99/mo' : '$14.99/mo'; const standardPrice = tierRecommended === 'desk' ? '$49.99/mo' : '$19.99/mo'; return { - hook: `You've scanned ${totalScans} parlays this month. ${goodCount} graded B or higher — ${compliment}.`, + hook: `You've read ${totalScans} parlays this month. ${goodCount} graded B or higher — ${compliment}.`, insight: `Your best edge has been ${topStatType} ${topDirection}s. ${tierBenefit}`, - cta: `Unlock unlimited scans for ${founderPrice} (founder rate)`, + cta: `Unlock unlimited reads for ${founderPrice} (founder rate)`, tier_recommended: tierRecommended, founder_price: founderPrice, standard_price: standardPrice, diff --git a/tests/integration/snapshotRoutes.test.js b/tests/integration/snapshotRoutes.test.js index 4e11744..45864fc 100644 --- a/tests/integration/snapshotRoutes.test.js +++ b/tests/integration/snapshotRoutes.test.js @@ -72,15 +72,15 @@ describe('GET /api/ticker', () => { } it('returns snapshot items newest-first', async () => { - mockCache.value = [{ tag: 'SCAN', text: 'MLB slate scanned · 248 props graded' }, { tag: 'A+', text: 'BOMBER Judge graded A+' }]; + mockCache.value = [{ tag: 'READ', text: 'MLB slate read · 248 props graded' }, { tag: 'A+', text: 'BOMBER Judge graded A+' }]; delete process.env.TICKER_MANUAL; const res = await request(mountTicker()).get('/api/ticker'); expect(res.status).toBe(200); - expect(res.body.items[0].tag).toBe('SCAN'); + expect(res.body.items[0].tag).toBe('READ'); }); it('merges editorial pins from TICKER_MANUAL', async () => { - mockCache.value = [{ tag: 'SCAN', text: 'scanned' }]; + mockCache.value = [{ tag: 'READ', text: 'read' }]; process.env.TICKER_MANUAL = JSON.stringify([{ tag: 'ALERT', text: 'VYNDR 2.0 is live.' }]); const res = await request(mountTicker()).get('/api/ticker'); expect(res.body.items.find((i) => i.tag === 'ALERT')).toBeTruthy(); diff --git a/tests/integration/snapshotStatus.test.js b/tests/integration/snapshotStatus.test.js index 5cb62a8..ffdacdc 100644 --- a/tests/integration/snapshotStatus.test.js +++ b/tests/integration/snapshotStatus.test.js @@ -34,7 +34,7 @@ describe('GET /api/internal/snapshot/status', () => { mockCacheGet.mockImplementation(async (key) => { if (key === 'snapshot:mlb:latest') return { updated_at: '2026-06-19T18:00:00Z', grades: [{}, {}, {}], deltas: [{}] }; if (key === 'grades:mlb') return { grades: [{}, {}, {}] }; - if (key === 'ticker:items') return [{ type: 'SCAN' }, { type: 'ALERT' }]; + if (key === 'ticker:items') return [{ type: 'READ' }, { type: 'ALERT' }]; return null; }); diff --git a/tests/unit/ds5PricingStates.test.js b/tests/unit/ds5PricingStates.test.js index 6c2f934..b10acf2 100644 --- a/tests/unit/ds5PricingStates.test.js +++ b/tests/unit/ds5PricingStates.test.js @@ -36,12 +36,12 @@ describe('Pricing — Desk is the hero, real prices, single primary CTA', () => expect(tierBlock(pricing, 'desk')).toBe('true'); }); - test('real prices only — Desk $34.99 founder / $44.99 regular, Analyst $14.99/$19.99, Free 5 scans', () => { + test('real prices only — Desk $34.99 founder / $44.99 regular, Analyst $14.99/$19.99, Free 5 reads', () => { expect(pricing).toMatch(/id: 'desk'[\s\S]*?price: '\$34\.99'/); expect(pricing).toMatch(/id: 'desk'[\s\S]*?originalPrice: '\$44\.99'/); expect(pricing).toMatch(/id: 'analyst'[\s\S]*?price: '\$14\.99'/); expect(pricing).toMatch(/id: 'analyst'[\s\S]*?originalPrice: '\$19\.99'/); - expect(pricing).toContain('5 scans to try the model'); + expect(pricing).toContain('5 reads to try the model'); // no fabricated legacy price expect(pricing).not.toContain("originalPrice: '$24.99'"); expect(pricing).not.toContain("originalPrice: '$49.99'"); diff --git a/tests/unit/snapshotOverlay.test.js b/tests/unit/snapshotOverlay.test.js index ed60da0..71d28f8 100644 --- a/tests/unit/snapshotOverlay.test.js +++ b/tests/unit/snapshotOverlay.test.js @@ -73,8 +73,8 @@ describe('Slate uses the VYNDR card + pre-graded snapshot (swap is real)', () => describe('StatStrip renders snapshot states', () => { const src = read('components/vyndr/StatStrip.tsx'); - it('renders Awaiting next scan + line-delta sub-line', () => { - expect(src).toContain('Awaiting next scan'); + it('renders Awaiting next read + line-delta sub-line', () => { + expect(src).toContain('Awaiting next read'); expect(src).toContain('TOWARD'); expect(src).toContain('Graded '); }); diff --git a/tests/unit/snapshotService.test.js b/tests/unit/snapshotService.test.js index 9b3c1aa..14d408a 100644 --- a/tests/unit/snapshotService.test.js +++ b/tests/unit/snapshotService.test.js @@ -62,7 +62,7 @@ describe('generateTickerEvents', () => { ]; it('emits a SCAN summary + GRADE events for A/A+ only', () => { const ev = svc.generateTickerEvents('mlb', grades, [], '2026-06-18T20:00:00Z'); - expect(ev[0].tag).toBe('SCAN'); + expect(ev[0].tag).toBe('READ'); expect(ev[0].text).toContain('2 props graded'); const grade = ev.find((e) => e.tag === 'A+'); expect(grade.text).toContain('BOMBER'); @@ -167,7 +167,7 @@ describe('runSnapshot (fully injected)', () => { await svc.runSnapshot('mlb', deps(cache)); const items = cache.store['ticker:items']; expect(Array.isArray(items)).toBe(true); - expect(items.find((e) => e.tag === 'SCAN')).toBeTruthy(); + expect(items.find((e) => e.tag === 'READ')).toBeTruthy(); expect(items.length).toBeLessThanOrEqual(50); }); diff --git a/tests/unit/tickerDedup.test.js b/tests/unit/tickerDedup.test.js index 7ed5803..5ae115f 100644 --- a/tests/unit/tickerDedup.test.js +++ b/tests/unit/tickerDedup.test.js @@ -10,38 +10,38 @@ function memCache(initial) { describe('pushTickerItems — SCAN dedup', () => { it('replaces a prior SCAN for the same sport (only one MLB SCAN)', async () => { const cache = memCache([ - { tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 20 props graded' }, + { tag: 'READ', sport: 'mlb', text: 'MLB slate read · 20 props graded' }, { tag: 'MOVE', text: 'Judge o2.5 → o3.5 ▲+1' }, ]); - await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache); + await svc.pushTickerItems([{ tag: 'READ', sport: 'mlb', text: 'MLB slate read · 25 props graded' }], cache); const items = cache.store['ticker:items']; - expect(items.filter((e) => e.tag === 'SCAN' && e.sport === 'mlb')).toHaveLength(1); - expect(items.find((e) => e.tag === 'SCAN').text).toContain('25 props'); + expect(items.filter((e) => e.tag === 'READ' && e.sport === 'mlb')).toHaveLength(1); + expect(items.find((e) => e.tag === 'READ').text).toContain('25 props'); }); it('preserves MOVE/GRADE events and other sports', async () => { const cache = memCache([ { tag: 'MOVE', text: 'move 1' }, { tag: 'A+', text: 'BOMBER graded A+' }, - { tag: 'SCAN', sport: 'nba', text: 'NBA slate scanned · 10 props graded' }, + { tag: 'READ', sport: 'nba', text: 'NBA slate read · 10 props graded' }, ]); - await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache); + await svc.pushTickerItems([{ tag: 'READ', sport: 'mlb', text: 'MLB slate read · 25 props graded' }], cache); const items = cache.store['ticker:items']; expect(items.find((e) => e.tag === 'MOVE')).toBeTruthy(); expect(items.find((e) => e.tag === 'A+')).toBeTruthy(); - expect(items.find((e) => e.tag === 'SCAN' && e.sport === 'nba')).toBeTruthy(); + expect(items.find((e) => e.tag === 'READ' && e.sport === 'nba')).toBeTruthy(); }); it('dedupes legacy SCAN items that lack a sport field (parse from text)', async () => { - const cache = memCache([{ tag: 'SCAN', text: 'MLB slate scanned · 20 props graded' }]); - await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache); - expect(cache.store['ticker:items'].filter((e) => e.tag === 'SCAN')).toHaveLength(1); + const cache = memCache([{ tag: 'READ', text: 'MLB slate read · 20 props graded' }]); + await svc.pushTickerItems([{ tag: 'READ', sport: 'mlb', text: 'MLB slate read · 25 props graded' }], cache); + expect(cache.store['ticker:items'].filter((e) => e.tag === 'READ')).toHaveLength(1); }); it('stays capped at 50 items', async () => { const many = Array.from({ length: 60 }, (_, i) => ({ tag: 'MOVE', text: `m${i}` })); const cache = memCache(many); - await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'scanned' }], cache); + await svc.pushTickerItems([{ tag: 'READ', sport: 'mlb', text: 'read' }], cache); expect(cache.store['ticker:items'].length).toBeLessThanOrEqual(50); }); }); diff --git a/tests/unit/verbLaw.test.js b/tests/unit/verbLaw.test.js new file mode 100644 index 0000000..f18199d --- /dev/null +++ b/tests/unit/verbLaw.test.js @@ -0,0 +1,61 @@ +'use strict'; + +// VERB LAW (Truth-Everywhere Part 2, item 1): the user-facing verb is READ, +// never SCAN. This lint fails if user-VISIBLE scan copy reappears anywhere in +// the web app or the backend strings that reach the UI (ticker text, upgrade +// pitch). It targets RENDERED patterns, not internal identifiers — route paths +// (/api/scan), state vars (scanning), DB columns (scan_count), component names +// (DemoScan/ScanIcon), the CSS 'scanlines' class, and the transitional ticker +// color-map key are all legitimately internal and NOT matched. + +const fs = require('fs'); +const path = require('path'); + +const ROOTS = [ + path.join(__dirname, '..', '..', 'web', 'src'), + path.join(__dirname, '..', '..', 'src', 'services'), +]; + +// Each pattern is a user-visible scan phrase that must never ship. +const FORBIDDEN = [ + { re: /label:\s*['"]Scan['"]/, why: "nav/tab label 'Scan' → 'Read'" }, + { re: /slate scanned/i, why: "ticker 'slate scanned' → 'slate read'" }, + { re: /\b\d+\s+scans\b/i, why: "tier copy 'N scans' → 'N reads'" }, + { re: /unlimited scans/i, why: "'unlimited scans' → 'unlimited reads'" }, + { re: /you'?ve scanned/i, why: "'You've scanned …' → 'You've read …'" }, + { re: /Awaiting next scan/i, why: "'Awaiting next scan' → 'Awaiting next read'" }, + { re: /Scanning\.\.\./, why: "loading 'Scanning…' → 'Reading…'" }, + { re: />\s*Scan\b(?!Icon)/, why: "JSX text 'Scan' → 'Read'" }, +]; + +function walk(dir, out) { + for (const name of fs.readdirSync(dir)) { + const p = path.join(dir, name); + const st = fs.statSync(p); + if (st.isDirectory()) { if (name !== 'node_modules') walk(p, out); } + else if (/\.(tsx?|jsx?)$/.test(name)) out.push(p); + } +} + +describe('VERB LAW — no user-visible "scan" copy', () => { + const files = []; + for (const r of ROOTS) if (fs.existsSync(r)) walk(r, files); + + test('every rendered surface uses READ, never SCAN', () => { + const hits = []; + for (const f of files) { + const lines = fs.readFileSync(f, 'utf8').split('\n'); + lines.forEach((line, i) => { + const t = line.trim(); + // Comments aren't user-visible — skip comment-only lines (rate-limit + // docs, history notes). A rendered string never starts with a comment. + if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return; + for (const { re, why } of FORBIDDEN) { + if (re.test(line)) hits.push(`${path.relative(process.cwd(), f)}:${i + 1} — ${why}\n ${line.trim()}`); + } + }); + } + if (hits.length) throw new Error(`User-visible "scan" copy found (verb is READ):\n${hits.join('\n')}`); + expect(hits).toEqual([]); + }); +}); diff --git a/tests/unit/vyndrMobile.test.js b/tests/unit/vyndrMobile.test.js index 46fb091..1f564eb 100644 --- a/tests/unit/vyndrMobile.test.js +++ b/tests/unit/vyndrMobile.test.js @@ -14,7 +14,7 @@ describe('Phase F — BottomTabBar (5-tab spec)', () => { // Session 57 (Phase 0) — Terminal tab retired (fabricated surface); // Explore holds its slot so the bar keeps 5 tabs. it('renders the five spec tabs', () => { - ['Slate', 'Explore', 'Scan', 'Ledger', 'More'].forEach((label) => { + ['Slate', 'Explore', 'Read', 'Ledger', 'More'].forEach((label) => { expect(src).toContain(`'${label}'`); }); }); diff --git a/tests/unit/vyndrParityQA.test.js b/tests/unit/vyndrParityQA.test.js index 9e28a34..a1de704 100644 --- a/tests/unit/vyndrParityQA.test.js +++ b/tests/unit/vyndrParityQA.test.js @@ -57,7 +57,7 @@ describe('QA.11 — mobile parity (5-tab bar)', () => { // Session 57 (Phase 0) — Terminal retired; Explore holds its slot. it('BottomTabBar declares Slate/Explore/Scan/Ledger/More', () => { const src = read('components/BottomTabBar.tsx'); - ['Slate', 'Explore', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`)); + ['Slate', 'Explore', 'Read', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`)); }); }); diff --git a/web/src/components/BottomTabBar.tsx b/web/src/components/BottomTabBar.tsx index ceefa57..53f7e1c 100644 --- a/web/src/components/BottomTabBar.tsx +++ b/web/src/components/BottomTabBar.tsx @@ -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 }, ]; diff --git a/web/src/components/Nav.tsx b/web/src/components/Nav.tsx index 3c6206a..98fecb3 100644 --- a/web/src/components/Nav.tsx +++ b/web/src/components/Nav.tsx @@ -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 = [ diff --git a/web/src/components/Pricing.tsx b/web/src/components/Pricing.tsx index ec9e84a..593a24f 100644 --- a/web/src/components/Pricing.tsx +++ b/web/src/components/Pricing.tsx @@ -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', diff --git a/web/src/components/vyndr/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx index 6bbbd9d..edddbb9 100644 --- a/web/src/components/vyndr/StatStrip.tsx +++ b/web/src/components/vyndr/StatStrip.tsx @@ -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({
{p.stat} {p.line} - {next ? `Grades post ${next}` : 'Awaiting next scan'} + {next ? `Grades post ${next}` : 'Awaiting next read'}
); } diff --git a/web/src/components/vyndr/Ticker.tsx b/web/src/components/vyndr/Ticker.tsx index c9fbb6c..e1ceabd 100644 --- a/web/src/components/vyndr/Ticker.tsx +++ b/web/src/components/vyndr/Ticker.tsx @@ -23,7 +23,7 @@ type TickerProps = { }; const TAG_COLORS: Record = { - '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)', }; diff --git a/web/src/lib/pipelineSchedule.js b/web/src/lib/pipelineSchedule.js index 5884aa6..23126d4 100644 --- a/web/src/lib/pipelineSchedule.js +++ b/web/src/lib/pipelineSchedule.js @@ -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 diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index 2d408c9..496e1b7 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -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