Recursive AI

The cascade, computed styles, interaction overlays and restyle

All onehtml docs

Normative behaviour of RecursiveAI.OneHtml.Css.DocumentStyles (namespace RecursiveAI.OneHtml.Css), written so another language can reproduce it. The algorithms are those of CSS Cascade 5 (with the Cascade 6 revert-rule keyword), CSS Nesting 1, CSS Variables 1, CSS Conditional 3/4, Selectors 4 and the HTML Standard's Rendering section (§15). This file records what those texts leave open and what is not implemented. Values stay token lists unless a typed parser exists (css-values.md); there is no layout and no painting.

Entry points

MemberWhat it does
DocumentStyles.Create(document, context?, options?)Gathers the style sources and prepares the cascade. No element is computed yet.
GetStyle(element)The ComputedStyle of an element, computed with its ancestors on first use and cached. Thread-safe.
ComputeAll()Computes every element in document order.
WithContext(context)A view for another StyleContext that recomputes only what the context can reach (overlay).
Restyle(newDocument, change)A view for an edited document that recomputes only what the change can reach.
Sheets, Diagnostics, RuleCount, DistinctStyleCountWhat was gathered, what could not be, and sharing figures.

ComputedStyle exposes, for every property in the property table: GetValue(name) (a StyleValue: tokens, text, the declaration it came from, whether it is initial or inherited), GetPropertyValue(name) (text), GetSource(name) (DeclarationSource: sheet, rule, source range, importance, origin, layer path), TryGetCustomProperty, and GetComputedValue(name) (the getComputedStyle serialization for the typed set). Typed accessors build on first read and are cached: Display, Visibility, Opacity, Position, the offsets, OverflowX/Y, PointerEvents, Speak, SpeakAs, Content, Clip, ClipPath, BackgroundImage, FontStyle, FontFamily, FontSizePx, FontWeight, Color, BackgroundColor, OutlineColor, OutlineStyle, OutlineWidthPx, OutlineOffsetPx. Resolution chains (color → currentcolor, font-size → parent, root, viewport) are iterative.

Style sources

Gathered in this order, each with an origin and a position in the cascade order:

  1. User agent sheet. The CSS blocks of HTML §15 (33 standard blocks, 4 quirks-mode blocks), generated by tools/generate-ua-stylesheet.py into Css/Generated/UserAgentStylesheet.g.cs with the retrieval date and the SHA-256 of the page. The quirks blocks apply when the document is in quirks mode. Blocks that are not CSS or are encoding specific are skipped (the generator lists them). Turn it off with StyleSourceOptions.IncludeUserAgentSheet.
  2. User sheets from StyleSourceOptions.UserSheets (empty by default).
  3. Presentational hints (their own tier between user and author, HTML §15.3.x): bgcolor, text, font color/face/size, align on div, p and h1–h6, width/height on img, video, canvas, iframe, embed, object, table, td, th, nowrap, and the body element's link attribute, which colours every a[href] and area[href] of the document (HTML Rendering, "Page"), with the legacy colour parser. Left out: vlink/alink (:visited never matches; :active is a context state), border, cellpadding, cellspacing, valign, list type, hr attributes, frame/rules.
  4. Author sheets in document order: <style> elements and <link rel=stylesheet> (bytes from StyleSourceOptions.Resolver, keyed by absolute URL: the document URL from BaseUrl, changed by the first <base href>). A media attribute that does not match the context's media, disabled, a non-CSS type and rel=alternate sheets are skipped. A sheet the resolver does not return is reported as a MissingStylesheet diagnostic and contributes nothing; the library never fetches.
  5. @import (same resolver) is followed in place, with layer(), supports() and media conditions, only before other rules (an @import after a rule is ignored). A cycle, or a chain deeper than MaxImportDepth (16), is reported (ImportCycle, ImportDepthExceeded) and skipped. A layered import establishes its layer even when the sheet is missing.
  6. style="" attributes, as an element-attached declaration source above all author rules of the same importance.

