feat(core): implement createPreviewAdapter (R7, Task 3)#1291
Conversation
ceac466 to
b67c97e
Compare
0886829 to
03dcbaa
Compare
b67c97e to
b8132ea
Compare
03dcbaa to
6160c9e
Compare
b8132ea to
b32249b
Compare
6160c9e to
a067aa9
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Core logic is sound and the adapter boundary is clean. Three things I'd flag before merge:
P1 — revertDraft cannot restore translate if applyDraft never set it
GestureState stores originalTranslate: string | undefined, captured from el.style.getPropertyValue("translate") at gesture start. revertDraft does if (state.originalTranslate !== undefined) el.style.translate = state.originalTranslate. So far fine.
The bug is: applyDraft sets --hf-studio-offset-x/y (CSS custom properties), not translate directly. commitPreview produces a moveElement patch from those custom properties, which the caller applies as a real translate. If a caller applies applyDraft → commitPreview → applyDraft again on the same element (e.g. nudging twice without a revert between), originalTranslate on the second gesture captures whatever commitPreview's caller set — which is correct. But if commitPreview is skipped and only revertDraft is called, translate may have been set by a previous committed gesture and revertDraft will leave it in place. Fine for the happy path, risky for error recovery or gesture-cancel flows. At minimum, add a contract test for "commit → revert after abort does not restore stale translate".
P2 — elementAtPoint opacity check uses inline style, not computed
const opacity = el.style.getPropertyValue("opacity")This misses opacity inherited from ancestors (e.g. parent is opacity: 0 in a stylesheet). The spec tests set el.style.opacity = "0" which works in jsdom, but in a real browser an element whose parent is hidden via CSS class would slip through the hit test. Consider getComputedStyle(el).opacity — jsdom supports it when styles are applied via CSSOM.CSSStyleDeclaration.
P3 — hardcoded CSS property strings will be de-duped in #1292
"--hf-studio-offset-x" etc. appear as string literals throughout this file. #1292 extracts them to draftMarkers.ts. No action here — just confirming the duplication is transient.
Otherwise the adapter contract is well-shaped and the closure-scoped GestureState is the right approach.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Clean implementation — one focused file (previewAdapter.ts), 101 lines, satisfies the 20-test contract from #1286. Walked each method against the spec:
elementAtPoint—resolvePoint?.(x,y)for hit, walks ancestors, returns firstdata-hf-idmatch (filtered by opacity), ornullon outermostdata-hf-root. ✅applyDraft— recordsoriginalTranslate, sets--hf-studio-offset-x/yfor move or--hf-studio-width/heightfor resize, marker viadata-hf-studio-manual-edit-gesture. ✅revertDraft— clears all 4 draft props + marker, restores translate from gesture state. ✅commitPreview— null on no-gesture, derivesmoveElementorresizepatch, clears marker. ✅getElementTimings— scans[data-hf-id], reads start/end attrs. ✅
20 tests pass cleanly against this impl.
Concerns
[Mixed-type re-apply leaks CSS props] The applyDraft impl doesn't clean up previous-gesture state when called repeatedly. Walk through:
applyDraft({ type: "resize", hfId: "X", w: 200, h: 100 })— sets--hf-studio-width: 200px,--hf-studio-height: 100px, capturesoriginalTranslate.applyDraft({ type: "move", hfId: "X", dx: 10, dy: 5 })— gesture state is REPLACED, sets--hf-studio-offset-x: 10px,--hf-studio-offset-y: 5px.
After step 2, the element has ALL FOUR custom props set (width + height from step 1 still there, offset-x/y from step 2). commitPreview() returns a moveElement patch (correct for the last applyDraft), but the element's visual state has both move AND resize drafts applied — the studio's CSS rules consuming --hf-studio-* will render with both translate and explicit dimensions, which is not what the user gestured.
The double-apply test (previewAdapter.test.ts:165-172) only covers move→move, where the same two custom props get overwritten — so the test passes. The contract specifies only "second applyDraft overwrites first" without saying "and the previous payload's props are cleared first." Test passes, contract holds, behavior leaks.
#1292 fixes this by auto-reverting in-flight gestures before applying a new one:
if (gesture) {
const prev = findById(gesture.payload.hfId);
if (prev) revertGesture(prev, gesture);
gesture = null;
}Same merge-coupling pattern as #1289 — if the stack lands atomically the bug never reaches main; if not, fold the fix into this PR (and ideally add a test for resize→move switch).
[Opacity check is fragile in non-jsdom environments] L26-29:
function opacity(el: Element): number {
const view = doc.defaultView;
if (!view) return 1;
return parseFloat(view.getComputedStyle(el).opacity) || 0;
}When getComputedStyle(el).opacity returns "" (empty string — possible in linkedom or any DOM-like environment that doesn't compute CSS cascade), parseFloat("") → NaN → NaN || 0 → 0 → returns null from elementAtPoint. Every element without an explicit inline opacity becomes invisible.
The current test environment is jsdom, which returns "1" by default for unstyled elements, so this passes. But the file-header comment in previewAdapter.test.ts (#1286) references linkedom too — so this adapter is plausibly going to be exercised in a linkedom context at some point, and the contract will break silently.
#1292 fixes this:
const op = parseFloat(style.opacity);
return Number.isNaN(op) || op >= 0.01;NaN treated as visible. Plus adds display: none / visibility: hidden checks. The full isVisible is the safer shape. Worth folding in.
[atTime param is silently ignored] L33: the per-call opts is _opts (intentionally unused). The signature accepts { atTime?: number } and the spec L83-88 / L182-188 of #1286 names atTime in two test cases. But the impl reads current getComputedStyle regardless of atTime.
This is OK as an MVP — the caller is expected to seek the GSAP timeline to atTime before invoking elementAtPoint, and the impl reads whatever the timeline-driven inline styles currently say. But:
- The contract isn't documented anywhere. A new caller will reasonably assume passing
atTimeis enough. - There's no warning when
atTimeis passed but the timeline isn't at that position.
Two paths:
- JSDoc the method:
"atTime is currently a marker; caller must seek the timeline to atTime before invoking. Future work may evaluate atTime against timeline state directly." - Or: keep the param off the public signature until it's actually honored.
Concern not blocker — but worth picking one before downstream callers come to rely on the false promise.
Nits
[findById interpolates hfId into a CSS selector] L21:
function findById(hfId: string): HTMLElement | null {
return doc.querySelector(`[data-hf-id="${hfId}"]`) as HTMLElement | null;
}mintHfId in parsers/hfIds.ts produces hf-[a-z0-9]{4} — completely safe for CSS-selector interpolation. But the type signature of findById accepts any string. If a future contract change loosens the id format (or a caller passes user-controlled input), a payload like "hf-aaaa\"][data-evil]" breaks the selector or matches unintended elements. CSS.escape exists for this. Cheap defense:
return doc.querySelector(`[data-hf-id="${CSS.escape(hfId)}"]`) as HTMLElement | null;Cosmetic now; load-bearing if the format ever evolves.
[Closure captures opts from outer scope, shadowing the _opts parameter] L33-34:
elementAtPoint(x, y, _opts) {
const hit = opts?.resolvePoint?.(x, y) ?? null;The bare opts resolves to the OUTER createPreviewAdapter's arg (which has resolvePoint). _opts is the per-call options. Both are valid scopes, but the visual proximity makes it easy to misread "are we using per-call opts or factory opts?" at a glance. Renaming the per-call param to something like _perCallOpts (or just _atTimeOpts) reduces the ambiguity. Cosmetic.
[Array.from(doc.querySelectorAll(...)) then iterate] L70:
for (const el of Array.from(doc.querySelectorAll("[data-hf-id]"))) {NodeList is already iterable. Drop the Array.from:
for (const el of doc.querySelectorAll("[data-hf-id]")) {(#1292 already drops it.) Cosmetic.
Questions
[Should elementAtPoint ignore display: none / visibility: hidden?] The current opacity-only filter would happily return elements that are display-none'd. Not exercised by the tests (#1286 only set inline opacity), but a draggable hidden element seems wrong. #1292's isVisible handles this. Worth confirming whether the use case ever exposes display-none'd or visibility-hidden'd elements (it probably does — GSAP display: none is common for "off-screen until time T" patterns).
What I didn't verify
- That the 20 tests from #1286 actually all pass on this impl's branch in CI. The regression-shards CI status is in_progress; if vitest unit tests run in a separate workflow I'm not seeing, I haven't observed them green.
- That
getComputedStylein jsdom returns "1" rather than "" for an unstyled element (the opacity-fragility concern above hinges on this). The "returns the nearest ancestor with data-hf-id" test in #1286 implicitly tests this — if jsdom returned "", that test would fail. - That no other studio-side code reads
data-hf-studio-manual-edit-gestureand expects a specific value beyond the truthy check. The studio'smanualEditsDom.tsuses a token-based gesture marker (sets and compares tokens); the previewAdapter uses"true"as the value. If the studio's gesture-aware code is meant to coordinate with PreviewAdapter's gestures, the value mismatch could matter. Worth a sanity check.
Verdict
The implementation greens #1286's 20 tests and the shape is right. The three Concerns are all "the contract test happens to be loose enough that the bug doesn't surface" — and #1292 fixes all three independently. If the stack merges atomically, this is ready. Otherwise, fold #1292's previewAdapter.ts fixes into this PR. Ready from where I sit; leaving as a comment.
— Review by Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
Building on Rames's review.
Rames flagged three concerns and I'd escalate one of them.
Escalating: the mixed-type re-apply leak is the highest-risk bug in the stack. When the user applies a resize gesture, then applies a move gesture on the same element (without committing in between — likely in a rapid drag scenario), all four --hf-studio-* custom props are set simultaneously. The studio CSS that consumes these props will render the element with BOTH a translate AND explicit width/height — a combined transform the user never applied. #1292 fixes this with the auto-revert on re-apply, but if #1291 ever lands without #1292, studio gestures will produce visually corrupted intermediate states.
This is worse than my P1 (the revert-after-commit path) because it fires on the hot path — any user who drags twice without committing hits it immediately. Rames's framing is accurate; I'd categorize it P0 if the stack can land non-atomically.
Confirming: getComputedStyle vs inline style. Rames and I flagged the same gap. The NaN-guard fix in #1292 (Number.isNaN(op) || op >= 0.01) also resolves the linkedom empty-string case. If the stack lands atomically: fine. If not: the opacity(el) function needs the NaN guard now.
New: the opts vs _opts closure shadowing. Rames flagged this as cosmetic; I'd upgrade it to P2. In elementAtPoint, opts (the factory arg) is silently used instead of _opts (the per-call arg). This isn't just confusing to read — if a future call site passes per-call opts expecting them to be honored (e.g. a different resolvePoint per call for multi-layer hit testing), the factory opts win silently. The underscore prefix signals "intentionally unused" but it's the wrong signal — the per-call opts ARE the intended interface. Rename to clarify intent.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Round 2 — HEAD is unchanged at b32249b since my round-1 review. All three concerns I flagged (mixed-type re-apply leaks, fragile opacity check in non-jsdom DOMs, atTime ignored without documentation) are addressed in #1292's 2af2228 and 4afb5a7 commits at the top of stack rather than here:
- Auto-revert on
applyDraftre-apply (resize → move no longer leaks width/height props). isVisiblereplacement (display / visibility / NaN-tolerant opacity).- JSDoc on
PreviewAdapter.elementAtPoint'satTimeparam spelling out caller-seek semantics. - Plus the
CSS.escape-fallback nit and_opts → _perCallOptsrename I'd raised — both landed.
So the round-1 concerns on this PR are merge-coupled to #1292. Same atomicity property as #1289 — stack lands as one unit and this is ready; lands alone and the fragile bits are exposed.
My round-1 review at this SHA still stands.
— Review by Rames D Jusso
jrusso1020
left a comment
There was a problem hiding this comment.
Approved on behalf of James.
a067aa9 to
4b1a36d
Compare
b32249b to
d67d7f3
Compare
Locks the preservation guarantee the write-back design depends on: a Studio edit targeting by id or selector (it never sends hfId) must not strip an existing data-hf-id, or the stable handle is destroyed by the next edit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…review) Addresses review on #1272 (Miguel P3 + Rames): findTargetElement interpolated target.hfId raw into a [data-hf-id="..."] selector. Escape it (CSS attr-value injection guard) and warn when a hfId matches more than one element instead of silently patching an arbitrary one. Adds an injection-guard test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Task 3) elementAtPoint: resolvePoint callback → walk ancestors for data-hf-id, skip data-hf-root without data-hf-id (stage root), skip opacity-0 elements. applyDraft: find element by hfId, record originalTranslate, set --hf-studio-offset-x/y (move) or --hf-studio-width/height (resize), mark data-hf-studio-manual-edit-gesture. revertDraft: remove draft CSS props, clear gesture marker, restore originalTranslate if one was recorded. commitPreview: extract patch (move→moveElement, resize→resize with w/h renamed to width/height), clear gesture marker, return patch or null. getElementTimings: scan [data-hf-id] elements, parse data-start/data-end as floats, return map with undefined fields for absent attributes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…HfIds mints hf- ids Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
d67d7f3 to
6a4137d
Compare

Summary
createPreviewAdapterinpackages/core/src/studio-api/helpers/previewAdapter.ts, greening all 20 T10 tests from test(core): previewAdapter contract failing tests (T10 spec for R7) #1286elementAtPoint: walks ancestors from hit element, returns firstdata-hf-idmatch; stops atdata-hf-rootwithoutdata-hf-id(outermost stage root); filters elements whose computed opacity is 0applyDraft: sets--hf-studio-offset-x/yor--hf-studio-width/heightCSS custom properties +data-hf-studio-manual-edit-gesturemarker; tracks originaltranslatefor restorerevertDraft: removes all draft CSS props and gesture marker; restores original translatecommitPreview: extractsmoveElementorresizepatch from gesture state, clears markergetElementTimings: scans all[data-hf-id]elements fordata-start/data-endattributesTest plan
bunx vitest run src/studio-api/helpers/previewAdapter.test.ts— 20 passbunx vitest run— 1378 pass, 0 fail