D1 finish: row-hover rationale, IntersectionObserver reveal, team-gradient chips

Additive frontend. Backend untouched (git diff src/ = empty): no grade, model,
classifier or ledger change. Scope held to the row anatomy these three items
need — no System-artboard-wide rebuild. Push scoring untouched.

REVIEW ZERO — the two checks that decided whether these could be honest:

0.2 RATIONALE SOURCE — VERIFIED REAL. Live snapshot grades carry `reasoning`
    and `kill_conditions_triggered`. The summary is built by analyzeViaEngine1
    from the actual feature vector (l5/l20 averages, gap to the line, home/away,
    opponent defensive rank, rest days) and kills carry real codes + reasons.
    So the hover shows genuine grade truth, not a placeholder.

0.3 TEAM COLOURS — PARTIAL, and deliberately left partial. The System artboard
    defines a colour pair for only 10 teams (BOS CHC CHI DEN LAD MIL MIN NYY PIT
    SD), lifted verbatim; lib/teams.js holds ~80. The other ~70 are NOT invented
    — a wrong team colour is a recognition error the user reads as fact. Unknown
    teams get the honest-neutral chip (muted border, no colour claim), never a
    guess and never a blank gap. Coverage is reported by coverage(), not hidden.

SHIPPED:
- web/src/lib/rowRationale.js — rationaleFor() returns real summary + kills, or
  NULL. No generic fallback: an empty hover is honest, a manufactured "why" is a
  fabricated model explanation. A locked/tier-gated reasoning is treated as
  ABSENT rather than paraphrased or leaked, and a kill condition with no reason
  explains nothing so it is dropped.
- web/src/lib/reveal.js — IntersectionObserver reveal that fires ONCE then
  unobserves ("react to truth, then rest"), reuses D1-A's bootDelayMs for the
  60ms stagger so there is ONE source of truth for the timing, and reveals
  IMMEDIATELY when IntersectionObserver is absent (SSR/test) so a missing API can
  never hide real content. Reduced motion is handled by the existing CSS, so the
  row is visible either way.
- web/src/lib/teamChips.js — Rev-3 geometry (10px, 135deg, before the abbr,
  inside the row) plus the ranked opacity ramp 1/.86/.64/.48 so chips dim with
  their row. Swap-ready for licensed logos at the same size.

Floor: 318 suites / 3961 tests green (15 new), web build exit 0.

