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
| Member | What 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, DistinctStyleCount | What 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:
- User agent sheet. The CSS blocks of HTML §15 (33 standard blocks, 4 quirks-mode blocks), generated by
tools/generate-ua-stylesheet.pyintoCss/Generated/UserAgentStylesheet.g.cswith 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 withStyleSourceOptions.IncludeUserAgentSheet. - User sheets from
StyleSourceOptions.UserSheets(empty by default). - Presentational hints (their own tier between user and author, HTML §15.3.x):
bgcolor,text,fontcolor/face/size,alignondiv,pandh1–h6,width/heightonimg,video,canvas,iframe,embed,object,table,td,th,nowrap, and thebodyelement'slinkattribute, which colours everya[href]andarea[href]of the document (HTML Rendering, "Page"), with the legacy colour parser. Left out:vlink/alink(:visitednever matches;:activeis a context state),border,cellpadding,cellspacing,valign, listtype,hrattributes,frame/rules. - Author sheets in document order:
<style>elements and<link rel=stylesheet>(bytes fromStyleSourceOptions.Resolver, keyed by absolute URL: the document URL fromBaseUrl, changed by the first<base href>). Amediaattribute that does not match the context's media,disabled, a non-CSStypeandrel=alternatesheets are skipped. A sheet the resolver does not return is reported as aMissingStylesheetdiagnostic and contributes nothing; the library never fetches. @import(same resolver) is followed in place, withlayer(),supports()and media conditions, only before other rules (an@importafter a rule is ignored). A cycle, or a chain deeper thanMaxImportDepth(16), is reported (ImportCycle,ImportDepthExceeded) and skipped. A layered import establishes its layer even when the sheet is missing.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):
- Tier: normal declarations rank user agent < user < presentational hint < author < inline
style;!importantreverses origins: author important < inline important < user important < user agent important. Transitions and animations are not evaluated. - Layer: layers are ranked in a post-order of the layer tree per origin (unlayered last); for important declarations the rank is reversed.
- Specificity (Selectors 4;
:is()and:not()take their most specific argument). - 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:
:focusand:focus-within(the focused element and its flat-tree ancestors);:focus-visibleis true for a keyboard focus, and for pointer focus only on text-entry controls and editing hosts (as Chrome documents it).:hoveron the hovered element and its ancestors, and on the control a hovered<label>labels (HTML §4.10.4);:activelikewise.:targetand:target-within.:visitednever 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.NodeIndexis 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'sMapToNewof old nodeo, 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
Attributesflag is anElementChange. Its names are those of theSetAttributeandRemoveAttributeedits that targeted it, in lower case (styleforSetStyleandRemoveStyle); 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
Childrenflag, and the parent of a text node with theDataflag, is anElementChangewith an empty name list. Added, removed and moved nodes are left to the restyle's own tree comparison. SheetsChangedis set when a sheet update of the batch has edits, when astyle,linkorbaseelement or a doctype was added, removed or changed, or when the text of astyleelement 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 theStylesheetChangeSet, 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,::afterand::markerget styles (see Pseudo-element styles); rules for the other pseudo-elements are parsed and never match. @scopeand@containerrules are reported (UnsupportedRule) and skipped.@propertyregistrations,@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 asunsetinstead of rolling back.- Typed values cover the accessibility set only.
GetComputedValuefor any other property returns the cascaded text, sobox-shadowis not reordered or normalized; layout-dependent values (percentages of a containing block,autosizes) stay as specified.font-style: oblique calc(...)is not typed. - Shadow DOM, slots and
:hostdo not exist in the document model.
Behaviour of the typed serialization (GetComputedValue)
font-sizeis never negative;outline-widthis never negative and snaps down to whole pixels (at least 1px when positive);outline-offset: insetis kept as the keyword when the declaration is accepted.font-familynames 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: obliquealone staysoblique; a written angle is kept;oblique 0degserializes asnormal.overflow-x/overflow-y:visiblecomputes toautowhen the other axis ishidden,scrollorauto; theoverflowshorthand 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.
| Directory | Pass | Known failure | N/A |
|---|---|---|---|
css/css-cascade | 104 | 12 | 314 |
css/css-variables | 179 | 10 | 164 |
css/css-nesting | 23 | 1 | 84 |
css/selectors (specificity files) | 12 | 0 | 18 |
css/css-fonts/parsing (computed) | 49 | 4 | 0 |
css/css-display/parsing | 108 | 0 | 0 |
css/css-position/parsing | 25 | 0 | 2 |
css/css-overflow/parsing | 34 | 0 | 0 |
css/css-ui/parsing | 20 | 1 | 5 |
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.