// renderers/_shared/cell.js — the ONE reusable card/cell component. // Single owner of card chrome (spec §7.3): renderers build the standardized // shell here and own only what goes INSIDE the chart area. Everything is // DOM-built (no innerHTML with payload data), so payload strings need no // escaping. Styles live in the sibling cell.css / tooltip.css, auto-injected // once per document by ensureSharedCss() (createCard calls it for you). // // Typical renderer usage: // import { createCard, statCell, setCell, applyTips, // renderTable, renderMethodology } from '../_shared/cell.js'; // export function mount(el, payload) { // const { card, body, setStamp } = createCard(el, payload.meta); // const grid = document.createElement('div'); // grid.className = 'cf-cell-grid'; // grid.append(statCell('phi', 'persistence · φ')); // body.append(grid); // applyTips(card, payload.tips); // setCell(card, 'phi', 'persistence · φ', payload.stats.phi.toFixed(2)); // renderTable(body, payload.table); // renderMethodology(body, payload.method); // ... // } const SHARED_CSS = ['cell.css', 'tooltip.css']; /** * Idempotently inject tags for the shared stylesheets into the * document head. Resolved relative to this module, so it works from the * gallery, a dashboard shell, or a test page alike. The gallery also links * these directly; the data-cf-shared guard prevents doubles. */ export function ensureSharedCss(doc = document) { const head = doc.head || doc.getElementsByTagName('head')[0]; if (!head) return; for (const file of SHARED_CSS) { if (head.querySelector(`link[data-cf-shared="${file}"]`)) continue; const link = doc.createElement('link'); link.rel = 'stylesheet'; link.href = new URL(`./${file}`, import.meta.url).href; link.setAttribute('data-cf-shared', file); head.appendChild(link); } } /** * Build the standardized card shell under `el`, namespaced by chart id: * the card root gets class `cf-card` PLUS `meta.id` (the per-chart CSS * namespace — scope all renderer-specific rules under it; no global ids). * * @param {HTMLElement} el mount host * @param {object} meta payload.meta ({ id, title, ... }) * @returns {{ card: HTMLElement, body: HTMLElement, setStamp: (t:string)=>void }} * card — the .cf-card root (query slots against this) * body — .cf-card-body, append chart/grid/table/method content here * setStamp — sets the quiet header stamp (e.g. "30 days" or a date) */ export function createCard(el, meta = {}) { ensureSharedCss(el.ownerDocument); const doc = el.ownerDocument; const card = doc.createElement('section'); card.className = meta.id ? `cf-card ${meta.id}` : 'cf-card'; const title = doc.createElement('div'); title.className = 'cf-card-title'; const titleText = doc.createElement('span'); titleText.setAttribute('data-field', 'title'); titleText.textContent = meta.title ?? meta.id ?? ''; const stamp = doc.createElement('span'); stamp.className = 'cf-stamp'; stamp.setAttribute('data-field', 'stamp'); stamp.textContent = '—'; title.append(titleText, stamp); const body = doc.createElement('div'); body.className = 'cf-card-body'; card.append(title, body); el.appendChild(card); return { card, body, setStamp(text) { stamp.textContent = text ?? '—'; }, }; } /** * Stat-cell factory. Produces the standard quiet-label-over-big-value cell: *
* * — * The slot string is the coupling key: markup (data-slot), CSS grid * placement (per-chart CSS targets [data-slot="x"]), and tip copy * (payload.tips[slot], applied here or later via applyTips). */ export function statCell(slot, label, tip = '') { const cell = document.createElement('div'); cell.className = 'cf-cell'; cell.setAttribute('data-slot', slot); if (tip) cell.setAttribute('data-tip', tip); const lab = document.createElement('span'); lab.className = 'cf-cell-label'; lab.setAttribute('data-field', 'label'); lab.textContent = label ?? ''; const val = document.createElement('b'); val.className = 'cf-cell-value'; val.setAttribute('data-field', 'val'); val.textContent = '—'; cell.append(lab, val); return cell; } /** * Set a stat cell's label + formatted value. `host` is any ancestor of the * cell (usually the card root). Missing slots are ignored (renderer decides * its slot set; payloads with fewer stats simply leave cells at "—"). */ export function setCell(host, slot, label, val) { const cell = host.querySelector(`[data-slot="${slot}"]`); if (!cell) return; const lab = cell.querySelector('[data-field="label"]'); const v = cell.querySelector('[data-field="val"]'); if (lab && label != null) lab.textContent = label; if (v) v.textContent = val == null || val === '' ? '—' : String(val); } /** * Apply payload.tips ({ slot: copy }) onto every matching [data-slot] under * `host`. Empty/absent copy REMOVES the attribute, so the CSS empty-tip * guard never shows a blank box. */ export function applyTips(host, tips = {}) { for (const el of host.querySelectorAll('[data-slot]')) { const copy = tips ? tips[el.getAttribute('data-slot')] : null; if (copy) el.setAttribute('data-tip', copy); else el.removeAttribute('data-tip'); } } /** * Format one value per a CONTRACT-2 column spec ({ precision }). * null/undefined → em-dash; numbers honor precision; rest pass through. */ export function formatValue(v, precision) { if (v == null || v === '') return '—'; if (typeof v === 'number' && Number.isFinite(v)) { return precision == null ? String(v) : v.toFixed(precision); } return String(v); } /** * Generic renderer for the CONTRACT-2 table block: * { columns: [{ key, label, unit, precision }], rows: [{ : value }] } * Column headers show "label · unit" when a unit is given; numeric columns * (any column with a precision, or whose first non-null value is a number) * right-align with tabular numerals. Replaces `host`'s previous table * (data-cf-table) so update() can just call it again. * Returns the wrapper element (or null when tableSpec is empty). */ export function renderTable(host, tableSpec) { host.querySelector(':scope > [data-cf-table]')?.remove(); if (!tableSpec || !Array.isArray(tableSpec.columns) || tableSpec.columns.length === 0) { return null; } const doc = host.ownerDocument; const rows = Array.isArray(tableSpec.rows) ? tableSpec.rows : []; const numeric = tableSpec.columns.map((c) => { if (c.precision != null) return true; const probe = rows.find((r) => r[c.key] != null); return probe ? typeof probe[c.key] === 'number' : false; }); const wrap = doc.createElement('div'); wrap.className = 'cf-table-wrap'; wrap.setAttribute('data-cf-table', ''); const table = doc.createElement('table'); table.className = 'cf-table'; const thead = doc.createElement('thead'); const hr = doc.createElement('tr'); tableSpec.columns.forEach((c, i) => { const th = doc.createElement('th'); th.textContent = c.unit ? `${c.label} · ${c.unit}` : (c.label ?? c.key); if (numeric[i]) th.classList.add('cf-num'); hr.appendChild(th); }); thead.appendChild(hr); const tbody = doc.createElement('tbody'); for (const row of rows) { const tr = doc.createElement('tr'); tableSpec.columns.forEach((c, i) => { const td = doc.createElement('td'); td.textContent = formatValue(row[c.key], c.precision); if (numeric[i]) td.classList.add('cf-num'); tr.appendChild(td); }); tbody.appendChild(tr); } table.append(thead, tbody); wrap.appendChild(table); host.appendChild(wrap); return wrap; } /** * Render the engine-emitted methodology block (CONTRACT-2 method): * { prose: string | string[], equations: string[], citations: [{key, apa}] } * Prose splits on blank lines into paragraphs (or takes an array as-is); * equations render as display lines; citations as an APA hanging-indent * list. Replaces `host`'s previous methodology block (data-cf-method). * Returns the block element (or null when methodBlock is empty). */ export function renderMethodology(host, methodBlock) { host.querySelector(':scope > [data-cf-method]')?.remove(); if (!methodBlock) return null; const doc = host.ownerDocument; const block = doc.createElement('div'); block.className = 'cf-method'; block.setAttribute('data-cf-method', ''); block.setAttribute('data-slot', 'method'); const heading = doc.createElement('h4'); heading.className = 'cf-method-heading'; heading.textContent = 'Methodology'; block.appendChild(heading); const paragraphs = Array.isArray(methodBlock.prose) ? methodBlock.prose : String(methodBlock.prose ?? '').split(/\n\s*\n/); for (const para of paragraphs) { const text = String(para).trim(); if (!text) continue; const p = doc.createElement('p'); p.textContent = text; block.appendChild(p); } for (const eq of methodBlock.equations ?? []) { const span = doc.createElement('span'); span.className = 'cf-eq'; span.textContent = eq; block.appendChild(span); } const citations = methodBlock.citations ?? []; if (citations.length) { const list = doc.createElement('ul'); list.className = 'cf-citations'; for (const c of citations) { const li = doc.createElement('li'); li.className = 'cf-citation'; if (c.key) li.setAttribute('data-litref', c.key); li.textContent = c.apa ?? c.key ?? ''; list.appendChild(li); } block.appendChild(list); } host.appendChild(block); return block; } /** * Render the engine-emitted results scaffold (payload.results) as a "Results" * block beneath the methodology, mirroring renderMethodology. resultsBlock is * { summary, findings[] } from engine/results.py — the stated numbers, so the * card shows both a Method and a Results section per chart. */ export function renderResults(host, resultsBlock) { host.querySelector(':scope > [data-cf-results]')?.remove(); if (!resultsBlock || !(resultsBlock.summary || (Array.isArray(resultsBlock.findings) && resultsBlock.findings.length))) { return null; } const doc = host.ownerDocument; const block = doc.createElement('div'); block.className = 'cf-results'; block.setAttribute('data-cf-results', ''); block.setAttribute('data-slot', 'results'); const heading = doc.createElement('h4'); heading.className = 'cf-results-heading'; heading.textContent = 'Results'; block.appendChild(heading); if (resultsBlock.summary) { const p = doc.createElement('p'); p.textContent = String(resultsBlock.summary); block.appendChild(p); } const findings = resultsBlock.findings ?? []; if (findings.length) { const list = doc.createElement('ul'); list.className = 'cf-findings'; for (const f of findings) { const li = doc.createElement('li'); li.className = 'cf-finding'; li.textContent = String(f); list.appendChild(li); } block.appendChild(list); } host.appendChild(block); return block; }