The three modules are pure and unit-locked; mounting them into the live row
components is a follow-up, and the visual result belongs in the Chrome audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
Kev
2026-07-31 06:18:12 -04:00
parent e34e99c426
commit 085e8a3a63
5 changed files with 286 additions and 1 deletions
+119
View File
@@ -0,0 +1,119 @@
/**
* D1 FINISH — row-hover rationale · IntersectionObserver reveal · team-gradient chips.
* Ported to the exact System-artboard anatomy. The honesty properties are the point.
*/
const { rationaleFor, hasRationale } = require('../../web/src/lib/rowRationale');
const chips = require('../../web/src/lib/teamChips');
const reveal = require('../../web/src/lib/reveal');
const { bootDelayMs } = require('../../web/src/lib/reactions');
describe('row-hover rationale — real grade factors or NOTHING', () => {
const real = {
reasoning: { summary: 'Averaging 5.8 assists over the last 5. L20 4.5. 0.7 below the line of 6.5.' },
kill_conditions_triggered: [{ code: 'TOP_DEFENSE', reason: 'Opponent ranks top-five defending this stat.' }],
};
test('surfaces the grade OWN reasoning + real kill conditions', () => {
const out = rationaleFor(real);
expect(out.summary).toMatch(/5\.8 assists/);
expect(out.kills).toEqual([{ code: 'TOP_DEFENSE', reason: 'Opponent ranks top-five defending this stat.' }]);
});
test('a row with NO reasoning is honest-absent — null, never a generic why', () => {
expect(rationaleFor({ grade: 'B' })).toBeNull();
expect(rationaleFor({ reasoning: {} })).toBeNull();
expect(rationaleFor({ reasoning: { summary: ' ' } })).toBeNull();
expect(rationaleFor(null)).toBeNull();
expect(hasRationale({})).toBe(false);
});
test('a LOCKED/gated reasoning is treated as absent — never paraphrased or leaked', () => {
expect(rationaleFor({ reasoning: { summary: 'secret', locked: true } })).toBeNull();
expect(rationaleFor({ reasoning: { summary: 'secret' }, tier_gated: true })).toBeNull();
});
test('a kill condition with no reason explains nothing and is dropped', () => {
expect(rationaleFor({ kill_conditions_triggered: [{ code: 'X' }] })).toBeNull();
});
test('kills alone (no summary) still count as a real why', () => {
const out = rationaleFor({ kill_conditions_triggered: [{ code: 'A', reason: 'Real reason.' }] });
expect(out.summary).toBeNull();
expect(out.kills).toHaveLength(1);
});
});
describe('team-gradient chips — partial coverage, honest-neutral elsewhere', () => {
test('known teams use the artboard pair VERBATIM', () => {
const bos = chips.chipStyle('BOS');
expect(bos.known).toBe(true);
expect(bos.style.background).toBe('linear-gradient(135deg,#BD3039,#6b1c22)');
});
test('an UNKNOWN team renders honest-neutral — no guessed colour, no blank gap', () => {
const x = chips.chipStyle('XYZ');
expect(x.known).toBe(false);
expect(x.style.background).toBe('transparent');
expect(x.style.border).toMatch(/var\(--border-hi\)/);
expect(x.style.width).toBe(10); // still occupies the slot
});
test('coverage is PARTIAL and reported, not hidden', () => {
const c = chips.coverage(80);
expect(c.known).toBe(10);
expect(c.total).toBe(80);
expect(c.known).toBeLessThan(c.total); // we do not invent the rest
});
test('chips carry the Rev-3 geometry (10px, inside-row placement)', () => {
const s = chips.chipStyle('BOS').style;
expect(s.width).toBe(10);
expect(s.height).toBe(10);
expect(s.borderRadius).toBe(3);
expect(s.marginRight).toBe(3);
});
test('the ranked opacity ramp dims chips with their row (1/.86/.64/.48)', () => {
expect(chips.rankOpacity(0)).toBe(1);
expect(chips.rankOpacity(1)).toBe(0.86);
expect(chips.rankOpacity(2)).toBe(0.64);
expect(chips.rankOpacity(3)).toBe(0.48);
expect(chips.rankOpacity(99)).toBe(0.48); // clamps to the tail
});
test('handles junk input without throwing or claiming a colour', () => {
for (const v of [null, undefined, '', 123]) expect(chips.chipStyle(v).known).toBe(false);
});
});
describe('reveal — once on view, reusing D1-A motion discipline', () => {
test('with no IntersectionObserver it reveals IMMEDIATELY (never hides content)', () => {
const node = { dataset: {}, style: {}, classList: { add(c) { this._c = c; } } };
const stop = reveal.observeRows([node], null);
expect(node.classList._c).toBe('vy-rowin');
expect(node.dataset.revealed).toBe('1');
expect(typeof stop).toBe('function');
});
test('stagger reuses D1-A bootDelayMs — ONE source of truth, 60ms steps', () => {
const nodes = [0, 1, 2].map(() => ({ dataset: {}, style: {}, classList: { add() {} } }));
reveal.observeRows(nodes, null);
expect(nodes[0].style.animationDelay).toBe(`${bootDelayMs(0)}ms`);
expect(nodes[1].style.animationDelay).toBe('60ms');
expect(nodes[2].style.animationDelay).toBe('120ms');
});
test('a row already revealed is NOT re-fired (react then rest, never a loop)', () => {
let adds = 0;
const node = { dataset: { revealed: '1' }, style: {}, classList: { add() { adds += 1; } } };
reveal.observeRows([node], null);
expect(adds).toBe(0);
});
test('reduced motion is handled in CSS, so the row is visible either way', () => {
const css = require('fs').readFileSync(
require('path').join(__dirname, '../../web/src/app/globals.css'), 'utf8');
expect(css).toMatch(/prefers-reduced-motion: reduce/);
expect(css).toMatch(/\.vy-rowin/);
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+48
View File
@@ -0,0 +1,48 @@
'use strict';
/**
* REVEAL (D1-finish, 2026-07-31) — IntersectionObserver row reveal + rail highlight,
* per `Vyndr System.dc.html`.
*
* Reuses D1-A's motion discipline rather than inventing a second pattern:
* - fires ONCE on scroll-into-view, then unobserves ("react to truth, then rest")
* - stagger is D1-A's 60ms step (`reactions.bootDelayMs`) — one source of truth
* - reduced-motion is honoured by the CSS (`.vy-rowin` is disabled there), so no
* JS branch is needed and the row is ALWAYS visible either way
*
* SSR/test-safe: with no IntersectionObserver present it reveals immediately rather
* than leaving rows invisible — a missing API must never hide real content.
*/
const { bootDelayMs } = require('./reactions');
function supported() {
return typeof window !== 'undefined' && typeof window.IntersectionObserver === 'function';
}
/**
* observeRows(nodes, onReveal) — reveal each node once when it enters view.
* Returns a disconnect function. `onReveal(node, index)` receives the 60ms-stepped
* delay via `bootDelayMs(index)` applied as animation-delay.
*/
function observeRows(nodes, onReveal) {
const list = Array.from(nodes || []);
const fire = (node, i) => {
if (!node || node.dataset && node.dataset.revealed === '1') return;
if (node.dataset) node.dataset.revealed = '1';
if (node.style) node.style.animationDelay = `${bootDelayMs(i)}ms`;
if (node.classList) node.classList.add('vy-rowin');
if (typeof onReveal === 'function') onReveal(node, i);
};
if (!supported()) { list.forEach(fire); return () => {}; }
const idx = new Map(list.map((n, i) => [n, i]));
const io = new window.IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
fire(e.target, idx.get(e.target) || 0);
io.unobserve(e.target); // ONCE — never a loop
}
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.15 });
list.forEach((n) => n && io.observe(n));
return () => io.disconnect();
}
module.exports = { observeRows, supported };
+50
View File
@@ -0,0 +1,50 @@
'use strict';
/**
* ROW RATIONALE (D1-finish, 2026-07-31) — the row-hover "why", per the System artboard.
*
* 🔴 IT IS GRADE TRUTH OR IT IS NOTHING. The source is the grade's OWN output —
* `reasoning.summary` (built by `analyzeViaEngine1` from the real feature vector:
* l5/l20 averages, the gap to the line, home/away, opponent defensive rank, rest)
* and `kill_conditions_triggered` (real codes + reasons). VERIFIED present on live
* snapshot grades.
*
* There is NO generic fallback and no invented narrative. A row whose grade carries
* no reasoning returns null and the surface shows nothing — an empty hover is honest,
* a manufactured "why" is a fabricated model explanation, which is worse than silence.
* A tier-gated (locked/redacted) reasoning is ALSO treated as absent rather than
* leaked or paraphrased.
*/
/**
* rationaleFor(grade) -> { summary, kills: [{code, reason}] } | null
* null means: we have no real explanation for this row. Render nothing.
*/
function rationaleFor(grade) {
if (!grade || typeof grade !== 'object') return null;
const r = grade.reasoning;
// A locked/redacted reasoning is absent for our purposes — never paraphrase a gated field.
const locked = !!(r && (r.locked === true || grade.tier_gated === true));
const rawSummary = r && typeof r.summary === 'string' ? r.summary.trim() : '';
const summary = locked || !rawSummary ? null : rawSummary;
const kills = Array.isArray(grade.kill_conditions_triggered)
? grade.kill_conditions_triggered
.filter((k) => k && typeof k === 'object' && !k.locked)
.map((k) => ({
code: typeof k.code === 'string' ? k.code : null,
reason: typeof k.reason === 'string' ? k.reason.trim() : null,
}))
.filter((k) => k.reason) // a code with no reason explains nothing
: [];
if (!summary && kills.length === 0) return null; // honest-absent
return { summary, kills };
}
/** Does this row have a real "why" to show on hover? */
function hasRationale(grade) {
return rationaleFor(grade) !== null;
}
module.exports = { rationaleFor, hasRationale };
+68
View File
@@ -0,0 +1,68 @@
'use strict';
/**
* TEAM-GRADIENT CHIPS (D1-finish, 2026-07-31) — Rev-3 pattern from HANDOFF.md:
* 10-12px chip, 135deg two-colour gradient, placed BEFORE each team abbr and
* INSIDE the row, so the ranked opacity ramp (1 / .86 / .64 / .48) dims it too.
* Swap for a licensed logo <img> at the same size when assets land.
*
* 🔴 COVERAGE IS PARTIAL AND THAT IS DELIBERATE. The System artboard defines a
* colour pair for only 10 teams; the registry (`lib/teams.js`) holds ~80. We do
* NOT invent the other ~70 — a wrong team colour is a recognition error the user
* reads as fact. Unknown teams render the HONEST-NEUTRAL chip (a muted border,
* no colour claim), never a guessed hue and never a blank gap.
*
* Pairs below are lifted VERBATIM from `Vyndr System.dc.html` — no eyedropping,
* no approximation.
*/
/** abbr -> [c1, c2], exactly as the artboard defines them. */
const TEAM_GRADIENTS = Object.freeze({
BOS: ['#BD3039', '#6b1c22'],
CHC: ['#0E3386', '#CC3433'],
CHI: ['#CE1141', '#6d0a22'],
DEN: ['#0E4DA4', '#4C8BF5'],
LAD: ['#005A9C', '#2f7fd0'],
MIL: ['#00471B', '#0a7a3a'],
MIN: ['#0C2340', '#236192'],
NYY: ['#1b3a6b', '#0C2340'],
PIT: ['#2b2b2b', '#c9a227'],
SD: ['#2F241D', '#8a6d4a'],
});
/** The ranked opacity ramp — chips sit inside rows so they dim with the row. */
const RANK_OPACITY = Object.freeze([1, 0.86, 0.64, 0.48]);
/** Opacity for rank index i (clamped to the ramp's tail). */
function rankOpacity(i) {
const n = Number(i);
if (!Number.isFinite(n) || n < 0) return RANK_OPACITY[0];
return RANK_OPACITY[Math.min(Math.floor(n), RANK_OPACITY.length - 1)];
}
/**
* chipStyle(abbr) — inline style for the chip, or the honest-neutral style when
* the team has no designed colour. Returns `{ style, known }` so a caller can
* tell "we know this team" from "we are not claiming to".
*/
function chipStyle(abbr) {
const key = String(abbr || '').trim().toUpperCase();
const pair = TEAM_GRADIENTS[key];
const base = {
display: 'inline-block', width: 10, height: 10, borderRadius: 3,
verticalAlign: -1.5, marginRight: 3,
};
if (!pair) {
// HONEST-NEUTRAL: a shape, not a colour claim.
return { known: false, style: { ...base, background: 'transparent', border: '1px solid var(--border-hi)' } };
}
return { known: true, style: { ...base, background: `linear-gradient(135deg,${pair[0]},${pair[1]})` } };
}
/** How much of the registry we actually have colours for — reported, not hidden. */
function coverage(totalTeams) {
const known = Object.keys(TEAM_GRADIENTS).length;
const total = Number(totalTeams);
return { known, total: Number.isFinite(total) ? total : null };
}
module.exports = { TEAM_GRADIENTS, RANK_OPACITY, rankOpacity, chipStyle, coverage };