Conditional group rules: @media (evaluated against the context's MediaFeatures), @supports (including selector(), which accepts &; a selector that starts with a combinator is not valid there), @layer (named, anonymous, nested, statement form; a statement inside a style rule is ignored), @namespace, and style rules with CSS Nesting (& is replaced by :is(parent), or :scope at the top level; an item without & is relative to the parent; a nested declaration run after nested rules becomes a separate ordered rule; an invalid nested selector drops that rule and its content; the resolved selector text is capped at 64 KB). Rules with a pseudo-element selector are kept out of the element match index; those that end in ::before, ::after or ::marker (or the single-colon forms of the first two) go into a separate index per pseudo-element (see "Pseudo-element styles").

Cascade

For every element and property the winner is the declaration with the greatest key, compared in this order (Cascade 5 §6):

  1. Tier: normal declarations rank user agent < user < presentational hint < author < inline style; !important reverses origins: author important < inline important < user important < user agent important. Transitions and animations are not evaluated.
  2. Layer: layers are ranked in a post-order of the layer tree per origin (unlayered last); for important declarations the rank is reversed.
  3. Specificity (Selectors 4; :is() and :not() take their most specific argument).
  4. Order of appearance (rule order across sheets, then the declaration's position).

Shorthands are expanded before they are compared (Shorthands.Expand); a shorthand with no grammar in the library (NotExpanded, for example box-shadow) is kept as its own token list and does not override earlier longhands. all accepts only a CSS-wide keyword and sets every property except direction, unicode-bidi and custom properties. Logical properties are merged with their physical counterparts using the element's writing-mode and direction (the later in the cascade wins). Inheritance follows the property table. CSS-wide keywords: initial, inherit, unset; revert removes the declarations of the same origin class (user agent / user / hint-and-author); revert-layer removes the winner's layer and every later layer of that origin at both importances (Cascade 5 §7.3.5 "or between its normal and important levels"; element-attached styles roll back only themselves); revert-rule removes the winner's rule. After a rollback the cascade is evaluated again until a non-rollback winner remains; nothing left means unset.

Custom properties cascade like any property, then resolve per element: var() inside custom properties is substituted, cycles and references to invalid properties make the property guaranteed-invalid (it is absent, and does not fall back to the inherited value), and the results inherit. For other properties var() is substituted when the style is built; a value that is invalid then behaves as unset (Variables 1 §3.2). A declaration with var() is checked at parse time for well-formed var() syntax; the fallback of a var() may not contain a top-level ! or ;.

Provenance. Each winning value keeps the declaration that supplied it (DeclarationSource). For rules from an inline style attribute or a presentational hint, Owner is the null node, because rules are shared by text between elements.

The style table

Styles are immutable and shared. The key of a style is the parent style object plus the identities of the winning declaration values (and custom-property winners); an element with the same key gets the same ComputedStyle. A style holds its own non-inherited values, a shared table of inherited values and the custom properties. The effect: siblings with the same winners share a style; equal styles under different parent objects do not, because lazily resolved values (currentcolor, em) read the parent. On the 35,000-node benchmark page below, 45 % as many styles as elements are built. Reads are lookups; typed values are built once on first read and published with a volatile write.

Interaction state and overlays

StyleContext adds Focus (+ FocusSource: Keyboard or PointingDevice), Hover, Active and Target, next to the media features. They feed the selector matcher's InteractionState:

  • :focus and :focus-within (the focused element and its flat-tree ancestors); :focus-visible is true for a keyboard focus, and for pointer focus only on text-entry controls and editing hosts (as Chrome documents it).
  • :hover on the hovered element and its ancestors, and on the control a hovered <label> labels (HTML §4.10.4); :active likewise.
  • :target and :target-within.
  • :visited never matches.
  • Script-driven state is invisible, so these results are CSS only.

WithContext returns a view that keeps the base view's styles and stores new ones for the elements the new state can change. It matches the index of rules that read dynamic pseudo-classes (Hover, Active, Focus, FocusWithin, Target, including inside :is(), :not() and :has()) under the old and the new state, and recomputes the elements whose matches differ, parents before children, then the descendants whose inherited values changed. A media change rebuilds the base.

When only the focused element differs, the matching is bounded instead of run over the page. Each compound of a rule that reads focus is an anchor; a selector's match can change only where an anchor compound matches differently, and from there only along the rest of the selector. The anchor compounds that can change are those of the old and new focused element and, for :focus-within and for focus read inside :has(), of their ancestors; a :has() argument with a sibling combinator also reaches the earlier siblings of those ancestors. Anchors are bucketed by the id, class or type their compound requires, so an element is tried against only the anchors it could satisfy. What follows the anchor in the selector reaches below it and beside it: all descendants after a descendant combinator, but only as many levels as there are child combinators when no descendant combinator follows (li:focus-within > a reaches the li's children, not its subtree). Hover, active and target changes, and focus changes together with them, test every element against the index of dynamic rules. Overlays share the engine's style table. ChangedElements lists the indices an overlay or restyle replaced.

Restyle after a change

Restyle(newDocument, StyleChange) returns RestyleResult(Styles, RestyledElements, FullRecompute).

  • StyleChange.Elements: ElementChange(NodeIndex, AttributeNames?) for elements whose attributes changed (names in lower case; null means any), or whose children or text changed. NodeIndex is an index in the new document.
  • StyleChange.OldToNew: for a new document whose node indices differ from the old one (an edit parses again), the new index of each old node, or -1 if it was removed. Added, removed and moved nodes are then found by comparing the trees, so the caller does not list them. Without it the new document must have the same node indices, parents and kinds.
  • StyleChange.SheetsChanged: a <style> text, a linked sheet or a <link> changed. The sheets are gathered again; the elements matched by the rules between the common prefix and suffix of the old and new rule lists are restyled.

An element is recomputed when it is listed, new, moved, or next to an added or removed node (siblings and parents, when a sibling, position or :empty selector exists, and the descendants of those siblings when such a selector reads a position or sibling and then reaches below, as li:last-child a does), when a selector that reads a changed attribute, class or id matches it differently in the old and the new document (found by matching only those rules, which covers descendant, sibling, :has() and :nth-* reach), when a changed rule matches it, or when its parent's style object changed and the values differ. Everything else keeps its style object. A change of quirks mode or of the layer structure, and a tree of another shape without OldToNew, recompute fully (FullRecompute).

:has() after a change of shape

A :has() result for an element X reads only what X's relative selectors reach (Selectors 4 section 4.5), so after nodes were added or removed the engine compares old and new matches (as for attribute changes, matching only the rules with :has()) for the elements the change can reach, instead of recomputing the page. The reach depends on where the :has() sits in a rule:

  • Bounded: every :has() of the rule is in a subject compound, alone or inside :is(), :where() or :not() items that are subjects themselves (.a:has(.b), li:not(:has(> a)), :is(.a:has(.b), .c)). X is then an ancestor of what changed, and, when a relative selector starts with + or ~, also an earlier element sibling of the change or of one of its ancestors. Only those elements are compared. This holds because :has() cannot nest, so no other :has() result feeds X's.
  • Unbounded: a :has() before a combinator (.a:has(.b) + .c, .a:has(.b) .c), or inside :nth-child(… of S). Elements beside or below X depend on X's result, so these rules are compared for every element of the page. This costs one match of those rules per element and no style recomputation beyond the elements whose matches differ.

Seeds are the elements the shape change touched (new nodes, the parents of removed ones, neighbours and, for rules that reach below, the descendants of neighbours). A change of attributes is found by the attribute reach above.

From an edit result

styles.Restyle(EditResult) and styles.Restyle(BatchResult) (extension methods in RecursiveAI.OneHtml.Css) take the styles of the edited snapshot and the result of Apply and return the RestyleResult for the new document. The result equals DocumentStyles.Create on the new document with the same context and options. They build the StyleChange:

  • OldToNew[o] is the change set's MapToNew of old node o, or -1 when it was removed; its length is the old document's node count. A batch with no document change (only linked or loose sheets) restyles the same document with no map.
  • Each changed element with the Attributes flag is an ElementChange. Its names are those of the SetAttribute and RemoveAttribute edits that targeted it, in lower case (style for SetStyle and RemoveStyle); they are null (any attribute) when no such edit explains the flag or the element is not in the HTML namespace.
  • An element with the Children flag, and the parent of a text node with the Data flag, is an ElementChange with an empty name list. Added, removed and moved nodes are left to the restyle's own tree comparison.
  • SheetsChanged is set when a sheet update of the batch has edits, when a style, link or base element or a doctype was added, removed or changed, or when the text of a style element changed. Narrowing a sheet change is the restyle's own: it gathers the sheets again and restyles the elements matched by the rules between the common prefix and suffix of the old and new rule lists (the rules of the StylesheetChangeSet, found by comparison).
  • A linked sheet the batch edited is served from its new text: the new resolver returns it for LinkedStylesheet.Url (which must be the absolute URL the cascade asks for) and delegates every other URL to the resolver the styles were made with. The old styles keep the old sheet.

The restyle keeps an old style object for an element whose recomputed values are textually equal only when it still hangs below the same parent style object: em, %, currentcolor and bolder resolve through the parent chain, so a replaced parent replaces every descendant style that the new rules reach.

A retained style also needs the same origin for each winning declaration (user agent, user, author): the same display: none from the user-agent rule for [hidden] and from an author rule are different answers to the accessibility tree, which tells a hidden attribute from a style.

RestyleResult.PseudoElementsMayDiffer lists, apart from RestyledElements, the elements whose ::before, ::after or ::marker style may differ. An element's own style does not include its generated boxes, so a change that reaches only a pseudo-element rule (a class on an ancestor in .on .b::before, an edited p::after rule) restyles nothing. The list is the changed elements, the elements the shape change touches, the elements the pseudo-element selectors match differently before and after (matched up to the pseudo-element), the elements matched by an added, removed or edited pseudo-element rule, and RestyledElements (a pseudo-element inherits from its element). GetPseudoStyle always computes under the view's own element style, so a retained object is never reused under another parent. A change of StyleContext does not fill the list; DocumentStyles.HasInteractivePseudoElementRules says when such a rule exists (the :hover of a:hover::before).

Measured on a 3,600-node page with 300 rules: a class change restyles 3 elements in 1.7 ms against 11 ms for a full compute of the new document; a declaration change in the style element takes 3.6 ms against 8 ms.

Limits

StyleSourceOptions: MaxImportDepth 16, MaxSheets 4096, MaxRules 2,000,000, MaxLayers 65,536. Reaching one is reported as a diagnostic (LimitExceeded, ImportDepthExceeded) and the excess is dropped. Diagnostics are capped at 256. No loop follows input depth: gathering uses an explicit stack, ancestor chains and the restyle worklist are iterative, and var() substitution has step and size limits. Selector complexity limits are those of selectors.md.

Not supported

  • Other pseudo-elements. ::before, ::after and ::marker get styles (see Pseudo-element styles); rules for the other pseudo-elements are parsed and never match.
  • @scope and @container rules are reported (UnsupportedRule) and skipped. @property registrations, @starting-style, keyframes, animations and transitions are ignored. ident(), if() and container-query units are not implemented.
  • var(--x, revert-layer): a substituted rollback keyword behaves as unset instead of rolling back.
  • Typed values cover the accessibility set only. GetComputedValue for any other property returns the cascaded text, so box-shadow is not reordered or normalized; layout-dependent values (percentages of a containing block, auto sizes) stay as specified. font-style: oblique calc(...) is not typed.
  • Shadow DOM, slots and :host do not exist in the document model.

Behaviour of the typed serialization (GetComputedValue)

  • font-size is never negative; outline-width is never negative and snaps down to whole pixels (at least 1px when positive); outline-offset: inset is kept as the keyword when the declaration is accepted.
  • font-family names that are runs of identifiers serialize without quotes; names that are not (digits, symbols, the generic keywords written as strings, CSS-wide keywords) keep quotes.
  • font-style: oblique alone stays oblique; a written angle is kept; oblique 0deg serializes as normal.
  • overflow-x/overflow-y: visible computes to auto when the other axis is hidden, scroll or auto; the overflow shorthand serializes as one keyword when both axes are equal.

Pseudo-element styles

GetPseudoStyle(element, PseudoElementKind) returns the computed style of the ::before, ::after or ::marker of an element, or null when no rule styles it (then no box is generated for ::before and ::after). A selector is indexed only when the pseudo-element is the last simple selector of its last compound; it matches when everything before the pseudo-element matches the originating element (Selectors 4 section 3.6), with the originating element's specificity plus one type selector for the pseudo-element. The cascade is the element cascade restricted to those rules: no style attribute and no presentational hints reach a pseudo-element, and the style inherits from the element's own computed style. The box exists for ::before and ::after only when content is neither normal nor none; the typed Content accessor gives strings, attr(), counter()/counters(), quotes and the alternative text after /. Counter values and attribute lookups are the caller's to resolve (the accessibility tree does both). User-agent rules apply, for example q::before { content: open-quote }. Not done: ::first-line, ::first-letter, ::placeholder, ::selection, ::backdrop and the other pseudo-elements, pseudo-classes after a pseudo-element (the selector is not indexed), and generation of ::marker content.

Conformance (web-platform-tests, pinned commit in the notices file)

The replay in tests/OneHtml.Tests/Style/WptStyleTests.cs translates getComputedStyle assertions, test_computed_value calls, testCases tables and reference tests to this API without a browser. A reference test passes when the page paints red exactly as its reference does, using a conservative notion of painting (visible background, border, outline or text colour; a red box counts as covered when a descendant, or an absolutely positioned box, has an opaque non-red background). Files that need script to change the page, the event loop, layout or animations are counted as not applicable with the reason. Every failure is listed with its reason in testdata/css/wpt-style-expectations.txt; the test fails on an undocumented failure.

DirectoryPassKnown failureN/A
css/css-cascade10412314
css/css-variables17910164
css/css-nesting23184
css/selectors (specificity files)12018
css/css-fonts/parsing (computed)4940
css/css-display/parsing10800
css/css-position/parsing2502
css/css-overflow/parsing3400
css/css-ui/parsing2015

The largest N/A groups are tests that need script (@scope and CSSOM manipulation, CSS.registerProperty, events) and pages whose script changes the document before asserting. The known failures are @scope, ident(), var() with a revert-layer fallback, the tentative revert-rule cycle, calc() angles and container units, and reference tests that depend on pseudo-elements, glyph painting or sibling stacking.