Skip to content

Reader improvements from issue #15 - #16

Merged
Cuzeth merged 32 commits into
mainfrom
issue-15-improvements
Sep 24, 2026
Merged

Cuzeth merged 32 commits into
mainfrom
issue-15-improvements

Conversation

@Cuzeth

@Cuzeth Cuzeth commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

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

  • Punctuation pauses: each punctuation type has its own pause: sentence ends, clause marks (, ; :), 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.
  • Hyphenated compounds: a compound like wedge-shaped gets extra display time for each further part. This is always on.
  • Em-dashed words: words joined by an em dash are split, so elements—stone shows as elements—, then stone.
  • Acronyms: acronyms like FBI, PhD, and COVID-19 stay 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.
  • Blank after sentences (opt-in): the screen goes empty for 0.5–4 words' time after a sentence. It skips abbreviations and initials (Mr., U.S., J.), looks at the next word, and never fires before a chapter or after the last word.
  • Year and time units: 2000 BCE, AD 79, and 10:30 PM display as one word. This applies to new imports only, and the join rules are strict.
  • Chapter titles no longer repeat: after a chapter announcement, playback moves past the heading words the title already showed. This happens at playback, so books already in the library are fixed too, and nothing stored changes.
  • Suspended hyphens: pre- and post-war no longer turns into preand.

Reading display

  • Word position: the word sits on a fixation line at 47% of the screen height. It no longer moves when the bars fade.
  • True black background: on by default.
  • Book title and chapter at the top (opt-in): shown faded while reading.
  • Previous and next words (opt-in): shown inline on either side of the current word, at its size, dimmed and without the red letter. The current word doesn't move.
  • Open quotes and parentheses (opt-in): while you're inside a quotation or parenthetical, its opening mark stays faded above the word until it closes.
  • Settings: the new "Reading Display" card holds these options, and horizontal panning in Settings is fixed.

Review notes

  • Always-on timing: compound and acronym timing have no toggle. One setting could cover both if you want one.
  • Anchor letter: the red anchor now counts digits as well as letters, so numbers anchor like words (2000 highlights its second digit).
  • Timing composition: a unit like 2000 BCE gets both compound and acronym time (2.25 words), the same as COVID-19.
  • Tests: each feature has its own test file under StrobeTests/, and CLAUDE.md is updated.

Testing

  • iOS: the Simulator suite passed with 496 tests on 7be2011, before the suspended-hyphen merge and the switch to inline context words.
  • macOS: a later run found two test-only failures, both fixed:
    • the ZIP temp-directory test assumed an unsandboxed test host;
    • one quote-mark pixel comparison was byte-exact, so macOS rendering noise tripped it.
  • Typecheck: the final head typechecks for macOS and the iOS Simulator.

🤖 Generated with Claude Code

Cuzeth and others added 30 commits September 20, 2026 21:30
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.
@Cuzeth
Cuzeth merged commit af0a474 into main Sep 24, 2026
1 of 2 checks passed
@Cuzeth
Cuzeth deleted the issue-15-improvements branch September 24, 2026 06:59
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.

1 participant