/* ══════════════════════════════════════════════════════════════════════════════ eos.js — THE PAGE SEAT, served at /eos/api/ (pageseat 0.1.0 DRAFT, 2026-09-19 · the document is /eos/api/eos.md, tag `pageseat`, prefix PS). WHAT THIS IS (PS1-02). The www leg adds ONE tag to every page it serves — `` before `` — and this file is that tag. It is the page-side twin of `chat.html`'s wrapper: a utility class `EOS` on `window`, a key, a launcher, and a panel that raises the page's own chat (`?chat&eos=1`) beside the page the visitor is on. THE LIVE PAGE (PS6). The chat opened through this seat asks the page for its WORDS — `EOS.page.text()` — at every ask: the text the visitor sees, with every form control rendered as `label: "value"` as it stands in the browser, the field in focus and the selection named. That text rides the ask as the `page` word (PS7-01) and the backend seats it where the server file's words stood (PS7-02), so the answer knows what was typed, which field is empty, and what the next press on this page is. WHAT NEVER RIDES (PS6-02). A password's characters (its length alone), a payment field's characters, a hidden input, a subtree marked `data-eos="off"`, a script, a style, this panel. The projection is built at the ask and rests nowhere (the value law, UW2-05). THE PAGE'S OWN TOOLS (PS7-06 · PS7-07, 0.1.6). A page — or the `context.js` the www leg serves beside this file where one stands in the page's folder (PS3-05) — defines its verbs through `EOS.tools.define`; the chat reads their catalog with the page words and runs a ```call fence only through `EOS.tools.call`, arguments checked before the page's code runs. THE LIFECYCLE (PS5). The chat is available for the whole life of the page: the chord is armed the moment this file runs (parse end, before the first paint is done), the launcher stands once the chord has been used (PS1-2), the panel is FIXED so every scroll, section and state of the page keeps it in reach, the frame is built once and kept across presses so the conversation stands, and a panel the reader left open is open again after a reload of the same page in the same tab (sessionStorage; the words are not — no text is kept). Nothing but this file loads at boot (~15 KB, one request); the chat loads on the FIRST press and never before (UW2-01) — unless the page says `data-eos="warm"`, when the frame is built hidden at the first idle moment after load, so the first press is instant. A page that owns the chord keeps it (the seat yields to `defaultPrevented`). A page opts out with ``; hides the launcher with `quiet`; does not remember with `forget` (the words may stand together: `data-eos="warm quiet"`); rebinds the chord with `data-eos-key="Control+I"` (UI Events key names, modifiers first — the `aria-keyshortcuts` grammar). THE PURE PART RUNS IN NODE (PS8-01): `chord · match · label · project · redact · clip` take a document or an event and answer data; the spec beside this file runs them under jsdom. Only `chat.open/close` and the launcher touch the live document. ═══════════════════════════════════════════════════════════════════════════ */ (function (root) { 'use strict'; const ID = 'eos', VERSION = '0.1.6'; /* ── 1 · THE PURE PART ─────────────────────────────────────────────────── */ /* a passcode rides `?token=` on an operator's page; the url line of the projection passes here (the bug report's own regex, BUG4-05) */ const TOKEN = /([?&#]token=)[^&#\s"']*/g; function redact(s) { return String(s == null ? '' : s).replace(TOKEN, '$1[redacted]'); } /* THE CHORD (PS4-01 · PS4-03). One string in the `aria-keyshortcuts` grammar — modifiers first, one non-modifier key last, `+` between — parsed once into the facts a keydown is matched against. `Control+K Meta+K` is two chords; either fires. The default is the platform's: Meta on a Mac, Control else. */ const MODS = { control: 'ctrlKey', ctrl: 'ctrlKey', meta: 'metaKey', cmd: 'metaKey', command: 'metaKey', alt: 'altKey', option: 'altKey', shift: 'shiftKey' }; function chord(spec) { return String(spec || '').trim().split(/\s+/).filter(Boolean).map((one) => { const parts = one.split('+').map((p) => p.trim()).filter(Boolean); const key = parts.pop() || ''; const want = { ctrlKey: false, metaKey: false, altKey: false, shiftKey: false }; for (const m of parts) { const f = MODS[m.toLowerCase()]; if (f) want[f] = true; } return { key: key.length === 1 ? key.toLowerCase() : key, want, text: one }; }); } const DEFAULT_KEY = 'Control+K Meta+K'; /* a keydown against the chords: the modifiers exactly, the key by name; an event a page already answered (`defaultPrevented`), an IME composition, or a held key (`repeat`) never fires the seat */ function match(e, chords) { if (!e || e.defaultPrevented || e.isComposing || e.repeat) return null; const k = typeof e.key === 'string' ? (e.key.length === 1 ? e.key.toLowerCase() : e.key) : ''; for (const c of chords) { if (c.key !== k) continue; if (!!e.ctrlKey !== c.want.ctrlKey || !!e.metaKey !== c.want.metaKey || !!e.altKey !== c.want.altKey || !!e.shiftKey !== c.want.shiftKey) continue; return c; } return null; } /* THE PROJECTION (PS6) — the page's words as the visitor sees them. */ const CEILING = 60 * 1024; /* under the backend's PAGE_MAX (64 KiB of words), with room for the header */ const VALUE_MAX = 2000; /* one control's value, cut like a ring row */ const SELECTION_MAX = 500; const CHOICES_MAX = 20; const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS', 'IFRAME', 'OBJECT', 'EMBED', 'HEAD', 'TITLE', 'META', 'LINK', 'OPTION', 'OPTGROUP', 'DATALIST', 'MAP', 'AUDIO', 'VIDEO']); /* the block roster mirrors the backend's HTML_BLOCK (Service.java r28) — one truth of what a line is — plus the form containers a page's rows ride in */ const BLOCK = new Set(['P', 'DIV', 'BR', 'LI', 'TR', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SECTION', 'ARTICLE', 'HEADER', 'FOOTER', 'NAV', 'TABLE', 'UL', 'OL', 'DL', 'DT', 'DD', 'BLOCKQUOTE', 'PRE', 'HR', 'FIGURE', 'FIGCAPTION', 'MAIN', 'ASIDE', 'FORM', 'FIELDSET', 'LEGEND', 'DETAILS', 'SUMMARY', 'ADDRESS', 'DIALOG']); const CONTROL = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON']); function clip(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) + '…' : s; } function squash(s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); } /* what a control is CALLED, in the order a visitor would name it */ function label(el) { const doc = el.ownerDocument; const own = (t) => squash(t); try { if (el.labels && el.labels.length) { const t = own(Array.prototype.map.call(el.labels, (l) => l.textContent).join(' ')); if (t) return t; } } catch (e) { /* no labels on this element */ } const al = el.getAttribute('aria-label'); if (al && own(al)) return own(al); const by = el.getAttribute('aria-labelledby'); if (by) { const t = own(by.split(/\s+/).map((id) => { const n = doc.getElementById(id); return n ? n.textContent : ''; }).join(' ')); if (t) return t; } for (const a of ['placeholder', 'title', 'name', 'id']) { const v = el.getAttribute(a); if (v && own(v)) return own(v); } const type = (el.getAttribute('type') || el.tagName).toLowerCase(); return type; } /* what the visitor sees — `hidden`, `display:none` and `visibility:hidden` are not seen; where the browser can say (`checkVisibility`) it is asked; a `display:contents` wrapper has no box of its own and is walked */ function visible(el) { if (el.hidden) return false; const view = el.ownerDocument && el.ownerDocument.defaultView; let cs = null; try { cs = view && view.getComputedStyle ? view.getComputedStyle(el) : null; } catch (e) { cs = null; } if (cs && (cs.display === 'none' || cs.visibility === 'hidden')) return false; if (typeof el.checkVisibility === 'function') { try { if (!el.checkVisibility({ visibilityProperty: true, contentVisibilityAuto: true })) return !!(cs && cs.display === 'contents'); } catch (e) { /* an older grammar of the call: the style's word stands */ } } return true; } function off(el) { const v = el.getAttribute && el.getAttribute('data-eos'); return !!v && /(^|\s)off(\s|$)/.test(v); } /* ONE control as a line of words (PS6-01's grammar) */ function control(el, focused) { const tag = el.tagName, type = (el.getAttribute('type') || '').toLowerCase(); const name = label(el); const marks = []; if (el === focused) marks.push('focused'); const auto = (el.getAttribute('autocomplete') || '').toLowerCase(); const value = typeof el.value === 'string' ? el.value : ''; let line; if (tag === 'BUTTON' || type === 'submit' || type === 'button' || type === 'reset' || type === 'image') { const text = tag === 'BUTTON' ? squash(el.textContent) : (el.getAttribute('value') || el.getAttribute('alt') || type); line = '[button: ' + (text || name) + ']'; if (el.disabled) marks.push('disabled'); } else if (type === 'checkbox') { line = (el.checked ? '[x] ' : '[ ] ') + name; } else if (type === 'radio') { line = (el.checked ? '(•) ' : '( ) ') + name; } else if (type === 'hidden') { return null; /* the page's plumbing, never its words */ } else if (type === 'password') { line = name + ': ' + (value ? '(password, ' + value.length + ' characters)' : '(password, empty)'); } else if (/^cc-/.test(auto)) { line = name + ': ' + (value ? '(payment field, ' + value.length + ' characters)' : '(payment field, empty)'); } else if (type === 'file') { let files = ''; try { files = Array.prototype.map.call(el.files || [], (f) => f.name).join(', '); } catch (e) { files = ''; } line = name + ': ' + (files ? '(file: ' + files + ')' : '(no file)'); } else if (tag === 'SELECT') { const opts = Array.prototype.slice.call(el.options || []); const chosen = opts.filter((o) => o.selected && !o.disabled).map((o) => squash(o.textContent || o.value)); line = name + ': ' + (chosen.length ? '[' + chosen.join(', ') + ']' : '(none selected)'); const choices = opts.filter((o) => !o.disabled).map((o) => squash(o.textContent || o.value)).filter(Boolean); if (choices.length && choices.length <= CHOICES_MAX) line += ' (choices: ' + choices.join(' · ') + ')'; } else { const v = tag === 'TEXTAREA' ? value : value; line = name + ': ' + (v ? JSON.stringify(clip(v, VALUE_MAX)) : '(empty)'); if (el.required && !v) marks.push('required'); if (el.readOnly) marks.push('read-only'); if (el.disabled) marks.push('disabled'); try { if (v && el.validity && el.validity.valid === false) marks.push('invalid' + (el.validationMessage ? ': ' + squash(el.validationMessage) : '')); } catch (e) { /* no constraint api */ } } return marks.length ? line + ' (' + marks.join(', ') + ')' : line; } /* the walk — text as it stands, a block as a line, a control as its line, a link with where it goes, an image as its alt; nothing hidden, nothing off */ function walk(node, out, ctx) { if (!node) return; if (node.nodeType === 3) { /* text */ const t = node.nodeValue; if (!t) return; if (ctx.pre) out.push(t); else { const s = t.replace(/\s+/g, ' '); if (s.trim()) out.push(s); else if (s) out.push(' '); } return; } if (node.nodeType !== 1) return; const el = node, tag = el.tagName; if (SKIP.has(tag)) return; if (el.id === 'eos-chat' || el.id === 'eos-launch') return; /* the seat's own furniture is not the page */ if (off(el)) return; if (!visible(el)) return; if (CONTROL.has(tag)) { const line = control(el, ctx.focused); if (line != null) { out.push('\n'); out.push(line); out.push('\n'); } return; } if (el.isContentEditable && !(el.parentElement && el.parentElement.isContentEditable)) { const v = squash(el.textContent); out.push('\n'); out.push(label(el) + ': ' + (v ? JSON.stringify(clip(v, VALUE_MAX)) : '(empty)') + (el === ctx.focused ? ' (focused)' : '')); out.push('\n'); return; } if (tag === 'IMG') { const alt = squash(el.getAttribute('alt')); if (alt) out.push(' [image: ' + alt + '] '); return; } if (tag === 'METER' || tag === 'PROGRESS') { out.push(' ' + String(el.value) + ' '); return; } /* a reading, inline; an is its text */ if (tag === 'BR') { out.push('\n'); return; } const block = BLOCK.has(tag); if (block) out.push('\n'); const pre = ctx.pre || tag === 'PRE'; const inner = pre === ctx.pre ? ctx : { pre, focused: ctx.focused }; for (let c = el.firstChild; c; c = c.nextSibling) walk(c, out, inner); if (tag === 'A') { const href = el.getAttribute('href') || ''; if (href && !/^\s*(#|javascript:|mailto:|tel:)/i.test(href)) { let where = href; try { const u = new URL(href, el.baseURI); where = u.origin === ctx.origin ? u.pathname + u.search + u.hash : u.href; } catch (e) { where = href; } out.push(' (→ ' + redact(where) + ')'); } } if (block) out.push('\n'); } /* THE TEXT — one string: a header (title · url), the words, the field in focus and the selection; whitespace collapsed as the backend's lexer collapses it; clipped at the ceiling and the clip NAMED */ function project(doc, opts) { opts = opts || {}; const view = doc.defaultView || root; const loc = (opts.location || (view && view.location) || {}); const origin = loc.origin || ''; const focused = doc.activeElement && doc.activeElement !== doc.body ? doc.activeElement : null; const out = []; walk(doc.body, out, { pre: false, focused, origin }); let words = out.join(''); words = words.replace(/[ \t\x0B\f\r]+/g, ' ').replace(/ ?\n ?/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); const head = []; head.push('title: ' + squash(doc.title)); head.push('url: ' + redact((loc.pathname || '') + (loc.search || '') + (loc.hash || ''))); const tail = []; if (focused && (CONTROL.has(focused.tagName) || focused.isContentEditable)) { const line = CONTROL.has(focused.tagName) ? control(focused, null) : label(focused); if (line) tail.push('the field in focus: ' + line); } let sel = ''; try { sel = view && view.getSelection ? squash(String(view.getSelection())) : ''; } catch (e) { sel = ''; } if (sel) tail.push('the visitor has selected: ' + JSON.stringify(clip(sel, SELECTION_MAX))); let text = head.join('\n') + '\n\n' + words + (tail.length ? '\n\n' + tail.join('\n') : ''); const total = text.length; if (total > CEILING) { const note = '\n\n(clipped: the page runs ' + total + ' characters; the first ' + CEILING + ' ride)'; text = text.slice(0, CEILING - note.length) + note; } return text; } /* ── 1b · THE TOOLS — the page's own verbs (pageseat 0.8, PS3-05 · PS7-06 · PS7-07) ────────── A page — or the `context.js` beside its folder's `context.ai`, which the www leg serves right after this file (PS3-05, usna_ai r41) — DEFINES its tools here. The chat opened through this seat reads their catalog at every ask (it rides inside the `page` word, PS7-01) and CALLS one only through `EOS.tools.call`, with the arguments checked against the tool's own input shape BEFORE its code runs. A tool is data plus one function the page wrote; the seat evaluates no text. Refusals by name: tool-absent · tool-args · tool-threw · tool-refused. The catalog is clipped at TOOLS_CEILING so the page word and the catalog together stay under the backend's PAGE_MAX. */ const TOOL_NAME = /^[a-z][a-z0-9_]{0,39}$/; const TOOLS_CEILING = 3072; const TOOLS = new Map(); /* name → {owner, name, description, input, confirm, run} */ const OWNERS = new Map(); /* owner → {name, version, src} */ const TOOL_LOG = { calls: 0, last: null }; function toolRefuse(word, detail) { return { ok: false, refused: word, detail: String(detail == null ? '' : detail) }; } function checkArgs(input, args) { const shape = input && typeof input === 'object' ? input : { type: 'object', properties: {} }; const props = shape.properties || {}; const a = args == null ? {} : args; if (typeof a !== 'object' || Array.isArray(a)) return 'args must be an object'; for (const k of (shape.required || [])) if (a[k] === undefined || a[k] === null || a[k] === '') return 'missing ' + k; for (const k of Object.keys(a)) { const v = a[k], p = props[k]; if (!p) { if (shape.additionalProperties === true) continue; return 'unknown argument ' + k; } if (v === undefined || v === null) continue; const t = p.type; if (t === 'string' && typeof v !== 'string') return k + ' must be a string'; if (t === 'number' && !(typeof v === 'number' && isFinite(v))) return k + ' must be a number'; if (t === 'integer' && !(typeof v === 'number' && Number.isInteger(v))) return k + ' must be an integer'; if (t === 'boolean' && typeof v !== 'boolean') return k + ' must be true or false'; if (Array.isArray(p.enum) && !p.enum.includes(v)) return k + ' must be one of ' + p.enum.join(' · '); if (typeof v === 'number') { if (typeof p.minimum === 'number' && v < p.minimum) return k + ' must be at least ' + p.minimum; if (typeof p.maximum === 'number' && v > p.maximum) return k + ' must be at most ' + p.maximum; } if (typeof v === 'string' && v.length > (p.maxLength || 200)) return k + ' is longer than ' + (p.maxLength || 200); } return null; } function argLine(input) { const props = (input && input.properties) || {}; const req = new Set((input && input.required) || []); const parts = Object.keys(props).map((k) => { const p = props[k] || {}; const t = Array.isArray(p.enum) ? p.enum.map((e) => JSON.stringify(e)).join('|') : (p.type || 'any'); const lim = (typeof p.minimum === 'number' || typeof p.maximum === 'number') ? ' ' + (typeof p.minimum === 'number' ? p.minimum : '') + '..' + (typeof p.maximum === 'number' ? p.maximum : '') : ''; return '"' + k + '": ' + t + lim + (req.has(k) ? ' (required)' : '') + (p.description ? ' — ' + squash(p.description) : ''); }); return parts.length ? '{' + parts.join('; ') + '}' : '{}'; } function toolsText() { if (!TOOLS.size) return ''; const owners = Array.from(OWNERS.values()).map((o) => o.name + (o.version ? ' ' + o.version : '') + (o.src ? ' (' + o.src + ')' : '')); const lines = ['THE TOOLS OF THIS PAGE — ' + owners.join(' · ') + '. Act through one with a ```call fence: {"tool":"name","args":{…}}.']; for (const t of TOOLS.values()) { lines.push('- ' + t.name + (t.confirm ? ' (the reader confirms before it runs)' : '') + ': ' + t.description + ' args ' + argLine(t.input)); } let text = lines.join('\n'); if (text.length > TOOLS_CEILING) { const note = '\n(clipped: the catalog runs ' + text.length + ' characters; the first ' + TOOLS_CEILING + ' ride)'; text = text.slice(0, TOOLS_CEILING - note.length) + note; } return text; } /* ── 2 · THE FACE — `window.EOS`, a utility class ───────────────────────── */ class EOS { static get id() { return ID; } static get version() { return VERSION; } static get ceiling() { return CEILING; } /* the pure part, reachable for a spec and for a page's own script */ static chord(spec) { return chord(spec); } static match(e, chords) { return match(e, chords || EOS.chords); } static redact(s) { return redact(s); } static label(el) { return label(el); } static control(el) { return control(el, null); } static project(doc, opts) { return project(doc, opts); } /* THE PAGE — what the chat may read, stated once (PS7-05): the words, the path */ static page = { text() { return project(root.document); }, at() { return root.location.pathname; }, title() { return squash(root.document.title); }, }; /* THE TOOLS — what the chat may DO on this page, defined by the page (PS7-06): data plus one function each; the chat calls them through this face alone (PS7-07) */ static tools = { define(spec) { const o = spec && typeof spec === 'object' ? spec : {}; const owner = squash(String(o.name || 'page')).slice(0, 40) || 'page'; const list = Array.isArray(o.tools) ? o.tools : []; const took = [], refused = []; for (const t of list) { const name = t && String(t.name || ''); if (!TOOL_NAME.test(name) || typeof t.run !== 'function' || !t.description) { refused.push(name || '(unnamed)'); continue; } TOOLS.set(name, { owner, name, description: squash(String(t.description)).slice(0, 300), input: t.input && typeof t.input === 'object' ? t.input : { type: 'object', properties: {} }, confirm: !!t.confirm, run: t.run }); took.push(name); } let src = ''; try { src = (root.document.currentScript && new root.URL(root.document.currentScript.src, root.location.href).pathname) || ''; } catch (e) { src = ''; } if (took.length) OWNERS.set(owner, { name: owner, version: o.version ? String(o.version).slice(0, 20) : '', src }); return { ok: refused.length === 0, owner, defined: took, refused }; }, list() { return Array.from(TOOLS.values()).map((t) => ({ name: t.name, description: t.description, input: t.input, confirm: t.confirm, owner: t.owner })); }, text() { return toolsText(); }, async call(name, args) { const t = TOOLS.get(String(name == null ? '' : name)); TOOL_LOG.calls++; if (!t) return (TOOL_LOG.last = toolRefuse('tool-absent', name)); const bad = checkArgs(t.input, args); if (bad) return (TOOL_LOG.last = toolRefuse('tool-args', bad)); try { const out = await t.run(Object.assign({}, args || {}), { tool: t.name }); if (out && typeof out === 'object' && out.refused) return (TOOL_LOG.last = toolRefuse('tool-refused', out.refused + (out.detail ? ' — ' + out.detail : ''))); TOOL_LOG.last = { ok: true, tool: t.name }; return { ok: true, tool: t.name, result: out === undefined ? null : out }; } catch (e) { return (TOOL_LOG.last = toolRefuse('tool-threw', (e && e.message) || e)); } }, state() { return { defined: TOOLS.size, owners: Array.from(OWNERS.keys()), calls: TOOL_LOG.calls, last: TOOL_LOG.last }; }, }; /* THE CHORD as the page carries it — from `data-eos-key` on ``, else the default */ static key = DEFAULT_KEY; static chords = chord(DEFAULT_KEY); static get keyLabel() { const mac = /mac|iphone|ipad|ipod/i.test((root.navigator && (root.navigator.platform || root.navigator.userAgent)) || ''); const first = EOS.chords.find((c) => (mac ? c.want.metaKey : c.want.ctrlKey)) || EOS.chords[0]; if (!first) return ''; return first.text.replace(/Control\+/i, mac ? '⌃' : 'Ctrl+').replace(/Meta\+/i, mac ? '⌘' : 'Win+') .replace(/Alt\+/i, mac ? '⌥' : 'Alt+').replace(/Shift\+/i, mac ? '⇧' : 'Shift+'); } /* THE CHAT — the panel and its frame; built on the first press, kept across presses */ static chat = { opened: false, el: null, frame: null, returnTo: null, src() { const q = new root.URLSearchParams(root.location.search); const token = q.get('token'); return root.location.pathname + '?chat&eos=1' + (token ? '&token=' + encodeURIComponent(token) : ''); }, build() { const c = EOS.chat; if (c.el) return c.el; const doc = root.document; style(doc); const el = doc.createElement('div'); el.id = 'eos-chat'; el.setAttribute('role', 'complementary'); el.setAttribute('aria-label', 'ask about this page'); el.hidden = true; el.innerHTML = '
ask about this page' + '' + '
'; el.querySelector('.eos-key').textContent = EOS.keyLabel; el.querySelector('.eos-close').addEventListener('click', () => c.close()); if (root.location.protocol === 'file:') { /* a page opened from disk has no www leg to answer `?chat`: the panel says so instead of framing itself (a mockup reviewed from a folder) */ const note = doc.createElement('p'); note.className = 'eos-note'; note.textContent = 'this page is open from a file, so there is no page to ask yet — serve it ' + '(node tool/eos-demo.js, or the www leg) and the chat stands here.'; el.appendChild(note); } else { const frame = doc.createElement('iframe'); frame.title = 'ask about this page'; frame.setAttribute('loading', 'eager'); /* THE FRAME DELEGATES NOTHING (PS5-10, rewritten at 0.1.5). 0.1.4 carried `allow="fullscreen"` for the mechanism XP-1 · gate 1 chose; gate 2 replaced it with the full-page panel below, so the grant lost its only consumer and was WITHDRAWN on the ruling of 2026-09-20 (Q5) — a grant nothing uses is a grant a reader auditing this page cannot explain. Nothing is delegated here: no fullscreen, no camera, no microphone (the voice press is the chat's own permission, asked inside the frame's own document at the first hold), no payment, no display capture. It returns in one line if it is ever wanted. */ frame.src = c.src(); el.appendChild(frame); c.frame = frame; watchInk(frame); /* the panel wears the chat's theme (PS5-09) */ } el.appendChild(seam(doc, el)); /* the left border is the handle (PS5-07) */ doc.body.appendChild(el); c.el = el; widthSet(widthStored() || WIDTH_DEFAULT, false); /* the reader's standing width, honoured */ return el; }, open() { const c = EOS.chat; const doc = root.document; if (!c.el) c.build(); c.returnTo = doc.activeElement; c.el.removeAttribute('data-eos-warm'); /* a warmed panel becomes a shown one */ c.el.hidden = false; mirrorInk(); c.opened = true; doc.documentElement.setAttribute('data-eos-chat', 'open'); remember(true); const w = c.frame && c.frame.contentWindow; try { if (w) w.focus(); if (w && w.USNA && typeof w.USNA.focus === 'function') w.USNA.focus(); } catch (e) { /* the frame answers for itself */ } return true; }, close() { const c = EOS.chat; if (!c.el || !c.opened) return false; c.el.removeAttribute('data-eos-warm'); c.el.hidden = true; c.opened = false; root.document.documentElement.removeAttribute('data-eos-chat'); remember(false); try { if (c.returnTo && typeof c.returnTo.focus === 'function' && c.returnTo.isConnected) c.returnTo.focus(); } catch (e) { /* the page keeps its own focus */ } return true; }, toggle() { return EOS.chat.opened ? EOS.chat.close() : EOS.chat.open(); }, /* the facts a page's own script may read: whether the panel is open, built, warmed */ state() { const c = EOS.chat; return { opened: c.opened, built: !!c.el, framed: !!c.frame, warm: WORDS.has('warm'), width: c.el ? (parseInt(c.el.style.width, 10) || 0) : 0, full: !!fullFrom }; }, /* the panel's width, read or set (PS5-07 · PS7-05); the bounds are the seam's own */ width(px) { return px === undefined ? (EOS.chat.el ? (parseInt(EOS.chat.el.style.width, 10) || 0) : 0) : widthSet(px); }, /* the panel to the PAGE for an expanded visual, and back (PS5-02 amended · XP-1 · gate 2); a number asks for that many pixels clamped to the page, `false` restores */ full(v) { return fullSet(v); }, }; } /* THE STANDING PRESS — a panel the reader left open is open after a reload of the same page in this tab (PS5-05); the store holds the FACT alone, per path, and dies with the tab; a page says `forget` to keep nothing */ const WORDS = new Set(); function remember(open) { if (WORDS.has('forget')) return; try { const key = 'eos.chat:' + root.location.pathname; if (open) root.sessionStorage.setItem(key, '1'); else root.sessionStorage.removeItem(key); } catch (e) { /* no store: the press is not remembered */ } } function remembered() { if (WORDS.has('forget')) return false; try { return root.sessionStorage.getItem('eos.chat:' + root.location.pathname) === '1'; } catch (e) { return false; } } /* THE PRESSED FACT (PS4-04, the gate's ruling PS1-2 with the architect's note) — the launcher stands only once the chord has been used, and then on every later page of the same tab; the store holds the FACT alone, tab-wide, and dies with the tab (the value law, UW2-05: a fact, never a word of text); a page says `forget` to keep none */ function markPressed() { if (WORDS.has('forget')) return; try { root.sessionStorage.setItem('eos.pressed', '1'); } catch (e) { /* no store: this tab does not remember the press */ } } function pressed() { if (WORDS.has('forget')) return false; try { return root.sessionStorage.getItem('eos.pressed') === '1'; } catch (e) { return false; } } /* THE WIDTH (PS5-07). The reader sets it at the seam and the tab remembers the NUMBER — a fact, like the pressed fact, gone with the tab (UW2-05: never a word of the page). The default is the ruled 440 (PS5-02); the floor keeps the chat's own header legible, the ceiling leaves the page visible beside it, because a panel that covers the page is a page the visitor cannot read. */ const WIDTH_MIN = 320, WIDTH_DEFAULT = 440, NARROW = 720; function widthMax() { return Math.max(WIDTH_MIN, Math.min((root.innerWidth || 1024) - 60, 1200)); } function widthStored() { if (WORDS.has('forget')) return 0; try { return parseInt(root.sessionStorage.getItem('eos.width'), 10) || 0; } catch (e) { return 0; } } /* THE FULL PAGE (PS5-02 amended · PS5-10 · XP-1 · gate 2, ruled 2026-09-20). The chat asks for this while the reader has a visual EXPANDED and gives it back when they close it. It is the one place the panel is allowed past the seam's own ceiling: `widthMax()` caps at 1200 so the page stays readable beside the chat, and on a 1920 page that is 62% — not a full page by any reading. So `full(px)` clamps to the PAGE instead, takes what the content asked for and no more (the chat measures it: a mermaid's viewBox, a source face's longest line), and drops the panel's left rule and shadow because a rule against nothing is a rule against nothing. The seam is frozen while it lasts — a drag mid-expand would set a width the close is about to overwrite. WHAT IT MUST NOT DO. It MUST NOT remember (`widthSet(px, false)`): the reader's own width is theirs, and an expand is a moment, not a preference. `full(false)` restores exactly the width the panel had when it was asked. Below NARROW the panel is already the page, so the verb is a no-op there and answers 0. */ let fullFrom = 0; function fullSet(v) { const el = EOS.chat.el; if (!el) return 0; if ((root.innerWidth || 1024) <= NARROW) return 0; /* already the page */ if (v === false || v === null) { if (!fullFrom) return 0; const back = fullFrom; fullFrom = 0; el.classList.remove('eos-full'); return widthSet(back, false); } if (!fullFrom) fullFrom = Math.round(parseInt(el.style.width, 10) || widthStored() || WIDTH_DEFAULT); const page = root.innerWidth || 1024; const w = Math.round(Math.max(WIDTH_MIN, Math.min(Number(v) || page, page))); el.style.width = w + 'px'; el.classList.add('eos-full'); return w; } function widthRemember(px) { if (WORDS.has('forget')) return; try { root.sessionStorage.setItem('eos.width', String(px)); } catch (e) { /* no store: this tab does not remember */ } } /* under NARROW the panel IS the viewport and the stylesheet says so; an inline width would beat the media query, so none is written there */ function widthSet(px, remember) { const el = EOS.chat.el; if (!el) return 0; if ((root.innerWidth || 1024) <= NARROW) { el.style.removeProperty('width'); return 0; } const w = Math.round(Math.max(WIDTH_MIN, Math.min(Number(px) || WIDTH_DEFAULT, widthMax()))); el.style.width = w + 'px'; const seam = el.querySelector('.eos-seam'); if (seam) { seam.setAttribute('aria-valuenow', String(w)); seam.setAttribute('aria-valuemin', String(WIDTH_MIN)); seam.setAttribute('aria-valuemax', String(widthMax())); seam.setAttribute('aria-valuetext', w + ' pixels wide'); } if (remember !== false) widthRemember(w); return w; } /* THE SEAM — the panel's left border, made a handle. The pointer is CAPTURED on the strip, so a drag that crosses the frame still belongs to the seam (a frame is a document and swallows a pointer otherwise); the keyboard gets the same act, because a control only a mouse can reach is not a control (⟦@aesthetic?§9⟧ AE9-03, the launcher's own reasoning). */ function seam(doc, panel) { const g = doc.createElement('div'); g.className = 'eos-seam'; g.setAttribute('role', 'separator'); g.setAttribute('aria-orientation', 'vertical'); g.setAttribute('aria-label', 'resize this panel'); g.setAttribute('tabindex', '0'); g.title = 'drag to resize · ← → to nudge · double-click to reset'; let dragging = false; g.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse' && e.button !== 0) return; dragging = true; try { g.setPointerCapture(e.pointerId); } catch (x) { /* no capture: the move still tracks */ } doc.documentElement.setAttribute('data-eos-drag', ''); e.preventDefault(); }); g.addEventListener('pointermove', (e) => { if (dragging) widthSet((root.innerWidth || 1024) - e.clientX, false); }); const done = (e) => { if (!dragging) return; dragging = false; try { g.releasePointerCapture(e.pointerId); } catch (x) { /* already released */ } doc.documentElement.removeAttribute('data-eos-drag'); widthRemember(parseInt(panel.style.width, 10) || WIDTH_DEFAULT); }; g.addEventListener('pointerup', done); g.addEventListener('pointercancel', done); g.addEventListener('dblclick', () => widthSet(WIDTH_DEFAULT)); g.addEventListener('keydown', (e) => { const at = parseInt(panel.style.width, 10) || WIDTH_DEFAULT; const step = e.shiftKey ? 64 : 16; let to = null; if (e.key === 'ArrowLeft') to = at + step; /* the panel is on the right: left is WIDER */ else if (e.key === 'ArrowRight') to = at - step; else if (e.key === 'Home') to = widthMax(); else if (e.key === 'End') to = WIDTH_MIN; else if (e.key === 'Enter' || e.key === ' ') to = WIDTH_DEFAULT; if (to === null) return; e.preventDefault(); e.stopPropagation(); /* the seat's own Escape/chord stay the window's */ widthSet(to); }); return g; } /* THE INK MIRROR (PS5-09). The framed chat carries the READER's stored theme; the panel carried only the device's preference, so a reader who set the chat light met a dark bar above it. The panel reads the frame's theme through the chat's own face (`USNA.root`, BUG3-01's rule — a face, never a name inside the document) and wears it; a frame of another origin throws and the catch leaves the device's scheme standing. */ function mirrorInk() { const c = EOS.chat; if (!c.el) return null; let theme = null; try { const w = c.frame && c.frame.contentWindow; const face = w && w.USNA; const el = (face && face.root) || (w && w.document && w.document.documentElement) || null; theme = el ? el.getAttribute('data-theme') : null; } catch (e) { theme = null; } /* another origin: the device's scheme stands */ if (theme) c.el.setAttribute('data-theme', theme); else c.el.removeAttribute('data-theme'); return theme; } /* the frame repaints its own theme on a press, so the mirror watches the attribute rather than polling */ function watchInk(frame) { const go = () => { mirrorInk(); try { const d = frame.contentDocument; if (d && root.MutationObserver) new root.MutationObserver(mirrorInk).observe(d.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); } catch (e) { /* another origin: nothing to watch */ } }; frame.addEventListener('load', go); go(); } /* ── 3 · THE FURNITURE — the panel's ink and the launcher ──────────────── */ function style(doc) { if (doc.getElementById('eos-style')) return; const st = doc.createElement('style'); st.id = 'eos-style'; /* the dialog tier's ink (⟦@aesthetic?§6.2⟧ AE6-02): a 3px ink border, a hard offset shadow, no radius, no motion — on the seat's own two inks, because the host page carries no tokens of its own; dark follows the device */ st.textContent = [ /* THE CONTAINMENT (PS5-08). The seat's furniture stands in a STRANGER's document and inherits its cascade: a host `p{margin:1em 0}` pushes the note, a host `box-sizing:content-box` adds the border to the width. Every element the seat owns is therefore reset here, and nothing the panel holds may overflow it. */ '#eos-chat,#eos-chat *{box-sizing:border-box;margin:0}', '#eos-chat{position:fixed;inset:0 0 0 auto;width:min(100vw,440px);height:100%;z-index:2147483000;', 'display:flex;flex-direction:column;overflow:hidden;background:#fbfbf9;color:#111;border-left:3px solid #111;', 'box-shadow:-10px 0 0 rgba(17,17,17,.28);font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}', '#eos-chat[hidden]{display:none}', '#eos-chat .eos-bar{display:flex;align-items:center;gap:10px;padding:6px 10px;border-bottom:2px solid #111;', 'font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:10px}', '#eos-chat .eos-key{opacity:.6;text-transform:none;letter-spacing:0;font-weight:400}', '#eos-chat .eos-close{margin-left:auto;background:none;border:2px solid #111;color:inherit;font:inherit;', 'padding:2px 8px;cursor:pointer;text-transform:lowercase;letter-spacing:.05em}', '#eos-chat .eos-close:focus-visible{outline:3px solid #1a7f5a;outline-offset:1px}', /* DISPLAY:BLOCK IS LOAD-BEARING (PS5-08). An iframe is inline-level by default, so it sits on the text baseline and leaves a descender gap beneath it; in a column flex box of height 100% that gap is overflow, and the panel grew a scrollbar of its own (read on usna.ai/biu, 2026-09-19). `min-height:0` lets the flex item shrink below its 150px intrinsic height. */ '#eos-chat iframe{flex:1 1 0;display:block;width:100%;height:auto;min-height:0;border:0;padding:0;background:transparent}', '#eos-chat .eos-note{padding:18px 16px;font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;max-width:36em}', /* THE SEAM (PS5-07) — the left border IS the grab: a 9px strip over the 3px rule, the pointer's col-resize, and a keyboard's own arrows. It carries the panel's width for assistive technology (`separator` with a value, the window-splitter pattern). */ '#eos-chat .eos-seam{position:absolute;inset:0 auto 0 -3px;width:9px;padding:0;border:0;', 'background:transparent;cursor:col-resize;touch-action:none;z-index:2}', '#eos-chat .eos-seam:hover,#eos-chat .eos-seam:focus-visible{background:#1a7f5a}', '#eos-chat .eos-seam:focus-visible{outline:none}', /* while the seam is dragged the frame must not swallow the pointer, and no text may select */ 'html[data-eos-drag],html[data-eos-drag] body{cursor:col-resize!important;user-select:none}', 'html[data-eos-drag] #eos-chat iframe{pointer-events:none}', '#eos-launch{position:fixed;right:18px;bottom:18px;z-index:2147482999;cursor:pointer;', 'font:600 12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.06em;', 'padding:8px 12px;background:#fbfbf9;color:#111;border:3px solid #111;box-shadow:6px 6px 0 rgba(17,17,17,.28)}', '#eos-launch:focus-visible{outline:3px solid #1a7f5a;outline-offset:2px}', '#eos-launch kbd{font:inherit;opacity:.6;margin-left:6px}', 'html[data-eos-chat="open"] #eos-launch{display:none}', '@media (prefers-color-scheme:dark){#eos-chat,#eos-launch{background:#131311;color:#eceae3;border-color:#eceae3}', '#eos-chat .eos-bar,#eos-chat .eos-close{border-color:#eceae3}#eos-chat{box-shadow:-10px 0 0 rgba(0,0,0,.55)}}', '#eos-chat.eos-full{border-left:0;box-shadow:none}', '#eos-chat.eos-full .eos-seam{display:none}', '@media (max-width:720px){#eos-chat{width:100vw!important;border-left:0;box-shadow:none}', '#eos-chat .eos-seam{display:none}}', '@media (forced-colors:active){#eos-chat,#eos-launch{border-color:CanvasText;background:Canvas;color:CanvasText;box-shadow:none}}', /* THE WARM PANEL IS LAID OUT, NEVER display:none (PS5-04). A frame built inside a display:none box boots at 0x0: its media queries answer for a zero viewport and its first paint is mislaid when the panel is finally shown. Warm therefore means VISIBILITY hidden — real geometry, no paint, no hit. */ '#eos-chat[data-eos-warm]{visibility:hidden;pointer-events:none}', /* THE PANEL MIRRORS THE FRAME'S INK (PS5-09), and these stand LAST so they beat the device's scheme: the chat carries the reader's own stored theme, the device carries only a preference, and a dark bar over a light chat is two chromes fighting on one edge (read on usna.ai, 2026-09-19). */ '#eos-chat[data-theme="light"]{background:#fbfbf9;color:#111;border-color:#111;box-shadow:-10px 0 0 rgba(17,17,17,.28)}', '#eos-chat[data-theme="light"] .eos-bar,#eos-chat[data-theme="light"] .eos-close{border-color:#111}', '#eos-chat[data-theme="dark"]{background:#131311;color:#eceae3;border-color:#eceae3;box-shadow:-10px 0 0 rgba(0,0,0,.55)}', '#eos-chat[data-theme="dark"] .eos-bar,#eos-chat[data-theme="dark"] .eos-close{border-color:#eceae3}', ].join(''); (doc.head || doc.documentElement).appendChild(st); } /* THE LAUNCHER (PS4-04) — a control a keyboard alone can reach is not a control on a touch screen (⟦@aesthetic?§9⟧ AE9-03), so the press stands visibly unless the page says `data-eos="quiet"`; it carries the chord for assistive technology (`aria-keyshortcuts`, ARIA 1.1) and names it in its title */ function launcher(doc) { if (doc.getElementById('eos-launch')) return; style(doc); const b = doc.createElement('button'); b.id = 'eos-launch'; b.type = 'button'; b.setAttribute('aria-keyshortcuts', EOS.key); b.title = 'ask about this page (' + EOS.keyLabel + ')'; b.innerHTML = 'ask'; b.querySelector('kbd').textContent = EOS.keyLabel; b.addEventListener('click', () => EOS.chat.toggle()); doc.body.appendChild(b); } /* ── 4 · THE BOOT — inert on chat.html, inert where the page says so ────── */ function boot() { const doc = root.document; if (!doc || !doc.documentElement) return; const html = doc.documentElement; const mode = html.getAttribute('data-eos') || ''; mode.split(/\s+/).filter(Boolean).forEach((w) => WORDS.add(w.toLowerCase())); if (root.USNA || WORDS.has('off')) return; /* the wrapper is never wrapped; the page opted out */ const spec = html.getAttribute('data-eos-key'); if (spec && chord(spec).length) { EOS.key = spec; EOS.chords = chord(spec); } /* THE PRESS — at the window, in the bubble phase, AFTER the page's own handlers: a page that answered the chord keeps it (`defaultPrevented`). The FIRST chord press marks the tab pressed and reveals the launcher for this page and every later one (PS4-04, the gate's ruling PS1-2). */ root.addEventListener('keydown', (e) => { if (match(e, EOS.chords)) { e.preventDefault(); if (!pressed()) { markPressed(); if (!WORDS.has('quiet')) launcher(doc); } EOS.chat.toggle(); return; } if (e.key === 'Escape' && EOS.chat.opened && !e.defaultPrevented) { e.preventDefault(); EOS.chat.close(); } }); const ready = () => { if (!WORDS.has('quiet') && pressed()) launcher(doc); /* only once the chord has been used in this tab (PS1-2) */ if (remembered()) EOS.chat.open(); /* the reader's standing press, honoured */ }; /* the ceiling is the window's: a narrowed window re-clamps the panel, and crossing NARROW hands the width back to the stylesheet (PS5-07) */ root.addEventListener('resize', () => { if (!EOS.chat.el) return; if (fullFrom) { EOS.chat.el.style.width = (root.innerWidth || 1024) + 'px'; return; } /* a full panel IS the page */ widthSet(parseInt(EOS.chat.el.style.width, 10) || WIDTH_DEFAULT, false); }); if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', ready, { once: true }); else ready(); /* WARM (PS5-04): the frame built hidden at the first idle moment after the load event — never on the critical path, never before the page's own load */ if (WORDS.has('warm')) { const warm = () => { if (EOS.chat.el) return; EOS.chat.build(); const el = EOS.chat.el; /* laid out at its true width, painted by nothing */ if (el && !EOS.chat.opened) { el.setAttribute('data-eos-warm', ''); el.hidden = false; } }; const idle = () => { if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(warm, { timeout: 2000 }); else root.setTimeout(warm, 0); }; if (doc.readyState === 'complete') idle(); else root.addEventListener('load', idle, { once: true }); } } /* ── 5 · THE TWO SEATS: the page's window, and node ───────────────────── */ if (root && root.document) { root.EOS = EOS; boot(); } if (typeof module !== 'undefined' && module.exports) module.exports = EOS; }(typeof window !== 'undefined' ? window : globalThis));