Recursive AI

Restyle on hover, focus and form state

All onehtml recipes

Keep one set of computed styles current while the pointer moves, focus changes and a user ticks a checkbox, restyle only what each change reaches, and read the styles of a range slider’s thumb and a field’s placeholder.

DocumentStyles.WithContext answers one what-if question at a time. A renderer that moves the pointer every frame needs styles that move in place instead. LiveDocumentStyles (1.3.0 and later) does that: it owns its buffers, restyles only the elements a change reaches, and allocates nothing once warmed up. Form state from script and the range slider parts need 1.4.0 and 1.5.0.

The page is a settings form, settings.html, with this stylesheet:

CSS
body { font-family: Georgia, serif; color: #222 }
button { background-color: #eee; color: #222 }
button:hover { background-color: #2f6b4f; color: #fff }
button:focus-visible { outline: 2px solid #b8432f }
input:checked + label { font-weight: 700 }
fieldset:disabled label { color: #767676 }
input[type=range]::-webkit-slider-thumb { background-color: #999 }
input[type=range]:hover::-webkit-slider-thumb { background-color: #b8432f }
input::placeholder { color: #6b6b6b; font-style: italic }
C#
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Css;
using RecursiveAI.OneHtml.Selectors;
 
Document doc = Document.Parse(File.ReadAllBytes("settings.html"));
DocumentStyles styles = DocumentStyles.Create(doc);
 
// A live view owns its buffers and moves in place.
// The snapshot it was made from is untouched.
LiveDocumentStyles live = LiveDocumentStyles.Create(styles);
 
Node button = doc.Root.QuerySelector("button");
Node slider = doc.Root.QuerySelector("#size");
Node due = doc.Root.QuerySelector("#due");
Node dueLabel = doc.Root.QuerySelector("label[for=due]");
Node fieldset = doc.Root.QuerySelector("#alerts");
Node holdsLabel = doc.Root.QuerySelector("label[for=holds]");
 
string Css(Node element, string property) =>
    live.GetStyle(element).GetPropertyValue(property);
string Label(Node label) =>
    $"font-weight {Css(label, "font-weight")}, color {Css(label, "color")}";
void Report(string step) =>
    Console.WriteLine($"{step}: {live.ChangedElementCount} restyled");
 
// The pointer moves onto the button, then to the slider.
live.ApplyState(ElementStateChange.Hover(button));
Report("hover button");
Console.WriteLine($"  button background: {Css(button, "background-color")}");
 
live.ApplyState(ElementStateChange.Hover(slider));
Report("hover slider");
Console.WriteLine($"  button background: {Css(button, "background-color")}");
ComputedStyle thumb = live.GetPseudoStyle(slider, PseudoElementKind.SliderThumb)!;
Console.WriteLine($"  slider thumb: {thumb.GetPropertyValue("background-color")}");
 
// Keyboard focus on the button; the hover stays where it is.
live.ApplyState(ElementStateChange.Focus(button, FocusSource.Keyboard));
Report("focus button");
Console.WriteLine($"  button outline: {Css(button, "outline-style")} " +
                  $"{Css(button, "outline-color")}");
 
// A user ticks a checkbox and a script disables the fieldset. Neither is in the HTML.
live.ApplyFormState([
    FormStateChange.Checked(due, true),
    FormStateChange.Disabled(fieldset, true),
]);
Report("form state");
Console.WriteLine($"  due label: {Label(dueLabel)}");
Console.WriteLine($"  holds label: {Label(holdsLabel)}");
 
// Generated boxes: the search field's placeholder.
Node search = doc.Root.QuerySelector("#q");
ComputedStyle placeholder = live.GetPseudoStyle(search, PseudoElementKind.Placeholder)!;
Console.WriteLine($"placeholder: {placeholder.GetPropertyValue("color")}, " +
                  $"{placeholder.GetPropertyValue("font-style")}");
 
// The current state as an immutable snapshot, and the snapshot the view started from.
string Weight(DocumentStyles s) => s.GetStyle(dueLabel).GetPropertyValue("font-weight");
Console.WriteLine($"due label now: {Weight(live.ToSnapshot())}, " +
                  $"at the start: {Weight(styles)}");
Text
hover button: 1 restyled
  button background: #2f6b4f
hover slider: 1 restyled
  button background: #eee
  slider thumb: #b8432f
focus button: 1 restyled
  button outline: solid #b8432f
form state: 2 restyled
  due label: font-weight 700, color #767676
  holds label: font-weight normal, color #767676
placeholder: #6b6b6b, italic
due label now: 700, at the start: normal

How to read it:

  • ApplyState takes an ElementStateChange: Hover, Active, Focus (with its FocusSource) and Target, alone or combined with WithHover and the like. The parts it names move and the others keep their value, so the focus step leaves the slider hovered.
  • ChangedElementIndices lists the elements whose style object changed, valid until the next change; ChangedElementsInto(Span<int>) copies them into your own buffer. Hovering the slider restyles one element, the button that lost its hover. The thumb is a generated box, read with GetPseudoStyle, so it isn’t in the list. HasInteractivePseudoElementRules tells you when generated boxes can change with state, as they do here.
  • ApplyFormState states what isn’t in the attributes: a checkbox the user ticked, a control a script disabled, a checkbox set to indeterminate. :checked, :disabled, :enabled, :indeterminate, :read-only and :read-write follow it, with what follows from it: the disabled fieldset greys both labels. Pass null as the value to read the attributes again. On the snapshot path, the same overrides go in StyleContext.WithFormState.
  • PseudoElementKind covers Before, After, Marker, Selection, Placeholder, SliderThumb, SliderTrack and SliderFill. The slider parts answer to the standard names and to the -webkit- and -moz- ones. A state pseudo-class on the part itself, such as ::-webkit-slider-thumb:hover, isn’t supported.
  • ToSnapshot() gives an immutable DocumentStyles for the current state, equal to DocumentStyles.Create with the context GetContext() returns. The snapshot the live view was made from never changes.
  • One thread writes: don’t call ApplyState beside another call on the same object. Between changes any number of threads can read, and a ComputedStyle you were handed stays valid.

Media features and the viewport are fixed when the live view is made. For another viewport, make new styles with WithContext and a new live view. The accessibility tree ignores form-state overrides, so build it from a document whose attributes say what the page shows. The full rules are in Live interaction state and Form state from script. Compute CSS styles covers the snapshot API.