198 lines
6.8 KiB
JavaScript
198 lines
6.8 KiB
JavaScript
/*
|
|
* 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, '<')
|
|
.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, '<code>$1</code>')
|
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/(^|[\s(])\*([^*]+)\*/g, '$1<em>$2</em>');
|
|
}
|
|
|
|
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, '<br>');
|
|
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);
|