SmartestHome/digest-engine/render/digest-canvas-sdk/window-chrome.js

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, '&lt;')
.replace(/>/g, '&gt;');
}
// 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);