Skip to content

typeset.us 4.4.0: adoption fixes (defaults unchanged, expanded coverage opt-in) - #14

Merged
speedwarnsf merged 48 commits into
masterfrom
release/4.4.0
Sep 30, 2026
Merged

speedwarnsf merged 48 commits into
masterfrom
release/4.4.0

Conversation

@speedwarnsf

Copy link
Copy Markdown
Owner

Not a release yet: this is the change set for 4.4.0, before the cut. It carries the adoption fixes and merges the 4.3.2 cut.

Defaults stay as 4.3. Expanded coverage (more Latin-script languages, footnote superscripts, some inline tags) is opt-in via coverage: 'extended'. On the existing corpus, 0 of 3,932 golden cells change against 4.3.1 in every engine.

What's in it

  • Safety and weight:
    • The long-unbreakable-run guard (native:run-budget), which prevents the Safari freeze.
    • Marker styles move into the engine's stylesheet.
    • A copy: false option.
    • The audit reports text that is clipped on purpose as a review item.
  • Coverage fixes that are on by default:
    • Common lang misspellings such as en_US.
    • A few Greek or Cyrillic letters inside Latin text.
    • Safer smart quotes: a spaced or unmatched quote stays straight, and the script tag curls quotes only on pages that declare English.
    • headings: false.
  • Integration:
    • A framework hydration wait for the script tag and loaders, plus data-typeset-defer.
    • whenSettled().
    • The ./opt-in export name, with ./go kept as an alias.
    • jsDelivr and unpkg now default to dist/auto.js.
  • Docs:
    • MIGRATION, SUPPORT and for-agents describe 4.4.0.
    • Hydration and user-generated-content warnings, and the effects on reading tools.
    • A defaults table, a languages row and a dated support window.
    • A Visual tests section.

Checks were run locally once each under the owner's lean-testing rule; see the CHANGELOG's Development section. This PR's one CI run is the full-suite check.

🤖 Generated with Claude Code

speedwarnsf and others added 30 commits September 29, 2026 12:00
The existing golden corpus has none of the constructions the 4.4 adoption
fixes change, so a golden run against 4.3.1 would report "0 cells changed"
whatever those fixes did (PLAN decision 3). tests/v4-corpus-4.4.json holds
33 cases: long unbreakable runs (499 and 501 letters, 600 closing quotes,
600 letters and a hyphen, 600 spaced opening brackets, 600 no-break-joined
characters, and a 600-character URL that breaks at its slashes), declared
languages (en_US, english, en-GB, pt, pt-BR, it, nl, vi, sr-Latn, a Spanish
phrase in English), Greek letters in Latin text (5 μg/mL, α-synuclein, a
Greek sentence), inline elements 4.3 declines (sup, sub, time, kbd, dfn,
ins, visually hidden link text, an aria-hidden icon) and quote cases
(width="100", French spaced quotes, dialogue, primes, elisions, untagged
German). Each carries a group and the class the plan gives it; E1 adds the
long-run expectations and E2 appends its cases.

verify-golden composes every case at the corpus widths and fonts (264
cells per engine) as the "coverage" variant, against --coverage-baseline
(default: the published 4.3.1). A coverage cell must be identical to that
release unless its case names an expected outcome or a change this build
makes; it may not gain overflow the baseline lacked, a new orphan or more
line-end problems. The report counts, per engine and per group, how many
cells each build composed, and the summary gives the existing corpus
(3,932 cells per engine) and the coverage corpus apart. The published 4.3.1
composes 55, 54 and 63 of the 264 coverage cells in Chromium, WebKit and
Firefox (run of 2026-09-29, output/golden-E1-vs-4.3.1.json).

