Accessibility
Loader from @prepared911/ui/loader.
The root is a span with role="status", which is polite and atomic. It is not an output: that element is form-associated, so it joins its form's elements, and a reset of the form makes some browsers replace its children (the spinner and the text span) with their joined text. A Loader inside a composer that resets after send keeps its markup. A screen reader hears the visible text, or the aria-label when there is no text.
A live region is announced when its content changes after it exists. A Loader that mounts with its text may not be announced by every screen reader. When the pending state must be heard, keep a status region mounted in the conversation and let the Loader's text change inside it, or let the container that owns announcements (ChatScroll's log) speak.
The spinner is decorative: aria-hidden, no name. The text or the aria-label carries the meaning.
The glyph stops turning under prefers-reduced-motion: reduce and under the app's data-motion="reduced", and stays visible. Nothing is conveyed by movement alone.
The text is at the subtle tier and the glyph at the subtle icon color, which clear AA on the surfaces they sit on. Under forced-colors: active the glyph takes the system text color.
The glyph comes first in reading order, so it sits at the inline start in both directions. The spin is clockwise in screen space in both directions, as Icon guarantees.