/** * Roster rendering. * * Rendering only — computes nothing, stores nothing. It receives an ordered * list from the ranker and makes the DOM match it. * * The important idea here is that RANK IS EXPRESSED AS LIGHT. Each row gets a * `--lit` value in [0,1] and the stylesheet spends it on colour, weight, size * and contrast at once. The model's opinion arrives as illumination rather than * as a number, which is both more legible at a glance and more honest: an entry * in shadow is dimmer, never removed. Everything stays in the document. * * The falloff is exponential rather than linear. A linear ramp across forty-two * entries gives every row a slightly different grey and reads as mush; an * exponential one puts three or four entries genuinely in the light and lets * the rest recede, which is how a painting directs attention. */ const TERMS = [ ['affinity', 'affinity', false], ['semantic', 'semantic', false], ['freshness', 'freshness', false], ['fatigue', 'fatigue', true], ]; /** Rank -> illumination. */ function illumination(i) { return Math.max(0.06, Math.exp(-i / 7)); } function paintScore(row, parts) { const pips = row.querySelectorAll('[data-score] span'); if (pips.length !== 4) return; const vals = [parts.affinity, parts.semantic, parts.freshness, parts.fatigue]; vals.forEach((v, i) => pips[i].style.setProperty('--w', Math.min(1, Math.abs(v)).toFixed(3))); } function paintWhy(row, parts) { const panel = row.querySelector('[data-whypanel]'); if (!panel) return; panel.innerHTML = TERMS.map(([key, label, cost]) => { const raw = parts[key]; const v = cost ? -raw : raw; return `
${label} ${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(2)}
`; }).join(''); } /** * Apply a new order to the DOM. * @param {HTMLElement} container * @param {Array<{item: object, parts: object}>} ranked * @param {{animate?: boolean}} [opts] */ export function applyOrder(container, ranked, opts = {}) { const nodes = new Map( [...container.querySelectorAll('[data-item-id]')].map((el) => [el.dataset.itemId, el]), ); const commit = () => { const frag = document.createDocumentFragment(); ranked.forEach(({ item, parts }, i) => { const el = nodes.get(item.id); if (!el) return; el.style.setProperty('--lit', illumination(i).toFixed(4)); const n = el.querySelector('[data-rank]'); if (n) n.textContent = String(i + 1).padStart(2, '0'); el.__parts = parts; paintScore(el, parts); if (el.classList.contains('is-open')) paintWhy(el, parts); el.classList.toggle('is-deferred', i >= 8); frag.appendChild(el); }); container.appendChild(frag); }; if (opts.animate && typeof document.startViewTransition === 'function') { document.startViewTransition(commit); } else { commit(); } } /** Wire the score disclosures. Delegated, so it survives re-ordering. */ export function bindWhy(container) { container.addEventListener('click', (ev) => { const btn = ev.target.closest?.('[data-why]'); if (!btn) return; ev.preventDefault(); const row = btn.closest('[data-item-id]'); if (!row) return; const open = !row.classList.contains('is-open'); row.classList.toggle('is-open', open); btn.setAttribute('aria-expanded', String(open)); btn.textContent = open ? 'hide' : 'why this one'; if (open && row.__parts) paintWhy(row, row.__parts); }); }