Sightline docs
Sightline turns what's on screen into a compact text outline for an LLM agent: roles, accessible names, states, the dialog that's open, alerts and form errors. It runs inside the page and never reads what the user typed.
What it does
An assistant built into your app can answer "why can't I click Generate?" from what's actually rendered instead of from documentation that may be out of date. Sightline gives it that view. It reads the accessibility tree the way a screen reader does and writes one short line per heading, landmark and control:
Page: /api-keys
Title: "Anam Lab"
Viewport: 1440px wide
Scroll: top, more below
navigation "Main navigation"
link "Personas" → / [ref=e1]
link "API Keys" → /api-keys [current] [ref=e2]
link "Sessions" → /sessions [ref=e3]
main
h1 "API Keys"
button "New Key" [ref=e4]
searchbox "Search API keys..." [empty] [ref=e5]
table: columns Key ID | Label | Created | Actions; 9 rows
button "1675c7a…3cd2" [ref=e6]
button "Open actions for production" [collapsed, opens menu] [ref=e7]
… 6 more similar items
It's built for an assistant inside your own product, looking at a real user's screen, so it follows a few rules a testing snapshot doesn't need:
- It never reads field values, passwords or contenteditable text. Email addresses become
[email]. - With a modal open, only the modal is listed.
- Repeats and tables are summarised, and the whole outline fits a character budget.
- It runs in the page with no CDP, extension, server or dependencies, in a few milliseconds.
- A
skipselector keeps your agent's own panel out. - Refs stay stable between captures, so the agent can point at a control after the page changes.
Install
npm install @smidge/sightline
Sightline is an ES module with TypeScript types and no runtime dependencies. It needs a DOM, so call it in the browser, in the same page as the UI it describes.
Quick start
Create one Sightline per page and capture an outline whenever the user asks your assistant something. Keep the instance around: it remembers which element has which ref.
import { createSightline } from '@smidge/sightline';
const sightline = createSightline({
skip: '[data-agent-panel]', // never describe the agent's own UI
viewport: true, // mark [offscreen] controls, add a Scroll: line
});
// Send with each message to your LLM.
const outline = sightline.capture();
await chat.send({ text: question, pageOutline: outline?.text });
// Let the agent point at things.
sightline.highlight('e5'); // by ref from the outline
sightline.highlight('New Key'); // or by visible name
capture() returns null when the page has
nothing to list, so a failed capture never blocks the message.
Reading an outline
An outline is plain text, one item per line. Indentation shows nesting: controls sit under the landmark, dialog or table that holds them. Each line starts with a role and, when there is one, the accessible name in quotes. Everything after the name is optional.
textbox "Label" [required, empty, invalid] [ref=e1] — "Enter a label."
That line is a text field named "Label". It's required, has nothing
in it and is marked invalid, its ref is e1, and the
text after the dash is the error message it points to with
aria-describedby or aria-errormessage.
Header lines
| Line | Meaning |
|---|---|
Page: /settings/api-keys | The path, plus any query parameters in keepParams (tab by default). Change it with pageAddress. |
Title: "API keys · Acme" | The document title. |
Viewport: 1440px wide | The window width, so the agent knows whether it's looking at a phone layout. |
Scroll: top, more below | Where the page is scrolled. Only with viewport: true. |
Turn all of these off with header: false.
Controls
| Part | Example | Meaning |
|---|---|---|
| Role | button, link, textbox, switch, combobox, tab | From the element or its role attribute. A file input reads as file upload. |
| Name | button "New key" | The accessible name: aria-labelledby, aria-label, a <label>, the element's own text, then title or placeholder. |
| Nearby text | switch near "Email alerts" | No accessible name, so Sightline quotes the text in the same row, as a sighted user would read it. |
| Hint | button (unlabelled, data-testid save) | No name and no nearby text. The hintAttribute is quoted instead. |
| Value | combobox "Expires" = "In 30 days" | The chosen option of a select, or a slider's value. Never anything typed. |
| Destination | link "Docs" → /docs | Where a link goes: a path on the same site, host and path on another. |
| States | [current] [checked] [on] [collapsed, opens menu] | See the list below. |
| Ref | [ref=e12] | A handle for find and highlight. On by default in createSightline. |
| Description | — "Enter a label." | Text from aria-describedby or aria-errormessage. |
| Guess | (guessed from icon) | A name inferred by infer, and where it came from. |
States, in the order they appear: checked,
unchecked, mixed, on and
off for switches, selected,
current, pressed, expanded
or collapsed, opens menu (or
opens dialog, opens listbox and so on),
disabled, required, empty,
invalid and offscreen.
Structure
| Line | Meaning |
|---|---|
h2 "Billing" | A heading and its level. |
main, navigation "Main", form "New key", region "Usage", complementary | Landmarks. Their contents are indented under them. Empty ones are dropped. |
dialog "Delete key?" (open) | An open dialog. When it's modal, the page behind it isn't listed. |
tabs | A tab list, one tab per line with refs on. |
table: columns Name | Key | Created; 9 rows | A table as its column headers and row count. Controls inside rows are listed under it. |
… 6 more similar items | A run of similar items, shortened to the first three (repeatKeep) once it reaches eight (repeatMin). |
alert "Payment failed.", status "Saved", toast "Key copied" | Messages from alert and status roles, short live regions and Sonner toasts. |
label "Plan", legend "Billing address" | Labels and legends that don't belong to a single field. |
log "Conversation" | A role="log" region. Only its name is listed: chat logs hold what people said. |
… (outline truncated) | The outline reached maxChars or maxElements. |
Paragraph text isn't listed on its own. It reaches the outline when it names something: a button's text, a field's label, a dialog's description, an error message.
Sending it to a model
Send a fresh outline with every user message, and tell the model what it is. Refs give the model a way to name a control exactly, so ask it to use them, then point at what it named:
const outline = sightline.capture();
const system = [
'You are the assistant inside Acme.',
'Each message includes an outline of the screen the user is looking at.',
'Answer from the outline. To point at a control, include its ref, like [ref=e12].',
].join('\n');
const reply = await model.respond({
system,
message: `${question}\n\n<screen>\n${outline?.text ?? 'No outline available.'}\n</screen>`,
});
for (const [, ref] of reply.matchAll(/\[ref=(e\d+)\]/g)) sightline.highlight(ref);
A few things that help:
-
Keep the default budget. 6,000 characters is about 1,500 tokens,
and most app pages fit. Raise
maxCharsfor long documents rather than dropping it. -
Turn on
viewportif the agent gives directions. It can then say "scroll down to Webhooks" for an[offscreen]control. -
For a live agent, send the
watchdiff instead of the whole outline each time.change.diff.addedholds just the new lines.
createSightline
createSightline(options?: SightlineOptions): Sightline
Takes every outline option, plus
document to read a document other than the current
one (a same-origin iframe, for example). Refs are on by default.
The returned object keeps element refs stable across captures.
capture(overrides?)
capture(overrides?: Partial<OutlineOptions>): Outline | null
Outlines the page now. overrides change options for
this capture only, such as { infer: true }. Returns
null when nothing useful was found.
type Outline = {
text: string; // the outline
lines: string[]; // the same, one entry per line
refs: Map<string, Element>; // e1 → element, for this capture
stats: {
chars: number;
lines: number;
visited: number; // elements walked
controls: number;
truncated: boolean;
ms: number;
};
};
resolve(ref)
resolve(ref: string): Element | null
The element behind a ref from an earlier capture, or
null once it has left the page. Accepts
e12 or ref=e12.
find(target, role?)
find(target: string, role?: string | null): Element | null
A visible control by ref or by the name the outline shows. An exact
name beats a partial one. Pass role to narrow it:
find('Save', 'button').
highlight(target, options?)
highlight(target: string | Element, options?: HighlightOptions & { role?: string | null }): boolean
Scrolls a control into view and draws a pulsing ring over it, so
the user can see what the agent means. The ring sits above the
page, ignores the pointer and is hidden from assistive technology;
the element itself isn't changed. Returns false when
nothing matched.
| Option | Default | Purpose |
|---|---|---|
durationMs | 2,400 | How long the ring stays |
pulseMs | 800 | Length of one pulse |
color | #2f6bff | Ring colour |
scroll | true | Scroll the element into view first |
reducedMotion | the user's setting | Skip the pulse and the smooth scroll |
watch(onChange, options?)
watch(onChange: (change: PageChange) => void, options?: WatchOptions): () => void
Reports the page each time it settles into a new state. Returns a function that stops watching. See Watching a live page.
track(onInteraction, options?)
track(onInteraction: (interaction: Interaction & { ref: string }) => void, options?: TrackOptions): () => void
Reports clicks, toggles, chosen options, focus moving into a field and fields being filled or cleared, each with the control's ref. Returns a function that stops tracking. See Tracking the user.
Options
createSightline and outline take the same
options.
| Option | Default | Purpose |
|---|---|---|
skip | none | Selector for subtrees to leave out, such as your agent's panel |
maxChars | 6,000 | Character budget. The outline ends with … (outline truncated) |
maxElements | 20,000 | Element budget for one walk |
refs | true in createSightline, else false | Add [ref=eN] to controls, one control per line |
viewport | false | Mark [offscreen] controls and add a Scroll: line |
header | true | Add the Page:, Title: and Viewport: lines |
keepParams | ['tab'] | Query parameters kept on the Page: line |
pageAddress | path and kept params | Function that writes the Page: line |
location | document.location | The address to report, for iframes and tests |
keepEmail | none | Email addresses matching this pattern are kept |
redact | none | Extra redaction for every name and text excerpt |
hintAttribute | data-testid | Quoted for a control with no name and no nearby text |
repeatMin / repeatKeep | 8 / 3 | When a run of similar items is shortened, and how many are kept |
infer | false | Also read pages built without semantic HTML (see below) |
Without refs, navigation and tabs collapse onto one line each:
navigation "Settings": Profile · API keys [current] · Billing main h1 "API keys" button "New key"
Watching a live page
A live agent, such as a voice or avatar assistant, needs to know
when the screen changes without polling. watch calls
back once with the initial page, then each time the page settles
into a new state.
const stop = sightline.watch((change) => {
if (change.kind === 'update') return; // only navigation, dialogs and alerts
agent.addContext(`[screen] ${change.kind} ${change.path}\n${change.outline.text}`);
});
| Field | Meaning |
|---|---|
kind | initial, navigation, dialog-opened, dialog-closed, alert or update |
path | The path and query when the change settled |
outline / previous | The outline now, and the last one reported |
diff | { added, removed }: lines that appeared and disappeared |
dialogs | Names of the dialogs open now |
alerts | Alert, toast and status texts that appeared with this change |
It captures at most once every 150 ms while the page is busy. It
reports a change only after the page has held still for 250 ms, or
700 ms after the address changes so the new page can load. A poll
every 1.5 s catches changes that no mutation reports, and mutations
inside skip are ignored.
| Option | Default | Purpose |
|---|---|---|
settleMs | 250 | How long the page must hold still |
navigationSettleMs | 700 | The same, after the address changed |
throttleMs | 150 | Minimum gap between captures while busy |
pollMs | 1,500 | Safety poll for changes no mutation reports |
updates | true | Report plain updates, not only navigation, dialogs and alerts |
initial | true | Report the page as it is when watching starts |
path | path and query | Function that returns the current address |
Tracking the user
track tells the agent what the user did, by the
control's role and name, in the same words as the outline. It never
reports what was typed: an edit is only filled or
empty.
import { describeInteraction } from '@smidge/sightline';
const stop = sightline.track((interaction) => {
agent.addContext(`[user] ${describeInteraction(interaction)} (${interaction.ref})`);
});
| Type | describeInteraction says |
|---|---|
click | clicked button "New key" |
toggle | turned switch "Email alerts" on |
select | chose "In 30 days" in combobox "Expires" |
focus | moved into textbox "Label" |
edit | filled in textbox "Label" or cleared textbox "Label" |
submit | submitted button "Create key" |
Each interaction also carries role, name,
state (for toggles and edits), option
(for selects), hint (the hintAttribute
value), element and at, a timestamp.
Repeats of the same interaction within 300 ms
(dedupeMs) are dropped. With infer, clicks
on the clickable elements it finds are reported too.
Pages without semantic HTML
Many sites build buttons from plain divs with a click handler, leave
icon buttons unnamed, and fake headings and modals with styling.
With infer: true, Sightline reads those as a sighted
user would. Everything it infers is marked as a guess.
textbox near "× Summer sale Email Add to bag" [empty] [ref=e1]
infer: trueclickable "shopping bag" (guessed from image file name) [ref=e1] clickable "close" (guessed from ×) [ref=e2] h2 "Summer sale" textbox near "Email" [empty] [ref=e3] clickable "Add to bag" [ref=e4]
-
An element that reacts to clicks without a button or link role is
listed as
clickable. Sightline counts anonclickattribute or property, React (__reactProps$…) and Vue click props, jQuery handlers, and the element where a pointer cursor starts. It leaves out a wrapper around a real link or button, a container holding form fields, anything covering half the screen, and a clickable with nothing to call it. -
A control with no name gets a guess, marked with its source, such
as
button "close" (guessed from icon). The guess comes from a tooltip attribute, the icon inside (fa-trash,lucide-x,<use href="#icon-cart">, Material UI'sdata-testid="DeleteIcon"), an image file name, a test id,name,idor the class. A guess from a file name, id or class is kept only if it contains a word that says what the control does. A field takes the short text just before it, and a control showing only×or☰reads ascloseormenu. - Short text set noticeably larger and bolder than the body text becomes a heading. Prices are left out.
-
A fixed element over the centre of the viewport that covers a
fifth of it, holds something to click and isn't the whole app
counts as an open dialog. Only the dialog is shown, and
watchreports it opening and closing. - Open shadow roots, their slots and same-origin frames are read. A frame from another site is listed by its title.
find, highlight, track and
describeElement use the same names, so an agent can
ask to click "close" when the page shows ×. Inference
costs a computed-style read per element, a few milliseconds on a
busy page. Listeners added with addEventListener are
invisible to page script, so a div with one and no pointer cursor
stays unlisted.
Privacy
Sightline is meant to run on real users' screens, so it reads less than a testing tool would:
- Text fields show
[empty]or nothing. Their values, passwords and contenteditable text never appear, in outlines or intrackevents. - A select shows its chosen option, since that is a fixed choice the page offered, not typed text.
- Email addresses in names and text become
[email]. PasskeepEmailto keep, say, your support address. redactruns on every name and text excerpt, for anything else you don't want to leave the page, such as account numbers.- Chat logs (
role="log") are listed by name only.
createSightline({
keepEmail: /@acme\.com$/,
redact: (text) => text.replace(/\b\d{8,}\b/g, '[number]'),
});
Sightline sends nothing anywhere. What leaves the page is whatever your code sends to your model.
Lower-level functions
createSightline is built from these, and each is
exported for custom setups.
| Function | Purpose |
|---|---|
outline(doc?, options?, registry?) | One outline. Pass a RefRegistry to keep refs stable across calls. |
findControl(name, options?, doc?) | A visible control by the name the outline shows. |
describeElement(target, options?, doc?) | The control an event landed on, as { role, name, hint, element }. |
watchPage(doc, capture, onChange, options?) | The settling loop behind watch, with your own capture function. |
trackInteractions(doc, onInteraction, options?) | The listeners behind track, without refs. |
describeInteraction(interaction) | A short sentence such as clicked button "New key". |
diffLines(before, after) | Lines added and removed between two outlines, counting repeats. |
highlight(element, options?) | The ring, for an element you already have. Returns a function that removes it. |
RefRegistry | Stable eN refs for one document. |
DEFAULT_MAX_CHARS | 6,000. |
Performance
Measured in Chromium 143 on generated pages (nav, labelled forms, a
large table, card grids and a dialog), with refs and
viewport on. Each figure is the median of 20 runs
after 3 warm-ups (pnpm bench):
| Elements | Median | p95 | Output |
|---|---|---|---|
| 515 | 0.9 ms | 1.1 ms | 5,971 chars |
| 2,015 | 0.8 ms | 0.9 ms | 4,661 chars |
| 10,015 | 0.9 ms | 1.3 ms | 4,661 chars |
| 50,015 | 1.7 ms | 1.8 ms | 4,662 chars |
Long runs of similar items are counted and skipped, not walked, so cost follows how varied a page is rather than how big it is. With no character cap at all, the 50,000-element page still took 1.7 ms. On real Anam Lab pages an outline took under 6 ms, including the first, unwarmed capture.
With infer on, measured on live sites:
| Page | Controls | Unnamed | Time |
|---|---|---|---|
| Deque's Mars Commuter (built inaccessible) | 103 → 111 | 7 → 3 | 5.5 → 7.0 ms |
| University of Washington "before" demo | 25 → 27 | 1 → 0 | 1.7 → 3.8 ms |
| TikTok Explore | 23 → 28 | 6 → 6 | 1.7 → 4.5 ms |
| AliExpress home | 80 → 82 | 0 → 0 | 2.8 → 4.2 ms |
| BBC home (accessible) | 68 → 68 | 0 → 0 | 5.1 → 6.8 ms |
On an accessible page, inference adds nothing but time.
Accessible names are the contract
Sightline sees what a screen reader sees. An icon button with no
aria-label reaches the agent as
button near "…", named from the text beside it, or
button (unlabelled). Naming your controls properly
makes the agent accurate, and it's good accessibility practice
anyway. For pages you don't control, infer fills some
of the gaps with marked guesses.
To see what your own app looks like to an agent, run the bookmarklet on it, or try a public page in the demo.
Development
pnpm install
pnpm test # Vitest + happy-dom
pnpm bench # Chromium benchmark via Playwright (needs a local Chromium)
pnpm build # emits dist/
pnpm site # builds this site into _site/ and serves it on :4173
Source, issues and releases are on GitHub. Sightline is MIT licensed.