/* ══════════════════════════════════════════════════════════════════════════════
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