diff --git a/.changeset/annotations-start-from-controller.md b/.changeset/annotations-start-from-controller.md
new file mode 100644
index 000000000..a8c5de935
--- /dev/null
+++ b/.changeset/annotations-start-from-controller.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/react': patch
+---
+
+`useAnnotations` starts from the records the controller already holds: those its annotation storage loaded before the hook listened were told by no event, so a book opened again listed no annotations until the next change (a scrolled view, which draws them from that list, showed none).
diff --git a/.changeset/atom-line-annotation-space.md b/.changeset/atom-line-annotation-space.md
new file mode 100644
index 000000000..21e023621
--- /dev/null
+++ b/.changeset/atom-line-annotation-space.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A ruby line after a line holding only an image (an illustration paragraph) reaches only into the space under the image, as Chromium measures that line's content by the image, instead of half the image line's height.
diff --git a/.changeset/atom-vertical-align-middle.md b/.changeset/atom-vertical-align-middle.md
new file mode 100644
index 000000000..bc7727a24
--- /dev/null
+++ b/.changeset/atom-vertical-align-middle.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+An image or inline-block styled `vertical-align: middle` centres on its line as Chromium places it: its box moves by half its ascent less its descent, less half its parent's x-height, so its middle sits half the parent's x-height above the parent's baseline, down a vertical line too. calibre's centred plate pages, a page-wide spacer and a full-height block both aligned middle, now stand where Chromium draws them.
diff --git a/.changeset/block-sizes-on-layout-grid.md b/.changeset/block-sizes-on-layout-grid.md
new file mode 100644
index 000000000..e1bf393c2
--- /dev/null
+++ b/.changeset/block-sizes-on-layout-grid.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A box's fixed, minimum and maximum block size sits on the LayoutUnit grid, truncated as Chromium stores a used size: paragraphs held to `min-width: 1.6em` down a vertical page stack 25.59375px apart instead of 25.6, so their excess no longer piles up over a page and moves where the next page's lines start.
diff --git a/.changeset/box-edges-as-tags.md b/.changeset/box-edges-as-tags.md
new file mode 100644
index 000000000..ae48f65a0
--- /dev/null
+++ b/.changeset/box-edges-as-tags.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+An inline element's start and end margin, border and padding take their width as Chromium's open and close tags do: a line that breaks where the element opens sends that start to the next line with the element's content, instead of leaving it on the line above or pushing the character before it down; nested elements each add their own, an empty element takes its width, vertical text reads the top and bottom sides, and percentage padding resolves against the containing block.
diff --git a/.changeset/break-before-past-page-end.md b/.changeset/break-before-past-page-end.md
new file mode 100644
index 000000000..32ee37e76
--- /dev/null
+++ b/.changeset/break-before-past-page-end.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A block whose start lands past the end of the page, after a line or plate taller than the page, opens the next page however small it is, as Chromium breaks before it: a page-break `
` after an over-wide plate no longer hangs off the bottom of the plate's page.
diff --git a/.changeset/broken-box-keeps-the-page.md b/.changeset/broken-box-keeps-the-page.md
new file mode 100644
index 000000000..8502e27d7
--- /dev/null
+++ b/.changeset/broken-box-keeps-the-page.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A box that breaks across pages paints as Chromium slices it: the fragment that breaks before or inside a child (or between a paragraph's lines) keeps the rest of the page, and no fragment strokes a border on an edge the break cut. A bordered box whose illustration moves to the next page draws its side borders down to the page bottom above it, with no bottom border there and no top border where it resumes.
diff --git a/.changeset/broken-image-without-alt.md b/.changeset/broken-image-without-alt.md
new file mode 100644
index 000000000..b616d7024
--- /dev/null
+++ b/.changeset/broken-image-without-alt.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A missing image with no `alt` attribute lays out as Chromium's 16px broken-image icon, as one with alt text does; only an empty `alt` collapses. A missing image in a short line no longer leaves every line after it a pixel high.
diff --git a/.changeset/canvas-image-formats.md b/.changeset/canvas-image-formats.md
new file mode 100644
index 000000000..b8a4071c9
--- /dev/null
+++ b/.changeset/canvas-image-formats.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+The browser reader decodes GIF, WebP and BMP images as well as PNG and JPEG — every format Core sizes — instead of painting their placeholder.
diff --git a/.changeset/chromium-line-breaking.md b/.changeset/chromium-line-breaking.md
new file mode 100644
index 000000000..40c1f20b9
--- /dev/null
+++ b/.changeset/chromium-line-breaking.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+Lines break where Chromium breaks them in every language. Each text item reads the ICU line rules its content language (`lang`) and `line-break` value select, as Chromium's ICU does: Chinese text, and Japanese and Korean text under `line-break: normal` or `loose`, reads the curly double quotes as brackets and may start a line with 〜 or ゠ (Japanese breaks only by default otherwise); `loose` also breaks before iteration marks and centered punctuation and between inseparables; a quotation mark breaks only between East Asian characters; and a number keeps its prefix and postfix together only as a number does. `word-break: break-all` and `keep-all` add and withhold breaks per text item as Blink does, and `line-break: anywhere` applies only to its own text. A line broken before spaces ends after them (a space followed by an ideographic space ends the line after the space), and a no-break space never hangs past the line's end. `-webkit-line-break` and `-epub-word-break` (and `-epub-text-transform` and `-epub-caption-side`) reach the engine as the properties Chromium aliases them to.
diff --git a/.changeset/clearing-block-keeps-its-margin.md b/.changeset/clearing-block-keeps-its-margin.md
new file mode 100644
index 000000000..554e9417e
--- /dev/null
+++ b/.changeset/clearing-block-keeps-its-margin.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A block with content that clears the floats before it keeps its top margin inside its container, as Chromium settles the container's position above a block cleared past adjoining floats: floats opening a box no longer move down by the margin of the paragraph that clears them.
diff --git a/.changeset/closers-before-a-break.md b/.changeset/closers-before-a-break.md
new file mode 100644
index 000000000..7eb765ee4
--- /dev/null
+++ b/.changeset/closers-before-a-break.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A chapter written as one long ` `-broken paragraph no longer has its text shaped again for every wrapped line that ends in a closing bracket or quote before a ` `; what is laid out is unchanged.
diff --git a/.changeset/closers-trim-across-elements.md b/.changeset/closers-trim-across-elements.md
new file mode 100644
index 000000000..eda73b58b
--- /dev/null
+++ b/.changeset/closers-trim-across-elements.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A closing bracket or quote that overflows a line by half its width is trimmed to keep it on the line when the line crosses an element boundary and when the closer ends its element, as Chromium does since version 153: such a line no longer sends a whole note or the run after an annotated name down to the next line, and a trimmed closer keeps the end padding of the box it closes.
diff --git a/.changeset/column-images-snap.md b/.changeset/column-images-snap.md
new file mode 100644
index 000000000..d2c4cd680
--- /dev/null
+++ b/.changeset/column-images-snap.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+An image in a vertical line paints placed by its `object-fit` and snapped to whole CSS pixels, as an image in a horizontal line does: a plate centred down a column no longer lands a pixel off.
diff --git a/.changeset/contain-snaps-after-placement.md b/.changeset/contain-snaps-after-placement.md
new file mode 100644
index 000000000..21622d02b
--- /dev/null
+++ b/.changeset/contain-snaps-after-placement.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+An image letterboxed by `object-fit: contain` is placed in its unsnapped box and the placed rect snaps to whole pixels, as Chromium snaps it: a thin rule image scaled into a fractional box now rasters on the one row the browser draws it on instead of smearing across two. A raster placed under a pixel tall or wide keeps one pixel where its start rounds, as the browser keeps it, instead of reaching the hosts as an empty image they reject; only one thinner than four LayoutUnits paints nothing.
diff --git a/.changeset/han-kerning-classes.md b/.changeset/han-kerning-classes.md
new file mode 100644
index 000000000..c3d346e5f
--- /dev/null
+++ b/.changeset/han-kerning-classes.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+Adjacent CJK punctuation trims by Chromium's punctuation classes for every bracket and quote: the CJK double prime quotes (`〝〞〟`) and any other fullwidth bracket trim like `「」`, a fullwidth opener after a narrow one (`(「`) and a fullwidth closer before a narrow one (`」)`) trim, the middle dots `·` and `‧` make their neighbours trim as `・` does, and each of these closers can trim at a line end.
diff --git a/.changeset/inline-block-margin-box.md b/.changeset/inline-block-margin-box.md
new file mode 100644
index 000000000..9243e01d5
--- /dev/null
+++ b/.changeset/inline-block-margin-box.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+An inline block stands on its line by its margin box, as Chromium lays it out: its margins, borders and padding surround its content, take their room along the line and push its last line's baseline down, so the line it rides grows by them (a translation wrapped in `display: inline-block; margin: 8px 0` sits 8px clear of the text above and below it).
diff --git a/.changeset/inline-blocks-as-atomic-inlines.md b/.changeset/inline-blocks-as-atomic-inlines.md
new file mode 100644
index 000000000..c90266670
--- /dev/null
+++ b/.changeset/inline-blocks-as-atomic-inlines.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+An element styled `display: inline-block` lays out as Chromium's atomic inline whatever its tag: it takes its own `width` and `height` (a percentage across a vertical line resolving against the page when the line's own size is open, as Chromium's orthogonal fallback does), an empty one still stands on its line at its size, and one holding blocks lays them out inside it. calibre's centred-plate pages now land on the page Chromium shows them on, with the plate centred across it.
diff --git a/.changeset/inline-box-after-forced-break.md b/.changeset/inline-box-after-forced-break.md
new file mode 100644
index 000000000..45251021f
--- /dev/null
+++ b/.changeset/inline-box-after-forced-break.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+An inline-block right after a forced break starts the next line when a box measures its content's widest line, as Chromium measures it: a shrink-to-fit table column holding `text ` takes the wider of the two lines instead of their sum, and an inline box opening there brings its padding along.
diff --git a/.changeset/inline-table-monolithic.md b/.changeset/inline-table-monolithic.md
new file mode 100644
index 000000000..95366f9f3
--- /dev/null
+++ b/.changeset/inline-table-monolithic.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+An inline table (`display: inline-table`) moves to the next page whole when it does not fit what is left of the page, as Chromium fragments an atomic inline, instead of breaking between its rows like a block table.
diff --git a/.changeset/justified-item-exact-advance.md b/.changeset/justified-item-exact-advance.md
new file mode 100644
index 000000000..30ab513a3
--- /dev/null
+++ b/.changeset/justified-item-exact-advance.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+On a justified line, each inline item (a ruby pair, a styled span, the text between them) ends at the 1/64px ceiling of its exact advance, its glyphs' fixed-point advances plus its justification shares, as Chromium snaps an item's width; a share that lifts an item only slightly past a grid point no longer leaves it on the point below.
diff --git a/.changeset/justify-no-break-space.md b/.changeset/justify-no-break-space.md
new file mode 100644
index 000000000..0ca5651ec
--- /dev/null
+++ b/.changeset/justify-no-break-space.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A justified line spreads the gap after a no-break space with the other word gaps, as Chromium treats the no-break space as a space when it justifies (a numbered item such as "2. Particular care…" with a no-break space after its number no longer keeps that gap narrow), and a no-break space ending a justified line counts as content instead of hanging.
diff --git a/.changeset/ligature-joins.md b/.changeset/ligature-joins.md
new file mode 100644
index 000000000..cf45e48b4
--- /dev/null
+++ b/.changeset/ligature-joins.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A ligature paints as one cluster from its start, and the cluster after it joins when the whole ligature steps by its glyph's advance, so the GPU pen and the canvas pen both draw the ligature the font forms: an embedded face's contextual pair of em dashes no longer paints as two isolated dashes a pixel above the browser's joined bar.
diff --git a/.changeset/line-breaker-defects.md b/.changeset/line-breaker-defects.md
new file mode 100644
index 000000000..c83e9e16d
--- /dev/null
+++ b/.changeset/line-breaker-defects.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A ` ` breaks the line under `line-break: anywhere` and after a preserved space, a paragraph shaped as one run longer than 64 KiB lays out instead of failing, a ligature is measured by its own glyphs in any run, a line rewound to the start of an item keeps an inline image that comes before it, an ideographic space (or another space separator) that overflows a line hangs past its end where the line may break after it, as Chromium hangs it, and a short ruby (a base of at most four glyphs under an annotation of at most eight) moves to the next line whole instead of breaking inside its base, as Chromium keeps it.
diff --git a/.changeset/line-end-reshape.md b/.changeset/line-end-reshape.md
new file mode 100644
index 000000000..6d6fdd9a8
--- /dev/null
+++ b/.changeset/line-end-reshape.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A line's last glyph is measured as Chromium reshapes a line's end: where the shaper marks the break after it unsafe (a kern pair with the space that hangs after it, as Tinos kerns "A" and "’" against the space, or with the next line's first character), the glyph takes the advance it shapes to alone. A line that fits only with that kerning breaks before the glyph, as Chromium breaks it, and a justified line spreads the slack its unkerned end leaves, in Latin text as in CJK.
diff --git a/.changeset/lines-decided-as-broken.md b/.changeset/lines-decided-as-broken.md
new file mode 100644
index 000000000..f41fcb23b
--- /dev/null
+++ b/.changeset/lines-decided-as-broken.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A paragraph's line edges (a closer trimmed to fit, a punctuation pair a break separates, a ruby base split across lines) are decided one line at a time as the line is broken, so a paragraph is built at most twice however many lines decide; a pair trim a later line moved away from its break is applied again, as Chromium applies it.
diff --git a/.changeset/margin-sets-through-wrappers.md b/.changeset/margin-sets-through-wrappers.md
new file mode 100644
index 000000000..44f36ba23
--- /dev/null
+++ b/.changeset/margin-sets-through-wrappers.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+Margins collapsing through several plain wrappers join as one set, the largest positive plus the most negative, as CSS collapses adjoining margins: a negative margin deep in a chain of wrappers no longer cancels against only the nearest one, which left a title page's heading lower than in the browser.
diff --git a/.changeset/one-reader-paged-and-scrolled.md b/.changeset/one-reader-paged-and-scrolled.md
new file mode 100644
index 000000000..6cc3b293c
--- /dev/null
+++ b/.changeset/one-reader-paged-and-scrolled.md
@@ -0,0 +1,6 @@
+---
+'@ritojs/kit': minor
+'@ritojs/react': minor
+---
+
+A book read in pages and scrolled is one reader: switching between them lays the open book out again instead of opening it a second time. The page controller's `suspend()` hands its reader to another view (its canvas off the page, its page buffers let go, no painting, turns, keys or layouts answered, while its annotations, search and stored position stay and its layout calls still lay the reader out), `resume()` takes it back as laid out then, and `suspended` says which. `useRitoScrollView(controller, { active, paged, scroll })` in `@ritojs/react` runs the switch: the reader laid out scrolled and shown from where the pages were, then paged again from where the view was read. A scroll view has its windows as soon as it is made, so it can be moved before it is mounted, and through every new layout it stays on the window the reader reads, which a chapter laid out alone and the whole book number differently (switching to scrolled used to land at the book's start once the whole book was laid out).
diff --git a/.changeset/ruby-line-edge-alignment.md b/.changeset/ruby-line-edge-alignment.md
new file mode 100644
index 000000000..cf64aebb9
--- /dev/null
+++ b/.changeset/ruby-line-edge-alignment.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+In a justified paragraph, a ruby whose annotation is wider than its base sets its base against the line's start when it opens a line, and against the line's end when it ends a justified line, as Chromium aligns ruby at line edges; elsewhere the base stays centered under its annotation.
diff --git a/.changeset/ruby-overhang-at-line-edges.md b/.changeset/ruby-overhang-at-line-edges.md
new file mode 100644
index 000000000..dbd43b795
--- /dev/null
+++ b/.changeset/ruby-overhang-at-line-edges.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A ruby whose annotation is wider than its base overhangs the text before it only when that text is on its own line, as Chromium lays it out: a ruby opening a line starts its annotation at the line's start. A justified, centered or right-aligned line that ends where the next element's first character did not fit aligns as Chromium aligns it, counting each ruby on it at its full width, so such a line ending at a ruby is not stretched.
diff --git a/.changeset/ruby-reaches-above-the-line.md b/.changeset/ruby-reaches-above-the-line.md
new file mode 100644
index 000000000..06e02c97b
--- /dev/null
+++ b/.changeset/ruby-reaches-above-the-line.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A paragraph's first-line ruby reaches into the space above the line before the line grows, as Chromium lays it out: the collapsed margin above the paragraph and the space under the previous paragraph's last line, not across a top border or out of a paragraph that is its own formatting context; a ruby line that opens a page takes its whole height there.
diff --git a/.changeset/scroll-highlight-hits.md b/.changeset/scroll-highlight-hits.md
new file mode 100644
index 000000000..cad56c01e
--- /dev/null
+++ b/.changeset/scroll-highlight-hits.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/kit': minor
+---
+
+A scroll view says which of the app's highlights the pointer is on, the one drawn on top: its `tap` event carries `highlight` (the highlight's `id` and its `bounds` in screen coordinates), and the new `highlightHover` event tells the one under a resting mouse each time it changes or the strip moves it, null once the mouse leaves or presses, so an app opens an annotation's editor on a tap and shows its tooltip on hover in a scrolled view as a page view's `annotationClick` and `annotationHover` let it.
diff --git a/.changeset/scroll-page-length.md b/.changeset/scroll-page-length.md
new file mode 100644
index 000000000..b6cbf0de1
--- /dev/null
+++ b/.changeset/scroll-page-length.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/kit': minor
+---
+
+A scroll view's `pageLength` says how far a page moves its strip, as PageDown and PageUp move it (the view's length along the axis less two lines), so an app's own page commands — a menu's Previous and Next Page — move it as the keys do.
diff --git a/.changeset/scroll-selection-handles-hook.md b/.changeset/scroll-selection-handles-hook.md
new file mode 100644
index 000000000..d5933d9f8
--- /dev/null
+++ b/.changeset/scroll-selection-handles-hook.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/react': minor
+---
+
+`useScrollSelectionHandles(controller)` gives a scrolled view's selection ends where the view shows them, following the strip as it moves, with `focusEdge` naming the focus: what a toolbar over a scrolled selection stands on, as `useSelection`'s `focusRect` is for pages. `ScrollSelectionHandles` is built on it.
diff --git a/.changeset/scroll-view-selects-in-the-app.md b/.changeset/scroll-view-selects-in-the-app.md
new file mode 100644
index 000000000..97f46af01
--- /dev/null
+++ b/.changeset/scroll-view-selects-in-the-app.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/kit': patch
+---
+
+Text selection works in a scroll view a React host mounts through a ref: mounting the view again where it already is no longer lays the strip out again, which ended every drag and double press and, with each rest re-rendering the host, laid the strip out on every frame. While a selection is being made the whole-book layout waits, as it does for the page controller, so a layout landing mid-drag does not end the gesture.
diff --git a/.changeset/scrolled-reading.md b/.changeset/scrolled-reading.md
new file mode 100644
index 000000000..e0561b74a
--- /dev/null
+++ b/.changeset/scrolled-reading.md
@@ -0,0 +1,7 @@
+---
+'@ritojs/core': minor
+'@ritojs/kit': minor
+'@ritojs/react': minor
+---
+
+Scrolled reading: a reader created with `spread: 'scrolled'` lays each chapter out as one continuous strip read through windows the size of the viewport, one window a spread, down the screen, or leftwards for a book whose spine's `page-progression-direction` is `rtl` (its vertical-rl chapters run leftwards as strips; a chapter whose text runs across the book's axis is paginated, each page one window). `getScrollLayout()` gives the windows end to end (axis, window length, each chapter's first spread, window count and extent), a located page's `blockOffset` says where the locator's line starts in its window, and a target the size of a window paints that window alone. The kit's `createScrollController(reader, canvas, { physics, input })` shows the windows end to end on one canvas at whole device pixels, keeps each window's picture while it is near, tells the reader which window is under the reading line, reports where the reader reads at rest and what a tap is on, and moves by `defaultScrollPhysics` or any `ScrollPhysics` an app gives; an app drives it itself with `grab`, `follow`, `release`, `scrollTo`, `scrollBy`, `scrollToSpread` and `scrollToLocator`. The view selects text with a mouse drag or a touch held still across windows (word and paragraph presses, scrolling on near the ends) and moves the selection's focus with Shift chords, following it off screen, draws the selection over each window and finds it again in a new layout (`selection` event, `selectionRects`, `clearSelection`), and `setHighlights` draws an app's search matches or annotations as page rects over the same windows (`viewRects` maps page rects onto the view, `canvas` is what it draws on); with `a11y` it mirrors the text on screen for screen readers once the strip rests. Its `scroll` event says how far through the book the strip is (`progress`, with the view's own length as `viewport`). `useRitoScrollReader` in `@ritojs/react` owns a scrolled reader and its view for a component (`containerRef`, `load`, `unload`, and `progress`, `position` and `selection` as state), and `ScrollSelectionHandles` draws its selection handles for touch input; the kit view gives the handles (`selectionHandles`, `beginSelectionHandleDrag`) in the page controller's shapes.
diff --git a/.changeset/software-webgl-canvas-pen.md b/.changeset/software-webgl-canvas-pen.md
new file mode 100644
index 000000000..555a2f39b
--- /dev/null
+++ b/.changeset/software-webgl-canvas-pen.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+Where WebGL is a software rasterizer (SwiftShader, llvmpipe — a machine with no GPU), the canvas pen paints every page and the GPU only copies it in, as it already did where the browser refused WebGL for a performance caveat: Chromium creates a context on SwiftShader all the same, and the GPU pen there blended text a level or two off the canvas.
diff --git a/.changeset/text-emphasis.md b/.changeset/text-emphasis.md
new file mode 100644
index 000000000..de2e2ebbe
--- /dev/null
+++ b/.changeset/text-emphasis.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': minor
+---
+
+Emphasis marks paint as Chromium paints them. `text-emphasis-style`, `text-emphasis-color`, `text-emphasis-position` and the `text-emphasis` shorthand (with their `-webkit-` spellings, and the `-epub-` spellings of all but the position) reach the engine; a character that takes a mark gets one, set in its face at half its size, outside its em box on the side the position names (a vertical line's right unless it says left), an upright mark centred on it down the column. Marks over a line push it down by what the room under the previous line's text does not absorb, marks under one grow its box, and the next line rises into that growth by the room above its own text.
diff --git a/.changeset/unlinked-stylesheets.md b/.changeset/unlinked-stylesheets.md
new file mode 100644
index 000000000..86d886d84
--- /dev/null
+++ b/.changeset/unlinked-stylesheets.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+A chapter lays out under the stylesheets it links and embeds and no others, as a browser applies them: a chapter without a `` no longer takes every stylesheet the package lists (a book's unlinked note and plate pages keep their default paragraphs and image layout where its stylesheet indents and frames the other chapters).
diff --git a/.changeset/vertical-records.md b/.changeset/vertical-records.md
new file mode 100644
index 000000000..bcd2b814d
--- /dev/null
+++ b/.changeset/vertical-records.md
@@ -0,0 +1,7 @@
+---
+'@ritojs/core': minor
+'@ritojs/kit': minor
+'@ritojs/react': minor
+---
+
+A vertical-rl page's targets, outline, range rectangles and carets are where its columns are drawn, in the same page-content space as a horizontal page's, paged or scrolled; a scrolled window's records move with its pixels by the device rows it is drawn from. A caret's `geometry` gains `width`: it is the segment across its line, `width` 0 on a horizontal line and `height` 0 down a vertical one, and the kit projects it with that shape. `ReaderTextCaret.writingMode` (`ReaderWritingMode`) is the mode of the caret's line, and the kit's keyboard selection reads an arrow key on a `vertical-rl` focus as the key a quarter turn from it, as the browser does (down moves along the line, left to the next line). The React selection handles hang their knobs off the right and left ends of a caret across a vertical line, as they hang them over and under one across a horizontal line.
diff --git a/.changeset/vertical-ruby-base-origin.md b/.changeset/vertical-ruby-base-origin.md
new file mode 100644
index 000000000..528c98603
--- /dev/null
+++ b/.changeset/vertical-ruby-base-origin.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+In a vertical line, the text of a ruby element moves by the rounding of the element's start down the line to a whole CSS pixel, every base of one `` with the first, as Chromium paints a box's text; plain text keeps its fractional place.
diff --git a/.changeset/vertical-text.md b/.changeset/vertical-text.md
new file mode 100644
index 000000000..e7b9de82f
--- /dev/null
+++ b/.changeset/vertical-text.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': minor
+---
+
+Vertical text is laid out and painted as Chromium does it. A vertical-rl chapter's boxes take their margins, padding, borders and sizes on the sides its flow puts them, and their backgrounds and borders paint there; each line hangs from its central baseline; each upright glyph stands in its face's vertical forms at the font's vertical origin and advances down the column by its vertical advance (by the face's whole-pixel height in a face without vertical metrics), and characters that lie sideways in a mixed line (Latin, digits) are set turned a quarter; a ruby annotation reaches into the space before its line and stands on its base where Chromium places it; an image sizes to the column's length; `text-orientation` (with its `-webkit-` and `-epub-` spellings) reaches the engine; and `text-combine-upright: all` sets its text as one horizontal string within an em of the column, in the face's half-, third- or quarter-width forms or scaled when the string is wider than 1.1em. An inline box whose writing mode differs from its parent's lays out as an inline-block across the parent's lines, and a block's minimum and maximum block size (`min-height`/`max-height`, or `min-width`/`max-width` in a vertical flow) bound it. An artifact's font references carry `features` (reader protocol 9): a reference with features is the same bytes as a face, registered under a family of its own with those OpenType features on (`vert`, the vertical forms; `hwid`, `twid` and `qwid`, the narrower forms a combined string takes), which the browser reader registers as its `font-feature-settings` descriptor.
diff --git a/.changeset/vertical-underline.md b/.changeset/vertical-underline.md
new file mode 100644
index 000000000..5f61e77ba
--- /dev/null
+++ b/.changeset/vertical-underline.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+An underlined or overlined run in a vertical line (a link in a vertical table of contents) draws its line down beside the column, where Chromium draws it: Japanese and Korean text swaps the two lines, its underline on the right and its overline on the left, where other text draws its underline, a pixel off the em box.
diff --git a/.changeset/word-movement-skips-spaces.md b/.changeset/word-movement-skips-spaces.md
new file mode 100644
index 000000000..1b8fd124d
--- /dev/null
+++ b/.changeset/word-movement-skips-spaces.md
@@ -0,0 +1,5 @@
+---
+'@ritojs/core': patch
+---
+
+Extending a selection by a word (Option+Shift+Arrow on Apple platforms, Ctrl+Shift+Arrow elsewhere) moves to the end of the next word or the start of the previous one past spaces and punctuation, as the browser does; it stopped right after the space.
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index db1bff905..18f2996f5 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -32,6 +32,10 @@ jobs:
- name: Install matching wasm-bindgen CLI
run: cargo install wasm-bindgen-cli --version 0.2.120 --locked
- run: pnpm install --frozen-lockfile
+ # The demo book split by the repository's own packer, which the
+ # reader's "Sharded Demo" opens: it is not committed, so it is packed
+ # here, before the reader's build copies it from public/.
+ - run: pnpm --filter @ritojs/reader run pack:shards-demo
- run: pnpm run build
- uses: actions/upload-pages-artifact@v3
with:
diff --git a/Cargo.lock b/Cargo.lock
index 0a0634cac..85e28ed06 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1066,6 +1066,7 @@ dependencies = [
name = "rito-inline"
version = "0.0.0"
dependencies = [
+ "icu_properties",
"parley",
"rito-fragment",
"rito-style-contract",
diff --git a/apps/reader/package.json b/apps/reader/package.json
index b32ebf7c0..33996e5b2 100644
--- a/apps/reader/package.json
+++ b/apps/reader/package.json
@@ -35,13 +35,13 @@
"vaul": "^1.1.2"
},
"devDependencies": {
- "@playwright/test": "^1.59.1",
+ "@playwright/test": "^1.64.1",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^25.5.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"tailwindcss": "^4.2.2",
- "vite": "^6.4.3"
+ "vite": "^6.4.5"
}
}
diff --git a/apps/reader/playwright.config.ts b/apps/reader/playwright.config.ts
index 541063ae4..bb476db5e 100644
--- a/apps/reader/playwright.config.ts
+++ b/apps/reader/playwright.config.ts
@@ -48,7 +48,13 @@ export default defineConfig({
name: 'chromium',
use: {
...devices['Desktop Chrome'],
- channel: process.env['PLAYWRIGHT_BROWSER_CHANNEL'],
+ // The full Chromium build: it rasterizes page and canvas text on
+ // the GPU as Chrome does, where the headless shell uses the CPU.
+ channel: process.env['PLAYWRIGHT_BROWSER_CHANNEL'] ?? 'chromium',
+ // No smart minimum font size: Chromium lifts em- and %-sized text
+ // to a minimum its UI language and platform choose, which CSS does
+ // not define and the engine never applies.
+ launchOptions: { args: ['--blink-settings=minimumLogicalFontSize=0'] },
},
},
],
diff --git a/apps/reader/scroll.html b/apps/reader/scroll.html
new file mode 100644
index 000000000..bff5e517a
--- /dev/null
+++ b/apps/reader/scroll.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+ Rito scrolled reading
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/reader/src/App.tsx b/apps/reader/src/App.tsx
index d2a92fdff..df18c3150 100644
--- a/apps/reader/src/App.tsx
+++ b/apps/reader/src/App.tsx
@@ -9,6 +9,8 @@ import { ReadingProgressArea } from '@/components/reading-progress';
import { readerViewportMargin, useReader } from '@/hooks/use-reader';
import { useTheme } from '@/hooks/use-theme';
import { Reader } from '@/components/reader';
+import { ScrollReaderPane } from '@/components/scroll-reader-pane';
+import { ReaderContextMenu } from '@/components/reader-context-menu';
interface AppProps {
readonly pinnedFontPolicy: ReaderPinnedFontPolicy;
@@ -69,9 +71,8 @@ export function App({ pinnedFontPolicy }: AppProps) {
return (
-
-
-
+ >
+ {
+ void reader.annotateScrollSelection(kind, color, note);
+ }}
+ footnote={reader.footnote}
+ onCloseFootnote={reader.dismissFootnote}
+ image={reader.lightboxImage}
+ onCloseImage={reader.dismissLightbox}
+ onImageExitComplete={reader.onLightboxExitComplete}
+ />
+
+ ) : (
+
+
+
+ )}
| null;
annotations: ReturnType['annotations'];
onClose: () => void;
}
diff --git a/apps/reader/src/components/annotation-toolbar.tsx b/apps/reader/src/components/annotation-toolbar.tsx
index 23f0cfc7a..6c09a1f44 100644
--- a/apps/reader/src/components/annotation-toolbar.tsx
+++ b/apps/reader/src/components/annotation-toolbar.tsx
@@ -17,42 +17,55 @@ interface Props {
annotations: ReturnType['annotations'];
}
-type AnnotationKind = 'highlight' | 'underline' | 'note';
+export type AnnotationKind = 'highlight' | 'underline' | 'note';
+
+interface SelectionToolbarProps {
+ /** The selection's focus, which the toolbar stands over, in its container's CSS pixels. */
+ readonly anchor: { readonly x: number; readonly y: number };
+ /** Makes the selection an annotation; the toolbar is done with it then. */
+ readonly onAnnotate: (kind: AnnotationKind, color: string, note?: string) => void;
+}
function stop(e: React.SyntheticEvent): void {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
}
+/** The paged reader's selection, made an annotation through the controller. */
+export function AnnotationToolbar({ selection, annotations }: Props) {
+ if (!selection.hasSelection || !selection.focusRect || !selection.text) {
+ return null;
+ }
+ return (
+ {
+ annotations.add({ kind, color, ...(note ? { note } : {}) }).catch((error: unknown) => {
+ toast.error('Could not add the annotation', {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ });
+ selection.clear();
+ }}
+ />
+ );
+}
+
/**
* What the selection becomes: a highlight in one of four colours, an
- * underline, or a note (a highlight that carries text).
+ * underline, or a note (a highlight that carries text), over its focus.
*/
-export function AnnotationToolbar({ selection, annotations }: Props) {
+export function SelectionToolbar({ anchor, onAnnotate }: SelectionToolbarProps) {
const [showNote, setShowNote] = useState(false);
const [note, setNote] = useState('');
const [pickedColor, setPickedColor] = useState(ANNOTATION_COLORS[0].value as string);
- if (!selection.hasSelection || !selection.focusRect || !selection.text) {
- return null;
- }
-
- const anchor = selection.focusRect;
-
- // focusRect lives in viewport-logical coordinates, so convert it to display CSS px.
const top = anchor.y - (showNote ? 110 : 60);
const left = anchor.x;
const submit = (kind: AnnotationKind, color: string, noteText?: string) => {
- if (!selection.hasSelection) return;
- annotations
- .add({ kind, color, ...(noteText ? { note: noteText } : {}) })
- .catch((error: unknown) => {
- toast.error('Could not add the annotation', {
- description: error instanceof Error ? error.message : String(error),
- });
- });
- selection.clear();
+ onAnnotate(kind, color, noteText);
setShowNote(false);
setNote('');
};
diff --git a/apps/reader/src/components/annotation-tooltip.tsx b/apps/reader/src/components/annotation-tooltip.tsx
index 9a069bcdd..a1856993e 100644
--- a/apps/reader/src/components/annotation-tooltip.tsx
+++ b/apps/reader/src/components/annotation-tooltip.tsx
@@ -1,15 +1,22 @@
import { useRef } from 'react';
+import type { AnnotationRecord } from '@ritojs/kit';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import type { useReader } from '@/hooks/use-reader';
interface Props {
- hover: ReturnType['annotations']['hover'];
+ /** The annotation the mouse rests on and the tooltip's place (screen coordinates); null for none. */
+ hover: {
+ readonly annotation: { readonly record: AnnotationRecord };
+ readonly x: number;
+ readonly y: number;
+ } | null;
}
export function AnnotationTooltip({ hover }: Props) {
const staleRef = useRef(hover);
if (hover) staleRef.current = hover;
const display = hover ?? staleRef.current;
+ // An emptied note is stored empty (see AnnotationDialog): there is none to show.
+ const note = display?.annotation.record.note?.trim();
return (
@@ -19,9 +26,7 @@ export function AnnotationTooltip({ hover }: Props) {
style={{ top: display?.y ?? 0, left: display?.x ?? 0, width: 1, height: 1 }}
/>
-
- {display?.annotation.record.note ?? 'Click to edit'}
-
+ {note ? note : 'Click to edit'}
);
}
diff --git a/apps/reader/src/components/reader-context-menu.tsx b/apps/reader/src/components/reader-context-menu.tsx
index 5cd304b02..fad567dcb 100644
--- a/apps/reader/src/components/reader-context-menu.tsx
+++ b/apps/reader/src/components/reader-context-menu.tsx
@@ -1,5 +1,5 @@
import { toast } from 'sonner';
-import { useCallback } from 'react';
+import { useCallback, useMemo } from 'react';
import {
Bug,
ChevronLeft,
@@ -51,30 +51,18 @@ export function ReaderContextMenu({
onFileLoad,
children,
}: Props) {
- const hasSelection = reader.selection.hasSelection && reader.selection.text.length > 0;
- const canPrev = reader.isLoaded && reader.currentSpread > 0;
- const canNext = reader.isLoaded && reader.currentSpread < reader.spreads.length - 1;
+ const selection = useMenuSelection(reader);
+ const turns = menuTurns(reader);
+ const hasSelection = selection.text.length > 0;
const { inputRef, handleChange, openFilePicker } = useFileLoader(onFileLoad);
const handleCopy = useCallback(() => {
- if (!reader.selection.text) return;
- void navigator.clipboard.writeText(reader.selection.text).catch(() => {
+ if (!selection.text) return;
+ void navigator.clipboard.writeText(selection.text).catch(() => {
// Clipboard access may be denied; swallow silently.
});
- reader.selection.clear();
- }, [reader.selection]);
-
- const handleHighlight = useCallback(() => {
- if (!reader.selection.hasSelection) return;
- reader.annotations
- .add({ kind: 'highlight', color: ANNOTATION_COLORS[0].value })
- .catch((error: unknown) => {
- toast.error('Could not add the highlight', {
- description: error instanceof Error ? error.message : String(error),
- });
- });
- reader.selection.clear();
- }, [reader.annotations, reader.selection]);
+ selection.clear();
+ }, [selection]);
const handleCopyDebugLog = useCallback(() => {
void copyDebugReport().then((outcome) => {
@@ -85,12 +73,12 @@ export function ReaderContextMenu({
}, []);
const handleSearchSelection = useCallback(() => {
- if (!reader.selection.text) return;
- const text = reader.selection.text;
- reader.selection.clear();
+ if (!selection.text) return;
+ const text = selection.text;
+ selection.clear();
reader.search.setQuery(text);
reader.setSearchOpen(true);
- }, [reader]);
+ }, [reader, selection]);
return (
@@ -106,7 +94,7 @@ export function ReaderContextMenu({
Copy
⌘C
-
+
Highlight
@@ -128,25 +116,15 @@ export function ReaderContextMenu({
Contents
- {
- reader.prevSpread();
- }}
- disabled={!canPrev}
- >
+
Previous Page
- ←
+ {turns.prevKey}
- {
- reader.nextSpread();
- }}
- disabled={!canNext}
- >
+
Next Page
- →
+ {turns.nextKey}
@@ -177,3 +155,60 @@ export function ReaderContextMenu({
);
}
+
+/**
+ * What is selected, the pages' or the scroll view's while the book is read
+ * scrolled, and what the menu does with it.
+ */
+function useMenuSelection(reader: ReturnType) {
+ const scrolled = reader.spreadMode === 'scrolled';
+ const text = scrolled ? (reader.scroll.selection?.text ?? '') : reader.selection.text;
+ const view = reader.scroll.controller;
+ const { annotations, annotateScrollSelection } = reader;
+ const pageSelection = reader.selection;
+ const clear = useCallback(() => {
+ if (scrolled) view?.clearSelection();
+ else pageSelection.clear();
+ }, [pageSelection, scrolled, view]);
+ const highlight = useCallback(() => {
+ const color = ANNOTATION_COLORS[0].value;
+ if (scrolled) {
+ void annotateScrollSelection('highlight', color);
+ return;
+ }
+ if (!pageSelection.hasSelection) return;
+ annotations.add({ kind: 'highlight', color }).catch((error: unknown) => {
+ toast.error('Could not add the highlight', {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ });
+ pageSelection.clear();
+ }, [annotateScrollSelection, annotations, pageSelection, scrolled]);
+ return useMemo(() => ({ text, clear, highlight }), [clear, highlight, text]);
+}
+
+/** A page back and on: a turn, or a page of the strip while the book is read scrolled. */
+function menuTurns(reader: ReturnType) {
+ if (reader.spreadMode === 'scrolled') {
+ const view = reader.scroll.controller;
+ const by = (direction: number) => () => {
+ if (view) view.scrollBy(direction * view.pageLength, { animate: true });
+ };
+ return {
+ prev: by(-1),
+ next: by(1),
+ canPrev: view !== null && reader.scroll.progress > 0,
+ canNext: view !== null && reader.scroll.progress < 1,
+ prevKey: 'PgUp',
+ nextKey: 'PgDn',
+ };
+ }
+ return {
+ prev: reader.prevSpread,
+ next: reader.nextSpread,
+ canPrev: reader.isLoaded && reader.currentSpread > 0,
+ canNext: reader.isLoaded && reader.currentSpread < reader.spreads.length - 1,
+ prevKey: '←',
+ nextKey: '→',
+ };
+}
diff --git a/apps/reader/src/components/scroll-reader-pane.tsx b/apps/reader/src/components/scroll-reader-pane.tsx
new file mode 100644
index 000000000..d8f4ba944
--- /dev/null
+++ b/apps/reader/src/components/scroll-reader-pane.tsx
@@ -0,0 +1,139 @@
+import { useRef, useState } from 'react';
+import { Copy, List, Loader2, Search, Settings } from 'lucide-react';
+import { ScrollSelectionHandles, useScrollSelectionHandles } from '@ritojs/react';
+import { toast } from 'sonner';
+import { Button } from '@/components/ui/button';
+import { FootnoteDrawer } from '@/components/footnote-drawer';
+import { ImageLightbox } from '@/components/image-lightbox';
+import { SelectionToolbar, type AnnotationKind } from '@/components/annotation-toolbar';
+import { AnnotationDialog } from '@/components/annotation-dialog';
+import { AnnotationTooltip } from '@/components/annotation-tooltip';
+import type { useReader } from '@/hooks/use-reader';
+import type { useScrollMode } from '@/hooks/use-scroll-mode';
+
+interface ScrollReaderPaneProps {
+ readonly containerRef: (node: HTMLElement | null) => void;
+ readonly scroll: ReturnType;
+ readonly onOpenToc: () => void;
+ readonly onOpenSearch: () => void;
+ readonly onOpenSettings: () => void;
+ /** The book's annotations, edited and removed from their dialog. */
+ readonly annotations: ReturnType['annotations'];
+ /** The annotation a tap opened and the one under the mouse. */
+ readonly annotationPointer: ReturnType['scrollAnnotationPointer'];
+ /** Makes the selection an annotation, as the paged reader's toolbar does. */
+ readonly onAnnotate: (kind: AnnotationKind, color: string, note?: string) => void;
+ readonly footnote: ReturnType['footnote'];
+ readonly onCloseFootnote: () => void;
+ readonly image: ReturnType['lightboxImage'];
+ readonly onCloseImage: () => void;
+ readonly onImageExitComplete: () => void;
+}
+
+/** The book read scrolled: the kit's scroll view and a small bar over it. */
+export function ScrollReaderPane({
+ containerRef,
+ scroll,
+ onOpenToc,
+ onOpenSearch,
+ onOpenSettings,
+ annotations,
+ annotationPointer,
+ onAnnotate,
+ footnote,
+ onCloseFootnote,
+ image,
+ onCloseImage,
+ onImageExitComplete,
+}: ScrollReaderPaneProps) {
+ const selected = scroll.selection?.text ?? '';
+ // Handles show for a touch or a pen, as the paged reader's do.
+ const [touchInput, setTouchInput] = useState(false);
+ const view = useRef(null);
+ // The toolbar stands over the selection's focus and moves with the strip.
+ const handles = useScrollSelectionHandles(scroll.controller);
+ const focus = handles?.focusEdge === 'start' ? handles.start : (handles?.end ?? null);
+ return (
+
+