Repository navigation
Conversation
…as(~ x) A change under an element restyled every element a :has() could be tested on: its ancestors, and every earlier sibling of each. Only a :has() whose argument starts with + or ~ can match from a sibling, so the siblings are walked only when a rule has one. In a list styled with li:has(.on), toggling a class in one item restyled every item before it. The box model and a list marker's text style are kept by the style object a resolved style keeps until it changes, as the paint style is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…style change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A list marker is not ASCII, so every frame measured and drew each item's bullet through Intl.Segmenter. A short text's graphemes are now kept. The frame writer appended every cell to an array it then joined, and tested each one-character cell with a regex. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry rule Each list item's marker filtered the whole rule list for ::marker rules every frame. The rules are already filed by pseudo-element, and are now filed again in cascade order whenever the rule list is sorted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rom the top of a sheet A sheet kept an @import or @namespace after a style rule, after a later kind of prelude rule, or nested in another rule. Browsers drop each of these, as css-cascade-5 and css-namespaces-3 require. With namespaces only at the top, each style rule's parse reads the sheet's namespaces from the top instead of walking every rule in the sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every document parsed the UA sheet again, and reading its selectors with css-tree was most of what a new document's stylesheet parse cost. A reading depends only on the selector's text and nothing changes it, so readings are kept by that text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…window Each new window spread every platform class into a fresh object two times, and parsed the Web IDL text of each Window operation after building a map of every interface class. Both depend on nothing a window has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rules outside shadow trees The rule index filed rules by id and class, so a plain span tried 152 rules, among them the UA's rules for inputs, dialogs and table cells. A rule whose subject names an element is now filed by that name too. Pseudo-element rules match through a shadow host, so outside a shadow tree they are rejected before the ::part() test. Checking a name for uppercase is a loop rather than a regex. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A child added to a tree that was then removed before the frame still had its boxes derived, and asking whether each was display: contents resolved a style for every node of it. Building 20,000 spans under a div and removing the div resolved 20,000 styles. A detached tree holds no boxes, and the record that brings it back derives it whole. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…without a NodeList Each added element's descendants came from querySelectorAll, which built a NodeList and defined an index property per element. They are now walked directly. A detached tree's pseudo-elements and shadow roots wait for the record that inserts it, which visits the same elements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s or goes The UA sheet's details > summary:first-of-type sets display and list styles, so in every document a sibling rule reached descendants, and a parent gaining a child restyled everything under it. Appending a message to a chat log restyled every earlier message. Each child is now checked against the sibling rules' keys, as it already was when no rule reached descendants, and only one they could name restyles with its subtree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measuring a box walked its ancestors and asked each whether it was the html or body element, reading both through the window's bindings, before reading a scroll offset that was nearly always zero. CodeMirror measures on every keystroke. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bench/run.ts runs each scenario in bench/scenarios in a process of its own and prints the median CPU time of each measurement. With --against it checks a ref out into a temporary worktree and alternates runs between the two checkouts. Four scenarios are shaped like apps: startup to a first frame, scrolling a long document, appending to a React chat log, and typing in CodeMirror. Four are synthetic: the email fixtures, a 1000-row table, a :has() toggle and building 20,000 spans. A workflow runs the comparison on every pull request and writes the table to the job summary. Shared runners are too noisy to fail on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The symbol-keyed methods were moved off the public prototypes onto the records at startup, and called through those symbols. Each is now a module function that takes the record, as the engine's other reads do. The gradient and pattern lookups, the one pair a call chose between, are chosen by the paint's class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d drop fallbacks no runtime takes A class's internal steps were symbol-keyed methods that slots.ts moved off the public prototype onto the record. They are now methods whose names start with "_", which no Web IDL member's does, and slots.ts moves those instead, so a step reads slots._insertionSteps() rather than slots[kInsertionSteps](). Overrides and super calls work as before. A test holds that no interface a page can reach shows such a member, and that no member the specs name starts with one. Node 20.19, Bun and Deno all have DOMException, FormData, AbortController and AbortSignal, so the stand-ins for a runtime without them, the sealed DOMException and a FormData of our own among them, and the checks for their absence are gone. FormData extends the runtime's directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…flags without a step TreeWalker's step built a fresh object of three of its own slots for every node the filter looked at, and NodeIterator built the same; both now hand filterNode their record, which holds those slots. The token list's step only called ensureList, and the validity state's only called its own flags(). A computed style's custom property names come from a plain function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… by symbols that are gone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… describe nothing Several doc comments had been left above the wrong declaration when code moved around them: flushLayout's above revealEditingCaret, Window's above two runtime constants, createWindow's above the class table, the hover note above the :active map. Others headed nothing, said the same thing twice, told how a bug once looked, or named writeTokenList() for the clipboard's write(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hed element with a TypeError, and fix comments that misdescribe their code img.x and img.y returned 0 while their comment said they read the geometry; they are now the border box's edges from the document's origin, as CSSOM View has them. requestFullscreen() on an element outside the document rejected with an Error named InvalidStateError through a try/catch that could not otherwise fire; it now fires fullscreenerror and rejects with the TypeError the Fullscreen Standard names. The document kept a children field nothing read. Comments that told how a bug once looked now state the rule. Others moved to the code they describe, named a function that exists instead of one that does not, or claimed innerText did not read the rendered text, which it does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d parameters and dead branches, and have the typechecker refuse new ones Every supported runtime has Event, EventTarget and Blob, so the guards for their absence go. insertAdjacentHTML tested whether its context, always an element, was one. hasRuntimeGlobal tested for names its one caller never passes. blur() looked the focused element up twice with nothing between. isDisabled forwarded to isActuallyDisabled with a match state no one read, and isMutable took that state only to pass it on. paintOutline and matchingChangedMembers took arguments they never used. tsconfig now sets noUnusedLocals and noUnusedParameters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er module reads canvas.ts's domException(name, message) only built a DOMException with its arguments the other way round, so the twelve calls build it themselves. resetCanvasContext, getCaretColumn, createBeforeUnloadEvent and hasPaintContainment each forwarded to one call at one or two sites. getValidityState set the internal construction flag by hand where constructInternal does it. text.ts tested for a control byte by hand beside its own isControlByte, and the inspector wrote a closing angle bracket on two paths that could share it. Eleven exports nothing outside their module imports are module-local. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roll container test, collapsing a selection to an edge The url reflection, a hyperlink's href and its toString resolved the attribute against the base URL in three copies. Layout asked whether either axis scrolls in three places. collapseToStart and collapseToEnd differed in which boundary point they took. getChildElementsNamed repeated isHTMLElementNamed, and the document's named collections walked the tree by hand beside getDescendantElements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…over forwarder and a hand-written focus walk lookUpCustomElementDefinition only checked for a null registry before calling lookUpDefinition, which nothing else called. hidePopoversUntil forwarded to hidePopoverStackUntil for one caller. dropUnfocusableFocus walked through shadow roots to the focused element as getFocusedElement does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PopoverState.trigger was set on show and cleared on hide but never read: HTML reads a popover's invoker only for CSS anchoring, which this engine does not do. flushStyle and flushLayout each worked the document out from the node by hand, though the attached document already is it, and flushLayout repeated flushStyle line for line before its layout pass. getInnerText settles layout through getSettledLayout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rest stay for now: they are called through an optional chain, or their names are taken by a function already. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
renderStatic stays a method, since index.ts has a function of that name. The test helper that drew a frame through any object with beginFrame and endFrame takes a Framebuffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in functions Those that read the layout take it first; those that never did take no layout, and their calls drop a receiver they did not need. Methods that dom.ts or cssom.ts call stay: layout.ts loads both, and importing a function back from it would close a cycle and change which module finishes loading first. So do the ones called through an optional chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n functions start and flush become startExchange and flushExchange, since a bare start() or flush() says nothing at the call. Methods that dom.ts or framebuffer.ts call stay, as importing them back from exchange.ts would close a cycle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n functions CellGrid's clear, clone and copyFrom become clearGrid, cloneGrid and copyGridFrom, and Input's dispatch becomes dispatchInput, since the bare names say nothing at the call. FrameWriter stays a class whose methods chain, which reads better than the nested calls functions would need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A frame repaints the rows a box painted before and the rows it paints now. Two things moved those rows without the damage pass seeing it. A box whose style changed as it moved was judged by its new style alone, so a background it lost left its old rows unrepainted. And a sticky box's shift moves its rows with no change to its rect, so each solve now compares every sticky box's rows, shift included, with the last. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Counters follow css-lists-3: an element inherits its parent's and its previous sibling's counters at the values the element before it left, then applies counter-reset, counter-increment and counter-set. An element's counters begin with every ancestor's, so inheriting is cutting the list of the element before to the length of the previous sibling's or the parent's. An element that changes no counter is transparent: inserting or removing one renumbers nothing, so the counts are dropped only when an element's counter inputs change or a subtree holding one comes or goes. A derivation retires the boxes of elements it walks through, and now walks into every inline box, as the enumeration does. Asking whether an inline was broken around a block read the state after the change, so a block that dissolved inside an inline kept its old box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y read Table parts a container that is no table holds share an anonymous table, one per unbroken sequence (CSS2 §17.2.1), with a layout node of its own that the parts hang from, so cells outside a row sit side by side and stray rows share columns. White space in a table, a row group or a row is no box, so any run left there is content, which the table puts in an anonymous cell. A row group spans its own rows. A size measured with no width was reused for a request of the width it came out at, which holds for text but not for content with a percentage, which an unbounded probe takes as auto. A node whose content resolves a percentage against the width it is offered now reuses a size only for the request it answered. An outline of a box with no height drew on the row above, which no extent covered, so a frame repainting that row skipped it. The layout check also asserts the tree's shape: every child names its parent, no node is held twice or hangs out of reach, and only an element that generates a box has one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The from-scratch style, layout and partial-frame checks run in the frame loop behind import.meta.litest, which libuild 0.2.27 defines in every test bundle and the build replaces with undefined, so the checks and the functions only they call are dropped from dist. setDecodeTimeout and getWindowInterfaceNames existed only for a test and a script. The tests wait out the real timeout, or pass an expired deadline to decodeImageForPage, and the script reads the names off getInterfaceObjects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getInterfaceObjects keeps only the interfaces the generated IDL already names, which is the file the generator writes, so the names came out short and the freshness check failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idths exact A table part takes its place in the anonymous table around it, so an absolute box after one starts below the table rather than above it. An inline-block's percentage width was rounded to whole cells as it resolved, so three at a third each asked for more cells than the line had. Layout keeps the exact width and the grid rounds it, as it does every other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd read counters()
A pseudo-element's counter-reset, counter-increment and counter-set
were ignored, so the common h2::before { counter-increment: section }
numbered every heading 0. An element's counters are now counted where
its subtree starts, after its ::before, which its first child takes,
and where it ends, after its ::after, which its next sibling takes its
values from.
counters() joins every counter of the name in scope. Both functions are
read as a content value's tokens, so a string that spells counter() is
only text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
inline-table was not a display layout knew, so it laid out as a block. It is now an atomic inline whose own formatting context is a table, as an inline-grid's is a grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…paint A box an inline-block lays out has its extents counted from the inline-block's own root. Painted through its stacking layer, such a box was culled against the band counted in document rows, so a positioned cell in a table made an inline-block went unpainted when the frame's rows were not its root-relative ones. The band is now counted from the root there, as it is when the inline-block paints its content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s in another frame A box an inline-block lays out has its extents counted from the inline-block's own root. Hit-testing compared the pointer's document row with them, to rule a box out and to search a list's children, so a pointer over such a list landed on the list rather than the child. Both shortcuts now apply only to boxes counted in document rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An inline-block paints its content, which can reach past its own box and the line it sits on, and the line's extent left that out. A box is ruled out of a hit test only by an extent that covers it, so content overflowing an inline-block could not be hit. The line's extent now reaches as far as each inline-block's content does, and a box measured on a line is ruled out by the line's extent, or by none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly in it An extent's rows count from the document's root or from the root an inline-block lays its content out under. Readers compared them with rows of the document wherever they came from, and three fixes each put a check in front of one reader. The extent now records its frame, and paintExtent answers only a caller counting in that frame: the painter, which knows the frame it is painting, and hit-testing and damage, which count in the document's. An extent of another frame reads as none, which every reader already takes to mean "do not cull". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re percentages resolve A run's measured size is reused only for the request it answered when its content's size depends on the width it is given. That was flagged where an inline-block resolved a percentage width or max-width, and a replaced box resolving its own percentage width was never flagged. An atomic inline with a percentage in any horizontal sizing property now marks the run as it is collected, however it is later resolved, and so does a percentage text-indent on the container. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The key that says when to count again and the counting itself each read an element's counter properties, in two places that could disagree: the key read them only where counter rules exist, and counting always. Both now take one record of what the element, its ::before and its ::after reset, step and set, and whether it shows, and the key is made from it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t unredrawn A form control's shadow tree is a function of its state, redrawn at each of some fifty places that change the state, and a place that forgot left the control showing the old state. A frame now brings every connected control's tree up to its state before it applies mutations, so a forgotten place cannot reach the screen. Under test, a control the frame would change first throws, so a forgotten place is still found, as it matters to a read before the frame: a canceled click put a checkbox's checkedness back without redrawing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-block A sticky box's shift walked its layout parents to the scroller it holds against, and the walk ends at an inline-block's own root, so a sticky box inside one held against the viewport with its position counted from that root. The walk now goes on from the inline-block, reading it and the scroller where they are drawn, and the frame's repaint compares each sticky box's shift too, repainting the inline-block's rows, which cover any shift inside it. A third fuzz page holds what boxes that lay out their own content hold: a sticky box against a scroller's bottom in an inline-block, content past an inline-block's height, an inline-table, ::before counters and cells with no table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Counters were counted along the light tree, so a shadow tree's content and a slot's assigned nodes did not take the counters around their host or slot. Counting now walks flat-tree parents and siblings, a removed subtree is searched through its shadow trees for counters it changed, and a slot taking other nodes or a host taking a shadow root counts again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rap any non-cell in a row An inline-block's own root takes its display from the element, a table for an inline-table and a block for an inline-block, but it was styled only when the root was made, so an inline-table made an inline-block went on laying its content out as a table. The root is now styled wherever its element is, and a run syncs each member's root as it syncs the member. A row wraps whatever in it is no cell in an anonymous cell (CSS2 §17.2.1), a row or a group included, which the cell then lays out as a table of its own. A row held in a row was taken for a cell. A node's kept sizes are dropped when it moves under another parent, or its parent's display changes, since what it was sized as depends on what holds it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Whether an element lays its content out under a root of its own follows its display, but the root was dropped only when its node was made again. A node kept through a change, as one moved into an anonymous table is, kept the root, and its content went on being placed under a root nothing laid out. A restyle that changes the kind of box now syncs the root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… count from different roots A static position is the in-flow position less the containing block's, and the two were subtracted though one counted from an inline-block's own root and the other from the document's. An absolute box in an inline-table placed against a positioned ancestor outside it took a position from rows that meant nothing there, as a fresh render, which reached it before the root was laid out, did not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An atomic inline that lays its content out under a root of its own keeps its node while it sits on a line, and the node kept the style it had when made. A restyle compares the element with that style to tell whether its box changed kind, so an inline-table made a block again, compared with the block it was made as, left the line that measured it in place. A run now styles each member's kept node as it syncs it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode is held A member of a run is measured by the run and has no box of its own, but one that lays its content out under a root of its own kept the node it had before it joined the line. Its rect and its paint read that node, a block's, so a sticky footer made an inline-table painted the width of the line. A run now drops every member's node, as a fresh render never makes one, and the layout check fails on a node no box holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… goes, and check they reach their containing blocks An out-of-flow box hangs where its containing block can be reached from its parent, which was checked only as it was placed. An element that took a root of its own moved what it holds under that root, taking an absolute box with it out of its containing block's reach, so the box was laid out against a parent that was not its containing block. Making or dropping a root now places its out-of-flow boxes again, and the layout check fails on one out of its containing block's reach. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An atomic inline lays its content out under a root of its own that its run's measure lays out. One taken out of flow is measured by no run, and a root made for it took its content from its own node and was laid out by nothing, so an absolute inline-table showed nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A box measured as a run of its own, as a text node's is, has no parent box, and the run's measure asked that missing parent for its text-indent. The block the lines are indented in is the run's container, or the box's own element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An inline-block, inline-table, inline-grid or image sat at the top of its line, which it filled, and the text beside it sat on the line's top row. Each now sits with its baseline on the line's (css2 §10.8.1): an inline-block's last line, an inline-table's, inline-grid's or inline-flex's first, and the bottom edge of an image or of a box that clips. vertical-align: top, middle and bottom place one against the line instead. A line is as tall as what reaches furthest above the baseline and below it, and its text sits on the baseline's row; a line of text alone is placed as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An inline-block flex item's measure counts its cross-axis border and padding in its size, so its layout node holds none on those edges. The content rect read the node's edges as the box's, so a focused, empty textarea in a flex row parked its caret on its top border. Which edges are masked is now one function, which the masking and the content rect both read. Co-Authored-By: Claude Opus 5.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.
Makes TermDOM faster and keeps it responsive when the machine is busy, makes its caches provably follow every change, lines atomic inlines up on the baseline, cleans up the internals, and fixes the bugs found along the way.
Performance
<html>a flex item, so every layout ran an extra min-content pass over the whole page.setTimeout(0), which puts the process to sleep and adds delay when the machine is busy. It now usessetImmediate.getBoundingClientRectno longer re-clamps every scroll offset, or searches for an enclosing<math>, when nothing calls for it.DOMRectinstead of three.getComputedStylekeeps one view per element, so a restyle no longer redefines some 400 index properties.:has()change restyles earlier siblings only when a sheet uses:has(+ x)or:has(~ x).Responsiveness under load
bench/scenarios/latency.tsandeditor-latency.tstime a key from input to frame in three conditions: idle, every core busy, and two busy processes per core.<input>: median key-to-frame went from 5.3 ms to 2.3 ms idle, and from 4.8 ms to 1.9 ms with every core busy.Benchmarks
The branch adds a benchmark harness (
bench/run.ts), and the Benchmarks workflow posts this branch's numbers against its base. Medians againstmain, measured locally on a busy machine. Key to frame is wall time; the rest is CPU time.li:has(), 500 items)<input>key to frameThe last round of changes, made after that run, also cut CodeMirror's CPU per key by 18% and emails by 9%.
Staying correct through changes
Every cache layout, style and paint keep between frames is a way for a page that has changed to drift from the same page rendered fresh. Rather than chase those one at a time, the engine now keeps four rules:
grid) that only rounding writes and layout never reads.Some bug classes are now impossible to write rather than only tested for:
Each rule is checked in every test, on every frame:
The checks run only under
libuild test. They sit behindimport.meta.litest, which libuild 0.2.27 defines in every test bundle, andlibuild buildremoves them, sodistcarries none of them.tests/incremental.test.tsdrives this: it applies seeded random changes to three pages built from the features those caches cover (tests/fuzz-pages.ts), and after each change compares the screen and every element's rect with a fresh render.Baseline alignment
Inline-blocks, inline-tables, inline-grids and images used to sit at the top of their line and fill it, with the text beside them on the line's top row. Each now sits with its baseline on the line's (CSS 2.1 §10.8.1):
<textarea>vertical-align: top,middleandbottomplace a box against the line instead. A line of text alone is placed as before, and no email snapshot changed.display: inline-tableis also new: it used to lay out as a block.Bug fixes
now takes a line. Outlook writes every blank line this way, so those blank lines were dropped.xandyreport its position.TypeError.@importand@namespacein the wrong place in a sheet are dropped, as the spec requires.Found by the checks and the fuzz tests:
display: contentsno longer keeps its old box.<style>added inside a subtree applies.<input>shows its new value when itsvalueortypeattribute changes.<textarea>shows its new text children until edited.counter-setworks, and a box that isn't shown doesn't count;::beforeand::aftercount as an element's first and last child, soh2::before { counter-increment: section }numbers headings;counters()works, and a string that spellscounter()stays text;Cleanup
tree.ts(tree reads),units.ts(cell size) andwindow.ts(window assembly).setDecodeTimeoutandgetWindowInterfaceNamesexisted only for a test and a script, and are gone.Tests
CI passes on node 20.19 and 26, bun and deno. The branch requires libuild 0.2.27. New or changed tests:
tests/import-graph.test.ts: fails on any runtime import cycle.tests/style-caches.test.ts: the cached box model and marker style follow every kind of style change.tests/stylesheet.test.ts::has()invalidation.tests/whitespace.test.ts: paragraphs.tests/cell-size.test.ts: the half-cell case.tests/test-utils.ts: turns on the layout, style and partial-frame checks for every test.tests/incremental.test.tsandtests/fuzz-pages.ts: seeded changes against fresh renders, plus every seed that once found a bug.tests/partial-repaint.test.ts: each way a page can change names the rows it repaints.tests/counters.test.ts: CSS counters, pseudo-element counters,counters()and the flat tree.tests/tables.test.ts: anonymous tables, row groups and inline-tables.tests/inline-block.test.ts: baseline alignment.tests/sticky-position.test.ts: a sticky box inside an inline-block.tests/element-from-point-shadow.test.ts: hit-testing inside inline-blocks. spacer rows now appear, where lines that crossed their box now wrap inside it, and where a Ghost newsletter's feedback buttons, which were drawn at no size, now show. Centred text in two of them moved one column, from widths kept exact before rounding.🤖 Generated with Claude Code