fix(studio): flashless z-order commits and visible-overlap stepping

Two legibility fixes for the canvas z-order menu, from user feel-testing:

- z-only commits no longer remount the preview iframe. The commit hook
  already applies the inline z (+ injected position) to the live elements and
  updates the store synchronously; the post-commit reloadPreview() was a
  redundant full remount that read as a canvas 'blink' on every action.
  commitDomEditPatchBatches gains skipReload, engaged only when provably
  safe: every op is an inline-style patch AND the server reports every patch
  matched — anything else falls back to the reload so the preview reconverges
  with disk. The file-watcher's own reload stays suppressed by the existing
  domEditSaveTimestampRef window, so the skip is real.
- Bring Forward / Send Backward step over the next VISIBLY overlapping
  sibling. The nearest z-neighbor in a composition is often invisible at the
  current frame (runtime hides time-inactive clips with inline
  visibility/display; GSAP parks elements at opacity 0), so the step crossed
  something the user couldn't see — 'enabled but nothing happens'. The
  forward/backward set now filters on element-level computed visibility
  (display/visibility/opacity, injectable for tests); enable/disable shares
  the resolver so the menu is honest: actions disable when no visible
  neighbor exists. Front/back keep the full painting family.
- The neighbor that was stepped over gets a 600ms accent flash, drawn in the
  studio overlay layer (never in the iframe DOM), so the action shows its
  work.
