Skip to content

Conversation

@seanmcguire12
Copy link
Member

@seanmcguire12 seanmcguire12 commented Jan 27, 2026

why

  • top‑layer UI (like <dialog> and popovers) is painted above the normal document stacking context, so a mask appended to document.body can never cover it. in order to mask dialog content, the overlay must be inserted inside the top‑layer container itself
  • the screenshot masking logic for elements inside <dialog> nodes (and other top‑layer elements) didn’t work because the overlay divs were always appended to the document root, which renders underneath the dialog
  • this PR addresses mask selector not working within an open dialog #1616

what changed

  • updated logic in resolveMaskRect() to detect top‑layer roots (dialog[open], :popover-open), compute rects relative to that root, and return a rootToken so the caller can target the correct container
  • updated applyMaskOverlays() to insert mask overlays inside the top‑layer root when a rootToken is present, and to restore any temporary position changes on cleanup. resolveMaskRectForObject() now passes a maskToken and preserves the rootToken output

test plan

  • added another test in page-screenshot.spec.ts to check whether masks are correctly injected into the dialog node

Summary by cubic

Fixes screenshot masking for elements inside dialog and other top‑layer UIs by inserting overlays into the top‑layer root instead of the document root. Masks now correctly cover dialogs and are fully cleaned up after the screenshot.

  • Bug Fixes
    • Detect top‑layer roots (dialog[open], :popover-open) in resolveMaskRect, compute rects relative to that root, and return a rootToken.
    • Insert mask overlays inside the detected top‑layer container; temporarily adjust position when needed and restore on cleanup.
    • Thread a maskToken/rootToken through masking calls to group overlays and handle multi-frame cases safely.
    • Add a test to confirm masks are injected into dialog elements.

Written for commit 4781745. Summary will update on new commits. Review in cubic

@changeset-bot
Copy link

changeset-bot bot commented Jan 27, 2026

🦋 Changeset detected

Latest commit: 4781745

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@browserbasehq/stagehand Patch
@browserbasehq/stagehand-evals Patch
@browserbasehq/stagehand-server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Jan 27, 2026

Greptile Overview

Greptile Summary

Fixes screenshot masking for <dialog> and other top-layer elements by detecting them and inserting overlay masks inside the top-layer container instead of the document root.

The PR correctly identifies that top-layer UI (dialogs, popovers) renders above the normal document stacking context, so masks appended to document.body render underneath them. The solution:

  • Detects top-layer roots using dialog[open] and :popover-open selectors
  • Computes mask positions relative to the top-layer container
  • Inserts overlay divs inside the detected container
  • Temporarily adjusts position: static to position: relative when needed
  • Properly cleans up all attributes and style changes

The implementation is well-structured with defensive programming (try-catch blocks, safe helper functions). The test coverage validates the core fix.

Confidence Score: 4/5

  • Safe to merge with one minor consideration about position attribute edge case
  • The implementation is solid with proper defensive coding and cleanup logic. The core algorithm correctly detects top-layer elements and repositions masks. There's one edge case around the data-stagehand-mask-root-pos attribute check that could potentially cause issues if the attribute exists from a previous incomplete operation, but this is unlikely in practice given the cleanup logic.
  • No files require special attention - the changes are well-contained and properly tested

Important Files Changed

Filename Overview
packages/core/lib/v3/dom/screenshotScripts/resolveMaskRect.ts Adds top-layer detection logic with safe helper functions and relative positioning calculations for dialog/popover elements
packages/core/lib/v3/understudy/screenshotUtils.ts Threads maskToken through masking pipeline, inserts overlays into top-layer roots, handles position style changes with cleanup
packages/core/lib/v3/tests/page-screenshot.spec.ts Adds test verifying mask overlays are injected inside dialog elements rather than document root

Sequence Diagram