The "recorded trade-off still occurs" check now runs only when --baseline is
a 4.2 build: the trade-offs are changes against 4.2.0, and against 4.3.1
(the plan's golden command) they are identical cells, so the check failed
for a reason that says nothing about the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tsconfig.json includes every .ts file, so the site's type check (next build
runs it) also read the global.d.ts files that packages/typeset-v4/dist and
public/releases/4.3.0 and 4.3.1 declare: each types window.Typeset as its
own build's API. src/lib/v4/typeset.go.ts types it as the API in src, so any
change to that API before a release cut makes the declarations disagree.
Adding one outcome to OUTCOMES (the next commit) gave TS2717 and TS2322 in
typeset.go.ts, typeset.release.standalone.ts, typeset.website-go.ts and
src/components/TypesetV4.tsx ("Source has 43 element(s) but target allows
only 42"), which would fail npm run build on release/4.4.0 until the cut
rewrites dist. The site imports the engine from src, never from dist or the
release archive, so both are excluded; tsc -p tsconfig.json passes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e measuring it

WebKit's Range.getClientRects() takes time in proportion to the length of
the line a range is on, and measuring a paragraph reads a word the browser
splits across lines one character at a time, so a long run costs the square
of its length. SUPPORT.md documents about 37 s in typeset() for 11,000
closing quotes and a letter and 15 s for 11,000 letters, a hyphen and a
letter, both under the 12,000-character budget, and the default script tag
composes every p, li and td, comments and profiles included: one hostile
comment froze Safari for every visitor (adoption review, serious item 5).

exceedsRunBudget() (break-opportunities.ts) finds whether more than 500 code
points, collapsible white space aside, lie between two line-break
opportunities. Prose costs almost nothing: the text is first split where a
break is certain (a letter or digit, spaces, a letter: UAX #14 LB18 with no
earlier rule applying; all 2,868 such breaks in the 118 corpus paragraphs
are breaks the Unicode rules give), and only a piece longer than 500
characters runs the Unicode rules. typeset() checks it before the first measureLayout and
declines as native:run-budget (named after native:budget, native:line-budget
and native:tracking-budget) with no line boxes read; planRichText() checks
it too, so TypesetRichText declines the same way, and its onResult reads no
boxes for it either. No opt-out: it is a safety limit, and the browser wraps
such runs itself (overflow-wrap: break-word wraps them at the measure).

Measured with typeset() at 600 px, 18px Georgia, 4.3.1 against this build
(output/e1/run-budget-timing.json, 2026-09-29):
  WebKit 26.5:    11,000 closing quotes 24,842 ms -> 18 ms; 11,000 letters,
                  a hyphen and a letter 9,904 ms -> 8 ms
  Chromium 149:   29 ms -> 19 ms; 1,328 ms -> 7 ms
  Firefox 151:    33 ms -> 24 ms; 156 ms -> 10 ms
verify-promise now checks five such inputs (quotes, letters and a hyphen,
letters in a sentence, 5,000 spaced opening brackets, 3,000 no-break-joined
words) in all three engines: native:run-budget in under 50 ms from typeset()
and from planRichText(), nothing measured, text unchanged; and that runs of
499 and 500 characters are not declined and 501 is.

Rendering: golden run against 4.3.1 (output/golden-E1-vs-4.3.1.json), the
existing 3,932 cells per engine are unchanged by this commit; in the
coverage corpus the 5 long-run cases over the limit (40 cells per engine)
go from native:no-candidate to native:run-budget, and no cell that 4.3.1
composed changes (55, 54 and 63 composed in Chromium, WebKit and Firefox,
before and after). The 499-letter run and the 600-character URL that breaks
at its slashes are identical to 4.3.1. The coverage cases now carry these
expected outcomes. OUTCOMES.md, docs/outcomes.md (npm run docs:outcomes),
the README's outcome count (43) and capabilities.json name the new code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every spacing and hanging marker carried 18 inline declarations, and every
tracking wrapper an inline all: unset, which Chromium, WebKit and Firefox
serialize as every longhand: 6,473, 7,938 and 5,224 bytes of style
attribute per wrapper (output/e1/cascade-probe, 2026-09-29). The adoption
review counted "about 18 inline style rules" per marker and every added
element styled inline, which bloats pages and session-replay recordings
(serious item 4).

The shared declarations now live in the engine's constructable stylesheet
(LIFECYCLE_CSS, mirrored in dist/styles.css). Each marker keeps only its
own margin-left, each wrapper its letter-spacing and word-spacing:
- Spacing and hanging markers: !important in the typeset-markers cascade
  layer. An unlayered !important page rule, which beat the inline
  declarations, loses to it, so page CSS reaches them no more easily than
  before. margin-left stays inline and outside the layer, so the stale and
  print rules still zero it.
- Tracking wrappers: all: unset in a rule that is not important, weighted
  with three :not(#_) so ordinary page rules do not reach it, as they did
  not reach the inline all: unset. An !important all: unset (tried first,
  with letter-spacing: revert-layer) hid the inline spacing: revert-layer in
  an important layer rolls back to "normal" in all three engines, and
  tracking rolled back everywhere. Normal importance keeps the wrapper's
  spacing reachable by the stale and print rules and by page !important
  rules such as the WCAG 1.4.12 text-spacing override, as in 4.3.
- Breaks keep their one inline !important declaration, which no rule can
  override; a layer would let an earlier layer's !important rule hide them.
- markerRules() installs the sheet before markers are written and checks
  it is adopted by the element's document or shadow root. Where it is not
  (no constructable stylesheets), markers carry the 4.3 declarations inline.
  The inline margin uses longhands: React 19 warned about a shorthand
  beside its longhand when a style object changed between renders.
  TypesetRichText keeps its last answer while React 19's strict mode renders
  with the host ref detached.

Measured on the 85 tests/v4-corpus.json paragraphs at 375 px in 18px
Georgia, default options, per composed paragraph (output/e1/marker-metrics.json):
  engine     elements   style attribute bytes   outerHTML bytes
  Chromium   20.6       21,991 -> 690            23,659 -> 2,357
  WebKit     20.7       26,192 -> 700            27,865 -> 2,373
  Firefox    20.5       18,389 -> 672            20,055 -> 2,338
Element counts do not change.

Rendering: golden run against 4.3.1 (output/golden-E1-vs-4.3.1.json), 0 of
the 3,932 existing cells per engine change; 2,117 (Chromium), 2,104
(WebKit) and 2,119 (Firefox) differ only in marker style attributes and are
counted apart as styleMoved, a new neutral category that requires the same
per-marker values and identical line boxes and marker boxes; in the
coverage corpus 52, 51 and 59 cells are styleMoved and none otherwise
changes. (WebKit's computed margin-left reports the used 1/64 px value for
4.3's inline declarations and the specified one here; the line boxes are
identical, so the geometry check compares boxes, not that string.)

verify-spacing gains checks in all three engines: markers carry only their
own values inline and compute as 4.3 wrote them; a shadow root adopts the
sheet; with adoptedStyleSheets removed they fall back to the 4.3
declarations with identical outcomes, finishes and geometry; and with
span{display:block!important;padding:3px!important;margin:9px} on the page
every paragraph gets 4.3.1's outcome or a native one, text intact, one line
per composed line. React markers carry only their own values. The
/pairing-cards PNG export (html2canvas-pro clones the page without adopted
sheets) renders identically with the sheet and with every marker's 4.3
declarations inline: byte-identical PNGs in Chromium and WebKit, 0 differing
pixels in Firefox, both card sizes (next build of this branch, 2026-09-29).
verify-promise: a page's [data-ts-hang]{position:relative!important} no
longer reaches the marker, so the hang applies, static, where 4.3 rolled it
back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Composed text installs a document copy handler that puts the source text
on the clipboard without the generated line breaks. It yields to a handler
the page registered first, but there was no way to turn it off, which the
adoption review's security lens flagged for publishers whose attribution
or data-loss-prevention scripts run later and read the native copy
(smaller enterprise-review snags, item a).

copy (default true) is now an option of typeset(), mount() and both React
adapters, and data-typeset-copy="false" on the auto and go loaders. An
element composed with copy: false is never registered with the handler,
and the handler is added only when some composed element has copy on, so a
page composed entirely with copy: false has none. The browser's own copy of
such an element has a line break at every composed line end; the option's
JSDoc and the README row say so. A copy that reaches an enabled host is
still serialized, as before. The option is part of the options key, a
TypesetRichText copy prop change registers or releases the host, and
development builds validate it as a boolean.

Tests, all three engines: verify-copy-privacy copies from a page composed
with copy: false (no copy handled; a copy inside a paragraph is native,
with line breaks, and has the uncomposed page's words) and from a page with
one host off and the rest on (the off host's copy is native; copies that
reach an enabled host equal native copy of the uncomposed page).
verify-loaders runs every loader with data-typeset-copy="false" (copy left
to the browser, composition and finishes unchanged) and checks that by
default a copy puts the source text on the clipboard. verify-options checks
that copy: true and false log nothing and copy: "no" warns once. The
warning text is not yet in SUPPORT.md's list, which the DOCS step edits.

Rendering: none. The golden run against 4.3.1 is unchanged by this commit
(the default is true).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
auditJSON() and typeset-audit reported an overflow error, and pass: false,
for a paragraph that cuts its own text off with white-space: nowrap,
overflow: hidden and text-overflow: ellipsis (the adoption review's #ellip
probe: "1270.66px outside content box"), so a page using ordinary
truncation failed the CI gate the README sells.

When an element overflows and its overflow is hidden or clip with
text-overflow: ellipsis or a -webkit-line-clamp, the audit now adds the
review item clipped ("Npx clipped on purpose (overflow: hidden,
text-overflow: ellipsis)") instead of the overflow error. Review items
never fail the gate, so pass follows the existing rule. Overflow without
clipping is still an error. The CLI's help says review items, text clipped
on purpose among them, never fail it; capabilities.json says the same.

Tests, all three engines: verify-audit checks that the ellipsis case passes
with exactly one clipped review item, that a line clamp clipping a long
word is a clipped review and not an overflow error, and that unclipped
overflow is still an error. verify-cli audits a page with such a paragraph
through the CLI: exit 0, pass true, a clipped review targeting #ellip, no
overflow issue (4.3: exit 1).

Rendering: none (the audit only reads).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured per commit with the release recipe (Node 24 zlib, gzip level 9),
from the 4.3.1 cut to this branch, on typeset.global.js (react.js+shared in
parentheses):
  run budget (f33340c)            +227 B (+200)
  marker stylesheet (5ec07a7)     +359 B (+489)
  copy: false (e7e7734)            +57 B  (+72)
  clipped audit item (19ff84a)     +95 B   (+0)
  total                           +738 B (+761), 1.3% (1.4%)
Every entry: mount-only 50,361 -> 51,005, TypesetText-only 50,489 ->
51,157, TypesetRichText-only 46,820 -> 47,399, smartQuotes-only 1,544
unchanged, react.js+shared 54,083 -> 54,844, TypesetText-no-tree-shaking
63,169 -> 64,027, go.js 56,467 -> 57,213, typeset.global.js 56,122 ->
56,860; dist/auto.js 56,479 -> 57,228. Measured against the 4.3.0
budgets, seven entries exceeded the 2% tolerance, so the budgets now hold
the measured sizes, with this raise recorded in budgets.json. The marker
move costs bytes in the bundle and saves about 21 KB of style attributes
per composed paragraph at 375 px in Chromium (25 KB WebKit, 18 KB Firefox).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full test:v4 on 4533257 (M2 Pro, 2026-09-29) failed one check out of
13,895: verify-package-contents' "unpacked package is under 1.91 MB", at
1,936,451 bytes (87 files), against 4.3.1's 1,902,907. Per file, against
public/releases/4.3.1: react.cjs +3,438 B, index.cjs +3,150, the shared ESM
chunk +3,142, typeset.global.js.map +2,242, go.js.map +2,220, auto.js
+2,090, go.js +2,080, typeset.global.js +2,042, lifecycle.d.ts and .d.cts
+1,770 each, capabilities.json +1,367, break-opportunities.d.ts and .d.cts
+991 each, styles.css +958, react.js +927, OUTCOMES.md +589, the other
declarations and README.md the rest: the run budget, the markers'
stylesheet with its inline fallback, the copy option and the clipped audit
item in every build, and their docs. The limit moves to 1.94 MB rather
than any fix being dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ct at the script tag

The adoption review (2026-09-28) found three first-hour traps the README
left to SUPPORT.md or to nothing:

- Language. English line-end preferences apply only to text declared
  English. On 42 paragraphs at 375 px in Chromium, stranded short words at
  line ends went from 59 to 7 with lang="en" and only to 36 untagged; the
  install section never said to set lang. "Do I need it?" gains a row for
  languages, scripts and text styles Typeset leaves native, noting the
  script still downloads.
- User-generated text. The script tag's default selector covers comments
  and profiles, and in 4.3 one long unbroken run froze a Safari tab. The
  warning under the script tag says to exclude those areas, and that from
  4.4 such paragraphs are declined as native:run-budget (E1, f33340c).
  SUPPORT.md's limitation now gives E1's timings (WebKit 24,842 ms and
  9,904 ms against 18 ms and 8 ms) instead of "4.3 keeps the exact
  measurement".
- Server-rendered React. Composing before hydration made React 19 report
  #418 and React 18 #425, #418 and #423, and client-render the root; a
  300 ms hydration delay was enough. The warning says so, says 4.4 waits
  for hydration where it finds a framework's markers, and points to the
  adapters or mount() in useEffect.

The React section also stops saying server-rendered text rewraps "without
adding a line" (its own FAQ says 1 in 6 paragraphs at 320 px take one),
points MDX, next/link, router-link and <Trans> content to the SetArticle
mount() recipe on /install/frameworks, and says the adapters are React DOM
only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion

The README's SEO answer was a bare "No", and "What it won't do" left out
the side effects of real <br> line breaks that SUPPORT.md and the adoption
review measured:

- Find-in-page and Text Fragment links (#:~:text=, which Google uses to
  highlight a quoted passage) miss a phrase across a generated break:
  window.find found 43 of 60 five-word phrases at 375 px and 52 of 60 at
  768 px, in each of Chromium, WebKit and Firefox, against 60 of 60
  uncomposed.
- Firefox Reader View (Readability) double-wrapped 35 of 40 paragraphs
  composed at 375 px (4.2.0: 0). Safari Reader, Chrome Reading mode and
  Edge Immersive Reader are unmeasured, and the README now says so.
- A live English to Spanish Google Translate check put a stray space
  before punctuation in 3 of 9 paragraphs in all three engines. Safari's
  and Firefox's translators are not detected and untested; README and
  SUPPORT.md now say that, and SUPPORT.md notes that WebKit's text
  manipulation API ends an item at each <br>.

The SEO answer keeps "not for indexing or ranking" and adds the effect on
readers arriving from a search, with data-no-typeset recommended for
documentation and reference pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le recipe

The adoption review found that the defaults differ by entry point and were
spread over two tables and prose: the script tag curls quotes and hangs
punctuation, typeset.us/go, mount() and the adapters do not, and spacing
and tracking are on everywhere. A table under Options now gives, per
install method (script tag or typeset.us/auto, typeset.us/go, mount(),
typeset(), TypesetText, TypesetRichText), what it sets, smart quotes,
hanging punctuation, spacing and tracking, headings and the copy option,
as the 4.3.1 loaders and adapters behave.

"What it costs" gains a recipe that imports the package after
requestIdleCallback (with a timer fallback) and says it moves the
download, not the main-thread work, plus the gzip size of each entry. Sizes
are 4.3.1's, like the rest of that section: esbuild bundles of
packages/typeset-v4/dist, minified, gzip -9, measured 2026-09-29:
typeset() 44,294 B, mount 50,361, TypesetText 50,489, TypesetRichText
46,820, analyzeBreaks 14,522, smartQuotes 1,544, auto.js 56,476. On the
E1 candidate (7e20d13) they are 44,785, 51,005, 51,157, 47,399, 14,522,
1,544 and 57,228; the 4.4.0 docs pass restates them for the final build.
The FAQ's "can I defer it?" answer links the recipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
STABILITY.md said only that the previous minor gets security fixes, and
SECURITY.md's table had no dates, so nobody could plan an upgrade (PLAN
decision 19; the dates await the owner as O3, and these are the defaults):

- The 4.x line gets bug and security fixes until at least 2027-09-30,
  whenever 5.0 ships.
- When a minor ships, the minor before it gets security fixes for 60 days.
  4.2.x: until 2026-11-26, 60 days after 4.3.0 reached npm
  (npm view typeset.us time: 2026-09-27T05:09Z). The row also says 4.2
  has the screen-reader defect 4.3 fixed.

The package README's Stability section says the same in one sentence.

SECURITY.md lists the email first: GitHub private vulnerability reporting
is switched off for the repository (S10, gh api 2026-09-29), so the old
"Preferred" link led nowhere. "How releases are made" now says 4.3.1 was
published by release.yml with provenance
(npm view typeset.us@4.3.1 dist.attestations, 2026-09-29: an SLSA v1
provenance attestation) and 4.0.0 to 4.3.0 have none.

verify-release-trust requires the dated rows, the until-at-least date, the
60-day rule and the email before the private-reporting link, instead of
the literal "| 4.2.x | Security fixes only |". packages/typeset-v4/SECURITY.md
is kept byte-identical to SECURITY.md, as that suite checks and as
release-cut would copy it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The description and the README's first line said "verified in Chrome,
Safari and Firefox". The suites run Playwright's Chromium, WebKit and
Firefox on macOS; WebKit there is not Safari, and no shipping Safari,
physical phone or Windows machine is in the matrix (adoption review,
project-health and stranger-first-hour lenses). The words now name what
is tested. verify-package-contents checks the new description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verify-package-contents failed on the adoption docs: "unpacked package is
under 1.94 MB" at 1,944,532 bytes, against E1's 1,936,451 (M2 Pro,
2026-09-29). After two small wording cuts the tree packs 1,944,497 B,
+8,046 B over 7e20d13, all in packaged docs: README.md +6,919 (install
warnings for user-generated text, server-rendered React and lang, the
defaults table, the idle-load recipe with per-entry sizes, and the
find-in-page, reader-view and translation limits), SUPPORT.md +672
(run-budget timings, translator notes) and SECURITY.md +455 (the dated
support window). No build output changed. The limit moves to 1.95 MB
rather than any of those docs being dropped; the suite passed at 1.95 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…only declared English in the auto loader

A double quote with white space or the text's edge on both sides now stays
straight: French spaced quotes (Il a dit : " Bonjour ") became two closing
quotes. So does a double quote with no open quotation to close:
width="100" became width=”100", and aria-live="polite" and said:"hello"
got a closing quote first. "Hello," she said., primes (5'10", 27") and
elisions (Rock 'n' roll in the '90s) are as in 4.3.1, and the million-text
equivalence check holds against the updated reference. These apply
wherever quotes are educated (class B).

The auto loader curled quotes in untagged text, so untagged German and
French pages got English quotes (Er sagte: "Hallo Welt" -> “Hallo Welt”).
Its default is now smartQuotes: 'en-declared', a new value: English quotes
only where the element or an ancestor declares English.
data-typeset-smart-quotes="en" restores the 4.3 behaviour, and an explicit
npm smartQuotes: 'en' keeps its meaning. go.js accepts "en-declared" too.
The React adapters see only their own lang prop during render, so
'en-declared' needs lang on the component there.

A lang is read through the new language.ts: en_US and en_US.UTF-8 are
en-US and english is en, so such pages count as English for quotes.

SUPPORT.md lists the development warning for smartQuotes and is edited
only by the DOCS step, so verify-options' check of that list is recorded
in known-failures.json until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4.3 said nothing when a page's blocks all declined (a lang it could not
read, such as en_US before 4.4, left every block native) or when composed
text declared no language, so English line-end preferences were off.

- Both loaders log at most two console.info lines after their first pass,
  never a warning or an error: "no lang attribute, so English line-end
  preferences are off; add lang="en" to <html> if the page is in
  English", only when composed blocks are untagged; and "none of the N
  matched blocks was composed; most common: <outcome> ×n; see
  https://typeset.us/docs". Outcomes that mean nothing needed improving
  (fits, empty, sentence-aligned, paragraph-rhythm) do not count.
- auditJSON() gains the review items untagged (one for the scope) and
  uncomposed (nothing in scope composed for another reason, including
  native:environment under jsdom and happy-dom), with the most common
  outcome. pass does not change.
- typeset-audit prints one "typeset-audit: warning:" line on stderr for
  uncomposed, naming the widths; the exit code does not change.

Tests: verify-audit (untagged, lang="en", the legacy path, a declined
scope, a scope that fits, native:environment), verify-cli (the warning
line and exit 0; no line for a composed page) and verify-react-node (the
jsdom lane's uncomposed item). The loader lines are checked in
verify-loaders with the other 4.4 loader changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A composed heading that wraps has a generated line break inside it, which
iOS VoiceOver may read as two items. No one has checked that by ear yet,
and STABILITY.md changes a default only for a verified accessibility
failure, so the script tag keeps composing h1-h6 by default (PLAN
decision 5) and organisations that need an accessibility sign-off can
turn it off:

- headings: false for mount() and typesetAll(), and
  data-typeset-headings="false" on both loaders, leave h1-h6,
  [role=heading] and anything inside them untouched, with no outcome
  written (no data-ts-outcome, not even native:environment under jsdom).
- typeset(el) still composes the element it is given; the JSDoc and the
  README say so.
- Development builds check that headings is a boolean.

Without the option output is identical to 4.3.1 (class A). The checks
(typesetAll(), mount() and typeset() in three engines, the auto loader
with the attribute, the option warnings) land with the coverage suite in
the next commit, which exercises all of the 4.4 loader options together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arkup, with coverage: 'core' to keep 4.3.1's coverage

Verified defects, on under both coverages (class B):
- A lang in another spelling is read as the language it names: en_US,
  en_US.UTF-8, EN_us and en_GB (CMS and PHP templates), and the
  unambiguous names english, Deutsch, francais, español, italiano,
  nederlands, português. 4.3 read them as unknown languages and left
  every block native:language; they now compose exactly as en-US, en,
  de, fr and es do (the golden run's reference check, all 24 cells per
  engine, outcome and markup).
- Up to three Greek or Cyrillic letters in a row in Latin text: "5 μg/mL",
  "α-synuclein", "ΔG" and "ПЦР" compose, which makes the μg entries in
  phrase-boundaries reachable ("5 μg" is bound like "5 mg"). A Greek
  sentence, a Cyrillic word or four such letters still decline.

Coverage additions (class C), behind coverage: 'extended' | 'core'
(data-typeset-coverage on both loaders, the coverage prop on both React
adapters), whose default is the one constant COVERAGE_DEFAULT in
coverage.ts: 'extended' for now, owner decision O1 at the cut.
- Any lang whose likely script is Latin (pt, it, nl, pl, sv, tr, vi,
  fil, sw, ht, sr-Latn and the rest) composes with neutral preferences,
  as untagged text does, and an unreadable tag counts as untagged. ar,
  he, ja, zh, th, hi, ko, el and Cyrillic sr still decline.
- A descendant in another Latin-script language is part of its
  paragraph, set with the block's preferences; a Japanese phrase still
  leaves it native:mixed-language.
- time, dfn, kbd and ins; sup, sub, vertical-align super and sub, and sup
  raised with position: relative and a top offset (normalize.css),
  measured in place with the rendered lines as the check.
- Visually hidden text (the sr-only pattern) and aria-hidden elements
  with no width are zero-width atoms: no break inside them, left out of
  measurement, spacing and tracking, and a space beside them keeps its
  natural width (a spacing marker there left a white-space-only text node
  that Chromium dropped from its tree: "hours on" read "hourson").
- ::after link icons, visible aria-hidden icons, br, img, svg, q and bdi
  stay native.

Golden, once on coverage: 'core' against the published 4.3.1, with the
coverage corpus composed again with the defaults (the new
coverage-default pass): existing corpus 0 of 3,932 cells changed per
engine; coverage corpus (47 cases, 376 cells) under core 104 changed, all
class B or E1's run budget, and every class C cell identical; with the
defaults 280 changed, 0 unexplained; composed 55/54/63 (4.3.1) ->
103/102/111 (core) -> 278/276/286 (default) in Chromium/WebKit/Firefox.
Two cells that newly compose as English end with more weak line ends
than native are allowed trade-offs with their reasons (ΔG at 320 px, the
footnote sup at 240 px, whose lines are those 4.3.1 gives the paragraph
without it).

verify-coverage (new, in suites.mjs) checks spellings, 23 declared
languages, descendants, scripts, a 30-case inline matrix, quotes and
headings against 4.3.1 and under core in three engines;
verify-native-ax composes the new markup and compares its words with
Chromium's tree before composition, which joins words at sr-only text
natively too; verify-loaders and verify-options cover the 4.4 loader
attributes and options (quotes, coverage, headings, the notes). OUTCOMES
meanings regenerated with docs:outcomes; no new outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on the E2 candidate against E1's head (7e20d13), release recipe
(Node 24 zlib, gzip level 9):
  typeset.global.js            56,860 -> 58,482  +1,622 B (2.9%)
  go.js                        57,213 -> 59,037  +1,824 B (3.2%)
  react.js+shared              54,844 -> 56,150  +1,306 B (2.4%)
  mount-only                   51,005 -> 52,262  +1,257 B (2.5%)
  TypesetText-only             51,157 -> 52,416  +1,259 B (2.5%)
  TypesetRichText-only         47,399 -> 48,562  +1,163 B (2.5%)
  TypesetText-no-tree-shaking  64,027 -> 65,697  +1,670 B (2.6%)
  smartQuotes-only              1,544 ->  1,556     +12 B (0.8%)
  dist/auto.js                 57,228 -> 59,035  +1,807 B (3.2%)
Seven entries exceeded the 2% tolerance, so budgets.json holds the
measured sizes, with the raise and what it pays for recorded.

verify-package-contents failed at 2,001,720 B against the 1.94 MB cap;
with the last fix the package is 2,003,354 B (95 files, +66,903 B over
E1): each engine bundle +4.0 to +7.1 KB, the two source maps +6.5 and
+7.4 KB, eight new declaration files (7.0 KB), and the options' docs.
The cap moves to 2.01 MB (4.2.0: 2.57 MB) rather than any fix being
dropped. Re-run: verify-budgets 8 checks and verify-package-contents 29
checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Visual tests and screenshot tools had no way to know when the engine had
finished: mount()'s ready resolves once, before offscreen and later work,
and the React adapters expose nothing. whenSettled(options?: { timeout?:
number }) resolves { settled: true } once every mount() controller and
every React adapter host has its outcome, no composition work is queued or
timed (a resize settle, records waiting a task, a reveal waiting a frame),
no web font is loading, at two checks 50 ms apart. It resolves
{ settled: false } if work remains at the timeout (10 s by default), and
{ settled: true } at once where nothing can be composed (jsdom, happy-dom).
It is exported from typeset.us, typeset.us/react and window.Typeset.

Each controller and each document's adapter registry registers a busy test
in a list kept on globalThis under Symbol.for('typeset.us/work'), so copies
of the engine on one page share it (the CommonJS index and react bundles,
or a script-tag loader beside an npm import). It polls with a timer, not
an animation frame: a frame requested every frame left no idle period long
enough for the adapters' offscreen work, which then composed one host a
second (the first verify-react run caught that; 10 s timeouts).

Tests: verify-controller (settles after the first composition; waits out a
width change's settle and recomposition, then nothing changes for 300 ms;
{ timeout: 40 } during the settle gives false), verify-react (60 adapter
hosts, on screen and off, beside a mount() paragraph from dist/index.js,
with a web font served 400 ms late: both entries resolve settled after the
font, every host has an outcome, and two screenshots and the DOM 500 ms
apart are identical, React 18 and 19 in Chromium, WebKit and Firefox),
verify-react-node (resolves at once under jsdom and happy-dom, both
entries, ESM and CJS; the types accept it and reject a string timeout).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The auto and go loaders composed at DOMContentLoaded. On a server-rendered
React page whose hydration came later, the composed paragraphs no longer
matched the server HTML: React 18 and 19 reported recoverable hydration
errors (#418, #425, #423), threw the server markup away and rendered the
root again on the client, and the page was composed twice. The adoption
review's probe showed it for every hydration after composition, and the
typeset.us site tells Framer and Wix users about it.

Now, when the page carries a server-rendering marker (#__next,
#__NEXT_DATA__, self.__next_f, #___gatsby, [data-framer-hydrate-v2],
astro-island, [data-server-rendered], or a React root already on the
document, the body or a child of the body), the first composition waits
for the load event, then for the framework's own signal, then for one
idle callback (a 50 ms timer without requestIdleCallback), capped at 10 s
after load. React's signal is the keys it sets: a __reactContainer$ root,
and a __reactFiber$ key on each server-rendered target or on an ancestor
below the body (which covers dangerouslySetInnerHTML content React never
hydrates node by node). Astro's is no astro-island[ssr] left (client:visible
and client:media islands are not waited for); Vue 2's is no
[data-server-rendered] left. data-typeset-defer="hydration" waits without
a marker; data-typeset-defer="none" keeps 4.3's timing. Without a marker
the timing is 4.3's. whenSettled() counts the wait as work.

Probe (adoption-fixes/tools/hydration-probe, Chromium, candidate auto.js,
hydrateRoot 300 ms after the client script runs), SSR article under <div
id="__next">: React 18.2.0: 0 recoverable errors, server node kept, 47
breaks, first composition at 358 ms after hydrateRoot at 325 ms; React
19.3.0: 0 errors, kept, 47 breaks, 384 ms after 348 ms. The same page
under <div id=root> (no marker), and under #__next with data-typeset-
defer="none", still gives 4.3's errors (12 recoverable errors under React
18, 1 under React 19). verify-loaders checks both loaders in three engines
with React's keys simulated: nothing composed before the root or the fiber
keys, composed after, whenSettled() waiting for both; the defer="none" and
unmarked defer="hydration" pages compose within 400 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The package's jsdelivr and unpkg fields pointed at dist/typeset.global.js,
which only defines window.Typeset: a newcomer who pasted the bare
https://cdn.jsdelivr.net/npm/typeset.us URL got a script that composed
nothing. Both fields now point at dist/auto.js, the automatic loader, the
same bytes as typeset.us/go@<version>.js.

typeset.us/go is the opt-in loader (it composes only [data-typeset]), while
the website's go.js is the automatic one, so the name misled. A new export,
typeset.us/opt-in, points at the same file and types (dist/go.js,
go.d.ts, with a typesVersions entry). typeset.us/go stays as a deprecated
alias of it, removed no sooner than 5.0; nothing is removed now.

capabilities.json gains whenSettled, hydration and entryPoints entries.
Its websiteLoader.cdnDefault still names typeset.global.js: the E2 branch
edits that line too, so it is left for the merge (changelog-E3.md says so).

Tests: verify-package-contents (the CDN fields; ./opt-in and ./go resolve
to the same file and types, packed), verify-react-node (import
'typeset.us/opt-in' types window.TypesetReady and window.Typeset, and the
globals type whenSettled()), and verify-test-runners' attw run now names
./opt-in (CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verify-package-contents measured 1,956,832 bytes against the 1.94 MB cap
on the first E3 run, and 1,956,329 bytes (89 files) after whenSettled()
moved to a timer: +19,878 over E1's 1,936,451. Per file, against the E1
candidate (7e20d13): go.js.map +3,766 B, auto.js and go.js +2,248 each
(the hydration wait and whenSettled()), the shared ESM chunk +1,718,
index.cjs +1,724, react.cjs +1,678, typeset.global.js.map +1,523,
typeset.global.js +808, settled.d.ts and .d.cts +810 each, the four
release declarations +420 together, manifest.json +285, react.js +203,
and capabilities.json's whenSettled, hydration and entryPoints entries
(+2.1 KB). The limit moves to 1.96 MB rather than any fix being dropped.
Merged with E2 the package will need measuring again.

Gzip, level 9, against E1: dist/auto.js 57,228 -> 58,115, go.js 57,213 ->
58,107, typeset.global.js 56,860 -> 57,140, react.js+shared 54,844 ->
55,215, mount-only 51,005 -> 51,088, TypesetText-only 51,157 -> 51,230,
TypesetRichText-only 47,399 -> 47,487, TypesetText-no-tree-shaking 64,027
-> 64,366, smartQuotes-only 1,544 unchanged; all within verify-budgets'
2% tolerance, so budgets.json is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng idle periods

After a stepped 1280 to 900 px window resize on the BWA site in Chromium,
off-screen TypesetText and TypesetRichText blocks sometimes caught up late
(last host mutation about 4.4 s after the resize, against about 1.6 s) and
some still showed native lines 8 s on. A first A/B sample (3 of 7
route-runs with 4.3.1, 0 of 6 with 4.3.0) made it look like a 4.3.1
regression, with db22d8d's on-screen page-time hold-off as the suspect.
It is not one:
- the same probe in one bisect flagged 3 of 16 route-runs with the 4.3.0
  build, 1 of 24 with 4.3.1's, 4 of 16 with cea3a10 and 1 of 16 with
  db22d8d;
- instrumented builds (db22d8d, and cea3a10 before it) held no block in
  any of 68 route-runs, flagged or not (0 HOLD and 0 held events), so the
  hold-off and its settle never acted;
- in all 10 flagged instrumented runs Chromium had stopped giving the page
  idle periods once the probe's frames stopped, and every one of the
  registry's idle passes fired on its timeout (clean runs: 2, both while
  the probe's frames ran); with an idle monitor, those runs got 0 or 1
  idle callbacks between 1.6 and 5 s after the resize, the clean ones 65
  to 73.

The registry waits up to IDLE_TIMEOUT_MS (1 s) for a long idle period, and
a callback that fires on its timeout composes one block, so a page given
no idle time composed its off-screen blocks one a second.

Now, once an idle period has come, the callback also times out
STARVED_IDLE_MS (50 ms) of page time after the last one. With none since,
or none at all by the 1 s timeout, the browser has stopped giving them:
the remaining blocks compose in frames within FRAME_BUDGET_MS, as nearby
blocks do, until an idle period comes again. A timeout that fires more than
20 ms late (a long task held the thread) waits on instead, and while short
idle periods keep coming a block still composes only on the 1 s timeout,
so a screen push still spends no animation frame on far text. The
continuous-change hold-off (dbd30a3, db22d8d) is unchanged, so the storm
fix (D1) and the separate-change fix (R1) stand.

verify-scheduler gains a check: 48 TypesetText blocks, their container
narrowed from 420 to 380 px with frames running for 600 ms, then frames and
idle periods stop (every idle callback fires on its timeout). 4.3.1: the 23
off-screen blocks left native took 44 s in Chromium, 19 still native at
5 s; this change: 199 ms in Chromium, 173 ms in Firefox (WebKit has no
requestIdleCallback; the registry uses a timer there).

Tested once each, M2 Pro: tsc (tsconfig.json, tsconfig.scripts.json);
verify-release --only recompose-storms,settle,scheduler,reflow-triggers,react
6 of 6 suites pass, 910 checks (storms 53, reflow-triggers 46, scheduler 99,
settle 420, react 231, react-node 61). BWA probe (candidate packed into a
copy of the 4.3.0 site build, Chromium, routes / and /team, 1280 to 900):
3 runs, 0 of 6 route-runs flagged, last resize mutation 1.52 to 1.66 s.

The WebKit verify-reflow-triggers text-spacing outlier (538 ms against a
400 ms limit, one hosted PR run of 244308d) does not share this cause: that
check drives mount(), not the React registry, and WebKit has no
requestIdleCallback. Locally 4.3.0 and 4.3.1 both measure 1 to 2 ms (16
runs, 6 under 8 CPU burners).

Evidence: ~/claude-handoff/2026-09-29/typeset-4.3.2-bisect/runs/ (the
instrumented timelines) and ~/claude-handoff/2026-09-29/typeset-4.3.2-fix/
(suite run, the check's scenario on 4.3.1 and on this change, probe runs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ult file

E3 branched from E1's 7e20d13, beside E2. The only conflict was the
unpacked-package cap in verify-package-contents; both agents' notes are kept
and the cap is set from the merged package once it is measured. capabilities.json's
websiteLoader.cdnDefault now says bare CDN URLs serve dist/auto.js, as E3's
entryPoints.cdnDefault already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…port window

The docs branched from E1's 7e20d13, beside E2 and E3. The only conflict was
the unpacked-package cap in verify-package-contents again; the docs' note joins
E2's and E3's and the cap is set from the merged package once it is measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e22ce25 composes off-screen React blocks in frames once the browser stops
giving idle periods. It merged cleanly beside E3's whenSettled() registration
in adapter-registry.ts: the registry's busy check reads pending hosts, which
the starved path drains, so whenSettled() waits for that work too. The
"4.3.2 - Unreleased" section comes along and stays below 4.4.0's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d install lines, demo resizes and the CI trim

origin/adoption/site at 27b9d95, which master merged as PR #12, including
the CI trim (pull requests and master only, weekly release-artifact runs,
monthly Dependabot). It merged without conflicts: the site owns
verify-smart-quotes' prose file list and verify-docs' CURRENT list (both drop
docs/show-hn.md), E2 owns the rest of verify-smart-quotes, and the site's ci.yml
check in verify-release-trust sits apart from the docs' support-window rows.
The site copy still describes 4.3.1; its 4.4 wording (Framer and Wix under
the hydration wait, the defaults under each snippet) is left for the docs pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The section is assembled from the E1, E2 and E3 notes, the docs and site
branches and the 4.3.2 fix it carries. It states the open coverage-default
decision (O1): the build defaults to coverage: 'extended', and the cut
either keeps that with the STABILITY.md sentence that allows it or sets
COVERAGE_DEFAULT to 'core'.

- Rendering changes: E2's golden run against the published 4.3.1 on
  coverage: 'core', with the coverage corpus composed again on the default.
  Existing corpus 0 of 3,932 cells changed per engine (style-moved-only
  2,117 / 2,104 / 2,119); coverage corpus 104 of 376 under 'core', all
  defect fixes, and 280 of 376 on the default, 0 unexplained; coverage
  cells composed 55/54/63 (4.3.1), 103/102/111 ('core'), 278/276/286
  (default) in Chromium, WebKit and Firefox.
- Sizes: the published 4.3.1 dist against this candidate, gzip -9 (go.js
  56,467 -> 59,887, auto.js 56,476 -> 59,880, typeset.global.js 56,122 ->
  58,767, mount-only 50,361 -> 52,356, TypesetText-only 50,489 -> 52,629).
- The package README, STABILITY.md and llms.txt name 4.4.0 with the
  integrity placeholder release-cut fills, and the README's download sizes
  are 4.4.0's. The README also stops saying what 4.4 changed: the script
  tag curls quotes only in declared English, typeset.us/opt-in is the
  opt-in loader's name, data-typeset-defer has a row, and other
  Latin-script languages compose with neutral preferences.
- SUPPORT.md names the new development warnings (copy, headings, coverage
  and the three smartQuotes values), so verify-options' known failure is
  removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4.4.0 composes other Latin-script languages and more inline markup by
default (coverage: 'extended'). STABILITY.md as 4.3 wrote it allows a
change to default output only for a verified defect or behind an option
that is off by default, so this sentence is what makes that default
allowed: each addition is listed with its count, never changes a paragraph
4.3.1 composed (the golden run on coverage: 'core' shows 0 changed cells
there), and coverage: 'core' turns it off.

This is the maintainer's decision O1, open until the cut. It is its own
commit so that declining it is a revert of this commit plus setting
COVERAGE_DEFAULT in src/lib/v4/coverage.ts to 'core'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
speedwarnsf and others added 18 commits September 29, 2026 14:54
verify-package-contents on the merged candidate (b913c19) measured
2,034,092 B in 97 files, under the 2.04 MB cap set at the merges (E2 2.01
MB, plus E3's 19,878 B and the docs' 8,046 B, each measured on E1). The cap
comment and the CHANGELOG's size note now give the measured figure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CHANGELOG's 4.3.2 section makes verify-docs require the shipped docs to
name the release they describe: install lines, pins (with the integrity
placeholder the cut fills), release links and llms.txt. verify-docs: 43 of 43.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kdown

/privacy says the fetch proxy keeps a client's IP address briefly to limit
abuse; the map only pruned itself past 10,000 keys, so addresses stayed until
the instance restarted. Expired keys are now swept at most once a minute, and
/privacy says one to two minutes. OWNER-ACTIONS records that npm publishing
allows no tokens and the publish tokens are revoked (2026-09-29), and
RELEASING says CI now runs on the pull request, not on release-branch pushes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release.yml's registry check ran 2 s after 4.3.1's publish, while npm was
still processing it, and failed the job before the GitHub Release. It now
waits up to 10 minutes for the version and 5 for its attestation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the default

MIGRATION.md is now "Moving to 4.4.0": from 4.3 it covers the loader's
en-declared quote default and data-typeset-smart-quotes="en", coverage
'extended' and coverage: 'core', the run budget and its outcome, the
hydration wait and data-typeset-defer, whenSettled(), ./opt-in with the
./go alias, the jsDelivr and unpkg default file, the audit's new review
items, marker styles and sizes; the 4.2.0 steps stay below it. SUPPORT.md
and for-agents.md describe 4.4.0 throughout, with every new option,
attribute, outcome and review item, and their language claims qualified by
coverage (4.3.1's "only en, fr, de, es" and "mixed-language blocks stay
native" are false under the default). The three limitations 4.3.1 said were
planned for 4.4 are marked as not addressed.

The package README no longer says a minor release changes default
rendering only for a verified defect, which the O1 sentence in STABILITY.md
contradicts. It gains a Visual tests section (whenSettled() in Playwright
and a Storybook play function), an audit --apply preview before installing,
the overflow-x: clip and priority="sync" workarounds, spacing: false as the
low-element mode, and the hosted loader's lack of an uptime guarantee.

OUTCOMES.md names extended coverage through COVERAGE_DEFAULT, so it follows
the default if O1 is declined, and the React adapters' coverage JSDoc
defers to Options.coverage. The CHANGELOG's O1 note lists every file to
change if it is declined.

Site copy for 4.4: a defaults line under each install snippet, separate
Framer and Wix hydration notes (Wix: add data-typeset-defer="hydration"),
FAQ answers on page language, hydration errors, user-generated text,
find-in-page, translation and reader views, and a languages row in the
homepage's "Do I need it?" list.

The docs add 32,975 B of package text; the unpacked-package cap goes from
2.04 MB to 2.08 MB (measured 2,067,743 B).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…load once hydrated

The loaders' hydration wait (hydration.ts) began only at the load event and
capped itself at 10 s after it, so a marked page whose load came late (an
image, an iframe or a stalled request) waited for load however long it
took, and one whose framework never hydrated composed 10 s after that.

Now the cap runs from afterHydration()'s call (DOMContentLoaded), the load
event included, and the framework's own signal ends the wait before load:
a marked page, or one with a React root, whose server-rendered targets are
hydrated goes on to its idle callback at once. Only
data-typeset-defer="hydration" with no React root yet still waits for the
load event, where a framework's scripts may not have run. The poll stops
at the deadline rather than up to 50 ms past it.

Found on the way: mount() waits for document.fonts.ready, which Chromium
and WebKit resolve only at the load event (Firefox before it), so in those
two the first composition still comes no earlier than load, as in 4.3.
That wait is mount()'s and unchanged; the CHANGELOG's known limitation and
capabilities.json say so.

verify-loaders gains a variant whose load event is held (the page served
from a route, an image held until 1.5 s; a page set with setContent()
reports load in Chromium with the image pending) and a page that never
hydrates, its load at 11 s: it must compose by 12 s in every engine
(capped from load it composed at 21 s), and Firefox must compose the
hydrated page before load.

Tested once each, M2 Pro: tsc (tsconfig.json, tsconfig.scripts.json);
verify-release --only loaders 441 checks pass; --only docs 43 pass. React
19 SSR probe (Chromium, candidate auto.js, <div id="__next">, hydrateRoot
300 ms after the client script): hydrated 334 ms, composed 403 ms, 0
recoverable errors, server node kept, 47 breaks; with a 6 s image holding
load, the hydration wait ended at 350 ms (its idle callback ran then, its
timeout 9,663 ms: the cap counted from the loader's start), fonts.ready
came at load (6,011 ms) and the composition at 6,060 ms, 0 errors.

Evidence: ~/claude-handoff/2026-09-29/review-engine-fixes/ (loaders-44,
hydration-probe/probe-out.json, fonts-ready-vs-load.mjs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release-cut --version 4.3.2 on the calibration machine (Apple M2 Pro, official
node 24.8.0, npm 11.6.0): the staged run passed 45 of 45 suites and 13,641
checks with every speed-calibrated check enforced, and the runtime budgets.
Tarball public/releases/4.3.2/typeset.us-4.3.2.tgz, sha1
d0c06dc51909b0bb38b0a9c880252c6493901cb6. No earlier release's files changed.
The CHANGELOG gains the measured frame-time trade-off of the catch-up as a
known limitation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decision O1 is declined. STABILITY.md keeps 4.3's rule that a minor release
changes default rendering only to fix a verified defect, so its O1 sentence
(b913c19) is reverted and COVERAGE_DEFAULT is 'core'. Existing sites keep
4.3's output apart from the defect fixes; other Latin-script languages and
more inline markup compose with coverage: 'extended',
data-typeset-coverage="extended" or the adapters' coverage prop.

The option's JSDoc, the package README (languages row, the install-line and
Stability sentences back to the plain rule, the options and loader rows,
line-end preferences), capabilities.json, SUPPORT.md, MIGRATION.md step 4,
for-agents.md and llms.txt now describe 'core' as the default. OUTCOMES.md's
native:language and native:mixed-language rows follow COVERAGE_DEFAULT like
the rich-text rows do, and npm run docs:outcomes regenerated both copies.

The CHANGELOG states the decision at the top of 4.4.0, lists the coverage
additions under Added as opt-in and takes them out of Rendering changes,
whose table is now the default's: against the published 4.3.1, 0 of 3,932
existing cells change in each engine, and on the coverage corpus 104 of 376
do, all class B (long runs 40, tag spellings 24, Greek letters 24, quotes
16); 55/103, 54/102 and 63/111 composed.

verify-coverage and verify-native-ax's coverage fixture pass
coverage: 'extended' where they test it, and verify-coverage checks that the
language and inline cases compose with no coverage option exactly as under
'core'.
verify-loaders expects a lang="pt" page native by default. verify-golden
reads the default from coverage.ts, so a plain run holds the class C cells
to 4.3.1's output and skips the footnote-sup trade-off, which occurs only
under 'extended'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s and what 4.4 shipped

Known limitation, in the 4.4.0 and 4.3.2 CHANGELOG sections and in
SUPPORT.md: while a script fills every frame (Chromium, 4x CPU slowdown,
120 Hz), the React adapters' off-screen catch-up takes up to 12 ms of each
frame for about 1.3 s: 92 fps falls to 66 to 75, frames over 20 ms go from
2 or 3 to 31 to 39, with no new long tasks. 4.3.1 kept those frames but
left 21 of 23 off-screen blocks stale at 3 s. (Measured by the engine
agent.)

SUPPORT.md's hydration-wait limitation now says that mount() waits for
document.fonts.ready, which Chromium and WebKit resolve only at the load
event, so the first composition comes no earlier than load there; this
dates from 4.3.

Sizes re-measured from a fresh candidate build (gzip -9, the published
4.3.1 column reproduced exactly by the same method): go.js 59,941,
auto.js 59,935, typeset.global.js 58,770, mount only 52,357, typeset()
only 45,961, analyzeBreaks() only 14,916; whenSettled() alone 1,429. The
staged package packs to 2,068,719 B unpacked, 97 files (cap 2.08 MB).

ROADMAP.md: the 4.4 section says what shipped (the adoption fixes, with
coverage: 'extended' opt-in), and what it planned but did not ship moves to
"Later in 4.x"; whether 'extended' becomes the default joins 5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes the 4.3.2 release files, version and dated CHANGELOG heading; keeps
4.4.0's shipped docs (which name 4.4.0 with the integrity placeholder) and
one Known limitations note in the 4.3.2 section. verify-docs 43 of 43,
verify-ledger and tsc pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release-cut --version 4.4.0 on the calibration machine (Apple M2 Pro, official
node 24.8.0, npm 11.6.0): the staged run passed 46 of 46 suites and 14,167
checks with every speed-calibrated check enforced, and the runtime budgets.
No earlier release's files changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…romptly again

This reverts commit a5ba256.

Since 4.3.2's idle-period change (e22ce25), hosted runs of verify-react
Chromium "a real ancestor style change still recomposes every block" left
the one block more than a viewport from the screen (r5) unwritten through a
300 ms quiet window: 2 of 3 release verify runs of 4.3.2 and the 4.4.0 PR
run (CI 36643568020), after about 36 hosted runs without a failure. The
same PR run failed verify-settle Firefox "TypesetRichText at 375px resized
to 335px" (217 mutations 2 to 5 s after the resize: an off-screen
grid-auto-pair block still stale at 2 s). The cut's dist,
public/releases/4.4.0, pins, aliases, sri.json, release.json and ledger
entry were built before the fix. 4.4.0 was never tagged or published, so
nothing immutable is withdrawn; release-cut cuts 4.4.0 again from the
fixed tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he 1 s timeout

Since 4.3.2 (e22ce25) the React registry also timed each idle callback out
50 ms of page time after the last idle period, and a timeout that fired
more than 20 ms late waited on instead of composing. On hosted runners
verify-react Chromium "a real ancestor style change still recomposes every
block" then left r5, the one block more than a viewport from the screen,
unwritten through a 300 ms quiet window: 2 of 3 release verify runs of
4.3.2 and the 4.4.0 PR run (CI 36643568020), against none in about 36
hosted runs before.

Not reproduced on the M2 Pro: 0 misses in 70 throttled restyles (CDP CPU
throttling 4x, 6x and 10x; viewports 900, 700 and 500 px high; React 18 and
19; 14 CPU burners; 4 pages in parallel) with the 4.4.0 build, and none
with 4.3.1's. An instrumented 4.4.0 build shows the 4.3.2 paths the
restyle takes: the first callback after a quiet spell asks for a 1 ms
timeout (the last idle period's page time was never cleared), fires 89 to
130 ms late behind the on-screen frame and waits on, then 50 ms timeouts;
locally r5 still composes in the first long idle period once frames stop,
as with 4.3.1. The late-timeout wait-on is the path that can hold a block
where 4.3.1 composed it: with no idle periods and every timeout 30 ms late
(an emulated busy runner), 4.4.0 waited on every 31 ms and r5 was still
unwritten 8 s on; 4.3.1 and this change wrote it at 1.05 s. Starved-mode
frames cannot hold r5 (they compose it).

Now requestIdle() asks exactly what 4.3.1 asked (the rest of
IDLE_TIMEOUT_MS from the first request), a long idle period composes and a
short one waits, and a callback that fires on its timeout composes one
block, as in 4.3.1. Only when that timeout comes with no idle period of any
length in the last STARVED_IDLE_MS of page time is the browser marked
starved, and frames then compose the remaining off-screen blocks after
on-screen and nearby ones, within FRAME_BUDGET_MS, until a long idle
period comes or nothing is left; short idle periods (the ends of those
frames) no longer end it. The idle-period record and the flag reset when
new work arrives after none, so a stale value no longer shortens the next
wait.

verify-settle Firefox "TypesetRichText at 375px resized to 335px" in the
same PR run has a different cause, not changed here: the page samples
every animation frame, so no long idle period comes, and the off-screen
grid-auto and grid-auto-pair blocks the scroll-through leaves compose one
per 1 s timeout, the second 1.94 s after the scroll-through with 4.3.1
too (Chromium 1940 to 1952 ms, Firefox 1941 ms), 60 ms inside the check's
2 s wait.

CHANGELOG 4.4.0 Fixed describes the correction; its known limitation and
SUPPORT.md say the frames are spent only once a callback has waited 1 s.

Tested once each, M2 Pro: tsc (tsconfig.json, tsconfig.scripts.json);
verify-release --only react,settle,scheduler,recompose-storms,reflow-triggers
6 of 6 suites pass, 939 checks (storms 53, reflow-triggers 46, scheduler
99, settle 420, react 243, react-node 78); verify-docs 43. Starvation
scenario (verify-scheduler's check alone): 0.51 s Chromium, 0.54 s Firefox
(4.4.0 build 0.17 and 0.19 s, 4.3.1 44 s). Throttled restyle, 6x, React
18, 8 rounds: 4.4.0 build 0 misses, last host 279 to 330 ms; this change 0
misses, 283 to 330 ms.

Evidence: ~/claude-handoff/2026-09-29/typeset-4.4.0-idle-fix/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It measured 51.1 ms against 50 ms once, in the v4.3.2 release run
36644238293 (attempt 2), with the three browsers sharing the runner. The
linear-time bound stays enforced by the WebKit and Firefox browser checks and
on the calibration machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release-cut --version 4.4.0 on the calibration machine (Apple M2 Pro, official
node 24.8.0, npm 11.6.0), with off-screen React blocks following a restyle as
4.3.1 did: the staged run passed 46 of 46 suites and 14,167 checks with every
speed-calibrated check enforced, and the runtime budgets. Tarball sha1
2be4af91dc1497a682bd9f1fb4965e01091b4d1a. No earlier release's files changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4.3.2 was cut and tagged (v4.3.2, fc7f84e) but its release verification
failed in 4 of 5 hosted runs on verify-react's "a real ancestor style change
still recomposes every block": the idle-starvation change let one off-screen
React block recompose after the check's 300 ms quiet window. 4.4.0 carries the
corrected change, so 4.3.2 is not published. scripts/v4/withdrawn.json records
it; verify-ledger --network skips its registry cross-check (the ledger stays
append-only and every archived file is still checked), and the CHANGELOG says
so at the top of the 4.3.2 section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@speedwarnsf
speedwarnsf merged commit 46872e2 into master Sep 30, 2026
1 check passed

This branch was successfully deployed

1 active deployment
npm — 96d0b0bc Deployed Sep 30, 2026 by speedwarnsf via publish #4
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