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.
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, andstyle="". The same options reach overlays (WithContext) and restyle (Restyle, edit results), so they equal a freshDocumentStyles.Createwith 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 containingvar()(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 examplebox-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.
TokenRangeis the same public token cursor the library's value parsers use: indexes into the owningCssTree, withKind,Next,Inner,IsIdent,Number,TryUnitand 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:
: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.
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:
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).ElementStateChangeis a struct; the parts it names move, the others keep their value, the null node clears. The result equalsDocumentStyles.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 newDocumentStyles.WithContext(media)and a newCreateare the way to change them. - Snapshots are untouched. The live object copies the resolved styles it starts from and never writes to the
DocumentStylesit was made from, nor to any view of it; those keep their immutable guarantees.ToSnapshot()gives an immutableDocumentStylesfor the current state (it allocates).GetContext()gives the equivalentStyleContext. - Changed elements.
ChangedElementIndicesis a span over an internal buffer, valid until the nextApplyState;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 thanChangedElementCount. The list is of style objects replaced. Generated boxes (::before,::after,::marker) are read withGetPseudoStyleand are not listed unless the element's own style changed;HasInteractivePseudoElementRulessays when that matters. - Reads.
GetStyle(int),GetStyle(Node)andGetValue(elementIndex, propertyId)(ids fromPropertyTable.IdOf) return the shared objects of the style table. Typed values (ComputedStyle.Colorand so on) are built on first read of a style and cached on it. - Threads. One writer:
ApplyStatemust not run beside another call on the same object. Between calls any number of threads may read.ComputedStyleobjects 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,GetStyleandGetValueallocate 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 userevert,revert-layerorrevert-rule, andToSnapshot,GetContextandGetPseudoStyle. - How the change is found. A state pseudo-class matches an element and, for
:hover,:active,:focus-withinand: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
styleattribute 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:
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.
Checkedreplaces the checkedness of a checkbox or radio button (input.checked = ...) and the selectedness of anoption(option.selected = ...);Disabledreplaces the presence of thedisabledattribute on a control,fieldset,optgrouporoption;Indeterminateis theindeterminateproperty 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):
:enabledis the complement of:disabled; a disabledfieldsetdisables the controls in it except those in its firstlegend, and a disabledoptgroupits options;:read-onlyand:read-writefollow disabledness; a radio button in a named group is:indeterminatewhen 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-shownand 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 disabledselectas:disabled; the HTML Standard's definition, which the library follows, does not. - Snapshot path.
StyleContext.FormStateholds the overrides (immutable, content-equal, tied to one document).WithContextwith a differentFormStatetests 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.Restylecarries 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) ~ xand 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 equalsDocumentStyles.Createfor the equivalent context, whichGetContext()andToSnapshot()return, form state included. - Rules with
:checked,:disabled,:enabled,:indeterminate,:read-onlyor:read-writeare now dynamic rules (their selectors reportSelectorDependencies.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
Applychange 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.FormStatecarries the overrides intoMatchesand 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.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. Styles are served for
::before,::after,::marker,::selection,::placeholderand the range slider parts (seePseudoElementKind); rules with any other pseudo-element are parsed and never match. @scopeand@containerrules are reported (UnsupportedRule) and skipped.@propertyregistrations,@starting-style, animations and transitions are ignored (no clock;@keyframesrules are collected, see below).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, ::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
::selectionstyle, for non-inherited properties too (p::selection { background-color: yellow }colours the background of aspanin thep). The style exists for an element when a rule matches it or any ancestor, and isnullotherwise (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.PseudoElementsMayDifferadds every element below one that may differ when the sheets have a::selectionrule.
::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:
| Part | Names |
|---|---|
| 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
widthandheightof the parts are used values from layout (16px for the thumb) and are not declared. - Blockification: Chrome reports
display: blockfor a track an author set toinline(it is a flex item); the library keeps the author's value. - The container (
::-webkit-slider-container) and the vertical orientation'stouch-actionandmin-*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.
| 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.
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.PositionisPositionValue.Running(a new last member; the five classic values keep their numbers) andComputedStyle.RunningNameis the identifier (null for every other value).GetPropertyValue("position")returns the text as written (running(Header)),GetComputedValue("position")the normalizedrunning(Header).- Code that knows only the classic keywords should treat
Runninglikestatic:Position is Absolute or Fixedis false,Displayis 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. Soanimation: x -1sis a delay of -1s,transition: opacity -1s -2sis invalid. A unitless0is not a time:animation: x 0is an iteration count of 0.animationalso takesautoas the duration (the shorthand's own default stays0s). - Names.
<keyframes-name>andtransition-propertytake a custom identifier: not a CSS-wide keyword, notdefault.animation-namealso takes a non-empty string;noneis a name only when it cannot be a fill mode first, and intransitionit is valid only when the shorthand has one layer (transition: none, opacity 1sandtransition-property: none, opacityare invalid).animation-timelineand 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 withjump-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-delayis a shorthand ofanimation-delay-startand-endin 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)issteps(4),100msis0.1s,calc()folded); values are the specified text. - Known difference: Chrome reports
animation-durationas0swhere the computed value isautoand the timeline isauto; here it isauto. The shorthand writes0s.
@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 onLiveDocumentStyles) returns the winningKeyframesRulefor a name,KeyframesRulesall of them by name. A name is a custom identifier (notnone, a CSS-wide keyword ordefault, 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,@supportsor@layerblocks 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;fromis 0 andtois 1. In a block these are ignored:!importantdeclarations, invalid declarations (judged as everywhere else in the cascade, includingDeclarationValidator), custom properties (no@property), theanimation-*andtransition-*families, anddirection,unicode-bidi,writing-mode,text-orientation,will-change,contain,container*andall(what Chrome 156 leaves out).animation-timing-functionandanimation-compositionare the block's own timing function and composition when valid, withoutvar(). A shorthand the library expands is split into longhands; one withvar()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 fromstyle.CustomProperties(a failed substitution, or a value invalid for the property, is invalid at computed-value time: valueunset, 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 newIsImplicitkeyframe with compositionreplace. 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'sanimation-timing-functionandanimation-compositionlists at the animation's index, repeating. - Values are the specified text with
var()substituted. Not computed values: Chrome reportsrgb(255, 0, 0)forred,20pxfor1emandcalc(10px * 2), and each omitted part of a shorthand as the property's initial value where this library writesinitial; CSS-wide keywords stay keywords. Implicit keyframes carryGetComputedValue. Animation-tainted custom properties are not modelled: Chrome resolvesvar(--x)in a keyframe with the custom property as the animation itself sets it (a--xdeclared in thefromkeyframe), here it is the element's value without animations. - Cost.
GetAnimationKeyframescomputes once per computed style object and keeps the result in a table owned by the rule set, so reading an element again allocates nothing, onDocumentStylesand onLiveDocumentStylesalike (elements that share a style share the result).ResolveKeyframesrecomputes each time. Resolution is linear in blocks plus animated properties times keyframes. - After an edit. Sheet edits and edits to a
styleelement's text rebuild the rule set onRestyle;RestyleResult.ChangedKeyframeslists 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 inRestyledElements: readGetAnimationKeyframesagain for them on the new styles. OldDocumentStylesare unchanged. - Verified against Chrome 156 (
getKeyframes()over 185 pages of synthetic cases intests/OneHtml.Tests/Style/KeyframesChromeTests.cs): same animations, frame counts, offsets, timing functions, compositions and property names; values differ in the forms above.