// The artifact bridge, injected first in every HTML page of an artifact // (robot-docs/artifacts.md). The page runs with an opaque origin, so the // browser's own storage throws; this stands in for it, keeps what the page // saves, reports what breaks, gives the page albedo's colours, and lets it // talk to the chat through the window that holds it. (function () { 'use strict'; var boot = window.__albedo || {}; delete window.__albedo; var base = boot.base || './'; var embedded = window.parent !== window; function post(path, body, keepalive) { try { return fetch(base + '.albedo/' + path, { method: 'POST', mode: 'no-cors', body: body, // A body past 64 KiB cannot ride along with a closing page. keepalive: !!keepalive && body.length < 60000, credentials: 'omit' }).catch(function () {}); } catch (_) { return Promise.resolve(); } } // Storage. localStorage is kept per artifact, across reloads and versions; // sessionStorage and cookies last as long as the page. function memory(initial, changed) { var items = Object.create(null); for (var name in initial) items[name] = String(initial[name]); var storage = { getItem: function (key) { key = String(key); return key in items ? items[key] : null; }, setItem: function (key, value) { items[String(key)] = String(value); changed(items); }, removeItem: function (key) { key = String(key); if (key in items) { delete items[key]; changed(items); } }, clear: function () { items = Object.create(null); changed(items); }, key: function (index) { var keys = Object.keys(items); return index >= 0 && index < keys.length ? keys[index] : null; } }; Object.defineProperty(storage, 'length', { get: function () { return Object.keys(items).length; } }); // storage.foo and storage.foo = 'x' work as they do on real storage. return typeof Proxy === 'function' ? new Proxy(storage, { get: function (target, key) { if (typeof key !== 'string' || key in target) return target[key]; return target.getItem(key) === null ? undefined : target.getItem(key); }, set: function (target, key, value) { if (typeof key === 'string' && !(key in target)) target.setItem(key, value); return true; }, deleteProperty: function (target, key) { target.removeItem(key); return true; }, ownKeys: function () { return Object.keys(items); }, getOwnPropertyDescriptor: function (_, key) { return key in items ? { value: items[key], enumerable: true, configurable: true } : undefined; } }) : storage; } var saved = {}; try { saved = JSON.parse(boot.state || '{}') || {}; } catch (_) {} var pending = null; var timer = 0; function flush(closing) { if (pending === null) return Promise.resolve(); var body = pending; pending = null; clearTimeout(timer); return post('state', body, closing); } var local = memory(saved, function (items) { pending = JSON.stringify(items); clearTimeout(timer); timer = setTimeout(flush, 400); }); window.addEventListener('pagehide', function () { flush(true); }); function stand(name, value) { try { Object.defineProperty(window, name, { value: value, configurable: true, enumerable: true }); } catch (_) {} } stand('localStorage', local); stand('sessionStorage', memory({}, function () {})); var cookies = Object.create(null); try { Object.defineProperty(document, 'cookie', { configurable: true, get: function () { return Object.keys(cookies) .map(function (name) { return name + '=' + cookies[name]; }) .join('; '); }, set: function (text) { var pair = String(text).split(';')[0]; var at = pair.indexOf('='); if (at < 1) return; var name = pair.slice(0, at).trim(); var value = pair.slice(at + 1).trim(); if (/max-age=0|expires=thu, 01 jan 1970/i.test(text)) delete cookies[name]; else cookies[name] = value; } }); } catch (_) {} // Theme. Until the window says otherwise, albedo's palette for the // system's colour scheme. var palettes = { light: { '--albedo-bg': '#f6eff8', '--albedo-surface': '#fdfafd', '--albedo-text': '#584a5c', '--albedo-muted': '#7a6c7e', '--albedo-faint': '#9d90a1', '--albedo-accent': '#6928bd', '--albedo-border': '#e7dcea', '--albedo-added': '#5e8933', '--albedo-danger': '#ce2700' }, dark: { '--albedo-bg': '#241c26', '--albedo-surface': '#2b222d', '--albedo-text': '#e1d1e7', '--albedo-muted': '#ad9db2', '--albedo-faint': '#86788b', '--albedo-accent': '#ddb3ff', '--albedo-border': '#3d3241', '--albedo-added': '#c1dd4a', '--albedo-danger': '#ff8b79' } }; var fonts = { '--albedo-sans': "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif", '--albedo-mono': "ui-monospace, 'SF Mono', Menlo, Consolas, monospace" }; var themeStyle = document.createElement('style'); themeStyle.setAttribute('data-albedo', 'theme'); (document.head || document.documentElement).appendChild(themeStyle); var scheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; var hosted = false; var listeners = []; var theme = 'light'; function apply(name, variables) { theme = name === 'dark' ? 'dark' : 'light'; var values = {}; var key; for (key in palettes[theme]) values[key] = palettes[theme][key]; for (key in fonts) values[key] = fonts[key]; for (key in variables || {}) { if (/^--albedo-[a-z-]+$/.test(key) && typeof variables[key] === 'string') values[key] = variables[key]; } var css = ':root{color-scheme:' + theme + ';'; for (key in values) css += key + ':' + String(values[key]).replace(/[;{}<]/g, '') + ';'; themeStyle.textContent = css + '}'; document.documentElement.setAttribute('data-albedo-theme', theme); listeners.forEach(function (listener) { try { listener(theme); } catch (_) {} }); } apply(scheme && scheme.matches ? 'dark' : 'light'); if (scheme && scheme.addEventListener) { scheme.addEventListener('change', function (event) { if (!hosted) apply(event.matches ? 'dark' : 'light'); }); } function context(hostContext) { if (!hostContext) return; hosted = true; apply(hostContext.theme, hostContext.styles && hostContext.styles.variables); } // The window: JSON-RPC 2.0 over postMessage, as MCP Apps speaks it. var calls = Object.create(null); var counter = 0; function send(message) { if (embedded) window.parent.postMessage(Object.assign({ jsonrpc: '2.0' }, message), '*'); } function request(method, params) { return new Promise(function (resolve, reject) { if (!embedded) return reject(new Error('this page is open on its own, outside albedo')); var id = 'albedo-' + ++counter; calls[id] = { resolve: resolve, reject: reject }; send({ id: id, method: method, params: params }); }); } window.addEventListener('message', function (event) { if (event.source !== window.parent || !event.data || event.data.jsonrpc !== '2.0') return; var data = event.data; if (data.id in calls && (data.result !== undefined || data.error !== undefined)) { var call = calls[data.id]; delete calls[data.id]; if (data.error) call.reject(new Error(data.error.message || 'the window refused')); else call.resolve(data.result); } else if (data.method === 'ui/notifications/host-context-changed') { context(data.params); } else if (data.method === 'albedo/point') { point(data.params); } else if (data.method === 'albedo/watch') { follow(data.params); } }); // Notes. A note on the canvas can be tied to a part of the page or a // passage of its text: while its tie is drawn over the page the window // asks what is under the pointer, and for every part and passage a note // is tied to, where it is now. Text the person selects is reported too, // so the window can offer a note on it. The page only says what and where. var CONTROLS = 'button,a[href],input,select,textarea,label,summary,[role=button],[role=link],[role=tab],[role=checkbox],[role=switch],[role=menuitem],[role=option]'; var PICTURES = /^(img|picture|svg|canvas|video|audio|iframe|object|embed)$/i; var CONTEXT = 32; var LONGEST = 2000; // Something covering most of the viewport is the page, not a part of it. function huge(rect) { return rect.width * rect.height > 0.6 * window.innerWidth * window.innerHeight; } function seen(color) { return !!color && color !== 'transparent' && !/[,/]\s*0(\.0*)?\s*\)$/.test(color); } // Whether a box paints anything of its own: a background, a border or a shadow. function paints(style) { if (seen(style.backgroundColor) || style.backgroundImage !== 'none' || style.boxShadow !== 'none') return true; return ['Top', 'Right', 'Bottom', 'Left'].some(function (side) { return parseFloat(style['border' + side + 'Width']) > 0 && style['border' + side + 'Style'] !== 'none' && seen(style['border' + side + 'Color']); }); } function inline(style) { return style.display === 'inline' || style.display === 'contents'; } // Text of its own: words directly in it, or in inline elements directly in it. function worded(element) { for (var child = element.firstChild; child; child = child.nextSibling) { if (child.nodeType === 3 ? /\S/.test(child.data) : child.nodeType === 1 && !PICTURES.test(child.tagName) && inline(getComputedStyle(child)) && /\S/.test(child.textContent)) return true; } return false; } // Whether a reader would see an element as a thing of its own: a picture, // a box that paints something, or a block with its own words. Wrappers // that only lay others out are not. function distinct(element) { if (PICTURES.test(element.tagName)) return true; var style = getComputedStyle(element); if (style.visibility === 'hidden') return false; return paints(style) || (!inline(style) && worded(element)); } // The smallest thing a reader sees under a point: a control, else the // first element up from the deepest one there that is distinct, short of // one covering most of the page. With only those, the sentence under the // pointer, else the smallest of them. function partAt(x, y) { var hit = document.elementFromPoint(x, y); if (!hit || hit === document.body || hit === document.documentElement) return null; // An svg's shapes are one picture. while (hit.ownerSVGElement) hit = hit.ownerSVGElement; var control = hit.closest(CONTROLS); if (control && !huge(control.getBoundingClientRect())) return { element: control }; var covering = null; for (var node = hit; node && node !== document.body && node !== document.documentElement; node = node.parentElement) { var rect = node.getBoundingClientRect(); if (rect.width < 2 || rect.height < 2 || !distinct(node)) continue; if (!huge(rect)) return { element: node }; covering = covering || node; } return sentenceAt(x, y) || (covering ? { element: covering } : huge(hit.getBoundingClientRect()) ? null : { element: hit }); } function sentenceAt(x, y) { var caret = document.caretPositionFromPoint ? document.caretPositionFromPoint(x, y) : document.caretRangeFromPoint ? document.caretRangeFromPoint(x, y) : null; var node = caret && (caret.offsetNode || caret.startContainer); if (!node || node.nodeType !== 3 || !node.parentElement) return null; var text = node.data; var at = caret.offsetNode ? caret.offset : caret.startOffset; var start = 0; var end = text.length; var stop = /[.!?]\s/g; for (var match; (match = stop.exec(text)); ) { if (match.index < at) start = match.index + 1; else { end = match.index + 1; break; } } while (start < end && /\s/.test(text[start])) start++; var range = document.createRange(); range.setStart(node, start); range.setEnd(node, end); var near = [].some.call(range.getClientRects(), function (line) { return x >= line.left && x <= line.right && y >= line.top && y <= line.bottom; }); if (!near || !/\S/.test(range.toString())) return null; return { element: node.parentElement, range: range }; } function selectorOf(element) { var steps = []; for (var node = element; node && node !== document.body && node !== document.documentElement; node = node.parentElement) { if (node.id && document.querySelectorAll('#' + CSS.escape(node.id)).length === 1) { steps.unshift('#' + CSS.escape(node.id)); return steps.join(' > '); } var tag = node.tagName.toLowerCase(); var same = 0; var index = 0; for (var sibling = node.parentElement && node.parentElement.firstElementChild; sibling; sibling = sibling.nextElementSibling) { if (sibling.tagName === node.tagName) same++; if (sibling === node) index = same; } steps.unshift(same > 1 ? tag + ':nth-of-type(' + index + ')' : tag); } steps.unshift('body'); return steps.join(' > '); } function tidy(text) { return String(text).replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim(); } function quoteOf(element) { var tag = element.tagName.toLowerCase(); var text = tag === 'img' ? element.alt || '' : tag === 'input' || tag === 'textarea' ? element.value || element.placeholder || '' : element.innerText || element.textContent || ''; text = tidy(text); if (!text) return 'a <' + tag + '>'; return text.length > 600 ? text.slice(0, 600) + '…' : text; } function box(rect) { return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; } // A passage as the window keeps it (web/src/lib/quote.ts): its words, and // a little of the text either side, inside the element that holds it. function passageOf(scope, range) { var before = document.createRange(); before.setStart(scope, 0); before.setEnd(range.startContainer, range.startOffset); var start = before.toString().length; var text = scope.textContent; var exact = range.toString().slice(0, LONGEST); var end = start + exact.length; return { exact: exact, prefix: text.slice(Math.max(0, start - CONTEXT), start), suffix: text.slice(end, end + CONTEXT) }; } function shared(a, b, fromEnd) { var n = 0; while (n < a.length && n < b.length && (fromEnd ? a[a.length - 1 - n] === b[b.length - 1 - n] : a[n] === b[n])) n++; return n; } // Finds a passage again: of every place its words appear, the one whose // surroundings match best. function find(scope, passage) { var text = scope.textContent; var exact = passage.exact; var best = -1; var score = -1; if (!exact) return null; for (var at = text.indexOf(exact), count = 0; at >= 0 && count < 1000; at = text.indexOf(exact, at + 1), count++) { var around = shared(text.slice(Math.max(0, at - passage.prefix.length), at), passage.prefix, true) + shared(text.slice(at + exact.length, at + exact.length + passage.suffix.length), passage.suffix, false); if (around > score) { best = at; score = around; } } if (best < 0) return null; var walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); var range = document.createRange(); var passed = 0; var begun = false; for (var node = walker.nextNode(); node; node = walker.nextNode()) { var next = passed + node.data.length; if (!begun && best < next) { range.setStart(node, best - passed); begun = true; } if (begun && best + exact.length <= next) { range.setEnd(node, best + exact.length - passed); return range; } passed = next; } return null; } // The lines of text a range covers; the boxes of elements it holds whole are left out. function lines(range) { var all = [].filter.call(range.getClientRects(), function (rect) { return rect.width > 0; }).slice(0, 200); return all.filter(function (rect) { return !all.some(function (other) { return other !== rect && other.top >= rect.top && other.bottom <= rect.bottom && other.left >= rect.left && other.right <= rect.right && (other.width < rect.width || other.height < rect.height); }); }).slice(0, 64).map(box); } function point(params) { if (!params || typeof params.x !== 'number' || typeof params.y !== 'number') return; var part = partAt(params.x, params.y); var answer = { x: params.x, y: params.y, selector: null }; if (part && part.range) { var passage = passageOf(part.element, part.range); answer = { x: params.x, y: params.y, selector: selectorOf(part.element), quote: tidy(passage.exact), rect: box(part.range.getBoundingClientRect()), passage: passage }; } else if (part) { answer = { x: params.x, y: params.y, selector: selectorOf(part.element), quote: quoteOf(part.element), rect: box(part.element.getBoundingClientRect()) }; } send({ method: 'albedo/pointed', params: answer }); } var watched = []; var passages = []; function locate() { var rects = {}; watched.forEach(function (selector) { var element = null; try { element = document.querySelector(selector); } catch (_) {} rects[selector] = element ? box(element.getBoundingClientRect()) : null; }); var spans = {}; passages.forEach(function (passage) { var scope = null; try { scope = document.querySelector(passage.selector); } catch (_) {} var range = find(scope || document.body, passage); spans[passage.key] = range ? lines(range) : null; }); send({ method: 'albedo/rects', params: { rects: rects, passages: spans } }); } // What the person has selected, sent when it changes and they have let go. var pressed = false; var said = ''; var showing = false; function selected() { var chosen = document.getSelection(); var range = chosen && chosen.rangeCount && !chosen.isCollapsed ? chosen.getRangeAt(0) : null; var params = { text: '' }; if (range && /\S/.test(range.toString())) { var scope = range.commonAncestorContainer; if (scope.nodeType !== 1) scope = scope.parentElement; if (!scope || scope === document.documentElement) scope = document.body; var passage = passageOf(scope, range); params = { text: passage.exact, prefix: passage.prefix, suffix: passage.suffix, selector: selectorOf(scope), rects: lines(range) }; } var next = JSON.stringify(params); if (next === said) return; said = next; showing = !!params.text; send({ method: 'albedo/selected', params: params }); } var measuring = 0; function refresh() { measuring = 0; if (watched.length || passages.length) locate(); if (!pressed) selected(); } function soon() { if (!measuring) measuring = requestAnimationFrame(refresh); } function follow(params) { var listed = function (items) { return Array.isArray(items) ? items.slice(0, 64) : []; }; watched = listed(params && params.selectors).filter(function (item) { return typeof item === 'string'; }); passages = listed(params && params.passages).filter(function (item) { return item && typeof item.key === 'string' && typeof item.selector === 'string' && typeof item.exact === 'string' && typeof item.prefix === 'string' && typeof item.suffix === 'string'; }); soon(); } if (embedded) { window.addEventListener('scroll', soon, true); window.addEventListener('resize', soon); document.addEventListener('selectionchange', function () { if (!pressed) soon(); }); window.addEventListener('pointerdown', function () { pressed = true; }, true); window.addEventListener('pointerup', function () { pressed = false; soon(); }, true); if (typeof MutationObserver === 'function') { var changes = new MutationObserver(function () { if (watched.length || passages.length || showing) soon(); }); var observe = function () { changes.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', observe); else observe(); } } // What breaks, the agent hears about: it keeps the newest twenty. var reported = 0; function report(message, source, line, column, stack) { if (reported >= 20) return; reported++; var failure = { message: String(message || 'unknown error').slice(0, 4000), source: String(source || '').slice(0, 2000), line: line | 0, column: column | 0, stack: String(stack || '').slice(0, 8000) }; post('errors', JSON.stringify(failure), false); send({ method: 'albedo/error', params: failure }); } window.addEventListener('error', function (event) { if (event.error || event.message) { report(event.message, event.filename, event.lineno, event.colno, event.error && event.error.stack); } else if (event.target && event.target !== window) { var target = event.target; report('could not load ' + (target.src || target.href || target.tagName), target.src || target.href, 0, 0, ''); } }, true); window.addEventListener('unhandledrejection', function (event) { var reason = event.reason; report(reason && reason.message ? reason.message : String(reason), '', 0, 0, reason && reason.stack); }); // Size, for the phone, which fits an artifact to its content. var height = 0; var frame = 0; function measure() { frame = 0; // The content's own height: scrollHeight never reports less than the frame. var next = Math.ceil(document.documentElement.getBoundingClientRect().height); if (next !== height) { height = next; send({ method: 'ui/notifications/size-changed', params: { width: Math.ceil(document.documentElement.getBoundingClientRect().width), height: next } }); } } if (embedded && typeof ResizeObserver === 'function') { var observer = new ResizeObserver(function () { if (!frame) frame = requestAnimationFrame(measure); }); var watch = function () { observer.observe(document.documentElement); if (document.body) observer.observe(document.body); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch); else watch(); } // A pinch over the page zooms albedo's canvas, as it does anywhere else. if (embedded) { window.addEventListener('wheel', function (event) { if (!event.ctrlKey && !event.metaKey) return; event.preventDefault(); send({ method: 'albedo/wheel', params: { deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, x: event.clientX, y: event.clientY } }); }, { passive: false }); } if (embedded) { request('ui/initialize', { protocolVersion: '2026-01-26', appInfo: { name: boot.title || 'artifact', version: String(boot.version || '') }, appCapabilities: {} }).then(function (result) { context(result && result.hostContext); send({ method: 'ui/notifications/initialized' }); }, function () {}); } var fresh = false; ['pointerdown', 'keydown'].forEach(function (type) { window.addEventListener(type, function (event) { if (event.isTrusted) fresh = true; }, true); }); // A message from a page in a tab of its own, through albedo web's frame // route, which answers 204 or {error: {detail}}. function message(text, gesture) { return fetch(base + '.albedo/message', { method: 'POST', // A text/plain body keeps it a simple request, with no preflight. body: JSON.stringify({ text: text, gesture: gesture }), credentials: 'omit' }).then(function (response) { if (response.ok) return; return response.json().then( function (problem) { throw new Error((problem && problem.error && problem.error.detail) || 'albedo did not take the message'); }, function () { throw new Error('this page cannot reach albedo web to send a message'); } ); }); } // A tab of its own on `latest/` reloads when the agent updates the page. // The daemon holds each check open until there is a newer version or 25 s // pass; a hidden tab stops asking until it is shown again. if (boot.follow && !embedded) { var checking = false; var check = function () { if (checking || document.hidden) return; checking = true; fetch(base + '.albedo/version?after=' + boot.version, { cache: 'no-store', credentials: 'omit' }) .then(function (response) { // Deleted: nothing will come. if (response.status === 404) return null; if (!response.ok) throw new Error('no answer'); return response.json(); }) .then(function (answer) { if (!answer) return; if (answer.version > boot.version) { // The saved storage lands before the next version reads it. flush(false).then(function () { location.reload(); }); return; } checking = false; check(); }, function () { setTimeout(function () { checking = false; check(); }, 5000); }); }; document.addEventListener('visibilitychange', check); check(); } window.albedo = Object.freeze({ artifact: Object.freeze({ id: boot.id, version: boot.version, latest: boot.latest, title: boot.title }), get theme() { return theme; }, /** Calls `listener(theme)` whenever albedo's colours change. */ onTheme: function (listener) { if (typeof listener === 'function') listeners.push(listener); }, /** Posts `text` to the chat as the person; resolves to {sent, reason}. */ send: function (text) { // One click or key press vouches for one message: a timer started by // a click stays inside the browser's activation window, but is not // the person asking again. var gesture = fresh && !!(navigator.userActivation ? navigator.userActivation.isActive : true); fresh = false; text = String(text); // In a tab of its own there is no window: albedo web posts it instead. var posted = embedded ? request('ui/message', { role: 'user', content: [{ type: 'text', text: text }], gesture: gesture }) : message(text, gesture); return posted.then( function () { return { sent: true }; }, function (error) { return { sent: false, reason: error.message }; } ); } }); })();