The Derived Surface
Part of One User Interface — why the agent surface derives from what tosijs already knows.
Your app already wrote its own agent map — every binding and handler tosijs records is a line in it. Below: a todo list built the ordinary way, and the schematic — the map, drawn — growing as you add items.
The map, drawn: a schematic embodiment (live)
.preview.split-view {
display: grid;
grid-template-columns: 50% 50%;
gap: 8px;
}
/* app above map on narrow displays */
@media (max-width: 600px) {
.preview.split-view {
grid-template-columns: 100%;
}
}
import {
elements,
tosi,
enableAgentInterface,
schematicSVG,
rasterizeSVG,
getListItem,
} from 'tosijs'
import { tosiTabs, icons } from 'tosijs-ui'
const { mapDemo } = tosi({
mapDemo: {
newItem: '',
items: [
{ id: 1, text: 'draw the map', done: true },
{ id: 2, text: 'add more todos', done: false },
],
addItem() {
const text = mapDemo.newItem.value.trim()
if (!text) return
mapDemo.items.push({ id: Math.random(), text, done: false })
mapDemo.newItem = ''
},
},
})
const agent = globalThis.tosiAgent ?? enableAgentInterface({ expose: 'all' })
const { div, input, button, ul, pre, img } = elements
// an ordinary todo list — each row's checkbox and label are WIRED, so every
// item is part of the map, and the map grows as the list does
preview.append(
div(
input({
placeholder: 'add a todo…',
bindValue: mapDemo.newItem,
// Enter COMMITS the field — change fires after the binding writes
// state, so addItem acts on committed state, mutates atomically, and
// the UI catches up on its own. (Acting on keydown instead means
// racing the commit — the classic clear-that-doesn't.) The PROXY form:
// resolves live by path, and the map names it just like the string
onChange: mapDemo.addItem,
}),
' ',
// another PRECONDITION: nothing to add = nothing to press (an
// observant transform — the button follows the field)
button('add', {
onClick: 'mapDemo.addItem',
disabled: mapDemo.newItem.tosi.take((text) => text.trim() === ''),
}),
ul(
{ style: { maxHeight: '10em', overflow: 'auto', margin: 0 } },
...mapDemo.items.listBinding(
({ li, input: check, span, button }, item) =>
li(
check({ type: 'checkbox', bindValue: item.done }),
' ',
span(item.text),
' ',
// an icon-only affordance: `title` gives it its NAME (harvested
// the way a screen reader reads it), and an observant transform
// makes checked-ness a PRECONDITION — watch the delete button
// fade in and out of the map as you toggle its todo
button(icons.x(), {
title: 'delete',
disabled: item.done.tosi.take((done) => !done),
onClick(event) {
const row = getListItem(event.target.closest('li'))
mapDemo.items.listRemove((i) => i.id, row.id)
},
})
),
{ idPath: 'id' }
)
)
)
)
// four live views in tabs — the selected one redraws on every state
// change: the map follows the app, hands off
const detail = pre({ style: { maxHeight: '8em', overflow: 'auto', margin: 0 } })
const pane = (name) =>
div({ name, style: { overflow: 'auto', height: '100%' } })
const panes = [
// what an agent's vision encoder receives: map -> SVG -> raster, 2× —
// the default view, because it's the one that has to stand alone
pane('image'),
// scoped by HIERARCHY: this demo's subtree (a within-rect is REGIONAL)
pane('schematic'),
// the camera: what the USER sees right now, in screen coordinates
pane('screen'),
// the atlas: the ENTIRE host app, chrome and all (best maximized)
pane('whole page'),
]
const tabs = tosiTabs(
{ style: { flex: '1 1 auto', minHeight: '160px' } },
...panes
)
// display fit + click-to-inspect (the image is an index: data-record links
// each box to its JSON record)
const show = (target, d) => {
target.innerHTML = schematicSVG(d, { index: true })
const svg = target.querySelector('svg')
if (svg) {
svg.setAttribute('width', '100%')
svg.removeAttribute('height')
}
target.onclick = (event) => {
const g = event.target.closest('[data-record]')
if (g) {
detail.textContent = JSON.stringify(
d.wiring[Number(g.dataset.record)],
null,
2
)
}
}
}
let rasterBusy = false
const render = [
async () => {
if (rasterBusy) return // rasterization is async — never overlap ticks
rasterBusy = true
try {
const d = agent.describe({ styles: true, scope: preview })
const svg = schematicSVG(d, { index: true })
const blob = await rasterizeSVG(svg, { scale: 2 })
// update the ONE img in place — the old frame stays up until the new
// src decodes, so the view never flickers
let shot = panes[0].querySelector('img')
if (!shot) {
shot = img({ style: { maxWidth: '100%' } })
panes[0].append(shot)
}
const prior = shot.src
shot.src = URL.createObjectURL(blob)
if (prior) URL.revokeObjectURL(prior)
} finally {
rasterBusy = false
}
},
() => show(panes[1], agent.describe({ styles: true, scope: preview })),
() => show(panes[2], agent.describe({ styles: true, view: 'viewport' })),
() => show(panes[3], agent.describe({ styles: true })),
]
// no polling: the drawing is an OBSERVER — any state change (or focus
// move, which state can't see) redraws the selected view, debounced a beat
const redraw = { timer: 0 }
const requestRedraw = () => {
clearTimeout(redraw.timer)
redraw.timer = setTimeout(() => {
if (tabs.isConnected) render[tabs.value ?? 0]()
}, 100)
}
const off = agent.observe(/./, requestRedraw)
document.addEventListener('focusin', requestRedraw)
tabs.addEventListener('change', requestRedraw)
const cleanup = setInterval(() => {
if (!tabs.isConnected) {
off()
document.removeEventListener('focusin', requestRedraw)
clearInterval(cleanup)
}
}, 2000)
requestRedraw()
// side by side: the app IS the left pane, its map the right — same state,
// two renderings (the JSON inspector runs full width below)
preview.classList.add('split-view')
detail.style.gridColumn = '1 / -1'
preview.append(tabs, detail)
import { enableAgentInterface, schematicSVG, rasterizeSVG, elements, tosi, updates } from 'tosijs'
test('the full pipeline: map -> SVG string -> PNG blob (real browsers only)', async () => {
const agent = globalThis.tosiAgent ?? enableAgentInterface({ expose: 'all' })
const svg = schematicSVG(agent.describe({ styles: true }))
expect(svg.includes('<svg xmlns=')).toBe(true)
expect(svg.includes('data-record=')).toBe(true)
// rasterization needs a real rendering engine — the exact capability a
// unit DOM cannot fake, which is why this lives in the browser tier
const blob = await rasterizeSVG(svg, { scale: 2 })
expect(blob.type).toBe('image/png')
expect(blob.size > 0).toBe(true)
})
test('the delete affordance: named by its title, gated by checked-ness', async () => {
const agent = globalThis.tosiAgent ?? enableAgentInterface({ expose: 'all' })
// build our OWN rows: a doc test must not depend on another fence's DOM
// (the runner executes fences in isolated frames, in no fixed order)
const { ul, li, button } = elements
const { delFence } = tosi({
delFence: { rows: [{ id: 1, done: true }, { id: 2, done: false }] },
})
const list = ul(
...delFence.rows.listBinding(
({ li: row, button: btn }, item) =>
row(
btn('x', {
title: 'delete-fence',
bind: {
value: item.done,
binding: (el, done) => {
el.disabled = !done
},
},
onClick() {},
})
),
{ idPath: 'id' }
)
)
preview.append(list)
await updates()
const dels = agent
.describe()
.wiring.filter((w) => w.label === 'delete-fence')
expect(dels.length).toBe(2)
// the checked row's delete is live; the unchecked one is disabled —
// a PRECONDITION, visible in the map (and faded in the schematic)
expect(dels.some((w) => w.disabled === true)).toBe(true)
expect(dels.some((w) => w.disabled == null)).toBe(true)
list.remove()
})
One rectangle per wired element, at its actual position and size, wearing
the app's own colors (describe({ styles: true })) — and since bounds is
plain data, a remote agent can draw a page nobody is looking at.
Above — a todo list, because a list's map changes shape as you use it:
every item you add is new wired elements and the map grows with them, hands
off. Each row's icon-only delete button is named by its title and
gated by its checkbox — toggle a todo and watch its delete button fade in
and out of the map, hands off: the drawing observes the same state it draws
(plus focus moves, which state can't see). Type in the input and watch the caption
change from italic hint to held value; check a box and watch the glyph flip.
The schematic tab is scoped by hierarchy — agent.describe({ scope: preview }) walks only this demo's subtree, so the map is the same whether
the example is inline or maximized. (Spatial scoping — schematicSVG(map, { within: rect }) — also exists, but a region includes whatever overlaps it,
occluded or not; use it for viewport maps, not app parts.) whole page
drops the scope for the full reveal; click any rectangle for its JSON record:
SVG vs. bitmap, per consumer. For an LLM, SVG source is the worst
encoding of the three: same information as the JSON plus 2–4× markup overhead,
through the same text channel with the same no-perception problem (models
compute "x:340 is right of x:120"; they don't see it — and they're famously
poor at mentally rendering SVG). The division of labor: JSON for text
reasoning (minimal, precise), rasterized PNG for vision encoders (the
only channel where adjacency/containment/alignment are perceived, and where
the ~10× visual token compression lives — rasterize at 2× so labels land
≥ 12px effective and OCR is near-lossless), SVG as the master artifact for
humans and tools (deterministic, diffable, clickable — the index). The helper
this implies: schematicSVG(description) → string, a pure DOM-free function
(bounds is plain data, so the headless embodiment can vend its own schematic
and a remote agent can draw a page nobody is viewing), plus a rasterize step —
canvas in-browser, @resvg/resvg-js under bun (already a devDep for ePub
covers).
The grammar (explicit, so "can I act here?" never needs guessing):
bold outline = wired to act (has handlers) · an ↔ badge at the right
edge = editable here · caption ending * = required · a red corner
flag (top-left) = invalid right now — live ValidityState, the same
truth :invalid styles · toggle state is drawn, live (an ✕
fills a checked box; radios are circles with a dot when selected) · a
double outline = keyboard focus, where the user is right now · italic =
placeholder hint, not content · faded = disabled right now (it beats
bold: a disabled button is not an affordance) · plain solid = display ·
faint dotted = structure, including list containers (subtle on purpose —
the ground, not the figure: a list's items are the affordances; the
container is where they live, and its wiring stays in the JSON record).
The audit: findings, not vibes
Every rule below is one the map makes obvious — every element with handlers
should have a name, a role, and enough contrast and size to use — and
because the map is the framework's own record of its wiring, a finding is a
real defect rather than a scraper's guess. auditFlags() turns the report
into schematic flags, so the drawing shows you where they are.
import {
enableAgentInterface,
auditAccessibility,
auditFlags,
schematicSVG,
elements,
} from 'tosijs'
const agent = globalThis.tosiAgent ?? enableAgentInterface({ expose: 'all' })
const { div, button, pre } = elements
// a deliberately bad little UI — an anonymous div-button with faint text
const bad = div(
{ style: { display: 'flex', gap: '8px', alignItems: 'center' } },
div({
onClick() {},
style: {
color: '#bbb',
background: 'white',
padding: '2px 6px',
cursor: 'pointer',
},
textContent: 'delete',
}),
div({ onClick() {} }) // no name at all
)
const out = pre({ style: { maxHeight: '10em', overflow: 'auto', margin: 0 } })
preview.append(
bad,
button('audit this demo', {
onClick() {
const map = agent.describe({ styles: true, scope: preview })
const report = auditAccessibility(map)
out.textContent =
`${report.failed} error(s), ${report.findings.length} finding(s)\n\n` +
report.findings
.map((f) => `[${f.severity}] ${f.rule}\n ${f.message}`)
.join('\n\n') +
(report.skipped.length
? `\n\nskipped: ${report.skipped.join('; ')}`
: '')
},
}),
out
)
import { enableAgentInterface, auditAccessibility } from 'tosijs'
test('the audit finds real defects in a real page', () => {
const agent = globalThis.tosiAgent ?? enableAgentInterface({ expose: 'all' })
const report = auditAccessibility(agent.describe({ styles: true }))
// shape, not score: this page is a live document, its findings will vary
expect(Array.isArray(report.findings)).toBe(true)
expect(typeof report.failed).toBe('number')
// styles WERE supplied, so the rule must not skip for lack of them…
expect(
report.skipped.some((s) => s.includes('no computed styles'))
).toBe(false)
// …though it may honestly report that transparent backgrounds can't be
// measured from the map alone (the effective ancestor colour is unknown)
expect(
report.skipped.every((s) => s.startsWith('contrast:'))
).toBe(true)
})
Kitchen sink: the truth test
Every "obvious" rendering claim, verifiable at a glance — one of everything,
drawn live (250ms) with index: true, so each box wears its wiring index and
the legend below maps numbers to records (the raster form of
data-record: a vision consumer reads the number off the image and looks up
the JSON). Interact with anything on the left; the map must never disagree:
import { elements, tosi, enableAgentInterface, schematicSVG } from 'tosijs'
const { sink } = tosi({
sink: {
text: '',
email: '',
qty: 3,
volume: 7,
flavor: 'mango',
terms: true,
spam: false,
size: 'medium',
pick(event) {
sink.size = event.target.value
},
submit() {},
},
})
const agent = globalThis.tosiAgent ?? enableAgentInterface({ expose: 'all' })
const { div, label, input, select, option, button, span, pre } = elements
const radio = (value) =>
label(
input({
type: 'radio',
name: 'sink-size',
value,
checked: sink.size.value === value,
onChange: 'sink.pick',
}),
' ',
value
)
const controls = div(
{ style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
input({ placeholder: 'type here…', bindValue: sink.text }),
// required + empty = INVALID right now: the map reads live ValidityState
// (red corner flag + asterisk) — type a real address and watch it clear
input({
type: 'email',
required: true,
placeholder: 'email (required)',
bindValue: sink.email,
}),
label(
input({ type: 'number', bindValue: sink.qty, style: { width: '4em' } }),
' qty'
),
label(
input({ type: 'range', min: 0, max: 10, bindValue: sink.volume }),
' volume'
),
label(input({ type: 'checkbox', bindValue: sink.terms }), ' terms'),
label(input({ type: 'checkbox', bindValue: sink.spam }), ' spam me'),
div(radio('small'), radio('medium'), radio('large')),
select({ bindValue: sink.flavor }, option('mango'), option('lime')),
// contenteditable surfaces AS an input: the agent cares that the region
// EXISTS and which path feeds it (it writes state, not keystrokes)
div(
{
contenteditable: 'true',
ariaPlaceholder: 'editable region…',
style: {
border: '1px dashed gray',
padding: '2px 4px',
minHeight: '1.2em',
},
},
'edit me'
),
div(
button('submit', { onClick: 'sink.submit' }),
' ',
button('disabled', { disabled: true, onClick: 'sink.submit' })
),
// a DISPLAY affordance: bound text, colored — mapped, but neither bold
// nor badged (you can read it, not act on it)
span({
title: 'flavor',
textContent: sink.flavor,
style: {
background: 'rgb(0, 128, 96)',
color: 'white',
padding: '2px 6px',
alignSelf: 'flex-start',
},
})
)
const drawing = div({ style: { flex: '1 1 auto', overflow: 'auto' } })
const legend = pre({
style: { maxHeight: '9em', overflow: 'auto', margin: 0, fontSize: '10px' },
})
const draw = () => {
const d = agent.describe({ styles: true, scope: preview })
drawing.innerHTML = schematicSVG(d, { index: true })
const svg = drawing.querySelector('svg')
if (svg) {
svg.setAttribute('width', '100%')
svg.removeAttribute('height')
}
legend.textContent = d.wiring
.map(
(w, i) =>
`${i} ${w.tag}${w.type ? ':' + w.type : ''}` +
`${w.disabled === true ? ' (disabled)' : ''}` +
`${w.required === true ? ' (required)' : ''}` +
`${w.invalid === true ? ' (invalid)' : ''} ` +
`${w.label ?? w.placeholder ?? w.text ?? w.value ?? ''}`
)
.join('\n')
}
// state changes redraw (the map is an observer); focus moves too, since
// state can't see them
const redraw = { timer: 0 }
const requestRedraw = () => {
clearTimeout(redraw.timer)
redraw.timer = setTimeout(() => {
if (drawing.isConnected) draw()
}, 100)
}
const off = agent.observe(/./, requestRedraw)
document.addEventListener('focusin', requestRedraw)
const cleanup = setInterval(() => {
if (!drawing.isConnected) {
off()
document.removeEventListener('focusin', requestRedraw)
clearInterval(cleanup)
}
}, 2000)
requestRedraw()
preview.append(
div(
{ style: { display: 'flex', gap: '8px', height: '100%' } },
controls,
div(
{
style: {
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
},
drawing,
legend
)
)
)
import { enableAgentInterface, schematicSVG, updates } from 'tosijs'
test('kitchen sink: the map never disagrees with the controls', async () => {
const agent = globalThis.tosiAgent ?? enableAgentInterface({ expose: 'all' })
const d = agent.describe({ styles: true })
// toggle state is harvested live, both ways
const checks = d.wiring.filter((w) => w.type === 'checkbox')
expect(checks.some((w) => w.checked === true)).toBe(true)
expect(checks.some((w) => w.checked === false)).toBe(true)
// exactly one radio in the group is checked
const radios = d.wiring.filter((w) => w.type === 'radio')
expect(radios.filter((w) => w.checked === true).length).toBe(1)
// a placeholder is a hint field, never the label
const hinted = d.wiring.find((w) => w.placeholder === 'type here…')
expect(hinted != null).toBe(true)
expect(hinted.label).toBe(undefined)
// disabled is a record fact
expect(
d.wiring.some((w) => w.tag === 'button' && w.disabled === true)
).toBe(true)
// contenteditable: an affordance in itself, live text as value
const ce = d.wiring.find((w) => w.contentEditable === true)
expect(ce != null).toBe(true)
expect(ce.value).toBe('edit me')
// required + empty email = invalid, live (ValidityState is map truth)
const email = d.wiring.find((w) => w.type === 'email')
expect(email != null).toBe(true)
expect(email.required).toBe(true)
expect(email.invalid).toBe(true)
// and the drawing carries all of it
const svg = schematicSVG(d, { index: true })
expect(svg.includes('<line')).toBe(true) // the checked box's ✕
expect(svg.includes('<circle')).toBe(true) // radios are circles
expect(svg.includes('font-style="italic"')).toBe(true) // the hint
expect(svg.includes('opacity="0.4"')).toBe(true) // the disabled button
expect(svg.includes('#d32f2f')).toBe(true) // the invalid corner flag
expect(svg.includes(' *')).toBe(true) // the required asterisk
// the agent types: the value must replace the hint
agent.write('sink.text', 'actual content')
await updates()
const after = schematicSVG(agent.describe({ styles: true }))
expect(after.includes('actual content')).toBe(true)
agent.write('sink.text', '') // leave the demo as found
})
Why the map beats pixels:
- Layout is free structure — containment says nesting, adjacency says order, arrows say direction; none of it spends a token on syntax (the DeepSeek-OCR result, applied to UI).
- The image is an index — every rectangle links to its JSON record: glance at the picture, zoom into the one subtree you need.
- Three encodings, one wiring — JSON, DOM, schematic; a diff invisible in one is glaring in another (a schematic diff is a regression a human sees).
- It's the demo of the thesis — a map that draws itself from a page that never declared one. Every tosijs demo can end with the reveal.
The raster economics (encoders normalize the long edge, so cost is capped by construction — the budget is aspect ratio, not bytes):
| what you rasterize | vision tokens | labels readable? | role |
|---|---|---|---|
viewport render (view: 'viewport', ≈16:10) |
~700–1100 | yes — the frame pages are designed for | the user's-eye glance |
| whole page (≈1300×6500) | ~650–800 | no (crushed ~4×) | the atlas — topology + structure |
region map, ≤3:1 (scope/within) |
~600–1500 | yes | the zoom — full fidelity |
| JSON map, 60-control app (haltija's measurement) | ~1,567 (text) | exact | the reasoning form |
Compression and index are the same design: the glance is cheap because the zoom exists to recover what it discarded.
The schematic above is ~60 lines of vanilla SVG generation over describe()
output — no layout engine, no rendering, no screenshots. This code and the
thinking belong in haltija too: a test driver that can draw the schematic of
any page it's driving has a map view humans can check at a glance and a
compressed overview a vision model can ingest — the natural shared artifact
between the agent surface and the test harness (they are, after all, the same
interface).
Proof: the harvest, assembled live
The UI below is built with ordinary element sugar — a labeled filter input (two-way bound), a read-only total (one-way prop binding), and a button whose handler is attached by path. There is not one agent-specific declaration in it. Click describe() and the affordance graph is assembled on demand — and because this page runs in introspection mode, the graph is the whole page's: the two-actors demo, the doc site's own chrome, and the very button you clicked to ask.
import { elements, tosi, enableAgentInterface } from 'tosijs'
const { harvest } = tosi({
harvest: {
filter: '',
total: 3,
restock() {
harvest.total = harvest.total.value + 1
},
},
})
// reuse the page's surface if one is already installed
const agent = globalThis.tosiAgent ?? enableAgentInterface({ expose: 'all' })
const { div, input, span, button, pre } = elements
// output as a DIRECT preview child: height 100% is a no-op inline and
// fills the box when the example is maximized
const out = pre({ style: { height: '100%', overflow: 'auto', margin: 0 } })
// an ordinary UI — no agent-specific declarations anywhere
preview.append(
div(
input({ placeholder: 'filter stock…', bindValue: harvest.filter }),
' ',
span({ title: 'items in stock', textContent: harvest.total }),
' ',
button('restock', { onClick: 'harvest.restock' }),
' ',
button('describe()', {
onClick() {
const d = agent.describe()
const summary = [
`exposure: ${d.exposure}`,
`roots: ${Object.keys(d.roots).join(', ')}`,
`actions: ${d.actions.join(', ')}`,
`wired elements: ${d.wiring.length}`,
].join('\n')
out.textContent = summary + '\n\n' + JSON.stringify(d.wiring, null, 2)
},
})
),
out
)
import {
elements,
tosi,
enableAgentInterface,
bind,
bindings,
updates,
} from 'tosijs'
test('describe() harvests the affordance join from ordinary declarations', async () => {
const agent = globalThis.tosiAgent ?? enableAgentInterface({ expose: 'all' })
tosi({ harvestTest: { q: '' } })
const input = elements.input({ placeholder: 'harvest-test…' })
preview.append(input)
bind(input, 'harvestTest.q', bindings.value)
await updates()
const d = agent.describe()
// a placeholder is a HINT: its own field, never conflated with label
const record = d.wiring.find((w) => w.placeholder === 'harvest-test…')
expect(record != null).toBe(true)
expect(record.label).toBe(undefined)
// the two-way arrow ⟷ marks a user-writable affordance, provenance inline
expect(String(record.value).includes('⟷ harvestTest.q')).toBe(true)
// geometry rides in the map — in a real browser the input has real bounds
// (this line is exactly what happy-dom cannot assert: it reports zero-size)
expect(record.bounds.width > 0).toBe(true)
input.remove()
})
Read what it prints. Records are flat — the semantically visible facts sit
at the top level, sized for an LLM to scan. The input's record joins its
placeholder hint to its live value and its path in one string:
value: "⟷ harvest.filter" — the two-way arrow means user-writable
affordance. The total is the same idea, one-way: text: "3 ⟵ harvest.total"
(current value, display-only; a plain text: "restock" with no arrow is
static). The restock button's handler is not an anonymous ƒ but a name —
on: { click: "harvest.restock" } — and the same path appears under
actions. That is the affordance descriptor from the table above: harvested,
joined, and serialized, authored by nobody.
The part nobody else can offer: the wiring diagram is already recorded
Here is the b8r inheritance paying off. In b8r it was the norm to put state
and event handlers in the registry. tosijs kept that affordance
(onClick: 'app.doThing' binds a handler by path) but also allows the
ergonomic shortcut (onClick: (event) => { … }) — and crucially, the shortcut
still passes through tosijs. Every handler attached via element sugar or
on() is recorded. Every data binding records element ↔ path. The framework
doesn't just hold the model — it holds the complete map of which widget is
wired to what, as a side effect of normal operation.
That means an introspection surface costs the programmer nothing. Today, with shipped exports:
| Question an agent asks | Answer, today |
|---|---|
| What state exists? | xin / boxed — plain serializable objects (this is what hotReload already relies on) |
| What is the value at a path? | boxed[path].value |
| Change it (and update the UI) | xin[path] = value — observers fire, DOM updates surgically |
| Tell me when it changes | observe(path, cb) — and unobserve to hang up |
| Which elements are data-bound? | document.getElementsByClassName(BOUND_CLASS) — the marker class is the enumerable index |
| What is this element wired to? | getElementBindings(el) → its data bindings (paths + binding types) and its event handlers (one export away from public) |
| Which datum does this row render? | getListItem(el) — DOM → model, including virtual list rows |
| What actions exist? | Functions in the registry (app.addItem) — addressable, callable, and already supported as by-path event handlers |
| What happened while I watched? | The path-touch stream — the connectDevTools pattern (every touch is {path, snapshot}) |
The React/Angular worlds cannot produce this table without becoming tosijs: it requires a universal addressing scheme, a central observable registry, stable DOM, and recorded wiring — which is simply a description of the observant model. This is the "two steps beyond" claim, made concrete.
A useful subtlety: shadow-DOM components are already agent-shaped. A shadow
component is bound like an <input> — its value is the interface and its
internals are private by design. That's precisely the affordance/implementation
split an agent needs: it should set the date-picker's value, not fumble with
its internal buttons. (Closed shadow roots stay closed — for agents too.)
The opportunistic harvest
The wiring table above is what tosijs records deliberately. There is a second,
larger layer it can harvest opportunistically — metadata that exists only
because developers use elementCreators and their syntax sugar. Every prop passed
to div({…}), every initAttributes, every listBinding flows through one
chokepoint (elementSet / the creator machinery), and almost all of it persists
somewhere recoverable (the element's attributes, the binding WeakMaps, the
component class). Nothing new needs recording — describe() computes the
harvest on demand from what's already there:
| The developer wrote (for their own reasons) | What it tells an agent, free |
|---|---|
onClick: (e) => {…} |
this element is interactive; event type known |
onClick: 'app.doThing' — or onClick: app.doThing (a proxy IS a path: on() normalizes it at registration) |
the action is addressable and nameable — a tool with a path; a named plain function leaves a breadcrumb (ƒ addThing — dev-mode value only, minification scrambles it); anonymous maps as ƒ |
bindValue: app.filter (binding has fromDOM) |
this path is user-writable — an input affordance |
textContent: app.total (prop binding is toDOM-only) |
this path is displayed — read-only output |
bindEnabled: app.cart.valid |
a precondition: the guarded action's availability depends on this path |
listBinding(template, { idPath: 'id' }) |
app.items is a collection keyed by id; the template's relative (^.field) bindings enumerate which fields of each item the UI presents, and per-row handlers are per-row actions |
aria-label(ledby), title, placeholder, alt |
the accessible name, resolved — what a screen reader would say, harvested at the source |
aria-describedby, aria-disabled/disabled, aria-required |
the author's own explanation + live affordance state (description, disabled, required on the record) |
aria-hidden |
hidden from assistive tech = hidden from the agent — the map reads the page the way a screen reader does |
aria-description / aria-describedby |
the author's own explanation, read back into the record — and a component's contract.description is stamped here (a description is not a name: it never touches aria-label) |
<a href> |
links are affordances — mapped bindings-or-not; href is a distinct fact from the text ("says X" is not "goes to Y"), captions nameless links, and always rides the legend |
contenteditable |
surfaces as an input field, mapped even before bindings attach: live text as its value, aria-placeholder as its hint, ↔ unbid — what an agent needs is that the region exists and which path feeds it (it writes state, not keystrokes) |
input({ type: 'email' }), required, min/max/pattern |
value types and validation constraints, straight from the markup sugar |
part: 'searchBox' |
the developer's own name for the affordance |
contract: { type: 'integer', examples: [1, 42] } |
an inline contract, declared at the element: rides the record, aggregates into describe().contract under the bound path, gates agent writes, and its examples are executable — see The Agent Surface |
static initAttributes = { count: 0, live: false } |
a typed per-component attribute schema — types already inferred from defaults at runtime (typeof branch in the attribute machinery) |
component value + formAssociated |
the component's value surface and its form contract |
Two compounding effects make this more than a list of facts:
- The join. Because everything is declared on the same element through the
same call, tosijs can join what no one else can:
role="search"×bindValue: app.filter×onKeydown: app.submiton one element is a complete affordance descriptor — semantic label, writable state, and action, co-located. The a11y-tree agents get the label but not the path; API tools get the action but not the label. tosijs stands in the middle when all three are declared, and the DOM + WeakMaps preserve the join for on-demand assembly. - The behavioral harvest. At runtime, the audit stream adds provenance:
call('app.addItem')→ touches observed onapp.itemsteaches the agent the causal graph (which actions affect which paths) purely by watching.describe()gets richer the longer the app runs, with zero developer effort. - Triangulated typing without schema.
typeofthe state at the path ∧ the input's declaredtype∧initAttributesdefaults: three independent signals agree on a field's type before tosijs-schema is even introduced — and flag a smell when they disagree.
The punchline is the same as the wiring table's, one octave up: the WebMCP world asks developers to author tool schemas by hand; in tosijs, the app's ordinary construction is the authoring. The sugar was designed for ergonomics; it turns out to have been designing an agent interface all along.