* feat(studio): apply a style to a run of characters
Styling text in a composition cannot be done by wrapping a DOM range in a
span. That is three lines, and then every interesting case is a special
case: recolouring nests spans that shadow each other, removing a style
cannot reach the ancestor that set it, and styling across an existing run's
boundary has to split it. Each fix is a new branch and the branches
interact.
So the element is read into a flat list of styled runs, the style is applied
to a span of characters in that list, and the element is rebuilt from it.
Replacing, removing, splitting and merging stop being cases: the rebuild
emits one span per distinct run and cannot nest or duplicate, whatever was
there before.
Selection offsets count UTF-16 units, so a boundary can land between the
halves of an emoji; the applied range widens to whole characters. A colour
an ancestor overpaints is mirrored into the fill, because a colour that does
not paint reads to the user as a colour that did not save.
The toolbar that drives this arrives with the editor in the next change.
* fix(studio): harden inline text styling boundaries
* fix(studio): align inline styling with persistence
* test(studio): pin inline identity delimiters
* feat(core): sanitize rich text on the way into a composition
Studio's patch vocabulary was inline-style, attribute, html-attribute and
text-content. text-content assigns textContent, and the text-field model
escapes markup on the way out and refuses a change in child structure, so a
styled span had no route into a composition file.
Adds a rich-text operation with one, guarded by a single sanitizer called on
both ends of the trip: in the browser so the preview shows what will be
saved, and on the server because that is where the file is written. Tags and
style properties are a small allowlist, and an unexpected tag loses its
formatting rather than its words. Spans an edit adds get their ids in the
same write, so a follow-up write cannot race it.
No UI yet — this is the persistence contract the editor is built on.
* fix(core): document and test the sanitizer boundary
* fix(core): harden rich text sanitizer traversal