feat(core): sanitize rich text on the way into a composition - #3141
Open
miguel-heygen wants to merge 2 commits into
Open
feat(core): sanitize rich text on the way into a composition#3141miguel-heygen wants to merge 2 commits into
miguel-heygen wants to merge 2 commits into
Conversation
miguel-heygen
force-pushed
the
stack/rich-text-persistence
branch
from
August 9, 2026 23:07
5ac9e7a to
bb983d2
Compare
miguel-heygen
force-pushed
the
stack/rich-text-persistence
branch
2 times, most recently
from
August 10, 2026 18:28
50b1699 to
f184959
Compare
…le (#3163) * fix(studio): read the rotate property when measuring an element's angle Turning an element with Studio's rotate handle left every piece of overlay chrome square across it: the selection box, the crop outline and the child outlines all drew upright while the element underneath was clearly rotated. The handle writes the CSS `rotate` property. `rotate` is an individual transform property, not part of `transform`, so `getComputedStyle(el).transform` reports nothing for it and both places that measure an element's angle — the overlay geometry and the crop frame — read the element as upright. Both now read `rotate` alongside `transform` and compose them the way CSS does, individual properties first. A rotation about any axis but z has no single in-plane angle, so it reports nothing and the caller keeps its axis-aligned fallback rather than drawing chrome at a plausible wrong angle. * fix(studio): stop the crop outline refusing the transforms GSAP writes The crop outline still drew square on a rotated element after the rotate- property fix, because it refused the transform outright: it accepted only `matrix(...)`, and GSAP writes `matrix3d(...)` for an ordinary 2D move or spin (force3D). A composition that mirrors an element writes one with a negative z scale, and the negative determinant that follows was refused too. Both are ordinary planar transforms. The outline now reads the same 2D projection the rest of the chrome takes through DOMMatrix, and sizes a flipped element from the magnitude of its determinant. Only a perspective term still falls back, because that is where the mapping stops being affine and no single angle describes it. The test that asserted "a 3D matrix means give up" asserted the bug: its fixture was the identity written as matrix3d, which is as planar as a transform gets. It now checks the behaviour that replaced it, alongside the perspective case, which still falls back. * fix(studio): draw the crop outline at the angle the element paints under Selecting a text layer inside a rotated card drew its crop outline across the text at roughly a right angle. The outline read the element's own transform, but what the user sees is that composed with every ancestor's — the layer carries its own spin and its parent turns it again. It now walks to the composition root and composes each level, the element's `rotate` property before its `transform` and an ancestor outside its child, which is the order CSS applies them in. Nothing transformed anywhere still falls back to the caller's axis-aligned rect, since that comes from real layout and describes the element exactly. The chrome test stubbed getComputedStyle to answer "rotated 30deg" for every node in the document, so composing read the same turn once per ancestor. The stub now answers per element, which is what it always meant. * fix(studio): stop the dev server reloading the page on every canvas edit A composition lives under this package's root, so Vite's HMR saw a write to one as an html page dependency changing and full-reloaded the browser. That reload is the flash after every edit in the canvas: the whole app remounts, taking the preview iframe with it. The decision was never Vite's to make. Studio already knows whether a write was its own — that is what the write receipt is for — and refreshes the preview itself when it needs to. Vite's watcher now ignores the project data, and the dev plugin watches it on a watcher of its own, announcing changes as hf:file-change exactly as before. Measured on a drag: Vite full reloads went from one per edit to none, and the receipt now reports 'suppressed: own write token' where it previously never saw a matching path. * refactor(studio): compose an element's transform in one walk, not two Review: the crop frame hand-composed ancestor matrices while the geometry file did the same walk through DOMMatrix. Both were right, but the next individual transform property CSS grows — `translate`, `scale` — would have to land in both, and a miss puts the crop outline back at the wrong angle while the selection box draws the right one. The walk now lives in one place and takes the arithmetic as a parameter. The geometry file keeps DOMMatrix, because it goes on to transform corner points and needs the translation; the crop frame keeps plain 2D components, because it only needs an angle and a scale. Which transforms count, and in what order, is stated once. Also from review: the nested case was verified by hand only, so the composed walk is now covered on both sides — a child inside a rotated parent reports the angle it paints at, the parent's rotation alone when the child has none, and the walk stopping at the composition root. And `hasAttribute?.` was dead on a narrowed HTMLElement; it only survived because the crop test's fake element was not one. The fake now models an element and the guard is gone. * style(studio): format the shared transform module
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.
miguel-heygen
force-pushed
the
stack/rich-text-persistence
branch
from
August 10, 2026 21:46
f184959 to
bf1fc56
Compare
miguel-heygen
marked this pull request as ready for review
August 10, 2026 21:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A rich-text patch operation, guarded by one sanitizer, with stable element ids. No UI.
Why
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.
How
One sanitizer in packages/core, 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 no follow-up write can race it.
Test plan
First of the feature PRs: the persistence contract the editor is built on.
Part of a stack re-cutting #3077, which stays open as the reference for the whole tree. The preview fixes land first; this is the feature half.