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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user