S6 (a1): display — the full picture under the grammar

- specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark
  law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar.
  StatStrip violations fixed: MovementChip before the grade (market
  context before model output); ViabilityChips after the archetype
  (identity is one contiguous run).
- Line-movement sparklines: intradayRefreshService.trackHistory captures
  real {t,line} points per grade (seeded with the lock, deduped when
  flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline
  renders at >=3 points (green toward / amber against / dim flat).
- Last-10 dot strips: services/last10Dots (streaksService accessors) ->
  /api/snapshot/:sport attaches last10_dots from rosterlogs:{sport};
  StatStrip.DotStrip renders vs the LOCKED line, newest first.
- CLV distribution: getModelAggregate emits clv_distribution (7 signed
  buckets, outliers clamped) only past the centralized n>=20 gate;
  ledger MODEL header renders the bar strip.
- Global search: SearchModal (cmd-K via GlobalHosts + window.__search),
  players via /api/players/search per sport + static lib/teams.js
  (soccer deliberately absent); Nav search icon + Search first in the
  mobile More sheet. Explore tab untouched.
- Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on
  first frame, S33 visible-floor rule) + IBM Plex Mono preload:false
  (4 decorative font files off the slow-4G critical path).

2654 -> 2698 tests (226 suites) green; web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 20:08:24 -04:00
parent 1d46b446c9
commit d3637e7abd
26 changed files with 1408 additions and 25 deletions
+6 -1
View File
@@ -401,8 +401,13 @@ h1, h2, h3, h4, h5, h6 {
animation: shimmer 1.5s linear infinite;
}
/* S6 (A1 board, LCP) — floored at a VISIBLE state (.6), matching the S33
entrance-keyframe rule (a paused frame is never invisible). Starting at
opacity 0 also delayed the hero heading's first contentful paint — an
invisible element isn't an LCP candidate until it becomes visible, so the
old 0-start pushed the landing LCP by the animation delay + duration. */
@keyframes fade-up {
0% { opacity: 0; transform: translateY(8px); }
0% { opacity: 0.6; transform: translateY(8px); }
100% { opacity: 1; transform: translateY(0); }
}
+6
View File
@@ -37,6 +37,12 @@ const ibmPlexMono = IBM_Plex_Mono({
weight: ['400', '500', '600', '700'],
variable: '--font-ibm',
display: 'swap',
// S6 (A1 board, LCP) — IBM Plex is the legacy/decorative mono (wordmark,
// hero sport chips); its FOUR weight files were <link rel=preload>'d ahead
// of the render-critical CSS + Inter (the hero h1's face) on slow 4G.
// Not preloading them frees that bandwidth; display:swap still swaps them
// in when ready. Inter + JetBrains (the data face) stay preloaded.
preload: false,
});
const fontVars = `${inter.variable} ${jetbrainsMono.variable} ${ibmPlexMono.variable}`;
+47
View File
@@ -45,6 +45,9 @@ interface LedgerRow {
}
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
// S6 (A1 board) — settled-CLV distribution bucket. Server-side only when the
// n≥20 gate passes (null below — the gate lives in getModelAggregate).
interface ClvBucket { label: string; count: number; side: 'beat' | 'faded' | 'flat' }
interface ModelAggregate {
settled: number;
hits: number;
@@ -58,6 +61,7 @@ interface ModelAggregate {
min_sample?: number;
// Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier).
by_tier?: Record<string, TierRecord>;
clv_distribution?: ClvBucket[] | null;
}
const SPORT_COLOR: Record<string, string> = {
@@ -187,6 +191,48 @@ function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: n
);
}
/** S6 (A1 board) — compact settled-CLV distribution strip. Bars are DATA
* (mono, tabular): green = beat-the-close side, red = faded side, dim = flat.
* Renders only when the server passed the n≥20 gate (clv_distribution set). */
function ClvDistribution({ agg }: { agg: ModelAggregate }) {
const dist = agg.clv_distribution;
if (!Array.isArray(dist) || dist.length === 0) return null;
const total = dist.reduce((n, b) => n + b.count, 0);
if (total === 0) return null;
const max = Math.max(...dist.map((b) => b.count));
const color = (side: ClvBucket['side']) =>
side === 'beat' ? 'var(--g-a, #00D4A0)' : side === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
return (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
CLV DISTRIBUTION · {total} SETTLED
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', maxWidth: 420 }}>
{dist.map((b) => (
<div key={b.label} title={`${b.label}: ${b.count}`} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 0 }}>
<span className="mono" style={{ fontSize: 10, fontVariantNumeric: 'tabular-nums', color: b.count > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}>
{b.count}
</span>
<div
aria-label={`${b.label}: ${b.count} settled`}
style={{
width: '100%',
height: Math.max(3, Math.round((b.count / max) * 40)),
background: b.count > 0 ? color(b.side) : 'var(--border)',
borderRadius: 2,
opacity: b.count > 0 ? 0.9 : 0.6,
}}
/>
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '0.02em', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
{b.label}
</span>
</div>
))}
</div>
</div>
);
}
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
const ready = agg.settled >= minSample && agg.hit_pct != null;
return (
@@ -227,6 +273,7 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
</p>
</div>
)}
<ClvDistribution agg={agg} />
<TierCalibration agg={agg} minSample={minSample} />
</div>
);