// "What changed" for versioned things (scenario sets, judges): word-level text // diffs, field-by-field comparisons and the diff modal // (templates/partials/version_diff_modal.html). Needs diff.min.js (jsdiff) and // the global esc() from ui_js.html. (function () { const text = v => Array.isArray(v) ? v.join('\n') : String(v ?? ''); /** Word-level diff of two texts as HTML: insertions green, deletions red. */ function words(a, b) { if (!a && !b) return 'empty'; return Diff.diffWordsWithSpace(a, b).map(p => p.added ? `${esc(p.value)}` : p.removed ? `${esc(p.value)}` : esc(p.value)).join(''); } /** * Compare named fields. rows: [{label, from, to, collapsed?, hint?}], values * are strings or lists (one item per line). Changed fields are shown as diffs * (collapsed ones in a
); unchanged ones are listed in one line. */ function fields(rows, { unchanged = true } = {}) { let html = ''; const kept = []; rows.forEach(({ label, from, to, collapsed, hint }) => { const a = text(from), b = text(to); if (a === b) { if (a) kept.push(esc(label) + (a.length < 40 && !a.includes('\n') ? ` (${esc(a)})` : '')); return; } const block = `
${words(a, b)}
`; const note = hint ? ` (${esc(hint)})` : ''; html += collapsed ? `
${esc(label)}${note}${block}
` : `

${esc(label)}${note}

${block}
`; }); if (unchanged && kept.length) html += `

Unchanged: ${kept.join(', ')}.

`; return html; } /** Count chips: {added, removed, changed, unchanged}. */ function counts({ added = 0, removed = 0, changed = 0, unchanged = 0 }) { const chip = (cls, label) => `${label}`; return `
${ chip('bg-green-900/40 text-green-400', `+${added} added`)}${ chip('bg-red-900/40 text-red-400', `−${removed} removed`)}${ chip('bg-yellow-900/40 text-yellow-400', `~${changed} changed`)}${ chip('bg-gray-800 text-gray-500', `${unchanged} unchanged`)}
`; } /** * Wire a diff modal. render(api) fills api.body; it runs on open and when * the From / to pickers change. Returns {open(from, to), close(), body, * from, to, loading(), error(message)}. */ function modal(id, render) { const root = document.getElementById(id); if (!root) throw new Error(`VersionDiff.modal: no #${id} on the page (include version_diff_modal.html before the script that wires it)`); const from = root.querySelector('[data-diff-from]'); const to = root.querySelector('[data-diff-to]'); const api = { root, from, to, body: root.querySelector('[data-diff-body]'), open(a, b) { if (from && a != null) from.value = a; if (to && b != null) to.value = b; root.classList.remove('hidden'); (to || root.querySelector('[data-diff-close]')).focus(); render(api); }, close() { root.classList.add('hidden'); }, loading() { api.body.innerHTML = '

Loading…

'; }, error(message) { api.body.innerHTML = `

${esc(message)}

`; }, }; [from, to].forEach(select => select && select.addEventListener('change', () => render(api))); root.addEventListener('click', e => { if (e.target === root || e.target.closest('[data-diff-close]')) api.close(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && !root.classList.contains('hidden')) api.close(); }); return api; } window.VersionDiff = { words, fields, counts, modal }; })();