Repository navigation
feat(react): wrap estimated rows by measured segment widths (47/48 -> 48/48 line counts, 3.500px -> 3.083px) - #367
Merged
Merged
Conversation
prepareText counted characters with Array.from — code points. A ZWJ emoji sequence counted 7 and a combining-mark cluster 2, and those counts feed graphemeCount, the breakpoint scan and token lengths, which is what the wrap arithmetic divides by. Non-ASCII text therefore predicted too many lines. Segment with Intl.Segmenter at granularity "grapheme", memoized at module scope (constructing one per call is expensive and this is on the estimate path), falling back to Array.from where Segmenter is unavailable. ASCII is unaffected: the estimator accuracy instrument stays at 47/48 line counts and 3.5px mean error, and text-core's existing tests pass unedited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…upplied `PrepareTextInput` gains an optional `measureSegment(segment) => px`. The caller owns the font; text-core still never sees one, so the package stays DOM-free and unit-testable. Given a measurer, `prepareText` measures each *distinct* token once and attaches `tokenWidthsPx` to the prepared record, index-aligned with `tokens`. Its presence is what switches `layoutPreparedText` onto a pixel path that accumulates advance widths against the available width; without it, both functions behave exactly as before and the record does not even carry the key. The pixel path is a twin of the character path, and the bridge test pins that: a measurer returning `averageCharWidth × graphemeCount` produces identical line counts across 8 texts × 16 widths. Making that hold required two things worth naming — an over-wide word is split by its own average density, since one token-level measurement says nothing about where its graphemes fall, and the fit comparison carries a sub-subpixel epsilon so float accumulation cannot wrap a line that fits exactly. Zero and negative widths clamp to zero rather than being divided by, matching the character path's `Math.max(1, ...)`. The estimator accuracy instrument is unchanged at 47/48 line counts and 3.5px mean error — nothing supplies a measurer yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PrepareTextInput` gains `letterSpacingPx`. It folds into `averageCharWidth` on the average path and into each `tokenWidthsPx` entry on the measured path, so the two paths keep agreeing. CSS charges the spacing to every grapheme, a line's last included. That was measured, not assumed: a Playwright probe at `20px monospace` with `letter-spacing: 10px` and the 11-grapheme string "aaaaa aaaaa" gave an inline width exactly 110px wider than unspaced in Chromium, WebKit and Firefox, and all three needed ~242px (11 x 22) to keep it on one line — at ~232px, the trailing-trimmed prediction, it wrapped. No divergence. The bridge test now runs every case at four letter spacings, including a negative one; trimming the trailing spacing on the pixel path fails it. `undefined` and `0` are asserted identical to today on both paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Left unformatted by an earlier commit on this branch; the repo's format check runs prettier over everything, so it would have failed CI at the phase gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`layoutPreparedText` gains `wrapMode: "pre-wrap"` alongside `wrap` and
`nowrap`. Preserved whitespace is charged to the line it starts on: a
space run is never dropped at the start of a line, never moves down to
the next one, and never breaks the line by itself — it hangs past the
edge and the word that follows makes the break decision.
That rule was measured, not assumed. A Playwright probe at
`font: 20px monospace` in Chromium 151.0.7922.34, WebKit 26.5 and
Firefox 153.0 read line boxes back per grapheme: `"aa aa"` is two
lines at every container from 2 to 9 advances and one at 10, with the
run hanging 72px past a 24px box. All three engines agreed on every
case; the one apparent divergence (" aa aa" at exactly 4 advances,
Chromium 3 lines against 2) is Chromium measuring a space ~0.015px
wider than a letter, and it goes away at 4.5 advances.
Trailing whitespace is charged toward `measuredWidth`, matching what
`wrap` already does, with the existing clamp to the available width
standing in for the browser's content box.
The bridge test now runs `pre-wrap` as well as `wrap` — 8 texts × 4
letter spacings × 16 widths × 2 modes = 1024 comparisons, pinned as a
literal. `wrap` and `nowrap` are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`text-core` gained an optional `measureSegment` in Tasks 1-4 and nothing supplied one, so the estimator still modelled a font as a single average advance. This supplies it: `@pretable/react` measures each token on the SAME canvas context `measureAverageCharWidth` already uses, caches by `(segment, font)`, and hands the estimator a measurer bound to the font the grid is actually drawing in. `letter-spacing` comes off the same computed style and is charged per grapheme on both paths. On the hero accuracy fixture — 48 rows measured in a real browser, both columns reading the SAME font — line counts go 47/48 -> 48/48 and mean height error 3.500px -> 3.083px. Reported, not tuned; the gate verdict is Task 6's. One context and one observer, as required: - The DOM read is now shared. Font, letter spacing and sample text come off one `querySelector` + `getComputedStyle` per theme change, for all three consumers; `density.test.ts` pins it at two reads per swap (one box, one text style) for four getters called 50 times each. - Invalidation runs through `density.ts`'s existing MutationObserver. `invalidateGridAverageCharWidth` is renamed `invalidateGridTextMetrics` since it now covers more than the width. Per-segment widths are NOT dropped on a swap: they are keyed by font, so a font change measures under a new key and a swap that keeps the font would re-measure identical numbers. The cache is bounded two ways, because grid text is unbounded in principle and this sits on the per-estimate path: 4096 segments per font and 8 fonts, both evicted in insertion order rather than by LRU — keeping recency would mean a delete plus a set on every cache HIT, which is the path the cache exists to make cheap. The measurer and `letterSpacingPx` join the estimate memo key, the former by identity (one function per font) and the latter by value. Mutation-checked: dropping either from EITHER cache-hit branch fails a test, as does dropping either from the `prepareText` input. Performance, which is where the previous regression in this series surfaced: repeated estimates over the same content do not grow the `measureText` call count (react) or the measurer call count (renderer-dom). app-bench stays at 128/128. The Node-side accuracy test cannot measure — no canvas — so the fixture now carries captured widths for all 102 tokens its samples tokenize into, taken in headless Chromium with the analyst cell's computed font shorthand. Provenance cross-check: the 762-grapheme corpus `HERO_AVERAGE_CHAR_WIDTH_PX` was averaged over measures 4957.4423828125px in that session against 4956.8955078125px recorded in the original one — 0.011 per cent apart, recorded rather than smoothed over. A missing token throws rather than measuring zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bias instrument reported signed scroll-extent error for three floor policies, and every number it printed was the average-character-width path. Nothing passed it a measurer, so the one question Phase B exists to answer — does measuring segments move the systematic per-line shortfall? — was invisible to the instrument built to see systematic error. Every policy is now reported twice, average path against measured path, over the same font: `HERO_AVERAGE_CHAR_WIDTH_PX` on the left, `measureHeroSegment` on the right. The average column is unchanged; the comparison is the point. Each floor is re-learned per path, because the floor admits rows the estimator predicts at <= 1 line and the two paths are entitled to different ones. A second test keeps the right-hand column from going vacuous: a dropped argument anywhere on the way to `prepareText` would print two identical columns and read as "segment measurement changed nothing", which is one of the conclusions this file is used to draw. PREDICTION, recorded before running it -------------------------------------- The bias will barely move and the sign will not change. Segment measurement changes only WHERE a line breaks; the per-line height arithmetic (21px line height, 25px chrome) comes from HERO_ROW_BOX_METRICS and this phase does not touch it. So the only way it can move a height is by moving a line count, and it moves exactly one (47/48 -> 48/48). One 21px line over a ~3700px extent is about 4.6% of the no-floor shortfall. Expected: no-floor relative extent error -12.2201% -> around -11.7%, still double-digit negative; directional split still ~42-43 under and 0 over; no-floor mean |error| down about 21/48 = 0.44px; the learned floor unchanged, because the row that flips is a 2->3 row, well clear of the floor's <=1 admission threshold. Expected finding: that the ~1px-per-line shortfall is NOT character-mix error, which localises it to the per-line height arithmetic or to unmodellable inline content, and redirects the next round away from text measurement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pretable-model.ts` builds the row layout controller with `getAverageCharWidthPx`, `getRowBoxMetrics`, `getSegmentMeasurer` and `getLetterSpacingPx`. All four options are optional on the controller, so deleting any of them type-checked, linted, and broke no test: the estimator reverted to its pre-measurement constants and every suite stayed green. Three phases of this series shipped through that gap. The obvious test cannot be written in jsdom, and the file records why rather than leaving it to be rediscovered. The real getters return null without a canvas; and even mocked, the effect is unobservable — only rows inside the planned viewport are estimated at all, every row past it carries `defaultRowHeight`, and the few that are estimated are measured within a frame at zero and floored back to the theme height. Measured directly, all four configurations produce an identical 17600px scroll extent. So it asserts one seam closer: `createRowLayoutController` is wrapped, the real controller still built and still driving the render, and the options object the model passed is captured and each getter invoked through it. Identity rather than equality for the box and the measurer, because the estimate memo compares both by reference. Mutation-checked: each of the four lines deleted in turn, each turning its own test plus the absent-safe test red, then restored. The other half of the chain — that the controller consults these options per estimate — stays pinned in renderer-dom's `indexed-renderer.test.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`@chenglou/pretext` (MIT, © Pretext contributors) is where the two-phase design this phase implements comes from: measure each segment once, cache by (segment, font), then decide line breaks with pure arithmetic over the cached widths. Checked before claiming courtesy rather than obligation. Every file in `text-core/src` and `react/src/text-metrics.ts` was compared against pretext's `measurement.ts`, `layout.ts`, `line-break.ts`, `line-text.ts` and `analysis.ts`: no copied code, no line-by-line translation, no shared constants or tables, and the algorithms differ in kind — pretable wraps three token kinds with a flat accumulator, pretext walks fifteen segment-break kinds with a cursor; pretable charges letter-spacing to every grapheme, pretext charges `n - 1` and handles the ends separately. Nothing needs pretext's copyright line carried into it. One overlap is disclosed rather than left implicit: `getMeasuringContext` probes `OffscreenCanvas` then a detached `<canvas>`, in that order, as pretext does. That is the conventional way to reach a measuring context, and the divergence is recorded in the comment — pretext throws where this returns null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
Vercel preview readyPreview: https://pretable-akze7j4tg-cacheplane.vercel.app Updated automatically by the |
This was referenced Aug 13, 2026
Merged
blove
added a commit
that referenced
this pull request
Aug 13, 2026
…per-line arithmetic) (#370) `row-height-bias.test.ts` reports 43/48 rows under-estimating for -432px of signed extent with the floor disabled, and #367 moved that split by zero rows. This decomposes it. Diagnostic only: no production code changes, and no cause is asserted — the console output is the deliverable. Of the -432px: -236px (55%, 12 rows) the analyst cell's `render` appends an inline-flex stance badge. It takes ~59-65px of horizontal space and pushes the text onto a line the estimator, which wraps the raw value only, cannot predict. -165px (38%, 11 rows) rows measuring 63px, decided by a different cell entirely. That is the learned floor's job, and this experiment disables the floor. -31px (7%, 31 rows) the arithmetic, with the rendered line count correct: exactly -1 on every 2- and 3-line row, exactly 0 on every 4-line row. The last term fits neither "constant chrome" nor "per-line shortfall". The measured heights bound the real line advance at 20 < a < 21 (strictly under the 21px the estimator reads from the CELL), which is what `.analyst`'s `line-height: 1.45` on a 14px font gives: 20.3px. The cell is `display: flex`, so that span is a flex item and its own line-height governs the line boxes. Two cross-checks that could each have failed: the rendered line count inverted out of the measured height lands in {predicted, predicted+1} for 48/48 rows, and a single badge advance derived from horizontal slack alone reproduces the same assignment over a non-empty interval, (58.6133, 64.8164]px. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
blove
added a commit
that referenced
this pull request
Aug 15, 2026
`prepareText` segmented the whole string into graphemes, then `tokenizeText` re-segmented every token via `countGraphemes` purely to fill each token's `length`. Every character was segmented twice. A CPU profile of an S2 wrapped scroll put `segmentGraphemes` at 193ms of self time in a 761ms window — 25.4%, roughly 3x TanStack's entire library-side JS cost for the same scroll. Token lengths now come from the grapheme array `prepareText` already built. The mapping is not the obvious one, and the obvious one is wrong. A grapheme cluster can straddle a token boundary: in "a ␣́b" the space and the following combining acute are ONE cluster, while the tokenizer's regex splits inside it. The old code called `countGraphemes` per token and so charged that cluster to BOTH sides. Mapping each grapheme to the token containing its start offset gives different answers and would have silently changed wrapping for combining marks. A grapheme is therefore counted against every token its span overlaps, with a rewind when a cluster runs past a token's end. This is behaviour-preserving on purpose. The safety net asserts, for a 31-entry corpus of ZWJ families, regional-indicator runs where pair parity matters, skin-tone modifiers, combining marks, straddles, CRLF and a lone CR, that every token's new `length` equals what `countGraphemes(token.value)` returned before. Counting code units instead reddens 10 tests including two pre-existing ones; dropping the straddle rewind reddens exactly the straddle cases. A corpus-fitness test fails if the corpus stops exercising the traps, and a perf pin stubs `Intl.Segmenter` and asserts one `segment()` call for a 15-token input, where there were 16. Measured, 3 repeats each side, same session: segmentGraphemes self 185ms -> 37ms (4 call paths -> 2) sampled scroll window 758ms -> 496ms scroll_frame_p95_ms 33.3 -> 17.3 rendered_rows_peak 11 -> 12 Nothing about what gets drawn moved: rendered cells, blank gap frames, long tasks and the 4px row-height error are identical either side, which is the check that this removed waste rather than work. The recovered row is worth naming. #388 recorded #367 dropping `rendered_rows_peak` 12 -> 11 and asked whether that frame was a fair price for 48/48 line counts. The price was not the segment measurement; it was the duplicated segmentation alongside it. The row comes back without giving up the accuracy. Against TanStack on S2 scroll, 5 repeats: 17.3ms vs 17.3ms, a 0.0ms gap against 0.7ms of noise — statistically tied, from a 16.1ms deficit. pretable's own spread fell from 7.4ms to 0.3ms; the variance WAS this work landing unevenly across frames. 1133 react tests, 117 renderer-dom, 69 grid-core, 60 text-core, typecheck, lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 15, 2026
blove
added a commit
that referenced
this pull request
Aug 15, 2026
…able (#415) * fix(bench): make S2 measure wrapped text in every grid, not just pretable S2 is the project's self-described primary wedge benchmark — `wrapped_columns: 3`, `row_height_mode: "variable"`. Only pretable was wrapping. The three comparator adapters imported scenario TYPES only and never read `column.wrap`, so their code path on S1 and S2 was byte-identical: fixed 48px rows, `nowrap`, `overflow: hidden`. Every S2 comparison has measured pretable doing variable-height text layout against three grids not doing it. Closes #400. The flag was already on the wire. It was simply unread. - ag-grid: `wrapText` + `autoHeight` per column. Both are needed and neither implies the other — `autoHeight` alone gives tall single-line rows, `wrapText` alone gives clipped wrapped text. `AllCommunityModule` already pulls in `RowAutoHeightModule`; `rowHeight` becomes a floor via `Math.max(cellHeight, rowHeight)`. - mui: `getRowHeight: () => "auto"`. Sufficient on v9 without an `sx` override — `row--dynamicHeight > .cell` sets `whiteSpace: initial` with two class selectors, outranking the single-class `nowrap` default. `rowHeight` is KEPT: it is not inert, it is the base estimate for unmeasured rows. - tanstack: `measureElement` + `indexAttribute: "data-row-index"`, and the wrapped columns get pretable's own text model (`anywhere`/`pre-wrap`) so the two lay the same string out under the same rules. Every change is gated on `column.wrap`, and every test asserts BOTH directions. That is not ceremony: enabling wrapping unconditionally passes a positive-only test while silently moving every other scenario. Each was proven by mutation — including the negative arm, which is the one that catches it. Two things found while doing this that the issue did not have: `indexAttribute` was a trap. TanStack's `measureElement` defaults to `data-index`; this adapter emits `data-row-index`. A missing attribute yields `-1`, `resizeItem` early-returns, and the row silently keeps its estimate — console warning only. Measurement would have looked enabled and done nothing. The scroll script sampled `scrollHeight` ONCE and derived all 36 targets from it. AG Grid does not estimate auto-height rows, so content grows underneath the pass as cells are measured: the run would have aimed at a mostly-unmeasured model and covered a shrinking fraction of the dataset, while a grid that sizes rows up front covered all of it. A like-for-like break introduced BY this change, in exactly the scenario it exists to measure. Targets are now fractions resolved against the live extent — arithmetically identical for a fixed-height grid, so it changes only the case the old form got wrong. Expect S2 numbers to move, and expect pretable's apparent lead to shrink. `dom_nodes_peak` is `querySelectorAll("*").length`, proportional to rendered row count, so most of the reported 2.4-3.4x DOM advantage was the row-count gap the asymmetry created. Per rendered row it is 1.13x over MUI and 1.40x over AG Grid. S2 figures from before this commit are not comparable to figures after it. 138 bench tests, typecheck, lint, format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(bench): stop AG Grid wrapping at 2.79x leading, and prove wrapping in pixels Two findings from the first like-for-like S2 run. **AG Grid's `autoHeight` was working.** At rest its rows measure 236/236/119/314 px with a 2px error. The 264px error the benchmark reported is the UNMEASURED state: AG Grid's auto height is a post-paint correction, not a layout mode. The row carries an explicit `style.height` from the row model, each cell measures itself on mount, and the apply pass is debounced behind `_debounce(..., 1)` -> `setRowHeight` -> redraw. A newly rendered row therefore always paints at the 48px fallback and is corrected at least a frame later, and the measurement is DELETED when the cell is destroyed, so a row that scrolls out and back pays the two-pass cost again. Reproduced independently: the harness's 36-jump loop replicated standalone gives p95 264, peak rows 27, rows sampled at 48px. Four and ten settle frames only reach 261 and 217; it needs ~500ms per jump to converge. That is not fixable from the adapter and is now recorded in-source so nobody re-chases it. It is also the honest form of the wedge claim: not "AG Grid is slow" but "its auto height is a two-pass correction, so it paints rows at the wrong height during scroll". **Half the error was ours.** AG Grid's CSS derives cell `line-height` from ROW height — correct for a single-line cell, a category error for a wrapped one, giving every line of a paragraph the full row height as leading. AG Grid was wrapping at 2.79x leading against pretable's and TanStack's 1.50 and MUI's 1.43, so S2 was still not comparing the same layout. A theme parameter cannot fix it: `--ag-line-height` is combined under `min()`, so it can only make leading smaller than the row height, never release it. An explicit `lineHeight: 1.5` does. Error drops 264 -> 120. That line height is a per-adapter parity choice, not a neutral default, and it must be disclosed wherever these numbers are published. **The pixel proof.** The jsdom test asserted `ag-cell-wrap-text` and `ag-cell-auto-height` were present. Those classes are toggled straight off the colDef, so it passed whether or not a pixel moved — and it cannot do better, because jsdom has no layout engine. Wrapping is a layout fact, so the proof now runs in real Chromium: rows taller than the 48px floor, heights that vary, each row agreeing with its tallest cell, and text genuinely on more than one line so the leading ratio cannot pass vacuously. Three mutations each redden it (`autoHeight` removed -> 48; `cellStyle` removed -> ratio 2.79; flags applied unconditionally -> S1 rows move). The jsdom test stays as a cheap colDef guard, retitled to say what it cannot see. `rendered_rows_peak` staying at 27 is the expected result, not a miss: it is a 320px viewport over a 48px fallback plus AG Grid's default 10-row buffer each side — the peak frame IS the unmeasured state. 138 bench tests, typecheck, lint, format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(text-core): segment each string once, not once per token as well `prepareText` segmented the whole string into graphemes, then `tokenizeText` re-segmented every token via `countGraphemes` purely to fill each token's `length`. Every character was segmented twice. A CPU profile of an S2 wrapped scroll put `segmentGraphemes` at 193ms of self time in a 761ms window — 25.4%, roughly 3x TanStack's entire library-side JS cost for the same scroll. Token lengths now come from the grapheme array `prepareText` already built. The mapping is not the obvious one, and the obvious one is wrong. A grapheme cluster can straddle a token boundary: in "a ␣́b" the space and the following combining acute are ONE cluster, while the tokenizer's regex splits inside it. The old code called `countGraphemes` per token and so charged that cluster to BOTH sides. Mapping each grapheme to the token containing its start offset gives different answers and would have silently changed wrapping for combining marks. A grapheme is therefore counted against every token its span overlaps, with a rewind when a cluster runs past a token's end. This is behaviour-preserving on purpose. The safety net asserts, for a 31-entry corpus of ZWJ families, regional-indicator runs where pair parity matters, skin-tone modifiers, combining marks, straddles, CRLF and a lone CR, that every token's new `length` equals what `countGraphemes(token.value)` returned before. Counting code units instead reddens 10 tests including two pre-existing ones; dropping the straddle rewind reddens exactly the straddle cases. A corpus-fitness test fails if the corpus stops exercising the traps, and a perf pin stubs `Intl.Segmenter` and asserts one `segment()` call for a 15-token input, where there were 16. Measured, 3 repeats each side, same session: segmentGraphemes self 185ms -> 37ms (4 call paths -> 2) sampled scroll window 758ms -> 496ms scroll_frame_p95_ms 33.3 -> 17.3 rendered_rows_peak 11 -> 12 Nothing about what gets drawn moved: rendered cells, blank gap frames, long tasks and the 4px row-height error are identical either side, which is the check that this removed waste rather than work. The recovered row is worth naming. #388 recorded #367 dropping `rendered_rows_peak` 12 -> 11 and asked whether that frame was a fair price for 48/48 line counts. The price was not the segment measurement; it was the duplicated segmentation alongside it. The row comes back without giving up the accuracy. Against TanStack on S2 scroll, 5 repeats: 17.3ms vs 17.3ms, a 0.0ms gap against 0.7ms of noise — statistically tied, from a 16.1ms deficit. pretable's own spread fell from 7.4ms to 0.3ms; the variance WAS this work landing unevenly across frames. 1133 react tests, 117 renderer-dom, 69 grid-core, 60 text-core, typecheck, lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gate: PASS
Both measured over the same 48 rows captured from a real Chromium session on the homepage hero, same font on both sides, the only variable being average-arithmetic versus accumulated measured widths.
Line counts
>= 47/48and mean error below 3.5px, so: PASS. The one row still mispredicted at 47/48 —Correlates 0.71 with NVDA. Combined AI-compute exposure 15.3% …at 404px, predicted 2 where Chromium drew 3 — is the digit-heavy string the average was always going to get wrong, and it is the one segment measurement fixed.My recorded prediction, and whether it held
Recorded in
6017db9ebefore the wired instrument was run:It held, to the pixel. No-floor extent went −12.2201% → −11.6536%, the split stayed 0 over / 43 under, no-floor mean error fell 9.4375 → 9.0000px (0.4375 = exactly 21/48), and the learned floor stayed at 68px. The total movement is 21px across all 48 rows — one line box, which is the one line count that changed.
The reasoning behind the prediction was that segment measurement can only move a height by moving a line count, because the per-line arithmetic (21px line height, 25px chrome) comes from
HERO_ROW_BOX_METRICSand this phase does not touch it. That is what the numbers say happened.The bias comparison, both paths, all three floor policies
Verbatim from
row-height-bias.test.ts:The finding that matters more than the gate
The ~1px-per-line shortfall is not character-mix error. With no floor, 43 of 48 rows under-estimate and none over-estimates — before and after. Segment measurement is the strongest available fix for character-mix error, and it moved that split by zero rows and the aggregate by one line box out of 453px. The residual lives somewhere else: the per-line height arithmetic, or inline content the estimator is structurally blind to (the stance badge, the two-line
dayPnlrenderer). The next round should go there, not at text measurement.The other thing this exposes: "keep the max floor" still rests on a cancellation. The max is biased high by construction and is offsetting a larger negative bias; Phase B removed 21px of that negative bias and the offset is still doing the work.
Closing two gaps first
The bias instrument was measuring the average path only (
6017db9e). Nothing passed it a measurer, so the one question this phase exists to answer was invisible to the instrument built to see systematic error. Every policy is now reported twice, average against measured, with each floor re-learned per path — the floor admits rows predicted at ≤1 line, so the two paths are entitled to different ones. A second test keeps the right-hand column from going vacuous.pretable-model.ts's wiring was untested (8f93c7d6). Deleting any ofgetAverageCharWidthPx,getRowBoxMetrics,getSegmentMeasurerorgetLetterSpacingPxtype-checked, linted, and broke no test in the repo — three phases of this series shipped through that gap.It could not be tested through a rendered row, and the new file records why rather than leaving it to be rediscovered: the real getters return null without a canvas, and even mocked the effect is unobservable, because only rows inside the planned viewport are estimated at all, every row past it carries
defaultRowHeight, and the few that are estimated are measured within a frame at zero and floored back to the theme height. Measured directly, all four configurations produce an identical 17600px scroll extent.So it asserts one seam closer:
createRowLayoutControlleris wrapped, the real controller still built and still driving the render, and the options object the model passed is captured and each getter invoked through it — by identity for the box and the measurer, since the estimate memo compares both by reference.Mutation results, each line deleted in turn then restored:
getAverageCharWidthPx: () => getGridAverageCharWidth()getRowBoxMetrics: () => getGridRowBoxMetrics()getSegmentMeasurer: () => getGridSegmentMeasurer()getLetterSpacingPx: () => getGridLetterSpacingPx()The controller's half of the chain stays pinned in
renderer-dom'sindexed-renderer.test.ts.Attribution
LICENSEgains an acknowledgements section crediting@chenglou/pretext(MIT, © Pretext contributors, https://github.com/chenglou/pretext) for the two-phase design: measure each segment once, cache by(segment, font), then decide line breaks with pure arithmetic over the cached widths.Checked before claiming courtesy rather than obligation. Every file in
text-core/srcplusreact/src/text-metrics.tswas read against pretext'smeasurement.ts,layout.ts,line-break.ts,line-text.tsandanalysis.ts. Verdict: independent, no file needs pretext's copyright line carried into it. No copied code, no line-by-line translation, no shared constants or tables (greppedlineFitEpsilon,kinsoku,SegmentMetrics,tabStopAdvance,1/64,0.005— all clean), and the algorithms differ in kind:word/space/newline) with a flat accumulator; pretext walks a fifteen-memberSegmentBreakKindunion with a cursor over eight parallel arrays.n − 1and handles the ends separately.One overlap is disclosed rather than left implicit.
getMeasuringContextprobesOffscreenCanvasthen a detached<canvas>, in that order, as pretext'sgetMeasureContextdoes. That is the conventional (essentially the only) way to reach a measuring context, and the divergence is now noted in the comment: pretext throws where this returns null, so an unmeasurable host keeps the average width instead of losing its estimates.Verification
Bench, no regression. Baseline runset
2026-08-13t04-27-03-476zagainst this branch's2026-08-13t19-58-44-744z,row_height_error_p95_px:Identical. Worth recording how: the first attempt was invalid and thrown away.
playwright.config.tssetsreuseExistingServer: false, but a parallel session in another worktree already held port 4173, sovite previewfailed to bind, Playwright found the port answering anyway, and the run measured that worktree's build. The reported run usedPRETABLE_BENCH_EXTERNAL_SERVER=1against this worktree's own preview on 4187. Machine load was 8-9 throughout, which can only inflate these numbers, and they did not move.Hero symptom check. Production build, served locally,
data-pretable-row-heightsampled once a second for 8s in Chromium at 1440x900:Three distinct heights, tracking the streamed commentary as it wraps. No first-paint shrink. The probe was a throwaway outside the repo and is deleted; the server is stopped.
apps/websitee2e/smoke.spec.ts: 26 passed (chromium + webkit), against the local production build.Suites:
text-core41/41,renderer-dom85/85,react993/993,app-bench128/128.Changeset (
@pretable/react, patch), thenpnpm typecheck && pnpm lint && pnpm formatclean — the one lint warning is the pre-existing TanStack Virtual notice in the bench adapter — thenpnpm build && pnpm api:checkin that order, both clean. No public API change.maindid not move:origin/mainis still5f6119cb, and it is an ancestor of this branch.Not done
Not merged, per the task.
🤖 Generated with Claude Code