Pretext Lab
19DOM target
Arc 7 · Rendering Targets

DOM target

The previous arc taught the walker. This arc asks the consumer's question: once Pretext has given you lines and positions, where do you put them? The first and friendliest answer: the ordinary DOM. A paragraph becomes a stack of block-level spans, one per wrapped line — and nothing else changes about the page.

Drag the width. The lines re-break live. Try to select a phrase and copy it; try tabbing into the region with a screen reader. The text is real text — crawlable, selectable, accessible — and the reflow itself touches zero measurement APIs.

selection works · SEO works · zero DOM reads
420

Mechanism

At boot we call prepareWithSegments(text, font) — the rich variant that keeps the segment array around so we can materialize line strings later. On each width change we call layoutWithLines(prepared, width, lineHeightPx), which returns { lineCount, height, lines }. Each line carries its .text, its measured .width, and start/end cursors back into the prepared segments.

We then stamp one <span style="display:block"> per line, setting .textContent to the line's text. Because the spans are block-level in source order, the browser's native selection model does exactly what it has always done — click-drag selects text top to bottom; Cmd+A grabs the paragraph; screen readers announce lines in order. We set the container's min-height from Pretext's returned height, so the box is pre-sized before paint. No getBoundingClientRect, no offsetHeight, no reflow flush.

The handle is width-independent: the same prepared object services every width the slider can produce. The work done on each drag is: one layoutWithLines call plus a .textContent assignment per line. For a paragraph of this size it rounds to zero.

Application

This is the default target for "render via Pretext." The spans are:

What you get in return for the small ceremony of spanifying: you can now per-line style, per-line animate, per-line decorate — drop-caps, hanging indents, line numbers, line-level ripple reveals — without blowing up the flow, because each line is its own box.

"I went to the woods because I wished to live deliberately, to front only the essential facts of life, and see if I could not learn what it had to teach, and not, when I came to die, discover that I had not lived. I did not wish to live what was not life, living is so dear; nor did I wish to practise resignation, unless it was quite necessary."

Henry David Thoreau, Walden (1854)

Direct Claude

"SEO-friendly Pretext text" per-line spans via DOM target; layoutWithLines + textContent "still selectable, never measured" render only — don't read back with offsetHeight or getBoundingClientRect "per-line decoration on live reflow" one <span style="display:block"> per lines[i]; style each independently "zero reflow cost on resize" set minHeight from Pretext's returned height before paint
Next: the same walker feeds a canvas. Selection goes away, but 60 fps motion arrives.
prepareWithSegments · layoutWithLines · per-line spans
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

const FONT = "400 20px 'EB Garamond', serif";
const LINE_HEIGHT_PX = 20 * 1.55;

// Phase 1: analyze + fit prep — run once.
const prepared = prepareWithSegments(text, FONT);

// Phase 2: render — stamp block-level spans. Selectable. SEO-visible.
function renderAt(width) {
  const { lines, height } = layoutWithLines(prepared, width, LINE_HEIGHT_PX);
  textRegion.style.minHeight = height + 'px';  // pre-size; no DOM read.
  lineStack.textContent = '';
  for (const line of lines) {
    const span = document.createElement('span');
    span.className = 'pt-line';
    span.textContent = line.text;   // real text; the DOM owns it.
    lineStack.appendChild(span);
  }
}

widthSlider.addEventListener('input', e => {
  renderAt(parseFloat(e.target.value));
});