This commit is contained in:
ukimsanov
2026-07-13 16:48:52 -07:00
parent 84963ea8ba
commit 760b88a6f3
12 changed files with 654 additions and 94 deletions
@@ -7,8 +7,23 @@
* the computed value. Treat missing / "auto" as 0 for comparison purposes.
*
* "Overlapping siblings" = siblings whose bounding rects intersect the
* target's bounding rect. Forward/backward operate within that set;
* front/back operate across all siblings.
* target's bounding rect AND are actually visible at the current frame.
* Forward/backward operate within that set; front/back operate across all
* siblings (full painting family, visible or not — unchanged semantics).
*
* ── Visibility ───────────────────────────────────────────────────────────────
* In HyperFrames compositions the nearest z-neighbor is often INVISIBLE at the
* paused frame: the runtime hides time-inactive clips with inline
* `visibility:hidden` / `display:none` (see core runtime
* syncTimedElementVisibility), and GSAP timelines park elements at `opacity:0`.
* Stepping "forward" over such a sibling looks like a silent no-op. The
* forward/backward comparison set therefore keeps only siblings whose
* element-level computed style is visible (display ≠ none, visibility ≠
* hidden, opacity > 0.01) — all runtime hiding signals are inline styles, so
* computed style covers them. Ancestor-chain checks are unnecessary here:
* siblings share the target's ancestors. The probe is injectable
* (ZOrderResolveOptions.isVisible) so the pure-module tests stay meaningful
* without a real style engine, mirroring how tests stub rect reading.
*
* ── Tie-awareness ────────────────────────────────────────────────────────────
* CSS paint order for elements that share a z-index is DOM document order:
@@ -26,6 +41,8 @@
* (project convention clamps z ≥ 0).
*/
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
/** A resolved change: set `element`'s z-index to `zIndex`. */
@@ -34,6 +51,48 @@ export interface ZOrderPatch {
zIndex: number;
}
/** Injectable knobs for the pure resolver (kept mockable like rect reading). */
export interface ZOrderResolveOptions {
/**
* Element-level visibility probe used to scope the forward/backward
* comparison set. Defaults to `isElementVisibleForZOrder` (computed-style
* display/visibility/opacity). Injectable so tests can run without a real
* style engine.
*/
isVisible?: (el: HTMLElement) => boolean;
}
/**
* Default visibility probe: is this element itself visible at the current
* frame? Element-level only (siblings share the target's ancestor chain).
* Covers the runtime's inactive-clip hiding (inline `visibility:hidden` /
* `display:none`) and animation-parked `opacity:0`, all of which surface
* through computed style. A color-grading source (hidden at opacity:0 while
* its canvas paints in its place) still counts as visible, matching
* isElementVisibleThroughAncestors in domEditingDom.
*/
export function isElementVisibleForZOrder(el: HTMLElement): boolean {
try {
const win = el.ownerDocument?.defaultView;
if (!win) return true;
const computed = win.getComputedStyle(el);
if (computed.display === "none") return false;
if (computed.visibility === "hidden" || computed.visibility === "collapse") return false;
const opacity = Number.parseFloat(computed.opacity);
if (
Number.isFinite(opacity) &&
opacity <= 0.01 &&
!el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
) {
return false;
}
return true;
} catch {
/* cross-origin / detached — assume visible (fail open, matches rect fallback) */
return true;
}
}
interface RenderEntry {
element: HTMLElement;
zIndex: number;
@@ -131,26 +190,35 @@ function rectsIntersect(
}
/**
* Restrict a family to the target plus siblings whose bounding rect overlaps
* the target's rect. The target is always retained. If the target's rect is
* unavailable or empty (headless / happy-dom returns 0×0), all entries are
* kept — matching the prior behavior.
* Restrict a family to the target plus siblings that are VISIBLE and whose
* bounding rect overlaps the target's rect. The target is always retained
* (even when itself hidden at the current frame — it is the user's explicit
* selection). If the target's rect is unavailable or empty (headless /
* happy-dom returns 0×0), the overlap filter is skipped and all VISIBLE
* entries are kept — matching the prior rect-fallback behavior.
*/
function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
function getOverlappingFamily(
target: HTMLElement,
entries: RenderEntry[],
isVisible: (el: HTMLElement) => boolean,
): RenderEntry[] {
const visibleEntries = entries.filter(
(entry) => entry.element === target || isVisible(entry.element),
);
let targetRect: DOMRect;
try {
targetRect = target.getBoundingClientRect();
} catch {
return entries;
return visibleEntries;
}
if (targetRect.width === 0 && targetRect.height === 0) return entries;
if (targetRect.width === 0 && targetRect.height === 0) return visibleEntries;
const tr = {
left: targetRect.left,
top: targetRect.top,
right: targetRect.right,
bottom: targetRect.bottom,
};
return entries.filter((entry) => {
return visibleEntries.filter((entry) => {
if (entry.element === target) return true;
try {
const r = entry.element.getBoundingClientRect();
@@ -305,6 +373,33 @@ function buildGlobalOrder(
return rest;
}
/**
* The shared scoping pipeline: full painting family for front/back, visible
* overlapping siblings for forward/backward, sorted into render order with the
* target's position. Null when the family/scope is too small to act on.
*/
function resolveScopedRenderOrder(
target: HTMLElement,
action: ZOrderAction,
options?: ZOrderResolveOptions,
): { entries: RenderEntry[]; order: RenderEntry[]; pos: number } | null {
const { entries } = getFamily(target);
// Family always includes the target; fewer than 2 means no siblings at all.
if (entries.length < 2) return null;
const isVisible = options?.isVisible ?? isElementVisibleForZOrder;
const scoped =
action === "bring-to-front" || action === "send-to-back"
? entries
: getOverlappingFamily(target, entries, isVisible);
if (scoped.length < 2) return null;
const order = toRenderOrder(scoped);
const pos = order.findIndex((e) => e.element === target);
if (pos === -1) return null;
return { entries, order, pos };
}
/**
* Resolve the z-order patches for an action.
*
@@ -314,20 +409,11 @@ function buildGlobalOrder(
export function resolveZOrderChange(
target: HTMLElement,
action: ZOrderAction,
options?: ZOrderResolveOptions,
): ZOrderPatch[] | null {
const { entries } = getFamily(target);
// Family always includes the target; fewer than 2 means no siblings at all.
if (entries.length < 2) return null;
const scoped =
action === "bring-to-front" || action === "send-to-back"
? entries
: getOverlappingFamily(target, entries);
if (scoped.length < 2) return null;
const order = toRenderOrder(scoped);
const pos = order.findIndex((e) => e.element === target);
if (pos === -1) return null;
const resolved = resolveScopedRenderOrder(target, action, options);
if (!resolved) return null;
const { entries, order, pos } = resolved;
const desired = [...order];
const [moved] = desired.splice(pos, 1);
@@ -354,9 +440,35 @@ export function resolveZOrderChange(
}
/**
* Whether a z-order action is available for the target.
* "disabled" = the element is already at that limit.
* The sibling a forward/backward step crosses: the visible overlapping
* neighbor directly above (bring-forward) or below (send-backward) the target
* in render order. Null for front/back, for a no-op step, or when the scope is
* too small. Uses the SAME scoping as resolveZOrderChange, so call it with the
* same options BEFORE any live styles are applied.
*/
export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
return resolveZOrderChange(target, action) !== null;
export function resolveCrossedNeighbor(
target: HTMLElement,
action: ZOrderAction,
options?: ZOrderResolveOptions,
): HTMLElement | null {
if (action !== "bring-forward" && action !== "send-backward") return null;
const resolved = resolveScopedRenderOrder(target, action, options);
if (!resolved) return null;
const { order, pos } = resolved;
const neighbor = action === "bring-forward" ? order[pos + 1] : order[pos - 1];
return neighbor?.element ?? null;
}
/**
* Whether a z-order action is available for the target.
* "disabled" = the element is already at that limit. Shares the resolver (and
* its visibility scoping), so enable/disable always matches what the action
* would actually do.
*/
export function isZOrderActionEnabled(
target: HTMLElement,
action: ZOrderAction,
options?: ZOrderResolveOptions,
): boolean {
return resolveZOrderChange(target, action, options) !== null;
}