/* * DigestWindow โ€” floating panel chrome for the digest canvas. * * Windows look like windows (title bar, rounded corners, shadow, glow accent) * but are not actually draggable. The canvas is read on a wall-mounted kiosk and * inside a Home Assistant iframe card; neither has a pointer doing window * management, and real drag-and-drop would only add state that nothing persists. * * x/y/w/h are optional. Given, the window is absolutely positioned (percentages * of the canvas) โ€” that is the escape hatch for a deliberately composed layout. * Omitted, the window participates in the canvas's normal flow/grid layout, which * is what render.js does by default so the layout survives any window count the * LLM decides to emit. */ (function (global) { 'use strict'; var counter = 0; function escapeHtml(text) { return String(text) .replace(/&/g, '&') .replace(//g, '>'); } // Intentionally tiny: bold, italic, inline code and paragraph breaks. The // schema calls content "markdown-ish", and pulling in a markdown parser for // three inline forms would violate this project's no-unnecessary-deps rule. function renderInline(text) { return escapeHtml(text) .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/(^|[\s(])\*([^*]+)\*/g, '$1$2'); } function renderContent(content) { var body = document.createElement('div'); body.className = 'digest-window-body'; if (Array.isArray(content)) { var list = document.createElement('ul'); list.className = 'digest-window-list'; content.forEach(function (item) { var li = document.createElement('li'); li.innerHTML = renderInline(item); list.appendChild(li); }); body.appendChild(list); return body; } // Duck-typed rather than `instanceof Node`: render.js hands the globe its // mount point through here, and an identity check against a global that may // not exist in every embedding context is a needless way to lose the globe. if (content && typeof content.nodeType === 'number') { body.appendChild(content); return body; } String(content === undefined || content === null ? '' : content) .split(/\n{2,}/) .forEach(function (block) { if (!block.trim()) { return; } var p = document.createElement('p'); p.innerHTML = renderInline(block).replace(/\n/g, '
'); body.appendChild(p); }); return body; } // Only http(s) becomes a clickable link. The content of every window here is // model output, and `javascript:` in an href would be script execution handed to // whatever the LLM emitted โ€” the one place this renderer's degrade-don't-throw // habit is not enough. Anything else is shown as plain text instead. function safeHref(url) { var text = String(url || '').trim(); return /^https?:\/\//i.test(text) ? text : null; } // The fold-out sources block. Collapsed by default because the digest is read // across a room: the claim is what you see, the receipts are one tap away, and // an open citation list would push the next window off the screen. function renderSources(sources, label) { var usable = (sources || []).filter(function (source) { return source && (source.title || source.outlet || source.url || source.quote); }); if (!usable.length) { return null; } var details = document.createElement('details'); details.className = 'digest-sources'; var summary = document.createElement('summary'); summary.textContent = (label || 'Sources') + ' (' + usable.length + ')'; details.appendChild(summary); var list = document.createElement('ul'); list.className = 'digest-source-list'; usable.forEach(function (source) { var li = document.createElement('li'); var head = document.createElement('div'); head.className = 'digest-source-head'; var href = safeHref(source.url); var titleText = source.title || source.url || source.outlet; if (href) { var link = document.createElement('a'); link.href = href; link.textContent = titleText; link.rel = 'noopener noreferrer'; head.appendChild(link); } else { head.appendChild(document.createTextNode(titleText)); } li.appendChild(head); // Outlet and ownership sit with the citation rather than in the prose: who // published a claim is part of reading it, and it belongs next to the claim. var attribution = [source.outlet, source.owner, source.bias] .filter(function (part) { return part; }) .join(' ยท '); if (attribution) { var meta = document.createElement('div'); meta.className = 'digest-source-meta'; meta.textContent = attribution; li.appendChild(meta); } if (source.quote) { var quote = document.createElement('blockquote'); quote.className = 'digest-source-quote'; quote.textContent = source.quote; li.appendChild(quote); } list.appendChild(li); }); details.appendChild(list); return details; } var DigestWindow = { // Exposed so render.js can attach the same block under a globe marker's brief, // where there is no window of its own to hang it on. sources: renderSources, open: function (options) { options = options || {}; var container = options.container || document.body; var el = document.createElement('section'); el.className = 'digest-window'; el.id = options.id || ('digest-window-' + (++counter)); if (options.variant) { el.classList.add('digest-window-' + options.variant); } var bar = document.createElement('header'); bar.className = 'digest-window-titlebar'; var dots = document.createElement('span'); dots.className = 'digest-window-dots'; dots.setAttribute('aria-hidden', 'true'); bar.appendChild(dots); var title = document.createElement('h2'); title.className = 'digest-window-title'; title.textContent = options.title || ''; bar.appendChild(title); el.appendChild(bar); var body = renderContent(options.content); var sources = renderSources(options.sources); if (sources) { body.appendChild(sources); } el.appendChild(body); if (typeof options.x === 'number' && typeof options.y === 'number') { el.classList.add('digest-window-positioned'); el.style.left = options.x + '%'; el.style.top = options.y + '%'; } if (typeof options.w === 'number') { el.style.width = options.w + '%'; } if (typeof options.h === 'number') { el.style.height = options.h + '%'; } container.appendChild(el); return el; } }; global.DigestWindow = DigestWindow; })(window);