Reader improvements from issue #15 - #16
Merged
Merged
Conversation
The word was centered between the top and bottom bars, which keep their layout space while faded out during playback. The bars differ in height (top ~140pt; bottom ~106pt, or ~166pt with chapter navigation) and the safe-area insets differ top vs bottom, so the word rested ~30pt below screen center for documents without chapters on Dynamic Island iPhones, and at a different height for documents with chapters. ReaderStageLayout pins the bars to the safe-area edges and centers the word at 47% of the full screen/window height (insets included) — the same spot for every document, stationary across play/pause. The word yields only when its slot would overlap a bar (large Dynamic Type, tiny windows), by the minimum distance. The completion card, which only appears with the bars visible, stays centered between them. Issue #15. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pauses previously applied only at sentence ends; commas, colons, semicolons, dashes, ellipses, and closing brackets or quotes paused only through Smart Timing's fixed bonus, so with Smart Timing off they did not pause at all (issue #15). PunctuationMarks.marks(in:) classifies a word's trailing punctuation into sentence end, clause, dash, ellipsis, and bracket, across Latin, CJK, and Arabic marks. Internal marks (3.14, 1,000) and leading marks never pause; an em dash or ellipsis fused inside a token (elements—stone) does. A word with several marks pauses once, for the longest of its types. sentencePauseEnabled and sentencePauseMultiplier keep their keys and meaning. New keys clausePauseMultiplier, dashPauseMultiplier, ellipsisPauseMultiplier, and bracketPauseMultiplier default to 1.3, 1.4, 1.5, and 1.2. While pauses are on, Smart Timing drops its 0.2 trailing-punctuation bonus so a mark is not timed twice; with pauses off it behaves as before. Settings replaces the Sentence Pauses block with Punctuation Pauses: a selector button per type and one slider for the selected type. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A "True Black Background" toggle (off by default) in a new "Reading Display" settings card paints the reading surface pure #000000 so OLED pixels stay unlit in a dark room. ReaderBackdrop is the single view that paints the reading surface's background; it resolves the trueBlackBackgroundEnabled setting through ReaderBackground. The reader, the passage view, the reader's chapter picker, and the settings tone swatches all use it. Library, settings, and tutorial chrome stay on StrobeTheme. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Words joined by an em dash with no surrounding spaces were tokenized as a single word, so "elements—stone," flashed by as one fused unit (issue #15). A dash run inside a token is now a word boundary when it contains an em dash (U+2014) or horizontal bar (U+2015), or is two or more hyphens/en dashes long ("word--word"). The dash stays on the preceding word, along with closing quotes or brackets that directly follow it. A single hyphen or en dash never splits, so compounds and ranges stay whole. Leading and trailing dashes ("—Hello", "wait—”") stay attached to their word. A trailing dash run ending in a hyphen ("wait--") is no longer treated as a line-break hyphen, which previously merged it into the next lowercase word ("wait-- he" → "wait-he"). Pieces cut from the middle of a token are never carried. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A compound such as "wedge-shaped" is one token, so it took a single word slot: exactly one base interval with Smart Timing off, and only the per-letter slowdown with it on, however many words it held (issue #15). CompoundWord.partCount(in:) counts the parts of a token joined by a single hyphen, en dash, or slash. A part counts when it holds two or more letters or digits, so e-mail, x-ray, T-shirt, and stutters stay single words. Edge punctuation and apostrophes belong to their part; leading and trailing joiners, "--", and em dashes join nothing. Each part after the first adds half a base interval, up to four timed parts (+1.5 intervals). RSVPEngine adds this to the word's own time before punctuation pauses and complexity scale it, so Smart Timing's per-letter share is counted once and a pause applies once to the whole compound. The minimum-word-length gate covers only the per-letter slowdown. Compound time applies regardless of the timing toggles and follows the playback speed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two toggles in the "Reading Display" settings card, both off by default: "Title While Reading" and "Chapter While Reading". During playback they show the document title and the current chapter as small, low-contrast lines at the top of the reader; paused, the top bar and the chapter navigation already carry both, so the header fades out on the top bar's timing and the two cross-fade in the same spot. ReaderHeaderView is an overlay on the reader stage, so the word's fixation position is identical whether the header is on or off, and it never takes touches from the hold-to-read layer. The chapter line drops out the moment a chapter announcement takes the word slot and fades back in as the announcement fades out, so the new title is never shown twice. Only the chapter line is exposed to VoiceOver, and only while visible. Header text uses ReaderTextTone.fadedTextColor, a per-tone opacity tuned to roughly 3:1 contrast so it reads equally quiet in every tone and on true black. ChapterTimeline normalizes a document's chapters (titled only, one per word index with the first listed winning as in RSVPEngine, sorted) and finds the chapter containing a word with a binary search. The per-tick currentIndex read lives in its own child view. ChapterNavigationView now uses the same search instead of its linear scan. Chapter is nonisolated so the lookup can be. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A token ending in a single hyphen was merged with any following lowercase
token, so a suspended compound lost its hyphen and fused with the joiner:
"pre- and post-war" tokenized to "preand", "post-war".
A next token that is exactly a suspended-hyphen joiner (and, or, nor, to,
and/or, und, oder) now never merges; the fragment is emitted as its own
word with its hyphen kept ("pre-", "and", "post-war"). The decision uses
the next token alone because EPUB extraction streams one token per
appendTokenizedText call, so the carry type, the final-carry flush, and
the EPUB marker mapping are unchanged.
A joiner carrying punctuation ("to,") still merges, which keeps
"pota-" + "to," whole. A word broken directly before a bare final
"to"/"or"/"and" ("pota-" + "to chips", "oper-" + "and") now stays split;
that case is indistinguishable from a suspended hyphen without a
dictionary and is far rarer.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pin the settings scroll content to the scroll view's width so a fractional overflow in a floating sheet can't enable horizontal panning. Issue #15. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An acronym took one word slot and showed as briefly as "the", though FBI is read as three letter names (issue #15). Acronym.letterCount(in:) counts the letter names in a token. Parts split at hyphens, dashes, and slashes read as letters when capitals outnumber lowercase letters (FBI, PhD, iOS, mRNA) or when they hold no letters (the 19 of COVID-19). A digit run and an ampersand after a capital are one name each, and periods are skipped. A final lowercase s is a plural, not a name. A part with more than six capitals is an ordinary word (CHAPTER). Single letters count for nothing (I, A, J., X-ray). A capital after an apostrophe reads as a contraction, so DON'T, IT'S, and NASA'S add nothing. Greek, Cyrillic, and Armenian count like Latin; other scripts are caseless. Each letter name after the first adds a quarter of a base interval, up to four names: UK +0.25, FBI +0.5, NASA and longer +0.75. RSVPEngine adds this to the word's time next to compound time, so smart timing's per-letter share is counted once and pauses and complexity scale the whole word. It is always on, like compound timing, and follows the playback speed. A word in capitals can't be told from an acronym by its shape. So three or more such words in a row, judged from two words on either side, are a passage in capitals and add nothing: headings, title pages, legal text, telegrams, small-caps openings, shouting. Punctuation doesn't split a passage. The exception is a list, where commas, semicolons, or colons separate every word (PNG, JPEG, GIF); a list stays timed. A lone or paired word in capitals is timed (REST API, PART II, NO!). Judgment calls - Always on, with no setting. This is compound timing's sibling: one more added line in nextInterval. The pending decision on compound timing can cover both, and one toggle could gate both lines. The effect lands only on acronyms. On sample text, total reading time rose 0.0% for fiction and for all-caps legal text, 0.7% for fiction with shouting and caps headings, and 3.3-4.1% for acronym-dense news. - Two-word headings in capitals and small-caps openings (PART II, MR. JONES) are timed; suppressing pairs would also untime REST API. - Three acronyms in a row with no commas (US FDA CDER) read as a passage. An all-caps passage that opens with a comma list times its first one or two items. - Complexity timing scores acronyms 0.44-0.53 with NLTagger, the neutral midpoint, so the two stack without overlap. Common words in capitals score low (NO! 0.17), so complexity cancels the time that capitals alone earned: NO! is back to 200 ms at 300 WPM. - In a compound of acronyms (TCP/IP), the second part's first letter is counted by both rules. The +0.75 cap keeps that small. - Roman numerals are timed like acronyms (XIV +0.5, VIII +0.75). Duration at 300 WPM in ms, before -> after; complexity uses NLTagger scores at the default intensity: word defaults smart pauses complexity the 200 224 200 150 UK 200 -> 250 216 -> 266 200 -> 250 195 -> 244 FBI 200 -> 300 224 -> 324 200 -> 300 199 -> 298 NASA 200 -> 350 232 -> 382 200 -> 350 200 -> 351 UNESCO 200 -> 350 248 -> 398 200 -> 350 203 -> 356 CPUs 200 -> 300 232 -> 332 200 -> 300 196 -> 293 NASA's 200 -> 350 248 -> 398 200 -> 350 203 -> 356 FBI. 200 -> 300 264 -> 364 300 -> 450 199 -> 298 COVID-19 300 -> 450 364 -> 514 300 -> 450 318 -> 478 TCP/IP 300 -> 450 348 -> 498 300 -> 450 298 -> 447 XIV 200 -> 300 224 -> 324 200 -> 300 198 -> 296 SHALL 200 240 200 168 (all-caps text) NO! 200 -> 250 256 -> 306 300 -> 375 160 -> 200 Cost per word (-O, macOS): 7-9 ns for lowercase ASCII, 9-13 ns for lowercase accented Latin, Cyrillic, and Greek, 7-12 ns for CJK and Arabic, 22-24 ns for Capitalized words, 26-38 ns for acronyms, and 93-101 ns inside all-caps prose. For comparison, CompoundWord takes 8-17 ns and PunctuationMarks.marks 71-93 ns. Words without a capital pass one scan of their scalars against a case table. The table is built once from the Unicode categories in about 60 µs. AcronymTimingTests covers letter-name counting, passages and lists in capitals, and how the engine combines acronym time with smart timing, pauses, complexity, compounds, and the playback speed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue #15: "Option to show before and after words in a faded color." A "Previous and Next Words" toggle at the end of the Settings "Reading Display" card (contextWordsEnabled, off by default) shows the previous word above the current word and the next word below it: in the reader font at 0.45x the text size (at least 14pt), in the tone's fadedTextColor, centered on the anchor's column. Nothing shows before the first word or after the last. VoiceOver skips them and they take no touches from the hold-to-read layer. Placement: above and below, not beside. ORP centering pins the anchor letter, so the word's left and right edges move with every word. A word beside it would either jump with those edges, up to 16 times a second in peripheral vision, which pulls the eye off the fixation point, or sit in a fixed slot that long words run into. On a phone in portrait a long word already fills most of the width at the default size. Above and below, the two slots never move, each context word gets the full width, earlier text sits above later text as on a page, and right-to-left scripts need no mirroring. The current word cannot move. ContextWordsView is its own stage subview with a new ReaderStageLayout role, fixationSurround: centered with the word and proposed the room that both bars' layout sizes leave around it. The word's placement code is unchanged. Offscreen renders of 2,016 configurations (393x852 phone with insets, 852x393 landscape, 1024x1366 iPad, 700x500 Mac window; 24, 40, 56 and 72pt; all seven fonts; Latin, accented Latin, Arabic with and without vowel marks, Thai, CJK, first and last words; both bottom bar heights; 10,080 images) are byte-identical between the pre-feature code, the setting off, the context at zero opacity, and the context with transparent text. With the context visible, every changed pixel lies inside the two slots, except stacked Arabic vowel marks and Thai tone marks, which reach at most 2.3pt past a slot, inside the margin before a bar. Tight space: the sizing depends on settings and layout only, never on the words, and the bars keep their layout size when they fade, so nothing moves from word to word or between paused and playing. Where the room between the bars runs short, the context shrinks to a 12pt floor, then hides; it never moves the word. Portrait phones (down to a zoomed iPhone SE) and iPads show it at full size at every text size. A 700x500 Mac window shrinks it from 36pt and hides it from 42pt. With today's bars, iPhone landscape shows it up to 36pt and hides it when there is a chapter bar; the landscape work can reclaim room from the bars. The slot is 1.5x the context size: every reader font's line box plus the dots under Arabic letters. No animation between words: the per-tick reads live in a small child view that disables animation for its subtree. A scrub that pauses playback delivers the new words inside the stage's play/pause fade, and without that guard the context cross-faded there. The context hides with the word during chapter announcements and returns with it. Toggling the setting fades it over 0.2s. The hold-speed readout moves from an overlay on the word to its own fixationSurround subview so it can follow the context's actual size. While the context shows, it hangs 8pt under the next word's slot, so a larger Dynamic Type size grows it away from that word. With the setting off, or with the context hidden for lack of room, it renders exactly where it did before. ContextWordsTests covers neighbor selection, the sizing limits, the room between the bars, and ImageRenderer pixel checks (identical word pixels; context only inside its slots; first word; chapter announcement) run from their own defaults suite at a fixed scale and the default Dynamic Type size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blank After Sentences, a new toggle in the Settings Reading Behavior card, is off by default. When it's on, the word slot goes empty for a moment after each sentence (issue #15). A slider sets the length in words at the reading speed: 0.5 to 4 in half-word steps, default 1 (200 ms at 300 WPM). The keys are sentenceBreakEnabled and sentenceBreakLength, in ReaderSettings and TimingSnapshot. RSVPEngine gets a blank phase modeled on the chapter announcement. Once a sentence's last word has had its time, advance() sets isInSentenceBreak and holds it for sentenceBreakLength base intervals. Then the next word shows. Each deadline is anchored to the one before, so blanks add no drift. currentIndex stays on the sentence's last word. pause(), seek(), and load() end the blank; a hold release ends it through pause(). CurrentWordView hides WordView, guide line included, by opacity with no animation, so the blank cuts in like a word change. Sentence ends PunctuationMarks marks every period as a sentence end. That suits a pause and is unchanged. A blank at Mr. or U.S. would split a sentence, so SentenceBreak.endsSentence(at:in:) adds two tests on top: - The next word must start like a sentence. That means a capital, or a letter of a script without case (CJK, Hangul, Arabic, Hebrew), after any opening quotes or brackets. A lowercase word continues the sentence ("Why?" she asked). A digit also starts one, except after a word ending in a bare period (No. 5, Vol. 3, ca. 1500). The Japanese quotative particles と and って continue a sentence, as a lowercase dialogue tag does. - A period right after the word's letters must not close an abbreviation or label: - a single capital (J., F.) or Arabic letter (د.); - a dotted form (U.S., e.g., Ph.D., a.m.). Groups are at most three letters, so example.com. and 0.2. still break; - a listed short form: Mr., Dr., St., Jr., Inc., vs., v., cf., et al., Vol., Fig., and a few Spanish, Italian, and German titles and short forms; - a list label: 1. or b. right after a sentence end or colon. A period after a closing mark (etc.). or U.S.") does end a sentence. etc., No., and a lone number or lowercase letter (born in 1985., the wage w.) do break when a capital follows. There they almost always end a sentence. A bare ellipsis (wait...) doesn't break; its own pause covers it. Every blank is also a sentence-end pause. On the integration word streams of the two sample books, a blank follows 91% of the EPUB's 4,800 sentence-end pauses and 82% of the PDF's 12,192. I read 140 random blanks and every blank after a capitalized word of 2-5 letters; none fell mid-sentence. Most pauses without a blank are initials in reference lists and U.S. before a capital, which was mid-sentence in every case I sampled. The real sentence ends that get no blank are mostly ones followed by a footnote or exercise number, and math labels (area E.). Judgment calls - The blank adds to the sentence-end pause instead of replacing it. Each setting keeps one meaning: the pause holds the last word longer, and the blank clears the screen after it. If the blank replaced the pause, the Sentence end slider would do nothing at most sentence ends but still apply where no blank follows. For a blank alone, set the Sentence end pause to Off. nextInterval() is unchanged. - The blank's length follows WPM and the hold-to-read override. Smart timing, compound, acronym, pause, and complexity time don't change it, since nothing is on screen to read. - There is no blank before a chapter start; the announcement is the break. There is none after the last word either, and completion is unchanged. - A setting changed mid-blank is measured against the blank's own length. onPlaybackSettingChanged() only pulls the deadline earlier, as it does for words. Dragging faster mid-blank ends it sooner, and dragging slower doesn't stretch it. Turning the setting off ends the current blank at once. - Pausing mid-blank shows the sentence's last word again. Resuming gives that word a full interval and then the blank, the same as resuming mid-word. A paused screen should show where the reader is, and the word helps them pick the sentence back up. - The opt-in title and chapter header stays up during the blank. It is chrome the user chose, and blinking it every sentence would distract more than an empty screen helps. The hold-speed readout stays too, since it answers a drag in progress. isInSentenceBreak is readable so overlays tied to the word, like context words and the quote indicator, can hide with it. - The word stays in the accessibility tree during the blank, so VoiceOver focus doesn't move at every sentence. - Line breaks are out of scope, as specced. EPUBContent turns block tags into spaces, PDF lines are layout wraps, and the tokenizer splits on all whitespace, so paragraph ends never reach the word array. Keeping them would take a new per-word blob. Most paragraphs end with a sentence mark anyway. Known limits: a dialogue tag that starts with a name gets a blank ("Stop!", blank, Harry shouted). So does a German ordinal before a noun (am 3. Oktober). Timing at 300 WPM (200 ms base), measured with real timers: late. 200 ms, then 200 ms blank (pauses off) late. 300 ms, then 200 ms blank (sentence-end pause 1.5x) late. 348 ms, then 200 ms blank (smart timing and pauses on) A blank of 0.5, 2, or 4 words lasts 100, 400, or 800 ms; a 1-word blank lasts 100 ms at a 600 WPM hold. Over 300 words and 27 blanks at 1000 WPM, total drift was +0.1 ms and every phase came within 0.4 ms. At 1 word, reading time for the sample books rises 5.7% (EPUB) and 4.0% (PDF). Cost per word (-O, M3 Pro), paid only while the setting is on: 19 ns for lowercase words and 30 ns for accented Latin. Synthetic CJK and Arabic sets, where a third to a half of the words end sentences, take 125-275 ns. Over the two books it averages 43-46 ns. For comparison, PunctuationMarks.marks, which pauses already run per word, takes 95-140 ns. SentenceBreakTests covers the classifier: marks, the next word, abbreviations, labels, numbers, CJK, the Japanese particle, Arabic, Hangul, Hebrew, and positions. It also covers the engine phase. A blank appears only when on, and the next word follows it. There is none inside sentences, at the end, or before a chapter. The blank's length follows WPM and the override and ignores the word's own timing. It adds to the sentence-end pause. Pause, seek, scrub, load, and hold release end it. Setting changes and turning it off mid-blank are covered, and so are the defaults and the settings snapshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tokenizer joins a number and its designator into one display word
with a plain space: a year and its era (2000 BCE, 44 B.C., 10,000 BCE,
2000–1500 BCE, the 400s BCE, AD 79, A.H. 1445) and a 12-hour clock time
and its meridiem (10 AM, 10:30 p.m., 9.05 P.M.). Punctuation around the
unit stays on it ("(2000 BCE),"), and an en dash between two units
splits at the dash: "27 BCE–14 CE" reads "27 BCE–", "14 CE". Documents
already in the library keep their stored words; new imports join.
The rules are tight on purpose; a missed join only leaves today's
behavior.
- After a year: BCE, BC, CE, AD, AH and their dotted forms. Before a
year: AD, AH, A.D., A.H. After a 1–12 hour with optional :MM or .MM:
AM, PM, A.M., P.M., a.m., p.m. Undotted lowercase forms don't join,
except bce: ad, ah and ce are words (an ad, French ce), bc is chat for
"because", am is a word in English and German (die 5 am häufigsten),
and pm alone would join only one end of "9 am to 5 pm". Mixed case
(Ad, Bce) and other acronyms such as BP never join.
- The first half may open with brackets, quotes or ~ and must end with
its numeral or designator, so "2000." or "2000," blocks the join ("It
ended in 2000. BC Hydro said"). Currency and number signs, footnote
digits (2000¹), ordinals, decimals and alphanumerics (B12) never open
a unit.
- The second half must start with its designator or year and carry only
trailing punctuation; % keeps it apart (AD 50%).
- A year has at most six digits, grouped by thousands if at all:
250,000 BCE joins, 1,000,000 BC doesn't.
- AM and PM need a 12-hour time, so "In 2010 PM Cameron" stays apart.
- A hyphen after a designator never splits a token ("In 1984 AH-64
Apaches"); only an en dash between two complete units does.
- Units never chain: "2000 AD 79 people" gives "2000 AD", "79".
- Nothing joins across an EPUB block. appendTokenizedText takes
startsBlock, and EPUBContent passes it for the first token after a
paragraph, heading, list item or spine-file start, so a heading
"Chapter 3" never takes the AD of "AD 79 was the year", and a chapter
never starts inside the previous chapter's last word. Streaming is
unchanged otherwise: chunks split at whitespace tokenize exactly like
the whole text. PDF pages and plain text carry no reliable block
structure and still join across line and page breaks.
Clock times count as the reporter's "and similar": AM and PM bind to a
number the way era designators do, and they are far more common — the
two books in pdfs/ hold 16 clock units and no era units.
Some readings can't be told apart without meaning and do join: BC as
British Columbia straight after a year ("the 2017 BC election"), AD as
Alzheimer's disease after a count ("120 AD patients"), PM as a title
after a date ("May 6 PM Cameron"), CE as another acronym after a year,
and a PDF or plain-text line ending in a number followed by one opening
with a designator.
Consumers of the word array:
- WordView anchors the ORP on digits as well as letters, for every
word. Counting letters alone anchored a unit in its designator
(2000 B[C]E), a decade on its final s (1990[s]), an ordinal on its
suffix (3r[d]), a time on its colon (10[:]30), and "1." on its
period. Numbers of four or more digits now anchor one glyph further
left (2[0]00); numbers up to three digits, words, superscripts (m²),
CJK and Arabic words keep their anchor.
- CompoundWord treats a unit's space as a joiner, so a unit gets the
half interval 1990–1995 gets; a one-digit number (5 PM) is too short
to count as a part. Smart timing doesn't count the space as a letter.
- PassageView matches phrases over the space-separated pieces of each
word, so "2000 BCE" and "in 2000" find and highlight the unit, and
each match keeps its own word range. Single-word queries are
unchanged.
- EPUBContent maps markers on either half to the unit's word.
- WordStorage, WordComplexityAnalyzer (tags map by scalar offset, and a
unit keeps the tag of its first half), PunctuationMarks, chapter
titles and the index-based views need no change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings acronym timing and sentence breaks under the chapter-title work, which changes how playback leaves a chapter announcement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue #15: "Parenthesis and quotation marks, etc. should remain fixed on screen for as long as the full sentence is running on screen." An "Open Quotes and Parentheses" toggle at the end of the Settings "Reading Display" card (enclosingMarksEnabled, off by default) keeps the opening mark of each quotation or parenthetical the current word is inside above the word, from the word carrying the opening mark through the word carrying the closing mark. The marks are in the reader font at 0.6x the text size (at least 14pt) in the tone's fadedTextColor. Nested spans show up to three marks: the outermost centered on the anchor's column, inner ones beside it on the side the text reads toward, so a mark never moves while it shows. VoiceOver skips them and they take no touches. Placement: above the word on the anchor's column, not at its edge. ORP centering moves the word's left and right edges every tick, so a mark on the edge would jump up to 16 times a second. A fixed spot beside the word is reached by long words: over 109,000 words of the two sample books, 0.02 to 0.15% of words (by font) reach 3x the text size out from the anchor on the leading side, and at 72pt on a portrait phone a mark can only sit 2.5x out, which 0.05 to 0.4% of words reach. It would have to blink off for those words, 120 to 190pt from the fixation point. Above, the word's line never reaches the slot (the closest ink, stacked accents at 24pt, stays 0.08pt below it), nothing depends on the word, the mark sits about 55pt from the fixation point at 40pt, and right-to-left text needs no side of its own: brackets and guillemets mirror as they do on the word, and inner marks line up leftward. With context words on, the marks take the tier above the previous word, so the column reads opening mark, previous, current, next. At the context words' 0.45x a lone quote mark was too faint to make out. Marks: parentheses, square brackets, and 「」『』()《》〈〉【】〔〕[] open and close by character, and „ and ‚ only open. Other quotes and guillemets open before a word's letters and close after them, which covers English “…”, German „…“ and »…«, Polish „…”, Swedish ”…”, and French «…» and ‹…›. French sets a space inside guillemets, and the tokenizer glues the spaced mark onto the word before it (dit«), so a « or ‹ after the letters opens when none is open. The CJK tokenizer glues openers the same way (说:「). Curly braces are left out: in prose they appear almost only in code and math. Single quotes: ’ and ' are apostrophes far more often than quotes. ’ never opens, and after a word's letters it closes a single quotation only when one is open, so possessives (dogs’) and elisions (’90s, rock ’n’ roll) close nothing. ‘ opens, except before a digit or a common elision (‘em, ‘til, ‘tis, ‘n’), where it is a misprinted apostrophe. Straight ' is ignored: straight-quote books lose single-quoted spans rather than have every 'em and '90s open one. A possessive inside a single quotation ends it early; nothing shows wrongly. Pairing: only spans that close are shown, so an unmatched mark shows nothing rather than staying up until a timeout. A closing mark closes the innermost open span of its kind and drops spans still open inside it. A quotation reopened while it is the innermost span continues across a paragraph break when the text before the new mark ends a sentence: each paragraph of a long quotation opens it and only the last closes it, and paragraph breaks aren't stored. Otherwise the earlier mark never closed and is dropped. Brackets nest instead. A chapter start closes everything. A span needs three words; in one or two, every word shows its own mark. A span closed right after its own punctuation (.” ,’ ?) —”) or continued across a paragraph may run 250 words, any other 60. In the sample books, real spans closing without punctuation stay under 30 words and real quotations reach 149, while the longest mispairings, in a garbled PDF table and caption, run 167 and 195 words and close with no punctuation. Cost: EnclosingMarks pairs a document in one pass off the main actor, once it has loaded and only while the setting is on: 4.6ms for Atomic Habits (77k words) and 17.6ms for the 250k-word Labor Economics PDF in a release build (82 and 285ms in debug); 55 to 110ms for synthetic 200k-word texts that are all Arabic, all CJK, or a mark on every word. It keeps a UInt16 per word (0.5MB for 250k words), and a lookup is one array read (4ns), so seeks, scrubs, passage taps, chapter jumps, and Read Again show the right marks at once. The word cannot move: EnclosingMarksView is a fixationSurround stage subview like the context words, and no layout code changed. Offscreen renders of 5,600 configurations (393x852 phone, 320x568 zoomed SE, 852x393 landscape, 1024x1366 iPad, 700x500 Mac window; 24, 40, 56, and 72pt; all seven fonts; both bottom bar heights; context words on and off; parentheses, nested quotes, a long word, three levels, German, French, Arabic quotes and parentheses, CJK, and a word in no span; 28,000 images) are byte-identical between no marks view, the setting off, the marks at zero opacity, and the marks with transparent text. With the marks visible, every changed pixel lies inside their slot, and nothing draws for a word in no span or without room. Tight space: the slot depends on settings and layout only. It shrinks to a 12pt floor, then hides; context words keep their size and the marks take the room left. Dynamic Island phones and iPads show them full size at every text size, with or without context words. A home-button phone shrinks them at 72pt with context words. A zoomed SE shows them at every size without context words and, with them, shrinks them from 48pt and hides them from 64pt. A 700x500 Mac window shows them up to 40pt without context words. With today's bars, iPhone landscape shows them only at 24 to 32pt without a chapter bar; the landscape work can reclaim room for the marks and the context words together. No animation between words: the per-tick read sits in a small child view that clears inherited animations. An NSHostingView probe shows every marks update unanimated across ticks, scrubs that pause playback, VoiceOver adjustments, seeks inside withAnimation, announcements, and Read Again; without the guard, the VoiceOver scrub and the animated seek fade the marks. Toggling the setting fades them over 0.2s. They hide with the word during chapter announcements, and EnclosingMarksSlot.isShowingWord is the one place another hide condition goes. EnclosingMarksTests covers each mark family, nesting and the three-level cap, unmatched, stray, and runaway marks, both span limits, apostrophes and straight single quotes, paragraph continuations, chapter resets, right-to-left spans, lookups in any order and outside the document, the slot's sizing and room, and ImageRenderer pixel checks (identical word pixels; marks only inside their slot; none for a word in no span; none during a chapter announcement). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When playback reaches a chapter, its title shows in the word slot as before; then reading goes on after the words at the chapter's start that spell the title, instead of playing them again word by word (issue #15, "Chapter names repeat after chapter announcements"). Playback time, not import The skip is worked out when the engine loads words and chapters, and nothing stored changes: - Every document already in a library gets it; an import-time drop would need each book re-imported. - The word stream keeps its headings, so the passage view still shows them for orientation and search, complexity scores stay parallel, and Chapter.wordIndex still points at the heading. Chapter jumps, the chapter list, the header line, and ChapterTimeline are untouched. - Document and Chapter keep their SwiftData shape: no migration. - A wrong match deletes nothing, and a better matcher later fixes every book at once. - The cost is one pass per load: about 1 ms for the Atomic Habits EPUB (116 chapters) and 4 ms for the Labor Economics PDF (492 chapters) on an M3 Pro. Per tick it's a dictionary lookup at an announcement's end. Engine - setChapters stores, for each chapter whose first words repeat its title, where reading goes on (ChapterHeading.readingStarts). - During the announcement currentIndex stays on the chapter's first word, so the counter, progress, and header line read as before. When the fade ends, the position moves past the heading; if another chapter starts there (Part One, then Chapter 1), it's announced next. - Pausing during an announcement (hold release, Space, backgrounding, leaving the reader) moves the position past the heading too: the paused word is the one playback resumes on, the saved position is after the heading, and resuming doesn't announce again. A chapter that starts right after still gets its announcement. - Seeking is unchanged. A chapter jump lands on Chapter.wordIndex, the paused screen shows the heading's first word, and play announces the chapter and skips its heading. - A heading that runs to the document's last word plays as today. - No blank before a chapter start, as before; a skipped heading never shows, so no blank follows it either. - ChapterNavigationView measures "near the chapter start" from where reading starts, so previous-chapter right after an announcement still goes to the previous chapter, as it did when the position sat on the heading's first word. Matching (Engine/ChapterHeading.swift) Titles and words are compared as streams of letters and digits, folded for case, diacritics, width, and compatibility forms (ff, ①), and the match must end where a word ends. That holds across the tokenizer's dash splits (One—, The), unit joins (44 BC), hyphen merges, punctuation glued to a word (Section&), CJK segmentation, curly and straight quotes, and the word lists older tokenizers stored. - A number or label only one side has is set aside: "1: The Surprising Power of Atomic Habits" covers "The Surprising Power of Atomic Habits" (import dropped the separate "1" as a page number), "The Beginning" covers "Chapter 1 The Beginning", "Prologue: The Storm" covers "The Storm", and "Preface" covers "Page vi Preface" (converted PDFs print page labels). - Numbers on both sides must agree, whatever their form: "1. The Beginning" covers "Chapter One: The Beginning", "Part II: The Long Road" covers "PART TWO The Long Road"; "6. Monopsony" doesn't cover "4-9 Monopsony". Arabic, roman, English number words to 99, a lettered appendix, and CJK 第…章 all count. - A title that is only a number or label covers the heading's labeled number and leaves the subtitle to play: "Chapter 1" and "Chapter One" cover "CHAPTER 1" of "CHAPTER 1 The Beginning". A bare number in the text never counts this way; at a page top it's a list item, a footnote, or an exercise number, and more than three digits is a year. - A chapter that starts inside another's heading is passed over only when its own heading ends inside it, so the outer title already showed it: "Introduction: My Story" covers the heading chapter "My Story". Otherwise reading stops there to announce it. - Text that reads on as a sentence is never skipped: the match must not start in lowercase under a capitalized title, and the word after it must not start in lowercase. On a PDF page that opens mid-section, "The Two-Minute Rule can seem like a trick" and "Evidence from the 1990s" stay. Measured on the books in pdfs/ - Atomic Habits (EPUB, 116 chapters): 113 skipped whole (76 heading chapters and 37 table-of-contents entries, among them the 20 numbered ones and 8 combined labels that also cover an inner heading chapter), none in part, 3 not at all. Copyright and Epigraph have no heading, and the contents entry for the index points at an A–Z bar one word before the "Index" heading, which is its own chapter and is skipped, so "Index" is still announced twice around the bar. Nine announcements now run straight into the next: each of the six parts into its first chapter, the appendix into its first section, and two pairs of figure labels the book marks up as headings. - Labor Economics (PDF, 492 outline chapters): 361 skipped whole, 5 of them past a "Page xiv"-style label; none in part; 131 not at all: 116 pages open with other text because the heading sits lower on the page or the page has none (the announcement fires at the page start, as before), 3 front-matter pages name the title a few words in, and 12 "Key Concepts" headings are followed by a lowercase term list, which the sentence rule leaves to play. - No wrong skips. Trying every title at every paragraph and page start, every word of the EPUB, and every fifth word of the PDF found matches away from a title's own chapter only where the text is that title: a contents listing, an index entry, a summary table, a figure caption, or a capitalized phrase such as "Unemployment Compensation (FPUC)" under "Chapter 12: Unemployment". There, the skipped words would be the ones the announcement had just shown. Judgment calls - The lowercase rule costs the 12 "Key Concepts" pages (and any heading followed by a glossary-style list) two repeated words each. Without it, sentences that open with a named concept would lose their subject whenever a page starts with one. - A title that abbreviates a longer heading ("Efficiency" over "11-6 Efficiency Wages") skips the part it spells when the next word is capitalized; the rest of the heading plays. - Labels are English plus common German, French, Spanish, Italian, and Dutch words; other languages still match on the title itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sentence break empties the screen for a moment after each sentence. The faded previous and next words and the open quote and parenthesis marks belong with the word, so they now leave and return with it, as they already do for chapter announcements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Number units in the tokenizer; acronym timing, the chapter heading skip, and sentence breaks in the engine; the opt-in views around the word and the fixationSurround role; the new settings keys; per-feature test files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unit timing test predates acronym timing on this branch. A unit's designator is read letter by letter, so 2000 BCE takes compound and acronym time together, 2.25 base intervals, the same as COVID-19. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps suspended hyphens out of line-break merging: "pre- and post-war" no longer tokenizes to "preand". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous and next words now sit on the word's line, one space from its edges, at the word's size in the tone's faded color and without an anchor letter. They are overlays inside WordView, so the word, its anchor, and its fitting never change; a long neighbour runs off the screen edge. A right-to-left document puts the previous word on the right. They hide with the word during chapter announcements and sentence breaks. This replaces the stacked layout above and below the word, so the hold speed readout returns to its original overlay and the open quote marks always sit right above the word. Context-word tests now check that the word's pixels never change, which side each neighbour takes in both reading directions, and that the neighbours are dimmer than the word and never take the anchor color, in every font and tone. The quote-mark pixel tests tolerate a few stray pixels of rendering noise, and the ZIP temp-directory test no longer expects a /private alias from a sandboxed macOS test host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The neighbours sat one space from the word, close enough to read as part of it. The gap is now three quarters of the text size, the same in every font. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Context words now stay readable while paused, but fade to a subtle dim state during active playback to reduce distraction. This adds per-tone dim opacity values, passes the playback state into the word view, updates the settings copy, and expands tests to cover contrast and visibility in each reader tone.
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.
Addresses #15. This covers the reporter's list except text color, which already ships as the reader text tones, and landscape, which was dropped.
Reading behavior
, ; :), dashes, ellipses, and closing brackets and quotes. This works for Latin, CJK, and Arabic script. A word with several marks pauses once, for the longest one.wedge-shapedgets extra display time for each further part. This is always on.elements—stoneshows aselements—, thenstone.FBI,PhD, andCOVID-19stay up longer. Each letter after the first adds a quarter of a word's time, capped at three quarters. Three or more all-caps words in a row are treated as ordinary text, so headings and legal text don't slow down. This is always on.Mr.,U.S.,J.), looks at the next word, and never fires before a chapter or after the last word.2000 BCE,AD 79, and10:30 PMdisplay as one word. This applies to new imports only, and the join rules are strict.pre- and post-warno longer turns intopreand.Reading display
Review notes
2000highlights its second digit).2000 BCEgets both compound and acronym time (2.25 words), the same asCOVID-19.StrobeTests/, and CLAUDE.md is updated.Testing
7be2011, before the suspended-hyphen merge and the switch to inline context words.🤖 Generated with Claude Code