sequenceDiagram
    participant Page as Page.screenshot()
    participant Utils as screenshotUtils
    participant Locator as Locator
    participant Script as resolveMaskRect
    participant Dialog as Dialog Element
    participant DOM as Document

    Page->>Utils: applyMaskOverlays(locators, color)
    Note over Utils: Generate unique maskToken
    
    loop For each locator
        Utils->>Utils: resolveMaskRects(locator, maskToken)
        Utils->>Locator: resolveNodesForMask()
        Locator-->>Utils: Array of objectIds
        
        loop For each objectId
            Utils->>Script: call resolveMaskRect(maskToken)
            Script->>Script: findTopLayerRoot(element)
            alt Element in dialog[open]
                Script->>Dialog: getBoundingClientRect()
                Dialog-->>Script: rootRect
                Script->>Dialog: setAttribute(data-stagehand-mask-root)
                Script-->>Utils: rect with rootToken
            else Element in normal flow
                Script-->>Utils: rect with null rootToken
            end
        end
    end
    
    loop For each frame with rects
        alt Has rootToken
            Utils->>Dialog: querySelector(data-stagehand-mask-root)
            Utils->>Dialog: Check position style
            Utils->>Dialog: Save original, set relative
            Utils->>Dialog: appendChild(maskOverlay)
        else No rootToken
            Utils->>DOM: documentElement.appendChild(maskOverlay)
        end
    end
    
    Page->>Page: Capture screenshot
    Page->>Utils: cleanup()
    
    loop For each frame
        Utils->>DOM: Remove all mask overlays
        alt Has rootTokens
            Utils->>Dialog: Restore original position
            Utils->>Dialog: Remove mask-root attributes
        end
    end
Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No files reviewed, no comments

Edit Code Review Agent Settings | Greptile

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.
Architecture diagram
sequenceDiagram
    participant Client
    participant Util as "ScreenshotUtil (Node)"
    participant DOM as "Browser DOM"

    Note over Client, DOM: NEW: Top-Layer Aware Masking Flow

    Client->>Util: page.screenshot({ mask: [elements] })
    Util->>Util: Generate unique session maskToken

    loop For each mask locator
        Util->>DOM: callFunction(resolveMaskRect, maskToken)
        activate DOM
        DOM->>DOM: NEW: Check closest dialog[open] / :popover-open

        alt Element inside Top Layer (NEW)
            DOM->>DOM: Generate/Get unique rootToken
            DOM->>DOM: Mark container: data-stagehand-mask-root={rootToken}
            DOM->>DOM: Compute rect relative to container
            DOM-->>Util: Rect + rootToken
        else Standard Element
            DOM->>DOM: Compute rect relative to document
            DOM-->>Util: Rect (no rootToken)
        end
        deactivate DOM
    end

    Util->>DOM: applyMaskOverlays(rects, token)
    activate DOM
    loop For each rect
        alt Has rootToken (NEW)
            DOM->>DOM: Select container via [data-stagehand-mask-root]
            opt Container position is static
                DOM->>DOM: Save current pos -> data-stagehand-mask-root-pos
                DOM->>DOM: Set style.position = "relative"
            end
            DOM->>DOM: Append mask <div> to container (top layer)
        else Standard
            DOM->>DOM: Append mask <div> to Document Body
        end
    end
    deactivate DOM

    Util->>Client: Return Screenshot Buffer

    Note over Client, DOM: Cleanup Phase

    Client->>Util: Cleanup callback
    Util->>DOM: Remove masks & Restore DOM
    activate DOM
    DOM->>DOM: Remove all mask elements by token

    loop For each used rootToken (NEW)
        DOM->>DOM: Find container
        opt Modified Position
            DOM->>DOM: Restore style.position from data attribute
        end
        DOM->>DOM: Remove data-stagehand-mask-root attributes
    end
    deactivate DOM
Loading

@theoephraim
Copy link

One note, although not sure if relevant - I think you can have a dialog[open] without having opened it via .showModal(), in which case I dont think it's in the top layer.

@seanmcguire12
Copy link
Member Author

@theoephraim good call out! i did test locally on dialogs opened via both show() & showModal(). correct me if im wrong here but i believe these are the only two ways to open a dialog

@theoephraim
Copy link

Just if someone (or a framework) wrote html that had a dialog and used the open property directly. Your code might assume it’s in the top layer, but calling out that you may need to verify that assumption.

@seanmcguire12
Copy link
Member Author

@theoephraim that makes sense! probably could have been more clear in my above comment, but was trying to explain that this PR fixes the mask issue for both cases. ie, after this is merged, the mask will work on dialogs regardless of whether the dialog was opened via show() or showModal()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants