Read a whole document: element children, a provider, and Player - #5
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4
<SpokenText>took astring, so a real page had to be cut into one componentper paragraph, each wired by hand, and nothing read continuously across them.
It now takes the elements themselves and reads the lot.
Scope
walkDocumentwalks the tree it is handed, wraps only text, and rebuildsthe same structure: an
h2stays anh2, a link stays a link. Astringchild renders byte for byte what it rendered before — asserted against the
old markup in a test.
paragraph, list item and blockquote is its own alignment request.
currentWordIndexis one index across the document;segments: { start, end, status }[]says where each block begins and ends. Words ina block that has not landed are
future, untimed andseekable: false.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.
durationcounts unloaded blocks at areading pace and
durationIsEstimatesays so.<Player>shows the totaldimmed with a tilde —
~1:47— until every block has landed.<SpokenTextProvider>and context wiring. The provider holds thecontroller;
<SpokenText>registers what it says and<Player>drives it,so the player can sit in a sticky header. Either still works alone, and
useSpokenTextstays the headless primitive.skip,only,data-spoken-skip, and defaults. Bothprops take a tag, a
.class, an[attr]or a predicate over the Reactelement.
onlyfences the field,skipcuts inside it. Skipped contentstill 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.with the following word timed to the second, a block skip,
only, blockboundaries 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.
/articlereads a short piece about sourdough starters: a provider, asticky
<Player>, apreblock and an inline1:5:5that render but arenot read. README usage and prop tables updated; changeset is a minor bump
to
0.2.0.Breaking
<Transport>is<Player>, andTransportProps/TransportClassNamesarePlayerProps/PlayerClassNames. No alias and no shim: nothing depends on thepackage yet.
Two judgement calls the issue did not cover
timestamp, or its block has not been fetched. The issue asks for both
seekable: falsein an unloaded block and for a click there to fetch it, soseekablekeeps its documented meaning ("this word has a timestamp") and theclick handler is decided separately. In the loaded state the markup is
unchanged; before a block lands, its words now carry
cursor: pointer.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 testandpnpm typecheckare all clean. Thedemo's Vercel build needs no configuration change:
/articleis an ordinarystatic route.
docs/demo.gifis deliberately not re-recorded — that comes after review.🤖 Generated with Claude Code
https://claude.ai/code/session_01EsXEwJ2uf1TnHXhuwfg1Eb