Skip to content

Read a whole document: element children, a provider, and Player - #5

Merged
awlevin merged 6 commits into
mainfrom
document-reading
Sep 3, 2026
Merged

Read a whole document: element children, a provider, and Player#5
awlevin merged 6 commits into
mainfrom
document-reading

Conversation

@awlevin

@awlevin awlevin commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Closes #4

<SpokenText> took a string, so a real page had to be cut into one component
per paragraph, each wired by hand, and nothing read continuously across them.
It now takes the elements themselves and reads the lot.

Scope

  • Children walk: collect text leaves, preserve element structure, wrap words.
    walkDocument walks the tree it is handed, wraps only text, and rebuilds
    the same structure: an h2 stays an h2, a link stays a link. A string
    child renders byte for byte what it rendered before — asserted against the
    old markup in a test.
  • Block-level segmentation and a segment-aware controller. Each heading,
    paragraph, list item and blockquote is its own alignment request.
    currentWordIndex is one index across the document; segments: { start, end, status }[] says where each block begins and ends. Words in
    a block that has not landed are future, untimed and seekable: false.
  • Lazy fetch with next-segment prefetch. The first block is fetched on
    mount so the play button is live; the next is warmed while the current one
    plays; clicking a word in a block nobody has asked for fetches it and
    plays from that word.
  • Estimate-and-correct duration. duration counts unloaded blocks at a
    reading pace and durationIsEstimate says so. <Player> shows the total
    dimmed with a tilde — ~1:47 — until every block has landed.
  • <SpokenTextProvider> and context wiring. The provider holds the
    controller; <SpokenText> registers what it says and <Player> drives it,
    so the player can sit in a sticky header. Either still works alone, and
    useSpokenText stays the headless primitive.
  • Skip mechanism: skip, only, data-spoken-skip, and defaults. Both
    props take a tag, a .class, an [attr] or a predicate over the React
    element. only fences the field, skip cuts inside it. Skipped content
    still renders where it was written and is dropped only from the text handed
    to the aligner, so the words either side of an inline <code> stay timed.
    Headings are read; skip={["h1","h2","h3"]} is the opt-out.
  • Tests. Nested elements, a link inside a sentence, an inline code skip
    with the following word timed to the second, a block skip, only, block
    boundaries and global index offsets, an estimate correcting itself as the
    second block lands, provider wiring, a click in an unfetched block, and the
    string-child path rendering identically to today. 50 pass.
  • Docs and a demo built from a realistic multi-heading article.
    /article reads a short piece about sourdough starters: a provider, a
    sticky <Player>, a pre block and an inline 1:5:5 that render but are
    not read. README usage and prop tables updated; changeset is a minor bump
    to 0.2.0.

Breaking

<Transport> is <Player>, and TransportProps / TransportClassNames are
PlayerProps / PlayerClassNames. No alias and no shim: nothing depends on the
package yet.

Two judgement calls the issue did not cover

  • A word is clickable whenever clicking it does something — it has a
    timestamp, or its block has not been fetched. The issue asks for both
    seekable: false in an unloaded block and for a click there to fetch it, so
    seekable keeps its documented meaning ("this word has a timestamp") and the
    click handler is decided separately. In the loaded state the markup is
    unchanged; before a block lands, its words now carry cursor: pointer.
  • A block that fails is not retried on its own. Found while looking at the
    page in a browser with no API key: the error re-ran the fetch effect, which
    hammered the route in a loop. It now stays failed until the reader presses
    play or clicks a word, which is a fresh attempt. Covered by a test.

Checks

pnpm build, pnpm lint, pnpm test and pnpm typecheck are all clean. The
demo's Vercel build needs no configuration change: /article is an ordinary
static route.

docs/demo.gif is deliberately not re-recorded — that comes after review.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EsXEwJ2uf1TnHXhuwfg1Eb

awlevin and others added 5 commits September 2, 2026 16:21
A passage was one string and one request. A page is not: it is a heading,
some paragraphs, a list, and nothing should wait on one enormous fetch.

`walkDocument` turns a tree of elements into the text to speak and the tree
to render, segmenting at block boundaries and excising anything `skip` or
`only` says is not to be read. `useSpokenDocument` drives the result: one
global word index, one playhead, one duration. Each block aligns on its own,
the first is fetched on mount, the next is warmed while the current one
plays, and `duration` counts the rest at a reading pace until they land.

`useSpokenText` is now the one-block case of that, so a string behaves
exactly as it did and the second copy of the playback code is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsXEwJ2uf1TnHXhuwfg1Eb
`<SpokenText>` accepts a tree as well as a string. `skip` and `only` say what
is read, taking a tag, a `.class`, an `[attr]` or a predicate, with
`data-spoken` and `data-spoken-skip` for the point of authorship. Skipped
content still renders where it was written; it is only cut from the text the
aligner sees, so the words either side of an inline `<code>` stay on time.

`<SpokenTextProvider>` holds the controller, so the play button no longer has
to be a sibling of the text. `<SpokenText>` registers what it says, `<Player>`
drives it, and either still works alone.

`<Transport>` is `<Player>`. It is a play button and a scrubber, not a DAW.
Nobody depends on the package yet, so there is no alias.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsXEwJ2uf1TnHXhuwfg1Eb
The alignment tests never needed a DOM. These do: a rendered tree, a
provider, and a fake aligner standing in for the route. jsdom has no media
pipeline, so `play`, `pause` and `load` are stood in for as well.

What is checked is what would go wrong: nested elements, a link inside a
sentence, an inline code span cut out with the following word still timed to
the second, a skipped block, `only`, block boundaries and the global index
offsets across them, an estimated duration correcting itself as the second
block lands, a click in a block nobody has fetched, a failed block that is
not asked for again on its own, and a string child rendering byte for byte
what it rendered before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsXEwJ2uf1TnHXhuwfg1Eb
A new page reads a short piece about sourdough starters end to end: one
provider, one `<SpokenText>` around the article, and a `<Player>` in a sticky
bar above it. The `pre` block and the `1:5:5` inside a sentence are both left
unspoken by the default skip list, and both still render where they were
written, which is the part worth seeing.

The front page gains a section for it and the API tables gain `skip`, `only`,
`segments`, `durationIsEstimate` and the provider. The live example now
imports `Player`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsXEwJ2uf1TnHXhuwfg1Eb
Usage for a tree, for the provider, and for leaving things unspoken; prop
tables for `<SpokenText>`, `<Player>` and the provider; `segments` and
`durationIsEstimate` in the controller table. The component boundary is
stated once, where the other limitations are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsXEwJ2uf1TnHXhuwfg1Eb
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
spoken-text Ready Ready Preview Sep 2, 2026 10:24pm UTC

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@awlevin
awlevin merged commit e49327f into main Sep 3, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Read a whole document: element children and a provider

1 participant