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.

Declaration validators

The library checks a declaration's value at parse time only as far as it knows the property's grammar (the typed properties, the shorthands it expands, and a syntax check for the rest; css-values.md). A caller that knows more, such as a layout engine with its own grammar for width, supplies StyleSourceOptions.DeclarationValidator: bool Validate(string property, TokenRange value, StyleOrigin origin). A declaration it rejects is dropped when the rule's declarations are collected, exactly like one that did not parse (Syntax 3 §5.4.6, Cascade 5 §4.3): it does not win, it does not count as !important, and the earlier declaration of the property, or the inherited or initial value, applies. This is how width: 100%; width: -webkit-fill-available falls back to 100% for an engine without the second value.

  • Consulted: author and user declarations, from style rules (nested ones included), <style> and linked sheets, and style="". The same options reach overlays (WithContext) and restyle (Restyle, edit results), so they equal a fresh DocumentStyles.Create with the same validator, provided the callback is deterministic.
  • Not consulted: custom properties; values that are CSS-wide keywords; all; the user-agent sheet and presentational hints (the library's own); the 33 typed properties, which keep their own parsers (the hook is additive and sees only properties the library does not type); values containing var() (or any substitution), which are not known until computed-value time and stay invalid at computed-value time, as without a validator. The callback is also not consulted for the substituted value at computed-value time.
  • Shorthands. The callback sees the declared name only for a shorthand the library does not expand (NotExpanded, for example box-shadow). For a shorthand it expands (margin, border, background, ...) it sees each untyped longhand with its expanded value, never the shorthand itself, so one grammar per longhand serves both spellings. One rejected longhand drops the whole shorthand declaration (a shorthand with an invalid part is invalid). The library's own shorthand grammar runs first, so the callback never sees values the library already rejected.
  • Input. TokenRange is the same public token cursor the library's value parsers use: indexes into the owning CssTree, with Kind, Next, Inner, IsIdent, Number, TryUnit and so on. The range has no leading or trailing whitespace and is never empty. TokenRange.Parse(text) makes one from text.
  • Contract. The callback must be deterministic, quick and thread-safe: declarations are validated once per rule on first use, and two threads may race to do so. An exception it throws surfaces from the style query that triggered it. With no validator the cascade is unchanged.

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.

Live interaction state

WithContext is the right tool for a few what-if contexts. A renderer that moves the pointer every frame wants no allocation and no per-change object, so LiveDocumentStyles.Create(DocumentStyles) makes a second kind of object that owns its buffers and moves in place:

C#
var live = LiveDocumentStyles.Create(styles);          // computes every element once; `styles` is untouched
live.ApplyState(ElementStateChange.Hover(node));       // hover, active, focus (+ source) and target, any combination
foreach (int i in live.ChangedElementIndices)          // ascending element indices whose style object changed
    use(live.GetStyle(i));                             // by index, no view object
  • What it covers. Hover, active, focus with its FocusSource, and :target (it is one more chain of the same kind, so it costs nothing extra). ElementStateChange is a struct; the parts it names move, the others keep their value, the null node clears. The result equals DocumentStyles.Create(document, equivalentContext) for every property of every element, :has(), sibling and descendant combinators, :focus-within, the hovered ancestor chain, the control of a hovered label, and inherited changes included. Media features and the viewport are fixed when the live object is made: they choose which rules exist, so a new DocumentStyles.WithContext(media) and a new Create are the way to change them.
  • Snapshots are untouched. The live object copies the resolved styles it starts from and never writes to the DocumentStyles it was made from, nor to any view of it; those keep their immutable guarantees. ToSnapshot() gives an immutable DocumentStyles for the current state (it allocates). GetContext() gives the equivalent StyleContext.
  • Changed elements. ChangedElementIndices is a span over an internal buffer, valid until the next ApplyState; ChangedElementsInto(Span<int>) copies it into a caller buffer, ascending, and returns how many it wrote: a buffer that is too small receives the lowest indices, and the result is smaller than ChangedElementCount. The list is of style objects replaced. Generated boxes (::before, ::after, ::marker) are read with GetPseudoStyle and are not listed unless the element's own style changed; HasInteractivePseudoElementRules says when that matters.
  • Reads. GetStyle(int), GetStyle(Node) and GetValue(elementIndex, propertyId) (ids from PropertyTable.IdOf) return the shared objects of the style table. Typed values (ComputedStyle.Color and so on) are built on first read of a style and cached on it.
  • Threads. One writer: ApplyState must not run beside another call on the same object. Between calls any number of threads may read. ComputedStyle objects handed out stay valid and unchanged after later moves.
  • Steady state allocates 0 bytes. After a warm-up in which each element and state combination was reached once, ApplyState, ChangedElementsInto, ChangedElementIndices, GetStyle and GetValue allocate nothing. Excluded: the first meeting of a style with a state (the style is built and joins the shared table), the first read of a typed value, the first match of a :has() that reads a state pseudo-class after each change (its page-wide marks are rebuilt into the same array, which costs one pass over the page, no allocation), the first full cascade of each element (its matches among the rules that read no state are found once and kept), the first change on each thread (about 8 KB of per-thread cascade scratch, made once), cascades that use revert, revert-layer or revert-rule, and ToSnapshot, GetContext and GetPseudoStyle.
  • How the change is found. A state pseudo-class matches an element and, for :hover, :active, :focus-within and :target-within, its ancestors (Selectors 4 §9). So a compound that reads state changes its match only on the elements the state moved from or to and on their ancestors, and from there a selector reaches only along its own combinators. The anchors described above are built for every state, not only focus, and the same walk finds the affected elements: the old and new element of each part that moved and their ancestors (stopping at an ancestor already visited), each tested against only the anchors its id, class or type allows, then what each changed anchor reaches. A state pseudo-class inside :is(), :where(), :not() or :nth-*(of) with a combinator inside the argument (a:not(.x:hover b)) cannot be anchored this way: the matching element is not on the chain the state moved along. The selectors that have one are kept in a small index of their own, and each change matches every element against just those, under the old and new state, one pass over the page. The other dynamic rules still go through the anchors.
  • What is recomputed. Affected elements are recomputed parents first, and a child is recomputed when its parent's style object changed. An element whose own rule matches did not move only inherits something new: its winning declarations are the ones it has, so the cascade is not run again and the shared style for those declarations under the new parent is looked up (skipped when the new parent's writing mode or direction differs, which can change how logical properties map). An element whose matches did move runs the cascade, reusing its matches among the rules that read no state (they cannot change with state) and matching only the rules that do. This keeps a change cheap on pages with thousands of rules, where matching every candidate rule of an element dominates.
  • What makes recomputing allocation-free. An element's key (the parent style and the winning declarations) is built in reusable buffers and looked up in the style table; arrays are made only for a style that is new. The id, class and type of an element are read as slices of the document text for the rule index, an element's style attribute and presentational hints are turned into rules once per document, and the selector matcher's bulk caches drop only what reads a state pseudo-class.

