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
+2 -2
View File
@@ -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'");
+2 -2
View File
@@ -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 ');
});
+2 -2
View File
@@ -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);
});
+11 -11
View File
@@ -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);
});
});
+61
View File
@@ -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([]);
});
});
+1 -1
View File
@@ -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}'`);
});
});
+1 -1
View File
@@ -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}'`));
});
});