ADR-015 — The reading position is a place in the text
Status: Accepted · Date: 2026-09-07 · Implements ADR-007 · Extends ADR-013
Context
A reader reached chapter two, closed the app, came back, and was in the middle of chapter one. Reported at least three times. Patched sixteen.
The mobile reader appends chapters into one document as you scroll; the URL never leaves the chapter you opened. Typography was an input to that document, so changing a font size rebuilt the WebView from the route chapter, and the restore then re-applied the reader’s fraction of the chapter they were in. Measured on the shipped code: 55% of chapter two became 74% of chapter one, and two seconds later the debounced save wrote it — locally and on the server. A pixel offset cannot be un-overwritten.
That was the trigger. The cause was older.
ADR-007, accepted 2026-01-19, decided:
The system stores a logical position in the text, not visual coordinates.
ReaderPosition { book_id, chapter_id, paragraph_index, offset_in_paragraph, progress_percent }The following are explicitly not used: scrollY, page numbers, viewport-based coordinates.
Its acceptance criteria include “Font changes do not break progress”.
On 2026-05-17, commit 30698d5e — “autosave + restore pixel-accurate scroll position” —
introduced scroll:<slug>:<pixelOffset>. Everything since has defended pixels. Sixteen commits
across web and mobile, seven of them in six days at the end of August, each fixing a real defect and
each believing a different cause: never read back → percent too coarse → duplication → async race →
no report on navigation → a DB write race → document vs chapter coordinates → unit ambiguity →
unprompted document rebuild → stale screen read → the row contradicts itself → the writer is
ungated → injection is not arrival → the rule was applied in one place.
None of them was “the position is stored in pixels”. No test anywhere asserted ADR-007’s criterion.
Decision
1. A position is a chapter, an anchor, an offset hint and a fraction
TextPosition { v: 1 chapterSlug: string // identity — NOT chapterId anchor: TextAnchor // prefix / exact(64) / suffix, 30 chars of context charOffset: number // hint, verified, never trusted chapterFraction: number // coarse fallback + input to computeBookProgress}packages/shared/src/reader/textPosition.ts. Which is to say: a reading position is a highlight
without a colour, resolved by the resolver highlights have used since it was written. That
module’s own docstring named this case years before it was used for it — “A highlight, a bookmark
or a reading position cannot be stored as a pixel offset or a character index.”
Not chapterId. Re-ingestion deletes and recreates every chapter (IngestionService), so the
Guids change. The slug is regenerated from the title and survives while the title does. ADR-013
reached the same conclusion from the other side: the locator is the position, and everything derived
from a chapter id can lag.
Not paragraph_index, which ADR-007 proposed. The reason is specific and worth stating, because
“it is unstable” would be wrong: the ordinal index of a <p> within a chapter survives a font
change perfectly — it is a DOM fact, not a text fact, and it is cheaper than an anchor. What kills
it is re-ingestion regenerating the HTML, and the two clients sanitising it differently. A text
anchor survives both, and one already existed.
2. An additive column, not a third locator space
reading_progresses.position_json, user_books.progress_position_json, both jsonb NULL, beside a
locator that new clients keep writing in the old scroll:<slug>:<offset> form.
text:<slug>:<charOffset> was the obvious shape and is a trap. ADR-013 §2 rule 3 — same space,
accept, declared or not — is the entire compatibility story for installed builds. Introduce a third
space and the first new client to write it turns every old client’s scroll: write into an
undeclared cross-space write, refused by rule 5: a phone that updated would silently stop a
desktop that had not from saving anything. On the catalog path, which MayReplace does not guard
at all, it is worse — the old client’s write lands, and the old client cannot parse what it
overwrote, so it resumes at the top of the chapter. That is the defect being fixed, inflicted on
every installed build.
LocatorSpaceTests asserts that Derive("text:…") is still null. The assertion is the point.
3. A row never holds two positions that disagree
The whole server-side job, in Application/ReadingTracking/ReaderPosition.cs:
- No position on the write → NULL. Not “keep what was there”. An old build writes the locator alone, and its pixel offset is then the only true statement on the row.
- Accepted locator not in scroll space → NULL. A PDF’s page is its logical position; mark-as-read has no position at all.
- Oversized → NULL, and only that. Unlike a foreign coordinate space, size says nothing about whether the rest of the snapshot is trustworthy.
- A refused write changes nothing, position included.
No backfill, and none is possible: a pixel offset cannot name text. Positions accumulate as people read.
4. The pixel offset stays, demoted
locator is still written, still parsed, still the fallback when an anchor resolves to nothing — an
image-heavy chapter can move text further than any anchor reaches. It is no longer the source of
truth. This ADR retires a meaning, not a column.
5. Typography is injected, not rebuilt
readerDocumentKey no longer contains font size, line height, alignment or the serif/sans family;
they are applied to the live document, as padding and colour already were since #467. What remains a
document input is the OpenDyslexic @font-face — 150KB of inlined base64 that is emitted only when
selected, and putting it in every document to save a rare rebuild is the wrong trade.
A live reflow invalidates every recorded chapter top, so recomputeChapterTops() is not polish:
without it currentChapterBounds names the wrong chapter and reportProgress posts that slug —
the same corruption by a different route.
Consequences
- Position survives a font change, a rotation, a re-parse and a different device. Verified in a
browser: captured at 62% of a chapter at 18px/1.65/left, restored into a document rebuilt at
25px/2.0/justified, same paragraph,
scrollY5686 → 11951. - Old builds are unaffected: they read and write the same
locatorin the same space. - A shared row loses anchor precision while an old device is still writing to it. Bounded, and correct — that device really does only know a pixel.
- The read is behind a per-device flag (
textstack.readerTextPosition); the write never is, so a device switched off keeps accumulating positions for when it is switched back.
Considered and declined: making the route follow the reader
The URL names one chapter while the document may contain several, and that split is where #496,
#500, #501 and the defect above all lived. The obvious fix is to router.replace as the reader
crosses a boundary, so the question “which chapter is this?” has one answer.
Declined, for now, on a cost that changed underneath it. chapterSlug is the reset key of
useReaderPersistence — it closes the write gate, clears restoredRef and issues a restore — and
it feeds readerDocumentKey through the chapter fetch. Making the route follow the reader naively
re-restores the reader backwards at every boundary and rebuilds the document each time. Doing it
properly means splitting one prop into route chapter and reader chapter across the two most
incident-prone files in the reader.
That was worth it while the position was a pixel, because the disagreement caused data loss. It
is not, now that it is an anchor: an anchor names its own chapter, so a position can no longer be
applied to the wrong one — resolveTextPosition returns null rather than a plausible-looking place,
and the caller changes route instead. What remains is the URL being cosmetically untrue on a client
with no address bar.
apps/mobile/src/lib/readerWriteTarget.test.ts pins the property the surgery would have made
structural: the write path reads the visible chapter, the restore path reads the document’s own. If
those assertions start failing, this reasoning no longer holds.
Addendum 2026-10-03 — the document holds one chapter
The split is gone the other way round: instead of the route following the reader, the reader no
longer leaves the route. The mobile reader stopped appending chapters (useReaderInfiniteScroll,
the sentinel, appendChapter and the multi-chapter registry in readerHtml.ts are deleted); the
reader moves on with an end-of-chapter block, as the web reader has since #161. The document keeps
exactly one data-chapter-slug element, and anchors stay scoped to it. onDocumentRebuild keeps only
its gate-shut (a same-chapter rebuild still loads at zero); the “rebuilt into another chapter →
navigate” branch and latchChapterEnd’s visible ≠ opened case went with the appending.
Why now, when the section above declined the surgery: the cost was never the appending itself but
the second answer it created, and a device audit found three live bugs that existed only because of
it — highlights in an appended chapter saved under the opened one, the toolbar chevrons and
“Discuss” pointing at the opened chapter. Removing the appending removes the question rather than
answering it twice. readerWriteTarget.test.ts now pins that it cannot come back.
Enforced by
packages/shared/src/reader/textPosition.ts+.test.ts— the model and its resolution ladderapps/mobile/src/lib/readerPositionScript.test.ts— the shipped WebView functions, run against a fake layout, including the regression this ADR is named afterapps/mobile/src/lib/readerChrome.test.ts— typography is absent from the document keyapps/mobile/src/lib/readerWriteTarget.test.ts— the write follows the readertests/TextStack.UnitTests/UpsertProgressTests.cs,UserBookProgressServiceTests.cs— the no-two-disagreeing-positions invariant on both pathstests/TextStack.UnitTests/LocatorSpaceTests.cs—text:is still not a spaceapps/web/e2e/tests/reader-smoke.spec.ts(“a font size change keeps the same paragraph”) — ADR-007’s acceptance criterion, at last (moved there fromreader-progress.spec.tsin #774)
See also
- ADR-007 — decided this in January 2026. This ADR implements it, eight months and sixteen
commits later, and replaces its
paragraph_indexwith a text anchor for the reason in §1. - ADR-013 — the coordinate-space rules this had to fit inside, and the reason it is a column.
- ADR-011 — the mobile progress architecture the position is written through.