Form state from script

A checkbox the user ticked, a control a script disabled and a tri-state checkbox are not in the document's attributes. A FormStateOverrides states them, for any number of elements, and a renderer gives it to either path:

C#
var form = FormStateOverrides.Empty.WithChecked(box, true).WithDisabled(fieldset, true).WithIndeterminate(tri, true);
var styles = DocumentStyles.Create(doc, StyleContext.Default.WithFormState(form));      // or styles.WithContext(ctx)
live.ApplyFormState([FormStateChange.Checked(box, true), FormStateChange.Disabled(other, null)]);   // null: read the attributes again
  • Meaning. Checked replaces the checkedness of a checkbox or radio button (input.checked = ...) and the selectedness of an option (option.selected = ...); Disabled replaces the presence of the disabled attribute on a control, fieldset, optgroup or option; Indeterminate is the indeterminate property of a checkbox. An override on another kind of element has no effect. Overrides are literal: checking a radio button does not uncheck its group mates and selecting an option does not deselect the others, so a caller that mirrors a browser states each element that changed. Without overrides nothing changes: every value is what it was.
  • What follows (HTML 4.16.3): :enabled is the complement of :disabled; a disabled fieldset disables the controls in it except those in its first legend, and a disabled optgroup its options; :read-only and :read-write follow disabledness; a radio button in a named group is :indeterminate when no member of the group is checked (counting the overrides), and one without a name when it is not checked itself. What does not follow: :default (it is defined by content attributes), :required, :optional, :placeholder-shown and the constraint-validation pseudo-classes :valid, :invalid, :in-range, :out-of-range, which keep reading attributes. Chrome 156 also treats the options and optgroups of a disabled select as :disabled; the HTML Standard's definition, which the library follows, does not.
  • Snapshot path. StyleContext.FormState holds the overrides (immutable, content-equal, tied to one document). WithContext with a different FormState tests every element against the index of rules that read form state (the controls a fieldset disables and the rest of a radio group are not on the changed element's own chain, so there is no anchored shortcut), then recomputes what differs. Restyle carries the overrides to the new document through the node mapping and drops those whose node is no longer an element.
  • Live path. LiveDocumentStyles.ApplyFormState(ReadOnlySpan<FormStateChange>) applies the changes in order, then restyles from the changed elements and from their dependents (the controls under a fieldset or optgroup whose disabledness changed, the members of a radio group whose checkedness changed): the same anchors as the interaction state, so :checked + label, li:has(:checked), :not(:checked) ~ x and nested forms are reached, and the same changed list, ChangedElementIndices, is filled. It allocates nothing in steady state (the exclusions of the interaction path apply). Hover, focus, active and target keep their values; both kinds of change can be applied in any order. The result equals DocumentStyles.Create for the equivalent context, which GetContext() and ToSnapshot() return, form state included.
  • Rules with :checked, :disabled, :enabled, :indeterminate, :read-only or :read-write are now dynamic rules (their selectors report SelectorDependencies.ScriptFormState): they are matched again for each element's cascade instead of once per document. The values they give are the same without overrides.
  • The accessibility tree ignores form-state overrides. It reads checkedness, disabledness and selectedness from attributes in many places (implicit properties, names that read a select's value, constraint validity, the dependency index of incremental recompute), and an override is not part of an Apply change set, so honouring it would need a second invalidation path. Build the tree from a document whose attributes say what the page shows (an edit makes that) when the tree must follow script state.
  • Selector API. InteractionState.FormState carries the overrides into Matches and the queries.

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. Styles are served for ::before, ::after, ::marker, ::selection, ::placeholder and the range slider parts (see PseudoElementKind); rules with any other pseudo-element are parsed and never match.
  • @scope and @container rules are reported (UnsupportedRule) and skipped. @property registrations, @starting-style, animations and transitions are ignored (no clock; @keyframes rules are collected, see below). 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, ::marker, ::selection, ::placeholder or range slider part (::slider-thumb, ::slider-track, ::slider-fill) 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, ::backdrop and the other pseudo-elements, pseudo-classes after a pseudo-element (the selector is not indexed), and generation of ::marker content.

::selection

A highlight pseudo-element (CSS Pseudo-Elements 4 section 3). Only the properties that apply to highlights are taken from its rules: color, background-color, text-decoration-line, -style, -color and -thickness, text-underline-offset, text-shadow, fill, stroke, stroke-width and custom properties (HighlightProperties; the list is what Chrome 156 applied when each property was set in turn, so caret-color, the -webkit-text-* paints, text-emphasis and every layout, font and box property are dropped).

  • Highlight inheritance. What no rule sets comes from the parent element's ::selection style, for non-inherited properties too (p::selection { background-color: yellow } colours the background of a span in the p). The style exists for an element when a rule matches it or any ancestor, and is null otherwise (Chrome answers for any element with the originating style; the library has nothing to say). With no highlight style above, the colours are the originating element's.
  • The other properties (font, line-height, spacing, margins, ...) are the originating element's, as Chrome shows for the font group. Deviation: Chrome resets the other inherited properties that are not highlight properties (text-transform, white-space, cursor, visibility, ...) to their initial values on a highlight style; the library leaves them inherited from the element.
  • Parents are computed first with an explicit loop and each step is cached, so depth does not recurse. RestyleResult.PseudoElementsMayDiffer adds every element below one that may differ when the sheets have a ::selection rule.

::placeholder

Exists (not null) for an input of type text, search, url, tel, email, password or number, and for a textarea, that has a non-empty placeholder attribute; ::-webkit-input-placeholder is the same pseudo-element. It is a regular pseudo-element: it inherits from the control and every property of its rules applies, except display, pointer-events and (on an input) line-height, which the user-agent style fixes with !important. Chrome 156 shows the same: grey color (#757575), pointer-events: none, display: block while the placeholder is shown and display: none while a value hides it (:placeholder-shown is false). The HTML Standard gives no such rules, and the spec's list of properties for ::placeholder (those of ::first-line) is narrower than what Chrome applies; the library follows Chrome here. Chrome's direction, overflow and text-overflow are not modelled.

Range slider parts: ::slider-thumb, ::slider-track, ::slider-fill

CSS Forms 1 names three parts of an input whose type state is Range (HTML Standard 4.10.5.1.13; type is matched ASCII case-insensitively, so type=RANGE counts and type=range on any other element does not). GetPseudoStyle answers null for every other element, and reads the type from the document it is asked about, so an edit that changes type adds or removes the parts and RestyleResult.PseudoElementsMayDiffer lists the element (an edited element always is listed).

Names. Each part has the standard name and the browsers' own, all indexed under the same part:

PartNames
thumb::slider-thumb, ::-webkit-slider-thumb, ::-moz-range-thumb
track::slider-track, ::-webkit-slider-runnable-track, ::-moz-range-track
fill::slider-fill, ::-moz-range-progress

Observed in Chrome 156 (CSSStyleSheet.insertRule): it accepts the -webkit- names (and any other -webkit- pseudo-element, as written), and rejects ::slider-thumb, ::slider-track, ::slider-fill and every -moz- name. The library accepts all of them as aliases, so one stylesheet written for any browser styles the parts. That is a deliberate difference from Chrome for the CSS Forms and -moz- names. As in Chrome, functional notation (::-webkit-slider-thumb()), another pseudo-element after a part and :disabled after a part are invalid and drop the rule. Selector lists mix names freely, and ::-webkit-slider-thumb alone matches every range input. A rule is found when the part is the last simple selector, as for the other generated boxes, and anything before it (input:hover, .a > input[disabled]) is matched against the input: its :hover, :active, :focus, :focus-visible and :disabled follow the StyleContext and form state on the snapshot path and ApplyState/ApplyFormState on the live path, which clear the cached parts that a state change reached.

Inheritance. In Chrome the parts are elements inside the input's shadow tree: container, then track, then thumb. The library models the two that CSS can name: the track inherits from the input, and the thumb and the fill inherit from the track (so input::slider-track { color: blue } also colours the thumb, as it does in Chrome). Properties that do not inherit start from their initial values, so the input's border, margin, padding, background and opacity do not reach the parts.

User-agent style. Chrome's DevTools protocol (CSS.getComputedStyleForNode on the shadow nodes; `getComputedStyle(input, '::-webkit-slider-thumb')` returns the input's own style there, so it cannot be used) reports, besides the initial values: the thumb display: block; box-sizing: border-box; appearance: auto; unicode-bidi: isolate; the track `display: block; box-sizing: border-box; flex: 1 1 0%; align-self: center; appearance: none; unicode-bidi: isolate`. The library's user-agent sheet has exactly these. A disabled input changes no part rule: the parts only inherit the colours the input's own disabled style gives them. Chrome has no node for the fill, so the fill has no defaults and exists only when a rule styles it (null otherwise), like ::before.

Not done.

  • A state pseudo-class on the part itself (::-webkit-slider-thumb:hover, :active, :focus): Chrome accepts it and answers from hit testing of the part. The library has no hit testing, so the rule is parsed and dropped, not applied to the input's state.
  • Sizes: Chrome's computed width and height of the parts are used values from layout (16px for the thumb) and are not declared.
  • Blockification: Chrome reports display: block for a track an author set to inline (it is a flex item); the library keeps the author's value.
  • The container (::-webkit-slider-container) and the vertical orientation's touch-action and min-* swaps.

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.

white-space and text-wrap

Both are shorthands of inherited longhands (CSS Text 4 sections 3.1 and 5.1): white-space sets white-space-collapse and text-wrap-mode, text-wrap sets text-wrap-mode and text-wrap-style. The shorthand is never stored; its longhands inherit, so a child of white-space: pre reads pre. The accepted values are Chrome 156's: white-space-collapse is collapse, preserve, preserve-breaks or break-spaces here (discard, preserve-spaces and white-space-trim make the shorthand invalid), and text-wrap-style is auto, balance, stable or pretty. An omitted part resets to its initial value. GetPropertyValue and GetComputedValue serialize the shorthand from the longhands as the shortest form: normal, nowrap, pre, pre-wrap, pre-line, break-spaces, otherwise <collapse> nowrap or the collapse keyword alone; text-wrap reads wrap, nowrap, <style> or nowrap <style>.

border-block and border-inline

One <line-width> || <line-style> || <color> sets the start and the end side, like border-block-start and border-block-end written with the same value; the flow-relative longhands map to physical sides by writing mode and direction (CSS Logical Properties 1 section 4). They accept var(), CSS-wide keywords and !important, and a DeclarationValidator sees the longhands.

position: running()

position: running(<custom-ident>) (CSS Generated Content for Paged Media 3 section 1) is a valid position value. The identifier is any identifier but a CSS-wide keyword or default; it is case-sensitive, spaces inside the parentheses are free, and !important is allowed. Anything else (running(), two identifiers, a string, a comma, bare running) is invalid and, as for any invalid declaration, leaves the earlier position.

  • ComputedStyle.Position is PositionValue.Running (a new last member; the five classic values keep their numbers) and ComputedStyle.RunningName is the identifier (null for every other value). GetPropertyValue("position") returns the text as written (running(Header)), GetComputedValue("position") the normalized running(Header).
  • Code that knows only the classic keywords should treat Running like static: Position is Absolute or Fixed is false, Display is not blockified, and a text comparison with "static", "relative", "absolute", "fixed" or "sticky" fails for it.
  • GCPM takes a running element out of the normal flow and does not display it in place. That is a paged-media effect; no layout is done here. Chrome 156 does not know the value (it drops the declaration, so the element stays in the page), and the accessibility tree and the checker follow the page Chrome renders: a running element is in the accessibility tree with the role, name, hidden state and focus order it has with position: static, and the checker treats it as not positioned (so it is not a stacking-context or out-of-flow hazard for the contrast and obscured-focus rules). Chrome is the reference only where the specification agrees with it: the invalid forms. The valid forms follow GCPM.

transition and animation

The shorthands and their list longhands follow CSS Transitions 1 section 2, CSS Animations 1 section 3 and CSS Cascade 5 section 7.3 as Chrome 156 implements them (tests/OneHtml.Tests/Style/ChromeAnimationShorthandTests.cs holds 297 declarations Chrome was asked about).

  • A CSS-wide keyword inside a list invalidates the declaration, for the shorthand (transition: initial 1s, animation: x initial, animation: x 1s, unset) and for every list longhand (animation-name: x, inherit). A lone keyword still resets.
  • Times. The first <time> that can be a duration (not negative) is the duration; a negative one, or the next one, is the delay; a third is an error. So animation: x -1s is a delay of -1s, transition: opacity -1s -2s is invalid. A unitless 0 is not a time: animation: x 0 is an iteration count of 0. animation also takes auto as the duration (the shorthand's own default stays 0s).
  • Names. <keyframes-name> and transition-property take a custom identifier: not a CSS-wide keyword, not default. animation-name also takes a non-empty string; none is a name only when it cannot be a fill mode first, and in transition it is valid only when the shorthand has one layer (transition: none, opacity 1s and transition-property: none, opacity are invalid). animation-timeline and the range longhands are reset by the shorthand; Chrome 156 does not accept a timeline inside it, so neither do we (CSS Animations 2 does).
  • Easing functions are checked: cubic-bezier() takes four numbers with x in [0, 1]; steps() an integer of at least 1 (2 with jump-none) and one of the six position keywords; linear() at least two points of a number and up to two percentages.
  • List longhands are checked item by item (transition-property|duration|delay|timing-function|behavior, animation-name|duration|delay|timing-function|iteration-count|direction|fill-mode|play-state|timeline|composition): a bad item drops the declaration at parse time, so an earlier declaration stands. animation-delay is a shorthand of animation-delay-start and -end in the property table, but a list of times in every browser; it is checked as the list. Not done: the computed serialization Chrome reports (steps(4, jump-end) is steps(4), 100ms is 0.1s, calc() folded); values are the specified text.
  • Known difference: Chrome reports animation-duration as 0s where the computed value is auto and the timeline is auto; here it is auto. The shorthand writes 0s.

@keyframes

The rules are collected with the style sources and kept with the document's styles; the library runs no clock and interpolates nothing.

  • Which rule. DocumentStyles.GetKeyframesRule(name) (and the same on LiveDocumentStyles) returns the winning KeyframesRule for a name, KeyframesRules all of them by name. A name is a custom identifier (not none, a CSS-wide keyword or default, case-insensitively) or a non-empty string; a string and an identifier are one name; names are case-sensitive. The rule has to be at the top level of a sheet or inside @media, @supports or @layer blocks that apply; one nested in a style rule does not exist. Among rules of one name the winner is the unprefixed one over @-webkit-keyframes (Chrome: whatever the layers and the order), then the later origin, the later layer of the origin (unlayered last, as CSS Cascade 5 section 6.4 says for at-rules), then the last in document order. An empty later rule still wins.
  • The blocks (KeyframesRule.Blocks): one per selector of <keyframe-selector>#; a block with an invalid selector (outside 0% to 100%, 0, a keyword, 50 %) is dropped whole; from is 0 and to is 1. In a block these are ignored: !important declarations, invalid declarations (judged as everywhere else in the cascade, including DeclarationValidator), custom properties (no @property), the animation-* and transition-* families, and direction, unicode-bidi, writing-mode, text-orientation, will-change, contain, container* and all (what Chrome 156 leaves out). animation-timing-function and animation-composition are the block's own timing function and composition when valid, without var(). A shorthand the library expands is split into longhands; one with var() stays whole (UsesVar) and one with no expansion grammar (border-radius, mask, offset) stays under its own name.
  • Resolving for an element (ResolveKeyframes(style, name, animationIndex), GetAnimationKeyframes(style)): flow-relative longhands are mapped to physical ones under the element's writing mode and direction and compete as one property (later declaration wins); var() is replaced from style.CustomProperties (a failed substitution, or a value invalid for the property, is invalid at computed-value time: value unset, flag set); blocks with the same offset, the same timing function (the animation's own counts when the block has none) and the same declared composition become one keyframe, later declarations winning, placed where the last of them stood (Chrome; Animations 2 section 3.2 groups by offset alone); keyframes are ordered by offset, then as written; the element's own computed value of every animated property a first or last keyframe leaves out is added at 0% and 100%, to the keyframe there with the animation's own timing function and no composition if one exists, else in a new IsImplicit keyframe with composition replace. Values are in property-name order. A timing function or composition list inside a block contributes its first item to every animation (Chrome); the animation's own comes from the element's animation-timing-function and animation-composition lists at the animation's index, repeating.
  • Values are the specified text with var() substituted. Not computed values: Chrome reports rgb(255, 0, 0) for red, 20px for 1em and calc(10px * 2), and each omitted part of a shorthand as the property's initial value where this library writes initial; CSS-wide keywords stay keywords. Implicit keyframes carry GetComputedValue. Animation-tainted custom properties are not modelled: Chrome resolves var(--x) in a keyframe with the custom property as the animation itself sets it (a --x declared in the from keyframe), here it is the element's value without animations.
  • Cost. GetAnimationKeyframes computes once per computed style object and keeps the result in a table owned by the rule set, so reading an element again allocates nothing, on DocumentStyles and on LiveDocumentStyles alike (elements that share a style share the result). ResolveKeyframes recomputes each time. Resolution is linear in blocks plus animated properties times keyframes.
  • After an edit. Sheet edits and edits to a style element's text rebuild the rule set on Restyle; RestyleResult.ChangedKeyframes lists the names whose winning rule differs (added, removed or different text) in ordinal order. Elements that use a changed name keep their computed style, so they are not in RestyledElements: read GetAnimationKeyframes again for them on the new styles. Old DocumentStyles are unchanged.
  • Verified against Chrome 156 (getKeyframes() over 185 pages of synthetic cases in tests/OneHtml.Tests/Style/KeyframesChromeTests.cs): same animations, frame counts, offsets, timing functions, compositions and property names; values differ in the forms above.